@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
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import * as vscode from 'vscode'
|
|
2
2
|
import type { FileReport } from '@eduardbar/drift'
|
|
3
3
|
|
|
4
|
+
const STATUSBAR_PRIORITY = 100
|
|
5
|
+
|
|
6
|
+
const SCORE_THRESHOLDS = {
|
|
7
|
+
WARNING: 50,
|
|
8
|
+
ERROR: 30,
|
|
9
|
+
WARNING_BG: 60,
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
export class DriftStatusBarItem {
|
|
5
13
|
private item: vscode.StatusBarItem
|
|
6
14
|
|
|
7
15
|
constructor() {
|
|
8
|
-
this.item = vscode.
|
|
16
|
+
this.item = vscode.createStatusBarItem(
|
|
9
17
|
vscode.StatusBarAlignment.Right,
|
|
10
|
-
|
|
18
|
+
STATUSBAR_PRIORITY
|
|
11
19
|
)
|
|
12
20
|
this.item.command = 'drift.scanWorkspace'
|
|
13
21
|
this.item.tooltip = 'Click to scan workspace'
|
|
@@ -27,12 +35,12 @@ export class DriftStatusBarItem {
|
|
|
27
35
|
const totalIssues = reports.reduce((sum, r) => sum + r.issues.length, 0)
|
|
28
36
|
const hasErrors = reports.some(r => r.issues.some(i => i.severity === 'error'))
|
|
29
37
|
|
|
30
|
-
const icon = hasErrors ? '$(error)' : totalScore <
|
|
38
|
+
const icon = hasErrors ? '$(error)' : totalScore < SCORE_THRESHOLDS.WARNING ? '$(warning)' : '$(check)'
|
|
31
39
|
this.item.text = `${icon} drift ${totalScore}/100 · ${totalIssues} issues`
|
|
32
40
|
|
|
33
|
-
if (hasErrors || totalScore <
|
|
41
|
+
if (hasErrors || totalScore < SCORE_THRESHOLDS.ERROR) {
|
|
34
42
|
this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground')
|
|
35
|
-
} else if (totalScore <
|
|
43
|
+
} else if (totalScore < SCORE_THRESHOLDS.WARNING_BG) {
|
|
36
44
|
this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground')
|
|
37
45
|
} else {
|
|
38
46
|
this.item.backgroundColor = undefined
|
package/src/analyzer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// drift-ignore-file
|
|
2
2
|
import * as path from 'node:path'
|
|
3
3
|
import { Project } from 'ts-morph'
|
|
4
|
-
import type { DriftIssue, FileReport, DriftConfig } from './types.js'
|
|
4
|
+
import type { DriftIssue, FileReport, DriftConfig, LoadedPlugin, PluginRuleContext } from './types.js'
|
|
5
5
|
|
|
6
6
|
// Rules
|
|
7
7
|
import { isFileIgnored } from './rules/shared.js'
|
|
@@ -15,15 +15,12 @@ import {
|
|
|
15
15
|
detectCatchSwallow,
|
|
16
16
|
detectMissingReturnTypes,
|
|
17
17
|
} from './rules/phase0-basic.js'
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
detectMagicNumbers,
|
|
25
|
-
detectCommentContradiction,
|
|
26
|
-
} from './rules/phase1-complexity.js'
|
|
18
|
+
import { detectHighComplexity } from './rules/complexity.js'
|
|
19
|
+
import { detectDeepNesting, detectTooManyParams } from './rules/nesting.js'
|
|
20
|
+
import { detectHighCoupling } from './rules/coupling.js'
|
|
21
|
+
import { detectPromiseStyleMix } from './rules/promise.js'
|
|
22
|
+
import { detectMagicNumbers } from './rules/magic.js'
|
|
23
|
+
import { detectCommentContradiction } from './rules/comments.js'
|
|
27
24
|
import {
|
|
28
25
|
detectDeadFiles,
|
|
29
26
|
detectUnusedExports,
|
|
@@ -34,6 +31,11 @@ import {
|
|
|
34
31
|
detectLayerViolations,
|
|
35
32
|
detectCrossBoundaryImports,
|
|
36
33
|
} from './rules/phase3-arch.js'
|
|
34
|
+
import {
|
|
35
|
+
detectControllerNoDb,
|
|
36
|
+
detectServiceNoHttp,
|
|
37
|
+
detectMaxFunctionLines,
|
|
38
|
+
} from './rules/phase3-configurable.js'
|
|
37
39
|
import {
|
|
38
40
|
detectOverCommented,
|
|
39
41
|
detectHardcodedConfig,
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
fingerprintFunction,
|
|
47
49
|
calculateScore,
|
|
48
50
|
} from './rules/phase8-semantic.js'
|
|
51
|
+
import { loadPlugins } from './plugins.js'
|
|
49
52
|
|
|
50
53
|
// Git analyzers (re-exported as part of the public API)
|
|
51
54
|
export { TrendAnalyzer } from './git/trend.js'
|
|
@@ -81,21 +84,113 @@ export const RULE_WEIGHTS: Record<string, { severity: DriftIssue['severity']; we
|
|
|
81
84
|
// Phase 3b/c: layer and module boundary enforcement (require drift.config.ts)
|
|
82
85
|
'layer-violation': { severity: 'error', weight: 16 },
|
|
83
86
|
'cross-boundary-import': { severity: 'warning', weight: 10 },
|
|
87
|
+
'controller-no-db': { severity: 'warning', weight: 11 },
|
|
88
|
+
'service-no-http': { severity: 'warning', weight: 11 },
|
|
89
|
+
'max-function-lines': { severity: 'warning', weight: 9 },
|
|
84
90
|
// Phase 5: AI authorship heuristics
|
|
85
91
|
'over-commented': { severity: 'info', weight: 4 },
|
|
86
92
|
'hardcoded-config': { severity: 'warning', weight: 10 },
|
|
87
93
|
'inconsistent-error-handling': { severity: 'warning', weight: 8 },
|
|
88
94
|
'unnecessary-abstraction': { severity: 'warning', weight: 7 },
|
|
89
95
|
'naming-inconsistency': { severity: 'warning', weight: 6 },
|
|
96
|
+
'ai-code-smell': { severity: 'warning', weight: 12 },
|
|
90
97
|
// Phase 8: semantic duplication
|
|
91
98
|
'semantic-duplication': { severity: 'warning', weight: 12 },
|
|
99
|
+
'plugin-error': { severity: 'warning', weight: 4 },
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const AI_SMELL_SIGNALS = new Set([
|
|
103
|
+
'over-commented',
|
|
104
|
+
'hardcoded-config',
|
|
105
|
+
'inconsistent-error-handling',
|
|
106
|
+
'unnecessary-abstraction',
|
|
107
|
+
'naming-inconsistency',
|
|
108
|
+
'comment-contradiction',
|
|
109
|
+
'promise-style-mix',
|
|
110
|
+
'any-abuse',
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
function detectAICodeSmell(issues: DriftIssue[], filePath: string): DriftIssue[] {
|
|
114
|
+
const signalCounts = new Map<string, number>()
|
|
115
|
+
for (const issue of issues) {
|
|
116
|
+
if (!AI_SMELL_SIGNALS.has(issue.rule)) continue
|
|
117
|
+
signalCounts.set(issue.rule, (signalCounts.get(issue.rule) ?? 0) + 1)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalSignals = [...signalCounts.values()].reduce((sum, count) => sum + count, 0)
|
|
121
|
+
if (totalSignals < 3) return []
|
|
122
|
+
|
|
123
|
+
const triggers = [...signalCounts.entries()]
|
|
124
|
+
.sort((a, b) => b[1] - a[1])
|
|
125
|
+
.slice(0, 3)
|
|
126
|
+
.map(([rule, count]) => `${rule} x${count}`)
|
|
127
|
+
|
|
128
|
+
return [{
|
|
129
|
+
rule: 'ai-code-smell',
|
|
130
|
+
severity: 'warning',
|
|
131
|
+
message: `Aggregated AI smell signals detected (${totalSignals}): ${triggers.join(', ')}`,
|
|
132
|
+
line: 1,
|
|
133
|
+
column: 1,
|
|
134
|
+
snippet: path.basename(filePath),
|
|
135
|
+
}]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runPluginRules(
|
|
139
|
+
file: import('ts-morph').SourceFile,
|
|
140
|
+
loadedPlugins: LoadedPlugin[],
|
|
141
|
+
config: DriftConfig | undefined,
|
|
142
|
+
projectRoot: string,
|
|
143
|
+
): DriftIssue[] {
|
|
144
|
+
if (loadedPlugins.length === 0) return []
|
|
145
|
+
const context: PluginRuleContext = {
|
|
146
|
+
projectRoot,
|
|
147
|
+
filePath: file.getFilePath(),
|
|
148
|
+
config,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const issues: DriftIssue[] = []
|
|
152
|
+
for (const loaded of loadedPlugins) {
|
|
153
|
+
for (const rule of loaded.plugin.rules) {
|
|
154
|
+
try {
|
|
155
|
+
const detected = rule.detect(file, context) ?? []
|
|
156
|
+
for (const issue of detected) {
|
|
157
|
+
issues.push({
|
|
158
|
+
...issue,
|
|
159
|
+
rule: issue.rule || `${loaded.plugin.name}/${rule.name}`,
|
|
160
|
+
severity: issue.severity ?? (rule.severity ?? 'warning'),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
issues.push({
|
|
165
|
+
rule: 'plugin-error',
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
message: `Plugin '${loaded.id}' rule '${rule.name}' failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
+
line: 1,
|
|
169
|
+
column: 1,
|
|
170
|
+
snippet: file.getBaseName(),
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return issues
|
|
92
176
|
}
|
|
93
177
|
|
|
94
178
|
// ---------------------------------------------------------------------------
|
|
95
179
|
// Per-file analysis
|
|
96
180
|
// ---------------------------------------------------------------------------
|
|
97
181
|
|
|
98
|
-
export function analyzeFile(
|
|
182
|
+
export function analyzeFile(
|
|
183
|
+
file: import('ts-morph').SourceFile,
|
|
184
|
+
options?: DriftConfig | {
|
|
185
|
+
config?: DriftConfig
|
|
186
|
+
loadedPlugins?: LoadedPlugin[]
|
|
187
|
+
projectRoot?: string
|
|
188
|
+
},
|
|
189
|
+
): FileReport {
|
|
190
|
+
const normalizedOptions = (options && typeof options === 'object' && ('config' in options || 'loadedPlugins' in options || 'projectRoot' in options))
|
|
191
|
+
? options
|
|
192
|
+
: { config: (options && typeof options === 'object' ? options : undefined) as DriftConfig | undefined }
|
|
193
|
+
|
|
99
194
|
if (isFileIgnored(file)) {
|
|
100
195
|
return {
|
|
101
196
|
path: file.getFilePath(),
|
|
@@ -127,8 +222,21 @@ export function analyzeFile(file: import('ts-morph').SourceFile): FileReport {
|
|
|
127
222
|
...detectInconsistentErrorHandling(file),
|
|
128
223
|
...detectUnnecessaryAbstraction(file),
|
|
129
224
|
...detectNamingInconsistency(file),
|
|
225
|
+
// Configurable architecture rules
|
|
226
|
+
...detectControllerNoDb(file, normalizedOptions?.config),
|
|
227
|
+
...detectServiceNoHttp(file, normalizedOptions?.config),
|
|
228
|
+
...detectMaxFunctionLines(file, normalizedOptions?.config),
|
|
229
|
+
// Plugin rules
|
|
230
|
+
...runPluginRules(
|
|
231
|
+
file,
|
|
232
|
+
normalizedOptions?.loadedPlugins ?? [],
|
|
233
|
+
normalizedOptions?.config,
|
|
234
|
+
normalizedOptions?.projectRoot ?? path.dirname(file.getFilePath()),
|
|
235
|
+
),
|
|
130
236
|
]
|
|
131
237
|
|
|
238
|
+
issues.push(...detectAICodeSmell(issues, file.getFilePath()))
|
|
239
|
+
|
|
132
240
|
return {
|
|
133
241
|
path: file.getFilePath(),
|
|
134
242
|
issues,
|
|
@@ -161,9 +269,14 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
|
|
|
161
269
|
])
|
|
162
270
|
|
|
163
271
|
const sourceFiles = project.getSourceFiles()
|
|
272
|
+
const pluginRuntime = loadPlugins(targetPath, config?.plugins)
|
|
164
273
|
|
|
165
274
|
// Phase 1: per-file analysis
|
|
166
|
-
const reports: FileReport[] = sourceFiles.map(analyzeFile
|
|
275
|
+
const reports: FileReport[] = sourceFiles.map((file) => analyzeFile(file, {
|
|
276
|
+
config,
|
|
277
|
+
loadedPlugins: pluginRuntime.plugins,
|
|
278
|
+
projectRoot: targetPath,
|
|
279
|
+
}))
|
|
167
280
|
const reportByPath = new Map<string, FileReport>()
|
|
168
281
|
for (const r of reports) reportByPath.set(r.path, r)
|
|
169
282
|
|
|
@@ -227,6 +340,25 @@ export function analyzeProject(targetPath: string, config?: DriftConfig): FileRe
|
|
|
227
340
|
}
|
|
228
341
|
}
|
|
229
342
|
|
|
343
|
+
// Plugin load failures are surfaced as synthetic report entries.
|
|
344
|
+
if (pluginRuntime.errors.length > 0) {
|
|
345
|
+
for (const err of pluginRuntime.errors) {
|
|
346
|
+
const pluginIssue: DriftIssue = {
|
|
347
|
+
rule: 'plugin-error',
|
|
348
|
+
severity: 'warning',
|
|
349
|
+
message: `Failed to load plugin '${err.pluginId}': ${err.message}`,
|
|
350
|
+
line: 1,
|
|
351
|
+
column: 1,
|
|
352
|
+
snippet: err.pluginId,
|
|
353
|
+
}
|
|
354
|
+
reports.push({
|
|
355
|
+
path: path.join(targetPath, '.drift-plugin-errors', `${err.pluginId}.plugin`),
|
|
356
|
+
issues: [pluginIssue],
|
|
357
|
+
score: calculateScore([pluginIssue], RULE_WEIGHTS),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
230
362
|
// ── Phase 2: dead-file + unused-export + unused-dependency ─────────────────
|
|
231
363
|
const deadFiles = detectDeadFiles(sourceFiles, allImportedPaths, RULE_WEIGHTS)
|
|
232
364
|
for (const [sfPath, issue] of deadFiles) {
|
package/src/badge.ts
CHANGED
|
@@ -3,19 +3,40 @@ import type {} from './types.js'
|
|
|
3
3
|
const LEFT_WIDTH = 47
|
|
4
4
|
const CHAR_WIDTH = 7
|
|
5
5
|
const PADDING = 16
|
|
6
|
+
const SVG_SCALE = 10
|
|
7
|
+
|
|
8
|
+
const GRADE_THRESHOLDS = {
|
|
9
|
+
LOW: 20,
|
|
10
|
+
MODERATE: 45,
|
|
11
|
+
HIGH: 70,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const GRADE_COLORS = {
|
|
15
|
+
LOW: '#4c1',
|
|
16
|
+
MODERATE: '#dfb317',
|
|
17
|
+
HIGH: '#fe7d37',
|
|
18
|
+
CRITICAL: '#e05d44',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GRADE_LABELS = {
|
|
22
|
+
LOW: 'LOW',
|
|
23
|
+
MODERATE: 'MODERATE',
|
|
24
|
+
HIGH: 'HIGH',
|
|
25
|
+
CRITICAL: 'CRITICAL',
|
|
26
|
+
}
|
|
6
27
|
|
|
7
28
|
function scoreColor(score: number): string {
|
|
8
|
-
if (score <
|
|
9
|
-
if (score <
|
|
10
|
-
if (score <
|
|
11
|
-
return
|
|
29
|
+
if (score < GRADE_THRESHOLDS.LOW) return GRADE_COLORS.LOW
|
|
30
|
+
if (score < GRADE_THRESHOLDS.MODERATE) return GRADE_COLORS.MODERATE
|
|
31
|
+
if (score < GRADE_THRESHOLDS.HIGH) return GRADE_COLORS.HIGH
|
|
32
|
+
return GRADE_COLORS.CRITICAL
|
|
12
33
|
}
|
|
13
34
|
|
|
14
35
|
function scoreLabel(score: number): string {
|
|
15
|
-
if (score <
|
|
16
|
-
if (score <
|
|
17
|
-
if (score <
|
|
18
|
-
return
|
|
36
|
+
if (score < GRADE_THRESHOLDS.LOW) return GRADE_LABELS.LOW
|
|
37
|
+
if (score < GRADE_THRESHOLDS.MODERATE) return GRADE_LABELS.MODERATE
|
|
38
|
+
if (score < GRADE_THRESHOLDS.HIGH) return GRADE_LABELS.HIGH
|
|
39
|
+
return GRADE_LABELS.CRITICAL
|
|
19
40
|
}
|
|
20
41
|
|
|
21
42
|
function rightWidth(text: string): number {
|
|
@@ -32,10 +53,11 @@ export function generateBadge(score: number): string {
|
|
|
32
53
|
const leftCenterX = LEFT_WIDTH / 2
|
|
33
54
|
const rightCenterX = LEFT_WIDTH + rWidth / 2
|
|
34
55
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
56
|
+
const leftTextWidth = (LEFT_WIDTH - PADDING) * SVG_SCALE
|
|
57
|
+
const rightTextWidth = (rWidth - PADDING) * SVG_SCALE
|
|
58
|
+
|
|
59
|
+
const leftCenterXScaled = leftCenterX * SVG_SCALE
|
|
60
|
+
const rightCenterXScaled = rightCenterX * SVG_SCALE
|
|
39
61
|
|
|
40
62
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="20">
|
|
41
63
|
<linearGradient id="s" x2="0" y2="100%">
|
|
@@ -51,10 +73,10 @@ export function generateBadge(score: number): string {
|
|
|
51
73
|
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
52
74
|
</g>
|
|
53
75
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
|
54
|
-
<text x="${
|
|
55
|
-
<text x="${
|
|
56
|
-
<text x="${
|
|
57
|
-
<text x="${
|
|
76
|
+
<text x="${leftCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
|
|
77
|
+
<text x="${leftCenterXScaled}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
|
|
78
|
+
<text x="${rightCenterXScaled}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
|
|
79
|
+
<text x="${rightCenterXScaled}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
|
|
58
80
|
</g>
|
|
59
81
|
</svg>`
|
|
60
82
|
}
|
package/src/ci.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
3
|
import type { DriftReport } from './types.js'
|
|
4
4
|
|
|
5
|
+
const GRADE_THRESHOLDS = {
|
|
6
|
+
A: 80,
|
|
7
|
+
B: 60,
|
|
8
|
+
C: 40,
|
|
9
|
+
D: 20,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TOP_FILES_LIMIT = 10
|
|
13
|
+
|
|
5
14
|
function encodeMessage(msg: string): string {
|
|
6
15
|
return msg
|
|
7
16
|
.replace(/%/g, '%25')
|
|
@@ -18,10 +27,10 @@ function severityToAnnotation(s: string): 'error' | 'warning' | 'notice' {
|
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
function scoreLabel(score: number): string {
|
|
21
|
-
if (score >=
|
|
22
|
-
if (score >=
|
|
23
|
-
if (score >=
|
|
24
|
-
if (score >=
|
|
30
|
+
if (score >= GRADE_THRESHOLDS.A) return 'A'
|
|
31
|
+
if (score >= GRADE_THRESHOLDS.B) return 'B'
|
|
32
|
+
if (score >= GRADE_THRESHOLDS.C) return 'C'
|
|
33
|
+
if (score >= GRADE_THRESHOLDS.D) return 'D'
|
|
25
34
|
return 'F'
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -38,28 +47,40 @@ export function emitCIAnnotations(report: DriftReport): void {
|
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
const summaryPath = process.env['GITHUB_STEP_SUMMARY']
|
|
43
|
-
if (!summaryPath) return
|
|
44
|
-
|
|
45
|
-
const score = report.totalScore
|
|
46
|
-
const grade = scoreLabel(score)
|
|
47
|
-
|
|
50
|
+
function countIssuesBySeverity(report: DriftReport): { errors: number; warnings: number; info: number } {
|
|
48
51
|
let errors = 0
|
|
49
52
|
let warnings = 0
|
|
50
53
|
let info = 0
|
|
51
54
|
|
|
52
55
|
for (const file of report.files) {
|
|
53
|
-
|
|
54
|
-
if (issue.severity === 'error') errors++
|
|
55
|
-
else if (issue.severity === 'warning') warnings++
|
|
56
|
-
else info++
|
|
57
|
-
}
|
|
56
|
+
countFileIssues(file, { errors: () => errors++, warnings: () => warnings++, info: () => info++ })
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
return { errors, warnings, info }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function countFileIssues(
|
|
63
|
+
file: { issues: Array<{ severity: string }> },
|
|
64
|
+
counters: { errors: () => void; warnings: () => void; info: () => void },
|
|
65
|
+
): void {
|
|
66
|
+
for (const issue of file.issues) {
|
|
67
|
+
if (issue.severity === 'error') counters.errors()
|
|
68
|
+
else if (issue.severity === 'warning') counters.warnings()
|
|
69
|
+
else counters.info()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function printCISummary(report: DriftReport): void {
|
|
74
|
+
const summaryPath = process.env['GITHUB_STEP_SUMMARY']
|
|
75
|
+
if (!summaryPath) return
|
|
76
|
+
|
|
77
|
+
const score = report.totalScore
|
|
78
|
+
const grade = scoreLabel(score)
|
|
79
|
+
const { errors, warnings, info } = countIssuesBySeverity(report)
|
|
80
|
+
|
|
60
81
|
const sorted = [...report.files]
|
|
61
82
|
.sort((a, b) => b.issues.length - a.issues.length)
|
|
62
|
-
.slice(0,
|
|
83
|
+
.slice(0, TOP_FILES_LIMIT)
|
|
63
84
|
|
|
64
85
|
const rows = sorted
|
|
65
86
|
.map((f) => {
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { generateHtmlReport } from './report.js'
|
|
|
16
16
|
import { generateBadge } from './badge.js'
|
|
17
17
|
import { emitCIAnnotations, printCISummary } from './ci.js'
|
|
18
18
|
import { applyFixes, type FixResult } from './fix.js'
|
|
19
|
+
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
|
|
20
|
+
import { generateReview } from './review.js'
|
|
21
|
+
import { generateArchitectureMap } from './map.js'
|
|
19
22
|
|
|
20
23
|
const program = new Command()
|
|
21
24
|
|
|
@@ -117,6 +120,45 @@ program
|
|
|
117
120
|
}
|
|
118
121
|
})
|
|
119
122
|
|
|
123
|
+
program
|
|
124
|
+
.command('review')
|
|
125
|
+
.description('Review drift against a base ref and output PR markdown')
|
|
126
|
+
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
127
|
+
.option('--json', 'Output structured review JSON')
|
|
128
|
+
.option('--comment', 'Output markdown comment body')
|
|
129
|
+
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
130
|
+
.action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
|
|
131
|
+
try {
|
|
132
|
+
const review = await generateReview(resolve('.'), options.base)
|
|
133
|
+
|
|
134
|
+
if (options.json) {
|
|
135
|
+
process.stdout.write(JSON.stringify(review, null, 2) + '\n')
|
|
136
|
+
} else {
|
|
137
|
+
process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const failOn = options.failOn ? Number(options.failOn) : undefined
|
|
141
|
+
if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
146
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
147
|
+
process.exit(1)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
program
|
|
152
|
+
.command('map [path]')
|
|
153
|
+
.description('Generate architecture.svg with simple layer dependencies')
|
|
154
|
+
.option('-o, --output <file>', 'Output SVG path (default: architecture.svg)', 'architecture.svg')
|
|
155
|
+
.action(async (targetPath: string | undefined, options: { output: string }) => {
|
|
156
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
157
|
+
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
|
|
158
|
+
const out = generateArchitectureMap(resolvedPath, options.output)
|
|
159
|
+
process.stderr.write(` Architecture map saved to ${out}\n\n`)
|
|
160
|
+
})
|
|
161
|
+
|
|
120
162
|
program
|
|
121
163
|
.command('report [path]')
|
|
122
164
|
.description('Generate a self-contained HTML report')
|
|
@@ -214,14 +256,20 @@ program
|
|
|
214
256
|
.command('fix [path]')
|
|
215
257
|
.description('Auto-fix safe issues (debug-leftover console.*, catch-swallow)')
|
|
216
258
|
.option('--rule <rule>', 'Fix only a specific rule')
|
|
259
|
+
.option('--preview', 'Preview changes without writing files')
|
|
260
|
+
.option('--write', 'Write fixes to disk')
|
|
217
261
|
.option('--dry-run', 'Show what would change without writing files')
|
|
218
|
-
.action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean }) => {
|
|
262
|
+
.action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean }) => {
|
|
219
263
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
220
264
|
const config = await loadConfig(resolvedPath)
|
|
265
|
+
const previewMode = Boolean(options.preview || options.dryRun)
|
|
266
|
+
const writeMode = options.write ?? !previewMode
|
|
221
267
|
|
|
222
268
|
const results = await applyFixes(resolvedPath, config, {
|
|
223
269
|
rule: options.rule,
|
|
224
|
-
dryRun:
|
|
270
|
+
dryRun: previewMode,
|
|
271
|
+
preview: previewMode,
|
|
272
|
+
write: writeMode,
|
|
225
273
|
})
|
|
226
274
|
|
|
227
275
|
if (results.length === 0) {
|
|
@@ -231,8 +279,8 @@ program
|
|
|
231
279
|
|
|
232
280
|
const applied = results.filter(r => r.applied)
|
|
233
281
|
|
|
234
|
-
if (
|
|
235
|
-
console.log(`\ndrift fix --
|
|
282
|
+
if (previewMode) {
|
|
283
|
+
console.log(`\ndrift fix --preview: ${results.length} fixable issues found\n`)
|
|
236
284
|
} else {
|
|
237
285
|
console.log(`\ndrift fix: ${applied.length} fixes applied\n`)
|
|
238
286
|
}
|
|
@@ -248,14 +296,56 @@ program
|
|
|
248
296
|
const relPath = file.replace(resolvedPath + '/', '').replace(resolvedPath + '\\', '')
|
|
249
297
|
console.log(` ${relPath}`)
|
|
250
298
|
for (const r of fileResults) {
|
|
251
|
-
const status = r.applied ? (
|
|
299
|
+
const status = r.applied ? (previewMode ? 'would fix' : 'fixed') : 'skipped'
|
|
252
300
|
console.log(` [${r.rule}] line ${r.line}: ${r.description} — ${status}`)
|
|
301
|
+
if (r.before || r.after) {
|
|
302
|
+
console.log(` before: ${r.before ?? '(empty)'}`)
|
|
303
|
+
console.log(` after : ${r.after ?? '(empty)'}`)
|
|
304
|
+
}
|
|
253
305
|
}
|
|
254
306
|
}
|
|
255
307
|
|
|
256
|
-
if (!
|
|
308
|
+
if (!previewMode && applied.length > 0) {
|
|
257
309
|
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`)
|
|
258
310
|
}
|
|
259
311
|
})
|
|
260
312
|
|
|
313
|
+
program
|
|
314
|
+
.command('snapshot [path]')
|
|
315
|
+
.description('Record a score snapshot to drift-history.json')
|
|
316
|
+
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
317
|
+
.option('--history', 'show all recorded snapshots')
|
|
318
|
+
.option('--diff', 'compare current score vs last snapshot')
|
|
319
|
+
.action(async (
|
|
320
|
+
targetPath: string | undefined,
|
|
321
|
+
opts: { label?: string; history?: boolean; diff?: boolean },
|
|
322
|
+
) => {
|
|
323
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
324
|
+
|
|
325
|
+
if (opts.history) {
|
|
326
|
+
const history = loadHistory(resolvedPath)
|
|
327
|
+
printHistory(history)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
332
|
+
const config = await loadConfig(resolvedPath)
|
|
333
|
+
const files = analyzeProject(resolvedPath, config)
|
|
334
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
335
|
+
const report = buildReport(resolvedPath, files)
|
|
336
|
+
|
|
337
|
+
if (opts.diff) {
|
|
338
|
+
const history = loadHistory(resolvedPath)
|
|
339
|
+
printSnapshotDiff(history, report.totalScore)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const entry = saveSnapshot(resolvedPath, report, opts.label)
|
|
344
|
+
const labelStr = entry.label ? ` [${entry.label}]` : ''
|
|
345
|
+
process.stdout.write(
|
|
346
|
+
` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
|
|
347
|
+
)
|
|
348
|
+
process.stdout.write(` Saved to drift-history.json\n\n`)
|
|
349
|
+
})
|
|
350
|
+
|
|
261
351
|
program.parse()
|
package/src/diff.ts
CHANGED
|
@@ -7,6 +7,40 @@ import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
|
|
|
7
7
|
* A "new" issue exists in `current` but not in `base`.
|
|
8
8
|
* A "resolved" issue exists in `base` but not in `current`.
|
|
9
9
|
*/
|
|
10
|
+
function computeFileDiff(
|
|
11
|
+
filePath: string,
|
|
12
|
+
baseFile: { score: number; issues: DriftIssue[] } | undefined,
|
|
13
|
+
currentFile: { score: number; issues: DriftIssue[] } | undefined,
|
|
14
|
+
): FileDiff | null {
|
|
15
|
+
const scoreBefore = baseFile?.score ?? 0
|
|
16
|
+
const scoreAfter = currentFile?.score ?? 0
|
|
17
|
+
const scoreDelta = scoreAfter - scoreBefore
|
|
18
|
+
|
|
19
|
+
const baseIssues = baseFile?.issues ?? []
|
|
20
|
+
const currentIssues = currentFile?.issues ?? []
|
|
21
|
+
|
|
22
|
+
const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
23
|
+
|
|
24
|
+
const baseKeys = new Set(baseIssues.map(issueKey))
|
|
25
|
+
const currentKeys = new Set(currentIssues.map(issueKey))
|
|
26
|
+
|
|
27
|
+
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
|
|
28
|
+
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
|
|
29
|
+
|
|
30
|
+
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
31
|
+
return {
|
|
32
|
+
path: filePath,
|
|
33
|
+
scoreBefore,
|
|
34
|
+
scoreAfter,
|
|
35
|
+
scoreDelta,
|
|
36
|
+
newIssues,
|
|
37
|
+
resolvedIssues,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
export function computeDiff(
|
|
11
45
|
base: DriftReport,
|
|
12
46
|
current: DriftReport,
|
|
@@ -14,11 +48,9 @@ export function computeDiff(
|
|
|
14
48
|
): DriftDiff {
|
|
15
49
|
const fileDiffs: FileDiff[] = []
|
|
16
50
|
|
|
17
|
-
// Build a map of base files by path for O(1) lookup
|
|
18
51
|
const baseByPath = new Map(base.files.map(f => [f.path, f]))
|
|
19
52
|
const currentByPath = new Map(current.files.map(f => [f.path, f]))
|
|
20
53
|
|
|
21
|
-
// All unique paths across both reports
|
|
22
54
|
const allPaths = new Set([
|
|
23
55
|
...base.files.map(f => f.path),
|
|
24
56
|
...current.files.map(f => f.path),
|
|
@@ -28,36 +60,10 @@ export function computeDiff(
|
|
|
28
60
|
const baseFile = baseByPath.get(filePath)
|
|
29
61
|
const currentFile = currentByPath.get(filePath)
|
|
30
62
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const scoreDelta = scoreAfter - scoreBefore
|
|
34
|
-
|
|
35
|
-
const baseIssues = baseFile?.issues ?? []
|
|
36
|
-
const currentIssues = currentFile?.issues ?? []
|
|
37
|
-
|
|
38
|
-
// Issue identity key: rule + line + column
|
|
39
|
-
const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
|
|
40
|
-
|
|
41
|
-
const baseKeys = new Set(baseIssues.map(issueKey))
|
|
42
|
-
const currentKeys = new Set(currentIssues.map(issueKey))
|
|
43
|
-
|
|
44
|
-
const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
|
|
45
|
-
const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
|
|
46
|
-
|
|
47
|
-
// Only include files that have actual changes
|
|
48
|
-
if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
|
|
49
|
-
fileDiffs.push({
|
|
50
|
-
path: filePath,
|
|
51
|
-
scoreBefore,
|
|
52
|
-
scoreAfter,
|
|
53
|
-
scoreDelta,
|
|
54
|
-
newIssues,
|
|
55
|
-
resolvedIssues,
|
|
56
|
-
})
|
|
57
|
-
}
|
|
63
|
+
const diff = computeFileDiff(filePath, baseFile, currentFile)
|
|
64
|
+
if (diff) fileDiffs.push(diff)
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
// Sort: most regressed first, then most improved last
|
|
61
67
|
fileDiffs.sort((a, b) => b.scoreDelta - a.scoreDelta)
|
|
62
68
|
|
|
63
69
|
return {
|