> **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>
233 lines
10 KiB
JavaScript
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.`);
|