test(core): ledger the legacy-id collections that gate a live column — the class the census cannot count (#3209)

## What

A population ratchet over **legacy-id collections consulted against a
live column value** (`SOME_SET.has(task.column)`), recorded as 23 sites.

## Why

The census scans `===`/`!==` comparisons. A Set or array literal is a
**definition**, so no census run has ever pointed at one. Three
found-by-hand defects came from that blind spot:

| collection | symptom |
|---|---|
| `GITHUB_TRACKING_EDITABLE_COLUMNS` | operator could not toggle GitHub
tracking **at all** on a renamed board — no error, affordance absent
(#3149) |
| `TIME_INDICATOR_COLUMNS` | wrong elapsed-time indicator on cards |
| `BLOCKER_ESCALATION_COLUMNS` | escalation skipped renamed lanes |

#3149 enumerated the population by hand and concluded *"this is where
the remaining renamed-board defects actually live."* A number in a PR
body rots. This is that enumeration as a ratchet.

## Census

**Unchanged — `AVAILABLE: 0` before and after, 12 documented deferrals
both sides.** This PR converts nothing. It ratchets a class the census
*structurally cannot see*, which is the point: the backlog reading zero
has never meant the lane vocabulary is fully converted, only that the
measurable part is. Recording that plainly instead of claiming a delta
this change does not produce.

## What it claims, and what it deliberately does not

It claims the **population** is the recorded set. It does **not** claim
each site is correct — 20 of the 23 are #3149's assessment ("most are
already correct, either no-flags fallbacks or seed-then-add resolved
sets"), and I did not re-verify them. Blessing sites I have not read is
how a ledger becomes a list of things someone once glanced at. A new
entry fails the test and a human reads that **one** site; that is the
entire mechanism.

## My own detector's pick-work list was 100% false positives

Measured, and the reason this ships with **no candidate list**. The
heuristic "no role-helper call in the file" flagged three sites; all
three were fine:

- `agent-role-policy.ts:32` — a documented **FLAGGED, NOT FIXED**
deferral with its reasoning recorded
- `DocumentsView.tsx:88` — already converted, flags-first; the flags
arrive as a threaded **object**, so a scan for resolver *calls* cannot
see the conversion
- `agent-assignment.ts:118` — a `DELIBERATE-LITERAL` fallback behind an
injected `countsAsAssignmentLoad` callback, reviewed `2026-07-31-05:40`

That is the same failure `--triage`'s pick-work list had before #3194
fixed it, from the same cause: **inferring "unexamined" from the absence
of a pattern rather than from evidence.** A detector that cannot
distinguish "not yet looked at" from "looked at and settled" must not be
pointed at a work queue. It can still hold a population steady, which is
all this does.

## Verification

Mutation-verified in **both** directions — a ledger fails by missing
additions *or* by keeping ghosts:

```
### baseline                                        Tests  4 passed (4)
### MUTATION 1 — new unrecorded gating collection
+   "packages/engine/src/worktree-pool.ts :: NEW_LANE_GATE",
                                                    Tests  1 failed | 3 passed (4)
### MUTATION 2 — recorded site vanishes (ghost)
+   "packages/engine/src/worktree-pool.ts :: managedRenamed",
+   "packages/engine/src/worktree-pool.ts :: managed",
                                                    Tests  2 failed | 2 passed (4)
### restored                                        Tests  4 passed (4)
```

Two anti-vacuity cases guard the detector: it still finds the
collections whose defects motivated the file, and it does **not** claim
plain comparisons (asserted against `self-healing.ts`, dense with column
comparisons and no gating collection) — pulling those in would
double-count a class that already has a gate.

## Flagged, not guessed

- **Line numbers are excluded** from ledger entries — they drift with
unrelated edits and would fail this test for reasons that are not about
lane vocabulary.
- **Comments stripped before scanning:** `TaskDetailModal.tsx` and
`TaskCard.tsx` both quote their own collection by name in FNXC notes
explaining the bug it caused. Counting prose would fire the ledger on
the files that document the hazard most carefully.
- **Stated reach limits** (in-file): only *named* collections consulted
as `.has`/`.includes`; the argument must mention column/lane;
property-reached collections are missed. A miss is a site nobody is
watching — not a false green on a listed site.

No changeset: test-only, behavior-preserving, no published-package
surface.

🤖 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:
gsxdsm
2026-07-31 10:44:08 -07:00
committed by GitHub
parent 215f09d88f
commit bad39e2ca3

View File

@@ -0,0 +1,221 @@
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-17:44 (a ratchet over the class the census cannot count):
THE CENSUS SCANS COMPARISONS. THIS CLASS IS COLLECTIONS.
`lifecycle-column-census.mjs` counts `===`/`!==` against a column. A legacy-id Set or array consulted
with `.has(task.column)` is a DEFINITION, not a comparison, so no census run has ever pointed at one.
That blind spot has now produced three found-by-hand defects:
`GITHUB_TRACKING_EDITABLE_COLUMNS` the operator could not toggle GitHub tracking at all on a
renamed board — no error, the affordance simply absent (#3149)
`TIME_INDICATOR_COLUMNS` cards showed the wrong elapsed-time indicator
`BLOCKER_ESCALATION_COLUMNS` escalation skipped renamed lanes
#3149 enumerated the population by hand and reported "19 such sites ... this is where the remaining
renamed-board defects actually live". A number in a PR body rots. This is that enumeration as a
ratchet, so the next one cannot appear silently.
WHAT THIS FILE CLAIMS, AND WHAT IT DELIBERATELY DOES NOT. It claims the POPULATION is the recorded
set. It does NOT claim each site is correct — 20 of the 23 are #3149's assessment ("most are already
correct, either no-flags fallbacks or seed-then-add resolved sets"), and I did not re-verify them.
Blessing sites I have not read is how a ledger becomes a list of things someone once glanced at. A
new entry fails this test and a human reads that ONE site; that is the whole mechanism.
MY OWN DETECTOR'S PICK-WORK LIST WAS 100% FALSE POSITIVES, measured, and it is why this file has no
"unresolved candidates" assertion. The heuristic "no role-helper call in the file" flagged three
sites; all three were fine:
agent-role-policy.ts:32 a documented FLAGGED-NOT-FIXED deferral with the reasoning recorded
DocumentsView.tsx:88 already converted — flags-first, threaded as an object rather than
called, so a scan for resolver CALLS cannot see the conversion
agent-assignment.ts:118 a `DELIBERATE-LITERAL` fallback behind an injected
`countsAsAssignmentLoad` callback, reviewed 2026-07-31-05:40
That is the same failure `--triage`'s pick-work list had before #3194 fixed it, from the same cause:
inferring "unexamined" from the absence of a pattern rather than from evidence. A detector that
cannot tell "not yet looked at" from "looked at and settled" must not be pointed at a work queue. It
can still hold a population steady, which is all this does.
KNOWN REACH LIMITS, stated so this is not mistaken for coverage of the class:
- Only NAMED collections (`const X = new Set([...])`) consulted as `X.has(col)`/`X.includes(col)`.
An inline `["todo","done"].includes(col)` is invisible.
- The argument must mention column/lane, so `.has(c)` with a short name is missed.
- Collections reached through a property (`CONFIG.columns.has(...)`) are missed.
A miss here is a site nobody is watching, not a false green on a site that is listed.
*/
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const REPO_ROOT = resolve(__dirname, "../../../..");
const SCAN_ROOTS = [
"packages/core/src",
"packages/engine/src",
"packages/dashboard/src",
"packages/dashboard/app",
"packages/cli/src",
];
/** Built-in lane ids. A collection holding two or more is about the default board's vocabulary. */
const LEGACY_IDS = ["triage", "todo", "in-progress", "in-review", "done", "archived", "ideas"];
/**
* The recorded population, as `file :: collectionName`. Line numbers are deliberately excluded —
* they drift with unrelated edits and would make this ledger fail for reasons that are not about
* lane vocabulary at all.
*/
const RECORDED_GATING_SITES: ReadonlySet<string> = new Set([
"packages/core/src/agent-role-policy.ts :: IMPLEMENTATION_TASK_COLUMNS",
"packages/core/src/column-roles.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS",
"packages/core/src/live-agent-count.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS",
"packages/core/src/task-store/branch-group-ops.ts :: satisfiedColumns",
"packages/core/src/task-store/update-task-deps.ts :: refineFrom",
"packages/core/src/workflow-analytics.ts :: LEGACY_ACTIVE_LANES",
"packages/dashboard/app/components/DocumentsView.tsx :: LEGACY_PRE_IMPLEMENTATION_COLUMNS",
"packages/dashboard/app/components/TaskCard.tsx :: TIME_INDICATOR_COLUMNS",
"packages/dashboard/app/components/TaskDetailModal.tsx :: GITHUB_TRACKING_EDITABLE_COLUMNS",
"packages/dashboard/app/hooks/useSessionFiles.ts :: LEGACY_ACTIVE_COLUMNS",
"packages/dashboard/app/hooks/useTasks.ts :: PLANNER_ACTIVITY_COLUMN_IDS",
"packages/dashboard/app/utils/columnRoles.ts :: LEGACY_FIELD_EDITABLE_COLUMN_IDS",
"packages/dashboard/app/utils/columnRoles.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS",
"packages/engine/src/agent-assignment.ts :: LEGACY_ACTIVE_COLUMNS",
"packages/engine/src/agent-reflection.ts :: completedColumns",
"packages/engine/src/ephemeral-worker-manager.ts :: TERMINAL_TASK_COLUMNS",
"packages/engine/src/executor.ts :: activeColumns",
"packages/engine/src/merger.ts :: finalizedColumns",
"packages/engine/src/merger.ts :: sourceTerminal",
"packages/engine/src/mission-execution-loop.ts :: fixTaskTerminalColumns",
"packages/engine/src/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS",
"packages/engine/src/triage.ts :: LEGACY_PLANNER_COLUMN_IDS",
"packages/engine/src/worktree-pool.ts :: managed",
]);
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === "dist" || entry === "__tests__") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else if (/\.tsx?$/.test(full)) yield full;
}
}
/*
Comments stripped first, for the reason every scanner in this series has had to learn: several of
these files quote their own collection by name in an FNXC note explaining the bug it caused.
`TaskDetailModal.tsx` and `TaskCard.tsx` both do. Counting prose would make the ledger fire on the
files that document the hazard most carefully.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
}
/** Named legacy-id collections consulted against a runtime column value. */
function findGatingSites(): string[] {
const found = new Set<string>();
const declPattern =
/(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]{0,120})?=\s*(?:new Set(?:<[^>]*>)?\(\s*)?\[([^\]]{0,400}?)\]/g;
for (const root of SCAN_ROOTS) {
for (const file of walk(join(REPO_ROOT, root))) {
const rel = relative(REPO_ROOT, file).split("\\").join("/");
const source = stripComments(readFileSync(file, "utf8"));
const collections = new Map<string, number>();
for (const decl of source.matchAll(declPattern)) {
const hits = LEGACY_IDS.filter((id) => new RegExp(`["'\`]${id}["'\`]`).test(decl[2])).length;
if (hits >= 2) collections.set(decl[1], hits);
}
if (collections.size === 0) continue;
for (const name of collections.keys()) {
const usePattern = new RegExp(String.raw`\b${name}\.(?:has|includes)\(([^)]{0,60})\)`, "g");
for (const use of source.matchAll(usePattern)) {
/* The argument must look like a live column, not an id or a string constant. */
if (!/column|lane/i.test(use[1])) continue;
found.add(`${rel} :: ${name}`);
}
}
}
}
return [...found].sort();
}
describe("legacy-id collections that gate a live column are a closed population", () => {
it("has no unrecorded gating site", () => {
const unrecorded = findGatingSites().filter((site) => !RECORDED_GATING_SITES.has(site));
expect(unrecorded, [
"",
"A legacy-id collection is gating a live column value, and it is not in the ledger.",
"",
"The lifecycle-column census CANNOT see this: it scans `===`/`!==` comparisons, and a Set or",
"array literal is a definition. Three shipped defects came from exactly this shape, the worst",
"being #3149 — the operator could not toggle GitHub tracking at all on a renamed board.",
"",
"Decide which this is, then record it here:",
" - a RESOLVED site keeping the legacy set as a no-flags fallback -> add it, this is the",
" normal shape (`if (!flags) return LEGACY_SET.has(column)`, roles decide otherwise);",
" - a DELIBERATE literal (an unconverted-caller default, a STATE marker) -> mark it at the",
" site and add it;",
" - genuinely unconverted -> convert it with the role helpers before adding it.",
"",
"Adding a line here is not the fix. Reading the one site is.",
"",
].join("\n")).toEqual([]);
});
/*
The ledger must not rot into names that no longer exist. A stale entry reads as "someone
considered this", which is the decay every ledger in this repo has hit — and it is how a ratchet
quietly stops ratcheting, because the population it compares against is fiction.
*/
it("has no stale ledger entry", () => {
const live = new Set(findGatingSites());
const stale = [...RECORDED_GATING_SITES].filter((site) => !live.has(site)).sort();
expect(stale, [
"",
"A recorded gating site no longer exists — the collection was renamed, converted away, or its",
"file moved. Delete the line. Population shrinking is the goal; a ledger that keeps ghosts",
"cannot tell you what is really left.",
"",
].join("\n")).toEqual([]);
});
/*
ANTI-VACUITY. Both cases above are set differences, and both pass trivially against an empty scan —
a moved directory, a regex that stopped matching, a walker that throws. This pins that the detector
still finds the three sites whose defects motivated the file.
*/
it("still finds the collections whose defects motivated this ledger", () => {
const sites = findGatingSites();
expect(sites).toContain(
"packages/dashboard/app/components/TaskDetailModal.tsx :: GITHUB_TRACKING_EDITABLE_COLUMNS",
);
expect(sites).toContain("packages/dashboard/app/components/TaskCard.tsx :: TIME_INDICATOR_COLUMNS");
expect(sites.length).toBeGreaterThan(15);
});
/*
The paired negative for the detector itself: it must key on the COLLECTION shape, not on any
mention of a legacy id. A file comparing a column directly is the CENSUS's population, and pulling
those in here would double-count a class that already has a gate.
*/
it("does not claim plain comparisons — those belong to the census", () => {
const sites = findGatingSites();
/* `self-healing.ts` is dense with column comparisons and has no gating collection. */
expect(sites.filter((s) => s.startsWith("packages/engine/src/self-healing.ts"))).toEqual([]);
});
});