@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
|
@@ -1,90 +1,116 @@
|
|
|
1
|
+
// drift-ignore-file
|
|
2
|
+
|
|
1
3
|
import * as vscode from 'vscode'
|
|
2
4
|
import { analyzeFilePath } from './analyzer'
|
|
3
5
|
import { DriftDiagnosticsProvider } from './diagnostics'
|
|
4
6
|
import { DriftTreeProvider } from './treeview'
|
|
5
7
|
import { DriftStatusBarItem } from './statusbar'
|
|
8
|
+
import { DriftCodeActionProvider } from './code-actions'
|
|
6
9
|
import type { FileReport } from '@eduardbar/drift'
|
|
7
10
|
|
|
8
11
|
const SUPPORTED_LANGUAGES = ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']
|
|
9
12
|
|
|
13
|
+
async function analyzeAndUpdate(
|
|
14
|
+
document: vscode.TextDocument,
|
|
15
|
+
diagnostics: DriftDiagnosticsProvider,
|
|
16
|
+
treeProvider: DriftTreeProvider,
|
|
17
|
+
reportCache: Map<string, FileReport>,
|
|
18
|
+
statusBar: DriftStatusBarItem
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const config = vscode.workspace.getConfiguration('drift')
|
|
21
|
+
if (!config.get<boolean>('enable', true)) return
|
|
22
|
+
|
|
23
|
+
if (!SUPPORTED_LANGUAGES.includes(document.languageId)) return
|
|
24
|
+
if (document.uri.scheme !== 'file') return
|
|
25
|
+
|
|
26
|
+
const filePath = document.uri.fsPath
|
|
27
|
+
|
|
28
|
+
const report = await analyzeFilePath(filePath)
|
|
29
|
+
if (!report) return
|
|
30
|
+
|
|
31
|
+
diagnostics.update(report)
|
|
32
|
+
treeProvider.updateFile(report)
|
|
33
|
+
reportCache.set(filePath, report)
|
|
34
|
+
statusBar.update(Array.from(reportCache.values()))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function scanWorkspace(
|
|
38
|
+
diagnostics: DriftDiagnosticsProvider,
|
|
39
|
+
treeProvider: DriftTreeProvider,
|
|
40
|
+
reportCache: Map<string, FileReport>,
|
|
41
|
+
statusBar: DriftStatusBarItem
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const files = await vscode.workspace.findFiles(
|
|
44
|
+
'**/*.{ts,tsx,js,jsx}',
|
|
45
|
+
'**/node_modules/**'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
vscode.window.withProgress(
|
|
49
|
+
{
|
|
50
|
+
location: vscode.ProgressLocation.Notification,
|
|
51
|
+
title: 'drift: Scanning workspace...',
|
|
52
|
+
cancellable: false,
|
|
53
|
+
},
|
|
54
|
+
async (progress) => {
|
|
55
|
+
const total = files.length
|
|
56
|
+
let done = 0
|
|
57
|
+
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const report = await analyzeFilePath(file.fsPath)
|
|
60
|
+
if (report) {
|
|
61
|
+
diagnostics.update(report)
|
|
62
|
+
treeProvider.updateFile(report)
|
|
63
|
+
reportCache.set(file.fsPath, report)
|
|
64
|
+
}
|
|
65
|
+
done++
|
|
66
|
+
progress.report({ increment: (done / total) * 100 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
statusBar.update(Array.from(reportCache.values()))
|
|
70
|
+
vscode.window.showInformationMessage(`drift: ${total} files scanned.`)
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clearDiagnostics(
|
|
76
|
+
diagnostics: DriftDiagnosticsProvider,
|
|
77
|
+
treeProvider: DriftTreeProvider,
|
|
78
|
+
reportCache: Map<string, FileReport>,
|
|
79
|
+
statusBar: DriftStatusBarItem
|
|
80
|
+
): void {
|
|
81
|
+
diagnostics.clear()
|
|
82
|
+
treeProvider.clearAll()
|
|
83
|
+
reportCache.clear()
|
|
84
|
+
statusBar.update([])
|
|
85
|
+
}
|
|
86
|
+
|
|
10
87
|
export function activate(context: vscode.ExtensionContext): void {
|
|
11
88
|
const diagnostics = new DriftDiagnosticsProvider()
|
|
12
89
|
const treeProvider = new DriftTreeProvider()
|
|
13
90
|
const statusBar = new DriftStatusBarItem()
|
|
91
|
+
const codeActions = new DriftCodeActionProvider()
|
|
14
92
|
|
|
15
|
-
// Registrar TreeView
|
|
16
93
|
const treeView = vscode.window.createTreeView('driftIssues', {
|
|
17
94
|
treeDataProvider: treeProvider,
|
|
18
95
|
showCollapseAll: true,
|
|
19
96
|
})
|
|
20
97
|
|
|
21
|
-
// Cache de reports para la status bar
|
|
22
98
|
const reportCache = new Map<string, FileReport>()
|
|
23
99
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!SUPPORTED_LANGUAGES.includes(document.languageId)) return
|
|
29
|
-
if (document.uri.scheme !== 'file') return
|
|
30
|
-
|
|
31
|
-
const filePath = document.uri.fsPath
|
|
32
|
-
|
|
33
|
-
const report = await analyzeFilePath(filePath)
|
|
34
|
-
if (!report) return
|
|
35
|
-
|
|
36
|
-
diagnostics.update(report)
|
|
37
|
-
treeProvider.updateFile(report)
|
|
38
|
-
reportCache.set(filePath, report)
|
|
39
|
-
statusBar.update(Array.from(reportCache.values()))
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Trigger: al guardar
|
|
43
|
-
const onSave = vscode.workspace.onDidSaveTextDocument(analyzeAndUpdate)
|
|
44
|
-
|
|
45
|
-
// Comando: scan workspace
|
|
46
|
-
const scanCmd = vscode.commands.registerCommand('drift.scanWorkspace', async () => {
|
|
47
|
-
const files = await vscode.workspace.findFiles(
|
|
48
|
-
'**/*.{ts,tsx,js,jsx}',
|
|
49
|
-
'**/node_modules/**'
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
vscode.window.withProgress(
|
|
53
|
-
{
|
|
54
|
-
location: vscode.ProgressLocation.Notification,
|
|
55
|
-
title: 'drift: Scanning workspace...',
|
|
56
|
-
cancellable: false,
|
|
57
|
-
},
|
|
58
|
-
async (progress) => {
|
|
59
|
-
const total = files.length
|
|
60
|
-
let done = 0
|
|
61
|
-
|
|
62
|
-
for (const file of files) {
|
|
63
|
-
const report = await analyzeFilePath(file.fsPath)
|
|
64
|
-
if (report) {
|
|
65
|
-
diagnostics.update(report)
|
|
66
|
-
treeProvider.updateFile(report)
|
|
67
|
-
reportCache.set(file.fsPath, report)
|
|
68
|
-
}
|
|
69
|
-
done++
|
|
70
|
-
progress.report({ increment: (done / total) * 100 })
|
|
71
|
-
}
|
|
100
|
+
const onSave = vscode.workspace.onDidSaveTextDocument(
|
|
101
|
+
(doc) => analyzeAndUpdate(doc, diagnostics, treeProvider, reportCache, statusBar)
|
|
102
|
+
)
|
|
72
103
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
104
|
+
const scanCmd = vscode.commands.registerCommand(
|
|
105
|
+
'drift.scanWorkspace',
|
|
106
|
+
() => scanWorkspace(diagnostics, treeProvider, reportCache, statusBar)
|
|
107
|
+
)
|
|
78
108
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
reportCache.clear()
|
|
84
|
-
statusBar.update([])
|
|
85
|
-
})
|
|
109
|
+
const clearCmd = vscode.commands.registerCommand(
|
|
110
|
+
'drift.clearDiagnostics',
|
|
111
|
+
() => clearDiagnostics(diagnostics, treeProvider, reportCache, statusBar)
|
|
112
|
+
)
|
|
86
113
|
|
|
87
|
-
// Comando: go to issue (desde TreeView click)
|
|
88
114
|
const goToCmd = vscode.commands.registerCommand(
|
|
89
115
|
'drift.goToIssue',
|
|
90
116
|
async (filePath: string, line: number) => {
|
|
@@ -97,6 +123,14 @@ export function activate(context: vscode.ExtensionContext): void {
|
|
|
97
123
|
}
|
|
98
124
|
)
|
|
99
125
|
|
|
126
|
+
const codeActionRegistration = vscode.languages.registerCodeActionsProvider(
|
|
127
|
+
SUPPORTED_LANGUAGES.map((language) => ({ language })),
|
|
128
|
+
codeActions,
|
|
129
|
+
{
|
|
130
|
+
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
100
134
|
context.subscriptions.push(
|
|
101
135
|
{ dispose: () => diagnostics.dispose() },
|
|
102
136
|
{ dispose: () => statusBar.dispose() },
|
|
@@ -105,6 +139,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|
|
105
139
|
scanCmd,
|
|
106
140
|
clearCmd,
|
|
107
141
|
goToCmd,
|
|
142
|
+
codeActionRegistration,
|
|
108
143
|
)
|
|
109
144
|
}
|
|
110
145
|
|
|
@@ -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) => {
|