@getmikk/core 1.8.3 → 2.0.0
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 +6 -4
- package/src/constants.ts +285 -0
- package/src/contract/contract-generator.ts +7 -0
- package/src/contract/index.ts +2 -3
- package/src/contract/lock-compiler.ts +66 -35
- package/src/contract/lock-reader.ts +30 -5
- package/src/contract/schema.ts +21 -0
- package/src/error-handler.ts +432 -0
- package/src/graph/cluster-detector.ts +52 -22
- package/src/graph/confidence-engine.ts +85 -0
- package/src/graph/graph-builder.ts +298 -255
- package/src/graph/impact-analyzer.ts +132 -119
- package/src/graph/index.ts +4 -0
- package/src/graph/memory-manager.ts +186 -0
- package/src/graph/query-engine.ts +76 -0
- package/src/graph/risk-engine.ts +86 -0
- package/src/graph/types.ts +89 -65
- package/src/index.ts +2 -0
- package/src/parser/change-detector.ts +99 -0
- package/src/parser/go/go-extractor.ts +18 -8
- package/src/parser/go/go-parser.ts +2 -0
- package/src/parser/index.ts +86 -36
- package/src/parser/javascript/js-extractor.ts +1 -1
- package/src/parser/javascript/js-parser.ts +2 -0
- package/src/parser/oxc-parser.ts +708 -0
- package/src/parser/oxc-resolver.ts +83 -0
- package/src/parser/tree-sitter/parser.ts +19 -10
- package/src/parser/types.ts +100 -73
- package/src/parser/typescript/ts-extractor.ts +229 -589
- package/src/parser/typescript/ts-parser.ts +16 -171
- package/src/parser/typescript/ts-resolver.ts +11 -1
- package/src/search/bm25.ts +16 -4
- package/src/utils/minimatch.ts +1 -1
- package/tests/contract.test.ts +2 -2
- package/tests/dead-code.test.ts +7 -7
- package/tests/esm-resolver.test.ts +75 -0
- package/tests/graph.test.ts +20 -20
- package/tests/helpers.ts +11 -6
- package/tests/impact-classified.test.ts +37 -41
- package/tests/parser.test.ts +7 -5
- package/tests/ts-parser.test.ts +27 -52
- 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 (
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
239
|
-
const targetFile = this.getFileForNode(edge.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
418
|
-
const dirs =
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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)?.
|
|
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
|
+
}
|