@getmikk/core 1.8.3 → 1.9.1

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.
Files changed (42) hide show
  1. package/package.json +6 -4
  2. package/src/constants.ts +285 -0
  3. package/src/contract/contract-generator.ts +7 -0
  4. package/src/contract/index.ts +2 -3
  5. package/src/contract/lock-compiler.ts +66 -35
  6. package/src/contract/lock-reader.ts +30 -5
  7. package/src/contract/schema.ts +21 -0
  8. package/src/error-handler.ts +432 -0
  9. package/src/graph/cluster-detector.ts +52 -22
  10. package/src/graph/confidence-engine.ts +85 -0
  11. package/src/graph/graph-builder.ts +298 -255
  12. package/src/graph/impact-analyzer.ts +132 -119
  13. package/src/graph/index.ts +4 -0
  14. package/src/graph/memory-manager.ts +186 -0
  15. package/src/graph/query-engine.ts +76 -0
  16. package/src/graph/risk-engine.ts +86 -0
  17. package/src/graph/types.ts +89 -65
  18. package/src/index.ts +2 -0
  19. package/src/parser/change-detector.ts +99 -0
  20. package/src/parser/go/go-extractor.ts +18 -8
  21. package/src/parser/go/go-parser.ts +2 -0
  22. package/src/parser/index.ts +86 -36
  23. package/src/parser/javascript/js-extractor.ts +1 -1
  24. package/src/parser/javascript/js-parser.ts +2 -0
  25. package/src/parser/oxc-parser.ts +708 -0
  26. package/src/parser/oxc-resolver.ts +83 -0
  27. package/src/parser/tree-sitter/parser.ts +19 -10
  28. package/src/parser/types.ts +100 -73
  29. package/src/parser/typescript/ts-extractor.ts +229 -589
  30. package/src/parser/typescript/ts-parser.ts +16 -171
  31. package/src/parser/typescript/ts-resolver.ts +11 -1
  32. package/src/search/bm25.ts +16 -4
  33. package/src/utils/minimatch.ts +1 -1
  34. package/tests/contract.test.ts +2 -2
  35. package/tests/dead-code.test.ts +7 -7
  36. package/tests/esm-resolver.test.ts +75 -0
  37. package/tests/graph.test.ts +20 -20
  38. package/tests/helpers.ts +11 -6
  39. package/tests/impact-classified.test.ts +37 -41
  40. package/tests/parser.test.ts +7 -5
  41. package/tests/ts-parser.test.ts +27 -52
  42. package/test-output.txt +0 -373
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Standardized Error Handling System
3
+ *
4
+ * Provides consistent error creation, handling, and reporting across the Mikk codebase.
5
+ * Uses centralized error codes and messages from constants.ts.
6
+ */
7
+
8
+ import type { ERROR_CODES } from './constants.js'
9
+ import { ERROR_MESSAGES } from './constants.js'
10
+ type ErrorCodes = keyof typeof ERROR_CODES
11
+
12
+ // ─── Error Types ─────────────────────────────────────────────────────────────
13
+
14
+ export class MikkError extends Error {
15
+ public readonly code: ErrorCodes
16
+ public readonly category: ErrorCategory
17
+ public readonly context: Record<string, unknown>
18
+ public readonly timestamp: Date
19
+ public readonly stack?: string
20
+
21
+ constructor(
22
+ code: ErrorCodes,
23
+ message?: string,
24
+ context: Record<string, unknown> = {},
25
+ cause?: Error
26
+ ) {
27
+ // Get the default message from constants if not provided
28
+ const defaultMessage = getDefaultErrorMessage(code, context)
29
+ const finalMessage = message || defaultMessage
30
+
31
+ super(finalMessage, { cause })
32
+ this.name = 'MikkError'
33
+ this.code = code
34
+ this.category = categorizeError(code)
35
+ this.context = context
36
+ this.timestamp = new Date()
37
+
38
+ // Capture stack trace
39
+ if (Error.captureStackTrace) {
40
+ Error.captureStackTrace(this, MikkError)
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Create a human-readable error summary
46
+ */
47
+ toSummary(): string {
48
+ return `[${this.code}] ${this.message}`
49
+ }
50
+
51
+ /**
52
+ * Get detailed error information for logging
53
+ */
54
+ toDetailed(): string {
55
+ const contextStr = Object.keys(this.context).length > 0
56
+ ? `\nContext: ${JSON.stringify(this.context, null, 2)}`
57
+ : ''
58
+ const stackStr = this.stack ? `\nStack: ${this.stack}` : ''
59
+
60
+ return `${this.toSummary()}${contextStr}${stackStr}`
61
+ }
62
+
63
+ /**
64
+ * Convert to JSON for API responses
65
+ */
66
+ toJSON(): {
67
+ code: ErrorCodes
68
+ message: string
69
+ category: ErrorCategory
70
+ context: Record<string, unknown>
71
+ timestamp: string
72
+ } {
73
+ return {
74
+ code: this.code,
75
+ message: this.message,
76
+ category: this.category,
77
+ context: this.context,
78
+ timestamp: this.timestamp.toISOString(),
79
+ }
80
+ }
81
+ }
82
+
83
+ export enum ErrorCategory {
84
+ FILE_SYSTEM = 'FILE_SYSTEM',
85
+ MODULE_LOADING = 'MODULE_LOADING',
86
+ GRAPH = 'GRAPH',
87
+ TOKEN_BUDGET = 'TOKEN_BUDGET',
88
+ VALIDATION = 'VALIDATION',
89
+ NETWORK = 'NETWORK',
90
+ PERFORMANCE = 'PERFORMANCE',
91
+ UNKNOWN = 'UNKNOWN',
92
+ }
93
+
94
+ // ─── Error Creation Helpers ───────────────────────────────────────────────────
95
+
96
+ export class ErrorBuilder {
97
+ private code?: ErrorCodes
98
+ private message?: string
99
+ private context: Record<string, unknown> = {}
100
+ private cause?: Error
101
+
102
+ static create(): ErrorBuilder {
103
+ return new ErrorBuilder()
104
+ }
105
+
106
+ withCode(code: ErrorCodes): ErrorBuilder {
107
+ this.code = code
108
+ return this
109
+ }
110
+
111
+ withMessage(message: string): ErrorBuilder {
112
+ this.message = message
113
+ return this
114
+ }
115
+
116
+ withContext(key: string, value: unknown): ErrorBuilder {
117
+ this.context[key] = value
118
+ return this
119
+ }
120
+
121
+ withContextObject(context: Record<string, unknown>): ErrorBuilder {
122
+ this.context = { ...this.context, ...context }
123
+ return this
124
+ }
125
+
126
+ withCause(cause: Error): ErrorBuilder {
127
+ this.cause = cause
128
+ return this
129
+ }
130
+
131
+ build(): MikkError {
132
+ if (!this.code) {
133
+ throw new Error('Error code is required')
134
+ }
135
+ return new MikkError(this.code, this.message, this.context, this.cause)
136
+ }
137
+ }
138
+
139
+ // ─── Error Handler Utility ───────────────────────────────────────────────────
140
+
141
+ export class ErrorHandler {
142
+ private static instance: ErrorHandler
143
+ private errorListeners: ((error: MikkError) => void)[] = []
144
+
145
+ static getInstance(): ErrorHandler {
146
+ if (!ErrorHandler.instance) {
147
+ ErrorHandler.instance = new ErrorHandler()
148
+ }
149
+ return ErrorHandler.instance
150
+ }
151
+
152
+ /**
153
+ * Add error listener for centralized error handling
154
+ */
155
+ addListener(listener: (error: MikkError) => void): void {
156
+ this.errorListeners.push(listener)
157
+ }
158
+
159
+ /**
160
+ * Remove error listener
161
+ */
162
+ removeListener(listener: (error: MikkError) => void): void {
163
+ const index = this.errorListeners.indexOf(listener)
164
+ if (index > -1) {
165
+ this.errorListeners.splice(index, 1)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Handle an error - notifies all listeners
171
+ */
172
+ handleError(error: MikkError): void {
173
+ for (const listener of this.errorListeners) {
174
+ try {
175
+ listener(error)
176
+ } catch (listenerError) {
177
+ console.error('Error in error listener:', listenerError)
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Wrap a function with error handling
184
+ */
185
+ wrap<T extends (...args: any[]) => any>(
186
+ fn: T,
187
+ errorCode: ErrorCodes,
188
+ context: Record<string, unknown> = {}
189
+ ): T {
190
+ return ((...args: any[]) => {
191
+ try {
192
+ const result = fn(...args)
193
+
194
+ // Handle async functions
195
+ if (result && typeof result.catch === 'function') {
196
+ return result.catch((error: Error) => {
197
+ const mikkError = ErrorBuilder.create()
198
+ .withCode(errorCode)
199
+ .withCause(error)
200
+ .withContextObject(context)
201
+ .build()
202
+
203
+ this.handleError(mikkError)
204
+ throw mikkError
205
+ })
206
+ }
207
+
208
+ return result
209
+ } catch (error) {
210
+ const mikkError = ErrorBuilder.create()
211
+ .withCode(errorCode)
212
+ .withCause(error as Error)
213
+ .withContextObject(context)
214
+ .build()
215
+
216
+ this.handleError(mikkError)
217
+ throw mikkError
218
+ }
219
+ }) as T
220
+ }
221
+ }
222
+
223
+ // ─── Specialized Error Types ─────────────────────────────────────────────────
224
+
225
+ export class FileSystemError extends MikkError {
226
+ constructor(code: ErrorCodes, filePath: string, cause?: Error) {
227
+ super(code, undefined, { filePath }, cause)
228
+ this.name = 'FileSystemError'
229
+ }
230
+ }
231
+
232
+ export class ModuleLoadError extends MikkError {
233
+ constructor(code: ErrorCodes, moduleName: string, cause?: Error) {
234
+ super(code, undefined, { moduleName }, cause)
235
+ this.name = 'ModuleLoadError'
236
+ }
237
+ }
238
+
239
+ export class GraphError extends MikkError {
240
+ constructor(code: ErrorCodes, nodeId?: string, cause?: Error) {
241
+ super(code, undefined, { nodeId }, cause)
242
+ this.name = 'GraphError'
243
+ }
244
+ }
245
+
246
+ export class TokenBudgetError extends MikkError {
247
+ constructor(code: ErrorCodes, used: number, budget: number) {
248
+ super(code, undefined, { used, budget })
249
+ this.name = 'TokenBudgetError'
250
+ }
251
+ }
252
+
253
+ export class ValidationError extends MikkError {
254
+ constructor(code: ErrorCodes, field: string, value: unknown, message?: string) {
255
+ super(code, message, { field, value })
256
+ this.name = 'ValidationError'
257
+ }
258
+ }
259
+
260
+ // ─── Error Creation Functions ─────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Create a file not found error
264
+ */
265
+ export function createFileNotFoundError(filePath: string): FileSystemError {
266
+ return new FileSystemError('FILE_NOT_FOUND', filePath)
267
+ }
268
+
269
+ /**
270
+ * Create a file too large error
271
+ */
272
+ export function createFileTooLargeError(filePath: string, size: number, limit: number): FileSystemError {
273
+ return new FileSystemError('FILE_TOO_LARGE', filePath)
274
+ }
275
+
276
+ /**
277
+ * Create a permission denied error
278
+ */
279
+ export function createPermissionDeniedError(path: string): FileSystemError {
280
+ return new FileSystemError('PERMISSION_DENIED', path)
281
+ }
282
+
283
+ /**
284
+ * Create a module not found error
285
+ */
286
+ export function createModuleNotFoundError(moduleName: string): ModuleLoadError {
287
+ return new ModuleLoadError('MODULE_NOT_FOUND', moduleName)
288
+ }
289
+
290
+ /**
291
+ * Create a module load failed error
292
+ */
293
+ export function createModuleLoadFailedError(moduleName: string, cause?: Error): ModuleLoadError {
294
+ return new ModuleLoadError('MODULE_LOAD_FAILED', moduleName, cause)
295
+ }
296
+
297
+ /**
298
+ * Create a graph build failed error
299
+ */
300
+ export function createGraphBuildFailedError(reason: string): GraphError {
301
+ return new GraphError('GRAPH_BUILD_FAILED', undefined, new Error(reason))
302
+ }
303
+
304
+ /**
305
+ * Create a node not found error
306
+ */
307
+ export function createNodeNotFoundError(nodeId: string): GraphError {
308
+ return new GraphError('NODE_NOT_FOUND', nodeId)
309
+ }
310
+
311
+ /**
312
+ * Create a token budget exceeded error
313
+ */
314
+ export function createTokenBudgetExceededError(used: number, budget: number): TokenBudgetError {
315
+ return new TokenBudgetError('TOKEN_BUDGET_EXCEEDED', used, budget)
316
+ }
317
+
318
+ /**
319
+ * Create a validation error
320
+ */
321
+ export function createValidationError(field: string, value: unknown, reason?: string): ValidationError {
322
+ return new ValidationError('INVALID_INPUT', field, value, reason)
323
+ }
324
+
325
+ // ─── Error Utilities ─────────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Check if an error is a MikkError
329
+ */
330
+ export function isMikkError(error: unknown): error is MikkError {
331
+ return error instanceof MikkError
332
+ }
333
+
334
+ /**
335
+ * Extract the original cause from an error chain
336
+ */
337
+ export function getRootCause(error: Error): Error {
338
+ let current = error
339
+ while (current.cause && current.cause instanceof Error) {
340
+ current = current.cause
341
+ }
342
+ return current
343
+ }
344
+
345
+ /**
346
+ * Convert any error to a MikkError
347
+ */
348
+ export function toMikkError(error: unknown, defaultCode: ErrorCodes = 'UNKNOWN_ERROR' as ErrorCodes): MikkError {
349
+ if (isMikkError(error)) {
350
+ return error
351
+ }
352
+
353
+ if (error instanceof Error) {
354
+ return new MikkError(defaultCode, error.message, {}, error)
355
+ }
356
+
357
+ if (typeof error === 'string') {
358
+ return new MikkError(defaultCode, error)
359
+ }
360
+
361
+ return new MikkError(defaultCode, 'Unknown error occurred')
362
+ }
363
+
364
+ /**
365
+ * Categorize an error code
366
+ */
367
+ function categorizeError(code: ErrorCodes): ErrorCategory {
368
+ if (code.includes('FILE') || code.includes('DIRECTORY')) {
369
+ return ErrorCategory.FILE_SYSTEM
370
+ }
371
+ if (code.includes('MODULE')) {
372
+ return ErrorCategory.MODULE_LOADING
373
+ }
374
+ if (code.includes('GRAPH') || code.includes('NODE')) {
375
+ return ErrorCategory.GRAPH
376
+ }
377
+ if (code.includes('TOKEN')) {
378
+ return ErrorCategory.TOKEN_BUDGET
379
+ }
380
+ if (code.includes('INVALID') || code.includes('VALIDATION')) {
381
+ return ErrorCategory.VALIDATION
382
+ }
383
+ if (code.includes('TIMEOUT')) {
384
+ return ErrorCategory.PERFORMANCE
385
+ }
386
+
387
+ return ErrorCategory.UNKNOWN
388
+ }
389
+
390
+ /**
391
+ * Get default error message from constants
392
+ */
393
+ function getDefaultErrorMessage(code: ErrorCodes, context: Record<string, unknown>): string {
394
+ const template = ERROR_MESSAGES[code] || 'Unknown error occurred'
395
+
396
+ let message = template as string
397
+
398
+ // Replace template variables
399
+ for (const [key, value] of Object.entries(context)) {
400
+ message = message.replace(new RegExp(`{${key}}`, 'g'), String(value))
401
+ }
402
+
403
+ return message
404
+ }
405
+
406
+ // ─── Default Error Listener ─────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Default error listener that logs to console
410
+ */
411
+ export function createDefaultErrorListener(): (error: MikkError) => void {
412
+ return (error: MikkError) => {
413
+ const timestamp = error.timestamp.toISOString()
414
+ const category = error.category
415
+
416
+ // Use appropriate console method based on category
417
+ const logMethod = category === ErrorCategory.FILE_SYSTEM || category === ErrorCategory.MODULE_LOADING
418
+ ? console.error
419
+ : console.warn
420
+
421
+ logMethod(`[${timestamp}] [${category}] ${error.toSummary()}`)
422
+
423
+ if (process.env.NODE_ENV === 'development') {
424
+ console.debug(error.toDetailed())
425
+ }
426
+ }
427
+ }
428
+
429
+ // NOTE: Do NOT register listeners at module load time - every import would
430
+ // add a duplicate listener that is never cleaned up. Instead, call:
431
+ // ErrorHandler.getInstance().addListener(createDefaultErrorListener())
432
+ // once during application bootstrap (CLI entry-point, MCP server startup).
@@ -194,21 +194,28 @@ export class ClusterDetector {
194
194
  }
195
195
  for (const [name, dupes] of nameCount) {
196
196
  if (dupes.length <= 1) continue
197
- for (const cluster of dupes) {
198
- // Try to find a distinctive directory segment from the cluster ID
199
- // e.g. "packages-diagram-generator" → "Diagram Generator"
197
+ for (let i = 0; i < dupes.length; i++) {
198
+ const cluster = dupes[i]
200
199
  const segments = cluster.id.split('-')
201
200
  .filter(s => s !== 'packages' && s !== 'apps' && s !== 'src')
202
201
  const suffix = segments
203
202
  .map(s => s.charAt(0).toUpperCase() + s.slice(1))
204
203
  .join(' ')
205
- if (suffix && suffix !== name) {
204
+
205
+ if (suffix && suffix.toLowerCase() !== name.toLowerCase()) {
206
206
  cluster.suggestedName = `${name} (${suffix})`
207
+ } else {
208
+ // Force disambiguation using the already-deduplicated cluster.id
209
+ cluster.suggestedName = `${name} (${cluster.id})`
207
210
  }
208
211
  }
209
212
  }
210
213
 
211
- return merged.sort((a, b) => b.confidence - a.confidence)
214
+ const sorted = merged.sort((a, b) => b.confidence - a.confidence)
215
+ if (sorted.length <= 1 && files.length > 1) {
216
+ return this.buildDirectoryClusters(files)
217
+ }
218
+ return sorted
212
219
  }
213
220
 
214
221
  // ─── Coupling Matrix ──────────────────────────────────────────
@@ -235,8 +242,8 @@ export class ClusterDetector {
235
242
  for (const edge of this.graph.edges) {
236
243
  if (edge.type !== 'imports' && edge.type !== 'calls') continue
237
244
 
238
- const sourceFile = this.getFileForNode(edge.source)
239
- const targetFile = this.getFileForNode(edge.target)
245
+ const sourceFile = this.getFileForNode(edge.from)
246
+ const targetFile = this.getFileForNode(edge.to)
240
247
 
241
248
  if (!sourceFile || !targetFile || sourceFile === targetFile) continue
242
249
  if (!fileSet.has(sourceFile) || !fileSet.has(targetFile)) continue
@@ -317,7 +324,7 @@ export class ClusterDetector {
317
324
  const outEdges = this.graph.outEdges.get(file) || []
318
325
  for (const edge of outEdges) {
319
326
  if (edge.type === 'imports') {
320
- if (fileSet.has(edge.target)) {
327
+ if (fileSet.has(edge.to)) {
321
328
  internalEdges++
322
329
  } else {
323
330
  externalEdges++
@@ -331,10 +338,10 @@ export class ClusterDetector {
331
338
  const containEdges = this.graph.outEdges.get(file) || []
332
339
  for (const containEdge of containEdges) {
333
340
  if (containEdge.type === 'contains') {
334
- const fnOutEdges = this.graph.outEdges.get(containEdge.target) || []
341
+ const fnOutEdges = this.graph.outEdges.get(containEdge.to) || []
335
342
  for (const callEdge of fnOutEdges) {
336
343
  if (callEdge.type === 'calls') {
337
- const targetNode = this.graph.nodes.get(callEdge.target)
344
+ const targetNode = this.graph.nodes.get(callEdge.to)
338
345
  if (targetNode && fileSet.has(targetNode.file)) {
339
346
  internalEdges++
340
347
  } else if (targetNode) {
@@ -380,7 +387,7 @@ export class ClusterDetector {
380
387
  const containEdges = this.graph.outEdges.get(f) || []
381
388
  return containEdges
382
389
  .filter(e => e.type === 'contains')
383
- .map(e => e.target)
390
+ .map(e => e.to)
384
391
  })
385
392
  }
386
393
 
@@ -413,18 +420,16 @@ export class ClusterDetector {
413
420
  * "features/auth/api/route.ts" → "features-auth-api"
414
421
  */
415
422
  private getDirSegments(filePath: string): string {
416
- const parts = filePath.split('/')
417
- // Remove filename (last part with an extension)
418
- const dirs = parts.filter((p, i) => i < parts.length - 1 || !p.includes('.'))
419
- // Drop 'src' prefix — it carries no semantic meaning
420
- const meaningful = dirs.filter(d => d !== 'src' && d !== '')
421
- if (meaningful.length === 0) {
422
- // Fallback: use the filename without extension
423
- const last = parts[parts.length - 1]
423
+ const parts = filePath.replace(/\\/g, '/').split('/')
424
+ const filtered = parts.filter(part => part && part.toLowerCase() !== 'src')
425
+ const dirs = filtered.slice(0, -1) // drop filename
426
+ if (dirs.length === 0) {
427
+ const last = filtered[filtered.length - 1] || ''
424
428
  return last.replace(/\.[^.]+$/, '') || 'unknown'
425
429
  }
426
- // Take up to 3 segments for a unique but concise ID
427
- return meaningful.slice(0, 3).join('-')
430
+ const sliceStart = Math.max(0, dirs.length - 3)
431
+ const meaningful = dirs.slice(sliceStart)
432
+ return meaningful.map(seg => seg.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()).join('-') || 'unknown'
428
433
  }
429
434
 
430
435
  // ─── Cluster Merging ──────────────────────────────────────────
@@ -470,6 +475,31 @@ export class ClusterDetector {
470
475
  return result
471
476
  }
472
477
 
478
+ private buildDirectoryClusters(fileNodes: string[]): ModuleCluster[] {
479
+ const buckets = new Map<string, string[]>()
480
+ for (const file of fileNodes) {
481
+ const bucketId = this.getDirSegments(this.getNodeFile(file))
482
+ const entry = buckets.get(bucketId) ?? []
483
+ entry.push(file)
484
+ buckets.set(bucketId, entry)
485
+ }
486
+
487
+ const clusters: ModuleCluster[] = []
488
+ for (const [id, bucket] of buckets) {
489
+ const filePaths = bucket.map(f => this.getNodeFile(f))
490
+ const functions = this.getFunctionIdsForFiles(bucket)
491
+ clusters.push({
492
+ id,
493
+ files: filePaths,
494
+ confidence: this.computeClusterConfidence(bucket),
495
+ suggestedName: this.inferSemanticName(filePaths, functions),
496
+ functions,
497
+ })
498
+ }
499
+
500
+ return clusters.sort((a, b) => b.confidence - a.confidence)
501
+ }
502
+
473
503
  /** Get the base directory (first meaningful segment) for a set of files */
474
504
  private getBaseDir(files: string[]): string {
475
505
  if (files.length === 0) return 'unknown'
@@ -505,7 +535,7 @@ export class ClusterDetector {
505
535
  private inferSemanticName(filePaths: string[], functionIds: string[]): string {
506
536
  // Collect words from function names
507
537
  const fnLabels = functionIds
508
- .map(id => this.graph.nodes.get(id)?.label ?? '')
538
+ .map(id => this.graph.nodes.get(id)?.name ?? '')
509
539
  .filter(Boolean)
510
540
 
511
541
  // Collect file basenames without extension
@@ -0,0 +1,85 @@
1
+ import type { DependencyGraph } from './types.js'
2
+
3
+ /**
4
+ * ConfidenceEngine — computes path-confidence for impact analysis results.
5
+ *
6
+ * ImpactAnalyzer builds paths by walking BACKWARDS through `inEdges`
7
+ * (dependent → dependency direction). After the BFS the paths are
8
+ * stored in forward-traversal order (changed-node → impacted-node).
9
+ *
10
+ * To find the edge between two consecutive path nodes we must therefore
11
+ * look in `inEdges[next]` for an edge whose `.from === current`, which is
12
+ * the same as looking in `outEdges[current]` for an edge whose `.to === next`.
13
+ * We prefer `outEdges` because it gives O(out-degree) scans instead of
14
+ * O(in-degree), but we fall back to `inEdges` so the engine is correct
15
+ * regardless of traversal direction stored in the path.
16
+ */
17
+ export class ConfidenceEngine {
18
+ constructor(private graph: DependencyGraph) {}
19
+
20
+ /**
21
+ * Compute confidence along a specific ordered path of node IDs.
22
+ *
23
+ * @param pathIds Array of node IDs forming a path (e.g. ['A', 'B', 'C'])
24
+ * in forward (caller → callee) order.
25
+ * @returns Cumulative confidence from 0.0 to 1.0; 1.0 for trivial paths.
26
+ */
27
+ calculatePathConfidence(pathIds: string[]): number {
28
+ if (pathIds.length < 2) return 1.0
29
+
30
+ let totalConfidence = 1.0
31
+
32
+ for (let i = 0; i < pathIds.length - 1; i++) {
33
+ const current = pathIds[i]
34
+ const next = pathIds[i + 1]
35
+
36
+ // Prefer outEdges[current] for O(out-degree) look-up
37
+ const edges = this.graph.outEdges.get(current)
38
+ ?? this.graph.inEdges.get(next) // fallback: scan inEdges of the next node
39
+ ?? []
40
+
41
+ let maxEdgeConfidence = 0.0
42
+ for (const edge of edges) {
43
+ // outEdges: edge.from === current, edge.to === next
44
+ // inEdges: edge.to === next, edge.from === current
45
+ if (edge.to === next && edge.from === current) {
46
+ if ((edge.confidence ?? 1.0) > maxEdgeConfidence) {
47
+ maxEdgeConfidence = edge.confidence ?? 1.0
48
+ }
49
+ }
50
+ }
51
+
52
+ if (maxEdgeConfidence === 0.0) {
53
+ // Try inEdges[next] if outEdges produced no match
54
+ const inbound = this.graph.inEdges.get(next) ?? []
55
+ for (const edge of inbound) {
56
+ if (edge.from === current) {
57
+ if ((edge.confidence ?? 1.0) > maxEdgeConfidence) {
58
+ maxEdgeConfidence = edge.confidence ?? 1.0
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ if (maxEdgeConfidence === 0.0) {
65
+ // No edge found in either direction — path is broken or unresolvable
66
+ return 0.0
67
+ }
68
+
69
+ totalConfidence *= maxEdgeConfidence
70
+ }
71
+
72
+ return totalConfidence
73
+ }
74
+
75
+ /**
76
+ * Average confidence across all paths leading to a target node.
77
+ */
78
+ calculateNodeAggregatedConfidence(paths: string[][]): number {
79
+ if (paths.length === 0) return 1.0
80
+
81
+ const pathConfidences = paths.map(p => this.calculatePathConfidence(p))
82
+ const sum = pathConfidences.reduce((a, b) => a + b, 0)
83
+ return Number((sum / paths.length).toFixed(3))
84
+ }
85
+ }