Skip to content

Commit cec190b

Browse files
committed
spec(changes): delete the committed spec-changes.json and its merge=os-regen route; gitignore both projections; an unrouted pending path prints a remedy that works (#22485)
Ruling 6078203801 on #22449 (B'): the committed copies and their two merge=os-regen routes are deleted. The guide's half landed with #22483; this is the spec-changes.json half. - packages/spec/spec-changes.json leaves git and is gitignored, with packages/spec/protocol-upgrade-guide.md. The publish lane still writes both into the package before npm pack, and files[] still ships them. - .gitattributes drops the last projection route; regen-artifacts.mjs moves gen:spec-changes to NOT_DRIVER_MANAGED as untracked output, which the merge-driver self-test now holds against git. Comment counts in git-merge-regen.mjs follow (16 routed paths, 16 rows, 14 defaulted). - build-spec-changes.ts writes no committed copy: its no-flag mode writes the gitignored package copy the lane and gen:spec-changes produce (projection-cli's committedPath becomes defaultPath). - check-regen-pending: a pending path with no REGEN_ARTIFACTS row stays blocked, but now says why and prints the remedy that settles it, and --release PATH removes exactly that line (refusing a routed path or one that is not pending). Its fixture derives its pending row instead of naming spec-changes.json. - check-generated's row text, os-regen-merge.sh's measured-path note and check-future-spec-major's exemption row for the deleted copy (forced: it matched nothing once the file was gone) follow. Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f64f4e commit cec190b

12 files changed

Lines changed: 256 additions & 7366 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)