Skip to content

Commit e443a60

Browse files
chore(scripts): ten ratchet baselines become one-sided — a decrease passes and is reported (#26549)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9MprVKsmUWQJJ3yeQLxiEMG)_ Part of #26444 Part of #26431 Clause-②: no Metadata-protocol: none The devx half of #26444 only. The ui floors are #26521 and the CLI floors are #26523, so this PR does not close the card. ## 维护者速读 - 改了什么:10 个棘轮读者(slot-lookup、query-options-erasure、role-word、i18n-coverage、corpus-claim-drift、objectql-double-limit、where-matcher、optional-error-sink、durability 两本账、published-readme-exports)从「两侧」改成「单侧」:基线里的数减少或文件被删 → 通过,并在输出里报告;增加或新文件 → 照旧变红。空扫描仍然变红:基线里列着、文件还在磁盘上、这次却没读到,就拒绝(第一轮席位复核加的)。 - 为什么改:删一个文件或修好一处,就要跑 `--update` 才能过 CI,跟这个 PR 的工作无关;卡 #26444 的 Wanted 3。 - 风险与回滚:代价是「余量」。减少后没跑 `--update`,旧数仍是上限,可以悄悄补回同样多的违例;每条报告都写明了余量。回滚就是 revert 本 PR,没有数据或接线变化。 - 先看:`scripts/check-query-options-erasure-ratchet.mjs`(唯一带聚合上限的一本)、`scripts/check-i18n-coverage.mjs`(比较逻辑抽成纯函数)、`scripts/check-durability-degradation-log-level.mjs`(两本账共用的 reconcileLedger)。 - 两分钟验证:`rm content/docs/api/error-catalog.mdx && node scripts/check-role-word.mjs; echo $?; git checkout HEAD -- content/docs/api/error-catalog.mdx`,应看到 `1 baselined file(s) improved (passing, reported)`,退出码 0。 - 需要你做的:无,席位落地。 ## What changes Each reader keeps its error path for the increase direction and gains a pure, separately-named decrease report: | Reader | Baseline | Decrease (now passes, printed on stdout) | Self-test | |:---|:---|:---|:---| | `check-slot-lookup-ratchet.mjs` | `slot-lookup-baseline.json` | `ratchetDownReport()` | battery 3 floor 6 → 11 | | `check-query-options-erasure-ratchet.mjs` | `query-options-erasure-baseline.json` (per-file + `testSurface` aggregate) | `ratchetDownReport()` (exported beside `diffRatchet`) | battery 2 floor 8 → 15 | | `check-role-word.mjs` | `role-word-baseline.json` | `ratchetVerdict().down` | new battery (7), roster floor 14 → 15 | | `check-i18n-coverage.mjs` | `i18n-coverage-baseline.json` | `ratchetVerdict().down` (comparison lifted out of the top level) | new battery (6), roster floor 6 → 7 | | `check-corpus-claim-drift.mjs` | `corpus-claim-drift-baseline.json` | `ratchetVerdict().down` | new battery (7), roster floor 17 → 18 | | `check-objectql-double-limit.mjs` | `objectql-double-limit.baseline.json` | `reconcileDown()` | battery floor 47 → 52 | | `check-where-matcher-conformance.mjs` | `where-matcher-conformance.baseline.json` | `reconcileDown()` | new battery (5), roster floor 3 → 4 | | `check-optional-error-sink-contract.mjs` | `optional-error-sink-contract.baseline.json` | `reconcileLedger().stale` | floor 66 → 70 | | `check-durability-degradation-log-level.mjs` | `durability-degradation.baseline.json`, `durability-read-invention.baseline.json` | `reconcileLedger().stale` (shared by both rules) | one row per partition; floors 68/57/125 → 69/58/127 | | `check-published-readme-exports.mjs` | `published-readme-exports.baseline.json` | stale entries printed on stdout; exit 0 | new end-to-end battery (3) on the scratch workspace, roster floor 27 → 28 | Every decrease line names its headroom: the old number stays the ceiling, or the stale key keeps licensing that site, until `--update` (or the hand edit, for the two gates with no `--update`) records it. `--update` is unchanged and remains the way to write the report down. Kept as errors on purpose: - A NEW file or key, and a grown count (the defect each gate exists for). - Every key-set monotonicity check ("ADDED to the baseline"). - corpus-claim-drift: a ledger row naming a rule `VOCABULARY` no longer holds. That is an unfinished retirement, not a decrease. The `$comment` headers of the seven baselines that described two-sided reconciliation now describe the one-sided one. Only `$comment` text changed; no counts or keys moved. ## Premise, measured on `origin/main` b7f2b0c before the change All ten readers were two-sided, and none was already one-sided. A real deletion of a listed file, run on the base tree, went red: | Gate | Deletion on base | Exit | |:---|:---|:---| | role-word | `content/docs/api/error-catalog.mdx` | 1 (`baselined file is clean/gone … ratchet DOWN`) | | corpus-claim-drift | `content/docs/protocol/kernel/http-protocol.mdx` | 1 (`improved 1 → 0 … ratchet DOWN`) | | objectql-double-limit | `packages/metadata-protocol/src/protocol-publish-drafts-advisories.test.ts` | 1 (`clean or gone -- ratchet DOWN`) | | optional-error-sink | `packages/services/service-settings/src/settings-service.types.ts` | 1 (`stale entry … shrink-only`) | | durability (both ledgers) | stale entry injected (simulates a gone file) | 1 each | | where-matcher | stale entry injected (the ledger is empty) | 1 (`clean or gone` + `ADDED`) | | slot-lookup, query-options | base self-test cases `a fall must be ratcheted down` / `a cleaned file must be dropped` assert 1 error | read off the pure `diffRatchet` | | i18n-coverage, published-readme-exports | code read: decrease / stale branch pushes an error and exits 1 | not run on base (build needed) | ## Acceptance: deleting a listed file reds no ratchet; a new violation still reds Run on head `60e74485`, after the dead-scan round. The first run, on `c4087da0`, gave the same exits. Each mutation is applied, the gate runs, then the tree is restored with `git checkout HEAD --` plus removal of new files, and the restore is checked with `git status` (clean every time). | Gate | DOWN: deleted a listed file → exit | UP: added a violation → exit | |:---|:---|:---| | role-word | `error-catalog.mdx` removed → **0**, reported | one more "role" in the same page → **1** | | corpus-claim-drift | `http-protocol.mdx` removed → **0**, reported | duplicated the claim line → **1** | | objectql-double-limit | `protocol-publish-drafts-advisories.test.ts` removed → **0**, reported | copied it to a new path → **1** (NEW) | | where-matcher | ledger is empty, so no real deletion is possible; self-test only | new test file (copied from a fixture) with an early-return matcher → **1** (NEW) | | optional-error-sink | `settings-service.types.ts` removed → **0**, stale reported | new `interface { error?: … }` → **1** | | durability (log-level) | stale entry injected → **0**, reported | new catch around `syncSchema` logging `warn` → **1** | | durability (read invention) | stale entry injected → **0**, reported | (the UP above runs both rules in one process) | | slot-lookup | `packages/core/examples/kernel-features-example.ts` removed → **0**, reported | one more `getService<any>` in that file → **1** | | query-options-erasure | `packages/core/src/security/resolve-authz-context.ts` removed → **0**, reported | one more `query as any` in that file → **1** | | i18n-coverage | `examples/app-todo/objectstack.config.ts` removed → **0**, reported | one `zh-CN` label line deleted → **1** | | published-readme-exports | ledger is empty, so a stale entry was injected → **0**, reported | an invented import in `packages/spec/README.md` → **1** | ## Dead scans stay red (seat review, round 1) When the ratchets were two-sided, a scan that lost part of its population dropped those baselined entries out of the measurement, and the DOWN direction went red once per entry. Now that the ratchets are one-sided, that same drop would read as a passing decrease. So the gate now asks the scan itself: if a baselined entry is still **on disk** but the run did not read it, the gate refuses. A deleted file is not on disk, and a swept file was read, so both still pass. The table lists, for each reader, what reds a scan or walk that read nothing. "New" marks a refusal added in this round. Each new refusal has self-test cases, and one of them is an empty population. | Reader | What reds a scan that read nothing | New self-test | |:---|:---|:---| | role-word | missing ROOT (existing, #9932); **new:** a baselined page on disk that the walk did not read | battery (7), including two spawned legs: a ledgered page parked in a skipped subtree exits 1, and the same entry with the page deleted exits 0 | | corpus-claim-drift | missing ROOT (existing); a scan that read nothing also leaves every VOCABULARY row unreachable (existing); **new:** a baselined page on disk that the walk did not read | battery (5) | | slot-lookup | ESLint throws on a target that matches no file; the rule-gone and rescoped refusals (existing); **new:** a baselined file on disk that the rule did not run on (not linted, or its `ignores` not lifted, read from `calculateConfigForFile`) | battery 6 (7), including two live legs: with the ignore left in place the resolved config has no rule, and with it lifted the resolved config has the rule | | query-options-erasure | the same ESLint and rule-gone guards (existing); **new:** a baselined non-test file the rule did not run on; **new:** the test surface measuring 0 against a non-zero ceiling | battery 6 (8) | | i18n-coverage | zero configs refused, exit 3 (existing, #10907); **new:** a baselined config on disk that the round did not measure; **new:** every measured config reading 0 while the ledger still holds strings (`os lint` renamed its `i18n/` rules, or the classifier went blind) | battery (6) | | objectql-double-limit | DISCOVERED: zero graded doubles (existing); **new:** a baselined file on disk that the corpus walk never read | 4 cases | | where-matcher-conformance | DISCOVERED: zero matchers found (existing). The ledger is empty, so there is no row a partial scan could turn into a decrease. | none (unchanged) | | optional-error-sink | **new:** no source file read, no sink declaring `error` found at all, or a ledgered file on disk the walk never reached. Before this round nothing caught it: a dead scan reported all five rows as stale and passed. | 5 cases | | durability (both ledgers) | a scan that read nothing leaves FAILURE_PROPAGATION_SITES / READ_FAILURE_DISCRIMINATORS stale, which still reds (existing, left two-sided); **new:** a ledgered file on disk the walk never read | one row per partition | | published-readme-exports | DOCUMENTS = 0 and the other population refusals (existing, self-tested) | none (unchanged) | No floor was introduced, so none rots on the next `--update`. Each refusal checks an identity (on disk and not read) or a zero. There are two places where a legitimate final sweep is refused until `--update` records it. The message names that remedy, and the zero ceiling that `--update` writes disarms the refusal: - i18n-coverage, when the last untranslated strings in every measured config are translated in one round. - query-options, when the last test-code erasure is swept. Deleting a file never trips either refusal. Dead-scan demos on `60e74485`. In each one, the gate's own walk or lift was broken for a single run, the gate was run, and the file was restored with `git checkout HEAD --` and a clean `git status`: | Gate | Mutation (script only) | Exit | |:---|:---|:---| | role-word | `content/docs/api` added to SKIP_SUBTREES | **1**, names the 2 ledgered pages it did not read | | corpus-claim-drift | `content/docs/protocol/kernel` added to SKIP_SUBTREES | **1**, names `http-protocol.mdx` | | slot-lookup | the measuring config lifts no `ignores` | **2**, 25 baselined files the rule did not run on | | query-options | the same, for its non-test run | **2**, 15 files | | query-options | the test globs not lifted | **2**, test surface 0 against a ceiling of 236 | | i18n-coverage | the classifier matches `i18nx/` instead of `i18n/` | **1**, every config at 0 against 527 | | optional-error-sink | SCAN_ROOTS pointed at a missing directory | **1**, no source file read | | objectql-double-limit | the walk skips `metadata-protocol` | **1**, UNREAD: 49 baselined files | | durability (read invention) | `packages/objectql/src` dropped from the read-seam roots | **1**, names `engine.ts` | ## Tests - `--self-test` of all ten readers: exit 0, each carrying its new decrease-passes and increase-reds cases (table above). - Production run of all ten readers on head: exit 0. - No package test exercises these scripts. The 15 `*.test.ts` files that name one of them only mention it in comments; those were read and none asserts behaviour this PR changes. ## Acceptance notes - **Headroom is the stated cost.** A one-sided ratchet left above reality licenses that many new sites until someone runs `--update`. The decrease report names the number on every run. The query-options test-surface line spells out `licenses N new test-code erasure(s)`. - **The detector-weakening signal moved to the self-tests.** A rule narrowed so far that live counts fall used to red the production run of slot-lookup, query-options and i18n-coverage. It now prints decrease reports. The self-tests, which drive the real rule over synthetic sources in both directions, are now the only thing that catches it. The slot-lookup header and the i18n classifier comment say this. - **eslint-ignore couplings (slot-lookup, query-options), re-read as the card asked.** The baseline keys are still the `ignores` of their `eslint.config.mjs` blocks, and both ratchets still measure listed files with the ignore lifted, so growth in a listed file stays red. A key whose file is gone stays an `ignores` glob until `--update` drops it, which does no harm. If a file is later recreated at that same path, it rides the stale count. The report says to drop the entry. - **Keyed ledgers** (optional-error-sink, both durability ledgers, readme-exports): a stale row re-licenses that exact key until it is deleted. The report says so. - Not touched: `.github/workflows/lint.yml` (no step's invocation changed). Its comment at line 4209 still calls the query-options test count "decrease-only". It is a governed file and only a comment. - Left two-sided, outside this card's list: `engine-double-contract.baseline.json` and `startup-registry-verdict.baseline.json` (both still fail on a stale entry), and the durability vocabulary staleness checks (`FAILURE_PROPAGATION_SITES`, `READ_FAILURE_DISCRIMINATORS`), which are declarations rather than baselines. - `packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts:636-639` has a comment saying the durability ledger and code are "pinned to each other in both directions". Only the restored-limb direction still reds. This is a comment outside the file surface, so it is left alone. ## Verification <details><summary>Derived gate families — 41/41 exit 0 on 60e7448 (and on c4087da before it), --ran reconciliation 0 NOT-MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 41 families, and each was run with its exit captured before any pipe. `pnpm check:entry-guard` first went red, because `reconcileLedger` was exported from a file with an unguarded top level. The function was made module-private (commit c4087da), and the gate then exited 0. `pnpm check:i18n-coverage` and `pnpm check:published-readme-exports` ran after a full `pnpm build` under `os-verify-lock`. After the dead-scan round, the same 41 families were derived again (the set was unchanged) and run again on `60e74485`, and all 41 exited 0. On that head, `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` printed: `41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero …)`. </details> <details><summary>Changeset</summary> Repository tooling only: `scripts/*.mjs` and `scripts/*.json`. No published package's `files[]` ships them, so this PR is labelled `skip-changeset`. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmPe3iuugZk1bwH3GNV --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPe3iuugZk1bwH3GNV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f181af commit e443a60

17 files changed

Lines changed: 1325 additions & 325 deletions

‎scripts/check-corpus-claim-drift.mjs‎

Lines changed: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -536,12 +536,78 @@ function grewMessage(file, rule, allowed, count) {
536536
);
537537
}
538538

539-
/** The improvement verdict. Ratcheting DOWN is squarely the author's job. */
539+
/**
540+
* The improvement REPORT. Ratcheting DOWN is squarely the author's job, and since
541+
* #26444 it is not a failure either: a decrease passes and this row is printed
542+
* beside the verdict, naming the headroom the stale count leaves until `--update`.
543+
*/
540544
function improvedMessage(file, rule, allowed, count) {
541545
return (
542546
`${file}: claim sites beside ${rule.subject} improved ${allowed} → ${count} (rule `
543-
+ `"${rule.id}") — ratchet DOWN: run \`node scripts/check-corpus-claim-drift.mjs --update\` `
544-
+ 'and commit the baseline.'
547+
+ `"${rule.id}"). Passing; until \`node scripts/check-corpus-claim-drift.mjs --update\` `
548+
+ `records it, ${allowed} stays this file's ceiling.`
549+
);
550+
}
551+
552+
/**
553+
* The comparison, as a pure function of the ledger and the scan, so the
554+
* self-test can drive it in both directions (#26444). ONE-SIDED: a NEW claim site
555+
* or a grown count is an error; a fallen count (a vanished file included) is a
556+
* REPORT that leaves the verdict green. A ledger row naming a rule VOCABULARY no
557+
* longer holds stays an error — that is an unfinished retirement, not a decrease.
558+
*/
559+
function ratchetVerdict(baseline, current, byId) {
560+
const errors = [];
561+
const down = [];
562+
for (const [file, perRule] of Object.entries(current)) {
563+
for (const [ruleId, n] of Object.entries(perRule)) {
564+
const allowed = baseline[file]?.[ruleId];
565+
const rule = byId.get(ruleId);
566+
if (allowed === undefined) errors.push(newClaimMessage(file, rule, n));
567+
else if (n > allowed) errors.push(grewMessage(file, rule, allowed, n));
568+
}
569+
}
570+
/* The improvement direction. A ledger that only ever shrinks has to be TOLD
571+
* when the tree shrank, or the debt it reports is the debt of some earlier
572+
* tree — so it is reported on every run, and it passes. */
573+
for (const [file, perRule] of Object.entries(baseline)) {
574+
for (const [ruleId, allowed] of Object.entries(perRule)) {
575+
const rule = byId.get(ruleId);
576+
if (!rule) {
577+
errors.push(`${BASELINE_PATH}: entry ${file} names rule "${ruleId}", which VOCABULARY no `
578+
+ 'longer holds. Retiring a row is a deliberate edit; finish it by running '
579+
+ '`node scripts/check-corpus-claim-drift.mjs --update` and committing the baseline.');
580+
continue;
581+
}
582+
const n = current[file]?.[ruleId] ?? 0;
583+
if (n < allowed) down.push(improvedMessage(file, rule, allowed, n));
584+
}
585+
}
586+
return { errors, down };
587+
}
588+
589+
/**
590+
* The dead-scan refusal the one-sided ratchet needs (#26444): baselined files
591+
* still ON DISK that this run did not read, sorted. While the ratchet was
592+
* two-sided, a scan that lost part of its population dropped those files out of
593+
* `current` and the DOWN direction redded; now that drop would be a passing
594+
* report. A deleted page is not on disk and a swept page was read, so both
595+
* still pass. (A scan that read NOTHING is also caught by the unreachable-row
596+
* refusal; this one covers the partial case it cannot see.) `exists` is
597+
* injected for the self-test.
598+
*/
599+
function unreadBaselined(baseline, read, exists = existsSync) {
600+
return Object.keys(baseline).filter((file) => exists(file) && !read.has(file)).sort();
601+
}
602+
603+
function unreadBaselinedMessage(unread) {
604+
return (
605+
`check-corpus-claim-drift: ${unread.length} baselined file(s) are still on disk but this run `
606+
+ `did not read them — ${unread.join(', ')}. REFUSING to reach a verdict. The scan lost part `
607+
+ 'of its population (a root emptied, a skip widened, or the walk broke), and since the '
608+
+ 'ratchet went one-sided (#26444) a file that drops out of the scan would otherwise pass as '
609+
+ 'a decrease. If the narrowing is deliberate, '
610+
+ '`node scripts/check-corpus-claim-drift.mjs --update` records it.'
545611
);
546612
}
547613

@@ -657,11 +723,13 @@ const SELF_TEST_BATTERIES = Object.freeze({
657723
'A missing ROOT is REFUSED, per root (#9932)': 4,
658724
'The dispatch-gates declaration (#9964\'s pattern)': 4,
659725
'At the PROGRAM level': 10,
726+
'The ratchet is ONE-SIDED: a decrease passes and is reported (#26444)': 7,
727+
'A baselined file the scan did not READ is REFUSED (#26444)': 5,
660728
});
661729

662730
// DELETING an entry silences that battery's floor exactly as effectively as
663731
// zeroing it, so the roster's own size is pinned too.
664-
const SELF_TEST_BATTERY_FLOOR = 17;
732+
const SELF_TEST_BATTERY_FLOOR = 19;
665733

666734
// The key an assertion is filed under when no battery is open. It is not a
667735
// declared battery, so it reds by the same set difference rather than silently
@@ -1232,6 +1300,49 @@ function selfTest() {
12321300
expect('every verdict TEACHES the truth rather than only naming a banned spelling',
12331301
[newClaim, grew].every((m) => m.includes('HAS A VALUE') && m.includes(RULE.refs)));
12341302

1303+
// ── The ratchet is ONE-SIDED: a decrease passes and is reported (#26444) ──
1304+
battery('The ratchet is ONE-SIDED: a decrease passes and is reported (#26444)');
1305+
{
1306+
const byId = new Map([[RULE.id, RULE]]);
1307+
const ledger = { 'content/docs/a.mdx': { [RULE.id]: 2 }, 'content/docs/b.mdx': { [RULE.id]: 1 } };
1308+
const same = ratchetVerdict(ledger, structuredClone(ledger), byId);
1309+
expect('#26444 — an unchanged tree has no error and no report',
1310+
same.errors.length === 0 && same.down.length === 0);
1311+
const fell = ratchetVerdict(ledger, { 'content/docs/a.mdx': { [RULE.id]: 1 }, 'content/docs/b.mdx': { [RULE.id]: 1 } }, byId);
1312+
expect('#26444 — a fallen count PASSES (no error) and is reported once',
1313+
fell.errors.length === 0 && fell.down.length === 1);
1314+
const gone = ratchetVerdict(ledger, { 'content/docs/a.mdx': { [RULE.id]: 2 } }, byId);
1315+
expect('#26444 — a vanished baselined file PASSES (no error) and is reported once',
1316+
gone.errors.length === 0 && gone.down.length === 1);
1317+
const grew = ratchetVerdict(ledger, { 'content/docs/a.mdx': { [RULE.id]: 3 }, 'content/docs/b.mdx': { [RULE.id]: 1 } }, byId);
1318+
expect('#26444 — a grown count still FAILS', grew.errors.length === 1 && grew.down.length === 0);
1319+
const fresh = ratchetVerdict(ledger, { ...structuredClone(ledger), 'content/docs/c.mdx': { [RULE.id]: 1 } }, byId);
1320+
expect('#26444 — a NEW claim site still FAILS', fresh.errors.length === 1);
1321+
const retired = ratchetVerdict({ 'content/docs/a.mdx': { 'no-such-row': 1 } }, {}, byId);
1322+
expect('#26444 — a ledger row naming a retired rule is still an ERROR, not a decrease',
1323+
retired.errors.length === 1 && retired.down.length === 0);
1324+
expect('#26444 — the improved report names the old count as the ceiling it leaves until --update',
1325+
improvedMessage('content/docs/a.mdx', RULE, 4, 2).includes('4 stays'));
1326+
}
1327+
1328+
// ── A baselined file the scan did not READ is REFUSED (#26444) ──────────
1329+
battery('A baselined file the scan did not READ is REFUSED (#26444)');
1330+
{
1331+
const ledger = { 'content/docs/a.mdx': { [RULE.id]: 1 }, 'skills/b.md': { [RULE.id]: 1 } };
1332+
const onDisk = () => true;
1333+
expect('#26444 — an EMPTY population with every ledgered page still on disk refuses every one',
1334+
unreadBaselined(ledger, new Set(), onDisk).length === 2);
1335+
expect('#26444 — a partial scan refuses only the pages it did not read',
1336+
unreadBaselined(ledger, new Set(['content/docs/a.mdx']), onDisk).join(',') === 'skills/b.md');
1337+
expect('#26444 — a DELETED page is not refused (it is a decrease, and decreases pass)',
1338+
unreadBaselined(ledger, new Set(['content/docs/a.mdx']), (f) => f !== 'skills/b.md').length === 0);
1339+
expect('#26444 — a page that was read is not refused, whatever its count now',
1340+
unreadBaselined(ledger, new Set(Object.keys(ledger)), onDisk).length === 0);
1341+
expect('#26444 — the refusal names the unread page and the --update remedy',
1342+
unreadBaselinedMessage(['skills/b.md']).includes('skills/b.md')
1343+
&& unreadBaselinedMessage(['skills/b.md']).includes('--update'));
1344+
}
1345+
12351346
// ── A missing ROOT is REFUSED, per root (#9932) ───────────────────────────
12361347
battery('A missing ROOT is REFUSED, per root (#9932)');
12371348
const PRESENT_ROOT = 'alpha/one';
@@ -1545,30 +1656,17 @@ const baseline = existsSync(BASELINE_PATH)
15451656
: {};
15461657
const byId = new Map(VOCABULARY.map((r) => [r.id, r]));
15471658

1548-
const errors = [];
1549-
for (const [file, perRule] of Object.entries(current)) {
1550-
for (const [ruleId, n] of Object.entries(perRule)) {
1551-
const allowed = baseline[file]?.[ruleId];
1552-
const rule = byId.get(ruleId);
1553-
if (allowed === undefined) errors.push(newClaimMessage(file, rule, n));
1554-
else if (n > allowed) errors.push(grewMessage(file, rule, allowed, n));
1555-
}
1659+
const unread = unreadBaselined(baseline, new Set(corpus.map((f) => f.rel)));
1660+
if (unread.length) {
1661+
console.error(unreadBaselinedMessage(unread));
1662+
process.exit(1);
15561663
}
1557-
/* The improvement direction. A ledger that only ever shrinks has to be TOLD
1558-
* when the tree shrank, or the debt it reports is the debt of some earlier
1559-
* tree. */
1560-
for (const [file, perRule] of Object.entries(baseline)) {
1561-
for (const [ruleId, allowed] of Object.entries(perRule)) {
1562-
const rule = byId.get(ruleId);
1563-
if (!rule) {
1564-
errors.push(`${BASELINE_PATH}: entry ${file} names rule "${ruleId}", which VOCABULARY no `
1565-
+ 'longer holds. Retiring a row is a deliberate edit; finish it by running '
1566-
+ '`node scripts/check-corpus-claim-drift.mjs --update` and committing the baseline.');
1567-
continue;
1568-
}
1569-
const n = current[file]?.[ruleId] ?? 0;
1570-
if (n < allowed) errors.push(improvedMessage(file, rule, allowed, n));
1571-
}
1664+
1665+
const { errors, down } = ratchetVerdict(baseline, current, byId);
1666+
if (down.length) {
1667+
console.log(`check-corpus-claim-drift: ${down.length} baselined count(s) improved (passing, reported)\n`);
1668+
for (const d of down) console.log(` • ${d}`);
1669+
console.log('');
15721670
}
15731671

15741672
if (errors.length) {

0 commit comments

Comments
 (0)