diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 5c4db49cc7..8843dc1d55 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -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 diff --git a/package.json b/package.json index 759886ec3f..a0c66a910e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 99f0bd0fee..fd0e9d8edf 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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, diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 3239bbbd1b..aca2445f32 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -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; + /* 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>; showBackendConnectionErrorPage: boolean; projectsError: string | null; t: TFunction; diff --git a/packages/dashboard/app/plugins/types.ts b/packages/dashboard/app/plugins/types.ts index 2d48d9ef57..0e2b2fe25d 100644 --- a/packages/dashboard/app/plugins/types.ts +++ b/packages/dashboard/app/plugins/types.ts @@ -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` 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>; renderTaskCard?: (task: Task | TaskDetail) => ReactNode; addToast?: (message: string, type?: PluginToastType) => void; /** diff --git a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx index 0fe04bcd88..b698c8b3d7 100644 --- a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx @@ -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>; 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} diff --git a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx index 75c23fe556..e4affe2331 100644 --- a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx @@ -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, "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; 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({ {getStatusLabel(task.status)} ) : null} - {}} disableDrag={true} /> + {}} disableDrag={true} /> ); } diff --git a/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx b/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx index 536e3da933..47a04bb50d 100644 --- a/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx @@ -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); + } + + it("treats a stalled card in a RENAMED wip lane as stuck, not active", () => { + const props = createProps(stalledCard()); + render(); + + 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)); + render(); + + expect(screen.getByTestId("graph-task-node-FN-TEST").className).toContain("graph-task-node--active"); + }); +}); diff --git a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts index 71a09d62b9..3aa00275f2 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts @@ -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, + ): 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>; 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; 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; workflowStepNameLookup?: ReadonlyMap; disableDrag?: boolean; } diff --git a/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx b/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx index 3c4396ad36..6dabe98a75 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx @@ -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, }); } diff --git a/scripts/__tests__/check-plugin-interop-drift.test.mjs b/scripts/__tests__/check-plugin-interop-drift.test.mjs new file mode 100644 index 0000000000..1332695485 --- /dev/null +++ b/scripts/__tests__/check-plugin-interop-drift.test.mjs @@ -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); +}); diff --git a/scripts/check-plugin-interop-drift.mjs b/scripts/check-plugin-interop-drift.mjs new file mode 100644 index 0000000000..c1e638c78c --- /dev/null +++ b/scripts/check-plugin-interop-drift.mjs @@ -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.`); diff --git a/scripts/lib/lane-wiring-baseline.json b/scripts/lib/lane-wiring-baseline.json index 480a4f1fc8..16187a1ec9 100644 --- a/scripts/lib/lane-wiring-baseline.json +++ b/scripts/lib/lane-wiring-baseline.json @@ -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 } }