Files
fusion/scripts/check-sql-column-literals.mjs
gsxdsm 7b68f20501 batch(docs): fold the three workflow-learnings / annotation PRs into one (#2942)
## Family batch — replaces #2926, #2892, #2887

Per the consolidation directive: the u9/e2e **docs family**, folded into
one branch and one CI run. Three PRs, five commits, **five files,
comment and markdown only**.

| folded PR | commits |
|---|---|
| #2892 `docs/union-vs-per-task` | the project union and the per-task
answer are not ranked; date correction |
| #2926 `docs/date-my-measured-claims` | date the measured claims (one
was wrong); date the grep-vs-AST measurement in the SQL gate header |
| #2887 `docs/archived-state-literals` | mark the three archived STATE
literals as deliberate |

Cherry-picked in original order with authorship preserved; all five
applied clean, no conflicts.

## Scope is provably comment-only

```
docs/solutions/workflow-learnings/lifecycle-conversions-that-score-as-wins.md
docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md
packages/core/src/task-store/async-maintenance.ts        ← FNXC DELIBERATE-LITERAL annotation
packages/core/src/task-store/workflow-definitions.ts     ← FNXC DELIBERATE-LITERAL annotation
scripts/check-sql-column-literals.mjs                    ← header prose only
```

Every added line in `packages/` and `scripts/` is inside a comment —
checked by filtering the diff for declarations, conditionals and
returns, which returns nothing. The two core files gain
`DELIBERATE-LITERAL` markers explaining that `'archived'` is a **state**
marker there, not a lane: the sweep collects rows Fusion itself archived
or soft-deleted, so widening to the resolved archived set would pull
live cards into a cleanup pass.

## Verification (scoped, per the directive — not the full suite)

- `pnpm lint` — clean
- `check-sql-column-literals` — exit 0 (the file it annotates)
- `check:lifecycle-columns` — exit 0 (the markers it adds are
census-visible)
- `sync-workflow-ir-callsite-allowlist.test.ts` — 3/3

## A correction worth recording

Mid-fold I saw a changeset, `self-healing.ts` and a test file in `git
diff origin/main..HEAD` and nearly reported the batch as impure. They
were **main's own commits** — `origin/main` advanced between branch
creation and the diff, so the comparison was against a stale base.
Rebasing onto current `main` reduced it to the five files above. Worth
flagging for anyone else folding a family today: with `main` moving this
fast, diff the branch **after** rebasing or the file list will lie to
you.

## Closing the originals

#2926, #2892 and #2887 are superseded by this and are being closed. I
hold no PRs of my own in this family — all mine merged — so this fold is
on behalf of the family rather than a rollup of my own work.

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

---------

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

345 lines
18 KiB
JavaScript

#!/usr/bin/env node
/*
FNXC:LifecycleColumnCensus 2026-07-30-09:30:
FREEZE THE SQL SURFACE — a legacy column id inside a query string is invisible to every other check.
The lifecycle-column census parses TypeScript COMPARISONS. A legacy id inside a SQL string is not a
comparison, it is string data, so the census has never counted these. The inert-seam gate reasons
about parameters and call sites, so it cannot see them either. The surface was uninstrumented.
WHAT IT COST. `cleanupStaleMergeQueueRowsImpl` filtered on `t.column != 'in-review'`. On a board with
a renamed review lane every queued card looked stale, its merge_queue row was deleted, and the card
became unleaseable. Found by the operator reviewing #2819 — in SQL that had already been read past
during that same work, because nothing draws the eye to a literal inside a query.
The analytics group is the quieter half: five sites count `"column" = 'done'`, so on a renamed board
throughput, cycle time, and team dashboards report zero completed work. Nothing errors. Wrong-but-
plausible numbers are the least likely defect for anyone to file.
WHAT THIS DOES. It does NOT fix the existing sites — `resolveProjectColumnsForRoles`
(core/src/project-lane-vocabulary.ts) is the mechanism for that and its migration has an owner (see
issue #2839). This freezes the population so the surface cannot grow while that migration runs: the
baseline records per-file counts, a new file or a higher count fails, and a LOWER count fails too so
the baseline is ratcheted down as sites are migrated rather than silently drifting.
COMMENTS ARE NOT MATCHED, and that is the whole reason this is AST-based. A line-oriented grep for
the same pattern reported 37 hits when this was written (2026-07-30), 25 of them prose quoting
`column === "done"` in an explanatory note. Re-measured 2026-07-31: 38 grep hits against 20 real ones.
The totals drift as conversions land and comments do not — the RATIO is the argument, and it has held
at roughly half. A guard with a 68% false-positive rate teaches its readers to skip it, and this repo already
learned that lesson the expensive way. Comments are not AST nodes, so walking string and template
literals cannot match them at all.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const PACKAGES = join(REPO, "packages");
const BASELINE = join(REPO, "scripts", "lib", "sql-column-literals-baseline.json");
const SKIP_DIRS = new Set(["node_modules", "dist", "__tests__", "__mocks__", "e2e", ".gate-bundle", "coverage"]);
/** The pre-workflow column ids. A query comparing a column to one of these is board-vocabulary-bound. */
const LEGACY_IDS = ["todo", "in-progress", "in-review", "done", "archived", "triage"];
/*
GLOBAL, because the unit of measurement is the COMPARISON, not the literal — two legacy comparisons
in one query must count as two. No file currently has that shape (every matching literal holds
exactly one), so this is defensive rather than a recorded incident; it is the same class as the
one-supplier floor the inert-seam gate had to fix, and cheaper to get right now than to discover.
*/
const COLUMN_REF = `(?:"column"|\\bcolumn)`;
const LEGACY_ID = `'(?:${LEGACY_IDS.join("|")})'`;
/*
FNXC:LifecycleColumnCensus 2026-07-30-20:20 (#2841 review, second round — greptile P1 "IN predicates
bypass the gate"):
`IN (...)` IS A COMPARISON, AND EACH ELEMENT IS ONE.
The operator list was `=`, `!=`, `<>`, so `"column" IN ('in-progress', 'in-review')` contributed
nothing and a second predicate in that form could be added while the baseline stayed green. It is the
same false-negative class as the pre-filters removed in the first round — a shape the pattern simply
did not describe.
`IS DISTINCT FROM` was a sixth-round finding and a live one: `async-merge-coordination.ts` writes
`${schema.project.tasks.column} IS DISTINCT FROM 'in-review'` — the merge-queue stale sweep, one of
the queries this gate exists to freeze — and the operator list did not contain it, so the predicate
counted zero. `IS`/`IS NOT` are included alongside for the same reason: they are the same comparison
wearing different SQL spelling, and enumerating operators one review round at a time is how the last
five holes happened.
The IN arm counts its LEGACY ELEMENTS, not the predicate: two ids in one list is two vocabulary-bound
sites, the same accounting the `=` arm uses when a query holds two comparisons. The list body is
matched loosely (`[^)]*`) so a mixed list — a legacy id beside a resolved one — is still caught, and
the per-element count is taken from the matched text afterwards.
*/
/*
FNXC:LifecycleColumnCensus 2026-07-30-22:20 (#2841 review, third round — greptile P1 "nested IN
expressions evade scanning"):
`[^)]*` STOPS AT THE FIRST `)`, WHICH A NESTED CALL SUPPLIES.
`"column" IN (COALESCE(x, y), 'done')` never reached its legacy id: the leading `[^)]*` halted at
`COALESCE(x, y)`'s closing paren, the id after it was unreachable, and the predicate contributed
nothing. A third false negative of the same family as the first two rounds — a shape the pattern did
not describe — and the reviewer is right that another one could be added with the baseline green.
The IN body now tolerates nested groups TWO levels deep (`LOWER(COALESCE(a, b))` is the realistic
worst case in this codebase). A regex cannot balance arbitrary nesting, and the alternative — matching
the predicate head and extracting the balanced region programmatically — buys a depth nobody writes at
the cost of a second scanner to keep correct. The bound is stated here rather than hidden: at three
levels the gate under-counts again, which is a known limit, not an unknown one.
*/
const IN_BODY = `(?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*`;
export const COMPARISON = new RegExp(
`${COLUMN_REF}\\s*(?:(?:=|!=|<>|IS\\s+(?:NOT\\s+)?DISTINCT\\s+FROM|IS\\s+(?:NOT\\s+)?)\\s*${LEGACY_ID}|(?:NOT\\s+)?IN\\s*\\(${IN_BODY}${LEGACY_ID}${IN_BODY}\\))`,
"gi",
);
/** Legacy ids inside one matched predicate — an `IN` list can hold several. */
const LEGACY_ID_GLOBAL = new RegExp(LEGACY_ID, "gi");
/** How many vocabulary-bound sites one matched predicate represents. */
export function comparisonWeight(match) {
LEGACY_ID_GLOBAL.lastIndex = 0;
return (match.match(LEGACY_ID_GLOBAL) ?? []).length;
}
function* walk(dir) {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) yield* walk(full);
else if (/\.tsx?$/.test(full) && !/\.d\.ts$/.test(full)) yield full;
}
}
/*
FNXC:LifecycleColumnCensus 2026-07-30-19:10 (#2841 review — greptile x2 + coderabbit x2, one root cause):
THE PRE-FILTERS WERE THE HOLE, SO THEY ARE GONE.
Four findings arrived against three lines and all reduce to the same mistake: deciding whether to RUN
the comparison regex, using cheaper patterns that disagree with it.
- A FILE-LEVEL `SQL_SHAPE.test(source)` short-circuit skipped whole files. A file holding only a
clause fragment (`"column" = 'done'`) has no SELECT/WHERE anywhere, so a new forbidden site could
be added to it and the gate passed. The exact shape the fragment carve-out below was added for,
reintroduced one level up.
- `BARE_CLAUSE` is anchored `^...$`, so a qualified or compound fragment — `t."column" = 'done'`,
`("column" = 'done' OR active = 1)` — matched neither it nor `SQL_SHAPE`, and the comparison never
ran.
- `node.getText()` returns SOURCE text, so a double-quoted TypeScript string spells the identifier
`\"column\"` with the backslashes intact, and every pattern here expects the decoded `"column"`.
A gate whose false-NEGATIVES are this easy to construct is worse than no gate, because the baseline it
prints reads as coverage. The fix is to stop pre-filtering: run `COMPARISON` — which is already
unanchored and already the definition of a forbidden site — over the DECODED text of every string and
template literal. One pattern, one answer, nothing to disagree with.
THE FALSE-POSITIVE ARGUMENT SURVIVES INTACT, because it never depended on the pre-filters: comments
are not AST nodes, so walking literals cannot match prose no matter how permissive the pattern is.
That is what makes dropping them safe.
`SQL_SHAPE` and `BARE_CLAUSE` are deleted rather than left unused — an unused pattern in a gate is an
invitation to re-add a filter that uses it.
*/
/**
* The DECODED content of a string or template literal, or null for any other node.
*
* `.text` is decoded (`\"` becomes `"`); `.getText()` is not.
*
* FNXC:LifecycleColumnCensus 2026-07-30-20:35 (#2841 review, second round — greptile P1
* "interpolated columns disappear during scanning"):
*
* A DRIZZLE COLUMN REFERENCE IS AN INTERPOLATION, AND DROPPING IT DROPPED THE WHOLE PREDICATE.
*
* The first version joined only the STATIC spans, on the reasoning that an interpolated expression
* cannot be part of a matched comparison. That is exactly backwards for the dominant production
* shape: a Drizzle template puts the COLUMN in the hole and the legacy id in the static text, so
* `${schema.project.tasks.column} != VALUE` joined to text with no column identifier in it and
* matched nothing. The merge-queue and self-healing queries this gate exists to freeze are written
* this way, so it was blind on its primary target.
*
* An interpolation that NAMES a column is therefore rendered as the literal token `"column"` — the
* spelling the pattern already looks for — and every other interpolation becomes a NUL sentinel.
* A space would not do: `\`"column" = ${expr}'done'\`` joins to `"column" = 'done'`, a comparison
* that is not in the source. NUL cannot appear inside any pattern here, so it breaks the splice.
*
* The test is the expression's TRAILING property, not a resolved type: this is a standalone script
* with no type-checker, and an AST gate earns its place by staying cheap. A false positive costs one
* baseline entry; the false NEGATIVE it replaces cost the gate its meaning on its own target files.
*/
/*
FNXC:LifecycleColumnCensus 2026-07-30-17:25 (#2841 review, fourth round — greptile P1
"bracket-access columns evade scanning"):
`schema.project.tasks["column"]` IS THE SAME REFERENCE WRITTEN DIFFERENTLY.
The dot form was the only one matched, so an element-access reference fell through to the NUL
sentinel and its predicate vanished — the identical blindness the static-span join had, reachable by
changing punctuation. Drizzle accepts both spellings and a formatter or a reserved-word column can
produce the bracket one.
Both quote styles and a trailing `!`/`?` are tolerated for the same reason the rest of this scanner
is permissive: the cost of a false positive is one baseline entry, and the cost of a false negative
is a gate that reads as coverage.
The `!` arrived as its own finding (#2841 review, fifth round) because the previous version DOCUMENTED
tolerating it and did not implement it — the comment described the intent and the regex described the
behaviour, and only one of them was executable. A comment that overstates a guard is worse than none:
it is the thing a reader checks instead of the code.
*/
const COLUMN_PROPERTY = /(?:(?:^|\.)column|\[\s*["'`]column["'`]\s*\])[!?]*$/;
export function literalText(node) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isTemplateExpression(node)) {
const parts = [node.head.text];
for (const span of node.templateSpans) {
const expression = span.expression.getText().trim();
parts.push(COLUMN_PROPERTY.test(expression) ? '"column"' : "\u0000");
parts.push(span.literal.text);
}
return parts.join("");
}
return null;
}
/** Per-file counts of SQL literals comparing a task column to a legacy id. */
/*
FNXC:LifecycleColumnCensus 2026-07-30-15:10:
`--list` prints every match, because a baseline number cannot be reviewed.
This gate reported "14 sites" for days and the real population was 31 — the gap was five classes of
false negative, and the one that mattered was found by asking "why is the merge-queue query, the
reason this check exists, not in the output?". That question is unanswerable against a count. A tool
that freezes a population has to be able to show it, or its own number is the only evidence anyone
has for what it covers.
*/
const LIST = process.argv.includes("--list");
const matches = [];
function scan() {
const counts = {};
for (const file of walk(PACKAGES)) {
const source = readFileSync(file, "utf8");
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let hits = 0;
const visit = (node) => {
const text = literalText(node);
if (text !== null) {
COMPARISON.lastIndex = 0; // a /g regex carries state between calls
for (const match of text.match(COMPARISON) ?? []) {
hits += comparisonWeight(match);
if (LIST) {
const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1;
const rel = relative(REPO, file).split("\\").join("/");
matches.push(` ${rel}:${line} ${match.replace(/\s+/g, " ").trim()}`);
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
if (hits > 0) counts[relative(REPO, file).split("\\").join("/")] = hits;
}
return counts;
}
/*
FNXC:LifecycleColumnCensus 2026-07-30-21:00 (#2841 review, second round):
GUARDED ENTRY POINT, so importing this module does not RUN the gate.
`check-sql-column-literals.test.mjs` imports `COMPARISON` and `literalText` to test the matcher
directly. Without this guard the import executed the whole scan, printed the gate's report, and called
`process.exit(1)` — so the test file failed for the gate's reasons rather than its own, and while the
gate happened to be green it passed for reasons unrelated to what it asserts. A test that can be made
to pass or fail by unrelated repo state is not a test.
*/
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const found = scan();
if (LIST) {
for (const line of matches.sort()) console.log(line);
console.log(`\n[check-sql-column-literals] ${matches.length} match(es) in ${Object.keys(found).length} file(s).`);
process.exit(0);
}
if (process.argv.includes("--update-baseline")) {
writeFileSync(BASELINE, `${JSON.stringify(found, null, 2)}\n`);
const total = Object.values(found).reduce((a, b) => a + b, 0);
console.log(`[check-sql-column-literals] baseline written: ${total} site(s) in ${Object.keys(found).length} file(s)`);
process.exit(0);
}
let baseline;
try {
baseline = JSON.parse(readFileSync(BASELINE, "utf8"));
} catch {
console.error("[check-sql-column-literals] missing baseline; run with --update-baseline");
process.exit(1);
}
const problems = [];
for (const [file, count] of Object.entries(found)) {
const allowed = baseline[file] ?? 0;
if (count > allowed) {
problems.push(` ${file}: ${count} SQL column literal(s), baseline allows ${allowed}`);
}
}
/*
FNXC:SqlColumnLiteralRatchet 2026-08-02-06:40 (a DROP now tightens instead of failing the merge gate):
A stale allowance is still rot — a migrated site that leaves its entry behind is a slot the surface
can regrow into. But hard-failing on it put THIS CHECK, which runs inside `pnpm test:gate`, into a
state where one converting PR blocked every other worker's PR until someone re-recorded by hand.
Observed twice: `team-analytics.ts` 6 -> 3 took the gate down, and the lifecycle census hit the same
shape earlier from a merge wave that dropped eleven files at once.
The census already resolved this exact trade-off and its reasoning applies here with MORE force,
because that ratchet is not in the blocking lane and this one is (docs/testing.md):
"the drop is almost never the failing author's to fix ... A permanently-red gate is a bigger hole
than a stale allowance, because it gets ignored and then nothing is guarded at all."
So a drop now rewrites the baseline downward, says what it lowered, and exits 0. The RISE check —
the actual purpose, "no new SQL column literals" — is untouched and still fails hard.
The rewritten file must be COMMITTED; in CI the write is discarded with the runner, which is why the
gate goes green rather than silently passing a stale allowance.
*/
const tightened = [];
for (const [file, allowed] of Object.entries(baseline)) {
const count = found[file] ?? 0;
if (count < allowed) tightened.push({ file, allowed, count });
}
if (problems.length > 0) {
console.error("\n[check-sql-column-literals] SQL column-literal population changed:\n");
for (const line of problems.sort()) console.error(line);
console.error(
"\nA legacy column id inside a query string is invisible to the lifecycle census and to the\n"
+ "inert-seam gate. Resolve the lane instead — `resolveProjectColumnsForRoles(store, roles)` in\n"
+ "core/src/project-lane-vocabulary.ts returns the column set for a role across all workflows.\n"
+ "If a count went DOWN, re-record the baseline in the same commit.\n",
);
process.exit(1);
}
if (tightened.length > 0) {
writeFileSync(BASELINE, `${JSON.stringify(found, null, 2)}\n`);
console.log("\n[check-sql-column-literals] baseline TIGHTENED — fewer literals than it allowed\n");
for (const { file, allowed, count } of tightened.sort((a, b) => a.file.localeCompare(b.file))) {
console.log(` ${file}: allowed ${allowed}, now ${count}`);
}
console.log(
"\nThe baseline has been rewritten downward. COMMIT IT so the allowance cannot be regrown into;\n"
+ "in CI this write is discarded with the runner, which is why the gate is green and not silent.\n",
);
process.exit(0);
}
const total = Object.values(found).reduce((a, b) => a + b, 0);
console.log(`[check-sql-column-literals] ${total} known SQL column literal(s), none added.`);
}