GitNexus/gitnexus/test/unit/call-attribution-issue-1166.test.ts
Gergő Magyar bd20dde825
fix(taint): report inline and factory handler flows (#3612)
* fix(taint): cover inline and returned handlers (U8)

Join expression-handler CFG summaries to distinct graph functions without changing existing anonymous-owner identities. Keep named function expressions private to their lexical scope.

Related: #3489

* fix(ci): encode inline handler taint paths (U8)

Use codec-safe generated display names while preserving positional function identity. Verify persisted hop continuity and remove the repaired inline-flow accuracy allowance.

* fix: keep anonymous names unbound (U8)

* test: pin inline callback captures (U8)

* ci: run inline taint guard (U8)

* test: align inline handler contracts (U8)

Pin callback-owned call edges while preserving nested callable IDs and module-level HOC attribution. Assert repaired inline flows and the retained captured-closure limitation.

* ci: run native inline proofs on all OSes (U8)

Register real-worker identity/CFG and persisted explain cases on the existing Windows/macOS matrix. Charge explicit scheduling estimates without changing watchdogs or runtime behavior.

* test(taint): reject all enclosing factory sources

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-10-17 20:22:58 +00:00

819 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('keeps a bare HOC call at module level and attributes its callback by position', () => {
// `useCallback(() => doStuff(), [])` at statement level (result thrown
// away) supplies no binding name. The callback has a positional identity
// so its calls belong to it without borrowing an authored name (#3489).
const sites = collectCallAttributions(`
useCallback(() => {
doSomething();
}, []);
`);
// The callback's exact position stays distinct from the module-level call.
expect(findCall(sites, 'doSomething')?.attributedTo).toBe('fn_1_18');
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');
}
});
});