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>
224 lines
10 KiB
JavaScript
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);
|
|
});
|