@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/dist/cli.js 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,16 +22,157 @@ import { applyFixes } 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
+ import { changeOrganizationPlan, generateSaasDashboardHtml, getOrganizationEffectiveLimits, getOrganizationUsageSnapshot, getSaasSummary, ingestSnapshotFromReport, listOrganizationPlanChanges, } from './saas.js';
26
+ import { buildTrustReport, explainTrustGatePolicy, formatTrustGatePolicyExplanation, formatTrustJson, renderTrustOutput, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, detectBranchName, } from './trust.js';
27
+ import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js';
28
+ import { runBenchmarkCli } from './benchmark.js';
29
+ import { runInit, INIT_PRESETS } from './init.js';
30
+ import { runDoctor } from './doctor.js';
31
+ import { resolveOutputFormat } from './format.js';
32
+ import { toSarif, diffToSarif } from './sarif.js';
25
33
  const program = new Command();
34
+ function parseOptionalPositiveInt(rawValue, flagName) {
35
+ if (rawValue == null)
36
+ return undefined;
37
+ const value = Number(rawValue);
38
+ if (!Number.isInteger(value) || value < 0) {
39
+ throw new Error(`${flagName} must be a non-negative integer`);
40
+ }
41
+ return value;
42
+ }
43
+ function resolveAnalysisOptions(options) {
44
+ return {
45
+ lowMemory: options.lowMemory,
46
+ chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
47
+ maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
48
+ maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
49
+ includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
50
+ };
51
+ }
52
+ function addResourceOptions(command) {
53
+ return command
54
+ .option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
55
+ .option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
56
+ .option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
57
+ .option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
58
+ .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode');
59
+ }
60
+ function parseOptionalNumber(rawValue, flagName) {
61
+ if (rawValue == null)
62
+ return undefined;
63
+ const value = Number(rawValue);
64
+ if (!Number.isFinite(value)) {
65
+ throw new Error(`${flagName} must be a valid number`);
66
+ }
67
+ return value;
68
+ }
69
+ function parseBySeverity(rawValue) {
70
+ if (rawValue == null)
71
+ return undefined;
72
+ const spec = rawValue.trim();
73
+ if (!spec) {
74
+ throw new Error('--by-severity must not be empty. Expected format: error=0,warning=2,info=5');
75
+ }
76
+ const thresholds = {};
77
+ const seen = new Set();
78
+ for (const segment of spec.split(',')) {
79
+ const pair = segment.trim();
80
+ if (!pair)
81
+ continue;
82
+ const equalIndex = pair.indexOf('=');
83
+ if (equalIndex <= 0 || equalIndex === pair.length - 1) {
84
+ throw new Error(`Invalid --by-severity entry '${pair}'. Expected key=value (e.g. warning=2).`);
85
+ }
86
+ const key = pair.slice(0, equalIndex).trim().toLowerCase();
87
+ const rawThreshold = pair.slice(equalIndex + 1).trim();
88
+ if (key !== 'error' && key !== 'warning' && key !== 'info') {
89
+ throw new Error(`Invalid --by-severity key '${key}'. Allowed keys: error, warning, info.`);
90
+ }
91
+ if (seen.has(key)) {
92
+ throw new Error(`Duplicate --by-severity key '${key}'.`);
93
+ }
94
+ const threshold = Number(rawThreshold);
95
+ if (!Number.isFinite(threshold)) {
96
+ throw new Error(`Invalid --by-severity value for '${key}': '${rawThreshold}'. Must be a valid number.`);
97
+ }
98
+ const severityKey = key;
99
+ thresholds[severityKey] = threshold;
100
+ seen.add(severityKey);
101
+ }
102
+ if (seen.size === 0) {
103
+ throw new Error('--by-severity must include at least one threshold. Example: error=0,warning=2');
104
+ }
105
+ return thresholds;
106
+ }
107
+ function formatSigned(value) {
108
+ return value > 0 ? `+${value}` : `${value}`;
109
+ }
110
+ function printGuardSummary(result) {
111
+ const modeLabel = result.mode === 'diff' ? `diff (${result.baseRef ?? 'unknown base'})` : 'baseline';
112
+ const statusLabel = result.passed ? 'PASS' : 'FAIL';
113
+ process.stdout.write('\n');
114
+ process.stdout.write(`Guard mode: ${modeLabel}\n`);
115
+ process.stdout.write(`Result: ${statusLabel}\n`);
116
+ process.stdout.write(`Score delta: ${formatSigned(result.metrics.scoreDelta)}\n`);
117
+ process.stdout.write(`Total issues delta: ${formatSigned(result.metrics.totalIssuesDelta)}\n`);
118
+ process.stdout.write(`Severity delta: error=${formatSigned(result.metrics.severityDelta.error)}, warning=${formatSigned(result.metrics.severityDelta.warning)}, info=${formatSigned(result.metrics.severityDelta.info)}\n`);
119
+ if (result.mode === 'baseline' && result.baselinePath) {
120
+ process.stdout.write(`Baseline file: ${result.baselinePath}\n`);
121
+ }
122
+ if (result.checks.length === 0) {
123
+ process.stdout.write('Checks: none configured\n');
124
+ return;
125
+ }
126
+ process.stdout.write('Checks:\n');
127
+ for (const check of result.checks) {
128
+ process.stdout.write(` - [${check.passed ? 'PASS' : 'FAIL'}] ${check.id}: ${check.message} (actual=${check.actual}, limit=${check.limit})\n`);
129
+ }
130
+ }
131
+ function parseTrustGateOverrides(options) {
132
+ const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined;
133
+ if (options.minTrust && Number.isNaN(cliMinTrust)) {
134
+ process.stderr.write('\n Error: --min-trust must be a valid number\n\n');
135
+ process.exit(1);
136
+ }
137
+ let cliMaxRisk;
138
+ if (options.maxRisk) {
139
+ cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk);
140
+ if (!cliMaxRisk) {
141
+ process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
142
+ process.exit(1);
143
+ }
144
+ }
145
+ return {
146
+ minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
147
+ maxRisk: cliMaxRisk,
148
+ };
149
+ }
150
+ function resolveBranchFromOption(branch) {
151
+ const normalized = branch?.trim();
152
+ if (normalized)
153
+ return normalized;
154
+ return detectBranchName();
155
+ }
156
+ function printTrustGatePolicyDebug(explanation) {
157
+ process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`);
158
+ if (explanation.invalidPolicyPack) {
159
+ process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
160
+ }
161
+ }
162
+ function printSaasErrorAndExit(error) {
163
+ const message = error instanceof Error ? error.message : String(error);
164
+ process.stderr.write(`\n Error: ${message}\n\n`);
165
+ process.exit(1);
166
+ }
26
167
  program
27
168
  .name('drift')
28
- .description('Detect silent technical debt left by AI-generated code')
169
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
29
170
  .version(VERSION);
30
- program
171
+ addResourceOptions(program
31
172
  .command('scan [path]', { isDefault: true })
32
173
  .description('Scan a directory for vibe coding drift')
33
174
  .option('-o, --output <file>', 'Write report to a Markdown file')
175
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
34
176
  .option('--json', 'Output raw JSON report')
35
177
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
36
178
  .option('--fix', 'Show fix suggestions for each issue')
@@ -39,18 +181,36 @@ program
39
181
  const resolvedPath = resolve(targetPath ?? '.');
40
182
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
41
183
  const config = await loadConfig(resolvedPath);
42
- const files = analyzeProject(resolvedPath, config);
184
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
43
185
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
44
186
  const report = buildReport(resolvedPath, files);
45
- if (options.ai) {
187
+ const format = resolveOutputFormat({
188
+ command: 'scan',
189
+ format: options.format,
190
+ supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
191
+ legacyAliases: [
192
+ { flag: 'json', used: options.json, mapsTo: 'json' },
193
+ { flag: 'ai', used: options.ai, mapsTo: 'ai' },
194
+ ],
195
+ onWarning: (message) => process.stderr.write(`${message}\n`),
196
+ });
197
+ if (format === 'sarif') {
198
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
199
+ return;
200
+ }
201
+ if (format === 'ai') {
46
202
  const aiOutput = formatAIOutput(report);
47
203
  process.stdout.write(JSON.stringify(aiOutput, null, 2));
48
204
  return;
49
205
  }
50
- if (options.json) {
206
+ if (format === 'json') {
51
207
  process.stdout.write(JSON.stringify(report, null, 2));
52
208
  return;
53
209
  }
210
+ if (format === 'markdown') {
211
+ process.stdout.write(`${formatMarkdown(report)}\n`);
212
+ return;
213
+ }
54
214
  printConsole(report, { showFix: options.fix });
55
215
  if (options.output) {
56
216
  const md = formatMarkdown(report);
@@ -63,24 +223,54 @@ program
63
223
  if (minScore > 0 && report.totalScore > minScore) {
64
224
  process.exit(1);
65
225
  }
66
- });
226
+ }));
67
227
  program
228
+ .command('init')
229
+ .description('Initialize drift configuration with presets and scaffolding')
230
+ .option('--preset <type>', `Scaffold config with preset: ${INIT_PRESETS.join(', ')}`)
231
+ .option('--ci', 'Generate GitHub Actions workflow for drift review')
232
+ .option('--baseline', 'Create drift-baseline.json with current project score')
233
+ .action(async (options) => {
234
+ const projectRoot = resolve('.');
235
+ try {
236
+ await runInit(projectRoot, {
237
+ preset: options.preset,
238
+ ci: options.ci,
239
+ baseline: options.baseline,
240
+ });
241
+ }
242
+ catch (err) {
243
+ const message = err instanceof Error ? err.message : String(err);
244
+ process.stderr.write(`\n Error: ${message}\n\n`);
245
+ process.exit(1);
246
+ }
247
+ });
248
+ addResourceOptions(program
68
249
  .command('diff [ref]')
69
250
  .description('Compare current state against a git ref (default: HEAD~1)')
251
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
70
252
  .option('--json', 'Output raw JSON diff')
71
253
  .action(async (ref, options) => {
72
254
  const baseRef = ref ?? 'HEAD~1';
73
255
  const projectPath = resolve('.');
256
+ const analysisOptions = resolveAnalysisOptions(options);
74
257
  let tempDir;
75
258
  try {
76
259
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`);
260
+ const format = resolveOutputFormat({
261
+ command: 'diff',
262
+ format: options.format,
263
+ supported: ['console', 'json', 'sarif'],
264
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
265
+ onWarning: (message) => process.stderr.write(`${message}\n`),
266
+ });
77
267
  // Scan current state
78
268
  const config = await loadConfig(projectPath);
79
- const currentFiles = analyzeProject(projectPath, config);
269
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions);
80
270
  const currentReport = buildReport(projectPath, currentFiles);
81
271
  // Extract base state from git
82
272
  tempDir = extractFilesAtRef(projectPath, baseRef);
83
- const baseFiles = analyzeProject(tempDir, config);
273
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions);
84
274
  // Remap base file paths to match current project paths
85
275
  // (temp dir paths → project paths for accurate comparison)
86
276
  const baseReport = buildReport(tempDir, baseFiles);
@@ -88,11 +278,14 @@ program
88
278
  ...baseReport,
89
279
  files: baseReport.files.map(f => ({
90
280
  ...f,
91
- path: f.path.replace(tempDir, projectPath),
281
+ path: resolve(projectPath, relative(tempDir, f.path)),
92
282
  })),
93
283
  };
94
284
  const diff = computeDiff(remappedBase, currentReport, baseRef);
95
- if (options.json) {
285
+ if (format === 'sarif') {
286
+ process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`);
287
+ }
288
+ else if (format === 'json') {
96
289
  process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
97
290
  }
98
291
  else {
@@ -108,22 +301,82 @@ program
108
301
  if (tempDir)
109
302
  cleanupTempDir(tempDir);
110
303
  }
304
+ }));
305
+ addResourceOptions(program
306
+ .command('guard [path]')
307
+ .description('Evaluate drift guard thresholds against diff or baseline')
308
+ .option('--base <ref>', 'Git base ref for diff guard mode')
309
+ .option('--baseline <file>', 'Baseline file path (default: drift-baseline.json)')
310
+ .option('--budget <n>', 'Allowed score delta budget')
311
+ .option('--by-severity <spec>', 'Severity thresholds: error=0,warning=2,info=5')
312
+ .option('--json', 'Output raw JSON guard result')
313
+ .action(async (targetPath, options) => {
314
+ try {
315
+ const resolvedPath = resolve(targetPath ?? '.');
316
+ const budget = parseOptionalNumber(options.budget, '--budget');
317
+ const bySeverity = parseBySeverity(options.bySeverity);
318
+ const result = await runGuard(resolvedPath, {
319
+ baseRef: options.base,
320
+ baselinePath: options.baseline,
321
+ budget,
322
+ bySeverity,
323
+ analysis: resolveAnalysisOptions(options),
324
+ });
325
+ if (options.json) {
326
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
327
+ }
328
+ else {
329
+ printGuardSummary(result);
330
+ }
331
+ if (!result.passed) {
332
+ process.exit(1);
333
+ }
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ process.stderr.write(`\n Error: ${message}\n\n`);
338
+ process.exit(1);
339
+ }
340
+ }));
341
+ program
342
+ .command('benchmark')
343
+ .description('Run benchmark harness for scan/review/trust commands')
344
+ .allowUnknownOption(true)
345
+ .action(async () => {
346
+ await runBenchmarkCli(process.argv.slice(3));
111
347
  });
112
348
  program
113
349
  .command('review')
114
350
  .description('Review drift against a base ref and output PR markdown')
115
351
  .option('--base <ref>', 'Git base ref to compare against', 'origin/main')
352
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
116
353
  .option('--json', 'Output structured review JSON')
117
354
  .option('--comment', 'Output markdown comment body')
118
355
  .option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
119
356
  .action(async (options) => {
120
357
  try {
121
358
  const review = await generateReview(resolve('.'), options.base);
122
- if (options.json) {
359
+ const format = resolveOutputFormat({
360
+ command: 'review',
361
+ format: options.format,
362
+ supported: ['console', 'json', 'markdown', 'sarif'],
363
+ legacyAliases: [
364
+ { flag: 'json', used: options.json, mapsTo: 'json' },
365
+ { flag: 'comment', used: options.comment, mapsTo: 'markdown' },
366
+ ],
367
+ onWarning: (message) => process.stderr.write(`${message}\n`),
368
+ });
369
+ if (format === 'sarif') {
370
+ process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`);
371
+ }
372
+ else if (format === 'json') {
123
373
  process.stdout.write(JSON.stringify(review, null, 2) + '\n');
124
374
  }
375
+ else if (format === 'markdown') {
376
+ process.stdout.write(`${review.markdown}\n`);
377
+ }
125
378
  else {
126
- process.stdout.write((options.comment ? review.markdown : `${review.summary}\n\n${review.markdown}`) + '\n');
379
+ process.stdout.write(`${review.summary}\n\n${review.markdown}\n`);
127
380
  }
128
381
  const failOn = options.failOn ? Number(options.failOn) : undefined;
129
382
  if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
@@ -136,6 +389,231 @@ program
136
389
  process.exit(1);
137
390
  }
138
391
  });
392
+ addResourceOptions(program
393
+ .command('trust [path]')
394
+ .description('Compute merge trust baseline from drift signals')
395
+ .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
396
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
397
+ .option('--json', 'Output structured trust JSON')
398
+ .option('--markdown', 'Output trust report as markdown (PR comment ready)')
399
+ .option('-o, --output <file>', 'Write trust output to file')
400
+ .option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
401
+ .option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
402
+ .option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
403
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
404
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
405
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
406
+ .option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
407
+ .option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
408
+ .option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
409
+ .action(async (targetPath, options) => {
410
+ let tempDir;
411
+ try {
412
+ const resolvedPath = resolve(targetPath ?? '.');
413
+ const analysisOptions = resolveAnalysisOptions(options);
414
+ process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`);
415
+ const config = await loadConfig(resolvedPath);
416
+ const files = analyzeProject(resolvedPath, config, analysisOptions);
417
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
418
+ const report = buildReport(resolvedPath, files);
419
+ const branchName = resolveBranchFromOption(options.branch);
420
+ const policyExplanation = explainTrustGatePolicy(config, {
421
+ branchName,
422
+ policyPack: options.policyPack,
423
+ overrides: parseTrustGateOverrides(options),
424
+ });
425
+ const policy = policyExplanation.effectivePolicy;
426
+ if (options.explainPolicy) {
427
+ printTrustGatePolicyDebug(policyExplanation);
428
+ }
429
+ else if (policyExplanation.invalidPolicyPack) {
430
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
431
+ }
432
+ let diff;
433
+ if (options.base) {
434
+ process.stderr.write(`Computing diff signals against ${options.base}...\n`);
435
+ tempDir = extractFilesAtRef(resolvedPath, options.base);
436
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions);
437
+ const baseReport = buildReport(tempDir, baseFiles);
438
+ const remappedBase = {
439
+ ...baseReport,
440
+ files: baseReport.files.map((file) => ({
441
+ ...file,
442
+ path: resolve(resolvedPath, relative(tempDir, file.path)),
443
+ })),
444
+ };
445
+ diff = computeDiff(remappedBase, report, options.base);
446
+ process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`);
447
+ }
448
+ let previousTrustReport;
449
+ let snapshots;
450
+ if (options.advancedTrust) {
451
+ if (options.previousTrust) {
452
+ const previousTrustPath = resolve(options.previousTrust);
453
+ const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8');
454
+ previousTrustReport = JSON.parse(rawPreviousTrust);
455
+ process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`);
456
+ }
457
+ if (options.historyFile) {
458
+ const historyPath = resolve(options.historyFile);
459
+ const rawHistory = readFileSync(historyPath, 'utf8');
460
+ const history = JSON.parse(rawHistory);
461
+ snapshots = history.snapshots;
462
+ process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`);
463
+ }
464
+ else {
465
+ snapshots = loadHistory(resolvedPath).snapshots;
466
+ }
467
+ }
468
+ const trust = buildTrustReport(report, {
469
+ diff,
470
+ advanced: {
471
+ enabled: options.advancedTrust,
472
+ previousTrust: previousTrustReport,
473
+ snapshots,
474
+ },
475
+ });
476
+ const format = resolveOutputFormat({
477
+ command: 'trust',
478
+ format: options.format,
479
+ supported: ['console', 'json', 'markdown', 'sarif'],
480
+ legacyAliases: [
481
+ { flag: 'json', used: options.json, mapsTo: 'json' },
482
+ { flag: 'markdown', used: options.markdown, mapsTo: 'markdown' },
483
+ ],
484
+ onWarning: (message) => process.stderr.write(`${message}\n`),
485
+ });
486
+ const rendered = format === 'sarif'
487
+ ? `${JSON.stringify(toSarif(report), null, 2)}\n`
488
+ : `${renderTrustOutput(trust, {
489
+ json: format === 'json',
490
+ markdown: format === 'markdown',
491
+ })}\n`;
492
+ process.stdout.write(rendered);
493
+ if (options.output) {
494
+ const outPath = resolve(options.output);
495
+ writeFileSync(outPath, rendered, 'utf8');
496
+ process.stderr.write(`Trust output saved to ${outPath}\n`);
497
+ }
498
+ if (options.jsonOutput) {
499
+ const jsonOutPath = resolve(options.jsonOutput);
500
+ writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8');
501
+ process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`);
502
+ }
503
+ if (policy.enabled === false) {
504
+ process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
505
+ return;
506
+ }
507
+ if (shouldFailTrustGate(trust, policy)) {
508
+ process.exit(1);
509
+ }
510
+ }
511
+ catch (err) {
512
+ const message = err instanceof Error ? err.message : String(err);
513
+ process.stderr.write(`\n Error: ${message}\n\n`);
514
+ process.exit(1);
515
+ }
516
+ finally {
517
+ if (tempDir)
518
+ cleanupTempDir(tempDir);
519
+ }
520
+ }));
521
+ program
522
+ .command('trust-gate <trustJsonFile>')
523
+ .description('Evaluate trust gate thresholds from an existing trust JSON file')
524
+ .option('--min-trust <n>', 'Fail if trust score is below threshold')
525
+ .option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
526
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
527
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
528
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
529
+ .action(async (trustJsonFile, options) => {
530
+ try {
531
+ const filePath = resolve(trustJsonFile);
532
+ const raw = readFileSync(filePath, 'utf8');
533
+ const parsed = JSON.parse(raw);
534
+ const config = await loadConfig(resolve('.'));
535
+ const branchName = resolveBranchFromOption(options.branch);
536
+ const policyExplanation = explainTrustGatePolicy(config, {
537
+ branchName,
538
+ policyPack: options.policyPack,
539
+ overrides: parseTrustGateOverrides(options),
540
+ });
541
+ const policy = policyExplanation.effectivePolicy;
542
+ if (options.explainPolicy) {
543
+ printTrustGatePolicyDebug(policyExplanation);
544
+ }
545
+ else if (policyExplanation.invalidPolicyPack) {
546
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
547
+ }
548
+ if (typeof parsed.trust_score !== 'number') {
549
+ process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n');
550
+ process.exit(1);
551
+ }
552
+ if (typeof parsed.merge_risk !== 'string') {
553
+ process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n');
554
+ process.exit(1);
555
+ }
556
+ const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk);
557
+ if (!actualRisk) {
558
+ process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
559
+ process.exit(1);
560
+ }
561
+ const trust = {
562
+ scannedAt: parsed.scannedAt ?? new Date().toISOString(),
563
+ targetPath: parsed.targetPath ?? '.',
564
+ trust_score: parsed.trust_score,
565
+ merge_risk: actualRisk,
566
+ top_reasons: parsed.top_reasons ?? [],
567
+ fix_priorities: parsed.fix_priorities ?? [],
568
+ diff_context: parsed.diff_context,
569
+ };
570
+ if (policy.enabled === false) {
571
+ process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
572
+ return;
573
+ }
574
+ if (shouldFailTrustGate(trust, policy)) {
575
+ process.exit(1);
576
+ }
577
+ process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`);
578
+ }
579
+ catch (err) {
580
+ const message = err instanceof Error ? err.message : String(err);
581
+ process.stderr.write(`\n Error: ${message}\n\n`);
582
+ process.exit(1);
583
+ }
584
+ });
585
+ program
586
+ .command('doctor')
587
+ .description('Run project environment diagnostics')
588
+ .option('--json', 'Output structured doctor JSON')
589
+ .action(async (opts) => {
590
+ try {
591
+ await runDoctor(process.cwd(), { json: opts.json });
592
+ }
593
+ catch (err) {
594
+ const message = err instanceof Error ? err.message : String(err);
595
+ process.stderr.write(`\n Error: ${message}\n\n`);
596
+ process.exitCode = 1;
597
+ }
598
+ });
599
+ program
600
+ .command('kpi <path>')
601
+ .description('Aggregate trust KPIs from trust JSON artifacts')
602
+ .option('--no-summary', 'Disable console KPI summary in stderr')
603
+ .action((targetPath, options) => {
604
+ try {
605
+ const kpi = computeTrustKpis(targetPath);
606
+ if (options.summary !== false) {
607
+ process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`);
608
+ }
609
+ process.stdout.write(`${formatTrustKpiJson(kpi)}\n`);
610
+ }
611
+ catch (err) {
612
+ const message = err instanceof Error ? err.message : String(err);
613
+ process.stderr.write(`\n Error: ${message}\n\n`);
614
+ process.exit(1);
615
+ }
616
+ });
139
617
  program
140
618
  .command('map [path]')
141
619
  .description('Generate architecture.svg with simple layer dependencies')
@@ -147,7 +625,7 @@ program
147
625
  const out = generateArchitectureMap(resolvedPath, options.output, config);
148
626
  process.stderr.write(` Architecture map saved to ${out}\n\n`);
149
627
  });
150
- program
628
+ addResourceOptions(program
151
629
  .command('report [path]')
152
630
  .description('Generate a self-contained HTML report')
153
631
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
@@ -155,15 +633,15 @@ program
155
633
  const resolvedPath = resolve(targetPath ?? '.');
156
634
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
157
635
  const config = await loadConfig(resolvedPath);
158
- const files = analyzeProject(resolvedPath, config);
636
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
159
637
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
160
638
  const report = buildReport(resolvedPath, files);
161
639
  const html = generateHtmlReport(report);
162
640
  const outPath = resolve(options.output);
163
641
  writeFileSync(outPath, html, 'utf8');
164
642
  process.stderr.write(` Report saved to ${outPath}\n\n`);
165
- });
166
- program
643
+ }));
644
+ addResourceOptions(program
167
645
  .command('badge [path]')
168
646
  .description('Generate a badge.svg with the current drift score')
169
647
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
@@ -171,30 +649,47 @@ program
171
649
  const resolvedPath = resolve(targetPath ?? '.');
172
650
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
173
651
  const config = await loadConfig(resolvedPath);
174
- const files = analyzeProject(resolvedPath, config);
652
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
175
653
  const report = buildReport(resolvedPath, files);
176
654
  const svg = generateBadge(report.totalScore);
177
655
  const outPath = resolve(options.output);
178
656
  writeFileSync(outPath, svg, 'utf8');
179
657
  process.stderr.write(` Badge saved to ${outPath}\n`);
180
658
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`);
181
- });
182
- program
659
+ }));
660
+ addResourceOptions(program
183
661
  .command('ci [path]')
184
662
  .description('Emit GitHub Actions annotations and step summary')
663
+ .option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
664
+ .option('--json', 'Output raw JSON report (legacy alias for --format json)')
185
665
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
186
666
  .action(async (targetPath, options) => {
187
667
  const resolvedPath = resolve(targetPath ?? '.');
188
668
  const config = await loadConfig(resolvedPath);
189
- const files = analyzeProject(resolvedPath, config);
669
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
190
670
  const report = buildReport(resolvedPath, files);
191
- emitCIAnnotations(report);
192
- printCISummary(report);
671
+ const format = resolveOutputFormat({
672
+ command: 'ci',
673
+ format: options.format,
674
+ supported: ['console', 'json', 'sarif'],
675
+ legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
676
+ onWarning: (message) => process.stderr.write(`${message}\n`),
677
+ });
678
+ if (format === 'sarif') {
679
+ process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
680
+ }
681
+ else if (format === 'json') {
682
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
683
+ }
684
+ else {
685
+ emitCIAnnotations(report);
686
+ printCISummary(report);
687
+ }
193
688
  const minScore = Number(options.minScore);
194
689
  if (minScore > 0 && report.totalScore > minScore) {
195
690
  process.exit(1);
196
691
  }
197
- });
692
+ }));
198
693
  program
199
694
  .command('trend [period]')
200
695
  .description('Analyze trend of technical debt over time')
@@ -303,7 +798,7 @@ program
303
798
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
304
799
  }
305
800
  });
306
- program
801
+ addResourceOptions(program
307
802
  .command('snapshot [path]')
308
803
  .description('Record a score snapshot to drift-history.json')
309
804
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
@@ -318,7 +813,7 @@ program
318
813
  }
319
814
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
320
815
  const config = await loadConfig(resolvedPath);
321
- const files = analyzeProject(resolvedPath, config);
816
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts));
322
817
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
323
818
  const report = buildReport(resolvedPath, files);
324
819
  if (opts.diff) {
@@ -330,64 +825,195 @@ program
330
825
  const labelStr = entry.label ? ` [${entry.label}]` : '';
331
826
  process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
332
827
  process.stdout.write(` Saved to drift-history.json\n\n`);
333
- });
828
+ }));
334
829
  const cloud = program
335
830
  .command('cloud')
336
831
  .description('Local SaaS foundations: ingest, summary, and dashboard');
337
- cloud
832
+ addResourceOptions(cloud
338
833
  .command('ingest [path]')
339
834
  .description('Scan path, build report, and store cloud snapshot')
835
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
340
836
  .requiredOption('--workspace <id>', 'Workspace id')
341
837
  .requiredOption('--user <id>', 'User id')
838
+ .option('--role <role>', 'Role hint (owner|member|viewer)')
839
+ .option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
342
840
  .option('--repo <name>', 'Repo name (default: basename of scanned path)')
841
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
343
842
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
344
843
  .action(async (targetPath, options) => {
345
- const resolvedPath = resolve(targetPath ?? '.');
346
- process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
347
- const config = await loadConfig(resolvedPath);
348
- const files = analyzeProject(resolvedPath, config);
349
- const report = buildReport(resolvedPath, files);
350
- const snapshot = ingestSnapshotFromReport(report, {
351
- workspaceId: options.workspace,
352
- userId: options.user,
353
- repoName: options.repo ?? basename(resolvedPath),
354
- storeFile: options.store,
355
- policy: config?.saas,
356
- });
357
- process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
358
- process.stdout.write(`Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
359
- process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
360
- });
844
+ try {
845
+ const resolvedPath = resolve(targetPath ?? '.');
846
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
847
+ const config = await loadConfig(resolvedPath);
848
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
849
+ const report = buildReport(resolvedPath, files);
850
+ const snapshot = ingestSnapshotFromReport(report, {
851
+ organizationId: options.org,
852
+ workspaceId: options.workspace,
853
+ userId: options.user,
854
+ role: options.role,
855
+ plan: options.plan,
856
+ repoName: options.repo ?? basename(resolvedPath),
857
+ actorUserId: options.actor,
858
+ storeFile: options.store,
859
+ policy: config?.saas,
860
+ });
861
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
862
+ process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
863
+ process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`);
864
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
865
+ }
866
+ catch (error) {
867
+ printSaasErrorAndExit(error);
868
+ }
869
+ }));
361
870
  cloud
362
871
  .command('summary')
363
872
  .description('Show SaaS usage metrics and free threshold status')
364
873
  .option('--json', 'Output raw JSON summary')
874
+ .option('--org <id>', 'Filter summary by organization id')
875
+ .option('--workspace <id>', 'Filter summary by workspace id')
876
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
365
877
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
366
878
  .action((options) => {
367
- const summary = getSaasSummary({ storeFile: options.store });
368
- if (options.json) {
369
- process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
370
- return;
879
+ try {
880
+ const summary = getSaasSummary({
881
+ storeFile: options.store,
882
+ organizationId: options.org,
883
+ workspaceId: options.workspace,
884
+ actorUserId: options.actor,
885
+ });
886
+ if (options.json) {
887
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
888
+ return;
889
+ }
890
+ process.stdout.write('\n');
891
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
892
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
893
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
894
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
895
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
896
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
897
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
898
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
899
+ process.stdout.write('Runs per month:\n');
900
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
901
+ if (monthly.length === 0) {
902
+ process.stdout.write(' - none\n\n');
903
+ return;
904
+ }
905
+ for (const [month, runs] of monthly) {
906
+ process.stdout.write(` - ${month}: ${runs}\n`);
907
+ }
908
+ process.stdout.write('\n');
371
909
  }
372
- process.stdout.write('\n');
373
- process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
374
- process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
375
- process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
376
- process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
377
- process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
378
- process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
379
- process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
380
- process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
381
- process.stdout.write('Runs per month:\n');
382
- const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
383
- if (monthly.length === 0) {
384
- process.stdout.write(' - none\n\n');
385
- return;
910
+ catch (error) {
911
+ printSaasErrorAndExit(error);
386
912
  }
387
- for (const [month, runs] of monthly) {
388
- process.stdout.write(` - ${month}: ${runs}\n`);
913
+ });
914
+ cloud
915
+ .command('plan-set')
916
+ .description('Set organization plan (owner role required when actor is provided)')
917
+ .requiredOption('--org <id>', 'Organization id')
918
+ .requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
919
+ .requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
920
+ .option('--reason <text>', 'Optional reason for audit trail')
921
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
922
+ .option('--json', 'Output raw JSON plan change')
923
+ .action((options) => {
924
+ try {
925
+ const change = changeOrganizationPlan({
926
+ organizationId: options.org,
927
+ actorUserId: options.actor,
928
+ newPlan: options.plan,
929
+ reason: options.reason,
930
+ storeFile: options.store,
931
+ });
932
+ if (options.json) {
933
+ process.stdout.write(JSON.stringify(change, null, 2) + '\n');
934
+ return;
935
+ }
936
+ process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`);
937
+ process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`);
938
+ if (change.reason)
939
+ process.stdout.write(`Reason: ${change.reason}\n`);
940
+ }
941
+ catch (error) {
942
+ printSaasErrorAndExit(error);
943
+ }
944
+ });
945
+ cloud
946
+ .command('plan-changes')
947
+ .description('List organization plan change audit trail')
948
+ .requiredOption('--org <id>', 'Organization id')
949
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
950
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
951
+ .option('--json', 'Output raw JSON plan changes')
952
+ .action((options) => {
953
+ try {
954
+ const changes = listOrganizationPlanChanges({
955
+ organizationId: options.org,
956
+ actorUserId: options.actor,
957
+ storeFile: options.store,
958
+ });
959
+ if (options.json) {
960
+ process.stdout.write(JSON.stringify(changes, null, 2) + '\n');
961
+ return;
962
+ }
963
+ if (changes.length === 0) {
964
+ process.stdout.write(`No plan changes found for org '${options.org}'.\n`);
965
+ return;
966
+ }
967
+ process.stdout.write(`Plan changes for org '${options.org}':\n`);
968
+ for (const change of changes) {
969
+ const reasonSuffix = change.reason ? ` reason='${change.reason}'` : '';
970
+ process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`);
971
+ }
972
+ }
973
+ catch (error) {
974
+ printSaasErrorAndExit(error);
975
+ }
976
+ });
977
+ cloud
978
+ .command('usage')
979
+ .description('Show organization usage and effective limits')
980
+ .requiredOption('--org <id>', 'Organization id')
981
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
982
+ .option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
983
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
984
+ .option('--json', 'Output usage and limits as raw JSON')
985
+ .action((options) => {
986
+ try {
987
+ const usage = getOrganizationUsageSnapshot({
988
+ organizationId: options.org,
989
+ actorUserId: options.actor,
990
+ month: options.month,
991
+ storeFile: options.store,
992
+ });
993
+ const limits = getOrganizationEffectiveLimits({
994
+ organizationId: options.org,
995
+ storeFile: options.store,
996
+ });
997
+ if (options.json) {
998
+ process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n');
999
+ return;
1000
+ }
1001
+ process.stdout.write(`Organization: ${usage.organizationId}\n`);
1002
+ process.stdout.write(`Plan: ${usage.plan}\n`);
1003
+ process.stdout.write(`Captured at: ${usage.capturedAt}\n`);
1004
+ process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`);
1005
+ process.stdout.write(`Repo count: ${usage.repoCount}\n`);
1006
+ process.stdout.write(`Runs total: ${usage.runCount}\n`);
1007
+ process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`);
1008
+ process.stdout.write('Effective limits:\n');
1009
+ process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`);
1010
+ process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`);
1011
+ process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`);
1012
+ process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`);
1013
+ }
1014
+ catch (error) {
1015
+ printSaasErrorAndExit(error);
389
1016
  }
390
- process.stdout.write('\n');
391
1017
  });
392
1018
  cloud
393
1019
  .command('dashboard')