From 5897d87e95961535539b3b01dd54d4c174256ad7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 01:03:24 -0700 Subject: [PATCH] fix(gate): the lane census judged a call against a signature it never had (the false positive #3013's merge introduced) (#3021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .changeset/lane-census-local-shadowing.md | 7 +++ scripts/__tests__/check-lane-wiring.test.mjs | 51 ++++++++++++++++++++ scripts/lib/lane-wiring-baseline.json | 1 - scripts/lib/lane-wiring-census.mjs | 44 ++++++++++++++++- 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 .changeset/lane-census-local-shadowing.md 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);