@eduardbar/drift 1.2.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.
Files changed (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
package/src/diff.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import type { DriftReport, DriftDiff, FileDiff, DriftIssue } from './types.js'
2
2
 
3
+ function normalizePath(filePath: string): string {
4
+ return filePath.replace(/\\/g, '/')
5
+ }
6
+
7
+ function normalizeIssueText(value: string): string {
8
+ return value
9
+ .replace(/\r\n/g, '\n')
10
+ .replace(/\r/g, '\n')
11
+ .replace(/\s+/g, ' ')
12
+ .trim()
13
+ }
14
+
3
15
  /**
4
16
  * Compute the diff between two DriftReports.
5
17
  *
6
- * Issues are matched by (rule + line + column) as a unique key within a file.
18
+ * Issues are matched in two passes:
19
+ * 1) strict location key (rule + line + column)
20
+ * 2) normalized content key (rule + severity + line + message + snippet)
21
+ *
22
+ * This keeps deterministic matching while preventing false churn caused by
23
+ * cross-platform line ending changes and small column offset noise.
7
24
  * A "new" issue exists in `current` but not in `base`.
8
25
  * A "resolved" issue exists in `base` but not in `current`.
9
26
  */
@@ -19,13 +36,61 @@ function computeFileDiff(
19
36
  const baseIssues = baseFile?.issues ?? []
20
37
  const currentIssues = currentFile?.issues ?? []
21
38
 
22
- const issueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
39
+ const strictIssueKey = (i: DriftIssue) => `${i.rule}:${i.line}:${i.column}`
40
+ const normalizedIssueKey = (i: DriftIssue) => {
41
+ const normalizedMessage = normalizeIssueText(i.message)
42
+ const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, 80)
43
+ return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
44
+ }
45
+
46
+ const matchedBaseIndexes = new Set<number>()
47
+ const matchedCurrentIndexes = new Set<number>()
48
+
49
+ const baseStrictIndex = new Map<string, number[]>()
50
+ for (const [index, issue] of baseIssues.entries()) {
51
+ const key = strictIssueKey(issue)
52
+ const bucket = baseStrictIndex.get(key)
53
+ if (bucket) bucket.push(index)
54
+ else baseStrictIndex.set(key, [index])
55
+ }
56
+
57
+ for (const [currentIndex, issue] of currentIssues.entries()) {
58
+ const key = strictIssueKey(issue)
59
+ const bucket = baseStrictIndex.get(key)
60
+ if (!bucket || bucket.length === 0) continue
23
61
 
24
- const baseKeys = new Set(baseIssues.map(issueKey))
25
- const currentKeys = new Set(currentIssues.map(issueKey))
62
+ const matchedIndex = bucket.shift()
63
+ if (matchedIndex === undefined) continue
64
+
65
+ matchedBaseIndexes.add(matchedIndex)
66
+ matchedCurrentIndexes.add(currentIndex)
67
+ }
68
+
69
+ const baseNormalizedIndex = new Map<string, number[]>()
70
+ for (const [index, issue] of baseIssues.entries()) {
71
+ if (matchedBaseIndexes.has(index)) continue
72
+ const key = normalizedIssueKey(issue)
73
+ const bucket = baseNormalizedIndex.get(key)
74
+ if (bucket) bucket.push(index)
75
+ else baseNormalizedIndex.set(key, [index])
76
+ }
77
+
78
+ for (const [currentIndex, issue] of currentIssues.entries()) {
79
+ if (matchedCurrentIndexes.has(currentIndex)) continue
80
+
81
+ const key = normalizedIssueKey(issue)
82
+ const bucket = baseNormalizedIndex.get(key)
83
+ if (!bucket || bucket.length === 0) continue
84
+
85
+ const matchedIndex = bucket.shift()
86
+ if (matchedIndex === undefined) continue
87
+
88
+ matchedBaseIndexes.add(matchedIndex)
89
+ matchedCurrentIndexes.add(currentIndex)
90
+ }
26
91
 
27
- const newIssues = currentIssues.filter(i => !baseKeys.has(issueKey(i)))
28
- const resolvedIssues = baseIssues.filter(i => !currentKeys.has(issueKey(i)))
92
+ const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
93
+ const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))
29
94
 
30
95
  if (scoreDelta !== 0 || newIssues.length > 0 || resolvedIssues.length > 0) {
31
96
  return {
@@ -48,12 +113,12 @@ export function computeDiff(
48
113
  ): DriftDiff {
49
114
  const fileDiffs: FileDiff[] = []
50
115
 
51
- const baseByPath = new Map(base.files.map(f => [f.path, f]))
52
- const currentByPath = new Map(current.files.map(f => [f.path, f]))
116
+ const baseByPath = new Map(base.files.map(f => [normalizePath(f.path), f]))
117
+ const currentByPath = new Map(current.files.map(f => [normalizePath(f.path), f]))
53
118
 
54
119
  const allPaths = new Set([
55
- ...base.files.map(f => f.path),
56
- ...current.files.map(f => f.path),
120
+ ...base.files.map(f => normalizePath(f.path)),
121
+ ...current.files.map(f => normalizePath(f.path)),
57
122
  ])
58
123
 
59
124
  for (const filePath of allPaths) {
package/src/git.ts CHANGED
@@ -63,6 +63,18 @@ function extractFile(projectPath: string, ref: string, filePath: string, tempDir
63
63
  writeFileSync(destPath, content, 'utf-8')
64
64
  }
65
65
 
66
+ function extractArchiveAtRef(projectPath: string, ref: string, tempDir: string): boolean {
67
+ try {
68
+ execSync(
69
+ `git archive --format=tar ${ref} | tar -x -C "${tempDir}"`,
70
+ { cwd: projectPath, stdio: 'pipe' }
71
+ )
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
66
78
  export function extractFilesAtRef(projectPath: string, ref: string): string {
67
79
  verifyGitRepo(projectPath)
68
80
  verifyRefExists(projectPath, ref)
@@ -76,6 +88,10 @@ export function extractFilesAtRef(projectPath: string, ref: string): string {
76
88
  const tempDir = join(tmpdir(), `drift-diff-${randomUUID()}`)
77
89
  mkdirSync(tempDir, { recursive: true })
78
90
 
91
+ if (extractArchiveAtRef(projectPath, ref, tempDir)) {
92
+ return tempDir
93
+ }
94
+
79
95
  for (const filePath of tsFiles) {
80
96
  extractFile(projectPath, ref, filePath, tempDir)
81
97
  }
package/src/index.ts CHANGED
@@ -2,6 +2,22 @@ export { analyzeProject, analyzeFile, RULE_WEIGHTS } from './analyzer.js'
2
2
  export { buildReport, formatMarkdown } from './reporter.js'
3
3
  export { computeDiff } from './diff.js'
4
4
  export { generateReview, formatReviewMarkdown } from './review.js'
5
+ export {
6
+ buildTrustReport,
7
+ formatTrustConsole,
8
+ formatTrustMarkdown,
9
+ formatTrustJson,
10
+ shouldFailByMaxRisk,
11
+ shouldFailTrustGate,
12
+ normalizeMergeRiskLevel,
13
+ MERGE_RISK_ORDER,
14
+ } from './trust.js'
15
+ export {
16
+ computeTrustKpis,
17
+ computeTrustKpisFromReports,
18
+ formatTrustKpiConsole,
19
+ formatTrustKpiJson,
20
+ } from './trust-kpi.js'
5
21
  export { generateArchitectureMap, generateArchitectureSvg } from './map.js'
6
22
  export type {
7
23
  DriftReport,
@@ -12,6 +28,15 @@ export type {
12
28
  DriftConfig,
13
29
  RepoQualityScore,
14
30
  MaintenanceRiskMetrics,
31
+ DriftTrustReport,
32
+ TrustReason,
33
+ TrustFixPriority,
34
+ TrustDiffContext,
35
+ TrustKpiReport,
36
+ TrustKpiDiagnostic,
37
+ TrustDiffTrendSummary,
38
+ TrustScoreStats,
39
+ MergeRiskLevel,
15
40
  DriftPlugin,
16
41
  DriftPluginRule,
17
42
  } from './types.js'
@@ -21,14 +46,37 @@ export {
21
46
  DEFAULT_SAAS_POLICY,
22
47
  defaultSaasStorePath,
23
48
  resolveSaasPolicy,
49
+ SaasActorRequiredError,
50
+ SaasPermissionError,
51
+ getRequiredRoleForOperation,
52
+ assertSaasPermission,
53
+ getSaasEffectiveLimits,
54
+ getOrganizationEffectiveLimits,
55
+ changeOrganizationPlan,
56
+ listOrganizationPlanChanges,
57
+ getOrganizationUsageSnapshot,
24
58
  ingestSnapshotFromReport,
59
+ listSaasSnapshots,
25
60
  getSaasSummary,
26
61
  generateSaasDashboardHtml,
27
62
  } from './saas.js'
28
63
  export type {
64
+ SaasRole,
65
+ SaasPlan,
29
66
  SaasPolicy,
67
+ SaasPolicyOverrides,
30
68
  SaasStore,
31
69
  SaasSummary,
32
70
  SaasSnapshot,
71
+ SaasQueryOptions,
33
72
  IngestOptions,
73
+ SaasPlanChange,
74
+ SaasOperation,
75
+ SaasPermissionContext,
76
+ SaasPermissionResult,
77
+ SaasEffectiveLimits,
78
+ SaasOrganizationUsageSnapshot,
79
+ ChangeOrganizationPlanOptions,
80
+ SaasUsageQueryOptions,
81
+ SaasPlanChangeQueryOptions,
34
82
  } from './saas.js'
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
- 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
- )
11
+ type PluginCandidate = {
12
+ name?: unknown
13
+ apiVersion?: unknown
14
+ capabilities?: unknown
15
+ rules?: unknown
19
16
  }
20
17
 
21
- function normalizePluginExport(mod: unknown): DriftPlugin | undefined {
22
- if (isPluginShape(mod)) return mod
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
- const maybeDefault = (mod as { default?: unknown }).default
25
- if (isPluginShape(maybeDefault)) return maybeDefault
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 plugin = normalizePluginExport(mod)
59
- if (!plugin) {
60
- errors.push({
61
- pluginId,
62
- message: `Invalid plugin contract in '${pluginId}'. Expected: { name, rules[] }`,
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
- loaded.push({ id: pluginId, plugin })
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: file.path.replace(tempDir!, resolvedPath),
69
+ path: resolve(resolvedPath, relative(tempDir!, file.path)),
70
70
  })),
71
71
  }
72
72
 
@@ -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.getFullText().split('\n')
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()