Files
fusion/scripts/__tests__/lifecycle-census-membership-and-switch.test.mjs
gsxdsm 301bd8ed1e fix(census): detect membership and switch column guards, which could land silently (#3247)
## What

The census prints **"a new guard cannot land silently"** next to a zero.
That claim was true only for the guard form it happened to parse. This
closes the two it could not see. No product change.

The comparison walk visits `BinaryExpression` only, so neither of these
was visible:

```ts
["done", "archived"].includes(task.column)
switch (task.column) { case "todo": ... }
```

Both are lifecycle-column guards by any reading.

## How I found it

By applying this program's own rule — **break the guard on purpose** —
to the guard itself. I staged a probe file with five guard forms and
measured which moved the count:

| form | counted before |
|---|---|
| `t.column === "todo"` | ✅ |
| `t.column !== "in-review"` | ✅ |
| `["done","archived"].includes(t.column)` | ❌ |
| `switch (t.column) { case "triage": }` | ❌ |
| SQL string `"column" = 'done'` | ❌ (separate gate owns this) |

A worker converting a `===` chain into an array membership would have
scored the conversion **and kept the guard**.

*(The first probe run was itself invalid — the file was untracked and
the census enumerates git-tracked files, so the scanned count stayed at
1961 and nothing was measured. Staging it moved the scan to 1962.
Checking the scanned count is what caught that.)*

## The near-miss worth reading

My first implementation counted **unless** the receiver looked like a
role or status — mirroring the `===` walk. On the real tree it reported
**7 column guards**, and I nearly published that as a hidden backlog.

Six were false: `switch (eventName)`, `switch (state)`, `switch (event)`
— event and state enums routinely carry `case "done"` / `case
"archived"`. Landing it would have injected six phantom guards into a
backlog the ratchet treats as zero, and `--strict` would then have
**failed every other worker's PR**.

So the new walks require a **positive** column signal instead. That
regression is pinned by a test asserting all three receivers stay
uncounted.

## Measured

```
real repo, before and after:  COLUMN guards 0, STATUS 185   (no false positives)
staged probe:                 2 detected before -> 4 after
new tests:                    6/6 pass; 3 FAIL with the extension reverted
existing lifecycle-census test: 9/9 still green
lint clean; census --strict passes; fnxc-future-dates: none added
```

## Known limit, stated rather than left to be discovered

The positive signal is the receiver **name**, so `switch (column.id)` —
a `Column` object rather than a task's column — is **not** counted. That
is a real guard shape and it is deliberately out of scope: widening to
reach it is exactly what produced the six false positives, so it needs
its own discrimination rather than a looser regex. Flagged here so the
next person extends it deliberately instead of assuming coverage.

## Why this and not another conversion PR

The conversion queue has been genuinely empty for several cycles —
census 0, 116 resolver sites unchanged across four commits, every site
blinded and pinned. The remaining risk in this program was never another
literal; it was that **the instrument defining "done" could not see two
of the shapes it claims to protect against**. A zero from a detector
with blind spots is the exact failure this phase has spent its time
documenting.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added lifecycle-column guard detection for array membership checks and
`switch` cases.
* Recognizes supported column receiver names and classifies findings
consistently with existing guards.
* Ignores status, event, and state receivers, and avoids duplicate trait
fallback findings.

* **Tests**
* Added coverage for membership checks, `indexOf`, `switch` guards, and
deliberate-literal suppression.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 14:48:20 -07:00

83 lines
3.9 KiB
JavaScript

/*
FNXC:LifecycleColumnCensus 2026-07-31-21:30:
THE CENSUS PRINTED "A NEW GUARD CANNOT LAND SILENTLY" NEXT TO A ZERO, AND TWO GUARD FORMS COULD.
The comparison walk only visits BinaryExpression, so these were invisible:
["done", "archived"].includes(task.column)
switch (task.column) { case "todo": ... }
Both are lifecycle-column guards by any reading. Measured with a staged probe file: of five guard
forms injected, only the two `===`/`!==` ones moved the count. A worker converting a `===` chain into
an array membership would have scored the conversion and kept the guard.
WHY THE NEW WALKS DEMAND A POSITIVE COLUMN SIGNAL, unlike the `===` walk which counts unless the
receiver looks like a role or status: switch statements over event and state enums routinely carry
`case "done"` / `case "archived"`. A count-unless-excluded rule reported SEVEN guards in the tree, of
which six were `switch (eventName)`, `switch (state)` and `switch (event)` — phantom debt injected
into a backlog the ratchet treats as zero, and `--strict` would then have failed every other worker's
PR. The last case below is that regression, pinned.
KNOWN LIMIT, stated rather than discovered later: the positive signal is the receiver NAME, so
`switch (column.id)` — a Column object rather than a task's column — is not counted. That is a real
guard shape and it is deliberately out of scope here; widening to it is what produced the six false
positives, so it needs its own discrimination rather than a looser regex.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { findComparisons } from "../lib/lifecycle-column-census-ast.mjs";
const columnGuards = (src) =>
findComparisons("t.ts", src).filter((f) => f.kind === "column").map((f) => f.columnId);
const kinds = (src) => findComparisons("t.ts", src).map((f) => f.kind);
test("array membership over legacy column ids counts as one column guard", () => {
assert.deepEqual(
columnGuards('const f = (t) => ["done", "archived"].includes(t.column);'),
["done"],
"one .includes site is ONE guard — emitting one finding per legacy id would inflate the backlog",
);
});
test("indexOf spelling counts the same as includes", () => {
assert.deepEqual(columnGuards('const f = (t) => ["in-review"].indexOf(t.column) >= 0;'), ["in-review"]);
});
test("a switch over the column with a legacy case counts as one column guard", () => {
assert.deepEqual(
columnGuards('const f = (t) => { switch (t.column) { case "todo": return 1; case "done": return 2; default: return 0; } };'),
["todo"],
"one switch is ONE guard however many legacy cases it lists",
);
});
test("membership over STATUS values is not a column guard", () => {
const src = 'const f = (t) => ["queued", "pending"].includes(t.status ?? "");';
assert.deepEqual(columnGuards(src), [], "status vocabulary must not enter the column backlog");
assert.ok(kinds(src).every((kind) => kind !== "column"));
});
test("switch over an event or state enum is NOT a column guard, even with overlapping case ids", () => {
/*
The regression that made the positive-signal rule necessary. These three shapes exist in the tree
today and every one of them carries a legacy column id as a case label.
*/
for (const receiver of ["eventName", "state", "event"]) {
const src = `const f = (x) => { switch (x.${receiver}) { case "done": return 1; case "archived": return 2; default: return 0; } };`;
assert.deepEqual(columnGuards(src), [], `switch (x.${receiver}) must not be counted as a column guard`);
}
});
test("a DELIBERATE-LITERAL marker still excuses the new forms", () => {
const src = [
"const f = (t) => {",
" /* DELIBERATE-LITERAL: legacy board only. */",
' return ["done"].includes(t.column);',
"};",
].join("\n");
assert.deepEqual(columnGuards(src), [], "the new walks must honour the same escape hatch as the comparison walk");
});