@eduardbar/drift 1.3.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 (168) 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/review-pr.yml +34 -41
  7. package/AGENTS.md +75 -251
  8. package/CHANGELOG.md +28 -0
  9. package/README.md +148 -41
  10. package/dist/benchmark.d.ts +1 -1
  11. package/dist/benchmark.js +71 -52
  12. package/dist/cli.js +243 -8
  13. package/dist/config.js +16 -2
  14. package/dist/diff.js +42 -50
  15. package/dist/doctor.d.ts +5 -0
  16. package/dist/doctor.js +133 -0
  17. package/dist/format.d.ts +17 -0
  18. package/dist/format.js +45 -0
  19. package/dist/guard-types.d.ts +57 -0
  20. package/dist/guard-types.js +2 -0
  21. package/dist/guard.d.ts +14 -0
  22. package/dist/guard.js +239 -0
  23. package/dist/index.d.ts +10 -3
  24. package/dist/index.js +4 -1
  25. package/dist/init.d.ts +15 -0
  26. package/dist/init.js +273 -0
  27. package/dist/map-cycles.d.ts +2 -0
  28. package/dist/map-cycles.js +34 -0
  29. package/dist/map-svg.d.ts +19 -0
  30. package/dist/map-svg.js +97 -0
  31. package/dist/map.js +78 -138
  32. package/dist/metrics.js +70 -55
  33. package/dist/output-metadata.d.ts +13 -0
  34. package/dist/output-metadata.js +17 -0
  35. package/dist/plugins-capabilities.d.ts +4 -0
  36. package/dist/plugins-capabilities.js +21 -0
  37. package/dist/plugins-messages.d.ts +10 -0
  38. package/dist/plugins-messages.js +16 -0
  39. package/dist/plugins-rules.d.ts +9 -0
  40. package/dist/plugins-rules.js +137 -0
  41. package/dist/plugins.d.ts +1 -1
  42. package/dist/plugins.js +45 -142
  43. package/dist/reporter-constants.d.ts +16 -0
  44. package/dist/reporter-constants.js +39 -0
  45. package/dist/reporter.d.ts +3 -3
  46. package/dist/reporter.js +35 -55
  47. package/dist/review.d.ts +2 -1
  48. package/dist/review.js +2 -1
  49. package/dist/rules/phase3-configurable.js +23 -15
  50. package/dist/saas/constants.d.ts +15 -0
  51. package/dist/saas/constants.js +48 -0
  52. package/dist/saas/dashboard.d.ts +8 -0
  53. package/dist/saas/dashboard.js +132 -0
  54. package/dist/saas/errors.d.ts +19 -0
  55. package/dist/saas/errors.js +37 -0
  56. package/dist/saas/helpers.d.ts +21 -0
  57. package/dist/saas/helpers.js +110 -0
  58. package/dist/saas/ingest.d.ts +3 -0
  59. package/dist/saas/ingest.js +249 -0
  60. package/dist/saas/organization.d.ts +5 -0
  61. package/dist/saas/organization.js +82 -0
  62. package/dist/saas/plan-change.d.ts +10 -0
  63. package/dist/saas/plan-change.js +15 -0
  64. package/dist/saas/store.d.ts +21 -0
  65. package/dist/saas/store.js +159 -0
  66. package/dist/saas/types.d.ts +191 -0
  67. package/dist/saas/types.js +2 -0
  68. package/dist/saas.d.ts +8 -218
  69. package/dist/saas.js +7 -761
  70. package/dist/sarif.d.ts +74 -0
  71. package/dist/sarif.js +122 -0
  72. package/dist/trust-advanced.d.ts +14 -0
  73. package/dist/trust-advanced.js +65 -0
  74. package/dist/trust-kpi-fs.d.ts +3 -0
  75. package/dist/trust-kpi-fs.js +141 -0
  76. package/dist/trust-kpi-parse.d.ts +7 -0
  77. package/dist/trust-kpi-parse.js +186 -0
  78. package/dist/trust-kpi-types.d.ts +16 -0
  79. package/dist/trust-kpi-types.js +2 -0
  80. package/dist/trust-kpi.d.ts +1 -3
  81. package/dist/trust-kpi.js +6 -266
  82. package/dist/trust-policy.d.ts +32 -0
  83. package/dist/trust-policy.js +160 -0
  84. package/dist/trust-render.d.ts +9 -0
  85. package/dist/trust-render.js +54 -0
  86. package/dist/trust-scoring.d.ts +9 -0
  87. package/dist/trust-scoring.js +208 -0
  88. package/dist/trust.d.ts +4 -32
  89. package/dist/trust.js +29 -432
  90. package/dist/types/app.d.ts +30 -0
  91. package/dist/types/app.js +2 -0
  92. package/dist/types/config.d.ts +25 -0
  93. package/dist/types/config.js +2 -0
  94. package/dist/types/core.d.ts +100 -0
  95. package/dist/types/core.js +2 -0
  96. package/dist/types/diff.d.ts +55 -0
  97. package/dist/types/diff.js +2 -0
  98. package/dist/types/plugin.d.ts +41 -0
  99. package/dist/types/plugin.js +2 -0
  100. package/dist/types/trust.d.ts +120 -0
  101. package/dist/types/trust.js +2 -0
  102. package/dist/types.d.ts +8 -365
  103. package/docs/release-notes-draft.md +40 -0
  104. package/docs/rules-catalog.md +49 -0
  105. package/docs/trust-core-release-checklist.md +37 -5
  106. package/package.json +3 -2
  107. package/packages/vscode-drift/src/code-actions.ts +1 -1
  108. package/schemas/drift-ai-output.v1.json +162 -0
  109. package/schemas/drift-report.v1.json +151 -0
  110. package/schemas/drift-trust.v1.json +131 -0
  111. package/scripts/smoke-repo.mjs +394 -0
  112. package/src/benchmark.ts +75 -53
  113. package/src/cli.ts +285 -13
  114. package/src/config.ts +19 -2
  115. package/src/diff.ts +57 -48
  116. package/src/doctor.ts +173 -0
  117. package/src/format.ts +81 -0
  118. package/src/guard-types.ts +64 -0
  119. package/src/guard.ts +324 -0
  120. package/src/index.ts +35 -0
  121. package/src/init.ts +298 -0
  122. package/src/map-cycles.ts +38 -0
  123. package/src/map-svg.ts +124 -0
  124. package/src/map.ts +111 -142
  125. package/src/metrics.ts +78 -59
  126. package/src/output-metadata.ts +30 -0
  127. package/src/plugins-capabilities.ts +36 -0
  128. package/src/plugins-messages.ts +35 -0
  129. package/src/plugins-rules.ts +296 -0
  130. package/src/plugins.ts +76 -283
  131. package/src/reporter-constants.ts +46 -0
  132. package/src/reporter.ts +64 -65
  133. package/src/review.ts +4 -2
  134. package/src/rules/phase3-configurable.ts +39 -26
  135. package/src/saas/constants.ts +56 -0
  136. package/src/saas/dashboard.ts +172 -0
  137. package/src/saas/errors.ts +45 -0
  138. package/src/saas/helpers.ts +140 -0
  139. package/src/saas/ingest.ts +278 -0
  140. package/src/saas/organization.ts +99 -0
  141. package/src/saas/plan-change.ts +19 -0
  142. package/src/saas/store.ts +172 -0
  143. package/src/saas/types.ts +216 -0
  144. package/src/saas.ts +49 -1031
  145. package/src/sarif.ts +232 -0
  146. package/src/trust-advanced.ts +99 -0
  147. package/src/trust-kpi-fs.ts +169 -0
  148. package/src/trust-kpi-parse.ts +219 -0
  149. package/src/trust-kpi-types.ts +19 -0
  150. package/src/trust-kpi.ts +8 -316
  151. package/src/trust-policy.ts +246 -0
  152. package/src/trust-render.ts +61 -0
  153. package/src/trust-scoring.ts +231 -0
  154. package/src/trust.ts +62 -576
  155. package/src/types/app.ts +30 -0
  156. package/src/types/config.ts +27 -0
  157. package/src/types/core.ts +105 -0
  158. package/src/types/diff.ts +61 -0
  159. package/src/types/plugin.ts +46 -0
  160. package/src/types/trust.ts +134 -0
  161. package/src/types.ts +78 -409
  162. package/tests/cli-sarif.test.ts +92 -0
  163. package/tests/format.test.ts +157 -0
  164. package/tests/new-features.test.ts +10 -2
  165. package/tests/phase1-init-doctor-guard.test.ts +199 -0
  166. package/tests/sarif.test.ts +160 -0
  167. package/tests/trust-kpi.test.ts +31 -4
  168. package/tests/trust.test.ts +18 -0
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { printConsole, printDiff } from './printer.js'
14
14
  import { loadConfig } from './config.js'
15
15
  import { extractFilesAtRef, cleanupTempDir } from './git.js'
16
16
  import { computeDiff } from './diff.js'
17
+ import { runGuard } from './guard.js'
17
18
  import { generateHtmlReport } from './report.js'
18
19
  import { generateBadge } from './badge.js'
19
20
  import { emitCIAnnotations, printCISummary } from './ci.js'
@@ -42,10 +43,15 @@ import {
42
43
  detectBranchName,
43
44
  } from './trust.js'
44
45
  import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js'
46
+ import { runBenchmarkCli } from './benchmark.js'
47
+ import { runInit, INIT_PRESETS } from './init.js'
48
+ import { runDoctor } from './doctor.js'
49
+ import { resolveOutputFormat } from './format.js'
50
+ import { toSarif, diffToSarif } from './sarif.js'
45
51
  import type { DriftDiff, DriftTrustReport, DriftAnalysisOptions, MergeRiskLevel } from './types.js'
52
+ import type { GuardResult, GuardThresholds } from './guard-types.js'
46
53
  import type { TrustGatePolicyExplanation } from './trust.js'
47
54
  import type { SnapshotHistory } from './snapshot.js'
48
-
49
55
  const program = new Command()
50
56
 
51
57
  type ResourceOptionFlags = {
@@ -84,6 +90,96 @@ function addResourceOptions(command: Command): Command {
84
90
  .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode')
85
91
  }
86
92
 
93
+ function parseOptionalNumber(rawValue: string | undefined, flagName: string): number | undefined {
94
+ if (rawValue == null) return undefined
95
+ const value = Number(rawValue)
96
+ if (!Number.isFinite(value)) {
97
+ throw new Error(`${flagName} must be a valid number`)
98
+ }
99
+ return value
100
+ }
101
+
102
+ function parseBySeverity(rawValue: string | undefined): GuardThresholds | undefined {
103
+ if (rawValue == null) return undefined
104
+
105
+ const spec = rawValue.trim()
106
+ if (!spec) {
107
+ throw new Error('--by-severity must not be empty. Expected format: error=0,warning=2,info=5')
108
+ }
109
+
110
+ const thresholds: GuardThresholds = {}
111
+ const seen = new Set<string>()
112
+
113
+ for (const segment of spec.split(',')) {
114
+ const pair = segment.trim()
115
+ if (!pair) continue
116
+
117
+ const equalIndex = pair.indexOf('=')
118
+ if (equalIndex <= 0 || equalIndex === pair.length - 1) {
119
+ throw new Error(`Invalid --by-severity entry '${pair}'. Expected key=value (e.g. warning=2).`)
120
+ }
121
+
122
+ const key = pair.slice(0, equalIndex).trim().toLowerCase()
123
+ const rawThreshold = pair.slice(equalIndex + 1).trim()
124
+
125
+ if (key !== 'error' && key !== 'warning' && key !== 'info') {
126
+ throw new Error(`Invalid --by-severity key '${key}'. Allowed keys: error, warning, info.`)
127
+ }
128
+
129
+ if (seen.has(key)) {
130
+ throw new Error(`Duplicate --by-severity key '${key}'.`)
131
+ }
132
+
133
+ const threshold = Number(rawThreshold)
134
+ if (!Number.isFinite(threshold)) {
135
+ throw new Error(`Invalid --by-severity value for '${key}': '${rawThreshold}'. Must be a valid number.`)
136
+ }
137
+
138
+ const severityKey: keyof GuardThresholds = key
139
+ thresholds[severityKey] = threshold
140
+ seen.add(severityKey)
141
+ }
142
+
143
+ if (seen.size === 0) {
144
+ throw new Error('--by-severity must include at least one threshold. Example: error=0,warning=2')
145
+ }
146
+
147
+ return thresholds
148
+ }
149
+
150
+ function formatSigned(value: number): string {
151
+ return value > 0 ? `+${value}` : `${value}`
152
+ }
153
+
154
+ function printGuardSummary(result: GuardResult): void {
155
+ const modeLabel = result.mode === 'diff' ? `diff (${result.baseRef ?? 'unknown base'})` : 'baseline'
156
+ const statusLabel = result.passed ? 'PASS' : 'FAIL'
157
+
158
+ process.stdout.write('\n')
159
+ process.stdout.write(`Guard mode: ${modeLabel}\n`)
160
+ process.stdout.write(`Result: ${statusLabel}\n`)
161
+ process.stdout.write(`Score delta: ${formatSigned(result.metrics.scoreDelta)}\n`)
162
+ process.stdout.write(`Total issues delta: ${formatSigned(result.metrics.totalIssuesDelta)}\n`)
163
+ process.stdout.write(
164
+ `Severity delta: error=${formatSigned(result.metrics.severityDelta.error)}, warning=${formatSigned(result.metrics.severityDelta.warning)}, info=${formatSigned(result.metrics.severityDelta.info)}\n`,
165
+ )
166
+ if (result.mode === 'baseline' && result.baselinePath) {
167
+ process.stdout.write(`Baseline file: ${result.baselinePath}\n`)
168
+ }
169
+
170
+ if (result.checks.length === 0) {
171
+ process.stdout.write('Checks: none configured\n')
172
+ return
173
+ }
174
+
175
+ process.stdout.write('Checks:\n')
176
+ for (const check of result.checks) {
177
+ process.stdout.write(
178
+ ` - [${check.passed ? 'PASS' : 'FAIL'}] ${check.id}: ${check.message} (actual=${check.actual}, limit=${check.limit})\n`,
179
+ )
180
+ }
181
+ }
182
+
87
183
  function parseTrustGateOverrides(options: { minTrust?: string; maxRisk?: string }): { minTrust?: number; maxRisk?: MergeRiskLevel } {
88
184
  const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined
89
185
  if (options.minTrust && Number.isNaN(cliMinTrust)) {
@@ -135,11 +231,12 @@ addResourceOptions(
135
231
  .command('scan [path]', { isDefault: true })
136
232
  .description('Scan a directory for vibe coding drift')
137
233
  .option('-o, --output <file>', 'Write report to a Markdown file')
234
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
138
235
  .option('--json', 'Output raw JSON report')
139
236
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
140
237
  .option('--fix', 'Show fix suggestions for each issue')
141
238
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
142
- .action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
239
+ .action(async (targetPath: string | undefined, options: { output?: string; format?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
143
240
  const resolvedPath = resolve(targetPath ?? '.')
144
241
 
145
242
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
@@ -148,17 +245,38 @@ addResourceOptions(
148
245
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
149
246
  const report = buildReport(resolvedPath, files)
150
247
 
151
- if (options.ai) {
248
+ const format = resolveOutputFormat({
249
+ command: 'scan',
250
+ format: options.format,
251
+ supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
252
+ legacyAliases: [
253
+ { flag: 'json', used: options.json, mapsTo: 'json' },
254
+ { flag: 'ai', used: options.ai, mapsTo: 'ai' },
255
+ ],
256
+ onWarning: (message) => process.stderr.write(`${message}\n`),
257
+ })
258
+
259
+ if (format === 'sarif') {
260
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`)
261
+ return
262
+ }
263
+
264
+ if (format === 'ai') {
152
265
  const aiOutput = formatAIOutput(report)
153
266
  process.stdout.write(JSON.stringify(aiOutput, null, 2))
154
267
  return
155
268
  }
156
269
 
157
- if (options.json) {
270
+ if (format === 'json') {
158
271
  process.stdout.write(JSON.stringify(report, null, 2))
159
272
  return
160
273
  }
161
274
 
275
+ if (format === 'markdown') {
276
+ process.stdout.write(`${formatMarkdown(report)}\n`)
277
+ return
278
+ }
279
+
162
280
  printConsole(report, { showFix: options.fix })
163
281
 
164
282
  if (options.output) {
@@ -176,12 +294,35 @@ addResourceOptions(
176
294
  }),
177
295
  )
178
296
 
297
+ program
298
+ .command('init')
299
+ .description('Initialize drift configuration with presets and scaffolding')
300
+ .option('--preset <type>', `Scaffold config with preset: ${INIT_PRESETS.join(', ')}`)
301
+ .option('--ci', 'Generate GitHub Actions workflow for drift review')
302
+ .option('--baseline', 'Create drift-baseline.json with current project score')
303
+ .action(async (options: { preset?: string; ci?: boolean; baseline?: boolean }) => {
304
+ const projectRoot = resolve('.')
305
+
306
+ try {
307
+ await runInit(projectRoot, {
308
+ preset: options.preset,
309
+ ci: options.ci,
310
+ baseline: options.baseline,
311
+ })
312
+ } catch (err) {
313
+ const message = err instanceof Error ? err.message : String(err)
314
+ process.stderr.write(`\n Error: ${message}\n\n`)
315
+ process.exit(1)
316
+ }
317
+ })
318
+
179
319
  addResourceOptions(
180
320
  program
181
321
  .command('diff [ref]')
182
322
  .description('Compare current state against a git ref (default: HEAD~1)')
323
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
183
324
  .option('--json', 'Output raw JSON diff')
184
- .action(async (ref: string | undefined, options: { json?: boolean } & ResourceOptionFlags) => {
325
+ .action(async (ref: string | undefined, options: { format?: string; json?: boolean } & ResourceOptionFlags) => {
185
326
  const baseRef = ref ?? 'HEAD~1'
186
327
  const projectPath = resolve('.')
187
328
  const analysisOptions = resolveAnalysisOptions(options)
@@ -191,6 +332,14 @@ addResourceOptions(
191
332
  try {
192
333
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`)
193
334
 
335
+ const format = resolveOutputFormat({
336
+ command: 'diff',
337
+ format: options.format,
338
+ supported: ['console', 'json', 'sarif'],
339
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
340
+ onWarning: (message) => process.stderr.write(`${message}\n`),
341
+ })
342
+
194
343
  // Scan current state
195
344
  const config = await loadConfig(projectPath)
196
345
  const currentFiles = analyzeProject(projectPath, config, analysisOptions)
@@ -213,7 +362,9 @@ addResourceOptions(
213
362
 
214
363
  const diff = computeDiff(remappedBase, currentReport, baseRef)
215
364
 
216
- if (options.json) {
365
+ if (format === 'sarif') {
366
+ process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`)
367
+ } else if (format === 'json') {
217
368
  process.stdout.write(JSON.stringify(diff, null, 2) + '\n')
218
369
  } else {
219
370
  printDiff(diff)
@@ -228,21 +379,93 @@ addResourceOptions(
228
379
  }),
229
380
  )
230
381
 
382
+ addResourceOptions(
383
+ program
384
+ .command('guard [path]')
385
+ .description('Evaluate drift guard thresholds against diff or baseline')
386
+ .option('--base <ref>', 'Git base ref for diff guard mode')
387
+ .option('--baseline <file>', 'Baseline file path (default: drift-baseline.json)')
388
+ .option('--budget <n>', 'Allowed score delta budget')
389
+ .option('--by-severity <spec>', 'Severity thresholds: error=0,warning=2,info=5')
390
+ .option('--json', 'Output raw JSON guard result')
391
+ .action(async (
392
+ targetPath: string | undefined,
393
+ options: {
394
+ base?: string
395
+ baseline?: string
396
+ budget?: string
397
+ bySeverity?: string
398
+ json?: boolean
399
+ } & ResourceOptionFlags,
400
+ ) => {
401
+ try {
402
+ const resolvedPath = resolve(targetPath ?? '.')
403
+ const budget = parseOptionalNumber(options.budget, '--budget')
404
+ const bySeverity = parseBySeverity(options.bySeverity)
405
+
406
+ const result = await runGuard(resolvedPath, {
407
+ baseRef: options.base,
408
+ baselinePath: options.baseline,
409
+ budget,
410
+ bySeverity,
411
+ analysis: resolveAnalysisOptions(options),
412
+ })
413
+
414
+ if (options.json) {
415
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n')
416
+ } else {
417
+ printGuardSummary(result)
418
+ }
419
+
420
+ if (!result.passed) {
421
+ process.exit(1)
422
+ }
423
+ } catch (err) {
424
+ const message = err instanceof Error ? err.message : String(err)
425
+ process.stderr.write(`\n Error: ${message}\n\n`)
426
+ process.exit(1)
427
+ }
428
+ }),
429
+ )
430
+
431
+ program
432
+ .command('benchmark')
433
+ .description('Run benchmark harness for scan/review/trust commands')
434
+ .allowUnknownOption(true)
435
+ .action(async () => {
436
+ await runBenchmarkCli(process.argv.slice(3))
437
+ })
438
+
231
439
  program
232
440
  .command('review')
233
441
  .description('Review drift against a base ref and output PR markdown')
234
442
  .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
443
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
235
444
  .option('--json', 'Output structured review JSON')
236
445
  .option('--comment', 'Output markdown comment body')
237
446
  .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
238
- .action(async (options: { base: string; json?: boolean; comment?: boolean; failOn?: string }) => {
447
+ .action(async (options: { base: string; format?: string; json?: boolean; comment?: boolean; failOn?: string }) => {
239
448
  try {
240
449
  const review = await generateReview(resolve('.'), options.base)
450
+ const format = resolveOutputFormat({
451
+ command: 'review',
452
+ format: options.format,
453
+ supported: ['console', 'json', 'markdown', 'sarif'],
454
+ legacyAliases: [
455
+ { flag: 'json', used: options.json, mapsTo: 'json' },
456
+ { flag: 'comment', used: options.comment, mapsTo: 'markdown' },
457
+ ],
458
+ onWarning: (message) => process.stderr.write(`${message}\n`),
459
+ })
241
460
 
242
- if (options.json) {
461
+ if (format === 'sarif') {
462
+ process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`)
463
+ } else if (format === 'json') {
243
464
  process.stdout.write(JSON.stringify(review, null, 2) + '\n')
465
+ } else if (format === 'markdown') {
466
+ process.stdout.write(`${review.markdown}\n`)
244
467
  } else {
245
- process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n')
468
+ process.stdout.write(`${review.summary}\n\n${review.markdown}\n`)
246
469
  }
247
470
 
248
471
  const failOn = options.failOn ? Number(options.failOn) : undefined
@@ -261,6 +484,7 @@ addResourceOptions(
261
484
  .command('trust [path]')
262
485
  .description('Compute merge trust baseline from drift signals')
263
486
  .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
487
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
264
488
  .option('--json', 'Output structured trust JSON')
265
489
  .option('--markdown', 'Output trust report as markdown (PR comment ready)')
266
490
  .option('-o, --output <file>', 'Write trust output to file')
@@ -277,6 +501,7 @@ addResourceOptions(
277
501
  targetPath: string | undefined,
278
502
  options: {
279
503
  base?: string
504
+ format?: string
280
505
  json?: boolean
281
506
  markdown?: boolean
282
507
  output?: string
@@ -364,7 +589,23 @@ addResourceOptions(
364
589
  },
365
590
  })
366
591
 
367
- const rendered = `${renderTrustOutput(trust, options)}\n`
592
+ const format = resolveOutputFormat({
593
+ command: 'trust',
594
+ format: options.format,
595
+ supported: ['console', 'json', 'markdown', 'sarif'],
596
+ legacyAliases: [
597
+ { flag: 'json', used: options.json, mapsTo: 'json' },
598
+ { flag: 'markdown', used: options.markdown, mapsTo: 'markdown' },
599
+ ],
600
+ onWarning: (message) => process.stderr.write(`${message}\n`),
601
+ })
602
+
603
+ const rendered = format === 'sarif'
604
+ ? `${JSON.stringify(toSarif(report), null, 2)}\n`
605
+ : `${renderTrustOutput(trust, {
606
+ json: format === 'json',
607
+ markdown: format === 'markdown',
608
+ })}\n`
368
609
 
369
610
  process.stdout.write(rendered)
370
611
 
@@ -469,6 +710,20 @@ program
469
710
  }
470
711
  })
471
712
 
713
+ program
714
+ .command('doctor')
715
+ .description('Run project environment diagnostics')
716
+ .option('--json', 'Output structured doctor JSON')
717
+ .action(async (opts: { json?: boolean }) => {
718
+ try {
719
+ await runDoctor(process.cwd(), { json: opts.json })
720
+ } catch (err) {
721
+ const message = err instanceof Error ? err.message : String(err)
722
+ process.stderr.write(`\n Error: ${message}\n\n`)
723
+ process.exitCode = 1
724
+ }
725
+ })
726
+
472
727
  program
473
728
  .command('kpi <path>')
474
729
  .description('Aggregate trust KPIs from trust JSON artifacts')
@@ -543,14 +798,31 @@ addResourceOptions(
543
798
  program
544
799
  .command('ci [path]')
545
800
  .description('Emit GitHub Actions annotations and step summary')
801
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
802
+ .option('--json', 'Output raw JSON report (legacy alias for --format json)')
546
803
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
547
- .action(async (targetPath: string | undefined, options: { minScore: string } & ResourceOptionFlags) => {
804
+ .action(async (targetPath: string | undefined, options: { format?: string; json?: boolean; minScore: string } & ResourceOptionFlags) => {
548
805
  const resolvedPath = resolve(targetPath ?? '.')
549
806
  const config = await loadConfig(resolvedPath)
550
807
  const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
551
808
  const report = buildReport(resolvedPath, files)
552
- emitCIAnnotations(report)
553
- printCISummary(report)
809
+
810
+ const format = resolveOutputFormat({
811
+ command: 'ci',
812
+ format: options.format,
813
+ supported: ['console', 'json', 'sarif'],
814
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
815
+ onWarning: (message) => process.stderr.write(`${message}\n`),
816
+ })
817
+
818
+ if (format === 'sarif') {
819
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`)
820
+ } else if (format === 'json') {
821
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
822
+ } else {
823
+ emitCIAnnotations(report)
824
+ printCISummary(report)
825
+ }
554
826
  const minScore = Number(options.minScore)
555
827
  if (minScore > 0 && report.totalScore > minScore) {
556
828
  process.exit(1)
package/src/config.ts CHANGED
@@ -3,6 +3,22 @@ import { join, resolve } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import type { DriftConfig } from './types.js'
5
5
 
6
+ function normalizeLegacyConfig(config: DriftConfig): DriftConfig {
7
+ if (config.modules !== undefined) {
8
+ return config
9
+ }
10
+
11
+ const legacyModules = config.moduleBoundaries ?? config.boundaries
12
+ if (!legacyModules || legacyModules.length === 0) {
13
+ return config
14
+ }
15
+
16
+ return {
17
+ ...config,
18
+ modules: legacyModules,
19
+ }
20
+ }
21
+
6
22
  /**
7
23
  * Load drift.config.ts / .js / .json from the given project root.
8
24
  * Returns undefined if no config file is found.
@@ -27,7 +43,8 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
27
43
 
28
44
  if (ext === 'json') {
29
45
  const { readFileSync } = await import('node:fs')
30
- return JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
46
+ const rawConfig = JSON.parse(readFileSync(candidate, 'utf-8')) as DriftConfig
47
+ return normalizeLegacyConfig(rawConfig)
31
48
  }
32
49
 
33
50
  // .ts / .js — dynamic import via file URL
@@ -35,7 +52,7 @@ export async function loadConfig(projectRoot: string): Promise<DriftConfig | und
35
52
  const mod = await import(fileUrl)
36
53
  const config: DriftConfig = mod.default ?? mod
37
54
 
38
- return config
55
+ return normalizeLegacyConfig(config)
39
56
  } catch { // drift-ignore
40
57
  // drift-ignore: catch-swallow — config is optional; load failure is non-fatal
41
58
  }
package/src/diff.ts CHANGED
@@ -12,6 +12,58 @@ function normalizeIssueText(value: string): string {
12
12
  .trim()
13
13
  }
14
14
 
15
+ const SNIPPET_PREFIX_LENGTH = 80
16
+
17
+ interface IssueMatchState {
18
+ matchedBaseIndexes: Set<number>
19
+ matchedCurrentIndexes: Set<number>
20
+ }
21
+
22
+ function strictIssueKey(i: DriftIssue): string {
23
+ return `${i.rule}:${i.line}:${i.column}`
24
+ }
25
+
26
+ function normalizedIssueKey(i: DriftIssue): string {
27
+ const normalizedMessage = normalizeIssueText(i.message)
28
+ const normalizedSnippetPrefix = normalizeIssueText(i.snippet).slice(0, SNIPPET_PREFIX_LENGTH)
29
+ return `${i.rule}:${i.severity}:${i.line}:${normalizedMessage}:${normalizedSnippetPrefix}`
30
+ }
31
+
32
+ function buildIssueIndex(
33
+ issues: DriftIssue[],
34
+ getKey: (issue: DriftIssue) => string,
35
+ skip?: Set<number>,
36
+ ): Map<string, number[]> {
37
+ const index = new Map<string, number[]>()
38
+ for (const [idx, issue] of issues.entries()) {
39
+ if (skip?.has(idx)) continue
40
+ const key = getKey(issue)
41
+ const bucket = index.get(key)
42
+ if (bucket) bucket.push(idx)
43
+ else index.set(key, [idx])
44
+ }
45
+ return index
46
+ }
47
+
48
+ function matchIssues(
49
+ currentIssues: DriftIssue[],
50
+ index: Map<string, number[]>,
51
+ state: IssueMatchState,
52
+ getKey: (issue: DriftIssue) => string,
53
+ ): void {
54
+ for (const [currentIndex, issue] of currentIssues.entries()) {
55
+ if (state.matchedCurrentIndexes.has(currentIndex)) continue
56
+ const bucket = index.get(getKey(issue))
57
+ if (!bucket || bucket.length === 0) continue
58
+
59
+ const matchedIndex = bucket.shift()
60
+ if (matchedIndex === undefined) continue
61
+
62
+ state.matchedBaseIndexes.add(matchedIndex)
63
+ state.matchedCurrentIndexes.add(currentIndex)
64
+ }
65
+ }
66
+
15
67
  /**
16
68
  * Compute the diff between two DriftReports.
17
69
  *
@@ -36,58 +88,15 @@ function computeFileDiff(
36
88
  const baseIssues = baseFile?.issues ?? []
37
89
  const currentIssues = currentFile?.issues ?? []
38
90
 
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
91
  const matchedBaseIndexes = new Set<number>()
47
92
  const matchedCurrentIndexes = new Set<number>()
93
+ const matchState = { matchedBaseIndexes, matchedCurrentIndexes }
48
94
 
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
61
-
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
95
+ const baseStrictIndex = buildIssueIndex(baseIssues, strictIssueKey)
96
+ matchIssues(currentIssues, baseStrictIndex, matchState, strictIssueKey)
84
97
 
85
- const matchedIndex = bucket.shift()
86
- if (matchedIndex === undefined) continue
87
-
88
- matchedBaseIndexes.add(matchedIndex)
89
- matchedCurrentIndexes.add(currentIndex)
90
- }
98
+ const baseNormalizedIndex = buildIssueIndex(baseIssues, normalizedIssueKey, matchedBaseIndexes)
99
+ matchIssues(currentIssues, baseNormalizedIndex, matchState, normalizedIssueKey)
91
100
 
92
101
  const newIssues = currentIssues.filter((_, index) => !matchedCurrentIndexes.has(index))
93
102
  const resolvedIssues = baseIssues.filter((_, index) => !matchedBaseIndexes.has(index))