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>
This commit is contained in:
gsxdsm
2026-07-31 01:30:47 -07:00
committed by GitHub
parent 25b3c06d2d
commit a460a9bbc0
13 changed files with 316 additions and 10 deletions

View File

@@ -64,6 +64,8 @@ jobs:
run: pnpm check:fnxc-future-dates
- name: Lane-wiring ratchet
run: pnpm check:lane-wiring
- name: Plugin interop declarations match the dashboard API
run: pnpm check:plugin-interop-drift
typecheck:
name: Typecheck

View File

@@ -24,6 +24,7 @@
"check:inert-flag-seams": "node scripts/check-inert-flag-seams.mjs",
"check:fnxc-future-dates": "node scripts/check-fnxc-future-dates.mjs",
"check:lane-wiring": "node scripts/check-lane-wiring.mjs",
"check:plugin-interop-drift": "node scripts/check-plugin-interop-drift.mjs",
"census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs",
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",

View File

@@ -368,6 +368,11 @@ export function MainContent({
context={{
projectId: currentProject?.id,
tasks: pluginContextTasks,
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: the same per-task trait map `renderTaskCard`
below already uses. A plugin view that draws its OWN card (the dependency graph imports
`TaskCard` directly) is a third producer that neither #3025 fix could reach, because this
context exposed nothing about the board's vocabulary. */
columnFlagsByTaskId,
workflowSteps,
subscribePluginEvents,
openTaskDetail: openPluginTaskDetail,

View File

@@ -19,6 +19,7 @@ import type {
TaskDetail,
ThemeMode,
WorkflowStep,
TraitFlags,
} from "@fusion/core";
import type {
AiSessionSummary,
@@ -68,7 +69,11 @@ export interface MainContentProps {
columns not on the current board, where the consumer degrades to the documented legacy names
rather than guessing.
*/
columnFlagsByTaskId?: ReadonlyMap<string, { complete?: boolean; archived?: boolean; intake?: boolean; hold?: boolean }>;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: widened to the flags the map REALLY carries. It is
built from `workflow.columns.find(...).flags` (App.tsx `footerColumnFlagsByTaskId`), so the four-flag
declaration was a narrower view than the value — and `countsTowardWip`, which the wip predicates
need, was invisible to any consumer typed through here. */
columnFlagsByTaskId?: ReadonlyMap<string, Partial<TraitFlags>>;
showBackendConnectionErrorPage: boolean;
projectsError: string | null;
t: TFunction;

View File

@@ -9,7 +9,7 @@
* and `react`. Do NOT import dashboard components, hooks, or CSS here.
*/
import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { Task, TaskDetail, TraitFlags, WorkflowStep } from "@fusion/core";
/**
* Tab identifiers for the task detail modal. Mirrors the dashboard's local enum.
@@ -37,6 +37,26 @@ export interface PluginDashboardViewContext {
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
/** Open a project-relative file in the dashboard's built-in file viewer. */
openFile: (path: string, options?: { workspace?: string; line?: number; col?: number }) => void;
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-15:30:
The board's resolved column traits, per task id — so a plugin view that draws its OWN card is not
forced back onto the legacy ids.
#3025 fixed the two producers that go through `renderTaskCard`. A plugin that imports `TaskCard`
directly is a THIRD producer, and it could not be fixed the same way: this context exposed `tasks`
and nothing about the board's vocabulary, so every role helper inside a plugin-drawn card, and
every trait predicate a plugin calls, fell back to the literal.
`Partial<TraitFlags>` rather than the dashboard's `ExecutorColumnFlags`, because this module is
deliberately importable by external plugin builds and may only reference `@fusion/core` and `react`
(see the header). The runtime value is the same object either way — the map is built from
`workflow.columns.find(...).flags`.
Optional and absent-means-legacy, matching how the host already treats remote rows and off-board
columns: a consumer degrades to the documented legacy names rather than reading "resolved and
empty" as "this board has no such lane".
*/
columnFlagsByTaskId?: ReadonlyMap<string, Partial<TraitFlags>>;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
/**

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import type { Task, TraitFlags } from "@fusion/core";
import { GraphTaskNode } from "./GraphTaskNode.js";
import { GraphToolbar } from "./GraphToolbar.js";
import { GraphEdges } from "./edges.js";
@@ -19,6 +19,8 @@ const NARROW_VIEWPORT_WIDTH = 768;
export interface DependencyGraphProps {
tasks: Task[];
projectId?: string;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: per-task resolved column traits from the host. */
columnFlagsByTaskId?: ReadonlyMap<string, Partial<TraitFlags>>;
onOpenTaskDetail?: (taskId: string) => void;
onOpenDetail?: (task: Task) => void;
addToast?: (message: string, type?: "success" | "error" | "info" | "warning") => void;
@@ -41,6 +43,7 @@ const POINTER_MOVE_THRESHOLD = 4;
export function DependencyGraph({
tasks,
projectId,
columnFlagsByTaskId,
onOpenTaskDetail,
onOpenDetail,
addToast,
@@ -471,6 +474,7 @@ export function DependencyGraph({
key={node.task.id}
task={node.task}
projectId={projectId}
taskColumnFlags={columnFlagsByTaskId?.get(node.task.id)}
isSelected={selectedTaskId === node.task.id}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
position={position}

View File

@@ -1,4 +1,5 @@
import type { CSSProperties, ComponentProps, HTMLAttributes } from "react";
import type { TraitFlags } from "@fusion/core";
import type { GraphPosition } from "./types.js";
import { useNodeDrag } from "./hooks/useNodeDrag.js";
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
@@ -30,6 +31,22 @@ type TaskCardBridgeProps = Pick<
>;
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-15:30:
This card's OWN resolved column traits, threaded from the host's plugin view context.
Two defects close here, both from this component having no access to the board's vocabulary:
- `isTaskStuck` was called without its `columnFlags` argument, so `isWipColumnRole` fell back to
the literal and NO card in the graph was ever shown stuck on a renamed board — while the same
card showed stuck correctly on the main board. That asymmetry was the tell.
- The `TaskCard` rendered below is a THIRD producer of unflagged cards, after the two #3025 fixed.
It bypasses `renderTaskCard` entirely by importing the component directly, so a host-side fix
could not reach it; every role helper inside it read the legacy ids.
Optional, and absent means legacy: the host omits the map for remote rows and off-board columns, and
the degraded answer there is the documented literal rather than "this board has no such lane".
*/
taskColumnFlags?: Partial<TraitFlags>;
style?: CSSProperties;
position: GraphPosition;
scale: number;
@@ -52,6 +69,7 @@ function getStatusLabel(status?: string): string {
}
export function GraphTaskNode({
taskColumnFlags,
style,
position,
scale,
@@ -69,7 +87,7 @@ export function GraphTaskNode({
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs, onOpenDetail } = taskCardProps;
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs, taskColumnFlags);
/*
FNXC:PluginLifecycleColumns 2026-07-30-03:40 (U11 #2515 audit):
Keyed on `column === "triage"`, this went permanently FALSE for default-lineage
@@ -154,7 +172,7 @@ export function GraphTaskNode({
<span className="graph-task-active-indicator-text">{getStatusLabel(task.status)}</span>
</div>
) : null}
<TaskCard {...taskCardProps} onOpenDetail={() => {}} disableDrag={true} />
<TaskCard {...taskCardProps} taskColumnFlags={taskColumnFlags} onOpenDetail={() => {}} disableDrag={true} />
</div>
);
}

View File

@@ -383,3 +383,43 @@ describe("GraphTaskNode", () => {
expect(boardCard.querySelector(".card-title")?.textContent).toBe(graphCard.querySelector(".card-title")?.textContent);
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-15:30:
THE INVARIANT: a stalled card in the board's OWN wip lane reads as stuck, not as healthily running.
`isTaskStuck` was called without its `columnFlags` argument, so `isWipColumnRole` fell back to the
literal `in-progress`. On a renamed board no graph card could ever be stuck — and because `isStuck`
gates `isActive`, a wedged card rendered with the ACTIVE styling instead: the graph said "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 whole defect, and it is what this pins.
Reverted (the 4th argument dropped, or the flags not threaded from the host context), the first case
gets the `--active` class back and fails.
*/
describe("stuck detection on a renamed board", () => {
const STALE_MS = 120_000;
function stalledCard() {
return createTask({
column: "building",
status: "executing",
updatedAt: new Date(Date.now() - STALE_MS).toISOString(),
} as Partial<Task>);
}
it("treats a stalled card in a RENAMED wip lane as stuck, not active", () => {
const props = createProps(stalledCard());
render(<GraphTaskNode {...props} taskColumnFlags={{ countsTowardWip: true }} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--active");
});
it("still reads a legacy in-progress card as active when it is fresh", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", updatedAt: new Date().toISOString() } as Partial<Task>));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).toContain("graph-task-node--active");
});
});

View File

@@ -1,12 +1,24 @@
declare module "@fusion/dashboard/app/utils/taskStuck" {
import type { Task } from "@fusion/core";
import type { Task, TraitFlags } from "@fusion/core";
export function isTaskStuck(task: Task, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): boolean;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: the 4th parameter existed upstream and this shim
did not declare it, so the plugin could not pass resolved traits even once it had them — and
`isWipColumnRole` fell back to the literal, meaning NO card in the graph was ever shown stuck on a
renamed board while the same card showed stuck correctly on the main board. */
export function isTaskStuck(
task: Task,
/* FNXC:PluginInteropDrift 2026-07-31-07:50: positionally REQUIRED in the real signature
(`number | undefined`), not optional — a mirror that is merely approximate is the drift this
file already caused once. Found by check-plugin-interop-drift. */
taskStuckTimeoutMs: number | undefined,
lastFetchTimeMs?: number,
columnFlags?: Partial<TraitFlags>,
): boolean;
}
declare module "@fusion/dashboard/app/plugins/types" {
import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { Task, TaskDetail, TraitFlags, WorkflowStep } from "@fusion/core";
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
@@ -17,6 +29,8 @@ declare module "@fusion/dashboard/app/plugins/types" {
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: mirrors the host's `PluginDashboardViewContext`. */
columnFlagsByTaskId?: ReadonlyMap<string, Partial<TraitFlags>>;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
}
@@ -25,7 +39,7 @@ declare module "@fusion/dashboard/app/plugins/types" {
}
declare module "@fusion/dashboard/app/components/TaskCard" {
import type { Column, Task, TaskDetail } from "@fusion/core";
import type { Column, Task, TaskDetail, TraitFlags } from "@fusion/core";
import type { ReactElement } from "react";
interface TaskCardProps {
@@ -47,6 +61,9 @@ declare module "@fusion/dashboard/app/components/TaskCard" {
onOpenMission?: (missionId: string) => void;
onMoveTask?: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
lastFetchTimeMs?: number;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: the prop the host card already accepts; without it
declared here a plugin-drawn card could not be given the board's traits at all. */
taskColumnFlags?: Partial<TraitFlags>;
workflowStepNameLookup?: ReadonlyMap<string, string>;
disableDrag?: boolean;
}

View File

@@ -12,6 +12,9 @@ export function DependencyGraphDashboardView({ context }: { context?: PluginDash
tasks: context?.tasks ?? [],
projectId: context?.projectId,
workflowStepNameLookup: createWorkflowStepNameLookup(context?.workflowSteps),
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: the board's resolved traits, now that the host
context carries them. Absent (remote rows, older host) degrades to the legacy ids as before. */
columnFlagsByTaskId: context?.columnFlagsByTaskId,
onOpenDetail: context?.openTaskDetail as ((task: Task | TaskDetail) => void) | undefined,
});
}

View File

@@ -0,0 +1,40 @@
/*
FNXC:PluginInteropDrift 2026-07-31-07:35:
THE NON-FUNCTION EXPORT RULE IS WHAT KEEPS THIS CHECK CREDIBLE.
Its first run reported `TaskCard` as a function the dashboard no longer exports. It exports it as
`export const TaskCard = memo(TaskCardComponent, ...)` — present, but with an arity that belongs to a
wrapped component rather than to the export. A check whose debut finding is a false positive does not
get a second reading, so the distinction between ABSENT and NOT-COMPARABLE is pinned here.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { exportedFunctions } from "../check-plugin-interop-drift.mjs";
const parse = (src) => exportedFunctions(src, "t.tsx");
test("an exported function declaration reports its arity", () => {
const found = parse("export function f(a, b, c) { return a; }");
assert.deepEqual(found.get("f"), { total: 3, required: 3 });
});
test("optional and defaulted parameters are not required", () => {
const found = parse("export function f(a, b?, c = 1, ...rest) { return a; }");
assert.deepEqual(found.get("f"), { total: 4, required: 1 });
});
test("an exported arrow function is comparable", () => {
const found = parse("export const f = (a, b) => a + b;");
assert.deepEqual(found.get("f"), { total: 2, required: 2 });
});
test("a memo()-wrapped export is PRESENT but not comparable", () => {
/* The false positive the first run produced: reported as a rename. */
const found = parse("export const TaskCard = memo(TaskCardComponent, areEqual);");
assert.equal(found.has("TaskCard"), true);
assert.equal(found.get("TaskCard"), null);
});
test("a non-exported function is invisible", () => {
assert.equal(parse("function hidden(a) { return a; }").has("hidden"), false);
});

View File

@@ -0,0 +1,152 @@
#!/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.`);

View File

@@ -14,7 +14,6 @@
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,
"packages/cli/src/commands/dashboard-tui/bucket-mapping.ts": 1,
"plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx": 1,
"plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts": 1
}
}