Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/spec/scripts/build-schemas-check-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -830,6 +830,50 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
},
);

it(
"check (a)'s remedy names every route and claims exhaustiveness for none (#12574)",
{ timeout: SPAWN_TIMEOUT_MS },
() => {
// Same trip condition as the test above — this one reads the REMEDY rather
// than the verdict. The message once said tombstoning was the only route and
// that an aged-out tombstone was "the ONE legitimate reason to delete": a
// claim of exhaustiveness that check (c) below already contradicted in its
// own logic (proofs 2 and 3), and that measurably re-routed a dev off a
// documented route. The pin is on the FORK — the precondition question, both
// of its answers, and the absence of the exhaustiveness claim.
seedBase((s) => [...s, 'data/Object:zzPhantom12574'].sort());
seedSurface((s) => [...s, 'data/Object:zzPhantom12574'].sort());

const { status, output } = run(['--check']);

expect(status).toBe(1);

// The fork is asked as a question about the def, not decreed.
expect(output).toContain('Does anything still PARSE this def');

// Route A — the def survives, so there is a reader to warn.
expect(output).toContain('tombstone the key instead of deleting it');
expect(output).toContain('retiredKey(');

// Route B (#4650 proofs 2 and 3) — nothing parses the def, or the whole def
// is leaving: the baseline line goes with the key, and check (c) adjudicates.
expect(output).toContain('not reachable from the metadata-type roots');
expect(output).toContain('the whole def is leaving this build');
expect(output).toContain('gen:schema');
expect(output).toContain('#4650 proofs');
// The 2026-08-02 ruling's limit travels with the route it waives.
expect(output).toContain("waives THIS file's tombstone requirement only");

// The aged-out tombstone survives as A route, no longer as THE reason.
expect(output).toContain('aged out (~two majors)');
expect(output).toContain('RETIRED_KEYS_BY_MAJOR');
expect(output).not.toContain('ONE legitimate reason');

// Still gate (a) talking: (c) never ran.
expect(output).not.toContain('deleted without proof');
},
);

it(
'fails --check on a hand-edit that changes no key (generated-form mismatch, #4662), and write mode regenerates it',
{ timeout: SPAWN_TIMEOUT_MS * 2 },
Expand Down
23 changes: 20 additions & 3 deletions packages/spec/scripts/build-schemas.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -814,15 +814,32 @@ if (surfaceDoc) {
`\n These schemas are NOT .strict(), so Zod silently STRIPS an unknown key: an author\n` +
` who keeps writing one gets a clean parse and a setting that never takes effect —\n` +
` no error, nothing to grep, nothing pointing at the changelog (#3733, ADR-0104).\n\n` +
` To retire a key, tombstone it instead of deleting it:\n` +
` You are seeing this because the BUILD stopped emitting the key while the committed\n` +
` baseline still records it — a bare deletion, which is the one shape none of the\n` +
` retirement routes has. Which route you owe turns on a single question:\n\n` +
` Does anything still PARSE this def — can an author keep writing this key?\n\n` +
` YES — the def survives and keeps emitting; only the key is leaving. There is a\n` +
` reader to warn, so tombstone the key instead of deleting it:\n` +
` 1. \`retiredKey('<key> was removed in ... — use <replacement>. ...')\` in the schema\n` +
` (or an UNKNOWN_KEY_GUIDANCE entry for an object top-level key), so the\n` +
` rejection carries the fix;\n` +
` 2. a D2 conversion (and D3 chain step) so the rename reaches spec-changes.json\n` +
` and \`os migrate meta\` can rewrite consumer sources;\n` +
` 3. a \`major\` changeset carrying the FROM → TO mapping.\n\n` +
` A tombstone that has aged out (~two majors) is the ONE legitimate reason to delete\n` +
` a line here — do it in the same PR, deliberately.`,
` NO — the def is not reachable from the metadata-type roots (no metadata document is\n` +
` ever parsed by it), or the whole def is leaving this build. Then there is no author\n` +
` to tombstone for, and a prescription nobody can receive is not worth its cost: let\n` +
` the baseline line go WITH the key, in the same commit —\n` +
` \`pnpm --filter @objectstack/spec gen:schema\`. You are not taken at your word on\n` +
` that answer: check (c) below recomputes it from this build's own Zod graph and from\n` +
` the manifest deletion gate, and refuses the deletion unless it holds (#4650 proofs\n` +
` 2 and 3). ⚠️ It waives THIS file's tombstone requirement only — plugin manifests,\n` +
` connector configs and other non-metadata authoring go through their own gates.\n\n` +
` Separately, a tombstone that has aged out (~two majors) may have its line deleted\n` +
` too — check (c) proof 1, which requires the exact key be declared in\n` +
` RETIRED_KEYS_BY_MAJOR. Do it in the same PR, deliberately.\n\n` +
` The routes and their preconditions are the spec-property-retirement playbook's\n` +
` (.claude/skills/); ADR-0104 and #4650 are the decisions behind them.`,
);
process.exit(1);
}
Expand Down
Loading