fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021)

#3013's merge of same-named declarations fixed a false **negative** and
introduced a false **positive**.

`ModelSelectorTab` declares its own two-parameter
`resolveEffectiveExecutor(task, settings)` — a pass-through with nothing
lane-related — while an unrelated exported function of the same name in
`effective-model-resolution.ts` takes `columnFlags`. Both local calls
were reported unwired against a signature they have never had.

Only **exported** declarations enter the accepting map, so the rule is
exact: if the calling file declares the name itself and the map entry
came from a different file, the call resolves to the local declaration
and is not a lane call here.

## The version I did not ship

My first attempt re-ran the detector over the single calling file and
used that result. It scored *better* on this tree — **19 → 16** instead
of 19 → 17, also clearing `bucket-mapping.ts` — and I threw it away.

A single-file pass cannot resolve an **imported** options interface. A
locally-declared function with an imported context type would quietly
stop being lane-accepting, and every call to it would stop being
checked. That is a false negative, which is the one failure a ratchet
must not have; the better-looking number came from the gate seeing less.
The global pass still does all type resolution here — only the *choice*
of declaration is local.

## Measured

| | |
|---|---|
| new tests | 3 |
| against the old census | **1 of 3 fails** — the positive |
| baseline | **19 → 17**, exactly the two `ModelSelectorTab` sites |

Both negatives pass either way and they are the ones that matter: a file
declaring its **own** exported lane function is not shadowed by itself,
and a file declaring nothing is judged normally. Shadowing must not
become a way to disappear a genuine unwired call.

## Still flagged, honestly

`bucket-mapping.ts:75` stays in the baseline. `bucketForTask(task:
TaskItem)` is only lane-accepting because `TaskItem` *declares*
`columnFlags` — the lane data rides on the domain object, so passing
`task` forwards it inherently. That is a different limitation
(options-bag vs domain-entity parameters) and I have not tried to fix it
here; it accounts for 2 of the remaining 17 along with
`otherBucketSecondaryLabel`.

## Verification

`node --test scripts/__tests__/check-lane-wiring.test.mjs` **19 passed**
· `pnpm test:gate` 13 + 161 + 487 + 71 · lint · lifecycle census
`--strict` · lane-wiring · fnxc-dates (TZ=UTC) · changesets — green.
This commit is contained in:
gsxdsm
2026-07-31 01:03:24 -07:00
committed by GitHub
parent 6f936f2de7
commit 5897d87e95
4 changed files with 100 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Internal gate fix; no user-visible change.
category: internal
dev: The lane-wiring census now resolves a call to a same-file declaration before a same-named exported one, removing two false positives in ModelSelectorTab.

View File

@@ -185,3 +185,54 @@ test("...and the merge does not excuse a call passing neither shape", () => {
].join("\n");
assert.equal(findUnwiredCallSitesIn(source).length, 1);
});
/*
FNXC:LaneWiring 2026-07-31-09:35:
SHADOWING — a call means the declaration it can actually see.
Merging same-named declarations fixed a false negative and introduced a false positive: two unrelated
functions named `resolveEffectiveExecutor` exist, one of which takes a lane answer, so every call to
the OTHER was reported unwired against a signature it never had.
The negatives matter more than the positive here. Shadowing must not become a way to disappear a
genuine unwired call: a file that declares its own EXPORTED lane-accepting function is not shadowed
by itself, and a file that declares nothing is judged normally.
*/
function unwiredAcross(sources) {
const dir = mkdtempSync(join(tmpdir(), "lane-wiring-multi-"));
const files = sources.map((source, index) => {
const file = join(dir, `f${index}.ts`);
writeFileSync(file, source);
return file;
});
return findUnwiredCallSites(files, findLaneAcceptingFunctions(files));
}
test("a local declaration shadows an unrelated exported function of the same name", () => {
const unwired = unwiredAcross([
"export function resolveThing(t: unknown, o?: { reviewColumns?: ReadonlySet<string> }) { return [t, o]; }",
[
"function resolveThing(t: unknown, s?: { quiet?: boolean }) { return [t, s]; }",
"export function use() { return resolveThing(1, { quiet: true }); }",
].join("\n"),
]);
assert.equal(unwired.length, 0);
});
test("...but a file declaring its OWN exported lane function is not shadowed by itself", () => {
const unwired = unwiredAcross([
[
"export function resolveThing(t: unknown, o?: { reviewColumns?: ReadonlySet<string> }) { return [t, o]; }",
"export function use() { return resolveThing(1, { quiet: true }); }",
].join("\n"),
]);
assert.equal(unwired.length, 1);
});
test("...and a file declaring nothing is judged against the exported signature", () => {
const unwired = unwiredAcross([
"export function resolveThing(t: unknown, o?: { reviewColumns?: ReadonlySet<string> }) { return [t, o]; }",
"export function use() { return resolveThing(1, { quiet: true }); }",
]);
assert.equal(unwired.length, 1);
});

View File

@@ -10,7 +10,6 @@
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,
"packages/dashboard/app/components/Lane.tsx": 1,
"packages/dashboard/app/components/ListView.tsx": 1,
"packages/dashboard/app/components/ModelSelectorTab.tsx": 2,
"packages/dashboard/app/components/TaskDetailModal.tsx": 1,
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,

View File

@@ -204,13 +204,14 @@ export function findLaneAcceptingFunctions(files) {
*/
const existing = accepting.get(node.name.text);
if (existing) {
existing.files.add(file);
for (const [index, names] of namesByIndex) {
if (!existing.namesByIndex.has(index)) existing.namesByIndex.set(index, new Set());
for (const name of names) existing.namesByIndex.get(index).add(name);
}
for (const position of positions) existing.positions.add(position);
} else {
accepting.set(node.name.text, { namesByIndex, positions });
accepting.set(node.name.text, { namesByIndex, positions, files: new Set([file]) });
}
}
});
@@ -334,9 +335,48 @@ export function findUnwiredCallSites(files, accepting) {
const unwired = [];
for (const file of files) {
const sf = parse(file);
/*
FNXC:LaneWiring 2026-07-31-09:25:
A call means the declaration it can actually SEE.
The census keys by name because it has no type resolution, and merging same-named declarations
(added when core's and the dashboard's `computeBlockerFanoutMap` collided) fixed a false NEGATIVE
at the cost of a false POSITIVE: `ModelSelectorTab` declares its own two-parameter
`resolveEffectiveExecutor(task, settings)` — a pass-through with nothing lane-related — while an
UNRELATED exported function of the same name in effective-model-resolution.ts takes `columnFlags`.
Every call to the local one was reported unwired against a signature it has never had.
Only EXPORTED declarations enter the accepting map, so the rule is exact: if the calling file
declares the name itself and the map entry came from a different file, the call resolves to the
local declaration and is not a lane call here.
Deliberately NOT done by re-running the detector over the single file: that pass cannot resolve an
imported options interface, so a locally-declared function with an imported context type would
quietly stop being lane-accepting — a false negative, which is the one failure a ratchet must not
have. The global pass still does all type resolution; only the CHOICE of declaration is local.
*/
const declaredLocally = new Set();
{
const collect = (node) => {
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
declaredLocally.add(node.name.text);
}
if (ts.isVariableStatement(node)) {
for (const d of node.declarationList.declarations) {
if (ts.isIdentifier(d.name)) declaredLocally.add(d.name.text);
}
}
ts.forEachChild(node, collect);
};
ts.forEachChild(sf, collect);
}
const visit = (node) => {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
const accepted = accepting.get(node.expression.text);
const entry = accepting.get(node.expression.text);
const shadowed = entry !== undefined
&& declaredLocally.has(node.expression.text)
&& !entry.files.has(file);
const accepted = shadowed ? undefined : entry;
if (accepted) {
const passesOption = node.arguments.some((arg, index) => {
const wanted = accepted.namesByIndex.get(index);