diff --git a/.changeset/lane-census-conditional-shapes.md b/.changeset/lane-census-conditional-shapes.md new file mode 100644 index 0000000000..012f68d9cd --- /dev/null +++ b/.changeset/lane-census-conditional-shapes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Internal gate fix; no user-visible change. +category: internal +dev: The lane-wiring census now sees lane arguments passed via a ternary or a conditional spread, adds `columnFlagsByTaskId` to its vocabulary, and merges same-named declarations instead of letting the last one clobber the rest. diff --git a/scripts/__tests__/check-lane-wiring.test.mjs b/scripts/__tests__/check-lane-wiring.test.mjs index a67e2a8a69..c2c7b17dc1 100644 --- a/scripts/__tests__/check-lane-wiring.test.mjs +++ b/scripts/__tests__/check-lane-wiring.test.mjs @@ -56,6 +56,12 @@ function callArgs(source) { return found; } +/** Run the whole census over one throwaway source and return its unwired sites. */ +function findUnwiredCallSitesIn(source) { + const files = fixture(source); + return findUnwiredCallSites(files, findLaneAcceptingFunctions(files)); +} + test("a property spelled `undefined` supplies nothing", () => { assert.equal(suppliesAValue(firstObjectProperty("f({ reviewColumns: undefined });")), false); }); @@ -121,3 +127,61 @@ test("the census does NOT report the same call once the property carries a value assert.deepEqual(unwired.map((u) => u.fn), []); }); + +/* +FNXC:LaneWiring 2026-07-31-01:15: +CONDITIONAL shapes and NAME COLLISIONS — the third and fourth ways a wired call site stayed invisible. + +Both were found the same way the earlier holes were: by using the gate and noticing it disagreed with +what the code plainly said. Three call sites wired in #2990/#3004 still counted as unwired, and a +correctly-wired call in task-priority.ts started being reported as unwired the moment a second +same-named function entered the vocabulary. + +Every positive below is paired with its NEGATIVE. A shape the census merely stops flagging is not +progress — the question is whether deleting the argument still fails. +*/ +const LANE_FN = "export function f(t: unknown, o?: { reviewColumns?: ReadonlySet }) { return [t, o]; }"; + +test("a lane passed only in a ternary's true branch counts as wired", () => { + const unwired = findUnwiredCallSitesIn(`${LANE_FN}\nf(1, cond ? { reviewColumns: r } : {});`); + assert.equal(unwired.length, 0); +}); + +test("...and a ternary supplying it in NEITHER branch is still unwired", () => { + const unwired = findUnwiredCallSitesIn(`${LANE_FN}\nf(1, cond ? { nowMs: 1 } : {});`); + assert.equal(unwired.length, 1); +}); + +test("a lane passed through a conditional spread counts as wired", () => { + const unwired = findUnwiredCallSitesIn(`${LANE_FN}\nf(1, { ...(cond ? { reviewColumns: r } : {}) });`); + assert.equal(unwired.length, 0); +}); + +test("...and a conditional spread carrying no lane is still unwired", () => { + const unwired = findUnwiredCallSitesIn(`${LANE_FN}\nf(1, { ...(cond ? { nowMs: 1 } : {}) });`); + assert.equal(unwired.length, 1); +}); + +test("two same-named lane functions MERGE rather than clobber", () => { + /* + The real pair: core's `computeBlockerFanoutMap(tasks, n, opts)` and the dashboard wrapper + `computeBlockerFanoutMap(tasks, opts)`. Their lane options sit at different indices, so the last + declaration parsed used to replace the first and every caller of the other shape was misreported. + */ + const source = [ + "export function g(t: unknown, n: number, o?: { reviewColumns?: ReadonlySet }) { return [t, n, o]; }", + "export function g(t: unknown, o?: { columnFlagsByTaskId?: ReadonlyMap }) { return [t, o]; }", + "g(1, 2, { reviewColumns: r });", + "g(1, { columnFlagsByTaskId: m });", + ].join("\n"); + assert.equal(findUnwiredCallSitesIn(source).length, 0); +}); + +test("...and the merge does not excuse a call passing neither shape", () => { + const source = [ + "export function g(t: unknown, n: number, o?: { reviewColumns?: ReadonlySet }) { return [t, n, o]; }", + "export function g(t: unknown, o?: { columnFlagsByTaskId?: ReadonlyMap }) { return [t, o]; }", + "g(1, 2, { nowMs: 1 });", + ].join("\n"); + assert.equal(findUnwiredCallSitesIn(source).length, 1); +}); diff --git a/scripts/lib/lane-wiring-baseline.json b/scripts/lib/lane-wiring-baseline.json index 3793880375..587e0328aa 100644 --- a/scripts/lib/lane-wiring-baseline.json +++ b/scripts/lib/lane-wiring-baseline.json @@ -2,18 +2,16 @@ "counts": { "packages/core/src/task-merge.ts": 1, "packages/core/src/task-store/branch-and-pr-entities.ts": 1, - "packages/core/src/task-store/moves.ts": 2, + "packages/core/src/task-store/moves.ts": 1, "packages/core/src/task-store/task-update.ts": 1, "packages/engine/src/auto-merge-finalization.ts": 1, "packages/engine/src/project-engine.ts": 1, "packages/engine/src/runtimes/in-process-runtime.ts": 1, - "packages/engine/src/scheduler.ts": 1, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 1, - "packages/dashboard/app/components/ExecutorStatusBar.tsx": 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": 2, + "packages/dashboard/app/components/TaskDetailModal.tsx": 1, "packages/dashboard/app/hooks/useBlockerFanout.ts": 1, "packages/cli/src/commands/dashboard-tui/app.tsx": 1, "packages/cli/src/commands/dashboard-tui/bucket-mapping.ts": 1, diff --git a/scripts/lib/lane-wiring-census.mjs b/scripts/lib/lane-wiring-census.mjs index 8315e2c069..ec9ba48b13 100644 --- a/scripts/lib/lane-wiring-census.mjs +++ b/scripts/lib/lane-wiring-census.mjs @@ -45,6 +45,15 @@ export const LANE_ARGUMENT_NAMES = new Set([ /* FNXC:WorkflowLifecycleColumns 2026-07-30-22:00: the MEMBERSHIP form, added with the surfacing family's split-role fix — without it the gate cannot see a dropped `holdColumns`. */ "holdColumns", + /* + FNXC:LaneWiring 2026-07-31-00:50: + The per-TASK trait index, the shape the dashboard threads (`columnFlagsByTaskId`). It is how three + dashboard surfaces answer every lane question at once, so a call site that drops it goes back to + the legacy vocabulary wholesale — exactly what this census exists to notice. It was missing, so all + three of those newly-wired sites still counted as unwired and the gate could not have caught a + regression at any of them. + */ + "columnFlagsByTaskId", ]); function parse(file) { @@ -179,7 +188,30 @@ export function findLaneAcceptingFunctions(files) { } }); if (namesByIndex.size > 0 || positions.size > 0) { - accepting.set(node.name.text, { namesByIndex, positions }); + /* + FNXC:LaneWiring 2026-07-31-01:05: + MERGE on a name collision; a plain `set` let the last declaration win. + + Two different functions legitimately share a name here — core's `computeBlockerFanoutMap` and + the dashboard wrapper that calls it — and their lane options sit at DIFFERENT argument + indices (2 and 1). Overwriting meant core's callers were checked against the wrapper's + signature, so a call passing `terminalColumns` at index 2 was reported unwired. That is the + false positive that makes a ratchet's number noise, and the first person to open one of these + sites learns not to trust it. + + The census keys by name because it has no type resolution, so a collision cannot be told + apart from an overload — union both, and a call satisfying EITHER shape counts. + */ + const existing = accepting.get(node.name.text); + if (existing) { + 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 }); + } } }); } @@ -214,6 +246,50 @@ function unwrapObjectLiteral(node) { return ts.isObjectLiteralExpression(current) ? current : null; } +/* +FNXC:LaneWiring 2026-07-31-00:55: +CONDITIONAL shapes, the third way a wired call site stayed invisible. + +Passing lanes only when they resolved is the CORRECT way to write these — an empty trait index means +"not loaded yet", not "nothing is terminal", so the caller must fall through to the documented legacy +default rather than fabricate one. Both idioms it produces were invisible: + + computeBlockerFanoutMap(tasks, flags ? { columnFlagsByTaskId: flags } : {}) // ternary argument + computeBlockerFanoutMapCore(tasks, N, { ...(flags ? { classify } : {}) }) // conditional spread + +The first is a ConditionalExpression, not an object literal. The second is a SpreadAssignment, whose +`p.name` is undefined. So the census scored three call sites I had just wired as unwired — and would +equally have stayed silent had someone deleted the argument. + +Answer YES if EITHER branch supplies the lane. A branch that omits it is the deliberate +fall-through-to-legacy path, not an unwired call; a call site that supplies it in neither branch is +still counted. +*/ +function objectLiteralsFrom(node) { + let current = node; + while ( + ts.isSatisfiesExpression(current) + || ts.isAsExpression(current) + || ts.isParenthesizedExpression(current) + ) { + current = current.expression; + } + if (ts.isConditionalExpression(current)) { + return [...objectLiteralsFrom(current.whenTrue), ...objectLiteralsFrom(current.whenFalse)]; + } + return ts.isObjectLiteralExpression(current) ? [current] : []; +} + +/** Does this object literal supply one of `wanted`, looking through conditional spreads? */ +function suppliesLane(bag, wanted) { + return bag.properties.some((p) => { + if (ts.isSpreadAssignment(p)) { + return objectLiteralsFrom(p.expression).some((inner) => suppliesLane(inner, wanted)); + } + return p.name && ts.isIdentifier(p.name) && wanted.has(p.name.text) && suppliesAValue(p); + }); +} + /* FNXC:LaneWiring 2026-07-30-23:40: `undefined` IS NOT AN ANSWER, in either position this census checks. @@ -265,11 +341,7 @@ export function findUnwiredCallSites(files, accepting) { const passesOption = node.arguments.some((arg, index) => { const wanted = accepted.namesByIndex.get(index); if (wanted === undefined) return false; - const bag = unwrapObjectLiteral(arg); - return bag !== null - && bag.properties.some( - (p) => p.name && ts.isIdentifier(p.name) && wanted.has(p.name.text) && suppliesAValue(p), - ); + return objectLiteralsFrom(arg).some((bag) => suppliesLane(bag, wanted)); }); const passesPositional = [...accepted.positions].some( (index) => effectiveArgCount(node.arguments) > index,