diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 60a15b752..d363c3c97 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -740,18 +740,30 @@ function findExportByName( defs: readonly SymbolDefinition[], name: string, ): SymbolDefinition | undefined { - // Languages like TypeScript can emit MULTIPLE `SymbolDefinition`s with - // the same simple name from a single declaration: `const fn = () => - // {}` produces both a `Function` def (from `@declaration.function` on - // the inner arrow) AND a `Variable` def (from the generic - // `@declaration.variable` pattern matching the wrapping - // `lexical_declaration`). Both end up in `localDefs`. The CALLER who - // writes `import { fn }` wants the callable, not the variable - // shadow — without a preference rule, capture order silently decides - // which def the import binds to, which broke cross-file CALLS edges - // for arrow-typed exports (see `typescript-hof-callbacks.test.ts`). + // GENERIC RULE (applies to every language using this finalize + // algorithm): when MULTIPLE `SymbolDefinition`s share the same simple + // name in `localDefs`, prefer callable / type-like defs over plain + // value defs (`Variable`, `Property`, …). The CALLER side of an + // import almost always wants the callable, not a value shadow that + // happens to share the name — and without a deterministic + // preference, capture order silently decides which def the import + // binds to. // - // Prefer callable / class-like defs; fall back to first-name-match. + // The single-def case is unchanged: when only one def has the name, + // it's returned regardless of its type (the `fallback` path below). + // + // TypeScript is the first known language where this matters in + // practice: `const fn = () => {}` emits BOTH a `Function` def (from + // `@declaration.function` on the inner arrow) AND a `Variable` def + // (from the generic `@declaration.variable` pattern matching the + // wrapping `lexical_declaration`), and consumers of `import { fn }` + // need to bind to the callable. Other migrated languages don't + // currently produce dual emits of this shape, so the rule is a no-op + // for them today; future languages get the same correctness + // guarantee for free if they ever do. + // + // See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts` + // for the cross-file regression this rule prevents. let fallback: SymbolDefinition | undefined; for (const d of defs) { if (deriveSimpleName(d) !== name) continue; diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 7af2ccc9d..9d083c15c 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -240,6 +240,20 @@ export function emitTsScopeCaptures( // arity filter can narrow overloads. Count the `argument` named // children of the backing `arguments` node. TypeScript constructor // calls use `new_expression`; regular calls use `call_expression`. + // + // JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element` + // captured by the TSX-only suffix in `query.ts`) intentionally do + // NOT carry arity metadata. The lookup below would resolve `callNode` + // to `null` for a JSX anchor (the anchor is neither a call_expression + // nor a new_expression), so the synthesis branch silently no-ops and + // the JSX call enters the registry with name-only resolution. This + // is acceptable for React: components are virtually never + // overloaded in the current GitNexus graph model, so name-only + // dispatch matches the single component definition. If a future + // codebase introduces overloaded React components AND needs JSX + // calls to disambiguate by props-arity, a JSX-aware arity + // synthesizer would need to count `jsx_attribute` children of the + // opening tag instead of `arguments`. const callAnchor = pickFirstDefined(grouped, CALL_TAGS); if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { const callNode = diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 0459ef5d3..d97edeb2f 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -823,10 +823,7 @@ export function getTsParser(filePath?: string): Parser { export function getTsScopeQuery(filePath?: string): Parser.Query { if (filePath !== undefined && isTsxFile(filePath)) { if (_tsxQuery === null) { - _tsxQuery = new Parser.Query( - TSX_GRAMMAR, - TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX, - ); + _tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY + TSX_JSX_QUERY_SUFFIX); } return _tsxQuery; } diff --git a/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts index ec875fdaa..a7c64213b 100644 --- a/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts @@ -48,10 +48,7 @@ describe('TypeScript HOF-callback CALLS edges', () => { let result: PipelineResult; beforeAll(async () => { - result = await runPipelineFromRepo( - path.join(FIXTURES, 'typescript-hof-callbacks'), - () => {}, - ); + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-hof-callbacks'), () => {}); }, 60000); it('control: direct (x) => transform(x) emits direct → transform', () => { @@ -108,21 +105,30 @@ describe('TypeScript HOF-callback CALLS edges', () => { expect(fromCreate, 'create() must not be a phantom caller').toEqual([]); }); - it('Zustand call expressions are attributable (to File or absent — never to a wrong sibling)', () => { - // The complement check: if any CALLS edge is emitted for the - // module-scope calls in store.ts, its source must be either - // `store.ts` (the File fallback) or undefined. We accept zero - // edges here as a valid outcome; the strict assertion is the - // anti-self-loop one above. + it('Zustand module-level calls source from the File node (not a sibling Function)', () => { + // The positive complement to the anti-self-loop assertion above: + // module-level calls in `store.ts` (`create()`, `devtools(...)`, + // `persist(...)`) MUST attribute to the `File` node — that's the + // entire point of `isCallerAnchorLabel` excluding `Variable` from + // the caller-walk fallback. If the fix regresses (Variable defs + // re-enter the fallback, or the walk-up grabs a sibling Function), + // the source would change away from `File:store.ts`. + // + // Earlier formulation iterated `for (c of calls)` and asserted each + // edge sourced from File. That passed VACUOUSLY when `calls` was + // empty — any change that silenced ALL CALLS edges from `store.ts` + // would have slipped through. The structural assertion below is + // explicit: at least one File-rooted edge must exist (proving the + // fallback fired), and no edge may source from anything else + // (proving the fallback fired EXCLUSIVELY, not as one option + // alongside a buggy sibling-Function attribution). const calls = getRelationships(result, 'CALLS').filter( (c) => c.sourceFilePath === 'src/store.ts', ); - for (const c of calls) { - // Source must NOT be a sibling local Function. The only - // acceptable source for module-level calls in store.ts is the - // File node itself (label 'File', name 'store.ts'). - expect([c.sourceLabel, c.source]).toEqual(['File', 'store.ts']); - } + const fromFile = calls.filter((c) => c.sourceLabel === 'File' && c.source === 'store.ts'); + const fromOther = calls.filter((c) => !(c.sourceLabel === 'File' && c.source === 'store.ts')); + expect(fromOther, 'no module-level call may attribute to a non-File source').toEqual([]); + expect(fromFile.length, 'at least one File-rooted call edge must exist').toBeGreaterThan(0); }); it('transform is reachable from at least 3 of {direct, fanOut, wrap}', () => { diff --git a/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts index dfeaff770..d1fef4559 100644 --- a/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-jsx-as-call.test.ts @@ -35,10 +35,7 @@ describe('TypeScript JSX-as-call CALLS edges', () => { let result: PipelineResult; beforeAll(async () => { - result = await runPipelineFromRepo( - path.join(FIXTURES, 'typescript-jsx-as-call'), - () => {}, - ); + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-jsx-as-call'), () => {}); }, 60000); it('self-closing emits useFoo → Foo', () => { @@ -110,14 +107,22 @@ describe('TypeScript JSX-as-call CALLS edges', () => { expect(accesses).toEqual([]); }); - it('combined HOF + JSX: const Wrapped = () => emits Wrapped → Foo', () => { + it('combined HOF + JSX: const Wrapped = () => emits exactly one Wrapped → Foo', () => { // Probes the interaction between the HOF-callback caller-attribution // fix and the JSX-as-call fix. Pre-this-PR: caller mis-attribution // (HOF bug) plus invisible JSX (this fix's bug) both broke this // case. Post-PR: both are fixed and the edge lands. + // + // Asserts EXACTLY ONE edge: a single self-closing `` is one + // logical invocation. If the JSX query suffix ever accidentally + // double-matched the same site (e.g. both + // `jsx_self_closing_element` and a generic call pattern firing, or + // both an opening-tag and a closing-tag capture), this would catch + // the regression — duplicate CALLS edges silently inflate + // blast-radius counts in `gitnexus_impact`. const calls = getRelationships(result, 'CALLS').filter( (c) => c.source === 'Wrapped' && c.target === 'Foo', ); - expect(calls.length).toBeGreaterThan(0); + expect(calls).toHaveLength(1); }); });