@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.
Files changed (195) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +60 -0
  3. package/.github/actions/drift-review/action.yml +131 -0
  4. package/.github/actions/drift-scan/README.md +28 -32
  5. package/.github/actions/drift-scan/action.yml +78 -14
  6. package/.github/workflows/publish-vscode.yml +3 -3
  7. package/.github/workflows/publish.yml +3 -3
  8. package/.github/workflows/review-pr.yml +94 -9
  9. package/AGENTS.md +75 -245
  10. package/CHANGELOG.md +28 -0
  11. package/README.md +308 -51
  12. package/ROADMAP.md +6 -5
  13. package/dist/analyzer.d.ts +2 -2
  14. package/dist/analyzer.js +420 -159
  15. package/dist/benchmark.d.ts +2 -0
  16. package/dist/benchmark.js +204 -0
  17. package/dist/cli.js +693 -67
  18. package/dist/config.js +16 -2
  19. package/dist/diff.js +66 -10
  20. package/dist/doctor.d.ts +5 -0
  21. package/dist/doctor.js +133 -0
  22. package/dist/format.d.ts +17 -0
  23. package/dist/format.js +45 -0
  24. package/dist/git.js +12 -0
  25. package/dist/guard-types.d.ts +57 -0
  26. package/dist/guard-types.js +2 -0
  27. package/dist/guard.d.ts +14 -0
  28. package/dist/guard.js +239 -0
  29. package/dist/index.d.ts +12 -3
  30. package/dist/index.js +6 -1
  31. package/dist/init.d.ts +15 -0
  32. package/dist/init.js +273 -0
  33. package/dist/map-cycles.d.ts +2 -0
  34. package/dist/map-cycles.js +34 -0
  35. package/dist/map-svg.d.ts +19 -0
  36. package/dist/map-svg.js +97 -0
  37. package/dist/map.js +78 -138
  38. package/dist/metrics.js +70 -55
  39. package/dist/output-metadata.d.ts +13 -0
  40. package/dist/output-metadata.js +17 -0
  41. package/dist/plugins-capabilities.d.ts +4 -0
  42. package/dist/plugins-capabilities.js +21 -0
  43. package/dist/plugins-messages.d.ts +10 -0
  44. package/dist/plugins-messages.js +16 -0
  45. package/dist/plugins-rules.d.ts +9 -0
  46. package/dist/plugins-rules.js +137 -0
  47. package/dist/plugins.d.ts +2 -1
  48. package/dist/plugins.js +80 -28
  49. package/dist/printer.js +4 -0
  50. package/dist/reporter-constants.d.ts +16 -0
  51. package/dist/reporter-constants.js +39 -0
  52. package/dist/reporter.d.ts +3 -3
  53. package/dist/reporter.js +35 -55
  54. package/dist/review.d.ts +2 -1
  55. package/dist/review.js +4 -3
  56. package/dist/rules/comments.js +2 -2
  57. package/dist/rules/complexity.js +2 -7
  58. package/dist/rules/nesting.js +3 -13
  59. package/dist/rules/phase0-basic.js +10 -10
  60. package/dist/rules/phase3-configurable.js +23 -15
  61. package/dist/rules/shared.d.ts +2 -0
  62. package/dist/rules/shared.js +27 -3
  63. package/dist/saas/constants.d.ts +15 -0
  64. package/dist/saas/constants.js +48 -0
  65. package/dist/saas/dashboard.d.ts +8 -0
  66. package/dist/saas/dashboard.js +132 -0
  67. package/dist/saas/errors.d.ts +19 -0
  68. package/dist/saas/errors.js +37 -0
  69. package/dist/saas/helpers.d.ts +21 -0
  70. package/dist/saas/helpers.js +110 -0
  71. package/dist/saas/ingest.d.ts +3 -0
  72. package/dist/saas/ingest.js +249 -0
  73. package/dist/saas/organization.d.ts +5 -0
  74. package/dist/saas/organization.js +82 -0
  75. package/dist/saas/plan-change.d.ts +10 -0
  76. package/dist/saas/plan-change.js +15 -0
  77. package/dist/saas/store.d.ts +21 -0
  78. package/dist/saas/store.js +159 -0
  79. package/dist/saas/types.d.ts +191 -0
  80. package/dist/saas/types.js +2 -0
  81. package/dist/saas.d.ts +8 -82
  82. package/dist/saas.js +7 -320
  83. package/dist/sarif.d.ts +74 -0
  84. package/dist/sarif.js +122 -0
  85. package/dist/trust-advanced.d.ts +14 -0
  86. package/dist/trust-advanced.js +65 -0
  87. package/dist/trust-kpi-fs.d.ts +3 -0
  88. package/dist/trust-kpi-fs.js +141 -0
  89. package/dist/trust-kpi-parse.d.ts +7 -0
  90. package/dist/trust-kpi-parse.js +186 -0
  91. package/dist/trust-kpi-types.d.ts +16 -0
  92. package/dist/trust-kpi-types.js +2 -0
  93. package/dist/trust-kpi.d.ts +7 -0
  94. package/dist/trust-kpi.js +185 -0
  95. package/dist/trust-policy.d.ts +32 -0
  96. package/dist/trust-policy.js +160 -0
  97. package/dist/trust-render.d.ts +9 -0
  98. package/dist/trust-render.js +54 -0
  99. package/dist/trust-scoring.d.ts +9 -0
  100. package/dist/trust-scoring.js +208 -0
  101. package/dist/trust.d.ts +37 -0
  102. package/dist/trust.js +168 -0
  103. package/dist/types/app.d.ts +30 -0
  104. package/dist/types/app.js +2 -0
  105. package/dist/types/config.d.ts +25 -0
  106. package/dist/types/config.js +2 -0
  107. package/dist/types/core.d.ts +100 -0
  108. package/dist/types/core.js +2 -0
  109. package/dist/types/diff.d.ts +55 -0
  110. package/dist/types/diff.js +2 -0
  111. package/dist/types/plugin.d.ts +41 -0
  112. package/dist/types/plugin.js +2 -0
  113. package/dist/types/trust.d.ts +120 -0
  114. package/dist/types/trust.js +2 -0
  115. package/dist/types.d.ts +8 -211
  116. package/docs/PRD.md +187 -109
  117. package/docs/plugin-contract.md +61 -0
  118. package/docs/release-notes-draft.md +40 -0
  119. package/docs/rules-catalog.md +49 -0
  120. package/docs/trust-core-release-checklist.md +87 -0
  121. package/package.json +6 -3
  122. package/packages/vscode-drift/src/code-actions.ts +1 -1
  123. package/schemas/drift-ai-output.v1.json +162 -0
  124. package/schemas/drift-report.v1.json +151 -0
  125. package/schemas/drift-trust.v1.json +131 -0
  126. package/scripts/smoke-repo.mjs +394 -0
  127. package/src/analyzer.ts +484 -155
  128. package/src/benchmark.ts +266 -0
  129. package/src/cli.ts +840 -85
  130. package/src/config.ts +19 -2
  131. package/src/diff.ts +84 -10
  132. package/src/doctor.ts +173 -0
  133. package/src/format.ts +81 -0
  134. package/src/git.ts +16 -0
  135. package/src/guard-types.ts +64 -0
  136. package/src/guard.ts +324 -0
  137. package/src/index.ts +83 -0
  138. package/src/init.ts +298 -0
  139. package/src/map-cycles.ts +38 -0
  140. package/src/map-svg.ts +124 -0
  141. package/src/map.ts +111 -142
  142. package/src/metrics.ts +78 -59
  143. package/src/output-metadata.ts +30 -0
  144. package/src/plugins-capabilities.ts +36 -0
  145. package/src/plugins-messages.ts +35 -0
  146. package/src/plugins-rules.ts +296 -0
  147. package/src/plugins.ts +148 -27
  148. package/src/printer.ts +4 -0
  149. package/src/reporter-constants.ts +46 -0
  150. package/src/reporter.ts +64 -65
  151. package/src/review.ts +6 -4
  152. package/src/rules/comments.ts +2 -2
  153. package/src/rules/complexity.ts +2 -7
  154. package/src/rules/nesting.ts +3 -13
  155. package/src/rules/phase0-basic.ts +11 -12
  156. package/src/rules/phase3-configurable.ts +39 -26
  157. package/src/rules/shared.ts +31 -3
  158. package/src/saas/constants.ts +56 -0
  159. package/src/saas/dashboard.ts +172 -0
  160. package/src/saas/errors.ts +45 -0
  161. package/src/saas/helpers.ts +140 -0
  162. package/src/saas/ingest.ts +278 -0
  163. package/src/saas/organization.ts +99 -0
  164. package/src/saas/plan-change.ts +19 -0
  165. package/src/saas/store.ts +172 -0
  166. package/src/saas/types.ts +216 -0
  167. package/src/saas.ts +49 -433
  168. package/src/sarif.ts +232 -0
  169. package/src/trust-advanced.ts +99 -0
  170. package/src/trust-kpi-fs.ts +169 -0
  171. package/src/trust-kpi-parse.ts +219 -0
  172. package/src/trust-kpi-types.ts +19 -0
  173. package/src/trust-kpi.ts +210 -0
  174. package/src/trust-policy.ts +246 -0
  175. package/src/trust-render.ts +61 -0
  176. package/src/trust-scoring.ts +231 -0
  177. package/src/trust.ts +260 -0
  178. package/src/types/app.ts +30 -0
  179. package/src/types/config.ts +27 -0
  180. package/src/types/core.ts +105 -0
  181. package/src/types/diff.ts +61 -0
  182. package/src/types/plugin.ts +46 -0
  183. package/src/types/trust.ts +134 -0
  184. package/src/types.ts +78 -238
  185. package/tests/cli-sarif.test.ts +92 -0
  186. package/tests/diff.test.ts +124 -0
  187. package/tests/format.test.ts +157 -0
  188. package/tests/new-features.test.ts +80 -1
  189. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  190. package/tests/plugins.test.ts +219 -0
  191. package/tests/rules.test.ts +23 -1
  192. package/tests/saas-foundation.test.ts +358 -1
  193. package/tests/sarif.test.ts +160 -0
  194. package/tests/trust-kpi.test.ts +147 -0
  195. 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, LoadedPlugin } from './types.js'
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
- function isPluginShape(value: unknown): value is DriftPlugin {
9
- if (!value || typeof value !== 'object') return false
10
- const candidate = value as Partial<DriftPlugin>
11
- if (typeof candidate.name !== 'string') return false
12
- if (!Array.isArray(candidate.rules)) return false
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): DriftPlugin | undefined {
22
- if (isPluginShape(mod)) return mod
19
+ function normalizePluginExport(mod: unknown): unknown {
23
20
  if (mod && typeof mod === 'object' && 'default' in mod) {
24
- const maybeDefault = (mod as { default?: unknown }).default
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 plugin = normalizePluginExport(mod)
59
- if (!plugin) {
60
- errors.push({
61
- pluginId,
62
- message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
63
- })
64
- continue
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 }