@eduardbar/drift 1.1.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 (66) 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 +153 -0
  4. package/AGENTS.md +6 -0
  5. package/README.md +192 -4
  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 +509 -23
  12. package/dist/diff.js +74 -10
  13. package/dist/git.js +12 -0
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +3 -0
  16. package/dist/map.d.ts +3 -2
  17. package/dist/map.js +98 -10
  18. package/dist/plugins.d.ts +2 -1
  19. package/dist/plugins.js +177 -28
  20. package/dist/printer.js +4 -0
  21. package/dist/review.js +2 -2
  22. package/dist/rules/comments.js +2 -2
  23. package/dist/rules/complexity.js +2 -7
  24. package/dist/rules/nesting.js +3 -13
  25. package/dist/rules/phase0-basic.js +10 -10
  26. package/dist/rules/shared.d.ts +2 -0
  27. package/dist/rules/shared.js +27 -3
  28. package/dist/saas.d.ts +219 -0
  29. package/dist/saas.js +762 -0
  30. package/dist/trust-kpi.d.ts +9 -0
  31. package/dist/trust-kpi.js +445 -0
  32. package/dist/trust.d.ts +65 -0
  33. package/dist/trust.js +571 -0
  34. package/dist/types.d.ts +160 -0
  35. package/docs/PRD.md +199 -172
  36. package/docs/plugin-contract.md +61 -0
  37. package/docs/trust-core-release-checklist.md +55 -0
  38. package/package.json +5 -3
  39. package/packages/vscode-drift/src/code-actions.ts +53 -0
  40. package/packages/vscode-drift/src/extension.ts +11 -0
  41. package/src/analyzer.ts +484 -155
  42. package/src/benchmark.ts +244 -0
  43. package/src/cli.ts +628 -36
  44. package/src/diff.ts +75 -10
  45. package/src/git.ts +16 -0
  46. package/src/index.ts +63 -0
  47. package/src/map.ts +112 -10
  48. package/src/plugins.ts +354 -26
  49. package/src/printer.ts +4 -0
  50. package/src/review.ts +2 -2
  51. package/src/rules/comments.ts +2 -2
  52. package/src/rules/complexity.ts +2 -7
  53. package/src/rules/nesting.ts +3 -13
  54. package/src/rules/phase0-basic.ts +11 -12
  55. package/src/rules/shared.ts +31 -3
  56. package/src/saas.ts +1031 -0
  57. package/src/trust-kpi.ts +518 -0
  58. package/src/trust.ts +774 -0
  59. package/src/types.ts +177 -0
  60. package/tests/diff.test.ts +124 -0
  61. package/tests/new-features.test.ts +98 -0
  62. package/tests/plugins.test.ts +219 -0
  63. package/tests/rules.test.ts +23 -1
  64. package/tests/saas-foundation.test.ts +464 -0
  65. package/tests/trust-kpi.test.ts +120 -0
  66. package/tests/trust.test.ts +584 -0
package/src/cli.ts CHANGED
@@ -1,9 +1,11 @@
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 { 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
+ import { createInterface } from 'node:readline/promises'
8
+ import { stdin as input, stdout as output } from 'node:process'
7
9
  const require = createRequire(import.meta.url)
8
10
  const { version: VERSION } = require('../package.json') as { version: string }
9
11
  import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
@@ -19,28 +21,130 @@ import { applyFixes, type FixResult } from './fix.js'
19
21
  import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
20
22
  import { generateReview } from './review.js'
21
23
  import { generateArchitectureMap } from './map.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'
22
48
 
23
49
  const program = new Command()
24
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
+
25
128
  program
26
129
  .name('drift')
27
- .description('Detect silent technical debt left by AI-generated code')
130
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
28
131
  .version(VERSION)
29
132
 
30
- program
31
- .command('scan [path]', { isDefault: true })
133
+ addResourceOptions(
134
+ program
135
+ .command('scan [path]', { isDefault: true })
32
136
  .description('Scan a directory for vibe coding drift')
33
137
  .option('-o, --output <file>', 'Write report to a Markdown file')
34
138
  .option('--json', 'Output raw JSON report')
35
139
  .option('--ai', 'Output AI-optimized JSON for LLM consumption')
36
140
  .option('--fix', 'Show fix suggestions for each issue')
37
141
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
38
- .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) => {
39
143
  const resolvedPath = resolve(targetPath ?? '.')
40
144
 
41
145
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
42
146
  const config = await loadConfig(resolvedPath)
43
- const files = analyzeProject(resolvedPath, config)
147
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
44
148
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
45
149
  const report = buildReport(resolvedPath, files)
46
150
 
@@ -69,15 +173,18 @@ program
69
173
  if (minScore > 0 && report.totalScore > minScore) {
70
174
  process.exit(1)
71
175
  }
72
- })
176
+ }),
177
+ )
73
178
 
74
- program
75
- .command('diff [ref]')
179
+ addResourceOptions(
180
+ program
181
+ .command('diff [ref]')
76
182
  .description('Compare current state against a git ref (default: HEAD~1)')
77
183
  .option('--json', 'Output raw JSON diff')
78
- .action(async (ref: string | undefined, options: { json?: boolean }) => {
184
+ .action(async (ref: string | undefined, options: { json?: boolean } & ResourceOptionFlags) => {
79
185
  const baseRef = ref ?? 'HEAD~1'
80
186
  const projectPath = resolve('.')
187
+ const analysisOptions = resolveAnalysisOptions(options)
81
188
 
82
189
  let tempDir: string | undefined
83
190
 
@@ -86,12 +193,12 @@ program
86
193
 
87
194
  // Scan current state
88
195
  const config = await loadConfig(projectPath)
89
- const currentFiles = analyzeProject(projectPath, config)
196
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions)
90
197
  const currentReport = buildReport(projectPath, currentFiles)
91
198
 
92
199
  // Extract base state from git
93
200
  tempDir = extractFilesAtRef(projectPath, baseRef)
94
- const baseFiles = analyzeProject(tempDir, config)
201
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions)
95
202
 
96
203
  // Remap base file paths to match current project paths
97
204
  // (temp dir paths → project paths for accurate comparison)
@@ -100,7 +207,7 @@ program
100
207
  ...baseReport,
101
208
  files: baseReport.files.map(f => ({
102
209
  ...f,
103
- path: f.path.replace(tempDir!, projectPath),
210
+ path: resolve(projectPath, relative(tempDir!, f.path)),
104
211
  })),
105
212
  }
106
213
 
@@ -118,7 +225,8 @@ program
118
225
  } finally {
119
226
  if (tempDir) cleanupTempDir(tempDir)
120
227
  }
121
- })
228
+ }),
229
+ )
122
230
 
123
231
  program
124
232
  .command('review')
@@ -148,6 +256,239 @@ program
148
256
  }
149
257
  })
150
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
+
151
492
  program
152
493
  .command('map [path]')
153
494
  .description('Generate architecture.svg with simple layer dependencies')
@@ -155,52 +496,58 @@ program
155
496
  .action(async (targetPath: string | undefined, options: { output: string }) => {
156
497
  const resolvedPath = resolve(targetPath ?? '.')
157
498
  process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
158
- const out = generateArchitectureMap(resolvedPath, options.output)
499
+ const config = await loadConfig(resolvedPath)
500
+ const out = generateArchitectureMap(resolvedPath, options.output, config)
159
501
  process.stderr.write(` Architecture map saved to ${out}\n\n`)
160
502
  })
161
503
 
162
- program
163
- .command('report [path]')
504
+ addResourceOptions(
505
+ program
506
+ .command('report [path]')
164
507
  .description('Generate a self-contained HTML report')
165
508
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
166
- .action(async (targetPath: string | undefined, options: { output: string }) => {
509
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
167
510
  const resolvedPath = resolve(targetPath ?? '.')
168
511
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
169
512
  const config = await loadConfig(resolvedPath)
170
- const files = analyzeProject(resolvedPath, config)
513
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
171
514
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
172
515
  const report = buildReport(resolvedPath, files)
173
516
  const html = generateHtmlReport(report)
174
517
  const outPath = resolve(options.output)
175
518
  writeFileSync(outPath, html, 'utf8')
176
519
  process.stderr.write(` Report saved to ${outPath}\n\n`)
177
- })
520
+ }),
521
+ )
178
522
 
179
- program
180
- .command('badge [path]')
523
+ addResourceOptions(
524
+ program
525
+ .command('badge [path]')
181
526
  .description('Generate a badge.svg with the current drift score')
182
527
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
183
- .action(async (targetPath: string | undefined, options: { output: string }) => {
528
+ .action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
184
529
  const resolvedPath = resolve(targetPath ?? '.')
185
530
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
186
531
  const config = await loadConfig(resolvedPath)
187
- const files = analyzeProject(resolvedPath, config)
532
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
188
533
  const report = buildReport(resolvedPath, files)
189
534
  const svg = generateBadge(report.totalScore)
190
535
  const outPath = resolve(options.output)
191
536
  writeFileSync(outPath, svg, 'utf8')
192
537
  process.stderr.write(` Badge saved to ${outPath}\n`)
193
538
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
194
- })
539
+ }),
540
+ )
195
541
 
196
- program
197
- .command('ci [path]')
542
+ addResourceOptions(
543
+ program
544
+ .command('ci [path]')
198
545
  .description('Emit GitHub Actions annotations and step summary')
199
546
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
200
- .action(async (targetPath: string | undefined, options: { minScore: string }) => {
547
+ .action(async (targetPath: string | undefined, options: { minScore: string } & ResourceOptionFlags) => {
201
548
  const resolvedPath = resolve(targetPath ?? '.')
202
549
  const config = await loadConfig(resolvedPath)
203
- const files = analyzeProject(resolvedPath, config)
550
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
204
551
  const report = buildReport(resolvedPath, files)
205
552
  emitCIAnnotations(report)
206
553
  printCISummary(report)
@@ -208,7 +555,8 @@ program
208
555
  if (minScore > 0 && report.totalScore > minScore) {
209
556
  process.exit(1)
210
557
  }
211
- })
558
+ }),
559
+ )
212
560
 
213
561
  program
214
562
  .command('trend [period]')
@@ -259,12 +607,38 @@ program
259
607
  .option('--preview', 'Preview changes without writing files')
260
608
  .option('--write', 'Write fixes to disk')
261
609
  .option('--dry-run', 'Show what would change without writing files')
262
- .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean }) => {
610
+ .option('-y, --yes', 'Skip interactive confirmation for --write')
611
+ .action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean; yes?: boolean }) => {
263
612
  const resolvedPath = resolve(targetPath ?? '.')
264
613
  const config = await loadConfig(resolvedPath)
265
614
  const previewMode = Boolean(options.preview || options.dryRun)
266
615
  const writeMode = options.write ?? !previewMode
267
616
 
617
+ if (writeMode && !options.yes) {
618
+ const previewResults = await applyFixes(resolvedPath, config, {
619
+ rule: options.rule,
620
+ dryRun: true,
621
+ preview: true,
622
+ write: false,
623
+ })
624
+
625
+ if (previewResults.length === 0) {
626
+ console.log('No fixable issues found.')
627
+ return
628
+ }
629
+
630
+ const files = new Set(previewResults.map((result) => result.file)).size
631
+ const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `
632
+ const rl = createInterface({ input, output })
633
+ const answer = (await rl.question(prompt)).trim().toLowerCase()
634
+ rl.close()
635
+
636
+ if (answer !== 'y' && answer !== 'yes') {
637
+ console.log('Aborted. No files were modified.')
638
+ return
639
+ }
640
+ }
641
+
268
642
  const results = await applyFixes(resolvedPath, config, {
269
643
  rule: options.rule,
270
644
  dryRun: previewMode,
@@ -310,15 +684,16 @@ program
310
684
  }
311
685
  })
312
686
 
313
- program
314
- .command('snapshot [path]')
687
+ addResourceOptions(
688
+ program
689
+ .command('snapshot [path]')
315
690
  .description('Record a score snapshot to drift-history.json')
316
691
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
317
692
  .option('--history', 'show all recorded snapshots')
318
693
  .option('--diff', 'compare current score vs last snapshot')
319
694
  .action(async (
320
695
  targetPath: string | undefined,
321
- opts: { label?: string; history?: boolean; diff?: boolean },
696
+ opts: { label?: string; history?: boolean; diff?: boolean } & ResourceOptionFlags,
322
697
  ) => {
323
698
  const resolvedPath = resolve(targetPath ?? '.')
324
699
 
@@ -330,7 +705,7 @@ program
330
705
 
331
706
  process.stderr.write(`\nScanning ${resolvedPath}...\n`)
332
707
  const config = await loadConfig(resolvedPath)
333
- const files = analyzeProject(resolvedPath, config)
708
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts))
334
709
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
335
710
  const report = buildReport(resolvedPath, files)
336
711
 
@@ -346,6 +721,223 @@ program
346
721
  ` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
347
722
  )
348
723
  process.stdout.write(` Saved to drift-history.json\n\n`)
724
+ }),
725
+ )
726
+
727
+ const cloud = program
728
+ .command('cloud')
729
+ .description('Local SaaS foundations: ingest, summary, and dashboard')
730
+
731
+ addResourceOptions(
732
+ cloud
733
+ .command('ingest [path]')
734
+ .description('Scan path, build report, and store cloud snapshot')
735
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
736
+ .requiredOption('--workspace <id>', 'Workspace id')
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)')
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)')
742
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
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
+ })
762
+
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
+ )
772
+
773
+ cloud
774
+ .command('summary')
775
+ .description('Show SaaS usage metrics and free threshold status')
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)')
780
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
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
+ })
789
+
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)
818
+ }
819
+ })
820
+
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)
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
+ }
913
+
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)
928
+ }
929
+ })
930
+
931
+ cloud
932
+ .command('dashboard')
933
+ .description('Generate an HTML dashboard with trends and hotspots')
934
+ .option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
935
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
936
+ .action((options: { output: string; store?: string }) => {
937
+ const html = generateSaasDashboardHtml({ storeFile: options.store })
938
+ const outPath = resolve(options.output)
939
+ writeFileSync(outPath, html, 'utf8')
940
+ process.stdout.write(`Dashboard saved to ${outPath}\n`)
349
941
  })
350
942
 
351
943
  program.parse()