@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.
@@ -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
- * Two-pass approach: first add all nodes, then add all edges.
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
- // First pass: add all nodes
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
- // Second pass: add all edges
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
- // Third pass: build adjacency maps for fast lookup
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
- graph.nodes.set(fn.id, {
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 => ({ name: p.name, type: p.type, ...(p.optional ? { optional: true } : {}) })),
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
- // Add a node for the class itself
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, // reusing hash or just storing the type string
146
+ hash: gen.type,
112
147
  },
113
148
  })
114
149
  }
115
150
 
116
- /** Creates edges for import statements: fileA imports fileB → edge(A, B, 'imports') */
117
- private addImportEdges(graph: DependencyGraph, file: ParsedFile): void {
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 && graph.nodes.has(imp.resolvedPath)) {
120
- graph.edges.push({
121
- source: file.path,
122
- target: imp.resolvedPath,
123
- type: 'imports',
124
- confidence: 1.0, // Import edges are always deterministic from AST
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
- /** Creates edges for function calls: fnA calls fnB → edge(A, B, 'calls') */
131
- private addCallEdges(graph: DependencyGraph, file: ParsedFile): void {
132
- // Build a map of import names to function IDs for resolving calls
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
- for (const name of imp.names) {
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
- const allFunctions = [...file.functions, ...file.classes.flatMap(c => c.methods)]
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
- // Try to resolve: first check imported names, then local functions
147
- const simpleName = call.includes('.') ? call.split('.').pop()! : call
230
+ const hasDot = call.includes('.')
231
+ const simpleName = hasDot ? call.split('.').pop()! : call
232
+ const receiver = hasDot ? call.split('.')[0] : null
148
233
 
149
- // Check if it's an imported function (confidence 0.8 resolved via import names)
150
- const importedId = importedNames.get(simpleName) || importedNames.get(call)
151
- if (importedId && graph.nodes.has(importedId)) {
152
- graph.edges.push({
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: importedId,
239
+ target: namedId,
155
240
  type: 'calls',
156
- confidence: 0.8, // Resolved through import names, not direct AST binding
241
+ confidence: 0.8,
157
242
  })
158
243
  continue
159
244
  }
160
245
 
161
- // Check if it's a local function in the same file (confidence 1.0 for exact, 0.5 for fuzzy)
162
- const localId = `fn:${file.path}:${simpleName}`
163
- if (graph.nodes.has(localId) && localId !== fn.id) {
164
- // Direct local match high confidence
165
- graph.edges.push({
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: localId,
267
+ target: localExactId,
168
268
  type: 'calls',
169
- confidence: simpleName === call ? 1.0 : 0.5, // exact name = 1.0, dot-access strip = 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
- /** Creates containment edges: file contains function → edge(file, fn, 'contains') */
177
- private addContainmentEdges(graph: DependencyGraph, file: ParsedFile): void {
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
- graph.edges.push({
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
- graph.edges.push({
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
- graph.edges.push({
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
- // outEdges
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
- // inEdges
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
- * Now includes risk classification:
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
- const queue: { id: string; depth: number }[] = changedNodeIds.map(id => ({ id, depth: 0 }))
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
- queue.push({ id: edge.source, depth: depth + 1 })
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
- depth === 1 ? 'high' :
69
- depth === 2 ? 'medium' :
70
- 'low'
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.length, maxDepth),
101
+ confidence: this.computeConfidence(impacted, pathConfidence),
89
102
  classified,
90
103
  }
91
104
  }
92
105
 
93
106
  /**
94
- * How confident are we in this impact analysis?
95
- * High = few nodes affected, shallow depth
96
- * Low = many nodes affected, deep chains
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
- impactedCount: number,
100
- depth: number
117
+ impacted: string[],
118
+ pathConfidence: Map<string, number>,
101
119
  ): 'high' | 'medium' | 'low' {
102
- if (impactedCount < 5 && depth < 3) return 'high'
103
- if (impactedCount < 20 && depth < 6) return 'medium'
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
- const seen = new Set(fns.map(f => f.name))
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.name)) { fns.push(fn); seen.add(fn.name) }
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
- const seen = new Set(esm.map(e => e.name))
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
- if (!seen.has(exp.name)) { esm.push(exp); seen.add(exp.name) }
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 => (e.name as ts.Identifier).text)
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 = extractor.extractClasses()
22
- const generics = extractor.extractGenerics()
23
- const imports = extractor.extractImports()
24
- const exports = extractor.extractExports()
25
- const routes = extractor.extractRoutes()
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
- const exportedNames = new Set(exports.map(e => e.name))
30
- for (const fn of functions) { if (!fn.isExported && exportedNames.has(fn.name)) fn.isExported = true }
31
- for (const cls of classes) { if (!cls.isExported && exportedNames.has(cls.name)) cls.isExported = true }
32
- for (const gen of generics) { if (!gen.isExported && exportedNames.has(gen.name)) gen.isExported = true }
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
- const allFilePaths = files.map(f => f.path)
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 JS/block comments before parsing (both formats allow them).
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 stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '')
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 = config.compilerOptions ?? {}
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