@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.
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +98 -6
- package/AGENTS.md +6 -0
- package/README.md +160 -10
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +185 -0
- package/dist/cli.js +453 -62
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -1
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +177 -28
- package/dist/printer.js +4 -0
- package/dist/review.js +2 -2
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas.d.ts +143 -7
- package/dist/saas.js +478 -37
- package/dist/trust-kpi.d.ts +9 -0
- package/dist/trust-kpi.js +445 -0
- package/dist/trust.d.ts +65 -0
- package/dist/trust.js +571 -0
- package/dist/types.d.ts +154 -0
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +562 -79
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +48 -0
- package/src/plugins.ts +354 -26
- package/src/printer.ts +4 -0
- package/src/review.ts +2 -2
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/shared.ts +31 -3
- package/src/saas.ts +641 -43
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +171 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +71 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +358 -1
- package/tests/trust-kpi.test.ts +120 -0
- 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
|
+
})
|
package/tests/rules.test.ts
CHANGED
|
@@ -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
|
|