@eduardbar/drift 1.3.0 → 1.5.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 (198) hide show
  1. package/.gga +50 -0
  2. package/.github/actions/drift-review/README.md +62 -0
  3. package/.github/actions/drift-review/action.yml +148 -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 +1 -3
  7. package/.github/workflows/publish.yml +8 -0
  8. package/.github/workflows/quality.yml +15 -0
  9. package/.github/workflows/reusable-quality-checks.yml +95 -0
  10. package/.github/workflows/review-pr.yml +33 -41
  11. package/AGENTS.md +75 -251
  12. package/CHANGELOG.md +41 -0
  13. package/README.md +177 -43
  14. package/benchmarks/fixtures/critical/drift.config.ts +21 -0
  15. package/benchmarks/fixtures/critical/src/app/user-service.ts +30 -0
  16. package/benchmarks/fixtures/critical/src/domain/entities.ts +19 -0
  17. package/benchmarks/fixtures/critical/src/domain/policies.ts +22 -0
  18. package/benchmarks/fixtures/critical/src/index.ts +10 -0
  19. package/benchmarks/fixtures/critical/src/infra/memory-user-repo.ts +14 -0
  20. package/benchmarks/perf-budget.v1.json +27 -0
  21. package/dist/benchmark.d.ts +1 -1
  22. package/dist/benchmark.js +83 -52
  23. package/dist/cli.js +243 -8
  24. package/dist/config.js +16 -2
  25. package/dist/diff.js +42 -50
  26. package/dist/doctor.d.ts +26 -0
  27. package/dist/doctor.js +140 -0
  28. package/dist/format.d.ts +17 -0
  29. package/dist/format.js +45 -0
  30. package/dist/guard-baseline.d.ts +12 -0
  31. package/dist/guard-baseline.js +57 -0
  32. package/dist/guard-metrics.d.ts +6 -0
  33. package/dist/guard-metrics.js +39 -0
  34. package/dist/guard-types.d.ts +58 -0
  35. package/dist/guard-types.js +2 -0
  36. package/dist/guard.d.ts +16 -0
  37. package/dist/guard.js +178 -0
  38. package/dist/index.d.ts +10 -3
  39. package/dist/index.js +4 -1
  40. package/dist/init.d.ts +15 -0
  41. package/dist/init.js +273 -0
  42. package/dist/map-cycles.d.ts +2 -0
  43. package/dist/map-cycles.js +34 -0
  44. package/dist/map-svg.d.ts +19 -0
  45. package/dist/map-svg.js +97 -0
  46. package/dist/map.js +78 -138
  47. package/dist/metrics.js +70 -55
  48. package/dist/output-metadata.d.ts +15 -0
  49. package/dist/output-metadata.js +19 -0
  50. package/dist/plugins-capabilities.d.ts +4 -0
  51. package/dist/plugins-capabilities.js +21 -0
  52. package/dist/plugins-messages.d.ts +10 -0
  53. package/dist/plugins-messages.js +16 -0
  54. package/dist/plugins-rules.d.ts +9 -0
  55. package/dist/plugins-rules.js +137 -0
  56. package/dist/plugins.d.ts +1 -1
  57. package/dist/plugins.js +45 -142
  58. package/dist/reporter-constants.d.ts +16 -0
  59. package/dist/reporter-constants.js +39 -0
  60. package/dist/reporter.d.ts +3 -3
  61. package/dist/reporter.js +35 -55
  62. package/dist/review.d.ts +2 -1
  63. package/dist/review.js +2 -1
  64. package/dist/rules/phase3-configurable.js +23 -15
  65. package/dist/saas/constants.d.ts +15 -0
  66. package/dist/saas/constants.js +48 -0
  67. package/dist/saas/dashboard.d.ts +8 -0
  68. package/dist/saas/dashboard.js +132 -0
  69. package/dist/saas/errors.d.ts +19 -0
  70. package/dist/saas/errors.js +37 -0
  71. package/dist/saas/helpers.d.ts +21 -0
  72. package/dist/saas/helpers.js +110 -0
  73. package/dist/saas/ingest.d.ts +3 -0
  74. package/dist/saas/ingest.js +249 -0
  75. package/dist/saas/organization.d.ts +5 -0
  76. package/dist/saas/organization.js +82 -0
  77. package/dist/saas/plan-change.d.ts +10 -0
  78. package/dist/saas/plan-change.js +15 -0
  79. package/dist/saas/store.d.ts +21 -0
  80. package/dist/saas/store.js +159 -0
  81. package/dist/saas/types.d.ts +191 -0
  82. package/dist/saas/types.js +2 -0
  83. package/dist/saas.d.ts +8 -218
  84. package/dist/saas.js +7 -761
  85. package/dist/sarif.d.ts +74 -0
  86. package/dist/sarif.js +122 -0
  87. package/dist/trust-advanced.d.ts +14 -0
  88. package/dist/trust-advanced.js +65 -0
  89. package/dist/trust-kpi-fs.d.ts +3 -0
  90. package/dist/trust-kpi-fs.js +141 -0
  91. package/dist/trust-kpi-parse.d.ts +7 -0
  92. package/dist/trust-kpi-parse.js +186 -0
  93. package/dist/trust-kpi-types.d.ts +16 -0
  94. package/dist/trust-kpi-types.js +2 -0
  95. package/dist/trust-kpi.d.ts +1 -3
  96. package/dist/trust-kpi.js +6 -266
  97. package/dist/trust-policy.d.ts +32 -0
  98. package/dist/trust-policy.js +160 -0
  99. package/dist/trust-render.d.ts +9 -0
  100. package/dist/trust-render.js +54 -0
  101. package/dist/trust-scoring.d.ts +9 -0
  102. package/dist/trust-scoring.js +208 -0
  103. package/dist/trust.d.ts +5 -32
  104. package/dist/trust.js +29 -432
  105. package/dist/types/app.d.ts +30 -0
  106. package/dist/types/app.js +2 -0
  107. package/dist/types/config.d.ts +25 -0
  108. package/dist/types/config.js +2 -0
  109. package/dist/types/core.d.ts +100 -0
  110. package/dist/types/core.js +2 -0
  111. package/dist/types/diff.d.ts +55 -0
  112. package/dist/types/diff.js +2 -0
  113. package/dist/types/plugin.d.ts +41 -0
  114. package/dist/types/plugin.js +2 -0
  115. package/dist/types/trust.d.ts +120 -0
  116. package/dist/types/trust.js +2 -0
  117. package/dist/types.d.ts +8 -365
  118. package/docs/AGENTS.md +1 -1
  119. package/docs/release-notes-draft.md +40 -0
  120. package/docs/rules-catalog.md +49 -0
  121. package/docs/trust-core-release-checklist.md +37 -5
  122. package/package.json +11 -4
  123. package/packages/vscode-drift/src/code-actions.ts +1 -1
  124. package/schemas/drift-ai-output.v1.json +162 -0
  125. package/schemas/drift-doctor.v1.json +57 -0
  126. package/schemas/drift-guard.v1.json +298 -0
  127. package/schemas/drift-report.v1.json +151 -0
  128. package/schemas/drift-trust.v1.json +131 -0
  129. package/scripts/check-docs-drift.mjs +154 -0
  130. package/scripts/check-performance-budget.mjs +360 -0
  131. package/scripts/check-runtime-policy.mjs +66 -0
  132. package/scripts/smoke-repo.mjs +394 -0
  133. package/src/benchmark.ts +92 -53
  134. package/src/cli.ts +285 -13
  135. package/src/config.ts +19 -2
  136. package/src/diff.ts +57 -48
  137. package/src/doctor.ts +185 -0
  138. package/src/format.ts +81 -0
  139. package/src/guard-baseline.ts +74 -0
  140. package/src/guard-metrics.ts +52 -0
  141. package/src/guard-types.ts +66 -0
  142. package/src/guard.ts +248 -0
  143. package/src/index.ts +36 -0
  144. package/src/init.ts +298 -0
  145. package/src/map-cycles.ts +38 -0
  146. package/src/map-svg.ts +124 -0
  147. package/src/map.ts +111 -142
  148. package/src/metrics.ts +78 -59
  149. package/src/output-metadata.ts +32 -0
  150. package/src/plugins-capabilities.ts +36 -0
  151. package/src/plugins-messages.ts +35 -0
  152. package/src/plugins-rules.ts +296 -0
  153. package/src/plugins.ts +76 -283
  154. package/src/reporter-constants.ts +46 -0
  155. package/src/reporter.ts +64 -65
  156. package/src/review.ts +4 -2
  157. package/src/rules/phase3-configurable.ts +39 -26
  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 -1031
  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 +8 -316
  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 +62 -576
  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 +79 -409
  185. package/tests/ci-quality-matrix.test.ts +37 -0
  186. package/tests/ci-smoke-gate.test.ts +26 -0
  187. package/tests/ci-version-alignment.test.ts +93 -0
  188. package/tests/cli-sarif.test.ts +92 -0
  189. package/tests/docs-drift-check.test.ts +115 -0
  190. package/tests/format.test.ts +157 -0
  191. package/tests/new-features.test.ts +11 -3
  192. package/tests/perf-budget-check.test.ts +146 -0
  193. package/tests/phase1-init-doctor-guard.test.ts +301 -0
  194. package/tests/runtime-policy-alignment.test.ts +46 -0
  195. package/tests/sarif.test.ts +160 -0
  196. package/tests/trust-kpi.test.ts +31 -4
  197. package/tests/trust.test.ts +18 -0
  198. package/vitest.config.ts +2 -0
package/src/plugins.ts CHANGED
@@ -1,12 +1,13 @@
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 { DriftIssue, DriftPlugin, DriftPluginRule, PluginLoadError, PluginLoadWarning, 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)
7
- const VALID_SEVERITIES: DriftIssue['severity'][] = ['error', 'warning', 'info']
8
10
  const SUPPORTED_PLUGIN_API_VERSION = 1
9
- const RULE_ID_REQUIRED = /^[a-z][a-z0-9]*(?:[-_/][a-z0-9]+)*$/
10
11
 
11
12
  type PluginCandidate = {
12
13
  name?: unknown
@@ -15,15 +16,6 @@ type PluginCandidate = {
15
16
  rules?: unknown
16
17
  }
17
18
 
18
- type RuleCandidate = {
19
- id?: unknown
20
- name?: unknown
21
- severity?: unknown
22
- weight?: unknown
23
- detect?: unknown
24
- fix?: unknown
25
- }
26
-
27
19
  function normalizePluginExport(mod: unknown): unknown {
28
20
  if (mod && typeof mod === 'object' && 'default' in mod) {
29
21
  return (mod as { default?: unknown }).default ?? mod
@@ -31,194 +23,46 @@ function normalizePluginExport(mod: unknown): unknown {
31
23
  return mod
32
24
  }
33
25
 
34
- function pushError(
35
- errors: PluginLoadError[],
26
+ function ensureObjectCandidate(
36
27
  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
- }
28
+ candidate: unknown,
29
+ errors: PluginLoadError[],
30
+ ): PluginCandidate | undefined {
31
+ if (candidate && typeof candidate === 'object') {
32
+ return candidate as PluginCandidate
33
+ }
48
34
 
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({
35
+ pushError(
36
+ errors,
56
37
  pluginId,
57
- pluginName: options?.pluginName,
58
- ruleId: options?.ruleId,
59
- code: options?.code,
60
- message,
61
- })
38
+ `Invalid plugin contract in '${pluginId}'. Expected an object export with shape { name, rules[] }`,
39
+ { code: 'plugin-shape-invalid' },
40
+ )
41
+ return undefined
62
42
  }
63
43
 
64
- function normalizeRule(
44
+ function ensurePluginName(
65
45
  pluginId: string,
66
- pluginName: string,
67
- rawRule: RuleCandidate,
68
- ruleIndex: number,
69
- options: { strictRuleId: boolean },
46
+ plugin: PluginCandidate,
70
47
  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
- }
48
+ ): string | undefined {
49
+ const pluginName = typeof plugin.name === 'string' ? plugin.name.trim() : ''
50
+ if (pluginName) return pluginName
178
51
 
179
- return {
180
- id: rawRuleId,
181
- name: rawRuleId,
182
- detect: rawRule.detect as DriftPluginRule['detect'],
183
- severity,
184
- weight,
185
- fix,
186
- }
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
187
59
  }
188
60
 
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
-
61
+ function validateApiVersion(
62
+ plugin: PluginCandidate,
63
+ context: PluginValidationContext,
64
+ ): { hasExplicitApiVersion: boolean; isLegacyPlugin: boolean; isSupported: boolean } {
65
+ const { pluginId, pluginName, errors, warnings } = context
222
66
  const hasExplicitApiVersion = plugin.apiVersion !== undefined
223
67
  const isLegacyPlugin = !hasExplicitApiVersion
224
68
 
@@ -229,119 +73,60 @@ function validatePluginContract(
229
73
  `Plugin '${pluginName}' does not declare 'apiVersion'. Assuming ${SUPPORTED_PLUGIN_API_VERSION} for backward compatibility; please add apiVersion: ${SUPPORTED_PLUGIN_API_VERSION}.`,
230
74
  { pluginName, code: 'plugin-api-version-implicit' },
231
75
  )
232
- } else if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
76
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true }
77
+ }
78
+
79
+ if (typeof plugin.apiVersion !== 'number' || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion <= 0) {
233
80
  pushError(
234
81
  errors,
235
82
  pluginId,
236
83
  `Plugin '${pluginName}' has invalid apiVersion '${String(plugin.apiVersion)}'. Expected a positive integer (for example: ${SUPPORTED_PLUGIN_API_VERSION}).`,
237
84
  { pluginName, code: 'plugin-api-version-invalid' },
238
85
  )
239
- return { errors, warnings }
240
- } else if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
86
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false }
87
+ }
88
+
89
+ if (plugin.apiVersion !== SUPPORTED_PLUGIN_API_VERSION) {
241
90
  pushError(
242
91
  errors,
243
92
  pluginId,
244
93
  `Plugin '${pluginName}' targets apiVersion ${plugin.apiVersion}, but this drift build supports apiVersion ${SUPPORTED_PLUGIN_API_VERSION}.`,
245
94
  { pluginName, code: 'plugin-api-version-unsupported' },
246
95
  )
247
- return { errors, warnings }
96
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: false }
248
97
  }
249
98
 
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
- }
99
+ return { hasExplicitApiVersion, isLegacyPlugin, isSupported: true }
100
+ }
281
101
 
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
- }
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[] = []
291
109
 
292
- const normalizedRules: DriftPluginRule[] = []
293
- const seenRuleIds = new Set<string>()
110
+ const plugin = ensureObjectCandidate(pluginId, candidate, errors)
111
+ if (!plugin) return { errors, warnings }
294
112
 
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
- }
113
+ const pluginName = ensurePluginName(pluginId, plugin, errors)
114
+ if (!pluginName) return { errors, warnings }
305
115
 
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
- }
116
+ const context: PluginValidationContext = { pluginId, pluginName, errors, warnings }
117
+ const apiVersion = validateApiVersion(plugin, context)
118
+ if (!apiVersion.isSupported) return { errors, warnings }
325
119
 
326
- seenRuleIds.add(normalized.id ?? normalized.name)
327
- normalizedRules.push(normalized)
328
- }
329
- }
120
+ const capabilities = validateCapabilities(plugin.capabilities, context)
121
+ if (errors.length > 0) return { errors, warnings }
330
122
 
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
- }
123
+ const normalizedRules = normalizeRules(plugin.rules, apiVersion.isLegacyPlugin, context)
124
+ if (!normalizedRules) return { errors, warnings }
340
125
 
341
126
  return {
342
127
  plugin: {
343
128
  name: pluginName,
344
- apiVersion: hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
129
+ apiVersion: apiVersion.hasExplicitApiVersion ? plugin.apiVersion as number : SUPPORTED_PLUGIN_API_VERSION,
345
130
  capabilities,
346
131
  rules: normalizedRules,
347
132
  },
@@ -350,6 +135,17 @@ function validatePluginContract(
350
135
  }
351
136
  }
352
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
+
353
149
  function resolvePluginSpecifier(projectRoot: string, pluginId: string): string {
354
150
  if (pluginId.startsWith('.') || pluginId.startsWith('/')) {
355
151
  const abs = isAbsolute(pluginId) ? pluginId : resolve(projectRoot, pluginId)
@@ -386,10 +182,7 @@ export function loadPlugins(projectRoot: string, pluginIds: string[] | undefined
386
182
  errors.push(...validation.errors)
387
183
  warnings.push(...validation.warnings)
388
184
 
389
- if (!validation.plugin) {
390
- continue
391
- }
392
-
185
+ if (!validation.plugin) continue
393
186
  loaded.push({ id: pluginId, plugin: validation.plugin })
394
187
  } catch (error) {
395
188
  errors.push({
@@ -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 }