@eduardbar/drift 1.0.0 → 1.2.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/.github/workflows/review-pr.yml +61 -0
- package/AGENTS.md +53 -11
- package/README.md +106 -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 +179 -6
- 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 +7 -1
- package/dist/index.js +4 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.js +191 -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/saas.d.ts +83 -0
- package/dist/saas.js +321 -0
- package/dist/snapshot.d.ts +19 -0
- package/dist/snapshot.js +119 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +1 -0
- package/docs/AGENTS.md +146 -0
- package/docs/PRD.md +157 -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/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +98 -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 +206 -7
- 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 +31 -1
- package/src/map.ts +219 -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/saas.ts +433 -0
- package/src/snapshot.ts +175 -0
- package/src/types.ts +81 -1
- package/src/utils.ts +3 -1
- package/tests/new-features.test.ts +180 -0
- package/tests/saas-foundation.test.ts +107 -0
package/src/map.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs'
|
|
2
|
+
import { resolve, relative } from 'node:path'
|
|
3
|
+
import { Project } from 'ts-morph'
|
|
4
|
+
import type { DriftConfig } from './types.js'
|
|
5
|
+
import { detectLayerViolations } from './rules/phase3-arch.js'
|
|
6
|
+
import { RULE_WEIGHTS } from './analyzer.js'
|
|
7
|
+
|
|
8
|
+
interface LayerNode {
|
|
9
|
+
name: string
|
|
10
|
+
files: Set<string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MapEdge {
|
|
14
|
+
key: string
|
|
15
|
+
from: string
|
|
16
|
+
to: string
|
|
17
|
+
count: number
|
|
18
|
+
kind: 'normal' | 'cycle' | 'violation'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function detectLayer(relPath: string): string {
|
|
22
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
23
|
+
const first = normalized.split('/')[0] || 'root'
|
|
24
|
+
return first
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function esc(value: string): string {
|
|
28
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateArchitectureSvg(targetPath: string, config?: DriftConfig): string {
|
|
32
|
+
const project = new Project({
|
|
33
|
+
skipAddingFilesFromTsConfig: true,
|
|
34
|
+
compilerOptions: { allowJs: true, jsx: 1 },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
project.addSourceFilesAtPaths([
|
|
38
|
+
`${targetPath}/**/*.ts`,
|
|
39
|
+
`${targetPath}/**/*.tsx`,
|
|
40
|
+
`${targetPath}/**/*.js`,
|
|
41
|
+
`${targetPath}/**/*.jsx`,
|
|
42
|
+
`!${targetPath}/**/node_modules/**`,
|
|
43
|
+
`!${targetPath}/**/dist/**`,
|
|
44
|
+
`!${targetPath}/**/.next/**`,
|
|
45
|
+
`!${targetPath}/**/*.d.ts`,
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
const layers = new Map<string, LayerNode>()
|
|
49
|
+
const edges = new Map<string, number>()
|
|
50
|
+
const layerAdjacency = new Map<string, Set<string>>()
|
|
51
|
+
const fileImportGraph = new Map<string, Set<string>>()
|
|
52
|
+
|
|
53
|
+
for (const file of project.getSourceFiles()) {
|
|
54
|
+
const filePath = file.getFilePath()
|
|
55
|
+
const rel = relative(targetPath, filePath).replace(/\\/g, '/')
|
|
56
|
+
const layerName = detectLayer(rel)
|
|
57
|
+
if (!layers.has(layerName)) layers.set(layerName, { name: layerName, files: new Set() })
|
|
58
|
+
layers.get(layerName)!.files.add(rel)
|
|
59
|
+
if (!fileImportGraph.has(filePath)) fileImportGraph.set(filePath, new Set())
|
|
60
|
+
|
|
61
|
+
for (const decl of file.getImportDeclarations()) {
|
|
62
|
+
const imported = decl.getModuleSpecifierSourceFile()
|
|
63
|
+
if (!imported) continue
|
|
64
|
+
fileImportGraph.get(filePath)!.add(imported.getFilePath())
|
|
65
|
+
const importedRel = relative(targetPath, imported.getFilePath()).replace(/\\/g, '/')
|
|
66
|
+
const importedLayer = detectLayer(importedRel)
|
|
67
|
+
if (importedLayer === layerName) continue
|
|
68
|
+
const key = `${layerName}->${importedLayer}`
|
|
69
|
+
edges.set(key, (edges.get(key) ?? 0) + 1)
|
|
70
|
+
if (!layerAdjacency.has(layerName)) layerAdjacency.set(layerName, new Set())
|
|
71
|
+
layerAdjacency.get(layerName)!.add(importedLayer)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cycleEdges = detectCycleEdges(layerAdjacency)
|
|
76
|
+
const violationEdges = new Set<string>()
|
|
77
|
+
|
|
78
|
+
if (config?.layers && config.layers.length > 0) {
|
|
79
|
+
const violations = detectLayerViolations(fileImportGraph, config.layers, targetPath, RULE_WEIGHTS)
|
|
80
|
+
for (const issues of violations.values()) {
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const match = issue.message.match(/Layer '([^']+)' must not import from layer '([^']+)'/)
|
|
83
|
+
if (!match) continue
|
|
84
|
+
const from = match[1]
|
|
85
|
+
const to = match[2]
|
|
86
|
+
violationEdges.add(`${from}->${to}`)
|
|
87
|
+
if (!layers.has(from)) layers.set(from, { name: from, files: new Set() })
|
|
88
|
+
if (!layers.has(to)) layers.set(to, { name: to, files: new Set() })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const edgeList: MapEdge[] = [...edges.entries()].map(([key, count]) => {
|
|
94
|
+
const [from, to] = key.split('->')
|
|
95
|
+
const kind = violationEdges.has(key)
|
|
96
|
+
? 'violation'
|
|
97
|
+
: cycleEdges.has(key)
|
|
98
|
+
? 'cycle'
|
|
99
|
+
: 'normal'
|
|
100
|
+
return { key, from, to, count, kind }
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
for (const key of violationEdges) {
|
|
104
|
+
if (edges.has(key)) continue
|
|
105
|
+
const [from, to] = key.split('->')
|
|
106
|
+
edgeList.push({ key, from, to, count: 1, kind: 'violation' })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const layerList = [...layers.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
110
|
+
const width = 960
|
|
111
|
+
const rowHeight = 90
|
|
112
|
+
const height = Math.max(180, layerList.length * rowHeight + 120)
|
|
113
|
+
const boxWidth = 240
|
|
114
|
+
const boxHeight = 50
|
|
115
|
+
const left = 100
|
|
116
|
+
|
|
117
|
+
const boxes = layerList.map((layer, index) => {
|
|
118
|
+
const y = 60 + index * rowHeight
|
|
119
|
+
return {
|
|
120
|
+
...layer,
|
|
121
|
+
x: left,
|
|
122
|
+
y,
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const boxByName = new Map(boxes.map((box) => [box.name, box]))
|
|
127
|
+
|
|
128
|
+
const lines = edgeList.map((edge) => {
|
|
129
|
+
const a = boxByName.get(edge.from)
|
|
130
|
+
const b = boxByName.get(edge.to)
|
|
131
|
+
if (!a || !b) return ''
|
|
132
|
+
const startX = a.x + boxWidth
|
|
133
|
+
const startY = a.y + boxHeight / 2
|
|
134
|
+
const endX = b.x
|
|
135
|
+
const endY = b.y + boxHeight / 2
|
|
136
|
+
const stroke = edge.kind === 'violation'
|
|
137
|
+
? '#ef4444'
|
|
138
|
+
: edge.kind === 'cycle'
|
|
139
|
+
? '#f59e0b'
|
|
140
|
+
: '#64748b'
|
|
141
|
+
const widthPx = edge.kind === 'normal' ? 2 : 3
|
|
142
|
+
return `
|
|
143
|
+
<line x1="${startX}" y1="${startY}" x2="${endX}" y2="${endY}" stroke="${stroke}" stroke-width="${widthPx}" marker-end="url(#arrow)" data-edge="${esc(edge.key)}" data-kind="${edge.kind}" />
|
|
144
|
+
<text x="${(startX + endX) / 2}" y="${(startY + endY) / 2 - 4}" fill="#94a3b8" font-size="11" text-anchor="middle">${edge.count}</text>`
|
|
145
|
+
}).join('')
|
|
146
|
+
|
|
147
|
+
const nodes = boxes.map((box) => `
|
|
148
|
+
<g>
|
|
149
|
+
<rect x="${box.x}" y="${box.y}" width="${boxWidth}" height="${boxHeight}" rx="8" fill="#0f172a" stroke="#334155" />
|
|
150
|
+
<text x="${box.x + 12}" y="${box.y + 22}" fill="#e2e8f0" font-size="13" font-family="monospace">${esc(box.name)}</text>
|
|
151
|
+
<text x="${box.x + 12}" y="${box.y + 38}" fill="#94a3b8" font-size="11" font-family="monospace">${box.files.size} file(s)</text>
|
|
152
|
+
</g>`).join('')
|
|
153
|
+
|
|
154
|
+
const cycleCount = edgeList.filter((edge) => edge.kind === 'cycle').length
|
|
155
|
+
const violationCount = edgeList.filter((edge) => edge.kind === 'violation').length
|
|
156
|
+
|
|
157
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
158
|
+
<defs>
|
|
159
|
+
<marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
|
|
160
|
+
<path d="M0,0 L0,6 L7,3 z" fill="#64748b"/>
|
|
161
|
+
</marker>
|
|
162
|
+
</defs>
|
|
163
|
+
<rect x="0" y="0" width="${width}" height="${height}" fill="#020617" />
|
|
164
|
+
<text x="28" y="34" fill="#f8fafc" font-size="16" font-family="monospace">drift architecture map</text>
|
|
165
|
+
<text x="28" y="54" fill="#94a3b8" font-size="11" font-family="monospace">Layers inferred from top-level directories</text>
|
|
166
|
+
<text x="28" y="72" fill="#94a3b8" font-size="11" font-family="monospace">Cycle edges: ${cycleCount} | Layer violations: ${violationCount}</text>
|
|
167
|
+
<line x1="520" y1="66" x2="560" y2="66" stroke="#f59e0b" stroke-width="3" /><text x="567" y="69" fill="#94a3b8" font-size="11" font-family="monospace">cycle</text>
|
|
168
|
+
<line x1="630" y1="66" x2="670" y2="66" stroke="#ef4444" stroke-width="3" /><text x="677" y="69" fill="#94a3b8" font-size="11" font-family="monospace">violation</text>
|
|
169
|
+
${lines}
|
|
170
|
+
${nodes}
|
|
171
|
+
</svg>`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function detectCycleEdges(adjacency: Map<string, Set<string>>): Set<string> {
|
|
175
|
+
const visited = new Set<string>()
|
|
176
|
+
const inStack = new Set<string>()
|
|
177
|
+
const stack: string[] = []
|
|
178
|
+
const cycleEdges = new Set<string>()
|
|
179
|
+
|
|
180
|
+
function dfs(node: string): void {
|
|
181
|
+
visited.add(node)
|
|
182
|
+
inStack.add(node)
|
|
183
|
+
stack.push(node)
|
|
184
|
+
|
|
185
|
+
for (const neighbor of adjacency.get(node) ?? []) {
|
|
186
|
+
if (!visited.has(neighbor)) {
|
|
187
|
+
dfs(neighbor)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!inStack.has(neighbor)) continue
|
|
192
|
+
|
|
193
|
+
const startIndex = stack.indexOf(neighbor)
|
|
194
|
+
if (startIndex >= 0) {
|
|
195
|
+
for (let i = startIndex; i < stack.length - 1; i++) {
|
|
196
|
+
cycleEdges.add(`${stack[i]}->${stack[i + 1]}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
cycleEdges.add(`${node}->${neighbor}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stack.pop()
|
|
203
|
+
inStack.delete(node)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const node of adjacency.keys()) {
|
|
207
|
+
if (!visited.has(node)) dfs(node)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return cycleEdges
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function generateArchitectureMap(targetPath: string, outputFile = 'architecture.svg', config?: DriftConfig): string {
|
|
214
|
+
const resolvedTarget = resolve(targetPath)
|
|
215
|
+
const svg = generateArchitectureSvg(resolvedTarget, config)
|
|
216
|
+
const outPath = resolve(outputFile)
|
|
217
|
+
writeFileSync(outPath, svg, 'utf8')
|
|
218
|
+
return outPath
|
|
219
|
+
}
|
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
|
+
}
|
package/src/printer.ts
CHANGED
|
@@ -113,6 +113,26 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
113
113
|
'Pick one naming convention (camelCase for variables/functions, PascalCase for types)',
|
|
114
114
|
'Rename snake_case identifiers to camelCase to match TypeScript conventions',
|
|
115
115
|
],
|
|
116
|
+
'controller-no-db': [
|
|
117
|
+
'Move DB access to a service/repository and inject it into the controller',
|
|
118
|
+
'Keep controllers focused on transport and orchestration only',
|
|
119
|
+
],
|
|
120
|
+
'service-no-http': [
|
|
121
|
+
'Move HTTP concerns to adapters/clients and keep services framework-agnostic',
|
|
122
|
+
'Inject interfaces for outbound calls instead of calling fetch/express directly',
|
|
123
|
+
],
|
|
124
|
+
'max-function-lines': [
|
|
125
|
+
'Split the function into smaller units with clear responsibilities',
|
|
126
|
+
'Extract branch-heavy chunks into dedicated helpers',
|
|
127
|
+
],
|
|
128
|
+
'ai-code-smell': [
|
|
129
|
+
'Address the listed AI-smell signals in this file before adding more code',
|
|
130
|
+
'Prioritize consistency: naming, error handling, and abstraction level',
|
|
131
|
+
],
|
|
132
|
+
'plugin-error': [
|
|
133
|
+
'Fix or remove the failing plugin in drift.config.*',
|
|
134
|
+
'Validate plugin contract: export { name, rules[] } and detector functions',
|
|
135
|
+
],
|
|
116
136
|
}
|
|
117
137
|
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
118
138
|
}
|
package/src/report.ts
CHANGED
|
@@ -644,6 +644,8 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
644
644
|
const projColor = scoreColor(report.totalScore)
|
|
645
645
|
const projLabel = scoreLabel(report.totalScore)
|
|
646
646
|
const projGrade = scoreGrade(report.totalScore)
|
|
647
|
+
const quality = report.quality
|
|
648
|
+
const risk = report.maintenanceRisk
|
|
647
649
|
|
|
648
650
|
const filesWithIssues = report.files.filter(f => f.issues.length > 0).length
|
|
649
651
|
|
|
@@ -658,6 +660,17 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
658
660
|
<span class="rule-count">${count}</span>
|
|
659
661
|
</button>`).join('')
|
|
660
662
|
|
|
663
|
+
const hotspotsHtml = risk.hotspots.length === 0
|
|
664
|
+
? '<div class="empty-state" style="padding:0.8rem">No hotspots detected.</div>'
|
|
665
|
+
: risk.hotspots.slice(0, 5).map((hotspot) => `
|
|
666
|
+
<div class="issue-row" style="grid-template-columns: 62px 1fr;grid-template-rows:auto auto;">
|
|
667
|
+
<span class="issue-line">R${hotspot.risk}</span>
|
|
668
|
+
<div class="issue-rule-msg">
|
|
669
|
+
<span class="issue-rule">hotspot</span>
|
|
670
|
+
<span class="issue-msg">${escapeHtml(hotspot.file)} (${hotspot.reasons.join(', ')})</span>
|
|
671
|
+
</div>
|
|
672
|
+
</div>`).join('')
|
|
673
|
+
|
|
661
674
|
// ── File sections ──────────────────────────────────────────────────────
|
|
662
675
|
const fileSections = report.files
|
|
663
676
|
.filter(f => f.issues.length > 0)
|
|
@@ -770,6 +783,21 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
770
783
|
</div>
|
|
771
784
|
</div>
|
|
772
785
|
|
|
786
|
+
<div class="sidebar-block">
|
|
787
|
+
<div class="sidebar-label">Repo Quality</div>
|
|
788
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(100 - quality.overall)}">${quality.overall}/100</div>
|
|
789
|
+
<div style="font-size:0.72rem;color:var(--muted)">Architecture ${quality.dimensions.architecture} · Complexity ${quality.dimensions.complexity}</div>
|
|
790
|
+
<div style="font-size:0.72rem;color:var(--muted)">AI patterns ${quality.dimensions['ai-patterns']} · Testing ${quality.dimensions.testing}</div>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div class="sidebar-block">
|
|
794
|
+
<div class="sidebar-label">Maintenance Risk</div>
|
|
795
|
+
<div style="font-size:1.1rem;font-weight:700;color:${scoreColor(risk.score)}">${risk.score}/100 (${risk.level.toUpperCase()})</div>
|
|
796
|
+
<div style="font-size:0.72rem;color:var(--muted)">High complexity: ${risk.signals.highComplexityFiles}</div>
|
|
797
|
+
<div style="font-size:0.72rem;color:var(--muted)">No tests: ${risk.signals.filesWithoutNearbyTests}</div>
|
|
798
|
+
<div style="font-size:0.72rem;color:var(--muted)">Frequent changes: ${risk.signals.frequentChangeFiles}</div>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
773
801
|
<!-- Severity filters -->
|
|
774
802
|
<div class="sidebar-block">
|
|
775
803
|
<div class="sidebar-label">Severity</div>
|
|
@@ -819,6 +847,13 @@ export function generateHtmlReport(report: DriftReport): string {
|
|
|
819
847
|
<div class="main-header">
|
|
820
848
|
<span id="issue-counter" style="color:var(--muted);font-size:0.75rem">Loading…</span>
|
|
821
849
|
</div>
|
|
850
|
+
<section class="file-section" open>
|
|
851
|
+
<summary>
|
|
852
|
+
<span class="file-name">Risk hotspots</span>
|
|
853
|
+
<span class="file-score" style="color:${scoreColor(risk.score)}">${risk.score}/100</span>
|
|
854
|
+
</summary>
|
|
855
|
+
<div class="issues-list">${hotspotsHtml}</div>
|
|
856
|
+
</section>
|
|
822
857
|
${fileSections || noIssues}
|
|
823
858
|
</main>
|
|
824
859
|
|