## 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 -->
83 lines
3.9 KiB
JavaScript
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");
|
|
});
|