@getmikk/core 1.8.1 → 1.8.2
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-reader.ts +9 -6
- package/src/graph/dead-code-detector.ts +111 -52
- package/src/graph/graph-builder.ts +199 -61
- package/src/graph/impact-analyzer.ts +48 -16
- 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 +353 -69
- package/src/parser/types.ts +2 -0
- package/src/parser/typescript/ts-extractor.ts +17 -6
- 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,88 +143,178 @@ export class GraphBuilder {
|
|
|
108
143
|
endLine: gen.endLine,
|
|
109
144
|
isExported: gen.isExported,
|
|
110
145
|
purpose: gen.purpose,
|
|
111
|
-
hash: gen.type,
|
|
146
|
+
hash: gen.type,
|
|
112
147
|
},
|
|
113
148
|
})
|
|
114
149
|
}
|
|
115
150
|
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
// Edge construction
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Import edges: fileA → fileB via 'imports'.
|
|
157
|
+
* Only created when resolvedPath is non-empty AND the target file node exists.
|
|
158
|
+
* When resolvedPath is empty the import was unresolved — no edge is created,
|
|
159
|
+
* avoiding false positive graph connections to non-existent paths.
|
|
160
|
+
*/
|
|
161
|
+
private addImportEdges(
|
|
162
|
+
graph: DependencyGraph,
|
|
163
|
+
file: ParsedFile,
|
|
164
|
+
edgeKeys: Set<string>,
|
|
165
|
+
): void {
|
|
118
166
|
for (const imp of file.imports) {
|
|
119
|
-
if (imp.resolvedPath
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
167
|
+
if (!imp.resolvedPath || !graph.nodes.has(imp.resolvedPath)) continue
|
|
168
|
+
this.pushEdge(graph, edgeKeys, {
|
|
169
|
+
source: file.path,
|
|
170
|
+
target: imp.resolvedPath,
|
|
171
|
+
type: 'imports',
|
|
172
|
+
confidence: 1.0,
|
|
173
|
+
})
|
|
127
174
|
}
|
|
128
175
|
}
|
|
129
176
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Call edges: fnA → fnB via 'calls'.
|
|
179
|
+
*
|
|
180
|
+
* Resolution strategy (in priority order):
|
|
181
|
+
* 1. Named imports: `import { foo } from './x'` → maps foo → fn:./x:foo
|
|
182
|
+
* 2. Default import aliased calls: `import jwt from 'x'; jwt.verify()` →
|
|
183
|
+
* the receiver (jwt) is matched against default import bindings.
|
|
184
|
+
* The method name (verify) is looked up in the resolved module.
|
|
185
|
+
* 3. Local function in the same file (exact name match)
|
|
186
|
+
* 4. Local class method (ClassName.method format)
|
|
187
|
+
*
|
|
188
|
+
* Confidence levels:
|
|
189
|
+
* 1.0 — exact name, same file
|
|
190
|
+
* 0.8 — resolved through named import
|
|
191
|
+
* 0.6 — resolved through default import + method name lookup
|
|
192
|
+
* 0.5 — dotted-access name stripped to simple name (uncertain receiver)
|
|
193
|
+
*/
|
|
194
|
+
private addCallEdges(
|
|
195
|
+
graph: DependencyGraph,
|
|
196
|
+
file: ParsedFile,
|
|
197
|
+
edgeKeys: Set<string>,
|
|
198
|
+
): void {
|
|
199
|
+
// Build named-import map: importedName → canonicalFunctionId
|
|
133
200
|
const importedNames = new Map<string, string>()
|
|
201
|
+
// Build default-import map: localAlias → resolvedFilePath
|
|
202
|
+
const defaultImports = new Map<string, string>()
|
|
203
|
+
|
|
134
204
|
for (const imp of file.imports) {
|
|
135
|
-
if (imp.resolvedPath)
|
|
136
|
-
|
|
205
|
+
if (!imp.resolvedPath) continue
|
|
206
|
+
for (const name of imp.names) {
|
|
207
|
+
if (imp.isDefault) {
|
|
208
|
+
// `import jwt from 'x'` → defaultImports.set('jwt', 'src/x.ts')
|
|
209
|
+
defaultImports.set(name, imp.resolvedPath)
|
|
210
|
+
} else {
|
|
211
|
+
// `import { foo } from 'x'` → importedNames.set('foo', 'fn:src/x.ts:foo')
|
|
137
212
|
importedNames.set(name, `fn:${imp.resolvedPath}:${name}`)
|
|
138
213
|
}
|
|
139
214
|
}
|
|
140
215
|
}
|
|
141
216
|
|
|
142
|
-
|
|
217
|
+
// Build class name → class id map for this file (for method resolution)
|
|
218
|
+
const localClassIds = new Map<string, string>()
|
|
219
|
+
for (const cls of file.classes ?? []) {
|
|
220
|
+
localClassIds.set(cls.name, cls.id)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const allFunctions = [
|
|
224
|
+
...file.functions,
|
|
225
|
+
...(file.classes ?? []).flatMap(c => c.methods),
|
|
226
|
+
]
|
|
143
227
|
|
|
144
228
|
for (const fn of allFunctions) {
|
|
145
229
|
for (const call of fn.calls) {
|
|
146
|
-
|
|
147
|
-
const simpleName =
|
|
230
|
+
const hasDot = call.includes('.')
|
|
231
|
+
const simpleName = hasDot ? call.split('.').pop()! : call
|
|
232
|
+
const receiver = hasDot ? call.split('.')[0] : null
|
|
148
233
|
|
|
149
|
-
//
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
152
|
-
|
|
234
|
+
// --- 1. Named import exact match ---
|
|
235
|
+
const namedId = importedNames.get(call) ?? importedNames.get(simpleName)
|
|
236
|
+
if (namedId && graph.nodes.has(namedId)) {
|
|
237
|
+
this.pushEdge(graph, edgeKeys, {
|
|
153
238
|
source: fn.id,
|
|
154
|
-
target:
|
|
239
|
+
target: namedId,
|
|
155
240
|
type: 'calls',
|
|
156
|
-
confidence: 0.8,
|
|
241
|
+
confidence: 0.8,
|
|
157
242
|
})
|
|
158
243
|
continue
|
|
159
244
|
}
|
|
160
245
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
246
|
+
// --- 2. Default import: receiver is the alias, method is simpleName ---
|
|
247
|
+
if (receiver && defaultImports.has(receiver)) {
|
|
248
|
+
const resolvedFile = defaultImports.get(receiver)!
|
|
249
|
+
// Try to find "fn:resolvedFile:simpleName" in graph
|
|
250
|
+
const methodId = `fn:${resolvedFile}:${simpleName}`
|
|
251
|
+
if (graph.nodes.has(methodId)) {
|
|
252
|
+
this.pushEdge(graph, edgeKeys, {
|
|
253
|
+
source: fn.id,
|
|
254
|
+
target: methodId,
|
|
255
|
+
type: 'calls',
|
|
256
|
+
confidence: 0.6,
|
|
257
|
+
})
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- 3. Local function exact match ---
|
|
263
|
+
const localExactId = `fn:${file.path}:${simpleName}`
|
|
264
|
+
if (graph.nodes.has(localExactId) && localExactId !== fn.id) {
|
|
265
|
+
this.pushEdge(graph, edgeKeys, {
|
|
166
266
|
source: fn.id,
|
|
167
|
-
target:
|
|
267
|
+
target: localExactId,
|
|
168
268
|
type: 'calls',
|
|
169
|
-
confidence: simpleName === call ? 1.0 : 0.5,
|
|
269
|
+
confidence: simpleName === call ? 1.0 : 0.5,
|
|
170
270
|
})
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- 4. Local class method: ClassName.method format ---
|
|
275
|
+
if (receiver && localClassIds.has(receiver)) {
|
|
276
|
+
const clsId = localClassIds.get(receiver)!
|
|
277
|
+
// Method IDs are stored as "fn:file:ClassName.methodName"
|
|
278
|
+
const classMethodId = `fn:${file.path}:${receiver}.${simpleName}`
|
|
279
|
+
if (graph.nodes.has(classMethodId) && classMethodId !== fn.id) {
|
|
280
|
+
this.pushEdge(graph, edgeKeys, {
|
|
281
|
+
source: fn.id,
|
|
282
|
+
target: classMethodId,
|
|
283
|
+
type: 'calls',
|
|
284
|
+
confidence: 0.8,
|
|
285
|
+
})
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
171
288
|
}
|
|
289
|
+
|
|
290
|
+
// Unresolved call — not added to graph.
|
|
291
|
+
// Callers can detect incomplete coverage by comparing
|
|
292
|
+
// fn.calls.length to the number of outgoing 'calls' edges from fn.id.
|
|
172
293
|
}
|
|
173
294
|
}
|
|
174
295
|
}
|
|
175
296
|
|
|
176
|
-
/**
|
|
177
|
-
private addContainmentEdges(
|
|
297
|
+
/** Containment edges: file → function, file → class, class → method */
|
|
298
|
+
private addContainmentEdges(
|
|
299
|
+
graph: DependencyGraph,
|
|
300
|
+
file: ParsedFile,
|
|
301
|
+
edgeKeys: Set<string>,
|
|
302
|
+
): void {
|
|
178
303
|
for (const fn of file.functions) {
|
|
179
|
-
|
|
304
|
+
this.pushEdge(graph, edgeKeys, {
|
|
180
305
|
source: file.path,
|
|
181
306
|
target: fn.id,
|
|
182
307
|
type: 'contains',
|
|
183
308
|
})
|
|
184
309
|
}
|
|
185
|
-
for (const cls of file.classes) {
|
|
186
|
-
|
|
310
|
+
for (const cls of file.classes ?? []) {
|
|
311
|
+
this.pushEdge(graph, edgeKeys, {
|
|
187
312
|
source: file.path,
|
|
188
313
|
target: cls.id,
|
|
189
314
|
type: 'contains',
|
|
190
315
|
})
|
|
191
316
|
for (const method of cls.methods) {
|
|
192
|
-
|
|
317
|
+
this.pushEdge(graph, edgeKeys, {
|
|
193
318
|
source: cls.id,
|
|
194
319
|
target: method.id,
|
|
195
320
|
type: 'contains',
|
|
@@ -198,19 +323,32 @@ export class GraphBuilder {
|
|
|
198
323
|
}
|
|
199
324
|
}
|
|
200
325
|
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
// Helpers
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Push an edge only if it hasn't been added before.
|
|
332
|
+
* Edge key format: "source->target:type"
|
|
333
|
+
*/
|
|
334
|
+
private pushEdge(
|
|
335
|
+
graph: DependencyGraph,
|
|
336
|
+
edgeKeys: Set<string>,
|
|
337
|
+
edge: GraphEdge,
|
|
338
|
+
): void {
|
|
339
|
+
const key = `${edge.source}->${edge.target}:${edge.type}`
|
|
340
|
+
if (edgeKeys.has(key)) return
|
|
341
|
+
edgeKeys.add(key)
|
|
342
|
+
graph.edges.push(edge)
|
|
343
|
+
}
|
|
344
|
+
|
|
201
345
|
/** Build adjacency maps from edge list for O(1) lookups */
|
|
202
346
|
private buildAdjacencyMaps(graph: DependencyGraph): void {
|
|
203
347
|
for (const edge of graph.edges) {
|
|
204
|
-
|
|
205
|
-
if (!graph.outEdges.has(edge.source)) {
|
|
206
|
-
graph.outEdges.set(edge.source, [])
|
|
207
|
-
}
|
|
348
|
+
if (!graph.outEdges.has(edge.source)) graph.outEdges.set(edge.source, [])
|
|
208
349
|
graph.outEdges.get(edge.source)!.push(edge)
|
|
209
350
|
|
|
210
|
-
|
|
211
|
-
if (!graph.inEdges.has(edge.target)) {
|
|
212
|
-
graph.inEdges.set(edge.target, [])
|
|
213
|
-
}
|
|
351
|
+
if (!graph.inEdges.has(edge.target)) graph.inEdges.set(edge.target, [])
|
|
214
352
|
graph.inEdges.get(edge.target)!.push(edge)
|
|
215
353
|
}
|
|
216
354
|
}
|
|
@@ -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,7 +22,11 @@ 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 }))
|
|
22
30
|
let maxDepth = 0
|
|
23
31
|
|
|
24
32
|
const changedSet = new Set(changedNodeIds)
|
|
@@ -31,17 +39,22 @@ export class ImpactAnalyzer {
|
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
while (queue.length > 0) {
|
|
34
|
-
const { id: current, depth } = queue.shift()!
|
|
42
|
+
const { id: current, depth, confidence: pathConf } = queue.shift()!
|
|
35
43
|
if (visited.has(current)) continue
|
|
36
44
|
visited.add(current)
|
|
37
45
|
depthMap.set(current, depth)
|
|
46
|
+
pathConfidence.set(current, pathConf)
|
|
38
47
|
maxDepth = Math.max(maxDepth, depth)
|
|
39
48
|
|
|
40
49
|
// Find everything that depends on current (incoming edges)
|
|
41
50
|
const dependents = this.graph.inEdges.get(current) || []
|
|
42
51
|
for (const edge of dependents) {
|
|
43
52
|
if (!visited.has(edge.source) && edge.type !== 'contains') {
|
|
44
|
-
|
|
53
|
+
// Propagate the minimum confidence seen so far on this path.
|
|
54
|
+
// A chain is only as trustworthy as its weakest link.
|
|
55
|
+
const edgeConf = edge.confidence ?? 1.0
|
|
56
|
+
const newPathConf = Math.min(pathConf, edgeConf)
|
|
57
|
+
queue.push({ id: edge.source, depth: depth + 1, confidence: newPathConf })
|
|
45
58
|
}
|
|
46
59
|
}
|
|
47
60
|
}
|
|
@@ -65,9 +78,9 @@ export class ImpactAnalyzer {
|
|
|
65
78
|
|
|
66
79
|
const risk: RiskLevel =
|
|
67
80
|
depth === 1 && crossesBoundary ? 'critical' :
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
depth === 1 ? 'high' :
|
|
82
|
+
depth === 2 ? 'medium' :
|
|
83
|
+
'low'
|
|
71
84
|
|
|
72
85
|
const entry: ClassifiedImpact = {
|
|
73
86
|
nodeId: id,
|
|
@@ -85,22 +98,41 @@ export class ImpactAnalyzer {
|
|
|
85
98
|
changed: changedNodeIds,
|
|
86
99
|
impacted,
|
|
87
100
|
depth: maxDepth,
|
|
88
|
-
confidence: this.computeConfidence(impacted
|
|
101
|
+
confidence: this.computeConfidence(impacted, pathConfidence),
|
|
89
102
|
classified,
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
107
|
+
* Derive confidence from the actual quality of edges traversed, not from
|
|
108
|
+
* result size. A small result built from fuzzy/unresolved edges is LOW
|
|
109
|
+
* confidence; a large result built from high-confidence AST edges is HIGH.
|
|
110
|
+
*
|
|
111
|
+
* Algorithm:
|
|
112
|
+
* - Compute the average minimum-path-confidence across all impacted nodes.
|
|
113
|
+
* - Penalise for deep chains (they amplify uncertainty).
|
|
114
|
+
* - Map the combined score to HIGH / MEDIUM / LOW.
|
|
97
115
|
*/
|
|
98
116
|
private computeConfidence(
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
impacted: string[],
|
|
118
|
+
pathConfidence: Map<string, number>,
|
|
101
119
|
): 'high' | 'medium' | 'low' {
|
|
102
|
-
if (
|
|
103
|
-
|
|
120
|
+
if (impacted.length === 0) return 'high'
|
|
121
|
+
|
|
122
|
+
// Average path confidence across all impacted nodes
|
|
123
|
+
let total = 0
|
|
124
|
+
for (const id of impacted) {
|
|
125
|
+
total += pathConfidence.get(id) ?? 1.0
|
|
126
|
+
}
|
|
127
|
+
const avgConf = total / impacted.length
|
|
128
|
+
|
|
129
|
+
// Penalise for large impact sets: confidence erodes with result size
|
|
130
|
+
const sizePenalty = impacted.length > 20 ? 0.15 : impacted.length > 10 ? 0.08 : 0
|
|
131
|
+
|
|
132
|
+
const score = avgConf - sizePenalty
|
|
133
|
+
|
|
134
|
+
if (score >= 0.75) return 'high'
|
|
135
|
+
if (score >= 0.50) return 'medium'
|
|
104
136
|
return 'low'
|
|
105
137
|
}
|
|
106
138
|
}
|
|
@@ -22,9 +22,10 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
22
22
|
/** ESM functions + module.exports-assigned functions */
|
|
23
23
|
override extractFunctions(): ParsedFunction[] {
|
|
24
24
|
const fns = super.extractFunctions()
|
|
25
|
-
|
|
25
|
+
// Use id (which includes start line) to avoid false deduplication
|
|
26
|
+
const seen = new Set(fns.map(f => f.id))
|
|
26
27
|
for (const fn of this.extractCommonJsFunctions()) {
|
|
27
|
-
if (!seen.has(fn.
|
|
28
|
+
if (!seen.has(fn.id)) { fns.push(fn); seen.add(fn.id) }
|
|
28
29
|
}
|
|
29
30
|
return fns
|
|
30
31
|
}
|
|
@@ -42,9 +43,15 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
42
43
|
/** ESM exports + CommonJS module.exports / exports.x */
|
|
43
44
|
override extractExports(): ParsedExport[] {
|
|
44
45
|
const esm = super.extractExports()
|
|
45
|
-
|
|
46
|
+
// Index by name; for default exports use type as secondary key to avoid
|
|
47
|
+
// a local function named 'default' from being incorrectly matched.
|
|
48
|
+
const seen = new Map(esm.map(e => [`${e.name}:${e.type}`, true]))
|
|
46
49
|
for (const exp of this.extractCommonJsExports()) {
|
|
47
|
-
|
|
50
|
+
const key = `${exp.name}:${exp.type}`
|
|
51
|
+
if (!seen.has(key)) {
|
|
52
|
+
esm.push(exp)
|
|
53
|
+
seen.set(key, true)
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
return esm
|
|
50
57
|
}
|
|
@@ -91,11 +98,20 @@ export class JavaScriptExtractor extends TypeScriptExtractor {
|
|
|
91
98
|
private getRequireBindingNames(call: ts.CallExpression): string[] {
|
|
92
99
|
const parent = call.parent
|
|
93
100
|
if (!parent || !ts.isVariableDeclaration(parent)) return []
|
|
94
|
-
// const { a, b } = require('...') → ['a', 'b']
|
|
101
|
+
// const { a: myA, b } = require('...') → ['a', 'b'] (use the SOURCE name, not the alias)
|
|
102
|
+
// The source name (propertyName) is what the module exports.
|
|
103
|
+
// The local alias (element.name) is only visible in this file.
|
|
95
104
|
if (ts.isObjectBindingPattern(parent.name)) {
|
|
96
105
|
return parent.name.elements
|
|
97
106
|
.filter(e => ts.isIdentifier(e.name))
|
|
98
|
-
.map(e =>
|
|
107
|
+
.map(e => {
|
|
108
|
+
// If there is a property name (the "a" in "a: myA"), use it.
|
|
109
|
+
// Otherwise the binding uses the same name for both sides.
|
|
110
|
+
if (e.propertyName && ts.isIdentifier(e.propertyName)) {
|
|
111
|
+
return e.propertyName.text
|
|
112
|
+
}
|
|
113
|
+
return (e.name as ts.Identifier).text
|
|
114
|
+
})
|
|
99
115
|
}
|
|
100
116
|
// const x = require('...') → ['x']
|
|
101
117
|
if (ts.isIdentifier(parent.name)) return [parent.name.text]
|
|
@@ -5,6 +5,7 @@ import { JavaScriptExtractor } from './js-extractor.js'
|
|
|
5
5
|
import { JavaScriptResolver } from './js-resolver.js'
|
|
6
6
|
import { hashContent } from '../../hash/file-hasher.js'
|
|
7
7
|
import type { ParsedFile } from '../types.js'
|
|
8
|
+
import { MIN_FILES_FOR_COMPLETE_SCAN, parseJsonWithComments } from '../parser-constants.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* JavaScriptParser -- implements BaseParser for .js / .mjs / .cjs / .jsx files.
|
|
@@ -18,18 +19,25 @@ export class JavaScriptParser extends BaseParser {
|
|
|
18
19
|
const extractor = new JavaScriptExtractor(filePath, content)
|
|
19
20
|
|
|
20
21
|
const functions = extractor.extractFunctions()
|
|
21
|
-
const classes
|
|
22
|
-
const generics
|
|
23
|
-
const imports
|
|
24
|
-
const exports
|
|
25
|
-
const routes
|
|
22
|
+
const classes = extractor.extractClasses()
|
|
23
|
+
const generics = extractor.extractGenerics()
|
|
24
|
+
const imports = extractor.extractImports()
|
|
25
|
+
const exports = extractor.extractExports()
|
|
26
|
+
const routes = extractor.extractRoutes()
|
|
26
27
|
|
|
27
28
|
// Cross-reference: CJS exports may mark a name exported even when the
|
|
28
29
|
// declaration itself had no `export` keyword.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
//
|
|
31
|
+
// We only mark a symbol as exported when the export list contains an
|
|
32
|
+
// entry with BOTH a matching name AND a non-default type. This prevents
|
|
33
|
+
// `module.exports = function() {}` (which produces name='default', type='default')
|
|
34
|
+
// from accidentally marking an unrelated local function called 'default' as exported.
|
|
35
|
+
const exportedNonDefault = new Set(
|
|
36
|
+
exports.filter(e => e.type !== 'default').map(e => e.name)
|
|
37
|
+
)
|
|
38
|
+
for (const fn of functions) { if (!fn.isExported && exportedNonDefault.has(fn.name)) fn.isExported = true }
|
|
39
|
+
for (const cls of classes) { if (!cls.isExported && exportedNonDefault.has(cls.name)) cls.isExported = true }
|
|
40
|
+
for (const gen of generics) { if (!gen.isExported && exportedNonDefault.has(gen.name)) gen.isExported = true }
|
|
33
41
|
|
|
34
42
|
return {
|
|
35
43
|
path: filePath,
|
|
@@ -47,7 +55,10 @@ export class JavaScriptParser extends BaseParser {
|
|
|
47
55
|
|
|
48
56
|
resolveImports(files: ParsedFile[], projectRoot: string): ParsedFile[] {
|
|
49
57
|
const aliases = loadAliases(projectRoot)
|
|
50
|
-
|
|
58
|
+
// Only pass the file list when it represents a reasonably complete scan.
|
|
59
|
+
// A sparse list (< MIN_FILES_FOR_COMPLETE_SCAN files) causes valid alias-resolved
|
|
60
|
+
// imports to return '' because the target file is not in the partial list.
|
|
61
|
+
const allFilePaths = files.length >= MIN_FILES_FOR_COMPLETE_SCAN ? files.map(f => f.path) : []
|
|
51
62
|
const resolver = new JavaScriptResolver(projectRoot, aliases)
|
|
52
63
|
return files.map(file => ({
|
|
53
64
|
...file,
|
|
@@ -62,8 +73,7 @@ export class JavaScriptParser extends BaseParser {
|
|
|
62
73
|
|
|
63
74
|
/**
|
|
64
75
|
* Load path aliases from jsconfig.json → tsconfig.json → tsconfig.base.json.
|
|
65
|
-
* Strips
|
|
66
|
-
* Falls back to raw content if comment-stripping breaks a URL.
|
|
76
|
+
* Strips JSON5 comments via the shared helper and falls back to raw content if parsing fails.
|
|
67
77
|
* Returns {} when no config is found.
|
|
68
78
|
*/
|
|
69
79
|
function loadAliases(projectRoot: string): Record<string, string[]> {
|
|
@@ -71,12 +81,9 @@ function loadAliases(projectRoot: string): Record<string, string[]> {
|
|
|
71
81
|
const configPath = path.join(projectRoot, name)
|
|
72
82
|
try {
|
|
73
83
|
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
74
|
-
const
|
|
75
|
-
let config: any
|
|
76
|
-
try { config = JSON.parse(stripped) }
|
|
77
|
-
catch { config = JSON.parse(raw) } // URL stripping may have broken JSON
|
|
84
|
+
const config: any = parseJsonWithComments(raw)
|
|
78
85
|
|
|
79
|
-
const options
|
|
86
|
+
const options = config.compilerOptions ?? {}
|
|
80
87
|
const rawPaths: Record<string, string[]> = options.paths ?? {}
|
|
81
88
|
if (Object.keys(rawPaths).length === 0) continue
|
|
82
89
|
|