@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
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // drift-ignore-file
3
3
  import { Command } from 'commander'
4
- import { writeFileSync } from 'node:fs'
5
- import { basename, resolve } from 'node:path'
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
+ import { basename, relative, resolve } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  import { createInterface } from 'node:readline/promises'
8
8
  import { stdin as input, stdout as output } from 'node:process'
@@ -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'
@@ -21,43 +22,261 @@ import { applyFixes, type FixResult } from './fix.js'
21
22
  import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
22
23
  import { generateReview } from './review.js'
23
24
  import { generateArchitectureMap } from './map.js'
24
- import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js'
25
-
25
+ import {
26
+ changeOrganizationPlan,
27
+ generateSaasDashboardHtml,
28
+ getOrganizationEffectiveLimits,
29
+ getOrganizationUsageSnapshot,
30
+ getSaasSummary,
31
+ ingestSnapshotFromReport,
32
+ listOrganizationPlanChanges,
33
+ } from './saas.js'
34
+ import {
35
+ buildTrustReport,
36
+ explainTrustGatePolicy,
37
+ formatTrustGatePolicyExplanation,
38
+ formatTrustJson,
39
+ renderTrustOutput,
40
+ shouldFailTrustGate,
41
+ normalizeMergeRiskLevel,
42
+ MERGE_RISK_ORDER,
43
+ detectBranchName,
44
+ } from './trust.js'
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'
51
+ import type { DriftDiff, DriftTrustReport, DriftAnalysisOptions, MergeRiskLevel } from './types.js'
52
+ import type { GuardResult, GuardThresholds } from './guard-types.js'
53
+ import type { TrustGatePolicyExplanation } from './trust.js'
54
+ import type { SnapshotHistory } from './snapshot.js'
26
55
  const program = new Command()
27
56
 
57
+ type ResourceOptionFlags = {
58
+ lowMemory?: boolean
59
+ chunkSize?: string
60
+ maxFiles?: string
61
+ maxFileSizeKb?: string
62
+ withSemanticDuplication?: boolean
63
+ }
64
+
65
+ function parseOptionalPositiveInt(rawValue: string | undefined, flagName: string): number | undefined {
66
+ if (rawValue == null) return undefined
67
+ const value = Number(rawValue)
68
+ if (!Number.isInteger(value) || value < 0) {
69
+ throw new Error(`${flagName} must be a non-negative integer`)
70
+ }
71
+ return value
72
+ }
73
+
74
+ function resolveAnalysisOptions(options: ResourceOptionFlags): DriftAnalysisOptions {
75
+ return {
76
+ lowMemory: options.lowMemory,
77
+ chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
78
+ maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
79
+ maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
80
+ includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
81
+ }
82
+ }
83
+
84
+ function addResourceOptions(command: Command): Command {
85
+ return command
86
+ .option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
87
+ .option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
88
+ .option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
89
+ .option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
90
+ .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode')
91
+ }
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
+
183
+ function parseTrustGateOverrides(options: { minTrust?: string; maxRisk?: string }): { minTrust?: number; maxRisk?: MergeRiskLevel } {
184
+ const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined
185
+ if (options.minTrust && Number.isNaN(cliMinTrust)) {
186
+ process.stderr.write('\n Error: --min-trust must be a valid number\n\n')
187
+ process.exit(1)
188
+ }
189
+
190
+ let cliMaxRisk: MergeRiskLevel | undefined
191
+ if (options.maxRisk) {
192
+ cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk)
193
+ if (!cliMaxRisk) {
194
+ process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
195
+ process.exit(1)
196
+ }
197
+ }
198
+
199
+ return {
200
+ minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
201
+ maxRisk: cliMaxRisk,
202
+ }
203
+ }
204
+
205
+ function resolveBranchFromOption(branch?: string): string | undefined {
206
+ const normalized = branch?.trim()
207
+ if (normalized) return normalized
208
+ return detectBranchName()
209
+ }
210
+
211
+ function printTrustGatePolicyDebug(explanation: TrustGatePolicyExplanation): void {
212
+ process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`)
213
+ if (explanation.invalidPolicyPack) {
214
+ process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
215
+ }
216
+ }
217
+
218
+ function printSaasErrorAndExit(error: unknown): never {
219
+ const message = error instanceof Error ? error.message : String(error)
220
+ process.stderr.write(`\n Error: ${message}\n\n`)
221
+ process.exit(1)
222
+ }
223
+
28
224
  program
29
225
  .name('drift')
30
- .description('Detect silent technical debt left by AI-generated code')
226
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
31
227
  .version(VERSION)
32
228
 
33
- program
34
- .command('scan [path]', { isDefault: true })
229
+ addResourceOptions(
230
+ program
231
+ .command('scan [path]', { isDefault: true })
35
232
  .description('Scan a directory for vibe coding drift')
36
233
  .option('-o, --output <file>', 'Write report to a Markdown file')
234
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
37
235
  .option('--json', 'Output raw JSON report')
38
236
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
39
237
  .option('--fix', 'Show fix suggestions for each issue')
40
238
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
41
- .action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string }) => {
239
+ .action(async (targetPath: string | undefined, options: { output?: string; format?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
42
240
  const resolvedPath = resolve(targetPath ?? '.')
43
241
 
44
242
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
45
243
  const config = await loadConfig(resolvedPath)
46
- const files = analyzeProject(resolvedPath, config)
244
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
47
245
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
48
246
  const report = buildReport(resolvedPath, files)
49
247
 
50
- 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') {
51
265
  const aiOutput = formatAIOutput(report)
52
266
  process.stdout.write(JSON.stringify(aiOutput, null, 2))
53
267
  return
54
268
  }
55
269
 
56
- if (options.json) {
270
+ if (format === 'json') {
57
271
  process.stdout.write(JSON.stringify(report, null, 2))
58
272
  return
59
273
  }
60
274
 
275
+ if (format === 'markdown') {
276
+ process.stdout.write(`${formatMarkdown(report)}\n`)
277
+ return
278
+ }
279
+
61
280
  printConsole(report, { showFix: options.fix })
62
281
 
63
282
  if (options.output) {
@@ -72,29 +291,63 @@ program
72
291
  if (minScore > 0 && report.totalScore > minScore) {
73
292
  process.exit(1)
74
293
  }
75
- })
294
+ }),
295
+ )
76
296
 
77
297
  program
78
- .command('diff [ref]')
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
+
319
+ addResourceOptions(
320
+ program
321
+ .command('diff [ref]')
79
322
  .description('Compare current state against a git ref (default: HEAD~1)')
323
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
80
324
  .option('--json', 'Output raw JSON diff')
81
- .action(async (ref: string | undefined, options: { json?: boolean }) => {
325
+ .action(async (ref: string | undefined, options: { format?: string; json?: boolean } & ResourceOptionFlags) => {
82
326
  const baseRef = ref ?? 'HEAD~1'
83
327
  const projectPath = resolve('.')
328
+ const analysisOptions = resolveAnalysisOptions(options)
84
329
 
85
330
  let tempDir: string | undefined
86
331
 
87
332
  try {
88
333
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`)
89
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
+
90
343
  // Scan current state
91
344
  const config = await loadConfig(projectPath)
92
- const currentFiles = analyzeProject(projectPath, config)
345
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions)
93
346
  const currentReport = buildReport(projectPath, currentFiles)
94
347
 
95
348
  // Extract base state from git
96
349
  tempDir = extractFilesAtRef(projectPath, baseRef)
97
- const baseFiles = analyzeProject(tempDir, config)
350
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions)
98
351
 
99
352
  // Remap base file paths to match current project paths
100
353
  // (temp dir paths → project paths for accurate comparison)
@@ -103,13 +356,15 @@ program
103
356
  ...baseReport,
104
357
  files: baseReport.files.map(f => ({
105
358
  ...f,
106
- path: f.path.replace(tempDir!, projectPath),
359
+ path: resolve(projectPath, relative(tempDir!, f.path)),
107
360
  })),
108
361
  }
109
362
 
110
363
  const diff = computeDiff(remappedBase, currentReport, baseRef)
111
364
 
112
- if (options.json) {
365
+ if (format === 'sarif') {
366
+ process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`)
367
+ } else if (format === 'json') {
113
368
  process.stdout.write(JSON.stringify(diff, null, 2) + '\n')
114
369
  } else {
115
370
  printDiff(diff)
@@ -121,23 +376,96 @@ program
121
376
  } finally {
122
377
  if (tempDir) cleanupTempDir(tempDir)
123
378
  }
379
+ }),
380
+ )
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))
124
437
  })
125
438
 
126
439
  program
127
440
  .command('review')
128
441
  .description('Review drift against a base ref and output PR markdown')
129
442
  .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
443
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
130
444
  .option('--json', 'Output structured review JSON')
131
445
  .option('--comment', 'Output markdown comment body')
132
446
  .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
133
- .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 }) => {
134
448
  try {
135
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
+ })
136
460
 
137
- if (options.json) {
461
+ if (format === 'sarif') {
462
+ process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`)
463
+ } else if (format === 'json') {
138
464
  process.stdout.write(JSON.stringify(review, null, 2) + '\n')
465
+ } else if (format === 'markdown') {
466
+ process.stdout.write(`${review.markdown}\n`)
139
467
  } else {
140
- 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`)
141
469
  }
142
470
 
143
471
  const failOn = options.failOn ? Number(options.failOn) : undefined
@@ -151,6 +479,271 @@ program
151
479
  }
152
480
  })
153
481
 
482
+ addResourceOptions(
483
+ program
484
+ .command('trust [path]')
485
+ .description('Compute merge trust baseline from drift signals')
486
+ .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
487
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
488
+ .option('--json', 'Output structured trust JSON')
489
+ .option('--markdown', 'Output trust report as markdown (PR comment ready)')
490
+ .option('-o, --output <file>', 'Write trust output to file')
491
+ .option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
492
+ .option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
493
+ .option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
494
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
495
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
496
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
497
+ .option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
498
+ .option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
499
+ .option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
500
+ .action(async (
501
+ targetPath: string | undefined,
502
+ options: {
503
+ base?: string
504
+ format?: string
505
+ json?: boolean
506
+ markdown?: boolean
507
+ output?: string
508
+ jsonOutput?: string
509
+ minTrust?: string
510
+ maxRisk?: string
511
+ branch?: string
512
+ policyPack?: string
513
+ explainPolicy?: boolean
514
+ advancedTrust?: boolean
515
+ previousTrust?: string
516
+ historyFile?: string
517
+ } & ResourceOptionFlags,
518
+ ) => {
519
+ let tempDir: string | undefined
520
+
521
+ try {
522
+ const resolvedPath = resolve(targetPath ?? '.')
523
+ const analysisOptions = resolveAnalysisOptions(options)
524
+
525
+ process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`)
526
+ const config = await loadConfig(resolvedPath)
527
+ const files = analyzeProject(resolvedPath, config, analysisOptions)
528
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
529
+
530
+ const report = buildReport(resolvedPath, files)
531
+ const branchName = resolveBranchFromOption(options.branch)
532
+ const policyExplanation = explainTrustGatePolicy(config, {
533
+ branchName,
534
+ policyPack: options.policyPack,
535
+ overrides: parseTrustGateOverrides(options),
536
+ })
537
+ const policy = policyExplanation.effectivePolicy
538
+
539
+ if (options.explainPolicy) {
540
+ printTrustGatePolicyDebug(policyExplanation)
541
+ } else if (policyExplanation.invalidPolicyPack) {
542
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
543
+ }
544
+
545
+ let diff: DriftDiff | undefined
546
+ if (options.base) {
547
+ process.stderr.write(`Computing diff signals against ${options.base}...\n`)
548
+ tempDir = extractFilesAtRef(resolvedPath, options.base)
549
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions)
550
+ const baseReport = buildReport(tempDir, baseFiles)
551
+ const remappedBase = {
552
+ ...baseReport,
553
+ files: baseReport.files.map((file) => ({
554
+ ...file,
555
+ path: resolve(resolvedPath, relative(tempDir!, file.path)),
556
+ })),
557
+ }
558
+ diff = computeDiff(remappedBase, report, options.base)
559
+ process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`)
560
+ }
561
+
562
+ let previousTrustReport: Partial<DriftTrustReport> | undefined
563
+ let snapshots: SnapshotHistory['snapshots'] | undefined
564
+ if (options.advancedTrust) {
565
+ if (options.previousTrust) {
566
+ const previousTrustPath = resolve(options.previousTrust)
567
+ const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8')
568
+ previousTrustReport = JSON.parse(rawPreviousTrust) as Partial<DriftTrustReport>
569
+ process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`)
570
+ }
571
+
572
+ if (options.historyFile) {
573
+ const historyPath = resolve(options.historyFile)
574
+ const rawHistory = readFileSync(historyPath, 'utf8')
575
+ const history = JSON.parse(rawHistory) as SnapshotHistory
576
+ snapshots = history.snapshots
577
+ process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`)
578
+ } else {
579
+ snapshots = loadHistory(resolvedPath).snapshots
580
+ }
581
+ }
582
+
583
+ const trust = buildTrustReport(report, {
584
+ diff,
585
+ advanced: {
586
+ enabled: options.advancedTrust,
587
+ previousTrust: previousTrustReport,
588
+ snapshots,
589
+ },
590
+ })
591
+
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`
609
+
610
+ process.stdout.write(rendered)
611
+
612
+ if (options.output) {
613
+ const outPath = resolve(options.output)
614
+ writeFileSync(outPath, rendered, 'utf8')
615
+ process.stderr.write(`Trust output saved to ${outPath}\n`)
616
+ }
617
+
618
+ if (options.jsonOutput) {
619
+ const jsonOutPath = resolve(options.jsonOutput)
620
+ writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8')
621
+ process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`)
622
+ }
623
+
624
+ if (policy.enabled === false) {
625
+ process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
626
+ return
627
+ }
628
+
629
+ if (shouldFailTrustGate(trust, policy)) {
630
+ process.exit(1)
631
+ }
632
+ } catch (err) {
633
+ const message = err instanceof Error ? err.message : String(err)
634
+ process.stderr.write(`\n Error: ${message}\n\n`)
635
+ process.exit(1)
636
+ } finally {
637
+ if (tempDir) cleanupTempDir(tempDir)
638
+ }
639
+ }),
640
+ )
641
+
642
+ program
643
+ .command('trust-gate <trustJsonFile>')
644
+ .description('Evaluate trust gate thresholds from an existing trust JSON file')
645
+ .option('--min-trust <n>', 'Fail if trust score is below threshold')
646
+ .option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
647
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
648
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
649
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
650
+ .action(async (trustJsonFile: string, options: { minTrust?: string; maxRisk?: string; branch?: string; policyPack?: string; explainPolicy?: boolean }) => {
651
+ try {
652
+ const filePath = resolve(trustJsonFile)
653
+ const raw = readFileSync(filePath, 'utf8')
654
+ const parsed = JSON.parse(raw) as Partial<DriftTrustReport>
655
+ const config = await loadConfig(resolve('.'))
656
+ const branchName = resolveBranchFromOption(options.branch)
657
+ const policyExplanation = explainTrustGatePolicy(config, {
658
+ branchName,
659
+ policyPack: options.policyPack,
660
+ overrides: parseTrustGateOverrides(options),
661
+ })
662
+ const policy = policyExplanation.effectivePolicy
663
+
664
+ if (options.explainPolicy) {
665
+ printTrustGatePolicyDebug(policyExplanation)
666
+ } else if (policyExplanation.invalidPolicyPack) {
667
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
668
+ }
669
+
670
+ if (typeof parsed.trust_score !== 'number') {
671
+ process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n')
672
+ process.exit(1)
673
+ }
674
+
675
+ if (typeof parsed.merge_risk !== 'string') {
676
+ process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n')
677
+ process.exit(1)
678
+ }
679
+
680
+ const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk)
681
+ if (!actualRisk) {
682
+ process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
683
+ process.exit(1)
684
+ }
685
+
686
+ const trust: DriftTrustReport = {
687
+ scannedAt: parsed.scannedAt ?? new Date().toISOString(),
688
+ targetPath: parsed.targetPath ?? '.',
689
+ trust_score: parsed.trust_score,
690
+ merge_risk: actualRisk,
691
+ top_reasons: parsed.top_reasons ?? [],
692
+ fix_priorities: parsed.fix_priorities ?? [],
693
+ diff_context: parsed.diff_context,
694
+ }
695
+
696
+ if (policy.enabled === false) {
697
+ process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
698
+ return
699
+ }
700
+
701
+ if (shouldFailTrustGate(trust, policy)) {
702
+ process.exit(1)
703
+ }
704
+
705
+ process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`)
706
+ } catch (err) {
707
+ const message = err instanceof Error ? err.message : String(err)
708
+ process.stderr.write(`\n Error: ${message}\n\n`)
709
+ process.exit(1)
710
+ }
711
+ })
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
+
727
+ program
728
+ .command('kpi <path>')
729
+ .description('Aggregate trust KPIs from trust JSON artifacts')
730
+ .option('--no-summary', 'Disable console KPI summary in stderr')
731
+ .action((targetPath: string, options: { summary?: boolean }) => {
732
+ try {
733
+ const kpi = computeTrustKpis(targetPath)
734
+
735
+ if (options.summary !== false) {
736
+ process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`)
737
+ }
738
+
739
+ process.stdout.write(`${formatTrustKpiJson(kpi)}\n`)
740
+ } catch (err) {
741
+ const message = err instanceof Error ? err.message : String(err)
742
+ process.stderr.write(`\n Error: ${message}\n\n`)
743
+ process.exit(1)
744
+ }
745
+ })
746
+
154
747
  program
155
748
  .command('map [path]')
156
749
  .description('Generate architecture.svg with simple layer dependencies')
@@ -163,56 +756,79 @@ program
163
756
  process.stderr.write(` Architecture map saved to ${out}\n\n`)
164
757
  })
165
758
 
166
- program
167
- .command('report [path]')
759
+ addResourceOptions(
760
+ program
761
+ .command('report [path]')
168
762
  .description('Generate a self-contained HTML report')
169
763
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
170
- .action(async (targetPath: string | undefined, options: { output: string }) => {
764
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
171
765
  const resolvedPath = resolve(targetPath ?? '.')
172
766
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
173
767
  const config = await loadConfig(resolvedPath)
174
- const files = analyzeProject(resolvedPath, config)
768
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
175
769
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
176
770
  const report = buildReport(resolvedPath, files)
177
771
  const html = generateHtmlReport(report)
178
772
  const outPath = resolve(options.output)
179
773
  writeFileSync(outPath, html, 'utf8')
180
774
  process.stderr.write(` Report saved to ${outPath}\n\n`)
181
- })
775
+ }),
776
+ )
182
777
 
183
- program
184
- .command('badge [path]')
778
+ addResourceOptions(
779
+ program
780
+ .command('badge [path]')
185
781
  .description('Generate a badge.svg with the current drift score')
186
782
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
187
- .action(async (targetPath: string | undefined, options: { output: string }) => {
783
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
188
784
  const resolvedPath = resolve(targetPath ?? '.')
189
785
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
190
786
  const config = await loadConfig(resolvedPath)
191
- const files = analyzeProject(resolvedPath, config)
787
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
192
788
  const report = buildReport(resolvedPath, files)
193
789
  const svg = generateBadge(report.totalScore)
194
790
  const outPath = resolve(options.output)
195
791
  writeFileSync(outPath, svg, 'utf8')
196
792
  process.stderr.write(` Badge saved to ${outPath}\n`)
197
793
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
198
- })
794
+ }),
795
+ )
199
796
 
200
- program
201
- .command('ci [path]')
797
+ addResourceOptions(
798
+ program
799
+ .command('ci [path]')
202
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)')
203
803
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
204
- .action(async (targetPath: string | undefined, options: { minScore: string }) => {
804
+ .action(async (targetPath: string | undefined, options: { format?: string; json?: boolean; minScore: string } & ResourceOptionFlags) => {
205
805
  const resolvedPath = resolve(targetPath ?? '.')
206
806
  const config = await loadConfig(resolvedPath)
207
- const files = analyzeProject(resolvedPath, config)
807
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
208
808
  const report = buildReport(resolvedPath, files)
209
- emitCIAnnotations(report)
210
- 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
+ }
211
826
  const minScore = Number(options.minScore)
212
827
  if (minScore > 0 && report.totalScore > minScore) {
213
828
  process.exit(1)
214
829
  }
215
- })
830
+ }),
831
+ )
216
832
 
217
833
  program
218
834
  .command('trend [period]')
@@ -340,15 +956,16 @@ program
340
956
  }
341
957
  })
342
958
 
343
- program
344
- .command('snapshot [path]')
959
+ addResourceOptions(
960
+ program
961
+ .command('snapshot [path]')
345
962
  .description('Record a score snapshot to drift-history.json')
346
963
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
347
964
  .option('--history', 'show all recorded snapshots')
348
965
  .option('--diff', 'compare current score vs last snapshot')
349
966
  .action(async (
350
967
  targetPath: string | undefined,
351
- opts: { label?: string; history?: boolean; diff?: boolean },
968
+ opts: { label?: string; history?: boolean; diff?: boolean } & ResourceOptionFlags,
352
969
  ) => {
353
970
  const resolvedPath = resolve(targetPath ?? '.')
354
971
 
@@ -360,7 +977,7 @@ program
360
977
 
361
978
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
362
979
  const config = await loadConfig(resolvedPath)
363
- const files = analyzeProject(resolvedPath, config)
980
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts))
364
981
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
365
982
  const report = buildReport(resolvedPath, files)
366
983
 
@@ -376,73 +993,211 @@ program
376
993
  ` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
377
994
  )
378
995
  process.stdout.write(` Saved to drift-history.json\n\n`)
379
- })
996
+ }),
997
+ )
380
998
 
381
999
  const cloud = program
382
1000
  .command('cloud')
383
1001
  .description('Local SaaS foundations: ingest, summary, and dashboard')
384
1002
 
385
- cloud
386
- .command('ingest [path]')
1003
+ addResourceOptions(
1004
+ cloud
1005
+ .command('ingest [path]')
387
1006
  .description('Scan path, build report, and store cloud snapshot')
1007
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
388
1008
  .requiredOption('--workspace <id>', 'Workspace id')
389
1009
  .requiredOption('--user <id>', 'User id')
1010
+ .option('--role <role>', 'Role hint (owner|member|viewer)')
1011
+ .option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
390
1012
  .option('--repo <name>', 'Repo name (default: basename of scanned path)')
1013
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
391
1014
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
392
- .action(async (targetPath: string | undefined, options: { workspace: string; user: string; repo?: string; store?: string }) => {
393
- const resolvedPath = resolve(targetPath ?? '.')
394
- process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
395
- const config = await loadConfig(resolvedPath)
396
- const files = analyzeProject(resolvedPath, config)
397
- const report = buildReport(resolvedPath, files)
1015
+ .action(async (targetPath: string | undefined, options: { org: string; workspace: string; user: string; role?: string; plan?: string; repo?: string; actor?: string; store?: string } & ResourceOptionFlags) => {
1016
+ try {
1017
+ const resolvedPath = resolve(targetPath ?? '.')
1018
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
1019
+ const config = await loadConfig(resolvedPath)
1020
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
1021
+ const report = buildReport(resolvedPath, files)
398
1022
 
399
- const snapshot = ingestSnapshotFromReport(report, {
400
- workspaceId: options.workspace,
401
- userId: options.user,
402
- repoName: options.repo ?? basename(resolvedPath),
403
- storeFile: options.store,
404
- policy: config?.saas,
405
- })
1023
+ const snapshot = ingestSnapshotFromReport(report, {
1024
+ organizationId: options.org,
1025
+ workspaceId: options.workspace,
1026
+ userId: options.user,
1027
+ role: options.role as 'owner' | 'member' | 'viewer' | undefined,
1028
+ plan: options.plan as 'free' | 'sponsor' | 'team' | 'business' | undefined,
1029
+ repoName: options.repo ?? basename(resolvedPath),
1030
+ actorUserId: options.actor,
1031
+ storeFile: options.store,
1032
+ policy: config?.saas,
1033
+ })
406
1034
 
407
- process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
408
- process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
409
- process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
410
- })
1035
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
1036
+ process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
1037
+ process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`)
1038
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
1039
+ } catch (error) {
1040
+ printSaasErrorAndExit(error)
1041
+ }
1042
+ }),
1043
+ )
411
1044
 
412
1045
  cloud
413
1046
  .command('summary')
414
1047
  .description('Show SaaS usage metrics and free threshold status')
415
1048
  .option('--json', 'Output raw JSON summary')
1049
+ .option('--org <id>', 'Filter summary by organization id')
1050
+ .option('--workspace <id>', 'Filter summary by workspace id')
1051
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
416
1052
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
417
- .action((options: { json?: boolean; store?: string }) => {
418
- const summary = getSaasSummary({ storeFile: options.store })
1053
+ .action((options: { json?: boolean; org?: string; workspace?: string; actor?: string; store?: string }) => {
1054
+ try {
1055
+ const summary = getSaasSummary({
1056
+ storeFile: options.store,
1057
+ organizationId: options.org,
1058
+ workspaceId: options.workspace,
1059
+ actorUserId: options.actor,
1060
+ })
419
1061
 
420
- if (options.json) {
421
- process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
422
- return
1062
+ if (options.json) {
1063
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
1064
+ return
1065
+ }
1066
+
1067
+ process.stdout.write('\n')
1068
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
1069
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
1070
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
1071
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
1072
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
1073
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
1074
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
1075
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
1076
+ process.stdout.write('Runs per month:\n')
1077
+
1078
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
1079
+ if (monthly.length === 0) {
1080
+ process.stdout.write(' - none\n\n')
1081
+ return
1082
+ }
1083
+
1084
+ for (const [month, runs] of monthly) {
1085
+ process.stdout.write(` - ${month}: ${runs}\n`)
1086
+ }
1087
+ process.stdout.write('\n')
1088
+ } catch (error) {
1089
+ printSaasErrorAndExit(error)
423
1090
  }
1091
+ })
424
1092
 
425
- process.stdout.write('\n')
426
- process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
427
- process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
428
- process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
429
- process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
430
- process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
431
- process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
432
- process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
433
- process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
434
- process.stdout.write('Runs per month:\n')
435
-
436
- const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
437
- if (monthly.length === 0) {
438
- process.stdout.write(' - none\n\n')
439
- return
1093
+ cloud
1094
+ .command('plan-set')
1095
+ .description('Set organization plan (owner role required when actor is provided)')
1096
+ .requiredOption('--org <id>', 'Organization id')
1097
+ .requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
1098
+ .requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
1099
+ .option('--reason <text>', 'Optional reason for audit trail')
1100
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
1101
+ .option('--json', 'Output raw JSON plan change')
1102
+ .action((options: { org: string; plan: string; actor: string; reason?: string; store?: string; json?: boolean }) => {
1103
+ try {
1104
+ const change = changeOrganizationPlan({
1105
+ organizationId: options.org,
1106
+ actorUserId: options.actor,
1107
+ newPlan: options.plan as 'free' | 'sponsor' | 'team' | 'business',
1108
+ reason: options.reason,
1109
+ storeFile: options.store,
1110
+ })
1111
+
1112
+ if (options.json) {
1113
+ process.stdout.write(JSON.stringify(change, null, 2) + '\n')
1114
+ return
1115
+ }
1116
+
1117
+ process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`)
1118
+ process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`)
1119
+ if (change.reason) process.stdout.write(`Reason: ${change.reason}\n`)
1120
+ } catch (error) {
1121
+ printSaasErrorAndExit(error)
440
1122
  }
1123
+ })
1124
+
1125
+ cloud
1126
+ .command('plan-changes')
1127
+ .description('List organization plan change audit trail')
1128
+ .requiredOption('--org <id>', 'Organization id')
1129
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
1130
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
1131
+ .option('--json', 'Output raw JSON plan changes')
1132
+ .action((options: { org: string; actor: string; store?: string; json?: boolean }) => {
1133
+ try {
1134
+ const changes = listOrganizationPlanChanges({
1135
+ organizationId: options.org,
1136
+ actorUserId: options.actor,
1137
+ storeFile: options.store,
1138
+ })
1139
+
1140
+ if (options.json) {
1141
+ process.stdout.write(JSON.stringify(changes, null, 2) + '\n')
1142
+ return
1143
+ }
1144
+
1145
+ if (changes.length === 0) {
1146
+ process.stdout.write(`No plan changes found for org '${options.org}'.\n`)
1147
+ return
1148
+ }
1149
+
1150
+ process.stdout.write(`Plan changes for org '${options.org}':\n`)
1151
+ for (const change of changes) {
1152
+ const reasonSuffix = change.reason ? ` reason='${change.reason}'` : ''
1153
+ process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`)
1154
+ }
1155
+ } catch (error) {
1156
+ printSaasErrorAndExit(error)
1157
+ }
1158
+ })
1159
+
1160
+ cloud
1161
+ .command('usage')
1162
+ .description('Show organization usage and effective limits')
1163
+ .requiredOption('--org <id>', 'Organization id')
1164
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
1165
+ .option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
1166
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
1167
+ .option('--json', 'Output usage and limits as raw JSON')
1168
+ .action((options: { org: string; actor: string; month?: string; store?: string; json?: boolean }) => {
1169
+ try {
1170
+ const usage = getOrganizationUsageSnapshot({
1171
+ organizationId: options.org,
1172
+ actorUserId: options.actor,
1173
+ month: options.month,
1174
+ storeFile: options.store,
1175
+ })
1176
+ const limits = getOrganizationEffectiveLimits({
1177
+ organizationId: options.org,
1178
+ storeFile: options.store,
1179
+ })
1180
+
1181
+ if (options.json) {
1182
+ process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n')
1183
+ return
1184
+ }
441
1185
 
442
- for (const [month, runs] of monthly) {
443
- process.stdout.write(` - ${month}: ${runs}\n`)
1186
+ process.stdout.write(`Organization: ${usage.organizationId}\n`)
1187
+ process.stdout.write(`Plan: ${usage.plan}\n`)
1188
+ process.stdout.write(`Captured at: ${usage.capturedAt}\n`)
1189
+ process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`)
1190
+ process.stdout.write(`Repo count: ${usage.repoCount}\n`)
1191
+ process.stdout.write(`Runs total: ${usage.runCount}\n`)
1192
+ process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`)
1193
+ process.stdout.write('Effective limits:\n')
1194
+ process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`)
1195
+ process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`)
1196
+ process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`)
1197
+ process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`)
1198
+ } catch (error) {
1199
+ printSaasErrorAndExit(error)
444
1200
  }
445
- process.stdout.write('\n')
446
1201
  })
447
1202
 
448
1203
  cloud