@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.
- package/package.json +3 -1
- package/src/constants.ts +285 -0
- package/src/contract/contract-generator.ts +7 -0
- package/src/contract/index.ts +2 -3
- package/src/contract/lock-compiler.ts +74 -42
- package/src/contract/lock-reader.ts +24 -4
- package/src/contract/schema.ts +27 -1
- package/src/error-handler.ts +430 -0
- package/src/graph/cluster-detector.ts +45 -20
- package/src/graph/confidence-engine.ts +60 -0
- package/src/graph/dead-code-detector.ts +27 -5
- package/src/graph/graph-builder.ts +298 -238
- package/src/graph/impact-analyzer.ts +131 -114
- package/src/graph/index.ts +4 -0
- package/src/graph/memory-manager.ts +345 -0
- package/src/graph/query-engine.ts +79 -0
- package/src/graph/risk-engine.ts +86 -0
- package/src/graph/types.ts +89 -64
- package/src/parser/boundary-checker.ts +3 -1
- package/src/parser/change-detector.ts +99 -0
- package/src/parser/go/go-extractor.ts +28 -9
- package/src/parser/go/go-parser.ts +2 -0
- package/src/parser/index.ts +88 -38
- package/src/parser/javascript/js-extractor.ts +1 -1
- package/src/parser/javascript/js-parser.ts +2 -0
- package/src/parser/oxc-parser.ts +675 -0
- package/src/parser/oxc-resolver.ts +83 -0
- package/src/parser/tree-sitter/parser.ts +27 -15
- package/src/parser/types.ts +100 -73
- package/src/parser/typescript/ts-extractor.ts +241 -537
- package/src/parser/typescript/ts-parser.ts +16 -171
- package/src/parser/typescript/ts-resolver.ts +11 -1
- package/src/search/bm25.ts +5 -2
- package/src/utils/minimatch.ts +1 -1
- package/tests/contract.test.ts +2 -2
- package/tests/dead-code.test.ts +7 -7
- package/tests/esm-resolver.test.ts +75 -0
- package/tests/graph.test.ts +20 -20
- package/tests/helpers.ts +11 -6
- package/tests/impact-classified.test.ts +37 -41
- package/tests/parser.test.ts +7 -5
- package/tests/ts-parser.test.ts +27 -52
- package/test-output.txt +0 -373
|
@@ -1,355 +1,415 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as nodePath from 'node:path'
|
|
2
2
|
import type { DependencyGraph, GraphNode, GraphEdge } from './types.js'
|
|
3
|
-
import type {
|
|
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 —
|
|
16
|
+
* GraphBuilder — three-pass dependency graph construction.
|
|
7
17
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
for (const
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
type: 'file',
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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:
|
|
181
|
+
private addGenericNode(graph: DependencyGraph, gen: ParsedGeneric): void {
|
|
136
182
|
graph.nodes.set(gen.id, {
|
|
137
|
-
id: gen.id,
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
203
|
+
// Pass 2: Structural Edges
|
|
153
204
|
// -------------------------------------------------------------------------
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
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 '
|
|
209
|
-
|
|
296
|
+
// `import jwt from './jwt'` → defaultImportPaths['jwt'] = '/abs/.../jwt.ts'
|
|
297
|
+
defaultImportPaths.set(localName, resolvedPath)
|
|
210
298
|
} else {
|
|
211
|
-
// `import {
|
|
212
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
229
|
-
for (const call of
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
//
|
|
247
|
-
if (receiver &&
|
|
248
|
-
const resolvedFile =
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
263
|
-
const
|
|
264
|
-
if (graph.nodes.has(
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
//
|
|
375
|
+
// ── 4. Local class method: SomeClass.method ──────────────
|
|
275
376
|
if (receiver && localClassIds.has(receiver)) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
//
|
|
397
|
+
// Edge helpers
|
|
328
398
|
// -------------------------------------------------------------------------
|
|
329
399
|
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
349
|
-
graph.outEdges.get(edge.
|
|
350
|
-
|
|
351
|
-
|
|
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
|
}
|