@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/dist/cli.js 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');
9
11
  import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js';
@@ -19,12 +21,77 @@ import { applyFixes } 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 { 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';
22
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
+ }
23
90
  program
24
91
  .name('drift')
25
- .description('Detect silent technical debt left by AI-generated code')
92
+ .description('AI Code Audit CLI for merge trust in AI-assisted PRs')
26
93
  .version(VERSION);
27
- program
94
+ addResourceOptions(program
28
95
  .command('scan [path]', { isDefault: true })
29
96
  .description('Scan a directory for vibe coding drift')
30
97
  .option('-o, --output <file>', 'Write report to a Markdown file')
@@ -36,7 +103,7 @@ program
36
103
  const resolvedPath = resolve(targetPath ?? '.');
37
104
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
38
105
  const config = await loadConfig(resolvedPath);
39
- const files = analyzeProject(resolvedPath, config);
106
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
40
107
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
41
108
  const report = buildReport(resolvedPath, files);
42
109
  if (options.ai) {
@@ -60,24 +127,25 @@ program
60
127
  if (minScore > 0 && report.totalScore > minScore) {
61
128
  process.exit(1);
62
129
  }
63
- });
64
- program
130
+ }));
131
+ addResourceOptions(program
65
132
  .command('diff [ref]')
66
133
  .description('Compare current state against a git ref (default: HEAD~1)')
67
134
  .option('--json', 'Output raw JSON diff')
68
135
  .action(async (ref, options) => {
69
136
  const baseRef = ref ?? 'HEAD~1';
70
137
  const projectPath = resolve('.');
138
+ const analysisOptions = resolveAnalysisOptions(options);
71
139
  let tempDir;
72
140
  try {
73
141
  process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`);
74
142
  // Scan current state
75
143
  const config = await loadConfig(projectPath);
76
- const currentFiles = analyzeProject(projectPath, config);
144
+ const currentFiles = analyzeProject(projectPath, config, analysisOptions);
77
145
  const currentReport = buildReport(projectPath, currentFiles);
78
146
  // Extract base state from git
79
147
  tempDir = extractFilesAtRef(projectPath, baseRef);
80
- const baseFiles = analyzeProject(tempDir, config);
148
+ const baseFiles = analyzeProject(tempDir, config, analysisOptions);
81
149
  // Remap base file paths to match current project paths
82
150
  // (temp dir paths → project paths for accurate comparison)
83
151
  const baseReport = buildReport(tempDir, baseFiles);
@@ -85,7 +153,7 @@ program
85
153
  ...baseReport,
86
154
  files: baseReport.files.map(f => ({
87
155
  ...f,
88
- path: f.path.replace(tempDir, projectPath),
156
+ path: resolve(projectPath, relative(tempDir, f.path)),
89
157
  })),
90
158
  };
91
159
  const diff = computeDiff(remappedBase, currentReport, baseRef);
@@ -105,7 +173,7 @@ program
105
173
  if (tempDir)
106
174
  cleanupTempDir(tempDir);
107
175
  }
108
- });
176
+ }));
109
177
  program
110
178
  .command('review')
111
179
  .description('Review drift against a base ref and output PR markdown')
@@ -133,6 +201,201 @@ program
133
201
  process.exit(1);
134
202
  }
135
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
+ });
136
399
  program
137
400
  .command('map [path]')
138
401
  .description('Generate architecture.svg with simple layer dependencies')
@@ -140,10 +403,11 @@ program
140
403
  .action(async (targetPath, options) => {
141
404
  const resolvedPath = resolve(targetPath ?? '.');
142
405
  process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`);
143
- const out = generateArchitectureMap(resolvedPath, options.output);
406
+ const config = await loadConfig(resolvedPath);
407
+ const out = generateArchitectureMap(resolvedPath, options.output, config);
144
408
  process.stderr.write(` Architecture map saved to ${out}\n\n`);
145
409
  });
146
- program
410
+ addResourceOptions(program
147
411
  .command('report [path]')
148
412
  .description('Generate a self-contained HTML report')
149
413
  .option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
@@ -151,15 +415,15 @@ program
151
415
  const resolvedPath = resolve(targetPath ?? '.');
152
416
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
153
417
  const config = await loadConfig(resolvedPath);
154
- const files = analyzeProject(resolvedPath, config);
418
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
155
419
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
156
420
  const report = buildReport(resolvedPath, files);
157
421
  const html = generateHtmlReport(report);
158
422
  const outPath = resolve(options.output);
159
423
  writeFileSync(outPath, html, 'utf8');
160
424
  process.stderr.write(` Report saved to ${outPath}\n\n`);
161
- });
162
- program
425
+ }));
426
+ addResourceOptions(program
163
427
  .command('badge [path]')
164
428
  .description('Generate a badge.svg with the current drift score')
165
429
  .option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
@@ -167,22 +431,22 @@ program
167
431
  const resolvedPath = resolve(targetPath ?? '.');
168
432
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
169
433
  const config = await loadConfig(resolvedPath);
170
- const files = analyzeProject(resolvedPath, config);
434
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
171
435
  const report = buildReport(resolvedPath, files);
172
436
  const svg = generateBadge(report.totalScore);
173
437
  const outPath = resolve(options.output);
174
438
  writeFileSync(outPath, svg, 'utf8');
175
439
  process.stderr.write(` Badge saved to ${outPath}\n`);
176
440
  process.stderr.write(` Score: ${report.totalScore}/100\n\n`);
177
- });
178
- program
441
+ }));
442
+ addResourceOptions(program
179
443
  .command('ci [path]')
180
444
  .description('Emit GitHub Actions annotations and step summary')
181
445
  .option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
182
446
  .action(async (targetPath, options) => {
183
447
  const resolvedPath = resolve(targetPath ?? '.');
184
448
  const config = await loadConfig(resolvedPath);
185
- const files = analyzeProject(resolvedPath, config);
449
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
186
450
  const report = buildReport(resolvedPath, files);
187
451
  emitCIAnnotations(report);
188
452
  printCISummary(report);
@@ -190,7 +454,7 @@ program
190
454
  if (minScore > 0 && report.totalScore > minScore) {
191
455
  process.exit(1);
192
456
  }
193
- });
457
+ }));
194
458
  program
195
459
  .command('trend [period]')
196
460
  .description('Analyze trend of technical debt over time')
@@ -232,11 +496,33 @@ program
232
496
  .option('--preview', 'Preview changes without writing files')
233
497
  .option('--write', 'Write fixes to disk')
234
498
  .option('--dry-run', 'Show what would change without writing files')
499
+ .option('-y, --yes', 'Skip interactive confirmation for --write')
235
500
  .action(async (targetPath, options) => {
236
501
  const resolvedPath = resolve(targetPath ?? '.');
237
502
  const config = await loadConfig(resolvedPath);
238
503
  const previewMode = Boolean(options.preview || options.dryRun);
239
504
  const writeMode = options.write ?? !previewMode;
505
+ if (writeMode && !options.yes) {
506
+ const previewResults = await applyFixes(resolvedPath, config, {
507
+ rule: options.rule,
508
+ dryRun: true,
509
+ preview: true,
510
+ write: false,
511
+ });
512
+ if (previewResults.length === 0) {
513
+ console.log('No fixable issues found.');
514
+ return;
515
+ }
516
+ const files = new Set(previewResults.map((result) => result.file)).size;
517
+ const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `;
518
+ const rl = createInterface({ input, output });
519
+ const answer = (await rl.question(prompt)).trim().toLowerCase();
520
+ rl.close();
521
+ if (answer !== 'y' && answer !== 'yes') {
522
+ console.log('Aborted. No files were modified.');
523
+ return;
524
+ }
525
+ }
240
526
  const results = await applyFixes(resolvedPath, config, {
241
527
  rule: options.rule,
242
528
  dryRun: previewMode,
@@ -277,7 +563,7 @@ program
277
563
  console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
278
564
  }
279
565
  });
280
- program
566
+ addResourceOptions(program
281
567
  .command('snapshot [path]')
282
568
  .description('Record a score snapshot to drift-history.json')
283
569
  .option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
@@ -292,7 +578,7 @@ program
292
578
  }
293
579
  process.stderr.write(`\nScanning ${resolvedPath}...\n`);
294
580
  const config = await loadConfig(resolvedPath);
295
- const files = analyzeProject(resolvedPath, config);
581
+ const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts));
296
582
  process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
297
583
  const report = buildReport(resolvedPath, files);
298
584
  if (opts.diff) {
@@ -304,6 +590,206 @@ program
304
590
  const labelStr = entry.label ? ` [${entry.label}]` : '';
305
591
  process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
306
592
  process.stdout.write(` Saved to drift-history.json\n\n`);
593
+ }));
594
+ const cloud = program
595
+ .command('cloud')
596
+ .description('Local SaaS foundations: ingest, summary, and dashboard');
597
+ addResourceOptions(cloud
598
+ .command('ingest [path]')
599
+ .description('Scan path, build report, and store cloud snapshot')
600
+ .option('--org <id>', 'Organization id (default: default-org)', 'default-org')
601
+ .requiredOption('--workspace <id>', 'Workspace id')
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)')
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)')
607
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
608
+ .action(async (targetPath, options) => {
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
+ }));
635
+ cloud
636
+ .command('summary')
637
+ .description('Show SaaS usage metrics and free threshold status')
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)')
642
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
643
+ .action((options) => {
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');
674
+ }
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`);
778
+ }
779
+ catch (error) {
780
+ printSaasErrorAndExit(error);
781
+ }
782
+ });
783
+ cloud
784
+ .command('dashboard')
785
+ .description('Generate an HTML dashboard with trends and hotspots')
786
+ .option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
787
+ .option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
788
+ .action((options) => {
789
+ const html = generateSaasDashboardHtml({ storeFile: options.store });
790
+ const outPath = resolve(options.output);
791
+ writeFileSync(outPath, html, 'utf8');
792
+ process.stdout.write(`Dashboard saved to ${outPath}\n`);
307
793
  });
308
794
  program.parse();
309
795
  //# sourceMappingURL=cli.js.map