@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.
- package/.github/actions/drift-scan/README.md +61 -0
- package/.github/actions/drift-scan/action.yml +65 -0
- package/.github/workflows/publish-vscode.yml +3 -1
- package/AGENTS.md +53 -11
- package/README.md +68 -1
- package/dist/analyzer.d.ts +6 -2
- package/dist/analyzer.js +116 -3
- package/dist/badge.js +40 -22
- package/dist/ci.js +32 -18
- package/dist/cli.js +83 -5
- package/dist/diff.d.ts +0 -7
- package/dist/diff.js +26 -25
- package/dist/fix.d.ts +4 -0
- package/dist/fix.js +59 -47
- package/dist/git/trend.js +1 -0
- package/dist/git.d.ts +0 -9
- package/dist/git.js +25 -19
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -0
- package/dist/map.js +103 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +176 -0
- package/dist/plugins.d.ts +6 -0
- package/dist/plugins.js +74 -0
- package/dist/printer.js +20 -0
- package/dist/report.js +34 -0
- package/dist/reporter.js +85 -2
- package/dist/review.d.ts +15 -0
- package/dist/review.js +80 -0
- package/dist/rules/comments.d.ts +4 -0
- package/dist/rules/comments.js +45 -0
- package/dist/rules/complexity.d.ts +4 -0
- package/dist/rules/complexity.js +51 -0
- package/dist/rules/coupling.d.ts +4 -0
- package/dist/rules/coupling.js +19 -0
- package/dist/rules/magic.d.ts +4 -0
- package/dist/rules/magic.js +33 -0
- package/dist/rules/nesting.d.ts +5 -0
- package/dist/rules/nesting.js +82 -0
- package/dist/rules/phase0-basic.js +14 -7
- package/dist/rules/phase1-complexity.d.ts +6 -30
- package/dist/rules/phase1-complexity.js +7 -276
- package/dist/rules/phase2-crossfile.d.ts +0 -4
- package/dist/rules/phase2-crossfile.js +52 -39
- package/dist/rules/phase3-arch.d.ts +0 -8
- package/dist/rules/phase3-arch.js +26 -23
- package/dist/rules/phase3-configurable.d.ts +6 -0
- package/dist/rules/phase3-configurable.js +97 -0
- package/dist/rules/phase8-semantic.d.ts +0 -5
- package/dist/rules/phase8-semantic.js +30 -29
- package/dist/rules/promise.d.ts +4 -0
- package/dist/rules/promise.js +24 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +69 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +208 -0
- package/package.json +1 -1
- package/packages/eslint-plugin-drift/src/index.ts +1 -1
- package/packages/vscode-drift/package.json +1 -1
- package/packages/vscode-drift/src/analyzer.ts +2 -0
- package/packages/vscode-drift/src/extension.ts +87 -63
- package/packages/vscode-drift/src/statusbar.ts +13 -5
- package/packages/vscode-drift/src/treeview.ts +2 -0
- package/src/analyzer.ts +144 -12
- package/src/badge.ts +38 -16
- package/src/ci.ts +38 -17
- package/src/cli.ts +96 -6
- package/src/diff.ts +36 -30
- package/src/fix.ts +77 -53
- package/src/git/trend.ts +3 -2
- package/src/git.ts +31 -22
- package/src/index.ts +16 -1
- package/src/map.ts +117 -0
- package/src/metrics.ts +200 -0
- package/src/plugins.ts +76 -0
- package/src/printer.ts +20 -0
- package/src/report.ts +35 -0
- package/src/reporter.ts +95 -2
- package/src/review.ts +98 -0
- package/src/rules/comments.ts +56 -0
- package/src/rules/complexity.ts +57 -0
- package/src/rules/coupling.ts +23 -0
- package/src/rules/magic.ts +38 -0
- package/src/rules/nesting.ts +88 -0
- package/src/rules/phase0-basic.ts +14 -7
- package/src/rules/phase1-complexity.ts +8 -302
- package/src/rules/phase2-crossfile.ts +68 -40
- package/src/rules/phase3-arch.ts +34 -30
- package/src/rules/phase3-configurable.ts +132 -0
- package/src/rules/phase8-semantic.ts +33 -29
- package/src/rules/promise.ts +29 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +75 -1
- package/src/utils.ts +3 -1
- 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?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
+
}
|