@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.
- 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 +66 -35
- package/src/contract/lock-reader.ts +24 -4
- package/src/contract/schema.ts +21 -0
- 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/graph-builder.ts +298 -255
- package/src/graph/impact-analyzer.ts +130 -119
- 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 -65
- package/src/parser/change-detector.ts +99 -0
- package/src/parser/go/go-extractor.ts +18 -8
- 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 +19 -10
- package/src/parser/types.ts +100 -73
- package/src/parser/typescript/ts-extractor.ts +229 -589
- 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,372 +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
|
+
}
|
|
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
|
-
//
|
|
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
|
-
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
|
-
//
|
|
203
|
+
// Pass 2: Structural Edges
|
|
157
204
|
// -------------------------------------------------------------------------
|
|
158
205
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
172
|
-
this.
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
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>()
|
|
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 '
|
|
213
|
-
|
|
296
|
+
// `import jwt from './jwt'` → defaultImportPaths['jwt'] = '/abs/.../jwt.ts'
|
|
297
|
+
defaultImportPaths.set(localName, resolvedPath)
|
|
214
298
|
} else {
|
|
215
|
-
// `import {
|
|
216
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
233
|
-
for (const call of
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
//
|
|
255
|
-
if (receiver &&
|
|
256
|
-
const resolvedFile =
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
//
|
|
271
|
-
const
|
|
272
|
-
if (graph.nodes.has(
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
//
|
|
375
|
+
// ── 4. Local class method: SomeClass.method ──────────────
|
|
283
376
|
if (receiver && localClassIds.has(receiver)) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
//
|
|
397
|
+
// Edge helpers
|
|
345
398
|
// -------------------------------------------------------------------------
|
|
346
399
|
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
366
|
-
graph.outEdges.get(edge.
|
|
367
|
-
|
|
368
|
-
|
|
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
|
}
|