@eduardbar/drift 1.0.0 → 1.1.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 (99) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +3 -1
  4. package/AGENTS.md +53 -11
  5. package/README.md +68 -1
  6. package/dist/analyzer.d.ts +6 -2
  7. package/dist/analyzer.js +116 -3
  8. package/dist/badge.js +40 -22
  9. package/dist/ci.js +32 -18
  10. package/dist/cli.js +83 -5
  11. package/dist/diff.d.ts +0 -7
  12. package/dist/diff.js +26 -25
  13. package/dist/fix.d.ts +4 -0
  14. package/dist/fix.js +59 -47
  15. package/dist/git/trend.js +1 -0
  16. package/dist/git.d.ts +0 -9
  17. package/dist/git.js +25 -19
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +3 -0
  20. package/dist/map.d.ts +3 -0
  21. package/dist/map.js +103 -0
  22. package/dist/metrics.d.ts +4 -0
  23. package/dist/metrics.js +176 -0
  24. package/dist/plugins.d.ts +6 -0
  25. package/dist/plugins.js +74 -0
  26. package/dist/printer.js +20 -0
  27. package/dist/report.js +34 -0
  28. package/dist/reporter.js +85 -2
  29. package/dist/review.d.ts +15 -0
  30. package/dist/review.js +80 -0
  31. package/dist/rules/comments.d.ts +4 -0
  32. package/dist/rules/comments.js +45 -0
  33. package/dist/rules/complexity.d.ts +4 -0
  34. package/dist/rules/complexity.js +51 -0
  35. package/dist/rules/coupling.d.ts +4 -0
  36. package/dist/rules/coupling.js +19 -0
  37. package/dist/rules/magic.d.ts +4 -0
  38. package/dist/rules/magic.js +33 -0
  39. package/dist/rules/nesting.d.ts +5 -0
  40. package/dist/rules/nesting.js +82 -0
  41. package/dist/rules/phase0-basic.js +14 -7
  42. package/dist/rules/phase1-complexity.d.ts +6 -30
  43. package/dist/rules/phase1-complexity.js +7 -276
  44. package/dist/rules/phase2-crossfile.d.ts +0 -4
  45. package/dist/rules/phase2-crossfile.js +52 -39
  46. package/dist/rules/phase3-arch.d.ts +0 -8
  47. package/dist/rules/phase3-arch.js +26 -23
  48. package/dist/rules/phase3-configurable.d.ts +6 -0
  49. package/dist/rules/phase3-configurable.js +97 -0
  50. package/dist/rules/phase8-semantic.d.ts +0 -5
  51. package/dist/rules/phase8-semantic.js +30 -29
  52. package/dist/rules/promise.d.ts +4 -0
  53. package/dist/rules/promise.js +24 -0
  54. package/dist/snapshot.d.ts +19 -0
  55. package/dist/snapshot.js +119 -0
  56. package/dist/types.d.ts +69 -0
  57. package/dist/utils.d.ts +2 -1
  58. package/dist/utils.js +1 -0
  59. package/docs/AGENTS.md +146 -0
  60. package/docs/PRD.md +208 -0
  61. package/package.json +1 -1
  62. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  63. package/packages/vscode-drift/package.json +1 -1
  64. package/packages/vscode-drift/src/analyzer.ts +2 -0
  65. package/packages/vscode-drift/src/extension.ts +87 -63
  66. package/packages/vscode-drift/src/statusbar.ts +13 -5
  67. package/packages/vscode-drift/src/treeview.ts +2 -0
  68. package/src/analyzer.ts +144 -12
  69. package/src/badge.ts +38 -16
  70. package/src/ci.ts +38 -17
  71. package/src/cli.ts +96 -6
  72. package/src/diff.ts +36 -30
  73. package/src/fix.ts +77 -53
  74. package/src/git/trend.ts +3 -2
  75. package/src/git.ts +31 -22
  76. package/src/index.ts +16 -1
  77. package/src/map.ts +117 -0
  78. package/src/metrics.ts +200 -0
  79. package/src/plugins.ts +76 -0
  80. package/src/printer.ts +20 -0
  81. package/src/report.ts +35 -0
  82. package/src/reporter.ts +95 -2
  83. package/src/review.ts +98 -0
  84. package/src/rules/comments.ts +56 -0
  85. package/src/rules/complexity.ts +57 -0
  86. package/src/rules/coupling.ts +23 -0
  87. package/src/rules/magic.ts +38 -0
  88. package/src/rules/nesting.ts +88 -0
  89. package/src/rules/phase0-basic.ts +14 -7
  90. package/src/rules/phase1-complexity.ts +8 -302
  91. package/src/rules/phase2-crossfile.ts +68 -40
  92. package/src/rules/phase3-arch.ts +34 -30
  93. package/src/rules/phase3-configurable.ts +132 -0
  94. package/src/rules/phase8-semantic.ts +33 -29
  95. package/src/rules/promise.ts +29 -0
  96. package/src/snapshot.ts +175 -0
  97. package/src/types.ts +75 -1
  98. package/src/utils.ts +3 -1
  99. package/tests/new-features.test.ts +153 -0
package/src/fix.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, statSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { analyzeProject, analyzeFile } from './analyzer.js'
4
- import type { DriftIssue, DriftConfig } from './types.js'
4
+ import type { DriftIssue, DriftConfig, FileReport } from './types.js'
5
5
  import { Project } from 'ts-morph'
6
6
 
7
7
  export interface FixResult {
@@ -10,6 +10,8 @@ export interface FixResult {
10
10
  line: number
11
11
  description: string
12
12
  applied: boolean
13
+ before?: string
14
+ after?: string
13
15
  }
14
16
 
15
17
  const FIXABLE_RULES = new Set(['debug-leftover', 'catch-swallow'])
@@ -73,15 +75,84 @@ function applyFixToLines(
73
75
  return null
74
76
  }
75
77
 
78
+ function collectFixableIssues(
79
+ fileReports: FileReport[],
80
+ options?: { rule?: string }
81
+ ): Map<string, DriftIssue[]> {
82
+ const fixableByFile = new Map<string, DriftIssue[]>()
83
+
84
+ for (const report of fileReports) {
85
+ const fixableIssues = report.issues.filter(issue => {
86
+ if (!isFixable(issue)) return false
87
+ if (options?.rule && issue.rule !== options.rule) return false
88
+ return true
89
+ })
90
+
91
+ if (fixableIssues.length > 0) {
92
+ fixableByFile.set(report.path, fixableIssues)
93
+ }
94
+ }
95
+
96
+ return fixableByFile
97
+ }
98
+
99
+ function processFile(
100
+ filePath: string,
101
+ issues: DriftIssue[],
102
+ dryRun: boolean
103
+ ): FixResult[] {
104
+ const content = readFileSync(filePath, 'utf8')
105
+ let lines = content.split('\n')
106
+ const results: FixResult[] = []
107
+
108
+ const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
109
+
110
+ for (const issue of sortedIssues) {
111
+ const before = lines[issue.line - 1]?.trim() ?? ''
112
+ const fixResult = applyFixToLines(lines, issue)
113
+
114
+ if (fixResult) {
115
+ const after = fixResult.newLines[issue.line - 1]?.trim() ?? ''
116
+ results.push({
117
+ file: filePath,
118
+ rule: issue.rule,
119
+ line: issue.line,
120
+ description: fixResult.description,
121
+ applied: true,
122
+ before,
123
+ after,
124
+ })
125
+ lines = fixResult.newLines
126
+ } else {
127
+ results.push({
128
+ file: filePath,
129
+ rule: issue.rule,
130
+ line: issue.line,
131
+ description: 'no fix available',
132
+ applied: false,
133
+ })
134
+ }
135
+ }
136
+
137
+ if (!dryRun) {
138
+ writeFileSync(filePath, lines.join('\n'), 'utf8')
139
+ }
140
+
141
+ return results
142
+ }
143
+
76
144
  export async function applyFixes(
77
145
  targetPath: string,
78
146
  config?: DriftConfig,
79
- options?: { rule?: string; dryRun?: boolean }
147
+ options?: { rule?: string; dryRun?: boolean; write?: boolean; preview?: boolean }
80
148
  ): Promise<FixResult[]> {
81
149
  const resolvedPath = resolve(targetPath)
82
- const dryRun = options?.dryRun ?? false
150
+ const dryRun = options?.write
151
+ ? false
152
+ : options?.preview || options?.dryRun
153
+ ? true
154
+ : false
83
155
 
84
- // Determine if target is a file or directory
85
156
  let fileReports
86
157
  const stat = statSync(resolvedPath)
87
158
 
@@ -96,58 +167,11 @@ export async function applyFixes(
96
167
  fileReports = analyzeProject(resolvedPath, config)
97
168
  }
98
169
 
99
- // Collect fixable issues, optionally filtered by rule
100
- const fixableByFile = new Map<string, DriftIssue[]>()
101
-
102
- for (const report of fileReports) {
103
- const fixableIssues = report.issues.filter(issue => {
104
- if (!isFixable(issue)) return false
105
- if (options?.rule && issue.rule !== options.rule) return false
106
- return true
107
- })
108
-
109
- if (fixableIssues.length > 0) {
110
- fixableByFile.set(report.path, fixableIssues)
111
- }
112
- }
113
-
170
+ const fixableByFile = collectFixableIssues(fileReports, options)
114
171
  const results: FixResult[] = []
115
172
 
116
173
  for (const [filePath, issues] of fixableByFile) {
117
- const content = readFileSync(filePath, 'utf8')
118
- let lines = content.split('\n')
119
-
120
- // Sort issues by line descending to avoid line number drift after fixes
121
- const sortedIssues = [...issues].sort((a, b) => b.line - a.line)
122
-
123
- // Track line offset caused by deletions (debug-leftover removes lines)
124
- // We process top-to-bottom after sorting descending, so no offset needed per issue
125
- for (const issue of sortedIssues) {
126
- const fixResult = applyFixToLines(lines, issue)
127
-
128
- if (fixResult) {
129
- results.push({
130
- file: filePath,
131
- rule: issue.rule,
132
- line: issue.line,
133
- description: fixResult.description,
134
- applied: true,
135
- })
136
- lines = fixResult.newLines
137
- } else {
138
- results.push({
139
- file: filePath,
140
- rule: issue.rule,
141
- line: issue.line,
142
- description: 'no fix available',
143
- applied: false,
144
- })
145
- }
146
- }
147
-
148
- if (!dryRun) {
149
- writeFileSync(filePath, lines.join('\n'), 'utf8')
150
- }
174
+ results.push(...processFile(filePath, issues, dryRun))
151
175
  }
152
176
 
153
177
  return results
package/src/git/trend.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { SourceFile } from 'ts-morph'
2
- import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport, BlameAttribution } from '../types.js'
1
+ // drift-ignore-file
2
+
3
+ import type { FileReport, DriftConfig, TrendDataPoint, DriftTrendReport } from '../types.js'
3
4
  import { assertGitRepo, analyzeHistoricalCommits } from './helpers.js'
4
5
  import { buildReport } from '../reporter.js'
5
6
 
package/src/git.ts CHANGED
@@ -13,22 +13,23 @@ import { randomUUID } from 'node:crypto'
13
13
  *
14
14
  * Throws if the directory is not a git repo or the ref is invalid.
15
15
  */
16
- export function extractFilesAtRef(projectPath: string, ref: string): string {
17
- // Verify git repo
16
+ function verifyGitRepo(projectPath: string): void {
18
17
  try {
19
18
  execSync('git rev-parse --git-dir', { cwd: projectPath, stdio: 'pipe' })
20
19
  } catch {
21
20
  throw new Error(`Not a git repository: ${projectPath}`)
22
21
  }
22
+ }
23
23
 
24
- // Verify ref exists
24
+ function verifyRefExists(projectPath: string, ref: string): void {
25
25
  try {
26
26
  execSync(`git rev-parse --verify ${ref}`, { cwd: projectPath, stdio: 'pipe' })
27
27
  } catch {
28
28
  throw new Error(`Invalid git ref: '${ref}'. Run 'git log --oneline' to see available commits.`)
29
29
  }
30
+ }
30
31
 
31
- // List all .ts files tracked at this ref (excluding .d.ts)
32
+ function listTsFilesAtRef(projectPath: string, ref: string): string[] {
32
33
  let fileList: string
33
34
  try {
34
35
  fileList = execSync(
@@ -39,36 +40,44 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
39
40
  throw new Error(`Failed to list files at ref '${ref}'`)
40
41
  }
41
42
 
42
- const tsFiles = fileList
43
+ return fileList
43
44
  .split('\n')
44
45
  .map(f => f.trim())
45
46
  .filter(f => (f.endsWith('.ts') || f.endsWith('.tsx') || f.endsWith('.js') || f.endsWith('.jsx')) && !f.endsWith('.d.ts'))
47
+ }
48
+
49
+ function extractFile(projectPath: string, ref: string, filePath: string, tempDir: string): void {
50
+ let content: string
51
+ try {
52
+ content = execSync(
53
+ `git show ${ref}:${filePath}`,
54
+ { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
55
+ )
56
+ } catch {
57
+ return
58
+ }
59
+
60
+ const destPath = join(tempDir, filePath.split('/').join(sep))
61
+ const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
62
+ mkdirSync(destDir, { recursive: true })
63
+ writeFileSync(destPath, content, 'utf-8')
64
+ }
65
+
66
+ export function extractFilesAtRef(projectPath: string, ref: string): string {
67
+ verifyGitRepo(projectPath)
68
+ verifyRefExists(projectPath, ref)
69
+
70
+ const tsFiles = listTsFilesAtRef(projectPath, ref)
46
71
 
47
72
  if (tsFiles.length === 0) {
48
73
  throw new Error(`No TypeScript files found at ref '${ref}'`)
49
74
  }
50
75
 
51
- // Create temp directory
52
76
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
53
77
  mkdirSync(tempDir, { recursive: true })
54
78
 
55
- // Extract each file
56
79
  for (const filePath of tsFiles) {
57
- let content: string
58
- try {
59
- content = execSync(
60
- `git show ${ref}:${filePath}`,
61
- { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }
62
- )
63
- } catch {
64
- // File may not exist at this ref — skip
65
- continue
66
- }
67
-
68
- const destPath = join(tempDir, filePath.split('/').join(sep))
69
- const destDir = destPath.substring(0, destPath.lastIndexOf(sep))
70
- mkdirSync(destDir, { recursive: true })
71
- writeFileSync(destPath, content, 'utf-8')
80
+ extractFile(projectPath, ref, filePath, tempDir)
72
81
  }
73
82
 
74
83
  return tempDir
package/src/index.ts CHANGED
@@ -1,4 +1,19 @@
1
1
  export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
3
  export { computeDiff } from './diff.js'
4
- export type { DriftReport, FileReport, DriftIssue, DriftDiff, FileDiff, DriftConfig } from './types.js'
4
+ export { generateReview, formatReviewMarkdown } from './review.js'
5
+ export { generateArchitectureMap, generateArchitectureSvg } from './map.js'
6
+ export type {
7
+ DriftReport,
8
+ FileReport,
9
+ DriftIssue,
10
+ DriftDiff,
11
+ FileDiff,
12
+ DriftConfig,
13
+ RepoQualityScore,
14
+ MaintenanceRiskMetrics,
15
+ DriftPlugin,
16
+ DriftPluginRule,
17
+ } from './types.js'
18
+ export { loadHistory, saveSnapshot } from './snapshot.js'
19
+ export type { SnapshotEntry, SnapshotHistory } from './snapshot.js'
package/src/map.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { resolve, relative } from 'node:path'
3
+ import { Project } from 'ts-morph'
4
+
5
+ interface LayerNode {
6
+ name: string
7
+ files: Set<string>
8
+ }
9
+
10
+ function detectLayer(relPath: string): string {
11
+ const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
12
+ const first = normalized.split('/')[0] || 'root'
13
+ return first
14
+ }
15
+
16
+ function esc(value: string): string {
17
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
18
+ }
19
+
20
+ export function generateArchitectureSvg(targetPath: string): string {
21
+ const project = new Project({
22
+ skipAddingFilesFromTsConfig: true,
23
+ compilerOptions: { allowJs: true, jsx: 1 },
24
+ })
25
+
26
+ project.addSourceFilesAtPaths([
27
+ `${targetPath}/**/*.ts`,
28
+ `${targetPath}/**/*.tsx`,
29
+ `${targetPath}/**/*.js`,
30
+ `${targetPath}/**/*.jsx`,
31
+ `!${targetPath}/**/node_modules/**`,
32
+ `!${targetPath}/**/dist/**`,
33
+ `!${targetPath}/**/.next/**`,
34
+ `!${targetPath}/**/*.d.ts`,
35
+ ])
36
+
37
+ const layers = new Map<string, LayerNode>()
38
+ const edges = new Map<string, number>()
39
+
40
+ for (const file of project.getSourceFiles()) {
41
+ const rel = relative(targetPath, file.getFilePath()).replace(/\\/g, '/')
42
+ const layerName = detectLayer(rel)
43
+ if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
44
+ layers.get(layerName)!.files.add(rel)
45
+
46
+ for (const decl of file.getImportDeclarations()) {
47
+ const imported = decl.getModuleSpecifierSourceFile()
48
+ if (!imported) continue
49
+ const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
50
+ const importedLayer = detectLayer(importedRel)
51
+ if (importedLayer === layerName) continue
52
+ const key = `${layerName}->${importedLayer}`
53
+ edges.set(key, (edges.get(key) ?? 0) + 1)
54
+ }
55
+ }
56
+
57
+ const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
58
+ const width = 960
59
+ const rowHeight = 90
60
+ const height = Math.max(180, layerList.length * rowHeight + 120)
61
+ const boxWidth = 240
62
+ const boxHeight = 50
63
+ const left = 100
64
+
65
+ const boxes = layerList.map((layer, index) => {
66
+ const y = 60 + index * rowHeight
67
+ return {
68
+ ...layer,
69
+ x: left,
70
+ y,
71
+ }
72
+ })
73
+
74
+ const boxByName = new Map(boxes.map((box) => [box.name, box]))
75
+
76
+ const lines = [...edges.entries()].map(([key, count]) => {
77
+ const [from, to] = key.split('->')
78
+ const a = boxByName.get(from)
79
+ const b = boxByName.get(to)
80
+ if (!a || !b) return ''
81
+ const startX = a.x + boxWidth
82
+ const startY = a.y + boxHeight / 2
83
+ const endX = b.x
84
+ const endY = b.y + boxHeight / 2
85
+ return `
86
+ <line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)" />
87
+ <text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${count}</text>`
88
+ }).join('')
89
+
90
+ const nodes = boxes.map((box) => `
91
+ <g>
92
+ <rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
93
+ <text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
94
+ <text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
95
+ </g>`).join('')
96
+
97
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
98
+ <defs>
99
+ <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
100
+ <path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
101
+ </marker>
102
+ </defs>
103
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
104
+ <text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
105
+ <text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
106
+ ${lines}
107
+ ${nodes}
108
+ </svg>`
109
+ }
110
+
111
+ export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg'): string {
112
+ const resolvedTarget = resolve(targetPath)
113
+ const svg = generateArchitectureSvg(resolvedTarget)
114
+ const outPath = resolve(outputFile)
115
+ writeFileSync(outPath, svg, 'utf8')
116
+ return outPath
117
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { execSync } from 'node:child_process'
3
+ import { join, relative } from 'node:path'
4
+ import type { DriftIssue, DriftReport, FileReport, MaintenanceRiskMetrics, RepoQualityScore, RiskHotspot } from './types.js'
5
+
6
+ const ARCH_RULES = new Set([
7
+ 'circular-dependency',
8
+ 'layer-violation',
9
+ 'cross-boundary-import',
10
+ 'controller-no-db',
11
+ 'service-no-http',
12
+ ])
13
+
14
+ const COMPLEXITY_RULES = new Set([
15
+ 'large-file',
16
+ 'large-function',
17
+ 'high-complexity',
18
+ 'deep-nesting',
19
+ 'too-many-params',
20
+ 'max-function-lines',
21
+ ])
22
+
23
+ const AI_RULES = new Set([
24
+ 'over-commented',
25
+ 'hardcoded-config',
26
+ 'inconsistent-error-handling',
27
+ 'unnecessary-abstraction',
28
+ 'naming-inconsistency',
29
+ 'comment-contradiction',
30
+ 'ai-code-smell',
31
+ ])
32
+
33
+ function clamp(value: number, min: number, max: number): number {
34
+ return Math.max(min, Math.min(max, value))
35
+ }
36
+
37
+ function listFilesRecursively(root: string): string[] {
38
+ if (!existsSync(root)) return []
39
+ const out: string[] = []
40
+ const stack = [root]
41
+ while (stack.length > 0) {
42
+ const current = stack.pop()!
43
+ const entries = readdirSync(current)
44
+ for (const entry of entries) {
45
+ const full = join(current, entry)
46
+ const stat = statSync(full)
47
+ if (stat.isDirectory()) {
48
+ if (entry === 'node_modules' || entry === 'dist' || entry === '.git' || entry === '.next' || entry === 'build') {
49
+ continue
50
+ }
51
+ stack.push(full)
52
+ } else {
53
+ out.push(full)
54
+ }
55
+ }
56
+ }
57
+ return out
58
+ }
59
+
60
+ function hasNearbyTest(targetPath: string, filePath: string): boolean {
61
+ const rel = relative(targetPath, filePath).replace(/\\/g, '/')
62
+ const noExt = rel.replace(/\.[^.]+$/, '')
63
+ const candidates = [
64
+ `${noExt}.test.ts`,
65
+ `${noExt}.test.tsx`,
66
+ `${noExt}.spec.ts`,
67
+ `${noExt}.spec.tsx`,
68
+ `${noExt}.test.js`,
69
+ `${noExt}.spec.js`,
70
+ ]
71
+ return candidates.some((candidate) => existsSync(join(targetPath, candidate)))
72
+ }
73
+
74
+ function getCommitTouchCount(targetPath: string, filePath: string): number {
75
+ try {
76
+ const rel = relative(targetPath, filePath).replace(/\\/g, '/')
77
+ const output = execSync(`git rev-list --count HEAD -- "${rel}"`, {
78
+ cwd: targetPath,
79
+ encoding: 'utf8',
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ }).trim()
82
+ return Number(output) || 0
83
+ } catch {
84
+ return 0
85
+ }
86
+ }
87
+
88
+ function qualityFromIssues(totalFiles: number, issues: DriftIssue[], rules: Set<string>): number {
89
+ const count = issues.filter((issue) => rules.has(issue.rule)).length
90
+ if (totalFiles === 0) return 100
91
+ return clamp(100 - Math.round((count / totalFiles) * 20), 0, 100)
92
+ }
93
+
94
+ export function computeRepoQuality(targetPath: string, files: FileReport[]): RepoQualityScore {
95
+ const allIssues = files.flatMap((file) => file.issues)
96
+ const sourceFiles = files.filter((file) => !file.path.endsWith('package.json'))
97
+ const totalFiles = Math.max(sourceFiles.length, 1)
98
+
99
+ const testingCandidates = listFilesRecursively(targetPath).filter((filePath) =>
100
+ /\.(ts|tsx|js|jsx)$/.test(filePath) &&
101
+ !/\.test\.|\.spec\./.test(filePath) &&
102
+ !filePath.includes('node_modules')
103
+ )
104
+
105
+ const withoutTests = testingCandidates.filter((filePath) => !hasNearbyTest(targetPath, filePath)).length
106
+ const testing = testingCandidates.length === 0
107
+ ? 100
108
+ : clamp(100 - Math.round((withoutTests / testingCandidates.length) * 100), 0, 100)
109
+
110
+ const dimensions = {
111
+ architecture: qualityFromIssues(totalFiles, allIssues, ARCH_RULES),
112
+ complexity: qualityFromIssues(totalFiles, allIssues, COMPLEXITY_RULES),
113
+ 'ai-patterns': qualityFromIssues(totalFiles, allIssues, AI_RULES),
114
+ testing,
115
+ }
116
+
117
+ const overall = Math.round((
118
+ dimensions.architecture +
119
+ dimensions.complexity +
120
+ dimensions['ai-patterns'] +
121
+ dimensions.testing
122
+ ) / 4)
123
+
124
+ return { overall, dimensions }
125
+ }
126
+
127
+ export function computeMaintenanceRisk(report: DriftReport): MaintenanceRiskMetrics {
128
+ const allFiles = report.files
129
+ const hotspots: RiskHotspot[] = allFiles
130
+ .map((file) => {
131
+ const complexityIssues = file.issues.filter((issue) =>
132
+ issue.rule === 'high-complexity' ||
133
+ issue.rule === 'deep-nesting' ||
134
+ issue.rule === 'large-function' ||
135
+ issue.rule === 'max-function-lines'
136
+ ).length
137
+
138
+ const changeFrequency = getCommitTouchCount(report.targetPath, file.path)
139
+ const hasTests = hasNearbyTest(report.targetPath, file.path)
140
+ const reasons: string[] = []
141
+ let risk = 0
142
+
143
+ if (complexityIssues > 0) {
144
+ risk += Math.min(40, complexityIssues * 10)
145
+ reasons.push('high complexity signals')
146
+ }
147
+ if (!hasTests) {
148
+ risk += 25
149
+ reasons.push('no nearby tests')
150
+ }
151
+ if (changeFrequency >= 8) {
152
+ risk += 20
153
+ reasons.push('frequently changed file')
154
+ }
155
+ if (file.score >= 50) {
156
+ risk += 15
157
+ reasons.push('high drift score')
158
+ }
159
+
160
+ return {
161
+ file: file.path,
162
+ driftScore: file.score,
163
+ complexityIssues,
164
+ hasNearbyTests: hasTests,
165
+ changeFrequency,
166
+ risk: clamp(risk, 0, 100),
167
+ reasons,
168
+ }
169
+ })
170
+ .filter((hotspot) => hotspot.risk > 0)
171
+ .sort((a, b) => b.risk - a.risk)
172
+ .slice(0, 10)
173
+
174
+ const highComplexityFiles = hotspots.filter((hotspot) => hotspot.complexityIssues > 0).length
175
+ const filesWithoutNearbyTests = hotspots.filter((hotspot) => !hotspot.hasNearbyTests).length
176
+ const frequentChangeFiles = hotspots.filter((hotspot) => hotspot.changeFrequency >= 8).length
177
+
178
+ const score = hotspots.length === 0
179
+ ? 0
180
+ : Math.round(hotspots.reduce((sum, hotspot) => sum + hotspot.risk, 0) / hotspots.length)
181
+
182
+ const level = score >= 75
183
+ ? 'critical'
184
+ : score >= 55
185
+ ? 'high'
186
+ : score >= 30
187
+ ? 'medium'
188
+ : 'low'
189
+
190
+ return {
191
+ score,
192
+ level,
193
+ hotspots,
194
+ signals: {
195
+ highComplexityFiles,
196
+ filesWithoutNearbyTests,
197
+ frequentChangeFiles,
198
+ },
199
+ }
200
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { isAbsolute, resolve } from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import type { DriftPlugin, PluginLoadError, LoadedPlugin } from './types.js'
5
+
6
+ const require = createRequire(import.meta.url)
7
+
8
+ function isPluginShape(value: unknown): value is DriftPlugin {
9
+ if (!value || typeof value !== 'object') return false
10
+ const candidate = value as Partial<DriftPlugin>
11
+ if (typeof candidate.name !== 'string') return false
12
+ if (!Array.isArray(candidate.rules)) return false
13
+ return candidate.rules.every((rule) =>
14
+ rule &&
15
+ typeof rule === 'object' &&
16
+ typeof rule.name === 'string' &&
17
+ typeof rule.detect === 'function'
18
+ )
19
+ }
20
+
21
+ function normalizePluginExport(mod: unknown): DriftPlugin | undefined {
22
+ if (isPluginShape(mod)) return mod
23
+ if (mod && typeof mod === 'object' && 'default' in mod) {
24
+ const maybeDefault = (mod as { default?: unknown }).default
25
+ if (isPluginShape(maybeDefault)) return maybeDefault
26
+ }
27
+ return undefined
28
+ }
29
+
30
+ function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
31
+ if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
32
+ const abs = isAbsolute(pluginId) ? pluginId : resolve(projectRoot, pluginId)
33
+ if (existsSync(abs)) return abs
34
+ if (existsSync(`${abs}.js`)) return `${abs}.js`
35
+ if (existsSync(`${abs}.cjs`)) return `${abs}.cjs`
36
+ if (existsSync(`${abs}.mjs`)) return `${abs}.mjs`
37
+ if (existsSync(`${abs}.ts`)) return `${abs}.ts`
38
+ return abs
39
+ }
40
+ return pluginId
41
+ }
42
+
43
+ export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
44
+ plugins: LoadedPlugin[]
45
+ errors: PluginLoadError[]
46
+ } {
47
+ if (!pluginIds || pluginIds.length === 0) {
48
+ return { plugins: [], errors: [] }
49
+ }
50
+
51
+ const loaded: LoadedPlugin[] = []
52
+ const errors: PluginLoadError[] = []
53
+
54
+ for (const pluginId of pluginIds) {
55
+ const resolved = resolvePluginSpecifier(projectRoot, pluginId)
56
+ try {
57
+ const mod = require(resolved)
58
+ const plugin = normalizePluginExport(mod)
59
+ if (!plugin) {
60
+ errors.push({
61
+ pluginId,
62
+ message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
63
+ })
64
+ continue
65
+ }
66
+ loaded.push({ id: pluginId, plugin })
67
+ } catch (error) {
68
+ errors.push({
69
+ pluginId,
70
+ message: error instanceof Error ? error.message : String(error),
71
+ })
72
+ }
73
+ }
74
+
75
+ return { plugins: loaded, errors }
76
+ }