@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.
@@ -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,109 +143,229 @@ 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
+ // 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
- /** Creates edges for import statements: fileA imports fileB → edge(A, B, 'imports') */
117
- private addImportEdges(graph: DependencyGraph, file: ParsedFile): void {
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 && 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
- }
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
- /** 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
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
- for (const name of imp.names) {
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
- const allFunctions = [...file.functions, ...file.classes.flatMap(c => c.methods)]
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
- // Try to resolve: first check imported names, then local functions
147
- const simpleName = call.includes('.') ? call.split('.').pop()! : call
234
+ const hasDot = call.includes('.')
235
+ const simpleName = hasDot ? call.split('.').pop()! : call
236
+ const receiver = hasDot ? call.split('.')[0] : null
148
237
 
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({
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: importedId,
247
+ target: namedId,
155
248
  type: 'calls',
156
- confidence: 0.8, // Resolved through import names, not direct AST binding
249
+ confidence: 0.8,
157
250
  })
158
251
  continue
159
252
  }
160
253
 
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({
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: localId,
275
+ target: localExactId,
168
276
  type: 'calls',
169
- confidence: simpleName === call ? 1.0 : 0.5, // exact name = 1.0, dot-access strip = 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
- /** Creates containment edges: file contains function → edge(file, fn, 'contains') */
177
- private addContainmentEdges(graph: DependencyGraph, file: ParsedFile): void {
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
- graph.edges.push({
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
- graph.edges.push({
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
- graph.edges.push({
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
- // outEdges
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
- // inEdges
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
- * 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,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
- 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 }))
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
- const changedModules = new Set<string | undefined>()
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 > 0) {
34
- const { id: current, depth } = queue.shift()!
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
- queue.push({ id: edge.source, depth: depth + 1 })
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
- const crossesBoundary = !changedModules.has(node.moduleId)
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
- depth === 1 ? 'high' :
69
- depth === 2 ? 'medium' :
70
- 'low'
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.length, maxDepth),
107
+ confidence: this.computeConfidence(impacted, pathConfidence),
89
108
  classified,
90
109
  }
91
110
  }
92
111
 
93
112
  /**
94
- * How confident are we in this impact analysis?
95
- * High = few nodes affected, shallow depth
96
- * Low = many nodes affected, deep chains
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
- impactedCount: number,
100
- depth: number
123
+ impacted: string[],
124
+ pathConfidence: Map<string, number>,
101
125
  ): 'high' | 'medium' | 'low' {
102
- if (impactedCount < 5 && depth < 3) return 'high'
103
- if (impactedCount < 20 && depth < 6) return 'medium'
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
  }
@@ -19,6 +19,7 @@ export interface GraphNode {
19
19
  isAsync?: boolean
20
20
  hash?: string
21
21
  purpose?: string
22
+ genericKind?: string
22
23
  params?: { name: string; type: string; optional?: boolean }[]
23
24
  returnType?: string
24
25
  edgeCasesHandled?: string[]
@@ -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 importedPath of file.imports) {
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 === '`' || 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