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:
2
.github/workflows/pr-checks.yml
vendored
2
.github/workflows/pr-checks.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
40
scripts/__tests__/check-plugin-interop-drift.test.mjs
Normal file
40
scripts/__tests__/check-plugin-interop-drift.test.mjs
Normal 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);
|
||||
});
|
||||
152
scripts/check-plugin-interop-drift.mjs
Normal file
152
scripts/check-plugin-interop-drift.mjs
Normal 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.`);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user