Files
fusion/scripts/check-plugin-interop-drift.mjs
gsxdsm a460a9bbc0 fix(plugins,dashboard): the dependency graph drew every card with the LEGACY lane vocabulary (#3029)
## The third producer of unflagged cards — the one a host-side fix could
not reach

#3025 fixed the two producers that go through `renderTaskCard`.
`GraphTaskNode` is a third: it imports `TaskCard` **directly** through
the plugin's interop shim, so that fix bypassed it and every role helper
inside a graph card kept reading the legacy ids.

The same component also called the stuck predicate without its flags:

```ts
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);   // no columnFlags
```

so `isWipColumnRole` fell back to the literal and **no card in the graph
could ever be stuck on a renamed board**. Because `isStuck` gates
`isActive`, a wedged card rendered with the **active** styling — the
graph reported *"running"* about a task that had not moved in hours,
while the main board showed the same card as stuck.

That asymmetry between two views of one task is the defect, and it is
what the new test pins.

## One cause, so one fix

Both symptoms came from the same gap: `PluginDashboardViewContext`
exposed `tasks` and nothing about the board's vocabulary. It now carries
`columnFlagsByTaskId` — the same per-task map `renderTaskCard` already
uses, **two lines away in the same object literal**.

## I filed this twice as blocked on a public-API change. It was not.

```
packages/dashboard                        @fusion/dashboard                        private: true
packages/plugin-sdk                       @fusion/plugin-sdk                       private: true
plugins/fusion-plugin-dependency-graph    @fusion-plugin-examples/dependency-graph private: true
```

No published surface anywhere in the path — three in-repo private
packages and a hand-written `.d.ts`. **#3026 landed the general form of
that mistake while I was still making it**: a deferral's stated blocker
is a claim, and mine decayed unchecked until I finally measured it.

## Two type decisions worth reviewing

- **`Partial<TraitFlags>`** in the plugin-facing type, not the
dashboard's `ExecutorColumnFlags` — that module's own header restricts
it to `@fusion/core` and `react` imports so external plugin builds can
consume it. Same runtime object either way.
- **`MainContentProps.columnFlagsByTaskId` widened** from `{complete,
archived, intake, hold}` to the flags the map really carries. It is
built from `workflow.columns.find(...).flags`, so the four-flag
declaration was a narrower view than the value — and `countsTowardWip`,
which every wip predicate needs, was invisible through it. That narrow
type is why threading this looked impossible at first.

Absent still means legacy, matching how the host treats remote rows and
off-board columns: the degraded answer is the documented literal, never
*"this board has no wip lane"*.

## Revert proof

Dropping the 4th argument:

```
AssertionError: expected 'graph-task-node graph-task-node--acti…' not to contain 'graph-task-node--active'
      Tests  1 failed | 26 passed (27)
```

The paired case (a fresh legacy `in-progress` card still reads active)
passes both ways by design — it guards against over-detection, so I am
not counting it as coverage.

The gate agrees independently:
`plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx: 1 -> 0`,
baseline re-recorded 16 → 15 in the same commit.

## Verification (measured)

- plugin suite — **185 passed / 20 files**
- dashboard `dashboard/` + `plugins/` suites — **48 passed / 6 files**
- `tsc --noEmit` clean in both packages; `pnpm lint` clean
- `lifecycle-column-census --strict`, `check-lane-wiring` (15, none
added), `check-sql-column-literals`, `check-inert-flag-seams`,
`check-fnxc-future-dates` — green

No changeset: all three packages are `private: true`.

---------

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

153 lines
6.8 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 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;
}
const mirrors = globSync("plugins/*/src/dashboard-interop.d.ts", { cwd: REPO }).sort();
const problems = [];
let compared = 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)`,
);
}
}
}
/*
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) {
console.error(`[check-plugin-interop-drift] scanned ${mirrors.length} mirror(s) and compared ${compared} function(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) across ${mirrors.length} plugin(s) match the real dashboard API.`);