Skip to content

Commit e9fe819

Browse files
committed
fix(spec): the dropped-refinement ledger refusal names sites, the key it requires
`readDroppedRefinementsBaseline`'s shape diagnostic said the entries are `key -> { count, reason }` while the very next check in the same function requires `sites: string[]`, and the shipped `DroppedRefinementsEntry` carries neither `count` nor `reason`. A ledger an author repaired by following that sentence is refused again by the same function. The module docblock carried the same vocabulary over from `unemitted-schemas.ts`, the module this one was copied from: it promised a per-entry `reason` the gate requires to be non-empty, which the `DroppedRefinementsEntry` docblock seventy lines below explains at length is deliberately absent, and it described the ratchet as re-checking a recorded `count`. Both now describe the shipped shape: a non-empty `sites` list, re-checked path by path. The pin is a closed loop rather than a wording match — whatever the refusal names has to be what the reader then accepts — with a lit control on the ledger an author following the old sentence would have written. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 034f5a3 commit e9fe819

2 files changed

Lines changed: 69 additions & 9 deletions

File tree

‎packages/spec/scripts/dropped-refinements.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
*/
3333
import { describe, expect, it } from 'vitest';
3434
import fs from 'fs';
35+
import os from 'os';
3536
import path from 'path';
3637
import { z } from 'zod';
3738
import {
@@ -292,6 +293,60 @@ describe('the ratchet adjudicates against the ledger', () => {
292293
});
293294
});
294295

296+
describe("the reader's refusal names the shape the reader ACCEPTS", () => {
297+
// The trap (#18747): the shape diagnostic used to say the entries are
298+
// `key -> { count, reason }` while the very next check in the same function
299+
// requires `sites: string[]` and the shipped `DroppedRefinementsEntry` has no
300+
// `count` and no `reason` at all. An author — or an AI — repairing a broken
301+
// ledger by following that sentence writes a ledger the SAME function refuses
302+
// again. So the pin is a closed loop, not a wording match: whatever the
303+
// refusal names has to be what the reader then takes.
304+
const withLedger = <T,>(json: string, fn: (pkgDir: string) => T): T => {
305+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-dropped-refinements-'));
306+
try {
307+
fs.writeFileSync(path.join(dir, DROPPED_REFINEMENTS_BASELINE_FILE), json, 'utf8');
308+
return fn(dir);
309+
} finally {
310+
fs.rmSync(dir, { recursive: true, force: true });
311+
}
312+
};
313+
314+
const refusalFor = (json: string): string =>
315+
withLedger(json, (dir) => {
316+
try {
317+
readDroppedRefinementsBaseline(dir);
318+
} catch (error) {
319+
return (error as Error).message;
320+
}
321+
throw new Error('the reader accepted a ledger it should have refused');
322+
});
323+
324+
it('the shape diagnostic names `sites`', () => {
325+
// `entries` as an array is the branch that prints the shape.
326+
expect(refusalFor('{ "entries": [] }')).toContain('sites');
327+
});
328+
329+
it('a ledger written to that shape is then ACCEPTED — the loop closes', () => {
330+
const accepted = withLedger('{ "entries": { "a/One": { "sites": ["x"] } } }', (dir) =>
331+
readDroppedRefinementsBaseline(dir),
332+
);
333+
expect(accepted?.entries['a/One'].sites).toEqual(['x']);
334+
});
335+
336+
it('LIT CONTROL — the shape the OLD diagnostic named is refused, and the refusal still says `sites`', () => {
337+
// Without this leg the two assertions above pass on a reader that accepts
338+
// anything: this is the ledger an author following the old sentence wrote.
339+
const message = refusalFor('{ "entries": { "a/One": { "count": 1, "reason": "zod drops custom checks" } } }');
340+
expect(message).toContain('sites');
341+
});
342+
343+
it('the shape diagnostic names no key the entry shape does not have', () => {
344+
const message = refusalFor('{ "entries": [] }');
345+
expect(message).not.toContain('count');
346+
expect(message).not.toContain('reason');
347+
});
348+
});
349+
295350
describe('the committed ledger', () => {
296351
it('names at least one site per entry, and its header totals match its body', () => {
297352
const baseline = readDroppedRefinementsBaseline(PKG_DIR);

‎packages/spec/scripts/lib/dropped-refinements.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,23 @@
5656
*
5757
* - a published schema with dropped refinements that is NOT recorded fails
5858
* the build — a new gap has to be a reviewed line in a diff;
59-
* - a recorded `count` the build does not observe ALSO fails, in either
60-
* direction. A ledger that keeps saying 3 while the tree grew to 4 has
61-
* stopped describing the tree, and the 4th arrives inside a number nobody
62-
* re-read.
59+
* - a recorded `sites` list the build does not observe ALSO fails, in either
60+
* direction and path by path. A ledger that keeps naming a site the tree no
61+
* longer has stopped describing the tree, and the next gap arrives inside a
62+
* list nobody re-read.
6363
*
6464
* ## Why the ledger is HAND-EDITED and has no `gen:` script
6565
*
6666
* Identical to `unemitted-schemas.baseline.json`: a generator would let a new
67-
* gap be admitted by running a command instead of by a decision. Every entry
68-
* carries a `reason` in prose and the gate requires it to be non-empty, because
69-
* a baseline that records only a COUNT lets the next gap slip in behind a
70-
* repaired one with nobody able to see which was replaced.
67+
* gap be admitted by running a command instead of by a decision. Where THAT
68+
* ledger requires a non-empty per-entry `reason`, this one requires a non-empty
69+
* `sites` list and has no `reason` field at all — the reason is the same for
70+
* every site here and is written once, above and in the ledger's own
71+
* `description`, so a per-entry copy would be exactly the prose a required
72+
* `reason` exists to prevent (`DroppedRefinementsEntry` below argues that in
73+
* full). Both refuse the same thing: an entry recording only MEMBERSHIP, which
74+
* lets the next gap slip in behind a repaired one with nobody able to see which
75+
* was replaced.
7176
*/
7277
import fs from 'fs';
7378
import path from 'path';
@@ -493,7 +498,7 @@ export function readDroppedRefinementsBaseline(pkgDir: string): DroppedRefinemen
493498
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { entries?: unknown };
494499
const entries = parsed.entries;
495500
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) {
496-
throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: "entries" must be an object of key -> { count, reason }`);
501+
throw new Error(`${DROPPED_REFINEMENTS_BASELINE_FILE}: "entries" must be an object of key -> { sites: string[] }`);
497502
}
498503
for (const [key, value] of Object.entries(entries as Record<string, unknown>)) {
499504
const entry = value as Partial<DroppedRefinementsEntry>;

0 commit comments

Comments
 (0)