TAKING scripts/: parse instead of grep — an AST classifier for the lifecycle-column bar, cross-checked by a second implementation (#2633)
The program's completion bar is "`column === "triage"` reaches zero".
This measures what that bar actually covers, and checks the measurement
in so it cannot drift.
## The number, measured by the checked-in tool
```
lifecycle-column-census: scanned 1956 source files
COLUMN guards (the backlog): 1031
ROLE comparisons (not guards): 10
DELIBERATE-LITERAL (reviewed): 4
by column id:
313 done
217 in-review
201 in-progress
177 archived
83 todo
40 triage
top files:
151 packages/engine/src/executor.ts
136 packages/engine/src/self-healing.ts
50 packages/dashboard/app/components/TaskCard.tsx
44 packages/core/src/task-store/moves.ts
34 packages/dashboard/app/components/TaskDetailModal.tsx
```
**`triage` is under 4% of the class.** Every one of those 1031 sites is
the same defect: a lifecycle decision made by column NAME, which stops
matching the moment a board renames a column. The bar can be met in full
while 991 identical guards remain — and two files hold a quarter of
them.
## The tracked count is wrong in three directions at once
Each of these cost real work this week, which is why this is a PR and
not a comment.
1. **Vocabulary.** It measures one of six legacy ids.
2. **Receiver.** It is anchored on locals named
`column`/`toColumn`/`fromColumn`, so it never saw the three real guards
in `executor.ts` written against `from` and `originColumn`. One of those
meant completed-but-stranded work was never recovered on a renamed
board, with nothing else owning that state (converted in #2628).
3. **Collision.** `role === "triage"`, `agentType === "triage"`,
`entry.agent === "triage"` compare an **AGENT ROLE**. The planner *lane*
is named `triage` and keeps that name — U11 removed the *column*. Ten
such sites were counted as backlog, and the "obvious" fix (renaming the
role) silently empties the planner's prompt template and mis-binds its
model markers.
A count that is too high and too low simultaneously sends work to the
wrong files while hiding the files that need it. So the census reports
**three separate numbers** and never nets them.
## Proven to fail on the original defect
Not asserted — exercised:
```
$ # reintroduce `task.column === "triage" || task.column === "todo"` into live-agent-count.ts
$ node scripts/lifecycle-column-census.mjs --strict; echo "exit=$?"
packages/core/src/live-agent-count.ts: 10 -> 12
exit=1
$ # restore the file
$ node scripts/lifecycle-column-census.mjs --strict >/dev/null; echo "exit=$?"
exit=0
```
The CLI also exits 1 when its own file list comes back empty — a guard
that reports success without checking anything is worse than no guard.
## 12 regression cases, split by what they defend
Must catch: all six ids; a guard on a local named `from`/`originColumn`
(verbatim the executor.ts shape); single quotes; negation; several
comparisons on one line.
Must **not** catch: role comparisons; comment prose (two tracked
"guards" in `replan-target.ts` were prose about a filter that lives in
another file); a trailing `// … === "triage"` on a code line; sites
carrying a `DELIBERATE-LITERAL` marker.
Plus: **one marker cannot launder a distant guard in the same file** —
that is how allowlists rot.
## Report-only, deliberately
`--strict` compares per-file counts against
`scripts/lib/lifecycle-column-census-baseline.json` and fails when any
file's count **rises**. It is **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 re-record the baseline in the PR that
lowers it. This is the ratchet shape the `DELIBERATE-LITERAL` markers
scattered through the program already anticipate.
## Stated limitation
Classification is by receiver **name**, so a future field named `agent`
that holds a column would be misclassified as a role comparison.
Recorded at the site, and it is precisely why the two classes are
reported separately instead of netted into one figure.
## Verification
- 12/12 new cases
(`packages/engine/src/__tests__/lifecycle-column-census.test.ts`)
- `pnpm test:gate` **71/71**; `pnpm lint` clean
- `pnpm census:lifecycle-columns`, `--json`, and `--strict` all
exercised end to end
- documented in `docs/testing.md`; no production code touched
🤖 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:
@@ -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)
|
||||
|
||||
<!-- FNXC:WorkflowLifecycleColumns 2026-07-30-14:50: added while converting the last of the tracked triage guards. The point of documenting it is the measurement, not the script: the number the workflow-owned-lifecycle program tracked was wrong in three ways at once, and the same mistake is available to any future migration that greps for one string. -->
|
||||
`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
|
||||
|
||||
<!-- FNXC:DashboardAvailability 2026-06-30-23:20: The dashboard needs a supervised restart mode for long-lived remote access sessions. Planning parse failures now surface as retryable session errors instead of causing process-level exits. -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } }) => (`,
|
||||
` <span className={task.column === "triage" ? "is-planning" : "is-live"} />`,
|
||||
`);`,
|
||||
].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);
|
||||
});
|
||||
});
|
||||
344
packages/engine/src/__tests__/lifecycle-column-census.test.ts
Normal file
344
packages/engine/src/__tests__/lifecycle-column-census.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
202
scripts/lib/lifecycle-column-census-ast.mjs
Normal file
202
scripts/lib/lifecycle-column-census-ast.mjs
Normal file
@@ -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)));
|
||||
}
|
||||
161
scripts/lib/lifecycle-column-census-baseline.json
Normal file
161
scripts/lib/lifecycle-column-census-baseline.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
225
scripts/lib/lifecycle-column-census.mjs
Normal file
225
scripts/lib/lifecycle-column-census.mjs
Normal file
@@ -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)));
|
||||
}
|
||||
193
scripts/lifecycle-column-census.mjs
Normal file
193
scripts/lifecycle-column-census.mjs
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user