@eduardbar/drift 1.1.0 → 1.3.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 (66) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. package/tests/trust.test.ts +584 -0
package/src/types.ts CHANGED
@@ -97,6 +97,137 @@ export interface AIIssue {
97
97
  effort: 'low' | 'medium' | 'high'
98
98
  }
99
99
 
100
+ export type MergeRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
101
+
102
+ export interface TrustGatePolicyPreset {
103
+ branch: string
104
+ enabled?: boolean
105
+ minTrust?: number
106
+ maxRisk?: MergeRiskLevel
107
+ }
108
+
109
+ export interface TrustGatePolicyPack {
110
+ enabled?: boolean
111
+ minTrust?: number
112
+ maxRisk?: MergeRiskLevel
113
+ }
114
+
115
+ export interface TrustGatePolicyConfig {
116
+ enabled?: boolean
117
+ minTrust?: number
118
+ maxRisk?: MergeRiskLevel
119
+ presets?: TrustGatePolicyPreset[]
120
+ policyPacks?: Record<string, TrustGatePolicyPack>
121
+ }
122
+
123
+ export interface TrustReason {
124
+ label: string
125
+ detail: string
126
+ impact: number
127
+ }
128
+
129
+ export interface TrustFixPriority {
130
+ rank: number
131
+ rule: string
132
+ severity: DriftIssue['severity']
133
+ occurrences: number
134
+ estimated_trust_gain: number
135
+ effort: 'low' | 'medium' | 'high'
136
+ suggestion: string
137
+ confidence?: 'low' | 'medium' | 'high'
138
+ explanation?: string
139
+ systemic?: boolean
140
+ }
141
+
142
+ export interface TrustAdvancedComparison {
143
+ source: 'previous-trust-json' | 'snapshot-history'
144
+ trend: 'improving' | 'regressing' | 'stable'
145
+ summary: string
146
+ trust_delta?: number
147
+ previous_trust_score?: number
148
+ previous_merge_risk?: MergeRiskLevel
149
+ snapshot_score_delta?: number
150
+ snapshot_label?: string
151
+ snapshot_timestamp?: string
152
+ }
153
+
154
+ export interface TrustAdvancedContext {
155
+ comparison?: TrustAdvancedComparison
156
+ team_guidance: string[]
157
+ }
158
+
159
+ export interface TrustDiffContext {
160
+ baseRef: string
161
+ status: 'improved' | 'regressed' | 'neutral'
162
+ scoreDelta: number
163
+ newIssues: number
164
+ resolvedIssues: number
165
+ filesChanged: number
166
+ penalty: number
167
+ bonus: number
168
+ netImpact: number
169
+ }
170
+
171
+ export interface DriftTrustReport {
172
+ scannedAt: string
173
+ targetPath: string
174
+ trust_score: number
175
+ merge_risk: MergeRiskLevel
176
+ top_reasons: TrustReason[]
177
+ fix_priorities: TrustFixPriority[]
178
+ diff_context?: TrustDiffContext
179
+ advanced_context?: TrustAdvancedContext
180
+ }
181
+
182
+ export interface TrustKpiDiagnostic {
183
+ level: 'warning' | 'error'
184
+ code: 'path-not-found' | 'path-not-supported' | 'read-failed' | 'parse-failed' | 'invalid-shape' | 'invalid-diff-context'
185
+ message: string
186
+ file?: string
187
+ }
188
+
189
+ export interface TrustScoreStats {
190
+ average: number | null
191
+ median: number | null
192
+ min: number | null
193
+ max: number | null
194
+ }
195
+
196
+ export interface TrustDiffTrendSummary {
197
+ available: boolean
198
+ samples: number
199
+ statusDistribution: {
200
+ improved: number
201
+ regressed: number
202
+ neutral: number
203
+ }
204
+ scoreDelta: {
205
+ average: number | null
206
+ median: number | null
207
+ }
208
+ issues: {
209
+ newTotal: number
210
+ resolvedTotal: number
211
+ netNew: number
212
+ }
213
+ }
214
+
215
+ export interface TrustKpiReport {
216
+ generatedAt: string
217
+ input: string
218
+ files: {
219
+ matched: number
220
+ parsed: number
221
+ malformed: number
222
+ }
223
+ prsEvaluated: number
224
+ mergeRiskDistribution: Record<MergeRiskLevel, number>
225
+ trustScore: TrustScoreStats
226
+ highRiskRatio: number | null
227
+ diffTrend: TrustDiffTrendSummary
228
+ diagnostics: TrustKpiDiagnostic[]
229
+ }
230
+
100
231
  // ---------------------------------------------------------------------------
101
232
  // Configuration
102
233
  // ---------------------------------------------------------------------------
@@ -127,11 +258,42 @@ export interface DriftConfig {
127
258
  layers?: LayerDefinition[]
128
259
  modules?: ModuleBoundary[]
129
260
  plugins?: string[]
261
+ performance?: DriftPerformanceConfig
130
262
  architectureRules?: {
131
263
  controllerNoDb?: boolean
132
264
  serviceNoHttp?: boolean
133
265
  maxFunctionLines?: number
134
266
  }
267
+ saas?: {
268
+ freeUserThreshold?: number
269
+ maxRunsPerWorkspacePerMonth?: number
270
+ maxReposPerWorkspace?: number
271
+ retentionDays?: number
272
+ strictActorEnforcement?: boolean
273
+ maxWorkspacesPerOrganizationByPlan?: {
274
+ free?: number
275
+ sponsor?: number
276
+ team?: number
277
+ business?: number
278
+ }
279
+ }
280
+ trustGate?: TrustGatePolicyConfig
281
+ }
282
+
283
+ export interface DriftPerformanceConfig {
284
+ lowMemory?: boolean
285
+ chunkSize?: number
286
+ maxFiles?: number
287
+ maxFileSizeKb?: number
288
+ includeSemanticDuplication?: boolean
289
+ }
290
+
291
+ export interface DriftAnalysisOptions {
292
+ lowMemory?: boolean
293
+ chunkSize?: number
294
+ maxFiles?: number
295
+ maxFileSizeKb?: number
296
+ includeSemanticDuplication?: boolean
135
297
  }
136
298
 
137
299
  export interface PluginRuleContext {
@@ -141,14 +303,18 @@ export interface PluginRuleContext {
141
303
  }
142
304
 
143
305
  export interface DriftPluginRule {
306
+ id?: string
144
307
  name: string
145
308
  severity?: DriftIssue['severity']
146
309
  weight?: number
147
310
  detect: (file: SourceFile, context: PluginRuleContext) => DriftIssue[]
311
+ fix?: (issue: DriftIssue, file: SourceFile, context: PluginRuleContext) => DriftIssue | void
148
312
  }
149
313
 
150
314
  export interface DriftPlugin {
151
315
  name: string
316
+ apiVersion?: number
317
+ capabilities?: Record<string, string | number | boolean>
152
318
  rules: DriftPluginRule[]
153
319
  }
154
320
 
@@ -159,6 +325,17 @@ export interface LoadedPlugin {
159
325
 
160
326
  export interface PluginLoadError {
161
327
  pluginId: string
328
+ pluginName?: string
329
+ ruleId?: string
330
+ code?: string
331
+ message: string
332
+ }
333
+
334
+ export interface PluginLoadWarning {
335
+ pluginId: string
336
+ pluginName?: string
337
+ ruleId?: string
338
+ code?: string
162
339
  message: string
163
340
  }
164
341
 
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeDiff } from '../src/diff.js'
3
+ import type { DriftIssue, DriftReport } from '../src/types.js'
4
+
5
+ function createReport(pathPrefix: string): DriftReport {
6
+ return {
7
+ scannedAt: new Date().toISOString(),
8
+ targetPath: pathPrefix,
9
+ files: [
10
+ {
11
+ path: `${pathPrefix}/src/a.ts`,
12
+ score: 10,
13
+ issues: [
14
+ {
15
+ rule: 'magic-number',
16
+ severity: 'info',
17
+ message: 'Magic number 42 used directly in logic. Extract to a named constant.',
18
+ line: 4,
19
+ column: 10,
20
+ snippet: 'const answer = 42',
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ totalIssues: 1,
26
+ totalScore: 10,
27
+ totalFiles: 1,
28
+ summary: {
29
+ errors: 0,
30
+ warnings: 0,
31
+ infos: 1,
32
+ byRule: {
33
+ 'magic-number': 1,
34
+ },
35
+ },
36
+ quality: {
37
+ overall: 90,
38
+ dimensions: {
39
+ architecture: 100,
40
+ complexity: 90,
41
+ 'ai-patterns': 90,
42
+ testing: 100,
43
+ },
44
+ },
45
+ maintenanceRisk: {
46
+ score: 10,
47
+ level: 'low',
48
+ hotspots: [],
49
+ signals: {
50
+ highComplexityFiles: 0,
51
+ filesWithoutNearbyTests: 0,
52
+ frequentChangeFiles: 0,
53
+ },
54
+ },
55
+ }
56
+ }
57
+
58
+ function withIssues(report: DriftReport, issues: DriftIssue[]): DriftReport {
59
+ return {
60
+ ...report,
61
+ files: report.files.map((file, index) => {
62
+ if (index !== 0) return file
63
+ return {
64
+ ...file,
65
+ issues,
66
+ }
67
+ }),
68
+ totalIssues: issues.length,
69
+ }
70
+ }
71
+
72
+ describe('computeDiff', () => {
73
+ it('treats slash and backslash file paths as the same file', () => {
74
+ const base = createReport('C:/repo')
75
+ const current = createReport('C:\\repo')
76
+
77
+ const diff = computeDiff(base, current, 'origin/main')
78
+
79
+ expect(diff.files).toHaveLength(0)
80
+ expect(diff.newIssuesCount).toBe(0)
81
+ expect(diff.resolvedIssuesCount).toBe(0)
82
+ expect(diff.totalDelta).toBe(0)
83
+ })
84
+
85
+ it('does not create churn for LF vs CRLF snippets and column noise', () => {
86
+ const base = createReport('C:/repo')
87
+ const current = withIssues(createReport('C:/repo'), [
88
+ {
89
+ ...base.files[0].issues[0],
90
+ column: 12,
91
+ snippet: 'const answer = 42\r\n',
92
+ },
93
+ ])
94
+
95
+ const diff = computeDiff(base, current, 'origin/main')
96
+
97
+ expect(diff.files).toHaveLength(0)
98
+ expect(diff.newIssuesCount).toBe(0)
99
+ expect(diff.resolvedIssuesCount).toBe(0)
100
+ expect(diff.totalDelta).toBe(0)
101
+ })
102
+
103
+ it('still detects genuinely new issues', () => {
104
+ const base = createReport('C:/repo')
105
+ const current = withIssues(createReport('C:/repo'), [
106
+ ...base.files[0].issues,
107
+ {
108
+ rule: 'any-abuse',
109
+ severity: 'warning',
110
+ message: 'Avoid using any type. Use a specific type or unknown and narrow it safely.',
111
+ line: 6,
112
+ column: 9,
113
+ snippet: 'const value: any = source',
114
+ },
115
+ ])
116
+
117
+ const diff = computeDiff(base, current, 'origin/main')
118
+
119
+ expect(diff.files).toHaveLength(1)
120
+ expect(diff.newIssuesCount).toBe(1)
121
+ expect(diff.resolvedIssuesCount).toBe(0)
122
+ expect(diff.files[0]?.newIssues[0]?.rule).toBe('any-abuse')
123
+ })
124
+ })
@@ -125,6 +125,33 @@ describe('new feature MVP', () => {
125
125
  expect(svg).toContain('domain')
126
126
  })
127
127
 
128
+ it('marks cycle and layer violation edges in architecture SVG', () => {
129
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-map-flags-'))
130
+ mkdirSync(join(tmpDir, 'ui'))
131
+ mkdirSync(join(tmpDir, 'api'))
132
+
133
+ writeFileSync(join(tmpDir, 'ui', 'a.ts'), "import { b } from '../api/b.js'\nexport const a = b\n")
134
+ writeFileSync(join(tmpDir, 'api', 'b.ts'), "import { a } from '../ui/a.js'\nexport const b = a\n")
135
+
136
+ const svg = generateArchitectureSvg(tmpDir, {
137
+ layers: [
138
+ {
139
+ name: 'ui',
140
+ patterns: [`${tmpDir.replace(/\\/g, '/')}/ui/**`],
141
+ canImportFrom: ['api'],
142
+ },
143
+ {
144
+ name: 'api',
145
+ patterns: [`${tmpDir.replace(/\\/g, '/')}/api/**`],
146
+ canImportFrom: [],
147
+ },
148
+ ],
149
+ })
150
+
151
+ expect(svg).toContain('data-kind="cycle"')
152
+ expect(svg).toContain('data-kind="violation"')
153
+ })
154
+
128
155
  it('falls back safely when plugin cannot be loaded', () => {
129
156
  tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-fallback-'))
130
157
  writeFileSync(join(tmpDir, 'index.ts'), 'export const x = 1\n')
@@ -150,4 +177,75 @@ describe('new feature MVP', () => {
150
177
  expect(write.length).toBeGreaterThan(0)
151
178
  expect(readFileSync(file, 'utf8')).not.toContain('console.log')
152
179
  })
180
+
181
+ it('supports low-memory chunked analysis with cross-file rules', () => {
182
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-low-memory-'))
183
+ writeFileSync(join(tmpDir, 'a.ts'), "import { b } from './b.js'\nexport const a = b\n")
184
+ writeFileSync(join(tmpDir, 'b.ts'), "import { a } from './a.js'\nexport const b = a\n")
185
+
186
+ const fullRules = new Set(analyzeProject(tmpDir).flatMap((report) => report.issues.map((issue) => issue.rule)))
187
+ const lowMemoryRules = new Set(
188
+ analyzeProject(tmpDir, undefined, { lowMemory: true, chunkSize: 1, includeSemanticDuplication: true })
189
+ .flatMap((report) => report.issues.map((issue) => issue.rule)),
190
+ )
191
+
192
+ expect(fullRules.has('circular-dependency')).toBe(true)
193
+ expect(lowMemoryRules.has('circular-dependency')).toBe(true)
194
+ })
195
+
196
+ it('adds diagnostics when max file size guardrail skips files', () => {
197
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-max-file-size-'))
198
+ writeFileSync(join(tmpDir, 'small.ts'), 'export const ok = 1\n')
199
+ writeFileSync(join(tmpDir, 'big.ts'), `export const payload = '${'x'.repeat(5000)}'\n`)
200
+
201
+ const reports = analyzeProject(tmpDir, undefined, { maxFileSizeKb: 1 })
202
+ const skipIssues = reports.flatMap((report) => report.issues.filter((issue) => issue.rule === 'analysis-skip-file-size'))
203
+
204
+ expect(skipIssues.length).toBeGreaterThan(0)
205
+ expect(skipIssues[0].message).toContain('maxFileSizeKb')
206
+ })
207
+
208
+ it('adds diagnostics when max files guardrail skips files', () => {
209
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-max-files-'))
210
+ writeFileSync(join(tmpDir, 'a.ts'), 'export const a = 1\n')
211
+ writeFileSync(join(tmpDir, 'b.ts'), 'export const b = 2\n')
212
+ writeFileSync(join(tmpDir, 'c.ts'), 'export const c = 3\n')
213
+
214
+ const reports = analyzeProject(tmpDir, undefined, { maxFiles: 1 })
215
+ const skipped = reports.flatMap((report) => report.issues.filter((issue) => issue.rule === 'analysis-skip-max-files'))
216
+
217
+ expect(skipped).toHaveLength(2)
218
+ })
219
+
220
+ it('disables semantic duplication by default in low-memory mode but keeps opt-in', () => {
221
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-low-memory-semantic-'))
222
+ const functionA = [
223
+ 'export function same(x: number): number {',
224
+ ' const a = x + 1',
225
+ ' const b = a * 2',
226
+ ' const c = b - 3',
227
+ ' const d = c + 4',
228
+ ' const e = d * 5',
229
+ ' const f = e - 6',
230
+ ' const g = f + 7',
231
+ ' return g',
232
+ '}',
233
+ ].join('\n')
234
+ const functionB = functionA
235
+ .replace('same', 'same2')
236
+ .replace(/\bx\b/g, 'n')
237
+
238
+ writeFileSync(join(tmpDir, 'a.ts'), `${functionA}\n`)
239
+ writeFileSync(join(tmpDir, 'b.ts'), `${functionB}\n`)
240
+
241
+ const lowMemoryDefault = analyzeProject(tmpDir, undefined, { lowMemory: true })
242
+ .flatMap((report) => report.issues.map((issue) => issue.rule))
243
+ const lowMemoryWithSemantic = analyzeProject(tmpDir, undefined, {
244
+ lowMemory: true,
245
+ includeSemanticDuplication: true,
246
+ }).flatMap((report) => report.issues.map((issue) => issue.rule))
247
+
248
+ expect(lowMemoryDefault).not.toContain('semantic-duplication')
249
+ expect(lowMemoryWithSemantic).toContain('semantic-duplication')
250
+ })
153
251
  })
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { analyzeProject } from '../src/analyzer.js'
6
+
7
+ describe('plugin contract hardening', () => {
8
+ let tmpDir = ''
9
+
10
+ afterEach(() => {
11
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
12
+ tmpDir = ''
13
+ })
14
+
15
+ it('keeps legacy plugins compatible when apiVersion is missing', () => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-legacy-compatible-'))
17
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const foo = 1\n')
18
+ writeFileSync(join(tmpDir, 'legacy-plugin.js'), [
19
+ 'module.exports = {',
20
+ " name: 'legacy-plugin',",
21
+ ' rules: [',
22
+ ' {',
23
+ " id: 'no-foo-export',",
24
+ " severity: 'error',",
25
+ ' weight: 12,',
26
+ ' detect(file) {',
27
+ " if (!file.getFullText().includes('foo')) return []",
28
+ ' return [{',
29
+ " message: 'Avoid exporting foo',",
30
+ ' line: 1,',
31
+ ' column: 1,',
32
+ " snippet: 'export const foo = 1',",
33
+ ' }]',
34
+ ' }',
35
+ ' },',
36
+ ' {',
37
+ " id: 'Legacy Rule Name',",
38
+ ' detect() { return [] }',
39
+ ' }',
40
+ ' ]',
41
+ '}',
42
+ ].join('\n'))
43
+
44
+ const reports = analyzeProject(tmpDir, {
45
+ plugins: ['./legacy-plugin.js'],
46
+ })
47
+
48
+ const allIssues = reports.flatMap((report) => report.issues)
49
+ expect(allIssues.some((issue) => issue.rule === 'legacy-plugin/no-foo-export')).toBe(true)
50
+ expect(allIssues.some((issue) => issue.rule === 'plugin-error')).toBe(false)
51
+ expect(allIssues.some((issue) => issue.rule === 'plugin-warning' && issue.message.includes('[plugin-api-version-implicit]'))).toBe(true)
52
+ expect(allIssues.some((issue) => issue.rule === 'plugin-warning' && issue.message.includes('[plugin-rule-id-format-legacy]'))).toBe(true)
53
+ })
54
+
55
+ it('reports actionable diagnostics for invalid plugin contract', () => {
56
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-invalid-contract-'))
57
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
58
+ writeFileSync(join(tmpDir, 'broken-plugin.js'), [
59
+ 'module.exports = {',
60
+ " name: 'broken-plugin',",
61
+ " apiVersion: 1,",
62
+ ' rules: [',
63
+ ' {',
64
+ " name: 'broken rule id',",
65
+ " severity: 'fatal',",
66
+ ' weight: 999,',
67
+ " detect: 'not-a-function',",
68
+ ' }',
69
+ ' ]',
70
+ '}',
71
+ ].join('\n'))
72
+
73
+ const reports = analyzeProject(tmpDir, {
74
+ plugins: ['./broken-plugin.js'],
75
+ })
76
+
77
+ const pluginIssues = reports
78
+ .flatMap((report) => report.issues)
79
+ .filter((issue) => issue.rule === 'plugin-error')
80
+
81
+ expect(pluginIssues.length).toBeGreaterThan(0)
82
+ expect(pluginIssues.some((issue) => issue.message.includes('broken-plugin.js'))).toBe(true)
83
+ expect(pluginIssues.some((issue) => issue.message.includes('[plugin-rule-detect-invalid]'))).toBe(true)
84
+ })
85
+
86
+ it('rejects plugins with unsupported apiVersion', () => {
87
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-version-mismatch-'))
88
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
89
+ writeFileSync(join(tmpDir, 'version-mismatch-plugin.js'), [
90
+ 'module.exports = {',
91
+ " name: 'version-mismatch-plugin',",
92
+ ' apiVersion: 99,',
93
+ ' rules: [',
94
+ ' {',
95
+ " id: 'valid-rule-id',",
96
+ ' detect() { return [] }',
97
+ ' }',
98
+ ' ]',
99
+ '}',
100
+ ].join('\n'))
101
+
102
+ const reports = analyzeProject(tmpDir, {
103
+ plugins: ['./version-mismatch-plugin.js'],
104
+ })
105
+
106
+ const issues = reports.flatMap((report) => report.issues)
107
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-api-version-unsupported]'))).toBe(true)
108
+ expect(issues.some((issue) => issue.rule === 'version-mismatch-plugin/valid-rule-id')).toBe(false)
109
+ })
110
+
111
+ it('rejects plugins with invalid apiVersion format', () => {
112
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-version-invalid-'))
113
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
114
+ writeFileSync(join(tmpDir, 'version-invalid-plugin.js'), [
115
+ 'module.exports = {',
116
+ " name: 'version-invalid-plugin',",
117
+ " apiVersion: '1',",
118
+ ' rules: [',
119
+ ' {',
120
+ " id: 'valid-rule-id',",
121
+ ' detect() { return [] }',
122
+ ' }',
123
+ ' ]',
124
+ '}',
125
+ ].join('\n'))
126
+
127
+ const reports = analyzeProject(tmpDir, {
128
+ plugins: ['./version-invalid-plugin.js'],
129
+ })
130
+
131
+ const issues = reports.flatMap((report) => report.issues)
132
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-api-version-invalid]'))).toBe(true)
133
+ expect(issues.some((issue) => issue.rule === 'version-invalid-plugin/valid-rule-id')).toBe(false)
134
+ })
135
+
136
+ it('rejects duplicate rule IDs within the same plugin', () => {
137
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-duplicate-rules-'))
138
+ writeFileSync(join(tmpDir, 'index.ts'), 'export const ok = true\n')
139
+ writeFileSync(join(tmpDir, 'duplicate-rules-plugin.js'), [
140
+ 'module.exports = {',
141
+ " name: 'duplicate-rules-plugin',",
142
+ ' apiVersion: 1,',
143
+ ' rules: [',
144
+ ' {',
145
+ " id: 'duplicate-rule',",
146
+ ' detect() {',
147
+ ' return [{',
148
+ " message: 'first duplicate still runs',",
149
+ ' line: 1,',
150
+ ' column: 1,',
151
+ " snippet: 'export const ok = true',",
152
+ ' }]',
153
+ ' }',
154
+ ' },',
155
+ ' {',
156
+ " id: 'duplicate-rule',",
157
+ ' detect() { return [] }',
158
+ ' }',
159
+ ' ]',
160
+ '}',
161
+ ].join('\n'))
162
+
163
+ const reports = analyzeProject(tmpDir, {
164
+ plugins: ['./duplicate-rules-plugin.js'],
165
+ })
166
+
167
+ const issues = reports.flatMap((report) => report.issues)
168
+ expect(issues.some((issue) => issue.rule === 'plugin-error' && issue.message.includes('[plugin-rule-id-duplicate]'))).toBe(true)
169
+ expect(issues.some((issue) => issue.rule === 'duplicate-rules-plugin/duplicate-rule')).toBe(true)
170
+ })
171
+
172
+ it('isolates plugin runtime failures and continues analysis', () => {
173
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-plugin-runtime-isolation-'))
174
+ writeFileSync(join(tmpDir, 'index.ts'), [
175
+ 'export function run(input: any) {',
176
+ ' return input',
177
+ '}',
178
+ ].join('\n'))
179
+ writeFileSync(join(tmpDir, 'mixed-plugin.js'), [
180
+ 'module.exports = {',
181
+ " name: 'mixed-plugin',",
182
+ ' apiVersion: 1,',
183
+ ' capabilities: {',
184
+ ' fixes: true,',
185
+ ' runtimeSafe: true',
186
+ ' },',
187
+ ' rules: [',
188
+ ' {',
189
+ " name: 'throwing-rule',",
190
+ ' detect() {',
191
+ " throw new Error('boom')",
192
+ ' }',
193
+ ' },',
194
+ ' {',
195
+ " name: 'safe-rule',",
196
+ ' detect() {',
197
+ ' return [{',
198
+ " message: 'Safe rule still runs',",
199
+ ' line: 1,',
200
+ ' column: 1,',
201
+ " snippet: 'export function run(input: any)',",
202
+ ' }]',
203
+ ' }',
204
+ ' }',
205
+ ' ]',
206
+ '}',
207
+ ].join('\n'))
208
+
209
+ const reports = analyzeProject(tmpDir, {
210
+ plugins: ['./mixed-plugin.js'],
211
+ })
212
+
213
+ const rules = reports.flatMap((report) => report.issues.map((issue) => issue.rule))
214
+ expect(rules).toContain('mixed-plugin/safe-rule')
215
+ expect(rules).toContain('plugin-error')
216
+ expect(rules).toContain('any-abuse')
217
+ expect(rules).not.toContain('plugin-warning')
218
+ })
219
+ })