@eduardbar/drift 1.2.0 → 1.4.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/.gga +50 -0
- package/.github/actions/drift-review/README.md +60 -0
- package/.github/actions/drift-review/action.yml +131 -0
- package/.github/actions/drift-scan/README.md +28 -32
- package/.github/actions/drift-scan/action.yml +78 -14
- package/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +94 -9
- package/AGENTS.md +75 -245
- package/CHANGELOG.md +28 -0
- package/README.md +308 -51
- 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 +204 -0
- package/dist/cli.js +693 -67
- package/dist/config.js +16 -2
- package/dist/diff.js +66 -10
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +133 -0
- package/dist/format.d.ts +17 -0
- package/dist/format.js +45 -0
- package/dist/git.js +12 -0
- package/dist/guard-types.d.ts +57 -0
- package/dist/guard-types.js +2 -0
- package/dist/guard.d.ts +14 -0
- package/dist/guard.js +239 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -1
- package/dist/init.d.ts +15 -0
- package/dist/init.js +273 -0
- package/dist/map-cycles.d.ts +2 -0
- package/dist/map-cycles.js +34 -0
- package/dist/map-svg.d.ts +19 -0
- package/dist/map-svg.js +97 -0
- package/dist/map.js +78 -138
- package/dist/metrics.js +70 -55
- package/dist/output-metadata.d.ts +13 -0
- package/dist/output-metadata.js +17 -0
- package/dist/plugins-capabilities.d.ts +4 -0
- package/dist/plugins-capabilities.js +21 -0
- package/dist/plugins-messages.d.ts +10 -0
- package/dist/plugins-messages.js +16 -0
- package/dist/plugins-rules.d.ts +9 -0
- package/dist/plugins-rules.js +137 -0
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +80 -28
- package/dist/printer.js +4 -0
- package/dist/reporter-constants.d.ts +16 -0
- package/dist/reporter-constants.js +39 -0
- package/dist/reporter.d.ts +3 -3
- package/dist/reporter.js +35 -55
- package/dist/review.d.ts +2 -1
- package/dist/review.js +4 -3
- 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/phase3-configurable.js +23 -15
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas/constants.d.ts +15 -0
- package/dist/saas/constants.js +48 -0
- package/dist/saas/dashboard.d.ts +8 -0
- package/dist/saas/dashboard.js +132 -0
- package/dist/saas/errors.d.ts +19 -0
- package/dist/saas/errors.js +37 -0
- package/dist/saas/helpers.d.ts +21 -0
- package/dist/saas/helpers.js +110 -0
- package/dist/saas/ingest.d.ts +3 -0
- package/dist/saas/ingest.js +249 -0
- package/dist/saas/organization.d.ts +5 -0
- package/dist/saas/organization.js +82 -0
- package/dist/saas/plan-change.d.ts +10 -0
- package/dist/saas/plan-change.js +15 -0
- package/dist/saas/store.d.ts +21 -0
- package/dist/saas/store.js +159 -0
- package/dist/saas/types.d.ts +191 -0
- package/dist/saas/types.js +2 -0
- package/dist/saas.d.ts +8 -82
- package/dist/saas.js +7 -320
- package/dist/sarif.d.ts +74 -0
- package/dist/sarif.js +122 -0
- package/dist/trust-advanced.d.ts +14 -0
- package/dist/trust-advanced.js +65 -0
- package/dist/trust-kpi-fs.d.ts +3 -0
- package/dist/trust-kpi-fs.js +141 -0
- package/dist/trust-kpi-parse.d.ts +7 -0
- package/dist/trust-kpi-parse.js +186 -0
- package/dist/trust-kpi-types.d.ts +16 -0
- package/dist/trust-kpi-types.js +2 -0
- package/dist/trust-kpi.d.ts +7 -0
- package/dist/trust-kpi.js +185 -0
- package/dist/trust-policy.d.ts +32 -0
- package/dist/trust-policy.js +160 -0
- package/dist/trust-render.d.ts +9 -0
- package/dist/trust-render.js +54 -0
- package/dist/trust-scoring.d.ts +9 -0
- package/dist/trust-scoring.js +208 -0
- package/dist/trust.d.ts +37 -0
- package/dist/trust.js +168 -0
- package/dist/types/app.d.ts +30 -0
- package/dist/types/app.js +2 -0
- package/dist/types/config.d.ts +25 -0
- package/dist/types/config.js +2 -0
- package/dist/types/core.d.ts +100 -0
- package/dist/types/core.js +2 -0
- package/dist/types/diff.d.ts +55 -0
- package/dist/types/diff.js +2 -0
- package/dist/types/plugin.d.ts +41 -0
- package/dist/types/plugin.js +2 -0
- package/dist/types/trust.d.ts +120 -0
- package/dist/types/trust.js +2 -0
- package/dist/types.d.ts +8 -211
- package/docs/PRD.md +187 -109
- package/docs/plugin-contract.md +61 -0
- package/docs/release-notes-draft.md +40 -0
- package/docs/rules-catalog.md +49 -0
- package/docs/trust-core-release-checklist.md +87 -0
- package/package.json +6 -3
- package/packages/vscode-drift/src/code-actions.ts +1 -1
- package/schemas/drift-ai-output.v1.json +162 -0
- package/schemas/drift-report.v1.json +151 -0
- package/schemas/drift-trust.v1.json +131 -0
- package/scripts/smoke-repo.mjs +394 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +266 -0
- package/src/cli.ts +840 -85
- package/src/config.ts +19 -2
- package/src/diff.ts +84 -10
- package/src/doctor.ts +173 -0
- package/src/format.ts +81 -0
- package/src/git.ts +16 -0
- package/src/guard-types.ts +64 -0
- package/src/guard.ts +324 -0
- package/src/index.ts +83 -0
- package/src/init.ts +298 -0
- package/src/map-cycles.ts +38 -0
- package/src/map-svg.ts +124 -0
- package/src/map.ts +111 -142
- package/src/metrics.ts +78 -59
- package/src/output-metadata.ts +30 -0
- package/src/plugins-capabilities.ts +36 -0
- package/src/plugins-messages.ts +35 -0
- package/src/plugins-rules.ts +296 -0
- package/src/plugins.ts +148 -27
- package/src/printer.ts +4 -0
- package/src/reporter-constants.ts +46 -0
- package/src/reporter.ts +64 -65
- package/src/review.ts +6 -4
- 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/phase3-configurable.ts +39 -26
- package/src/rules/shared.ts +31 -3
- package/src/saas/constants.ts +56 -0
- package/src/saas/dashboard.ts +172 -0
- package/src/saas/errors.ts +45 -0
- package/src/saas/helpers.ts +140 -0
- package/src/saas/ingest.ts +278 -0
- package/src/saas/organization.ts +99 -0
- package/src/saas/plan-change.ts +19 -0
- package/src/saas/store.ts +172 -0
- package/src/saas/types.ts +216 -0
- package/src/saas.ts +49 -433
- package/src/sarif.ts +232 -0
- package/src/trust-advanced.ts +99 -0
- package/src/trust-kpi-fs.ts +169 -0
- package/src/trust-kpi-parse.ts +219 -0
- package/src/trust-kpi-types.ts +19 -0
- package/src/trust-kpi.ts +210 -0
- package/src/trust-policy.ts +246 -0
- package/src/trust-render.ts +61 -0
- package/src/trust-scoring.ts +231 -0
- package/src/trust.ts +260 -0
- package/src/types/app.ts +30 -0
- package/src/types/config.ts +27 -0
- package/src/types/core.ts +105 -0
- package/src/types/diff.ts +61 -0
- package/src/types/plugin.ts +46 -0
- package/src/types/trust.ts +134 -0
- package/src/types.ts +78 -238
- package/tests/cli-sarif.test.ts +92 -0
- package/tests/diff.test.ts +124 -0
- package/tests/format.test.ts +157 -0
- package/tests/new-features.test.ts +80 -1
- package/tests/phase1-init-doctor-guard.test.ts +199 -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/sarif.test.ts +160 -0
- package/tests/trust-kpi.test.ts +147 -0
- package/tests/trust.test.ts +602 -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';
|
|
@@ -14,6 +14,7 @@ import { printConsole, printDiff } from './printer.js';
|
|
|
14
14
|
import { loadConfig } from './config.js';
|
|
15
15
|
import { extractFilesAtRef, cleanupTempDir } from './git.js';
|
|
16
16
|
import { computeDiff } from './diff.js';
|
|
17
|
+
import { runGuard } from './guard.js';
|
|
17
18
|
import { generateHtmlReport } from './report.js';
|
|
18
19
|
import { generateBadge } from './badge.js';
|
|
19
20
|
import { emitCIAnnotations, printCISummary } from './ci.js';
|
|
@@ -21,16 +22,157 @@ import { applyFixes } from './fix.js';
|
|
|
21
22
|
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js';
|
|
22
23
|
import { generateReview } from './review.js';
|
|
23
24
|
import { generateArchitectureMap } from './map.js';
|
|
24
|
-
import {
|
|
25
|
+
import { changeOrganizationPlan, generateSaasDashboardHtml, getOrganizationEffectiveLimits, getOrganizationUsageSnapshot, getSaasSummary, ingestSnapshotFromReport, listOrganizationPlanChanges, } from './saas.js';
|
|
26
|
+
import { buildTrustReport, explainTrustGatePolicy, formatTrustGatePolicyExplanation, formatTrustJson, renderTrustOutput, shouldFailTrustGate, normalizeMergeRiskLevel, MERGE_RISK_ORDER, detectBranchName, } from './trust.js';
|
|
27
|
+
import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js';
|
|
28
|
+
import { runBenchmarkCli } from './benchmark.js';
|
|
29
|
+
import { runInit, INIT_PRESETS } from './init.js';
|
|
30
|
+
import { runDoctor } from './doctor.js';
|
|
31
|
+
import { resolveOutputFormat } from './format.js';
|
|
32
|
+
import { toSarif, diffToSarif } from './sarif.js';
|
|
25
33
|
const program = new Command();
|
|
34
|
+
function parseOptionalPositiveInt(rawValue, flagName) {
|
|
35
|
+
if (rawValue == null)
|
|
36
|
+
return undefined;
|
|
37
|
+
const value = Number(rawValue);
|
|
38
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
39
|
+
throw new Error(`${flagName} must be a non-negative integer`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function resolveAnalysisOptions(options) {
|
|
44
|
+
return {
|
|
45
|
+
lowMemory: options.lowMemory,
|
|
46
|
+
chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
|
|
47
|
+
maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
|
|
48
|
+
maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
|
|
49
|
+
includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function addResourceOptions(command) {
|
|
53
|
+
return command
|
|
54
|
+
.option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
|
|
55
|
+
.option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
|
|
56
|
+
.option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
|
|
57
|
+
.option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
|
|
58
|
+
.option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode');
|
|
59
|
+
}
|
|
60
|
+
function parseOptionalNumber(rawValue, flagName) {
|
|
61
|
+
if (rawValue == null)
|
|
62
|
+
return undefined;
|
|
63
|
+
const value = Number(rawValue);
|
|
64
|
+
if (!Number.isFinite(value)) {
|
|
65
|
+
throw new Error(`${flagName} must be a valid number`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function parseBySeverity(rawValue) {
|
|
70
|
+
if (rawValue == null)
|
|
71
|
+
return undefined;
|
|
72
|
+
const spec = rawValue.trim();
|
|
73
|
+
if (!spec) {
|
|
74
|
+
throw new Error('--by-severity must not be empty. Expected format: error=0,warning=2,info=5');
|
|
75
|
+
}
|
|
76
|
+
const thresholds = {};
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const segment of spec.split(',')) {
|
|
79
|
+
const pair = segment.trim();
|
|
80
|
+
if (!pair)
|
|
81
|
+
continue;
|
|
82
|
+
const equalIndex = pair.indexOf('=');
|
|
83
|
+
if (equalIndex <= 0 || equalIndex === pair.length - 1) {
|
|
84
|
+
throw new Error(`Invalid --by-severity entry '${pair}'. Expected key=value (e.g. warning=2).`);
|
|
85
|
+
}
|
|
86
|
+
const key = pair.slice(0, equalIndex).trim().toLowerCase();
|
|
87
|
+
const rawThreshold = pair.slice(equalIndex + 1).trim();
|
|
88
|
+
if (key !== 'error' && key !== 'warning' && key !== 'info') {
|
|
89
|
+
throw new Error(`Invalid --by-severity key '${key}'. Allowed keys: error, warning, info.`);
|
|
90
|
+
}
|
|
91
|
+
if (seen.has(key)) {
|
|
92
|
+
throw new Error(`Duplicate --by-severity key '${key}'.`);
|
|
93
|
+
}
|
|
94
|
+
const threshold = Number(rawThreshold);
|
|
95
|
+
if (!Number.isFinite(threshold)) {
|
|
96
|
+
throw new Error(`Invalid --by-severity value for '${key}': '${rawThreshold}'. Must be a valid number.`);
|
|
97
|
+
}
|
|
98
|
+
const severityKey = key;
|
|
99
|
+
thresholds[severityKey] = threshold;
|
|
100
|
+
seen.add(severityKey);
|
|
101
|
+
}
|
|
102
|
+
if (seen.size === 0) {
|
|
103
|
+
throw new Error('--by-severity must include at least one threshold. Example: error=0,warning=2');
|
|
104
|
+
}
|
|
105
|
+
return thresholds;
|
|
106
|
+
}
|
|
107
|
+
function formatSigned(value) {
|
|
108
|
+
return value > 0 ? `+${value}` : `${value}`;
|
|
109
|
+
}
|
|
110
|
+
function printGuardSummary(result) {
|
|
111
|
+
const modeLabel = result.mode === 'diff' ? `diff (${result.baseRef ?? 'unknown base'})` : 'baseline';
|
|
112
|
+
const statusLabel = result.passed ? 'PASS' : 'FAIL';
|
|
113
|
+
process.stdout.write('\n');
|
|
114
|
+
process.stdout.write(`Guard mode: ${modeLabel}\n`);
|
|
115
|
+
process.stdout.write(`Result: ${statusLabel}\n`);
|
|
116
|
+
process.stdout.write(`Score delta: ${formatSigned(result.metrics.scoreDelta)}\n`);
|
|
117
|
+
process.stdout.write(`Total issues delta: ${formatSigned(result.metrics.totalIssuesDelta)}\n`);
|
|
118
|
+
process.stdout.write(`Severity delta: error=${formatSigned(result.metrics.severityDelta.error)}, warning=${formatSigned(result.metrics.severityDelta.warning)}, info=${formatSigned(result.metrics.severityDelta.info)}\n`);
|
|
119
|
+
if (result.mode === 'baseline' && result.baselinePath) {
|
|
120
|
+
process.stdout.write(`Baseline file: ${result.baselinePath}\n`);
|
|
121
|
+
}
|
|
122
|
+
if (result.checks.length === 0) {
|
|
123
|
+
process.stdout.write('Checks: none configured\n');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write('Checks:\n');
|
|
127
|
+
for (const check of result.checks) {
|
|
128
|
+
process.stdout.write(` - [${check.passed ? 'PASS' : 'FAIL'}] ${check.id}: ${check.message} (actual=${check.actual}, limit=${check.limit})\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function parseTrustGateOverrides(options) {
|
|
132
|
+
const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined;
|
|
133
|
+
if (options.minTrust && Number.isNaN(cliMinTrust)) {
|
|
134
|
+
process.stderr.write('\n Error: --min-trust must be a valid number\n\n');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
let cliMaxRisk;
|
|
138
|
+
if (options.maxRisk) {
|
|
139
|
+
cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk);
|
|
140
|
+
if (!cliMaxRisk) {
|
|
141
|
+
process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
|
|
147
|
+
maxRisk: cliMaxRisk,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function resolveBranchFromOption(branch) {
|
|
151
|
+
const normalized = branch?.trim();
|
|
152
|
+
if (normalized)
|
|
153
|
+
return normalized;
|
|
154
|
+
return detectBranchName();
|
|
155
|
+
}
|
|
156
|
+
function printTrustGatePolicyDebug(explanation) {
|
|
157
|
+
process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`);
|
|
158
|
+
if (explanation.invalidPolicyPack) {
|
|
159
|
+
process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function printSaasErrorAndExit(error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
26
167
|
program
|
|
27
168
|
.name('drift')
|
|
28
|
-
.description('
|
|
169
|
+
.description('AI Code Audit CLI for merge trust in AI-assisted PRs')
|
|
29
170
|
.version(VERSION);
|
|
30
|
-
program
|
|
171
|
+
addResourceOptions(program
|
|
31
172
|
.command('scan [path]', { isDefault: true })
|
|
32
173
|
.description('Scan a directory for vibe coding drift')
|
|
33
174
|
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
175
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
34
176
|
.option('--json', 'Output raw JSON report')
|
|
35
177
|
.option('--ai', 'Output AI-optimized JSON for LLM consumption')
|
|
36
178
|
.option('--fix', 'Show fix suggestions for each issue')
|
|
@@ -39,18 +181,36 @@ program
|
|
|
39
181
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
40
182
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
41
183
|
const config = await loadConfig(resolvedPath);
|
|
42
|
-
const files = analyzeProject(resolvedPath, config);
|
|
184
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
|
|
43
185
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
44
186
|
const report = buildReport(resolvedPath, files);
|
|
45
|
-
|
|
187
|
+
const format = resolveOutputFormat({
|
|
188
|
+
command: 'scan',
|
|
189
|
+
format: options.format,
|
|
190
|
+
supported: ['console', 'json', 'markdown', 'ai', 'sarif'],
|
|
191
|
+
legacyAliases: [
|
|
192
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
193
|
+
{ flag: 'ai', used: options.ai, mapsTo: 'ai' },
|
|
194
|
+
],
|
|
195
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
196
|
+
});
|
|
197
|
+
if (format === 'sarif') {
|
|
198
|
+
process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (format === 'ai') {
|
|
46
202
|
const aiOutput = formatAIOutput(report);
|
|
47
203
|
process.stdout.write(JSON.stringify(aiOutput, null, 2));
|
|
48
204
|
return;
|
|
49
205
|
}
|
|
50
|
-
if (
|
|
206
|
+
if (format === 'json') {
|
|
51
207
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
52
208
|
return;
|
|
53
209
|
}
|
|
210
|
+
if (format === 'markdown') {
|
|
211
|
+
process.stdout.write(`${formatMarkdown(report)}\n`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
54
214
|
printConsole(report, { showFix: options.fix });
|
|
55
215
|
if (options.output) {
|
|
56
216
|
const md = formatMarkdown(report);
|
|
@@ -63,24 +223,54 @@ program
|
|
|
63
223
|
if (minScore > 0 && report.totalScore > minScore) {
|
|
64
224
|
process.exit(1);
|
|
65
225
|
}
|
|
66
|
-
});
|
|
226
|
+
}));
|
|
67
227
|
program
|
|
228
|
+
.command('init')
|
|
229
|
+
.description('Initialize drift configuration with presets and scaffolding')
|
|
230
|
+
.option('--preset <type>', `Scaffold config with preset: ${INIT_PRESETS.join(', ')}`)
|
|
231
|
+
.option('--ci', 'Generate GitHub Actions workflow for drift review')
|
|
232
|
+
.option('--baseline', 'Create drift-baseline.json with current project score')
|
|
233
|
+
.action(async (options) => {
|
|
234
|
+
const projectRoot = resolve('.');
|
|
235
|
+
try {
|
|
236
|
+
await runInit(projectRoot, {
|
|
237
|
+
preset: options.preset,
|
|
238
|
+
ci: options.ci,
|
|
239
|
+
baseline: options.baseline,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
244
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
addResourceOptions(program
|
|
68
249
|
.command('diff [ref]')
|
|
69
250
|
.description('Compare current state against a git ref (default: HEAD~1)')
|
|
251
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
70
252
|
.option('--json', 'Output raw JSON diff')
|
|
71
253
|
.action(async (ref, options) => {
|
|
72
254
|
const baseRef = ref ?? 'HEAD~1';
|
|
73
255
|
const projectPath = resolve('.');
|
|
256
|
+
const analysisOptions = resolveAnalysisOptions(options);
|
|
74
257
|
let tempDir;
|
|
75
258
|
try {
|
|
76
259
|
process.stderr.write(`\nComputing diff: HEAD vs ${baseRef}...\n\n`);
|
|
260
|
+
const format = resolveOutputFormat({
|
|
261
|
+
command: 'diff',
|
|
262
|
+
format: options.format,
|
|
263
|
+
supported: ['console', 'json', 'sarif'],
|
|
264
|
+
legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
|
|
265
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
266
|
+
});
|
|
77
267
|
// Scan current state
|
|
78
268
|
const config = await loadConfig(projectPath);
|
|
79
|
-
const currentFiles = analyzeProject(projectPath, config);
|
|
269
|
+
const currentFiles = analyzeProject(projectPath, config, analysisOptions);
|
|
80
270
|
const currentReport = buildReport(projectPath, currentFiles);
|
|
81
271
|
// Extract base state from git
|
|
82
272
|
tempDir = extractFilesAtRef(projectPath, baseRef);
|
|
83
|
-
const baseFiles = analyzeProject(tempDir, config);
|
|
273
|
+
const baseFiles = analyzeProject(tempDir, config, analysisOptions);
|
|
84
274
|
// Remap base file paths to match current project paths
|
|
85
275
|
// (temp dir paths → project paths for accurate comparison)
|
|
86
276
|
const baseReport = buildReport(tempDir, baseFiles);
|
|
@@ -88,11 +278,14 @@ program
|
|
|
88
278
|
...baseReport,
|
|
89
279
|
files: baseReport.files.map(f => ({
|
|
90
280
|
...f,
|
|
91
|
-
path:
|
|
281
|
+
path: resolve(projectPath, relative(tempDir, f.path)),
|
|
92
282
|
})),
|
|
93
283
|
};
|
|
94
284
|
const diff = computeDiff(remappedBase, currentReport, baseRef);
|
|
95
|
-
if (
|
|
285
|
+
if (format === 'sarif') {
|
|
286
|
+
process.stdout.write(`${JSON.stringify(diffToSarif(diff), null, 2)}\n`);
|
|
287
|
+
}
|
|
288
|
+
else if (format === 'json') {
|
|
96
289
|
process.stdout.write(JSON.stringify(diff, null, 2) + '\n');
|
|
97
290
|
}
|
|
98
291
|
else {
|
|
@@ -108,22 +301,82 @@ program
|
|
|
108
301
|
if (tempDir)
|
|
109
302
|
cleanupTempDir(tempDir);
|
|
110
303
|
}
|
|
304
|
+
}));
|
|
305
|
+
addResourceOptions(program
|
|
306
|
+
.command('guard [path]')
|
|
307
|
+
.description('Evaluate drift guard thresholds against diff or baseline')
|
|
308
|
+
.option('--base <ref>', 'Git base ref for diff guard mode')
|
|
309
|
+
.option('--baseline <file>', 'Baseline file path (default: drift-baseline.json)')
|
|
310
|
+
.option('--budget <n>', 'Allowed score delta budget')
|
|
311
|
+
.option('--by-severity <spec>', 'Severity thresholds: error=0,warning=2,info=5')
|
|
312
|
+
.option('--json', 'Output raw JSON guard result')
|
|
313
|
+
.action(async (targetPath, options) => {
|
|
314
|
+
try {
|
|
315
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
316
|
+
const budget = parseOptionalNumber(options.budget, '--budget');
|
|
317
|
+
const bySeverity = parseBySeverity(options.bySeverity);
|
|
318
|
+
const result = await runGuard(resolvedPath, {
|
|
319
|
+
baseRef: options.base,
|
|
320
|
+
baselinePath: options.baseline,
|
|
321
|
+
budget,
|
|
322
|
+
bySeverity,
|
|
323
|
+
analysis: resolveAnalysisOptions(options),
|
|
324
|
+
});
|
|
325
|
+
if (options.json) {
|
|
326
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
printGuardSummary(result);
|
|
330
|
+
}
|
|
331
|
+
if (!result.passed) {
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
337
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}));
|
|
341
|
+
program
|
|
342
|
+
.command('benchmark')
|
|
343
|
+
.description('Run benchmark harness for scan/review/trust commands')
|
|
344
|
+
.allowUnknownOption(true)
|
|
345
|
+
.action(async () => {
|
|
346
|
+
await runBenchmarkCli(process.argv.slice(3));
|
|
111
347
|
});
|
|
112
348
|
program
|
|
113
349
|
.command('review')
|
|
114
350
|
.description('Review drift against a base ref and output PR markdown')
|
|
115
351
|
.option('--base <ref>', 'Git base ref to compare against', 'origin/main')
|
|
352
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
116
353
|
.option('--json', 'Output structured review JSON')
|
|
117
354
|
.option('--comment', 'Output markdown comment body')
|
|
118
355
|
.option('--fail-on <n>', 'Exit with code 1 if score delta is >= n')
|
|
119
356
|
.action(async (options) => {
|
|
120
357
|
try {
|
|
121
358
|
const review = await generateReview(resolve('.'), options.base);
|
|
122
|
-
|
|
359
|
+
const format = resolveOutputFormat({
|
|
360
|
+
command: 'review',
|
|
361
|
+
format: options.format,
|
|
362
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
363
|
+
legacyAliases: [
|
|
364
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
365
|
+
{ flag: 'comment', used: options.comment, mapsTo: 'markdown' },
|
|
366
|
+
],
|
|
367
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
368
|
+
});
|
|
369
|
+
if (format === 'sarif') {
|
|
370
|
+
process.stdout.write(`${JSON.stringify(diffToSarif(review.diff), null, 2)}\n`);
|
|
371
|
+
}
|
|
372
|
+
else if (format === 'json') {
|
|
123
373
|
process.stdout.write(JSON.stringify(review, null, 2) + '\n');
|
|
124
374
|
}
|
|
375
|
+
else if (format === 'markdown') {
|
|
376
|
+
process.stdout.write(`${review.markdown}\n`);
|
|
377
|
+
}
|
|
125
378
|
else {
|
|
126
|
-
process.stdout.write(
|
|
379
|
+
process.stdout.write(`${review.summary}\n\n${review.markdown}\n`);
|
|
127
380
|
}
|
|
128
381
|
const failOn = options.failOn ? Number(options.failOn) : undefined;
|
|
129
382
|
if (typeof failOn === 'number' && !Number.isNaN(failOn) && review.totalDelta >= failOn) {
|
|
@@ -136,6 +389,231 @@ program
|
|
|
136
389
|
process.exit(1);
|
|
137
390
|
}
|
|
138
391
|
});
|
|
392
|
+
addResourceOptions(program
|
|
393
|
+
.command('trust [path]')
|
|
394
|
+
.description('Compute merge trust baseline from drift signals')
|
|
395
|
+
.option('--base <ref>', 'Git base ref for diff-aware trust scoring')
|
|
396
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
397
|
+
.option('--json', 'Output structured trust JSON')
|
|
398
|
+
.option('--markdown', 'Output trust report as markdown (PR comment ready)')
|
|
399
|
+
.option('-o, --output <file>', 'Write trust output to file')
|
|
400
|
+
.option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
|
|
401
|
+
.option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
|
|
402
|
+
.option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
|
|
403
|
+
.option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
|
|
404
|
+
.option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
|
|
405
|
+
.option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
|
|
406
|
+
.option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
|
|
407
|
+
.option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
|
|
408
|
+
.option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
|
|
409
|
+
.action(async (targetPath, options) => {
|
|
410
|
+
let tempDir;
|
|
411
|
+
try {
|
|
412
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
413
|
+
const analysisOptions = resolveAnalysisOptions(options);
|
|
414
|
+
process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`);
|
|
415
|
+
const config = await loadConfig(resolvedPath);
|
|
416
|
+
const files = analyzeProject(resolvedPath, config, analysisOptions);
|
|
417
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
418
|
+
const report = buildReport(resolvedPath, files);
|
|
419
|
+
const branchName = resolveBranchFromOption(options.branch);
|
|
420
|
+
const policyExplanation = explainTrustGatePolicy(config, {
|
|
421
|
+
branchName,
|
|
422
|
+
policyPack: options.policyPack,
|
|
423
|
+
overrides: parseTrustGateOverrides(options),
|
|
424
|
+
});
|
|
425
|
+
const policy = policyExplanation.effectivePolicy;
|
|
426
|
+
if (options.explainPolicy) {
|
|
427
|
+
printTrustGatePolicyDebug(policyExplanation);
|
|
428
|
+
}
|
|
429
|
+
else if (policyExplanation.invalidPolicyPack) {
|
|
430
|
+
process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
|
|
431
|
+
}
|
|
432
|
+
let diff;
|
|
433
|
+
if (options.base) {
|
|
434
|
+
process.stderr.write(`Computing diff signals against ${options.base}...\n`);
|
|
435
|
+
tempDir = extractFilesAtRef(resolvedPath, options.base);
|
|
436
|
+
const baseFiles = analyzeProject(tempDir, config, analysisOptions);
|
|
437
|
+
const baseReport = buildReport(tempDir, baseFiles);
|
|
438
|
+
const remappedBase = {
|
|
439
|
+
...baseReport,
|
|
440
|
+
files: baseReport.files.map((file) => ({
|
|
441
|
+
...file,
|
|
442
|
+
path: resolve(resolvedPath, relative(tempDir, file.path)),
|
|
443
|
+
})),
|
|
444
|
+
};
|
|
445
|
+
diff = computeDiff(remappedBase, report, options.base);
|
|
446
|
+
process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`);
|
|
447
|
+
}
|
|
448
|
+
let previousTrustReport;
|
|
449
|
+
let snapshots;
|
|
450
|
+
if (options.advancedTrust) {
|
|
451
|
+
if (options.previousTrust) {
|
|
452
|
+
const previousTrustPath = resolve(options.previousTrust);
|
|
453
|
+
const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8');
|
|
454
|
+
previousTrustReport = JSON.parse(rawPreviousTrust);
|
|
455
|
+
process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`);
|
|
456
|
+
}
|
|
457
|
+
if (options.historyFile) {
|
|
458
|
+
const historyPath = resolve(options.historyFile);
|
|
459
|
+
const rawHistory = readFileSync(historyPath, 'utf8');
|
|
460
|
+
const history = JSON.parse(rawHistory);
|
|
461
|
+
snapshots = history.snapshots;
|
|
462
|
+
process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
snapshots = loadHistory(resolvedPath).snapshots;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const trust = buildTrustReport(report, {
|
|
469
|
+
diff,
|
|
470
|
+
advanced: {
|
|
471
|
+
enabled: options.advancedTrust,
|
|
472
|
+
previousTrust: previousTrustReport,
|
|
473
|
+
snapshots,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const format = resolveOutputFormat({
|
|
477
|
+
command: 'trust',
|
|
478
|
+
format: options.format,
|
|
479
|
+
supported: ['console', 'json', 'markdown', 'sarif'],
|
|
480
|
+
legacyAliases: [
|
|
481
|
+
{ flag: 'json', used: options.json, mapsTo: 'json' },
|
|
482
|
+
{ flag: 'markdown', used: options.markdown, mapsTo: 'markdown' },
|
|
483
|
+
],
|
|
484
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
485
|
+
});
|
|
486
|
+
const rendered = format === 'sarif'
|
|
487
|
+
? `${JSON.stringify(toSarif(report), null, 2)}\n`
|
|
488
|
+
: `${renderTrustOutput(trust, {
|
|
489
|
+
json: format === 'json',
|
|
490
|
+
markdown: format === 'markdown',
|
|
491
|
+
})}\n`;
|
|
492
|
+
process.stdout.write(rendered);
|
|
493
|
+
if (options.output) {
|
|
494
|
+
const outPath = resolve(options.output);
|
|
495
|
+
writeFileSync(outPath, rendered, 'utf8');
|
|
496
|
+
process.stderr.write(`Trust output saved to ${outPath}\n`);
|
|
497
|
+
}
|
|
498
|
+
if (options.jsonOutput) {
|
|
499
|
+
const jsonOutPath = resolve(options.jsonOutput);
|
|
500
|
+
writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8');
|
|
501
|
+
process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`);
|
|
502
|
+
}
|
|
503
|
+
if (policy.enabled === false) {
|
|
504
|
+
process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (shouldFailTrustGate(trust, policy)) {
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
if (tempDir)
|
|
518
|
+
cleanupTempDir(tempDir);
|
|
519
|
+
}
|
|
520
|
+
}));
|
|
521
|
+
program
|
|
522
|
+
.command('trust-gate <trustJsonFile>')
|
|
523
|
+
.description('Evaluate trust gate thresholds from an existing trust JSON file')
|
|
524
|
+
.option('--min-trust <n>', 'Fail if trust score is below threshold')
|
|
525
|
+
.option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
|
|
526
|
+
.option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
|
|
527
|
+
.option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
|
|
528
|
+
.option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
|
|
529
|
+
.action(async (trustJsonFile, options) => {
|
|
530
|
+
try {
|
|
531
|
+
const filePath = resolve(trustJsonFile);
|
|
532
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
533
|
+
const parsed = JSON.parse(raw);
|
|
534
|
+
const config = await loadConfig(resolve('.'));
|
|
535
|
+
const branchName = resolveBranchFromOption(options.branch);
|
|
536
|
+
const policyExplanation = explainTrustGatePolicy(config, {
|
|
537
|
+
branchName,
|
|
538
|
+
policyPack: options.policyPack,
|
|
539
|
+
overrides: parseTrustGateOverrides(options),
|
|
540
|
+
});
|
|
541
|
+
const policy = policyExplanation.effectivePolicy;
|
|
542
|
+
if (options.explainPolicy) {
|
|
543
|
+
printTrustGatePolicyDebug(policyExplanation);
|
|
544
|
+
}
|
|
545
|
+
else if (policyExplanation.invalidPolicyPack) {
|
|
546
|
+
process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`);
|
|
547
|
+
}
|
|
548
|
+
if (typeof parsed.trust_score !== 'number') {
|
|
549
|
+
process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n');
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
if (typeof parsed.merge_risk !== 'string') {
|
|
553
|
+
process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n');
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk);
|
|
557
|
+
if (!actualRisk) {
|
|
558
|
+
process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
const trust = {
|
|
562
|
+
scannedAt: parsed.scannedAt ?? new Date().toISOString(),
|
|
563
|
+
targetPath: parsed.targetPath ?? '.',
|
|
564
|
+
trust_score: parsed.trust_score,
|
|
565
|
+
merge_risk: actualRisk,
|
|
566
|
+
top_reasons: parsed.top_reasons ?? [],
|
|
567
|
+
fix_priorities: parsed.fix_priorities ?? [],
|
|
568
|
+
diff_context: parsed.diff_context,
|
|
569
|
+
};
|
|
570
|
+
if (policy.enabled === false) {
|
|
571
|
+
process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (shouldFailTrustGate(trust, policy)) {
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`);
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
581
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
program
|
|
586
|
+
.command('doctor')
|
|
587
|
+
.description('Run project environment diagnostics')
|
|
588
|
+
.option('--json', 'Output structured doctor JSON')
|
|
589
|
+
.action(async (opts) => {
|
|
590
|
+
try {
|
|
591
|
+
await runDoctor(process.cwd(), { json: opts.json });
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
595
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
596
|
+
process.exitCode = 1;
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
program
|
|
600
|
+
.command('kpi <path>')
|
|
601
|
+
.description('Aggregate trust KPIs from trust JSON artifacts')
|
|
602
|
+
.option('--no-summary', 'Disable console KPI summary in stderr')
|
|
603
|
+
.action((targetPath, options) => {
|
|
604
|
+
try {
|
|
605
|
+
const kpi = computeTrustKpis(targetPath);
|
|
606
|
+
if (options.summary !== false) {
|
|
607
|
+
process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`);
|
|
608
|
+
}
|
|
609
|
+
process.stdout.write(`${formatTrustKpiJson(kpi)}\n`);
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
613
|
+
process.stderr.write(`\n Error: ${message}\n\n`);
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
139
617
|
program
|
|
140
618
|
.command('map [path]')
|
|
141
619
|
.description('Generate architecture.svg with simple layer dependencies')
|
|
@@ -147,7 +625,7 @@ program
|
|
|
147
625
|
const out = generateArchitectureMap(resolvedPath, options.output, config);
|
|
148
626
|
process.stderr.write(` Architecture map saved to ${out}\n\n`);
|
|
149
627
|
});
|
|
150
|
-
program
|
|
628
|
+
addResourceOptions(program
|
|
151
629
|
.command('report [path]')
|
|
152
630
|
.description('Generate a self-contained HTML report')
|
|
153
631
|
.option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
|
|
@@ -155,15 +633,15 @@ program
|
|
|
155
633
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
156
634
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
157
635
|
const config = await loadConfig(resolvedPath);
|
|
158
|
-
const files = analyzeProject(resolvedPath, config);
|
|
636
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
|
|
159
637
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
160
638
|
const report = buildReport(resolvedPath, files);
|
|
161
639
|
const html = generateHtmlReport(report);
|
|
162
640
|
const outPath = resolve(options.output);
|
|
163
641
|
writeFileSync(outPath, html, 'utf8');
|
|
164
642
|
process.stderr.write(` Report saved to ${outPath}\n\n`);
|
|
165
|
-
});
|
|
166
|
-
program
|
|
643
|
+
}));
|
|
644
|
+
addResourceOptions(program
|
|
167
645
|
.command('badge [path]')
|
|
168
646
|
.description('Generate a badge.svg with the current drift score')
|
|
169
647
|
.option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
|
|
@@ -171,30 +649,47 @@ program
|
|
|
171
649
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
172
650
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
173
651
|
const config = await loadConfig(resolvedPath);
|
|
174
|
-
const files = analyzeProject(resolvedPath, config);
|
|
652
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
|
|
175
653
|
const report = buildReport(resolvedPath, files);
|
|
176
654
|
const svg = generateBadge(report.totalScore);
|
|
177
655
|
const outPath = resolve(options.output);
|
|
178
656
|
writeFileSync(outPath, svg, 'utf8');
|
|
179
657
|
process.stderr.write(` Badge saved to ${outPath}\n`);
|
|
180
658
|
process.stderr.write(` Score: ${report.totalScore}/100\n\n`);
|
|
181
|
-
});
|
|
182
|
-
program
|
|
659
|
+
}));
|
|
660
|
+
addResourceOptions(program
|
|
183
661
|
.command('ci [path]')
|
|
184
662
|
.description('Emit GitHub Actions annotations and step summary')
|
|
663
|
+
.option('--format <type>', 'Output format: console|json|markdown|ai|sarif')
|
|
664
|
+
.option('--json', 'Output raw JSON report (legacy alias for --format json)')
|
|
185
665
|
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
186
666
|
.action(async (targetPath, options) => {
|
|
187
667
|
const resolvedPath = resolve(targetPath ?? '.');
|
|
188
668
|
const config = await loadConfig(resolvedPath);
|
|
189
|
-
const files = analyzeProject(resolvedPath, config);
|
|
669
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
|
|
190
670
|
const report = buildReport(resolvedPath, files);
|
|
191
|
-
|
|
192
|
-
|
|
671
|
+
const format = resolveOutputFormat({
|
|
672
|
+
command: 'ci',
|
|
673
|
+
format: options.format,
|
|
674
|
+
supported: ['console', 'json', 'sarif'],
|
|
675
|
+
legacyAliases: [{ flag: 'json', used: options.json, mapsTo: 'json' }],
|
|
676
|
+
onWarning: (message) => process.stderr.write(`${message}\n`),
|
|
677
|
+
});
|
|
678
|
+
if (format === 'sarif') {
|
|
679
|
+
process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`);
|
|
680
|
+
}
|
|
681
|
+
else if (format === 'json') {
|
|
682
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
emitCIAnnotations(report);
|
|
686
|
+
printCISummary(report);
|
|
687
|
+
}
|
|
193
688
|
const minScore = Number(options.minScore);
|
|
194
689
|
if (minScore > 0 && report.totalScore > minScore) {
|
|
195
690
|
process.exit(1);
|
|
196
691
|
}
|
|
197
|
-
});
|
|
692
|
+
}));
|
|
198
693
|
program
|
|
199
694
|
.command('trend [period]')
|
|
200
695
|
.description('Analyze trend of technical debt over time')
|
|
@@ -303,7 +798,7 @@ program
|
|
|
303
798
|
console.log(`\n${applied.length} issue(s) fixed. Re-run drift scan to verify.`);
|
|
304
799
|
}
|
|
305
800
|
});
|
|
306
|
-
program
|
|
801
|
+
addResourceOptions(program
|
|
307
802
|
.command('snapshot [path]')
|
|
308
803
|
.description('Record a score snapshot to drift-history.json')
|
|
309
804
|
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
@@ -318,7 +813,7 @@ program
|
|
|
318
813
|
}
|
|
319
814
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`);
|
|
320
815
|
const config = await loadConfig(resolvedPath);
|
|
321
|
-
const files = analyzeProject(resolvedPath, config);
|
|
816
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts));
|
|
322
817
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`);
|
|
323
818
|
const report = buildReport(resolvedPath, files);
|
|
324
819
|
if (opts.diff) {
|
|
@@ -330,64 +825,195 @@ program
|
|
|
330
825
|
const labelStr = entry.label ? ` [${entry.label}]` : '';
|
|
331
826
|
process.stdout.write(` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`);
|
|
332
827
|
process.stdout.write(` Saved to drift-history.json\n\n`);
|
|
333
|
-
});
|
|
828
|
+
}));
|
|
334
829
|
const cloud = program
|
|
335
830
|
.command('cloud')
|
|
336
831
|
.description('Local SaaS foundations: ingest, summary, and dashboard');
|
|
337
|
-
cloud
|
|
832
|
+
addResourceOptions(cloud
|
|
338
833
|
.command('ingest [path]')
|
|
339
834
|
.description('Scan path, build report, and store cloud snapshot')
|
|
835
|
+
.option('--org <id>', 'Organization id (default: default-org)', 'default-org')
|
|
340
836
|
.requiredOption('--workspace <id>', 'Workspace id')
|
|
341
837
|
.requiredOption('--user <id>', 'User id')
|
|
838
|
+
.option('--role <role>', 'Role hint (owner|member|viewer)')
|
|
839
|
+
.option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
|
|
342
840
|
.option('--repo <name>', 'Repo name (default: basename of scanned path)')
|
|
841
|
+
.option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
|
|
343
842
|
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
344
843
|
.action(async (targetPath, options) => {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
844
|
+
try {
|
|
845
|
+
const resolvedPath = resolve(targetPath ?? '.');
|
|
846
|
+
process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`);
|
|
847
|
+
const config = await loadConfig(resolvedPath);
|
|
848
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options));
|
|
849
|
+
const report = buildReport(resolvedPath, files);
|
|
850
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
851
|
+
organizationId: options.org,
|
|
852
|
+
workspaceId: options.workspace,
|
|
853
|
+
userId: options.user,
|
|
854
|
+
role: options.role,
|
|
855
|
+
plan: options.plan,
|
|
856
|
+
repoName: options.repo ?? basename(resolvedPath),
|
|
857
|
+
actorUserId: options.actor,
|
|
858
|
+
storeFile: options.store,
|
|
859
|
+
policy: config?.saas,
|
|
860
|
+
});
|
|
861
|
+
process.stdout.write(`Ingested snapshot ${snapshot.id}\n`);
|
|
862
|
+
process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`);
|
|
863
|
+
process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`);
|
|
864
|
+
process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`);
|
|
865
|
+
}
|
|
866
|
+
catch (error) {
|
|
867
|
+
printSaasErrorAndExit(error);
|
|
868
|
+
}
|
|
869
|
+
}));
|
|
361
870
|
cloud
|
|
362
871
|
.command('summary')
|
|
363
872
|
.description('Show SaaS usage metrics and free threshold status')
|
|
364
873
|
.option('--json', 'Output raw JSON summary')
|
|
874
|
+
.option('--org <id>', 'Filter summary by organization id')
|
|
875
|
+
.option('--workspace <id>', 'Filter summary by workspace id')
|
|
876
|
+
.option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
|
|
365
877
|
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
366
878
|
.action((options) => {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
879
|
+
try {
|
|
880
|
+
const summary = getSaasSummary({
|
|
881
|
+
storeFile: options.store,
|
|
882
|
+
organizationId: options.org,
|
|
883
|
+
workspaceId: options.workspace,
|
|
884
|
+
actorUserId: options.actor,
|
|
885
|
+
});
|
|
886
|
+
if (options.json) {
|
|
887
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
process.stdout.write('\n');
|
|
891
|
+
process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`);
|
|
892
|
+
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
|
|
893
|
+
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
|
|
894
|
+
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
|
|
895
|
+
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
|
|
896
|
+
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
|
|
897
|
+
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
|
|
898
|
+
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
|
|
899
|
+
process.stdout.write('Runs per month:\n');
|
|
900
|
+
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
|
|
901
|
+
if (monthly.length === 0) {
|
|
902
|
+
process.stdout.write(' - none\n\n');
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
for (const [month, runs] of monthly) {
|
|
906
|
+
process.stdout.write(` - ${month}: ${runs}\n`);
|
|
907
|
+
}
|
|
908
|
+
process.stdout.write('\n');
|
|
371
909
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`);
|
|
375
|
-
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`);
|
|
376
|
-
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`);
|
|
377
|
-
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`);
|
|
378
|
-
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`);
|
|
379
|
-
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`);
|
|
380
|
-
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`);
|
|
381
|
-
process.stdout.write('Runs per month:\n');
|
|
382
|
-
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b));
|
|
383
|
-
if (monthly.length === 0) {
|
|
384
|
-
process.stdout.write(' - none\n\n');
|
|
385
|
-
return;
|
|
910
|
+
catch (error) {
|
|
911
|
+
printSaasErrorAndExit(error);
|
|
386
912
|
}
|
|
387
|
-
|
|
388
|
-
|
|
913
|
+
});
|
|
914
|
+
cloud
|
|
915
|
+
.command('plan-set')
|
|
916
|
+
.description('Set organization plan (owner role required when actor is provided)')
|
|
917
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
918
|
+
.requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
|
|
919
|
+
.requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
|
|
920
|
+
.option('--reason <text>', 'Optional reason for audit trail')
|
|
921
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
922
|
+
.option('--json', 'Output raw JSON plan change')
|
|
923
|
+
.action((options) => {
|
|
924
|
+
try {
|
|
925
|
+
const change = changeOrganizationPlan({
|
|
926
|
+
organizationId: options.org,
|
|
927
|
+
actorUserId: options.actor,
|
|
928
|
+
newPlan: options.plan,
|
|
929
|
+
reason: options.reason,
|
|
930
|
+
storeFile: options.store,
|
|
931
|
+
});
|
|
932
|
+
if (options.json) {
|
|
933
|
+
process.stdout.write(JSON.stringify(change, null, 2) + '\n');
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`);
|
|
937
|
+
process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`);
|
|
938
|
+
if (change.reason)
|
|
939
|
+
process.stdout.write(`Reason: ${change.reason}\n`);
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
printSaasErrorAndExit(error);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
cloud
|
|
946
|
+
.command('plan-changes')
|
|
947
|
+
.description('List organization plan change audit trail')
|
|
948
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
949
|
+
.requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
|
|
950
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
951
|
+
.option('--json', 'Output raw JSON plan changes')
|
|
952
|
+
.action((options) => {
|
|
953
|
+
try {
|
|
954
|
+
const changes = listOrganizationPlanChanges({
|
|
955
|
+
organizationId: options.org,
|
|
956
|
+
actorUserId: options.actor,
|
|
957
|
+
storeFile: options.store,
|
|
958
|
+
});
|
|
959
|
+
if (options.json) {
|
|
960
|
+
process.stdout.write(JSON.stringify(changes, null, 2) + '\n');
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (changes.length === 0) {
|
|
964
|
+
process.stdout.write(`No plan changes found for org '${options.org}'.\n`);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
process.stdout.write(`Plan changes for org '${options.org}':\n`);
|
|
968
|
+
for (const change of changes) {
|
|
969
|
+
const reasonSuffix = change.reason ? ` reason='${change.reason}'` : '';
|
|
970
|
+
process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
printSaasErrorAndExit(error);
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
cloud
|
|
978
|
+
.command('usage')
|
|
979
|
+
.description('Show organization usage and effective limits')
|
|
980
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
981
|
+
.requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
|
|
982
|
+
.option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
|
|
983
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
984
|
+
.option('--json', 'Output usage and limits as raw JSON')
|
|
985
|
+
.action((options) => {
|
|
986
|
+
try {
|
|
987
|
+
const usage = getOrganizationUsageSnapshot({
|
|
988
|
+
organizationId: options.org,
|
|
989
|
+
actorUserId: options.actor,
|
|
990
|
+
month: options.month,
|
|
991
|
+
storeFile: options.store,
|
|
992
|
+
});
|
|
993
|
+
const limits = getOrganizationEffectiveLimits({
|
|
994
|
+
organizationId: options.org,
|
|
995
|
+
storeFile: options.store,
|
|
996
|
+
});
|
|
997
|
+
if (options.json) {
|
|
998
|
+
process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
process.stdout.write(`Organization: ${usage.organizationId}\n`);
|
|
1002
|
+
process.stdout.write(`Plan: ${usage.plan}\n`);
|
|
1003
|
+
process.stdout.write(`Captured at: ${usage.capturedAt}\n`);
|
|
1004
|
+
process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`);
|
|
1005
|
+
process.stdout.write(`Repo count: ${usage.repoCount}\n`);
|
|
1006
|
+
process.stdout.write(`Runs total: ${usage.runCount}\n`);
|
|
1007
|
+
process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`);
|
|
1008
|
+
process.stdout.write('Effective limits:\n');
|
|
1009
|
+
process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`);
|
|
1010
|
+
process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`);
|
|
1011
|
+
process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`);
|
|
1012
|
+
process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`);
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
printSaasErrorAndExit(error);
|
|
389
1016
|
}
|
|
390
|
-
process.stdout.write('\n');
|
|
391
1017
|
});
|
|
392
1018
|
cloud
|
|
393
1019
|
.command('dashboard')
|