GitNexus/gitnexus/test/unit/call-attribution-issue-1166.test.ts
Adam B. 7376347058
feat(ingestion): add tRPC procedure detection and MCP chain/route surfacing (#3339)
* feat(ingestion): tRPC pattern detection — procedures, curried calls, route extraction

Three changes to properly index tRPC router files:

1. HOC-in-pair patterns: detect procedures like
   'create: procedure.mutation(async ({ input }) => {...})'
   as named Function nodes (pair > call_expression > arguments > arrow_function)

2. Curried call detection: capture 'workflow(db)(input)' chained calls
   where call_expression.function is itself a call_expression

3. tRPC route extraction: new route-extractors/trpc.ts detects
   .query()/.mutation()/.subscription() procedures, maps to /trpc/* routes
   with prefix inference from router variable names

4. Function/Const dedup: structural check skips Const nodes when
   variable_declarator value is arrow_function/function_expression

5. Process-route linking: match by (filePath, methodName) instead of
   filePath only, preventing shared flows across procedures in same router

(cherry picked from commit eced15e60a39a181a48bff8c5bbe278d26aa53b8)

* fix(route-extractors): line-by-line scanner for chained tRPC procedures

Original regex only matched direct patterns (list: proc.query()) but not
chained patterns (create: proc.input(z.object({...})).mutation()). Only
41/256 routes were detected on Jurialis.

Rewrote extractTrpcRoutes() as a line-by-line scanner that:
- Detects procedure keys starting with *Procedure builders
- Tracks currentProcedure forward until terminal .query()/.mutation()
- Handles .input() chaining naturally
- Deduplicates via seen Set on procedurePath

Result: 245 routes from 30 routers (was 41), 256 total after re-index.
(cherry picked from commit 49edf953e93e6b67b77932e866fcbfb8d089b1f3)

* feat(mcp): expose tRPC chains via context/query tools

Eight fixes to make the tRPC route->procedure->workflow->sub-workflow chain
visible through the standard MCP tools (context, query) that AI agents use,
instead of requiring raw Cypher queries.

- A context: order incoming/outgoing CALLS test-last, raise LIMIT 30->100
- B query: batched ENTRY_POINT_OF lookup, surface route URL on processes
- C query: mark process_symbols entry point with is_entry_point=true
- D entry-point-scoring: skip UTILITY_PATTERNS (get*/set*) penalty for
  symbols in tRPC router files so framework boost (3.0x) is preserved
- E fts-schema: index Route nodes so /trpc/* URLs are keyword-searchable
- F tools: bump max_symbols default 10->25 to fit procedure->workflow chain
- G context: optional chain_depth (0-3) param walks CALLS edges in both
  directions and returns layered chain field
- H LadybugDB bug workaround: WHERE r.type IN [...] silently drops edges
  on relationship properties; replace with OR chains (7 occurrences in
  local-backend.ts, pdg-impact.ts, graph-queries.ts)

Validated on Jurialis: setProviderCap procedure now appears as caller of
setProviderCapWorkflow, /trpc/cabinet.setProviderCap Route is searchable,
chain_depth=3 returns the layered call graph.

(cherry picked from commit c24df0adce91e80f191a5c5d2e8b14e9da677366)

* feat(mcp): surface is_entry_point + routes in context, raise query limit

context() is the mandatory pre-edit tool (AGENTS.md). Until now an agent
had to issue a separate query() call just to learn whether its symbol is
a process entry point or which HTTP route it handles. These two fields
make context() self-sufficient for the bmad-dev flow.

- context: query STEP_IN_PROCESS now returns p.entryPointId; new
  ENTRY_POINT_OF lookup attributes routes only from processes where the
  symbol is the entry point (middle steps do not own the route) plus any
  direct Route->symbol handler edge (tRPC procedures outside any Process)
- context: new top-level is_entry_point (true only) and routes[] fields
  ({url, method?}), emitted only when non-empty to keep the diff additive
- query: raise default limit 5->10 so dense domains (tRPC action router
  with 20 chains, data-export with 9 flows) surface more of their flow
  set without an explicit param

(cherry picked from commit 004f5b0ba985fcef3dfac5e67a2f2f7262184215)

* docs(mcp): document chain_depth, routes, and entry-point flag; neutralize examples in comments

* fix(mcp): address code-review findings on tRPC entry points, queries, and dispatch

- entry-point scoring: optional-chain framework detection and normalize
  path separators before router-pattern matching (P1 crash on .js routers)
- tRPC extractor: emit controllerName as null (callers resolve via
  lookupClassByName; a router object stringified as name corrupted lookups)
- route dedup: key seenRoutes by method:url so GET/POST pairs survive
- legacy TS queries: drop curried-call patterns (arity-corrupting for
  overload resolution) and require non-array callee on pair member calls
- registry-primary TS query: mirror the non-array-callee predicate on the
  new pair member-expression patterns (fixes query compile error)
- group tool port: forward chain_depth into per-tool context args

* fix(mcp): nested tRPC router paths, controller-less route binding, query chain_depth

- trpc extractor: brace-depth nesting stack composes sibling router paths (bare router() import style included); merge-prefix dot normalization; strict publicProcedure allowlist gate; drop phantom router metadata

- call-processor: bind controller-less tRPC routes to same-file handlers via exact single-match symbol lookup; ambiguous or missing handlers skipped

- query/group query: optional chain_depth (0-3) enriches ranked processes with their context chain; fix _computeContextChain layer docstring

- tree-sitter TS/JS scope queries capture string-key function declarations; tRPC pattern scoring narrowed to server router files

- parse-cache schema bump to v103 for extractor and route-binding changes

- add trpc route extractor regression tests (9 cases incl. sibling nesting and merge prefix)

* Address PR review feedback (#3339)

Close remaining review threads: compact tRPC keys, comment-safe terminals,
quoted-key identifier HOC captures, group-query default alignment, and
LadybugDB label scalars on context chains.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test: pin PARSE_CACHE schema bump to 103 (PR #3339 review fixes)

* fix(ingestion): bind same-name tRPC handlers and surface HANDLES_ROUTE

Same-name procedures resolve by startLine, the extractor keeps nested paths
through multiline schemas, and context/query UNION HANDLES_ROUTE for leaf
procedures that never become Process entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Clamp group query bounds, drop HOC pair false positives, emit tRPC
terminal lines and hyphenated quoted keys, cap chain BFS concurrency,
and restrict the router utility exemption to accessor names.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Score JS/JSX tRPC routers like TypeScript, ignore inner db.query and
unrelated .merge calls, mask regex braces, and assert clamp tests invoke
the query mock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(ingestion,mcp): emit-side HOC callback guards + MCP schema prettier (PR #3339 round 2)

* Address PR review feedback (#3339)

Treat `/` after return-style keywords as a regex, drop the synthetic
appRouter path prefix, and bind the create mutation via an inline callback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Qualify query() docs so is_entry_point is promised only when the entry
symbol is among the search hits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Emit tRPC terminals only at procedure paren depth, drop the filename
prefix on bare appRouter = router(), mask regex after if (), and cap
groupQuery member fan-out.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

De-duplicate context-chain BFS nodes reached through multiple frontier
edges, and bind tRPC identifier callbacks (`.mutation(handler)`) to the
handler symbol instead of the procedure key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Apply LIMIT 50 after DISTINCT neighbors in the context-chain BFS, and
treat the official lowercase `procedure` builder as a tRPC procedure key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Keep the later duplicate tRPC object-literal key so route binding matches
the handler JavaScript actually ships.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Compose same-file identifier-mounted tRPC subrouters so admin: adminRouter
emits the live admin.list path instead of an unprefixed /trpc/list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Memoize tRPC identifier-mount paths so a depth-N chain stays linear, and
gate it with the build-free measure.mjs / baselines.json harness.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Compose identifier mounts without phantom /trpc URLs, bind wrapped and
multiline handlers, fail-close missing bench budgets, and reject query
page bounds before search.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Reject advertised MCP query bounds in group mode and chain_depth
instead of clamping or accepting non-integers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Reject invalid groupContext chain_depth and ignore non-callable
same-file tRPC handler candidates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Memoize live tRPC mount paths so the depth-N chain bench stays linear,
and render invalid group/MCP bounds without throwing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Keep t.merge('prefix.', namedRouter) procedures live by recording a
zero-hop mount so the unmounted-router drop does not hide them.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3339)

Recognize type-annotated `const adminRouter: AppRouter = t.router(`
bindings so identifier mounts still compose.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 09:44:42 +01:00

821 lines
33 KiB
TypeScript

/**
* Regression coverage for issue #1166: CALLS edge collector misses ~75% of
* functions in HOF / callback patterns.
*
* The bug had two distinct roots, both in the funcName fallback used by
* `findEnclosingFunctionId` (parse-worker.ts) and `findEnclosingFunction`
* (call-processor.ts):
*
* const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
* const funcName = efnResult?.funcName ?? genericFuncName(current);
*
* A. `genericFuncName` scanned `arrow_function` / `function_expression`
* children for the first identifier and returned it. For unparenthesized
* arrows like `file => processFile(file)` the first identifier is the
* parameter `file`, so calls inside got attributed to a phantom
* `Function file` ID. The CALLS edges were emitted with a dangling
* sourceId and never showed up in `(:Function)-[:CALLS]->()` queries.
*
* B. `tsExtractFunctionName` only named arrows whose parent was
* `variable_declarator`. Object-property arrows like
* `addItem: (item) => set(...)` (Zustand / TanStack / config objects)
* have a `pair` parent and were treated as anonymous. With no named
* ancestor up to the file, every call inside fell back to the File ID.
*
* These tests pin attribution behavior for both root causes and the common
* patterns from the issue (Zustand store, Promise.all+map, TanStack query).
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TS from 'tree-sitter-typescript';
import JS from 'tree-sitter-javascript';
import {
JAVASCRIPT_QUERIES,
TYPESCRIPT_QUERIES,
} from '../../src/core/ingestion/tree-sitter-queries.js';
import { typescriptProvider } from '../../src/core/ingestion/languages/typescript.js';
import {
getJsParser,
getJsScopeQuery,
} from '../../src/core/ingestion/languages/javascript/query.js';
import {
getTsParser,
getTsScopeQuery,
} from '../../src/core/ingestion/languages/typescript/query.js';
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/captures.js';
import { emitJsScopeCaptures } from '../../src/core/ingestion/languages/javascript/captures.js';
import {
FUNCTION_NODE_TYPES,
genericFuncName,
inferFunctionLabel,
type SyntaxNode,
} from '../../src/core/ingestion/utils/ast-helpers.js';
// ─── Test harness ────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TS_GRAMMAR = (TS as any).typescript as Parameters<Parser['setLanguage']>[0];
function makeParserAndQuery(): { parser: Parser; query: Parser.Query } {
const parser = new Parser();
parser.setLanguage(TS_GRAMMAR);
const query = new Parser.Query(TS_GRAMMAR, TYPESCRIPT_QUERIES);
return { parser, query };
}
/**
* Mirror of the name-resolution slice of `findEnclosingFunctionId`
* (parse-worker.ts) and `findEnclosingFunction` (call-processor.ts).
*
* We deliberately re-implement the parent walk here rather than importing
* the production function: parse-worker.ts is a Worker entry point that
* can't be loaded from the main thread, and the function is private to
* its module. Using the same exported primitives (FUNCTION_NODE_TYPES,
* genericFuncName, provider.methodExtractor.extractFunctionName) means a
* real fix flows through here unchanged.
*/
function attributeCall(callNode: SyntaxNode): { name: string | null; nodeType: string | null } {
let current = callNode.parent;
while (current) {
if (FUNCTION_NODE_TYPES.has(current.type)) {
const efn = typescriptProvider.methodExtractor?.extractFunctionName?.(current);
const funcName = efn?.funcName ?? genericFuncName(current);
// Touch inferFunctionLabel so the import isn't dead — it's the same
// call the real findEnclosingFunctionId makes and we want to keep
// this harness aligned with production.
void inferFunctionLabel(current.type);
if (funcName) return { name: funcName, nodeType: current.type };
}
current = current.parent;
}
return { name: null, nodeType: null };
}
interface CallSite {
calledName: string;
line: number;
attributedTo: string | null;
}
function collectCallAttributions(code: string): CallSite[] {
const { parser, query } = makeParserAndQuery();
const tree = parser.parse(code);
const results: CallSite[] = [];
for (const match of query.matches(tree.rootNode)) {
const captures: Record<string, SyntaxNode> = {};
for (const c of match.captures) captures[c.name] = c.node;
if (!captures['call'] || !captures['call.name']) continue;
const callNode = captures['call'];
const name = captures['call.name'].text;
results.push({
calledName: name,
line: callNode.startPosition.row + 1,
attributedTo: attributeCall(callNode).name,
});
}
return results;
}
function findCall(sites: CallSite[], name: string): CallSite | undefined {
return sites.find((s) => s.calledName === name);
}
// ─── Bug A: genericFuncName must not return parameter identifiers ───────────
describe('issue #1166 — Bug A: anonymous arrows do not borrow parameter names', () => {
it('returns null for arrow_function (would otherwise leak parameter identifier)', () => {
const { parser } = makeParserAndQuery();
// Single-param unparenthesized arrow: `file => processFile(file)`.
// tree-sitter-typescript wires the parameter as a direct identifier
// child of the arrow, so genericFuncName's "first identifier child"
// fallback used to return "file".
const tree = parser.parse('const x = files.map(file => processFile(file));');
let arrow: SyntaxNode | null = null;
const walk = (n: SyntaxNode) => {
if (n.type === 'arrow_function') {
arrow = n;
return;
}
for (let i = 0; i < n.namedChildCount; i++) {
if (arrow) return;
const child = n.namedChild(i);
if (child) walk(child);
}
};
walk(tree.rootNode);
expect(arrow).not.toBeNull();
expect(genericFuncName(arrow!)).toBeNull();
});
it('returns null for function_expression (would otherwise leak named-funexpr identifier)', () => {
const { parser } = makeParserAndQuery();
// `function (x) { return x; }` (anonymous function expression). We
// also accept named expressions like `function inner(x) { ... }`
// returning null here — naming for those flows through the parent
// (variable_declarator / pair / call argument), same as arrows.
const tree = parser.parse('const x = arr.filter(function (x) { return isOk(x); });');
let fnExpr: SyntaxNode | null = null;
const walk = (n: SyntaxNode) => {
if (n.type === 'function_expression') {
fnExpr = n;
return;
}
for (let i = 0; i < n.namedChildCount; i++) {
if (fnExpr) return;
const child = n.namedChild(i);
if (child) walk(child);
}
};
walk(tree.rootNode);
expect(fnExpr).not.toBeNull();
expect(genericFuncName(fnExpr!)).toBeNull();
});
it('attributes call inside `.map(file => fn(file))` to the outer named function, not "file"', () => {
const sites = collectCallAttributions(`
export const processSelectedFiles = async (files: File[]) => {
return Promise.all(files.map(file => processFile(file)));
};
`);
const processFileCall = findCall(sites, 'processFile');
expect(processFileCall, 'processFile call should be captured').toBeDefined();
expect(processFileCall!.attributedTo).toBe('processSelectedFiles');
// Defensive: assert we never produce the bogus parameter-as-name
// attribution for ANY call in this snippet.
for (const s of sites) {
expect(s.attributedTo, `call ${s.calledName} at L${s.line}`).not.toBe('file');
}
});
it('attributes call inside parenthesized single-param arrow to the outer function', () => {
// `(item) => doStuff(item)` — different from unparenthesized `item => ...`
// because the param sits inside `formal_parameters` and isn't a direct
// child. Used to work; we pin it to guard against future regressions.
const sites = collectCallAttributions(`
export const handler = (items: Item[]) => items.forEach((item) => doStuff(item));
`);
const doStuff = findCall(sites, 'doStuff');
expect(doStuff?.attributedTo).toBe('handler');
});
});
// ─── Bug B: object-property arrows take their name from pair.key ────────────
describe('issue #1166 — Bug B: object-property arrows are named by pair.key', () => {
it('attributes call inside `addItem: (item) => fn(item)` to "addItem"', () => {
const sites = collectCallAttributions(`
export const store = {
addItem: (item) => doSomething(item),
fetchData: async () => {
const result = await api.fetch();
return result;
},
};
`);
const doSomething = findCall(sites, 'doSomething');
expect(doSomething?.attributedTo).toBe('addItem');
const fetchCall = findCall(sites, 'fetch');
// `fetch` is in BUILT_INS in typescript.ts, but the harness here doesn't
// filter built-ins — what we care about is the *attribution*, which
// should be 'fetchData', not the file.
expect(fetchCall?.attributedTo).toBe('fetchData');
});
it('handles function_expression pair values (`addItem: function(item) { ... }`)', () => {
const sites = collectCallAttributions(`
export const store = {
addItem: function (item) { doSomething(item); },
};
`);
expect(findCall(sites, 'doSomething')?.attributedTo).toBe('addItem');
});
it('handles string-key pairs (`"add-item": (item) => ...`)', () => {
const sites = collectCallAttributions(`
export const store = {
"add-item": (item) => doSomething(item),
};
`);
expect(findCall(sites, 'doSomething')?.attributedTo).toBe('add-item');
});
it('handles computed property keys gracefully — falls back to outer scope or file', () => {
// Computed keys like `[ACTION_KEY]: (item) => fn(item)` cannot be
// statically named. We don't want to invent a name from inner tokens.
// Either attribute to the enclosing named scope, or to null (file) —
// both are acceptable; what matters is no phantom IDs.
const sites = collectCallAttributions(`
export const buildStore = () => ({
[ACTION_KEY]: (item) => doSomething(item),
});
`);
const attr = findCall(sites, 'doSomething')?.attributedTo;
expect([null, 'buildStore']).toContain(attr);
});
it('handles Zustand-style nested HOF — calls inside addItem attribute to "addItem"', () => {
const sites = collectCallAttributions(`
export const useStore = create<State>()(
devtools(persist((set, get) => ({
addItem: (item) => set((state) => doSomething(state, item)),
fetchData: async () => {
const result = await api.fetch();
return result;
},
}), { name: 'store' }))
);
`);
const doSomething = findCall(sites, 'doSomething');
expect(doSomething, 'doSomething call should be captured').toBeDefined();
expect(doSomething!.attributedTo).toBe('addItem');
const fetchCall = findCall(sites, 'fetch');
expect(fetchCall?.attributedTo).toBe('fetchData');
// `set` and `state` are local to the callback chain. `set` lives in the
// body of the addItem arrow → addItem is the right caller.
const setCall = findCall(sites, 'set');
expect(setCall?.attributedTo).toBe('addItem');
});
it('handles TanStack Query factory — `queryFn: () => api.getUser()` attributes to "queryFn"', () => {
const sites = collectCallAttributions(`
export const useUserQuery = () =>
useQuery({
queryFn: () => api.getUser(),
queryKey: ['user'],
});
`);
const getUser = findCall(sites, 'getUser');
expect(getUser?.attributedTo).toBe('queryFn');
});
it('does not attribute then / setTimeout pair callbacks to the property key', () => {
const sites = collectCallAttributions(`
const o = {
result: promise.then(() => workThen()),
timer: setTimeout(() => workTimer(), 1),
handler: wrap(() => workWrap()),
};
`);
const workThen = findCall(sites, 'workThen');
const workTimer = findCall(sites, 'workTimer');
expect(workThen, 'workThen call should be captured').toBeDefined();
expect(workTimer, 'workTimer call should be captured').toBeDefined();
expect(workThen!.attributedTo).not.toBe('result');
expect(workTimer!.attributedTo).not.toBe('timer');
expect(findCall(sites, 'workWrap')?.attributedTo).toBe('handler');
});
});
// ─── Definition-phase consistency ───────────────────────────────────────────
describe('issue #1166 — definition-phase consistency', () => {
/** Run TYPESCRIPT_QUERIES and return the names captured under @definition.function. */
function definedFunctionNames(code: string): string[] {
const { parser, query } = makeParserAndQuery();
const tree = parser.parse(code);
const out: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'definition.function') isFn = true;
if (c.name === 'name') name = c.node.text;
}
if (isFn && name) out.push(name);
}
return out;
}
it('captures pair-with-arrow as @definition.function so call sourceIds resolve', () => {
const names = definedFunctionNames(`
export const store = {
addItem: (item) => doSomething(item),
fetchData: async () => api.fetch(),
};
`);
// Both addItem and fetchData should appear so that the Function nodes
// exist when calls inside them claim sourceId = Function:file:addItem.
expect(names).toContain('addItem');
expect(names).toContain('fetchData');
});
it('captures pair-with-function-expression as @definition.function', () => {
const names = definedFunctionNames(`
export const store = {
legacy: function (x) { return doStuff(x); },
};
`);
expect(names).toContain('legacy');
});
it('captures string-key pairs (`"add-item": () => ...`)', () => {
const names = definedFunctionNames(`
export const store = {
"add-item": (item) => doSomething(item),
};
`);
expect(names).toContain('add-item');
});
it("captures quoted-key identifier HOC pairs (`'handler': wrap(() => ...)`)", () => {
const names = definedFunctionNames(`
export const store = {
'handler': wrap(() => doSomething()),
};
`);
expect(names).toContain('handler');
});
it('captures tRPC pair HOC create: publicProcedure.mutation(...)', () => {
const names = definedFunctionNames(`
const r = { create: publicProcedure.mutation(async () => {}) };
`);
expect(names).toContain('create');
});
it('captures curried tRPC pair HOC create: publicProcedure.mutation(withAuth(...))', () => {
const names = definedFunctionNames(`
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
`);
expect(names).toContain('create');
});
it('captures quoted-key curried pair HOC create: mutation(withAuth(function () {}))', () => {
const names = definedFunctionNames(`
const r = { 'create': mutation(withAuth(function () { return null; })) };
`);
expect(names).toContain('create');
});
it('does not name value-returning pair callbacks then / setTimeout / Array.from', () => {
const names = definedFunctionNames(`
const o = {
result: promise.then(() => work()),
timer: setTimeout(() => work(), 1),
visible: Array.from(items, x => x),
handler: wrap(() => work()),
create: procedure.mutation(async () => work()),
};
`);
expect(names).not.toContain('result');
expect(names).not.toContain('timer');
expect(names).not.toContain('visible');
expect(names).toContain('handler');
expect(names).toContain('create');
});
// The emitters also enforce the built-in blocklists emit-side so the
// guarantee holds regardless of query-predicate sharing behavior in
// node-tree-sitter 0.21 (implicit-global stringValues across compiled
// queries). This pins the emitter-level contract independently of the
// query-level gates above.
it('does not invent Function declarations for built-in callback registrations (emit-side)', () => {
const declaredPairNames = (src: string, filePath: string): (string | undefined)[] => {
const matches = filePath.endsWith('.js')
? emitJsScopeCaptures(src, filePath)
: emitTsScopeCaptures(src, filePath);
return matches
.filter((m) => m['@declaration.function'] !== undefined)
.map((m) => m['@declaration.name']?.text);
};
const src = `
export const timers = {
timer: setTimeout(() => doSomething(), 100),
later: Promise.resolve().then(() => doSomething()),
ids: Array.from([1, 2], (n) => doSomething(n)),
handler: wrapIt(() => doSomething()),
};
`;
const tsNames = declaredPairNames(src, 'test.ts');
expect(tsNames).not.toContain('timer');
expect(tsNames).not.toContain('later');
expect(tsNames).not.toContain('ids');
expect(tsNames).toContain('handler');
const jsNames = declaredPairNames(src, 'test.js');
expect(jsNames).not.toContain('timer');
expect(jsNames).not.toContain('later');
expect(jsNames).not.toContain('ids');
expect(jsNames).toContain('handler');
});
it('TYPESCRIPT_SCOPE_QUERY names curried tRPC pair HOC mutation(withAuth(arrow))', () => {
const parser = getTsParser('router.ts');
const query = getTsScopeQuery('router.ts');
const tree = parser.parse(`
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
`);
const names: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'declaration.function') isFn = true;
if (c.name === 'declaration.name') name = c.node.text;
}
if (isFn && name) names.push(name);
}
expect(names).toContain('create');
});
it('TYPESCRIPT_SCOPE_QUERY does not name then / setTimeout / Array.from pair values', () => {
const parser = getTsParser('router.ts');
const query = getTsScopeQuery('router.ts');
const tree = parser.parse(`
const o = {
result: promise.then(() => work()),
timer: setTimeout(() => work(), 1),
visible: Array.from(items, x => x),
handler: wrap(() => work()),
create: procedure.mutation(async () => work()),
};
`);
const names: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'declaration.function') isFn = true;
if (c.name === 'declaration.name') name = c.node.text;
}
if (isFn && name) names.push(name);
}
expect(names).not.toContain('result');
expect(names).not.toContain('timer');
expect(names).not.toContain('visible');
expect(names).toContain('handler');
expect(names).toContain('create');
});
it('does not invent names for computed-key pairs (`[K]: () => ...`)', () => {
const names = definedFunctionNames(`
export const store = {
[ACTION_KEY]: (item) => doSomething(item),
};
`);
// Whatever else is captured, we must NOT capture a Function named
// "ACTION_KEY" (it's a value reference, not a property name).
expect(names).not.toContain('ACTION_KEY');
});
it('still captures top-level `const fn = () => ...` (regression)', () => {
const names = definedFunctionNames(`
export const helper = (x: number) => x + 1;
`);
expect(names).toContain('helper');
});
});
// ─── Regression guards: existing patterns still work ────────────────────────
describe('issue #1166 — regression guards', () => {
it('attributes calls in plain helper functions correctly (control)', () => {
const sites = collectCallAttributions(`
export const validateFile = (file: File) => {
return sharedValidateFile(file);
};
export const processFile = async (file: File) => {
const result = validateFile(file);
return fileToDataUrl(file);
};
`);
expect(findCall(sites, 'sharedValidateFile')?.attributedTo).toBe('validateFile');
expect(findCall(sites, 'validateFile')?.attributedTo).toBe('processFile');
expect(findCall(sites, 'fileToDataUrl')?.attributedTo).toBe('processFile');
});
it('attributes calls inside Promise constructor callbacks to the enclosing named arrow', () => {
// `new Promise((resolve, reject) => { ... })` — the callback is anonymous,
// its parent is `arguments`. Walk continues to the outer
// `(file) => new Promise(...)` arrow, which IS named via variable_declarator.
const sites = collectCallAttributions(`
export const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
});
`);
expect(findCall(sites, 'FileReader')?.attributedTo).toBe('fileToDataUrl');
expect(findCall(sites, 'readAsDataURL')?.attributedTo).toBe('fileToDataUrl');
});
it('attributes top-level calls in module-init expressions to the file (no enclosing function)', () => {
// `const useStore = create(...)(...)` — the calls live in the value
// expression of useStore itself, NOT inside any function body. The
// right answer here is "no enclosing function" (file-level). This pins
// that we don't accidentally start treating Variables as Functions.
const sites = collectCallAttributions(`
export const useStore = create<State>()(devtools(persist({}, { name: 'store' })));
`);
expect(findCall(sites, 'create')?.attributedTo).toBeNull();
expect(findCall(sites, 'devtools')?.attributedTo).toBeNull();
expect(findCall(sites, 'persist')?.attributedTo).toBeNull();
});
});
// ─── HOC-wrapped variable declarations (issue #1166 follow-up) ──────────────
describe('issue #1166 follow-up — HOC-wrapped variable declarations', () => {
// The third `tsExtractFunctionName` branch: `arguments → call_expression →
// variable_declarator`. Covers React.forwardRef / memo / useCallback /
// useMemo / observer / debounce — every HOC factory whose result is bound
// to a const. Without this branch, the wrapped arrow had no name and calls
// inside attributed to the file. See `languages/typescript.ts` for the
// resolution logic and `tree-sitter-queries.ts` for the @definition.function
// capture mirror.
it('attributes call inside `const X = forwardRef((p, r) => fn())` to "X"', () => {
// Bare-identifier callee form. The arrow's parent is `arguments`; the
// walker climbs to `call_expression` then to `variable_declarator` and
// returns the const's name.
const sites = collectCallAttributions(`
const Button = forwardRef((props, ref) => {
return doSomething(props);
});
`);
const call = findCall(sites, 'doSomething');
expect(call, 'doSomething call should be captured').toBeDefined();
expect(call!.attributedTo).toBe('Button');
});
it('attributes call inside `const X = React.forwardRef((p, r) => fn())` to "X" (member-expression callee)', () => {
// Member-expression callee form (`React.forwardRef`). The `arguments`-
// parent walk doesn't constrain the function field, so this resolves
// identically to the bare-identifier form.
const sites = collectCallAttributions(`
const Card = React.forwardRef((props, ref) => {
return doStuff(props);
});
`);
expect(findCall(sites, 'doStuff')?.attributedTo).toBe('Card');
});
it('attributes call inside `const X = useCallback(() => fn(), [])` to "X"', () => {
// useCallback / useMemo are the most common HOC-wrapped form in real
// React codebases. The trailing `[deps]` array doesn't affect the walk
// — the arrow is still the first `arguments` child.
const sites = collectCallAttributions(`
const handleClick = useCallback(() => {
sendEvent('click');
}, []);
`);
expect(findCall(sites, 'sendEvent')?.attributedTo).toBe('handleClick');
});
it('attributes call inside `const X = memo((props) => fn())` to "X"', () => {
const sites = collectCallAttributions(`
const Item = memo((props) => {
return render(props);
});
`);
expect(findCall(sites, 'render')?.attributedTo).toBe('Item');
});
it('does NOT name the HOC callback after its sibling (no first-sibling-wins regression)', () => {
// Two HOC-wrapped consts in the same module — each must take its own
// name, not bleed into the first declared. Mirrors the multi-action
// Zustand regression from PR #1175 review applied to HOC patterns.
const sites = collectCallAttributions(`
const handleClick = useCallback(() => {
doA();
}, []);
const handleSubmit = useCallback((value) => {
doB(value);
}, []);
`);
expect(findCall(sites, 'doA')?.attributedTo).toBe('handleClick');
expect(findCall(sites, 'doB')?.attributedTo).toBe('handleSubmit');
});
it('does NOT name a bare statement-level HOC call (unbound result)', () => {
// `useCallback(() => doStuff(), [])` at statement level (result thrown
// away). The walk climbs `arguments → call_expression → expression_statement`,
// which is NOT `variable_declarator`, so the branch returns null and the
// arrow stays anonymous. Calls inside attribute to no enclosing function.
const sites = collectCallAttributions(`
useCallback(() => {
doSomething();
}, []);
`);
// The `useCallback` call itself is module-level → null. The `doSomething`
// call is inside an unnamed arrow → null. Both must NOT borrow a name.
expect(findCall(sites, 'doSomething')?.attributedTo).toBeNull();
expect(findCall(sites, 'useCallback')?.attributedTo).toBeNull();
});
// ─── Definition-phase: HOC-wrapped consts must register as @definition.function ───
function definedFunctionNames(code: string): string[] {
const { parser, query } = makeParserAndQuery();
const tree = parser.parse(code);
const out: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'definition.function') isFn = true;
if (c.name === 'name') name = c.node.text;
}
if (isFn && name) out.push(name);
}
return out;
}
it('captures `const X = HOC((args) => ...)` as @definition.function in TYPESCRIPT_QUERIES', () => {
// The query mirror of the resolver fix. Without these patterns,
// `Function:X` would never enter the registry on the legacy DAG and
// any CALLS edge claiming `Function:X` as source would dangle.
const names = definedFunctionNames(`
const Button = forwardRef((p, r) => render(p));
const Card = React.memo((p) => layout(p));
const handleClick = useCallback(() => doStuff(), []);
const computed = useMemo(() => result(), []);
const debounced = debounce((q) => search(q), 250);
export const Exported = forwardRef((p, r) => render(p));
`);
expect(names).toContain('Button');
expect(names).toContain('Card');
expect(names).toContain('handleClick');
expect(names).toContain('computed');
expect(names).toContain('debounced');
expect(names).toContain('Exported');
});
it('captures `const X = HOC(function (args) { ... })` (function-expression form)', () => {
// Pre-arrow legacy code uses `function () { ... }` instead of `() => ...`.
// The mirror pattern uses `(function_expression)` and must trigger.
const names = definedFunctionNames(`
const Legacy = wrap(function (x) { return doStuff(x); });
`);
expect(names).toContain('Legacy');
});
// ─── Documented trade-offs: pin behaviour so future readers aren't surprised ───
it('does not attribute array-method callbacks to the result binding', () => {
// Array-method callbacks used to borrow the surrounding const name via the
// HOC-wrapped-function path. That produced phantom callers like
// `Function:found -> predicate` even though `found` is the Array#find
// result value, not a callable. The array-method blocklist now suppresses
// that synthetic Function anchor, so inner calls stay anonymous here.
const sites = collectCallAttributions(`
const found = items.find((item) => predicate(item));
`);
expect(findCall(sites, 'predicate')?.attributedTo ?? null).toBeNull();
});
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');
});
});
// ─── JavaScript HOC-wrapped pair values (tRPC / Express-style) ──────────────
describe('issue #1166 — JavaScript HOC-wrapped pair values (tRPC)', () => {
const JS_GRAMMAR = JS as Parameters<Parser['setLanguage']>[0];
function definedJsFunctionNames(code: string): string[] {
const parser = new Parser();
parser.setLanguage(JS_GRAMMAR);
const query = new Parser.Query(JS_GRAMMAR, JAVASCRIPT_QUERIES);
const tree = parser.parse(code);
const out: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'definition.function') isFn = true;
if (c.name === 'name') name = c.node.text;
}
if (isFn && name) out.push(name);
}
return out;
}
function definedJsScopeFunctionNames(code: string): string[] {
const parser = getJsParser('router.js');
const query = getJsScopeQuery('router.js');
const tree = parser.parse(code);
const out: string[] = [];
for (const match of query.matches(tree.rootNode)) {
let isFn = false;
let name: string | undefined;
for (const c of match.captures) {
if (c.name === 'declaration.function') isFn = true;
if (c.name === 'declaration.name') name = c.node.text;
}
if (isFn && name) out.push(name);
}
return out;
}
it('captures create: publicProcedure.mutation as @definition.function in JAVASCRIPT_QUERIES', () => {
const names = definedJsFunctionNames(`
const r = { create: publicProcedure.mutation(async () => {}) };
`);
expect(names).toContain('create');
});
it('captures create: publicProcedure.mutation(withAuth(...)) in JAVASCRIPT_QUERIES', () => {
const names = definedJsFunctionNames(`
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
`);
expect(names).toContain('create');
});
it('captures create: procedure.mutation in JAVASCRIPT_SCOPE_QUERY', () => {
const names = definedJsScopeFunctionNames(`
const r = { create: procedure.mutation(async () => {}) };
`);
expect(names).toContain('create');
});
it('captures create: procedure.mutation(withAuth(...)) in JAVASCRIPT_SCOPE_QUERY', () => {
const names = definedJsScopeFunctionNames(`
const r = { create: procedure.mutation(withAuth(async () => {})) };
`);
expect(names).toContain('create');
});
it('does not name then / setTimeout / Array.from pair values in JS queries', () => {
const src = `
const o = {
result: promise.then(() => work()),
timer: setTimeout(() => work(), 1),
visible: Array.from(items, x => x),
handler: wrap(() => work()),
create: procedure.mutation(async () => work()),
};
`;
for (const names of [definedJsFunctionNames(src), definedJsScopeFunctionNames(src)]) {
expect(names).not.toContain('result');
expect(names).not.toContain('timer');
expect(names).not.toContain('visible');
expect(names).toContain('handler');
expect(names).toContain('create');
}
});
});