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