@eduardbar/drift 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
package/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';
@@ -21,13 +21,77 @@ import { applyFixes } from './fix.js';
21
21
  import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
22
22
  import { generateReview } from './review.js';
23
23
  import { generateArchitectureMap } from './map.js';
24
- import { ingestSnapshotFromReport, getSaasSummary, generateSaasDashboardHtml } from './saas.js';
24
+ import { changeOrganizationPlan, generateSaasDashboardHtml, getOrganizationEffectiveLimits, getOrganizationUsageSnapshot, getSaasSummary, ingestSnapshotFromReport, listOrganizationPlanChanges, } from './saas.js';
25
+ import { buildTrustReport, explainTrustGatePolicy, formatTrustGatePolicyExplanation, formatTrustJson, renderTrustOutput, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, detectBranchName, } from './trust.js';
26
+ import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js';
25
27
  const program = new Command();
28
+ function parseOptionalPositiveInt(rawValue, flagName) {
29
+ if (rawValue == null)
30
+ return undefined;
31
+ const value = Number(rawValue);
32
+ if (!Number.isInteger(value) || value < 0) {
33
+ throw new Error(`${flagName} must be a non-negative integer`);
34
+ }
35
+ return value;
36
+ }
37
+ function resolveAnalysisOptions(options) {
38
+ return {
39
+ lowMemory: options.lowMemory,
40
+ chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
41
+ maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
42
+ maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
43
+ includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
44
+ };
45
+ }
46
+ function addResourceOptions(command) {
47
+ return command
48
+ .option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
49
+ .option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
50
+ .option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
51
+ .option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
52
+ .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode');
53
+ }
54
+ function parseTrustGateOverrides(options) {
55
+ const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined;
56
+ if (options.minTrust && Number.isNaN(cliMinTrust)) {
57
+ process.stderr.write('\n Error: --min-trust must be a valid number\n\n');
58
+ process.exit(1);
59
+ }
60
+ let cliMaxRisk;
61
+ if (options.maxRisk) {
62
+ cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk);
63
+ if (!cliMaxRisk) {
64
+ process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
65
+ process.exit(1);
66
+ }
67
+ }
68
+ return {
69
+ minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
70
+ maxRisk: cliMaxRisk,
71
+ };
72
+ }
73
+ function resolveBranchFromOption(branch) {
74
+ const normalized = branch?.trim();
75
+ if (normalized)
76
+ return normalized;
77
+ return detectBranchName();
78
+ }
79
+ function printTrustGatePolicyDebug(explanation) {
80
+ process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`);
81
+ if (explanation.invalidPolicyPack) {
82
+ process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
83
+ }
84
+ }
85
+ function printSaasErrorAndExit(error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ process.stderr.write(`\n Error: ${message}\n\n`);
88
+ process.exit(1);
89
+ }
26
90
  program
27
91
  .name('drift')
28
- .description('Detect silent technical debt left by AI-generated code')
92
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
29
93
  .version(VERSION);
30
- program
94
+ addResourceOptions(program
31
95
  .command('scan [path]', { isDefault: true })
32
96
  .description('Scan a directory for vibe coding drift')
33
97
  .option('-o, --output <file>', 'Write report to a Markdown file')
@@ -39,7 +103,7 @@ program
39
103
  const resolvedPath = resolve(targetPath ?? '.');
40
104
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
41
105
  const config = await loadConfig(resolvedPath);
42
- const files = analyzeProject(resolvedPath, config);
106
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
43
107
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
44
108
  const report = buildReport(resolvedPath, files);
45
109
  if (options.ai) {
@@ -63,24 +127,25 @@ program
63
127
  if (minScore > 0 && report.totalScore > minScore) {
64
128
  process.exit(1);
65
129
  }
66
- });
67
- program
130
+ }));
131
+ addResourceOptions(program
68
132
  .command('diff [ref]')
69
133
  .description('Compare current state against a git ref (default: HEAD~1)')
70
134
  .option('--json', 'Output raw JSON diff')
71
135
  .action(async (ref, options) => {
72
136
  const baseRef = ref ?? 'HEAD~1';
73
137
  const projectPath = resolve('.');
138
+ const analysisOptions = resolveAnalysisOptions(options);
74
139
  let tempDir;
75
140
  try {
76
141
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`);
77
142
  // Scan current state
78
143
  const config = await loadConfig(projectPath);
79
- const currentFiles = analyzeProject(projectPath, config);
144
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions);
80
145
  const currentReport = buildReport(projectPath, currentFiles);
81
146
  // Extract base state from git
82
147
  tempDir = extractFilesAtRef(projectPath, baseRef);
83
- const baseFiles = analyzeProject(tempDir, config);
148
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions);
84
149
  // Remap base file paths to match current project paths
85
150
  // (temp dir paths → project paths for accurate comparison)
86
151
  const baseReport = buildReport(tempDir, baseFiles);
@@ -88,7 +153,7 @@ program
88
153
  ...baseReport,
89
154
  files: baseReport.files.map(f => ({
90
155
  ...f,
91
- path: f.path.replace(tempDir, projectPath),
156
+ path: resolve(projectPath, relative(tempDir, f.path)),
92
157
  })),
93
158
  };
94
159
  const diff = computeDiff(remappedBase, currentReport, baseRef);
@@ -108,7 +173,7 @@ program
108
173
  if (tempDir)
109
174
  cleanupTempDir(tempDir);
110
175
  }
111
- });
176
+ }));
112
177
  program
113
178
  .command('review')
114
179
  .description('Review drift against a base ref and output PR markdown')
@@ -136,6 +201,201 @@ program
136
201
  process.exit(1);
137
202
  }
138
203
  });
204
+ addResourceOptions(program
205
+ .command('trust [path]')
206
+ .description('Compute merge trust baseline from drift signals')
207
+ .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
208
+ .option('--json', 'Output structured trust JSON')
209
+ .option('--markdown', 'Output trust report as markdown (PR comment ready)')
210
+ .option('-o, --output <file>', 'Write trust output to file')
211
+ .option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
212
+ .option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
213
+ .option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
214
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
215
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
216
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
217
+ .option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
218
+ .option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
219
+ .option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
220
+ .action(async (targetPath, options) => {
221
+ let tempDir;
222
+ try {
223
+ const resolvedPath = resolve(targetPath ?? '.');
224
+ const analysisOptions = resolveAnalysisOptions(options);
225
+ process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`);
226
+ const config = await loadConfig(resolvedPath);
227
+ const files = analyzeProject(resolvedPath, config, analysisOptions);
228
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
229
+ const report = buildReport(resolvedPath, files);
230
+ const branchName = resolveBranchFromOption(options.branch);
231
+ const policyExplanation = explainTrustGatePolicy(config, {
232
+ branchName,
233
+ policyPack: options.policyPack,
234
+ overrides: parseTrustGateOverrides(options),
235
+ });
236
+ const policy = policyExplanation.effectivePolicy;
237
+ if (options.explainPolicy) {
238
+ printTrustGatePolicyDebug(policyExplanation);
239
+ }
240
+ else if (policyExplanation.invalidPolicyPack) {
241
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
242
+ }
243
+ let diff;
244
+ if (options.base) {
245
+ process.stderr.write(`Computing diff signals against ${options.base}...\n`);
246
+ tempDir = extractFilesAtRef(resolvedPath, options.base);
247
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions);
248
+ const baseReport = buildReport(tempDir, baseFiles);
249
+ const remappedBase = {
250
+ ...baseReport,
251
+ files: baseReport.files.map((file) => ({
252
+ ...file,
253
+ path: resolve(resolvedPath, relative(tempDir, file.path)),
254
+ })),
255
+ };
256
+ diff = computeDiff(remappedBase, report, options.base);
257
+ process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`);
258
+ }
259
+ let previousTrustReport;
260
+ let snapshots;
261
+ if (options.advancedTrust) {
262
+ if (options.previousTrust) {
263
+ const previousTrustPath = resolve(options.previousTrust);
264
+ const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8');
265
+ previousTrustReport = JSON.parse(rawPreviousTrust);
266
+ process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`);
267
+ }
268
+ if (options.historyFile) {
269
+ const historyPath = resolve(options.historyFile);
270
+ const rawHistory = readFileSync(historyPath, 'utf8');
271
+ const history = JSON.parse(rawHistory);
272
+ snapshots = history.snapshots;
273
+ process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`);
274
+ }
275
+ else {
276
+ snapshots = loadHistory(resolvedPath).snapshots;
277
+ }
278
+ }
279
+ const trust = buildTrustReport(report, {
280
+ diff,
281
+ advanced: {
282
+ enabled: options.advancedTrust,
283
+ previousTrust: previousTrustReport,
284
+ snapshots,
285
+ },
286
+ });
287
+ const rendered = `${renderTrustOutput(trust, options)}\n`;
288
+ process.stdout.write(rendered);
289
+ if (options.output) {
290
+ const outPath = resolve(options.output);
291
+ writeFileSync(outPath, rendered, 'utf8');
292
+ process.stderr.write(`Trust output saved to ${outPath}\n`);
293
+ }
294
+ if (options.jsonOutput) {
295
+ const jsonOutPath = resolve(options.jsonOutput);
296
+ writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8');
297
+ process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`);
298
+ }
299
+ if (policy.enabled === false) {
300
+ process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
301
+ return;
302
+ }
303
+ if (shouldFailTrustGate(trust, policy)) {
304
+ process.exit(1);
305
+ }
306
+ }
307
+ catch (err) {
308
+ const message = err instanceof Error ? err.message : String(err);
309
+ process.stderr.write(`\n Error: ${message}\n\n`);
310
+ process.exit(1);
311
+ }
312
+ finally {
313
+ if (tempDir)
314
+ cleanupTempDir(tempDir);
315
+ }
316
+ }));
317
+ program
318
+ .command('trust-gate <trustJsonFile>')
319
+ .description('Evaluate trust gate thresholds from an existing trust JSON file')
320
+ .option('--min-trust <n>', 'Fail if trust score is below threshold')
321
+ .option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
322
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
323
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
324
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
325
+ .action(async (trustJsonFile, options) => {
326
+ try {
327
+ const filePath = resolve(trustJsonFile);
328
+ const raw = readFileSync(filePath, 'utf8');
329
+ const parsed = JSON.parse(raw);
330
+ const config = await loadConfig(resolve('.'));
331
+ const branchName = resolveBranchFromOption(options.branch);
332
+ const policyExplanation = explainTrustGatePolicy(config, {
333
+ branchName,
334
+ policyPack: options.policyPack,
335
+ overrides: parseTrustGateOverrides(options),
336
+ });
337
+ const policy = policyExplanation.effectivePolicy;
338
+ if (options.explainPolicy) {
339
+ printTrustGatePolicyDebug(policyExplanation);
340
+ }
341
+ else if (policyExplanation.invalidPolicyPack) {
342
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
343
+ }
344
+ if (typeof parsed.trust_score !== 'number') {
345
+ process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n');
346
+ process.exit(1);
347
+ }
348
+ if (typeof parsed.merge_risk !== 'string') {
349
+ process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n');
350
+ process.exit(1);
351
+ }
352
+ const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk);
353
+ if (!actualRisk) {
354
+ process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
355
+ process.exit(1);
356
+ }
357
+ const trust = {
358
+ scannedAt: parsed.scannedAt ?? new Date().toISOString(),
359
+ targetPath: parsed.targetPath ?? '.',
360
+ trust_score: parsed.trust_score,
361
+ merge_risk: actualRisk,
362
+ top_reasons: parsed.top_reasons ?? [],
363
+ fix_priorities: parsed.fix_priorities ?? [],
364
+ diff_context: parsed.diff_context,
365
+ };
366
+ if (policy.enabled === false) {
367
+ process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
368
+ return;
369
+ }
370
+ if (shouldFailTrustGate(trust, policy)) {
371
+ process.exit(1);
372
+ }
373
+ process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`);
374
+ }
375
+ catch (err) {
376
+ const message = err instanceof Error ? err.message : String(err);
377
+ process.stderr.write(`\n Error: ${message}\n\n`);
378
+ process.exit(1);
379
+ }
380
+ });
381
+ program
382
+ .command('kpi <path>')
383
+ .description('Aggregate trust KPIs from trust JSON artifacts')
384
+ .option('--no-summary', 'Disable console KPI summary in stderr')
385
+ .action((targetPath, options) => {
386
+ try {
387
+ const kpi = computeTrustKpis(targetPath);
388
+ if (options.summary !== false) {
389
+ process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`);
390
+ }
391
+ process.stdout.write(`${formatTrustKpiJson(kpi)}\n`);
392
+ }
393
+ catch (err) {
394
+ const message = err instanceof Error ? err.message : String(err);
395
+ process.stderr.write(`\n Error: ${message}\n\n`);
396
+ process.exit(1);
397
+ }
398
+ });
139
399
  program
140
400
  .command('map [path]')
141
401
  .description('Generate architecture.svg with simple layer dependencies')
@@ -147,7 +407,7 @@ program
147
407
  const out = generateArchitectureMap(resolvedPath, options.output, config);
148
408
  process.stderr.write(` Architecture map saved to ${out}\n\n`);
149
409
  });
150
- program
410
+ addResourceOptions(program
151
411
  .command('report [path]')
152
412
  .description('Generate a self-contained HTML report')
153
413
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
@@ -155,15 +415,15 @@ program
155
415
  const resolvedPath = resolve(targetPath ?? '.');
156
416
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
157
417
  const config = await loadConfig(resolvedPath);
158
- const files = analyzeProject(resolvedPath, config);
418
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
159
419
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
160
420
  const report = buildReport(resolvedPath, files);
161
421
  const html = generateHtmlReport(report);
162
422
  const outPath = resolve(options.output);
163
423
  writeFileSync(outPath, html, 'utf8');
164
424
  process.stderr.write(` Report saved to ${outPath}\n\n`);
165
- });
166
- program
425
+ }));
426
+ addResourceOptions(program
167
427
  .command('badge [path]')
168
428
  .description('Generate a badge.svg with the current drift score')
169
429
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
@@ -171,22 +431,22 @@ program
171
431
  const resolvedPath = resolve(targetPath ?? '.');
172
432
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
173
433
  const config = await loadConfig(resolvedPath);
174
- const files = analyzeProject(resolvedPath, config);
434
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
175
435
  const report = buildReport(resolvedPath, files);
176
436
  const svg = generateBadge(report.totalScore);
177
437
  const outPath = resolve(options.output);
178
438
  writeFileSync(outPath, svg, 'utf8');
179
439
  process.stderr.write(` Badge saved to ${outPath}\n`);
180
440
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`);
181
- });
182
- program
441
+ }));
442
+ addResourceOptions(program
183
443
  .command('ci [path]')
184
444
  .description('Emit GitHub Actions annotations and step summary')
185
445
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
186
446
  .action(async (targetPath, options) => {
187
447
  const resolvedPath = resolve(targetPath ?? '.');
188
448
  const config = await loadConfig(resolvedPath);
189
- const files = analyzeProject(resolvedPath, config);
449
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
190
450
  const report = buildReport(resolvedPath, files);
191
451
  emitCIAnnotations(report);
192
452
  printCISummary(report);
@@ -194,7 +454,7 @@ program
194
454
  if (minScore > 0 && report.totalScore > minScore) {
195
455
  process.exit(1);
196
456
  }
197
- });
457
+ }));
198
458
  program
199
459
  .command('trend [period]')
200
460
  .description('Analyze trend of technical debt over time')
@@ -303,7 +563,7 @@ program
303
563
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
304
564
  }
305
565
  });
306
- program
566
+ addResourceOptions(program
307
567
  .command('snapshot [path]')
308
568
  .description('Record a score snapshot to drift-history.json')
309
569
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
@@ -318,7 +578,7 @@ program
318
578
  }
319
579
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
320
580
  const config = await loadConfig(resolvedPath);
321
- const files = analyzeProject(resolvedPath, config);
581
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts));
322
582
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
323
583
  const report = buildReport(resolvedPath, files);
324
584
  if (opts.diff) {
@@ -330,64 +590,195 @@ program
330
590
  const labelStr = entry.label ? ` [${entry.label}]` : '';
331
591
  process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
332
592
  process.stdout.write(` Saved to drift-history.json\n\n`);
333
- });
593
+ }));
334
594
  const cloud = program
335
595
  .command('cloud')
336
596
  .description('Local SaaS foundations: ingest, summary, and dashboard');
337
- cloud
597
+ addResourceOptions(cloud
338
598
  .command('ingest [path]')
339
599
  .description('Scan path, build report, and store cloud snapshot')
600
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
340
601
  .requiredOption('--workspace <id>', 'Workspace id')
341
602
  .requiredOption('--user <id>', 'User id')
603
+ .option('--role <role>', 'Role hint (owner|member|viewer)')
604
+ .option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
342
605
  .option('--repo <name>', 'Repo name (default: basename of scanned path)')
606
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
343
607
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
344
608
  .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
- });
609
+ try {
610
+ const resolvedPath = resolve(targetPath ?? '.');
611
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
612
+ const config = await loadConfig(resolvedPath);
613
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
614
+ const report = buildReport(resolvedPath, files);
615
+ const snapshot = ingestSnapshotFromReport(report, {
616
+ organizationId: options.org,
617
+ workspaceId: options.workspace,
618
+ userId: options.user,
619
+ role: options.role,
620
+ plan: options.plan,
621
+ repoName: options.repo ?? basename(resolvedPath),
622
+ actorUserId: options.actor,
623
+ storeFile: options.store,
624
+ policy: config?.saas,
625
+ });
626
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
627
+ process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
628
+ process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`);
629
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
630
+ }
631
+ catch (error) {
632
+ printSaasErrorAndExit(error);
633
+ }
634
+ }));
361
635
  cloud
362
636
  .command('summary')
363
637
  .description('Show SaaS usage metrics and free threshold status')
364
638
  .option('--json', 'Output raw JSON summary')
639
+ .option('--org <id>', 'Filter summary by organization id')
640
+ .option('--workspace <id>', 'Filter summary by workspace id')
641
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
365
642
  .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
366
643
  .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;
644
+ try {
645
+ const summary = getSaasSummary({
646
+ storeFile: options.store,
647
+ organizationId: options.org,
648
+ workspaceId: options.workspace,
649
+ actorUserId: options.actor,
650
+ });
651
+ if (options.json) {
652
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
653
+ return;
654
+ }
655
+ process.stdout.write('\n');
656
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
657
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
658
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
659
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
660
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
661
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
662
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
663
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
664
+ process.stdout.write('Runs per month:\n');
665
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
666
+ if (monthly.length === 0) {
667
+ process.stdout.write(' - none\n\n');
668
+ return;
669
+ }
670
+ for (const [month, runs] of monthly) {
671
+ process.stdout.write(` - ${month}: ${runs}\n`);
672
+ }
673
+ process.stdout.write('\n');
371
674
  }
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;
675
+ catch (error) {
676
+ printSaasErrorAndExit(error);
677
+ }
678
+ });
679
+ cloud
680
+ .command('plan-set')
681
+ .description('Set organization plan (owner role required when actor is provided)')
682
+ .requiredOption('--org <id>', 'Organization id')
683
+ .requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
684
+ .requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
685
+ .option('--reason <text>', 'Optional reason for audit trail')
686
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
687
+ .option('--json', 'Output raw JSON plan change')
688
+ .action((options) => {
689
+ try {
690
+ const change = changeOrganizationPlan({
691
+ organizationId: options.org,
692
+ actorUserId: options.actor,
693
+ newPlan: options.plan,
694
+ reason: options.reason,
695
+ storeFile: options.store,
696
+ });
697
+ if (options.json) {
698
+ process.stdout.write(JSON.stringify(change, null, 2) + '\n');
699
+ return;
700
+ }
701
+ process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`);
702
+ process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`);
703
+ if (change.reason)
704
+ process.stdout.write(`Reason: ${change.reason}\n`);
705
+ }
706
+ catch (error) {
707
+ printSaasErrorAndExit(error);
708
+ }
709
+ });
710
+ cloud
711
+ .command('plan-changes')
712
+ .description('List organization plan change audit trail')
713
+ .requiredOption('--org <id>', 'Organization id')
714
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
715
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
716
+ .option('--json', 'Output raw JSON plan changes')
717
+ .action((options) => {
718
+ try {
719
+ const changes = listOrganizationPlanChanges({
720
+ organizationId: options.org,
721
+ actorUserId: options.actor,
722
+ storeFile: options.store,
723
+ });
724
+ if (options.json) {
725
+ process.stdout.write(JSON.stringify(changes, null, 2) + '\n');
726
+ return;
727
+ }
728
+ if (changes.length === 0) {
729
+ process.stdout.write(`No plan changes found for org '${options.org}'.\n`);
730
+ return;
731
+ }
732
+ process.stdout.write(`Plan changes for org '${options.org}':\n`);
733
+ for (const change of changes) {
734
+ const reasonSuffix = change.reason ? ` reason='${change.reason}'` : '';
735
+ process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`);
736
+ }
737
+ }
738
+ catch (error) {
739
+ printSaasErrorAndExit(error);
740
+ }
741
+ });
742
+ cloud
743
+ .command('usage')
744
+ .description('Show organization usage and effective limits')
745
+ .requiredOption('--org <id>', 'Organization id')
746
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
747
+ .option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
748
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
749
+ .option('--json', 'Output usage and limits as raw JSON')
750
+ .action((options) => {
751
+ try {
752
+ const usage = getOrganizationUsageSnapshot({
753
+ organizationId: options.org,
754
+ actorUserId: options.actor,
755
+ month: options.month,
756
+ storeFile: options.store,
757
+ });
758
+ const limits = getOrganizationEffectiveLimits({
759
+ organizationId: options.org,
760
+ storeFile: options.store,
761
+ });
762
+ if (options.json) {
763
+ process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n');
764
+ return;
765
+ }
766
+ process.stdout.write(`Organization: ${usage.organizationId}\n`);
767
+ process.stdout.write(`Plan: ${usage.plan}\n`);
768
+ process.stdout.write(`Captured at: ${usage.capturedAt}\n`);
769
+ process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`);
770
+ process.stdout.write(`Repo count: ${usage.repoCount}\n`);
771
+ process.stdout.write(`Runs total: ${usage.runCount}\n`);
772
+ process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`);
773
+ process.stdout.write('Effective limits:\n');
774
+ process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`);
775
+ process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`);
776
+ process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`);
777
+ process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`);
386
778
  }
387
- for (const [month, runs] of monthly) {
388
- process.stdout.write(` - ${month}: ${runs}\n`);
779
+ catch (error) {
780
+ printSaasErrorAndExit(error);
389
781
  }
390
- process.stdout.write('\n');
391
782
  });
392
783
  cloud
393
784
  .command('dashboard')