@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/plugins.ts
CHANGED
|
@@ -1,30 +1,353 @@
|
|
|
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, LoadedPlugin } from './types.js'
|
|
4
|
+
import type { DriftIssue, DriftPlugin, DriftPluginRule, PluginLoadError, PluginLoadWarning, LoadedPlugin } from './types.js'
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url)
|
|
7
|
+
const VALID_SEVERITIES: DriftIssue['severity'][] = ['error', 'warning', 'info']
|
|
8
|
+
const SUPPORTED_PLUGIN_API_VERSION = 1
|
|
9
|
+
const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/
|
|
7
10
|
|
|
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
|
-
)
|
|
11
|
+
type PluginCandidate = {
|
|
12
|
+
name?: unknown
|
|
13
|
+
apiVersion?: unknown
|
|
14
|
+
capabilities?: unknown
|
|
15
|
+
rules?: unknown
|
|
19
16
|
}
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
type RuleCandidate = {
|
|
19
|
+
id?: unknown
|
|
20
|
+
name?: unknown
|
|
21
|
+
severity?: unknown
|
|
22
|
+
weight?: unknown
|
|
23
|
+
detect?: unknown
|
|
24
|
+
fix?: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizePluginExport(mod: unknown): unknown {
|
|
23
28
|
if (mod && typeof mod === 'object' && 'default' in mod) {
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
return (mod as { default?: unknown }).default ?? mod
|
|
30
|
+
}
|
|
31
|
+
return mod
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pushError(
|
|
35
|
+
errors: PluginLoadError[],
|
|
36
|
+
pluginId: string,
|
|
37
|
+
message: string,
|
|
38
|
+
options?: { pluginName?: string; ruleId?: string; code?: string },
|
|
39
|
+
): void {
|
|
40
|
+
errors.push({
|
|
41
|
+
pluginId,
|
|
42
|
+
pluginName: options?.pluginName,
|
|
43
|
+
ruleId: options?.ruleId,
|
|
44
|
+
code: options?.code,
|
|
45
|
+
message,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pushWarning(
|
|
50
|
+
warnings: PluginLoadWarning[],
|
|
51
|
+
pluginId: string,
|
|
52
|
+
message: string,
|
|
53
|
+
options?: { pluginName?: string; ruleId?: string; code?: string },
|
|
54
|
+
): void {
|
|
55
|
+
warnings.push({
|
|
56
|
+
pluginId,
|
|
57
|
+
pluginName: options?.pluginName,
|
|
58
|
+
ruleId: options?.ruleId,
|
|
59
|
+
code: options?.code,
|
|
60
|
+
message,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeRule(
|
|
65
|
+
pluginId: string,
|
|
66
|
+
pluginName: string,
|
|
67
|
+
rawRule: RuleCandidate,
|
|
68
|
+
ruleIndex: number,
|
|
69
|
+
options: { strictRuleId: boolean },
|
|
70
|
+
errors: PluginLoadError[],
|
|
71
|
+
warnings: PluginLoadWarning[],
|
|
72
|
+
): DriftPluginRule | undefined {
|
|
73
|
+
const rawRuleId = typeof rawRule.id === 'string'
|
|
74
|
+
? rawRule.id.trim()
|
|
75
|
+
: typeof rawRule.name === 'string'
|
|
76
|
+
? rawRule.name.trim()
|
|
77
|
+
: ''
|
|
78
|
+
|
|
79
|
+
const ruleLabel = rawRuleId || `rule#${ruleIndex + 1}`
|
|
80
|
+
|
|
81
|
+
if (!rawRuleId) {
|
|
82
|
+
pushError(
|
|
83
|
+
errors,
|
|
84
|
+
pluginId,
|
|
85
|
+
`Invalid rule at index ${ruleIndex}. Expected 'id' or 'name' as a non-empty string.`,
|
|
86
|
+
{ pluginName, code: 'plugin-rule-id-missing' },
|
|
87
|
+
)
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof rawRule.detect !== 'function') {
|
|
92
|
+
pushError(
|
|
93
|
+
errors,
|
|
94
|
+
pluginId,
|
|
95
|
+
`Rule '${rawRuleId}' is invalid. Expected 'detect(file, context)' function.`,
|
|
96
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-invalid' },
|
|
97
|
+
)
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (rawRule.detect.length > 2) {
|
|
102
|
+
pushWarning(
|
|
103
|
+
warnings,
|
|
104
|
+
pluginId,
|
|
105
|
+
`Rule '${rawRuleId}' detect() declares ${rawRule.detect.length} parameters. Expected 1-2 parameters (file, context).`,
|
|
106
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-detect-arity' },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!RULE_ID_REQUIRED.test(rawRuleId)) {
|
|
111
|
+
if (options.strictRuleId) {
|
|
112
|
+
pushError(
|
|
113
|
+
errors,
|
|
114
|
+
pluginId,
|
|
115
|
+
`Rule id '${ruleLabel}' is invalid. Use lowercase letters, numbers, and separators (-, _, /), starting with a letter.`,
|
|
116
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-invalid' },
|
|
117
|
+
)
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pushWarning(
|
|
122
|
+
warnings,
|
|
123
|
+
pluginId,
|
|
124
|
+
`Rule id '${ruleLabel}' uses a legacy format. For forward compatibility, migrate to lowercase kebab-case and set apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
125
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-id-format-legacy' },
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let severity: DriftIssue['severity'] | undefined
|
|
130
|
+
if (rawRule.severity !== undefined) {
|
|
131
|
+
if (typeof rawRule.severity === 'string' && VALID_SEVERITIES.includes(rawRule.severity as DriftIssue['severity'])) {
|
|
132
|
+
severity = rawRule.severity as DriftIssue['severity']
|
|
133
|
+
} else {
|
|
134
|
+
pushError(
|
|
135
|
+
errors,
|
|
136
|
+
pluginId,
|
|
137
|
+
`Rule '${rawRuleId}' has invalid severity '${String(rawRule.severity)}'. Allowed: error, warning, info.`,
|
|
138
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-severity-invalid' },
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let weight: number | undefined
|
|
144
|
+
if (rawRule.weight !== undefined) {
|
|
145
|
+
if (typeof rawRule.weight === 'number' && Number.isFinite(rawRule.weight) && rawRule.weight >= 0 && rawRule.weight <= 100) {
|
|
146
|
+
weight = rawRule.weight
|
|
147
|
+
} else {
|
|
148
|
+
pushError(
|
|
149
|
+
errors,
|
|
150
|
+
pluginId,
|
|
151
|
+
`Rule '${rawRuleId}' has invalid weight '${String(rawRule.weight)}'. Expected a finite number between 0 and 100.`,
|
|
152
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-weight-invalid' },
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let fix: DriftPluginRule['fix'] | undefined
|
|
158
|
+
if (rawRule.fix !== undefined) {
|
|
159
|
+
if (typeof rawRule.fix === 'function') {
|
|
160
|
+
fix = rawRule.fix as DriftPluginRule['fix']
|
|
161
|
+
if (rawRule.fix.length > 3) {
|
|
162
|
+
pushWarning(
|
|
163
|
+
warnings,
|
|
164
|
+
pluginId,
|
|
165
|
+
`Rule '${rawRuleId}' fix() declares ${rawRule.fix.length} parameters. Expected up to 3 (issue, file, context).`,
|
|
166
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-arity' },
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
pushError(
|
|
171
|
+
errors,
|
|
172
|
+
pluginId,
|
|
173
|
+
`Rule '${rawRuleId}' has invalid fix. Expected a function when provided.`,
|
|
174
|
+
{ pluginName, ruleId: rawRuleId, code: 'plugin-rule-fix-invalid' },
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
id: rawRuleId,
|
|
181
|
+
name: rawRuleId,
|
|
182
|
+
detect: rawRule.detect as DriftPluginRule['detect'],
|
|
183
|
+
severity,
|
|
184
|
+
weight,
|
|
185
|
+
fix,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function validatePluginContract(
|
|
190
|
+
pluginId: string,
|
|
191
|
+
candidate: unknown,
|
|
192
|
+
): {
|
|
193
|
+
plugin?: DriftPlugin
|
|
194
|
+
errors: PluginLoadError[]
|
|
195
|
+
warnings: PluginLoadWarning[]
|
|
196
|
+
} {
|
|
197
|
+
const errors: PluginLoadError[] = []
|
|
198
|
+
const warnings: PluginLoadWarning[] = []
|
|
199
|
+
|
|
200
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
201
|
+
pushError(
|
|
202
|
+
errors,
|
|
203
|
+
pluginId,
|
|
204
|
+
`Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`,
|
|
205
|
+
{ code: 'plugin-shape-invalid' },
|
|
206
|
+
)
|
|
207
|
+
return { errors, warnings }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const plugin = candidate as PluginCandidate
|
|
211
|
+
const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : ''
|
|
212
|
+
if (!pluginName) {
|
|
213
|
+
pushError(
|
|
214
|
+
errors,
|
|
215
|
+
pluginId,
|
|
216
|
+
`Invalid plugin contract in '${pluginId}'. Expected 'name' as a non-empty string.`,
|
|
217
|
+
{ code: 'plugin-name-missing' },
|
|
218
|
+
)
|
|
219
|
+
return { errors, warnings }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hasExplicitApiVersion = plugin.apiVersion !== undefined
|
|
223
|
+
const isLegacyPlugin = !hasExplicitApiVersion
|
|
224
|
+
|
|
225
|
+
if (isLegacyPlugin) {
|
|
226
|
+
pushWarning(
|
|
227
|
+
warnings,
|
|
228
|
+
pluginId,
|
|
229
|
+
`Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
230
|
+
{ pluginName, code: 'plugin-api-version-implicit' },
|
|
231
|
+
)
|
|
232
|
+
} else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
|
|
233
|
+
pushError(
|
|
234
|
+
errors,
|
|
235
|
+
pluginId,
|
|
236
|
+
`Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`,
|
|
237
|
+
{ pluginName, code: 'plugin-api-version-invalid' },
|
|
238
|
+
)
|
|
239
|
+
return { errors, warnings }
|
|
240
|
+
} else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
|
|
241
|
+
pushError(
|
|
242
|
+
errors,
|
|
243
|
+
pluginId,
|
|
244
|
+
`Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`,
|
|
245
|
+
{ pluginName, code: 'plugin-api-version-unsupported' },
|
|
246
|
+
)
|
|
247
|
+
return { errors, warnings }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let capabilities: DriftPlugin['capabilities'] | undefined
|
|
251
|
+
if (plugin.capabilities !== undefined) {
|
|
252
|
+
if (!plugin.capabilities || typeof plugin.capabilities !== 'object' || Array.isArray(plugin.capabilities)) {
|
|
253
|
+
pushError(
|
|
254
|
+
errors,
|
|
255
|
+
pluginId,
|
|
256
|
+
`Plugin '${pluginName}' has invalid capabilities metadata. Expected an object map like { "fixes": true } when provided.`,
|
|
257
|
+
{ pluginName, code: 'plugin-capabilities-invalid' },
|
|
258
|
+
)
|
|
259
|
+
return { errors, warnings }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries = Object.entries(plugin.capabilities as Record<string, unknown>)
|
|
263
|
+
for (const [capabilityKey, capabilityValue] of entries) {
|
|
264
|
+
const capabilityType = typeof capabilityValue
|
|
265
|
+
if (capabilityType !== 'string' && capabilityType !== 'number' && capabilityType !== 'boolean') {
|
|
266
|
+
pushError(
|
|
267
|
+
errors,
|
|
268
|
+
pluginId,
|
|
269
|
+
`Plugin '${pluginName}' capability '${capabilityKey}' has invalid value type '${capabilityType}'. Allowed: string | number | boolean.`,
|
|
270
|
+
{ pluginName, code: 'plugin-capabilities-value-invalid' },
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (errors.length > 0) {
|
|
276
|
+
return { errors, warnings }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
capabilities = plugin.capabilities as DriftPlugin['capabilities']
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!Array.isArray(plugin.rules)) {
|
|
283
|
+
pushError(
|
|
284
|
+
errors,
|
|
285
|
+
pluginId,
|
|
286
|
+
`Invalid plugin '${pluginName}'. Expected 'rules' to be an array.`,
|
|
287
|
+
{ pluginName, code: 'plugin-rules-not-array' },
|
|
288
|
+
)
|
|
289
|
+
return { errors, warnings }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const normalizedRules: DriftPluginRule[] = []
|
|
293
|
+
const seenRuleIds = new Set<string>()
|
|
294
|
+
|
|
295
|
+
for (const [ruleIndex, rawRule] of plugin.rules.entries()) {
|
|
296
|
+
if (!rawRule || typeof rawRule !== 'object') {
|
|
297
|
+
pushError(
|
|
298
|
+
errors,
|
|
299
|
+
pluginId,
|
|
300
|
+
`Invalid rule at index ${ruleIndex} in plugin '${pluginName}'. Expected an object.`,
|
|
301
|
+
{ pluginName, code: 'plugin-rule-shape-invalid' },
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const normalized = normalizeRule(
|
|
307
|
+
pluginId,
|
|
308
|
+
pluginName,
|
|
309
|
+
rawRule as RuleCandidate,
|
|
310
|
+
ruleIndex,
|
|
311
|
+
{ strictRuleId: !isLegacyPlugin },
|
|
312
|
+
errors,
|
|
313
|
+
warnings,
|
|
314
|
+
)
|
|
315
|
+
if (normalized) {
|
|
316
|
+
if (seenRuleIds.has(normalized.id ?? normalized.name)) {
|
|
317
|
+
pushError(
|
|
318
|
+
errors,
|
|
319
|
+
pluginId,
|
|
320
|
+
`Plugin '${pluginName}' defines duplicate rule id '${normalized.id ?? normalized.name}'. Rule ids must be unique within a plugin.`,
|
|
321
|
+
{ pluginName, ruleId: normalized.id ?? normalized.name, code: 'plugin-rule-id-duplicate' },
|
|
322
|
+
)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
seenRuleIds.add(normalized.id ?? normalized.name)
|
|
327
|
+
normalizedRules.push(normalized)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (normalizedRules.length === 0) {
|
|
332
|
+
pushError(
|
|
333
|
+
errors,
|
|
334
|
+
pluginId,
|
|
335
|
+
`Plugin '${pluginName}' has no valid rules after validation.`,
|
|
336
|
+
{ pluginName, code: 'plugin-rules-empty' },
|
|
337
|
+
)
|
|
338
|
+
return { errors, warnings }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
plugin: {
|
|
343
|
+
name: pluginName,
|
|
344
|
+
apiVersion: hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
|
|
345
|
+
capabilities,
|
|
346
|
+
rules: normalizedRules,
|
|
347
|
+
},
|
|
348
|
+
errors,
|
|
349
|
+
warnings,
|
|
26
350
|
}
|
|
27
|
-
return undefined
|
|
28
351
|
}
|
|
29
352
|
|
|
30
353
|
function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
@@ -43,34 +366,39 @@ function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
|
|
|
43
366
|
export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined): {
|
|
44
367
|
plugins: LoadedPlugin[]
|
|
45
368
|
errors: PluginLoadError[]
|
|
369
|
+
warnings: PluginLoadWarning[]
|
|
46
370
|
} {
|
|
47
371
|
if (!pluginIds || pluginIds.length === 0) {
|
|
48
|
-
return { plugins: [], errors: [] }
|
|
372
|
+
return { plugins: [], errors: [], warnings: [] }
|
|
49
373
|
}
|
|
50
374
|
|
|
51
375
|
const loaded: LoadedPlugin[] = []
|
|
52
376
|
const errors: PluginLoadError[] = []
|
|
377
|
+
const warnings: PluginLoadWarning[] = []
|
|
53
378
|
|
|
54
379
|
for (const pluginId of pluginIds) {
|
|
55
380
|
const resolved = resolvePluginSpecifier(projectRoot, pluginId)
|
|
56
381
|
try {
|
|
57
382
|
const mod = require(resolved)
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
383
|
+
const normalized = normalizePluginExport(mod)
|
|
384
|
+
const validation = validatePluginContract(pluginId, normalized)
|
|
385
|
+
|
|
386
|
+
errors.push(...validation.errors)
|
|
387
|
+
warnings.push(...validation.warnings)
|
|
388
|
+
|
|
389
|
+
if (!validation.plugin) {
|
|
64
390
|
continue
|
|
65
391
|
}
|
|
66
|
-
|
|
392
|
+
|
|
393
|
+
loaded.push({ id: pluginId, plugin: validation.plugin })
|
|
67
394
|
} catch (error) {
|
|
68
395
|
errors.push({
|
|
69
396
|
pluginId,
|
|
397
|
+
code: 'plugin-load-failed',
|
|
70
398
|
message: error instanceof Error ? error.message : String(error),
|
|
71
399
|
})
|
|
72
400
|
}
|
|
73
401
|
}
|
|
74
402
|
|
|
75
|
-
return { plugins: loaded, errors }
|
|
403
|
+
return { plugins: loaded, errors, warnings }
|
|
76
404
|
}
|
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
|
}
|
package/src/review.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { relative, resolve } from 'node:path'
|
|
2
2
|
import { analyzeProject } from './analyzer.js'
|
|
3
3
|
import { loadConfig } from './config.js'
|
|
4
4
|
import { buildReport } from './reporter.js'
|
|
@@ -66,7 +66,7 @@ export async function generateReview(projectPath: string, baseRef: string): Prom
|
|
|
66
66
|
...baseReport,
|
|
67
67
|
files: baseReport.files.map((file) => ({
|
|
68
68
|
...file,
|
|
69
|
-
path:
|
|
69
|
+
path: resolve(resolvedPath, relative(tempDir!, file.path)),
|
|
70
70
|
})),
|
|
71
71
|
}
|
|
72
72
|
|
package/src/rules/comments.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SourceFile } from 'ts-morph'
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
|
-
import { hasIgnoreComment } from './shared.js'
|
|
3
|
+
import { hasIgnoreComment, getFileLines } from './shared.js'
|
|
4
4
|
|
|
5
5
|
const TRIVIAL_COMMENT_PATTERNS = [
|
|
6
6
|
{ comment: /\/\/\s*return\b/i, code: /^\s*return\b/ },
|
|
@@ -41,7 +41,7 @@ function checkLineForContradiction(
|
|
|
41
41
|
|
|
42
42
|
export function detectCommentContradiction(file: SourceFile): DriftIssue[] {
|
|
43
43
|
const issues: DriftIssue[] = []
|
|
44
|
-
const lines = file
|
|
44
|
+
const lines = getFileLines(file)
|
|
45
45
|
|
|
46
46
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
47
47
|
const commentLine = lines[i].trim()
|
package/src/rules/complexity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
|
-
import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
|
|
3
|
+
import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
|
|
4
4
|
|
|
5
5
|
const COMPLEXITY_THRESHOLD = 10
|
|
6
6
|
|
|
@@ -31,12 +31,7 @@ function getCyclomaticComplexity(fn: FunctionLike): number {
|
|
|
31
31
|
|
|
32
32
|
export function detectHighComplexity(file: SourceFile): DriftIssue[] {
|
|
33
33
|
const issues: DriftIssue[] = []
|
|
34
|
-
const fns: FunctionLike[] =
|
|
35
|
-
...file.getFunctions(),
|
|
36
|
-
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
37
|
-
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
38
|
-
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
39
|
-
]
|
|
34
|
+
const fns: FunctionLike[] = collectFunctionLikes(file)
|
|
40
35
|
|
|
41
36
|
for (const fn of fns) {
|
|
42
37
|
const complexity = getCyclomaticComplexity(fn)
|
package/src/rules/nesting.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SourceFile, SyntaxKind, Node } from 'ts-morph'
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
|
-
import { hasIgnoreComment, getSnippet, type FunctionLike } from './shared.js'
|
|
3
|
+
import { hasIgnoreComment, getSnippet, collectFunctionLikes, type FunctionLike } from './shared.js'
|
|
4
4
|
|
|
5
5
|
const NESTING_THRESHOLD = 3
|
|
6
6
|
const PARAMS_THRESHOLD = 4
|
|
@@ -35,12 +35,7 @@ function getMaxNestingDepth(fn: FunctionLike): number {
|
|
|
35
35
|
|
|
36
36
|
export function detectDeepNesting(file: SourceFile): DriftIssue[] {
|
|
37
37
|
const issues: DriftIssue[] = []
|
|
38
|
-
const fns: FunctionLike[] =
|
|
39
|
-
...file.getFunctions(),
|
|
40
|
-
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
41
|
-
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
42
|
-
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
43
|
-
]
|
|
38
|
+
const fns: FunctionLike[] = collectFunctionLikes(file)
|
|
44
39
|
|
|
45
40
|
for (const fn of fns) {
|
|
46
41
|
const depth = getMaxNestingDepth(fn)
|
|
@@ -62,12 +57,7 @@ export function detectDeepNesting(file: SourceFile): DriftIssue[] {
|
|
|
62
57
|
|
|
63
58
|
export function detectTooManyParams(file: SourceFile): DriftIssue[] {
|
|
64
59
|
const issues: DriftIssue[] = []
|
|
65
|
-
const fns: FunctionLike[] =
|
|
66
|
-
...file.getFunctions(),
|
|
67
|
-
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
68
|
-
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
69
|
-
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
70
|
-
]
|
|
60
|
+
const fns: FunctionLike[] = collectFunctionLikes(file)
|
|
71
61
|
|
|
72
62
|
for (const fn of fns) {
|
|
73
63
|
const paramCount = fn.getParameters().length
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SourceFile, SyntaxKind } from 'ts-morph'
|
|
2
2
|
import type { DriftIssue } from '../types.js'
|
|
3
|
-
import { hasIgnoreComment, getSnippet, getFunctionLikeLines,
|
|
3
|
+
import { hasIgnoreComment, getSnippet, getFunctionLikeLines, collectFunctionLikes, getFileLines } from './shared.js'
|
|
4
4
|
|
|
5
5
|
const LARGE_FILE_THRESHOLD = 300
|
|
6
6
|
const LARGE_FUNCTION_THRESHOLD = 50
|
|
@@ -26,12 +26,7 @@ export function detectLargeFile(file: SourceFile): DriftIssue[] {
|
|
|
26
26
|
|
|
27
27
|
export function detectLargeFunctions(file: SourceFile): DriftIssue[] {
|
|
28
28
|
const issues: DriftIssue[] = []
|
|
29
|
-
const fns
|
|
30
|
-
...file.getFunctions(),
|
|
31
|
-
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
32
|
-
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
33
|
-
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
34
|
-
]
|
|
29
|
+
const fns = collectFunctionLikes(file)
|
|
35
30
|
|
|
36
31
|
for (const fn of fns) {
|
|
37
32
|
const lines = getFunctionLikeLines(fn)
|
|
@@ -70,7 +65,7 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
|
|
|
70
65
|
}
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
const lines = file
|
|
68
|
+
const lines = getFileLines(file)
|
|
74
69
|
lines.forEach((lineContent, i) => {
|
|
75
70
|
if (/\/\/\s*(TODO|FIXME|HACK|XXX|TEMP)\b/i.test(lineContent)) {
|
|
76
71
|
if (hasIgnoreComment(file, i + 1)) return
|
|
@@ -90,14 +85,18 @@ export function detectDebugLeftovers(file: SourceFile): DriftIssue[] {
|
|
|
90
85
|
|
|
91
86
|
export function detectDeadCode(file: SourceFile): DriftIssue[] {
|
|
92
87
|
const issues: DriftIssue[] = []
|
|
88
|
+
const identifierCounts = new Map<string, number>()
|
|
89
|
+
|
|
90
|
+
for (const id of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
91
|
+
const text = id.getText()
|
|
92
|
+
identifierCounts.set(text, (identifierCounts.get(text) ?? 0) + 1)
|
|
93
|
+
}
|
|
93
94
|
|
|
94
95
|
for (const imp of file.getImportDeclarations()) {
|
|
95
96
|
for (const named of imp.getNamedImports()) {
|
|
96
97
|
const name = named.getName()
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
if (refs.length === 0) {
|
|
98
|
+
const refsCount = Math.max(0, (identifierCounts.get(name) ?? 0) - 1)
|
|
99
|
+
if (refsCount === 0) {
|
|
101
100
|
issues.push({
|
|
102
101
|
rule: 'dead-code',
|
|
103
102
|
severity: 'warning',
|
package/src/rules/shared.ts
CHANGED
|
@@ -5,12 +5,40 @@ import {
|
|
|
5
5
|
ArrowFunction,
|
|
6
6
|
FunctionExpression,
|
|
7
7
|
MethodDeclaration,
|
|
8
|
+
SyntaxKind,
|
|
8
9
|
} from 'ts-morph'
|
|
9
10
|
|
|
10
11
|
export type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
const fileLinesCache = new WeakMap<SourceFile, string[]>()
|
|
14
|
+
const functionLikesCache = new WeakMap<SourceFile, FunctionLike[]>()
|
|
15
|
+
|
|
16
|
+
export function getFileLines(file: SourceFile): string[] {
|
|
17
|
+
const cached = fileLinesCache.get(file)
|
|
18
|
+
if (cached) return cached
|
|
19
|
+
|
|
13
20
|
const lines = file.getFullText().split('\n')
|
|
21
|
+
fileLinesCache.set(file, lines)
|
|
22
|
+
return lines
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collectFunctionLikes(file: SourceFile): FunctionLike[] {
|
|
26
|
+
const cached = functionLikesCache.get(file)
|
|
27
|
+
if (cached) return cached
|
|
28
|
+
|
|
29
|
+
const fns: FunctionLike[] = [
|
|
30
|
+
...file.getFunctions(),
|
|
31
|
+
...file.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
32
|
+
...file.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
33
|
+
...file.getClasses().flatMap((c) => c.getMethods()),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
functionLikesCache.set(file, fns)
|
|
37
|
+
return fns
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasIgnoreComment(file: SourceFile, line: number): boolean {
|
|
41
|
+
const lines = getFileLines(file)
|
|
14
42
|
const currentLine = lines[line - 1] ?? ''
|
|
15
43
|
const prevLine = lines[line - 2] ?? ''
|
|
16
44
|
|
|
@@ -20,13 +48,13 @@ export function hasIgnoreComment(file: SourceFile, line: number): boolean {
|
|
|
20
48
|
}
|
|
21
49
|
|
|
22
50
|
export function isFileIgnored(file: SourceFile): boolean {
|
|
23
|
-
const firstLines = file
|
|
51
|
+
const firstLines = getFileLines(file).slice(0, 10).join('\n') // drift-ignore
|
|
24
52
|
return /\/\/\s*drift-ignore-file\b/.test(firstLines)
|
|
25
53
|
}
|
|
26
54
|
|
|
27
55
|
export function getSnippet(node: Node, file: SourceFile): string {
|
|
28
56
|
const startLine = node.getStartLineNumber()
|
|
29
|
-
const lines = file
|
|
57
|
+
const lines = getFileLines(file)
|
|
30
58
|
return lines
|
|
31
59
|
.slice(Math.max(0, startLine - 1), startLine + 1)
|
|
32
60
|
.join('\n')
|