diff --git a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx index b698c8b3d7..5b77ad424f 100644 --- a/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx @@ -35,7 +35,6 @@ export interface DependencyGraphProps { onOpenMission?: (missionId: string) => void; onMoveTask?: (id: string, column: Task["column"], optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; lastFetchTimeMs?: number; - workflowStepNameLookup?: ReadonlyMap; } const POINTER_MOVE_THRESHOLD = 4; @@ -58,7 +57,6 @@ export function DependencyGraph({ onOpenMission, onMoveTask, lastFetchTimeMs, - workflowStepNameLookup, }: DependencyGraphProps) { const viewportRef = useRef(null); const pointerDownRef = useRef<{ x: number; y: number } | null>(null); @@ -506,7 +504,6 @@ export function DependencyGraph({ onOpenMission={onOpenMission} onMoveTask={onMoveTask} lastFetchTimeMs={lastFetchTimeMs} - workflowStepNameLookup={workflowStepNameLookup} onMouseEnter={() => setHoveredTaskId(node.task.id)} onMouseLeave={() => setHoveredTaskId(null)} onClick={(event) => { diff --git a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx index e4affe2331..123003d172 100644 --- a/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx @@ -27,7 +27,6 @@ type TaskCardBridgeProps = Pick< | "onOpenMission" | "onMoveTask" | "lastFetchTimeMs" - | "workflowStepNameLookup" >; export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick, "onMouseEnter" | "onMouseLeave" | "onClick"> { 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 3aa00275f2..ddcb29ee92 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts @@ -64,7 +64,6 @@ declare module "@fusion/dashboard/app/components/TaskCard" { /* 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 6dabe98a75..66c98c6d78 100644 --- a/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx @@ -1,17 +1,25 @@ -import type { Task, TaskDetail, WorkflowStep } from "@fusion/core"; +import type { Task, TaskDetail } from "@fusion/core"; import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; import { createElement } from "react"; import { DependencyGraph } from "./DependencyGraph.js"; -function createWorkflowStepNameLookup(workflowSteps: WorkflowStep[] | undefined): ReadonlyMap { - return new Map((workflowSteps ?? []).map((step) => [step.id, step.name] as const)); -} +/* +FNXC:PluginInteropDrift 2026-07-31-08:30: +`createWorkflowStepNameLookup` and the `workflowStepNameLookup` prop are DELETED, not moved. + +`TaskCard` had that prop when FN-2466/FN-7039 added this threading; the dashboard removed it later +and nothing propagated the removal to this plugin's hand-written `dashboard-interop.d.ts`. The map +was still built from `context.workflowSteps` on every render, threaded through two components, and +discarded by a `TaskCard` that has no such prop. + +Behaviour-preserving: the value never reached anything. Found by check-plugin-interop-drift the first +time it compared interfaces, which is the case that check exists for. +*/ export function DependencyGraphDashboardView({ context }: { context?: PluginDashboardViewContext }) { return createElement(DependencyGraph, { 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, diff --git a/scripts/__tests__/check-plugin-interop-drift.test.mjs b/scripts/__tests__/check-plugin-interop-drift.test.mjs index 1332695485..47efdada23 100644 --- a/scripts/__tests__/check-plugin-interop-drift.test.mjs +++ b/scripts/__tests__/check-plugin-interop-drift.test.mjs @@ -38,3 +38,31 @@ test("a memo()-wrapped export is PRESENT but not comparable", () => { test("a non-exported function is invisible", () => { assert.equal(parse("function hidden(a) { return a; }").has("hidden"), false); }); + +/* +FNXC:PluginInteropDrift 2026-07-31-08:20: +INTERFACES ARE ONE-DIRECTIONAL: fewer properties is correct, unknown ones are the drift. + +All six mirrors declare subsets (6, 8, 7, 7, 3, 6 against the real nine) because a plugin mirrors +only the fields it uses. Demanding equality would fail every plugin for not using everything, which +is how a check gets deleted. A property the real type lacks is a rename nobody propagated — the +plugin keeps compiling and reads a field the host never sends. +*/ +import { declaredInterfacesForTest } from "../check-plugin-interop-drift.mjs"; + +test("an interface's property names are collected", () => { + const found = declaredInterfacesForTest("export interface P { a: string; b?: number }", "t.tsx"); + assert.deepEqual([...(found.get("P") ?? new Map()).keys()], ["a", "b"]); +}); + +test("a mirror declaring FEWER properties is not drift", () => { + const real = declaredInterfacesForTest("export interface P { a: string; b?: number; c?: boolean }", "t.tsx").get("P"); + const mirrored = ["a"]; + assert.equal(mirrored.every((p) => real.has(p)), true); +}); + +test("a mirror declaring an UNKNOWN property is drift", () => { + /* The live case: `TaskCardProps.workflowStepNameLookup` outlived its removal from TaskCard. */ + const real = declaredInterfacesForTest("export interface P { a: string }", "t.tsx").get("P"); + assert.equal(real.has("workflowStepNameLookup"), false); +}); diff --git a/scripts/check-plugin-interop-drift.mjs b/scripts/check-plugin-interop-drift.mjs index c1e638c78c..21dd1e0f91 100644 --- a/scripts/check-plugin-interop-drift.mjs +++ b/scripts/check-plugin-interop-drift.mjs @@ -55,6 +55,10 @@ wrapped component, not to the export. Arity is not comparable there, so those ar 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(); @@ -102,9 +106,72 @@ function mirroredFunctions(file) { 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); @@ -129,14 +196,27 @@ for (const rel of mirrors) { ); } } + + 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) { - console.error(`[check-plugin-interop-drift] scanned ${mirrors.length} mirror(s) and compared ${compared} function(s) — refusing to report success on an empty comparison.`); +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); } @@ -149,4 +229,4 @@ if (problems.length > 0) { process.exit(1); } -console.log(`[check-plugin-interop-drift] ${compared} mirrored function(s) across ${mirrors.length} plugin(s) match the real dashboard API.`); +console.log(`[check-plugin-interop-drift] ${compared} mirrored function(s) and ${comparedInterfaces} interface(s) across ${mirrors.length} plugin(s) match the real dashboard API.`);