@eduardbar/drift 1.2.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 (61) 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 +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  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 +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. 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,6 +258,7 @@ 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
@@ -137,7 +269,31 @@ export interface DriftConfig {
137
269
  maxRunsPerWorkspacePerMonth?: number
138
270
  maxReposPerWorkspace?: number
139
271
  retentionDays?: number
272
+ strictActorEnforcement?: boolean
273
+ maxWorkspacesPerOrganizationByPlan?: {
274
+ free?: number
275
+ sponsor?: number
276
+ team?: number
277
+ business?: number
278
+ }
140
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
141
297
  }
142
298
 
143
299
  export interface PluginRuleContext {
@@ -147,14 +303,18 @@ export interface PluginRuleContext {
147
303
  }
148
304
 
149
305
  export interface DriftPluginRule {
306
+ id?: string
150
307
  name: string
151
308
  severity?: DriftIssue['severity']
152
309
  weight?: number
153
310
  detect: (file: SourceFile, context: PluginRuleContext) => DriftIssue[]
311
+ fix?: (issue: DriftIssue, file: SourceFile, context: PluginRuleContext) => DriftIssue | void
154
312
  }
155
313
 
156
314
  export interface DriftPlugin {
157
315
  name: string
316
+ apiVersion?: number
317
+ capabilities?: Record<string, string | number | boolean>
158
318
  rules: DriftPluginRule[]
159
319
  }
160
320
 
@@ -165,6 +325,17 @@ export interface LoadedPlugin {
165
325
 
166
326
  export interface PluginLoadError {
167
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
168
339
  message: string
169
340
  }
170
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
+ })
@@ -177,4 +177,75 @@ describe('new feature MVP', () => {
177
177
  expect(write.length).toBeGreaterThan(0)
178
178
  expect(readFileSync(file, 'utf8')).not.toContain('console.log')
179
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
+ })
180
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
+ })
@@ -124,6 +124,17 @@ describe('dead-code', () => {
124
124
  const code = `import fs from 'fs'\nconst x = 1`
125
125
  expect(getRules(code)).not.toContain('dead-code')
126
126
  })
127
+
128
+ it('keeps used named imports clean even with many identifiers', () => {
129
+ const code = [
130
+ `import { join } from 'path'`,
131
+ `const a = 'x'`,
132
+ `const b = 'y'`,
133
+ `const c = 'z'`,
134
+ `const p = join(a, b + c)`,
135
+ ].join('\n')
136
+ expect(getRules(code)).not.toContain('dead-code')
137
+ })
127
138
  })
128
139
 
129
140
  // ─────────────────────────────────────────────────────────────────────────────
@@ -262,6 +273,12 @@ describe('high-complexity', () => {
262
273
  }`
263
274
  expect(getRules(code)).toContain('high-complexity')
264
275
  })
276
+
277
+ it('detects high complexity in class methods', () => {
278
+ const ifs = Array.from({ length: 11 }, (_, i) => ` if (x === ${i}) return ${i}`).join('\n')
279
+ const code = `class C {\n run(x: number): number {\n${ifs}\n return -1\n }\n}`
280
+ expect(getRules(code)).toContain('high-complexity')
281
+ })
265
282
  })
266
283
 
267
284
  // ─────────────────────────────────────────────────────────────────────────────
@@ -344,6 +361,11 @@ describe('too-many-params', () => {
344
361
  const code = `const fn = (a: string, b: number, c: boolean, d: string, e: number): void => {}`
345
362
  expect(getRules(code)).toContain('too-many-params')
346
363
  })
364
+
365
+ it('detects class method with too many params', () => {
366
+ const code = `class C {\n run(a: string, b: number, c: boolean, d: string, e: number): void {}\n}`
367
+ expect(getRules(code)).toContain('too-many-params')
368
+ })
347
369
  })
348
370
 
349
371
  // ─────────────────────────────────────────────────────────────────────────────
@@ -685,7 +707,7 @@ abstract class Processor {
685
707
  abstract process(x: number): void
686
708
  }
687
709
  `
688
- expect(getRules(code2, 'src/processor.ts')).toContain('unnecessary-abstraction')
710
+ expect(getRules(code2, undefined, 'src/processor.ts')).toContain('unnecessary-abstraction')
689
711
  })
690
712
  })
691
713