@eduardbar/drift 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.github/workflows/publish-vscode.yml +3 -3
  2. package/.github/workflows/publish.yml +3 -3
  3. package/.github/workflows/review-pr.yml +98 -6
  4. package/AGENTS.md +6 -0
  5. package/README.md +160 -10
  6. package/ROADMAP.md +6 -5
  7. package/dist/analyzer.d.ts +2 -2
  8. package/dist/analyzer.js +420 -159
  9. package/dist/benchmark.d.ts +2 -0
  10. package/dist/benchmark.js +185 -0
  11. package/dist/cli.js +453 -62
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +3 -1
  16. package/dist/plugins.d.ts +2 -1
  17. package/dist/plugins.js +177 -28
  18. package/dist/printer.js +4 -0
  19. package/dist/review.js +2 -2
  20. package/dist/rules/comments.js +2 -2
  21. package/dist/rules/complexity.js +2 -7
  22. package/dist/rules/nesting.js +3 -13
  23. package/dist/rules/phase0-basic.js +10 -10
  24. package/dist/rules/shared.d.ts +2 -0
  25. package/dist/rules/shared.js +27 -3
  26. package/dist/saas.d.ts +143 -7
  27. package/dist/saas.js +478 -37
  28. package/dist/trust-kpi.d.ts +9 -0
  29. package/dist/trust-kpi.js +445 -0
  30. package/dist/trust.d.ts +65 -0
  31. package/dist/trust.js +571 -0
  32. package/dist/types.d.ts +154 -0
  33. package/docs/PRD.md +187 -109
  34. package/docs/plugin-contract.md +61 -0
  35. package/docs/trust-core-release-checklist.md +55 -0
  36. package/package.json +5 -3
  37. package/src/analyzer.ts +484 -155
  38. package/src/benchmark.ts +244 -0
  39. package/src/cli.ts +562 -79
  40. package/src/diff.ts +75 -10
  41. package/src/git.ts +16 -0
  42. package/src/index.ts +48 -0
  43. package/src/plugins.ts +354 -26
  44. package/src/printer.ts +4 -0
  45. package/src/review.ts +2 -2
  46. package/src/rules/comments.ts +2 -2
  47. package/src/rules/complexity.ts +2 -7
  48. package/src/rules/nesting.ts +3 -13
  49. package/src/rules/phase0-basic.ts +11 -12
  50. package/src/rules/shared.ts +31 -3
  51. package/src/saas.ts +641 -43
  52. package/src/trust-kpi.ts +518 -0
  53. package/src/trust.ts +774 -0
  54. package/src/types.ts +171 -0
  55. package/tests/diff.test.ts +124 -0
  56. package/tests/new-features.test.ts +71 -0
  57. package/tests/plugins.test.ts +219 -0
  58. package/tests/rules.test.ts +23 -1
  59. package/tests/saas-foundation.test.ts +358 -1
  60. package/tests/trust-kpi.test.ts +120 -0
  61. package/tests/trust.test.ts +584 -0
package/src/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'
@@ -21,29 +21,130 @@ import { applyFixes, type FixResult } 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 {
25
+ changeOrganizationPlan,
26
+ generateSaasDashboardHtml,
27
+ getOrganizationEffectiveLimits,
28
+ getOrganizationUsageSnapshot,
29
+ getSaasSummary,
30
+ ingestSnapshotFromReport,
31
+ listOrganizationPlanChanges,
32
+ } from './saas.js'
33
+ import {
34
+ buildTrustReport,
35
+ explainTrustGatePolicy,
36
+ formatTrustGatePolicyExplanation,
37
+ formatTrustJson,
38
+ renderTrustOutput,
39
+ shouldFailTrustGate,
40
+ normalizeMergeRiskLevel,
41
+ MERGE_RISK_ORDER,
42
+ detectBranchName,
43
+ } from './trust.js'
44
+ import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js'
45
+ import type { DriftDiff, DriftTrustReport, DriftAnalysisOptions, MergeRiskLevel } from './types.js'
46
+ import type { TrustGatePolicyExplanation } from './trust.js'
47
+ import type { SnapshotHistory } from './snapshot.js'
25
48
 
26
49
  const program = new Command()
27
50
 
51
+ type ResourceOptionFlags = {
52
+ lowMemory?: boolean
53
+ chunkSize?: string
54
+ maxFiles?: string
55
+ maxFileSizeKb?: string
56
+ withSemanticDuplication?: boolean
57
+ }
58
+
59
+ function parseOptionalPositiveInt(rawValue: string | undefined, flagName: string): number | undefined {
60
+ if (rawValue == null) return undefined
61
+ const value = Number(rawValue)
62
+ if (!Number.isInteger(value) || value < 0) {
63
+ throw new Error(`${flagName} must be a non-negative integer`)
64
+ }
65
+ return value
66
+ }
67
+
68
+ function resolveAnalysisOptions(options: ResourceOptionFlags): DriftAnalysisOptions {
69
+ return {
70
+ lowMemory: options.lowMemory,
71
+ chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
72
+ maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
73
+ maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
74
+ includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
75
+ }
76
+ }
77
+
78
+ function addResourceOptions(command: Command): Command {
79
+ return command
80
+ .option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
81
+ .option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
82
+ .option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
83
+ .option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
84
+ .option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode')
85
+ }
86
+
87
+ function parseTrustGateOverrides(options: { minTrust?: string; maxRisk?: string }): { minTrust?: number; maxRisk?: MergeRiskLevel } {
88
+ const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined
89
+ if (options.minTrust && Number.isNaN(cliMinTrust)) {
90
+ process.stderr.write('\n Error: --min-trust must be a valid number\n\n')
91
+ process.exit(1)
92
+ }
93
+
94
+ let cliMaxRisk: MergeRiskLevel | undefined
95
+ if (options.maxRisk) {
96
+ cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk)
97
+ if (!cliMaxRisk) {
98
+ process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
99
+ process.exit(1)
100
+ }
101
+ }
102
+
103
+ return {
104
+ minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
105
+ maxRisk: cliMaxRisk,
106
+ }
107
+ }
108
+
109
+ function resolveBranchFromOption(branch?: string): string | undefined {
110
+ const normalized = branch?.trim()
111
+ if (normalized) return normalized
112
+ return detectBranchName()
113
+ }
114
+
115
+ function printTrustGatePolicyDebug(explanation: TrustGatePolicyExplanation): void {
116
+ process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`)
117
+ if (explanation.invalidPolicyPack) {
118
+ process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
119
+ }
120
+ }
121
+
122
+ function printSaasErrorAndExit(error: unknown): never {
123
+ const message = error instanceof Error ? error.message : String(error)
124
+ process.stderr.write(`\n Error: ${message}\n\n`)
125
+ process.exit(1)
126
+ }
127
+
28
128
  program
29
129
  .name('drift')
30
- .description('Detect silent technical debt left by AI-generated code')
130
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
31
131
  .version(VERSION)
32
132
 
33
- program
34
- .command('scan [path]', { isDefault: true })
133
+ addResourceOptions(
134
+ program
135
+ .command('scan [path]', { isDefault: true })
35
136
  .description('Scan a directory for vibe coding drift')
36
137
  .option('-o, --output <file>', 'Write report to a Markdown file')
37
138
  .option('--json', 'Output raw JSON report')
38
139
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
39
140
  .option('--fix', 'Show fix suggestions for each issue')
40
141
  .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 }) => {
142
+ .action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
42
143
  const resolvedPath = resolve(targetPath ?? '.')
43
144
 
44
145
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
45
146
  const config = await loadConfig(resolvedPath)
46
- const files = analyzeProject(resolvedPath, config)
147
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
47
148
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
48
149
  const report = buildReport(resolvedPath, files)
49
150
 
@@ -72,15 +173,18 @@ program
72
173
  if (minScore > 0 && report.totalScore > minScore) {
73
174
  process.exit(1)
74
175
  }
75
- })
176
+ }),
177
+ )
76
178
 
77
- program
78
- .command('diff [ref]')
179
+ addResourceOptions(
180
+ program
181
+ .command('diff [ref]')
79
182
  .description('Compare current state against a git ref (default: HEAD~1)')
80
183
  .option('--json', 'Output raw JSON diff')
81
- .action(async (ref: string | undefined, options: { json?: boolean }) => {
184
+ .action(async (ref: string | undefined, options: { json?: boolean } & ResourceOptionFlags) => {
82
185
  const baseRef = ref ?? 'HEAD~1'
83
186
  const projectPath = resolve('.')
187
+ const analysisOptions = resolveAnalysisOptions(options)
84
188
 
85
189
  let tempDir: string | undefined
86
190
 
@@ -89,12 +193,12 @@ program
89
193
 
90
194
  // Scan current state
91
195
  const config = await loadConfig(projectPath)
92
- const currentFiles = analyzeProject(projectPath, config)
196
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions)
93
197
  const currentReport = buildReport(projectPath, currentFiles)
94
198
 
95
199
  // Extract base state from git
96
200
  tempDir = extractFilesAtRef(projectPath, baseRef)
97
- const baseFiles = analyzeProject(tempDir, config)
201
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions)
98
202
 
99
203
  // Remap base file paths to match current project paths
100
204
  // (temp dir paths → project paths for accurate comparison)
@@ -103,7 +207,7 @@ program
103
207
  ...baseReport,
104
208
  files: baseReport.files.map(f => ({
105
209
  ...f,
106
- path: f.path.replace(tempDir!, projectPath),
210
+ path: resolve(projectPath, relative(tempDir!, f.path)),
107
211
  })),
108
212
  }
109
213
 
@@ -121,7 +225,8 @@ program
121
225
  } finally {
122
226
  if (tempDir) cleanupTempDir(tempDir)
123
227
  }
124
- })
228
+ }),
229
+ )
125
230
 
126
231
  program
127
232
  .command('review')
@@ -151,6 +256,239 @@ program
151
256
  }
152
257
  })
153
258
 
259
+ addResourceOptions(
260
+ program
261
+ .command('trust [path]')
262
+ .description('Compute merge trust baseline from drift signals')
263
+ .option('--base <ref>', 'Git base ref for diff-aware trust scoring')
264
+ .option('--json', 'Output structured trust JSON')
265
+ .option('--markdown', 'Output trust report as markdown (PR comment ready)')
266
+ .option('-o, --output <file>', 'Write trust output to file')
267
+ .option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
268
+ .option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
269
+ .option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
270
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
271
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
272
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
273
+ .option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
274
+ .option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
275
+ .option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
276
+ .action(async (
277
+ targetPath: string | undefined,
278
+ options: {
279
+ base?: string
280
+ json?: boolean
281
+ markdown?: boolean
282
+ output?: string
283
+ jsonOutput?: string
284
+ minTrust?: string
285
+ maxRisk?: string
286
+ branch?: string
287
+ policyPack?: string
288
+ explainPolicy?: boolean
289
+ advancedTrust?: boolean
290
+ previousTrust?: string
291
+ historyFile?: string
292
+ } & ResourceOptionFlags,
293
+ ) => {
294
+ let tempDir: string | undefined
295
+
296
+ try {
297
+ const resolvedPath = resolve(targetPath ?? '.')
298
+ const analysisOptions = resolveAnalysisOptions(options)
299
+
300
+ process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`)
301
+ const config = await loadConfig(resolvedPath)
302
+ const files = analyzeProject(resolvedPath, config, analysisOptions)
303
+ process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
304
+
305
+ const report = buildReport(resolvedPath, files)
306
+ const branchName = resolveBranchFromOption(options.branch)
307
+ const policyExplanation = explainTrustGatePolicy(config, {
308
+ branchName,
309
+ policyPack: options.policyPack,
310
+ overrides: parseTrustGateOverrides(options),
311
+ })
312
+ const policy = policyExplanation.effectivePolicy
313
+
314
+ if (options.explainPolicy) {
315
+ printTrustGatePolicyDebug(policyExplanation)
316
+ } else if (policyExplanation.invalidPolicyPack) {
317
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
318
+ }
319
+
320
+ let diff: DriftDiff | undefined
321
+ if (options.base) {
322
+ process.stderr.write(`Computing diff signals against ${options.base}...\n`)
323
+ tempDir = extractFilesAtRef(resolvedPath, options.base)
324
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions)
325
+ const baseReport = buildReport(tempDir, baseFiles)
326
+ const remappedBase = {
327
+ ...baseReport,
328
+ files: baseReport.files.map((file) => ({
329
+ ...file,
330
+ path: resolve(resolvedPath, relative(tempDir!, file.path)),
331
+ })),
332
+ }
333
+ diff = computeDiff(remappedBase, report, options.base)
334
+ process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`)
335
+ }
336
+
337
+ let previousTrustReport: Partial<DriftTrustReport> | undefined
338
+ let snapshots: SnapshotHistory['snapshots'] | undefined
339
+ if (options.advancedTrust) {
340
+ if (options.previousTrust) {
341
+ const previousTrustPath = resolve(options.previousTrust)
342
+ const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8')
343
+ previousTrustReport = JSON.parse(rawPreviousTrust) as Partial<DriftTrustReport>
344
+ process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`)
345
+ }
346
+
347
+ if (options.historyFile) {
348
+ const historyPath = resolve(options.historyFile)
349
+ const rawHistory = readFileSync(historyPath, 'utf8')
350
+ const history = JSON.parse(rawHistory) as SnapshotHistory
351
+ snapshots = history.snapshots
352
+ process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`)
353
+ } else {
354
+ snapshots = loadHistory(resolvedPath).snapshots
355
+ }
356
+ }
357
+
358
+ const trust = buildTrustReport(report, {
359
+ diff,
360
+ advanced: {
361
+ enabled: options.advancedTrust,
362
+ previousTrust: previousTrustReport,
363
+ snapshots,
364
+ },
365
+ })
366
+
367
+ const rendered = `${renderTrustOutput(trust, options)}\n`
368
+
369
+ process.stdout.write(rendered)
370
+
371
+ if (options.output) {
372
+ const outPath = resolve(options.output)
373
+ writeFileSync(outPath, rendered, 'utf8')
374
+ process.stderr.write(`Trust output saved to ${outPath}\n`)
375
+ }
376
+
377
+ if (options.jsonOutput) {
378
+ const jsonOutPath = resolve(options.jsonOutput)
379
+ writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8')
380
+ process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`)
381
+ }
382
+
383
+ if (policy.enabled === false) {
384
+ process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
385
+ return
386
+ }
387
+
388
+ if (shouldFailTrustGate(trust, policy)) {
389
+ process.exit(1)
390
+ }
391
+ } catch (err) {
392
+ const message = err instanceof Error ? err.message : String(err)
393
+ process.stderr.write(`\n Error: ${message}\n\n`)
394
+ process.exit(1)
395
+ } finally {
396
+ if (tempDir) cleanupTempDir(tempDir)
397
+ }
398
+ }),
399
+ )
400
+
401
+ program
402
+ .command('trust-gate <trustJsonFile>')
403
+ .description('Evaluate trust gate thresholds from an existing trust JSON file')
404
+ .option('--min-trust <n>', 'Fail if trust score is below threshold')
405
+ .option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
406
+ .option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
407
+ .option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
408
+ .option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
409
+ .action(async (trustJsonFile: string, options: { minTrust?: string; maxRisk?: string; branch?: string; policyPack?: string; explainPolicy?: boolean }) => {
410
+ try {
411
+ const filePath = resolve(trustJsonFile)
412
+ const raw = readFileSync(filePath, 'utf8')
413
+ const parsed = JSON.parse(raw) as Partial<DriftTrustReport>
414
+ const config = await loadConfig(resolve('.'))
415
+ const branchName = resolveBranchFromOption(options.branch)
416
+ const policyExplanation = explainTrustGatePolicy(config, {
417
+ branchName,
418
+ policyPack: options.policyPack,
419
+ overrides: parseTrustGateOverrides(options),
420
+ })
421
+ const policy = policyExplanation.effectivePolicy
422
+
423
+ if (options.explainPolicy) {
424
+ printTrustGatePolicyDebug(policyExplanation)
425
+ } else if (policyExplanation.invalidPolicyPack) {
426
+ process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
427
+ }
428
+
429
+ if (typeof parsed.trust_score !== 'number') {
430
+ process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n')
431
+ process.exit(1)
432
+ }
433
+
434
+ if (typeof parsed.merge_risk !== 'string') {
435
+ process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n')
436
+ process.exit(1)
437
+ }
438
+
439
+ const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk)
440
+ if (!actualRisk) {
441
+ process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
442
+ process.exit(1)
443
+ }
444
+
445
+ const trust: DriftTrustReport = {
446
+ scannedAt: parsed.scannedAt ?? new Date().toISOString(),
447
+ targetPath: parsed.targetPath ?? '.',
448
+ trust_score: parsed.trust_score,
449
+ merge_risk: actualRisk,
450
+ top_reasons: parsed.top_reasons ?? [],
451
+ fix_priorities: parsed.fix_priorities ?? [],
452
+ diff_context: parsed.diff_context,
453
+ }
454
+
455
+ if (policy.enabled === false) {
456
+ process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
457
+ return
458
+ }
459
+
460
+ if (shouldFailTrustGate(trust, policy)) {
461
+ process.exit(1)
462
+ }
463
+
464
+ process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`)
465
+ } catch (err) {
466
+ const message = err instanceof Error ? err.message : String(err)
467
+ process.stderr.write(`\n Error: ${message}\n\n`)
468
+ process.exit(1)
469
+ }
470
+ })
471
+
472
+ program
473
+ .command('kpi <path>')
474
+ .description('Aggregate trust KPIs from trust JSON artifacts')
475
+ .option('--no-summary', 'Disable console KPI summary in stderr')
476
+ .action((targetPath: string, options: { summary?: boolean }) => {
477
+ try {
478
+ const kpi = computeTrustKpis(targetPath)
479
+
480
+ if (options.summary !== false) {
481
+ process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`)
482
+ }
483
+
484
+ process.stdout.write(`${formatTrustKpiJson(kpi)}\n`)
485
+ } catch (err) {
486
+ const message = err instanceof Error ? err.message : String(err)
487
+ process.stderr.write(`\n Error: ${message}\n\n`)
488
+ process.exit(1)
489
+ }
490
+ })
491
+
154
492
  program
155
493
  .command('map [path]')
156
494
  .description('Generate architecture.svg with simple layer dependencies')
@@ -163,48 +501,53 @@ program
163
501
  process.stderr.write(` Architecture map saved to ${out}\n\n`)
164
502
  })
165
503
 
166
- program
167
- .command('report [path]')
504
+ addResourceOptions(
505
+ program
506
+ .command('report [path]')
168
507
  .description('Generate a self-contained HTML report')
169
508
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
170
- .action(async (targetPath: string | undefined, options: { output: string }) => {
509
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
171
510
  const resolvedPath = resolve(targetPath ?? '.')
172
511
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
173
512
  const config = await loadConfig(resolvedPath)
174
- const files = analyzeProject(resolvedPath, config)
513
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
175
514
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
176
515
  const report = buildReport(resolvedPath, files)
177
516
  const html = generateHtmlReport(report)
178
517
  const outPath = resolve(options.output)
179
518
  writeFileSync(outPath, html, 'utf8')
180
519
  process.stderr.write(` Report saved to ${outPath}\n\n`)
181
- })
520
+ }),
521
+ )
182
522
 
183
- program
184
- .command('badge [path]')
523
+ addResourceOptions(
524
+ program
525
+ .command('badge [path]')
185
526
  .description('Generate a badge.svg with the current drift score')
186
527
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
187
- .action(async (targetPath: string | undefined, options: { output: string }) => {
528
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
188
529
  const resolvedPath = resolve(targetPath ?? '.')
189
530
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
190
531
  const config = await loadConfig(resolvedPath)
191
- const files = analyzeProject(resolvedPath, config)
532
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
192
533
  const report = buildReport(resolvedPath, files)
193
534
  const svg = generateBadge(report.totalScore)
194
535
  const outPath = resolve(options.output)
195
536
  writeFileSync(outPath, svg, 'utf8')
196
537
  process.stderr.write(` Badge saved to ${outPath}\n`)
197
538
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
198
- })
539
+ }),
540
+ )
199
541
 
200
- program
201
- .command('ci [path]')
542
+ addResourceOptions(
543
+ program
544
+ .command('ci [path]')
202
545
  .description('Emit GitHub Actions annotations and step summary')
203
546
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
204
- .action(async (targetPath: string | undefined, options: { minScore: string }) => {
547
+ .action(async (targetPath: string | undefined, options: { minScore: string } & ResourceOptionFlags) => {
205
548
  const resolvedPath = resolve(targetPath ?? '.')
206
549
  const config = await loadConfig(resolvedPath)
207
- const files = analyzeProject(resolvedPath, config)
550
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
208
551
  const report = buildReport(resolvedPath, files)
209
552
  emitCIAnnotations(report)
210
553
  printCISummary(report)
@@ -212,7 +555,8 @@ program
212
555
  if (minScore > 0 && report.totalScore > minScore) {
213
556
  process.exit(1)
214
557
  }
215
- })
558
+ }),
559
+ )
216
560
 
217
561
  program
218
562
  .command('trend [period]')
@@ -340,15 +684,16 @@ program
340
684
  }
341
685
  })
342
686
 
343
- program
344
- .command('snapshot [path]')
687
+ addResourceOptions(
688
+ program
689
+ .command('snapshot [path]')
345
690
  .description('Record a score snapshot to drift-history.json')
346
691
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
347
692
  .option('--history', 'show all recorded snapshots')
348
693
  .option('--diff', 'compare current score vs last snapshot')
349
694
  .action(async (
350
695
  targetPath: string | undefined,
351
- opts: { label?: string; history?: boolean; diff?: boolean },
696
+ opts: { label?: string; history?: boolean; diff?: boolean } & ResourceOptionFlags,
352
697
  ) => {
353
698
  const resolvedPath = resolve(targetPath ?? '.')
354
699
 
@@ -360,7 +705,7 @@ program
360
705
 
361
706
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
362
707
  const config = await loadConfig(resolvedPath)
363
- const files = analyzeProject(resolvedPath, config)
708
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts))
364
709
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
365
710
  const report = buildReport(resolvedPath, files)
366
711
 
@@ -376,73 +721,211 @@ program
376
721
  ` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
377
722
  )
378
723
  process.stdout.write(` Saved to drift-history.json\n\n`)
379
- })
724
+ }),
725
+ )
380
726
 
381
727
  const cloud = program
382
728
  .command('cloud')
383
729
  .description('Local SaaS foundations: ingest, summary, and dashboard')
384
730
 
385
- cloud
386
- .command('ingest [path]')
731
+ addResourceOptions(
732
+ cloud
733
+ .command('ingest [path]')
387
734
  .description('Scan path, build report, and store cloud snapshot')
735
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
388
736
  .requiredOption('--workspace <id>', 'Workspace id')
389
737
  .requiredOption('--user <id>', 'User id')
738
+ .option('--role <role>', 'Role hint (owner|member|viewer)')
739
+ .option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
390
740
  .option('--repo <name>', 'Repo name (default: basename of scanned path)')
741
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
391
742
  .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)
398
-
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
- })
743
+ .action(async (targetPath: string | undefined, options: { org: string; workspace: string; user: string; role?: string; plan?: string; repo?: string; actor?: string; store?: string } & ResourceOptionFlags) => {
744
+ try {
745
+ const resolvedPath = resolve(targetPath ?? '.')
746
+ process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
747
+ const config = await loadConfig(resolvedPath)
748
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
749
+ const report = buildReport(resolvedPath, files)
750
+
751
+ const snapshot = ingestSnapshotFromReport(report, {
752
+ organizationId: options.org,
753
+ workspaceId: options.workspace,
754
+ userId: options.user,
755
+ role: options.role as 'owner' | 'member' | 'viewer' | undefined,
756
+ plan: options.plan as 'free' | 'sponsor' | 'team' | 'business' | undefined,
757
+ repoName: options.repo ?? basename(resolvedPath),
758
+ actorUserId: options.actor,
759
+ storeFile: options.store,
760
+ policy: config?.saas,
761
+ })
406
762
 
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
- })
763
+ process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
764
+ process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
765
+ process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`)
766
+ process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
767
+ } catch (error) {
768
+ printSaasErrorAndExit(error)
769
+ }
770
+ }),
771
+ )
411
772
 
412
773
  cloud
413
774
  .command('summary')
414
775
  .description('Show SaaS usage metrics and free threshold status')
415
776
  .option('--json', 'Output raw JSON summary')
777
+ .option('--org <id>', 'Filter summary by organization id')
778
+ .option('--workspace <id>', 'Filter summary by workspace id')
779
+ .option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
416
780
  .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 })
781
+ .action((options: { json?: boolean; org?: string; workspace?: string; actor?: string; store?: string }) => {
782
+ try {
783
+ const summary = getSaasSummary({
784
+ storeFile: options.store,
785
+ organizationId: options.org,
786
+ workspaceId: options.workspace,
787
+ actorUserId: options.actor,
788
+ })
419
789
 
420
- if (options.json) {
421
- process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
422
- return
790
+ if (options.json) {
791
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
792
+ return
793
+ }
794
+
795
+ process.stdout.write('\n')
796
+ process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
797
+ process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
798
+ process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
799
+ process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
800
+ process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
801
+ process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
802
+ process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
803
+ process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
804
+ process.stdout.write('Runs per month:\n')
805
+
806
+ const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
807
+ if (monthly.length === 0) {
808
+ process.stdout.write(' - none\n\n')
809
+ return
810
+ }
811
+
812
+ for (const [month, runs] of monthly) {
813
+ process.stdout.write(` - ${month}: ${runs}\n`)
814
+ }
815
+ process.stdout.write('\n')
816
+ } catch (error) {
817
+ printSaasErrorAndExit(error)
423
818
  }
819
+ })
424
820
 
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
821
+ cloud
822
+ .command('plan-set')
823
+ .description('Set organization plan (owner role required when actor is provided)')
824
+ .requiredOption('--org <id>', 'Organization id')
825
+ .requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
826
+ .requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
827
+ .option('--reason <text>', 'Optional reason for audit trail')
828
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
829
+ .option('--json', 'Output raw JSON plan change')
830
+ .action((options: { org: string; plan: string; actor: string; reason?: string; store?: string; json?: boolean }) => {
831
+ try {
832
+ const change = changeOrganizationPlan({
833
+ organizationId: options.org,
834
+ actorUserId: options.actor,
835
+ newPlan: options.plan as 'free' | 'sponsor' | 'team' | 'business',
836
+ reason: options.reason,
837
+ storeFile: options.store,
838
+ })
839
+
840
+ if (options.json) {
841
+ process.stdout.write(JSON.stringify(change, null, 2) + '\n')
842
+ return
843
+ }
844
+
845
+ process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`)
846
+ process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`)
847
+ if (change.reason) process.stdout.write(`Reason: ${change.reason}\n`)
848
+ } catch (error) {
849
+ printSaasErrorAndExit(error)
440
850
  }
851
+ })
852
+
853
+ cloud
854
+ .command('plan-changes')
855
+ .description('List organization plan change audit trail')
856
+ .requiredOption('--org <id>', 'Organization id')
857
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
858
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
859
+ .option('--json', 'Output raw JSON plan changes')
860
+ .action((options: { org: string; actor: string; store?: string; json?: boolean }) => {
861
+ try {
862
+ const changes = listOrganizationPlanChanges({
863
+ organizationId: options.org,
864
+ actorUserId: options.actor,
865
+ storeFile: options.store,
866
+ })
867
+
868
+ if (options.json) {
869
+ process.stdout.write(JSON.stringify(changes, null, 2) + '\n')
870
+ return
871
+ }
872
+
873
+ if (changes.length === 0) {
874
+ process.stdout.write(`No plan changes found for org '${options.org}'.\n`)
875
+ return
876
+ }
877
+
878
+ process.stdout.write(`Plan changes for org '${options.org}':\n`)
879
+ for (const change of changes) {
880
+ const reasonSuffix = change.reason ? ` reason='${change.reason}'` : ''
881
+ process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`)
882
+ }
883
+ } catch (error) {
884
+ printSaasErrorAndExit(error)
885
+ }
886
+ })
887
+
888
+ cloud
889
+ .command('usage')
890
+ .description('Show organization usage and effective limits')
891
+ .requiredOption('--org <id>', 'Organization id')
892
+ .requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
893
+ .option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
894
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
895
+ .option('--json', 'Output usage and limits as raw JSON')
896
+ .action((options: { org: string; actor: string; month?: string; store?: string; json?: boolean }) => {
897
+ try {
898
+ const usage = getOrganizationUsageSnapshot({
899
+ organizationId: options.org,
900
+ actorUserId: options.actor,
901
+ month: options.month,
902
+ storeFile: options.store,
903
+ })
904
+ const limits = getOrganizationEffectiveLimits({
905
+ organizationId: options.org,
906
+ storeFile: options.store,
907
+ })
908
+
909
+ if (options.json) {
910
+ process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n')
911
+ return
912
+ }
441
913
 
442
- for (const [month, runs] of monthly) {
443
- process.stdout.write(` - ${month}: ${runs}\n`)
914
+ process.stdout.write(`Organization: ${usage.organizationId}\n`)
915
+ process.stdout.write(`Plan: ${usage.plan}\n`)
916
+ process.stdout.write(`Captured at: ${usage.capturedAt}\n`)
917
+ process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`)
918
+ process.stdout.write(`Repo count: ${usage.repoCount}\n`)
919
+ process.stdout.write(`Runs total: ${usage.runCount}\n`)
920
+ process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`)
921
+ process.stdout.write('Effective limits:\n')
922
+ process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`)
923
+ process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`)
924
+ process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`)
925
+ process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`)
926
+ } catch (error) {
927
+ printSaasErrorAndExit(error)
444
928
  }
445
- process.stdout.write('\n')
446
929
  })
447
930
 
448
931
  cloud