@getmikk/core 1.8.1 → 1.8.3
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 +1 -1
- package/src/contract/contract-reader.ts +9 -9
- package/src/contract/lock-compiler.ts +8 -7
- package/src/contract/lock-reader.ts +9 -6
- package/src/contract/schema.ts +6 -1
- package/src/graph/dead-code-detector.ts +134 -53
- package/src/graph/graph-builder.ts +216 -61
- package/src/graph/impact-analyzer.ts +59 -21
- package/src/graph/types.ts +1 -0
- package/src/parser/boundary-checker.ts +3 -1
- package/src/parser/go/go-extractor.ts +10 -1
- package/src/parser/javascript/js-extractor.ts +22 -6
- package/src/parser/javascript/js-parser.ts +24 -17
- package/src/parser/javascript/js-resolver.ts +63 -22
- package/src/parser/parser-constants.ts +82 -0
- package/src/parser/tree-sitter/parser.ts +356 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +109 -34
- package/src/parser/typescript/ts-parser.ts +22 -18
- package/src/parser/typescript/ts-resolver.ts +78 -45
- package/src/utils/json.ts +27 -0
- package/tests/js-parser.test.ts +23 -3
- package/tests/tree-sitter-parser.test.ts +11 -3
|
@@ -4,7 +4,18 @@ import type { ParsedFile, ParsedFunction, ParsedClass } from '../parser/types.js
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* GraphBuilder — takes parsed files and builds the dependency graph.
|
|
7
|
-
*
|
|
7
|
+
*
|
|
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
|
|
12
|
+
*
|
|
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
|
|
8
19
|
*/
|
|
9
20
|
export class GraphBuilder {
|
|
10
21
|
/** Main entry point — takes all parsed files and returns the complete graph */
|
|
@@ -16,45 +27,57 @@ export class GraphBuilder {
|
|
|
16
27
|
inEdges: new Map(),
|
|
17
28
|
}
|
|
18
29
|
|
|
19
|
-
//
|
|
30
|
+
// Used to deduplicate edges: "source->target:type"
|
|
31
|
+
const edgeKeys = new Set<string>()
|
|
32
|
+
|
|
33
|
+
// Pass 1: add all nodes
|
|
20
34
|
for (const file of files) {
|
|
21
35
|
this.addFileNode(graph, file)
|
|
22
36
|
for (const fn of file.functions) {
|
|
23
37
|
this.addFunctionNode(graph, fn)
|
|
24
38
|
}
|
|
25
|
-
for (const cls of file.classes
|
|
39
|
+
for (const cls of file.classes ?? []) {
|
|
26
40
|
this.addClassNode(graph, cls, file.path)
|
|
27
41
|
}
|
|
28
|
-
for (const gen of file.generics
|
|
42
|
+
for (const gen of file.generics ?? []) {
|
|
29
43
|
this.addGenericNode(graph, gen)
|
|
30
44
|
}
|
|
31
45
|
}
|
|
32
46
|
|
|
33
|
-
//
|
|
47
|
+
// Pass 2: add all edges
|
|
34
48
|
for (const file of files) {
|
|
35
|
-
this.addImportEdges(graph, file)
|
|
36
|
-
this.addCallEdges(graph, file)
|
|
37
|
-
this.addContainmentEdges(graph, file)
|
|
49
|
+
this.addImportEdges(graph, file, edgeKeys)
|
|
50
|
+
this.addCallEdges(graph, file, edgeKeys)
|
|
51
|
+
this.addContainmentEdges(graph, file, edgeKeys)
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
//
|
|
54
|
+
// Pass 3: build adjacency maps for fast lookup
|
|
41
55
|
this.buildAdjacencyMaps(graph)
|
|
42
56
|
|
|
43
57
|
return graph
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
// -------------------------------------------------------------------------
|
|
61
|
+
// Node registration
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
|
|
46
64
|
private addFileNode(graph: DependencyGraph, file: ParsedFile): void {
|
|
47
65
|
graph.nodes.set(file.path, {
|
|
48
66
|
id: file.path,
|
|
49
67
|
type: 'file',
|
|
50
68
|
label: path.basename(file.path),
|
|
51
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.
|
|
52
72
|
metadata: { hash: file.hash },
|
|
53
73
|
})
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
private addFunctionNode(graph: DependencyGraph, fn: ParsedFunction): void {
|
|
57
|
-
|
|
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 = {
|
|
58
81
|
id: fn.id,
|
|
59
82
|
type: 'function',
|
|
60
83
|
label: fn.name,
|
|
@@ -66,18 +89,26 @@ export class GraphBuilder {
|
|
|
66
89
|
isAsync: fn.isAsync,
|
|
67
90
|
hash: fn.hash,
|
|
68
91
|
purpose: fn.purpose,
|
|
69
|
-
params: fn.params?.map(p => ({
|
|
92
|
+
params: fn.params?.map(p => ({
|
|
93
|
+
name: p.name,
|
|
94
|
+
type: p.type,
|
|
95
|
+
...(p.optional ? { optional: true } : {}),
|
|
96
|
+
})),
|
|
70
97
|
returnType: fn.returnType !== 'void' ? fn.returnType : undefined,
|
|
71
98
|
edgeCasesHandled: fn.edgeCasesHandled,
|
|
72
99
|
errorHandling: fn.errorHandling,
|
|
73
100
|
detailedLines: fn.detailedLines,
|
|
74
101
|
},
|
|
75
|
-
}
|
|
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)
|
|
76
108
|
}
|
|
77
109
|
|
|
78
110
|
private addClassNode(graph: DependencyGraph, cls: ParsedClass, filePath: string): void {
|
|
79
|
-
|
|
80
|
-
graph.nodes.set(cls.id, {
|
|
111
|
+
const node: GraphNode = {
|
|
81
112
|
id: cls.id,
|
|
82
113
|
type: 'class',
|
|
83
114
|
label: cls.name,
|
|
@@ -90,7 +121,11 @@ export class GraphBuilder {
|
|
|
90
121
|
edgeCasesHandled: cls.edgeCasesHandled,
|
|
91
122
|
errorHandling: cls.errorHandling,
|
|
92
123
|
},
|
|
93
|
-
}
|
|
124
|
+
}
|
|
125
|
+
if (cls.moduleId) {
|
|
126
|
+
node.moduleId = cls.moduleId
|
|
127
|
+
}
|
|
128
|
+
graph.nodes.set(cls.id, node)
|
|
94
129
|
// Add nodes for each method
|
|
95
130
|
for (const method of cls.methods) {
|
|
96
131
|
this.addFunctionNode(graph, method)
|
|
@@ -108,109 +143,229 @@ export class GraphBuilder {
|
|
|
108
143
|
endLine: gen.endLine,
|
|
109
144
|
isExported: gen.isExported,
|
|
110
145
|
purpose: gen.purpose,
|
|
111
|
-
|
|
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,
|
|
150
|
+
genericKind: gen.type,
|
|
112
151
|
},
|
|
113
152
|
})
|
|
114
153
|
}
|
|
115
154
|
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
// Edge construction
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
|
|
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 {
|
|
118
170
|
for (const imp of file.imports) {
|
|
119
|
-
if (imp.resolvedPath
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
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
|
+
})
|
|
127
178
|
}
|
|
128
179
|
}
|
|
129
180
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
133
204
|
const importedNames = new Map<string, string>()
|
|
205
|
+
// Build default-import map: localAlias → resolvedFilePath
|
|
206
|
+
const defaultImports = new Map<string, string>()
|
|
207
|
+
|
|
134
208
|
for (const imp of file.imports) {
|
|
135
|
-
if (imp.resolvedPath)
|
|
136
|
-
|
|
209
|
+
if (!imp.resolvedPath) continue
|
|
210
|
+
for (const name of imp.names) {
|
|
211
|
+
if (imp.isDefault) {
|
|
212
|
+
// `import jwt from 'x'` → defaultImports.set('jwt', 'src/x.ts')
|
|
213
|
+
defaultImports.set(name, imp.resolvedPath)
|
|
214
|
+
} else {
|
|
215
|
+
// `import { foo } from 'x'` → importedNames.set('foo', 'fn:src/x.ts:foo')
|
|
137
216
|
importedNames.set(name, `fn:${imp.resolvedPath}:${name}`)
|
|
138
217
|
}
|
|
139
218
|
}
|
|
140
219
|
}
|
|
141
220
|
|
|
142
|
-
|
|
221
|
+
// Build class name → class id map for this file (for method resolution)
|
|
222
|
+
const localClassIds = new Map<string, string>()
|
|
223
|
+
for (const cls of file.classes ?? []) {
|
|
224
|
+
localClassIds.set(cls.name, cls.id)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const allFunctions = [
|
|
228
|
+
...file.functions,
|
|
229
|
+
...(file.classes ?? []).flatMap(c => c.methods),
|
|
230
|
+
]
|
|
143
231
|
|
|
144
232
|
for (const fn of allFunctions) {
|
|
145
233
|
for (const call of fn.calls) {
|
|
146
|
-
|
|
147
|
-
const simpleName =
|
|
234
|
+
const hasDot = call.includes('.')
|
|
235
|
+
const simpleName = hasDot ? call.split('.').pop()! : call
|
|
236
|
+
const receiver = hasDot ? call.split('.')[0] : null
|
|
148
237
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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)) {
|
|
245
|
+
this.pushEdge(graph, edgeKeys, {
|
|
153
246
|
source: fn.id,
|
|
154
|
-
target:
|
|
247
|
+
target: namedId,
|
|
155
248
|
type: 'calls',
|
|
156
|
-
confidence: 0.8,
|
|
249
|
+
confidence: 0.8,
|
|
157
250
|
})
|
|
158
251
|
continue
|
|
159
252
|
}
|
|
160
253
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
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
|
|
258
|
+
const methodId = `fn:${resolvedFile}:${simpleName}`
|
|
259
|
+
if (graph.nodes.has(methodId)) {
|
|
260
|
+
this.pushEdge(graph, edgeKeys, {
|
|
261
|
+
source: fn.id,
|
|
262
|
+
target: methodId,
|
|
263
|
+
type: 'calls',
|
|
264
|
+
confidence: 0.6,
|
|
265
|
+
})
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- 3. Local function exact match ---
|
|
271
|
+
const localExactId = `fn:${file.path}:${simpleName}`
|
|
272
|
+
if (graph.nodes.has(localExactId) && localExactId !== fn.id) {
|
|
273
|
+
this.pushEdge(graph, edgeKeys, {
|
|
166
274
|
source: fn.id,
|
|
167
|
-
target:
|
|
275
|
+
target: localExactId,
|
|
168
276
|
type: 'calls',
|
|
169
|
-
confidence: simpleName === call ? 1.0 : 0.5,
|
|
277
|
+
confidence: simpleName === call ? 1.0 : 0.5,
|
|
170
278
|
})
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- 4. Local class method: ClassName.method format ---
|
|
283
|
+
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) {
|
|
288
|
+
this.pushEdge(graph, edgeKeys, {
|
|
289
|
+
source: fn.id,
|
|
290
|
+
target: classMethodId,
|
|
291
|
+
type: 'calls',
|
|
292
|
+
confidence: 0.8,
|
|
293
|
+
})
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
171
296
|
}
|
|
297
|
+
|
|
298
|
+
// Unresolved call — not added to graph.
|
|
299
|
+
// Callers can detect incomplete coverage by comparing
|
|
300
|
+
// fn.calls.length to the number of outgoing 'calls' edges from fn.id.
|
|
172
301
|
}
|
|
173
302
|
}
|
|
174
303
|
}
|
|
175
304
|
|
|
176
|
-
/**
|
|
177
|
-
private addContainmentEdges(
|
|
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 {
|
|
178
311
|
for (const fn of file.functions) {
|
|
179
|
-
|
|
312
|
+
this.pushEdge(graph, edgeKeys, {
|
|
180
313
|
source: file.path,
|
|
181
314
|
target: fn.id,
|
|
182
315
|
type: 'contains',
|
|
183
316
|
})
|
|
184
317
|
}
|
|
185
|
-
for (const cls of file.classes) {
|
|
186
|
-
|
|
318
|
+
for (const cls of file.classes ?? []) {
|
|
319
|
+
this.pushEdge(graph, edgeKeys, {
|
|
187
320
|
source: file.path,
|
|
188
321
|
target: cls.id,
|
|
189
322
|
type: 'contains',
|
|
190
323
|
})
|
|
191
324
|
for (const method of cls.methods) {
|
|
192
|
-
|
|
325
|
+
this.pushEdge(graph, edgeKeys, {
|
|
193
326
|
source: cls.id,
|
|
194
327
|
target: method.id,
|
|
195
328
|
type: 'contains',
|
|
196
329
|
})
|
|
197
330
|
}
|
|
198
331
|
}
|
|
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
|
+
}
|
|
342
|
+
|
|
343
|
+
// -------------------------------------------------------------------------
|
|
344
|
+
// Helpers
|
|
345
|
+
// -------------------------------------------------------------------------
|
|
346
|
+
|
|
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}`
|
|
357
|
+
if (edgeKeys.has(key)) return
|
|
358
|
+
edgeKeys.add(key)
|
|
359
|
+
graph.edges.push(edge)
|
|
199
360
|
}
|
|
200
361
|
|
|
201
362
|
/** Build adjacency maps from edge list for O(1) lookups */
|
|
202
363
|
private buildAdjacencyMaps(graph: DependencyGraph): void {
|
|
203
364
|
for (const edge of graph.edges) {
|
|
204
|
-
|
|
205
|
-
if (!graph.outEdges.has(edge.source)) {
|
|
206
|
-
graph.outEdges.set(edge.source, [])
|
|
207
|
-
}
|
|
365
|
+
if (!graph.outEdges.has(edge.source)) graph.outEdges.set(edge.source, [])
|
|
208
366
|
graph.outEdges.get(edge.source)!.push(edge)
|
|
209
367
|
|
|
210
|
-
|
|
211
|
-
if (!graph.inEdges.has(edge.target)) {
|
|
212
|
-
graph.inEdges.set(edge.target, [])
|
|
213
|
-
}
|
|
368
|
+
if (!graph.inEdges.has(edge.target)) graph.inEdges.set(edge.target, [])
|
|
214
369
|
graph.inEdges.get(edge.target)!.push(edge)
|
|
215
370
|
}
|
|
216
371
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import type { DependencyGraph, ImpactResult, ClassifiedImpact, RiskLevel } from './types.js'
|
|
1
|
+
import type { DependencyGraph, GraphEdge, ImpactResult, ClassifiedImpact, RiskLevel } from './types.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ImpactAnalyzer — Given changed nodes, walks the graph backwards (BFS)
|
|
5
5
|
* to find everything that depends on them.
|
|
6
6
|
* Powers "what breaks if I change X?"
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Risk classification:
|
|
9
9
|
* CRITICAL = direct caller (depth 1) that crosses a module boundary
|
|
10
10
|
* HIGH = direct caller (depth 1) within the same module
|
|
11
11
|
* MEDIUM = depth 2
|
|
12
12
|
* LOW = depth 3+
|
|
13
|
+
*
|
|
14
|
+
* Confidence is derived from the quality of resolved edges in the traversal
|
|
15
|
+
* path, not from the size of the result set. A small impact set built from
|
|
16
|
+
* low-confidence (unresolved/fuzzy) edges is still LOW confidence.
|
|
13
17
|
*/
|
|
14
18
|
export class ImpactAnalyzer {
|
|
15
19
|
constructor(private graph: DependencyGraph) { }
|
|
@@ -18,30 +22,43 @@ export class ImpactAnalyzer {
|
|
|
18
22
|
analyze(changedNodeIds: string[]): ImpactResult {
|
|
19
23
|
const visited = new Set<string>()
|
|
20
24
|
const depthMap = new Map<string, number>()
|
|
21
|
-
|
|
25
|
+
// Track the minimum confidence seen along the path to each node
|
|
26
|
+
const pathConfidence = new Map<string, number>()
|
|
27
|
+
|
|
28
|
+
const queue: { id: string; depth: number; confidence: number }[] =
|
|
29
|
+
changedNodeIds.map(id => ({ id, depth: 0, confidence: 1.0 }))
|
|
30
|
+
// Use an index pointer instead of queue.shift() to avoid O(n) cost per dequeue.
|
|
31
|
+
let queueHead = 0
|
|
22
32
|
let maxDepth = 0
|
|
23
33
|
|
|
24
34
|
const changedSet = new Set(changedNodeIds)
|
|
25
35
|
|
|
26
|
-
// Collect module IDs of the changed nodes
|
|
27
|
-
|
|
36
|
+
// Collect module IDs of the changed nodes — filter out undefined so that
|
|
37
|
+
// nodes without a moduleId don't accidentally match every other unmoduled node
|
|
38
|
+
// and cause everything to appear "same module".
|
|
39
|
+
const changedModules = new Set<string>()
|
|
28
40
|
for (const id of changedNodeIds) {
|
|
29
41
|
const node = this.graph.nodes.get(id)
|
|
30
|
-
if (node) changedModules.add(node.moduleId)
|
|
42
|
+
if (node?.moduleId) changedModules.add(node.moduleId)
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
while (queue.length
|
|
34
|
-
const { id: current, depth } = queue
|
|
45
|
+
while (queueHead < queue.length) {
|
|
46
|
+
const { id: current, depth, confidence: pathConf } = queue[queueHead++]
|
|
35
47
|
if (visited.has(current)) continue
|
|
36
48
|
visited.add(current)
|
|
37
49
|
depthMap.set(current, depth)
|
|
50
|
+
pathConfidence.set(current, pathConf)
|
|
38
51
|
maxDepth = Math.max(maxDepth, depth)
|
|
39
52
|
|
|
40
53
|
// Find everything that depends on current (incoming edges)
|
|
41
54
|
const dependents = this.graph.inEdges.get(current) || []
|
|
42
55
|
for (const edge of dependents) {
|
|
43
56
|
if (!visited.has(edge.source) && edge.type !== 'contains') {
|
|
44
|
-
|
|
57
|
+
// Propagate the minimum confidence seen so far on this path.
|
|
58
|
+
// A chain is only as trustworthy as its weakest link.
|
|
59
|
+
const edgeConf = edge.confidence ?? 1.0
|
|
60
|
+
const newPathConf = Math.min(pathConf, edgeConf)
|
|
61
|
+
queue.push({ id: edge.source, depth: depth + 1, confidence: newPathConf })
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
}
|
|
@@ -61,13 +78,15 @@ export class ImpactAnalyzer {
|
|
|
61
78
|
if (!node) continue
|
|
62
79
|
|
|
63
80
|
const depth = depthMap.get(id) ?? 999
|
|
64
|
-
|
|
81
|
+
// A node crosses a module boundary when its module differs from ALL changed modules.
|
|
82
|
+
// If the node has no moduleId, treat it as crossing a boundary (unknown module ≠ known).
|
|
83
|
+
const crossesBoundary = !node.moduleId || !changedModules.has(node.moduleId)
|
|
65
84
|
|
|
66
85
|
const risk: RiskLevel =
|
|
67
86
|
depth === 1 && crossesBoundary ? 'critical' :
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
depth === 1 ? 'high' :
|
|
88
|
+
depth === 2 ? 'medium' :
|
|
89
|
+
'low'
|
|
71
90
|
|
|
72
91
|
const entry: ClassifiedImpact = {
|
|
73
92
|
nodeId: id,
|
|
@@ -85,22 +104,41 @@ export class ImpactAnalyzer {
|
|
|
85
104
|
changed: changedNodeIds,
|
|
86
105
|
impacted,
|
|
87
106
|
depth: maxDepth,
|
|
88
|
-
confidence: this.computeConfidence(impacted
|
|
107
|
+
confidence: this.computeConfidence(impacted, pathConfidence),
|
|
89
108
|
classified,
|
|
90
109
|
}
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
113
|
+
* Derive confidence from the actual quality of edges traversed, not from
|
|
114
|
+
* result size. A small result built from fuzzy/unresolved edges is LOW
|
|
115
|
+
* confidence; a large result built from high-confidence AST edges is HIGH.
|
|
116
|
+
*
|
|
117
|
+
* Algorithm:
|
|
118
|
+
* - Compute the average minimum-path-confidence across all impacted nodes.
|
|
119
|
+
* - Penalise for deep chains (they amplify uncertainty).
|
|
120
|
+
* - Map the combined score to HIGH / MEDIUM / LOW.
|
|
97
121
|
*/
|
|
98
122
|
private computeConfidence(
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
impacted: string[],
|
|
124
|
+
pathConfidence: Map<string, number>,
|
|
101
125
|
): 'high' | 'medium' | 'low' {
|
|
102
|
-
if (
|
|
103
|
-
|
|
126
|
+
if (impacted.length === 0) return 'high'
|
|
127
|
+
|
|
128
|
+
// Average path confidence across all impacted nodes
|
|
129
|
+
let total = 0
|
|
130
|
+
for (const id of impacted) {
|
|
131
|
+
total += pathConfidence.get(id) ?? 1.0
|
|
132
|
+
}
|
|
133
|
+
const avgConf = total / impacted.length
|
|
134
|
+
|
|
135
|
+
// Penalise for large impact sets: confidence erodes with result size
|
|
136
|
+
const sizePenalty = impacted.length > 20 ? 0.15 : impacted.length > 10 ? 0.08 : 0
|
|
137
|
+
|
|
138
|
+
const score = avgConf - sizePenalty
|
|
139
|
+
|
|
140
|
+
if (score >= 0.75) return 'high'
|
|
141
|
+
if (score >= 0.50) return 'medium'
|
|
104
142
|
return 'low'
|
|
105
143
|
}
|
|
106
144
|
}
|
package/src/graph/types.ts
CHANGED
|
@@ -74,7 +74,9 @@ export class BoundaryChecker {
|
|
|
74
74
|
|
|
75
75
|
for (const file of Object.values(this.lock.files)) {
|
|
76
76
|
if (file.moduleId === 'unknown' || !file.imports?.length) continue
|
|
77
|
-
for (const
|
|
77
|
+
for (const imp of file.imports) {
|
|
78
|
+
const importedPath = imp.resolvedPath
|
|
79
|
+
if (!importedPath) continue
|
|
78
80
|
const importedFile = this.lock.files[importedPath]
|
|
79
81
|
if (!importedFile || importedFile.moduleId === 'unknown' || file.moduleId === importedFile.moduleId) continue
|
|
80
82
|
const v = this.checkFileImport(file, importedFile)
|
|
@@ -54,6 +54,7 @@ const ROUTE_PATTERNS: RoutePattern[] = [
|
|
|
54
54
|
*/
|
|
55
55
|
export class GoExtractor {
|
|
56
56
|
private readonly lines: string[]
|
|
57
|
+
private cachedFunctions: ReturnType<typeof this.scanFunctions> | null = null
|
|
57
58
|
|
|
58
59
|
constructor(
|
|
59
60
|
private readonly filePath: string,
|
|
@@ -174,6 +175,7 @@ export class GoExtractor {
|
|
|
174
175
|
endLine: number
|
|
175
176
|
purpose: string
|
|
176
177
|
}> {
|
|
178
|
+
if (this.cachedFunctions) return this.cachedFunctions
|
|
177
179
|
const results: Array<{
|
|
178
180
|
name: string
|
|
179
181
|
receiverType?: string
|
|
@@ -237,6 +239,7 @@ export class GoExtractor {
|
|
|
237
239
|
i = bodyEnd + 1
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
this.cachedFunctions = results
|
|
240
243
|
return results
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -547,11 +550,17 @@ function findBodyBounds(lines: string[], startLine: number): { bodyStart: number
|
|
|
547
550
|
|
|
548
551
|
if (ch === '/' && next === '/') { inLineComment = true; break }
|
|
549
552
|
if (ch === '/' && next === '*') { inBlockComment = true; j++; continue }
|
|
550
|
-
if (ch === '"' || ch === '`'
|
|
553
|
+
if (ch === '"' || ch === '`') {
|
|
551
554
|
inString = true
|
|
552
555
|
stringChar = ch
|
|
553
556
|
continue
|
|
554
557
|
}
|
|
558
|
+
if (ch === '\'') {
|
|
559
|
+
// Go rune literal: consume exactly one character (or escape) then close
|
|
560
|
+
if (next === '\\') j += 3 // '\n' or '\x00' etc.
|
|
561
|
+
else j += 2 // 'a'
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
555
564
|
|
|
556
565
|
if (ch === '{') {
|
|
557
566
|
if (bodyStart === -1) bodyStart = i
|