devlensio 1.0.4 → 1.1.1

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 (73) hide show
  1. package/dist/extractors/runner.js +1 -0
  2. package/dist/extractors/types.d.ts +2 -1
  3. package/dist/graph/edges/callEdges.js +97 -5
  4. package/dist/graph/edges/callEdges.test.js +154 -0
  5. package/dist/parser/exportsMap.d.ts +9 -0
  6. package/dist/parser/exportsMap.js +349 -0
  7. package/dist/parser/exportsMap.test.d.ts +1 -0
  8. package/dist/parser/exportsMap.test.js +324 -0
  9. package/dist/parser/extractors/classes.js +23 -4
  10. package/dist/parser/extractors/functions.d.ts +10 -3
  11. package/dist/parser/extractors/functions.js +135 -65
  12. package/dist/parser/extractors/hooks.js +5 -29
  13. package/dist/parser/extractors/objectMethods.js +3 -4
  14. package/dist/parser/index.d.ts +2 -1
  15. package/dist/parser/index.js +44 -6
  16. package/dist/parser/overloads.d.ts +2 -0
  17. package/dist/parser/overloads.js +96 -0
  18. package/dist/parser/overloads.test.d.ts +1 -0
  19. package/dist/parser/overloads.test.js +175 -0
  20. package/dist/parser/typeUtils.d.ts +2 -0
  21. package/dist/parser/typeUtils.js +2 -0
  22. package/dist/pipeline/index.d.ts +2 -1
  23. package/dist/pipeline/index.js +1 -0
  24. package/dist/storage/fileStorage.d.ts +2 -1
  25. package/dist/storage/fileStorage.js +5 -1
  26. package/dist/storage/fileStorage.test.d.ts +1 -0
  27. package/dist/storage/fileStorage.test.js +112 -0
  28. package/dist/types.d.ts +10 -0
  29. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  30. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  31. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  32. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  33. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  34. package/extractors/go/calls.go +134 -2
  35. package/extractors/go/contract.go +1 -0
  36. package/extractors/go/exports.go +107 -0
  37. package/extractors/go/exports_test.go +64 -0
  38. package/extractors/go/extractor.go +1 -0
  39. package/extractors/go/lookup.go +63 -7
  40. package/extractors/go/nodes.go +28 -0
  41. package/extractors/go/parser.go +51 -8
  42. package/extractors/java/devlens_java_extractor.jar +0 -0
  43. package/extractors/java/src/devlens/extractor/ExportsMapBuilder.java +148 -0
  44. package/extractors/java/src/devlens/extractor/Extractor.java +34 -0
  45. package/extractors/java/src/devlens/extractor/LookupMaps.java +12 -0
  46. package/extractors/java/src/devlens/extractor/Overloads.java +290 -0
  47. package/extractors/java/src/devlens/extractor/Parser.java +39 -0
  48. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +9 -0
  49. package/extractors/java/src/devlens/extractor/edges/Calls.java +42 -36
  50. package/extractors/java/src/devlens/extractor/edges/Routes.java +10 -3
  51. package/extractors/python/devlens_extractors_python/edges/calls.py +127 -26
  52. package/extractors/python/devlens_extractors_python/exports_map.py +263 -0
  53. package/extractors/python/devlens_extractors_python/extractor.py +7 -1
  54. package/extractors/python/devlens_extractors_python/lookup.py +6 -0
  55. package/extractors/python/devlens_extractors_python/parser/__init__.py +6 -0
  56. package/extractors/python/devlens_extractors_python/parser/functions.py +103 -6
  57. package/extractors/python/devlens_extractors_python/parser/overloads.py +105 -0
  58. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  59. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  60. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  61. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  62. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  63. package/extractors/rust/src/calls.rs +27 -16
  64. package/extractors/rust/src/contract.rs +2 -0
  65. package/extractors/rust/src/exports.rs +387 -0
  66. package/extractors/rust/src/exports_test.rs +105 -0
  67. package/extractors/rust/src/extractor.rs +2 -0
  68. package/extractors/rust/src/lookup.rs +60 -0
  69. package/extractors/rust/src/main.rs +4 -0
  70. package/extractors/rust/src/nodes.rs +19 -0
  71. package/extractors/rust/src/parser.rs +66 -1
  72. package/extractors/rust/src/routes.rs +2 -1
  73. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { parseRepo } from "./index.js";
5
+ function createFakeRepo(files) {
6
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-overloads-test-"));
7
+ for (const [filePath, content] of Object.entries(files)) {
8
+ const fullPath = path.join(tmpDir, filePath);
9
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
10
+ fs.writeFileSync(fullPath, content);
11
+ }
12
+ return tmpDir;
13
+ }
14
+ function deleteFakeRepo(repoPath) {
15
+ fs.rmSync(repoPath, { recursive: true, force: true });
16
+ }
17
+ describe("overload disambiguation (same-name function-likes in one file)", () => {
18
+ it("keeps the clean unsuffixed id when the name is unique in the file", () => {
19
+ const repo = createFakeRepo({
20
+ "src/unique.ts": `
21
+ export function formatValue(v: string) { return v.trim(); }
22
+ `,
23
+ });
24
+ const { nodes } = parseRepo(repo);
25
+ const fn = nodes.find((n) => n.name === "formatValue");
26
+ expect(fn?.id).toBe("src/unique.ts::formatValue");
27
+ expect(fn?.metadata.isOverloadSignature).toBeUndefined();
28
+ deleteFakeRepo(repo);
29
+ });
30
+ it("suffixes each same-name function with its OWN arity (no ordinals)", () => {
31
+ const repo = createFakeRepo({
32
+ "src/arity.ts": `
33
+ export function formatValue(v: string) { return v.trim(); }
34
+ export function formatValue(v: string, n: number) { return v.repeat(n); }
35
+ export function formatValue(v: string, n: number, flag: boolean) { return v; }
36
+ `,
37
+ });
38
+ const { nodes } = parseRepo(repo);
39
+ const ids = nodes.filter((n) => n.name === "formatValue").map((n) => n.id).sort();
40
+ expect(ids).toEqual([
41
+ "src/arity.ts::formatValue#1",
42
+ "src/arity.ts::formatValue#2",
43
+ "src/arity.ts::formatValue#3",
44
+ ]);
45
+ deleteFakeRepo(repo);
46
+ });
47
+ it("suffixes same-arity different-type overloads with an arity+signature hash", () => {
48
+ const repo = createFakeRepo({
49
+ "src/types.ts": `
50
+ export function decode(input: string, mode: string) { return input; }
51
+ export function decode(input: number, mode: string) { return String(input); }
52
+ `,
53
+ });
54
+ const { nodes } = parseRepo(repo);
55
+ const fns = nodes.filter((n) => n.name === "decode");
56
+ expect(fns.length).toBe(2);
57
+ const ids = fns.map((n) => n.id).sort();
58
+ // both share arity 2, differ only by types → distinct sig hashes
59
+ expect(ids[0]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
60
+ expect(ids[1]).toMatch(/^src\/types\.ts::decode#2#[0-9a-f]{8}$/);
61
+ expect(ids[0]).not.toBe(ids[1]);
62
+ // deterministic: same signature → same hash across runs
63
+ expect(ids).toEqual([...ids].sort());
64
+ deleteFakeRepo(repo);
65
+ });
66
+ it("collapses an overload signature into a signature-identical implementation", () => {
67
+ const repo = createFakeRepo({
68
+ "src/collapse.ts": `
69
+ export function greet(name: string): string;
70
+ export function greet(name: string) { return "hi " + name; }
71
+ `,
72
+ });
73
+ const { nodes } = parseRepo(repo);
74
+ const greets = nodes.filter((n) => n.name === "greet");
75
+ expect(greets.length).toBe(1);
76
+ expect(greets[0].id).toBe("src/collapse.ts::greet");
77
+ expect(greets[0].rawCode).toContain("return");
78
+ deleteFakeRepo(repo);
79
+ });
80
+ it("keeps an overload signature separate when the implementation has different params", () => {
81
+ const repo = createFakeRepo({
82
+ "src/keepboth.ts": `
83
+ export function parse(input: string): string;
84
+ export function parse(input: string | number) { return String(input); }
85
+ `,
86
+ });
87
+ const { nodes } = parseRepo(repo);
88
+ const parses = nodes.filter((n) => n.name === "parse");
89
+ expect(parses.length).toBe(2);
90
+ // one of them is the bare signature
91
+ const sig = parses.find((n) => n.metadata.isOverloadSignature === true);
92
+ const impl = parses.find((n) => n.metadata.isOverloadSignature === undefined);
93
+ expect(sig).toBeDefined();
94
+ expect(impl).toBeDefined();
95
+ // distinct ids, both arity 1 with different type shapes → hash suffixes
96
+ expect(sig.id).not.toBe(impl.id);
97
+ expect(sig.id).toMatch(/^src\/keepboth\.ts::parse#1#[0-9a-f]{8}$/);
98
+ deleteFakeRepo(repo);
99
+ });
100
+ it("suffixes class method overloads with dotted names, not cross-class names", () => {
101
+ const repo = createFakeRepo({
102
+ "src/logger.ts": `
103
+ export class Logger {
104
+ log(msg: string) { console.log(msg); }
105
+ log(msg: string, level: number) { console.log(level, msg); }
106
+ }
107
+ export class Auditor {
108
+ log(entry: string) { console.log(entry); }
109
+ }
110
+ `,
111
+ });
112
+ const { nodes } = parseRepo(repo);
113
+ const logMethods = nodes.filter((n) => n.type === "METHOD" && n.name.endsWith(".log"));
114
+ const ids = logMethods.map((n) => n.id).sort();
115
+ expect(ids).toEqual([
116
+ "src/logger.ts::Auditor.log", // unique within its class → no suffix
117
+ "src/logger.ts::Logger.log#1",
118
+ "src/logger.ts::Logger.log#2",
119
+ ]);
120
+ deleteFakeRepo(repo);
121
+ });
122
+ it("never changes ids of different-name or different-file nodes", () => {
123
+ const repo = createFakeRepo({
124
+ "src/a.ts": `
125
+ export function helper() { return 1; }
126
+ `,
127
+ "src/b.ts": `
128
+ export function helper() { return 2; }
129
+ `,
130
+ });
131
+ const { nodes } = parseRepo(repo);
132
+ const helpers = nodes.filter((n) => n.name === "helper").map((n) => n.id).sort();
133
+ expect(helpers).toEqual(["src/a.ts::helper", "src/b.ts::helper"]);
134
+ deleteFakeRepo(repo);
135
+ });
136
+ it("captures callSites with argCount, argTypes and hasSpread", () => {
137
+ const repo = createFakeRepo({
138
+ "src/caller.ts": `
139
+ export function run() {
140
+ formatValue("abc");
141
+ formatValue("abc", 3);
142
+ logAll("a", "b", ...rest);
143
+ }
144
+ `,
145
+ });
146
+ const { nodes } = parseRepo(repo);
147
+ const run = nodes.find((n) => n.name === "run");
148
+ const sites = run?.metadata.callSites;
149
+ expect(sites).toBeDefined();
150
+ const byName = new Map(sites.map((s) => [`${s.name}/${s.argCount}`, s]));
151
+ expect(byName.get("formatValue/1")?.argTypes).toEqual(["string"]);
152
+ expect(byName.get("formatValue/2")?.argTypes).toEqual(["string", "number"]);
153
+ expect(byName.get("logAll/3")?.hasSpread).toBe(true);
154
+ // legacy calls list stays in sync
155
+ expect(run?.metadata.calls).toContain("formatValue");
156
+ deleteFakeRepo(repo);
157
+ });
158
+ it("does not treat same-name functions in different files as overloads (per-file scope)", () => {
159
+ const repo = createFakeRepo({
160
+ "src/x.ts": `
161
+ export function handle(a: string) { return a; }
162
+ `,
163
+ "src/y.ts": `
164
+ export function handle(a: string, b: string) { return a + b; }
165
+ `,
166
+ });
167
+ const { nodes } = parseRepo(repo);
168
+ const handlers = nodes.filter((n) => n.name === "handle");
169
+ expect(handlers.map((n) => n.id).sort()).toEqual([
170
+ "src/x.ts::handle",
171
+ "src/y.ts::handle",
172
+ ]);
173
+ deleteFakeRepo(repo);
174
+ });
175
+ });
@@ -2,6 +2,8 @@ import { SourceFile } from "ts-morph";
2
2
  export interface ParamInfo {
3
3
  name: string;
4
4
  type?: string;
5
+ isOptional?: boolean;
6
+ isRest?: boolean;
5
7
  }
6
8
  export declare function extractParams(node: any): ParamInfo[];
7
9
  export declare function extractReturnTypeAnnotation(node: any): string | undefined;
@@ -3,6 +3,8 @@ export function extractParams(node) {
3
3
  return params.map((p) => ({
4
4
  name: p.getName(),
5
5
  type: p.getTypeNode()?.getText() ?? undefined,
6
+ isOptional: typeof p.isOptional === "function" ? p.isOptional() : undefined,
7
+ isRest: typeof p.isRestParameter === "function" ? p.isRestParameter() : undefined,
6
8
  }));
7
9
  }
8
10
  export function extractReturnTypeAnnotation(node) {
@@ -1,5 +1,5 @@
1
1
  import type { FilterThresholds } from "../scoring/noiseFilter.js";
2
- import type { CodeNode, CodeEdge, ProjectFingerprint, RouteNode, BackendRouteNode } from "../types.js";
2
+ import type { CodeNode, CodeEdge, ProjectFingerprint, RouteNode, BackendRouteNode, GraphExports } from "../types.js";
3
3
  export type { FilterThresholds };
4
4
  export interface GitInfo {
5
5
  commitHash: string;
@@ -37,6 +37,7 @@ export interface PipelineResult {
37
37
  analyzedAt: string;
38
38
  fingerprint: ProjectFingerprint;
39
39
  routes: (RouteNode | BackendRouteNode)[];
40
+ exports?: GraphExports;
40
41
  nodes: CodeNode[];
41
42
  edges: CodeEdge[];
42
43
  allNodes: CodeNode[];
@@ -251,6 +251,7 @@ export async function analyzePipeline(repoPath, isGithubRepo, options) {
251
251
  analyzedAt,
252
252
  fingerprint: extractorResult.fingerprint,
253
253
  routes: extractorResult.routes ?? [],
254
+ exports: extractorResult.exports,
254
255
  nodes: scoringResult.filteredNodes,
255
256
  edges: scoringResult.filteredEdges,
256
257
  allNodes,
@@ -1,5 +1,5 @@
1
1
  import type { PipelineResult, PipelineStats } from "../pipeline/index.js";
2
- import type { CodeNode, CodeEdge } from "../types.js";
2
+ import type { CodeNode, CodeEdge, GraphExports } from "../types.js";
3
3
  import type { GraphStorage } from "./interface.js";
4
4
  export interface GraphIndexEntry {
5
5
  graphId: string;
@@ -44,6 +44,7 @@ export interface CommitData {
44
44
  allEdges: CodeEdge[];
45
45
  nodeScores: Record<string, number>;
46
46
  stats: PipelineStats;
47
+ exports?: GraphExports;
47
48
  }
48
49
  export interface NodeDiff {
49
50
  added: DiffNode[];
@@ -14,7 +14,9 @@ import { simpleGit } from "simple-git";
14
14
  //
15
15
  // Diffs are computed on demand — not stored.
16
16
  // GitHub scope is reserved in meta.json for future cloud repo support.
17
- const STORAGE_DIR = path.join(os.homedir(), ".devlens");
17
+ const STORAGE_DIR = process.env.DEVLENS_STORAGE_DIR
18
+ ? path.resolve(process.env.DEVLENS_STORAGE_DIR)
19
+ : path.join(os.homedir(), ".devlens");
18
20
  const GRAPHS_DIR = path.join(STORAGE_DIR, "graphs");
19
21
  const INDEX_FILE = path.join(STORAGE_DIR, "index.json");
20
22
  const SCHEMA_VERSION = "1.0";
@@ -118,6 +120,7 @@ function buildCommitData(result) {
118
120
  allEdges: result.allEdges,
119
121
  nodeScores: result.nodeScores,
120
122
  stats: result.stats,
123
+ exports: result.exports,
121
124
  };
122
125
  }
123
126
  // ─── Public API ───────────────────────────────────────────────────────────────
@@ -239,6 +242,7 @@ export function getGraph(graphId, commitHash // defaults to latest commit
239
242
  allEdges: data.allEdges,
240
243
  nodeScores: data.nodeScores,
241
244
  stats: data.stats,
245
+ exports: data.exports,
242
246
  isGithubRepo: meta.isGithubRepo,
243
247
  gitInfo: {
244
248
  commitHash: data.commitHash,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ // Storage round-trip + back-compat tests for the exports map.
2
+ //
3
+ // STORAGE_DIR is computed from os.homedir() at module load, so these tests
4
+ // point it at a temp dir via DEVLENS_STORAGE_DIR and import the module
5
+ // dynamically AFTER setting the env var — nothing here ever touches the
6
+ // real ~/.devlens.
7
+ import { describe, expect, test } from "bun:test";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ const FAKE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-storage-test-"));
12
+ process.env.DEVLENS_STORAGE_DIR = FAKE_STORAGE_ROOT;
13
+ const { saveGraph, getGraph, deleteGraph } = await import("./fileStorage.js");
14
+ function makeNode(id, name) {
15
+ return {
16
+ id,
17
+ name,
18
+ type: "FUNCTION",
19
+ filePath: `src/${name}.ts`,
20
+ startLine: 1,
21
+ endLine: 2,
22
+ parentFile: `file::src/${name}.ts`,
23
+ metadata: {},
24
+ };
25
+ }
26
+ function makeResult(overrides) {
27
+ const nodes = [makeNode("src/alpha.ts::alpha", "alpha"), makeNode("src/alpha.ts::alpha#2", "alpha")];
28
+ const edges = [];
29
+ return {
30
+ repoPath: FAKE_STORAGE_ROOT,
31
+ analyzedAt: "2026-10-07T00:00:00.000Z",
32
+ fingerprint: { language: "typescript" },
33
+ routes: [],
34
+ nodes,
35
+ edges,
36
+ allNodes: nodes,
37
+ allEdges: edges,
38
+ nodeScores: {},
39
+ stats: {
40
+ totalNodesBeforeFilter: nodes.length,
41
+ totalEdgesBeforeFilter: 0,
42
+ totalNodesAfterFilter: nodes.length,
43
+ totalEdgesAfterFilter: 0,
44
+ removedNodeCount: 0,
45
+ removedEdgeCount: 0,
46
+ averageNodeScore: 0,
47
+ topScoringNodes: [],
48
+ topScoringFiles: [],
49
+ },
50
+ isGithubRepo: false,
51
+ gitInfo: { commitHash: "abc12345", branch: "main", message: "test", hasGit: true },
52
+ ...overrides,
53
+ };
54
+ }
55
+ const MAP = {
56
+ exports: {
57
+ ".": {
58
+ alpha: ["src/alpha.ts::alpha", "src/alpha.ts::alpha#2"],
59
+ beta: ["src/beta.ts::beta"],
60
+ },
61
+ "./server": { serve: ["src/server.ts::serve"] },
62
+ },
63
+ ambiguousNames: {},
64
+ };
65
+ describe("exports map persistence (saveGraph -> getGraph)", () => {
66
+ test("round trip: map survives byte-for-byte, including multi-id groups and order", () => {
67
+ const result = makeResult({ graphId: "rt-test-graph", exports: MAP });
68
+ saveGraph(result, { force: true });
69
+ const loaded = getGraph("rt-test-graph", "abc12345");
70
+ expect(loaded).toBeDefined();
71
+ expect(loaded.exports).toBeDefined();
72
+ // deep identity: shape, keys, values, AND array order within groups
73
+ expect(loaded.exports).toEqual(MAP);
74
+ expect(loaded.exports.exports["."].alpha).toEqual(["src/alpha.ts::alpha", "src/alpha.ts::alpha#2"]);
75
+ expect(loaded.exports.ambiguousNames).toEqual({});
76
+ deleteGraph("rt-test-graph");
77
+ });
78
+ test("legacy artifact without the exports field: loads, yields undefined, no fabricated empty map", () => {
79
+ const result = makeResult({ graphId: "legacy-test-graph" });
80
+ delete result.exports;
81
+ saveGraph(result, { force: true });
82
+ const commitFile = path.join(FAKE_STORAGE_ROOT, "graphs", "legacy-test-graph", "commits", "abc12345.json");
83
+ const onDisk = JSON.parse(fs.readFileSync(commitFile, "utf-8"));
84
+ expect("exports" in onDisk).toBe(false);
85
+ const loaded = getGraph("legacy-test-graph", "abc12345");
86
+ expect(loaded).toBeDefined();
87
+ expect(loaded.exports).toBeUndefined();
88
+ deleteGraph("legacy-test-graph");
89
+ });
90
+ test("degraded input: exports present but EMPTY subpaths load without crash", () => {
91
+ const emptyMap = { exports: { ".": {} }, ambiguousNames: {} };
92
+ const result = makeResult({ graphId: "empty-map-graph", exports: emptyMap });
93
+ saveGraph(result, { force: true });
94
+ const loaded = getGraph("empty-map-graph", "abc12345");
95
+ expect(loaded).toBeDefined();
96
+ expect(loaded.exports).toEqual(emptyMap);
97
+ deleteGraph("empty-map-graph");
98
+ });
99
+ test("degraded input: exports referencing node ids ABSENT from the nodes array load without crash", () => {
100
+ const danglingMap = {
101
+ exports: { ".": { ghost: ["src/gone.ts::gone"] } },
102
+ ambiguousNames: {},
103
+ };
104
+ const result = makeResult({ graphId: "dangling-map-graph", exports: danglingMap });
105
+ saveGraph(result, { force: true });
106
+ const loaded = getGraph("dangling-map-graph", "abc12345");
107
+ expect(loaded).toBeDefined();
108
+ expect(loaded.exports).toEqual(danglingMap);
109
+ expect(loaded.exports.exports["."].ghost).toEqual(["src/gone.ts::gone"]);
110
+ deleteGraph("dangling-map-graph");
111
+ });
112
+ });
package/dist/types.d.ts CHANGED
@@ -48,6 +48,12 @@ export interface BackendRouteNode {
48
48
  };
49
49
  }
50
50
  export type NodeType = "COMPONENT" | "HOOK" | "FUNCTION" | "STATE_STORE" | "UTILITY" | "FILE" | "GHOST" | "ROUTE" | "TEST" | "STORY" | "THIRD_PARTY" | "CLASS" | "METHOD" | "INTERFACE" | "ENUM" | "STRUCT" | "MODULE" | "TRAIT" | "IMPL_BLOCK" | "PACKAGE";
51
+ export interface CallSite {
52
+ name: string;
53
+ argCount: number;
54
+ argTypes?: string[];
55
+ hasSpread?: boolean;
56
+ }
51
57
  export interface CodeNode {
52
58
  id: string;
53
59
  name: string;
@@ -77,3 +83,7 @@ export interface CodeEdge {
77
83
  type: EdgeType;
78
84
  metadata?: Record<string, unknown>;
79
85
  }
86
+ export interface GraphExports {
87
+ exports: Record<string, Record<string, string[]>>;
88
+ ambiguousNames: Record<string, string[]>;
89
+ }
@@ -152,8 +152,11 @@ func resolveCall(c CallSite, fn *ParsedFunc, pf *ParsedFile, info *types.Info,
152
152
  if id, ok := l.MethodNodeByPkgTypeName[pkgPath+"::"+c.Receiver+"."+c.Sel]; ok {
153
153
  return id, c.Sel, c.Receiver, callEdge(callerID, id, c.Sel, c.Receiver)
154
154
  }
155
- // receiver is a plain variable whose type we can't see — unresolvable
156
- // tier (documented; type info covers the exact cases)
155
+ // receiver is a plain variable whose type we can't see — bare-method-name
156
+ // candidates across packages, filtered by arity/type shape
157
+ if id, matchedBy := resolveByCandidates(l.MethodCandidatesByName[c.Sel], c, pf.RelPath); id != "" {
158
+ return id, c.Sel, "", callEdgeMatched(callerID, id, c.Sel, "", matchedBy)
159
+ }
157
160
  return "", "", "", nil
158
161
  }
159
162
 
@@ -161,6 +164,11 @@ func resolveCall(c CallSite, fn *ParsedFunc, pf *ParsedFile, info *types.Info,
161
164
  if id, ok := l.FuncNodeByPkgName[pkgPath+"::"+c.Str]; ok {
162
165
  return id, "", "", callEdge(callerID, id, "", "")
163
166
  }
167
+ // same-package miss: bare-name candidates across packages, filtered by
168
+ // arity/type shape (fire only where resolution previously gave up)
169
+ if id, matchedBy := resolveByCandidates(l.FuncCandidatesByName[c.Str], c, pf.RelPath); id != "" {
170
+ return id, "", "", callEdgeMatched(callerID, id, "", "", matchedBy)
171
+ }
164
172
  return "", "", "", nil
165
173
  }
166
174
 
@@ -175,6 +183,130 @@ func callEdge(from, to, method, class string) map[string]any {
175
183
  return edgeWithMeta(from, to, EdgeCalls, meta)
176
184
  }
177
185
 
186
+ // callEdgeMatched — callEdge plus resolution confidence from the arity-aware
187
+ // candidate tier: "arity" (arg count singled out the target), "signature"
188
+ // (arg type agreement decided it), "name" (path proximity fallback).
189
+ func callEdgeMatched(from, to, method, class, matchedBy string) map[string]any {
190
+ e := callEdge(from, to, method, class)
191
+ e["metadata"].(map[string]any)["matchedBy"] = matchedBy
192
+ return e
193
+ }
194
+
195
+ // ── arity-aware candidate resolution ──
196
+
197
+ // arityAccepts — Go call compatibility: exact arg count, except a variadic
198
+ // tail accepts any count >= required. Spread calls (f(xs...)) have an
199
+ // unknowable runtime arity — every candidate stays admissible.
200
+ func arityAccepts(cand funcCandidate, argCount int, hasSpread bool) bool {
201
+ if hasSpread {
202
+ return true
203
+ }
204
+ if cand.Variadic {
205
+ return argCount >= cand.Required
206
+ }
207
+ return argCount == cand.Required
208
+ }
209
+
210
+ // typeAgrees — loose agreement between a call-site arg tag and a declared
211
+ // param type text; "unknown" tags never match (parity with the TS resolver).
212
+ func typeAgrees(argType, paramType string) bool {
213
+ if argType == "" || argType == "unknown" || paramType == "" {
214
+ return false
215
+ }
216
+ return paramType == argType || strings.Contains(paramType, argType)
217
+ }
218
+
219
+ // typeScore — how many argument tags agree with the declared params.
220
+ func typeScore(cand funcCandidate, argTypes []string) int {
221
+ score := 0
222
+ for i, at := range argTypes {
223
+ if i < len(cand.ParamTypes) && typeAgrees(at, cand.ParamTypes[i]) {
224
+ score++
225
+ }
226
+ }
227
+ return score
228
+ }
229
+
230
+ // pathProximity — shared leading path segments between two rel paths
231
+ // (mirrors the TS engine's closestByPath tiebreak).
232
+ func pathProximity(candidate, reference string) int {
233
+ a := strings.Split(candidate, "/")
234
+ b := strings.Split(reference, "/")
235
+ score := 0
236
+ for i := 0; i < len(a) && i < len(b); i++ {
237
+ if a[i] != b[i] {
238
+ break
239
+ }
240
+ score++
241
+ }
242
+ return score
243
+ }
244
+
245
+ // resolveByCandidates — pick among same-name declarations by argument shape:
246
+ // arity filter first, then arg-type agreement tiebreak, then path proximity.
247
+ // Single-candidate groups return immediately (matchedBy "arity" only when the
248
+ // call shape actually participated). Returns ("", "") when nothing applies.
249
+ func resolveByCandidates(candidates []funcCandidate, c CallSite, callerRel string) (string, string) {
250
+ if len(candidates) == 0 {
251
+ return "", ""
252
+ }
253
+ if len(candidates) == 1 {
254
+ // a lone same-name declaration elsewhere — arity still gates it
255
+ if arityAccepts(candidates[0], c.ArgCount, c.HasSpread) {
256
+ return candidates[0].ID, "arity"
257
+ }
258
+ return "", ""
259
+ }
260
+
261
+ pool := make([]funcCandidate, 0, len(candidates))
262
+ for _, cand := range candidates {
263
+ if arityAccepts(cand, c.ArgCount, c.HasSpread) {
264
+ pool = append(pool, cand)
265
+ }
266
+ }
267
+ if len(pool) == 0 {
268
+ return "", "" // nothing accepts this shape — stay unresolvable
269
+ }
270
+ if len(pool) == 1 {
271
+ return pool[0].ID, "arity"
272
+ }
273
+
274
+ // type agreement tiebreak — needs usable (non-unknown) arg tags
275
+ usable := false
276
+ for _, at := range c.ArgTypes {
277
+ if at != "unknown" {
278
+ usable = true
279
+ break
280
+ }
281
+ }
282
+ if usable {
283
+ best := pool[0]
284
+ bestScore := typeScore(pool[0], c.ArgTypes)
285
+ tie := 1
286
+ for _, cand := range pool[1:] {
287
+ s := typeScore(cand, c.ArgTypes)
288
+ if s > bestScore {
289
+ best, bestScore, tie = cand, s, 1
290
+ } else if s == bestScore {
291
+ tie++
292
+ }
293
+ }
294
+ if bestScore > 0 && tie == 1 {
295
+ return best.ID, "signature"
296
+ }
297
+ }
298
+
299
+ // path proximity fallback
300
+ best := pool[0]
301
+ bestScore := pathProximity(best.RelPath, callerRel)
302
+ for _, cand := range pool[1:] {
303
+ if s := pathProximity(cand.RelPath, callerRel); s > bestScore {
304
+ best, bestScore = cand, s
305
+ }
306
+ }
307
+ return best.ID, "name"
308
+ }
309
+
178
310
  // receiverDeclaredType — the receiver's type from its DECLARATION: function
179
311
  // parameters first (func(c *gin.Context) — the documented handler signature),
180
312
  // then package-level typed vars (var DB *gorm.DB). Returns (importPath,
@@ -89,6 +89,7 @@ type ExtractorResult struct {
89
89
  Nodes []map[string]any `json:"nodes"`
90
90
  Edges []map[string]any `json:"edges"`
91
91
  Routes []map[string]any `json:"routes"`
92
+ Exports *ExportsMap `json:"exports,omitempty"`
92
93
  Stats Stats `json:"stats"`
93
94
  Errors []ExtractorError `json:"errors"`
94
95
  }