@getmikk/core 1.8.3 → 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 (41) 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 +66 -35
  6. package/src/contract/lock-reader.ts +24 -4
  7. package/src/contract/schema.ts +21 -0
  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/graph-builder.ts +298 -255
  12. package/src/graph/impact-analyzer.ts +130 -119
  13. package/src/graph/index.ts +4 -0
  14. package/src/graph/memory-manager.ts +345 -0
  15. package/src/graph/query-engine.ts +79 -0
  16. package/src/graph/risk-engine.ts +86 -0
  17. package/src/graph/types.ts +89 -65
  18. package/src/parser/change-detector.ts +99 -0
  19. package/src/parser/go/go-extractor.ts +18 -8
  20. package/src/parser/go/go-parser.ts +2 -0
  21. package/src/parser/index.ts +88 -38
  22. package/src/parser/javascript/js-extractor.ts +1 -1
  23. package/src/parser/javascript/js-parser.ts +2 -0
  24. package/src/parser/oxc-parser.ts +675 -0
  25. package/src/parser/oxc-resolver.ts +83 -0
  26. package/src/parser/tree-sitter/parser.ts +19 -10
  27. package/src/parser/types.ts +100 -73
  28. package/src/parser/typescript/ts-extractor.ts +229 -589
  29. package/src/parser/typescript/ts-parser.ts +16 -171
  30. package/src/parser/typescript/ts-resolver.ts +11 -1
  31. package/src/search/bm25.ts +5 -2
  32. package/src/utils/minimatch.ts +1 -1
  33. package/tests/contract.test.ts +2 -2
  34. package/tests/dead-code.test.ts +7 -7
  35. package/tests/esm-resolver.test.ts +75 -0
  36. package/tests/graph.test.ts +20 -20
  37. package/tests/helpers.ts +11 -6
  38. package/tests/impact-classified.test.ts +37 -41
  39. package/tests/parser.test.ts +7 -5
  40. package/tests/ts-parser.test.ts +27 -52
  41. package/test-output.txt +0 -373
@@ -1,372 +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
+ }
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
+ }
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
- // Store the declaration kind (interface|type|const) separately from the
147
- // content hash. gen.hash is the actual content hash; gen.type is the
148
- // declaration kind string — they are different things and must not be mixed.
149
- hash: gen.hash ?? undefined,
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
150
189
  genericKind: gen.type,
151
190
  },
152
191
  })
153
192
  }
154
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
+
155
202
  // -------------------------------------------------------------------------
156
- // Edge construction
203
+ // Pass 2: Structural Edges
157
204
  // -------------------------------------------------------------------------
158
205
 
159
- /**
160
- * Import edges: fileA → fileB via 'imports'.
161
- * Only created when resolvedPath is non-empty AND the target file node exists.
162
- * When resolvedPath is empty the import was unresolved — no edge is created,
163
- * avoiding false positive graph connections to non-existent paths.
164
- */
165
- private addImportEdges(
166
- graph: DependencyGraph,
167
- file: ParsedFile,
168
- edgeKeys: Set<string>,
169
- ): void {
206
+ private addImportEdges(graph: DependencyGraph, file: ParsedFile, edgeKeys: Set<string>): void {
207
+ const src = this.normPath(file.path)
170
208
  for (const imp of file.imports) {
171
- if (!imp.resolvedPath || !graph.nodes.has(imp.resolvedPath)) continue
172
- this.pushEdge(graph, edgeKeys, {
173
- source: file.path,
174
- target: imp.resolvedPath,
175
- type: 'imports',
176
- confidence: 1.0,
177
- })
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 })
178
213
  }
179
214
  }
180
215
 
181
- /**
182
- * Call edges: fnA → fnB via 'calls'.
183
- *
184
- * Resolution strategy (in priority order):
185
- * 1. Named imports: `import { foo } from './x'` → maps foo → fn:./x:foo
186
- * 2. Default import aliased calls: `import jwt from 'x'; jwt.verify()`
187
- * the receiver (jwt) is matched against default import bindings.
188
- * The method name (verify) is looked up in the resolved module.
189
- * 3. Local function in the same file (exact name match)
190
- * 4. Local class method (ClassName.method format)
191
- *
192
- * Confidence levels:
193
- * 1.0 — exact name, same file
194
- * 0.8 — resolved through named import
195
- * 0.6 resolved through default import + method name lookup
196
- * 0.5 dotted-access name stripped to simple name (uncertain receiver)
197
- */
198
- private addCallEdges(
199
- graph: DependencyGraph,
200
- file: ParsedFile,
201
- edgeKeys: Set<string>,
202
- ): void {
203
- // Build named-import map: importedName canonicalFunctionId
204
- const importedNames = new Map<string, string>()
205
- // Build default-import map: localAlias → resolvedFilePath
206
- 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>()
207
288
 
208
289
  for (const imp of file.imports) {
209
290
  if (!imp.resolvedPath) continue
291
+ const resolvedPath = this.normPath(imp.resolvedPath)
292
+
210
293
  for (const name of imp.names) {
294
+ const localName = name.toLowerCase()
211
295
  if (imp.isDefault) {
212
- // `import jwt from 'x'` → defaultImports.set('jwt', 'src/x.ts')
213
- defaultImports.set(name, imp.resolvedPath)
296
+ // `import jwt from './jwt'` → defaultImportPaths['jwt'] = '/abs/.../jwt.ts'
297
+ defaultImportPaths.set(localName, resolvedPath)
214
298
  } else {
215
- // `import { foo } from 'x'` → importedNames.set('foo', 'fn:src/x.ts:foo')
216
- 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
+ }
217
304
  }
218
305
  }
219
306
  }
220
307
 
221
- // Build class name → class id map for this file (for method resolution)
308
+ // Class name → class node ID for local method resolution
222
309
  const localClassIds = new Map<string, string>()
223
310
  for (const cls of file.classes ?? []) {
224
311
  localClassIds.set(cls.name, cls.id)
225
312
  }
226
313
 
227
- const allFunctions = [
228
- ...file.functions,
229
- ...(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
+ ),
230
324
  ]
231
325
 
232
- for (const fn of allFunctions) {
233
- for (const call of fn.calls) {
234
- const hasDot = call.includes('.')
235
- const simpleName = hasDot ? call.split('.').pop()! : call
236
- const receiver = hasDot ? call.split('.')[0] : null
237
-
238
- // --- 1. Named import exact match ---
239
- // Only fall back to simpleName when there is no dotted receiver.
240
- // If call = "jwt.verify", falling back to importedNames.get("verify") could
241
- // match a completely different import named "verify" — wrong target, high
242
- // confidence false-positive. Only strip the receiver when there is none.
243
- const namedId = importedNames.get(call) ?? (receiver === null ? importedNames.get(simpleName) : undefined)
244
- if (namedId && graph.nodes.has(namedId)) {
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'
336
+
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) {
245
341
  this.pushEdge(graph, edgeKeys, {
246
- source: fn.id,
247
- target: namedId,
248
- type: 'calls',
249
- confidence: 0.8,
342
+ from: sourceId, to: namedId,
343
+ type: isPropertyAccess ? 'accesses' : 'calls',
344
+ confidence: 0.8, weight: EDGE_WEIGHTS.calls.exact,
250
345
  })
251
346
  continue
252
347
  }
253
348
 
254
- // --- 2. Default import: receiver is the alias, method is simpleName ---
255
- if (receiver && defaultImports.has(receiver)) {
256
- const resolvedFile = defaultImports.get(receiver)!
257
- // 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)!
258
352
  const methodId = `fn:${resolvedFile}:${simpleName}`
259
353
  if (graph.nodes.has(methodId)) {
260
354
  this.pushEdge(graph, edgeKeys, {
261
- source: fn.id,
262
- target: methodId,
263
- type: 'calls',
264
- confidence: 0.6,
355
+ from: sourceId, to: methodId,
356
+ type: isPropertyAccess ? 'accesses' : 'calls',
357
+ confidence: 0.6, weight: EDGE_WEIGHTS.calls.method,
265
358
  })
266
359
  continue
267
360
  }
268
361
  }
269
362
 
270
- // --- 3. Local function exact match ---
271
- const localExactId = `fn:${file.path}:${simpleName}`
272
- 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) {
273
366
  this.pushEdge(graph, edgeKeys, {
274
- source: fn.id,
275
- target: localExactId,
276
- type: 'calls',
277
- 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,
278
371
  })
279
372
  continue
280
373
  }
281
374
 
282
- // --- 4. Local class method: ClassName.method format ---
375
+ // ── 4. Local class method: SomeClass.method ──────────────
283
376
  if (receiver && localClassIds.has(receiver)) {
284
- const clsId = localClassIds.get(receiver)!
285
- // Method IDs are stored as "fn:file:ClassName.methodName"
286
- const classMethodId = `fn:${file.path}:${receiver}.${simpleName}`
287
- 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) {
288
380
  this.pushEdge(graph, edgeKeys, {
289
- source: fn.id,
290
- target: classMethodId,
291
- type: 'calls',
292
- confidence: 0.8,
381
+ from: sourceId, to: classMethodId,
382
+ type: isPropertyAccess ? 'accesses' : 'calls',
383
+ confidence: 0.8, weight: EDGE_WEIGHTS.calls.method,
293
384
  })
294
385
  continue
295
386
  }
296
387
  }
297
388
 
298
- // Unresolved call — not added to graph.
389
+ // Unresolved call — intentionally not added to graph.
299
390
  // Callers can detect incomplete coverage by comparing
300
- // fn.calls.length to the number of outgoing 'calls' edges from fn.id.
301
- }
302
- }
303
- }
304
-
305
- /** Containment edges: file → function, file → class, class → method, file → generic */
306
- private addContainmentEdges(
307
- graph: DependencyGraph,
308
- file: ParsedFile,
309
- edgeKeys: Set<string>,
310
- ): void {
311
- for (const fn of file.functions) {
312
- this.pushEdge(graph, edgeKeys, {
313
- source: file.path,
314
- target: fn.id,
315
- type: 'contains',
316
- })
317
- }
318
- for (const cls of file.classes ?? []) {
319
- this.pushEdge(graph, edgeKeys, {
320
- source: file.path,
321
- target: cls.id,
322
- type: 'contains',
323
- })
324
- for (const method of cls.methods) {
325
- this.pushEdge(graph, edgeKeys, {
326
- source: cls.id,
327
- target: method.id,
328
- type: 'contains',
329
- })
391
+ // fn.calls.length vs outgoing 'calls' edge count on fn.id.
330
392
  }
331
393
  }
332
- // Generic declarations (interfaces, type aliases, top-level constants) are also
333
- // contained by their file — needed so dead-code and impact analysis can trace them.
334
- for (const gen of file.generics ?? []) {
335
- this.pushEdge(graph, edgeKeys, {
336
- source: file.path,
337
- target: gen.id,
338
- type: 'contains',
339
- })
340
- }
341
394
  }
342
395
 
343
396
  // -------------------------------------------------------------------------
344
- // Helpers
397
+ // Edge helpers
345
398
  // -------------------------------------------------------------------------
346
399
 
347
- /**
348
- * Push an edge only if it hasn't been added before.
349
- * Edge key format: "source->target:type"
350
- */
351
- private pushEdge(
352
- graph: DependencyGraph,
353
- edgeKeys: Set<string>,
354
- edge: GraphEdge,
355
- ): void {
356
- 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}`
357
402
  if (edgeKeys.has(key)) return
358
403
  edgeKeys.add(key)
359
404
  graph.edges.push(edge)
360
405
  }
361
406
 
362
- /** Build adjacency maps from edge list for O(1) lookups */
363
407
  private buildAdjacencyMaps(graph: DependencyGraph): void {
364
408
  for (const edge of graph.edges) {
365
- if (!graph.outEdges.has(edge.source)) graph.outEdges.set(edge.source, [])
366
- graph.outEdges.get(edge.source)!.push(edge)
367
-
368
- if (!graph.inEdges.has(edge.target)) graph.inEdges.set(edge.target, [])
369
- 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)
370
413
  }
371
414
  }
372
415
  }