Files
fusion/scripts/check-plugin-interop-drift.mjs
gsxdsm ccf562f178 gate: compare mirrored INTERFACES too, and delete the dead prop that found (#3034)
> **Re-landing the second half of #3031.** That PR merged into #3029's
branch and only its first commit reached `main` — the arity rule
shipped, the interface rule and its finding did not. Verified on `main`:
the gate reports *"7 mirrored function(s)"* with no interface count, and
the dead prop below is still there.

## What

The arity rule covers exported functions. The same files also mirror
**interfaces**, which is the larger surface — six copies of
`PluginDashboardViewContext` alone.

**One direction only.** A mirror may declare *fewer* properties, and all
six do (6, 8, 7, 7, 3, 6 against the real nine) because a plugin mirrors
the fields it uses. Demanding equality would fail every plugin for not
using everything — which is how a check gets ignored and then deleted. A
property the real type **doesn't have** is the drift that matters: a
rename nobody propagated, where the plugin keeps compiling and reads a
field the host never sends.

## Its first interface run found a live one

```
dashboard-interop.d.ts:67  TaskCardProps.workflowStepNameLookup is not a property of the real TaskCardProps
```

Git history says it **was** one when FN-2466 and FN-7039 added this
threading. The dashboard removed it later; nothing propagated that to
the plugin's hand-written declaration. So the plugin built a lookup map
from `context.workflowSteps` on every render, threaded it through two
components, and handed it to a `TaskCard` with no such prop.

Deleted rather than exempted — a new gate shouldn't ship with a waiver
for its own first finding. Behaviour-preserving: the value never reached
anything.

## Measured on `main`

| check | result |
|---|---|
| population | **7 functions + 10 interfaces across 6 plugins**, all
matching after the deletion |
| control probe | phantom property **caught**; clean tree exits 0 |
| anti-vacuity | now also requires a non-zero *interface* comparison |
| gate's own suite | **5 → 8** |
| dependency-graph suite | 179 green; `tsc` clean |
| other five gates · census | green |

## Running total for this check

Three real drifts, none of which any other instrument reported:

1. `isTaskStuck` stuck at three parameters through the whole lane
conversion (#3003)
2. `taskStuckTimeoutMs?: number` vs the required `number | undefined` —
in **two independent authors'** declarations
3. `workflowStepNameLookup` outliving its removal from `TaskCard`

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 01:43:58 -07:00

233 lines
10 KiB
JavaScript

#!/usr/bin/env node
/*
FNXC:PluginInteropDrift 2026-07-31-07:10:
A PLUGIN'S `dashboard-interop.d.ts` IS A HAND-MAINTAINED COPY OF ANOTHER PACKAGE'S API, and nothing
tied the two together until this check.
Six plugins declare `@fusion/dashboard/...` modules locally and wire them in through tsconfig
`paths`, because the dashboard package ships no consumable types. Those declarations are written by
hand and never verified, so the real function can change and the mirror keeps compiling — against a
signature that no longer exists.
MOTIVATING DEFECT (#3003 / #3028): `isTaskStuck` grew a fourth `columnFlags` parameter during the
lane conversion. `fusion-plugin-dependency-graph`'s mirror kept the three-argument shape, so the
plugin could not pass the argument even deliberately — the compiler said it did not exist. The
graph's stuck indicator answered for the legacy vocabulary on every renamed board, through an entire
conversion programme, and the reason looked like a build-plumbing problem from outside. Measured at
the time: one of five mirrored functions had drifted.
SCOPE, deliberately narrow: PARAMETER COUNT of exported functions. Arity is unambiguous and a
mismatch is always a defect, whereas comparing full types across two files needs a real program and
would produce arguments about structural equivalence — the kind of noise that gets a check ignored.
A mirror the real module does not export at all is also reported: that is a rename nobody propagated.
*/
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const MODULE_PREFIX = "@fusion/dashboard/";
/** `@fusion/dashboard/app/utils/taskStuck` -> `packages/dashboard/app/utils/taskStuck.ts(x)` */
function resolveRealFile(moduleName) {
const rel = moduleName.slice(MODULE_PREFIX.length);
for (const ext of [".ts", ".tsx"]) {
const candidate = join(REPO, "packages/dashboard", rel + ext);
try { readFileSync(candidate); return candidate; } catch { /* try next */ }
}
return null;
}
const paramCounts = (node) => ({
total: node.parameters.length,
required: node.parameters.filter((p) => !p.questionToken && !p.initializer && !p.dotDotDotToken).length,
});
/*
FNXC:PluginInteropDrift 2026-07-31-07:25:
A NON-FUNCTION EXPORT IS NOT A MISSING ONE — the first version reported `TaskCard` as renamed.
`export const TaskCard = memo(TaskCardComponent, ...)` is a value whose parameter list belongs to a
wrapped component, not to the export. Arity is not comparable there, so those are recorded as PRESENT
but not compared. Reporting them would have been a false positive on the very first run, and a check
whose debut finding is wrong does not get a second reading.
*/
export function declaredInterfacesForTest(sourceText, fileName) {
return declaredInterfaces(sourceText, fileName, ts.ScriptKind.TSX);
}
export function exportedFunctions(sourceText, fileName) {
const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const found = new Map();
const isExported = (node) => node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
const visit = (node) => {
if (ts.isFunctionDeclaration(node) && node.name && isExported(node)) {
found.set(node.name.text, paramCounts(node));
} else if (ts.isVariableStatement(node) && isExported(node)) {
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) continue;
const init = decl.initializer;
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
found.set(decl.name.text, paramCounts(init));
} else {
/* Present, but its arity is not the export's — see the note above. */
found.set(decl.name.text, null);
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Declared functions per `declare module "@fusion/dashboard/..."` block. */
function mirroredFunctions(file) {
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const out = [];
const visit = (node) => {
if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name) && node.name.text.startsWith(MODULE_PREFIX)) {
const moduleName = node.name.text;
const walk = (n) => {
if (ts.isFunctionDeclaration(n) && n.name) {
const line = sf.getLineAndCharacterOfPosition(n.getStart()).line + 1;
out.push({ moduleName, name: n.name.text, line, ...paramCounts(n) });
}
ts.forEachChild(n, walk);
};
walk(node);
}
ts.forEachChild(node, visit);
};
visit(sf);
return out;
}
/*
FNXC:PluginInteropDrift 2026-07-31-08:10:
INTERFACES ARE CHECKED ONE DIRECTION ONLY: a mirror may declare FEWER properties, never unknown ones.
A subset is the normal and correct state — a plugin mirrors the handful of context fields it uses,
and all six do exactly that (6, 8, 7, 7, 3, 6 properties against the real nine). Demanding equality
would fail every plugin for the crime of not using everything.
A property the real type does NOT have is the drift that matters: a rename nobody propagated, or a
typo. The plugin keeps compiling and reads a field the host never sends, which is the same silent
failure the function-arity case produced — code that looks wired and receives nothing.
Measured when added: zero across all six mirrors. The rule is here because these files provably
drift, not because they currently do.
*/
function declaredInterfaces(sourceText, fileName, kind) {
const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind);
const out = new Map();
const visit = (node) => {
if (ts.isInterfaceDeclaration(node)) {
const props = new Map();
for (const member of node.members) {
if (member.name && ts.isIdentifier(member.name)) {
props.set(member.name.text, sf.getLineAndCharacterOfPosition(member.getStart()).line + 1);
}
}
out.set(node.name.text, props);
}
ts.forEachChild(node, visit);
};
visit(sf);
return out;
}
/** Interfaces a `declare module` block mirrors, keyed by module then interface name. */
function mirroredInterfaces(file) {
const text = readFileSync(file, "utf8");
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const out = [];
const visit = (node) => {
if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name) && node.name.text.startsWith(MODULE_PREFIX)) {
const moduleName = node.name.text;
const walk = (n) => {
if (ts.isInterfaceDeclaration(n)) {
const props = new Map();
for (const member of n.members) {
if (member.name && ts.isIdentifier(member.name)) {
props.set(member.name.text, sf.getLineAndCharacterOfPosition(member.getStart()).line + 1);
}
}
out.push({ moduleName, name: n.name.text, props });
}
ts.forEachChild(n, walk);
};
walk(node);
}
ts.forEachChild(node, visit);
};
visit(sf);
return out;
}
const mirrors = globSync("plugins/*/src/dashboard-interop.d.ts", { cwd: REPO }).sort();
const problems = [];
let compared = 0;
let comparedInterfaces = 0;
for (const rel of mirrors) {
const file = join(REPO, rel);
for (const decl of mirroredFunctions(file)) {
const realFile = resolveRealFile(decl.moduleName);
if (!realFile) {
problems.push(`${rel}:${decl.line} mirrors ${decl.moduleName}, which resolves to no file in packages/dashboard`);
continue;
}
const exports = exportedFunctions(readFileSync(realFile, "utf8"), realFile);
if (!exports.has(decl.name)) {
problems.push(`${rel}:${decl.line} declares ${decl.name}(), which ${decl.moduleName} does not export`);
continue;
}
const real = exports.get(decl.name);
if (real === null) continue; /* exported, but not as a plain function — arity not comparable */
compared += 1;
if (real.total !== decl.total || real.required !== decl.required) {
problems.push(
`${rel}:${decl.line} ${decl.name}() declares ${decl.total} param(s) (${decl.required} required); `
+ `the real one takes ${real.total} (${real.required} required)`,
);
}
}
for (const decl of mirroredInterfaces(file)) {
const realFile = resolveRealFile(decl.moduleName);
if (!realFile) continue; /* already reported by the function pass */
const real = declaredInterfaces(readFileSync(realFile, "utf8"), realFile, ts.ScriptKind.TSX).get(decl.name);
if (!real) continue; /* the mirror may name a local shape the module does not export */
comparedInterfaces += 1;
for (const [prop, line] of decl.props) {
if (!real.has(prop)) {
problems.push(`${rel}:${line} ${decl.name}.${prop} is not a property of the real ${decl.name}`);
}
}
}
}
/*
ANTI-VACUITY: a resolver change or a rename could leave this walking nothing and reporting success
forever, which is the failure mode a ratchet must not have.
*/
if (mirrors.length === 0 || compared === 0 || comparedInterfaces === 0) {
console.error(`[check-plugin-interop-drift] scanned ${mirrors.length} mirror(s), compared ${compared} function(s) and ${comparedInterfaces} interface(s) — refusing to report success on an empty comparison.`);
process.exit(1);
}
if (problems.length > 0) {
console.error(`\n[check-plugin-interop-drift] plugin interop declarations disagree with the real dashboard API:\n`);
for (const p of problems) console.error(` ${p}`);
console.error(`\nThese files are hand-maintained copies wired in via tsconfig \`paths\`; nothing else checks them.`);
console.error(`Update the declaration to match the real signature — a stale one silently blocks callers`);
console.error(`from passing arguments that exist (#3003).\n`);
process.exit(1);
}
console.log(`[check-plugin-interop-drift] ${compared} mirrored function(s) and ${comparedInterfaces} interface(s) across ${mirrors.length} plugin(s) match the real dashboard API.`);