diff --git a/docs/testing.md b/docs/testing.md index 7dc57a5df5..05a52faeb9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,6 +74,71 @@ Public `@fusion/core` exports consumed by runtime tools should include a literal `packages/engine/src/__tests__/user-configured-command-no-execsync.test.ts` guards user-configured command execution helpers against accidental `execSync` usage or dropped async bounds. Its registry covers verification helpers, `fn_run_verification`, executor configured-command execution, merger post-merge script execution, routine command execution, and the native/bubblewrap/sandbox-exec sandbox backends. Each protected slice must keep the appropriate bounded async safeguard (`timeout`/`timeoutMs`, `maxBuffer`, or `maxLifetimeMs`). The test intentionally slices named function bodies instead of scanning whole files; deterministic git-plumbing `execSync` in merger/self-healing/already-merged/integration/worktree-prune paths and the executor git ancestry check are explicitly out of scope. +## Lifecycle-column census (report-only) + + +`pnpm census:lifecycle-columns` reports every comparison against the six legacy column ids +(`triage`, `todo`, `in-progress`, `in-review`, `done`, `archived`) across the packages and plugins +source trees, with comments stripped. It reports **four separate numbers**, and that separation is +the whole value — three of the four must NOT be converted, and every one of them was silently +inside the single tracked figure: + +- **COLUMN guards** — the real backlog. A lifecycle decision made by column NAME stops matching + the moment a board renames a column. +- **ROLE comparisons** — `role === "triage"`, `agentType === "triage"`, `entry.agent === "triage"`. + These compare an AGENT ROLE. The planner *lane* is named `triage` and keeps that name; U11 + removed only the *column*. These must NOT be converted — renaming the role silently empties the + planner's prompt template and mis-binds its model markers. +- **STATUS comparisons** — `step.status === "done"`, `goal.status === "archived"`, + `feature.status === "done"`. `StepStatus` is `pending | in-progress | done | skipped`, and + missions, goals and features carry their own statuses; three of those names collide with column + ids. This is the largest correction the census makes — 182 sites, inflating `done` by 105 and + `in-progress` by 49. Converting one is a category error: asking which column carries the + `complete` trait about a STEP's status would stop the step reading as finished. +- **DELIBERATE-LITERAL** — reviewed sites whose literal is correct, with the reason recorded at the + site rather than in a list that can drift from it. Grep `DELIBERATE-LITERAL` to enumerate them. + +Why it exists: the program tracked its remaining work by grepping `=== "triage"`, and that count +was simultaneously too low (six ids exist; `triage` was under 4% of the total, and the pattern was +anchored on locals named `column`, so real guards on `from` and `originColumn` were invisible) and +too high (12 role comparisons and 182 entity-status comparisons counted as backlog). A count that is +wrong in both directions sends work to the wrong files and hides the files that need it. + +The two non-column classes are recognised structurally, not by a name list, because names are +unbounded and a name list was already wrong twice (`sessionPurpose`, `surface`). `AgentRole` is +`triage | executor | reviewer | merger` and `StepStatus` is `pending | in-progress | done | skipped`; +the members that are NEVER column ids (`executor`/`reviewer`/`merger`, `pending`/`skipped`) identify +which vocabulary an expression belongs to whatever its variable is called. + +**The classifier is AST-based** (`scripts/lib/lifecycle-column-census-ast.mjs`, `ts.createSourceFile`), +because three people measured this backlog with three greps and got three different answers for the +role bucket (6, 8, 12). A regex cannot tell a column guard from an agent role, a session purpose, a +surface name, a step status, or a comment. The parser also sees shapes no per-line pattern can: +multi-line comparisons, literal-on-the-left, loose equality, and JSX. The text classifier is kept +beside it as an independent second implementation — `--compare` asserts the parser is a strict +SUPERSET of the regex (measured +6, all real) and FAILS if the regex ever finds something the parser +misses, which would mean the parser has a blind spot and its count cannot be the bar. + +What the parser still cannot do, stated rather than implied: without a full type-checker program it +cannot prove a receiver is column-typed, so classification remains evidence-based (receiver name plus +the vocabulary its siblings use). That is why the four classes are reported separately and never +netted — a wrong classification stays visible instead of silently moving the bar. + +`--json` emits the machine-readable form. `--strict` compares per-file counts against +`scripts/lib/lifecycle-column-census-baseline.json` and fails when any file's column-guard count +**rises** — the ratchet shape. It is deliberately **not** wired into the merge gate: a +thousand-site backlog cannot be a blocking check the day it is first measured, and a guard nobody +can pass is a guard everyone disables. Owners tightening their own area should re-record the +baseline in the same PR that lowers it. + +The regression suite is `packages/engine/src/__tests__/lifecycle-column-census.test.ts`. It pins +each form the census must catch (all six ids, non-`column` locals, single quotes, negation, +multiple hits per line) and each it must not (role comparisons, comment prose, trailing line +comments, marked sites) — plus that one marker cannot launder a distant guard in the same file. +The CLI additionally exits non-zero on an empty file list, because a guard that reports success +without checking anything is worse than no guard. + + ## Dashboard Availability & Supervised Mode diff --git a/package.json b/package.json index 43d2712ead..b8a1771ec7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "check:line-count": "node scripts/check-file-line-count.mjs", "check:routes-modular": "node scripts/check-routes-modular.mjs", "check:changesets": "node scripts/check-changeset-format.mjs", + "census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs", "check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs", "check:mock-completeness": "node scripts/check-mock-completeness.mjs", "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", diff --git a/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts b/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts new file mode 100644 index 0000000000..98040501c2 --- /dev/null +++ b/packages/engine/src/__tests__/lifecycle-column-census-ast.test.ts @@ -0,0 +1,196 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-23:20 (Phase C convergence — the AST classifier's tests): + +WHY A PARSER AT ALL. Three people measured this backlog with three greps and got three different +answers for the role bucket (6, 8, 12). A regex cannot tell a lifecycle-column comparison from an +agent role, a session purpose, a surface name, a step status, or a comment — so no grep-derived +number is authoritative, however careful the pattern. + +These cases are the four reintroduction shapes a ratchet must catch (double-quoted, single-quoted, +multiline, deeper-qualified) plus the shapes it must NOT flag. A guard nobody has tried to fool is +a number, not a measurement. + +The parser and the text classifier are kept as two independent implementations on purpose: the CLI's +`--compare` asserts the parser is a strict SUPERSET of the regex (measured +6, all real), and fails +if the regex ever finds something the parser misses — which would mean the parser has a blind spot +and its count cannot be the bar. +*/ +import { describe, expect, it } from "vitest"; + +import { + DELIBERATE_MARKER, + findComparisons, + summarize, +} from "../../../../scripts/lib/lifecycle-column-census-ast.mjs"; + +function census(source: string) { + return findComparisons("fixture.tsx", source); +} + +function totals(source: string) { + return summarize(census(source)).totals; +} + +describe("the parser catches every reintroduction shape", () => { + it("double-quoted", () => { + expect(totals(`if (task.column === "triage") return;`).column).toBe(1); + }); + + it("single-quoted", () => { + expect(totals(`if (task.column === 'triage') return;`).column).toBe(1); + }); + + it("multiline, where the operator and the literal are on different lines", () => { + // A per-line regex cannot see this at all; it is why the text classifier under-counts by 6. + const source = ["if (", " task.column", " ===", ' "triage"', ") return;"].join("\n"); + + expect(totals(source).column).toBe(1); + }); + + it("deeper-qualified receivers", () => { + const source = [ + `if (ctx.live.task.column === "triage") return;`, + `if (tasks[i].column === "todo") return;`, + `if (live?.column === "in-review") return;`, + `if (String(t.column) === "done") return;`, + ].join("\n"); + + expect(totals(source).column).toBe(4); + }); + + it("literal on the LEFT", () => { + // `"triage" === task.column` reads oddly but parses identically, and a regex anchored on the + // receiver misses it entirely. + expect(totals(`if ("triage" === task.column) return;`).column).toBe(1); + }); + + it("loose equality", () => { + expect(totals(`if (task.column == "triage") return;`).column).toBe(1); + expect(totals(`if (task.column != "triage") return;`).column).toBe(1); + }); + + it("inside JSX, which is why the parser uses ScriptKind.TSX", () => { + const source = [ + `export const Badge = ({ task }: { task: { column: string } }) => (`, + ` `, + `);`, + ].join("\n"); + + expect(totals(source).column).toBe(1); + }); +}); + +describe("the parser does not flag what a regex mistakes for a guard", () => { + it("ignores comments entirely — they are not tokens", () => { + // The text classifier needs a comment stripper for this, and a bug in that stripper let ONE + // marker launder FOUR live guards. A parse cannot have that class of bug. + const source = [ + `/* the old filter was \`column === "triage" && ready\` */`, + `// historical: fromColumn === "todo" meant planning`, + `const real = task.column === "done";`, + ].join("\n"); + + expect(totals(source)).toEqual({ column: 1, role: 0, status: 0, deliberate: 0 }); + }); + + it("classifies agent roles, session purposes and surfaces as role", () => { + const source = [ + `if (role === "triage") return TRIAGE_PROMPT;`, + `if (agentType === "triage") return planning;`, + `if (entry.agent !== "triage") return;`, + `const usesFallback = sessionPurpose === "triage" || sessionPurpose === "executor";`, + `return surface === "triage" ? A : B;`, + ].join("\n"); + + const result = totals(source); + + expect(result.column).toBe(0); + // Five, not six: the `sessionPurpose === "executor"` sibling is not itself a finding, because + // `executor` is not a column id. It only serves as EVIDENCE that its receiver holds a role — + // which is the whole mechanism, so it is worth having the count say so. + expect(result.role).toBe(5); + }); + + it("classifies step, goal and feature statuses as status", () => { + const source = [ + `const isDone = step.status === "done" || step.status === "skipped";`, + `if (existing.status === "archived") return;`, + ].join("\n"); + + const result = totals(source); + + expect(result.column).toBe(0); + expect(result.status).toBe(2); + }); + + it("treats a marked site as deliberate, including a marker above the enclosing FUNCTION", () => { + // The shape that caught a statement-only lookup: `legacyDependencySatisfied` in + // hold-release.ts carries the marker above the function while the comparisons are inside it. + const source = [ + `/* FNXC:Whatever ${DELIBERATE_MARKER}: the legacy half of a dual-accept pair. */`, + `function legacySatisfied(dep: { column: string }): boolean {`, + ` return dep.column === "done" || dep.column === "archived";`, + `}`, + ].join("\n"); + + const result = totals(source); + + expect(result.deliberate).toBe(2); + expect(result.column).toBe(0); + }); + + it("does NOT let a marker excuse a sibling construct", () => { + // Ancestor scope, not a line window: a marker excuses what it is attached to and what is + // inside it, and nothing else. The window version excused whatever was within twelve lines. + const source = [ + `/* ${DELIBERATE_MARKER}: reason for the function below. */`, + `function marked(dep: { column: string }) { return dep.column === "done"; }`, + `function unmarked(dep: { column: string }) { return dep.column === "triage"; }`, + ].join("\n"); + + const result = totals(source); + + expect(result.deliberate).toBe(1); + expect(result.column).toBe(1); + }); + + it("does not count a value that merely happens to equal a column id", () => { + // Assignments, object literals and arguments are not comparisons. A census that counted them + // would report the workflow DEFINITIONS as violations, and the builtin lineage legitimately + // declares these ids. + const source = [ + `const target = "triage";`, + `const columns = [{ id: "triage" }, { id: "todo" }];`, + `await store.moveTask(id, "todo");`, + ].join("\n"); + + expect(totals(source)).toEqual({ column: 0, role: 0, status: 0, deliberate: 0 }); + }); +}); + +describe("sibling detection uses the enclosing expression, not a line window", () => { + it("sees a role-only sibling across a long multi-line chain", () => { + const source = [ + `const usesRoleFallback = purposeOf(x) === "triage"`, + ` || somethingElse`, + ` || anotherThing`, + ` || yetAnother`, + ` || purposeOf(x) === "merger";`, + ].join("\n"); + + // Six lines apart: outside any reasonable line window, inside one expression. + expect(totals(source).role).toBe(1); + }); + + it("does NOT borrow a sibling from an adjacent, unrelated expression", () => { + const source = [ + `const isRole = agentType === "executor";`, + `const isPlanning = task.column === "triage";`, + ].join("\n"); + + const result = totals(source); + + expect(result.column).toBe(1); + expect(result.role).toBe(0); + }); +}); diff --git a/packages/engine/src/__tests__/lifecycle-column-census.test.ts b/packages/engine/src/__tests__/lifecycle-column-census.test.ts new file mode 100644 index 0000000000..dc052f2b2b --- /dev/null +++ b/packages/engine/src/__tests__/lifecycle-column-census.test.ts @@ -0,0 +1,344 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-14:35 (Phase C convergence — the census's own tests): + +A census nobody has tried to fool is a number, not a measurement. This pins every form the +lifecycle-column census must catch, and every form it must NOT count — because the tracked +`=== "triage"` grep it replaces was wrong in three separate ways, and each way cost real work: + + 1. it counted only ONE of six legacy column ids (triage was under 4% of the total); + 2. it missed guards whose local was named `from` / `originColumn` rather than `column`; + 3. it counted `role === "triage"` / `agentType === "triage"` — AGENT ROLE comparisons that + must never be converted, since the planner lane keeps that name. + +Each case below is one of those, plus the comment-prose case that inflated two files' counts. +*/ +import { describe, expect, it } from "vitest"; + +import { + DELIBERATE_MARKER, + LEGACY_COLUMN_IDS, + findComparisons, + receiverOf, + stripComments, + summarize, +} from "../../../../scripts/lib/lifecycle-column-census.mjs"; + +function census(source: string) { + return findComparisons("fixture.ts", source); +} + +function kinds(source: string): string[] { + return census(source).map((f) => (f as { kind: string }).kind); +} + +describe("the census counts a column guard in every shape the codebase actually uses", () => { + it("counts all six legacy column ids, not just triage", () => { + // Defect 1: the tracked grep measured `triage` only, which was under 4% of the real total. + const source = LEGACY_COLUMN_IDS.map((id, i) => `const a${i} = task.column === "${id}";`).join("\n"); + + expect(kinds(source)).toEqual(LEGACY_COLUMN_IDS.map(() => "column")); + }); + + it("counts a guard whose local is NOT named `column`", () => { + // Defect 2: this is verbatim the shape of the three executor.ts guards that were absent + // from the tracked list while the card they stranded had its work already complete. + const source = [ + `if ((from === "todo" || from === "triage") && to !== "in-progress") return;`, + `const promoted = originColumn === "todo" || originColumn === "triage";`, + ].join("\n"); + + expect(kinds(source).every((k) => k === "column")).toBe(true); + expect(kinds(source)).toHaveLength(5); + }); + + it("counts single-quoted and negated forms", () => { + const source = [ + `if (task.column !== 'in-review') return;`, + `const done = t.column === 'done';`, + ].join("\n"); + + expect(kinds(source)).toEqual(["column", "column"]); + }); + + it("counts more than one comparison on the same line", () => { + const source = `const planner = c === "todo" || c === "triage" || c === "archived";`; + + expect(kinds(source)).toHaveLength(3); + }); +}); + +describe("the census does NOT count things that are not column guards", () => { + it("ignores AGENT ROLE comparisons", () => { + // Defect 3. Converting these silently empties the planner's prompt template, so counting + // them as backlog actively invites the wrong fix. + const source = [ + `if (role === "triage") return TRIAGE_PROMPT;`, + `const lane = agentType === "triage" ? planning : execution;`, + `if (entry.agent !== "triage") return;`, + ].join("\n"); + + expect(kinds(source)).toEqual(["role", "role", "role"]); + expect(summarize(census(source)).totals.column).toBe(0); + }); + + it("ignores comment prose describing a past guard", () => { + // Two of the tracked hits in replan-target.ts were prose about a filter in another file. + const source = [ + `/* the discovery filter (\`column === "triage" && ready\`) never re-admitted it */`, + `// historical: fromColumn === "todo" used to mean planning`, + `const real = task.column === "done";`, + ].join("\n"); + + expect(kinds(source)).toEqual(["column"]); + }); + + it("counts a trailing line comment as prose, not code", () => { + // `stripComments` needs the multiline flag or a trailing comment survives and is counted. + const source = `const x = 1; // task.column === "triage" is gone`; + + expect(stripComments(source)).not.toContain("triage"); + expect(kinds(source)).toEqual([]); + }); + + it("classifies a reviewed literal as deliberate when the marker is at the site", () => { + const source = [ + `/* FNXC:Whatever ${DELIBERATE_MARKER}: the fallback must NOT be workflow-resolved. */`, + `const target = declared ? resolved : "triage";`, + `if (task.column === "triage") return legacy;`, + ].join("\n"); + + const summary = summarize(census(source)); + + expect(summary.totals.deliberate).toBe(1); + expect(summary.totals.column).toBe(0); + }); + + it("does NOT let a marker elsewhere in the file excuse a distant guard", () => { + // Otherwise one marker launders a whole file, which is how allowlists rot. + const source = [ + `/* ${DELIBERATE_MARKER}: reason for the site below. */`, + `const a = task.column === "triage";`, + ...Array.from({ length: 20 }, (_, i) => `const filler${i} = ${i};`), + `const b = task.column === "done";`, + ].join("\n"); + + const summary = summarize(census(source)); + + expect(summary.totals.deliberate).toBe(1); + expect(summary.totals.column).toBe(1); + }); +}); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-18:05 (PR #2633 review, greptile P1): + +COMMENT STRIPPING MUST PRESERVE LINE COUNT. Deleting a multi-line block comment outright shifted +every following line, and the consequence was not the harmless over-count I had written down: the +site-local DELIBERATE-LITERAL lookup ran at the wrong offset, so ONE marker in a file laundered +FOUR unrelated live guards in `replan-target.ts` — they were reported as reviewed-and-intentional +when they are neither. Findings also pointed at unrelated source lines, which sends a reader to +the wrong code. Blanking the comment in place fixes both. +*/ +describe("stripping a comment must not move the lines after it", () => { + it("reports the ORIGINAL line number after a multi-line block comment", () => { + const source = ["/* a", "multi", "line", "comment */", `const a = task.column === "triage";`].join("\n"); + + expect(census(source)[0]?.line).toBe(5); + }); + + it("finds a site-local marker across a multi-line comment", () => { + const source = [ + `/* FNXC:Whatever ${DELIBERATE_MARKER}: reason`, + "spanning", + "several", + "lines */", + `const a = task.column === "triage";`, + ].join("\n"); + + expect(summarize(census(source)).totals.deliberate).toBe(1); + }); + + it("does NOT let a marker launder guards the shift used to pull into range", () => { + // The replan-target.ts case, minimized: a marked site near the top, then a genuinely + // unrelated guard far below. Before the fix the deletion of the intervening comment moved + // the second guard inside the marker's window and it was scored `deliberate`. + const source = [ + `/* ${DELIBERATE_MARKER}: this fallback must not be workflow-resolved. */`, + `const fallback = declared ? resolved : "triage";`, + "/*", + ...Array.from({ length: 30 }, (_, i) => ` * filler line ${i}`), + " */", + `if (task.column === "in-progress" || task.column === "done") return true;`, + ].join("\n"); + + const summary = summarize(census(source)); + + expect(summary.totals.column).toBe(2); + expect(summary.totals.deliberate).toBe(0); + }); +}); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-18:35 (PR #2633 review follow-up): + +A NAME LIST IS GUESSWORK, and mine was already wrong: `skill-resolver.ts` compares +`sessionPurpose` and `tool-availability.ts` compares `surface`, and both scored as column guards +until a human found them by hand. Names are unbounded; the vocabulary is not. + +`AgentRole` is `triage | executor | reviewer | merger`, and three of those four are never column +ids. So an expression compared against `"executor"`/`"reviewer"`/`"merger"` nearby is being matched +against ROLES whatever it is called. Structural, not nominal — and it generalises to receivers +nobody has named yet. `triage` belonging to both vocabularies is the whole reason this exists. +*/ +describe("a role comparison is recognised by the vocabulary it uses, not only by its name", () => { + it("classifies an unfamiliar receiver as a role when it is matched against role-only values", () => { + const source = [ + `const usesRoleFallback = sessionPurpose === "triage"`, + ` || sessionPurpose === "executor"`, + ` || sessionPurpose === "reviewer";`, + ].join("\n"); + + expect(summarize(census(source)).totals).toEqual({ column: 0, role: 1, status: 0, deliberate: 0 }); + }); + + it("recognises the single-line ternary form too", () => { + // tool-availability.ts's shape: `surface === "triage" ? A : B` with the union declared above. + const source = `return surface === "triage" ? TRIAGE_GUIDANCE : EXECUTOR_GUIDANCE;\nif (surface === "executor") return x;`; + + expect(summarize(census(source)).totals.role).toBe(1); + }); + + it("does NOT reclassify a genuine column guard that merely sits near role code", () => { + // The signal is the RECEIVER being matched against a role-only value — not proximity alone. + // Otherwise one nearby role check would launder every column guard around it. + const source = [ + `if (agentType === "executor") return;`, + `if (task.column === "triage") return;`, + ].join("\n"); + + const summary = summarize(census(source)); + + expect(summary.totals.column).toBe(1); + expect(summary.totals.role).toBe(0); + }); + + it("still counts a column guard whose receiver is an unremarkable local", () => { + // `cli/src/commands/task.ts` compares `col` against the column ids for its board dots — + // an unfamiliar name, but the vocabulary is columns, so it stays in the backlog. + const source = [ + `const dot = col === "triage" ? "●" :`, + ` col === "todo" ? "●" :`, + ` col === "in-review" ? "●" : "○";`, + ].join("\n"); + + expect(summarize(census(source)).totals.column).toBe(3); + }); +}); + +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-20:20 (third colliding vocabulary): + +STATUS IS NOT A COLUMN, and this is the largest correction the census has produced: 182 of the 1030 +sites it first called column guards compare an ENTITY STATUS. `StepStatus` is +`pending | in-progress | done | skipped`; mission features and goals carry their own +`done`/`archived` statuses. So `step.status === "done"` and `goal.status === "archived"` were +counted as un-migrated lifecycle guards, inflating `done` (105 of 313) and `in-progress` (49 of +201). + +Converting one would be worse than leaving it: asking "which column carries the complete trait" +about a STEP's status is a category error, and the step would stop reading as finished. +*/ +describe("entity statuses that share a column name are not column guards", () => { + it("classifies step, goal and feature statuses as status, not backlog", () => { + const source = [ + `const isDone = step.status === "done" || step.status === "skipped";`, + `if (existing.status === "archived") return;`, + `if (feature.status === "done") count += 1;`, + ].join("\n"); + + const summary = summarize(census(source)); + + // Three, not four: `skipped` is a StepStatus value but NOT one of the six legacy column ids, + // so the census never looks at it. Only the `done`/`archived`/`done` comparisons are findings + // at all — which is itself worth knowing, since it means the status inflation comes entirely + // from the three names the two vocabularies share. + expect(summary.totals.status).toBe(3); + expect(summary.totals.column).toBe(0); + }); + + it("recognises a status by the vocabulary even when the receiver is not named `status`", () => { + // `pending` and `skipped` are StepStatus members and never column ids, so an expression + // matched against either is a status whatever it is called — the same structural signal the + // role classification uses, for the same reason: names are unbounded. + const source = [ + `const finished = s === "done"`, + ` || s === "pending"`, + ` || s === "skipped";`, + ].join("\n"); + + expect(summarize(census(source)).totals.status).toBe(1); + }); + + it("does NOT reclassify a real column guard sitting near status code", () => { + const source = [ + `if (step.status === "pending") return;`, + `if (task.column === "done") return;`, + ].join("\n"); + + const summary = summarize(census(source)); + + // The column guard stays backlog. The `pending` line is not a finding at all — it compares a + // value outside the column vocabulary — so a nearby status check cannot launder the guard, and + // it cannot pad the status count either. + expect(summary.totals.column).toBe(1); + expect(summary.totals.status).toBe(0); + }); + + it("keeps a column guard that merely lives in a file full of statuses", () => { + const source = `if (toColumn === "in-progress" && task.status === "pending") return;`; + const summary = summarize(census(source)); + + // The column half is still backlog; only the status half is excluded. + expect(summary.totals.column).toBe(1); + }); +}); + +describe("receiver extraction survives real call shapes", () => { + it("reads through property access, optional chaining, and parentheses", () => { + expect(receiverOf("if (task.column ")).toBe("column"); + expect(receiverOf("if (live?.column ")).toBe("column"); + expect(receiverOf("if (String(task.status) ")).toBe("status"); + expect(receiverOf(" const x = from ")).toBe("from"); + }); +}); + +describe("the census refuses to report success on nothing", () => { + it("summarizes an empty finding list as three zeros, never as a pass signal", () => { + /* + The CLI additionally exits 1 when its own file list comes back EMPTY, because a guard that + reports success without checking anything is worse than no guard. That path is a process + exit and is exercised by running the script; this pins the pure half — an empty census is + three zeros and carries no verdict of its own. + */ + expect(summarize([]).totals).toEqual({ column: 0, role: 0, status: 0, deliberate: 0 }); + expect(summarize([]).byFile).toEqual([]); + }); +}); + +describe("the summary separates the three classes", () => { + it("reports column guards, role comparisons and deliberate literals independently", () => { + // Netting them into one number is what produced a tracked figure that was simultaneously + // too high and too low. + const source = [ + `if (task.column === "todo") return;`, + `if (role === "triage") return;`, + `/* ${DELIBERATE_MARKER}: reason. */`, + `if (fallbackColumn === "triage") return;`, + ].join("\n"); + + const summary = summarize(census(source)); + + expect(summary.totals).toEqual({ column: 1, role: 1, status: 0, deliberate: 1 }); + expect(summary.byColumnId).toEqual({ todo: 1 }); + }); +}); diff --git a/scripts/lib/lifecycle-column-census-ast.mjs b/scripts/lib/lifecycle-column-census-ast.mjs new file mode 100644 index 0000000000..5206d717d7 --- /dev/null +++ b/scripts/lib/lifecycle-column-census-ast.mjs @@ -0,0 +1,202 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-22:20 (Phase C convergence — AST classifier): + +WHY AN AST AND NOT A REGEX. Three people measured the remaining lifecycle-column work with three +greps and got three answers (6, 8, 12 role-bucket sites). A regex cannot tell a lifecycle-column +comparison from an agent role, a session purpose, a surface name, a step status, or a comment — so +no grep-derived number is authoritative, however careful the pattern. This module parses instead. + +WHAT THE PARSER BUYS, concretely, over the text census next to it: + - comments are not tokens, so prose about an old guard cannot be counted (the text version needed + a comment stripper, and a bug in that stripper let ONE marker launder FOUR live guards); + - the receiver is a real expression, so `t.column`, `live?.column`, `String(task.status)` and + `tasks[i].column` all resolve without a hand-tuned pattern per shape; + - sibling comparisons are found by walking the ENCLOSING expression rather than a line window, so + a multi-line `||` chain is one unit and an unrelated line four rows away is not. + +WHAT IT STILL CANNOT DO, stated plainly rather than implied: without a full type-checker program it +cannot prove a receiver is column-typed. So classification remains evidence-based — the receiver's +name plus the vocabulary its siblings use — and the three non-column classes are reported +SEPARATELY rather than netted, so a wrong classification is visible instead of silently changing +the bar. Two independent implementations agreeing on 12 role sites is the strongest evidence +available; one number from one grep is the weakest. + +CLASSES (only the first is backlog): + column — a lifecycle-column guard. + role — AgentRole / session purpose / surface. Converting one is a real bug: the planner + LANE is named `triage` and keeps that name; U11 removed the COLUMN. + status — StepStatus / mission / goal / feature status. `done`, `in-progress` and `archived` + collide with column ids; `pending` and `skipped` never do. + deliberate — reviewed literal carrying a DELIBERATE-LITERAL marker in its leading comments. +*/ + +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +/** The legacy lifecycle column vocabulary — the ids that shipped as the builtin board. */ +export const LEGACY_COLUMN_IDS = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +/** Receiver names that denote an agent role / lane rather than a task column. */ +export const ROLE_RECEIVER_TOKENS = [ + "role", "agentType", "agent", "lane", "capability", "sessionPurpose", "surface", "purpose", "agentRole", +]; + +/* +Values that belong to ONE vocabulary only, and therefore identify which vocabulary an expression is +matching regardless of what its variable is called. `AgentRole` is `triage | executor | reviewer | +merger` and `StepStatus` is `pending | in-progress | done | skipped`; the members below are never +column ids. This is the signal that caught `sessionPurpose` and `surface`, which a name list missed. +*/ +const ROLE_ONLY_VALUES = new Set(["executor", "reviewer", "merger"]); +const STATUS_ONLY_VALUES = new Set(["pending", "skipped"]); + +export const DELIBERATE_MARKER = "DELIBERATE-LITERAL"; + +const COMPARISON_KINDS = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); + +/** The name a comparison is made against: the property, the identifier, or the callee's argument. */ +function receiverNameOf(node) { + if (ts.isPropertyAccessExpression(node)) return node.name.getText(); + if (ts.isElementAccessExpression(node)) return receiverNameOf(node.expression); + if (ts.isIdentifier(node)) return node.getText(); + if (ts.isNonNullExpression(node) || ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) { + return receiverNameOf(node.expression); + } + // `String(task.status)` / `normalize(col)` — the interesting name is the argument's. + if (ts.isCallExpression(node) && node.arguments.length === 1) return receiverNameOf(node.arguments[0]); + return ""; +} + +/** The string literal side of a comparison, if exactly one side is one. */ +function literalOf(binary) { + const left = binary.left; + const right = binary.right; + const leftIsLiteral = ts.isStringLiteralLike(left); + const rightIsLiteral = ts.isStringLiteralLike(right); + if (leftIsLiteral === rightIsLiteral) return undefined; + return leftIsLiteral + ? { literal: left.text, receiver: right } + : { literal: right.text, receiver: left }; +} + +/** + * The outermost expression this comparison participates in, so a multi-line `||` chain is examined + * as ONE unit. A line window cannot express that: it both misses long chains and pulls in + * unrelated neighbours. + */ +function enclosingExpression(node) { + let current = node; + while ( + current.parent + && (ts.isBinaryExpression(current.parent) + || ts.isParenthesizedExpression(current.parent) + || ts.isPrefixUnaryExpression(current.parent) + || ts.isConditionalExpression(current.parent)) + ) { + current = current.parent; + } + return current; +} + +/** Every string literal compared against `receiverName` inside `scope`. */ +function siblingLiteralsFor(scope, receiverName) { + const values = new Set(); + const visit = (node) => { + if (ts.isBinaryExpression(node) && COMPARISON_KINDS.has(node.operatorToken.kind)) { + const parts = literalOf(node); + if (parts && receiverNameOf(parts.receiver) === receiverName) values.add(parts.literal); + } + ts.forEachChild(node, visit); + }; + visit(scope); + return values; +} + +/** True when a DELIBERATE-LITERAL marker appears in the comments attached above this node. */ +function hasDeliberateMarker(sourceFile, node) { + const fullText = sourceFile.getFullText(); + /* + Walk every ANCESTOR, not just the enclosing statement. The real markers in this codebase sit above + the enclosing FUNCTION (`legacyDependencySatisfied` in hold-release.ts is the case that caught + this) while the comparison is a return statement inside it — so a statement-only lookup found + nothing and silently reclassified three reviewed literals as backlog. + + Ancestor scope is also the right SEMANTICS, and strictly tighter than the line window it replaces: + a marker excuses the construct it is attached to and everything inside it, and nothing else. The + window version excused whatever happened to be within twelve lines. + */ + let current = node; + while (current && !ts.isSourceFile(current)) { + const ranges = ts.getLeadingCommentRanges(fullText, current.getFullStart()) ?? []; + if (ranges.some((range) => fullText.slice(range.pos, range.end).includes(DELIBERATE_MARKER))) { + return true; + } + current = current.parent; + } + return false; +} + +/** Parse one file and classify every comparison against a legacy column id. */ +export function findComparisons(filePath, source) { + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const findings = []; + + const visit = (node) => { + if (ts.isBinaryExpression(node) && COMPARISON_KINDS.has(node.operatorToken.kind)) { + const parts = literalOf(node); + if (parts && LEGACY_COLUMN_IDS.includes(parts.literal)) { + const receiver = receiverNameOf(parts.receiver); + const siblings = siblingLiteralsFor(enclosingExpression(node), receiver); + const isRole = ROLE_RECEIVER_TOKENS.includes(receiver) + || [...siblings].some((value) => ROLE_ONLY_VALUES.has(value)); + const isStatus = /status/i.test(receiver) + || [...siblings].some((value) => STATUS_ONLY_VALUES.has(value)); + const deliberate = hasDeliberateMarker(sourceFile, node); + findings.push({ + file: filePath, + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + columnId: parts.literal, + receiver, + kind: deliberate ? "deliberate" : isRole ? "role" : isStatus ? "status" : "column", + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + return findings; +} + +/** Aggregate findings into the four headline counts plus per-file and per-column breakdowns. */ +export function summarize(findings) { + const totals = { column: 0, role: 0, status: 0, deliberate: 0 }; + const byColumnId = {}; + const byFile = new Map(); + + for (const finding of findings) { + totals[finding.kind] += 1; + if (finding.kind !== "column") continue; + byColumnId[finding.columnId] = (byColumnId[finding.columnId] ?? 0) + 1; + byFile.set(finding.file, (byFile.get(finding.file) ?? 0) + 1); + } + + return { + totals, + byColumnId, + byFile: [...byFile].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), + }; +} + +/** Read + census a list of files. Callers own enumeration so this stays pure and testable. */ +export function censusFiles(files, readFile = (f) => readFileSync(f, "utf8")) { + return files.flatMap((file) => findComparisons(file, readFile(file))); +} diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json new file mode 100644 index 0000000000..f3e7f01786 --- /dev/null +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -0,0 +1,161 @@ +{ + "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline (AST classifier)", + "totals": { + "column": 854, + "role": 12, + "status": 182, + "deliberate": 3 + }, + "byColumnId": { + "done": 210, + "in-progress": 153, + "in-review": 218, + "archived": 152, + "triage": 38, + "todo": 83 + }, + "byFile": { + "packages/engine/src/self-healing.ts": 126, + "packages/engine/src/executor.ts": 112, + "packages/dashboard/app/components/TaskCard.tsx": 45, + "packages/core/src/task-store/moves.ts": 44, + "packages/dashboard/app/components/TaskDetailModal.tsx": 34, + "packages/engine/src/scheduler.ts": 28, + "packages/core/src/default-workflow-hooks.ts": 25, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 22, + "packages/core/src/store.ts": 12, + "packages/engine/src/project-engine.ts": 12, + "packages/cli/src/commands/task.ts": 11, + "packages/dashboard/app/components/TaskContextMenu.tsx": 11, + "packages/core/src/live-agent-count.ts": 10, + "packages/dashboard/app/components/Column.tsx": 10, + "packages/engine/src/mission-execution-loop.ts": 10, + "packages/core/src/task-store/async-comments-attachments.ts": 9, + "packages/dashboard/src/github-tracking-comments.ts": 9, + "packages/dashboard/src/github-tracking-reconciler.ts": 9, + "packages/engine/src/notification/notification-service.ts": 9, + "packages/cli/src/commands/dashboard.ts": 8, + "packages/core/src/task-store/update-task-deps.ts": 7, + "packages/engine/src/agent-tools.ts": 7, + "packages/core/src/task-merge.ts": 6, + "packages/core/src/task-store/branch-group-ops.ts": 6, + "packages/core/src/task-store/task-artifacts-ops.ts": 6, + "packages/dashboard/app/components/ListView.tsx": 6, + "packages/dashboard/src/reliability-metrics.ts": 6, + "packages/engine/src/runtimes/in-process-runtime.ts": 6, + "packages/cli/src/extension.ts": 5, + "packages/core/src/task-store/merge-queue-ops-2.ts": 5, + "packages/dashboard/app/hooks/useTaskDiffStats.ts": 5, + "packages/dashboard/app/utils/taskActivity.ts": 5, + "packages/engine/src/merger.ts": 5, + "packages/engine/src/mission-feature-sync.ts": 5, + "packages/engine/src/restart-recovery-coordinator.ts": 5, + "packages/core/src/agent-store.ts": 4, + "packages/core/src/blocker-fanout.ts": 4, + "packages/core/src/task-age-staleness.ts": 4, + "packages/core/src/task-store/comments-ops.ts": 4, + "packages/core/src/task-store/task-store-helpers.ts": 4, + "packages/dashboard/app/components/command-center/MissionControlPanel.tsx": 4, + "packages/dashboard/app/components/DocumentsView.tsx": 4, + "packages/dashboard/app/components/TaskReviewTab.tsx": 4, + "packages/dashboard/app/components/taskSorting.ts": 4, + "packages/dashboard/app/utils/worktreeGrouping.ts": 4, + "packages/dashboard/src/gitlab-tracking-comments.ts": 4, + "packages/dashboard/src/routes/register-git-github.ts": 4, + "packages/engine/src/agent-heartbeat.ts": 4, + "packages/engine/src/replan-target.ts": 4, + "packages/engine/src/triage.ts": 4, + "packages/engine/src/usage-limit-detector.ts": 4, + "packages/core/src/async-mission-store-queries.ts": 3, + "packages/core/src/async-mission-store.ts": 3, + "packages/core/src/task-priority.ts": 3, + "packages/core/src/task-store/async-merge-coordination.ts": 3, + "packages/core/src/task-store/task-update.ts": 3, + "packages/dashboard/app/components/DockTaskList.tsx": 3, + "packages/dashboard/app/components/TaskChangesTab.tsx": 3, + "packages/dashboard/app/components/TaskChatTab.tsx": 3, + "packages/dashboard/src/chat.ts": 3, + "packages/dashboard/src/routes/register-chat-routes.ts": 3, + "packages/engine/src/cli-agent/state-machine.ts": 3, + "packages/engine/src/gridlock-detector.ts": 3, + "packages/engine/src/planner-overseer.ts": 3, + "packages/engine/src/worktree-pool.ts": 3, + "packages/cli/src/commands/dashboard-tui/app.tsx": 2, + "packages/core/src/assigned-task-ranking.ts": 2, + "packages/core/src/duplicate-intake.ts": 2, + "packages/core/src/near-duplicate-canonical.ts": 2, + "packages/core/src/node-override-guard.ts": 2, + "packages/core/src/task-move-disposer.ts": 2, + "packages/core/src/task-store/archive-lifecycle-2.ts": 2, + "packages/core/src/task-store/audit-ops.ts": 2, + "packages/core/src/task-store/project-store-ops.ts": 2, + "packages/core/src/task-store/reads.ts": 2, + "packages/core/src/task-store/symbol-locks.ts": 2, + "packages/core/src/task-store/task-id-integrity.ts": 2, + "packages/core/src/team-analytics.ts": 2, + "packages/core/src/workflow-analytics.ts": 2, + "packages/dashboard/app/components/Board.tsx": 2, + "packages/dashboard/app/components/effective-model-resolution.ts": 2, + "packages/dashboard/app/components/WorkflowResultsTab.tsx": 2, + "packages/dashboard/app/utils/prFeedback.ts": 2, + "packages/dashboard/app/utils/taskRevert.ts": 2, + "packages/dashboard/app/utils/taskTiming.ts": 2, + "packages/dashboard/src/github-tracking-state.ts": 2, + "packages/dashboard/src/routes/register-project-routes.ts": 2, + "packages/dashboard/src/routes/register-session-diff-routes.ts": 2, + "packages/dashboard/src/routes/register-system-maintenance-routes.ts": 2, + "packages/dashboard/src/server.ts": 2, + "packages/engine/src/agent-reflection.ts": 2, + "packages/engine/src/auto-merge-finalization.ts": 2, + "packages/engine/src/merger-scope-auto-widen.ts": 2, + "plugins/fusion-plugin-even-cards/src/cards/board-cards.ts": 2, + "plugins/fusion-plugin-reports/src/store/report-store.ts": 2, + "packages/cli/src/commands/pr.ts": 1, + "packages/core/src/eval-automation.ts": 1, + "packages/core/src/eval-signal-collector.ts": 1, + "packages/core/src/in-review-stall.ts": 1, + "packages/core/src/mission-store.ts": 1, + "packages/core/src/plugin-store.ts": 1, + "packages/core/src/stalled-review-detector.ts": 1, + "packages/core/src/task-store/branch-and-pr-entities.ts": 1, + "packages/core/src/task-store/lifecycle-ops.ts": 1, + "packages/core/src/task-store/merge-queue-ops.ts": 1, + "packages/core/src/task-timing.ts": 1, + "packages/dashboard/app/components/ChangesDiffModal.tsx": 1, + "packages/dashboard/app/components/command-center/liveSnapshotMetrics.ts": 1, + "packages/dashboard/app/components/DashboardLoader.tsx": 1, + "packages/dashboard/app/components/DevServerView.tsx": 1, + "packages/dashboard/app/components/MergeDetails.tsx": 1, + "packages/dashboard/app/components/PrPanel.tsx": 1, + "packages/dashboard/app/components/ResearchTaskActionModal.tsx": 1, + "packages/dashboard/app/components/RoutingTab.tsx": 1, + "packages/dashboard/app/components/TaskPlannerChatTab.tsx": 1, + "packages/dashboard/app/hooks/useExecutorStats.ts": 1, + "packages/dashboard/app/hooks/useTasks.ts": 1, + "packages/dashboard/app/utils/inReviewStallCopy.ts": 1, + "packages/dashboard/app/utils/quickAddStart.ts": 1, + "packages/dashboard/app/utils/stalePausedReviewCopy.ts": 1, + "packages/dashboard/app/utils/taskStuck.ts": 1, + "packages/dashboard/src/github-issue-comment.ts": 1, + "packages/dashboard/src/gitlab-issue-comment.ts": 1, + "packages/dashboard/src/gitlab-source-issue-reconciler.ts": 1, + "packages/dashboard/src/knowledge-index-refresh.ts": 1, + "packages/dashboard/src/planning-board-tools.ts": 1, + "packages/dashboard/src/research-routes.ts": 1, + "packages/dashboard/src/routes/register-agent-core-routes.ts": 1, + "packages/dashboard/src/task-planner-chat-context.ts": 1, + "packages/dashboard/src/task-planner-chat-metrics.ts": 1, + "packages/dashboard/src/test/mockCoreEngine.ts": 1, + "packages/engine/src/auto-recovery-handlers/branch-worktree.ts": 1, + "packages/engine/src/backlog-pressure-reporter.ts": 1, + "packages/engine/src/cli-agent/task-session.ts": 1, + "packages/engine/src/cli-agent/telemetry-hub.ts": 1, + "packages/engine/src/ephemeral-worker-manager.ts": 1, + "packages/engine/src/merger-integration-worktree.ts": 1, + "packages/engine/src/merger-orphan-rehome.ts": 1, + "packages/engine/src/plugin-runner.ts": 1, + "packages/engine/src/pr-comment-handler.ts": 1, + "plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts": 1, + "plugins/fusion-plugin-reports/src/store/report-types.ts": 1 + } +} diff --git a/scripts/lib/lifecycle-column-census.mjs b/scripts/lib/lifecycle-column-census.mjs new file mode 100644 index 0000000000..42ab88020a --- /dev/null +++ b/scripts/lib/lifecycle-column-census.mjs @@ -0,0 +1,225 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-14:10 (Phase C convergence — the census, measured): + +WHY THIS EXISTS. The workflow-owned-lifecycle program tracks its remaining work by grepping +`=== "triage"`. That number was wrong in both directions and by a wide margin, and both errors +cost real work: + + UNDER-COUNTING BY VOCABULARY. `triage` is ONE of six legacy column ids. Run this census for + the current numbers; when it was first written it reported 1031 column guards across 1956 + source files — done 313, in-review 217, in-progress 201, archived 177, todo 83, triage 40. + Every one is the same defect class: a lifecycle decision made by column NAME, which stops + matching on a renamed board. Driving `triage` alone to zero addresses under 4% of it, and two + files hold a quarter of the remainder (executor.ts 151, self-healing.ts 136). + + This tool is the authority on those numbers, not this comment: figures written into prose go + stale silently, which is how a tracked count survived being wrong in three separate ways. + + UNDER-COUNTING BY RECEIVER. A pattern anchored on `column`/`toColumn`/`fromColumn` misses + guards whose local was named for its role in the function. That is how three real guards in + `executor.ts` — on `from` and `originColumn` — were absent from the tracked list while the + card they stranded had its work already complete. + + OVER-COUNTING BY VOCABULARY COLLISION. `role === "triage"` and `agentType === "triage"` + compare an AGENT ROLE. The planner lane is named `triage` and keeps that name; U11 removed + only the COLUMN. Ten such sites were counted as un-migrated guards, and the "obvious" fix — + renaming the role — silently empties the planner's prompt template. + +WHAT THIS REPORTS, therefore, is four separate numbers rather than one: COLUMN guards (the real +backlog), ROLE comparisons, STATUS comparisons (step/mission/goal statuses that merely share the +names), and DELIBERATE-LITERAL sites (reviewed, with the reason recorded at the site). The last +three must NOT be converted, and each of them was silently inside the tracked figure. + +REPORT-ONLY BY DEFAULT. `--strict` compares against a recorded baseline and fails when the +column-guard count RISES, which is the ratchet shape; it is not wired into the merge gate here, +because a thousand-site backlog cannot be a blocking check on the day it is first measured. +*/ + +import { readFileSync } from "node:fs"; + +/** The legacy lifecycle column vocabulary — the ids that shipped as the builtin board. */ +export const LEGACY_COLUMN_IDS = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +/** + * Receivers that name an AGENT ROLE / lane rather than a task column. + * + * `agent` is here because `AgentLogEntry.agent` holds the role that wrote the entry. If a + * future field named `agent` holds a column, this classification is wrong for it — which is + * the honest limitation of classifying by receiver name, and the reason the census reports + * the two classes separately instead of silently netting them. + */ +export const ROLE_RECEIVER_TOKENS = [ + "role", "agentType", "agent", "lane", "capability", "sessionPurpose", "surface", "purpose", "agentRole", +]; + +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-18:30 (PR #2633 review follow-up): +A STRONGER SIGNAL THAN THE NAME LIST, because the name list is guesswork and was already wrong: +`skill-resolver.ts` compares `sessionPurpose` and `tool-availability.ts` compares `surface`, and +both were scored as column guards until they were found by hand. Names are unbounded. + +The AgentRole vocabulary is `triage | executor | reviewer | merger`, and three of those four are +NEVER column ids. So an expression compared against `"executor"`, `"reviewer"` or `"merger"` in the +same neighbourhood is being matched against ROLES, whatever its variable is called. That is +structural rather than nominal, and it generalises to receivers nobody has thought of yet. + +`triage` is the only member of both vocabularies, which is the entire reason this census exists. +*/ +const ROLE_ONLY_SIBLING_VALUES = ["executor", "reviewer", "merger"]; + +/* +FNXC:WorkflowLifecycleColumns 2026-07-30-20:10 (third colliding vocabulary — measured): +STATUS IS NOT A COLUMN, and this one is big: 182 of the 1030 sites this census first called column +guards compare an ENTITY STATUS. `StepStatus` is `pending | in-progress | done | skipped`; mission +features and goals carry their own `done`/`archived` statuses. So `step.status === "done"`, +`goal.status === "archived"` and `feature.status === "done"` were all counted as un-migrated +lifecycle guards, which inflated `done` (105 of 313) and `in-progress` (49 of 201) enormously. + +Converting one of them would be worse than leaving it: asking "which column carries the complete +trait" about a STEP's status is a category error, and the step would stop being recognised as +finished. + +Two signals, the same pair used for roles: + - receiver NAME contains `status` (this is what `step.status` and `goal.status` look like); + - STRUCTURAL: compared against a status-only value nearby. `pending` and `skipped` are StepStatus + members and never column ids, so an expression matched against either is a status. +The structural half is what generalises; the name half is what catches the single-comparison sites +where no sibling value appears. +*/ +const STATUS_ONLY_SIBLING_VALUES = ["pending", "skipped"]; + +/** Marker that records a reviewed, intentionally-unconverted literal at its own site. */ +export const DELIBERATE_MARKER = "DELIBERATE-LITERAL"; + +/** + * Strip comments so prose about a past bug is never counted as a live guard. + * + * Two of the tracked "guards" in `replan-target.ts` were comment prose describing a filter that + * lives in another file. Line comments need the `m` flag, or a trailing `// … === "triage"` on a + * code line survives. + */ +export function stripComments(source) { + /* + FNXC:WorkflowLifecycleColumns 2026-07-30-17:40 (PR #2633 review, greptile P1): + BLANK the comment, keep its NEWLINES. Deleting a multi-line block comment outright shifted every + following line, so `findComparisons` reported unrelated line numbers AND looked for the + site-local DELIBERATE-LITERAL marker at the wrong offset — a marker could be missed and a + reviewed literal counted as backlog, or the reverse. I had written this down as "over-counts, + visible in the report", which was wrong in the more damaging direction: wrong line numbers send + a reader to the wrong code. + */ + return source + .replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " ")) + .replace(/\/\/.*$/gm, ""); +} + +/** True when the marker appears within `window` lines above `index` (or on the line itself). */ +function hasDeliberateMarker(originalLines, lineIndex, window = 12) { + const start = Math.max(0, lineIndex - window); + for (let i = start; i <= lineIndex; i += 1) { + if (originalLines[i]?.includes(DELIBERATE_MARKER)) return true; + } + return false; +} + +/** The receiver token immediately left of a comparison, e.g. `task.column` -> `column`. */ +export function receiverOf(textBeforeOperator) { + const match = /([A-Za-z_$][\w$]*)\s*(?:\?\.)?\s*$/.exec(textBeforeOperator.replace(/[)\]\s]+$/, "")); + if (match) return match[1]; + const dotted = /([A-Za-z_$][\w$]*)\s*\)?\s*$/.exec(textBeforeOperator); + return dotted ? dotted[1] : ""; +} + +/** + * Classify and count every legacy-column comparison in one file's source. + * + * Returns findings rather than a bare count: a census that cannot say WHICH class a site + * belongs to is the census this replaces. + */ +export function findComparisons(filePath, source) { + const originalLines = source.split("\n"); + const stripped = stripComments(source); + const strippedLines = stripped.split("\n"); + const findings = []; + + const pattern = new RegExp( + `(===|!==)\\s*(["'])(${LEGACY_COLUMN_IDS.join("|")})\\2`, + "g", + ); + + strippedLines.forEach((line, index) => { + let match; + pattern.lastIndex = 0; + while ((match = pattern.exec(line)) !== null) { + const receiver = receiverOf(line.slice(0, match.index)); + const columnId = match[3]; + /* + `stripComments` blanks comments in place (PR #2633 review), so this index is the ORIGINAL + line number and the marker lookup is exact. Before that fix a multi-line block comment + shifted every following line and this lookup could miss a marker entirely. + */ + const deliberate = hasDeliberateMarker(originalLines, index); + const isRole = ROLE_RECEIVER_TOKENS.includes(receiver) + || comparedAgainstSiblingValues(strippedLines, index, receiver, ROLE_ONLY_SIBLING_VALUES); + const isStatus = /status/i.test(receiver) + || comparedAgainstSiblingValues(strippedLines, index, receiver, STATUS_ONLY_SIBLING_VALUES); + findings.push({ + file: filePath, + line: index + 1, + columnId, + receiver, + kind: deliberate ? "deliberate" : isRole ? "role" : isStatus ? "status" : "column", + }); + } + }); + + return findings; +} + +/** + * True when `receiver` is compared against a role-only value (`executor`/`reviewer`/`merger`) + * within a few lines — evidence that the expression holds an AGENT ROLE, not a column. + * + * A window rather than the same line, because these read as multi-line `||` chains: + * const purposeUsesRoleFallback = sessionPurpose === "triage" + * || sessionPurpose === "executor" + */ +function comparedAgainstSiblingValues(lines, lineIndex, receiver, values, window = 4) { + if (!receiver) return false; + const start = Math.max(0, lineIndex - window); + const end = Math.min(lines.length - 1, lineIndex + window); + for (let i = start; i <= end; i += 1) { + for (const value of values) { + const pattern = new RegExp(`\\b${receiver}\\b\\s*(?:===|!==)\\s*(["'])${value}\\1`); + if (pattern.test(lines[i] ?? "")) return true; + } + } + return false; +} + +/** Aggregate findings into the three headline counts plus per-file and per-column breakdowns. */ +export function summarize(findings) { + const totals = { column: 0, role: 0, status: 0, deliberate: 0 }; + const byColumnId = {}; + const byFile = new Map(); + + for (const finding of findings) { + totals[finding.kind] += 1; + if (finding.kind === "column") { + byColumnId[finding.columnId] = (byColumnId[finding.columnId] ?? 0) + 1; + const current = byFile.get(finding.file) ?? 0; + byFile.set(finding.file, current + 1); + } + } + + return { + totals, + byColumnId, + byFile: [...byFile].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), + }; +} + +/** Read + census a list of files. Callers own enumeration so this stays pure and testable. */ +export function censusFiles(files, readFile = (f) => readFileSync(f, "utf8")) { + return files.flatMap((file) => findComparisons(file, readFile(file))); +} diff --git a/scripts/lifecycle-column-census.mjs b/scripts/lifecycle-column-census.mjs new file mode 100644 index 0000000000..4346e9a649 --- /dev/null +++ b/scripts/lifecycle-column-census.mjs @@ -0,0 +1,193 @@ +#!/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}\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 }); +} + +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.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), + }, 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);