@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/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
- return project.getSourceFiles().map(analyzeFile)
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
  }