> **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>
69 lines
3.1 KiB
JavaScript
69 lines
3.1 KiB
JavaScript
/*
|
|
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);
|
|
});
|
|
|
|
/*
|
|
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);
|
|
});
|