diff --git a/scripts/__tests__/check-inert-flag-seams.test.mjs b/scripts/__tests__/check-inert-flag-seams.test.mjs index 4f69700581..238e63355f 100644 --- a/scripts/__tests__/check-inert-flag-seams.test.mjs +++ b/scripts/__tests__/check-inert-flag-seams.test.mjs @@ -26,7 +26,7 @@ import assert from "node:assert/strict"; import ts from "typescript"; -import { collectImportBindings, isRelevantCallSite } from "../check-inert-flag-seams.mjs"; +import { collectImportBindings, isRelevantCallSite, effectiveArgCount } from "../check-inert-flag-seams.mjs"; const DECLARING = "packages/core/src/near-duplicate-canonical.ts"; @@ -160,3 +160,50 @@ test("a plain import records no alias", () => { assert.equal(localAlias.size, 0); assert.equal(importedFrom.get("enqueueMergeQueue"), "@fusion/core"); }); + +/* +FNXC:LifecycleColumnCensus 2026-07-30-23:55: +`undefined` IS NOT AN ANSWER, and counting raw arguments treated it as one. + +`f("KB-1", undefined)` passes the arity check while the callee receives exactly what it received +before. The seam stays inert and the board keeps reading the legacy vocabulary — the gate just stops +saying so. This is what a partial wiring-up produces when the flags are threaded through an +intermediate that has none, and what a mechanical positional edit produces. + +The negatives are the half that keeps this honest: a MIDDLE undefined still positions the real +argument after it, so trimming must stop at the first non-undefined from the right. +*/ +function argsOf(source) { + const sf = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let found = null; + const visit = (node) => { + if (!found && ts.isCallExpression(node)) found = node.arguments; + ts.forEachChild(node, visit); + }; + visit(sf); + return found; +} + +test("a trailing `undefined` argument supplies nothing", () => { + assert.equal(effectiveArgCount(argsOf('f("KB-1", undefined);')), 1); +}); + +test("`void 0` is the same omission spelled differently", () => { + assert.equal(effectiveArgCount(argsOf('f("KB-1", void 0);')), 1); +}); + +test("several trailing undefineds all collapse", () => { + assert.equal(effectiveArgCount(argsOf('f("KB-1", undefined, undefined);')), 1); +}); + +test("a real trailing argument still counts", () => { + assert.equal(effectiveArgCount(argsOf('f("KB-1", flags);')), 2); +}); + +test("a MIDDLE undefined positions the argument after it, which is real", () => { + assert.equal(effectiveArgCount(argsOf('f("KB-1", undefined, flags);')), 3); +}); + +test("a call with no arguments is unchanged", () => { + assert.equal(effectiveArgCount(argsOf("f();")), 0); +}); diff --git a/scripts/check-inert-flag-seams.mjs b/scripts/check-inert-flag-seams.mjs index 0c55c512c7..d50e5411af 100644 --- a/scripts/check-inert-flag-seams.mjs +++ b/scripts/check-inert-flag-seams.mjs @@ -39,6 +39,39 @@ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PACKAGES = join(REPO, "packages"); const SKIP_DIRS = new Set(["node_modules", "dist", "__tests__", "__mocks__", "e2e", ".gate-bundle", "coverage"]); const TRAILING_FLAG_PARAM = /([Cc]olumnFlags|[Ll]ifecycleColumns|[Rr]eviewColumns|[Tt]erminalColumns|[Pp]lannerLanes)$/; + +/* +FNXC:LifecycleColumnCensus 2026-07-30-23:40: +PASSING `undefined` FOR THE LANE ANSWER IS NOT SUPPLYING IT. + +The seam is a trailing optional parameter, so this gate asked how many ARGUMENTS each call site +passes. A call that spells the omission out — + + resolveSomething("KB-1", undefined) + +— satisfies that count while the callee receives exactly what it received before: nothing. The +parameter is still inert and the board still reads the legacy vocabulary. + +That spelling is not exotic. It is what a partial wiring-up produces when the flags are threaded +through an intermediate that has none to pass, and what a mechanical edit produces when it fills +argument slots positionally. Either way the gate reported the seam as answered. + +Missed by BOTH gates: check-lane-wiring (#2966) covers the default-valued and options-object shapes +this one is structurally blind to, but it counts arguments the same way here. Found by probing my own +gate with shapes I had not designed it for — the discipline argued for in #2979. + +TRAILING ONLY. A middle `undefined` still positions the arguments after it, so those are real answers. +*/ +const isUndefinedArgument = (arg) => + (ts.isIdentifier(arg) && arg.text === "undefined") || ts.isVoidExpression(arg); + +/** Arguments that actually carry a value, ignoring trailing `undefined` / `void 0` placeholders. */ +export function effectiveArgCount(args) { + let count = args.length; + while (count > 0 && isUndefinedArgument(args[count - 1])) count -= 1; + return count; +} + /** Unanchored twin used only to skip files fast; see the note at the call site. */ const PREFILTER = /(olumnFlags|ifecycleColumns|eviewColumns|erminalColumns|lannerLanes)/; @@ -220,7 +253,7 @@ for (const file of walkAll(PACKAGES)) { if (!callSites.has(target)) callSites.set(target, []); callSites.get(target).push({ file: relative(REPO, file), - args: node.arguments.length, + args: effectiveArgCount(node.arguments), shadowed: locallyDeclared.has(callee), from: importedFrom.get(callee), viaProperty: ts.isPropertyAccessExpression(node.expression),