Skip to content

Commit 8366f3e

Browse files
ci(gates): the last two ratchet ledgers become one-sided — a decrease passes and is reported (#26701)
Refs #26586 (items 1, 3) Items 2 and 4 stay on the card: item 2 is a comment in the governed `.github/workflows/lint.yml` (Tier H), and item 4 asks the maintainer for a ruling, so neither is touched here. ## What changes The two ratchet ledgers that #26549 left two-sided become one-sided, the same treatment the other ten got: a decrease passes and is reported, an increase reds, and a baselined entry whose file is still on disk that the run never read is refused, so a dead scan stays red. | Reader | Baseline | Decrease (now passes, printed on stdout) | Dead scan stays red | Self-test | |:---|:---|:---|:---|:---| | `check-engine-double-contract.mjs` | `engine-double-contract.baseline.json` (120 DEBT + 3 EXEMPT rows) | `reconcileDebtLedger().down` — a fallen count, a file with nothing unguarded left, an entry whose double is gone | new `UNREAD [verb]` error: a ledgered file still on disk that the discovery walk never read | new battery (11), roster floor 33 → 34 | | `check-startup-registry-verdict.mjs` | `startup-registry-verdict.baseline.json` (empty today) | `reconcileLedger().stale` — printed with `↓`, exit 0 | new refusal: a ledgered key whose file is still on disk and was not in the corpus the walk read | new battery (8), roster floor 18 → 19 | Both comparisons move into pure functions (`reconcileDebtLedger`, `reconcileLedger` / `unreadBaselined`) so the self-test drives both directions with no tree at all. Neither gate has a `--fix`/`--update`, so each decrease line names the hand edit that records it and the ceiling it leaves until then. Kept as errors on purpose: a NEW unguarded engine double, a grown per-file count (`PINNED`), a finding with no ledger row, `DECLARED`, `DISCOVERED` / `SEAMS_DISCOVERED`, `RETAINED` / `SEAMS_RETAINED` (those two ledgers record COVERAGE and may only grow — the opposite polarity, untouched here), the population floors, and the dead-root refusals. Item 3: the comment at `packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts:636-639` said the durability ledger and the code are "pinned to each other in both directions". Since #26549 only the restored-limb direction reds; a stale entry left behind after the fix passes and is reported. Comment-only, no assertion changed. Header and `$comment` prose that described the old behaviour is rewritten rather than left behind: the `RECONCILED` invariant in `check-engine-double-contract.mjs` (with a new `UNREAD` row beside it), the RETAINED block's "ratchet the LEDGERED population in both directions" paragraph, which is now dated to #9680 rather than stated as current, one aside at L647, and both baselines' `$comment` blocks. No counts or keys moved in either baseline. ## Why A stale entry reds a PR that never touched the gate: delete a listed test file, or fix the site a row licenses, and CI goes red with a remedy that belongs to a different card. #26444's Wanted 3, measured below on this card's own two ledgers. ## Verification ### Premise, measured on the base tree `fe53769a` before the change Both gates exited 0 on that tree, and each redded on a legitimate decrease: | Gate | Mutation on the base tree | Exit | |:---|:---|:---| | engine-double-contract | deleted `packages/cli/src/commands/serve-email-appname-precedence.test.ts`, a file the ledger lists for both `delete` and `update` | **1** — `RECONCILED [delete]: baseline entry for … declares no engine double with a delete any more` plus the `update` twin | | startup-registry-verdict | the ledger is empty, so a stale entry was injected (`packages/core/src/gone-plugin.ts::GonePlugin.init()::cache`) | **1** — `1 stale baseline entr(ies) … the site no longer violates, so delete the entry` | Each mutation was restored with `git checkout HEAD --` and `git status --porcelain` read clean afterwards. ### Acceptance, on head `36c37411` | Gate | DOWN: a legitimate decrease | UP: a new violation | |:---|:---|:---| | engine-double-contract | the same file deleted → **0**, `DEBT ledger: 2 (file, verb) entr(ies) fell (passing, reported)` | the same file copied to a new path → **1**, `PINNED [delete]`/`PINNED [update]` on the copy | | startup-registry-verdict | an injected row for a gone file → **0**, `↓ 1 stale baseline entr(ies)`; and an injected row for a file the walk DOES read (`packages/core/src/kernel.ts`) → **0**, reported the same way | a new `init()` probe that warns on the absent `cache` service, written into `packages/core/src` → **1**, `1 startup registry read(s) record a verdict the boot can still contradict` | ### Dead scans stay red Each mutation landed through `node scripts/ablation-replace.mjs` (anchor must hit; write and restore verified on disk), and each restore printed `blob == HEAD` with an empty `git diff HEAD`: | Gate | Mutation (the gate's own walk, one run) | Exit | |:---|:---|:---| | engine-double-contract | the discovery walk skips any directory named `commands` | **1**, four `UNREAD` findings naming the two ledgered files under `packages/cli/src/commands` for both verbs | | startup-registry-verdict | `collectSourceFiles` skips `core`, with a row injected for a file under `packages/core/src` | **1**, `1 baselined entr(ies) … name a file still on disk that the scan never read` | The population refusals both gates already had are untouched: `DISCOVERED`/`SEAMS_DISCOVERED`, the four population floors reached by every scanning branch, the dead-root `assertRootsResolvable` refusal, and the empty-corpus refusal. ### Tests - `node scripts/check-engine-double-contract.mjs --self-test` → exit 0, carrying the new 11-case one-sided battery; production run → exit 0, `OK — 1091 pinned, 120 in the DEBT ledger, 3 exempt`. - `node scripts/check-startup-registry-verdict.mjs --self-test` → exit 0, `18 analysis case(s) + the dead-root hard error … + the one-sided ledger (8 case(s)) all passed`; production run → exit 0, `46 startup/open-registry seam(s) across 3585 file(s)`. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.metadata-store-outage.test.ts` → exit 0, `Test Files 1 passed (1)`, `Tests 32 passed (32)` (under the shared verify lock, `VERDICT command-exit 0`). - No package test exercises either script. Eight `*.test.ts` files name one of them; all eight mention it in a comment only, and none asserts behaviour this PR changes. <details><summary>Derived gate families — 66/69 exit 0, 3 NOT MEASURED (prerequisite), on 36c3741</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 families over the five changed paths; each ran with its exit captured before any pipe. 66 exited 0, including `pnpm check:engine-double-contract`, `pnpm check:startup-registry-verdict`, `pnpm check:durability-log-level`, `pnpm check:objectql-double-limit`, `pnpm check:ratchet-remedy-authority`, `pnpm check:self-test-wired`, `pnpm check:entry-guard` and `pnpm check:doc-authoring`. Three exited **3**, the prerequisite code, so they are NOT MEASURED rather than red — the same classification `--ran` derives from the recorded codes: - `node scripts/check-plugin-teardown-shape.mjs --self-test` — its positive-control fixture is pinned to a commit this shallow checkout cannot reach (`git rev-parse --is-shallow-repository` is true). - `pnpm check:dual-build-cjs-loads` — reads built output; 72 packages have no `dist/` in this container. - `pnpm check:lean-entry-closure` — its own self-test passed (22 cases); the gate then refused because `packages/objectql/dist/core.mjs` is absent. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`, with each line recorded as `COMMAND :: exit CODE`, printed: `69 derived famil(ies) accounted for — 66 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)`, 0 UNRUN. The local-only families were not run: this diff touches neither `packages/spec`'s build nor a four-axis frame file. The repo-wide scans (`pnpm lint` first among them) and the whole farm are CI's run, and the `Lint & Repo Gates` check-run is the reading of record for both gates on this PR. </details> <details><summary>Changeset</summary> `skip-changeset`. Root `scripts/*.mjs` and `scripts/*.json` ship in no package's `files[]`. The one `packages/**` path is a comment in a `*.test.ts`, and `@objectstack/metadata-protocol` ships `["dist","README.md","CHANGELOG.md"]` — measured after the build: the comment's text (`pinned to each other`) has zero hits anywhere under `packages/metadata-protocol/dist`, with `listCommits` as the positive control hitting `dist/index.js`, `dist/index.cjs` and `dist/index.d.cts`. </details> Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:#26549 漏下的两本棘轮台账(`engine-double-contract.baseline.json`、`startup-registry-verdict.baseline.json`)也改成单侧:数减少、文件被删、站点被修好 → 通过并在输出里报告;新增违例、计数变大 → 照旧变红。空扫描仍然变红:台账里列着、文件还在磁盘上、这次却没读到,就拒绝。另外改了一条过期注释(item 3)。 - 为什么改:删一个被台账列着的测试文件,或者修好一处被台账许可的站点,今天就会让一个根本没碰这个门禁的 PR 变红,而修法属于另一张卡。 - 风险与回滚:代价是「余量」——减少后没有手改台账,旧数仍是上限,可以悄悄补回同样多的违例;每条报告都写明了这个数。两个门禁都没有 `--update`,所以每条报告直接给出该手改哪一行。回滚就是 revert 本 PR,没有数据或接线变化。 - 先看:`scripts/check-engine-double-contract.mjs` 的 `reconcileDebtLedger()`(唯一带计数的一本,新增 `UNREAD` 拒绝),然后是 `scripts/check-startup-registry-verdict.mjs` 的 `reconcileLedger()` / `unreadBaselined()`。 - 两分钟验证:`git rm packages/cli/src/commands/serve-email-appname-precedence.test.ts && node scripts/check-engine-double-contract.mjs; echo $?; git checkout HEAD -- packages/cli/src/commands/serve-email-appname-precedence.test.ts`,应看到 `DEBT ledger: 2 (file, verb) entr(ies) fell (passing, reported)`,退出码 0。 - 需要你做的:item 4 的裁决(本 PR 不改行为):收窄后的检测器如果让活数下降,生产运行不再变红;对这两个门禁而言「磁盘上有、这次没读到就拒绝」这一臂能抓住哪些、抓不住哪些,写在下面的 Acceptance notes 里。item 2 在受管的 `lint.yml` 里,也留在卡上。席位落地本 PR。 ## Acceptance notes - **Headroom is the stated cost.** A one-sided ledger left above reality licenses that many sites until someone edits it by hand. Every decrease line names the number it leaves as the ceiling. The engine-double ledger's rows are keyed `(file, verb)`, so a stale row re-licenses that exact pair; if a file is later recreated at the same path it rides the stale count, and the report says to delete the entry. - **Item 4, measured on these two gates.** The `UNREAD` arm catches a scan that lost part of its *corpus* — a walk that stopped descending, a root that moved, a file-kind filter that stopped matching — because it asks an identity (on disk, and not read) rather than a count. It does **not** catch a *recognizer* that narrowed: `check-engine-double-contract`'s discovery still reads every ledgered file, so a `scanSource` that stopped recognising a double's shape leaves the file read and its row a passing decrease. For that half the gate has two other guards it already had, both untouched here: `DISCOVERED` (a slice finding zero doubles anywhere) and the four population floors, of which `discoveredFiles ≥ 700` and `pinnedRows ≥ 600` are exactly the recognizer's own output — this run reads 1119 and 1000, so a narrowing that costs more than ~37% of discovery, or 40% of pins, refuses with exit 2 rather than reporting decreases. What stays uncovered is a narrowing smaller than that band which happens to land on ledgered files; for that, the self-test batteries driving the real recognizer over synthetic sources in both directions are the only instrument. `check-startup-registry-verdict` has no population floor and an empty ledger, so for it the whole detector-weakening axis rests on its self-test: the 18 analysis rows are what would red if a vocabulary entry or the AST walk stopped matching, and the empty-corpus and dead-root refusals cover only the total collapse. A floor there would be invented rather than measured (46 seams, 39 of them read-only), which is why this PR adds none. - Not touched: `scripts/engine-double-contract.pinned.json` and `scripts/engine-double-contract.seams.json` (GENERATED, opposite polarity — they record coverage and may only grow; open PR #26337 touches the pinned ledger), the durability vocabulary staleness checks, and `.github/workflows/lint.yml` (item 2, governed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbaFpnMwgpCgneKe2qS --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbaFpnMwgpCgneKe2qS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88fb255 commit 8366f3e

5 files changed

Lines changed: 296 additions & 93 deletions

‎packages/metadata-protocol/src/protocol.metadata-store-outage.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -635,8 +635,9 @@ describe('[#5840] the narrowness is the design — three things it deliberately
635635
// outage split and not a blanket "listCommits now throws". The baseline entry
636636
// `packages/metadata-protocol/src/protocol.ts::listCommits` is removed in the
637637
// same PR, and its gate is shrink-only, so a lap with the limb restored and the
638-
// entry already removed must ALSO fail `check-durability-degradation-log-level`
639-
// — the ledger and the code are pinned to each other in both directions.
638+
// entry already removed must ALSO fail `check-durability-degradation-log-level`.
639+
// Only that direction reds: the ledger is one-sided since #26444, so a stale
640+
// entry left behind after the fix passes and is reported, not failed.
640641

641642
/** One `sys_metadata_commit` row, in the driver's snake_case wire shape. */
642643
function commitRow(id: string, createdAt: string, operation = 'apply') {

‎scripts/check-engine-double-contract.mjs‎

Lines changed: 178 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,18 @@
174174
// from `@objectstack/objectql` (which re-exports it from the
175175
// original path) -- or its file carries a measured baseline entry
176176
// for that verb.
177-
// RECONCILED in both directions. A baseline entry for a file with no
177+
// RECONCILED one-sided (#26586). A baseline entry for a file with no
178178
// unguarded doubles left, for a file that no longer exists, or
179-
// whose count is now lower, is an error. A ratchet that can only
180-
// accrete rots into a list nobody trusts.
179+
// whose count is now lower, PASSES and is reported with the edit
180+
// that ratchets it down -- deleting or fixing a test file must not
181+
// red a PR that never touched this gate. Until that edit lands
182+
// the old count stays the file's ceiling, and the report says so.
183+
// Growth is PINNED's, and still fails.
184+
// UNREAD the dead-scan half RECONCILED going one-sided needs. An entry
185+
// whose file is still on disk but was not among the test files
186+
// the walk read is an error, not a decrease: a walk that lost part
187+
// of the tree used to red through RECONCILED, and would otherwise
188+
// pass as a list of shrunken entries.
181189
// DECLARED every baseline entry names a `verb` this script actually
182190
// scans. Without it a typo'd or retired verb makes an entry
183191
// unreachable -- it would reconcile against nothing, forever,
@@ -189,14 +197,17 @@
189197
//
190198
// ## RETAINED, and why the pinned set is enumerated rather than counted (#9680)
191199
//
192-
// The four invariants above ratchet the LEDGERED population in both directions
193-
// -- measured on this branch, deleting a DEBT-ledgered double's member reddens
194-
// (`RECONCILED: baseline entry for … declares no engine double with a delete
195-
// any more`), and so does dropping one of the five doubles behind the EXEMPT
196-
// entry (`down to 4 … from the baseline's 5`). The PINNED population had no
197-
// such ratchet, and that asymmetry is the whole of #9680: DISCOVERED fires at
198-
// zero, never at one-fewer-than-yesterday, and PINNED iterates the discovered
199-
// set, so a pinned double that stops declaring the member simply LEAVES.
200+
// When #9680 landed, the invariants above ratcheted the LEDGERED population in
201+
// both directions -- measured on that branch, deleting a DEBT-ledgered double's
202+
// member reddened (`RECONCILED: baseline entry for … declares no engine double
203+
// with a delete any more`), and so did dropping one of the five doubles behind
204+
// the EXEMPT entry (`down to 4 … from the baseline's 5`). Since #26586 both are
205+
// passing reports instead: the DEBT ledger records looseness, so its shrinking
206+
// is the goal, and RETAINED below is what still reds on a PINNED loss. Before
207+
// #9680 the PINNED population had no ratchet at all, and that asymmetry is the
208+
// whole of #9680: DISCOVERED fires at zero, never at one-fewer-than-yesterday,
209+
// and PINNED iterates the discovered set, so a pinned double that stops
210+
// declaring the member simply LEAVES.
200211
//
201212
// Discovery requires the member to exist, so absence is invisible by
202213
// construction. Measured, two directions, on `packages/core/src/utils/
@@ -633,8 +644,8 @@ function mentionsVerb(text, verb) {
633644
* `delete: vi.fn(async (o, opts) => …)` is a CallExpression, so the two
634645
* initializer branches of `implOf` below used to answer `null` for it —
635646
* `consider()` then returned before the sibling and shape tests ever ran, and
636-
* the double was discovered by NEITHER side of a ledger that reconciles in both
637-
* directions. Not "declared out of scope": absent. That is the DISCOVERED
647+
* the double was discovered by NEITHER side of a ledger that then reconciled in
648+
* both directions. Not "declared out of scope": absent. That is the DISCOVERED
638649
* invariant's blind half, one layer down — `DISCOVERED != 0` catches a scan
639650
* that breaks entirely and cannot catch a scan that quietly skips one spelling,
640651
* and `vi.fn` is the spelling a test reaches for precisely when it wants to
@@ -3157,14 +3168,103 @@ function refusePopulationFloor(population, what) {
31573168
process.exit(EXIT_POPULATION_REFUSED);
31583169
}
31593170

3171+
/**
3172+
* The DEBT ledger against ONE slice's discovered set -- PINNED and RECONCILED,
3173+
* pure so the self-test drives both directions with no tree (#26586).
3174+
*
3175+
* ONE-SIDED. An unguarded double with no entry, or more of them than the entry
3176+
* records, is an error. A (file, verb) whose count fell, or that has nothing
3177+
* unguarded left, or whose double is gone (file deleted, fake removed, shape
3178+
* changed), PASSES and lands in `down` with the edit that ratchets it -- so
3179+
* deleting or fixing a test file reds no PR that never touched this gate. Until
3180+
* that edit lands, the old count stays the file's ceiling.
3181+
*
3182+
* UNREAD is the dead-scan half the one-sided ledger needs. While a vanished
3183+
* entry was an error, a walk that lost part of the tree redded through it; now
3184+
* it would pass as a decrease, so an entry whose file is still ON DISK but is
3185+
* not in `read` (the test files the walk offered) is refused instead. A deleted
3186+
* file is not on disk and a pinned or swept file was read, so both still pass.
3187+
* `exists` is injected for the self-test.
3188+
*
3189+
* @returns {{errors: string[], down: string[]}}
3190+
*/
3191+
function reconcileDebtLedger(slice, found, entries, read, exists = (f) => existsSync(join(ROOT, f))) {
3192+
const errors = [];
3193+
const down = [];
3194+
const mine = entries.filter((e) => e.verb === slice.verb);
3195+
const byFile = new Map(mine.map((e) => [e.file, e]));
3196+
const seen = new Set();
3197+
for (const { file, doubles } of found) {
3198+
const unguarded = doubles.filter((d) => !d.pinned);
3199+
const entry = byFile.get(file);
3200+
if (entry) seen.add(file);
3201+
3202+
if (unguarded.length === 0) {
3203+
if (entry) {
3204+
down.push(
3205+
`RECONCILED [${slice.verb}]: ${file} has no unguarded engine double left; the baseline `
3206+
+ `still records ${entry.unguarded}. Passing; delete the entry -- until then it licenses `
3207+
+ `${entry.unguarded} unguarded double(s) in that file.`,
3208+
);
3209+
}
3210+
continue;
3211+
}
3212+
if (!entry) {
3213+
errors.push(pinnedMessage(slice, file, unguarded));
3214+
continue;
3215+
}
3216+
if (unguarded.length > entry.unguarded) {
3217+
errors.push(
3218+
`PINNED [${slice.verb}]: ${file} now has ${unguarded.length} unguarded engine double(s), `
3219+
+ `baseline records ${entry.unguarded}. The baseline is shrink-only — pin the new one `
3220+
+ 'rather than raising it.'
3221+
+ INHERITANCE_NOTE(slice.verb),
3222+
);
3223+
} else if (unguarded.length < entry.unguarded) {
3224+
down.push(
3225+
`RECONCILED [${slice.verb}]: ${file} is down to ${unguarded.length} unguarded engine `
3226+
+ `double(s) from the baseline's ${entry.unguarded}. Passing; lower the number to `
3227+
+ `${unguarded.length} -- until then ${entry.unguarded} stays this file's ceiling.`,
3228+
);
3229+
}
3230+
}
3231+
3232+
for (const entry of mine) {
3233+
if (seen.has(entry.file)) continue;
3234+
if (exists(entry.file) && !read.has(entry.file)) {
3235+
errors.push(
3236+
`UNREAD [${slice.verb}]: baseline entry for ${entry.file} names a file still on disk that `
3237+
+ 'the discovery walk never read. The walk lost part of its population, and since the '
3238+
+ 'ledger went one-sided (#26586) this entry would otherwise pass as a decrease -- fix the '
3239+
+ 'walk, not the ledger. (If the file was moved out of the scan roots or renamed off the '
3240+
+ 'test-file pattern on purpose, delete the entry.)',
3241+
);
3242+
continue;
3243+
}
3244+
down.push(
3245+
`RECONCILED [${slice.verb}]: baseline entry for ${entry.file}, which declares no engine `
3246+
+ `double with a ${slice.verb} any more (file deleted, fake removed, or the shape `
3247+
+ `changed). Passing; delete the entry -- until then it licenses ${entry.unguarded} `
3248+
+ 'unguarded double(s) at that path.',
3249+
);
3250+
}
3251+
return { errors, down };
3252+
}
3253+
31603254
function audit() {
31613255
const baseline = readBaseline();
31623256
const errors = [];
3257+
// The DEBT ledger's decrease direction (#26586): reported, never an error.
3258+
const down = [];
31633259
const slices = [];
31643260
// The two walks, counted once here rather than inferred from what survived
31653261
// them. `scanSlice` and `scanAllSeams` each re-walk internally; these are the
3166-
// same functions, so the counts are the sets those scans were offered.
3167-
const testFileCount = testFiles().length;
3262+
// same functions, so the counts are the sets those scans were offered -- and
3263+
// `read` is the test-file set every slice read, which the ledger's UNREAD
3264+
// refusal asks.
3265+
const walked = testFiles();
3266+
const testFileCount = walked.length;
3267+
const read = new Set(walked.map((abs) => relative(ROOT, abs).split(sep).join('/')));
31683268
const productionFileCount = productionFiles().length;
31693269

31703270
// DECLARED — before anything reconciles, every entry must name a verb this
@@ -3182,9 +3282,6 @@ function audit() {
31823282

31833283
for (const slice of SLICES) {
31843284
const found = scanSlice(slice);
3185-
const byFile = new Map(
3186-
baseline.entries.filter((e) => e.verb === slice.verb).map((e) => [e.file, e]),
3187-
);
31883285
slices.push({ slice, found });
31893286

31903287
// DISCOVERED
@@ -3197,50 +3294,11 @@ function audit() {
31973294
);
31983295
}
31993296

3200-
const seen = new Set();
3201-
for (const { file, doubles } of found) {
3202-
const unguarded = doubles.filter((d) => !d.pinned);
3203-
const entry = byFile.get(file);
3204-
if (entry) seen.add(file);
3205-
3206-
if (unguarded.length === 0) {
3207-
if (entry) {
3208-
errors.push(
3209-
`RECONCILED [${slice.verb}]: ${file} has no unguarded engine double left, but the `
3210-
+ `baseline still records ${entry.unguarded}. Delete the entry in the same PR that `
3211-
+ 'fixed it.',
3212-
);
3213-
}
3214-
continue;
3215-
}
3216-
if (!entry) {
3217-
errors.push(pinnedMessage(slice, file, unguarded));
3218-
continue;
3219-
}
3220-
if (unguarded.length > entry.unguarded) {
3221-
errors.push(
3222-
`PINNED [${slice.verb}]: ${file} now has ${unguarded.length} unguarded engine double(s), `
3223-
+ `baseline records ${entry.unguarded}. The baseline is shrink-only — pin the new one `
3224-
+ 'rather than raising it.'
3225-
+ INHERITANCE_NOTE(slice.verb),
3226-
);
3227-
} else if (unguarded.length < entry.unguarded) {
3228-
errors.push(
3229-
`RECONCILED [${slice.verb}]: ${file} is down to ${unguarded.length} unguarded engine `
3230-
+ `double(s) from the baseline's ${entry.unguarded}. Lower the number in the same PR, so `
3231-
+ 'the ratchet holds.',
3232-
);
3233-
}
3234-
}
3235-
3236-
for (const entry of baseline.entries) {
3237-
if (entry.verb !== slice.verb || seen.has(entry.file)) continue;
3238-
errors.push(
3239-
`RECONCILED [${slice.verb}]: baseline entry for ${entry.file}, which declares no engine `
3240-
+ `double with a ${slice.verb} any more (file deleted, fake removed, or the shape `
3241-
+ 'changed). Delete the entry.',
3242-
);
3243-
}
3297+
// PINNED and RECONCILED — one-sided since #26586: growth fails, a decrease
3298+
// is reported, and an entry whose file the walk never read is refused.
3299+
const ledger = reconcileDebtLedger(slice, found, baseline.entries, read);
3300+
errors.push(...ledger.errors);
3301+
down.push(...ledger.down);
32443302
}
32453303

32463304
// ── RETAINED (#9680) — the pinned population is enumerated, not counted ───
@@ -3318,7 +3376,7 @@ function audit() {
33183376
));
33193377

33203378
return {
3321-
slices, baseline, errors, seamFiles, seamCount, seamCensus, census, pinnedLedger,
3379+
slices, baseline, errors, down, seamFiles, seamCount, seamCensus, census, pinnedLedger,
33223380
// The population floors' subjects (#13014). Two walk counts taken above and
33233381
// two reader counts derived from what this run actually parsed -- never
33243382
// from a ledger's size, which is shrink-only by design.
@@ -3449,7 +3507,7 @@ function writeSeamLedger(seamFiles) {
34493507
}
34503508

34513509
function report() {
3452-
const { slices, baseline, errors, seamFiles, seamCount, seamCensus, census, population } = audit();
3510+
const { slices, baseline, errors, down, seamFiles, seamCount, seamCensus, census, population } = audit();
34533511

34543512
console.log('');
34553513
let totalPinned = 0;
@@ -3528,6 +3586,14 @@ function report() {
35283586
// findings would be drawn from the same collapsed set (#13014).
35293587
refusePopulationFloor(population, 'No verdict was reached and no exit code below is a reading.');
35303588

3589+
// The DEBT ledger's decrease direction (#26586): printed on every run, red or
3590+
// green, and never a finding -- each line is the hand edit that ratchets it.
3591+
if (down.length) {
3592+
console.log(`DEBT ledger: ${down.length} (file, verb) entr(ies) fell (passing, reported):`);
3593+
for (const d of down) console.log(` ↓ ${d}`);
3594+
console.log('');
3595+
}
3596+
35313597
if (errors.length) {
35323598
for (const e of errors) console.error(` x ${e}`);
35333599
console.error(`\ncheck-engine-double-contract: ${errors.length} problem(s).\n`);
@@ -3636,11 +3702,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
36363702
'#11626: the DECLARED single-verb double': 14,
36373703
'The POPULATION FLOORS (#13014)': 23,
36383704
'Every branch that SCANS routes through the floor (#17117)': 6,
3705+
'The DEBT ledger is ONE-SIDED (#26586)': 11,
36393706
});
36403707

36413708
// DELETING an entry silences that battery's floor exactly as effectively as
36423709
// zeroing it, so the roster's own size is pinned too.
3643-
const SELF_TEST_BATTERY_FLOOR = 33;
3710+
const SELF_TEST_BATTERY_FLOOR = 34;
36443711

36453712
// The key an assertion is filed under when no battery is open. It is not a
36463713
// declared battery, so it reds by the same set difference rather than silently
@@ -5287,6 +5354,49 @@ const driver: any = { create: async (o: string, d: any) => d, find: async (o: st
52875354
expect('ROUTING — a refused write says nothing was written',
52885355
bodyOfFunction('writeLedger').includes('NOTHING WAS WRITTEN'));
52895356

5357+
// ── The DEBT ledger is ONE-SIDED (#26586) ──────────────────────────────────
5358+
//
5359+
// Driven through `reconcileDebtLedger`, the comparison `audit()` runs per
5360+
// slice: growth and a new unguarded double fail, every decrease passes and is
5361+
// reported, and an entry whose file is on disk but unread is refused -- so a
5362+
// walk that lost part of the tree cannot pass as a list of shrunken entries.
5363+
battery('The DEBT ledger is ONE-SIDED (#26586)');
5364+
{
5365+
const del = SLICES.find((s) => s.verb === 'delete');
5366+
const dbl = (pinned, line = 1) => ({ pinned, line });
5367+
const row = (file, unguarded, verb = 'delete') => ({ file, verb, unguarded, kind: 'DEBT' });
5368+
const onDisk = () => true;
5369+
const run = (found, entries, read = new Set(found.map((f) => f.file)), exists = onDisk) => {
5370+
const r = reconcileDebtLedger(del, found, entries, read, exists);
5371+
return `${r.errors.length}/${r.down.length}`;
5372+
};
5373+
expect('LEDGER — a matching entry neither fails nor reports',
5374+
run([{ file: 'a.test.ts', doubles: [dbl(false)] }], [row('a.test.ts', 1)]) === '0/0');
5375+
expect('LEDGER — a GROWN count still fails (PINNED), and is no decrease',
5376+
run([{ file: 'a.test.ts', doubles: [dbl(false), dbl(false, 2)] }], [row('a.test.ts', 1)]) === '1/0');
5377+
expect('LEDGER — an unguarded double with NO entry still fails',
5378+
run([{ file: 'n.test.ts', doubles: [dbl(false)] }], []) === '1/0');
5379+
const fell = reconcileDebtLedger(del, [{ file: 'a.test.ts', doubles: [dbl(false)] }],
5380+
[row('a.test.ts', 2)], new Set(['a.test.ts']), onDisk);
5381+
expect('LEDGER — a FALLEN count passes and is reported once',
5382+
fell.errors.length === 0 && fell.down.length === 1);
5383+
expect('LEDGER — ...and the report names the old count as the ceiling it leaves',
5384+
fell.down[0]?.includes('2 stays'));
5385+
expect('LEDGER — a file whose doubles are all PINNED now passes and is reported',
5386+
run([{ file: 'a.test.ts', doubles: [dbl(true)] }], [row('a.test.ts', 1)]) === '0/1');
5387+
expect('LEDGER — a DELETED file passes and is reported (not on disk, so not refused)',
5388+
run([], [row('gone.test.ts', 1)], new Set(), () => false) === '0/1');
5389+
expect('LEDGER — a file the walk READ whose fake is gone passes and is reported',
5390+
run([], [row('a.test.ts', 1)], new Set(['a.test.ts'])) === '0/1');
5391+
const unread = reconcileDebtLedger(del, [], [row('a.test.ts', 1)], new Set(), onDisk);
5392+
expect('LEDGER — a file still ON DISK that the walk never read is refused, not reported',
5393+
unread.errors.length === 1 && unread.down.length === 0 && unread.errors[0].startsWith('UNREAD [delete]'));
5394+
expect('LEDGER — an EMPTY walk with every entry still on disk refuses every one',
5395+
run([], [row('a.test.ts', 1), row('b.test.ts', 3)], new Set()) === '2/0');
5396+
expect('LEDGER — an entry on ANOTHER verb is not this slice\'s to judge',
5397+
run([], [row('a.test.ts', 1, 'update')], new Set()) === '0/0');
5398+
}
5399+
52905400
// ── The floor: every declared battery RAN, and ran its cases (#13489) ───
52915401
//
52925402
// Evaluated after every battery has had its chance and BEFORE the verdict, so
@@ -5376,7 +5486,9 @@ const driver: any = { create: async (o: string, d: any) => d, find: async (o: st
53765486
+ 'argument and a driver-shaped verb, and moving discovery and the census together; and '
53775487
+ 'holds EVERY branch that scans to the POPULATION FLOOR (#17117) -- `--write` refuses '
53785488
+ 'before it writes either ledger, the reporting run refuses before it reports, and exactly '
5379-
+ 'one place in this file turns a fallen floor into an exit.',
5489+
+ 'one place in this file turns a fallen floor into an exit; and reconciles the DEBT ledger '
5490+
+ 'ONE-SIDED (#26586) -- growth and a new unguarded double fail, every decrease passes and '
5491+
+ 'is reported, and an entry whose file is on disk but unread is refused.',
53805492
);
53815493

53825494
return SELF_TEST_VERDICT;

0 commit comments

Comments
 (0)