diff --git a/packages/spec/scripts/dropped-refinements.test.ts b/packages/spec/scripts/dropped-refinements.test.ts index 5dd5b6d8488..18c6a5c9a8d 100644 --- a/packages/spec/scripts/dropped-refinements.test.ts +++ b/packages/spec/scripts/dropped-refinements.test.ts @@ -32,6 +32,7 @@ */ import { describe, expect, it } from 'vitest'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { z } from 'zod'; import { @@ -292,6 +293,60 @@ describe('the ratchet adjudicates against the ledger', () => { }); }); +describe("the reader's refusal names the shape the reader ACCEPTS", () => { + // The trap (#18747): the shape diagnostic used to say the entries are + // `key -> { count, reason }` while the very next check in the same function + // requires `sites: string[]` and the shipped `DroppedRefinementsEntry` has no + // `count` and no `reason` at all. An author — or an AI — repairing a broken + // ledger by following that sentence writes a ledger the SAME function refuses + // again. So the pin is a closed loop, not a wording match: whatever the + // refusal names has to be what the reader then takes. + const withLedger = (json: string, fn: (pkgDir: string) => T): T => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-dropped-refinements-')); + try { + fs.writeFileSync(path.join(dir, DROPPED_REFINEMENTS_BASELINE_FILE), json, 'utf8'); + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + const refusalFor = (json: string): string => + withLedger(json, (dir) => { + try { + readDroppedRefinementsBaseline(dir); + } catch (error) { + return (error as Error).message; + } + throw new Error('the reader accepted a ledger it should have refused'); + }); + + it('the shape diagnostic names `sites`', () => { + // `entries` as an array is the branch that prints the shape. + expect(refusalFor('{ "entries": [] }')).toContain('sites'); + }); + + it('a ledger written to that shape is then ACCEPTED — the loop closes', () => { + const accepted = withLedger('{ "entries": { "a/One": { "sites": ["x"] } } }', (dir) => + readDroppedRefinementsBaseline(dir), + ); + expect(accepted?.entries['a/One'].sites).toEqual(['x']); + }); + + it('LIT CONTROL — the shape the OLD diagnostic named is refused, and the refusal still says `sites`', () => { + // Without this leg the two assertions above pass on a reader that accepts + // anything: this is the ledger an author following the old sentence wrote. + const message = refusalFor('{ "entries": { "a/One": { "count": 1, "reason": "zod drops custom checks" } } }'); + expect(message).toContain('sites'); + }); + + it('the shape diagnostic names no key the entry shape does not have', () => { + const message = refusalFor('{ "entries": [] }'); + expect(message).not.toContain('count'); + expect(message).not.toContain('reason'); + }); +}); + describe('the committed ledger', () => { it('names at least one site per entry, and its header totals match its body', () => { const baseline = readDroppedRefinementsBaseline(PKG_DIR); diff --git a/packages/spec/scripts/lib/dropped-refinements.ts b/packages/spec/scripts/lib/dropped-refinements.ts index 84599123730..9615f4e0c02 100644 --- a/packages/spec/scripts/lib/dropped-refinements.ts +++ b/packages/spec/scripts/lib/dropped-refinements.ts @@ -56,18 +56,23 @@ * * - a published schema with dropped refinements that is NOT recorded fails * the build — a new gap has to be a reviewed line in a diff; - * - a recorded `count` the build does not observe ALSO fails, in either - * direction. A ledger that keeps saying 3 while the tree grew to 4 has - * stopped describing the tree, and the 4th arrives inside a number nobody - * re-read. + * - a recorded `sites` list the build does not observe ALSO fails, in either + * direction and path by path. A ledger that keeps naming a site the tree no + * longer has stopped describing the tree, and the next gap arrives inside a + * list nobody re-read. * * ## Why the ledger is HAND-EDITED and has no `gen:` script * * Identical to `unemitted-schemas.baseline.json`: a generator would let a new - * gap be admitted by running a command instead of by a decision. Every entry - * carries a `reason` in prose and the gate requires it to be non-empty, because - * a baseline that records only a COUNT lets the next gap slip in behind a - * repaired one with nobody able to see which was replaced. + * gap be admitted by running a command instead of by a decision. Where THAT + * ledger requires a non-empty per-entry `reason`, this one requires a non-empty + * `sites` list and has no `reason` field at all — the reason is the same for + * every site here and is written once, above and in the ledger's own + * `description`, so a per-entry copy would be exactly the prose a required + * `reason` exists to prevent (`DroppedRefinementsEntry` below argues that in + * full). Both refuse the same thing: an entry recording only MEMBERSHIP, which + * lets the next gap slip in behind a repaired one with nobody able to see which + * was replaced. */ import fs from 'fs'; import path from 'path'; @@ -493,7 +498,7 @@ export function readDroppedRefinementsBaseline(pkgDir: string): DroppedRefinemen const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { entries?: unknown }; const entries = parsed.entries; if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) { - throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: "entries" must be an object of key -> { count, reason }`); + throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: "entries" must be an object of key -> { sites: string[] }`); } for (const [key, value] of Object.entries(entries as Record)) { const entry = value as Partial;