diff --git a/packages/engine/src/__tests__/lifecycle-column-census.test.ts b/packages/engine/src/__tests__/lifecycle-column-census.test.ts index 0b5547c998..1f173eaf88 100644 --- a/packages/engine/src/__tests__/lifecycle-column-census.test.ts +++ b/packages/engine/src/__tests__/lifecycle-column-census.test.ts @@ -23,6 +23,7 @@ import { summarize, mixedVocabularyFiles, hasDeferralNote, + describeBacklogState, } from "../../../../scripts/lib/lifecycle-column-census.mjs"; function census(source: string) { @@ -786,10 +787,25 @@ describe("the ratchet follows the count down", () => { } /** Inflate one file's allowance, which is a DROP from the CLI's point of view. */ + /* + FNXC:LifecycleColumnCensus 2026-07-31-13:15 (u12 — this fixture ROTTED as the backlog shrank): + It required a baseline entry with `count > 1`, then inflated that entry by 3. Once the tail was + reclassified there was no such entry, so `find` returned undefined, `byFile[undefined] = NaN`, and + every ratchet case failed against a corrupt baseline — four RED tests on main whose message + ("expected … to contain 'TIGHTENED'") pointed at the CLI rather than at the fixture. + + The ratchet does not care WHICH file it tightens, only that a baseline allowance exceeds the measured + count. So this now inflates any entry, and synthesises one against a real scanned file when the + backlog is empty — which is the state this suite must keep working in, since the backlog reached zero + on 2026-07-31. Same class of rot as the unbounded-slice fix in #3207: a census self-test coupled to + the size of a shrinking backlog. + */ + const INFLATE_FALLBACK_FILE = "packages/core/src/store.ts"; const inflate = (baseline: any): string => { - const [file, count] = Object.entries(baseline.byFile as Record).find(([, c]) => c > 1) ?? []; - baseline.byFile[file as string] = (count as number) + 3; - return file as string; + const byFile = baseline.byFile as Record; + const [file, count] = Object.entries(byFile)[0] ?? [INFLATE_FALLBACK_FILE, 0]; + byFile[file] = (count as number) + 3; + return file; }; it("TIGHTENS on a drop and exits 0, so somebody else's merge cannot redden the gate", async () => { @@ -833,10 +849,23 @@ describe("the ratchet follows the count down", () => { }, 30_000); it("still FAILS on a rise, which is the check's actual purpose", async () => { + /* + FNXC:LifecycleColumnCensus 2026-07-31-13:20 (u12 — same rot as `inflate`, plus the zero-backlog case): + A RISE means "measured exceeds allowed", so it needs an allowance BELOW the real count. While guards + exist, deflating any entry gives that. Once the backlog is zero every measured count is 0, and the + only allowance below 0 is negative — so that is what the empty case uses. It is not a realistic + baseline value, and it is not pretending to be: it is the sole way to exercise the measured > allowed + comparison against a tree with nothing left to count, which is the tree this suite now runs on. + */ const deflate = (baseline: any): string => { - const [file, count] = Object.entries(baseline.byFile as Record).find(([, c]) => c > 1) ?? []; - baseline.byFile[file as string] = (count as number) - 1; - return file as string; + const byFile = baseline.byFile as Record; + const entry = Object.entries(byFile)[0]; + if (!entry) { + byFile[INFLATE_FALLBACK_FILE] = -1; + return INFLATE_FALLBACK_FILE; + } + byFile[entry[0]] = (entry[1] as number) - 1; + return entry[0]; }; const run1 = await run(deflate, ["--strict"]); @@ -909,3 +938,43 @@ describe("the deferral-note rule the queue-empty verdict is computed from", () = } }); }); + +/* +FNXC:LifecycleColumnCensus 2026-07-31-13:00 (u12 — the state the tree cannot demonstrate yet): +`describeBacklogState` is pure precisely so the ZERO state is testable before the tree reaches zero. +While it was an inline branch in the CLI, only the CURRENT backlog state was observable, and the zero +branch did not exist at all — the report printed nothing at the finish line. +*/ +describe("the backlog-state verdict the bare command prints", () => { + it("states ZERO as a protected end state, not an empty scan", () => { + const lines = describeBacklogState({ columnGuards: 0, unexaminedGuards: 0 }); + + expect(lines.join(" ")).toContain("BACKLOG ZERO"); + // The load-bearing half: a bare "0" reads as a broken scan unless the report says otherwise. + expect(lines.join(" ")).toContain("not an empty scan"); + expect(lines.join(" ")).toContain("--strict"); + }); + + it("calls a fully-deferred backlog DEBT rather than a work queue", () => { + const lines = describeBacklogState({ columnGuards: 7, unexaminedGuards: 0 }); + + expect(lines.join(" ")).toContain("CONVERSION QUEUE EMPTY"); + expect(lines.join(" ")).toContain("7 remaining column guard(s)"); + expect(lines.join(" ")).toContain("DEBT, not a work queue"); + }); + + it("reports unexamined guards as claimable work", () => { + const lines = describeBacklogState({ columnGuards: 7, unexaminedGuards: 3 }); + + expect(lines.join(" ")).toContain("3 unexamined guard(s) remain"); + // Must NOT tell a worker the queue is empty while real work is outstanding. + expect(lines.join(" ")).not.toContain("QUEUE EMPTY"); + expect(lines.join(" ")).not.toContain("BACKLOG ZERO"); + }); + + it("prefers the ZERO state over the unexamined branch when both could apply", () => { + // Defensive: zero guards cannot have unexamined ones. If a caller ever passes both, the honest + // answer is still zero — reporting "N unexamined" against an empty backlog would be a fabrication. + expect(describeBacklogState({ columnGuards: 0, unexaminedGuards: 3 }).join(" ")).toContain("BACKLOG ZERO"); + }); +}); diff --git a/scripts/lib/lifecycle-column-census.mjs b/scripts/lib/lifecycle-column-census.mjs index 2d8caf0a33..27a9e63900 100644 --- a/scripts/lib/lifecycle-column-census.mjs +++ b/scripts/lib/lifecycle-column-census.mjs @@ -285,3 +285,34 @@ export const FLAG_MARKERS = /FLAGGED|LEFT COUNTED|left counted|deliberately NOT export function hasDeferralNote(lines, line) { return FLAG_MARKERS.test(lines.slice(Math.max(0, line - 41), line).join(" ")); } + +/* +FNXC:LifecycleColumnCensus 2026-07-31-12:55 (u12 — the report went SILENT at the finish line): +The backlog-state verdict was two inline branches in the CLI, and neither fired at a count of ZERO — +`CONVERSION QUEUE EMPTY` required `totals.column > 0`. So the one state the whole fleet phase was +working toward printed nothing at all, which reads as a broken scan rather than the protected end +state. Reached zero on 2026-07-31 (722 at the start of the phase). + +Pure and exported so all three states are unit-testable. Against the real tree only the CURRENT state +is observable, so an inline branch for zero could not be tested until the tree was already zero — and +a message nobody can test before they need it is the one that is wrong when they do. + +Returns an array of lines (empty = print nothing), so the caller stays a dumb printer. +*/ +export function describeBacklogState({ columnGuards, unexaminedGuards }) { + if (columnGuards === 0) { + return [ + "BACKLOG ZERO: no lifecycle-column guard remains.", + "This is the protected end state, not an empty scan — `--strict` fails on any RISE, so a new", + "guard cannot land silently. Use the role helpers (resolveLifecycleColumns / columnHasRole).", + ]; + } + if (unexaminedGuards === 0) { + return [ + `CONVERSION QUEUE EMPTY: all ${columnGuards} remaining column guard(s) carry a documented deferral note.`, + "There is no unexamined guard to claim. A nonzero backlog above is DEBT, not a work queue.", + "Re-read the note at a site before converting it; run --claims to also check open-PR ownership.", + ]; + } + return [`${unexaminedGuards} unexamined guard(s) remain (no deferral note) — run --triage to list them by file.`]; +} diff --git a/scripts/lifecycle-column-census.mjs b/scripts/lifecycle-column-census.mjs index 94895b5b33..7e1a59e9f6 100644 --- a/scripts/lifecycle-column-census.mjs +++ b/scripts/lifecycle-column-census.mjs @@ -48,6 +48,7 @@ import { summarize as summarizeText, mixedVocabularyFiles, hasDeferralNote, + describeBacklogState, } from "./lib/lifecycle-column-census.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -238,12 +239,10 @@ if (json) { `--claims` can see open PRs. */ const { open: unexaminedGuards } = triageFindings(); - if (summary.totals.column > 0 && unexaminedGuards.length === 0) { - console.log(`\n CONVERSION QUEUE EMPTY: all ${summary.totals.column} remaining column guard(s) carry a documented deferral note.`); - console.log(` There is no unexamined guard to claim. A nonzero backlog above is DEBT, not a work queue.`); - console.log(` Re-read the note at a site before converting it; run --claims to also check open-PR ownership.`); - } else if (unexaminedGuards.length > 0) { - console.log(`\n ${unexaminedGuards.length} unexamined guard(s) remain (no deferral note) — run --triage to list them by file.`); + const verdict = describeBacklogState({ columnGuards: summary.totals.column, unexaminedGuards: unexaminedGuards.length }); + if (verdict.length > 0) { + console.log(""); + for (const line of verdict) console.log(` ${line}`); } if (triage) { const { flagged, open } = triageFindings();