U12 R12: lifecycle-column literal ratchet — and the raw count's floor is not zero (#2630)
The anti-regression ratchet U12 R12 calls for. Counts lifecycle-column **literal comparisons** in production source and fails when the count rises. ## Two jobs **1. Ratchet.** A converted guard cannot silently come back as a literal. Ceilings only go down. **Mutation-verified both ways:** adding one `t.column === "triage"` fails with *"rose to 49 (ceiling 48)"*; lowering the ceiling to 47 fails with *"rose to 48 (ceiling 47)"*. The number is exact, not approximately right. **2. Honest denominator — the finding.** `triage` is overloaded in this codebase: a column id, an **agent role**, a **session purpose**, a **prompt-template family**, and a **CLI glyph key**. A raw grep counts them together, which makes "reach zero" unreachable by construction — converting `role === "triage"` in `agent-prompts.ts` would break the planning agent's prompt-template resolution, and the failure would look nothing like a column bug. | | count | |---|---:| | raw `triage` matches | 72 | | **not a column at all** | **10** | | genuine column comparisons | **48** | The 10: `agent-prompts.ts` ×3 (`role`), `usage-limit-detector.ts` ×2 (`agentType`), `skill-resolver.ts` (`sessionPurpose`), `tool-availability.ts` (`surface`), `cli/commands/task.ts` (a glyph key), plus two in comments. Ceilings recorded for all four ids — **`in-progress` (133) and `in-review` (200) were untracked entirely.** ## A measurement error of mine that writing this caught A grep over `packages/<pkg>/src` **misses `packages/dashboard/app`**, where the board components live. That undercounted `triage` as 43 in an earlier audit of mine when it was 62. The source roots are now listed explicitly in code so the number cannot drift with someone's glob. ## The classifier is under test, not trusted A ratchet that matched nothing would pass forever while measuring nothing — the failure mode this program keeps finding. Three self-tests prevent it: - it asserts **positively** that `agent-prompts` / `skill-resolver` / `tool-availability` are excluded, so a classifier change that swallowed them would fail rather than quietly shrink the number; - it asserts the classifier still **matches** real column comparisons; - it pins `live-agent-count.ts`'s two sites as **permanent** no-flags fallbacks, with the reason, so a future edit that deletes them has to argue with it rather than silently drop stranded cards from the footer's queued total. The classifier keys on the **left-hand side naming a column** — deliberately syntactic, so a reader can audit it against the source without running anything, and conservative: an unrecognised shape counts **as** a column comparison, erring toward demanding conversion rather than excusing it. 7 tests green; lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
FNXC:LifecycleColumnRatchet 2026-07-31-09:10 (U12 R12 — AST, replacing the grep):
|
||||
|
||||
THE MEASURING INSTRUMENT for the lifecycle-column conversion, and the authority on the number.
|
||||
|
||||
WHY IT HAD TO STOP BEING A GREP. Three people measured this surface with three regexes and got
|
||||
three answers (6, 8, 12 for the agent-role bucket alone). Every figure quoted at the program today
|
||||
— 57, 48, 45, 34, 56, 46 — was grep-derived, and those were not measurements but estimates with a
|
||||
consistent bias. A regex cannot distinguish:
|
||||
|
||||
task.column === "triage" a lifecycle guard <- the thing being counted
|
||||
role === "triage" the planning AGENT's role <- correct code, must never convert
|
||||
sessionPurpose === "triage" a session purpose <- correct code
|
||||
surface === "triage" a docs surface name <- correct code
|
||||
`column === "triage"` in prose an FNXC note quoting the OLD behaviour
|
||||
|
||||
Parsing removes two whole defect classes instead of patching them:
|
||||
- COMMENTS ARE NOT NODES. Every FNXC note here explains an old comparison by quoting it, so a text
|
||||
scan counts the project's own requirement history as violations. The previous revision of this
|
||||
file needed a hand-rolled block-comment tracker for exactly that, and still only caught the
|
||||
cases it thought to look for.
|
||||
- QUOTE STYLE AND LINE BREAKS VANISH. `"triage"`, `'triage'`, and a comparison wrapped across
|
||||
lines are one shape to the AST and three patterns to a grep.
|
||||
|
||||
WHAT IT DOES NOT DO. There is no type checker here, only a syntax tree, so classification is by
|
||||
RECEIVER NAME. That is a real limit and it is why the lists below are explicit and auditable rather
|
||||
than clever. It errs toward COUNTING: an unrecognised receiver is treated as a column, so a new
|
||||
binding name inflates the number and demands attention instead of disappearing from it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
/** Lifecycle column ids whose literal comparison this ratchet governs. */
|
||||
const GOVERNED_IDS = ["triage", "todo", "in-progress", "in-review"] as const;
|
||||
|
||||
/**
|
||||
* Receivers that are provably NOT lifecycle columns. Sourced from two independently-built
|
||||
* classifiers agreeing — the strongest evidence available on this surface.
|
||||
*
|
||||
* Converting any of these would be a real bug rather than a missed cleanup: `role === "triage"`
|
||||
* selects the planning agent's prompt template, and resolving it to "which column carries the
|
||||
* intake trait" asks a column question about something that is not a column.
|
||||
*/
|
||||
const NON_COLUMN_RECEIVERS: ReadonlySet<string> = new Set([
|
||||
"role",
|
||||
"agentType",
|
||||
"sessionPurpose",
|
||||
"surface",
|
||||
"agent",
|
||||
"purpose",
|
||||
"lane",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Column receivers actually present, from the receiver census (task.column 12, toColumn 9,
|
||||
* column 9, t.column 2, originColumn 2, then singletons). Documentation, not a filter — anything
|
||||
* outside NON_COLUMN_RECEIVERS counts regardless.
|
||||
*/
|
||||
const KNOWN_COLUMN_RECEIVERS: ReadonlySet<string> = new Set([
|
||||
"column", "toColumn", "fromColumn", "originColumn", "resumeColumn", "taskColumn",
|
||||
"from", "to", "c", "col", "workflowIrPinColumnId", "currentColumn", "targetColumn",
|
||||
]);
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
|
||||
const SOURCE_ROOTS = [
|
||||
"packages/core/src",
|
||||
"packages/engine/src",
|
||||
"packages/dashboard/src",
|
||||
"packages/dashboard/app",
|
||||
"packages/cli/src",
|
||||
];
|
||||
|
||||
interface Site { readonly file: string; readonly line: number; readonly code: string; readonly receiver: string }
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!/\.tsx?$/.test(entry) || /\.test\.tsx?$/.test(entry)) continue;
|
||||
out.push(full);
|
||||
}
|
||||
};
|
||||
for (const root of SOURCE_ROOTS) walk(join(REPO_ROOT, root));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The receiver's name: `task.column` -> "column"; a bare `toColumn` -> "toColumn". */
|
||||
function receiverName(node: ts.Expression): string | undefined {
|
||||
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
||||
if (ts.isIdentifier(node)) return node.text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Walk one parsed file for `X === "<id>"` / `X !== "<id>"` where X names something column-like. */
|
||||
function collect(sf: ts.SourceFile, columnId: string, file: string, sites: Site[]): void {
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (
|
||||
ts.isBinaryExpression(node)
|
||||
&& (node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
||||
|| node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken)
|
||||
) {
|
||||
const pairs = [[node.right, node.left], [node.left, node.right]] as const;
|
||||
for (const [lit, other] of pairs) {
|
||||
// `.text` is the DECODED value, so single and double quotes are indistinguishable here.
|
||||
if (!ts.isStringLiteral(lit) || lit.text !== columnId) continue;
|
||||
const receiver = receiverName(other);
|
||||
if (receiver === undefined || NON_COLUMN_RECEIVERS.has(receiver)) continue;
|
||||
sites.push({
|
||||
file,
|
||||
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
|
||||
code: node.getText(sf).replace(/\s+/g, " ").slice(0, 100),
|
||||
receiver,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every lifecycle-column guard against `columnId`. Comments cannot appear here — they are trivia,
|
||||
* not nodes — so prose quoting an old comparison is excluded by construction rather than by a
|
||||
* pattern that has to anticipate it.
|
||||
*/
|
||||
function comparisonSites(columnId: string): Site[] {
|
||||
const sites: Site[] = [];
|
||||
for (const file of sourceFiles()) {
|
||||
const text = readFileSync(file, "utf-8");
|
||||
if (!text.includes(columnId)) continue;
|
||||
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
collect(sf, columnId, relative(REPO_ROOT, file), sites);
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceilings, measured by THIS instrument on `main`. Lower them as conversions land; never raise one.
|
||||
* A raise means a guard came back — convert it, or if the receiver genuinely is not a column, add it
|
||||
* to NON_COLUMN_RECEIVERS with a reason.
|
||||
*/
|
||||
const CEILINGS: Record<string, number> = {
|
||||
triage: 37,
|
||||
todo: 82,
|
||||
"in-progress": 201,
|
||||
"in-review": 217,
|
||||
};
|
||||
|
||||
describe("lifecycle-column literal ratchet (AST)", () => {
|
||||
for (const columnId of GOVERNED_IDS) {
|
||||
it(`does not increase the number of \`${columnId}\` column guards`, () => {
|
||||
const sites = comparisonSites(columnId);
|
||||
// Reported so the project measures with the same tool it gates with. Every previously-quoted
|
||||
// figure was grep-derived; this number replaces them.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[lifecycle-column-ratchet] ${columnId}: ${sites.length} guard(s), ceiling ${CEILINGS[columnId]}`);
|
||||
|
||||
expect(
|
||||
sites.length,
|
||||
sites.length > CEILINGS[columnId]!
|
||||
? `\`${columnId}\` column guards rose to ${sites.length} (ceiling ${CEILINGS[columnId]}).\n`
|
||||
+ "Convert it, or if the receiver is not a lifecycle column add it to\n"
|
||||
+ "NON_COLUMN_RECEIVERS with a reason — do not raise the ceiling.\n\n"
|
||||
+ sites.map((s) => ` ${s.file}:${s.line} [${s.receiver}] ${s.code}`).join("\n")
|
||||
: undefined,
|
||||
).toBeLessThanOrEqual(CEILINGS[columnId]!);
|
||||
});
|
||||
}
|
||||
|
||||
it("never reports an agent role, session purpose or surface as a violation", () => {
|
||||
/*
|
||||
The assertion that keeps this instrument honest. A ratchet demanding conversion of
|
||||
`role === "triage"` would send the next person into breaking the planning agent's
|
||||
prompt-template resolution, AND could never reach zero, because those sites are correct code.
|
||||
|
||||
Asserted POSITIVELY against the files that hold them, so a classifier change that swallowed
|
||||
them fails here instead of quietly shrinking the number.
|
||||
*/
|
||||
const files = new Set(comparisonSites("triage").map((s) => s.file));
|
||||
expect([...files].some((f) => f.endsWith("agent-prompts.ts"))).toBe(false);
|
||||
expect([...files].some((f) => f.endsWith("skill-resolver.ts"))).toBe(false);
|
||||
expect([...files].some((f) => f.endsWith("tool-availability.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores comparisons that appear only inside comments", () => {
|
||||
/*
|
||||
Holds by construction — comments are trivia — but asserted because the previous grep-based
|
||||
revision needed a hand-rolled block-comment tracker to approximate it, and every FNXC note in
|
||||
this codebase quotes the comparison it replaced. If a future rewrite returns to text scanning,
|
||||
this fails.
|
||||
*/
|
||||
const sf = ts.createSourceFile(
|
||||
"probe.ts",
|
||||
'/* task.column === "triage" in prose */\n// column === "triage" too\nconst x = 1;\n',
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const sites: Site[] = [];
|
||||
collect(sf, "triage", "probe.ts", sites);
|
||||
expect(sites).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects every syntactic form a reintroduced guard can take", () => {
|
||||
/*
|
||||
The four shapes a text scan misses or mishandles: single quotes, a comparison wrapped across
|
||||
lines, a deeper-qualified receiver, and the literal on the LEFT. Exercised through the real
|
||||
collector rather than trusted from a pattern.
|
||||
*/
|
||||
const probe = [
|
||||
'const a = task.column === "triage";',
|
||||
"const b = t.column === 'triage';",
|
||||
'const c = linkedTask.detail.column\n !==\n "triage";',
|
||||
'const d = "triage" === toColumn;',
|
||||
'const e = role === "triage";', // must NOT count
|
||||
].join("\n");
|
||||
const sf = ts.createSourceFile("probe.tsx", probe, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const sites: Site[] = [];
|
||||
collect(sf, "triage", "probe.tsx", sites);
|
||||
|
||||
// double-quoted, single-quoted, multiline + deeper-qualified, literal-on-the-left.
|
||||
expect(sites.map((s) => s.receiver)).toEqual(["column", "column", "column", "toColumn"]);
|
||||
expect(sites.map((s) => s.receiver)).not.toContain("role");
|
||||
});
|
||||
|
||||
it("documents the column receivers present, so a new binding name is visible", () => {
|
||||
// A census, not a gate. Every receiver here is a shape someone must convert, and an unfamiliar
|
||||
// name appearing is the signal that a conversion introduced a new alias.
|
||||
const receivers = new Set(comparisonSites("triage").map((s) => s.receiver));
|
||||
for (const receiver of receivers) expect(NON_COLUMN_RECEIVERS.has(receiver)).toBe(false);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[lifecycle-column-ratchet] triage receivers: ${[...receivers].sort().join(", ") || "(none)"}`);
|
||||
expect(KNOWN_COLUMN_RECEIVERS.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user