@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.
Files changed (105) hide show
  1. package/.github/actions/drift-scan/README.md +61 -0
  2. package/.github/actions/drift-scan/action.yml +65 -0
  3. package/.github/workflows/publish-vscode.yml +3 -1
  4. package/.github/workflows/review-pr.yml +61 -0
  5. package/AGENTS.md +53 -11
  6. package/README.md +106 -1
  7. package/dist/analyzer.d.ts +6 -2
  8. package/dist/analyzer.js +116 -3
  9. package/dist/badge.js +40 -22
  10. package/dist/ci.js +32 -18
  11. package/dist/cli.js +179 -6
  12. package/dist/diff.d.ts +0 -7
  13. package/dist/diff.js +26 -25
  14. package/dist/fix.d.ts +4 -0
  15. package/dist/fix.js +59 -47
  16. package/dist/git/trend.js +1 -0
  17. package/dist/git.d.ts +0 -9
  18. package/dist/git.js +25 -19
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.js +4 -0
  21. package/dist/map.d.ts +4 -0
  22. package/dist/map.js +191 -0
  23. package/dist/metrics.d.ts +4 -0
  24. package/dist/metrics.js +176 -0
  25. package/dist/plugins.d.ts +6 -0
  26. package/dist/plugins.js +74 -0
  27. package/dist/printer.js +20 -0
  28. package/dist/report.js +34 -0
  29. package/dist/reporter.js +85 -2
  30. package/dist/review.d.ts +15 -0
  31. package/dist/review.js +80 -0
  32. package/dist/rules/comments.d.ts +4 -0
  33. package/dist/rules/comments.js +45 -0
  34. package/dist/rules/complexity.d.ts +4 -0
  35. package/dist/rules/complexity.js +51 -0
  36. package/dist/rules/coupling.d.ts +4 -0
  37. package/dist/rules/coupling.js +19 -0
  38. package/dist/rules/magic.d.ts +4 -0
  39. package/dist/rules/magic.js +33 -0
  40. package/dist/rules/nesting.d.ts +5 -0
  41. package/dist/rules/nesting.js +82 -0
  42. package/dist/rules/phase0-basic.js +14 -7
  43. package/dist/rules/phase1-complexity.d.ts +6 -30
  44. package/dist/rules/phase1-complexity.js +7 -276
  45. package/dist/rules/phase2-crossfile.d.ts +0 -4
  46. package/dist/rules/phase2-crossfile.js +52 -39
  47. package/dist/rules/phase3-arch.d.ts +0 -8
  48. package/dist/rules/phase3-arch.js +26 -23
  49. package/dist/rules/phase3-configurable.d.ts +6 -0
  50. package/dist/rules/phase3-configurable.js +97 -0
  51. package/dist/rules/phase8-semantic.d.ts +0 -5
  52. package/dist/rules/phase8-semantic.js +30 -29
  53. package/dist/rules/promise.d.ts +4 -0
  54. package/dist/rules/promise.js +24 -0
  55. package/dist/saas.d.ts +83 -0
  56. package/dist/saas.js +321 -0
  57. package/dist/snapshot.d.ts +19 -0
  58. package/dist/snapshot.js +119 -0
  59. package/dist/types.d.ts +75 -0
  60. package/dist/utils.d.ts +2 -1
  61. package/dist/utils.js +1 -0
  62. package/docs/AGENTS.md +146 -0
  63. package/docs/PRD.md +157 -0
  64. package/package.json +1 -1
  65. package/packages/eslint-plugin-drift/src/index.ts +1 -1
  66. package/packages/vscode-drift/package.json +1 -1
  67. package/packages/vscode-drift/src/analyzer.ts +2 -0
  68. package/packages/vscode-drift/src/code-actions.ts +53 -0
  69. package/packages/vscode-drift/src/extension.ts +98 -63
  70. package/packages/vscode-drift/src/statusbar.ts +13 -5
  71. package/packages/vscode-drift/src/treeview.ts +2 -0
  72. package/src/analyzer.ts +144 -12
  73. package/src/badge.ts +38 -16
  74. package/src/ci.ts +38 -17
  75. package/src/cli.ts +206 -7
  76. package/src/diff.ts +36 -30
  77. package/src/fix.ts +77 -53
  78. package/src/git/trend.ts +3 -2
  79. package/src/git.ts +31 -22
  80. package/src/index.ts +31 -1
  81. package/src/map.ts +219 -0
  82. package/src/metrics.ts +200 -0
  83. package/src/plugins.ts +76 -0
  84. package/src/printer.ts +20 -0
  85. package/src/report.ts +35 -0
  86. package/src/reporter.ts +95 -2
  87. package/src/review.ts +98 -0
  88. package/src/rules/comments.ts +56 -0
  89. package/src/rules/complexity.ts +57 -0
  90. package/src/rules/coupling.ts +23 -0
  91. package/src/rules/magic.ts +38 -0
  92. package/src/rules/nesting.ts +88 -0
  93. package/src/rules/phase0-basic.ts +14 -7
  94. package/src/rules/phase1-complexity.ts +8 -302
  95. package/src/rules/phase2-crossfile.ts +68 -40
  96. package/src/rules/phase3-arch.ts +34 -30
  97. package/src/rules/phase3-configurable.ts +132 -0
  98. package/src/rules/phase8-semantic.ts +33 -29
  99. package/src/rules/promise.ts +29 -0
  100. package/src/saas.ts +433 -0
  101. package/src/snapshot.ts +175 -0
  102. package/src/types.ts +81 -1
  103. package/src/utils.ts +3 -1
  104. package/tests/new-features.test.ts +180 -0
  105. 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
- async function analyzeAndUpdate(document: vscode.TextDocument): Promise<void> {
25
- const config = vscode.workspace.getConfiguration('drift')
26
- if (!config.get<boolean>('enable', true)) return
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
- statusBar.update(Array.from(reportCache.values()))
74
- vscode.window.showInformationMessage(`drift: ${total} files scanned.`)
75
- }
76
- )
77
- })
104
+ const scanCmd = vscode.commands.registerCommand(
105
+ 'drift.scanWorkspace',
106
+ () => scanWorkspace(diagnostics, treeProvider, reportCache, statusBar)
107
+ )
78
108
 
79
- // Comando: clear
80
- const clearCmd = vscode.commands.registerCommand('drift.clearDiagnostics', () => {
81
- diagnostics.clear()
82
- treeProvider.clearAll()
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.window.createStatusBarItem(
16
+ this.item = vscode.createStatusBarItem(
9
17
  vscode.StatusBarAlignment.Right,
10
- 100
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 < 50 ? '$(warning)' : '$(check)'
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 < 30) {
41
+ if (hasErrors || totalScore < SCORE_THRESHOLDS.ERROR) {
34
42
  this.item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground')
35
- } else if (totalScore < 60) {
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
@@ -1,3 +1,5 @@
1
+ // drift-ignore-file
2
+
1
3
  import * as vscode from 'vscode'
2
4
  import * as path from 'path'
3
5
  import type { FileReport } from '@eduardbar/drift'
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
- detectHighComplexity,
20
- detectDeepNesting,
21
- detectTooManyParams,
22
- detectHighCoupling,
23
- detectPromiseStyleMix,
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(file: import('ts-morph').SourceFile): FileReport {
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 < 20) return '#4c1'
9
- if (score < 45) return '#dfb317'
10
- if (score < 70) return '#fe7d37'
11
- return '#e05d44'
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 < 20) return 'LOW'
16
- if (score < 45) return 'MODERATE'
17
- if (score < 70) return 'HIGH'
18
- return 'CRITICAL'
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
- // shields.io pattern: font-size="110" + scale(.1) = effective 11px
36
- // all X/Y coords are ×10
37
- const leftTextWidth = (LEFT_WIDTH - 10) * 10
38
- const rightTextWidth = (rWidth - PADDING) * 10
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="${leftCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
55
- <text x="${leftCenterX * 10}" y="140" transform="scale(.1)" textLength="${leftTextWidth}" lengthAdjust="spacing">drift</text>
56
- <text x="${rightCenterX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
57
- <text x="${rightCenterX * 10}" y="140" transform="scale(.1)" textLength="${rightTextWidth}" lengthAdjust="spacing">${valueText}</text>
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 >= 80) return 'A'
22
- if (score >= 60) return 'B'
23
- if (score >= 40) return 'C'
24
- if (score >= 20) return 'D'
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
- export function printCISummary(report: DriftReport): void {
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
- for (const issue of file.issues) {
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, 10)
83
+ .slice(0, TOP_FILES_LIMIT)
63
84
 
64
85
  const rows = sorted
65
86
  .map((f) => {