From c8a6af13a06b1b9431d48c8f436cf4058c9f0eaa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 16:03:46 -0700 Subject: [PATCH] test(census): pin file DISCOVERY, which every existing test was blind to (#3259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-through on the recommendation I made reviewing #3256: **a gate needs a test for its file discovery, not only its matcher.** ## The gap This suite pinned the matcher and never the scan. Every case either feeds the classifier a source string or drives the CLI against the real tree — so **the file list could return empty and all 53 tests would still pass.** Not hypothetical. `git ls-files` lists tracked files only, so a new file with a plain `task.column === "in-review"` scored 0 until staged (#3254). The identical bug then turned up in the move-target ratchet **behind its own 12 matcher tests** (#3256) — I wrote those 12 specifically to stop that gate regressing, and they could not see it, because they import the matcher and never run a scan. ## Four cases, on a synthetic tree Driven through `FUSION_CENSUS_FILE_ROOT` + `FUSION_CENSUS_FILE_LIST`, so discovery is testable without creating files inside a checkout the operator writes to concurrently: - a guard in a scanned file **reaches the classifier** and is counted - `--strict` fails **for the right reason** (message names the file; not an ENOENT fail-closed) - **every** listed file is counted, not just the first - files are read from the **scan root**, so a listed path and a read path cannot diverge ## The second case earns its wording Its first version asserted only `code === 1` — and **passed while discovery was broken.** With the injected list ignored, paths come from the real repo while reads resolve against the fixture root, every read misses, and the gate fails closed with exit 1. Right code, unrelated cause. A test that cannot tell *"found a guard"* from *"could not read anything"* is not testing the ratchet. Asserting the message is what separates them. I found that only by checking which cases the control actually failed — 3 of 4, not 4 of 4. Had I stopped at "the control fails, ship it", I would have added a test that passes for the wrong reason to a suite whose whole purpose is catching tests that pass for the wrong reason. ## Measured | check | result | |---|---| | suite | **57 passed** (53 + 4) | | anti-vacuity: `injectedList` forced undefined | **all 4 fail** (3/4 before strengthening case 2) | | restored | 57/57 | | `census --strict` / `check-fnxc-future-dates` | 0 / 0 | Tests only — no gate or product change. The same four assertions port directly to the other lifecycle gates once each grows the fixture seam; the move-target ratchet is the obvious next one, and its `.mjs` is currently claimed by #3256. --- .../__tests__/lifecycle-column-census.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/engine/src/__tests__/lifecycle-column-census.test.ts b/packages/engine/src/__tests__/lifecycle-column-census.test.ts index 18501f5eb3..e88211af52 100644 --- a/packages/engine/src/__tests__/lifecycle-column-census.test.ts +++ b/packages/engine/src/__tests__/lifecycle-column-census.test.ts @@ -1027,3 +1027,96 @@ describe("the backlog-state verdict the bare command prints", () => { expect(describeBacklogState({ columnGuards: 0, unexaminedGuards: 3 }).join(" ")).toContain("BACKLOG ZERO"); }); }); + +/* +FNXC:LifecycleColumnCensus 2026-07-31-20:30 (u12 — the suite pinned the MATCHER, never the DISCOVERY): +Every test in this file feeds the classifier a source string, or drives the CLI against the real tree. +None asserts WHICH FILES REACH the classifier — so the scan could return an empty list and the whole +suite would still pass. + +That is not hypothetical. `git ls-files` lists TRACKED files only, so a brand-new file with a plain +`task.column === "in-review"` scored 0 until it was staged (#3254), and the identical bug sat in the +move-target ratchet behind its own 12 matcher tests (#3256) — I wrote those tests specifically to stop +that gate regressing, and they could not see it, because they import the matcher and never run a scan. + +`FUSION_CENSUS_FILE_ROOT` + `FUSION_CENSUS_FILE_LIST` drive a synthetic tree, so discovery is testable +without creating files inside a live checkout that the operator is writing to concurrently. +*/ +describe("the census scans the files it claims to scan", () => { + const repoRootPath = new URL("../../../../", import.meta.url).pathname; + const cli = `${repoRootPath}scripts/lifecycle-column-census.mjs`; + + async function runOnFixture(files: Record, args: string[] = []) { + const { mkdtemp, writeFile, mkdir } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join, dirname } = await import("node:path"); + const { execFile } = await import("node:child_process"); + + const root = await mkdtemp(join(tmpdir(), "fusion-census-discovery-")); + for (const [rel, body] of Object.entries(files)) { + await mkdir(dirname(join(root, rel)), { recursive: true }); + await writeFile(join(root, rel), body); + } + const baseline = join(root, "baseline.json"); + await writeFile(baseline, JSON.stringify({ byFile: {} })); + + return await new Promise<{ code: number; out: string }>((resolve) => { + execFile("node", [cli, ...args], { + cwd: repoRootPath, + env: { + ...process.env, + FUSION_CENSUS_FILE_ROOT: root, + FUSION_CENSUS_FILE_LIST: Object.keys(files).join(","), + FUSION_CENSUS_BASELINE_PATH: baseline, + }, + }, (err, stdout, stderr) => { + resolve({ code: (err as { code?: number } | null)?.code ?? 0, out: `${stdout}${stderr}` }); + }); + }); + } + + const GUARD = 'export const f = (t: { column: string }) => t.column === "in-review";\n'; + + it("REACHES the classifier — a guard in a scanned file is counted", async () => { + const { out } = await runOnFixture({ "pkg/src/a.ts": GUARD }); + + expect(out).toContain("scanned 1 source files"); + // The half that matters: discovery delivered the file AND the classifier saw its guard. + expect(out).toMatch(/COLUMN guards \(the backlog\):\s+1/); + }); + + it("fails --strict FOR THE RIGHT REASON on a guard the baseline does not allow", async () => { + /* + Asserted on the MESSAGE, not just the exit code, and that is not belt-and-braces — the first + version checked `code === 1` and passed while discovery was broken. With the injected list + ignored, paths come from the real repo while reads resolve against the fixture root, every read + misses, and the gate fails CLOSED with exit 1. Right code, unrelated cause: a test that cannot + tell "found a guard" from "could not read anything" is not testing the ratchet. + */ + const { code, out } = await runOnFixture({ "pkg/src/a.ts": GUARD }, ["--strict"]); + + expect(code).toBe(1); + expect(out).toContain("pkg/src/a.ts"); + expect(out).not.toContain("ENOENT"); + }); + + it("counts every listed file, not just the first", async () => { + const { out } = await runOnFixture({ "pkg/src/a.ts": GUARD, "pkg/src/b.ts": GUARD }); + + expect(out).toContain("scanned 2 source files"); + expect(out).toMatch(/COLUMN guards \(the backlog\):\s+2/); + }); + + it("reads each file from the SCAN root, not the script's repo", async () => { + /* + The scan root and the read root were separate values, so an injected list was LISTED against the + fixture and READ against the repo — ENOENT on every file, which a `catch { continue }` turned into + a clean zero. Asserting a non-zero count here is what pins them to the same value. + */ + const { out, code } = await runOnFixture({ "pkg/src/only-here.ts": GUARD }); + + expect(out).not.toContain("ENOENT"); + expect(code).toBe(0); + expect(out).toMatch(/COLUMN guards \(the backlog\):\s+1/); + }); +});