devlensio 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/graph/edges/callEdges.js +97 -5
  2. package/dist/graph/edges/callEdges.test.js +154 -0
  3. package/dist/parser/extractors/classes.js +23 -4
  4. package/dist/parser/extractors/functions.d.ts +10 -3
  5. package/dist/parser/extractors/functions.js +135 -65
  6. package/dist/parser/extractors/hooks.js +5 -29
  7. package/dist/parser/extractors/objectMethods.js +3 -4
  8. package/dist/parser/index.js +11 -6
  9. package/dist/parser/overloads.d.ts +2 -0
  10. package/dist/parser/overloads.js +96 -0
  11. package/dist/parser/overloads.test.d.ts +1 -0
  12. package/dist/parser/overloads.test.js +175 -0
  13. package/dist/parser/typeUtils.d.ts +2 -0
  14. package/dist/parser/typeUtils.js +2 -0
  15. package/dist/types.d.ts +6 -0
  16. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  17. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  18. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  19. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  20. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  21. package/extractors/go/calls.go +134 -2
  22. package/extractors/go/contract.go +1 -0
  23. package/extractors/go/exports.go +107 -0
  24. package/extractors/go/exports_test.go +64 -0
  25. package/extractors/go/extractor.go +1 -0
  26. package/extractors/go/lookup.go +63 -7
  27. package/extractors/go/nodes.go +28 -0
  28. package/extractors/go/parser.go +51 -8
  29. package/extractors/java/devlens_java_extractor.jar +0 -0
  30. package/extractors/java/src/devlens/extractor/ExportsMapBuilder.java +148 -0
  31. package/extractors/java/src/devlens/extractor/Extractor.java +34 -0
  32. package/extractors/java/src/devlens/extractor/LookupMaps.java +12 -0
  33. package/extractors/java/src/devlens/extractor/Overloads.java +290 -0
  34. package/extractors/java/src/devlens/extractor/Parser.java +39 -0
  35. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +9 -0
  36. package/extractors/java/src/devlens/extractor/edges/Calls.java +42 -36
  37. package/extractors/java/src/devlens/extractor/edges/Routes.java +10 -3
  38. package/extractors/python/devlens_extractors_python/edges/calls.py +127 -26
  39. package/extractors/python/devlens_extractors_python/exports_map.py +263 -0
  40. package/extractors/python/devlens_extractors_python/extractor.py +7 -1
  41. package/extractors/python/devlens_extractors_python/lookup.py +6 -0
  42. package/extractors/python/devlens_extractors_python/parser/__init__.py +6 -0
  43. package/extractors/python/devlens_extractors_python/parser/functions.py +103 -6
  44. package/extractors/python/devlens_extractors_python/parser/overloads.py +105 -0
  45. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  46. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  47. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  48. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  49. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  50. package/extractors/rust/src/calls.rs +27 -16
  51. package/extractors/rust/src/contract.rs +2 -0
  52. package/extractors/rust/src/exports.rs +387 -0
  53. package/extractors/rust/src/exports_test.rs +105 -0
  54. package/extractors/rust/src/extractor.rs +2 -0
  55. package/extractors/rust/src/lookup.rs +60 -0
  56. package/extractors/rust/src/main.rs +4 -0
  57. package/extractors/rust/src/nodes.rs +19 -0
  58. package/extractors/rust/src/parser.rs +66 -1
  59. package/extractors/rust/src/routes.rs +2 -1
  60. package/package.json +1 -1
@@ -1,8 +1,89 @@
1
1
  import { closestByPath } from "./utils.js";
2
+ // Arity compatibility: a candidate matches when the call provides at least all
3
+ // required (non-optional, non-rest) params, and does not exceed the declared
4
+ // count unless the candidate declares a rest parameter. Candidates without
5
+ // parameters metadata are permissive (unknown shape).
6
+ function arityMatches(candidate, argCount) {
7
+ const parameters = candidate.metadata?.parameters;
8
+ const params = candidate.metadata?.params;
9
+ if (!params && !parameters)
10
+ return true;
11
+ const total = parameters?.length ?? params?.length ?? 0;
12
+ const hasRest = parameters?.some((p) => p.isRest) ?? false;
13
+ const required = parameters
14
+ ? parameters.filter((p) => !p.isOptional && !p.isRest).length
15
+ : total;
16
+ return argCount >= required && (hasRest || argCount <= total);
17
+ }
18
+ // Loose type agreement between a call-site arg type tag and a declared param
19
+ // type text. Both sides are cheap/heuristic (no type checker), so this scores
20
+ // rather than gates: unknown tags never match, text containment counts.
21
+ function typeAgrees(argType, paramType) {
22
+ if (!argType || argType === "unknown" || !paramType)
23
+ return false;
24
+ const p = paramType.trim();
25
+ return p === argType || p.includes(argType);
26
+ }
27
+ function typeScore(candidate, argTypes) {
28
+ const parameters = candidate.metadata?.parameters;
29
+ if (!parameters)
30
+ return 0;
31
+ let score = 0;
32
+ for (let i = 0; i < argTypes.length; i++) {
33
+ if (parameters[i] && typeAgrees(argTypes[i], parameters[i].type))
34
+ score++;
35
+ }
36
+ return score;
37
+ }
38
+ // Picks the right overload among same-name candidates using the call site's
39
+ // argument shape. Ladder:
40
+ // 1. no usable call shape (USES edges, spread calls, missing callSites)
41
+ // → legacy closestByPath behavior
42
+ // 2. arity filter — survivors whose declared params accept argCount
43
+ // 3. type tiebreak — among arity survivors, prefer the candidate with the
44
+ // highest arg-type agreement (handles same arity, different types)
45
+ // 4. still ambiguous → closestByPath among survivors
46
+ function resolveOverloadTarget(candidates, caller, callSite) {
47
+ if (candidates.length === 1)
48
+ return { target: candidates[0], matchedBy: "name" };
49
+ const usableShape = callSite
50
+ && typeof callSite.argCount === "number"
51
+ && !callSite.hasSpread;
52
+ if (!usableShape)
53
+ return { target: closestByPath(candidates, caller.filePath), matchedBy: "name" };
54
+ let pool = candidates.filter((c) => arityMatches(c, callSite.argCount));
55
+ if (pool.length === 0)
56
+ pool = candidates; // nothing accepts this arity — keep all
57
+ if (pool.length === 1)
58
+ return { target: pool[0], matchedBy: "arity" };
59
+ const argTypes = callSite.argTypes ?? [];
60
+ if (argTypes.some((t) => t && t !== "unknown")) {
61
+ let best = pool[0];
62
+ let bestScore = typeScore(pool[0], argTypes);
63
+ let tieAtBest = 1;
64
+ for (let i = 1; i < pool.length; i++) {
65
+ const s = typeScore(pool[i], argTypes);
66
+ if (s > bestScore) {
67
+ best = pool[i];
68
+ bestScore = s;
69
+ tieAtBest = 1;
70
+ }
71
+ else if (s === bestScore)
72
+ tieAtBest++;
73
+ }
74
+ if (bestScore > 0 && tieAtBest === 1) {
75
+ return { target: best, matchedBy: "signature" };
76
+ }
77
+ }
78
+ return { target: closestByPath(pool, caller.filePath), matchedBy: "name" };
79
+ }
2
80
  export function detectCallEdges(nodes, lookupMp) {
3
81
  const edges = [];
4
82
  // Dedup for THIRD_PARTY CALLS edges — one per (caller, target) pair
5
83
  const createdThirdPartyEdges = new Set();
84
+ // Dedup for local CALLS/USES edges — overload call sites can resolve to
85
+ // the same target through different (name, arity) pairs
86
+ const createdLocalEdges = new Set();
6
87
  // Accumulate resolved calls per node — written back to metadata after edge loop
7
88
  const resolvedCallsMap = new Map();
8
89
  // Lazily-created method nodes for default/namespace import member-access calls
@@ -34,7 +115,16 @@ export function detectCallEdges(nodes, lookupMp) {
34
115
  if (!hasPrimary && !hasHooks)
35
116
  continue;
36
117
  // ── Primary names: both third-party and local edges ──────────────
37
- for (const calledName of (primaryNames ?? [])) {
118
+ // Structured callSites carry the argument shape for overload-aware
119
+ // resolution; fall back to bare calls/uses strings when absent.
120
+ const callSites = node.metadata.callSites;
121
+ // Each (name, arity) call site iterates separately — a caller that
122
+ // invokes two overloads of one name gets two edges.
123
+ const primaryCallSites = (callSites && callSites.length > 0)
124
+ ? callSites
125
+ : (primaryNames ?? []).map((name) => ({ name, argCount: -1 }));
126
+ for (const callSite of primaryCallSites) {
127
+ const calledName = callSite.name;
38
128
  // ── Third-party guard ─────────────────────────────────────────
39
129
  // The alias map is keyed by node.filePath (relative) and populated
40
130
  // by importEdges.ts (which runs first).
@@ -120,16 +210,18 @@ export function detectCallEdges(nodes, lookupMp) {
120
210
  }
121
211
  if (!targets || targets.length === 0)
122
212
  continue;
123
- const target = targets.length === 1
124
- ? targets[0]
125
- : closestByPath(targets, node.filePath);
213
+ const { target, matchedBy } = resolveOverloadTarget(targets, node, callSite.argCount >= 0 ? callSite : undefined);
126
214
  if (target.id === node.id)
127
215
  continue; // skip self-reference
216
+ const localEdgeKey = `${node.id}→${target.id}:${edgeType}`;
217
+ if (createdLocalEdges.has(localEdgeKey))
218
+ continue;
219
+ createdLocalEdges.add(localEdgeKey);
128
220
  edges.push({
129
221
  from: node.id,
130
222
  to: target.id,
131
223
  type: edgeType,
132
- metadata: { calledName: resolvedName },
224
+ metadata: { calledName: resolvedName, matchedBy },
133
225
  });
134
226
  if (!resolvedCallsMap.has(node.id))
135
227
  resolvedCallsMap.set(node.id, []);
@@ -198,3 +198,157 @@ describe("detectCallEdges with class methods", () => {
198
198
  deleteFakeRepo(repo);
199
199
  });
200
200
  });
201
+ // ─── Overload-aware resolution (same-name candidates in one file) ─────────────
202
+ describe("detectCallEdges with overloads", () => {
203
+ // Arity disambiguates same-name functions in one file
204
+ it("routes each call to the overload matching its argument count", () => {
205
+ const repo = createFakeRepo({
206
+ "src/fmt.ts": `
207
+ export function formatValue(v: string) { return v.trim(); }
208
+ export function formatValue(v: string, n: number) { return v.repeat(n); }
209
+ export function caller() {
210
+ formatValue("a");
211
+ formatValue("a", 3);
212
+ }
213
+ `,
214
+ });
215
+ const { edges } = detectCalls(repo);
216
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "formatValue");
217
+ const targets = callEdges.map((e) => e.to).sort();
218
+ expect(targets).toEqual([
219
+ "src/fmt.ts::formatValue#1",
220
+ "src/fmt.ts::formatValue#2",
221
+ ]);
222
+ // resolution confidence recorded on the edge
223
+ expect(callEdges.every((e) => e.metadata?.matchedBy === "arity")).toBe(true);
224
+ deleteFakeRepo(repo);
225
+ });
226
+ // Same arity, different param types — literal arg types break the tie
227
+ it("resolves same-arity overloads via argument type tags", () => {
228
+ const repo = createFakeRepo({
229
+ "src/dec.ts": `
230
+ export function decode(input: string, mode: string) { return input; }
231
+ export function decode(input: number, mode: string) { return String(input); }
232
+ export function caller() {
233
+ decode("abc", "utf8");
234
+ decode(42, "utf8");
235
+ }
236
+ `,
237
+ });
238
+ const { edges } = detectCalls(repo);
239
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "decode");
240
+ // first call (string literal) → string overload; second (number literal) → number overload
241
+ const stringTargets = callEdges.filter((e) => e.metadata?.matchedBy === "signature").map((e) => e.to).sort();
242
+ expect(stringTargets.length).toBe(2);
243
+ expect(stringTargets[0]).not.toBe(stringTargets[1]);
244
+ deleteFakeRepo(repo);
245
+ });
246
+ // A caller can invoke two overloads of one name — two distinct edges
247
+ it("emits one edge per overload call site, not one per name", () => {
248
+ const repo = createFakeRepo({
249
+ "src/multi.ts": `
250
+ export function send(x: string) { return x; }
251
+ export function send(x: string, y: string) { return x + y; }
252
+ export function client() {
253
+ send("a");
254
+ send("a", "b");
255
+ }
256
+ `,
257
+ });
258
+ const { edges } = detectCalls(repo);
259
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "send");
260
+ expect(callEdges.length).toBe(2);
261
+ expect(new Set(callEdges.map((e) => e.to)).size).toBe(2);
262
+ deleteFakeRepo(repo);
263
+ });
264
+ // Spread calls have unknowable arity — must NOT confidently pick an overload
265
+ it("falls back to path proximity for spread calls", () => {
266
+ const repo = createFakeRepo({
267
+ "src/spread.ts": `
268
+ export function combine(a: string) { return a; }
269
+ export function combine(a: string, b: string) { return a + b; }
270
+ export function caller(...args: string[]) {
271
+ return combine(...args);
272
+ }
273
+ `,
274
+ });
275
+ const { edges } = detectCalls(repo);
276
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "combine");
277
+ expect(callEdges.length).toBe(1);
278
+ expect(callEdges[0].metadata?.matchedBy).toBe("name");
279
+ deleteFakeRepo(repo);
280
+ });
281
+ // Optional params: a 1-arg call may hit a 2-param overload with optional 2nd
282
+ it("lets optional parameters accept shorter calls via arity fallback", () => {
283
+ const repo = createFakeRepo({
284
+ "src/opt.ts": `
285
+ export function render(template: string, opts?: object) { return template; }
286
+ export function render(template: string, opts: object, mode: string) { return template; }
287
+ export function caller() {
288
+ render("t");
289
+ }
290
+ `,
291
+ });
292
+ const { edges } = detectCalls(repo);
293
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "render");
294
+ // 1-arg call: renderStrict requires 3 → excluded; render (optional) → match
295
+ expect(callEdges.length).toBe(1);
296
+ expect(callEdges[0].to).toBe("src/opt.ts::render#2");
297
+ deleteFakeRepo(repo);
298
+ });
299
+ // Rest params accept any extra arguments
300
+ it("lets rest parameters accept longer calls", () => {
301
+ const repo = createFakeRepo({
302
+ "src/rest.ts": `
303
+ export function sum(first: number, ...rest: number[]) { return first; }
304
+ export function pair(a: number, b: number) { return a; }
305
+ export function caller() {
306
+ sum(1, 2, 3, 4);
307
+ }
308
+ `,
309
+ });
310
+ const { edges } = detectCalls(repo);
311
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "sum");
312
+ expect(callEdges.length).toBe(1);
313
+ expect(callEdges[0].to).toContain("sum");
314
+ deleteFakeRepo(repo);
315
+ });
316
+ // Legacy behavior preserved when no overload shape exists
317
+ it("still resolves plain single-candidate calls without callSites metadata", () => {
318
+ const repo = createFakeRepo({
319
+ "src/plain.ts": `
320
+ export function helper(x: number) { return x; }
321
+ export function caller() { return helper(1); }
322
+ `,
323
+ });
324
+ const { edges } = detectCalls(repo);
325
+ expect(edges).toContainEqual(expect.objectContaining({
326
+ from: "src/plain.ts::caller",
327
+ to: "src/plain.ts::helper",
328
+ type: "CALLS",
329
+ }));
330
+ deleteFakeRepo(repo);
331
+ });
332
+ // Class method overloads resolve through dotted names
333
+ it("resolves class method overloads by arity through dotted names", () => {
334
+ const repo = createFakeRepo({
335
+ "src/svc.ts": `
336
+ export class Repo {
337
+ find(id: string) { return id; }
338
+ find(id: string, depth: number) { return id; }
339
+ }
340
+ export function caller() {
341
+ Repo.find("a");
342
+ Repo.find("a", 2);
343
+ }
344
+ `,
345
+ });
346
+ const { edges } = detectCalls(repo);
347
+ const callEdges = edges.filter((e) => e.type === "CALLS" && e.metadata?.calledName === "Repo.find");
348
+ expect(callEdges.map((e) => e.to).sort()).toEqual([
349
+ "src/svc.ts::Repo.find#1",
350
+ "src/svc.ts::Repo.find#2",
351
+ ]);
352
+ deleteFakeRepo(repo);
353
+ });
354
+ });
@@ -17,7 +17,7 @@
17
17
  // Out of scope (deliberate): INTERFACE / ENUM / STRUCT nodes for JS/TS.
18
18
  import { SyntaxKind } from "ts-morph";
19
19
  import { detectFunctionDirective } from "../directives.js";
20
- import { extractFunctionCalls, extractHookCalls, extractApiCalls, hasErrorHandling, extractThrowStatements, } from "./functions.js";
20
+ import { extractCallsWithSites, hasErrorHandling, extractThrowStatements, } from "./functions.js";
21
21
  import { extractParams, extractReturnTypeAnnotation, extractBareTypeNames, extractReferencedInterfaces, } from "../typeUtils.js";
22
22
  // React lifecycle methods (incl. render) — flagged so consumers can tell
23
23
  // framework-registered callbacks apart from ordinary methods.
@@ -79,6 +79,12 @@ function rewriteThisCalls(calls, className, methodNames) {
79
79
  return `${className}.${rest.replace(/^#/, "")}`;
80
80
  });
81
81
  }
82
+ // Same rewrite as rewriteThisCalls but for structured CallSite records —
83
+ // the name must match the dotted METHOD node names for overload-aware
84
+ // resolution in callEdges.
85
+ function rewriteThisCallSites(callSites, className, methodNames) {
86
+ return callSites.map((cs) => ({ ...cs, name: rewriteThisCalls([cs.name], className, methodNames)[0] }));
87
+ }
82
88
  function extractDecoratorNames(cls) {
83
89
  if (typeof cls.getDecorators !== "function")
84
90
  return [];
@@ -96,9 +102,8 @@ function buildMethodNode(file, className, methodName, method, methodNameSet, fil
96
102
  const filePath = file.getFilePath();
97
103
  const dotted = `${className}.${methodName}`;
98
104
  const typedParams = extractParams(method);
99
- const calls = extractFunctionCalls(method);
100
- const hookCalls = extractHookCalls(method);
101
- const apiCalls = extractApiCalls(method);
105
+ const { calls, callSites: rawSites, hookCalls, apiCalls } = extractCallsWithSites(method);
106
+ const callSites = rewriteThisCallSites(rawSites, className, methodNameSet);
102
107
  const isAsync = typeof method.isAsync === "function" && method.isAsync();
103
108
  const hasErrors = hasErrorHandling(method);
104
109
  const throws = extractThrowStatements(method);
@@ -106,6 +111,7 @@ function buildMethodNode(file, className, methodName, method, methodNameSet, fil
106
111
  const returnType = extractReturnTypeAnnotation(method);
107
112
  const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
108
113
  const referencedTypes = extractReferencedInterfaces(file, bareTypeNames);
114
+ const isOverloadSignature = typeof method.getBody === "function" ? !method.getBody() : false;
109
115
  return {
110
116
  id: makeId(filePath, dotted),
111
117
  name: dotted,
@@ -121,6 +127,8 @@ function buildMethodNode(file, className, methodName, method, methodNameSet, fil
121
127
  returnType,
122
128
  referencedTypes,
123
129
  calls: rewriteThisCalls(calls, className, methodNameSet),
130
+ callSites,
131
+ ...(isOverloadSignature && { isOverloadSignature: true }),
124
132
  hookCalls,
125
133
  apiCalls,
126
134
  isAsync,
@@ -216,9 +224,20 @@ export function extractClasses(file, fileDirective = null) {
216
224
  if (isHashPrivate)
217
225
  node.metadata.isPrivate = true;
218
226
  nodes.push(node);
227
+ // Overload signatures — getMethods() returns only implementations;
228
+ // signatures hang off getOverloads() and become body-less METHOD nodes.
229
+ for (const overload of m.getOverloads?.() ?? []) {
230
+ const ovNode = buildMethodNode(file, name, methodName, overload, methodNameSet, fileDirective);
231
+ if (isHashPrivate)
232
+ ovNode.metadata.isPrivate = true;
233
+ nodes.push(ovNode);
234
+ }
219
235
  }
220
236
  for (const c of cls.getConstructors()) {
221
237
  nodes.push(buildMethodNode(file, name, "constructor", c, methodNameSet, fileDirective));
238
+ for (const overload of c.getOverloads?.() ?? []) {
239
+ nodes.push(buildMethodNode(file, name, "constructor", overload, methodNameSet, fileDirective));
240
+ }
222
241
  }
223
242
  for (const acc of cls.getGetAccessors()) {
224
243
  const accName = acc.getName();
@@ -1,10 +1,17 @@
1
1
  import { SourceFile } from "ts-morph";
2
- import type { CodeNode } from "../../types.js";
2
+ import type { CallSite, CodeNode } from "../../types.js";
3
3
  import { type RenderingBoundary } from "../directives.js";
4
4
  export declare const JS_BUILTINS: Set<string>;
5
+ export interface ExtractedCallData {
6
+ calls: string[];
7
+ callSites: CallSite[];
8
+ hookCalls: string[];
9
+ apiCalls: string[];
10
+ dependencyNames: string[];
11
+ contextRefs: string[];
12
+ }
13
+ export declare function extractCallsWithSites(node: any): ExtractedCallData;
5
14
  export declare function extractFunctionCalls(node: any): string[];
6
- export declare function extractHookCalls(node: any): string[];
7
- export declare function extractApiCalls(node: any): string[];
8
15
  export declare function hasErrorHandling(node: any): boolean;
9
16
  export declare function extractThrowStatements(node: any): boolean;
10
17
  export declare function extractFunctions(file: SourceFile, fileDirective?: RenderingBoundary): CodeNode[];
@@ -15,74 +15,107 @@ export const JS_BUILTINS = new Set([
15
15
  function makeId(filePath, name) {
16
16
  return `${filePath}::${name}`;
17
17
  }
18
- export function extractFunctionCalls(node) {
19
- const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
20
- const names = [];
21
- for (const call of calls) {
22
- const name = call.getExpression().getText();
23
- // Skip hooks, they are handled by hooks extractor
24
- if (name.startsWith("use"))
25
- continue;
26
- // Skip console calls and other noise calls, they are noise
27
- const rootName = name.split(".")[0];
28
- if (JS_BUILTINS.has(rootName))
29
- continue;
30
- names.push(name);
31
- }
32
- return [...new Set(names)];
33
- }
34
- export function extractHookCalls(node) {
35
- const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
36
- const hooks = [];
37
- for (const call of calls) {
38
- const name = call.getExpression().getText();
39
- // Only capture custom hooks — use[A-Z] pattern
40
- // Built-in React hooks (useState etc.) are not in our node graph
41
- if (/^use[A-Z]/.test(name))
42
- hooks.push(name);
18
+ // Cheap arg-type inference without the type checker: literal/object/array/
19
+ // function/new expressions map to coarse type tags; identifiers and property
20
+ // accesses stay undefined (resolving those needs the checker, too slow for
21
+ // whole-repo analysis).
22
+ function inferArgType(arg) {
23
+ const kind = arg.getKind?.();
24
+ if (kind === SyntaxKind.StringLiteral || kind === SyntaxKind.TemplateExpression)
25
+ return "string";
26
+ if (kind === SyntaxKind.NumericLiteral)
27
+ return "number";
28
+ if (kind === SyntaxKind.TrueKeyword || kind === SyntaxKind.FalseKeyword)
29
+ return "boolean";
30
+ if (kind === SyntaxKind.ObjectLiteralExpression)
31
+ return "object";
32
+ if (kind === SyntaxKind.ArrayLiteralExpression)
33
+ return "array";
34
+ if (kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression)
35
+ return "function";
36
+ if (kind === SyntaxKind.NewExpression) {
37
+ const ident = arg.getExpression?.();
38
+ return typeof ident?.getName === "function" ? ident.getName() : undefined;
43
39
  }
44
- return [...new Set(hooks)];
40
+ return undefined;
45
41
  }
46
- export function extractApiCalls(node) {
47
- const calls = node.getDescendantsOfKind(SyntaxKind.CallExpression);
42
+ // Arg-shape tags for the API-call families, used by the single-walk
43
+ // classifier below.
44
+ const API_HOOK_FAMILIES = new Set(["useQuery", "useMutation", "useInfiniteQuery", "useSuspenseQuery", "useSWR", "useSWRMutation"]);
45
+ const AXIOS_METHODS = new Set(["axios.get", "axios.post", "axios.put", "axios.delete", "axios.patch", "axios"]);
46
+ // SINGLE traversal over all call expressions producing every call-derived
47
+ // output at once: structured call sites + legacy bare names, custom hook
48
+ // calls, and API-call strings. The per-aspect extractors used to walk the
49
+ // same descendants tree four times per function-like; whole-repo cost is now
50
+ // one walk.
51
+ export function extractCallsWithSites(node) {
52
+ const callExprs = node.getDescendantsOfKind(SyntaxKind.CallExpression);
53
+ const sites = [];
54
+ const seenSites = new Set();
55
+ const hookCalls = [];
48
56
  const apiCalls = [];
49
- for (const call of calls) {
50
- const text = call.getText();
51
- const expr = call.getExpression().getText();
52
- // ─── fetch ────────────────────────────────────────────────────────────────
53
- if (expr === "fetch") {
54
- const args = call.getArguments();
55
- if (args.length > 0)
56
- apiCalls.push(`fetch(${args[0].getText()})`);
57
- }
58
- // ─── axios ────────────────────────────────────────────────────────────────
59
- if (expr === "axios.get" ||
60
- expr === "axios.post" ||
61
- expr === "axios.put" ||
62
- expr === "axios.delete" ||
63
- expr === "axios.patch" ||
64
- expr === "axios") {
65
- const args = call.getArguments();
66
- if (args.length > 0)
67
- apiCalls.push(`${expr}(${args[0].getText()})`);
57
+ const dependencyNames = [];
58
+ const contextRefs = [];
59
+ for (const call of callExprs) {
60
+ const name = call.getExpression().getText();
61
+ // use* family: custom hooks (^use[A-Z]) plus ALL use* names (incl.
62
+ // built-ins like useState) for hook dependency tracking
63
+ if (name.startsWith("use")) {
64
+ if (/^use[A-Z]/.test(name) && !hookCalls.includes(name))
65
+ hookCalls.push(name);
66
+ if (!dependencyNames.includes(name))
67
+ dependencyNames.push(name);
68
+ if (name === "useContext") {
69
+ const ctxArg = call.getArguments()[0];
70
+ if (ctxArg && !contextRefs.includes(ctxArg.getText()))
71
+ contextRefs.push(ctxArg.getText());
72
+ }
68
73
  }
69
- // ─── React Query (useQuery, useMutation, useInfiniteQuery) ────────────────
70
- if (expr === "useQuery" ||
71
- expr === "useMutation" ||
72
- expr === "useInfiniteQuery" ||
73
- expr === "useSuspenseQuery") {
74
+ // API-call families: fetch / axios.* / React Query / SWR
75
+ if (name === "fetch" || AXIOS_METHODS.has(name) || API_HOOK_FAMILIES.has(name)) {
74
76
  const args = call.getArguments();
75
77
  if (args.length > 0)
76
- apiCalls.push(`${expr}(${args[0].getText()})`);
78
+ apiCalls.push(`${name}(${args[0].getText()})`);
77
79
  }
78
- // ─── SWR ──────────────────────────────────────────────────────────────────
79
- if (expr === "useSWR" || expr === "useSWRMutation") {
80
- const args = call.getArguments();
81
- if (args.length > 0)
82
- apiCalls.push(`${expr}(${args[0].getText()})`);
80
+ // Plain calls/sites skip hooks and builtin noise
81
+ if (name.startsWith("use"))
82
+ continue;
83
+ const rootName = name.split(".")[0];
84
+ if (JS_BUILTINS.has(rootName))
85
+ continue;
86
+ const args = call.getArguments();
87
+ let hasSpread = false;
88
+ const argTypes = [];
89
+ for (const arg of args) {
90
+ if (arg.getKind?.() === SyntaxKind.SpreadElement) {
91
+ hasSpread = true;
92
+ argTypes.push("unknown");
93
+ }
94
+ else {
95
+ argTypes.push(inferArgType(arg) ?? "unknown");
96
+ }
83
97
  }
98
+ // One record per (name, arity, argTypes) triple — two calls to the same
99
+ // name with different arg counts OR arg type shapes are both overload
100
+ // signals (same arity + different literal types can hit different
101
+ // overloads, and each deserves its own edge).
102
+ const key = `${name}/${args.length}/${argTypes.join(",")}`;
103
+ if (seenSites.has(key))
104
+ continue;
105
+ seenSites.add(key);
106
+ sites.push({ name, argCount: args.length, argTypes, ...(hasSpread && { hasSpread: true }) });
84
107
  }
85
- return [...new Set(apiCalls)];
108
+ return {
109
+ calls: [...new Set(sites.map((cs) => cs.name))],
110
+ callSites: sites,
111
+ hookCalls,
112
+ apiCalls,
113
+ dependencyNames,
114
+ contextRefs,
115
+ };
116
+ }
117
+ export function extractFunctionCalls(node) {
118
+ return extractCallsWithSites(node).calls;
86
119
  }
87
120
  export function hasErrorHandling(node) {
88
121
  const tryCatch = node.getDescendantsOfKind(SyntaxKind.TryStatement);
@@ -109,9 +142,7 @@ export function extractFunctions(file, fileDirective = null) {
109
142
  if (/^use[A-Z]/.test(name))
110
143
  continue;
111
144
  const typedParams = extractParams(fn);
112
- const calls = extractFunctionCalls(fn);
113
- const hookCalls = extractHookCalls(fn);
114
- const apiCalls = extractApiCalls(fn);
145
+ const { calls, callSites, hookCalls, apiCalls } = extractCallsWithSites(fn);
115
146
  const isAsync = fn.isAsync();
116
147
  const hasErrors = hasErrorHandling(fn);
117
148
  const throws = extractThrowStatements(fn);
@@ -119,6 +150,9 @@ export function extractFunctions(file, fileDirective = null) {
119
150
  const returnType = extractReturnTypeAnnotation(fn);
120
151
  const bareTypeNames = extractBareTypeNames([...typedParams.map((p) => p.type), returnType]);
121
152
  const referencedTypes = extractReferencedInterfaces(file, bareTypeNames);
153
+ // Overload signatures have no body — flagged so the disambiguator can
154
+ // collapse them into the implementation when signatures match.
155
+ const isOverloadSignature = !fn.getBody();
122
156
  nodes.push({
123
157
  id: makeId(filePath, name),
124
158
  name,
@@ -133,6 +167,8 @@ export function extractFunctions(file, fileDirective = null) {
133
167
  returnType,
134
168
  referencedTypes,
135
169
  calls,
170
+ callSites,
171
+ ...(isOverloadSignature && { isOverloadSignature: true }),
136
172
  hookCalls,
137
173
  apiCalls,
138
174
  isAsync,
@@ -144,6 +180,40 @@ export function extractFunctions(file, fileDirective = null) {
144
180
  ...(renderingBoundary !== null && { renderingBoundary }),
145
181
  },
146
182
  });
183
+ // Overload signature declarations — ts-morph's getFunctions() returns only
184
+ // the implementation; signatures hang off getOverloads(). Each becomes its
185
+ // own FUNCTION node (no body) so the disambiguator can give it a
186
+ // signature-derived id or collapse it into an identical implementation.
187
+ for (const overload of fn.getOverloads() ?? []) {
188
+ const ovParams = extractParams(overload);
189
+ const ovReturnType = extractReturnTypeAnnotation(overload);
190
+ nodes.push({
191
+ id: makeId(filePath, name),
192
+ name,
193
+ type: "FUNCTION",
194
+ filePath,
195
+ startLine: overload.getStartLineNumber(),
196
+ endLine: overload.getEndLineNumber(),
197
+ rawCode: overload.getText(),
198
+ metadata: {
199
+ params: ovParams.map((p) => p.name),
200
+ parameters: ovParams,
201
+ returnType: ovReturnType,
202
+ referencedTypes: [],
203
+ calls: [],
204
+ callSites: [],
205
+ hookCalls: [],
206
+ apiCalls: [],
207
+ isAsync: false,
208
+ hasErrorHandling: false,
209
+ throws: false,
210
+ lineCount: overload.getEndLineNumber() - overload.getStartLineNumber(),
211
+ isHttpHandler: HTTP_METHOD_EXPORTS.has(name),
212
+ httpMethod: HTTP_METHOD_EXPORTS.has(name) ? name : undefined,
213
+ isOverloadSignature: true,
214
+ },
215
+ });
216
+ }
147
217
  }
148
218
  // ─── Arrow Function Declarations ───────────────────────────────────────────
149
219
  for (const variable of file.getVariableDeclarations()) {
@@ -161,9 +231,7 @@ export function extractFunctions(file, fileDirective = null) {
161
231
  if (!isArrow)
162
232
  continue;
163
233
  const typedParams = extractParams(initializer);
164
- const calls = extractFunctionCalls(initializer);
165
- const hookCalls = extractHookCalls(initializer);
166
- const apiCalls = extractApiCalls(initializer);
234
+ const { calls, callSites, hookCalls, apiCalls } = extractCallsWithSites(initializer);
167
235
  const isAsync = initializer.getText().startsWith("async");
168
236
  const hasErrors = hasErrorHandling(initializer);
169
237
  const throws = extractThrowStatements(initializer);
@@ -185,6 +253,7 @@ export function extractFunctions(file, fileDirective = null) {
185
253
  returnType,
186
254
  referencedTypes,
187
255
  calls,
256
+ callSites,
188
257
  hookCalls,
189
258
  apiCalls,
190
259
  isAsync,
@@ -230,6 +299,7 @@ export function extractFunctions(file, fileDirective = null) {
230
299
  metadata: {
231
300
  params: [],
232
301
  calls: [],
302
+ callSites: [],
233
303
  apiCalls: [],
234
304
  isAsync: false,
235
305
  hasErrorHandling: false,