Files
fusion/scripts/check-move-target-literals.mjs
gsxdsm 744c01f7fb FN-8657: extract move-target literal counter
Make the move-target literal ratchet directly testable without changing its scan behavior.

- Export the AST-based legacy destination counter for fixture testing
- Cover literal destinations, private moves, and deliberate exemptions

Files changed:
 .../__tests__/check-move-target-literals.test.mjs  | 30 ++++++++-
 scripts/check-move-target-literals.mjs             | 77 +++++++++-------------
 2 files changed, 60 insertions(+), 47 deletions(-)

Fusion-Task-Id: FN-8657

Fusion-Task-Lineage: 5b1f9ac4-6026-4baf-b42c-f41ab0562679

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 21:34:25 -07:00

225 lines
12 KiB
JavaScript

#!/usr/bin/env node
/*
FNXC:MoveTargetLiterals 2026-07-31-17:20 (u12 — #3150's population had no ratchet):
WHAT THIS GUARDS. A `moveTask(id, "in-review")` DESTINATION is a call argument, not a comparison, so
the lifecycle-column census — which parses comparisons — has never counted one. #3150 measured 31 such
targets across four files; they are now 0. Nothing held that at 0, so the population could regrow
silently, which is exactly how the comparison backlog drifted 787 -> 854 while its own gate was
unwired.
WHY IT MATTERS MORE THAN THE COMPARISON BACKLOG. A wrong lane GUARD silently answers "no". A wrong
move TARGET is rejected by `moveTaskInternal` with `TransitionRejectionError: unknown-column`, so on a
board that renamed its review lane, every task finishing implementation THREW instead of reaching
review. Loud, but only at runtime and only on a renamed board — the shape no unit test on the default
board can see.
AST, NOT GREP, and the reason is measured: a comment-naive scan of `self-healing.ts` reports one
remaining hit that is JSDoc prose (`* could call moveTask("in-review")`), and #3150's own SQL count
was 37 by grep against 12 real sites — 25 comments. Comments are not AST nodes, so this cannot
produce that class of false positive in either direction.
ESCAPE HATCH. A leading `DELIBERATE-LITERAL` comment on the enclosing statement exempts a site, for
the #1411 legacy safe-landing path (`recoveryRehome: true`), where the legacy id is the point rather
than an unconverted lane. Marker must be in LEADING comments — the census learned that an inline
marker attaches to the wrong node and is silently ignored.
Report-only by default; `--strict` fails on any change from the baseline, in EITHER direction, so a
drop is re-recorded in the same PR that earns it rather than leaving a stale allowance open.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const BASELINE_PATH = process.env.FUSION_MOVE_TARGET_BASELINE_PATH
?? join(REPO, "scripts", "lib", "move-target-literals-baseline.json");
const LEGACY_COLUMN_IDS = new Set(["triage", "todo", "in-progress", "in-review", "done", "archived"]);
const MOVE_FNS = new Set(["moveTask", "moveTaskInternal"]);
const DELIBERATE_MARKER = "DELIBERATE-LITERAL";
const strict = process.argv.includes("--strict");
const updateBaseline = process.argv.includes("--update-baseline");
const json = process.argv.includes("--json");
export function destinationLiterals(expr) {
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return [expr.text];
if (ts.isConditionalExpression(expr)) {
return [...destinationLiterals(expr.whenTrue), ...destinationLiterals(expr.whenFalse)];
}
if (ts.isParenthesizedExpression(expr)) return destinationLiterals(expr.expression);
/*
FNXC:MoveTargetRatchet 2026-07-31-19:25 (u12 — CAST yes, FALLBACK deliberately no):
Columns are typed `ColumnId`, so `moveTask(id, "done" as ColumnId)` is the NATURAL spelling wherever
the parameter is nominally typed. It was invisible until now, which left the gate weakest exactly
where this codebase is most likely to write a literal.
`??` / `||` / `&&` are NOT unwrapped, and that is a decision rather than an omission. I added them,
ran the tree, and they flagged this:
moveTask(id, (await resolveTaskLifecycleColumns(store, id))?.complete ?? "done", ...)
which is the fail-soft idiom the whole conversion programme is built on — resolve, and fall back to
the legacy id when the workflow is unreadable, exactly as the role helpers degrade. A gate that
demands a DELIBERATE-LITERAL marker on every safe fallback teaches people to sprinkle markers, and a
marker applied by habit is how the next real literal walks through.
So the legacy id AFTER `??` is the SAFE shape and stays unflagged; a legacy id as the WHOLE
destination is the unsafe one and is caught. If a fallback ever needs auditing it wants its own
report, not this ratchet's exit code.
*/
if (ts.isAsExpression(expr) || ts.isSatisfiesExpression?.(expr) || ts.isTypeAssertionExpression?.(expr)) {
return destinationLiterals(expr.expression);
}
return [];
}
function hasDeliberateMarker(node, source) {
for (let cur = node; cur; cur = cur.parent) {
const ranges = ts.getLeadingCommentRanges(source, cur.getFullStart()) ?? [];
for (const r of ranges) {
if (source.slice(r.pos, r.end).includes(DELIBERATE_MARKER)) return true;
}
if (ts.isStatement(cur)) break;
}
return false;
}
/*
FNXC:MoveTargetRatchet 2026-08-01-04:24:
FN-8657 verified the production population at zero, but a zero-only test cannot prove the ratchet still
sees the throw-causing regression. Keep the AST walk callable with an in-memory fixture so tests prove a
literal destination is counted and that only a leading DELIBERATE-LITERAL can exempt it.
The audit found moveTaskInternal confined to moves.ts, its private enforcement point. We still scan it as
stronger-than-required defense-in-depth and pin that behavior here; external callers would not be invisible.
*/
export function countLegacyMoveTargetLiterals(source, file = "fixture.ts") {
if (!source.includes("moveTask")) return 0;
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
let count = 0;
const walk = (node) => {
if (ts.isCallExpression(node)) {
const callee = ts.isPropertyAccessExpression(node.expression)
? node.expression.name.text
: ts.isIdentifier(node.expression) ? node.expression.text : "";
if (MOVE_FNS.has(callee)) {
const destination = node.arguments[1];
const hit = destination && destinationLiterals(destination).find((text) => LEGACY_COLUMN_IDS.has(text));
if (hit && !hasDeliberateMarker(node, source)) count += 1;
}
}
ts.forEachChild(node, walk);
};
walk(sf);
return count;
}
/*
FNXC:MoveTargetRatchet 2026-07-31-19:05 (u12 — the scan ran on IMPORT, so the shape list could not be tested):
Everything below executed at module load and ended in `process.exit`, so importing this file to unit-test
`destinationLiterals` would have run the whole scan and killed the test process. That list has missed FOUR
spellings across three rounds, each found by hand against a throwaway probe file and then discarded,
because there was nowhere to put a regression.
Behaviour-preserving: the CLI body is unchanged, only wrapped so it runs when this file is the entry point
and not when imported. The two pure helpers are hoisted above it and `destinationLiterals` is exported.
*/
const isEntryPoint = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isEntryPoint) {
let files;
try {
files = execSync(
/*
FNXC:MoveTargetRatchet 2026-07-31-22:30 (#3254's finding, same blind spot here):
`--cached --others --exclude-standard` so a BRAND-NEW file is visible before it is committed.
Plain `git ls-files` lists TRACKED files only, so a new file with `moveTask(id, "done")` scored
0 locally and flipped the ratchet the moment it was staged — the author sees a green gate, then
CI disagrees. Changes nothing in CI (nothing is untracked there) and nothing for the tracked
population; it only makes the local reading honest. #3254 made the same change to the census.
*/
"git ls-files --cached --others --exclude-standard 'packages/*/src/**/*.ts' 'packages/*/src/*.ts' 'packages/*/src/**/*.tsx' 'packages/*/app/**/*.ts' 'packages/*/app/**/*.tsx'",
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
).split("\n").map((f) => f.trim()).filter(Boolean)
/* A path can appear under both --cached and --others in some index states; scanning it twice
would double-count its hits against a baseline that expects one. */
.filter((f, i, all) => all.indexOf(f) === i)
.filter((f) => !f.includes("__tests__") && !/\.(test|spec)\.tsx?$/.test(f));
} catch (err) {
/* FAIL CLOSED: an unreadable file list means nothing was checked, which must not read as clean. */
console.error(`check-move-target-literals: could not list files — ${err?.message ?? err}`);
process.exit(1);
}
if (files.length === 0) {
console.error("check-move-target-literals: file list is EMPTY — refusing to report on zero files.");
process.exit(1);
}
const byFile = {};
for (const file of files) {
let source;
try {
source = readFileSync(join(REPO, file), "utf8");
} catch (error) {
/* FNXC:MoveTargetRatchet 2026-07-31-23:55 (#3246 review): an unreadable tracked file made the
count silently short, and this count is a claim about the whole tree. Fail closed. */
console.error(`check-move-target-literals: cannot read tracked file ${file}: ${error?.message ?? error}`);
console.error("check-move-target-literals: refusing to report a count that may be short.");
process.exit(2);
}
const count = countLegacyMoveTargetLiterals(source, file);
if (count > 0) byFile[file] = count;
}
const total = Object.values(byFile).reduce((a, b) => a + b, 0);
if (json) {
console.log(JSON.stringify({ total, byFile }, null, 2));
process.exit(0);
}
console.log(`check-move-target-literals: scanned ${files.length} source files`);
console.log(` moveTask targets that are legacy column literals: ${total}`);
for (const [file, n] of Object.entries(byFile).sort((a, b) => b[1] - a[1])) {
console.log(` ${String(n).padStart(4)} ${file}`);
}
if (total === 0) {
console.log(" POPULATION EMPTY — #3150 measured 31 here; a wrong target THROWS rather than no-ops,");
console.log(" so keep it empty. Use resolveTaskLifecycleColumns / the role helpers for destinations.");
}
const baseline = existsSync(BASELINE_PATH) ? JSON.parse(readFileSync(BASELINE_PATH, "utf8")) : { byFile: {} };
if (updateBaseline) {
writeFileSync(BASELINE_PATH, `${JSON.stringify({ byFile }, null, 2)}\n`);
console.log("check-move-target-literals: baseline re-recorded.");
process.exit(0);
}
if (!strict) process.exit(0);
const allowed = baseline.byFile ?? {};
const problems = [];
for (const [file, n] of Object.entries(byFile)) {
const cap = allowed[file] ?? 0;
if (n > cap) problems.push(` ${file}: ${n} legacy move target(s), baseline allows ${cap}`);
}
for (const [file, cap] of Object.entries(allowed)) {
const n = byFile[file] ?? 0;
if (n < cap) problems.push(` ${file}: ${n} legacy move target(s), baseline allows ${cap} — DROP, re-record it`);
}
if (problems.length > 0) {
console.error("\ncheck-move-target-literals --strict: move-target population DIVERGES from baseline:\n");
console.error(problems.join("\n"));
console.error("\nA legacy move TARGET is rejected on a renamed board (TransitionRejectionError: unknown-column),");
console.error("so this is a runtime throw rather than a silent no-op. Resolve the destination through the role");
console.error("helpers, or add a leading DELIBERATE-LITERAL comment if the legacy id is genuinely the point");
console.error("(the #1411 recoveryRehome safe-landing path). If a count went DOWN, re-record with");
console.error("--strict --update-baseline in the same commit.\n");
process.exit(1);
}
console.log("check-move-target-literals --strict: every file matches its baseline exactly.");
process.exit(0);
}