@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.
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +153 -0
- package/AGENTS.md +6 -0
- package/README.md +192 -4
- 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 +509 -23
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- 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 +219 -0
- package/dist/saas.js +762 -0
- 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 +160 -0
- package/docs/PRD.md +199 -172
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +628 -36
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +63 -0
- package/src/map.ts +112 -10
- 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 +1031 -0
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +177 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +98 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +464 -0
- 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,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
|
+
})
|