Skip to content

Commit a84da60

Browse files
os-billclaude
andauthored
fix(spec): the dropped-refinement ledger refusal names sites, the key it requires (#18816)
Fixes #18747 Clause-②: no ## Where the two defects actually are on `main` The card's line numbers were taken on a branch head. Re-anchored by symbol against `origin/main` at the base of this branch (`034f5a3afd`), in `packages/spec/scripts/lib/dropped-refinements.ts`: | the card said | on `main` it is at | symbol | |---|---|---| | `:453` | **`:496`** | the first `throw new Error` in `readDroppedRefinementsBaseline` | | `:456-458` | **`:499-501`** | the `entry.sites` check and the second `throw new Error` | | module docblock `:68` | **`:66-70`** | the "Every entry carries a `reason`" sentence | | (not named) | **`:134-150`** | the `DroppedRefinementsEntry` docblock that contradicts it | ## Which side is true — measured from the consumers, not chosen The card's open question was whether the refusal should name `sites` or the shape should really carry `count`/`reason`, and which docblock governs. Every consumer that reads this ledger answers `sites`, and none of them reads `count` or `reason` at all: | consumer | reading | |---|---| | `DroppedRefinementsEntry` (the shipped interface) | one member: `readonly sites: readonly string[]` | | `packages/spec/dropped-refinements.baseline.json` | 243 entries; **243** carry `sites`, **0** carry `count`, **0** carry `reason` | | `checkDroppedRefinements` | reads `entry.sites` only — set difference plus a length compare | | the `unreasoned` refusal | fires on `entry.sites.length === 0`, and the gate prints "carry an empty `sites` list" | | `build-schemas.ts` remedy text (both the undeclared and the miscounted arms) | prints the corrected entry as `"sites": [ ... ]` | | the ledger's own `description` field | documents `sites` and nothing else | | `dropped-refinements.test.ts` "the committed ledger" | asserts `entry.sites.length` per entry and that `measured.droppedRefinementSites` equals their sum | So `sites` is the contract and the `DroppedRefinementsEntry` docblock governs. The two wrong texts are both residue of the module this one was copied from: in `packages/spec/scripts/lib/unemitted-schemas.ts` the entries really are `{ cause, reason }`, its refusal really does say so, and its gate really does require a non-empty `reason` (`entry.reason.trim() === ''`). Copying the file carried the vocabulary across without the shape. ## LIT — the red leg, both messages verbatim The trap is a sequence, so the probe walks it: take a structurally broken ledger, read what the reader says the shape is, write that shape, and hand it back to the same function. **BEFORE** (the module restored to `origin/main`, the rest of the tree unchanged): ```text [step 1] input: {"entries": []} REFUSED dropped-refinements.baseline.json: "entries" must be an object of key -> { count, reason } [step 2] the ledger an author writes by FOLLOWING step 1 input: {"entries":{"system/TraceSamplingConfig":{"count":1,"reason":"zod projects no custom check"}}} REFUSED dropped-refinements.baseline.json: entry "system/TraceSamplingConfig" needs a `sites` array of path strings ``` The repair written from the message is refused by the **same function**, four lines below the message that prescribed it. **AFTER**: ```text [step 1] input: {"entries": []} REFUSED dropped-refinements.baseline.json: "entries" must be an object of key -> { sites: string[] } [step 3] the ledger an author writes by FOLLOWING step 1 input: {"entries":{"system/TraceSamplingConfig":{"sites":["properties.rate"]}}} ACCEPTED (1 entry/entries) ``` The mutation leg and the restore leg were each proved on disk (the deleted text present and the injected text absent, then the reverse), and the restore was verified byte-identical to `HEAD` by `git hash-object` (`9615f4e0c0…` both sides) with an empty `git diff HEAD` and an empty `git status --porcelain`. The probe itself lives outside the repository and nothing of it is committed. ## DARK — a legitimate ledger passes on both legs, and nothing else moved | reading | BEFORE | AFTER | |---|---|---| | the real committed ledger through `readDroppedRefinementsBaseline` | ACCEPTED — 243 entries, 737 sites | ACCEPTED — 243 entries, 737 sites | | `pnpm --filter @objectstack/spec test` | 487 files / **14051** passed, 0 failed | 487 files / **14055** passed, 0 failed | | `dropped-refinements.test.ts` | 23 tests | 27 tests | The `+4` is exactly the four tests this PR adds. The BEFORE row is a real run, not arithmetic: both files were checked out at the merge base, the suite was run, and both were restored and re-verified byte-identical to `HEAD`. Nothing else in the repository pins either message — `git grep "must be an object of key"` returns exactly two hits, this one and `unemitted-schemas.ts`'s own (which is correct for its own shape, and is the live control on that grep). ## The pin is a closed loop, not a wording match Error prose is not pinned here on its spelling; what is pinned is the **named subject** and the property that makes this class of defect a trap: whatever the refusal names has to be what the reader then accepts. Four cases in `packages/spec/scripts/dropped-refinements.test.ts`: 1. the shape diagnostic names `sites`; 2. a ledger written to that shape is then ACCEPTED — the loop closes; 3. **LIT CONTROL** — the shape the old diagnostic named is refused, and that refusal still says `sites` (without this leg the first two pass on a reader that accepts anything); 4. the shape diagnostic names no key the entry shape does not have. The docblock half has no pin, deliberately: no consumer parses a docblock, and a source-text assertion over prose is a gate that fails on rewording rather than on regression. ## Also in this diff, declared - The module docblock's **shrink-only bullet** said the ratchet re-checks "a recorded `count`" — the same contradiction as the `reason` sentence the card names, in the paragraph above it, against the same `DroppedRefinementsEntry` docblock ("The unit is the SITE and not a count, deliberately"). Repaired in place under the bounded exemption: same defect class as this card, same file, mechanical, the corrected form already fixed by the entry docblock, no new verification surface, and no other claim holds any `dropped-refinements*` path (measured across all 26 open `claude/issue-*` PRs, with `proof-registry.mts` reading out for #18797 as the live control). - `packages/spec/scripts/dropped-refinements.test.ts` was listed read-only on the claim. It is written here, and only to carry this card's own regression pin. ## The card's second open question — is there a third site? `unemitted-schemas.ts`, the sibling the docblock calls itself "Identical to", was read: it has the same defect **nowhere**. Its docblock claim, its refusal text, its interface and its ledger all agree on `{ cause, reason }`. It is the correct template, not a second instance. ## Changeset — measured, not inferred from the path `npm pack` on `packages/spec` after a full build, then grep over the packed bytes (142,490,031 of them): | reading | hits | |---|---| | tarball entries under `package/scripts/` | **0** | | `DROPPED_REFINEMENTS_BASELINE_FILE` in the packed bytes | **0** | | `readDroppedRefinementsBaseline` | **0** | | `must be an object of key -> { sites: string[] }` | **0** | | positive control — entries under `package/src/` | 203 | | positive control — entries under `package/json-schema/` | 1530 | | positive control — `x-dropped-refinements` in the packed bytes | 486 | | positive control — `ObjectSchema` in the packed bytes | 965 | `packages/spec/scripts/**` is absent from the package's `files[]`, and the build after this change leaves `git status` clean, so no `dist/` byte moves either. Nothing this diff changes publishes from any released package, so it carries `skip-changeset` rather than a changeset. ## Verification - `pnpm --filter @objectstack/spec build` — exit 0 (34/34 declaration files present). - `pnpm --filter @objectstack/spec test` — exit 0, 487 files / 14055 tests. - `pnpm --filter @objectstack/spec typecheck` — exit 0, including `tsconfig.scripts.json` (which is what compiles the edited file) and the test layer. - `node scripts/pm/dispatch-gates.mjs --commands` derived **57** families from the change set; all 57 were run and reconciled with `--ran`: **54 exit 0**, **3 exit 3 = NOT MEASURED** (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — each refuses its own prerequisite because only `packages/spec` is built in this worktree; all three read built output, which this diff cannot move, and CI builds the full closure). - `pnpm check:nul-bytes` exit 0, plus a direct control-character scan of both edited files — no match, with a live non-zero control on a file that carries one. - `origin/main` was merged in before this PR was opened (clean, no `os-regen` deferral). --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c993b7c commit a84da60

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)