@getmikk/core 1.8.2 → 1.9.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 (43) hide show
  1. package/package.json +3 -1
  2. package/src/constants.ts +285 -0
  3. package/src/contract/contract-generator.ts +7 -0
  4. package/src/contract/index.ts +2 -3
  5. package/src/contract/lock-compiler.ts +74 -42
  6. package/src/contract/lock-reader.ts +24 -4
  7. package/src/contract/schema.ts +27 -1
  8. package/src/error-handler.ts +430 -0
  9. package/src/graph/cluster-detector.ts +45 -20
  10. package/src/graph/confidence-engine.ts +60 -0
  11. package/src/graph/dead-code-detector.ts +27 -5
  12. package/src/graph/graph-builder.ts +298 -238
  13. package/src/graph/impact-analyzer.ts +131 -114
  14. package/src/graph/index.ts +4 -0
  15. package/src/graph/memory-manager.ts +345 -0
  16. package/src/graph/query-engine.ts +79 -0
  17. package/src/graph/risk-engine.ts +86 -0
  18. package/src/graph/types.ts +89 -64
  19. package/src/parser/boundary-checker.ts +3 -1
  20. package/src/parser/change-detector.ts +99 -0
  21. package/src/parser/go/go-extractor.ts +28 -9
  22. package/src/parser/go/go-parser.ts +2 -0
  23. package/src/parser/index.ts +88 -38
  24. package/src/parser/javascript/js-extractor.ts +1 -1
  25. package/src/parser/javascript/js-parser.ts +2 -0
  26. package/src/parser/oxc-parser.ts +675 -0
  27. package/src/parser/oxc-resolver.ts +83 -0
  28. package/src/parser/tree-sitter/parser.ts +27 -15
  29. package/src/parser/types.ts +100 -73
  30. package/src/parser/typescript/ts-extractor.ts +241 -537
  31. package/src/parser/typescript/ts-parser.ts +16 -171
  32. package/src/parser/typescript/ts-resolver.ts +11 -1
  33. package/src/search/bm25.ts +5 -2
  34. package/src/utils/minimatch.ts +1 -1
  35. package/tests/contract.test.ts +2 -2
  36. package/tests/dead-code.test.ts +7 -7
  37. package/tests/esm-resolver.test.ts +75 -0
  38. package/tests/graph.test.ts +20 -20
  39. package/tests/helpers.ts +11 -6
  40. package/tests/impact-classified.test.ts +37 -41
  41. package/tests/parser.test.ts +7 -5
  42. package/tests/ts-parser.test.ts +27 -52
  43. package/test-output.txt +0 -373
@@ -1,355 +1,415 @@
1
- import * as path from 'node:path'
1
+ import * as nodePath from 'node:path'
2
2
  import type { DependencyGraph, GraphNode, GraphEdge } from './types.js'
3
- import type { ParsedFile, ParsedFunction, ParsedClass } from '../parser/types.js'
3
+ import type { MikkLock } from '../contract/schema.js'
4
+ import type { ParsedFile, ParsedFunction, ParsedClass, ParsedVariable, ParsedGeneric } from '../parser/types.js'
5
+
6
+ export const EDGE_WEIGHTS = {
7
+ imports: 1.0,
8
+ extends: 0.9,
9
+ implements: 0.8,
10
+ calls: { exact: 1.0, fuzzy: 0.6, method: 0.8, dynamic: 0.4 },
11
+ accesses: 0.5,
12
+ contains: 1.0,
13
+ };
4
14
 
5
15
  /**
6
- * GraphBuilder — takes parsed files and builds the dependency graph.
16
+ * GraphBuilder — three-pass dependency graph construction.
7
17
  *
8
- * Three-pass approach:
9
- * Pass 1: Register all nodes (files, functions, classes, generics)
10
- * Pass 2: Add all edges (imports, calls, containment)
11
- * Pass 3: Build adjacency maps for O(1) traversal
18
+ * ID contract (must match oxc-parser.ts exactly):
19
+ * function: fn:<absolute-posix-path>:<FunctionName>
20
+ * class: class:<absolute-posix-path>:<ClassName>
21
+ * type/enum: type:<absolute-posix-path>:<Name> | enum:<absolute-posix-path>:<Name>
22
+ * variable: var:<absolute-posix-path>:<Name>
23
+ * file: <absolute-posix-path> (no prefix)
12
24
  *
13
- * Key correctness guarantees:
14
- * - No duplicate edges (tracked via edgeKey Set)
15
- * - Method nodes are resolved correctly via ClassName.method lookup
16
- * - Default imports are handled separately from named imports
17
- * - moduleId is propagated from ParsedFunction/ParsedClass to graph nodes
18
- * - Unresolved calls are tracked and emitted as low-confidence edges where possible
25
+ * No case normalisation — paths and names keep their original case.
26
+ * Lookups use exact string matching after posix-normalising separators.
19
27
  */
20
28
  export class GraphBuilder {
21
- /** Main entry point — takes all parsed files and returns the complete graph */
22
29
  build(files: ParsedFile[]): DependencyGraph {
23
- const graph: DependencyGraph = {
24
- nodes: new Map(),
25
- edges: [],
26
- outEdges: new Map(),
27
- inEdges: new Map(),
28
- }
29
-
30
- // Used to deduplicate edges: "source->target:type"
30
+ const graph = this.createEmptyGraph()
31
31
  const edgeKeys = new Set<string>()
32
32
 
33
- // Pass 1: add all nodes
33
+ // Pass 1: Register all nodes
34
34
  for (const file of files) {
35
35
  this.addFileNode(graph, file)
36
- for (const fn of file.functions) {
37
- this.addFunctionNode(graph, fn)
38
- }
39
- for (const cls of file.classes ?? []) {
40
- this.addClassNode(graph, cls, file.path)
41
- }
42
- for (const gen of file.generics ?? []) {
43
- this.addGenericNode(graph, gen)
44
- }
36
+ for (const fn of file.functions) this.addFunctionNode(graph, fn)
37
+ for (const cls of file.classes ?? []) this.addClassNode(graph, cls)
38
+ for (const gen of file.generics ?? []) this.addGenericNode(graph, gen)
39
+ for (const v of file.variables ?? []) this.addVariableNode(graph, v)
45
40
  }
46
41
 
47
- // Pass 2: add all edges
42
+ // Pass 2: Structural edges (imports, containment, inheritance)
48
43
  for (const file of files) {
49
44
  this.addImportEdges(graph, file, edgeKeys)
50
- this.addCallEdges(graph, file, edgeKeys)
51
45
  this.addContainmentEdges(graph, file, edgeKeys)
46
+ this.addInheritanceEdges(graph, file, edgeKeys)
47
+ }
48
+
49
+ // Pass 3: Behavioural edges (calls, accesses)
50
+ for (const file of files) {
51
+ this.addCallEdges(graph, file, edgeKeys)
52
52
  }
53
53
 
54
- // Pass 3: build adjacency maps for fast lookup
55
54
  this.buildAdjacencyMaps(graph)
55
+ return graph
56
+ }
57
+
58
+ /**
59
+ * Rebuild a lightweight DependencyGraph from a serialised lock file.
60
+ * Used for preflight checks without re-parsing the codebase.
61
+ */
62
+ buildFromLock(lock: MikkLock): DependencyGraph {
63
+ const graph = this.createEmptyGraph()
64
+ const edgeKeys = new Set<string>()
65
+
66
+ // File nodes
67
+ for (const file of Object.values(lock.files)) {
68
+ const p = this.normPath(file.path)
69
+ graph.nodes.set(p, {
70
+ id: p, type: 'file', name: nodePath.basename(p),
71
+ file: p, moduleId: file.moduleId,
72
+ metadata: { hash: file.hash },
73
+ })
74
+ }
75
+
76
+ // Function nodes
77
+ for (const fn of Object.values(lock.functions)) {
78
+ graph.nodes.set(fn.id, {
79
+ id: fn.id, type: 'function', name: fn.name,
80
+ file: this.normPath(fn.file), moduleId: fn.moduleId,
81
+ metadata: { ...fn },
82
+ })
83
+ }
84
+
85
+ // Class nodes
86
+ for (const cls of Object.values(lock.classes ?? {})) {
87
+ graph.nodes.set(cls.id, {
88
+ id: cls.id, type: 'class', name: cls.name,
89
+ file: this.normPath(cls.file), moduleId: cls.moduleId,
90
+ metadata: { ...cls },
91
+ })
92
+ }
93
+
94
+ // Edges from lock data
95
+ for (const file of Object.values(lock.files)) {
96
+ const fp = this.normPath(file.path)
97
+ // Import edges
98
+ for (const imp of file.imports ?? []) {
99
+ if (!imp.resolvedPath) continue
100
+ const rp = this.normPath(imp.resolvedPath)
101
+ if (graph.nodes.has(rp)) {
102
+ this.pushEdge(graph, edgeKeys, { from: fp, to: rp, type: 'imports', confidence: 1.0, weight: EDGE_WEIGHTS.imports })
103
+ }
104
+ }
105
+ // Containment: file → functions in this file
106
+ for (const fn of Object.values(lock.functions)) {
107
+ if (this.normPath(fn.file) === fp) {
108
+ this.pushEdge(graph, edgeKeys, { from: fp, to: fn.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
109
+ }
110
+ }
111
+ }
112
+
113
+ // Call edges
114
+ for (const fn of Object.values(lock.functions)) {
115
+ for (const calleeId of fn.calls) {
116
+ if (graph.nodes.has(calleeId)) {
117
+ this.pushEdge(graph, edgeKeys, { from: fn.id, to: calleeId, type: 'calls', confidence: 0.8, weight: EDGE_WEIGHTS.calls.exact })
118
+ }
119
+ }
120
+ }
56
121
 
122
+ this.buildAdjacencyMaps(graph)
57
123
  return graph
58
124
  }
59
125
 
60
126
  // -------------------------------------------------------------------------
61
- // Node registration
127
+ // Helpers
128
+ // -------------------------------------------------------------------------
129
+
130
+ private normPath(p: string): string {
131
+ return p.replace(/\\/g, '/').toLowerCase()
132
+ }
133
+
134
+ private createEmptyGraph(): DependencyGraph {
135
+ return { nodes: new Map(), edges: [], outEdges: new Map(), inEdges: new Map() }
136
+ }
137
+
138
+ // -------------------------------------------------------------------------
139
+ // Pass 1: Node Registration
62
140
  // -------------------------------------------------------------------------
63
141
 
64
142
  private addFileNode(graph: DependencyGraph, file: ParsedFile): void {
65
- graph.nodes.set(file.path, {
66
- id: file.path,
67
- type: 'file',
68
- label: path.basename(file.path),
69
- file: file.path,
70
- // moduleId on file nodes is not set here — it comes from the lock compiler
71
- // which maps files to declared modules after graph construction.
72
- metadata: { hash: file.hash },
143
+ const p = this.normPath(file.path)
144
+ graph.nodes.set(p, {
145
+ id: p, type: 'file', name: nodePath.basename(p),
146
+ file: p, metadata: { hash: file.hash },
73
147
  })
74
148
  }
75
149
 
76
150
  private addFunctionNode(graph: DependencyGraph, fn: ParsedFunction): void {
77
- // Use fn.moduleId if the parser/compiler populated it; otherwise leave undefined.
78
- // The lock compiler sets moduleId on MikkLockFunction; the graph node mirrors it
79
- // when graph is rebuilt from lock. For fresh parse output moduleId may be absent.
80
- const node: GraphNode = {
81
- id: fn.id,
82
- type: 'function',
83
- label: fn.name,
84
- file: fn.file,
151
+ graph.nodes.set(fn.id, {
152
+ id: fn.id, type: 'function', name: fn.name,
153
+ file: this.normPath(fn.file), moduleId: fn.moduleId,
85
154
  metadata: {
86
- startLine: fn.startLine,
87
- endLine: fn.endLine,
88
- isExported: fn.isExported,
89
- isAsync: fn.isAsync,
90
- hash: fn.hash,
91
- purpose: fn.purpose,
92
- params: fn.params?.map(p => ({
93
- name: p.name,
94
- type: p.type,
95
- ...(p.optional ? { optional: true } : {}),
96
- })),
97
- returnType: fn.returnType !== 'void' ? fn.returnType : undefined,
155
+ startLine: fn.startLine, endLine: fn.endLine,
156
+ isExported: fn.isExported, isAsync: fn.isAsync,
157
+ hash: fn.hash, purpose: fn.purpose,
158
+ params: fn.params, returnType: fn.returnType !== 'void' ? fn.returnType : undefined,
98
159
  edgeCasesHandled: fn.edgeCasesHandled,
99
160
  errorHandling: fn.errorHandling,
100
161
  detailedLines: fn.detailedLines,
101
162
  },
102
- }
103
- // Propagate moduleId if available on the parsed function
104
- if (fn.moduleId) {
105
- node.moduleId = fn.moduleId
106
- }
107
- graph.nodes.set(fn.id, node)
163
+ })
108
164
  }
109
165
 
110
- private addClassNode(graph: DependencyGraph, cls: ParsedClass, filePath: string): void {
111
- const node: GraphNode = {
112
- id: cls.id,
113
- type: 'class',
114
- label: cls.name,
115
- file: filePath,
166
+ private addClassNode(graph: DependencyGraph, cls: ParsedClass): void {
167
+ const p = this.normPath(cls.file)
168
+ graph.nodes.set(cls.id, {
169
+ id: cls.id, type: 'class', name: cls.name, file: p, moduleId: cls.moduleId,
116
170
  metadata: {
117
- startLine: cls.startLine,
118
- endLine: cls.endLine,
119
- isExported: cls.isExported,
120
- purpose: cls.purpose,
121
- edgeCasesHandled: cls.edgeCasesHandled,
122
- errorHandling: cls.errorHandling,
171
+ startLine: cls.startLine, endLine: cls.endLine,
172
+ isExported: cls.isExported, purpose: cls.purpose,
173
+ inheritsFrom: cls.extends ? [cls.extends] : [],
174
+ implements: cls.implements,
123
175
  },
124
- }
125
- if (cls.moduleId) {
126
- node.moduleId = cls.moduleId
127
- }
128
- graph.nodes.set(cls.id, node)
129
- // Add nodes for each method
130
- for (const method of cls.methods) {
131
- this.addFunctionNode(graph, method)
132
- }
176
+ })
177
+ for (const method of cls.methods) this.addFunctionNode(graph, method)
178
+ for (const prop of cls.properties ?? []) this.addVariableNode(graph, prop)
133
179
  }
134
180
 
135
- private addGenericNode(graph: DependencyGraph, gen: any): void {
181
+ private addGenericNode(graph: DependencyGraph, gen: ParsedGeneric): void {
136
182
  graph.nodes.set(gen.id, {
137
- id: gen.id,
138
- type: 'generic',
139
- label: gen.name,
140
- file: gen.file,
183
+ id: gen.id, type: 'generic', name: gen.name,
184
+ file: this.normPath(gen.file),
141
185
  metadata: {
142
- startLine: gen.startLine,
143
- endLine: gen.endLine,
144
- isExported: gen.isExported,
145
- purpose: gen.purpose,
146
- hash: gen.type,
186
+ startLine: gen.startLine, endLine: gen.endLine,
187
+ isExported: gen.isExported, purpose: gen.purpose,
188
+ // Store the kind (interface/type/enum) in genericKind, NOT hash
189
+ genericKind: gen.type,
147
190
  },
148
191
  })
149
192
  }
150
193
 
194
+ private addVariableNode(graph: DependencyGraph, v: ParsedVariable): void {
195
+ graph.nodes.set(v.id, {
196
+ id: v.id, type: 'variable', name: v.name,
197
+ file: this.normPath(v.file),
198
+ metadata: { startLine: v.line, isExported: v.isExported, purpose: v.purpose },
199
+ })
200
+ }
201
+
151
202
  // -------------------------------------------------------------------------
152
- // Edge construction
203
+ // Pass 2: Structural Edges
153
204
  // -------------------------------------------------------------------------
154
205
 
155
- /**
156
- * Import edges: fileA → fileB via 'imports'.
157
- * Only created when resolvedPath is non-empty AND the target file node exists.
158
- * When resolvedPath is empty the import was unresolved — no edge is created,
159
- * avoiding false positive graph connections to non-existent paths.
160
- */
161
- private addImportEdges(
162
- graph: DependencyGraph,
163
- file: ParsedFile,
164
- edgeKeys: Set<string>,
165
- ): void {
206
+ private addImportEdges(graph: DependencyGraph, file: ParsedFile, edgeKeys: Set<string>): void {
207
+ const src = this.normPath(file.path)
166
208
  for (const imp of file.imports) {
167
- if (!imp.resolvedPath || !graph.nodes.has(imp.resolvedPath)) continue
168
- this.pushEdge(graph, edgeKeys, {
169
- source: file.path,
170
- target: imp.resolvedPath,
171
- type: 'imports',
172
- confidence: 1.0,
173
- })
209
+ if (!imp.resolvedPath) continue
210
+ const tgt = this.normPath(imp.resolvedPath)
211
+ if (!graph.nodes.has(tgt)) continue
212
+ this.pushEdge(graph, edgeKeys, { from: src, to: tgt, type: 'imports', confidence: 1.0, weight: EDGE_WEIGHTS.imports })
174
213
  }
175
214
  }
176
215
 
177
- /**
178
- * Call edges: fnA → fnB via 'calls'.
179
- *
180
- * Resolution strategy (in priority order):
181
- * 1. Named imports: `import { foo } from './x'` → maps foo → fn:./x:foo
182
- * 2. Default import aliased calls: `import jwt from 'x'; jwt.verify()`
183
- * the receiver (jwt) is matched against default import bindings.
184
- * The method name (verify) is looked up in the resolved module.
185
- * 3. Local function in the same file (exact name match)
186
- * 4. Local class method (ClassName.method format)
187
- *
188
- * Confidence levels:
189
- * 1.0 — exact name, same file
190
- * 0.8 — resolved through named import
191
- * 0.6 resolved through default import + method name lookup
192
- * 0.5 dotted-access name stripped to simple name (uncertain receiver)
193
- */
194
- private addCallEdges(
195
- graph: DependencyGraph,
196
- file: ParsedFile,
197
- edgeKeys: Set<string>,
198
- ): void {
199
- // Build named-import map: importedName canonicalFunctionId
200
- const importedNames = new Map<string, string>()
201
- // Build default-import map: localAlias → resolvedFilePath
202
- const defaultImports = new Map<string, string>()
216
+ private addContainmentEdges(graph: DependencyGraph, file: ParsedFile, edgeKeys: Set<string>): void {
217
+ const src = this.normPath(file.path)
218
+ for (const fn of file.functions) {
219
+ this.pushEdge(graph, edgeKeys, { from: src, to: fn.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
220
+ }
221
+ for (const cls of file.classes ?? []) {
222
+ this.pushEdge(graph, edgeKeys, { from: src, to: cls.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
223
+ for (const method of cls.methods) {
224
+ this.pushEdge(graph, edgeKeys, { from: cls.id, to: method.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
225
+ }
226
+ for (const prop of cls.properties ?? []) {
227
+ this.pushEdge(graph, edgeKeys, { from: cls.id, to: prop.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
228
+ }
229
+ }
230
+ for (const gen of file.generics ?? []) {
231
+ this.pushEdge(graph, edgeKeys, { from: src, to: gen.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
232
+ }
233
+ for (const v of file.variables ?? []) {
234
+ this.pushEdge(graph, edgeKeys, { from: src, to: v.id, type: 'contains', confidence: 1.0, weight: EDGE_WEIGHTS.contains })
235
+ }
236
+ }
237
+
238
+ private addInheritanceEdges(graph: DependencyGraph, file: ParsedFile, edgeKeys: Set<string>): void {
239
+ // Build class name id map from graph (already populated in Pass 1)
240
+ const classNameToId = new Map<string, string>()
241
+ for (const [, node] of graph.nodes) {
242
+ if (node.type === 'class') classNameToId.set(node.name, node.id)
243
+ }
244
+
245
+ for (const cls of file.classes ?? []) {
246
+ if (cls.extends && classNameToId.has(cls.extends)) {
247
+ this.pushEdge(graph, edgeKeys, {
248
+ from: cls.id, to: classNameToId.get(cls.extends)!,
249
+ type: 'extends', confidence: 1.0, weight: EDGE_WEIGHTS.extends,
250
+ })
251
+ }
252
+ for (const iface of cls.implements ?? []) {
253
+ if (classNameToId.has(iface)) {
254
+ this.pushEdge(graph, edgeKeys, {
255
+ from: cls.id, to: classNameToId.get(iface)!,
256
+ type: 'implements', confidence: 1.0, weight: EDGE_WEIGHTS.implements,
257
+ })
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ // -------------------------------------------------------------------------
264
+ // Pass 3: Behavioural Edges (calls, accesses)
265
+ //
266
+ // Call resolution order (priority-first):
267
+ // 1. Named import: `import { foo } from './x'` → fn:<resolvedPath>:foo
268
+ // 2. Default import alias: `import jwt from './x'; jwt.verify()` → fn:<resolvedPath>:verify
269
+ // 3. Local function exact: same file, same name
270
+ // 4. Local class method: SomeClass.method in same file
271
+ //
272
+ // Confidence levels:
273
+ // 1.0 — local exact match (AST confirmed)
274
+ // 0.8 — named import match
275
+ // 0.6 — default-import method match
276
+ // 0.5 — dotted name stripped to simple name (receiver uncertain)
277
+ // -------------------------------------------------------------------------
278
+
279
+ private addCallEdges(graph: DependencyGraph, file: ParsedFile, edgeKeys: Set<string>): void {
280
+ const filePath = this.normPath(file.path)
281
+
282
+ // Build import lookup tables for this file
283
+ // key: the local name used in code (e.g. "verifyToken")
284
+ // value: the canonical graph node ID (e.g. "fn:/abs/path/jwt.ts:verifyToken")
285
+ const namedImportIds = new Map<string, string>()
286
+ // key: local alias (e.g. "jwt"), value: resolved absolute file path
287
+ const defaultImportPaths = new Map<string, string>()
203
288
 
204
289
  for (const imp of file.imports) {
205
290
  if (!imp.resolvedPath) continue
291
+ const resolvedPath = this.normPath(imp.resolvedPath)
292
+
206
293
  for (const name of imp.names) {
294
+ const localName = name.toLowerCase()
207
295
  if (imp.isDefault) {
208
- // `import jwt from 'x'` → defaultImports.set('jwt', 'src/x.ts')
209
- defaultImports.set(name, imp.resolvedPath)
296
+ // `import jwt from './jwt'` → defaultImportPaths['jwt'] = '/abs/.../jwt.ts'
297
+ defaultImportPaths.set(localName, resolvedPath)
210
298
  } else {
211
- // `import { foo } from 'x'` → importedNames.set('foo', 'fn:src/x.ts:foo')
212
- importedNames.set(name, `fn:${imp.resolvedPath}:${name}`)
299
+ // `import { verifyToken } from './jwt'` → namedImportIds['verifyToken'] = 'fn:.../jwt.ts:verifytoken'
300
+ const candidateId = `fn:${resolvedPath}:${localName}`
301
+ if (graph.nodes.has(candidateId)) {
302
+ namedImportIds.set(localName, candidateId)
303
+ }
213
304
  }
214
305
  }
215
306
  }
216
307
 
217
- // Build class name → class id map for this file (for method resolution)
308
+ // Class name → class node ID for local method resolution
218
309
  const localClassIds = new Map<string, string>()
219
310
  for (const cls of file.classes ?? []) {
220
311
  localClassIds.set(cls.name, cls.id)
221
312
  }
222
313
 
223
- const allFunctions = [
224
- ...file.functions,
225
- ...(file.classes ?? []).flatMap(c => c.methods),
314
+ // Collect all (sourceId, callName) pairs from every function and method
315
+ const behaviors: Array<{ sourceId: string; calls: Array<{ name: string; type: string }> }> = [
316
+ // Module-level calls
317
+ { sourceId: filePath, calls: file.calls ?? [] },
318
+ // Function-level calls
319
+ ...file.functions.map(fn => ({ sourceId: fn.id, calls: fn.calls })),
320
+ // Class method calls
321
+ ...(file.classes ?? []).flatMap(cls =>
322
+ cls.methods.map(m => ({ sourceId: m.id, calls: m.calls }))
323
+ ),
226
324
  ]
227
325
 
228
- for (const fn of allFunctions) {
229
- for (const call of fn.calls) {
230
- const hasDot = call.includes('.')
231
- const simpleName = hasDot ? call.split('.').pop()! : call
232
- const receiver = hasDot ? call.split('.')[0] : null
326
+ for (const { sourceId, calls } of behaviors) {
327
+ for (const call of calls) {
328
+ const callName = call.name
329
+ if (!callName || callName === 'super') continue
330
+
331
+ const normalizedCallName = callName.toLowerCase()
332
+ const hasDot = normalizedCallName.includes('.')
333
+ const simpleName = hasDot ? normalizedCallName.split('.').pop()! : normalizedCallName
334
+ const receiver = hasDot ? normalizedCallName.split('.')[0] : null
335
+ const isPropertyAccess = call.type === 'property'
233
336
 
234
- // --- 1. Named import exact match ---
235
- const namedId = importedNames.get(call) ?? importedNames.get(simpleName)
236
- if (namedId && graph.nodes.has(namedId)) {
337
+ // ── 1. Named import exact match ──────────────────────────
338
+ // Try full name first (e.g. "jwt.verify" mapped via named import of "verify")
339
+ const namedId = namedImportIds.get(normalizedCallName) ?? (receiver === null ? namedImportIds.get(simpleName) : undefined)
340
+ if (namedId) {
237
341
  this.pushEdge(graph, edgeKeys, {
238
- source: fn.id,
239
- target: namedId,
240
- type: 'calls',
241
- confidence: 0.8,
342
+ from: sourceId, to: namedId,
343
+ type: isPropertyAccess ? 'accesses' : 'calls',
344
+ confidence: 0.8, weight: EDGE_WEIGHTS.calls.exact,
242
345
  })
243
346
  continue
244
347
  }
245
348
 
246
- // --- 2. Default import: receiver is the alias, method is simpleName ---
247
- if (receiver && defaultImports.has(receiver)) {
248
- const resolvedFile = defaultImports.get(receiver)!
249
- // Try to find "fn:resolvedFile:simpleName" in graph
349
+ // ── 2. Default import: receiver is the alias ─────────────
350
+ if (receiver && defaultImportPaths.has(receiver)) {
351
+ const resolvedFile = defaultImportPaths.get(receiver)!
250
352
  const methodId = `fn:${resolvedFile}:${simpleName}`
251
353
  if (graph.nodes.has(methodId)) {
252
354
  this.pushEdge(graph, edgeKeys, {
253
- source: fn.id,
254
- target: methodId,
255
- type: 'calls',
256
- confidence: 0.6,
355
+ from: sourceId, to: methodId,
356
+ type: isPropertyAccess ? 'accesses' : 'calls',
357
+ confidence: 0.6, weight: EDGE_WEIGHTS.calls.method,
257
358
  })
258
359
  continue
259
360
  }
260
361
  }
261
362
 
262
- // --- 3. Local function exact match ---
263
- const localExactId = `fn:${file.path}:${simpleName}`
264
- if (graph.nodes.has(localExactId) && localExactId !== fn.id) {
363
+ // ── 3. Local function exact match ────────────────────────
364
+ const localId = `fn:${filePath}:${simpleName}`
365
+ if (graph.nodes.has(localId) && localId !== sourceId) {
265
366
  this.pushEdge(graph, edgeKeys, {
266
- source: fn.id,
267
- target: localExactId,
268
- type: 'calls',
269
- confidence: simpleName === call ? 1.0 : 0.5,
367
+ from: sourceId, to: localId,
368
+ type: isPropertyAccess ? 'accesses' : 'calls',
369
+ confidence: simpleName === callName ? 1.0 : 0.5,
370
+ weight: simpleName === callName ? EDGE_WEIGHTS.calls.exact : EDGE_WEIGHTS.calls.fuzzy,
270
371
  })
271
372
  continue
272
373
  }
273
374
 
274
- // --- 4. Local class method: ClassName.method format ---
375
+ // ── 4. Local class method: SomeClass.method ──────────────
275
376
  if (receiver && localClassIds.has(receiver)) {
276
- const clsId = localClassIds.get(receiver)!
277
- // Method IDs are stored as "fn:file:ClassName.methodName"
278
- const classMethodId = `fn:${file.path}:${receiver}.${simpleName}`
279
- if (graph.nodes.has(classMethodId) && classMethodId !== fn.id) {
377
+ // Method IDs are stored as fn:<file>:<ClassName>.<methodName>
378
+ const classMethodId = `fn:${filePath}:${receiver}.${simpleName}`
379
+ if (graph.nodes.has(classMethodId) && classMethodId !== sourceId) {
280
380
  this.pushEdge(graph, edgeKeys, {
281
- source: fn.id,
282
- target: classMethodId,
283
- type: 'calls',
284
- confidence: 0.8,
381
+ from: sourceId, to: classMethodId,
382
+ type: isPropertyAccess ? 'accesses' : 'calls',
383
+ confidence: 0.8, weight: EDGE_WEIGHTS.calls.method,
285
384
  })
286
385
  continue
287
386
  }
288
387
  }
289
388
 
290
- // Unresolved call — not added to graph.
389
+ // Unresolved call — intentionally not added to graph.
291
390
  // Callers can detect incomplete coverage by comparing
292
- // fn.calls.length to the number of outgoing 'calls' edges from fn.id.
293
- }
294
- }
295
- }
296
-
297
- /** Containment edges: file → function, file → class, class → method */
298
- private addContainmentEdges(
299
- graph: DependencyGraph,
300
- file: ParsedFile,
301
- edgeKeys: Set<string>,
302
- ): void {
303
- for (const fn of file.functions) {
304
- this.pushEdge(graph, edgeKeys, {
305
- source: file.path,
306
- target: fn.id,
307
- type: 'contains',
308
- })
309
- }
310
- for (const cls of file.classes ?? []) {
311
- this.pushEdge(graph, edgeKeys, {
312
- source: file.path,
313
- target: cls.id,
314
- type: 'contains',
315
- })
316
- for (const method of cls.methods) {
317
- this.pushEdge(graph, edgeKeys, {
318
- source: cls.id,
319
- target: method.id,
320
- type: 'contains',
321
- })
391
+ // fn.calls.length vs outgoing 'calls' edge count on fn.id.
322
392
  }
323
393
  }
324
394
  }
325
395
 
326
396
  // -------------------------------------------------------------------------
327
- // Helpers
397
+ // Edge helpers
328
398
  // -------------------------------------------------------------------------
329
399
 
330
- /**
331
- * Push an edge only if it hasn't been added before.
332
- * Edge key format: "source->target:type"
333
- */
334
- private pushEdge(
335
- graph: DependencyGraph,
336
- edgeKeys: Set<string>,
337
- edge: GraphEdge,
338
- ): void {
339
- const key = `${edge.source}->${edge.target}:${edge.type}`
400
+ private pushEdge(graph: DependencyGraph, edgeKeys: Set<string>, edge: GraphEdge): void {
401
+ const key = `${edge.from}->${edge.to}:${edge.type}`
340
402
  if (edgeKeys.has(key)) return
341
403
  edgeKeys.add(key)
342
404
  graph.edges.push(edge)
343
405
  }
344
406
 
345
- /** Build adjacency maps from edge list for O(1) lookups */
346
407
  private buildAdjacencyMaps(graph: DependencyGraph): void {
347
408
  for (const edge of graph.edges) {
348
- if (!graph.outEdges.has(edge.source)) graph.outEdges.set(edge.source, [])
349
- graph.outEdges.get(edge.source)!.push(edge)
350
-
351
- if (!graph.inEdges.has(edge.target)) graph.inEdges.set(edge.target, [])
352
- graph.inEdges.get(edge.target)!.push(edge)
409
+ if (!graph.outEdges.has(edge.from)) graph.outEdges.set(edge.from, [])
410
+ graph.outEdges.get(edge.from)!.push(edge)
411
+ if (!graph.inEdges.has(edge.to)) graph.inEdges.set(edge.to, [])
412
+ graph.inEdges.get(edge.to)!.push(edge)
353
413
  }
354
414
  }
355
415
  }