Skip to content

Commit 46064df

Browse files
spec(changes): delete the committed spec-changes.json and its merge=os-regen route; both projections are gitignored and generated at publish only (#22449 B′, card ③) (#22638)
Fixes #22485 Clause-②: no The committed `packages/spec/spec-changes.json` and its `merge=os-regen` route are deleted, and both ADR-0087 D4 projections are gitignored inside `@objectstack/spec`. This finishes ruling `6078203801` on #22449 (B′): "The committed copies and their two `merge=os-regen` routes are deleted; `check:spec-changes` and `check:upgrade-guide` stop comparing a committed copy." The checks stopped comparing in #22533 (#22482). The guide's copy and route went in #22556 (#22483). This PR removes what was left: the `spec-changes.json` copy, its route, and every reader of it. What ships does not change. The publish lane still writes both projections into the package right before the tarball is packed, `files[]` still lists them, and `--verify` still refuses a tarball that is missing one. Proof is in the dry run below. ## What changed - **The copy is gone.** `packages/spec/spec-changes.json` is deleted from git. `.gitignore` now lists it and `packages/spec/protocol-upgrade-guide.md`, which is #22482's note. - **The route is gone.** `.gitattributes` no longer routes either projection. `scripts/regen-artifacts.mjs` drops the row and records `gen:spec-changes` in `NOT_DRIVER_MANAGED` as untracked output. The merge-driver self-test holds that entry against git. `check:merge-driver` now reads 16 routed paths and 5 untracked dispositions. The comment counts in `scripts/git-merge-regen.mjs` now read 16 paths and 16 rows, 14 of them defaulted. - **The generator writes no committed copy.** `build-spec-changes.ts` with no flag writes `packages/spec/spec-changes.json`, which is now untracked and gitignored. It is the same file `release-spec-changes.sh --prepare` / `--generate` write for `files[]` to ship, and the one `gen:spec-changes` writes locally. `--check` and `--out` are unchanged. In `lib/projection-cli.ts`, `committedPath` becomes `defaultPath`. `build()` is byte-for-byte untouched, so the generated bytes are unchanged. - *Why not make a bare run a usage error, as #22483 did for the guide?* Three callers run the no-flag mode and read its output. `release-spec-changes.sh` calls it at `--generate`, at the first-ever `--prepare`, and at `--prepare --previous-package`. Cloud's changed-spec-surface guard runs `pnpm run gen:spec-changes` in its pinned framework checkout and reads only that output (cloud#2750, comment `6083243420` on #22482). A usage error there would turn cloud's only required check red on its next pin move, and would also need an edit to the release script, which is outside this card's file surface. The guide's no-flag mode moved to a different target. This one keeps its target, and the target is no longer tracked. - **H2: the ADR-0087 gate's parser-rot witness is generated.** See below. - **H3: an unrouted pending marker now prints a remedy that works.** See below. - `check-generated.ts`'s row now names the gitignored copy. `os-regen-merge.sh` gains a note that its 2026-09-17 readings were taken over a path that has since left the route list. That script reads the routed list from `.gitattributes` at run time and hard-codes none. - **Rider declared by the seat (pointer `6094908270`, carried from the #22605 dev):** `packages/spec/src/migrations/entries/README.md`, under "What this does not fix", told authors that both projections "still have to be regenerated and committed when an entry lands". It now says what is true after the deletion. An entry regenerates `registry.ts` alone, with `gen:migration-registry` (gate: `check:migration-registry`). The two projections are generated at publish, and in memory on the pull request. ⛔ Neither is regenerated for an entry. The #8344 merge-queue measurement stays, marked as history kept for its `registry.ts` conclusion. - **Forced, outside the claim's file list:** - `scripts/check-future-spec-major.mjs` deletes its `QUOTATION_EXEMPTIONS` row for the deleted copy. Measured: HEAD's version of the gate, run on this tree, exits 1 with `QUOTATION_EXEMPTIONS entry 5 (packages/spec/spec-changes.json, major 4997) matched NOTHING`. After the deletion the gate exits 0 with `6 witnessed ledger entr(y|ies), every witness still matching`. - `scripts/objectui-changeset-digest.mjs`: its `fw-gate` sandbox runs a copy of the real ADR-0087 gate, so it now stages the stand-in witness generator instead of a fixture `spec-changes.json`. Without that change, its round-trip cases die at the input assertions. ## H1: every reader of the committed copy, and what it reads now | Reader | After this PR | |---|---| | `scripts/check-adr-0087-registration.mjs` (`assertInputs`, formerly `:2184`) | Generates the projection at the rev it judges (H2). It no longer reads any copy. | | `packages/spec/scripts/build-spec-changes.ts` no-flag mode | Writes the untracked, gitignored package copy (above). | | `packages/spec/scripts/check-generated.ts` | Runs `check:spec-changes` in memory. That check never goes stale, so `--fix` never regenerates. The row text names the gitignored copy. | | `scripts/regen-artifacts.mjs`, `.gitattributes`, `scripts/git-merge-regen.mjs`, `.githooks/pre-commit` → `check-regen-pending.mjs` | No row and no route. The driver never sees the path, and an old marker naming it gets the UNROUTED refusal (H3). | | `scripts/pm/os-regen-merge.sh` | Reads routes at run time (16). It has no hard-coded list. Only the prose note changed. | | `scripts/release-spec-changes.sh` (`--prepare` / `--generate` / `--verify` / `--attach`) | Unchanged. It writes into the package and verifies the packed copy (dry run below). | | `packages/spec/package.json` `files[]` | Unchanged. It ships what the lane wrote. | | `scripts/check-release-spec-changes.mjs` | Unchanged. It reads the packed tarball and a fresh generation, never the tree copy. | | `scripts/release-verify-npm.mjs` (`MANIFEST` at `:2063`) | A path inside its stubbed workflow fixture. Unchanged, and its self-test is green. | | `packages/spec/scripts/render-projection-diff.ts` (`legacyPath`) | Generates both sides with `--out`. The legacy path is read only inside a throwaway archive of a base that predates `--out`, never from this checkout. Unchanged. | | `packages/cli/src/utils/spec-release-changes.ts` | Reads the installed package's `release` section. The committed copy never had one, so it answered `null` in the workspace before this PR, and an absent file answers `null` too. Unchanged. | | `packages/spec/src/**/*-retirement.test.ts` (`EXCLUDED_PREFIXES`) | Walk the disk and still exclude the path, which is still right: a local `gen:spec-changes` output sits there, gitignored. Unchanged. | | `scripts/check-future-spec-major.mjs` | Scans tracked files. Its row for the copy is removed (forced, above). | | `scripts/check-regen-pending.mjs` self-test fixture | Used the path as "a real REGEN_ARTIFACTS entry". It now derives its row from the table, and uses the path as the retired-route scenario. | | `scripts/objectui-changeset-digest.mjs` self-test sandbox | Staged a fixture copy for the gate. It now stages the stand-in generator (forced, above). | | cloud `scripts/audit-spec-changes.mjs` | Reads `gen:spec-changes` output in its pinned checkout since cloud#2750. That output path is unchanged here. | ## H2: the witness is generated at the judged rev Before this PR, `assertInputs` read `HEAD:packages/spec/spec-changes.json` as its independent witness that `extractIds()` still sees every id the build sees. Since #22533 that copy has been aging. A withdrawn migration id stays in an aged copy and false-reds the gate, and deleting the copy reds it outright. `generatedWitness()` now lays out the judged rev's `packages/spec/{src,scripts,package.json}` with `git archive`. It borrows the checkout's `node_modules` and runs `build-spec-changes.ts --out` under the installed `tsx`. So the witness describes exactly the rev being judged, never the working tree. A missing runner, a missing generator, a generator that exits non-zero, or one that writes nothing is each a red input, never a skip. On this tree the real run generates 414 ids, all of them seen by the parser. The gate run is about 2 s. - **Pins.** Battery `W1-W6`, with 9 cases, drives the real `generatedWitness` over a stand-in generator. The stand-in evaluates the fixture registry under tsx, as the real one does. - W1: a withdrawn id with a stale committed copy at HEAD is green, and the witness the gate used holds only the kept id. - W2: an uncommitted edit stays green, and the same edit committed is parser drift that names the id. - W3 to W6: a missing generator, a failing generator, a silent generator, and no tsx are each red. - I1 (parser rot) and the I2 entry-point run now go through the real path too. The self-test reports 450 assertions, up from 441. - **Ablation**, via `scripts/ablation-replace.mjs` at `538bec7e1d`. The witness read was put back to the committed-copy read. - The self-test exits 1 with 14 failures, including `W1: a withdrawn migration id must NOT red the gate -- got: ledger parser drift … - withdrawn-one`. - Restored: blob `7e383d48ea90` == HEAD, and `git diff HEAD` is empty. ## H3: a pending path whose route was retired The premise holds for the class, but not for this card's own path. The deletion does not reach the stuck marker: git runs no merge driver on a modify/delete, so a branch carrying `spec-changes.json` writes no marker for it (H4). The stuck marker comes from the guide's route, retired by #22483, and from any marker an earlier merge left. Measured with the real driver and hooks, in a detached scratch worktree: - The scratch worktree starts at `874a38d8d9`, which is `514bf3c101^1`, before #22483. A commit there edits `docs/protocol-upgrade-guide.md`. It then merges this branch (`cec190b908`). - The merge auto-completes with exit 0. The marker holds `docs/protocol-upgrade-guide.md`, and the tree holds the branch's generated guide: the stub was dropped. - The next commit exits 1: ```text ✗ docs/protocol-upgrade-guide.md — UNROUTED: pending, but scripts/regen-artifacts.mjs has no row for it any more, so no gate can verify it and nothing clears it by itself. The merge kept THIS side of it whole and dropped the side it merged in: compare them (`git diff HEAD^2 -- docs/protocol-upgrade-guide.md`), keep or restore the right bytes, then release it: node scripts/check-regen-pending.mjs --release docs/protocol-upgrade-guide.md ``` - Following it: - `git diff HEAD^2` shows +1590 −9. - `git checkout HEAD^2 -- docs/protocol-upgrade-guide.md` restores the 15-line stub. - `--release` exits 0 with "nothing left pending, marker cleared". - The commit exits 0, and `--pre-push` exits 0. - The guide is byte-identical to this branch's. - **Control:** `18d999031b`'s `check-regen-pending.mjs`, run on the same marker, exits 1 with `recorded as pending but absent from scripts/regen-artifacts.mjs (cannot verify)` and `This check clears itself the moment they are current — nothing to reset by hand.`. It has no `--release`, and its run leaves the marker in place. The path stays BLOCKED, never passed. Passing it would land exactly the drop the driver made, and #22556 recorded the refusal as the one thing that keeps that drop loud. `--release PATH` refuses a path that still has a row, because its gate discharges it, never a hand. It also refuses a path the marker does not hold, and keeps every other line and the deferral record. - **Pins** (`fixtureSelfTest`, in the script's own self-test harness): - The scenario's premise is asserted: the path has no row. - The refusal is exit 1 and no longer claims "nothing to reset by hand". - The printed command is parsed and run as printed, with no fallback spelling. It clears the marker, and the next push is accepted. - `--release` refuses a routed path and an absent path, and leaves a routed path's debt and its `stale` refusal intact. - The fixture's pending row is now derived from `REGEN_ARTIFACTS`, so retiring a row cannot break the fixture again. - **Ablation**, via `scripts/ablation-replace.mjs` at `538bec7e1d`. The printed `--release` line was dropped. - The self-test exits 1: "printing a release command", "the push is refused with the same remedy", "THE PRINTED REMEDY WORKS" and "the next push is accepted" go red, both directly and in the ambient-git-env inner run. - Restored: blob `ca127a866a97` == HEAD, and `git diff HEAD` is empty. ## H4: a branch that still carries a regenerated copy What the author does: ```bash git rm packages/spec/spec-changes.json && git commit --no-edit ``` The conflict is modify/delete, so git runs no merge driver and leaves no marker. The deletion is the resolution, and nothing regenerates the copy in git any more. Measured on a scratch commit at `18d999031b` that edited the copy, merging `cec190b908`: - the merge exits 1 with `CONFLICT (modify/delete): packages/spec/spec-changes.json deleted in cec190b… and modified in HEAD`, and no marker is written; - the command above exits 0, and the resulting tree equals this branch. PR #22421 (#11509, seat 2) is such a branch today. The same line is in `.gitattributes` and in `NOT_DRIVER_MANAGED`'s reason, which is where a merging author meets the route list. ## Publish lane dry run (no release act) At `928bee8573`, with `RELEASE_VERSION=17.7.0`: - `--generate` exits 0 and writes both files into `packages/spec/`. `git status --short` stays empty: `--ignored` shows both as `!!`. - `--verify` exits 0 with `✓ registry projections verified against a fresh generation: spec-changes.json (2 per-major record(s), 121 converted / 414 migrated) and protocol-upgrade-guide.md (1617979 bytes) match the artifact.` and `✓ no per-release section is owed: this lane (--generate) publishes none.`. - The packed `objectstack-spec-17.7.0.tgz` lists `package/spec-changes.json` and `package/protocol-upgrade-guide.md`. Its `spec-changes.json` is byte-identical to the generated one (`cmp`). - **Control:** with the generated `spec-changes.json` removed, `--verify` exits 1 with `the artifact about to publish ships no spec-changes.json`. A consumer's `node_modules` cannot be handed a tarball without it. ## Changeset: none, `skip-changeset` No published byte changes: - `build()` is untouched, so the lane generates the same bytes; - `packages/spec/scripts/**` is not in `files[]`; - the deleted copy never shipped as-is, because the lane overwrites it before packing (#22533). So no package warrants a bump, and this PR takes `skip-changeset`. The dispatch asked for H4's line to go in a changeset. With no changeset, that line is in H4 above and in the two in-repo places named there. ## Governed lines that name the copies or their routes (not edited here; the skills lane carries them) - `docs/adr/0087-metadata-protocol-upgrade-contract.md:263`–`:266`: "the deletion of the committed copies (the per-major projection in `packages/spec/spec-changes.json`, the guide body at `docs/protocol-upgrade-guide.md`) and of their two `merge=os-regen` routes in `.gitattributes` — #22485, which lands last." It is written as not yet landed. - `docs/adr/0087-metadata-protocol-upgrade-contract.md:268`–`:270`: "Until #22485 lands the committed copies stay in the tree, and the generators' no-flag mode (`gen:spec-changes`, `gen:upgrade-guide`) still writes them, but `check:spec-changes` and `check:upgrade-guide` no longer compare them". The guide half was already stale since #22556. - `docs/adr/0087-metadata-protocol-upgrade-contract.md:283`–`:284`: "The 2026-08-06 note above ("deliberately unsharded") is about that committed copy, which #22485 removes." - `docs/adr/0087-metadata-protocol-upgrade-contract.md:207`–`:208`: "`spec-changes.json` is deliberately unsharded — it is keyed by version, which has never been a conflict surface." This is about the committed copy, which no longer exists. - `docs/adr/0087-metadata-protocol-upgrade-contract.md:438`–`:440`: "Those copies stay in the tree until #22485 deletes them, and `check:spec-changes` / `check:upgrade-guide` no longer compare them." - `docs/adr/0087-metadata-protocol-upgrade-contract.md:219`–`:220`: the ruling's own words, quoted. They are listed only because they name the routes, and they are not to be edited. - `AGENTS.md:717`: "| An ADR-0087 conversion / migration registry | `check:spec-changes`, `check:upgrade-guide` | `gen:spec-changes`, `gen:upgrade-guide` |". Neither check can go stale any more, and both generators write only untracked files. The row that holds for an entry is `check:migration-registry` / `gen:migration-registry`. - `AGENTS.md:706`: "`packages/spec` has **eight** checked-in generated artifacts, each with its own CI gate." The two projections are no longer checked in, so the count needs a re-measure. - `.claude/skills/spec-property-retirement/SKILL.md:260`: "然后 `gen:spec-changes`、`gen:upgrade-guide`、`gen:api-surface`、`gen:docs`。" - `.claude/skills/spec-property-retirement/SKILL.md:336`–`:337`: "所以纯散文改动也要 `gen:spec-changes` + `gen:upgrade-guide`,和任何别的改动一样。" - `docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md:459`–`:460`: "regenerate with the conversion entry (`gen:spec-changes`, `gen:upgrade-guide`)." This is a record of what that PR did, likely left as history. Measured and **not** stale: `.claude/skills/pm-dispatch/SKILL.md:296`, the hot-file row for `os-regen-merge.sh`, which names no path, and `references/landing-operations.md:38`, which reads `.gitattributes` live. ## Verification Gate union read at `cb1ac74e76`, the final head. It is the 87 commands the dispatch named, plus what `node scripts/pm/dispatch-gates.mjs --commands` derives on this diff (105 today), 110 commands in all. Each ran with its exit code captured before any pipe, and all 110 exited 0. `--ran` reports `Run reconciliation — 105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN.` That includes: - `check:merge-driver`: 16 routed paths, and `5 untracked disposition(s) still hold — git tracks none of those paths`; - `check:changeset-gate-self-tests` and the ADR-0087 gate itself (`--base origin/main`: green, with a generated witness); - `check:generated`: `✓ All 15 generated artifacts are up to date`, with `check:spec-changes spec-changes.json (gitignored; written into the package at publish)`; - `check:objectui-changeset`, `check:pm-dispatch-gates`, `check:type-check-debt` and `check:nul-bytes`. Under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0, at `928bee8573`; the README rider after it touched no test input): - `@objectstack/spec`: `build` exit 0; `typecheck` exit 0, and `tsc -p tsconfig.scripts.json --listFiles` lists all four touched script and test files; - `vitest --project local`: 642 files, 19146 passed, 1 todo; - `--project repo`: 54 files, 915 passed; - `release-verify-npm.mjs --self-test`: 115 cases across 13 batteries; - `check-release-spec-changes.mjs --self-test`: 31 batteries. ## Acceptance notes (not filed) - **Size.** About 8,000 changed lines, 7,272 of them the deleted generated JSON. That is over the 3,000-line threshold, so this lands the Tier H way: an authorized approval or a human merge. - **Landing rule A relaxation.** The maintainer's temporary relaxation for D3-entry PRs (`6091886885`, corrected by `6091996837`) ends when this lands. - `.github/workflows/lint.yml:4728` lists `spec-changes.json` among the artifacts `merge=os-regen` protects. Comment only. carrier: none. - `.github/workflows/pr-automation.yml:959`–`:961`: "the two scripts import only node builtins and repo-local modules, so a failed install cannot change their verdict". The import half is still true. The ADR-0087 gate now needs the installed `tsx` for its witness, so a failed install also reds that step, in the enforcing direction. Comment only. carrier: none. - `scripts/check-published-files.mjs:291`: the `spec-changes.json` registration reason could say "generated at publish (it is not in the tree)", as the guide's does. carrier: none. - `docs/spec-generated-artifact-sharding.md:42` describes the committed file's merge shape as current. Design history. carrier: none. - **Cost.** `check-adr-0087-registration --self-test` costs about 7 s more on this shared box (20.7 s → 27.5 s): 8 real generator spawns. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5fb1746 commit 46064df

15 files changed

Lines changed: 591 additions & 7447 deletions

‎.gitattributes‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,14 @@
1717
# stay routed here as directories: the driver still owns a same-category
1818
# collision, which is the residue sharding cannot remove.
1919
#
20-
# One entry below is still a SINGLE file — spec-changes.json — and #8344 asked
21-
# what the driver-less queue does to it. MEASURED (2026-08-13, the real in-flight
22-
# case plus four synthetic pairs, each merged in a clone with no
23-
# merge.os-regen.driver, over it and the protocol upgrade guide, then a second
24-
# committed single file): the queue leaves neither stale-but-clean. Both were sorted
25-
# unions and an ADR-0087 registration is insertion-only, so the queue's text merge
26-
# either takes both sides — byte-identical to the regeneration, gates green — or
27-
# conflicts outright. It conflicts only when the two in-flight entries are ADJACENT
28-
# in registry sort order; one existing entry between them is already enough to
29-
# merge clean AND current.
30-
#
31-
# Sharding it would not buy back an ejection, which is why it is still a single
32-
# file: every conflicting case also conflicts in
33-
# packages/spec/src/migrations/registry.ts — generated, committed, unsharded,
34-
# NOT_DRIVER_MANAGED — and every registration touches it by construction. The table
35-
# and the reproduction are in packages/spec/src/migrations/entries/README.md.
20+
# packages/spec/spec-changes.json LEFT this list at #22485, because it left git: the
21+
# #22449 B′ ruling generates it at publish (`scripts/release-spec-changes.sh` writes
22+
# it into the package before the tarball is packed), the pull request generates it
23+
# in memory, and the file is gitignored. NOT_DRIVER_MANAGED records `gen:spec-changes`
24+
# as untracked output. A branch that still carries a regenerated copy meets a
25+
# modify/delete conflict when it merges main, and the deletion is the resolution:
26+
# `git rm packages/spec/spec-changes.json`, then commit — git runs no merge driver on
27+
# a modify/delete, so nothing is deferred and no marker is left.
3628
#
3729
# docs/protocol-upgrade-guide.md LEFT this list at #22483 because nothing generates
3830
# it any more. It is a hand-written pointer stub naming the guide's public address,
@@ -46,7 +38,9 @@
4638
# forked before #22483 still defers the guide on its first merge of main; the merged
4739
# tree has no row to discharge it against, so the next commit and the push are
4840
# refused instead of passed (measured on PR #22556 with the previous head as the
49-
# control, both merge directions).
41+
# control, both merge directions). Since #22485 that refusal names the path as
42+
# UNROUTED and prints the remedy that settles it — compare it with the merged-in
43+
# side, keep the right bytes, `node scripts/check-regen-pending.mjs --release PATH`.
5044
#
5145
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
5246
# NOT text-merge them. See that file for why it also does not regenerate them
@@ -177,7 +171,6 @@
177171
# case where two branches' rows do not overlap and the text merge exits 0 describing
178172
# neither side.
179173

180-
packages/spec/spec-changes.json merge=os-regen
181174
packages/spec/liveness/state-counts/** merge=os-regen
182175
packages/spec/authorable-surface/** merge=os-regen
183176
packages/spec/authorable-surface.base.json merge=os-regen

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,9 @@ examples/app-crm/storage/
136136
# The release lane's scratch space: the previously published tarball it unpacks
137137
# and the artifact it packs to verify the ADR-0087 D4 per-release section.
138138
.release-spec-changes/
139+
# The two ADR-0087 D4 projections `@objectstack/spec` ships: generated INTO the
140+
# package by that lane (`scripts/release-spec-changes.sh --prepare` / `--generate`)
141+
# right before the tarball is packed, and by `gen:spec-changes` locally. Never
142+
# committed (#22449 B′, #22485); `files[]` ships whatever the lane wrote.
143+
packages/spec/spec-changes.json
144+
packages/spec/protocol-upgrade-guide.md

‎packages/spec/scripts/build-spec-changes.ts‎

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
* WHERE it is generated (#22449 B′): at publish, by
1414
* `scripts/release-spec-changes.sh`, which writes it into the package before
1515
* `npm pack` and verifies the packed copy against a fresh generation. The
16-
* pull-request stage generates it IN MEMORY (`--check`) and compares no
17-
* committed copy, so a pull request that only adds a registry entry needs no
18-
* regeneration commit; `render-projection-diff.ts` renders the generated diff
19-
* against the base on the pull request instead. The command-line contract is
20-
* `lib/projection-cli.ts`.
16+
* pull-request stage generates it IN MEMORY (`--check`), so a pull request that
17+
* only adds a registry entry needs no regeneration commit;
18+
* `render-projection-diff.ts` renders the generated diff against the base on the
19+
* pull request instead. There is NO committed copy (#22485): the package path is
20+
* gitignored, and the no-flag mode writes that untracked file — the one the
21+
* lane's `--prepare` / `--generate` put there for `files[]` to ship. The
22+
* command-line contract is `lib/projection-cli.ts`.
2123
*
22-
* pnpm --filter @objectstack/spec gen:spec-changes # write the committed copy (until it is deleted)
24+
* pnpm --filter @objectstack/spec gen:spec-changes # write packages/spec/spec-changes.json (gitignored)
2325
* pnpm --filter @objectstack/spec check:spec-changes # CI: generate in memory, red when generation fails
2426
* tsx scripts/build-spec-changes.ts --out <file> # write the generated bytes anywhere
2527
*
@@ -61,17 +63,10 @@
6163
* exists to end. `scripts/check-release-spec-changes.mjs` derives the same
6264
* condition from the same artifacts and accepts the absence for the same reason.
6365
*
64-
* `spec-changes.json` itself stays a single file, deliberately (#5837), and #8344
65-
* re-measured that call rather than inheriting it. The original reason — "two PRs
66-
* append under different majors" — is not what actually holds: in-flight
67-
* registrations land in the SAME (current) major, so what separates them is their
68-
* distance in the registry's id sort order, not the major. What holds is the
69-
* conclusion. This file is a sorted union of an insertion-only registration, so a
70-
* driver-less server-side merge either takes both sides (byte-identical to the
71-
* regeneration) or conflicts; it is never stale-but-clean, it conflicts only on
72-
* ADJACENT ids, and in that case `src/migrations/registry.ts` — unsharded and
73-
* outside the merge driver — conflicts too, so splitting this file would not save
74-
* the PR. Measurement table: `../src/migrations/entries/README.md`.
66+
* `spec-changes.json` is one file, not sharded (#5837, re-measured at #8344).
67+
* That call was about MERGING a committed copy; since #22485 there is none, so
68+
* nothing merges it and the question no longer arises. The measurement table it
69+
* rested on stays in `../src/migrations/entries/README.md`.
7570
*/
7671

7772
import { readFileSync, existsSync } from 'node:fs';
@@ -95,7 +90,8 @@ import { exitWith, runProjectionCli } from './lib/projection-cli';
9590
import { API_SURFACE_DIR_NAME, readApiSurfaceFrom } from './lib/sharded-artifacts';
9691

9792
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
98-
const SNAPSHOT = resolve(PKG_DIR, 'spec-changes.json');
93+
/** The package's own copy — gitignored, written by the no-flag mode, shipped by `files[]`. */
94+
const PACKAGE_COPY = resolve(PKG_DIR, 'spec-changes.json');
9995
const SURFACE = resolve(PKG_DIR, API_SURFACE_DIR_NAME);
10096
const CHECK = process.argv.includes('--check');
10197
const prevSurfaceIdx = process.argv.indexOf('--previous-surface');
@@ -268,7 +264,7 @@ function build(): string {
268264
);
269265
const problem = surfaceScopeProblem(aggregate);
270266
if (problem) {
271-
console.error(`Refusing to write ${SNAPSHOT}: ${problem}`);
267+
console.error(`Refusing to write ${PACKAGE_COPY}: ${problem}`);
272268
process.exit(1);
273269
}
274270
const release = buildReleaseSection(aggregate);
@@ -329,7 +325,7 @@ function describeManifest(bytes: string): string {
329325
exitWith(
330326
runProjectionCli({
331327
name: 'spec-changes.json',
332-
committedPath: SNAPSHOT,
328+
defaultPath: PACKAGE_COPY,
333329
build,
334330
describe: describeManifest,
335331
argv: process.argv,

‎packages/spec/scripts/check-generated.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@ const GATED: ReadonlyArray<{
8686
gen: 'gen:migration-registry',
8787
artifact: 'src/migrations/registry.ts — its generated regions, from src/migrations/entries/',
8888
},
89-
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
89+
// Its committed copy is gone too (#22485): the publish lane writes the shipped one
90+
// into the package, `gen:spec-changes` writes the same gitignored file locally,
91+
// and the gate generates in memory, so it never reports stale.
92+
{
93+
check: 'check:spec-changes',
94+
gen: 'gen:spec-changes',
95+
artifact: 'spec-changes.json (gitignored; written into the package at publish)',
96+
},
9097
// Its committed copy is gone: `docs/protocol-upgrade-guide.md` is a hand-written
9198
// pointer stub, and `gen:upgrade-guide` writes the docs pages that are the
9299
// guide's address. The gate generates in memory, so it never reports stale.

‎packages/spec/scripts/lib/projection-cli.ts‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@
88
* Both are pure functions of the D2 conversion table and the D3 migration chain.
99
* Since the #22449 B′ ruling they are generated where they ship — at publish, by
1010
* `scripts/release-spec-changes.sh` — and the pull-request stage generates them
11-
* IN MEMORY instead of comparing a committed copy:
11+
* IN MEMORY. Neither has a committed copy any more (the guide's went at #22483,
12+
* `spec-changes.json`'s at #22485):
1213
*
13-
* (no flag) write the bytes to the committed path. Kept only until the
14-
* committed copies are deleted; nothing gates on that copy.
15-
* A projection with no committed copy (the upgrade guide,
16-
* whose no-flag mode writes its docs pages instead) passes
17-
* no `committedPath`, and a bare run here is a usage error.
14+
* (no flag) write the bytes to the projection's `defaultPath`. For
15+
* `spec-changes.json` that is the package's own copy,
16+
* `packages/spec/spec-changes.json`: gitignored, and the file
17+
* `files[]` ships — the publish lane's `--prepare` /
18+
* `--generate` run this mode right before `npm pack`. A
19+
* projection with no such path (the upgrade guide, whose
20+
* no-flag mode writes its docs pages instead) passes no
21+
* `defaultPath`, and a bare run here is a usage error.
1822
* --check generate in memory and write NOTHING. Green means the
19-
* registries project; red means they do not. ⛔ No committed
20-
* copy is read, so a pull request that only adds a registry
23+
* registries project; red means they do not. ⛔ Nothing on
24+
* disk is read, so a pull request that only adds a registry
2125
* entry needs no regeneration commit.
2226
* --out <path> write the bytes to `<path>`. The publish lane uses it to put
2327
* the guide inside the package, and the pull-request diff
@@ -39,11 +43,12 @@ export interface ProjectionCliOptions {
3943
/** The projection's name in every message, e.g. `spec-changes.json`. */
4044
name: string;
4145
/**
42-
* The committed copy the no-flag mode writes. Absent when the projection has
43-
* none — the upgrade guide's no-flag mode is its own (the docs pages,
44-
* `build-upgrade-guide.ts`) — and then a bare run is a usage error.
46+
* Where the no-flag mode writes: an untracked, gitignored path, never a
47+
* committed copy. Absent when the projection has none — the upgrade guide's
48+
* no-flag mode is its own (the docs pages, `build-upgrade-guide.ts`) — and then
49+
* a bare run is a usage error.
4550
*/
46-
committedPath?: string;
51+
defaultPath?: string;
4752
/** The projection's bytes, from the registries. Throws when it cannot. */
4853
build: () => string;
4954
/** One clause describing generated bytes, for `--check`'s success line. */
@@ -79,8 +84,8 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
7984
const usage = (line: string): ProjectionCliResult => ({ code: 2, stdout: [], stderr: [line], wrote: null });
8085

8186
if (out === null) return usage(`--out needs a path: --out <file> (${opts.name}).`);
82-
if (!check && out === undefined && opts.committedPath === undefined) {
83-
return usage(`${opts.name} has no committed copy: pass --check (generate in memory) or --out <file>.`);
87+
if (!check && out === undefined && opts.defaultPath === undefined) {
88+
return usage(`${opts.name} has no default path: pass --check (generate in memory) or --out <file>.`);
8489
}
8590
if (check && out !== undefined) {
8691
return usage(
@@ -110,15 +115,15 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
110115
code: 0,
111116
stdout: [
112117
`✓ ${opts.name} generates from the ADR-0087 registries, in memory${detail}.`,
113-
' No committed copy is compared: the publish lane generates the shipped one and verifies it from the tarball.',
118+
' No copy on disk is compared: the publish lane generates the shipped one and verifies it from the tarball.',
114119
],
115120
stderr: [],
116121
wrote: null,
117122
};
118123
}
119124

120-
// `out ?? committedPath` is never undefined here: the usage check above refused that.
121-
const target = resolve((out ?? opts.committedPath) as string);
125+
// `out ?? defaultPath` is never undefined here: the usage check above refused that.
126+
const target = resolve((out ?? opts.defaultPath) as string);
122127
mkdirSync(dirname(target), { recursive: true });
123128
writeFileSync(target, bytes);
124129
return { code: 0, stdout: [`Wrote ${target}`], stderr: [], wrote: target };

‎packages/spec/scripts/projection-cli.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// (`lib/projection-cli.ts`), which since the #22449 B′ ruling are generated at
55
// publish and generated IN MEMORY on a pull request:
66
//
7-
// - `--check` reads and writes no committed copy, so a pull request that only
7+
// - `--check` reads and writes no copy on disk, so a pull request that only
88
// adds a registry entry needs no regeneration commit;
99
// - a generation failure is red in every mode and writes nothing;
1010
// - `--out` writes the generated bytes where it is told, and nowhere else.
@@ -40,7 +40,7 @@ afterEach(() => {
4040
function options(over: Partial<ProjectionCliOptions> & { argv: string[] }): ProjectionCliOptions {
4141
return {
4242
name: 'fixture.json',
43-
committedPath: path.join(tmp, 'committed', 'fixture.json'),
43+
defaultPath: path.join(tmp, 'committed', 'fixture.json'),
4444
build: () => '{"generated":true}\n',
4545
...over,
4646
};
@@ -119,14 +119,14 @@ describe('runProjectionCli — the two projections’ command-line contract', ()
119119
expect(fs.readFileSync(committed, 'utf8')).toBe('{"before":true}\n');
120120
});
121121

122-
it('with no flag it writes the committed copy', () => {
122+
it('with no flag it writes the default path (the package copy, untracked since #22485)', () => {
123123
const result = runProjectionCli(options({ argv: [] }));
124124

125125
expect(result.code).toBe(0);
126126
expect(fs.readFileSync(path.join(tmp, 'committed', 'fixture.json'), 'utf8')).toBe('{"generated":true}\n');
127127
});
128128

129-
it('with no flag and no committed copy it refuses as a usage error, builds nothing and writes nothing', () => {
129+
it('with no flag and no default path it refuses as a usage error, builds nothing and writes nothing', () => {
130130
let builds = 0;
131131
const result = runProjectionCli({
132132
name: 'fixture.json',
@@ -138,7 +138,7 @@ describe('runProjectionCli — the two projections’ command-line contract', ()
138138
});
139139

140140
expect(result.code).toBe(2);
141-
expect(result.stderr.join('\n')).toContain('fixture.json has no committed copy');
141+
expect(result.stderr.join('\n')).toContain('fixture.json has no default path');
142142
expect(result.wrote).toBeNull();
143143
expect(builds).toBe(0);
144144
expect(fs.readdirSync(tmp)).toEqual([]);

0 commit comments

Comments
 (0)