diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 4a869baf6..8e165a837 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -85,14 +85,16 @@ export const TYPESCRIPT_QUERIES = ` value: (function_expression)) @definition.function ; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`. -; Mirrors the four registry-primary patterns in -; \`languages/typescript/query.ts\` so the legacy Call-Resolution DAG and -; the registry-primary pipeline produce the same set of \`Function\` nodes -; — required for the CI parity gate. Covers React.forwardRef / memo / -; useCallback / useMemo / observer / debounce / user-defined HOC -; factories. See \`tsExtractFunctionName\` for the resolution logic and -; the \`query.ts\` comment for the full anchor-discipline rationale and -; the chained-array-method trade-off. +; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\` +; so the legacy Call-Resolution DAG and the registry-primary pipeline +; produce the same set of \`Function\` nodes — required for the CI parity +; gate. Covers React.forwardRef / memo / useCallback / useMemo / observer +; / debounce / user-defined HOC factories. The \`var X = HOC(...)\` form is +; mirrored too (registry-primary has it) so that codebases mixing \`var\` and +; \`const\` see identical attribution on both pipelines. See +; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\` +; comment for the full anchor-discipline rationale and the chained- +; array-method trade-off. (lexical_declaration (variable_declarator name: (identifier) @name @@ -123,6 +125,22 @@ export const TYPESCRIPT_QUERIES = ` arguments: (arguments (function_expression)))))) @definition.function +; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any +; transpiler output that downlevels \`const\` to \`var\`) hits this shape. +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (call_expression + arguments: (arguments + (arrow_function))))) @definition.function + +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (call_expression + arguments: (arguments + (function_expression))))) @definition.function + ; Variable/constant declarations (non-function values). ; Overlap with @definition.function patterns is handled by parse-worker dedup. (lexical_declaration @@ -302,7 +320,9 @@ export const JAVASCRIPT_QUERIES = ` ; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`. ; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166 ; follow-up — covers forwardRef / memo / useCallback / useMemo / observer -; / debounce / user-defined HOC factories). +; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms +; are mirrored so JS code that uses \`var\` (or transpiler output) gets the +; same attribution as the registry-primary path. (lexical_declaration (variable_declarator name: (identifier) @name @@ -333,6 +353,21 @@ export const JAVASCRIPT_QUERIES = ` arguments: (arguments (function_expression)))))) @definition.function +; \`var X = HOC(...)\` parity with registry-primary. +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (call_expression + arguments: (arguments + (arrow_function))))) @definition.function + +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (call_expression + arguments: (arguments + (function_expression))))) @definition.function + ; Variable/constant declarations (non-function values). ; Overlap with @definition.function patterns is handled by parse-worker dedup. (lexical_declaration diff --git a/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts b/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts index af7ac24b9..e39971f80 100644 --- a/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-hoc-wrapped.test.ts @@ -239,4 +239,68 @@ describe('TypeScript HOC-wrapped variable declarations', () => { const functionStrays = stray.filter((c) => c.sourceLabel === 'Function'); expect(functionStrays, 'no other Function sources for doStuff calls').toEqual([]); }); + + // ───────────────────────────────────────────────────────────────── + // Documented limitation: deeply-nested HOCs (`memo(forwardRef(...))`). + // + // The fixture `nested.tsx` documents that the OUTER pattern requires + // the arrow to be a direct grandchild of the const's `call_expression` + // value — when the arrow is wrapped in another `call_expression` + // (`memo(forwardRef(arrow))`), the pattern misses and the deepest + // arrow stays anonymous. The const itself (`Wrapped`) is also NOT a + // Function: the immediate arg of the outer `memo(...)` call is a + // `call_expression` (`forwardRef(...)`), not an arrow / fn-expression, + // so no `@declaration.function` pattern matches the outer shape either. + // + // We assert ABSENCE here (rather than positive resolution) so that any + // future change to the patterns or to `tsExtractFunctionName` that + // accidentally starts matching nested HOCs surfaces immediately. A + // proper fix for nested HOCs would require deciding which level wins + // the name (outer wrapper? deepest behaviour-arrow?) and is out of + // scope for this PR. + // ───────────────────────────────────────────────────────────────── + + it('nested HOCs (memo(forwardRef(...))): Wrapped is NOT a Function (known limitation)', () => { + // The outer const `Wrapped` matches NO `@declaration.function` pattern + // because the outer call's first argument is itself a call_expression, + // not an arrow_function / function_expression. It should be picked up + // as a Variable by `@definition.const` (or skipped entirely) — but it + // must NOT appear as a Function node. + const functions = new Set(getNodesByLabel(result, 'Function')); + expect(functions, 'Wrapped (nested HOC) must NOT be a Function node').not.toContain('Wrapped'); + }); + + it('nested HOCs: helper() call inside the deepest arrow does NOT source from Function:Wrapped', () => { + // Calls inside the doubly-wrapped arrow have no named ancestor (deepest + // arrow is anonymous because `call_expression.parent` is `arguments`, + // not `variable_declarator`; the outer `memo` and `forwardRef` calls + // are themselves anonymous expressions). So calls in `nested.tsx` must + // either source from File or not be attributed to `Wrapped` at all. + // + // The negative assertion is what matters: a future change that wrongly + // attributes the deepest arrow to its outer const would silently corrupt + // impact analysis for any real code that nests HOCs (e.g., + // `memo(forwardRef(...))` UI primitives). + const helperCalls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/nested.tsx' && c.target === 'helper', + ); + expect(helperCalls.length, 'helper call must still be captured').toBeGreaterThan(0); + + const fromWrapped = helperCalls.filter((c) => c.source === 'Wrapped'); + expect( + fromWrapped, + 'helper call must NOT be attributed to Function:Wrapped (deepest arrow stays anonymous)', + ).toEqual([]); + + // Defensive: there should be no Function-sourced edges from anywhere in + // `nested.tsx` (everything is anonymous or module-level). + const allNestedCalls = getRelationships(result, 'CALLS').filter( + (c) => c.sourceFilePath === 'src/nested.tsx', + ); + const functionSourced = allNestedCalls.filter((c) => c.sourceLabel === 'Function'); + expect( + functionSourced, + 'no Function-sourced CALLS from nested.tsx (all anchors should be File)', + ).toEqual([]); + }); }); diff --git a/gitnexus/test/unit/call-attribution-issue-1166.test.ts b/gitnexus/test/unit/call-attribution-issue-1166.test.ts index 5bf15efe9..b7cc09de0 100644 --- a/gitnexus/test/unit/call-attribution-issue-1166.test.ts +++ b/gitnexus/test/unit/call-attribution-issue-1166.test.ts @@ -531,4 +531,57 @@ describe('issue #1166 follow-up — HOC-wrapped variable declarations', () => { `); expect(names).toContain('Legacy'); }); + + // ─── Documented trade-offs: pin behaviour so future readers aren't surprised ─── + + it('accepted false-positive: `const x = arr.find((y) => p(y))` attributes p to "x"', () => { + // The HOC-wrapped pattern (arrow's parent is `arguments`, grandparent is + // `call_expression`, great-grandparent is `variable_declarator`) is broad + // enough to also match chained array-method callbacks like Array#find / + // Array#some / Array#every — where `x` is a *value* (the result of the + // method), not a function. The arrow inside borrows the const's name and + // its inner calls attribute to it. + // + // This is documented in `languages/typescript/query.ts` as an accepted + // trade-off because: + // 1. `x` is never invoked as a function, so no incoming CALLS edge is + // ever created — the spurious `Function:x` is graph-isolated on the + // incoming side. + // 2. The outgoing edge `Function:x → predicate` is a minor mis-attribution + // (the call IS happening, just not from a "function called x"), and + // the alternative — narrowing the pattern to known HOC names — would + // require maintaining a wrapper allowlist that breaks for every new + // HOC factory. + // + // We pin this here so a future change that tightens the pattern is forced + // to update this test and re-evaluate the trade-off explicitly. + const sites = collectCallAttributions(` + const found = items.find((item) => predicate(item)); + `); + expect(findCall(sites, 'predicate')?.attributedTo).toBe('found'); + }); + + it('multi-arrow argument: both arrows resolve to the same const name (legacy DAG path)', () => { + // `const x = call(() => first(), () => second())` — both arrows share the + // same `arguments → call_expression → variable_declarator` ancestor chain, + // so `tsExtractFunctionName`'s third branch returns "x" for each. Calls + // inside both arrows therefore attribute to "x" on the legacy DAG path. + // + // In the registry-primary pipeline the two arrows produce two candidate + // `Function:x` defs that the qualified-name dedup collapses into one + // (last-write-wins by symbol range). The end result is the same set of + // CALLS edges sourced from "x"; only the def's range changes. We pin the + // legacy attribution here because that's what the unit harness exercises. + // + // The pattern is rare in real code (few APIs take two callbacks both + // worth tracking), but it does exist in some `register(setup, teardown)` + // / `addEventListener('event', handler, { once })` shaped helpers. If a + // future change drops one of the calls or attributes it elsewhere, we + // want to know. + const sites = collectCallAttributions(` + const x = call(() => first(), () => second()); + `); + expect(findCall(sites, 'first')?.attributedTo).toBe('x'); + expect(findCall(sites, 'second')?.attributedTo).toBe('x'); + }); });