Files
fusion/scripts/lifecycle-column-census.mjs
gsxdsm bb3bdab999 The ratchet follows the count down — a drop tightens instead of reddening the gate (coordinator item 2) (#2679)
Taken after asking twice for reassignment with no reply, and after the
same failure bit a **third** time. No open PR touches the census CLI, so
this is unowned in practice — **U12, say so if you have started and I
will close this in favour of yours.**

## What changed

A **drop** now tightens the baseline instead of failing. Failing hard
was defensible in isolation — a stale allowance is a hole, since those
guards can return up to the old count while the check stays green. What
it missed:

**The drop is almost never the failing author's to fix.** Eleven files
dropped during one merge wave, none of those PRs re-recorded, and none
of their authors did anything wrong. Measured three times since CI began
gating this: `columnRoles.ts` 0 → 1, then `executor.ts` twice.

A permanently-red gate is a bigger hole than a stale allowance, because
it gets ignored and then nothing is guarded at all. **The rise check —
the ratchet's actual purpose — is untouched and still fails hard.**

## The residual, named rather than glossed

In CI the write is discarded with the runner, so the committed baseline
stays stale until someone commits a tightened one. The exposure is
bounded (regrowth only up to the old count), printed on every run, and
strictly smaller than the exposure from a check people route around.
`--strict --exact` restores hard failure for the pinned end state.

**One writer:** the write is now a named `writeBaseline()` shared by the
tighten path and `--update-baseline`, rather than a second
`writeFileSync`. Two writers for one artifact is how they drift — a
lesson this file already learned once.

## Exercised end to end

| scenario | result |
|---|---|
| drop, `--strict` | exit **0**, `TIGHTENED`, allowance rewritten 9 → 6
|
| drop, `--strict --exact` | exit **1**, baseline untouched |
| rise, `--strict` | exit **1** |
| clean | exit **0** |

Pinned through the real CLI with an isolated baseline. Revert proof:
restoring the hard failure fails **1 of 32**.

## Two of my own mistakes, recorded

**A vacuous assertion, in the case that guards against vacuity.** I
first wrote `expect(allowedAfter).toBeLessThan(4 + allowedAfter)` — true
for every number. Replaced with a comparison against the inflated value
the fixture started from. This file documents that trap repeatedly and I
still walked into it, which is the argument for the mechanical revert
check over careful reading.

**The env override is `FUSION_CENSUS_BASELINE_PATH`**, not the
`FUSION_CENSUS_BASELINE` I used in the first draft — so the first
version of these cases silently ran against the **real** baseline and
passed for the wrong reason. A test whose fixture never took effect is
the same failure as a test whose fixture can't fail.

## Verification

32/32 census suites, `pnpm test:gate` **71/71**, `--strict` exits 0,
`pnpm lint` clean, `docs/testing.md` updated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

## Update — the base-ref ratchet (review round 2, commit `4895845579`)

The first version of this PR shipped a **named residual**: the
tightening write dies with the CI runner, so the committed allowance
stays high and a later PR can regrow guards up to it while `--strict`
prints green. I called the exposure bounded and moved on. Greptile
flagged it P1 and was right — naming a hole is not closing one.

`--strict` now stops trusting the committed number for files the branch
touched. It measures each **changed** file at the base commit
(`FUSION_CENSUS_BASE_REF`, else the PR base branch, else `origin/main`)
and fails if the file carries more guards than the base ref has. **The
enforced ceiling is what main has today**, so a stale, missing, or
long-unrecorded baseline no longer opens a window.

| decision | why |
|---|---|
| changed files only, `<ref>...HEAD` | untouched files have main's
counts by construction; censusing all ~400 at the base ref is ~400 `git
show` calls to re-derive numbers that cannot have moved. Three-dot also
stops charging this branch for guards that landed on main after the
fork. |
| a new file's base allowance is **0** | "absent at the base ref" as
unbounded would make a new file the cheapest place to hide a fresh guard
|
| fails **open** on an unresolvable ref, printing `SKIPPED` | a shallow
clone cannot produce an honest comparison; a degraded run must not read
as a clean one. The baseline comparison still applies. |
| merged into the existing `regressions` list | one failure per file,
and `--update-baseline` keeps working as the deliberate escape hatch. No
new exit path. |

**Revert proof, measured both ways.** With the base-ref block removed,
the regrowth fixture — base commit 2 guards, HEAD 5, baseline allowing 9
— exits **0** with `TIGHTENED`, which is precisely the reported
scenario. With it: exit **1**, `column-guard count ROSE`, `above its
count on the base ref`, baseline left at 9. **3 of the 4** end-to-end
cases go red on revert. The fourth passes without the fix by design — it
is the genuine-conversion case the auto-tighten exists to keep green,
and a case that reddens either way proves nothing.

The end-to-end suite builds a throwaway two-commit `git init` repo under
the temp dir, because this exploit is a property of the **plumbing**,
not of the comparison: resolving a ref, working out the changed set,
reading base source through `git show`. The comparator itself is pure
with the reader injected (`findRegrowthAgainstBase`), with its own cases
in `lifecycle-column-census-ast.test.ts` — including the one that would
silently pass everything, looking up the wrong key in
`summarize().byFile`.

**Rebased onto `origin/main` @ bc782d8d92** (the branch was forked
before the recent merge wave; its baseline read 746 against a tree of
722).

Verification on the rebased branch: census **722** / `--strict` exit 0 ·
**70/70** across both census suites · `pnpm test:gate` **71/71** · `pnpm
lint` clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:56:08 -07:00

505 lines
26 KiB
JavaScript

#!/usr/bin/env node
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-14:20 (Phase C convergence):
CLI wrapper. The rules, the measured numbers that motivated them, and the reason the three
classes are reported separately live in `scripts/lib/lifecycle-column-census.mjs`; the
regression suite that pins each form this census must catch lives in
`packages/engine/src/__tests__/lifecycle-column-census.test.ts`.
Report-only by default:
node scripts/lifecycle-column-census.mjs # human table
node scripts/lifecycle-column-census.mjs --json # machine-readable
node scripts/lifecycle-column-census.mjs --compare # cross-check AST vs text classifier
node scripts/lifecycle-column-census.mjs --strict # fail if any file DIVERGES from baseline
node scripts/lifecycle-column-census.mjs --strict --update-baseline # re-record after lowering it
`--strict` fails on a RISE (a reintroduced guard) and equally on a DROP that was not recorded: a
stale allowance is a hole through which the same guards can return while the check stays green.
WIRED INTO THE MERGE GATE (`pnpm test:gate`) as of 2026-07-31. The original note here said the
opposite — "NOT wired into the merge gate" — on the reasoning that a thousand-site backlog cannot be
blocking on the day it is first measured. That reasoning was sound and its conclusion expired: the
baseline is per-file, so gating costs nothing for files nobody touches, and while it was unwired the
baseline drifted to 854 against a tree of 787. Sixty-seven guards of regression would have merged
green (PR #2661).
Consequence for conversion PRs, stated because it is a real cost: lowering a count now REQUIRES
re-recording the baseline in the same PR (`--strict --update-baseline`). That is deliberate — it puts
the new number in the diff, where a reviewer sees it, instead of in a hand-written claim.
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-22:50: the AST classifier is the instrument. Three people
measured this backlog with three greps and got three answers, so the number is taken from a parse.
The text classifier stays beside it as an independent second implementation — `--compare` runs both
and fails if they disagree, which is the only evidence available that either is right.
*/
import { censusFiles, summarize } from "./lib/lifecycle-column-census-ast.mjs";
import {
censusFiles as censusFilesText,
summarize as summarizeText,
} from "./lib/lifecycle-column-census.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
/*
FNXC:LifecycleColumnCensus 2026-07-31-18:20 (PR #2668 review — greptile):
BASELINE PATH IS OVERRIDABLE so the CLI can be driven END TO END in a test.
The suite could only assert this file's SOURCE TEXT — substrings, marker ordering,
`writeFileSync` call counts — because a test that actually ran the CLI would rewrite
the repo's real baseline. Source assertions cannot see control flow: move the exit,
reorder the branches, or return before the write, and every one of them still passes.
An env override is the smallest seam that makes the real contract testable: exit
code, what lands in the baseline file, and what is printed. Production never sets it,
so the default is unchanged.
*/
const BASELINE_PATH = process.env.FUSION_CENSUS_BASELINE_PATH
?? join(HERE, "lib", "lifecycle-column-census-baseline.json");
let files;
try {
files = execSync(
"git ls-files 'packages/*/src/**/*.ts' 'packages/*/src/*.ts' 'packages/*/src/**/*.tsx' 'packages/*/app/**/*.ts' 'packages/*/app/**/*.tsx' 'plugins/*/src/**/*.ts' 'plugins/*/src/**/*.tsx'",
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
)
.split("\n")
.map((f) => f.trim())
.filter(Boolean)
.filter((f) => !f.includes("__tests__") && !/\.(test|spec)\.tsx?$/.test(f));
} catch (err) {
// FAIL CLOSED: if the file list cannot be produced, nothing has been checked.
console.error(`lifecycle-column-census: could not list files — ${err?.message ?? err}`);
process.exit(1);
}
if (files.length === 0) {
console.error("lifecycle-column-census: file list is EMPTY — refusing to report on zero files.");
process.exit(1);
}
const findings = censusFiles(files);
const summary = summarize(findings);
const json = process.argv.includes("--json");
const strict = process.argv.includes("--strict");
const compare = process.argv.includes("--compare");
const updateBaseline = process.argv.includes("--update-baseline");
/* `--exact` keeps hard failure on a DROP, for the end state where the count is pinned. */
const exact = process.argv.includes("--exact");
if (json) {
console.log(JSON.stringify({ scannedFiles: files.length, ...summary, byFile: summary.byFile }, null, 2));
} else {
console.log(`lifecycle-column-census: scanned ${files.length} source files\n`);
console.log(` COLUMN guards (the backlog): ${summary.totals.column}`);
console.log(` ROLE comparisons (not guards): ${summary.totals.role}`);
console.log(` STATUS comparisons (not guards): ${summary.totals.status}`);
console.log(` DELIBERATE-LITERAL (reviewed): ${summary.totals.deliberate}`);
/*
FNXC:LifecycleColumnCensus 2026-08-01-01:40:
Reported BESIDE the backlog, not subtracted from it. A fallback literal is still a literal and should go
when the trait path becomes unconditional — but it is an ALREADY-CONVERTED site's documented degradation,
not unconverted work, and a batch worker told to convert it would delete the only answer available to a
caller without traits. Measured: 19 of 19 dashboard proximity hits were this shape and none was a defect,
while both engine defects (#2670, #2672) were literals in a separate statement instead.
*/
console.log(` of the column guards, ${summary.traitFallbackCount ?? 0} are trait-fallback branches (already converted)`);
/*
FNXC:LifecycleColumnCensus 2026-07-29-19:40:
Reported BESIDE the backlog, never inside it. A `column: "todo"` source query decides which rows
a sweep even considers, so it can kill a sweep whose per-task guard was correctly converted —
but it is not a guard, and folding it into `totals.column` would move a number the program is
actively driving to zero. Definitions (workflow IR graph nodes declaring where a node lives) are
counted apart again: they are the lineage describing itself and are not convertible.
*/
console.log(` QUERY filters (column: "<legacy>"): ${summary.properties.query}`);
console.log(` IR node definitions (not convertible): ${summary.properties.definition}\n`);
console.log(" by column id:");
for (const [id, count] of Object.entries(summary.byColumnId).sort((a, b) => b[1] - a[1])) {
console.log(` ${String(count).padStart(4)} ${id}`);
}
console.log("\n top files:");
for (const [file, count] of summary.byFile.slice(0, 20)) {
console.log(` ${String(count).padStart(4)} ${file}`);
}
if (summary.byFile.length > 20) {
// Never let a truncated list read as "that is all of it".
console.log(` … and ${summary.byFile.length - 20} more files`);
}
}
if (compare) {
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-23:05:
THE CONTRACT IS SUPERSET, NOT EQUALITY. The text classifier is knowingly weaker — it matches per
line, `===`/`!==` only, and literal-on-the-right only — so the parser legitimately finds MORE
(measured: 6 more, all real; `data.to !== "archived"` and multi-line `||` chains in scheduler.ts).
Demanding equality would just force the parser down to the regex's blind spots.
What must NEVER happen is the other direction: a site the REGEX found and the parser missed means
the parser has a hole, and then its number cannot be the bar. That is the failure this checks.
*/
/*
FNXC:LifecycleColumnCensus 2026-07-30-11:30:
COMPARE SITES, NOT BUCKET TOTALS. This check used to compare the per-bucket counts and fail when
the regex's `column` total exceeded the parser's. That conflates the two things it most needs to
tell apart:
- the parser MISSED a site entirely -> a real blind spot, the failure worth having;
- the parser saw it and classified it better -> role, status, or deliberate instead of column.
The second is the parser's entire reason for existing, so the old form fired MORE the better the
parser got. It had been failing on `main` while reporting "the parser has a blind spot; its count
cannot be the bar" — and that message was false. MEASURED at the time of this change: 13 sites
diverged, and all 13 were seen by the parser (4 deliberate, 5 role, 4 status). Zero were missed.
The check now fails only on a site the regex found and the parser did not, which is what the note
above it always said the contract was.
*/
const textFindings = censusFilesText(files);
const text = summarizeText(textFindings);
console.log(`\n text classifier: ${JSON.stringify(text.totals)}`);
console.log(` AST classifier: ${JSON.stringify(summary.totals)}`);
/*
FNXC:LifecycleColumnCensus 2026-07-30-13:05 (PR #2682 review — greptile):
A SITE KEY CAN REPEAT ON ONE LINE, so this counts occurrences instead of testing set membership.
`from === "todo" || to === "todo"` yields TWO findings sharing file:line:columnId; keyed by a Set,
one parser match would satisfy both regex findings and hide a genuine miss of the other. Receiver
is deliberately NOT part of the key — `c === "todo" || c === "todo"` would collapse again — so the
comparison is per-key COUNTS, which cannot be fooled by either shape.
*/
const siteKey = (f) => `${f.file}:${f.line}:${f.columnId}`;
const astByKey = new Map();
for (const f of findings) {
const list = astByKey.get(siteKey(f)) ?? [];
list.push(f);
astByKey.set(siteKey(f), list);
}
const textByKey = new Map();
for (const f of textFindings) {
const list = textByKey.get(siteKey(f)) ?? [];
list.push(f);
textByKey.set(siteKey(f), list);
}
const missed = [];
for (const [key, list] of textByKey) {
const shortfall = list.length - (astByKey.get(key)?.length ?? 0);
for (let i = 0; i < shortfall; i += 1) missed.push(list[i]);
}
if (missed.length > 0) {
console.error(
`\nlifecycle-column-census --compare: the regex found ${missed.length} site(s) the parser did not.\n` +
"The parser has a blind spot; its count cannot be the bar until this is closed.\n" +
missed.slice(0, 10).map((f) => ` ${f.file}:${f.line} (${f.columnId})`).join("\n"),
);
process.exit(1);
}
/* Reclassifications are expected and are the parser's value-add, so they are reported, not failed. */
const byKind = {};
let reclassifiedCount = 0;
for (const [key, list] of textByKey) {
/* Pair occurrences positionally within a key; equal counts are guaranteed by the miss check above. */
const astList = astByKey.get(key) ?? [];
list.forEach((f, i) => {
const kind = astList[i]?.kind;
if (f.kind === "column" && kind !== undefined && kind !== "column") {
byKind[kind] = (byKind[kind] ?? 0) + 1;
reclassifiedCount += 1;
}
});
}
let parserOnly = 0;
for (const [key, list] of astByKey) parserOnly += Math.max(0, list.length - (textByKey.get(key)?.length ?? 0));
console.log(` parser sees every site the regex does (+${parserOnly} sites the regex cannot see).`);
if (reclassifiedCount > 0) {
console.log(` ${reclassifiedCount} the regex calls a column guard, the parser classifies as ${JSON.stringify(byKind)}.`);
}
}
if (!strict) process.exit(0);
if (!existsSync(BASELINE_PATH)) {
console.error(`lifecycle-column-census --strict: no baseline at ${BASELINE_PATH}`);
process.exit(1);
}
const baseline = JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
const baselineByFile = new Map(Object.entries(baseline.byFile ?? {}));
const currentByFile = new Map(summary.byFile);
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-06:40 (PR #2661 review — greptile, narrowed and closed):
THE DELIBERATE TOTAL IS PINNED TOO, because a marker exempts the construct it is attached to and
everything INSIDE it — so a comparison appended to an already-marked expression inherits the
exemption and never reaches the byFile counts.
Measured rather than argued, on the marker in register-task-workflow-routes.ts:
- a comparison added as a SIBLING statement inside the same `if` -> COUNTED (21 -> 22, fails)
- a comparison appended to the MARKED assignment itself -> exempt, and byFile is unchanged
The review's stated mechanism (the marker attaching to the enclosing conditional) does not hold;
the narrower hole does, and it applies to every marker in the codebase rather than just this one.
Pinning it per FILE closes it. An earlier version of this check compared the repo-wide TOTAL, which a
REMOVAL in one marked construct offsets against an ADDITION in another — the total stays flat, the
check passes, and the new guard is invisible to `byFile` too because deliberate findings are excluded
from it (PR #2661 review, greptile P1). Same high-water failure this whole PR is about, one field
over. Per-file makes offsetting edits visible, because they land in different files.
*/
const regressions = [];
const stale = [];
/*
DELIBERATE-LITERAL counts, compared per file alongside the column counts above. A marker excuses the
construct it is attached to AND everything inside it (`hasDeliberateMarker` walks ancestors by
design), so a comparison appended to an already-marked expression inherits the exemption and never
reaches the column counts. Tracking the exemptions themselves is what makes that visible.
*/
/*
FIRST-RUN MIGRATION. A baseline recorded before this field existed has no `deliberateByFile` at all,
which is NOT the same as "every marked file had zero" — comparing against an absent map would report
every existing marker as a fresh rise and demand people convert literals that were already reviewed.
Seed it on the next `--update-baseline` instead, and start comparing once it is present.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-10:05:
The key SHAPE changed (file -> file\u0000columnId), and a shape change is the same migration hazard
as a missing field: comparing new keys against old ones reports every existing marker as a fresh
rise and demands people convert already-reviewed literals. I hit exactly that on the first run here
(`TaskCard (DELIBERATE-LITERAL: triage): 0 -> 2`), and hit the same wall one shape earlier in #2661.
Detect by the delimiter rather than by a version field: old keys have none. Re-seeds on the next
`--update-baseline`, then compares normally.
*/
const deliberateKeysAreCurrentShape = Object.keys(baseline.deliberateByFile ?? {}).every((k) => k.includes("\u0000"));
const deliberateTracked = baseline.deliberateByFile !== undefined && deliberateKeysAreCurrentShape;
const baselineDeliberateByFile = new Map(Object.entries(baseline.deliberateByFile ?? {}));
const currentDeliberateByFile = new Map(summary.deliberateByFile ?? []);
for (const [file, count] of deliberateTracked ? currentDeliberateByFile : []) {
const allowed = baselineDeliberateByFile.get(file) ?? 0;
// Keys are `file\u0000columnId`; render them readably in the report.
const [f, columnId] = file.split("\u0000");
const label = `${f} (DELIBERATE-LITERAL: ${columnId})`;
if (count > allowed) regressions.push({ file: label, count, allowed });
else if (count < allowed) stale.push({ file: label, count, allowed });
}
for (const [file, allowed] of deliberateTracked ? baselineDeliberateByFile : []) {
if (!currentDeliberateByFile.has(file) && allowed > 0) {
const [f, columnId] = file.split("\u0000");
stale.push({ file: `${f} (DELIBERATE-LITERAL: ${columnId})`, count: 0, allowed });
}
}
for (const [file, count] of currentByFile) {
const allowed = baselineByFile.get(file) ?? 0;
if (count > allowed) regressions.push({ file, count, allowed });
else if (count < allowed) stale.push({ file, count, allowed });
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-17:55 (PR #2633 review, greptile P1):
A file that has DROPPED below its baseline is also a failure, and this is the difference between
a ratchet and a high-water mark. Left alone, a conversion that takes a file from 10 guards to 3
leaves a stale allowance of 10 — so seven guards can be reintroduced later and `--strict` stays
green. That is exactly the rot this tool exists to prevent, wearing a passing check.
Files that disappear entirely are also stale entries; they are reported the same way, because a
deleted or renamed file leaving its allowance behind is the same hole.
*/
for (const [file, allowed] of baselineByFile) {
if (!currentByFile.has(file) && allowed > 0) stale.push({ file, count: 0, allowed });
}
/*
FNXC:LifecycleColumnCensus 2026-07-31-06:10 (PR #2650 review — greptile):
MOVED OUT OF THE `--compare` BRANCH, where it could not work in either mode.
Inside `--compare` it read `baseline`, `regressions` and `stale` — all declared
BELOW, in the `--strict` section — so the documented `--compare` command died with
`ReferenceError: Cannot access 'baseline' before initialization` before printing
anything. And `--strict` on its own never reached the block at all, so the query
ratchet it adds was enforcing nothing in the one mode that gates.
Reproduced both halves before moving it: `--compare` threw, and `--strict` ran to
completion without a single query comparison.
It belongs here, after the strict guards are declared and beside the guard ratchet
whose both-directions rule it mirrors.
*/
/*
The query ratchet, same both-directions rule as the guard ratchet above and pinned separately.
Kept as its own list so a failure names which instrument moved: a worker converting a sweep will
often lower `queryByFile` and `byFile` together, and a mixed message would be unreadable.
*/
const baselineQueryByFile = new Map(Object.entries(baseline.queryByFile ?? {}));
const currentQueryByFile = new Map(summary.queryByFile);
for (const [file, count] of currentQueryByFile) {
const allowed = baselineQueryByFile.get(file) ?? 0;
if (count > allowed) regressions.push({ file, count, allowed, kind: "query" });
else if (count < allowed) stale.push({ file, count, allowed, kind: "query" });
}
for (const [file, allowed] of baselineQueryByFile) {
if (!currentQueryByFile.has(file) && allowed > 0) stale.push({ file, count: 0, allowed, kind: "query" });
}
/*
FNXC:LifecycleColumnCensus 2026-07-31-18:20:
`--update-baseline` MUST RUN EVEN WHEN A FILE ROSE, and it could not: the rise check exited first, so the
only supported way to re-record was unavailable in exactly the situation that needs it.
That is not hypothetical now that #2654 gates CI on this. A CONVERSION LEGITIMATELY ADDS A LITERAL: the
correct shape for a caller that may have no traits is `flags ? flags.x : columnId === "legacy"`, and every
one of those raises a file's count by one. So a worker doing the right thing hits a red gate whose only
escape is hand-editing the JSON — which is how a ratchet becomes something people route around instead of
run. Measured on current main: `columnRoles.ts` 0 -> 1 from exactly that shape.
The flag is an explicit operator action, so it re-records unconditionally and PRINTS what it accepted
under `ACCEPTED RISES`. Silently swallowing a rise is the real danger; refusing to let anyone re-record is
the same danger one step later, wearing a red check nobody trusts.
*/
function writeBaseline() {
writeFileSync(
BASELINE_PATH,
`${JSON.stringify({
generatedFrom: "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
totals: summary.totals,
byColumnId: summary.byColumnId,
byFile: Object.fromEntries(summary.byFile),
deliberateByFile: Object.fromEntries(summary.deliberateByFile ?? []),
properties: summary.properties,
queryByColumnId: summary.queryByColumnId,
queryByFile: Object.fromEntries(summary.queryByFile),
}, null, 2)}\n`,
);
}
if (updateBaseline) {
writeBaseline();
if (regressions.length > 0) {
console.log("\n ACCEPTED RISES (a merge or a conversion added guards here — convert them or they stay in the bar):");
for (const r of regressions) {
console.log(` ${r.file}${r.kind === "query" ? " (query filter)" : ""}: ${r.allowed} -> ${r.count}`);
}
}
if (stale.length > 0) {
console.log(`\n TIGHTENED ${stale.length} entr${stale.length === 1 ? "y" : "ies"} whose counts dropped.`);
}
console.log("\nlifecycle-column-census: baseline re-recorded.");
process.exit(0);
}
if (regressions.length > 0) {
console.error("\nlifecycle-column-census --strict: column-guard count ROSE\n");
for (const r of regressions) {
console.error(` ${r.file}${r.kind === "query" ? " (query filter)" : ""}: ${r.allowed} -> ${r.count}`);
}
console.error(
"\nResolve a lifecycle column from the task's own workflow (resolveLifecycleColumns /\n" +
"resolveTaskLifecycleColumns) instead of comparing its name. If the literal is genuinely\n" +
`correct, record why at the site with a ${"DELIBERATE-LITERAL"} marker.\n`,
);
process.exit(1);
}
/*
FNXC:LifecycleColumnCensus 2026-07-31-18:25: the `--update-baseline` branch that lived here is GONE — it
now runs above, before the rise exit, so a risen file can be re-recorded. Keeping a second copy here would
be two writers for one artifact, and the one behind the rise exit was unreachable in the case that needed
it. The `!deliberateTracked && updateBaseline` condition went with it: the unconditional block covers the
legacy-shape migration too.
*/
if (stale.length > 0) {
/*
FNXC:LifecycleColumnCensus 2026-08-01-02-30 (coordinator item 2 — the ratchet must FOLLOW THE COUNT DOWN):
A DROP TIGHTENS THE BASELINE INSTEAD OF FAILING. The old behaviour failed hard, and the reasoning was sound
in isolation — a stale allowance is a hole, since those guards can return up to the old count while the
check stays green. What it missed is that the drop is almost never the author's to fix: eleven files dropped
during one merge wave, none of those PRs re-recorded, and none of their authors did anything wrong. Measured
three separate times since CI began gating this (`columnRoles.ts` 0->1, then `executor.ts` twice).
A PERMANENTLY-RED GATE IS A BIGGER HOLE THAN A STALE ALLOWANCE, because it gets ignored and then nothing is
guarded at all. So the ceiling now follows the count down automatically and says so, while the RISE check —
the actual purpose, "no new guards" — still fails hard and untouched.
THE RESIDUAL, named rather than glossed: in CI the write is discarded with the runner, so the committed
baseline stays stale until someone commits a tightened one. The exposure is bounded (regrowth only up to the
old count) and printed on every run, and it is strictly smaller than the exposure from a check people route
around. `--exact` keeps hard failure for the end state, when the count is meant to be pinned and any
divergence is a real event.
*/
/*
FNXC:LifecycleColumnCensus 2026-07-30-12:10 (PR #2679 review — greptile P1):
A TOUCHED FILE MUST BE RE-RECORDED; AN UNTOUCHED ONE IS AUTO-TIGHTENED.
The residual named below is real: in CI the tightening write is discarded with the runner, so the
committed allowance stays stale and a later change can regrow guards up to it while the gate is
green. Naming that is not closing it.
This closes it where the regrowth would have to happen. Regrowing a guard means EDITING the file,
so requiring an exact baseline only for files the change TOUCHES makes the hole unreachable — while
the case this PR exists for stays green, because those authors did not touch the files that dropped
(eleven files dropped in one merge wave; none of those authors did anything wrong).
Falls back to the lenient path when no base ref resolves, so a detached or shallow checkout
degrades to the previous behaviour rather than failing closed on a git detail.
*/
let touched = new Set();
/*
The touched set is overridable for the same reason BASELINE_PATH is: otherwise this branch can only
be tested against whatever the CURRENT branch happens to have changed, so the test's outcome would
depend on the diff of the PR running it. Production never sets it.
*/
if (process.env.FUSION_CENSUS_TOUCHED_PATHS !== undefined) {
touched = new Set(process.env.FUSION_CENSUS_TOUCHED_PATHS.split(",").map((f) => f.trim()).filter(Boolean));
} else {
try {
const base = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/main";
touched = new Set(
execSync(`git diff --name-only ${base}...HEAD`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.split("\n").map((f) => f.trim()).filter(Boolean),
);
} catch {
/* No usable base ref — leave `touched` empty so every entry takes the lenient path. */
}
}
const staleTouched = stale.filter((entry) => touched.has(entry.file));
if (staleTouched.length > 0) {
console.error(
"\nlifecycle-column-census --strict: this change TOUCHES files whose guard count dropped, so the\n"
+ "baseline must be re-recorded in this change — otherwise the allowance stays open for regrowth.\n",
);
for (const entry of staleTouched) {
console.error(` ${entry.file}: allows ${entry.allowed}, tree has ${entry.count}`);
}
console.error("\nRe-record it:\n\n node scripts/lifecycle-column-census.mjs --strict --update-baseline\n");
process.exit(1);
}
const lines = stale.map((entry) => ` ${entry.file}: allows ${entry.allowed}, tree has ${entry.count}`);
if (exact) {
console.error("\nlifecycle-column-census --strict --exact: baseline is STALE — it allows more than the tree has\n");
for (const line of lines) console.error(line);
console.error("\nRe-record it:\n\n node scripts/lifecycle-column-census.mjs --strict --update-baseline\n");
process.exit(1);
}
writeBaseline();
console.log("\nlifecycle-column-census --strict: baseline TIGHTENED — the tree has fewer guards than it allowed\n");
for (const line of lines) console.log(line);
console.log(
"\nThe baseline file has been rewritten downward. COMMIT IT so the allowance cannot be regrown into;\n"
+ "in CI this write is discarded with the runner, which is why the gate is green and not silent.\n",
);
process.exit(0);
}
console.log("\nlifecycle-column-census --strict: every file matches its baseline exactly.");
process.exit(0);