@delegance/claude-autopilot 2.4.0 → 5.0.0-alpha.1
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/CHANGELOG.md +50 -0
- package/README.md +164 -106
- package/bin/_launcher.js +77 -0
- package/bin/claude-autopilot.js +3 -0
- package/bin/guardrail.js +3 -0
- package/package.json +15 -9
- package/presets/generic/guardrail.config.yaml +35 -0
- package/presets/generic/stack.md +40 -0
- package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
- package/scripts/autoregress.ts +27 -11
- package/skills/autopilot/SKILL.md +170 -0
- package/skills/claude-autopilot.md +80 -0
- package/skills/guardrail.md +39 -0
- package/skills/migrate/SKILL.md +83 -0
- package/src/adapters/council/claude.ts +41 -0
- package/src/adapters/council/openai.ts +40 -0
- package/src/adapters/council/types.ts +7 -0
- package/src/adapters/loader.ts +7 -7
- package/src/adapters/review-engine/auto.ts +2 -2
- package/src/adapters/review-engine/claude.ts +9 -11
- package/src/adapters/review-engine/codex.ts +9 -11
- package/src/adapters/review-engine/gemini.ts +9 -11
- package/src/adapters/review-engine/openai-compatible.ts +10 -12
- package/src/adapters/review-engine/parse-output.ts +32 -6
- package/src/adapters/review-engine/prompt-builder.ts +19 -0
- package/src/adapters/review-engine/types.ts +1 -1
- package/src/adapters/vcs-host/commit-status.ts +39 -0
- package/src/adapters/vcs-host/github.ts +2 -2
- package/src/cli/baseline.ts +125 -0
- package/src/cli/ci.ts +11 -8
- package/src/cli/costs.ts +80 -0
- package/src/cli/council.ts +96 -0
- package/src/cli/detector.ts +21 -5
- package/src/cli/explain.ts +197 -0
- package/src/cli/fix.ts +249 -0
- package/src/cli/hook.ts +72 -27
- package/src/cli/ignore-helper.ts +116 -0
- package/src/cli/index.ts +302 -28
- package/src/cli/init.ts +12 -12
- package/src/cli/lsp.ts +200 -0
- package/src/cli/mcp.ts +206 -0
- package/src/cli/pr-comment.ts +5 -5
- package/src/cli/pr-desc.ts +168 -0
- package/src/cli/pr-review-comments.ts +3 -3
- package/src/cli/pr.ts +76 -0
- package/src/cli/preflight.ts +15 -32
- package/src/cli/report.ts +186 -0
- package/src/cli/run.ts +140 -36
- package/src/cli/scan.ts +233 -0
- package/src/cli/setup.ts +121 -15
- package/src/cli/test-gen.ts +125 -0
- package/src/cli/triage.ts +137 -0
- package/src/cli/watch.ts +52 -31
- package/src/cli/worker.ts +109 -0
- package/src/core/cache/review-cache.ts +2 -2
- package/src/core/chunking/index.ts +2 -2
- package/src/core/config/loader.ts +24 -12
- package/src/core/config/preset-resolver.ts +6 -6
- package/src/core/config/schema.ts +121 -3
- package/src/core/config/types.ts +57 -2
- package/src/core/council/config.ts +71 -0
- package/src/core/council/context.ts +17 -0
- package/src/core/council/runner.ts +83 -0
- package/src/core/council/types.ts +45 -0
- package/src/core/detect/llm-key.ts +89 -0
- package/src/core/detect/workspaces.ts +103 -0
- package/src/core/errors.ts +4 -4
- package/src/core/fix/generator.ts +149 -0
- package/src/core/ignore/index.ts +4 -4
- package/src/core/mcp/concurrency.ts +16 -0
- package/src/core/mcp/handlers/fix-finding.ts +126 -0
- package/src/core/mcp/handlers/get-capabilities.ts +62 -0
- package/src/core/mcp/handlers/get-findings.ts +36 -0
- package/src/core/mcp/handlers/review-diff.ts +65 -0
- package/src/core/mcp/handlers/scan-files.ts +65 -0
- package/src/core/mcp/handlers/validate-fix.ts +41 -0
- package/src/core/mcp/run-store.ts +85 -0
- package/src/core/mcp/workspace.ts +35 -0
- package/src/core/persist/baseline.ts +112 -0
- package/src/core/persist/cost-log.ts +1 -1
- package/src/core/persist/findings-cache.ts +1 -1
- package/src/core/persist/triage.ts +112 -0
- package/src/core/phases/static-rules.ts +18 -5
- package/src/core/pipeline/review-phase.ts +65 -26
- package/src/core/pipeline/run.ts +42 -10
- package/src/core/runtime/lock.ts +2 -2
- package/src/core/runtime/state.ts +2 -2
- package/src/core/schema-alignment/detector.ts +59 -0
- package/src/core/schema-alignment/extractor/index.ts +24 -0
- package/src/core/schema-alignment/extractor/prisma.ts +21 -0
- package/src/core/schema-alignment/extractor/sql.ts +99 -0
- package/src/core/schema-alignment/llm-check.ts +91 -0
- package/src/core/schema-alignment/scanner.ts +107 -0
- package/src/core/schema-alignment/types.ts +43 -0
- package/src/core/shell.ts +3 -3
- package/src/core/static-rules/registry.ts +17 -8
- package/src/core/static-rules/rules/brand-tokens.ts +145 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
- package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
- package/src/core/static-rules/rules/missing-auth.ts +70 -0
- package/src/core/static-rules/rules/schema-alignment.ts +132 -0
- package/src/core/static-rules/rules/sql-injection.ts +71 -0
- package/src/core/static-rules/rules/ssrf.ts +63 -0
- package/src/core/static-rules/tailwind-extractor.ts +38 -0
- package/src/core/test-gen/coverage-analyzer.ts +93 -0
- package/src/core/test-gen/framework-detector.ts +21 -0
- package/src/core/test-gen/test-writer.ts +33 -0
- package/src/core/ui/design-context-loader.ts +87 -0
- package/src/core/worker/client.ts +46 -0
- package/src/core/worker/lockfile.ts +38 -0
- package/src/core/worker/server.ts +81 -0
- package/src/formatters/junit.ts +52 -0
- package/src/formatters/sarif.ts +2 -2
- package/src/index.ts +1 -2
- package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
- package/tests/snapshots/index.json +3 -3
- package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
- package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
- package/bin/autopilot.js +0 -20
- package/skills/autopilot.md +0 -157
- /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
- /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
- /package/{src → scripts}/snapshots/serializer.ts +0 -0
package/src/cli/pr.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { runCommand } from './run.ts';
|
|
5
|
+
|
|
6
|
+
const C = {
|
|
7
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
8
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
9
|
+
};
|
|
10
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
11
|
+
|
|
12
|
+
export interface PrCommandOptions {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
configPath?: string;
|
|
15
|
+
prNumber?: string;
|
|
16
|
+
noPostComments?: boolean;
|
|
17
|
+
noInlineComments?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ghJson<T>(args: string[], cwd: string): T | null {
|
|
21
|
+
const r = spawnSync('gh', args, { cwd, encoding: 'utf8' });
|
|
22
|
+
if (r.status !== 0) return null;
|
|
23
|
+
try { return JSON.parse(r.stdout) as T; } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function gitFetch(remote: string, ref: string, cwd: string): boolean {
|
|
27
|
+
const r = spawnSync('git', ['fetch', remote, ref], { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runPr(options: PrCommandOptions = {}): Promise<number> {
|
|
32
|
+
const cwd = options.cwd ?? process.cwd();
|
|
33
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(configPath)) {
|
|
36
|
+
console.error(fmt('red', `[pr] guardrail.config.yaml not found at ${configPath}`));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve PR number
|
|
41
|
+
let prNumber = options.prNumber;
|
|
42
|
+
if (!prNumber) {
|
|
43
|
+
const detected = ghJson<{ number: number }>(['pr', 'view', '--json', 'number'], cwd);
|
|
44
|
+
if (!detected) {
|
|
45
|
+
console.error(fmt('red', '[pr] No PR number given and no open PR found for current branch.'));
|
|
46
|
+
console.error(fmt('dim', ' Usage: guardrail pr <number>'));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
prNumber = String(detected.number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Look up PR metadata
|
|
53
|
+
interface PrMeta { number: number; baseRefName: string; headRefName: string; title: string }
|
|
54
|
+
const pr = ghJson<PrMeta>(['pr', 'view', prNumber, '--json', 'number,baseRefName,headRefName,title'], cwd);
|
|
55
|
+
if (!pr) {
|
|
56
|
+
console.error(fmt('red', `[pr] Could not fetch PR #${prNumber} — is gh authenticated?`));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`\n${fmt('bold', `[guardrail pr]`)} #${pr.number} ${fmt('dim', pr.title)}`);
|
|
61
|
+
console.log(fmt('dim', ` base: ${pr.baseRefName} head: ${pr.headRefName}`));
|
|
62
|
+
|
|
63
|
+
// Fetch base ref so diff works locally
|
|
64
|
+
const fetched = gitFetch('origin', pr.baseRefName, cwd);
|
|
65
|
+
if (!fetched) {
|
|
66
|
+
console.log(fmt('yellow', ` [pr] Warning: could not fetch origin/${pr.baseRefName} — diff may be stale`));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return runCommand({
|
|
70
|
+
cwd,
|
|
71
|
+
configPath,
|
|
72
|
+
base: `origin/${pr.baseRefName}`,
|
|
73
|
+
postComments: !options.noPostComments,
|
|
74
|
+
inlineComments: !options.noInlineComments,
|
|
75
|
+
});
|
|
76
|
+
}
|
package/src/cli/preflight.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { runSafe } from '../core/shell.ts';
|
|
6
|
+
import { detectLLMKey, loadEnvFile, LLM_KEY_NAMES } from '../core/detect/llm-key.ts';
|
|
6
7
|
|
|
7
8
|
const PASS = '\x1b[32m✓\x1b[0m';
|
|
8
9
|
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
@@ -16,21 +17,6 @@ interface Check {
|
|
|
16
17
|
message?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function loadEnvFile(filePath: string): Record<string, string> {
|
|
20
|
-
const vars: Record<string, string> = {};
|
|
21
|
-
try {
|
|
22
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
23
|
-
for (const line of content.split('\n')) {
|
|
24
|
-
const trimmed = line.trim();
|
|
25
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
26
|
-
const eq = trimmed.indexOf('=');
|
|
27
|
-
if (eq < 0) continue;
|
|
28
|
-
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
29
|
-
}
|
|
30
|
-
} catch { /* ignore */ }
|
|
31
|
-
return vars;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
20
|
export interface DoctorResult {
|
|
35
21
|
blockers: number;
|
|
36
22
|
warnings: number;
|
|
@@ -56,7 +42,7 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
56
42
|
checks.push({
|
|
57
43
|
name: 'tsx available',
|
|
58
44
|
result: tsxVersion ? 'pass' : 'fail',
|
|
59
|
-
message: !tsxVersion ? 'tsx not found — run: npm install @delegance/
|
|
45
|
+
message: !tsxVersion ? 'tsx not found — run: npm install @delegance/guardrail (includes tsx)' : undefined,
|
|
60
46
|
});
|
|
61
47
|
|
|
62
48
|
// 3. gh CLI authenticated
|
|
@@ -67,13 +53,13 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
67
53
|
message: ghAuth === null ? 'gh CLI not authenticated — run: gh auth login' : undefined,
|
|
68
54
|
});
|
|
69
55
|
|
|
70
|
-
// 4.
|
|
71
|
-
const configYaml = path.join(process.cwd(), '
|
|
56
|
+
// 4. guardrail.config.yaml in cwd
|
|
57
|
+
const configYaml = path.join(process.cwd(), 'guardrail.config.yaml');
|
|
72
58
|
checks.push({
|
|
73
|
-
name: '
|
|
59
|
+
name: 'guardrail.config.yaml',
|
|
74
60
|
result: fs.existsSync(configYaml) ? 'pass' : 'warn',
|
|
75
61
|
message: !fs.existsSync(configYaml)
|
|
76
|
-
? '
|
|
62
|
+
? 'guardrail.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/guardrail.config.yaml'
|
|
77
63
|
: undefined,
|
|
78
64
|
});
|
|
79
65
|
|
|
@@ -83,21 +69,18 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
83
69
|
name: `Local env file (${envFile ?? 'none found'})`,
|
|
84
70
|
result: envFile ? 'pass' : 'warn',
|
|
85
71
|
message: !envFile
|
|
86
|
-
? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with
|
|
72
|
+
? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with one of: ${LLM_KEY_NAMES.join(', ')}.`
|
|
87
73
|
: undefined,
|
|
88
74
|
});
|
|
89
75
|
|
|
90
|
-
// 6. LLM API key
|
|
76
|
+
// 6. LLM API key — shared detection with setup/scan/run (all 5 providers)
|
|
91
77
|
const envVars = envFile ? loadEnvFile(envFile) : {};
|
|
92
|
-
const
|
|
93
|
-
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
|
|
94
|
-
const hasLLMKey = hasAnthropic || hasOpenAI;
|
|
95
|
-
const llmKeyName = hasAnthropic ? 'ANTHROPIC_API_KEY' : hasOpenAI ? 'OPENAI_API_KEY' : 'none';
|
|
78
|
+
const { hasKey, preferred } = detectLLMKey({ extraEnv: envVars });
|
|
96
79
|
checks.push({
|
|
97
|
-
name: `LLM API key (${
|
|
98
|
-
result:
|
|
99
|
-
message: !
|
|
100
|
-
? `No LLM API key found — set
|
|
80
|
+
name: `LLM API key (${preferred ?? 'none'})`,
|
|
81
|
+
result: hasKey ? 'pass' : 'warn',
|
|
82
|
+
message: !hasKey
|
|
83
|
+
? `No LLM API key found — set one of: ${LLM_KEY_NAMES.join(', ')} to enable review`
|
|
101
84
|
: undefined,
|
|
102
85
|
});
|
|
103
86
|
|
|
@@ -125,7 +108,7 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
125
108
|
|
|
126
109
|
|
|
127
110
|
// Print results
|
|
128
|
-
console.log('\n\x1b[1m[doctor]
|
|
111
|
+
console.log('\n\x1b[1m[doctor] Guardrail prerequisite check\x1b[0m\n');
|
|
129
112
|
let blockers = 0;
|
|
130
113
|
let warnings = 0;
|
|
131
114
|
for (const check of checks) {
|
|
@@ -140,7 +123,7 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
140
123
|
|
|
141
124
|
console.log('');
|
|
142
125
|
if (blockers > 0) {
|
|
143
|
-
console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx
|
|
126
|
+
console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx guardrail run\x1b[0m\n`);
|
|
144
127
|
} else if (warnings > 0) {
|
|
145
128
|
console.log(`\x1b[33m[doctor] ${warnings} warning(s) — pipeline will run but some steps may be skipped\x1b[0m\n`);
|
|
146
129
|
} else {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { loadCachedFindings } from '../core/persist/findings-cache.ts';
|
|
4
|
+
import { readCostLog } from '../core/persist/cost-log.ts';
|
|
5
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
6
|
+
|
|
7
|
+
const C = {
|
|
8
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
9
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
10
|
+
};
|
|
11
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
12
|
+
|
|
13
|
+
export interface ReportCommandOptions {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
output?: string; // file path to write markdown (default: stdout)
|
|
16
|
+
trend?: boolean; // include trend analysis from cost log run history
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function severityOrder(s: Finding['severity']): number {
|
|
20
|
+
return s === 'critical' ? 0 : s === 'warning' ? 1 : 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildTrendSection(cwd: string): string {
|
|
24
|
+
const log = readCostLog(cwd);
|
|
25
|
+
if (log.length === 0) return '';
|
|
26
|
+
|
|
27
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
28
|
+
const recent = log.filter(e => new Date(e.timestamp).getTime() >= sevenDaysAgo);
|
|
29
|
+
const totalCost = log.reduce((s, e) => s + e.costUSD, 0);
|
|
30
|
+
const avgFiles = log.reduce((s, e) => s + e.files, 0) / log.length;
|
|
31
|
+
|
|
32
|
+
const lines: string[] = [
|
|
33
|
+
'## 📈 Trend',
|
|
34
|
+
'',
|
|
35
|
+
`| Metric | Value |`,
|
|
36
|
+
`|--------|-------|`,
|
|
37
|
+
`| Runs (7d) | ${recent.length} |`,
|
|
38
|
+
`| Runs (all-time) | ${log.length} |`,
|
|
39
|
+
`| All-time cost | $${totalCost.toFixed(4)} |`,
|
|
40
|
+
`| Avg files/run | ${avgFiles.toFixed(1)} |`,
|
|
41
|
+
'',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (recent.length > 0) {
|
|
45
|
+
lines.push('### Recent runs', '');
|
|
46
|
+
lines.push('| Date | Files | Cost |');
|
|
47
|
+
lines.push('|------|-------|------|');
|
|
48
|
+
for (const e of recent.slice(-7).reverse()) {
|
|
49
|
+
const d = new Date(e.timestamp).toLocaleDateString();
|
|
50
|
+
lines.push(`| ${d} | ${e.files} | $${e.costUSD.toFixed(4)} |`);
|
|
51
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildFileBreakdown(findings: Finding[]): string {
|
|
59
|
+
const withFiles = findings.filter(f => f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>');
|
|
60
|
+
if (withFiles.length === 0) return '';
|
|
61
|
+
|
|
62
|
+
const counts = new Map<string, { critical: number; warning: number; note: number; total: number }>();
|
|
63
|
+
for (const f of withFiles) {
|
|
64
|
+
const entry = counts.get(f.file) ?? { critical: 0, warning: 0, note: 0, total: 0 };
|
|
65
|
+
entry[f.severity]++;
|
|
66
|
+
entry.total++;
|
|
67
|
+
counts.set(f.file, entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 10);
|
|
71
|
+
if (sorted.length < 2) return ''; // single file — not worth a table
|
|
72
|
+
|
|
73
|
+
const lines = [
|
|
74
|
+
'## 📁 By File',
|
|
75
|
+
'',
|
|
76
|
+
'| File | Critical | Warning | Note | Total |',
|
|
77
|
+
'|------|----------|---------|------|-------|',
|
|
78
|
+
];
|
|
79
|
+
for (const [file, c] of sorted) {
|
|
80
|
+
lines.push(`| \`${file}\` | ${c.critical || '–'} | ${c.warning || '–'} | ${c.note || '–'} | **${c.total}** |`);
|
|
81
|
+
}
|
|
82
|
+
if (counts.size > 10) lines.push(`| *(${counts.size - 10} more files)* | | | | |`);
|
|
83
|
+
lines.push('');
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildSourceBreakdown(findings: Finding[]): string {
|
|
88
|
+
const counts = new Map<string, number>();
|
|
89
|
+
for (const f of findings) {
|
|
90
|
+
counts.set(f.source, (counts.get(f.source) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
if (counts.size < 2) return '';
|
|
93
|
+
|
|
94
|
+
const lines = ['## 🔬 By Source', '', '| Source | Findings |', '|--------|----------|'];
|
|
95
|
+
for (const [source, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
96
|
+
lines.push(`| ${source} | ${n} |`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildMarkdown(findings: Finding[], cwd: string, trend: boolean): string {
|
|
103
|
+
const critical = findings.filter(f => f.severity === 'critical');
|
|
104
|
+
const warnings = findings.filter(f => f.severity === 'warning');
|
|
105
|
+
const notes = findings.filter(f => f.severity === 'note');
|
|
106
|
+
const fixable = critical.length + warnings.length;
|
|
107
|
+
|
|
108
|
+
const lines: string[] = [
|
|
109
|
+
'# Guardrail Report',
|
|
110
|
+
'',
|
|
111
|
+
`> Generated ${new Date().toISOString()}`,
|
|
112
|
+
'',
|
|
113
|
+
'## Summary',
|
|
114
|
+
'',
|
|
115
|
+
`| Severity | Count |`,
|
|
116
|
+
`|----------|-------|`,
|
|
117
|
+
`| 🚨 Critical | ${critical.length} |`,
|
|
118
|
+
`| ⚠️ Warning | ${warnings.length} |`,
|
|
119
|
+
`| ℹ️ Note | ${notes.length} |`,
|
|
120
|
+
`| **Total** | **${findings.length}** |`,
|
|
121
|
+
'',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
if (fixable > 0) {
|
|
125
|
+
lines.push(`> **${fixable} finding${fixable !== 1 ? 's' : ''} can be auto-fixed** — run \`guardrail fix\` to attempt repairs.`, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (trend) {
|
|
129
|
+
const trendSection = buildTrendSection(cwd);
|
|
130
|
+
if (trendSection) lines.push(trendSection);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fileBreakdown = buildFileBreakdown(findings);
|
|
134
|
+
if (fileBreakdown) lines.push(fileBreakdown);
|
|
135
|
+
|
|
136
|
+
const sourceBreakdown = buildSourceBreakdown(findings);
|
|
137
|
+
if (sourceBreakdown) lines.push(sourceBreakdown);
|
|
138
|
+
|
|
139
|
+
function renderGroup(label: string, icon: string, group: Finding[]) {
|
|
140
|
+
if (group.length === 0) return;
|
|
141
|
+
lines.push(`## ${icon} ${label}`, '');
|
|
142
|
+
for (const f of group) {
|
|
143
|
+
const loc = f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>'
|
|
144
|
+
? `\`${f.file}${f.line ? `:${f.line}` : ''}\``
|
|
145
|
+
: null;
|
|
146
|
+
lines.push(`### ${loc ? `${loc} — ` : ''}${f.message}`);
|
|
147
|
+
if (f.suggestion) lines.push('', `> **Suggestion:** ${f.suggestion}`);
|
|
148
|
+
lines.push('', `*Source: ${f.source} · Rule: ${f.id}*`, '');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
renderGroup('Critical', '🚨', critical);
|
|
153
|
+
renderGroup('Warnings', '⚠️', warnings);
|
|
154
|
+
renderGroup('Notes', 'ℹ️', notes);
|
|
155
|
+
|
|
156
|
+
if (findings.length === 0) {
|
|
157
|
+
lines.push('## ✅ No findings', '', 'No cached findings — run `guardrail run` or `guardrail scan` first.', '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runReport(options: ReportCommandOptions = {}): Promise<number> {
|
|
164
|
+
const cwd = options.cwd ?? process.cwd();
|
|
165
|
+
const findings = loadCachedFindings(cwd);
|
|
166
|
+
|
|
167
|
+
if (findings.length === 0) {
|
|
168
|
+
console.log(fmt('yellow', '[report] No cached findings — run `guardrail run` or `guardrail scan` first.'));
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sorted = [...findings].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
|
|
173
|
+
const md = buildMarkdown(sorted, cwd, options.trend ?? false);
|
|
174
|
+
|
|
175
|
+
if (options.output) {
|
|
176
|
+
fs.writeFileSync(options.output, md, 'utf8');
|
|
177
|
+
const critical = findings.filter(f => f.severity === 'critical').length;
|
|
178
|
+
const warnings = findings.filter(f => f.severity === 'warning').length;
|
|
179
|
+
console.log(fmt('bold', `[report] Written to ${options.output}`));
|
|
180
|
+
console.log(` ${critical > 0 ? fmt('red', `${critical} critical`) : fmt('green', '0 critical')} ${warnings > 0 ? fmt('yellow', `${warnings} warning${warnings !== 1 ? 's' : ''}`) : fmt('dim', '0 warnings')}`);
|
|
181
|
+
} else {
|
|
182
|
+
process.stdout.write(md + '\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return findings.some(f => f.severity === 'critical') ? 1 : 0;
|
|
186
|
+
}
|
package/src/cli/run.ts
CHANGED
|
@@ -25,23 +25,37 @@ import { loadRulesFromConfig } from '../core/static-rules/registry.ts';
|
|
|
25
25
|
import { resolvePreset } from '../core/config/preset-resolver.ts';
|
|
26
26
|
import { mergeConfigs } from '../core/config/preset-resolver.ts';
|
|
27
27
|
import { loadAdapter } from '../adapters/loader.ts';
|
|
28
|
-
import {
|
|
28
|
+
import { runGuardrail } from '../core/pipeline/run.ts';
|
|
29
29
|
import { resolveGitTouchedFiles } from '../core/git/touched-files.ts';
|
|
30
30
|
import type { RunInput } from '../core/pipeline/run.ts';
|
|
31
31
|
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
32
|
-
import type {
|
|
32
|
+
import type { GuardrailConfig } from '../core/config/types.ts';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { toSarif } from '../formatters/sarif.ts';
|
|
35
|
+
import { toJUnit } from '../formatters/junit.ts';
|
|
36
|
+
import { loadTriage, filterTriaged } from '../core/persist/triage.ts';
|
|
35
37
|
import { emitAnnotations } from '../formatters/github-annotations.ts';
|
|
36
38
|
import { detectStack } from '../core/detect/stack.ts';
|
|
37
39
|
import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
|
|
38
40
|
import { detectGitContext } from '../core/detect/git-context.ts';
|
|
41
|
+
import { detectWorkspaces, mapFilesToWorkspaces } from '../core/detect/workspaces.ts';
|
|
39
42
|
import { detectProject } from './detector.ts';
|
|
40
43
|
import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
|
|
41
44
|
import { postReviewComments } from './pr-review-comments.ts';
|
|
42
45
|
import { loadIgnoreRules, parseConfigIgnore, applyIgnoreRules } from '../core/ignore/index.ts';
|
|
46
|
+
import { detectLLMKey, LLM_KEY_HINTS } from '../core/detect/llm-key.ts';
|
|
43
47
|
import { loadCachedFindings, saveCachedFindings, filterNewFindings } from '../core/persist/findings-cache.ts';
|
|
48
|
+
import { loadBaseline, filterBaselined } from '../core/persist/baseline.ts';
|
|
44
49
|
import { appendCostLog } from '../core/persist/cost-log.ts';
|
|
50
|
+
import { postCommitStatus, resolveCommitSha } from '../adapters/vcs-host/commit-status.ts';
|
|
51
|
+
|
|
52
|
+
function computeExitCode(findings: { severity: string }[], failOn: string): number {
|
|
53
|
+
if (failOn === 'none') return 0;
|
|
54
|
+
const fail = failOn === 'warning' ? ['critical', 'warning']
|
|
55
|
+
: failOn === 'note' ? ['critical', 'warning', 'note']
|
|
56
|
+
: ['critical'];
|
|
57
|
+
return findings.some(f => fail.includes(f.severity)) ? 1 : 0;
|
|
58
|
+
}
|
|
45
59
|
|
|
46
60
|
function readToolVersion(): string {
|
|
47
61
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -70,10 +84,13 @@ export interface RunCommandOptions {
|
|
|
70
84
|
dryRun?: boolean; // skip review, print what would run
|
|
71
85
|
diff?: boolean; // use diff strategy (send git hunks instead of full files)
|
|
72
86
|
delta?: boolean; // only report findings not present in last run's baseline
|
|
87
|
+
newOnly?: boolean; // only report findings not in committed .guardrail-baseline.json
|
|
88
|
+
failOn?: 'critical' | 'warning' | 'note' | 'none'; // severity threshold for exit 1
|
|
73
89
|
inlineComments?: boolean; // post per-line review comments on the PR diff
|
|
74
|
-
format?: 'text' | 'sarif';
|
|
90
|
+
format?: 'text' | 'sarif' | 'junit';
|
|
75
91
|
outputPath?: string;
|
|
76
92
|
postComments?: boolean; // post/update summary comment on the open PR
|
|
93
|
+
skipReview?: boolean; // skip tests and review phases (static rules only)
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
/**
|
|
@@ -82,27 +99,26 @@ export interface RunCommandOptions {
|
|
|
82
99
|
*/
|
|
83
100
|
export async function runCommand(options: RunCommandOptions = {}): Promise<number> {
|
|
84
101
|
const cwd = options.cwd ?? process.cwd();
|
|
85
|
-
const configPath = options.configPath ?? path.join(cwd, '
|
|
102
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
86
103
|
|
|
104
|
+
// Load + merge config (graceful zero-config fallback)
|
|
105
|
+
let config: GuardrailConfig;
|
|
87
106
|
if (!fs.existsSync(configPath)) {
|
|
88
|
-
console.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
console.log(fmt('dim', `[run] No guardrail.config.yaml found — using defaults. Run \`npx guardrail setup\` to configure.`));
|
|
108
|
+
config = { configVersion: 1, reviewEngine: { adapter: 'auto' }, testCommand: null };
|
|
109
|
+
} else {
|
|
110
|
+
try {
|
|
111
|
+
const userConfig = await loadConfig(configPath);
|
|
112
|
+
if (userConfig.preset) {
|
|
113
|
+
const preset = await resolvePreset(userConfig.preset);
|
|
114
|
+
config = mergeConfigs(preset.config, userConfig);
|
|
115
|
+
} else {
|
|
116
|
+
config = userConfig;
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(fmt('red', `[run] Config error: ${err instanceof Error ? err.message : String(err)}`));
|
|
120
|
+
return 1;
|
|
102
121
|
}
|
|
103
|
-
} catch (err) {
|
|
104
|
-
console.error(fmt('red', `[run] Config error: ${err instanceof Error ? err.message : String(err)}`));
|
|
105
|
-
return 1;
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
// Fill in missing config fields from auto-detection (track what was auto-detected for logging)
|
|
@@ -124,6 +140,13 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
124
140
|
config = { ...config, testCommand: detected };
|
|
125
141
|
autoDetected.push(`test: ${detected}`);
|
|
126
142
|
}
|
|
143
|
+
|
|
144
|
+
// Monorepo workspace detection
|
|
145
|
+
const workspaces = detectWorkspaces(cwd);
|
|
146
|
+
if (workspaces && workspaces.length > 0) {
|
|
147
|
+
autoDetected.push(`workspaces: ${workspaces.length} (${workspaces.map(w => w.name).slice(0, 3).join(', ')}${workspaces.length > 3 ? ` +${workspaces.length - 3} more` : ''})`);
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
const gitCtx = detectGitContext(cwd);
|
|
128
151
|
|
|
129
152
|
// Resolve touched files
|
|
@@ -134,7 +157,7 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
134
157
|
return 0;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
console.log(`\n${fmt('bold', '[
|
|
160
|
+
console.log(`\n${fmt('bold', '[guardrail run]')} ${fmt('dim', configPath)}`);
|
|
138
161
|
console.log(`${fmt('dim', ` ${touchedFiles.length} changed file(s):`)} ${touchedFiles.slice(0, 5).join(', ')}${touchedFiles.length > 5 ? ` … +${touchedFiles.length - 5} more` : ''}`);
|
|
139
162
|
if (gitCtx.summary) {
|
|
140
163
|
console.log(fmt('dim', ` ${gitCtx.summary}`));
|
|
@@ -152,10 +175,13 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
152
175
|
let reviewEngine: ReviewEngine | undefined;
|
|
153
176
|
if (config.reviewEngine) {
|
|
154
177
|
const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine : config.reviewEngine.adapter;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
if (!detectLLMKey().hasKey && ['auto', 'claude', 'gemini', 'codex', 'openai-compatible'].includes(ref)) {
|
|
179
|
+
console.log(fmt('yellow', '\n [run] No LLM API key — set one of:'));
|
|
180
|
+
for (const { name, url, note } of LLM_KEY_HINTS) {
|
|
181
|
+
const suffix = note ? ` (${note})` : '';
|
|
182
|
+
console.log(fmt('dim', ` ${name.padEnd(18)} ${url}${suffix}`));
|
|
183
|
+
}
|
|
184
|
+
console.log('');
|
|
159
185
|
} else {
|
|
160
186
|
try {
|
|
161
187
|
reviewEngine = await loadAdapter<ReviewEngine>({
|
|
@@ -179,6 +205,18 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
179
205
|
config = { ...config, reviewStrategy: 'diff' };
|
|
180
206
|
}
|
|
181
207
|
|
|
208
|
+
// Pre-run cost estimate
|
|
209
|
+
if (config.cost?.estimateBeforeRun) {
|
|
210
|
+
const totalChars = touchedFiles.reduce((sum, f) => {
|
|
211
|
+
try { return sum + fs.statSync(f).size; } catch { return sum; }
|
|
212
|
+
}, 0);
|
|
213
|
+
const estTokens = Math.round(totalChars / 4);
|
|
214
|
+
const estCost = estTokens / 1_000_000 * 3.0; // rough: $3/M tokens (Sonnet)
|
|
215
|
+
const cap = config.cost?.maxPerRun;
|
|
216
|
+
console.log(fmt('dim', ` [run] estimated: ~${estTokens.toLocaleString()} tokens, ~$${estCost.toFixed(4)}`
|
|
217
|
+
+ (cap ? ` (cap: $${cap})` : '')));
|
|
218
|
+
}
|
|
219
|
+
|
|
182
220
|
// Execute pipeline
|
|
183
221
|
const input: RunInput = {
|
|
184
222
|
touchedFiles,
|
|
@@ -188,12 +226,19 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
188
226
|
cwd,
|
|
189
227
|
gitSummary: gitCtx.summary ?? undefined,
|
|
190
228
|
base: options.base,
|
|
229
|
+
skipReview: options.skipReview,
|
|
191
230
|
};
|
|
192
231
|
|
|
232
|
+
// Post pending commit status (best-effort — never fatal)
|
|
233
|
+
const commitSha = resolveCommitSha(cwd);
|
|
234
|
+
if (commitSha) {
|
|
235
|
+
postCommitStatus({ sha: commitSha, state: 'pending', description: `Reviewing ${touchedFiles.length} file(s)…`, cwd });
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
console.log('');
|
|
194
|
-
const result = await
|
|
239
|
+
const result = await runGuardrail(input);
|
|
195
240
|
|
|
196
|
-
// Apply .
|
|
241
|
+
// Apply .guardrail-ignore + config ignore: rules
|
|
197
242
|
const ignoreRules = [...loadIgnoreRules(cwd), ...parseConfigIgnore(config.ignore)];
|
|
198
243
|
if (ignoreRules.length > 0) {
|
|
199
244
|
const before = result.allFindings.length;
|
|
@@ -203,11 +248,11 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
203
248
|
}
|
|
204
249
|
const suppressed = before - result.allFindings.length;
|
|
205
250
|
if (suppressed > 0) {
|
|
206
|
-
console.log(fmt('dim', ` [run] ${suppressed} finding${suppressed !== 1 ? 's' : ''} suppressed by .
|
|
251
|
+
console.log(fmt('dim', ` [run] ${suppressed} finding${suppressed !== 1 ? 's' : ''} suppressed by .guardrail-ignore`));
|
|
207
252
|
}
|
|
208
253
|
}
|
|
209
254
|
|
|
210
|
-
// Delta mode: filter to only new findings vs last run's
|
|
255
|
+
// Delta mode: filter to only new findings vs last run's cache, then persist
|
|
211
256
|
if (options.delta) {
|
|
212
257
|
const cached = loadCachedFindings(cwd);
|
|
213
258
|
const before = result.allFindings.length;
|
|
@@ -220,7 +265,41 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
220
265
|
console.log(fmt('dim', ` [run] ${existing} pre-existing finding${existing !== 1 ? 's' : ''} hidden (--delta mode)`));
|
|
221
266
|
}
|
|
222
267
|
}
|
|
223
|
-
|
|
268
|
+
|
|
269
|
+
// --new-only / policy.newOnly: filter against committed .guardrail-baseline.json
|
|
270
|
+
const policy = config.policy ?? {};
|
|
271
|
+
const newOnly = options.newOnly ?? policy.newOnly ?? false;
|
|
272
|
+
if (newOnly) {
|
|
273
|
+
const baseline = loadBaseline(cwd, policy.baselinePath);
|
|
274
|
+
if (baseline) {
|
|
275
|
+
const { newFindings, baselinedCount } = filterBaselined(result.allFindings, baseline);
|
|
276
|
+
result.allFindings = newFindings;
|
|
277
|
+
for (const phase of result.phases) {
|
|
278
|
+
phase.findings = filterBaselined(phase.findings, baseline).newFindings;
|
|
279
|
+
}
|
|
280
|
+
if (baselinedCount > 0) {
|
|
281
|
+
console.log(fmt('dim', ` [run] ${baselinedCount} baselined finding${baselinedCount !== 1 ? 's' : ''} suppressed (--new-only)`));
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
console.log(fmt('yellow', ' [run] --new-only: no .guardrail-baseline.json found — showing all findings'));
|
|
285
|
+
console.log(fmt('dim', ' Run `guardrail baseline create` after first scan to pin the baseline'));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Triage filter: suppress accepted-risk / false-positive findings
|
|
290
|
+
const triageStore = loadTriage(cwd);
|
|
291
|
+
if (triageStore.entries.length > 0) {
|
|
292
|
+
const { active, triageCount } = filterTriaged(result.allFindings, triageStore);
|
|
293
|
+
if (triageCount > 0) {
|
|
294
|
+
result.allFindings = active;
|
|
295
|
+
for (const phase of result.phases) {
|
|
296
|
+
phase.findings = filterTriaged(phase.findings, triageStore).active;
|
|
297
|
+
}
|
|
298
|
+
console.log(fmt('dim', ` [run] ${triageCount} triaged finding${triageCount !== 1 ? 's' : ''} suppressed (accepted-risk / false-positive)`));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Always persist the unfiltered findings as the run cache
|
|
224
303
|
saveCachedFindings(cwd, result.allFindings);
|
|
225
304
|
|
|
226
305
|
// Append to per-run cost log
|
|
@@ -245,6 +324,14 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
245
324
|
console.log(fmt('dim', `[run] SARIF written to ${options.outputPath}`));
|
|
246
325
|
}
|
|
247
326
|
|
|
327
|
+
// Write JUnit XML output if requested
|
|
328
|
+
if (options.format === 'junit' && options.outputPath) {
|
|
329
|
+
const junit = toJUnit(result);
|
|
330
|
+
fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true });
|
|
331
|
+
fs.writeFileSync(options.outputPath, junit, 'utf8');
|
|
332
|
+
console.log(fmt('dim', `[run] JUnit XML written to ${options.outputPath}`));
|
|
333
|
+
}
|
|
334
|
+
|
|
248
335
|
// Post inline PR review comments if requested
|
|
249
336
|
if (options.inlineComments) {
|
|
250
337
|
const pr = detectPrNumber(cwd);
|
|
@@ -304,16 +391,33 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
304
391
|
console.log(`\n ${fmt('dim', `${result.durationMs}ms total`)}`);
|
|
305
392
|
}
|
|
306
393
|
|
|
307
|
-
//
|
|
394
|
+
// Post final commit status
|
|
395
|
+
if (commitSha) {
|
|
396
|
+
const critical = result.allFindings.filter(f => f.severity === 'critical').length;
|
|
397
|
+
const warnings = result.allFindings.filter(f => f.severity === 'warning').length;
|
|
398
|
+
const state = result.status === 'fail' ? 'failure' : 'success';
|
|
399
|
+
const desc = result.status === 'pass'
|
|
400
|
+
? 'All checks passed'
|
|
401
|
+
: result.status === 'warn'
|
|
402
|
+
? `Passed with ${warnings} warning${warnings !== 1 ? 's' : ''}`
|
|
403
|
+
: `${critical} critical finding${critical !== 1 ? 's' : ''}`;
|
|
404
|
+
postCommitStatus({ sha: commitSha, state, description: desc, cwd });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Final verdict — apply policy.failOn threshold
|
|
408
|
+
const failOn = options.failOn ?? policy.failOn ?? 'critical';
|
|
409
|
+
const exitCode = computeExitCode(result.allFindings, failOn);
|
|
410
|
+
|
|
308
411
|
console.log('');
|
|
309
|
-
if (result.status
|
|
412
|
+
if (exitCode === 0 && result.status !== 'pass') {
|
|
413
|
+
const reason = failOn === 'none' ? ' (policy: fail-on=none)' : ` (policy: fail-on=${failOn})`;
|
|
414
|
+
console.log(fmt('yellow', `[run] ! Passed with findings${reason}\n`));
|
|
415
|
+
} else if (result.status === 'pass') {
|
|
310
416
|
console.log(fmt('green', '[run] ✓ All phases passed\n'));
|
|
311
|
-
return 0;
|
|
312
417
|
} else if (result.status === 'warn') {
|
|
313
418
|
console.log(fmt('yellow', '[run] ! Passed with warnings\n'));
|
|
314
|
-
return 0;
|
|
315
419
|
} else {
|
|
316
420
|
console.log(fmt('red', '[run] ✗ Pipeline failed — see findings above\n'));
|
|
317
|
-
return 1;
|
|
318
421
|
}
|
|
422
|
+
return exitCode;
|
|
319
423
|
}
|