From 1d0f21b4288eeef9de2d8c55fa0d28c928fa3b49 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Jul 2026 23:03:14 -0700 Subject: [PATCH] =?UTF-8?q?U12=20R12:=20lifecycle-column=20literal=20ratch?= =?UTF-8?q?et=20=E2=80=94=20and=20the=20raw=20count's=20floor=20is=20not?= =?UTF-8?q?=20zero=20(#2630)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- .../no-hardcoded-lifecycle-columns.test.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts diff --git a/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts b/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts new file mode 100644 index 0000000000..8ee01c75fd --- /dev/null +++ b/packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts @@ -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 = 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 = 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 === ""` / `X !== ""` 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 = { + 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); + }); +});