gate: compare mirrored INTERFACES too, and delete the dead prop that found (#3034)

> **Re-landing the second half of #3031.** That PR merged into #3029's
branch and only its first commit reached `main` — the arity rule
shipped, the interface rule and its finding did not. Verified on `main`:
the gate reports *"7 mirrored function(s)"* with no interface count, and
the dead prop below is still there.

## What

The arity rule covers exported functions. The same files also mirror
**interfaces**, which is the larger surface — six copies of
`PluginDashboardViewContext` alone.

**One direction only.** A mirror may declare *fewer* properties, and all
six do (6, 8, 7, 7, 3, 6 against the real nine) because a plugin mirrors
the fields it uses. Demanding equality would fail every plugin for not
using everything — which is how a check gets ignored and then deleted. A
property the real type **doesn't have** is the drift that matters: a
rename nobody propagated, where the plugin keeps compiling and reads a
field the host never sends.

## Its first interface run found a live one

```
dashboard-interop.d.ts:67  TaskCardProps.workflowStepNameLookup is not a property of the real TaskCardProps
```

Git history says it **was** one when FN-2466 and FN-7039 added this
threading. The dashboard removed it later; nothing propagated that to
the plugin's hand-written declaration. So the plugin built a lookup map
from `context.workflowSteps` on every render, threaded it through two
components, and handed it to a `TaskCard` with no such prop.

Deleted rather than exempted — a new gate shouldn't ship with a waiver
for its own first finding. Behaviour-preserving: the value never reached
anything.

## Measured on `main`

| check | result |
|---|---|
| population | **7 functions + 10 interfaces across 6 plugins**, all
matching after the deletion |
| control probe | phantom property **caught**; clean tree exits 0 |
| anti-vacuity | now also requires a non-zero *interface* comparison |
| gate's own suite | **5 → 8** |
| dependency-graph suite | 179 green; `tsc` clean |
| other five gates · census | green |

## Running total for this check

Three real drifts, none of which any other instrument reported:

1. `isTaskStuck` stuck at three parameters through the whole lane
conversion (#3003)
2. `taskStuckTimeoutMs?: number` vs the required `number | undefined` —
in **two independent authors'** declarations
3. `workflowStepNameLookup` outliving its removal from `TaskCard`

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 01:43:58 -07:00
committed by GitHub
parent e9b24b69e8
commit ccf562f178
6 changed files with 124 additions and 13 deletions

View File

@@ -35,7 +35,6 @@ export interface DependencyGraphProps {
onOpenMission?: (missionId: string) => void;
onMoveTask?: (id: string, column: Task["column"], optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
lastFetchTimeMs?: number;
workflowStepNameLookup?: ReadonlyMap<string, string>;
}
const POINTER_MOVE_THRESHOLD = 4;
@@ -58,7 +57,6 @@ export function DependencyGraph({
onOpenMission,
onMoveTask,
lastFetchTimeMs,
workflowStepNameLookup,
}: DependencyGraphProps) {
const viewportRef = useRef<HTMLDivElement | null>(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) => {

View File

@@ -27,7 +27,6 @@ type TaskCardBridgeProps = Pick<
| "onOpenMission"
| "onMoveTask"
| "lastFetchTimeMs"
| "workflowStepNameLookup"
>;
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {

View File

@@ -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<TraitFlags>;
workflowStepNameLookup?: ReadonlyMap<string, string>;
disableDrag?: boolean;
}

View File

@@ -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<string, string> {
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,

View File

@@ -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);
});

View File

@@ -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.`);