From 22c70cd1e9a381e8591fc7098d27e3ecacf766e9 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 17:43:38 -0700 Subject: [PATCH 1/3] docs: cite Ruby 0.17.3 in parity ledger (sj-a94) The ledger cited Ruby solid_objects 0.17.2, and RubyGems has 0.17.3. Ruby 0.17.3 adds four problem guides, their tests, and activejob in the development bundle. Between the tags v0.17.2 and v0.17.3, the only file in lib/ that changed is version.rb. The release has no runtime change, so the ledger needs no new row. --- CHANGELOG.md | 6 ++++++ docs/parity.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f69e33c..6a34de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- `docs/parity.md` now cites Ruby `solid_objects` 0.17.3. That Ruby release + adds guides, tests, and one development dependency, and does not change the + runtime, so the ledger has no new row. + ## 0.17.6 - 2026-10-09 - Add three problem guides in `docs/guides/`. Each one reproduces a failure, diff --git a/docs/parity.md b/docs/parity.md index 05a6749..6ee7a2d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem. Parity preserves a capability and its correctness or security boundary. It does not copy a Rails API into Node. -Reference: Ruby `solid_objects` 0.17.2. The JavaScript package began at the Ruby +Reference: Ruby `solid_objects` 0.17.3. The JavaScript package began at the Ruby design's `0.12` capability generation; that version number did not imply earlier JavaScript releases. From ae2ff29c1ffb9ec3b0f663b085058e058db8c873 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 17:43:44 -0700 Subject: [PATCH 2/3] ci: compare fixtures with Ruby main (sj-a94) The four compatibility/*.json fixtures are committed to this repository and to solid-objects-ruby. Each suite reads its own copy, and no job compared the two copies, so one side could change a wire format and both suites would stay green. The new script compares the *.json files in two directories by name and by bytes. The new workflow runs it on this repository and on a sparse checkout of solid-objects-ruby at main. The workflow does not run on a pull request. During a paired change, the other repository holds the old fixture until the two pull requests merge. --- .github/workflows/compatibility-fixtures.yml | 29 ++++++ CHANGELOG.md | 8 ++ docs/parity.md | 8 ++ scripts/check-compatibility-fixtures.mjs | 51 ++++++++++ test/check-compatibility-fixtures.test.ts | 101 +++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 .github/workflows/compatibility-fixtures.yml create mode 100644 scripts/check-compatibility-fixtures.mjs create mode 100644 test/check-compatibility-fixtures.test.ts diff --git a/.github/workflows/compatibility-fixtures.yml b/.github/workflows/compatibility-fixtures.yml new file mode 100644 index 0000000..9055cb9 --- /dev/null +++ b/.github/workflows/compatibility-fixtures.yml @@ -0,0 +1,29 @@ +name: Compatibility fixtures + +on: + push: + branches: + - main + schedule: + - cron: "23 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: cardmagic/solid-objects-ruby + ref: main + path: solid-objects-ruby + sparse-checkout: compatibility + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: "24.15.0" + - run: node scripts/check-compatibility-fixtures.mjs compatibility solid-objects-ruby/compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a34de3..9b52921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Add `scripts/check-compatibility-fixtures.mjs` and a workflow with the name + `Compatibility fixtures`. The script compares the `*.json` files in two + directories by name and by bytes. It fails with one line for each file that + is missing on one side or different. The workflow runs the script on this + repository's `compatibility/` directory and on the `compatibility/` directory + of solid-objects-ruby at `main`. It runs on each push to `main`, one time + each day, and on demand. Before this change, no job compared the shared + fixtures. - `docs/parity.md` now cites Ruby `solid_objects` 0.17.3. That Ruby release adds guides, tests, and one development dependency, and does not change the runtime, so the ledger has no new row. diff --git a/docs/parity.md b/docs/parity.md index 6ee7a2d..26e2080 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -284,6 +284,14 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it found (the optional `arguments` default) is fixed and pinned by the shared fixture. +The `Compatibility fixtures` workflow compares each `compatibility/*.json` file +with the same file in solid-objects-ruby at `main`, byte for byte. It runs on +each push to `main`, one time each day, and on demand. It does not run on a +pull request, because the other repository holds the old fixture until the two +pull requests of a paired change merge. Run the same check on a local clone of +the Ruby repository with +`node scripts/check-compatibility-fixtures.mjs compatibility ../solid-objects-ruby/compatibility`. + ## JavaScript-only: Cloudflare hosting The experimental `solid-objects/cloudflare` backend hosts each actor identity diff --git a/scripts/check-compatibility-fixtures.mjs b/scripts/check-compatibility-fixtures.mjs new file mode 100644 index 0000000..cad621d --- /dev/null +++ b/scripts/check-compatibility-fixtures.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs" +import path from "node:path" +import process from "node:process" + +const directories = process.argv.slice(2) + +if (directories.length !== 2) { + process.stderr.write( + "usage: node scripts/check-compatibility-fixtures.mjs \n", + ) + process.exit(1) +} + +const absentDirectories = directories.filter( + (directory) => fs.statSync(directory, { throwIfNoEntry: false })?.isDirectory() !== true, +) + +if (absentDirectories.length > 0) { + process.stderr.write( + `${absentDirectories.map((directory) => `${directory} is not a directory`).join("\n")}\n`, + ) + process.exit(1) +} + +const fixtureNames = [...new Set(directories.flatMap(jsonFileNames))].sort() +const differences = fixtureNames.flatMap(findDifferences) + +if (differences.length > 0) { + process.stderr.write(`${differences.join("\n")}\n`) + process.exitCode = 1 +} + +function jsonFileNames(directory) { + return fs.readdirSync(directory).filter((name) => name.endsWith(".json")) +} + +function findDifferences(name) { + const directoriesWithoutFile = directories.filter( + (directory) => !fs.existsSync(path.join(directory, name)), + ) + if (directoriesWithoutFile.length > 0) { + return directoriesWithoutFile.map((directory) => `${name} is missing from ${directory}`) + } + + const [first, second] = directories.map((directory) => + fs.readFileSync(path.join(directory, name)), + ) + if (first.equals(second)) return [] + + return [`${name} is different in ${directories.join(" and ")}`] +} diff --git a/test/check-compatibility-fixtures.test.ts b/test/check-compatibility-fixtures.test.ts new file mode 100644 index 0000000..71687ce --- /dev/null +++ b/test/check-compatibility-fixtures.test.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" + +const scriptPath = path.resolve("scripts/check-compatibility-fixtures.mjs") + +type Fixtures = Record + +const fixtures: Fixtures = { + "json-values.json": '{"cases":[]}\n', + "transmit-envelopes.json": '{"version":1}\n', +} + +let workspace: string | undefined + +afterEach(() => { + if (workspace !== undefined) fs.rmSync(workspace, { recursive: true, force: true }) + workspace = undefined +}) + +describe("compatibility fixture check", () => { + it("passes for equal sets", () => { + const result = compare({ javascript: fixtures, ruby: fixtures }) + + expect(result.stderr).toBe("") + expect(result.status).toBe(0) + }) + + it("fails for a changed byte", () => { + const result = compare({ + javascript: fixtures, + ruby: { ...fixtures, "json-values.json": '{"cases":[]}' }, + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain("json-values.json is different") + expect(result.stderr).not.toContain("transmit-envelopes.json") + }) + + it("fails for a file present on one side only", () => { + const result = compare({ + javascript: { ...fixtures, "sync-timeout.json": "{}\n" }, + ruby: { ...fixtures, "telemetry-events.json": "{}\n" }, + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(`sync-timeout.json is missing from ${directoryFor("ruby")}`) + expect(result.stderr).toContain( + `telemetry-events.json is missing from ${directoryFor("javascript")}`, + ) + }) + + it("fails for a missing directory", () => { + writeFixtures({ name: "javascript", files: fixtures }) + const result = runScript([directoryFor("javascript"), directoryFor("ruby")]) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(`${directoryFor("ruby")} is not a directory`) + }) + + it("ignores a file that is not JSON", () => { + const result = compare({ javascript: { ...fixtures, "README.md": "notes\n" }, ruby: fixtures }) + + expect(result.stderr).toBe("") + expect(result.status).toBe(0) + }) + + it("fails when a directory is not given", () => { + const result = runScript([]) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain("usage:") + }) +}) + +function compare({ javascript, ruby }: { javascript: Fixtures; ruby: Fixtures }) { + writeFixtures({ name: "javascript", files: javascript }) + writeFixtures({ name: "ruby", files: ruby }) + + return runScript([directoryFor("javascript"), directoryFor("ruby")]) +} + +function runScript(directories: string[]) { + return spawnSync(process.execPath, [scriptPath, ...directories], { encoding: "utf8" }) +} + +function writeFixtures({ name, files }: { name: string; files: Fixtures }) { + const directory = directoryFor(name) + fs.mkdirSync(directory) + for (const [fileName, content] of Object.entries(files)) { + fs.writeFileSync(path.join(directory, fileName), content) + } +} + +function directoryFor(name: string) { + workspace ??= fs.mkdtempSync(path.join(os.tmpdir(), "compatibility-fixtures-")) + + return path.join(workspace, name) +} From 29202ce89ff7f64fd1edbe98dc8fc779dc94e752 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Fri, 9 Oct 2026 18:08:08 -0700 Subject: [PATCH 3/3] refactor: inline jsonFileNames helper (sj-fev) Greptile found that jsonFileNames had one caller and held one short expression. The reader had to go to the function to read the filter. Put the expression at the call site. The behavior does not change. --- scripts/check-compatibility-fixtures.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/check-compatibility-fixtures.mjs b/scripts/check-compatibility-fixtures.mjs index cad621d..18da5a5 100644 --- a/scripts/check-compatibility-fixtures.mjs +++ b/scripts/check-compatibility-fixtures.mjs @@ -22,7 +22,13 @@ if (absentDirectories.length > 0) { process.exit(1) } -const fixtureNames = [...new Set(directories.flatMap(jsonFileNames))].sort() +const fixtureNames = [ + ...new Set( + directories.flatMap((directory) => + fs.readdirSync(directory).filter((name) => name.endsWith(".json")), + ), + ), +].sort() const differences = fixtureNames.flatMap(findDifferences) if (differences.length > 0) { @@ -30,10 +36,6 @@ if (differences.length > 0) { process.exitCode = 1 } -function jsonFileNames(directory) { - return fs.readdirSync(directory).filter((name) => name.endsWith(".json")) -} - function findDifferences(name) { const directoriesWithoutFile = directories.filter( (directory) => !fs.existsSync(path.join(directory, name)),