@eduardbar/drift 0.9.0 → 1.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.
Files changed (69) hide show
  1. package/.github/workflows/publish-vscode.yml +76 -0
  2. package/AGENTS.md +30 -12
  3. package/CHANGELOG.md +9 -0
  4. package/README.md +273 -168
  5. package/ROADMAP.md +130 -98
  6. package/dist/analyzer.d.ts +4 -38
  7. package/dist/analyzer.js +85 -1510
  8. package/dist/cli.js +47 -4
  9. package/dist/config.js +1 -1
  10. package/dist/fix.d.ts +13 -0
  11. package/dist/fix.js +120 -0
  12. package/dist/git/blame.d.ts +22 -0
  13. package/dist/git/blame.js +227 -0
  14. package/dist/git/helpers.d.ts +36 -0
  15. package/dist/git/helpers.js +152 -0
  16. package/dist/git/trend.d.ts +21 -0
  17. package/dist/git/trend.js +80 -0
  18. package/dist/git.d.ts +0 -4
  19. package/dist/git.js +2 -2
  20. package/dist/report.js +620 -293
  21. package/dist/rules/phase0-basic.d.ts +11 -0
  22. package/dist/rules/phase0-basic.js +176 -0
  23. package/dist/rules/phase1-complexity.d.ts +31 -0
  24. package/dist/rules/phase1-complexity.js +277 -0
  25. package/dist/rules/phase2-crossfile.d.ts +27 -0
  26. package/dist/rules/phase2-crossfile.js +122 -0
  27. package/dist/rules/phase3-arch.d.ts +31 -0
  28. package/dist/rules/phase3-arch.js +148 -0
  29. package/dist/rules/phase5-ai.d.ts +8 -0
  30. package/dist/rules/phase5-ai.js +262 -0
  31. package/dist/rules/phase8-semantic.d.ts +22 -0
  32. package/dist/rules/phase8-semantic.js +109 -0
  33. package/dist/rules/shared.d.ts +7 -0
  34. package/dist/rules/shared.js +27 -0
  35. package/package.json +8 -3
  36. package/packages/vscode-drift/.vscodeignore +9 -0
  37. package/packages/vscode-drift/LICENSE +21 -0
  38. package/packages/vscode-drift/README.md +64 -0
  39. package/packages/vscode-drift/images/icon.png +0 -0
  40. package/packages/vscode-drift/images/icon.svg +30 -0
  41. package/packages/vscode-drift/package-lock.json +485 -0
  42. package/packages/vscode-drift/package.json +119 -0
  43. package/packages/vscode-drift/src/analyzer.ts +38 -0
  44. package/packages/vscode-drift/src/diagnostics.ts +55 -0
  45. package/packages/vscode-drift/src/extension.ts +111 -0
  46. package/packages/vscode-drift/src/statusbar.ts +47 -0
  47. package/packages/vscode-drift/src/treeview.ts +108 -0
  48. package/packages/vscode-drift/tsconfig.json +18 -0
  49. package/packages/vscode-drift/vscode-drift-0.1.0.vsix +0 -0
  50. package/packages/vscode-drift/vscode-drift-0.1.1.vsix +0 -0
  51. package/src/analyzer.ts +124 -1726
  52. package/src/cli.ts +53 -4
  53. package/src/config.ts +1 -1
  54. package/src/fix.ts +154 -0
  55. package/src/git/blame.ts +279 -0
  56. package/src/git/helpers.ts +198 -0
  57. package/src/git/trend.ts +116 -0
  58. package/src/git.ts +2 -2
  59. package/src/report.ts +631 -296
  60. package/src/rules/phase0-basic.ts +187 -0
  61. package/src/rules/phase1-complexity.ts +302 -0
  62. package/src/rules/phase2-crossfile.ts +149 -0
  63. package/src/rules/phase3-arch.ts +179 -0
  64. package/src/rules/phase5-ai.ts +292 -0
  65. package/src/rules/phase8-semantic.ts +132 -0
  66. package/src/rules/shared.ts +39 -0
  67. package/tests/helpers.ts +45 -0
  68. package/tests/rules.test.ts +1269 -0
  69. package/vitest.config.ts +15 -0
package/src/analyzer.ts CHANGED
@@ -1,25 +1,60 @@
1
- import * as fs from 'node:fs'
2
- import * as crypto from 'node:crypto'
1
+ // drift-ignore-file
3
2
  import * as path from 'node:path'
4
- import * as os from 'node:os'
5
- import { execSync } from 'node:child_process'
3
+ import { Project } from 'ts-morph'
4
+ import type { DriftIssue, FileReport, DriftConfig } from './types.js'
5
+
6
+ // Rules
7
+ import { isFileIgnored } from './rules/shared.js'
8
+ import {
9
+ detectLargeFile,
10
+ detectLargeFunctions,
11
+ detectDebugLeftovers,
12
+ detectDeadCode,
13
+ detectDuplicateFunctionNames,
14
+ detectAnyAbuse,
15
+ detectCatchSwallow,
16
+ detectMissingReturnTypes,
17
+ } from './rules/phase0-basic.js'
18
+ import {
19
+ detectHighComplexity,
20
+ detectDeepNesting,
21
+ detectTooManyParams,
22
+ detectHighCoupling,
23
+ detectPromiseStyleMix,
24
+ detectMagicNumbers,
25
+ detectCommentContradiction,
26
+ } from './rules/phase1-complexity.js'
27
+ import {
28
+ detectDeadFiles,
29
+ detectUnusedExports,
30
+ detectUnusedDependencies,
31
+ } from './rules/phase2-crossfile.js'
6
32
  import {
7
- Project,
8
- SourceFile,
9
- SyntaxKind,
10
- Node,
11
- FunctionDeclaration,
12
- ArrowFunction,
13
- FunctionExpression,
14
- MethodDeclaration,
15
- } from 'ts-morph'
16
- import type {
17
- DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary,
18
- HistoricalAnalysis, TrendDataPoint, BlameAttribution, DriftTrendReport, DriftBlameReport,
19
- } from './types.js'
20
- import { buildReport } from './reporter.js'
33
+ detectCircularDependencies,
34
+ detectLayerViolations,
35
+ detectCrossBoundaryImports,
36
+ } from './rules/phase3-arch.js'
37
+ import {
38
+ detectOverCommented,
39
+ detectHardcodedConfig,
40
+ detectInconsistentErrorHandling,
41
+ detectUnnecessaryAbstraction,
42
+ detectNamingInconsistency,
43
+ } from './rules/phase5-ai.js'
44
+ import {
45
+ collectFunctions,
46
+ fingerprintFunction,
47
+ calculateScore,
48
+ } from './rules/phase8-semantic.js'
49
+
50
+ // Git analyzers (re-exported as part of the public API)
51
+ export { TrendAnalyzer } from './git/trend.js'
52
+ export { BlameAnalyzer } from './git/blame.js'
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Rule weights — single source of truth for severities and drift score weights
56
+ // ---------------------------------------------------------------------------
21
57
 
22
- // Rules and their drift score weight
23
58
  export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
24
59
  'large-file': { severity: 'error', weight: 20 },
25
60
  'large-function': { severity: 'error', weight: 15 },
@@ -56,954 +91,11 @@ export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; we
56
91
  'semantic-duplication': { severity: 'warning', weight: 12 },
57
92
  }
58
93
 
59
- type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
60
-
61
- function hasIgnoreComment(file: SourceFile, line: number): boolean {
62
- const lines = file.getFullText().split('\n')
63
- const currentLine = lines[line - 1] ?? ''
64
- const prevLine = lines[line - 2] ?? ''
65
-
66
- if (/\/\/\s*drift-ignore\b/.test(currentLine)) return true
67
- if (/\/\/\s*drift-ignore\b/.test(prevLine)) return true
68
- return false
69
- }
70
-
71
- function isFileIgnored(file: SourceFile): boolean {
72
- const firstLines = file.getFullText().split('\n').slice(0, 10).join('\n')
73
- return /\/\/\s*drift-ignore-file\b/.test(firstLines)
74
- }
75
-
76
- function getSnippet(node: Node, file: SourceFile): string {
77
- const startLine = node.getStartLineNumber()
78
- const lines = file.getFullText().split('\n')
79
- return lines
80
- .slice(Math.max(0, startLine - 1), startLine + 1)
81
- .join('\n')
82
- .trim()
83
- .slice(0, 120)
84
- }
85
-
86
- function getFunctionLikeLines(node: FunctionLike): number {
87
- return node.getEndLineNumber() - node.getStartLineNumber()
88
- }
89
-
90
94
  // ---------------------------------------------------------------------------
91
- // Existing rules
95
+ // Per-file analysis
92
96
  // ---------------------------------------------------------------------------
93
97
 
94
- function detectLargeFile(file: SourceFile): DriftIssue[] {
95
- const lineCount = file.getEndLineNumber()
96
- if (lineCount > 300) {
97
- return [
98
- {
99
- rule: 'large-file',
100
- severity: 'error',
101
- message: `File has ${lineCount} lines (threshold: 300). Large files are the #1 sign of AI-generated structural drift.`,
102
- line: 1,
103
- column: 1,
104
- snippet: `// ${lineCount} lines total`,
105
- },
106
- ]
107
- }
108
- return []
109
- }
110
-
111
- function detectLargeFunctions(file: SourceFile): DriftIssue[] {
112
- const issues: DriftIssue[] = []
113
- const fns: FunctionLike[] = [
114
- ...file.getFunctions(),
115
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
116
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
117
- ...file.getClasses().flatMap((c) => c.getMethods()),
118
- ]
119
-
120
- for (const fn of fns) {
121
- const lines = getFunctionLikeLines(fn)
122
- const startLine = fn.getStartLineNumber()
123
- if (lines > 50) {
124
- if (hasIgnoreComment(file, startLine)) continue
125
- issues.push({
126
- rule: 'large-function',
127
- severity: 'error',
128
- message: `Function spans ${lines} lines (threshold: 50). AI tends to dump logic into single functions.`,
129
- line: startLine,
130
- column: fn.getStartLinePos(),
131
- snippet: getSnippet(fn, file),
132
- })
133
- }
134
- }
135
- return issues
136
- }
137
-
138
- function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
139
- const issues: DriftIssue[] = []
140
-
141
- for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
142
- const expr = call.getExpression().getText()
143
- const line = call.getStartLineNumber()
144
- if (/^console\.(log|warn|error|debug|info)\b/.test(expr)) {
145
- if (hasIgnoreComment(file, line)) continue
146
- issues.push({
147
- rule: 'debug-leftover',
148
- severity: 'warning',
149
- message: `console.${expr.split('.')[1]} left in production code.`,
150
- line,
151
- column: call.getStartLinePos(),
152
- snippet: getSnippet(call, file),
153
- })
154
- }
155
- }
156
-
157
- const lines = file.getFullText().split('\n')
158
- lines.forEach((lineContent, i) => {
159
- if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
160
- if (hasIgnoreComment(file, i + 1)) return
161
- issues.push({
162
- rule: 'debug-leftover',
163
- severity: 'warning',
164
- message: `Unresolved marker found: ${lineContent.trim().slice(0, 60)}`,
165
- line: i + 1,
166
- column: 1,
167
- snippet: lineContent.trim().slice(0, 120),
168
- })
169
- }
170
- })
171
-
172
- return issues
173
- }
174
-
175
- function detectDeadCode(file: SourceFile): DriftIssue[] {
176
- const issues: DriftIssue[] = []
177
-
178
- for (const imp of file.getImportDeclarations()) {
179
- for (const named of imp.getNamedImports()) {
180
- const name = named.getName()
181
- const refs = file.getDescendantsOfKind(SyntaxKind.Identifier).filter(
182
- (id) => id.getText() === name && id !== named.getNameNode()
183
- )
184
- if (refs.length === 0) {
185
- issues.push({
186
- rule: 'dead-code',
187
- severity: 'warning',
188
- message: `Unused import '${name}'. AI often imports more than it uses.`,
189
- line: imp.getStartLineNumber(),
190
- column: imp.getStartLinePos(),
191
- snippet: getSnippet(imp, file),
192
- })
193
- }
194
- }
195
- }
196
-
197
- return issues
198
- }
199
-
200
- function detectDuplicateFunctionNames(file: SourceFile): DriftIssue[] {
201
- const issues: DriftIssue[] = []
202
- const seen = new Map<string, number>()
203
-
204
- const fns = file.getFunctions()
205
- for (const fn of fns) {
206
- const name = fn.getName()
207
- if (!name) continue
208
- const normalized = name.toLowerCase().replace(/[_-]/g, '')
209
- if (seen.has(normalized)) {
210
- issues.push({
211
- rule: 'duplicate-function-name',
212
- severity: 'error',
213
- message: `Function '${name}' looks like a duplicate of a previously defined function. AI often generates near-identical helpers.`,
214
- line: fn.getStartLineNumber(),
215
- column: fn.getStartLinePos(),
216
- snippet: getSnippet(fn, file),
217
- })
218
- } else {
219
- seen.set(normalized, fn.getStartLineNumber())
220
- }
221
- }
222
- return issues
223
- }
224
-
225
- function detectAnyAbuse(file: SourceFile): DriftIssue[] {
226
- const issues: DriftIssue[] = []
227
- for (const node of file.getDescendantsOfKind(SyntaxKind.AnyKeyword)) {
228
- issues.push({
229
- rule: 'any-abuse',
230
- severity: 'warning',
231
- message: `Explicit 'any' type detected. AI defaults to 'any' when it can't infer types properly.`,
232
- line: node.getStartLineNumber(),
233
- column: node.getStartLinePos(),
234
- snippet: getSnippet(node, file),
235
- })
236
- }
237
- return issues
238
- }
239
-
240
- function detectCatchSwallow(file: SourceFile): DriftIssue[] {
241
- const issues: DriftIssue[] = []
242
- for (const tryCatch of file.getDescendantsOfKind(SyntaxKind.TryStatement)) {
243
- const catchClause = tryCatch.getCatchClause()
244
- if (!catchClause) continue
245
- const block = catchClause.getBlock()
246
- const stmts = block.getStatements()
247
- if (stmts.length === 0) {
248
- issues.push({
249
- rule: 'catch-swallow',
250
- severity: 'warning',
251
- message: `Empty catch block silently swallows errors. Classic AI pattern to make code "not throw".`,
252
- line: catchClause.getStartLineNumber(),
253
- column: catchClause.getStartLinePos(),
254
- snippet: getSnippet(catchClause, file),
255
- })
256
- }
257
- }
258
- return issues
259
- }
260
-
261
- function detectMissingReturnTypes(file: SourceFile): DriftIssue[] {
262
- const issues: DriftIssue[] = []
263
- for (const fn of file.getFunctions()) {
264
- if (!fn.getReturnTypeNode()) {
265
- issues.push({
266
- rule: 'no-return-type',
267
- severity: 'info',
268
- message: `Function '${fn.getName() ?? 'anonymous'}' has no explicit return type.`,
269
- line: fn.getStartLineNumber(),
270
- column: fn.getStartLinePos(),
271
- snippet: getSnippet(fn, file),
272
- })
273
- }
274
- }
275
- return issues
276
- }
277
-
278
- // ---------------------------------------------------------------------------
279
- // Phase 1: complexity detection rules
280
- // ---------------------------------------------------------------------------
281
-
282
- /**
283
- * Cyclomatic complexity: count decision points in a function.
284
- * Each if/else if/ternary/?:/for/while/do/case/catch/&&/|| adds 1.
285
- * Threshold: > 10 is considered high complexity.
286
- */
287
- function getCyclomaticComplexity(fn: FunctionLike): number {
288
- let complexity = 1 // base path
289
-
290
- const incrementKinds = [
291
- SyntaxKind.IfStatement,
292
- SyntaxKind.ForStatement,
293
- SyntaxKind.ForInStatement,
294
- SyntaxKind.ForOfStatement,
295
- SyntaxKind.WhileStatement,
296
- SyntaxKind.DoStatement,
297
- SyntaxKind.CaseClause,
298
- SyntaxKind.CatchClause,
299
- SyntaxKind.ConditionalExpression, // ternary
300
- SyntaxKind.AmpersandAmpersandToken,
301
- SyntaxKind.BarBarToken,
302
- SyntaxKind.QuestionQuestionToken, // ??
303
- ]
304
-
305
- for (const kind of incrementKinds) {
306
- complexity += fn.getDescendantsOfKind(kind).length
307
- }
308
-
309
- return complexity
310
- }
311
-
312
- function detectHighComplexity(file: SourceFile): DriftIssue[] {
313
- const issues: DriftIssue[] = []
314
- const fns: FunctionLike[] = [
315
- ...file.getFunctions(),
316
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
317
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
318
- ...file.getClasses().flatMap((c) => c.getMethods()),
319
- ]
320
-
321
- for (const fn of fns) {
322
- const complexity = getCyclomaticComplexity(fn)
323
- if (complexity > 10) {
324
- const startLine = fn.getStartLineNumber()
325
- if (hasIgnoreComment(file, startLine)) continue
326
- issues.push({
327
- rule: 'high-complexity',
328
- severity: 'error',
329
- message: `Cyclomatic complexity is ${complexity} (threshold: 10). AI generates correct code, not simple code.`,
330
- line: startLine,
331
- column: fn.getStartLinePos(),
332
- snippet: getSnippet(fn, file),
333
- })
334
- }
335
- }
336
- return issues
337
- }
338
-
339
- /**
340
- * Deep nesting: count the maximum nesting depth of control flow inside a function.
341
- * Counts: if, for, while, do, try, switch.
342
- * Threshold: > 3 levels.
343
- */
344
- function getMaxNestingDepth(fn: FunctionLike): number {
345
- const nestingKinds = new Set([
346
- SyntaxKind.IfStatement,
347
- SyntaxKind.ForStatement,
348
- SyntaxKind.ForInStatement,
349
- SyntaxKind.ForOfStatement,
350
- SyntaxKind.WhileStatement,
351
- SyntaxKind.DoStatement,
352
- SyntaxKind.TryStatement,
353
- SyntaxKind.SwitchStatement,
354
- ])
355
-
356
- let maxDepth = 0
357
-
358
- function walk(node: Node, depth: number): void {
359
- if (nestingKinds.has(node.getKind())) {
360
- depth++
361
- if (depth > maxDepth) maxDepth = depth
362
- }
363
- for (const child of node.getChildren()) {
364
- walk(child, depth)
365
- }
366
- }
367
-
368
- walk(fn, 0)
369
- return maxDepth
370
- }
371
-
372
- function detectDeepNesting(file: SourceFile): DriftIssue[] {
373
- const issues: DriftIssue[] = []
374
- const fns: FunctionLike[] = [
375
- ...file.getFunctions(),
376
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
377
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
378
- ...file.getClasses().flatMap((c) => c.getMethods()),
379
- ]
380
-
381
- for (const fn of fns) {
382
- const depth = getMaxNestingDepth(fn)
383
- if (depth > 3) {
384
- const startLine = fn.getStartLineNumber()
385
- if (hasIgnoreComment(file, startLine)) continue
386
- issues.push({
387
- rule: 'deep-nesting',
388
- severity: 'warning',
389
- message: `Maximum nesting depth is ${depth} (threshold: 3). Deep nesting is the #1 readability killer.`,
390
- line: startLine,
391
- column: fn.getStartLinePos(),
392
- snippet: getSnippet(fn, file),
393
- })
394
- }
395
- }
396
- return issues
397
- }
398
-
399
- /**
400
- * Too many parameters: functions with more than 4 parameters.
401
- * AI avoids refactoring parameters into objects/options bags.
402
- */
403
- function detectTooManyParams(file: SourceFile): DriftIssue[] {
404
- const issues: DriftIssue[] = []
405
- const fns: FunctionLike[] = [
406
- ...file.getFunctions(),
407
- ...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
408
- ...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
409
- ...file.getClasses().flatMap((c) => c.getMethods()),
410
- ]
411
-
412
- for (const fn of fns) {
413
- const paramCount = fn.getParameters().length
414
- if (paramCount > 4) {
415
- const startLine = fn.getStartLineNumber()
416
- if (hasIgnoreComment(file, startLine)) continue
417
- issues.push({
418
- rule: 'too-many-params',
419
- severity: 'warning',
420
- message: `Function has ${paramCount} parameters (threshold: 4). AI avoids refactoring into options objects.`,
421
- line: startLine,
422
- column: fn.getStartLinePos(),
423
- snippet: getSnippet(fn, file),
424
- })
425
- }
426
- }
427
- return issues
428
- }
429
-
430
- /**
431
- * High coupling: files with more than 10 distinct import sources.
432
- * AI imports broadly without considering module cohesion.
433
- */
434
- function detectHighCoupling(file: SourceFile): DriftIssue[] {
435
- const imports = file.getImportDeclarations()
436
- const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()))
437
-
438
- if (sources.size > 10) {
439
- return [
440
- {
441
- rule: 'high-coupling',
442
- severity: 'warning',
443
- message: `File imports from ${sources.size} distinct modules (threshold: 10). High coupling makes refactoring dangerous.`,
444
- line: 1,
445
- column: 1,
446
- snippet: `// ${sources.size} import sources`,
447
- },
448
- ]
449
- }
450
- return []
451
- }
452
-
453
- /**
454
- * Promise style mix: async/await and .then()/.catch() used in the same file.
455
- * AI generates both styles without consistency.
456
- */
457
- function detectPromiseStyleMix(file: SourceFile): DriftIssue[] {
458
- const text = file.getFullText()
459
-
460
- // detect .then( or .catch( calls (property access on a promise)
461
- const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
462
- const name = node.getName()
463
- return name === 'then' || name === 'catch'
464
- })
465
-
466
- // detect async keyword usage
467
- const hasAsync =
468
- file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
469
- /\bawait\b/.test(text)
470
-
471
- if (hasThen && hasAsync) {
472
- return [
473
- {
474
- rule: 'promise-style-mix',
475
- severity: 'warning',
476
- message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
477
- line: 1,
478
- column: 1,
479
- snippet: `// mixed promise styles detected`,
480
- },
481
- ]
482
- }
483
- return []
484
- }
485
-
486
- /**
487
- * Magic numbers: numeric literals used directly in logic outside of named constants.
488
- * Excludes 0, 1, -1 (universally understood) and array indices in obvious patterns.
489
- */
490
- function detectMagicNumbers(file: SourceFile): DriftIssue[] {
491
- const issues: DriftIssue[] = []
492
- const ALLOWED = new Set([0, 1, -1, 2, 100])
493
-
494
- for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
495
- const value = Number(node.getLiteralValue())
496
- if (ALLOWED.has(value)) continue
497
-
498
- // Skip: variable/const initializers at top level (those ARE the named constants)
499
- const parent = node.getParent()
500
- if (!parent) continue
501
- const parentKind = parent.getKind()
502
- if (
503
- parentKind === SyntaxKind.VariableDeclaration ||
504
- parentKind === SyntaxKind.PropertyAssignment ||
505
- parentKind === SyntaxKind.EnumMember ||
506
- parentKind === SyntaxKind.Parameter
507
- ) continue
508
-
509
- const line = node.getStartLineNumber()
510
- if (hasIgnoreComment(file, line)) continue
511
-
512
- issues.push({
513
- rule: 'magic-number',
514
- severity: 'info',
515
- message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
516
- line,
517
- column: node.getStartLinePos(),
518
- snippet: getSnippet(node, file),
519
- })
520
- }
521
- return issues
522
- }
523
-
524
- /**
525
- * Comment contradiction: comments that restate exactly what the code does.
526
- * Classic AI pattern — documents the obvious instead of the why.
527
- * Detects: "// increment counter" above counter++, "// return x" above return x, etc.
528
- */
529
- function detectCommentContradiction(file: SourceFile): DriftIssue[] {
530
- const issues: DriftIssue[] = []
531
- const lines = file.getFullText().split('\n')
532
-
533
- // Patterns: comment that is a near-literal restatement of the next line
534
- const trivialCommentPatterns = [
535
- // "// return ..." above a return statement
536
- { comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
537
- // "// increment ..." or "// increase ..." above x++ or x += 1
538
- { comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
539
- // "// decrement ..." above x-- or x -= 1
540
- { comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
541
- // "// log ..." above console.log
542
- { comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
543
- // "// set ... to ..." or "// assign ..." above assignment
544
- { comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
545
- // "// call ..." above a function call
546
- { comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
547
- // "// declare ..." or "// define ..." or "// create ..." above const/let/var
548
- { comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
549
- // "// check if ..." above an if statement
550
- { comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
551
- // "// loop ..." or "// iterate ..." above for/while
552
- { comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
553
- // "// import ..." above an import
554
- { comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
555
- ]
556
-
557
- for (let i = 0; i < lines.length - 1; i++) {
558
- const commentLine = lines[i].trim()
559
- const nextLine = lines[i + 1]
560
-
561
- for (const { comment, code } of trivialCommentPatterns) {
562
- if (comment.test(commentLine) && code.test(nextLine)) {
563
- if (hasIgnoreComment(file, i + 1)) continue
564
- issues.push({
565
- rule: 'comment-contradiction',
566
- severity: 'warning',
567
- message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
568
- line: i + 1,
569
- column: 1,
570
- snippet: `${commentLine.slice(0, 60)}\n${nextLine.trim().slice(0, 60)}`,
571
- })
572
- break // one issue per comment line max
573
- }
574
- }
575
- }
576
-
577
- return issues
578
- }
579
-
580
- // ---------------------------------------------------------------------------
581
- // Phase 5: AI authorship heuristics
582
- // ---------------------------------------------------------------------------
583
-
584
- function detectOverCommented(file: SourceFile): DriftIssue[] {
585
- const issues: DriftIssue[] = []
586
-
587
- for (const fn of file.getFunctions()) {
588
- const body = fn.getBody()
589
- if (!body) continue
590
-
591
- const bodyText = body.getText()
592
- const lines = bodyText.split('\n')
593
- const totalLines = lines.length
594
-
595
- if (totalLines < 6) continue
596
-
597
- let commentLines = 0
598
- for (const line of lines) {
599
- const trimmed = line.trim()
600
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
601
- commentLines++
602
- }
603
- }
604
-
605
- const ratio = commentLines / totalLines
606
- if (ratio >= 0.4) {
607
- issues.push({
608
- rule: 'over-commented',
609
- severity: 'info',
610
- message: `Function has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
611
- line: fn.getStartLineNumber(),
612
- column: fn.getStartLinePos(),
613
- snippet: fn.getName() ? `function ${fn.getName()}` : '(anonymous function)',
614
- })
615
- }
616
- }
617
-
618
- for (const cls of file.getClasses()) {
619
- for (const method of cls.getMethods()) {
620
- const body = method.getBody()
621
- if (!body) continue
622
-
623
- const bodyText = body.getText()
624
- const lines = bodyText.split('\n')
625
- const totalLines = lines.length
626
-
627
- if (totalLines < 6) continue
628
-
629
- let commentLines = 0
630
- for (const line of lines) {
631
- const trimmed = line.trim()
632
- if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('*/')) {
633
- commentLines++
634
- }
635
- }
636
-
637
- const ratio = commentLines / totalLines
638
- if (ratio >= 0.4) {
639
- issues.push({
640
- rule: 'over-commented',
641
- severity: 'info',
642
- message: `Method '${method.getName()}' has ${Math.round(ratio * 100)}% comment density (${commentLines}/${totalLines} lines). AI documents the obvious instead of the why.`,
643
- line: method.getStartLineNumber(),
644
- column: method.getStartLinePos(),
645
- snippet: `${cls.getName()}.${method.getName()}`,
646
- })
647
- }
648
- }
649
- }
650
-
651
- return issues
652
- }
653
-
654
- function detectHardcodedConfig(file: SourceFile): DriftIssue[] {
655
- const issues: DriftIssue[] = []
656
-
657
- const CONFIG_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
658
- { pattern: /^https?:\/\//i, label: 'HTTP/HTTPS URL' },
659
- { pattern: /^wss?:\/\//i, label: 'WebSocket URL' },
660
- { pattern: /^mongodb(\+srv)?:\/\//i, label: 'MongoDB connection string' },
661
- { pattern: /^postgres(?:ql)?:\/\//i, label: 'PostgreSQL connection string' },
662
- { pattern: /^mysql:\/\//i, label: 'MySQL connection string' },
663
- { pattern: /^redis:\/\//i, label: 'Redis connection string' },
664
- { pattern: /^amqps?:\/\//i, label: 'AMQP connection string' },
665
- { pattern: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, label: 'IP address' },
666
- { pattern: /^:[0-9]{2,5}$/, label: 'Port number in string' },
667
- { pattern: /^\/[a-z]/i, label: 'Absolute file path' },
668
- { pattern: /localhost(:[0-9]+)?/i, label: 'localhost reference' },
669
- ]
670
-
671
- const filePath = file.getFilePath().replace(/\\/g, '/')
672
- if (filePath.includes('.test.') || filePath.includes('.spec.') || filePath.includes('__tests__')) {
673
- return issues
674
- }
675
-
676
- for (const node of file.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
677
- const value = node.getLiteralValue()
678
- if (!value || value.length < 4) continue
679
-
680
- const parent = node.getParent()
681
- if (!parent) continue
682
- const parentKind = parent.getKindName()
683
- if (
684
- parentKind === 'ImportDeclaration' ||
685
- parentKind === 'ExportDeclaration' ||
686
- (parentKind === 'CallExpression' && parent.getText().startsWith('import('))
687
- ) continue
688
-
689
- for (const { pattern, label } of CONFIG_PATTERNS) {
690
- if (pattern.test(value)) {
691
- issues.push({
692
- rule: 'hardcoded-config',
693
- severity: 'warning',
694
- message: `Hardcoded ${label} detected. AI skips environment variables — extract to process.env or a config module.`,
695
- line: node.getStartLineNumber(),
696
- column: node.getStartLinePos(),
697
- snippet: value.length > 60 ? value.slice(0, 60) + '...' : value,
698
- })
699
- break
700
- }
701
- }
702
- }
703
-
704
- return issues
705
- }
706
-
707
- function detectInconsistentErrorHandling(file: SourceFile): DriftIssue[] {
708
- const issues: DriftIssue[] = []
709
-
710
- let hasTryCatch = false
711
- let hasDotCatch = false
712
- let hasThenErrorHandler = false
713
- let firstLine = 0
714
-
715
- // Detectar try/catch
716
- const tryCatches = file.getDescendantsOfKind(SyntaxKind.TryStatement)
717
- if (tryCatches.length > 0) {
718
- hasTryCatch = true
719
- firstLine = firstLine || tryCatches[0].getStartLineNumber()
720
- }
721
-
722
- // Detectar .catch(handler) en call expressions
723
- for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
724
- const expr = call.getExpression()
725
- if (expr.getKindName() === 'PropertyAccessExpression') {
726
- const propAccess = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression)
727
- const propName = propAccess.getName()
728
- if (propName === 'catch') {
729
- // Verificar que tiene al menos un argumento (handler real, no .catch() vacío)
730
- if (call.getArguments().length > 0) {
731
- hasDotCatch = true
732
- if (!firstLine) firstLine = call.getStartLineNumber()
733
- }
734
- }
735
- // Detectar .then(onFulfilled, onRejected) — segundo argumento = error handler
736
- if (propName === 'then' && call.getArguments().length >= 2) {
737
- hasThenErrorHandler = true
738
- if (!firstLine) firstLine = call.getStartLineNumber()
739
- }
740
- }
741
- }
742
-
743
- const stylesUsed = [hasTryCatch, hasDotCatch, hasThenErrorHandler].filter(Boolean).length
744
-
745
- if (stylesUsed >= 2) {
746
- const styles: string[] = []
747
- if (hasTryCatch) styles.push('try/catch')
748
- if (hasDotCatch) styles.push('.catch()')
749
- if (hasThenErrorHandler) styles.push('.then(_, handler)')
750
-
751
- issues.push({
752
- rule: 'inconsistent-error-handling',
753
- severity: 'warning',
754
- message: `Mixed error handling styles: ${styles.join(', ')}. AI uses whatever pattern it saw last — pick one and stick to it.`,
755
- line: firstLine || 1,
756
- column: 1,
757
- snippet: styles.join(' + '),
758
- })
759
- }
760
-
761
- return issues
762
- }
763
-
764
- function detectUnnecessaryAbstraction(file: SourceFile): DriftIssue[] {
765
- const issues: DriftIssue[] = []
766
- const fileText = file.getFullText()
767
-
768
- // Interfaces con un solo método
769
- for (const iface of file.getInterfaces()) {
770
- const methods = iface.getMethods()
771
- const properties = iface.getProperties()
772
-
773
- // Solo reportar si tiene exactamente 1 método y 0 propiedades (abstracción pura de comportamiento)
774
- if (methods.length !== 1 || properties.length !== 0) continue
775
-
776
- const ifaceName = iface.getName()
777
-
778
- // Contar cuántas veces aparece el nombre en el archivo (excluyendo la declaración misma)
779
- const usageCount = (fileText.match(new RegExp(`\\b${ifaceName}\\b`, 'g')) ?? []).length
780
- // La declaración misma cuenta como 1 uso, implementaciones cuentan como 1 cada una
781
- // Si usageCount <= 2 (declaración + 1 uso), es candidata a innecesaria
782
- if (usageCount <= 2) {
783
- issues.push({
784
- rule: 'unnecessary-abstraction',
785
- severity: 'warning',
786
- message: `Interface '${ifaceName}' has 1 method and is used only once. AI creates abstractions preemptively — YAGNI.`,
787
- line: iface.getStartLineNumber(),
788
- column: iface.getStartLinePos(),
789
- snippet: `interface ${ifaceName} { ${methods[0].getName()}(...) }`,
790
- })
791
- }
792
- }
793
-
794
- // Clases abstractas con un solo método abstracto y sin implementaciones en el archivo
795
- for (const cls of file.getClasses()) {
796
- if (!cls.isAbstract()) continue
797
-
798
- const abstractMethods = cls.getMethods().filter(m => m.isAbstract())
799
- const concreteMethods = cls.getMethods().filter(m => !m.isAbstract())
800
-
801
- if (abstractMethods.length !== 1 || concreteMethods.length !== 0) continue
802
-
803
- const clsName = cls.getName() ?? ''
804
- const usageCount = (fileText.match(new RegExp(`\\b${clsName}\\b`, 'g')) ?? []).length
805
-
806
- if (usageCount <= 2) {
807
- issues.push({
808
- rule: 'unnecessary-abstraction',
809
- severity: 'warning',
810
- message: `Abstract class '${clsName}' has 1 abstract method and is extended nowhere in this file. AI over-engineers single-use code.`,
811
- line: cls.getStartLineNumber(),
812
- column: cls.getStartLinePos(),
813
- snippet: `abstract class ${clsName}`,
814
- })
815
- }
816
- }
817
-
818
- return issues
819
- }
820
-
821
- function detectNamingInconsistency(file: SourceFile): DriftIssue[] {
822
- const issues: DriftIssue[] = []
823
-
824
- const isCamelCase = (name: string) => /^[a-z][a-zA-Z0-9]*$/.test(name) && /[A-Z]/.test(name)
825
- const isSnakeCase = (name: string) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(name)
826
-
827
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
828
- function checkFunction(fn: any): void {
829
- const vars = fn.getVariableDeclarations()
830
- if (vars.length < 3) return // muy pocas vars para ser significativo
831
-
832
- let camelCount = 0
833
- let snakeCount = 0
834
- const snakeExamples: string[] = []
835
- const camelExamples: string[] = []
836
-
837
- for (const v of vars) {
838
- const name = v.getName()
839
- if (isCamelCase(name)) {
840
- camelCount++
841
- if (camelExamples.length < 2) camelExamples.push(name)
842
- } else if (isSnakeCase(name)) {
843
- snakeCount++
844
- if (snakeExamples.length < 2) snakeExamples.push(name)
845
- }
846
- }
847
-
848
- if (camelCount >= 1 && snakeCount >= 1) {
849
- issues.push({
850
- rule: 'naming-inconsistency',
851
- severity: 'warning',
852
- message: `Mixed naming conventions: camelCase (${camelExamples.join(', ')}) and snake_case (${snakeExamples.join(', ')}) in the same scope. AI mixes conventions from different training examples.`,
853
- line: fn.getStartLineNumber(),
854
- column: fn.getStartLinePos(),
855
- snippet: `camelCase: ${camelExamples[0]} / snake_case: ${snakeExamples[0]}`,
856
- })
857
- }
858
- }
859
-
860
- for (const fn of file.getFunctions()) {
861
- checkFunction(fn)
862
- }
863
-
864
- for (const cls of file.getClasses()) {
865
- for (const method of cls.getMethods()) {
866
- checkFunction(method)
867
- }
868
- }
869
-
870
- return issues
871
- }
872
-
873
- // ---------------------------------------------------------------------------
874
- // Score
875
- // ---------------------------------------------------------------------------
876
-
877
- function calculateScore(issues: DriftIssue[]): number {
878
- let raw = 0
879
- for (const issue of issues) {
880
- raw += RULE_WEIGHTS[issue.rule]?.weight ?? 5
881
- }
882
- return Math.min(100, raw)
883
- }
884
-
885
- // ---------------------------------------------------------------------------
886
- // Phase 8: Semantic duplication — AST fingerprinting helpers
887
- // ---------------------------------------------------------------------------
888
-
889
- type FunctionLikeNode = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
890
-
891
- /** Normalize a function body to a canonical string (Type-2 clone detection).
892
- * Variable names, parameter names, and numeric/string literals are replaced
893
- * with canonical tokens so that two functions with identical logic but
894
- * different identifiers produce the same fingerprint.
895
- */
896
- function normalizeFunctionBody(fn: FunctionLikeNode): string {
897
- // Build a substitution map: localName → canonical token
898
- const subst = new Map<string, string>()
899
-
900
- // Map parameters first
901
- for (const [i, param] of fn.getParameters().entries()) {
902
- const name = param.getName()
903
- if (name && name !== '_') subst.set(name, `P${i}`)
904
- }
905
-
906
- // Map locally declared variables (VariableDeclaration)
907
- let varIdx = 0
908
- fn.forEachDescendant(node => {
909
- if (node.getKind() === SyntaxKind.VariableDeclaration) {
910
- const nameNode = (node as import('ts-morph').VariableDeclaration).getNameNode()
911
- // Support destructuring — getNameNode() may be a BindingPattern
912
- if (nameNode.getKind() === SyntaxKind.Identifier) {
913
- const name = nameNode.getText()
914
- if (!subst.has(name)) subst.set(name, `V${varIdx++}`)
915
- }
916
- }
917
- })
918
-
919
- function serializeNode(node: Node): string {
920
- const kind = node.getKindName()
921
-
922
- switch (node.getKind()) {
923
- case SyntaxKind.Identifier: {
924
- const text = node.getText()
925
- return subst.get(text) ?? text // external refs (Math, console) kept as-is
926
- }
927
- case SyntaxKind.NumericLiteral:
928
- return 'NL'
929
- case SyntaxKind.StringLiteral:
930
- case SyntaxKind.NoSubstitutionTemplateLiteral:
931
- return 'SL'
932
- case SyntaxKind.TrueKeyword:
933
- return 'TRUE'
934
- case SyntaxKind.FalseKeyword:
935
- return 'FALSE'
936
- case SyntaxKind.NullKeyword:
937
- return 'NULL'
938
- }
939
-
940
- const children = node.getChildren()
941
- if (children.length === 0) return kind
942
-
943
- const childStr = children.map(serializeNode).join('|')
944
- return `${kind}(${childStr})`
945
- }
946
-
947
- const body = fn.getBody()
948
- if (!body) return ''
949
- return serializeNode(body)
950
- }
951
-
952
- /** Return a SHA-256 fingerprint for a function body (normalized). */
953
- function fingerprintFunction(fn: FunctionLikeNode): string {
954
- const normalized = normalizeFunctionBody(fn)
955
- return crypto.createHash('sha256').update(normalized).digest('hex')
956
- }
957
-
958
- /** Return all function-like nodes from a SourceFile that are worth comparing:
959
- * - At least MIN_LINES lines in their body
960
- * - Not test helpers (describe/it/test/beforeEach/afterEach)
961
- */
962
- const MIN_LINES = 8
963
-
964
- function collectFunctions(sf: SourceFile): Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> {
965
- const results: Array<{ fn: FunctionLikeNode; name: string; line: number; col: number }> = []
966
-
967
- const kinds = [
968
- SyntaxKind.FunctionDeclaration,
969
- SyntaxKind.FunctionExpression,
970
- SyntaxKind.ArrowFunction,
971
- SyntaxKind.MethodDeclaration,
972
- ] as const
973
-
974
- for (const kind of kinds) {
975
- for (const node of sf.getDescendantsOfKind(kind)) {
976
- const body = (node as FunctionLikeNode).getBody()
977
- if (!body) continue
978
-
979
- const start = body.getStartLineNumber()
980
- const end = body.getEndLineNumber()
981
- if (end - start + 1 < MIN_LINES) continue
982
-
983
- // Skip test-framework helpers
984
- const name = node.getKind() === SyntaxKind.FunctionDeclaration
985
- ? (node as FunctionDeclaration).getName() ?? '<anonymous>'
986
- : node.getKind() === SyntaxKind.MethodDeclaration
987
- ? (node as MethodDeclaration).getName()
988
- : '<anonymous>'
989
-
990
- if (['describe', 'it', 'test', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll'].includes(name)) continue
991
-
992
- const pos = node.getStart()
993
- const lineInfo = sf.getLineAndColumnAtPos(pos)
994
-
995
- results.push({ fn: node as FunctionLikeNode, name, line: lineInfo.line, col: lineInfo.column })
996
- }
997
- }
998
-
999
- return results
1000
- }
1001
-
1002
- // ---------------------------------------------------------------------------
1003
- // Public API
1004
- // ---------------------------------------------------------------------------
1005
-
1006
- export function analyzeFile(file: SourceFile): FileReport {
98
+ export function analyzeFile(file: import('ts-morph').SourceFile): FileReport {
1007
99
  if (isFileIgnored(file)) {
1008
100
  return {
1009
101
  path: file.getFilePath(),
@@ -1027,7 +119,6 @@ export function analyzeFile(file: SourceFile): FileReport {
1027
119
  ...detectTooManyParams(file),
1028
120
  ...detectHighCoupling(file),
1029
121
  ...detectPromiseStyleMix(file),
1030
- // Stubs now implemented
1031
122
  ...detectMagicNumbers(file),
1032
123
  ...detectCommentContradiction(file),
1033
124
  // Phase 5: AI authorship heuristics
@@ -1041,14 +132,18 @@ export function analyzeFile(file: SourceFile): FileReport {
1041
132
  return {
1042
133
  path: file.getFilePath(),
1043
134
  issues,
1044
- score: calculateScore(issues),
135
+ score: calculateScore(issues, RULE_WEIGHTS),
1045
136
  }
1046
137
  }
1047
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // Project-level analysis (phases 2, 3, 8 require the full file set)
141
+ // ---------------------------------------------------------------------------
142
+
1048
143
  export function analyzeProject(targetPath: string, config?: DriftConfig): FileReport[] {
1049
144
  const project = new Project({
1050
145
  skipAddingFilesFromTsConfig: true,
1051
- compilerOptions: { allowJs: true },
146
+ compilerOptions: { allowJs: true, jsx: 1 }, // 1 = JsxEmit.Preserve
1052
147
  })
1053
148
 
1054
149
  project.addSourceFilesAtPaths([
@@ -1072,11 +167,16 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1072
167
  const reportByPath = new Map<string, FileReport>()
1073
168
  for (const r of reports) reportByPath.set(r.path, r)
1074
169
 
1075
- // Phase 2: cross-file analysis build import graph first
1076
- const allImportedPaths = new Set<string>() // absolute paths of files that are imported
1077
- const allImportedNames = new Map<string, Set<string>>() // file path → set of imported names
1078
- const allLiteralImports = new Set<string>() // raw module specifiers (for unused-dependency)
1079
- const importGraph = new Map<string, Set<string>>() // Phase 3: filePath → Set of imported filePaths
170
+ // Build set of ignored paths so cross-file phases don't re-add issues
171
+ const ignoredPaths = new Set<string>(
172
+ sourceFiles.filter(sf => isFileIgnored(sf)).map(sf => sf.getFilePath())
173
+ )
174
+
175
+ // ── Phase 2 setup: build import graph ──────────────────────────────────────
176
+ const allImportedPaths = new Set<string>()
177
+ const allImportedNames = new Map<string, Set<string>>()
178
+ const allLiteralImports = new Set<string>()
179
+ const importGraph = new Map<string, Set<string>>()
1080
180
 
1081
181
  for (const sf of sourceFiles) {
1082
182
  const sfPath = sf.getFilePath()
@@ -1084,17 +184,14 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1084
184
  const moduleSpecifier = decl.getModuleSpecifierValue()
1085
185
  allLiteralImports.add(moduleSpecifier)
1086
186
 
1087
- // Resolve to absolute path for dead-file / unused-export
1088
187
  const resolved = decl.getModuleSpecifierSourceFile()
1089
188
  if (resolved) {
1090
189
  const resolvedPath = resolved.getFilePath()
1091
190
  allImportedPaths.add(resolvedPath)
1092
191
 
1093
- // Phase 3: populate directed import graph
1094
192
  if (!importGraph.has(sfPath)) importGraph.set(sfPath, new Set())
1095
193
  importGraph.get(sfPath)!.add(resolvedPath)
1096
194
 
1097
- // Collect named imports { A, B } and default imports
1098
195
  const named = decl.getNamedImports().map(n => n.getName())
1099
196
  const def = decl.getDefaultImport()?.getText()
1100
197
  const ns = decl.getNamespaceImport()?.getText()
@@ -1105,12 +202,10 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1105
202
  const nameSet = allImportedNames.get(resolvedPath)!
1106
203
  for (const n of named) nameSet.add(n)
1107
204
  if (def) nameSet.add('default')
1108
- if (ns) nameSet.add('*') // namespace import — counts all exports as used
205
+ if (ns) nameSet.add('*')
1109
206
  }
1110
207
  }
1111
208
 
1112
- // Also register re-exports: export { X, Y } from './module'
1113
- // These count as "using" X and Y from the source module
1114
209
  for (const exportDecl of sf.getExportDeclarations()) {
1115
210
  const reExportedModule = exportDecl.getModuleSpecifierSourceFile()
1116
211
  if (!reExportedModule) continue
@@ -1125,7 +220,6 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1125
220
 
1126
221
  const namedExports = exportDecl.getNamedExports()
1127
222
  if (namedExports.length === 0) {
1128
- // export * from './module' — namespace re-export, all names used
1129
223
  nameSet.add('*')
1130
224
  } else {
1131
225
  for (const ne of namedExports) nameSet.add(ne.getName())
@@ -1133,288 +227,85 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1133
227
  }
1134
228
  }
1135
229
 
1136
- // Detect unused-export and dead-file per source file
1137
- for (const sf of sourceFiles) {
1138
- const sfPath = sf.getFilePath()
230
+ // ── Phase 2: dead-file + unused-export + unused-dependency ─────────────────
231
+ const deadFiles = detectDeadFiles(sourceFiles, allImportedPaths, RULE_WEIGHTS)
232
+ for (const [sfPath, issue] of deadFiles) {
233
+ if (ignoredPaths.has(sfPath)) continue
1139
234
  const report = reportByPath.get(sfPath)
1140
- if (!report) continue
1141
-
1142
- // dead-file: file is never imported by anyone
1143
- // Exclude entry-point candidates: index.ts, main.ts, cli.ts, app.ts, bin files
1144
- const basename = path.basename(sfPath)
1145
- const isBinFile = sfPath.replace(/\\/g, '/').includes('/bin/')
1146
- const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile
1147
- if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
1148
- const issue: DriftIssue = {
1149
- rule: 'dead-file',
1150
- severity: RULE_WEIGHTS['dead-file'].severity,
1151
- message: 'File is never imported — may be dead code',
1152
- line: 1,
1153
- column: 1,
1154
- snippet: basename,
1155
- }
235
+ if (report) {
1156
236
  report.issues.push(issue)
1157
- report.score = calculateScore(report.issues)
1158
- }
1159
-
1160
- // unused-export: named exports not imported anywhere
1161
- // Skip barrel files (index.ts) — their entire surface is the public API
1162
- const isBarrel = /^index\.(ts|tsx|js|jsx)$/.test(basename)
1163
- const importedNamesForFile = allImportedNames.get(sfPath)
1164
- const hasNamespaceImport = importedNamesForFile?.has('*') ?? false
1165
- if (!isBarrel && !hasNamespaceImport) {
1166
- for (const exportDecl of sf.getExportDeclarations()) {
1167
- for (const namedExport of exportDecl.getNamedExports()) {
1168
- const name = namedExport.getName()
1169
- if (!importedNamesForFile?.has(name)) {
1170
- const line = namedExport.getStartLineNumber()
1171
- const issue: DriftIssue = {
1172
- rule: 'unused-export',
1173
- severity: RULE_WEIGHTS['unused-export'].severity,
1174
- message: `'${name}' is exported but never imported`,
1175
- line,
1176
- column: 1,
1177
- snippet: namedExport.getText().slice(0, 80),
1178
- }
1179
- report.issues.push(issue)
1180
- report.score = calculateScore(report.issues)
1181
- }
1182
- }
1183
- }
1184
-
1185
- // Also check inline export declarations (export function foo, export const bar)
1186
- for (const exportSymbol of sf.getExportedDeclarations()) {
1187
- const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]]
1188
- if (exportName === 'default') continue
1189
- if (importedNamesForFile?.has(exportName)) continue
1190
-
1191
- for (const decl of declarations) {
1192
- // Skip if this is a re-export from another file
1193
- if (decl.getSourceFile().getFilePath() !== sfPath) continue
1194
-
1195
- const line = decl.getStartLineNumber()
1196
- const issue: DriftIssue = {
1197
- rule: 'unused-export',
1198
- severity: RULE_WEIGHTS['unused-export'].severity,
1199
- message: `'${exportName}' is exported but never imported`,
1200
- line,
1201
- column: 1,
1202
- snippet: decl.getText().split('\n')[0].slice(0, 80),
1203
- }
1204
- report.issues.push(issue)
1205
- report.score = calculateScore(report.issues)
1206
- break // one issue per export name is enough
1207
- }
1208
- }
1209
- }
1210
- }
1211
-
1212
- // Detect unused-dependency: packages in package.json never imported
1213
- const pkgPath = path.join(targetPath, 'package.json')
1214
- if (fs.existsSync(pkgPath)) {
1215
- let pkg: Record<string, unknown>
1216
- try {
1217
- pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
1218
- } catch {
1219
- pkg = {}
1220
- }
1221
-
1222
- const deps = {
1223
- ...((pkg.dependencies as Record<string, string>) ?? {}),
1224
- }
1225
-
1226
- const unusedDeps: string[] = []
1227
- for (const depName of Object.keys(deps)) {
1228
- // Skip type-only packages (@types/*)
1229
- if (depName.startsWith('@types/')) continue
1230
-
1231
- // A dependency is "used" if any import specifier starts with the package name
1232
- // (handles sub-paths like 'lodash/merge', 'date-fns/format', etc.)
1233
- const isUsed = [...allLiteralImports].some(
1234
- imp => imp === depName || imp.startsWith(depName + '/')
1235
- )
1236
- if (!isUsed) unusedDeps.push(depName)
1237
- }
1238
-
1239
- if (unusedDeps.length > 0) {
1240
- const pkgIssues: DriftIssue[] = unusedDeps.map(dep => ({
1241
- rule: 'unused-dependency',
1242
- severity: RULE_WEIGHTS['unused-dependency'].severity,
1243
- message: `'${dep}' is in package.json but never imported`,
1244
- line: 1,
1245
- column: 1,
1246
- snippet: `"${dep}"`,
1247
- }))
1248
-
1249
- reports.push({
1250
- path: pkgPath,
1251
- issues: pkgIssues,
1252
- score: calculateScore(pkgIssues),
1253
- })
237
+ report.score = calculateScore(report.issues, RULE_WEIGHTS)
1254
238
  }
1255
239
  }
1256
240
 
1257
- // Phase 3: circular-dependency DFS cycle detection
1258
- function findCycles(graph: Map<string, Set<string>>): Array<string[]> {
1259
- const visited = new Set<string>()
1260
- const inStack = new Set<string>()
1261
- const cycles: Array<string[]> = []
1262
-
1263
- function dfs(node: string, stack: string[]): void {
1264
- visited.add(node)
1265
- inStack.add(node)
1266
- stack.push(node)
1267
-
1268
- for (const neighbor of graph.get(node) ?? []) {
1269
- if (!visited.has(neighbor)) {
1270
- dfs(neighbor, stack)
1271
- } else if (inStack.has(neighbor)) {
1272
- // Found a cycle — extract the cycle portion from the stack
1273
- const cycleStart = stack.indexOf(neighbor)
1274
- cycles.push(stack.slice(cycleStart))
1275
- }
1276
- }
1277
-
1278
- stack.pop()
1279
- inStack.delete(node)
1280
- }
1281
-
1282
- for (const node of graph.keys()) {
1283
- if (!visited.has(node)) {
1284
- dfs(node, [])
241
+ const unusedExports = detectUnusedExports(sourceFiles, allImportedNames, RULE_WEIGHTS)
242
+ for (const [sfPath, issues] of unusedExports) {
243
+ if (ignoredPaths.has(sfPath)) continue
244
+ const report = reportByPath.get(sfPath)
245
+ if (report) {
246
+ for (const issue of issues) {
247
+ report.issues.push(issue)
1285
248
  }
249
+ report.score = calculateScore(report.issues, RULE_WEIGHTS)
1286
250
  }
1287
-
1288
- return cycles
1289
251
  }
1290
252
 
1291
- const cycles = findCycles(importGraph)
1292
-
1293
- // De-duplicate: each unique cycle (regardless of starting node) reported once per file
1294
- const reportedCycleKeys = new Set<string>()
1295
-
1296
- for (const cycle of cycles) {
1297
- const cycleKey = [...cycle].sort().join('|')
1298
- if (reportedCycleKeys.has(cycleKey)) continue
1299
- reportedCycleKeys.add(cycleKey)
1300
-
1301
- // Report on the first file in the cycle
1302
- const firstFile = cycle[0]
1303
- const report = reportByPath.get(firstFile)
1304
- if (!report) continue
1305
-
1306
- const cycleDisplay = cycle
1307
- .map(p => path.basename(p))
1308
- .concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
1309
- .join(' → ')
253
+ const unusedDepIssues = detectUnusedDependencies(targetPath, allLiteralImports, RULE_WEIGHTS)
254
+ if (unusedDepIssues.length > 0) {
255
+ const pkgPath = path.join(targetPath, 'package.json')
256
+ reports.push({
257
+ path: pkgPath,
258
+ issues: unusedDepIssues,
259
+ score: calculateScore(unusedDepIssues, RULE_WEIGHTS),
260
+ })
261
+ }
1310
262
 
1311
- const issue: DriftIssue = {
1312
- rule: 'circular-dependency',
1313
- severity: RULE_WEIGHTS['circular-dependency'].severity,
1314
- message: `Circular dependency detected: ${cycleDisplay}`,
1315
- line: 1,
1316
- column: 1,
1317
- snippet: cycleDisplay,
263
+ // ── Phase 3: circular-dependency ────────────────────────────────────────────
264
+ const circularIssues = detectCircularDependencies(importGraph, RULE_WEIGHTS)
265
+ for (const [filePath, issue] of circularIssues) {
266
+ if (ignoredPaths.has(filePath)) continue
267
+ const report = reportByPath.get(filePath)
268
+ if (report) {
269
+ report.issues.push(issue)
270
+ report.score = calculateScore(report.issues, RULE_WEIGHTS)
1318
271
  }
1319
- report.issues.push(issue)
1320
- report.score = calculateScore(report.issues)
1321
272
  }
1322
273
 
1323
- // ── Phase 3b: layer-violation ──────────────────────────────────────────
274
+ // ── Phase 3b: layer-violation ────────────────────────────────────────────────
1324
275
  if (config?.layers && config.layers.length > 0) {
1325
- const { layers } = config
1326
-
1327
- function getLayer(filePath: string): LayerDefinition | undefined {
1328
- const rel = filePath.replace(/\\/g, '/')
1329
- return layers.find(layer =>
1330
- layer.patterns.some(pattern => {
1331
- const regexStr = pattern
1332
- .replace(/\\/g, '/')
1333
- .replace(/[.+^${}()|[\]]/g, '\\$&')
1334
- .replace(/\*\*/g, '###DOUBLESTAR###')
1335
- .replace(/\*/g, '[^/]*')
1336
- .replace(/###DOUBLESTAR###/g, '.*')
1337
- return new RegExp(`^${regexStr}`).test(rel)
1338
- })
1339
- )
1340
- }
1341
-
1342
- for (const [filePath, imports] of importGraph.entries()) {
1343
- const fileLayer = getLayer(filePath)
1344
- if (!fileLayer) continue
1345
-
1346
- for (const importedPath of imports) {
1347
- const importedLayer = getLayer(importedPath)
1348
- if (!importedLayer) continue
1349
- if (importedLayer.name === fileLayer.name) continue
1350
-
1351
- if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
1352
- const report = reportByPath.get(filePath)
1353
- if (report) {
1354
- const weight = RULE_WEIGHTS['layer-violation']?.weight ?? 5
1355
- report.issues.push({
1356
- rule: 'layer-violation',
1357
- severity: 'error',
1358
- message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
1359
- line: 1,
1360
- column: 1,
1361
- snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
1362
- })
1363
- report.score = Math.min(100, report.score + weight)
1364
- }
276
+ const layerIssues = detectLayerViolations(importGraph, config.layers, targetPath, RULE_WEIGHTS)
277
+ for (const [filePath, issues] of layerIssues) {
278
+ if (ignoredPaths.has(filePath)) continue
279
+ const report = reportByPath.get(filePath)
280
+ if (report) {
281
+ for (const issue of issues) {
282
+ report.issues.push(issue)
283
+ report.score = Math.min(100, report.score + (RULE_WEIGHTS['layer-violation']?.weight ?? 5))
1365
284
  }
1366
285
  }
1367
286
  }
1368
287
  }
1369
288
 
1370
- // ── Phase 3c: cross-boundary-import ────────────────────────────────────
289
+ // ── Phase 3c: cross-boundary-import ─────────────────────────────────────────
1371
290
  if (config?.modules && config.modules.length > 0) {
1372
- const { modules } = config
1373
-
1374
- function getModule(filePath: string): ModuleBoundary | undefined {
1375
- const rel = filePath.replace(/\\/g, '/')
1376
- return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')))
1377
- }
1378
-
1379
- for (const [filePath, imports] of importGraph.entries()) {
1380
- const fileModule = getModule(filePath)
1381
- if (!fileModule) continue
1382
-
1383
- for (const importedPath of imports) {
1384
- const importedModule = getModule(importedPath)
1385
- if (!importedModule) continue
1386
- if (importedModule.name === fileModule.name) continue
1387
-
1388
- const allowedImports = fileModule.allowedExternalImports ?? []
1389
- const relImported = importedPath.replace(/\\/g, '/')
1390
- const isAllowed = allowedImports.some(allowed =>
1391
- relImported.startsWith(allowed.replace(/\\/g, '/'))
1392
- )
1393
-
1394
- if (!isAllowed) {
1395
- const report = reportByPath.get(filePath)
1396
- if (report) {
1397
- const weight = RULE_WEIGHTS['cross-boundary-import']?.weight ?? 5
1398
- report.issues.push({
1399
- rule: 'cross-boundary-import',
1400
- severity: 'warning',
1401
- message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
1402
- line: 1,
1403
- column: 1,
1404
- snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
1405
- })
1406
- report.score = Math.min(100, report.score + weight)
1407
- }
291
+ const boundaryIssues = detectCrossBoundaryImports(importGraph, config.modules, targetPath, RULE_WEIGHTS)
292
+ for (const [filePath, issues] of boundaryIssues) {
293
+ if (ignoredPaths.has(filePath)) continue
294
+ const report = reportByPath.get(filePath)
295
+ if (report) {
296
+ for (const issue of issues) {
297
+ report.issues.push(issue)
298
+ report.score = Math.min(100, report.score + (RULE_WEIGHTS['cross-boundary-import']?.weight ?? 5))
1408
299
  }
1409
300
  }
1410
301
  }
1411
302
  }
1412
303
 
1413
- // ── Phase 8: semantic-duplication ────────────────────────────────────────
1414
- // Build a fingerprint → [{filePath, fnName, line, col}] map across all files
304
+ // ── Phase 8: semantic-duplication ───────────────────────────────────────────
1415
305
  const fingerprintMap = new Map<string, Array<{ filePath: string; name: string; line: number; col: number }>>()
1416
306
 
1417
307
  for (const sf of sourceFiles) {
308
+ if (isFileIgnored(sf)) continue
1418
309
  const sfPath = sf.getFilePath()
1419
310
  for (const { fn, name, line, col } of collectFunctions(sf)) {
1420
311
  const fp = fingerprintFunction(fn)
@@ -1423,7 +314,6 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1423
314
  }
1424
315
  }
1425
316
 
1426
- // For each fingerprint with 2+ functions: report each as a duplicate of the others
1427
317
  for (const [, entries] of fingerprintMap) {
1428
318
  if (entries.length < 2) continue
1429
319
 
@@ -1431,7 +321,6 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1431
321
  const report = reportByPath.get(entry.filePath)
1432
322
  if (!report) continue
1433
323
 
1434
- // Build the "duplicated in" list (all other locations)
1435
324
  const others = entries
1436
325
  .filter(e => e !== entry)
1437
326
  .map(e => {
@@ -1455,494 +344,3 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
1455
344
 
1456
345
  return reports
1457
346
  }
1458
-
1459
- // ---------------------------------------------------------------------------
1460
- // Git helpers
1461
- // ---------------------------------------------------------------------------
1462
-
1463
- /** Analyse a file given its absolute path string (wraps analyzeFile). */
1464
- function analyzeFilePath(filePath: string): FileReport {
1465
- const proj = new Project({
1466
- skipAddingFilesFromTsConfig: true,
1467
- compilerOptions: { allowJs: true },
1468
- })
1469
- const sf = proj.addSourceFileAtPath(filePath)
1470
- return analyzeFile(sf)
1471
- }
1472
-
1473
- /**
1474
- * Execute a git command synchronously and return stdout.
1475
- * Throws a descriptive error if the command fails or git is not available.
1476
- */
1477
- function execGit(cmd: string, cwd: string): string {
1478
- try {
1479
- return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
1480
- } catch (err) {
1481
- const msg = err instanceof Error ? err.message : String(err)
1482
- throw new Error(`Git command failed: ${cmd}\n${msg}`)
1483
- }
1484
- }
1485
-
1486
- /**
1487
- * Verify the given directory is a git repository.
1488
- * Throws if git is not available or the directory is not a repo.
1489
- */
1490
- function assertGitRepo(cwd: string): void {
1491
- try {
1492
- execGit('git rev-parse --is-inside-work-tree', cwd)
1493
- } catch {
1494
- throw new Error(`Directory is not a git repository: ${cwd}`)
1495
- }
1496
- }
1497
-
1498
- // ---------------------------------------------------------------------------
1499
- // Historical analysis helpers
1500
- // ---------------------------------------------------------------------------
1501
-
1502
- /**
1503
- * Analyse a single file as it existed at a given commit hash.
1504
- * Writes the blob to a temp file, runs analyzeFile, then cleans up.
1505
- */
1506
- async function analyzeFileAtCommit(
1507
- filePath: string,
1508
- commitHash: string,
1509
- projectRoot: string,
1510
- ): Promise<FileReport> {
1511
- const relPath = path.relative(projectRoot, filePath).replace(/\\/g, '/')
1512
- const blob = execGit(`git show ${commitHash}:${relPath}`, projectRoot)
1513
-
1514
- const tmpFile = path.join(os.tmpdir(), `drift-${crypto.randomBytes(8).toString('hex')}.ts`)
1515
- try {
1516
- fs.writeFileSync(tmpFile, blob, 'utf8')
1517
- const report = analyzeFilePath(tmpFile)
1518
- // Replace temp path with original for readable output
1519
- return { ...report, path: filePath }
1520
- } finally {
1521
- try { fs.unlinkSync(tmpFile) } catch { /* ignore cleanup errors */ }
1522
- }
1523
- }
1524
-
1525
- /**
1526
- * Analyse all TypeScript files changed in a single commit.
1527
- */
1528
- async function analyzeSingleCommit(
1529
- commitHash: string,
1530
- targetPath: string,
1531
- ): Promise<HistoricalAnalysis> {
1532
- // --name-only lists changed files; format gives metadata
1533
- const raw = execGit(
1534
- `git show --name-only --format="%H|%ai|%an|%s" ${commitHash}`,
1535
- targetPath,
1536
- )
1537
-
1538
- const lines = raw.split('\n')
1539
- // First non-empty line is the metadata line
1540
- const metaLine = lines[0] ?? ''
1541
- const [hash, dateStr, author, ...msgParts] = metaLine.split('|')
1542
- const message = msgParts.join('|').trim()
1543
- const commitDate = new Date(dateStr ?? '')
1544
-
1545
- // Collect changed .ts/.tsx files (lines after the empty separator)
1546
- const changedFiles: string[] = []
1547
- let pastSeparator = false
1548
- for (const line of lines.slice(1)) {
1549
- if (!pastSeparator && line.trim() === '') { pastSeparator = true; continue }
1550
- if (pastSeparator && (line.endsWith('.ts') || line.endsWith('.tsx'))) {
1551
- changedFiles.push(path.join(targetPath, line.trim()))
1552
- }
1553
- }
1554
-
1555
- const fileReports = await Promise.all(
1556
- changedFiles.map(f => analyzeFileAtCommit(f, hash ?? commitHash, targetPath).catch(() => null)),
1557
- )
1558
-
1559
- const validReports = fileReports.filter((r): r is FileReport => r !== null)
1560
- const totalScore = validReports.reduce((s, r) => s + r.score, 0)
1561
- const averageScore = validReports.length > 0 ? totalScore / validReports.length : 0
1562
-
1563
- return {
1564
- commitHash: hash ?? commitHash,
1565
- commitDate,
1566
- author: author ?? '',
1567
- message,
1568
- files: validReports,
1569
- totalScore,
1570
- averageScore,
1571
- }
1572
- }
1573
-
1574
- /**
1575
- * Run historical analysis over all commits since a given date.
1576
- * Returns results ordered chronologically (oldest first).
1577
- */
1578
- async function analyzeHistoricalCommits(
1579
- sinceDate: Date,
1580
- targetPath: string,
1581
- maxCommits: number,
1582
- ): Promise<HistoricalAnalysis[]> {
1583
- assertGitRepo(targetPath)
1584
-
1585
- const isoDate = sinceDate.toISOString()
1586
- const raw = execGit(
1587
- `git log --since="${isoDate}" --format="%H" --max-count=${maxCommits}`,
1588
- targetPath,
1589
- )
1590
-
1591
- if (!raw) return []
1592
-
1593
- const hashes = raw.split('\n').filter(Boolean)
1594
- const analyses = await Promise.all(
1595
- hashes.map(h => analyzeSingleCommit(h, targetPath).catch(() => null)),
1596
- )
1597
-
1598
- return analyses
1599
- .filter((a): a is HistoricalAnalysis => a !== null)
1600
- .sort((a, b) => a.commitDate.getTime() - b.commitDate.getTime())
1601
- }
1602
-
1603
- // ---------------------------------------------------------------------------
1604
- // TrendAnalyzer
1605
- // ---------------------------------------------------------------------------
1606
-
1607
- export class TrendAnalyzer {
1608
- private readonly projectPath: string
1609
- private readonly config: DriftConfig | undefined
1610
-
1611
- constructor(projectPath: string, config?: DriftConfig) {
1612
- this.projectPath = projectPath
1613
- this.config = config
1614
- }
1615
-
1616
- // --- Static utility methods -----------------------------------------------
1617
-
1618
- static calculateMovingAverage(data: TrendDataPoint[], windowSize: number): number[] {
1619
- return data.map((_, i) => {
1620
- const start = Math.max(0, i - windowSize + 1)
1621
- const window = data.slice(start, i + 1)
1622
- return window.reduce((s, p) => s + p.score, 0) / window.length
1623
- })
1624
- }
1625
-
1626
- static linearRegression(data: TrendDataPoint[]): { slope: number; intercept: number; r2: number } {
1627
- const n = data.length
1628
- if (n < 2) return { slope: 0, intercept: data[0]?.score ?? 0, r2: 0 }
1629
-
1630
- const xs = data.map((_, i) => i)
1631
- const ys = data.map(p => p.score)
1632
-
1633
- const xMean = xs.reduce((s, x) => s + x, 0) / n
1634
- const yMean = ys.reduce((s, y) => s + y, 0) / n
1635
-
1636
- const ssXX = xs.reduce((s, x) => s + (x - xMean) ** 2, 0)
1637
- const ssXY = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i]! - yMean), 0)
1638
- const ssYY = ys.reduce((s, y) => s + (y - yMean) ** 2, 0)
1639
-
1640
- const slope = ssXX === 0 ? 0 : ssXY / ssXX
1641
- const intercept = yMean - slope * xMean
1642
- const r2 = ssYY === 0 ? 1 : (ssXY ** 2) / (ssXX * ssYY)
1643
-
1644
- return { slope, intercept, r2 }
1645
- }
1646
-
1647
- /** Generate a simple horizontal ASCII bar chart (one bar per data point). */
1648
- static generateTrendChart(data: TrendDataPoint[]): string {
1649
- if (data.length === 0) return '(no data)'
1650
-
1651
- const maxScore = Math.max(...data.map(p => p.score), 1)
1652
- const chartWidth = 40
1653
-
1654
- const lines = data.map(p => {
1655
- const barLen = Math.round((p.score / maxScore) * chartWidth)
1656
- const bar = '█'.repeat(barLen)
1657
- const dateStr = p.date.toISOString().slice(0, 10)
1658
- return `${dateStr} │${bar.padEnd(chartWidth)} ${p.score.toFixed(1)}`
1659
- })
1660
-
1661
- return lines.join('\n')
1662
- }
1663
-
1664
- // --- Instance method -------------------------------------------------------
1665
-
1666
- async analyzeTrend(options: {
1667
- period?: 'week' | 'month' | 'quarter' | 'year'
1668
- since?: string
1669
- until?: string
1670
- }): Promise<DriftTrendReport> {
1671
- assertGitRepo(this.projectPath)
1672
-
1673
- const periodDays: Record<string, number> = {
1674
- week: 7, month: 30, quarter: 90, year: 365,
1675
- }
1676
- const days = periodDays[options.period ?? 'month'] ?? 30
1677
- const sinceDate = options.since
1678
- ? new Date(options.since)
1679
- : new Date(Date.now() - days * 24 * 60 * 60 * 1000)
1680
-
1681
- const historicalAnalyses = await analyzeHistoricalCommits(sinceDate, this.projectPath, 100)
1682
-
1683
- const trendPoints: TrendDataPoint[] = historicalAnalyses.map(h => ({
1684
- date: h.commitDate,
1685
- score: h.averageScore,
1686
- fileCount: h.files.length,
1687
- avgIssuesPerFile: h.files.length > 0
1688
- ? h.files.reduce((s, f) => s + f.issues.length, 0) / h.files.length
1689
- : 0,
1690
- }))
1691
-
1692
- const regression = TrendAnalyzer.linearRegression(trendPoints)
1693
-
1694
- // Current state report
1695
- const currentFiles = analyzeProject(this.projectPath, this.config)
1696
- const baseReport = buildReport(this.projectPath, currentFiles)
1697
-
1698
- return {
1699
- ...baseReport,
1700
- trend: trendPoints,
1701
- regression,
1702
- }
1703
- }
1704
- }
1705
-
1706
- // ---------------------------------------------------------------------------
1707
- // BlameAnalyzer
1708
- // ---------------------------------------------------------------------------
1709
-
1710
- interface GitBlameEntry {
1711
- hash: string
1712
- author: string
1713
- email: string
1714
- line: string
1715
- }
1716
-
1717
- function parseGitBlame(blameOutput: string): GitBlameEntry[] {
1718
- const entries: GitBlameEntry[] = []
1719
- const lines = blameOutput.split('\n')
1720
- let i = 0
1721
-
1722
- while (i < lines.length) {
1723
- const headerLine = lines[i]
1724
- if (!headerLine || headerLine.trim() === '') { i++; continue }
1725
-
1726
- // Porcelain blame format: first line is "<hash> <orig-line> <final-line> [<num-lines>]"
1727
- const headerMatch = headerLine.match(/^([0-9a-f]{40})\s/)
1728
- if (!headerMatch) { i++; continue }
1729
-
1730
- const hash = headerMatch[1]!
1731
- let author = ''
1732
- let email = ''
1733
- let codeLine = ''
1734
- i++
1735
-
1736
- while (i < lines.length && !lines[i]!.match(/^[0-9a-f]{40}\s/)) {
1737
- const l = lines[i]!
1738
- if (l.startsWith('author ')) author = l.slice(7).trim()
1739
- else if (l.startsWith('author-mail ')) email = l.slice(12).replace(/[<>]/g, '').trim()
1740
- else if (l.startsWith('\t')) codeLine = l.slice(1)
1741
- i++
1742
- }
1743
-
1744
- entries.push({ hash, author, email, line: codeLine })
1745
- }
1746
-
1747
- return entries
1748
- }
1749
-
1750
- export class BlameAnalyzer {
1751
- private readonly projectPath: string
1752
- private readonly config: DriftConfig | undefined
1753
-
1754
- constructor(projectPath: string, config?: DriftConfig) {
1755
- this.projectPath = projectPath
1756
- this.config = config
1757
- }
1758
-
1759
- /** Blame a single file: returns per-author attribution. */
1760
- static async analyzeFileBlame(filePath: string): Promise<BlameAttribution[]> {
1761
- const dir = path.dirname(filePath)
1762
- assertGitRepo(dir)
1763
-
1764
- const blameOutput = execGit(`git blame --porcelain "${filePath}"`, dir)
1765
- const entries = parseGitBlame(blameOutput)
1766
-
1767
- // Analyse issues in the file
1768
- const report = analyzeFilePath(filePath)
1769
-
1770
- // Map line numbers of issues to authors
1771
- const issuesByLine = new Map<number, number>()
1772
- for (const issue of report.issues) {
1773
- issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
1774
- }
1775
-
1776
- // Aggregate by author
1777
- const byAuthor = new Map<string, BlameAttribution>()
1778
- entries.forEach((entry, idx) => {
1779
- const key = entry.email || entry.author
1780
- if (!byAuthor.has(key)) {
1781
- byAuthor.set(key, {
1782
- author: entry.author,
1783
- email: entry.email,
1784
- commits: 0,
1785
- linesChanged: 0,
1786
- issuesIntroduced: 0,
1787
- avgScoreImpact: 0,
1788
- })
1789
- }
1790
- const attr = byAuthor.get(key)!
1791
- attr.linesChanged++
1792
- const lineNum = idx + 1
1793
- if (issuesByLine.has(lineNum)) {
1794
- attr.issuesIntroduced += issuesByLine.get(lineNum)!
1795
- }
1796
- })
1797
-
1798
- // Count unique commits per author
1799
- const commitsByAuthor = new Map<string, Set<string>>()
1800
- for (const entry of entries) {
1801
- const key = entry.email || entry.author
1802
- if (!commitsByAuthor.has(key)) commitsByAuthor.set(key, new Set())
1803
- commitsByAuthor.get(key)!.add(entry.hash)
1804
- }
1805
-
1806
- const total = entries.length || 1
1807
- const results: BlameAttribution[] = []
1808
- for (const [key, attr] of byAuthor) {
1809
- attr.commits = commitsByAuthor.get(key)?.size ?? 0
1810
- attr.avgScoreImpact = (attr.linesChanged / total) * report.score
1811
- results.push(attr)
1812
- }
1813
-
1814
- return results.sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1815
- }
1816
-
1817
- /** Blame for a specific rule across all files in targetPath. */
1818
- static async analyzeRuleBlame(rule: string, targetPath: string): Promise<BlameAttribution[]> {
1819
- assertGitRepo(targetPath)
1820
-
1821
- const tsFiles = fs
1822
- .readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
1823
- .filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('node_modules'))
1824
- .map(f => path.join(targetPath, f))
1825
-
1826
- const combined = new Map<string, BlameAttribution>()
1827
-
1828
- for (const file of tsFiles) {
1829
- const report = analyzeFilePath(file)
1830
- const ruleIssues = report.issues.filter(i => i.rule === rule)
1831
- if (ruleIssues.length === 0) continue
1832
-
1833
- let blameEntries: GitBlameEntry[] = []
1834
- try {
1835
- const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
1836
- blameEntries = parseGitBlame(blameOutput)
1837
- } catch { continue }
1838
-
1839
- for (const issue of ruleIssues) {
1840
- const entry = blameEntries[issue.line - 1]
1841
- if (!entry) continue
1842
- const key = entry.email || entry.author
1843
- if (!combined.has(key)) {
1844
- combined.set(key, {
1845
- author: entry.author,
1846
- email: entry.email,
1847
- commits: 0,
1848
- linesChanged: 0,
1849
- issuesIntroduced: 0,
1850
- avgScoreImpact: 0,
1851
- })
1852
- }
1853
- const attr = combined.get(key)!
1854
- attr.issuesIntroduced++
1855
- attr.avgScoreImpact += RULE_WEIGHTS[rule]?.weight ?? 5
1856
- }
1857
- }
1858
-
1859
- return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1860
- }
1861
-
1862
- /** Overall blame across all files and rules. */
1863
- static async analyzeOverallBlame(targetPath: string): Promise<BlameAttribution[]> {
1864
- assertGitRepo(targetPath)
1865
-
1866
- const tsFiles = fs
1867
- .readdirSync(targetPath, { recursive: true, encoding: 'utf8' })
1868
- .filter((f): f is string => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('node_modules'))
1869
- .map(f => path.join(targetPath, f))
1870
-
1871
- const combined = new Map<string, BlameAttribution>()
1872
- const commitsByAuthor = new Map<string, Set<string>>()
1873
-
1874
- for (const file of tsFiles) {
1875
- let blameEntries: GitBlameEntry[] = []
1876
- try {
1877
- const blameOutput = execGit(`git blame --porcelain "${file}"`, targetPath)
1878
- blameEntries = parseGitBlame(blameOutput)
1879
- } catch { continue }
1880
-
1881
- const report = analyzeFilePath(file)
1882
- const issuesByLine = new Map<number, number>()
1883
- for (const issue of report.issues) {
1884
- issuesByLine.set(issue.line, (issuesByLine.get(issue.line) ?? 0) + 1)
1885
- }
1886
-
1887
- blameEntries.forEach((entry, idx) => {
1888
- const key = entry.email || entry.author
1889
- if (!combined.has(key)) {
1890
- combined.set(key, {
1891
- author: entry.author,
1892
- email: entry.email,
1893
- commits: 0,
1894
- linesChanged: 0,
1895
- issuesIntroduced: 0,
1896
- avgScoreImpact: 0,
1897
- })
1898
- commitsByAuthor.set(key, new Set())
1899
- }
1900
- const attr = combined.get(key)!
1901
- attr.linesChanged++
1902
- commitsByAuthor.get(key)!.add(entry.hash)
1903
- const lineNum = idx + 1
1904
- if (issuesByLine.has(lineNum)) {
1905
- attr.issuesIntroduced += issuesByLine.get(lineNum)!
1906
- attr.avgScoreImpact += report.score * (1 / (blameEntries.length || 1))
1907
- }
1908
- })
1909
- }
1910
-
1911
- for (const [key, attr] of combined) {
1912
- attr.commits = commitsByAuthor.get(key)?.size ?? 0
1913
- }
1914
-
1915
- return Array.from(combined.values()).sort((a, b) => b.issuesIntroduced - a.issuesIntroduced)
1916
- }
1917
-
1918
- // --- Instance method -------------------------------------------------------
1919
-
1920
- async analyzeBlame(options: {
1921
- target?: 'file' | 'rule' | 'overall'
1922
- top?: number
1923
- filePath?: string
1924
- rule?: string
1925
- }): Promise<DriftBlameReport> {
1926
- assertGitRepo(this.projectPath)
1927
-
1928
- let blame: BlameAttribution[] = []
1929
- const mode = options.target ?? 'overall'
1930
-
1931
- if (mode === 'file' && options.filePath) {
1932
- blame = await BlameAnalyzer.analyzeFileBlame(options.filePath)
1933
- } else if (mode === 'rule' && options.rule) {
1934
- blame = await BlameAnalyzer.analyzeRuleBlame(options.rule, this.projectPath)
1935
- } else {
1936
- blame = await BlameAnalyzer.analyzeOverallBlame(this.projectPath)
1937
- }
1938
-
1939
- if (options.top) {
1940
- blame = blame.slice(0, options.top)
1941
- }
1942
-
1943
- const currentFiles = analyzeProject(this.projectPath, this.config)
1944
- const baseReport = buildReport(this.projectPath, currentFiles)
1945
-
1946
- return { ...baseReport, blame }
1947
- }
1948
- }