@eduardbar/drift 1.2.0 → 1.4.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/.gga +50 -0
- package/.github/actions/drift-review/README.md +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- 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 +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/git.js +12 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +13 -0
- package/dist/output-metadata.js +17 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +4 -3
- 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/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -82
- package/dist/saas.js +7 -320
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +7 -0
- package/dist/trust-kpi.js +185 -0
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +37 -0
- package/dist/trust.js +168 -0
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +30 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- 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/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -433
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +210 -0
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +260 -0
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +78 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -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/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { DriftIssue, DriftPluginRule, PluginLoadError, PluginLoadWarning } from './types.js'
|
|
2
|
+
import { pushError, pushWarning } from './plugins-messages.js'
|
|
3
|
+
|
|
4
|
+
const VALID_SEVERITIES: DriftIssue['severity'][] = ['error', 'warning', 'info']
|
|
5
|
+
const MAX_FIX_ARITY = 3
|
|
6
|
+
const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/
|
|
7
|
+
|
|
8
|
+
type RuleCandidate = {
|
|
9
|
+
id?: unknown
|
|
10
|
+
name?: unknown
|
|
11
|
+
severity?: unknown
|
|
12
|
+
weight?: unknown
|
|
13
|
+
detect?: unknown
|
|
14
|
+
fix?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type NormalizeRuleContext = {
|
|
18
|
+
pluginId: string
|
|
19
|
+
pluginName: string
|
|
20
|
+
ruleIndex: number
|
|
21
|
+
strictRuleId: boolean
|
|
22
|
+
errors: PluginLoadError[]
|
|
23
|
+
warnings: PluginLoadWarning[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RuleValidationContext = {
|
|
27
|
+
pluginId: string
|
|
28
|
+
pluginName: string
|
|
29
|
+
ruleId: string
|
|
30
|
+
errors: PluginLoadError[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type RuleMessageContext = {
|
|
34
|
+
pluginId: string
|
|
35
|
+
pluginName: string
|
|
36
|
+
ruleId: string
|
|
37
|
+
errors: PluginLoadError[]
|
|
38
|
+
warnings: PluginLoadWarning[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type PluginValidationContext = {
|
|
42
|
+
pluginId: string
|
|
43
|
+
pluginName: string
|
|
44
|
+
errors: PluginLoadError[]
|
|
45
|
+
warnings: PluginLoadWarning[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveRawRuleId(rawRule: RuleCandidate): string {
|
|
49
|
+
if (typeof rawRule.id === 'string') return rawRule.id.trim()
|
|
50
|
+
if (typeof rawRule.name === 'string') return rawRule.name.trim()
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureRuleId(
|
|
55
|
+
rawRuleId: string,
|
|
56
|
+
ruleIndex: number,
|
|
57
|
+
context: RuleMessageContext,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (rawRuleId) return true
|
|
60
|
+
|
|
61
|
+
pushError(
|
|
62
|
+
context.errors,
|
|
63
|
+
context.pluginId,
|
|
64
|
+
`Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`,
|
|
65
|
+
{ pluginName: context.pluginName, code: 'plugin-rule-id-missing' },
|
|
66
|
+
)
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureDetectFunction(
|
|
71
|
+
detect: unknown,
|
|
72
|
+
context: RuleMessageContext,
|
|
73
|
+
): detect is DriftPluginRule['detect'] {
|
|
74
|
+
if (typeof detect === 'function') return true
|
|
75
|
+
|
|
76
|
+
pushError(
|
|
77
|
+
context.errors,
|
|
78
|
+
context.pluginId,
|
|
79
|
+
`Rule '${context.ruleId}' is invalid. Expected 'detect(file, context)' function.`,
|
|
80
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-invalid' },
|
|
81
|
+
)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function warnDetectArity(
|
|
86
|
+
detect: DriftPluginRule['detect'],
|
|
87
|
+
context: RuleMessageContext,
|
|
88
|
+
): void {
|
|
89
|
+
if (detect.length <= 2) return
|
|
90
|
+
|
|
91
|
+
pushWarning(
|
|
92
|
+
context.warnings,
|
|
93
|
+
context.pluginId,
|
|
94
|
+
`Rule '${context.ruleId}' detect() declares ${detect.length} parameters. Expected 1-2 parameters (file, context).`,
|
|
95
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-detect-arity' },
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateRuleIdentifierFormat(
|
|
100
|
+
rawRuleId: string,
|
|
101
|
+
strictRuleId: boolean,
|
|
102
|
+
context: RuleMessageContext,
|
|
103
|
+
): void {
|
|
104
|
+
if (RULE_ID_REQUIRED.test(rawRuleId)) return
|
|
105
|
+
const ruleLabel = rawRuleId || 'unknown-rule'
|
|
106
|
+
|
|
107
|
+
if (strictRuleId) {
|
|
108
|
+
pushError(
|
|
109
|
+
context.errors,
|
|
110
|
+
context.pluginId,
|
|
111
|
+
`Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`,
|
|
112
|
+
{ pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' },
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pushWarning(
|
|
118
|
+
context.warnings,
|
|
119
|
+
context.pluginId,
|
|
120
|
+
`Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: 1.`,
|
|
121
|
+
{ pluginName: context.pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveRuleSeverity(
|
|
126
|
+
rawSeverity: unknown,
|
|
127
|
+
context: RuleValidationContext,
|
|
128
|
+
): DriftIssue['severity'] | undefined {
|
|
129
|
+
if (rawSeverity === undefined) return undefined
|
|
130
|
+
if (typeof rawSeverity === 'string' && VALID_SEVERITIES.includes(rawSeverity as DriftIssue['severity'])) {
|
|
131
|
+
return rawSeverity as DriftIssue['severity']
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pushError(
|
|
135
|
+
context.errors,
|
|
136
|
+
context.pluginId,
|
|
137
|
+
`Rule '${context.ruleId}' has invalid severity '${String(rawSeverity)}'. Allowed: error, warning, info.`,
|
|
138
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-severity-invalid' },
|
|
139
|
+
)
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveRuleWeight(rawWeight: unknown, context: RuleValidationContext): number | undefined {
|
|
144
|
+
if (rawWeight === undefined) return undefined
|
|
145
|
+
if (typeof rawWeight === 'number' && Number.isFinite(rawWeight) && rawWeight >= 0 && rawWeight <= 100) {
|
|
146
|
+
return rawWeight
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pushError(
|
|
150
|
+
context.errors,
|
|
151
|
+
context.pluginId,
|
|
152
|
+
`Rule '${context.ruleId}' has invalid weight '${String(rawWeight)}'. Expected a finite number between 0 and 100.`,
|
|
153
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-weight-invalid' },
|
|
154
|
+
)
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveRuleFix(
|
|
159
|
+
rawFix: unknown,
|
|
160
|
+
context: RuleMessageContext,
|
|
161
|
+
): DriftPluginRule['fix'] | undefined {
|
|
162
|
+
if (rawFix === undefined) return undefined
|
|
163
|
+
if (typeof rawFix !== 'function') {
|
|
164
|
+
pushError(
|
|
165
|
+
context.errors,
|
|
166
|
+
context.pluginId,
|
|
167
|
+
`Rule '${context.ruleId}' has invalid fix. Expected a function when provided.`,
|
|
168
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-invalid' },
|
|
169
|
+
)
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (rawFix.length > MAX_FIX_ARITY) {
|
|
174
|
+
pushWarning(
|
|
175
|
+
context.warnings,
|
|
176
|
+
context.pluginId,
|
|
177
|
+
`Rule '${context.ruleId}' fix() declares ${rawFix.length} parameters. Expected up to ${MAX_FIX_ARITY} (issue, file, context).`,
|
|
178
|
+
{ pluginName: context.pluginName, ruleId: context.ruleId, code: 'plugin-rule-fix-arity' },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return rawFix as DriftPluginRule['fix']
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeRule(
|
|
186
|
+
rawRule: RuleCandidate,
|
|
187
|
+
context: NormalizeRuleContext,
|
|
188
|
+
): DriftPluginRule | undefined {
|
|
189
|
+
const { pluginId, pluginName, ruleIndex, strictRuleId, errors, warnings } = context
|
|
190
|
+
const rawRuleId = resolveRawRuleId(rawRule)
|
|
191
|
+
const messageContext = { pluginId, pluginName, ruleId: rawRuleId, errors, warnings }
|
|
192
|
+
if (!ensureRuleId(rawRuleId, ruleIndex, messageContext)) return undefined
|
|
193
|
+
if (!ensureDetectFunction(rawRule.detect, messageContext)) return undefined
|
|
194
|
+
|
|
195
|
+
validateRuleIdentifierFormat(rawRuleId, strictRuleId, messageContext)
|
|
196
|
+
warnDetectArity(rawRule.detect, messageContext)
|
|
197
|
+
const ruleValidationContext: RuleValidationContext = { pluginId, pluginName, ruleId: rawRuleId, errors }
|
|
198
|
+
const severity = resolveRuleSeverity(rawRule.severity, ruleValidationContext)
|
|
199
|
+
const weight = resolveRuleWeight(rawRule.weight, ruleValidationContext)
|
|
200
|
+
const fix = resolveRuleFix(rawRule.fix, messageContext)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
id: rawRuleId,
|
|
204
|
+
name: rawRuleId,
|
|
205
|
+
detect: rawRule.detect as DriftPluginRule['detect'],
|
|
206
|
+
severity,
|
|
207
|
+
weight,
|
|
208
|
+
fix,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ensureUniqueRuleId(
|
|
213
|
+
rule: DriftPluginRule,
|
|
214
|
+
seenRuleIds: Set<string>,
|
|
215
|
+
context: PluginValidationContext,
|
|
216
|
+
): boolean {
|
|
217
|
+
const normalizedRuleId = rule.id ?? rule.name
|
|
218
|
+
if (seenRuleIds.has(normalizedRuleId)) {
|
|
219
|
+
pushError(
|
|
220
|
+
context.errors,
|
|
221
|
+
context.pluginId,
|
|
222
|
+
`Plugin '${context.pluginName}' defines duplicate rule id '${normalizedRuleId}'. Rule ids must be unique within a plugin.`,
|
|
223
|
+
{ pluginName: context.pluginName, ruleId: normalizedRuleId, code: 'plugin-rule-id-duplicate' },
|
|
224
|
+
)
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
seenRuleIds.add(normalizedRuleId)
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeRulesArray(
|
|
233
|
+
rulesCandidate: unknown[],
|
|
234
|
+
context: PluginValidationContext,
|
|
235
|
+
strictRuleId: boolean,
|
|
236
|
+
): DriftPluginRule[] {
|
|
237
|
+
const normalizedRules: DriftPluginRule[] = []
|
|
238
|
+
const seenRuleIds = new Set<string>()
|
|
239
|
+
|
|
240
|
+
for (const [ruleIndex, rawRule] of rulesCandidate.entries()) {
|
|
241
|
+
if (!rawRule || typeof rawRule !== 'object') {
|
|
242
|
+
pushError(
|
|
243
|
+
context.errors,
|
|
244
|
+
context.pluginId,
|
|
245
|
+
`Invalid rule at index ${ruleIndex} in plugin '${context.pluginName}'. Expected an object.`,
|
|
246
|
+
{ pluginName: context.pluginName, code: 'plugin-rule-shape-invalid' },
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const normalized = normalizeRule(rawRule as RuleCandidate, {
|
|
252
|
+
pluginId: context.pluginId,
|
|
253
|
+
pluginName: context.pluginName,
|
|
254
|
+
ruleIndex,
|
|
255
|
+
strictRuleId,
|
|
256
|
+
errors: context.errors,
|
|
257
|
+
warnings: context.warnings,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
if (!normalized) continue
|
|
261
|
+
if (ensureUniqueRuleId(normalized, seenRuleIds, context)) {
|
|
262
|
+
normalizedRules.push(normalized)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return normalizedRules
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function normalizeRules(
|
|
270
|
+
rulesCandidate: unknown,
|
|
271
|
+
isLegacyPlugin: boolean,
|
|
272
|
+
context: PluginValidationContext,
|
|
273
|
+
): DriftPluginRule[] | undefined {
|
|
274
|
+
if (!Array.isArray(rulesCandidate)) {
|
|
275
|
+
pushError(
|
|
276
|
+
context.errors,
|
|
277
|
+
context.pluginId,
|
|
278
|
+
`Invalid plugin '${context.pluginName}'. Expected 'rules' to be an array.`,
|
|
279
|
+
{ pluginName: context.pluginName, code: 'plugin-rules-not-array' },
|
|
280
|
+
)
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const normalizedRules = normalizeRulesArray(rulesCandidate, context, !isLegacyPlugin)
|
|
285
|
+
if (normalizedRules.length === 0) {
|
|
286
|
+
pushError(
|
|
287
|
+
context.errors,
|
|
288
|
+
context.pluginId,
|
|
289
|
+
`Plugin '${context.pluginName}' has no valid rules after validation.`,
|
|
290
|
+
{ pluginName: context.pluginName, code: 'plugin-rules-empty' },
|
|
291
|
+
)
|
|
292
|
+
return undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return normalizedRules
|
|
296
|
+
}
|
package/src/plugins.ts
CHANGED
|
@@ -1,32 +1,151 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path'
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
|
-
import type { DriftPlugin, PluginLoadError,
|
|
4
|
+
import type { DriftPlugin, LoadedPlugin, PluginLoadError, PluginLoadWarning } from './types.js'
|
|
5
|
+
import { normalizeRules, type PluginValidationContext } from './plugins-rules.js'
|
|
6
|
+
import { validateCapabilities } from './plugins-capabilities.js'
|
|
7
|
+
import { pushError, pushWarning } from './plugins-messages.js'
|
|
5
8
|
|
|
6
9
|
const require = createRequire(import.meta.url)
|
|
10
|
+
const SUPPORTED_PLUGIN_API_VERSION = 1
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return candidate.rules.every((rule) =>
|
|
14
|
-
rule &&
|
|
15
|
-
typeof rule === 'object' &&
|
|
16
|
-
typeof rule.name === 'string' &&
|
|
17
|
-
typeof rule.detect === 'function'
|
|
18
|
-
)
|
|
12
|
+
type PluginCandidate = {
|
|
13
|
+
name?: unknown
|
|
14
|
+
apiVersion?: unknown
|
|
15
|
+
capabilities?: unknown
|
|
16
|
+
rules?: unknown
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
function normalizePluginExport(mod: unknown):
|
|
22
|
-
if (isPluginShape(mod)) return mod
|
|
19
|
+
function normalizePluginExport(mod: unknown): unknown {
|
|
23
20
|
if (mod && typeof mod === 'object' && 'default' in mod) {
|
|
24
|
-
|
|
25
|
-
if (isPluginShape(maybeDefault)) return maybeDefault
|
|
21
|
+
return (mod as { default?: unknown }).default ?? mod
|
|
26
22
|
}
|
|
23
|
+
return mod
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureObjectCandidate(
|
|
27
|
+
pluginId: string,
|
|
28
|
+
candidate: unknown,
|
|
29
|
+
errors: PluginLoadError[],
|
|
30
|
+
): PluginCandidate | undefined {
|
|
31
|
+
if (candidate && typeof candidate === 'object') {
|
|
32
|
+
return candidate as PluginCandidate
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pushError(
|
|
36
|
+
errors,
|
|
37
|
+
pluginId,
|
|
38
|
+
`Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`,
|
|
39
|
+
{ code: 'plugin-shape-invalid' },
|
|
40
|
+
)
|
|
27
41
|
return undefined
|
|
28
42
|
}
|
|
29
43
|
|
|
44
|
+
function ensurePluginName(
|
|
45
|
+
pluginId: string,
|
|
46
|
+
plugin: PluginCandidate,
|
|
47
|
+
errors: PluginLoadError[],
|
|
48
|
+
): string | undefined {
|
|
49
|
+
const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : ''
|
|
50
|
+
if (pluginName) return pluginName
|
|
51
|
+
|
|
52
|
+
pushError(
|
|
53
|
+
errors,
|
|
54
|
+
pluginId,
|
|
55
|
+
`Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`,
|
|
56
|
+
{ code: 'plugin-name-missing' },
|
|
57
|
+
)
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateApiVersion(
|
|
62
|
+
plugin: PluginCandidate,
|
|
63
|
+
context: PluginValidationContext,
|
|
64
|
+
): { hasExplicitApiVersion: boolean; isLegacyPlugin: boolean; isSupported: boolean } {
|
|
65
|
+
const { pluginId, pluginName, errors, warnings } = context
|
|
66
|
+
const hasExplicitApiVersion = plugin.apiVersion !== undefined
|
|
67
|
+
const isLegacyPlugin = !hasExplicitApiVersion
|
|
68
|
+
|
|
69
|
+
if (isLegacyPlugin) {
|
|
70
|
+
pushWarning(
|
|
71
|
+
warnings,
|
|
72
|
+
pluginId,
|
|
73
|
+
`Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
74
|
+
{ pluginName, code: 'plugin-api-version-implicit' },
|
|
75
|
+
)
|
|
76
|
+
return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
|
|
80
|
+
pushError(
|
|
81
|
+
errors,
|
|
82
|
+
pluginId,
|
|
83
|
+
`Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`,
|
|
84
|
+
{ pluginName, code: 'plugin-api-version-invalid' },
|
|
85
|
+
)
|
|
86
|
+
return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
|
|
90
|
+
pushError(
|
|
91
|
+
errors,
|
|
92
|
+
pluginId,
|
|
93
|
+
`Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
94
|
+
{ pluginName, code: 'plugin-api-version-unsupported' },
|
|
95
|
+
)
|
|
96
|
+
return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validatePluginContractData(pluginId: string, candidate: unknown): {
|
|
103
|
+
plugin?: DriftPlugin
|
|
104
|
+
errors: PluginLoadError[]
|
|
105
|
+
warnings: PluginLoadWarning[]
|
|
106
|
+
} {
|
|
107
|
+
const errors: PluginLoadError[] = []
|
|
108
|
+
const warnings: PluginLoadWarning[] = []
|
|
109
|
+
|
|
110
|
+
const plugin = ensureObjectCandidate(pluginId, candidate, errors)
|
|
111
|
+
if (!plugin) return { errors, warnings }
|
|
112
|
+
|
|
113
|
+
const pluginName = ensurePluginName(pluginId, plugin, errors)
|
|
114
|
+
if (!pluginName) return { errors, warnings }
|
|
115
|
+
|
|
116
|
+
const context: PluginValidationContext = { pluginId, pluginName, errors, warnings }
|
|
117
|
+
const apiVersion = validateApiVersion(plugin, context)
|
|
118
|
+
if (!apiVersion.isSupported) return { errors, warnings }
|
|
119
|
+
|
|
120
|
+
const capabilities = validateCapabilities(plugin.capabilities, context)
|
|
121
|
+
if (errors.length > 0) return { errors, warnings }
|
|
122
|
+
|
|
123
|
+
const normalizedRules = normalizeRules(plugin.rules, apiVersion.isLegacyPlugin, context)
|
|
124
|
+
if (!normalizedRules) return { errors, warnings }
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
plugin: {
|
|
128
|
+
name: pluginName,
|
|
129
|
+
apiVersion: apiVersion.hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
|
|
130
|
+
capabilities,
|
|
131
|
+
rules: normalizedRules,
|
|
132
|
+
},
|
|
133
|
+
errors,
|
|
134
|
+
warnings,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validatePluginContract(
|
|
139
|
+
pluginId: string,
|
|
140
|
+
candidate: unknown,
|
|
141
|
+
): {
|
|
142
|
+
plugin?: DriftPlugin
|
|
143
|
+
errors: PluginLoadError[]
|
|
144
|
+
warnings: PluginLoadWarning[]
|
|
145
|
+
} {
|
|
146
|
+
return validatePluginContractData(pluginId, candidate)
|
|
147
|
+
}
|
|
148
|
+
|
|
30
149
|
function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
31
150
|
if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
|
|
32
151
|
const abs = isAbsolute(pluginId) ? pluginId : resolve(projectRoot, pluginId)
|
|
@@ -43,34 +162,36 @@ function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
|
43
162
|
export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
|
|
44
163
|
plugins: LoadedPlugin[]
|
|
45
164
|
errors: PluginLoadError[]
|
|
165
|
+
warnings: PluginLoadWarning[]
|
|
46
166
|
} {
|
|
47
167
|
if (!pluginIds || pluginIds.length === 0) {
|
|
48
|
-
return { plugins: [], errors: [] }
|
|
168
|
+
return { plugins: [], errors: [], warnings: [] }
|
|
49
169
|
}
|
|
50
170
|
|
|
51
171
|
const loaded: LoadedPlugin[] = []
|
|
52
172
|
const errors: PluginLoadError[] = []
|
|
173
|
+
const warnings: PluginLoadWarning[] = []
|
|
53
174
|
|
|
54
175
|
for (const pluginId of pluginIds) {
|
|
55
176
|
const resolved = resolvePluginSpecifier(projectRoot, pluginId)
|
|
56
177
|
try {
|
|
57
178
|
const mod = require(resolved)
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
loaded.push({ id: pluginId, plugin })
|
|
179
|
+
const normalized = normalizePluginExport(mod)
|
|
180
|
+
const validation = validatePluginContract(pluginId, normalized)
|
|
181
|
+
|
|
182
|
+
errors.push(...validation.errors)
|
|
183
|
+
warnings.push(...validation.warnings)
|
|
184
|
+
|
|
185
|
+
if (!validation.plugin) continue
|
|
186
|
+
loaded.push({ id: pluginId, plugin: validation.plugin })
|
|
67
187
|
} catch (error) {
|
|
68
188
|
errors.push({
|
|
69
189
|
pluginId,
|
|
190
|
+
code: 'plugin-load-failed',
|
|
70
191
|
message: error instanceof Error ? error.message : String(error),
|
|
71
192
|
})
|
|
72
193
|
}
|
|
73
194
|
}
|
|
74
195
|
|
|
75
|
-
return { plugins: loaded, errors }
|
|
196
|
+
return { plugins: loaded, errors, warnings }
|
|
76
197
|
}
|
package/src/printer.ts
CHANGED
|
@@ -133,6 +133,10 @@ function formatFixSuggestion(issue: DriftIssue): string[] {
|
|
|
133
133
|
'Fix or remove the failing plugin in drift.config.*',
|
|
134
134
|
'Validate plugin contract: export { name, rules[] } and detector functions',
|
|
135
135
|
],
|
|
136
|
+
'plugin-warning': [
|
|
137
|
+
'Review plugin validation warnings and align with the recommended contract',
|
|
138
|
+
'Use explicit rule ids and valid severity/weight values',
|
|
139
|
+
],
|
|
136
140
|
}
|
|
137
141
|
return suggestions[issue.rule] ?? ['Review and fix manually']
|
|
138
142
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DriftIssue } from './types.js'
|
|
2
|
+
|
|
3
|
+
export const FIX_SUGGESTIONS: Record<string, string> = {
|
|
4
|
+
'large-file': 'Consider splitting this file into smaller modules with single responsibility',
|
|
5
|
+
'large-function': 'Extract logic into smaller functions with descriptive names',
|
|
6
|
+
'debug-leftover': 'Remove this console.log or replace with proper logging library',
|
|
7
|
+
'dead-code': 'Remove unused import to keep code clean',
|
|
8
|
+
'duplicate-function-name': 'Consolidate with existing function or rename to clarify different behavior',
|
|
9
|
+
'any-abuse': "Replace 'any' with proper type definition",
|
|
10
|
+
'catch-swallow': 'Add error handling or logging in catch block',
|
|
11
|
+
'no-return-type': 'Add explicit return type for better type safety',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const RULE_EFFORT: Record<string, 'low' | 'medium' | 'high'> = {
|
|
15
|
+
'debug-leftover': 'low',
|
|
16
|
+
'dead-code': 'low',
|
|
17
|
+
'no-return-type': 'low',
|
|
18
|
+
'any-abuse': 'medium',
|
|
19
|
+
'catch-swallow': 'medium',
|
|
20
|
+
'large-file': 'high',
|
|
21
|
+
'large-function': 'high',
|
|
22
|
+
'duplicate-function-name': 'high',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SEVERITY_ORDER: Record<string, number> = { error: 0, warning: 1, info: 2 }
|
|
26
|
+
export const EFFORT_ORDER: Record<string, number> = { low: 0, medium: 1, high: 2 }
|
|
27
|
+
|
|
28
|
+
export const AI_SIGNAL_RULES = new Set([
|
|
29
|
+
'over-commented',
|
|
30
|
+
'hardcoded-config',
|
|
31
|
+
'inconsistent-error-handling',
|
|
32
|
+
'unnecessary-abstraction',
|
|
33
|
+
'naming-inconsistency',
|
|
34
|
+
'comment-contradiction',
|
|
35
|
+
'promise-style-mix',
|
|
36
|
+
'any-abuse',
|
|
37
|
+
'ai-code-smell',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
export const AI_CODE_SMELL_BOOST = 20
|
|
41
|
+
export const AI_TRIGGER_LIMIT = 4
|
|
42
|
+
export const AI_LIKELIHOOD_THRESHOLD = 35
|
|
43
|
+
export const AI_SMELL_SCORE_MULTIPLIER = 15
|
|
44
|
+
export const AI_SUSPECTED_LIMIT = 10
|
|
45
|
+
|
|
46
|
+
export type DriftIssueWithFile = { file: string; issue: DriftIssue }
|