diff --git a/.changeset/lane-census-local-shadowing.md b/.changeset/lane-census-local-shadowing.md new file mode 100644 index 0000000000..c44218a276 --- /dev/null +++ b/.changeset/lane-census-local-shadowing.md @@ -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. diff --git a/scripts/__tests__/check-lane-wiring.test.mjs b/scripts/__tests__/check-lane-wiring.test.mjs index c2c7b17dc1..2b907d0ecb 100644 --- a/scripts/__tests__/check-lane-wiring.test.mjs +++ b/scripts/__tests__/check-lane-wiring.test.mjs @@ -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 }) { 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 }) { 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 }) { return [t, o]; }", + "export function use() { return resolveThing(1, { quiet: true }); }", + ]); + assert.equal(unwired.length, 1); +}); diff --git a/scripts/lib/lane-wiring-baseline.json b/scripts/lib/lane-wiring-baseline.json index 3a54486ac3..480a4f1fc8 100644 --- a/scripts/lib/lane-wiring-baseline.json +++ b/scripts/lib/lane-wiring-baseline.json @@ -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, diff --git a/scripts/lib/lane-wiring-census.mjs b/scripts/lib/lane-wiring-census.mjs index ec9ba48b13..57a3ed6033 100644 --- a/scripts/lib/lane-wiring-census.mjs +++ b/scripts/lib/lane-wiring-census.mjs @@ -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);