@eduardbar/drift 0.2.3 → 0.4.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/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +39 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/AGENTS.md +229 -0
- package/CHANGELOG.md +105 -0
- package/CODE_OF_CONDUCT.md +30 -0
- package/CONTRIBUTING.md +125 -0
- package/LICENSE +21 -0
- package/README.md +89 -7
- package/ROADMAP.md +213 -0
- package/assets/og-v030-linkedin.png +0 -0
- package/assets/og-v030-linkedin.svg +120 -0
- package/assets/og-v030-x.png +0 -0
- package/assets/og-v030-x.svg +94 -0
- package/content-v030.txt +165 -0
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +630 -2
- package/dist/cli.js +61 -5
- package/dist/config.d.ts +12 -0
- package/dist/config.js +40 -0
- package/dist/diff.d.ts +10 -0
- package/dist/diff.js +58 -0
- package/dist/git.d.ts +19 -0
- package/dist/git.js +84 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/printer.d.ts +5 -2
- package/dist/printer.js +157 -2
- package/dist/reporter.d.ts +2 -1
- package/dist/reporter.js +86 -0
- package/dist/types.d.ts +70 -0
- package/package.json +9 -1
- package/src/analyzer.ts +688 -3
- package/src/cli.ts +66 -5
- package/src/config.ts +45 -0
- package/src/diff.ts +74 -0
- package/src/git.ts +98 -0
- package/src/index.ts +2 -1
- package/src/printer.ts +175 -3
- package/src/reporter.ts +94 -1
- package/src/types.ts +85 -0
package/src/analyzer.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
1
3
|
import {
|
|
2
4
|
Project,
|
|
3
5
|
SourceFile,
|
|
@@ -8,7 +10,7 @@ import {
|
|
|
8
10
|
FunctionExpression,
|
|
9
11
|
MethodDeclaration,
|
|
10
12
|
} from 'ts-morph'
|
|
11
|
-
import type { DriftIssue, FileReport } from './types.js'
|
|
13
|
+
import type { DriftIssue, FileReport, DriftConfig, LayerDefinition, ModuleBoundary } from './types.js'
|
|
12
14
|
|
|
13
15
|
// Rules and their drift score weight
|
|
14
16
|
const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: number }> = {
|
|
@@ -22,6 +24,21 @@ const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; weight: n
|
|
|
22
24
|
'catch-swallow': { severity: 'warning', weight: 10 },
|
|
23
25
|
'magic-number': { severity: 'info', weight: 3 },
|
|
24
26
|
'any-abuse': { severity: 'warning', weight: 8 },
|
|
27
|
+
// Phase 1: complexity detection
|
|
28
|
+
'high-complexity': { severity: 'error', weight: 15 },
|
|
29
|
+
'deep-nesting': { severity: 'warning', weight: 12 },
|
|
30
|
+
'too-many-params': { severity: 'warning', weight: 8 },
|
|
31
|
+
'high-coupling': { severity: 'warning', weight: 10 },
|
|
32
|
+
'promise-style-mix': { severity: 'warning', weight: 7 },
|
|
33
|
+
// Phase 2: cross-file dead code
|
|
34
|
+
'unused-export': { severity: 'warning', weight: 8 },
|
|
35
|
+
'dead-file': { severity: 'warning', weight: 10 },
|
|
36
|
+
'unused-dependency': { severity: 'warning', weight: 6 },
|
|
37
|
+
// Phase 3: architectural boundaries
|
|
38
|
+
'circular-dependency': { severity: 'error', weight: 14 },
|
|
39
|
+
// Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
|
|
40
|
+
'layer-violation': { severity: 'error', weight: 16 },
|
|
41
|
+
'cross-boundary-import': { severity: 'warning', weight: 10 },
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
@@ -55,6 +72,10 @@ function getFunctionLikeLines(node: FunctionLike): number {
|
|
|
55
72
|
return node.getEndLineNumber() - node.getStartLineNumber()
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Existing rules
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
58
79
|
function detectLargeFile(file: SourceFile): DriftIssue[] {
|
|
59
80
|
const lineCount = file.getEndLineNumber()
|
|
60
81
|
if (lineCount > 300) {
|
|
@@ -239,6 +260,312 @@ function detectMissingReturnTypes(file: SourceFile): DriftIssue[] {
|
|
|
239
260
|
return issues
|
|
240
261
|
}
|
|
241
262
|
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Phase 1: complexity detection rules
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Cyclomatic complexity: count decision points in a function.
|
|
269
|
+
* Each if/else if/ternary/?:/for/while/do/case/catch/&&/|| adds 1.
|
|
270
|
+
* Threshold: > 10 is considered high complexity.
|
|
271
|
+
*/
|
|
272
|
+
function getCyclomaticComplexity(fn: FunctionLike): number {
|
|
273
|
+
let complexity = 1 // base path
|
|
274
|
+
|
|
275
|
+
const incrementKinds = [
|
|
276
|
+
SyntaxKind.IfStatement,
|
|
277
|
+
SyntaxKind.ForStatement,
|
|
278
|
+
SyntaxKind.ForInStatement,
|
|
279
|
+
SyntaxKind.ForOfStatement,
|
|
280
|
+
SyntaxKind.WhileStatement,
|
|
281
|
+
SyntaxKind.DoStatement,
|
|
282
|
+
SyntaxKind.CaseClause,
|
|
283
|
+
SyntaxKind.CatchClause,
|
|
284
|
+
SyntaxKind.ConditionalExpression, // ternary
|
|
285
|
+
SyntaxKind.AmpersandAmpersandToken,
|
|
286
|
+
SyntaxKind.BarBarToken,
|
|
287
|
+
SyntaxKind.QuestionQuestionToken, // ??
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
for (const kind of incrementKinds) {
|
|
291
|
+
complexity += fn.getDescendantsOfKind(kind).length
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return complexity
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function detectHighComplexity(file: SourceFile): DriftIssue[] {
|
|
298
|
+
const issues: DriftIssue[] = []
|
|
299
|
+
const fns: FunctionLike[] = [
|
|
300
|
+
...file.getFunctions(),
|
|
301
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
302
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
303
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
for (const fn of fns) {
|
|
307
|
+
const complexity = getCyclomaticComplexity(fn)
|
|
308
|
+
if (complexity > 10) {
|
|
309
|
+
const startLine = fn.getStartLineNumber()
|
|
310
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
311
|
+
issues.push({
|
|
312
|
+
rule: 'high-complexity',
|
|
313
|
+
severity: 'error',
|
|
314
|
+
message: `Cyclomatic complexity is ${complexity} (threshold: 10). AI generates correct code, not simple code.`,
|
|
315
|
+
line: startLine,
|
|
316
|
+
column: fn.getStartLinePos(),
|
|
317
|
+
snippet: getSnippet(fn, file),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return issues
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Deep nesting: count the maximum nesting depth of control flow inside a function.
|
|
326
|
+
* Counts: if, for, while, do, try, switch.
|
|
327
|
+
* Threshold: > 3 levels.
|
|
328
|
+
*/
|
|
329
|
+
function getMaxNestingDepth(fn: FunctionLike): number {
|
|
330
|
+
const nestingKinds = new Set([
|
|
331
|
+
SyntaxKind.IfStatement,
|
|
332
|
+
SyntaxKind.ForStatement,
|
|
333
|
+
SyntaxKind.ForInStatement,
|
|
334
|
+
SyntaxKind.ForOfStatement,
|
|
335
|
+
SyntaxKind.WhileStatement,
|
|
336
|
+
SyntaxKind.DoStatement,
|
|
337
|
+
SyntaxKind.TryStatement,
|
|
338
|
+
SyntaxKind.SwitchStatement,
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
let maxDepth = 0
|
|
342
|
+
|
|
343
|
+
function walk(node: Node, depth: number): void {
|
|
344
|
+
if (nestingKinds.has(node.getKind())) {
|
|
345
|
+
depth++
|
|
346
|
+
if (depth > maxDepth) maxDepth = depth
|
|
347
|
+
}
|
|
348
|
+
for (const child of node.getChildren()) {
|
|
349
|
+
walk(child, depth)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
walk(fn, 0)
|
|
354
|
+
return maxDepth
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function detectDeepNesting(file: SourceFile): DriftIssue[] {
|
|
358
|
+
const issues: DriftIssue[] = []
|
|
359
|
+
const fns: FunctionLike[] = [
|
|
360
|
+
...file.getFunctions(),
|
|
361
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
362
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
363
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
for (const fn of fns) {
|
|
367
|
+
const depth = getMaxNestingDepth(fn)
|
|
368
|
+
if (depth > 3) {
|
|
369
|
+
const startLine = fn.getStartLineNumber()
|
|
370
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
371
|
+
issues.push({
|
|
372
|
+
rule: 'deep-nesting',
|
|
373
|
+
severity: 'warning',
|
|
374
|
+
message: `Maximum nesting depth is ${depth} (threshold: 3). Deep nesting is the #1 readability killer.`,
|
|
375
|
+
line: startLine,
|
|
376
|
+
column: fn.getStartLinePos(),
|
|
377
|
+
snippet: getSnippet(fn, file),
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return issues
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Too many parameters: functions with more than 4 parameters.
|
|
386
|
+
* AI avoids refactoring parameters into objects/options bags.
|
|
387
|
+
*/
|
|
388
|
+
function detectTooManyParams(file: SourceFile): DriftIssue[] {
|
|
389
|
+
const issues: DriftIssue[] = []
|
|
390
|
+
const fns: FunctionLike[] = [
|
|
391
|
+
...file.getFunctions(),
|
|
392
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
393
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
394
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
for (const fn of fns) {
|
|
398
|
+
const paramCount = fn.getParameters().length
|
|
399
|
+
if (paramCount > 4) {
|
|
400
|
+
const startLine = fn.getStartLineNumber()
|
|
401
|
+
if (hasIgnoreComment(file, startLine)) continue
|
|
402
|
+
issues.push({
|
|
403
|
+
rule: 'too-many-params',
|
|
404
|
+
severity: 'warning',
|
|
405
|
+
message: `Function has ${paramCount} parameters (threshold: 4). AI avoids refactoring into options objects.`,
|
|
406
|
+
line: startLine,
|
|
407
|
+
column: fn.getStartLinePos(),
|
|
408
|
+
snippet: getSnippet(fn, file),
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return issues
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* High coupling: files with more than 10 distinct import sources.
|
|
417
|
+
* AI imports broadly without considering module cohesion.
|
|
418
|
+
*/
|
|
419
|
+
function detectHighCoupling(file: SourceFile): DriftIssue[] {
|
|
420
|
+
const imports = file.getImportDeclarations()
|
|
421
|
+
const sources = new Set(imports.map((i) => i.getModuleSpecifierValue()))
|
|
422
|
+
|
|
423
|
+
if (sources.size > 10) {
|
|
424
|
+
return [
|
|
425
|
+
{
|
|
426
|
+
rule: 'high-coupling',
|
|
427
|
+
severity: 'warning',
|
|
428
|
+
message: `File imports from ${sources.size} distinct modules (threshold: 10). High coupling makes refactoring dangerous.`,
|
|
429
|
+
line: 1,
|
|
430
|
+
column: 1,
|
|
431
|
+
snippet: `// ${sources.size} import sources`,
|
|
432
|
+
},
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
return []
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Promise style mix: async/await and .then()/.catch() used in the same file.
|
|
440
|
+
* AI generates both styles without consistency.
|
|
441
|
+
*/
|
|
442
|
+
function detectPromiseStyleMix(file: SourceFile): DriftIssue[] {
|
|
443
|
+
const text = file.getFullText()
|
|
444
|
+
|
|
445
|
+
// detect .then( or .catch( calls (property access on a promise)
|
|
446
|
+
const hasThen = file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).some((node) => {
|
|
447
|
+
const name = node.getName()
|
|
448
|
+
return name === 'then' || name === 'catch'
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// detect async keyword usage
|
|
452
|
+
const hasAsync =
|
|
453
|
+
file.getDescendantsOfKind(SyntaxKind.AsyncKeyword).length > 0 ||
|
|
454
|
+
/\bawait\b/.test(text)
|
|
455
|
+
|
|
456
|
+
if (hasThen && hasAsync) {
|
|
457
|
+
return [
|
|
458
|
+
{
|
|
459
|
+
rule: 'promise-style-mix',
|
|
460
|
+
severity: 'warning',
|
|
461
|
+
message: `File mixes async/await with .then()/.catch(). AI generates both styles without picking one.`,
|
|
462
|
+
line: 1,
|
|
463
|
+
column: 1,
|
|
464
|
+
snippet: `// mixed promise styles detected`,
|
|
465
|
+
},
|
|
466
|
+
]
|
|
467
|
+
}
|
|
468
|
+
return []
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Magic numbers: numeric literals used directly in logic outside of named constants.
|
|
473
|
+
* Excludes 0, 1, -1 (universally understood) and array indices in obvious patterns.
|
|
474
|
+
*/
|
|
475
|
+
function detectMagicNumbers(file: SourceFile): DriftIssue[] {
|
|
476
|
+
const issues: DriftIssue[] = []
|
|
477
|
+
const ALLOWED = new Set([0, 1, -1, 2, 100])
|
|
478
|
+
|
|
479
|
+
for (const node of file.getDescendantsOfKind(SyntaxKind.NumericLiteral)) {
|
|
480
|
+
const value = Number(node.getLiteralValue())
|
|
481
|
+
if (ALLOWED.has(value)) continue
|
|
482
|
+
|
|
483
|
+
// Skip: variable/const initializers at top level (those ARE the named constants)
|
|
484
|
+
const parent = node.getParent()
|
|
485
|
+
if (!parent) continue
|
|
486
|
+
const parentKind = parent.getKind()
|
|
487
|
+
if (
|
|
488
|
+
parentKind === SyntaxKind.VariableDeclaration ||
|
|
489
|
+
parentKind === SyntaxKind.PropertyAssignment ||
|
|
490
|
+
parentKind === SyntaxKind.EnumMember ||
|
|
491
|
+
parentKind === SyntaxKind.Parameter
|
|
492
|
+
) continue
|
|
493
|
+
|
|
494
|
+
const line = node.getStartLineNumber()
|
|
495
|
+
if (hasIgnoreComment(file, line)) continue
|
|
496
|
+
|
|
497
|
+
issues.push({
|
|
498
|
+
rule: 'magic-number',
|
|
499
|
+
severity: 'info',
|
|
500
|
+
message: `Magic number ${value} used directly in logic. Extract to a named constant.`,
|
|
501
|
+
line,
|
|
502
|
+
column: node.getStartLinePos(),
|
|
503
|
+
snippet: getSnippet(node, file),
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
return issues
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Comment contradiction: comments that restate exactly what the code does.
|
|
511
|
+
* Classic AI pattern — documents the obvious instead of the why.
|
|
512
|
+
* Detects: "// increment counter" above counter++, "// return x" above return x, etc.
|
|
513
|
+
*/
|
|
514
|
+
function detectCommentContradiction(file: SourceFile): DriftIssue[] {
|
|
515
|
+
const issues: DriftIssue[] = []
|
|
516
|
+
const lines = file.getFullText().split('\n')
|
|
517
|
+
|
|
518
|
+
// Patterns: comment that is a near-literal restatement of the next line
|
|
519
|
+
const trivialCommentPatterns = [
|
|
520
|
+
// "// return ..." above a return statement
|
|
521
|
+
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
522
|
+
// "// increment ..." or "// increase ..." above x++ or x += 1
|
|
523
|
+
{ comment: /\/\/\s*(increment|increase|add\s+1|plus\s+1)\b/i, code: /\+\+|(\+= ?1)\b/ },
|
|
524
|
+
// "// decrement ..." above x-- or x -= 1
|
|
525
|
+
{ comment: /\/\/\s*(decrement|decrease|subtract\s+1|minus\s+1)\b/i, code: /--|(-= ?1)\b/ },
|
|
526
|
+
// "// log ..." above console.log
|
|
527
|
+
{ comment: /\/\/\s*log\b/i, code: /console\.(log|warn|error)/ },
|
|
528
|
+
// "// set ... to ..." or "// assign ..." above assignment
|
|
529
|
+
{ comment: /\/\/\s*(set|assign)\b/i, code: /^\s*\w[\w.[\]]*\s*=(?!=)/ },
|
|
530
|
+
// "// call ..." above a function call
|
|
531
|
+
{ comment: /\/\/\s*call\b/i, code: /^\s*\w[\w.]*\(/ },
|
|
532
|
+
// "// declare ..." or "// define ..." or "// create ..." above const/let/var
|
|
533
|
+
{ comment: /\/\/\s*(declare|define|create|initialize)\b/i, code: /^\s*(const|let|var)\b/ },
|
|
534
|
+
// "// check if ..." above an if statement
|
|
535
|
+
{ comment: /\/\/\s*check\s+if\b/i, code: /^\s*if\s*\(/ },
|
|
536
|
+
// "// loop ..." or "// iterate ..." above for/while
|
|
537
|
+
{ comment: /\/\/\s*(loop|iterate|for each|foreach)\b/i, code: /^\s*(for|while)\b/ },
|
|
538
|
+
// "// import ..." above an import
|
|
539
|
+
{ comment: /\/\/\s*import\b/i, code: /^\s*import\b/ },
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
543
|
+
const commentLine = lines[i].trim()
|
|
544
|
+
const nextLine = lines[i + 1]
|
|
545
|
+
|
|
546
|
+
for (const { comment, code } of trivialCommentPatterns) {
|
|
547
|
+
if (comment.test(commentLine) && code.test(nextLine)) {
|
|
548
|
+
if (hasIgnoreComment(file, i + 1)) continue
|
|
549
|
+
issues.push({
|
|
550
|
+
rule: 'comment-contradiction',
|
|
551
|
+
severity: 'warning',
|
|
552
|
+
message: `Comment restates what the code already says. AI documents the obvious instead of the why.`,
|
|
553
|
+
line: i + 1,
|
|
554
|
+
column: 1,
|
|
555
|
+
snippet: `${commentLine.slice(0, 60)}\n${nextLine.trim().slice(0, 60)}`,
|
|
556
|
+
})
|
|
557
|
+
break // one issue per comment line max
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return issues
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Score
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
242
569
|
function calculateScore(issues: DriftIssue[]): number {
|
|
243
570
|
let raw = 0
|
|
244
571
|
for (const issue of issues) {
|
|
@@ -247,6 +574,10 @@ function calculateScore(issues: DriftIssue[]): number {
|
|
|
247
574
|
return Math.min(100, raw)
|
|
248
575
|
}
|
|
249
576
|
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Public API
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
250
581
|
export function analyzeFile(file: SourceFile): FileReport {
|
|
251
582
|
if (isFileIgnored(file)) {
|
|
252
583
|
return {
|
|
@@ -265,6 +596,15 @@ export function analyzeFile(file: SourceFile): FileReport {
|
|
|
265
596
|
...detectAnyAbuse(file),
|
|
266
597
|
...detectCatchSwallow(file),
|
|
267
598
|
...detectMissingReturnTypes(file),
|
|
599
|
+
// Phase 1: complexity
|
|
600
|
+
...detectHighComplexity(file),
|
|
601
|
+
...detectDeepNesting(file),
|
|
602
|
+
...detectTooManyParams(file),
|
|
603
|
+
...detectHighCoupling(file),
|
|
604
|
+
...detectPromiseStyleMix(file),
|
|
605
|
+
// Stubs now implemented
|
|
606
|
+
...detectMagicNumbers(file),
|
|
607
|
+
...detectCommentContradiction(file),
|
|
268
608
|
]
|
|
269
609
|
|
|
270
610
|
return {
|
|
@@ -274,7 +614,7 @@ export function analyzeFile(file: SourceFile): FileReport {
|
|
|
274
614
|
}
|
|
275
615
|
}
|
|
276
616
|
|
|
277
|
-
export function analyzeProject(targetPath: string): FileReport[] {
|
|
617
|
+
export function analyzeProject(targetPath: string, config?: DriftConfig): FileReport[] {
|
|
278
618
|
const project = new Project({
|
|
279
619
|
skipAddingFilesFromTsConfig: true,
|
|
280
620
|
compilerOptions: { allowJs: true },
|
|
@@ -294,5 +634,350 @@ export function analyzeProject(targetPath: string): FileReport[] {
|
|
|
294
634
|
`!${targetPath}/**/*.spec.*`,
|
|
295
635
|
])
|
|
296
636
|
|
|
297
|
-
|
|
637
|
+
const sourceFiles = project.getSourceFiles()
|
|
638
|
+
|
|
639
|
+
// Phase 1: per-file analysis
|
|
640
|
+
const reports: FileReport[] = sourceFiles.map(analyzeFile)
|
|
641
|
+
const reportByPath = new Map<string, FileReport>()
|
|
642
|
+
for (const r of reports) reportByPath.set(r.path, r)
|
|
643
|
+
|
|
644
|
+
// Phase 2: cross-file analysis — build import graph first
|
|
645
|
+
const allImportedPaths = new Set<string>() // absolute paths of files that are imported
|
|
646
|
+
const allImportedNames = new Map<string, Set<string>>() // file path → set of imported names
|
|
647
|
+
const allLiteralImports = new Set<string>() // raw module specifiers (for unused-dependency)
|
|
648
|
+
const importGraph = new Map<string, Set<string>>() // Phase 3: filePath → Set of imported filePaths
|
|
649
|
+
|
|
650
|
+
for (const sf of sourceFiles) {
|
|
651
|
+
const sfPath = sf.getFilePath()
|
|
652
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
653
|
+
const moduleSpecifier = decl.getModuleSpecifierValue()
|
|
654
|
+
allLiteralImports.add(moduleSpecifier)
|
|
655
|
+
|
|
656
|
+
// Resolve to absolute path for dead-file / unused-export
|
|
657
|
+
const resolved = decl.getModuleSpecifierSourceFile()
|
|
658
|
+
if (resolved) {
|
|
659
|
+
const resolvedPath = resolved.getFilePath()
|
|
660
|
+
allImportedPaths.add(resolvedPath)
|
|
661
|
+
|
|
662
|
+
// Phase 3: populate directed import graph
|
|
663
|
+
if (!importGraph.has(sfPath)) importGraph.set(sfPath, new Set())
|
|
664
|
+
importGraph.get(sfPath)!.add(resolvedPath)
|
|
665
|
+
|
|
666
|
+
// Collect named imports { A, B } and default imports
|
|
667
|
+
const named = decl.getNamedImports().map(n => n.getName())
|
|
668
|
+
const def = decl.getDefaultImport()?.getText()
|
|
669
|
+
const ns = decl.getNamespaceImport()?.getText()
|
|
670
|
+
|
|
671
|
+
if (!allImportedNames.has(resolvedPath)) {
|
|
672
|
+
allImportedNames.set(resolvedPath, new Set())
|
|
673
|
+
}
|
|
674
|
+
const nameSet = allImportedNames.get(resolvedPath)!
|
|
675
|
+
for (const n of named) nameSet.add(n)
|
|
676
|
+
if (def) nameSet.add('default')
|
|
677
|
+
if (ns) nameSet.add('*') // namespace import — counts all exports as used
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Also register re-exports: export { X, Y } from './module'
|
|
682
|
+
// These count as "using" X and Y from the source module
|
|
683
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
684
|
+
const reExportedModule = exportDecl.getModuleSpecifierSourceFile()
|
|
685
|
+
if (!reExportedModule) continue
|
|
686
|
+
|
|
687
|
+
const reExportedPath = reExportedModule.getFilePath()
|
|
688
|
+
allImportedPaths.add(reExportedPath)
|
|
689
|
+
|
|
690
|
+
if (!allImportedNames.has(reExportedPath)) {
|
|
691
|
+
allImportedNames.set(reExportedPath, new Set())
|
|
692
|
+
}
|
|
693
|
+
const nameSet = allImportedNames.get(reExportedPath)!
|
|
694
|
+
|
|
695
|
+
const namedExports = exportDecl.getNamedExports()
|
|
696
|
+
if (namedExports.length === 0) {
|
|
697
|
+
// export * from './module' — namespace re-export, all names used
|
|
698
|
+
nameSet.add('*')
|
|
699
|
+
} else {
|
|
700
|
+
for (const ne of namedExports) nameSet.add(ne.getName())
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Detect unused-export and dead-file per source file
|
|
706
|
+
for (const sf of sourceFiles) {
|
|
707
|
+
const sfPath = sf.getFilePath()
|
|
708
|
+
const report = reportByPath.get(sfPath)
|
|
709
|
+
if (!report) continue
|
|
710
|
+
|
|
711
|
+
// dead-file: file is never imported by anyone
|
|
712
|
+
// Exclude entry-point candidates: index.ts, main.ts, cli.ts, app.ts, bin files
|
|
713
|
+
const basename = path.basename(sfPath)
|
|
714
|
+
const isBinFile = sfPath.replace(/\\/g, '/').includes('/bin/')
|
|
715
|
+
const isEntryPoint = /^(index|main|cli|app)\.(ts|tsx|js|jsx)$/.test(basename) || isBinFile
|
|
716
|
+
if (!isEntryPoint && !allImportedPaths.has(sfPath)) {
|
|
717
|
+
const issue: DriftIssue = {
|
|
718
|
+
rule: 'dead-file',
|
|
719
|
+
severity: RULE_WEIGHTS['dead-file'].severity,
|
|
720
|
+
message: 'File is never imported — may be dead code',
|
|
721
|
+
line: 1,
|
|
722
|
+
column: 1,
|
|
723
|
+
snippet: basename,
|
|
724
|
+
}
|
|
725
|
+
report.issues.push(issue)
|
|
726
|
+
report.score = calculateScore(report.issues)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// unused-export: named exports not imported anywhere
|
|
730
|
+
// Skip barrel files (index.ts) — their entire surface is the public API
|
|
731
|
+
const isBarrel = /^index\.(ts|tsx|js|jsx)$/.test(basename)
|
|
732
|
+
const importedNamesForFile = allImportedNames.get(sfPath)
|
|
733
|
+
const hasNamespaceImport = importedNamesForFile?.has('*') ?? false
|
|
734
|
+
if (!isBarrel && !hasNamespaceImport) {
|
|
735
|
+
for (const exportDecl of sf.getExportDeclarations()) {
|
|
736
|
+
for (const namedExport of exportDecl.getNamedExports()) {
|
|
737
|
+
const name = namedExport.getName()
|
|
738
|
+
if (!importedNamesForFile?.has(name)) {
|
|
739
|
+
const line = namedExport.getStartLineNumber()
|
|
740
|
+
const issue: DriftIssue = {
|
|
741
|
+
rule: 'unused-export',
|
|
742
|
+
severity: RULE_WEIGHTS['unused-export'].severity,
|
|
743
|
+
message: `'${name}' is exported but never imported`,
|
|
744
|
+
line,
|
|
745
|
+
column: 1,
|
|
746
|
+
snippet: namedExport.getText().slice(0, 80),
|
|
747
|
+
}
|
|
748
|
+
report.issues.push(issue)
|
|
749
|
+
report.score = calculateScore(report.issues)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Also check inline export declarations (export function foo, export const bar)
|
|
755
|
+
for (const exportSymbol of sf.getExportedDeclarations()) {
|
|
756
|
+
const [exportName, declarations] = [exportSymbol[0], exportSymbol[1]]
|
|
757
|
+
if (exportName === 'default') continue
|
|
758
|
+
if (importedNamesForFile?.has(exportName)) continue
|
|
759
|
+
|
|
760
|
+
for (const decl of declarations) {
|
|
761
|
+
// Skip if this is a re-export from another file
|
|
762
|
+
if (decl.getSourceFile().getFilePath() !== sfPath) continue
|
|
763
|
+
|
|
764
|
+
const line = decl.getStartLineNumber()
|
|
765
|
+
const issue: DriftIssue = {
|
|
766
|
+
rule: 'unused-export',
|
|
767
|
+
severity: RULE_WEIGHTS['unused-export'].severity,
|
|
768
|
+
message: `'${exportName}' is exported but never imported`,
|
|
769
|
+
line,
|
|
770
|
+
column: 1,
|
|
771
|
+
snippet: decl.getText().split('\n')[0].slice(0, 80),
|
|
772
|
+
}
|
|
773
|
+
report.issues.push(issue)
|
|
774
|
+
report.score = calculateScore(report.issues)
|
|
775
|
+
break // one issue per export name is enough
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Detect unused-dependency: packages in package.json never imported
|
|
782
|
+
const pkgPath = path.join(targetPath, 'package.json')
|
|
783
|
+
if (fs.existsSync(pkgPath)) {
|
|
784
|
+
let pkg: Record<string, unknown>
|
|
785
|
+
try {
|
|
786
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
787
|
+
} catch {
|
|
788
|
+
pkg = {}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const deps = {
|
|
792
|
+
...((pkg.dependencies as Record<string, string>) ?? {}),
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const unusedDeps: string[] = []
|
|
796
|
+
for (const depName of Object.keys(deps)) {
|
|
797
|
+
// Skip type-only packages (@types/*)
|
|
798
|
+
if (depName.startsWith('@types/')) continue
|
|
799
|
+
|
|
800
|
+
// A dependency is "used" if any import specifier starts with the package name
|
|
801
|
+
// (handles sub-paths like 'lodash/merge', 'date-fns/format', etc.)
|
|
802
|
+
const isUsed = [...allLiteralImports].some(
|
|
803
|
+
imp => imp === depName || imp.startsWith(depName + '/')
|
|
804
|
+
)
|
|
805
|
+
if (!isUsed) unusedDeps.push(depName)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (unusedDeps.length > 0) {
|
|
809
|
+
const pkgIssues: DriftIssue[] = unusedDeps.map(dep => ({
|
|
810
|
+
rule: 'unused-dependency',
|
|
811
|
+
severity: RULE_WEIGHTS['unused-dependency'].severity,
|
|
812
|
+
message: `'${dep}' is in package.json but never imported`,
|
|
813
|
+
line: 1,
|
|
814
|
+
column: 1,
|
|
815
|
+
snippet: `"${dep}"`,
|
|
816
|
+
}))
|
|
817
|
+
|
|
818
|
+
reports.push({
|
|
819
|
+
path: pkgPath,
|
|
820
|
+
issues: pkgIssues,
|
|
821
|
+
score: calculateScore(pkgIssues),
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Phase 3: circular-dependency — DFS cycle detection
|
|
827
|
+
function findCycles(graph: Map<string, Set<string>>): Array<string[]> {
|
|
828
|
+
const visited = new Set<string>()
|
|
829
|
+
const inStack = new Set<string>()
|
|
830
|
+
const cycles: Array<string[]> = []
|
|
831
|
+
|
|
832
|
+
function dfs(node: string, stack: string[]): void {
|
|
833
|
+
visited.add(node)
|
|
834
|
+
inStack.add(node)
|
|
835
|
+
stack.push(node)
|
|
836
|
+
|
|
837
|
+
for (const neighbor of graph.get(node) ?? []) {
|
|
838
|
+
if (!visited.has(neighbor)) {
|
|
839
|
+
dfs(neighbor, stack)
|
|
840
|
+
} else if (inStack.has(neighbor)) {
|
|
841
|
+
// Found a cycle — extract the cycle portion from the stack
|
|
842
|
+
const cycleStart = stack.indexOf(neighbor)
|
|
843
|
+
cycles.push(stack.slice(cycleStart))
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
stack.pop()
|
|
848
|
+
inStack.delete(node)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
for (const node of graph.keys()) {
|
|
852
|
+
if (!visited.has(node)) {
|
|
853
|
+
dfs(node, [])
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return cycles
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const cycles = findCycles(importGraph)
|
|
861
|
+
|
|
862
|
+
// De-duplicate: each unique cycle (regardless of starting node) reported once per file
|
|
863
|
+
const reportedCycleKeys = new Set<string>()
|
|
864
|
+
|
|
865
|
+
for (const cycle of cycles) {
|
|
866
|
+
const cycleKey = [...cycle].sort().join('|')
|
|
867
|
+
if (reportedCycleKeys.has(cycleKey)) continue
|
|
868
|
+
reportedCycleKeys.add(cycleKey)
|
|
869
|
+
|
|
870
|
+
// Report on the first file in the cycle
|
|
871
|
+
const firstFile = cycle[0]
|
|
872
|
+
const report = reportByPath.get(firstFile)
|
|
873
|
+
if (!report) continue
|
|
874
|
+
|
|
875
|
+
const cycleDisplay = cycle
|
|
876
|
+
.map(p => path.basename(p))
|
|
877
|
+
.concat(path.basename(cycle[0])) // close the loop visually: A → B → C → A
|
|
878
|
+
.join(' → ')
|
|
879
|
+
|
|
880
|
+
const issue: DriftIssue = {
|
|
881
|
+
rule: 'circular-dependency',
|
|
882
|
+
severity: RULE_WEIGHTS['circular-dependency'].severity,
|
|
883
|
+
message: `Circular dependency detected: ${cycleDisplay}`,
|
|
884
|
+
line: 1,
|
|
885
|
+
column: 1,
|
|
886
|
+
snippet: cycleDisplay,
|
|
887
|
+
}
|
|
888
|
+
report.issues.push(issue)
|
|
889
|
+
report.score = calculateScore(report.issues)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ── Phase 3b: layer-violation ──────────────────────────────────────────
|
|
893
|
+
if (config?.layers && config.layers.length > 0) {
|
|
894
|
+
const { layers } = config
|
|
895
|
+
|
|
896
|
+
function getLayer(filePath: string): LayerDefinition | undefined {
|
|
897
|
+
const rel = filePath.replace(/\\/g, '/')
|
|
898
|
+
return layers.find(layer =>
|
|
899
|
+
layer.patterns.some(pattern => {
|
|
900
|
+
const regexStr = pattern
|
|
901
|
+
.replace(/\\/g, '/')
|
|
902
|
+
.replace(/[.+^${}()|[\]]/g, '\\$&')
|
|
903
|
+
.replace(/\*\*/g, '###DOUBLESTAR###')
|
|
904
|
+
.replace(/\*/g, '[^/]*')
|
|
905
|
+
.replace(/###DOUBLESTAR###/g, '.*')
|
|
906
|
+
return new RegExp(`^${regexStr}`).test(rel)
|
|
907
|
+
})
|
|
908
|
+
)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
for (const [filePath, imports] of importGraph.entries()) {
|
|
912
|
+
const fileLayer = getLayer(filePath)
|
|
913
|
+
if (!fileLayer) continue
|
|
914
|
+
|
|
915
|
+
for (const importedPath of imports) {
|
|
916
|
+
const importedLayer = getLayer(importedPath)
|
|
917
|
+
if (!importedLayer) continue
|
|
918
|
+
if (importedLayer.name === fileLayer.name) continue
|
|
919
|
+
|
|
920
|
+
if (!fileLayer.canImportFrom.includes(importedLayer.name)) {
|
|
921
|
+
const report = reportByPath.get(filePath)
|
|
922
|
+
if (report) {
|
|
923
|
+
const weight = RULE_WEIGHTS['layer-violation']?.weight ?? 5
|
|
924
|
+
report.issues.push({
|
|
925
|
+
rule: 'layer-violation',
|
|
926
|
+
severity: 'error',
|
|
927
|
+
message: `Layer '${fileLayer.name}' must not import from layer '${importedLayer.name}'`,
|
|
928
|
+
line: 1,
|
|
929
|
+
column: 1,
|
|
930
|
+
snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
|
|
931
|
+
})
|
|
932
|
+
report.score = Math.min(100, report.score + weight)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ── Phase 3c: cross-boundary-import ────────────────────────────────────
|
|
940
|
+
if (config?.modules && config.modules.length > 0) {
|
|
941
|
+
const { modules } = config
|
|
942
|
+
|
|
943
|
+
function getModule(filePath: string): ModuleBoundary | undefined {
|
|
944
|
+
const rel = filePath.replace(/\\/g, '/')
|
|
945
|
+
return modules.find(m => rel.startsWith(m.root.replace(/\\/g, '/')))
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
for (const [filePath, imports] of importGraph.entries()) {
|
|
949
|
+
const fileModule = getModule(filePath)
|
|
950
|
+
if (!fileModule) continue
|
|
951
|
+
|
|
952
|
+
for (const importedPath of imports) {
|
|
953
|
+
const importedModule = getModule(importedPath)
|
|
954
|
+
if (!importedModule) continue
|
|
955
|
+
if (importedModule.name === fileModule.name) continue
|
|
956
|
+
|
|
957
|
+
const allowedImports = fileModule.allowedExternalImports ?? []
|
|
958
|
+
const relImported = importedPath.replace(/\\/g, '/')
|
|
959
|
+
const isAllowed = allowedImports.some(allowed =>
|
|
960
|
+
relImported.startsWith(allowed.replace(/\\/g, '/'))
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
if (!isAllowed) {
|
|
964
|
+
const report = reportByPath.get(filePath)
|
|
965
|
+
if (report) {
|
|
966
|
+
const weight = RULE_WEIGHTS['cross-boundary-import']?.weight ?? 5
|
|
967
|
+
report.issues.push({
|
|
968
|
+
rule: 'cross-boundary-import',
|
|
969
|
+
severity: 'warning',
|
|
970
|
+
message: `Module '${fileModule.name}' must not import from module '${importedModule.name}'`,
|
|
971
|
+
line: 1,
|
|
972
|
+
column: 1,
|
|
973
|
+
snippet: `import from '${path.relative(targetPath, importedPath).replace(/\\/g, '/')}'`,
|
|
974
|
+
})
|
|
975
|
+
report.score = Math.min(100, report.score + weight)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return reports
|
|
298
983
|
}
|