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:
7
.changeset/lane-census-local-shadowing.md
Normal file
7
.changeset/lane-census-local-shadowing.md
Normal 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.
|
||||||
@@ -185,3 +185,54 @@ test("...and the merge does not excuse a call passing neither shape", () => {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
assert.equal(findUnwiredCallSitesIn(source).length, 1);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,
|
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,
|
||||||
"packages/dashboard/app/components/Lane.tsx": 1,
|
"packages/dashboard/app/components/Lane.tsx": 1,
|
||||||
"packages/dashboard/app/components/ListView.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/components/TaskDetailModal.tsx": 1,
|
||||||
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
|
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
|
||||||
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,
|
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,
|
||||||
|
|||||||
@@ -204,13 +204,14 @@ export function findLaneAcceptingFunctions(files) {
|
|||||||
*/
|
*/
|
||||||
const existing = accepting.get(node.name.text);
|
const existing = accepting.get(node.name.text);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
existing.files.add(file);
|
||||||
for (const [index, names] of namesByIndex) {
|
for (const [index, names] of namesByIndex) {
|
||||||
if (!existing.namesByIndex.has(index)) existing.namesByIndex.set(index, new Set());
|
if (!existing.namesByIndex.has(index)) existing.namesByIndex.set(index, new Set());
|
||||||
for (const name of names) existing.namesByIndex.get(index).add(name);
|
for (const name of names) existing.namesByIndex.get(index).add(name);
|
||||||
}
|
}
|
||||||
for (const position of positions) existing.positions.add(position);
|
for (const position of positions) existing.positions.add(position);
|
||||||
} else {
|
} 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 = [];
|
const unwired = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const sf = parse(file);
|
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) => {
|
const visit = (node) => {
|
||||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
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) {
|
if (accepted) {
|
||||||
const passesOption = node.arguments.some((arg, index) => {
|
const passesOption = node.arguments.some((arg, index) => {
|
||||||
const wanted = accepted.namesByIndex.get(index);
|
const wanted = accepted.namesByIndex.get(index);
|
||||||
|
|||||||
Reference in New Issue
Block a user