Files
fusion/scripts/__tests__/check-sql-column-literals.test.mjs
gsxdsm ef50244234 feat(gate): freeze the SQL column-literal surface — 30 sites, none may be added (#2841)
Instruments a surface no existing check can see. Follows #2839, and
**corrects the count I reported there** (12 → 14).

## Why it was invisible

The lifecycle census parses TypeScript **comparisons**; a legacy id
inside a SQL string is string data. The inert-seam gate reasons about
parameters and call sites. Neither has ever looked here.

**What it cost:** `cleanupStaleMergeQueueRowsImpl` filtered on `t.column
!= 'in-review'`, so on a renamed board every queued card looked stale,
its `merge_queue` row was deleted, and the card became **unleaseable**.
The operator found it reviewing #2819 — in SQL I had already read past
during that same work.

The quieter half is analytics: five sites count `"column" = 'done'`, so
throughput, cycle time, and team dashboards report **zero completed
work** on a renamed board. Nothing errors, which is why nobody files it.

## What this does, and does not do

It does **not** fix the sites. `resolveProjectColumnsForRoles` is the
mechanism and its migration has an owner (#2839). This freezes the
population so the surface cannot grow underneath that migration: a new
file or a higher count fails, **and a lower count fails too** — so the
baseline ratchets down as sites migrate rather than leaving slots to
silently regrow into. That is the same rot as an allow-list entry for a
deleted function, which this repo already hit once.

AST-based, deliberately: a line grep for the same pattern reports **37**
hits, **25 of them prose** quoting `column === "done"` in explanatory
notes. A guard that is 68% false positives trains its readers to skip it
— a lesson this program has already paid for.

## Two corrections found by mutation-testing my own gate

**1. Clause fragments were missed.** Requiring a SQL keyword *in the
same literal* skipped `team-analytics.ts`, which builds
`["assignedAgentId IS NOT NULL", `"column" = 'done'`, ...]` and joins
them into a `WHERE` later. That fragment is as vocabulary-bound as any
full query but contains no keyword. Fixing it took the population **12 →
14**, so the number I put on #2839 was low.

**2. My first mutation test proved a direction it had not.** I replaced
the first textual occurrence in a file — which was inside a **comment**
— and read the unchanged count as the scanner being broken. The scanner
was right; my test was wrong. All three directions are now driven
against real SQL:

| mutation | result |
|---|---|
| add a full query with a legacy comparison | `3 SQL column literal(s),
baseline allows 2` |
| add a bare clause **fragment** (no keyword) | caught — same failure |
| migrate one away (count drops) | `1 site(s) now, baseline still allows
2 — re-record it` |
| restore | exit 0 |

I am flagging that second one because it is the exact failure mode this
program keeps finding: a green result read as evidence when the
experiment was invalid.

## Verification

`pnpm test:gate` green with the new check in it · lint 0 · single AST
pass. Wired into `test:gate` and both `pretest` hooks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

## Summary by CodeRabbit

* **Bug Fixes**
* Added automated checks to detect increases in legacy SQL column
literals.
* Added baseline tracking to ensure known SQL literal counts do not
regress.

* **Tests**
* Expanded pre-test and gated verification steps with SQL literal and
mock completeness checks.
  * Updated test validation workflows to enforce the new safeguards.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:06:24 -07:00

224 lines
10 KiB
JavaScript

/*
FNXC:LifecycleColumnCensus 2026-07-30-19:30 (#2841 review — four findings, one root cause):
EACH BLIND SPOT THE REVIEW FOUND, PINNED AS A CASE.
The gate's first version decided whether to RUN its comparison regex, using two cheaper patterns that
disagreed with it — a file-level SQL-keyword short-circuit and an anchored whole-string clause match.
Every finding was a false NEGATIVE constructible from that disagreement, which is the worst failure
mode a gate has: the baseline it prints reads as coverage while a new forbidden site walks past.
The fix was to delete the pre-filters and run one unanchored pattern over the DECODED text of every
string and template literal. These cases are the proof, and they are written as the four shapes rather
than as one generic case so a future "optimisation" that reintroduces a pre-filter fails on the
specific shape it breaks.
The last case is the paired negative. A pattern permissive enough to catch all four must still not
match a comparison against a column id that is not legacy — otherwise the gate would freeze converted
code too, and its baseline would stop meaning anything.
*/
import test from "node:test";
import assert from "node:assert/strict";
import ts from "typescript";
import { COMPARISON, comparisonWeight, literalText } from "../check-sql-column-literals.mjs";
/** Count forbidden comparisons the way the scanner does: over decoded literal text. */
function hits(source) {
const sf = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let total = 0;
const visit = (node) => {
const text = literalText(node);
if (text !== null) {
COMPARISON.lastIndex = 0;
/* Weighted exactly as the scanner counts: an IN list of two legacy ids is two sites. */
for (const match of text.match(COMPARISON) ?? []) total += comparisonWeight(match);
}
ts.forEachChild(node, visit);
};
visit(sf);
return total;
}
test("a bare clause fragment in a file with NO SQL keyword anywhere is still caught", () => {
/*
coderabbit's finding. The old file-level `SQL_SHAPE.test(source)` skipped the whole file before the
clause pattern could run, so a forbidden site could be added to any file holding only fragments.
*/
assert.equal(hits("const clauses = [`\"column\" = 'done'`];"), 1);
});
test("a QUALIFIED fragment is caught", () => {
/* greptile's finding, and the one that was live in the repo: `workflow-analytics.ts` builds
`t."column" = 'done'`, which the anchored whole-string pattern could not match. */
assert.equal(hits("const clauses = [`t.\"column\" = 'done'`];"), 1);
});
test("a COMPOUND fragment is caught", () => {
assert.equal(hits("const c = [`(\"column\" = 'done' OR active = 1)`];"), 1);
});
test("an ESCAPED identifier in a double-quoted TypeScript string is caught", () => {
/*
greptile's second finding. `node.getText()` returns SOURCE text, where the identifier is spelled
`\"column\"` with backslashes intact; every pattern expects the decoded `"column"`. Reading `.text`
is what closes it — this case fails against a `getText()` implementation.
*/
assert.equal(hits(`const q = "\\"column\\" = 'done'";`), 1);
});
test("two forbidden comparisons in one literal count as two", () => {
/* The unit of measurement is the comparison, not the literal. */
assert.equal(hits("const q = `WHERE \"column\" = 'done' OR \"column\" = 'archived'`;"), 2);
});
test("a comparison against a NON-legacy column id is NOT matched", () => {
/* The paired negative: a converted board's own vocabulary must not be frozen by this gate. */
assert.equal(hits("const q = `WHERE \"column\" = 'shipped'`;"), 0);
});
test("prose in a comment is never matched, whatever the pattern permits", () => {
/*
The property that made deleting the pre-filters safe. Comments are not AST nodes, so the walk cannot
reach them — a line-oriented grep for the same pattern reported a 68% false-positive rate on this
repo, which is what the AST approach exists to avoid.
*/
assert.equal(hits(`// this note mentions "column" = 'done' in prose\nconst x = 1;`), 0);
});
/*
FNXC:LifecycleColumnCensus 2026-07-30-20:50 (#2841 review, SECOND round — two more false negatives):
The first round removed pre-filters that disagreed with the pattern. These two are shapes the pattern
never described at all, and the second is the more serious: it was blind on the gate's own primary
target.
*/
test("an IN list is a comparison, and each legacy element counts", () => {
/*
greptile: the operator list was `=`, `!=`, `<>`, so `"column" IN ('in-progress', 'in-review')`
contributed nothing and a second predicate in that form could be added with the baseline green.
Two ids in one list is two vocabulary-bound sites — the same accounting the `=` arm uses for two
comparisons in one query. `team-analytics.ts` and `workflow-analytics.ts` each held one, unseen.
*/
assert.equal(hits("const q = `WHERE \"column\" IN ('in-progress', 'in-review')`;"), 2);
});
test("a NOT IN list is caught too", () => {
assert.equal(hits("const q = `WHERE \"column\" NOT IN ('done')`;"), 1);
});
test("an IN list MIXING a legacy id with a resolved one still counts the legacy one", () => {
/* The list body is matched loosely so a partially-migrated predicate is not silently exempted. */
assert.equal(hits("const q = `WHERE \"column\" IN ('shipped', 'done')`;"), 1);
});
test("a Drizzle template whose COLUMN is interpolated is caught", () => {
/*
greptile, and the finding that mattered most: production writes
`sql\`${schema.project.tasks.column} != 'archived'\``, putting the column in the interpolation hole
and the legacy id in the static text. Joining only the static spans produced ` != 'archived'` — no
column identifier, no match. The merge-queue and self-healing queries this gate was built to freeze
are written exactly this way, so it was blind on its own primary target; enabling this shape
revealed five previously-invisible files.
*/
const src = "const q = sql`${schema.project.tasks.column} != 'archived'`;";
assert.equal(hits(src), 1);
});
test("a NON-column interpolation does not splice two fragments into a false match", () => {
/*
The paired negative for the join. Non-column holes become a SPACE rather than nothing, so the text
on either side cannot be glued into a comparison that is not in the source.
*/
assert.equal(hits("const q = sql`WHERE \"column\" = ${someExpr}'done'`;"), 0);
});
/*
FNXC:LifecycleColumnCensus 2026-07-30-22:30 (#2841 review, THIRD round — greptile P1):
The third false negative of the same family: a shape the pattern did not describe. `[^)]*` stops at
the first `)`, which any nested call supplies, so the legacy id after it was unreachable.
*/
test("an IN list with a NESTED call before the legacy id is caught", () => {
assert.equal(hits("const q = `WHERE \"column\" IN (COALESCE(x, y), 'done')`;"), 1);
});
test("an IN list nested TWO levels deep is caught", () => {
/* The realistic worst case in this codebase, and the documented bound of the pattern. */
assert.equal(hits("const q = `WHERE \"column\" IN (LOWER(COALESCE(a, b)), 'archived')`;"), 1);
});
test("a nested IN list of only NON-legacy ids is still not matched", () => {
/* The paired negative: tolerating nesting must not turn every IN predicate into a hit. */
assert.equal(hits("const q = `WHERE \"column\" IN (COALESCE(x, y), 'shipped')`;"), 0);
});
/*
FNXC:LifecycleColumnCensus 2026-07-30-17:30 (#2841 review, FOURTH round — greptile P1):
The same blindness as the static-span join, reachable by changing punctuation: a column reference
written with brackets rather than a dot fell through to the NUL sentinel and its predicate vanished.
*/
test("a bracket-access column reference in a Drizzle template is caught", () => {
assert.equal(hits('const q = sql`${schema.project.tasks["column"]} != \'archived\'`;'), 1);
});
test("a backtick bracket-access reference is caught", () => {
assert.equal(hits("const q = sql`${t[`column`]} = 'done'`;"), 1);
});
test("a bracket access to a DIFFERENT property is not treated as a column", () => {
/* The paired negative: widening to any bracket access would make every interpolation a column. */
assert.equal(hits('const q = sql`${schema.project.tasks["title"]} = \'done\'`;'), 0);
});
/*
FNXC:LifecycleColumnCensus 2026-07-30-18:10 (#2841 review, FIFTH round — greptile P1):
The previous round's comment claimed a trailing `!`/`?` was tolerated; the regex did not implement it.
A comment that overstates a guard is worse than none — it is the thing a reader checks instead of the
code — so the behaviour is pinned rather than described.
*/
test("a non-null-asserted bracket reference is caught", () => {
assert.equal(hits('const q = sql`${schema.project.tasks["column"]!} != \'archived\'`;'), 1);
});
test("a non-null-asserted dot reference is caught", () => {
assert.equal(hits("const q = sql`${schema.project.tasks.column!} = 'done'`;"), 1);
});
test("an optional-chained reference is caught", () => {
assert.equal(hits("const q = sql`${t?.column} = 'done'`;"), 1);
});
test("a non-null assertion on a DIFFERENT property is still not a column", () => {
assert.equal(hits('const q = sql`${schema.project.tasks["title"]!} = \'done\'`;'), 0);
});
/*
FNXC:LifecycleColumnCensus 2026-07-30-19:20 (#2841 review, SIXTH round — greptile P1):
A LIVE one, unlike rounds four and five. `async-merge-coordination.ts` writes
`${schema.project.tasks.column} IS DISTINCT FROM 'in-review'` — the merge-queue stale sweep, one of
the very queries this gate exists to freeze — and the operator list did not contain that spelling, so
the predicate counted zero. `IS` / `IS NOT` are covered alongside it rather than waiting for a
seventh round to find them.
*/
test("IS DISTINCT FROM is a comparison — the live merge-queue predicate", () => {
assert.equal(hits("const q = sql`${schema.project.tasks.column} IS DISTINCT FROM 'in-review'`;"), 1);
});
test("IS NOT DISTINCT FROM is caught", () => {
assert.equal(hits("const q = sql`${t.column} IS NOT DISTINCT FROM 'done'`;"), 1);
});
test("IS / IS NOT are caught", () => {
assert.equal(hits("const q = `WHERE \"column\" IS 'done'`;"), 1);
assert.equal(hits("const q = `WHERE \"column\" IS NOT 'archived'`;"), 1);
});
test("IS DISTINCT FROM a NON-legacy id is not matched", () => {
assert.equal(hits("const q = sql`${t.column} IS DISTINCT FROM 'shipped'`;"), 0);
});