Files
fusion/scripts/lifecycle-column-census.mjs
gsxdsm 20878e9d5f census: count column: "<legacy>" query filters as a separate, separately-pinned instrument (backlog unchanged at 784) (#2650)
Pre-launch input for the 779-guard fleet. **The backlog number does not
move: 784 before, 784 after.** This adds a second number beside it.

## The problem it measures

A guard is not the only way a legacy column id decides behaviour:

```ts
const todo = await this.store.listTasks({ column: "todo", slim: true });
```

That is a **source query** — it selects the rows a sweep considers *at
all*. On a renamed or merged board it returns nothing, so a sweep whose
per-task predicate was correctly converted still does nothing, while
looking converted. `self-healing.ts:2849` names the pairing in prose,
and #2560 had to repair exactly that combination after a converted
predicate was left with a literal query.

The census walks comparison `BinaryExpression`s. A `PropertyAssignment`
is not one, so this class was invisible to the instrument **and to its
ratchet** — it could grow silently.

Measured: **83 query filters, 43 IR node definitions.**

I proved one live consequence earlier on #2648:
`recoverStuckMergeDeadlocks` cannot see a renamed board at all — the
renamed rows exist and none appear in its three-literal union
(`renamedInsideUnion=0`, on a live PG store).

## Why this matters *before* the fleet is briefed

The fleet rule is *"the baseline ratchet must shrink by exactly the
converted count."* In `self-healing.ts` — the largest batch at 111 —
both classes sit in the same functions, so today a worker either:

- converts only the comparisons → arithmetic is clean, and sweeps whose
source query still filters a dead literal stay blind; or
- converts the query too → the count does **not** move by the converted
amount, and a more-correct PR looks like a miscount.

The second punishes the better worker. With a second pinned number,
converting a query becomes visible work instead of an apparent error.

## Counted separately, deliberately

`totals.column` is a published shape — the baseline, the reporter, and
other workers' in-flight PRs read it, and the completion bar is defined
against it. Growing it would move a number the program is actively
driving to zero.

So the new counts live in `summary.properties` / `queryByFile`, under
their own baseline keys, with their own both-directions ratchet (same
rule as #2633's, including the stale-allowance half). `totals` keeps its
**exact** shape — two existing tests assert it with `toEqual`, and
breaking a contract others depend on mid-flight to add a number is not
worth it.

## Definitions are not queries

Workflow IR graph nodes carry `column:` to declare where a node lives —
`{ id: "review", kind: "...", column: "in-review" }`. That is the
lineage describing itself: not a lookup, not convertible, and ~43 of the
raw matches. They are told apart **structurally** (an `id`/`kind`
sibling in the same object literal), not by filename, so a definition
written anywhere classifies the same way.

## Baseline seeding, stated plainly

`--update-baseline` could not pin a **new** category: the regression
check runs before the write, and with no prior key every file reads as a
rise. I seeded the three new keys once, directly, leaving every guard
field byte-identical. The diff is purely additive — no removals.

## Finding, not caused by this change

**`--strict` is already red on clean main**:
`register-task-workflow-routes.ts` is **23** against a baseline of
**22**. Verified by stashing this branch and re-running on an unmodified
tree. Until that is reconciled the guard ratchet is passing nothing —
worth fixing before the fleet starts relying on it as the work order.

## Verification

- census suites **44 green**, 6 new cases: counted; kept out of the
backlog; definition-not-query; both instruments independent (a bug
routing comparisons into the query bucket would otherwise look clean on
both); `DELIBERATE-LITERAL` honoured; non-legacy id ignored
- `node scripts/lifecycle-column-census.mjs` → backlog still 784
- `pnpm lint` exit 0, `pnpm test:gate` exit 0 (695)

---------

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

240 lines
11 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.
NOT wired into the merge gate. A thousand-site backlog cannot be a blocking check on the day it
is first measured; `--strict` exists so it can become one incrementally, per-file, once owners have
converted their areas.
*/
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));
const 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");
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-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.
*/
const text = summarizeText(censusFilesText(files));
console.log(`\n text classifier: ${JSON.stringify(text.totals)}`);
console.log(` AST classifier: ${JSON.stringify(summary.totals)}`);
const regressions = ["column", "role", "status", "deliberate"].filter(
(kind) => text.totals[kind] > summary.totals[kind],
);
if (regressions.length > 0) {
console.error(
`\nlifecycle-column-census --compare: the regex found MORE than the parser for ${regressions.join(", ")}.\n` +
"The parser has a blind spot; its count cannot be the bar until this is closed.",
);
process.exit(1);
}
const extra = summary.totals.column - text.totals.column;
console.log(` parser is a superset (+${extra} column guards the regex cannot see).`);
}
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);
const regressions = [];
const stale = [];
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" });
}
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);
}
if (stale.length > 0) {
if (updateBaseline) {
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),
properties: summary.properties,
queryByColumnId: summary.queryByColumnId,
queryByFile: Object.fromEntries(summary.queryByFile),
}, null, 2)}\n`,
);
console.log(`\nlifecycle-column-census --strict: baseline TIGHTENED for ${stale.length} file(s).`);
process.exit(0);
}
console.error("\nlifecycle-column-census --strict: baseline is STALE — it allows more than the tree has\n");
for (const s of stale) {
console.error(` ${s.file}: allows ${s.allowed}, tree has ${s.count}`);
}
console.error(
"\nA stale allowance is a hole: those guards can be reintroduced later and this check stays\n" +
"green. Re-record the baseline in the SAME PR that lowered the count:\n\n" +
" node scripts/lifecycle-column-census.mjs --strict --update-baseline\n",
);
process.exit(1);
}
console.log("\nlifecycle-column-census --strict: every file matches its baseline exactly.");
process.exit(0);