@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.2
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 +63 -0
- package/README.md +169 -106
- package/bin/_launcher.js +77 -0
- package/bin/claude-autopilot.js +3 -0
- package/bin/guardrail.js +3 -0
- package/package.json +23 -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 +2 -2
- 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 +173 -111
- package/src/cli/hook.ts +72 -27
- package/src/cli/ignore-helper.ts +116 -0
- package/src/cli/index.ts +355 -31
- 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 +109 -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 +10 -10
- package/src/core/config/preset-resolver.ts +6 -6
- package/src/core/config/schema.ts +103 -2
- 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/detector.ts
CHANGED
|
@@ -34,6 +34,21 @@ function nodeTestCommand(cwd: string): string {
|
|
|
34
34
|
return cmd;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Detects Supabase signals beyond package.json deps — env vars, config files, or client
|
|
38
|
+
// usage. Required because many Next.js projects reference Supabase via the CLI/SSR tooling
|
|
39
|
+
// before installing the JS client.
|
|
40
|
+
function hasSupabaseSignals(cwd: string, deps: Record<string, string>): boolean {
|
|
41
|
+
if ('@supabase/supabase-js' in deps) return true;
|
|
42
|
+
if ('@supabase/ssr' in deps) return true;
|
|
43
|
+
if ('@supabase/auth-helpers-nextjs' in deps) return true;
|
|
44
|
+
if (fs.existsSync(path.join(cwd, 'supabase', 'config.toml'))) return true;
|
|
45
|
+
for (const envFile of ['.env', '.env.local', '.env.development']) {
|
|
46
|
+
const p = path.join(cwd, envFile);
|
|
47
|
+
if (fs.existsSync(p) && fileContains(p, 'SUPABASE_')) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export function detectProject(cwd: string): DetectionResult {
|
|
38
53
|
if (fs.existsSync(path.join(cwd, 'go.mod'))) {
|
|
39
54
|
return { preset: 'go', testCommand: 'go test ./...', confidence: 'high', evidence: 'found go.mod' };
|
|
@@ -63,14 +78,15 @@ export function detectProject(cwd: string): DetectionResult {
|
|
|
63
78
|
if ('@trpc/server' in deps) {
|
|
64
79
|
return { preset: 't3', testCommand: testCmd, confidence: 'high', evidence: 'found @trpc/server in package.json' };
|
|
65
80
|
}
|
|
66
|
-
if ('next' in deps &&
|
|
67
|
-
return { preset: 'nextjs-supabase', testCommand: testCmd, confidence: 'high', evidence: 'found next +
|
|
81
|
+
if ('next' in deps && hasSupabaseSignals(cwd, deps)) {
|
|
82
|
+
return { preset: 'nextjs-supabase', testCommand: testCmd, confidence: 'high', evidence: 'found next + supabase signals (deps/env/config)' };
|
|
68
83
|
}
|
|
69
84
|
if ('next' in deps) {
|
|
70
|
-
|
|
85
|
+
// Plain Next.js — fall through to generic rather than mislabel as nextjs-supabase.
|
|
86
|
+
return { preset: 'generic', testCommand: testCmd, confidence: 'low', evidence: 'found next in package.json but no Supabase signals — using generic preset (pass --preset nextjs-supabase if you do use Supabase)' };
|
|
71
87
|
}
|
|
72
|
-
return { preset: '
|
|
88
|
+
return { preset: 'generic', testCommand: testCmd, confidence: 'low', evidence: 'found package.json (no strong framework signals) — using generic preset' };
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
return { preset: '
|
|
91
|
+
return { preset: 'generic', testCommand: 'npm test', confidence: 'low', evidence: 'no project signals found — using generic preset' };
|
|
76
92
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { loadConfig } from '../core/config/loader.ts';
|
|
5
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
6
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
7
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
8
|
+
|
|
9
|
+
const C = {
|
|
10
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
11
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m',
|
|
12
|
+
};
|
|
13
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
14
|
+
|
|
15
|
+
const CONTEXT_LINES = 30;
|
|
16
|
+
|
|
17
|
+
const SECTION_ORDER = ['Root Cause', 'Risk', 'How to Fix', 'Example', 'When to Suppress'] as const;
|
|
18
|
+
type SectionName = typeof SECTION_ORDER[number];
|
|
19
|
+
|
|
20
|
+
export interface ExplainCommandOptions {
|
|
21
|
+
cwd?: string;
|
|
22
|
+
configPath?: string;
|
|
23
|
+
target?: string; // "file:line" or finding index (1-based) or finding id
|
|
24
|
+
index?: number; // 1-based index into cached findings
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pickFinding(findings: Finding[], target: string): Finding | null {
|
|
28
|
+
// Try "file:line" format
|
|
29
|
+
const colonIdx = target.lastIndexOf(':');
|
|
30
|
+
if (colonIdx > 0) {
|
|
31
|
+
const file = target.slice(0, colonIdx);
|
|
32
|
+
const line = parseInt(target.slice(colonIdx + 1), 10);
|
|
33
|
+
if (!isNaN(line)) {
|
|
34
|
+
const match = findings.find(f => f.file.endsWith(file) && f.line === line);
|
|
35
|
+
if (match) return match;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Try numeric index (1-based)
|
|
39
|
+
const n = parseInt(target, 10);
|
|
40
|
+
if (!isNaN(n) && n >= 1 && n <= findings.length) return findings[n - 1]!;
|
|
41
|
+
// Try finding id prefix
|
|
42
|
+
const byId = findings.find(f => f.id.startsWith(target));
|
|
43
|
+
if (byId) return byId;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse LLM output into named sections by ## headers. */
|
|
48
|
+
function parseSections(text: string): Map<string, string> {
|
|
49
|
+
const map = new Map<string, string>();
|
|
50
|
+
const parts = text.split(/^##\s+/m);
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
const newline = part.indexOf('\n');
|
|
53
|
+
if (newline < 0) continue;
|
|
54
|
+
const header = part.slice(0, newline).trim();
|
|
55
|
+
const body = part.slice(newline + 1).trim();
|
|
56
|
+
if (header && body) map.set(header, body);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printSection(label: SectionName, body: string): void {
|
|
62
|
+
const icon: Record<SectionName, string> = {
|
|
63
|
+
'Root Cause': '🔍',
|
|
64
|
+
'Risk': '⚠️ ',
|
|
65
|
+
'How to Fix': '🔧',
|
|
66
|
+
'Example': '📝',
|
|
67
|
+
'When to Suppress': '✅',
|
|
68
|
+
};
|
|
69
|
+
console.log(`\n${fmt('bold', `${icon[label]} ${label}`)}`);
|
|
70
|
+
console.log(fmt('dim', '─'.repeat(50)));
|
|
71
|
+
console.log(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runExplain(options: ExplainCommandOptions = {}): Promise<number> {
|
|
75
|
+
const cwd = options.cwd ?? process.cwd();
|
|
76
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
77
|
+
|
|
78
|
+
const findings = loadCachedFindings(cwd);
|
|
79
|
+
if (findings.length === 0) {
|
|
80
|
+
console.log(fmt('yellow', '[explain] No cached findings — run `guardrail run` or `guardrail scan` first.'));
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let finding: Finding | null = null;
|
|
85
|
+
|
|
86
|
+
if (options.target) {
|
|
87
|
+
finding = pickFinding(findings, options.target);
|
|
88
|
+
if (!finding) {
|
|
89
|
+
console.error(fmt('red', `[explain] No finding matching "${options.target}"`));
|
|
90
|
+
console.error(fmt('dim', ' Use file:line, finding index (1–' + findings.length + '), or rule id'));
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// No target — list findings and prompt
|
|
95
|
+
console.log(`\n${fmt('bold', '[guardrail explain]')} ${findings.length} cached finding${findings.length !== 1 ? 's' : ''}:\n`);
|
|
96
|
+
findings.forEach((f, i) => {
|
|
97
|
+
const sev = f.severity === 'critical' ? fmt('red', 'CRIT') : f.severity === 'warning' ? fmt('yellow', 'WARN') : fmt('dim', 'NOTE');
|
|
98
|
+
const loc = f.file !== '<unspecified>' ? fmt('dim', ` ${f.file}${f.line ? `:${f.line}` : ''}`) : '';
|
|
99
|
+
console.log(` ${String(i + 1).padStart(2)}. [${sev}]${loc} ${f.message.slice(0, 70)}`);
|
|
100
|
+
});
|
|
101
|
+
console.log(fmt('dim', '\n Run: guardrail explain <index|file:line|rule-id>\n'));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Load engine
|
|
106
|
+
let engine;
|
|
107
|
+
try {
|
|
108
|
+
const config = fs.existsSync(configPath) ? await loadConfig(configPath) : { configVersion: 1 as const };
|
|
109
|
+
const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine
|
|
110
|
+
: (config.reviewEngine?.adapter ?? 'auto');
|
|
111
|
+
engine = await loadAdapter({ point: 'review-engine', ref,
|
|
112
|
+
options: typeof config.reviewEngine === 'object' ? config.reviewEngine.options : undefined });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(fmt('red', `[explain] Could not load review engine: ${err instanceof Error ? err.message : String(err)}`));
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read file context
|
|
119
|
+
let codeContext = '';
|
|
120
|
+
if (finding.file && finding.file !== '<unspecified>' && finding.file !== '<pipeline>' && finding.line) {
|
|
121
|
+
const absPath = path.resolve(cwd, finding.file);
|
|
122
|
+
if (fs.existsSync(absPath)) {
|
|
123
|
+
const lines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
124
|
+
const lineIdx = finding.line - 1;
|
|
125
|
+
const start = Math.max(0, lineIdx - CONTEXT_LINES);
|
|
126
|
+
const end = Math.min(lines.length - 1, lineIdx + CONTEXT_LINES);
|
|
127
|
+
const numbered = lines.slice(start, end + 1).map((l, i) => {
|
|
128
|
+
const n = start + i + 1;
|
|
129
|
+
return `${n === finding!.line ? '>>>' : ' '} ${String(n).padStart(4)}: ${l}`;
|
|
130
|
+
}).join('\n');
|
|
131
|
+
codeContext = `\n\nRelevant code from ${finding.file} (>>> marks the finding):\n\`\`\`\n${numbered}\n\`\`\``;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const prompt = [
|
|
136
|
+
`I need a structured explanation of this code finding:`,
|
|
137
|
+
``,
|
|
138
|
+
`Rule: ${finding.id}`,
|
|
139
|
+
`Severity: ${finding.severity.toUpperCase()}`,
|
|
140
|
+
`File: ${finding.file}${finding.line ? `:${finding.line}` : ''}`,
|
|
141
|
+
`Message: ${finding.message}`,
|
|
142
|
+
finding.suggestion ? `Suggestion: ${finding.suggestion}` : '',
|
|
143
|
+
codeContext,
|
|
144
|
+
``,
|
|
145
|
+
`Respond using EXACTLY these five section headers (no other headers):`,
|
|
146
|
+
``,
|
|
147
|
+
`## Root Cause`,
|
|
148
|
+
`[technical explanation of why this problem exists]`,
|
|
149
|
+
``,
|
|
150
|
+
`## Risk`,
|
|
151
|
+
`[real-world impact, exploitability, and severity rationale]`,
|
|
152
|
+
``,
|
|
153
|
+
`## How to Fix`,
|
|
154
|
+
`[concrete, code-level remediation steps]`,
|
|
155
|
+
``,
|
|
156
|
+
`## Example`,
|
|
157
|
+
`[before/after code snippet showing the fix, in a fenced code block]`,
|
|
158
|
+
``,
|
|
159
|
+
`## When to Suppress`,
|
|
160
|
+
`[legitimate cases where this finding can be safely ignored]`,
|
|
161
|
+
].filter(s => s !== undefined && s !== null).join('\n');
|
|
162
|
+
|
|
163
|
+
const sev = finding.severity === 'critical' ? fmt('red', 'CRITICAL')
|
|
164
|
+
: finding.severity === 'warning' ? fmt('yellow', 'WARNING') : fmt('dim', 'NOTE');
|
|
165
|
+
|
|
166
|
+
console.log(`\n${fmt('bold', '[guardrail explain]')}`);
|
|
167
|
+
console.log(` [${sev}] ${fmt('dim', `${finding.file}${finding.line ? `:${finding.line}` : ''}`)} — ${finding.message}`);
|
|
168
|
+
if (finding.suggestion) console.log(` ${fmt('dim', `→ ${finding.suggestion}`)}`);
|
|
169
|
+
console.log('');
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const output = await (engine as unknown as ReviewEngine).review({ content: prompt, kind: 'file-batch' });
|
|
173
|
+
const text = output.rawOutput.trim();
|
|
174
|
+
const sections = parseSections(text);
|
|
175
|
+
|
|
176
|
+
let printed = false;
|
|
177
|
+
for (const label of SECTION_ORDER) {
|
|
178
|
+
const body = sections.get(label);
|
|
179
|
+
if (body) {
|
|
180
|
+
printSection(label, body);
|
|
181
|
+
printed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!printed) {
|
|
186
|
+
// LLM didn't follow the structure — print raw
|
|
187
|
+
console.log(text);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log('');
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error(fmt('red', `[explain] LLM error: ${err instanceof Error ? err.message : String(err)}`));
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
package/src/cli/fix.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import * as readline from 'node:readline';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
3
5
|
import { loadCachedFindings } from '../core/persist/findings-cache.ts';
|
|
4
6
|
import { loadConfig } from '../core/config/loader.ts';
|
|
5
7
|
import { loadAdapter } from '../adapters/loader.ts';
|
|
6
8
|
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
7
9
|
import type { Finding } from '../core/findings/types.ts';
|
|
10
|
+
import type { GuardrailConfig } from '../core/config/types.ts';
|
|
11
|
+
import { generateFix, buildUnifiedDiff, type GenerateResult } from '../core/fix/generator.ts';
|
|
8
12
|
|
|
9
13
|
const C = {
|
|
10
14
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
@@ -12,36 +16,48 @@ const C = {
|
|
|
12
16
|
};
|
|
13
17
|
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
14
18
|
|
|
15
|
-
const CONTEXT_LINES = 20; // lines of file context to send on each side of the finding
|
|
16
|
-
|
|
17
19
|
export interface FixCommandOptions {
|
|
18
20
|
cwd?: string;
|
|
19
21
|
configPath?: string;
|
|
20
|
-
severity?: 'critical' | 'warning' | 'all';
|
|
22
|
+
severity?: 'critical' | 'warning' | 'all';
|
|
21
23
|
dryRun?: boolean;
|
|
24
|
+
yes?: boolean; // skip per-fix confirmation prompts
|
|
25
|
+
noVerify?: boolean; // skip test verification after applying fix
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
interface FixResult {
|
|
25
29
|
file: string;
|
|
26
30
|
line: number;
|
|
27
31
|
findingMessage: string;
|
|
28
|
-
status: 'fixed' | 'skipped' | 'failed';
|
|
32
|
+
status: 'fixed' | 'skipped' | 'rejected' | 'failed';
|
|
29
33
|
reason?: string;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
async function confirmFix(diff: string, finding: Finding): Promise<'yes' | 'no' | 'quit'> {
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log(diff);
|
|
39
|
+
console.log('');
|
|
40
|
+
|
|
41
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
rl.question(fmt('bold', ' Apply this fix? [y]es / [n]o / [q]uit '), answer => {
|
|
44
|
+
rl.close();
|
|
45
|
+
const a = answer.trim().toLowerCase();
|
|
46
|
+
if (a === 'q') resolve('quit');
|
|
47
|
+
else if (a === 'y' || a === '') resolve('yes');
|
|
48
|
+
else resolve('no');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
export async function runFix(options: FixCommandOptions = {}): Promise<number> {
|
|
33
54
|
const cwd = options.cwd ?? process.cwd();
|
|
34
|
-
const configPath = options.configPath ?? path.join(cwd, '
|
|
55
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
35
56
|
const severityFilter = options.severity ?? 'critical';
|
|
36
57
|
|
|
37
|
-
if (!fs.existsSync(configPath)) {
|
|
38
|
-
console.error(fmt('red', `[fix] autopilot.config.yaml not found at ${configPath}`));
|
|
39
|
-
return 1;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
58
|
const findings = loadCachedFindings(cwd);
|
|
43
59
|
if (findings.length === 0) {
|
|
44
|
-
console.log(fmt('yellow', '[fix] No cached findings — run `
|
|
60
|
+
console.log(fmt('yellow', '[fix] No cached findings — run `guardrail scan <path>` or `guardrail run` first.'));
|
|
45
61
|
return 0;
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -57,131 +73,177 @@ export async function runFix(options: FixCommandOptions = {}): Promise<number> {
|
|
|
57
73
|
return 0;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
|
|
76
|
+
const modeNote = options.dryRun ? ' (dry run)' : options.yes ? '' : ' (interactive — use --yes to skip prompts)';
|
|
77
|
+
console.log(`\n${fmt('bold', '[guardrail fix]')} ${fixable.length} finding${fixable.length !== 1 ? 's' : ''} to attempt${modeNote}\n`);
|
|
61
78
|
|
|
62
|
-
//
|
|
79
|
+
// Print upfront summary of all fixable findings before prompting
|
|
80
|
+
for (const f of fixable) {
|
|
81
|
+
const sev = f.severity === 'critical' ? fmt('red', 'CRITICAL') : fmt('yellow', 'WARNING ');
|
|
82
|
+
const loc = fmt('dim', `${f.file}:${f.line}`);
|
|
83
|
+
console.log(` [${sev}] ${loc} ${f.message}`);
|
|
84
|
+
if (f.suggestion) console.log(fmt('dim', ` → ${f.suggestion}`));
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|
|
87
|
+
|
|
88
|
+
// Dry-run: listing the findings is sufficient — no LLM needed
|
|
89
|
+
if (options.dryRun) {
|
|
90
|
+
console.log(fmt('yellow', `[fix] Dry run — ${fixable.length} finding${fixable.length !== 1 ? 's' : ''} listed above, no files modified.\n`));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Load config + review engine (config optional — defaults to auto adapter)
|
|
63
95
|
let engine: ReviewEngine;
|
|
96
|
+
let loadedConfig: GuardrailConfig | null = null;
|
|
64
97
|
try {
|
|
65
|
-
|
|
66
|
-
const ref =
|
|
67
|
-
: (
|
|
98
|
+
loadedConfig = fs.existsSync(configPath) ? await loadConfig(configPath) : null;
|
|
99
|
+
const ref = loadedConfig
|
|
100
|
+
? (typeof loadedConfig.reviewEngine === 'string' ? loadedConfig.reviewEngine : (loadedConfig.reviewEngine?.adapter ?? 'auto'))
|
|
101
|
+
: 'auto';
|
|
68
102
|
engine = await loadAdapter<ReviewEngine>({
|
|
69
103
|
point: 'review-engine',
|
|
70
104
|
ref,
|
|
71
|
-
options: typeof
|
|
105
|
+
options: loadedConfig && typeof loadedConfig.reviewEngine === 'object' ? loadedConfig.reviewEngine.options : undefined,
|
|
72
106
|
});
|
|
73
107
|
} catch (err) {
|
|
74
108
|
console.error(fmt('red', `[fix] Could not load review engine: ${err instanceof Error ? err.message : String(err)}`));
|
|
75
109
|
return 1;
|
|
76
110
|
}
|
|
77
111
|
|
|
112
|
+
const testCommand = loadedConfig?.testCommand ?? null;
|
|
113
|
+
const shouldVerify = !options.noVerify && !!testCommand;
|
|
114
|
+
if (shouldVerify) {
|
|
115
|
+
console.log(fmt('dim', `[fix] Verified mode — running "${testCommand}" after each fix\n`));
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
const results: FixResult[] = [];
|
|
119
|
+
let quit = false;
|
|
79
120
|
|
|
80
121
|
for (const finding of fixable) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
console.log(
|
|
88
|
-
|
|
122
|
+
if (quit) {
|
|
123
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped', reason: 'user quit' });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sev = finding.severity === 'critical' ? fmt('red', 'CRITICAL') : fmt('yellow', 'WARNING');
|
|
128
|
+
console.log(`\n [${sev}] ${fmt('dim', `${finding.file}:${finding.line}`)} ${finding.message}`);
|
|
129
|
+
|
|
130
|
+
const result = await generateFix(finding, engine, cwd);
|
|
131
|
+
|
|
132
|
+
if (result.status === 'cannot_fix') {
|
|
133
|
+
console.log(fmt('dim', ` → skipped: ${result.reason}`));
|
|
134
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped', reason: result.reason });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result.status === 'rejected') {
|
|
139
|
+
console.log(fmt('yellow', ` → rejected: ${result.reason}`));
|
|
140
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'rejected', reason: result.reason });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (result.status === 'error') {
|
|
145
|
+
console.log(fmt('red', ` → error: ${result.reason}`));
|
|
146
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'failed', reason: result.reason });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Show diff
|
|
151
|
+
const diff = buildUnifiedDiff(result.originalLines!, result.replacementLines!, finding.file, result.startLine!);
|
|
152
|
+
|
|
153
|
+
if (options.dryRun) {
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(diff);
|
|
156
|
+
console.log(fmt('dim', ' (dry run — not applied)'));
|
|
157
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped', reason: 'dry run' });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Interactive confirmation (unless --yes)
|
|
162
|
+
if (!options.yes) {
|
|
163
|
+
const answer = await confirmFix(diff, finding);
|
|
164
|
+
if (answer === 'quit') {
|
|
165
|
+
quit = true;
|
|
166
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped', reason: 'user quit' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (answer === 'no') {
|
|
170
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped', reason: 'user declined' });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// --yes mode: still print the diff so there's a record
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(diff);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Apply fix atomically
|
|
180
|
+
try {
|
|
181
|
+
const absPath = path.resolve(cwd, finding.file);
|
|
182
|
+
const originalContent = fs.readFileSync(absPath, 'utf8');
|
|
183
|
+
const allLines = originalContent.split('\n');
|
|
184
|
+
const newLines = [
|
|
185
|
+
...allLines.slice(0, result.startLine! - 1),
|
|
186
|
+
...result.replacementLines!,
|
|
187
|
+
...allLines.slice(result.endLine!),
|
|
188
|
+
];
|
|
189
|
+
const tmp = absPath + '.guardrail.tmp';
|
|
190
|
+
fs.writeFileSync(tmp, newLines.join('\n'), 'utf8');
|
|
191
|
+
fs.renameSync(tmp, absPath);
|
|
192
|
+
|
|
193
|
+
if (shouldVerify) {
|
|
194
|
+
// Verified mode — same shell invocation pattern as phases/tests.ts
|
|
195
|
+
console.log(fmt('dim', ` ↻ verifying…`));
|
|
196
|
+
const passed = runTestCommand(testCommand!, cwd);
|
|
197
|
+
if (passed) {
|
|
198
|
+
console.log(fmt('green', ` ✓ applied + tests pass`));
|
|
199
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'fixed' });
|
|
200
|
+
} else {
|
|
201
|
+
fs.writeFileSync(absPath, originalContent, 'utf8');
|
|
202
|
+
console.log(fmt('yellow', ` ⚠ reverted — tests failed after fix`));
|
|
203
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'rejected', reason: 'tests failed after fix — reverted' });
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
console.log(fmt('green', ` ✓ applied`));
|
|
207
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'fixed' });
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.log(fmt('red', ` ✗ write failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
211
|
+
results.push({ file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'failed', reason: String(err) });
|
|
212
|
+
}
|
|
89
213
|
}
|
|
90
214
|
|
|
91
|
-
const fixed
|
|
92
|
-
const
|
|
215
|
+
const fixed = results.filter(r => r.status === 'fixed').length;
|
|
216
|
+
const rejected = results.filter(r => r.status === 'rejected').length;
|
|
217
|
+
const failed = results.filter(r => r.status === 'failed').length;
|
|
218
|
+
const skipped = results.filter(r => r.status === 'skipped').length;
|
|
219
|
+
|
|
93
220
|
console.log('');
|
|
94
221
|
if (options.dryRun) {
|
|
95
|
-
console.log(fmt('yellow', `[fix] Dry run —
|
|
222
|
+
console.log(fmt('yellow', `[fix] Dry run complete — ${fixable.length} finding${fixable.length !== 1 ? 's' : ''} previewed, no files modified.\n`));
|
|
96
223
|
} else {
|
|
97
|
-
|
|
224
|
+
const parts = [
|
|
225
|
+
fixed > 0 ? fmt('green', `${fixed} fixed`) : null,
|
|
226
|
+
rejected > 0 ? fmt('yellow', `${rejected} rejected`) : null,
|
|
227
|
+
failed > 0 ? fmt('red', `${failed} failed`) : null,
|
|
228
|
+
skipped > 0 ? fmt('dim', `${skipped} skipped`) : null,
|
|
229
|
+
].filter(Boolean).join(fmt('dim', ' · '));
|
|
230
|
+
console.log(`[fix] ${parts}\n`);
|
|
98
231
|
}
|
|
232
|
+
|
|
99
233
|
return failed > 0 ? 1 : 0;
|
|
100
234
|
}
|
|
101
235
|
|
|
102
|
-
|
|
103
|
-
finding: Finding,
|
|
104
|
-
engine: ReviewEngine,
|
|
105
|
-
cwd: string,
|
|
106
|
-
dryRun: boolean,
|
|
107
|
-
): Promise<FixResult> {
|
|
108
|
-
const base: FixResult = { file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped' };
|
|
109
|
-
|
|
110
|
-
const absPath = path.resolve(cwd, finding.file);
|
|
111
|
-
let fileContent: string;
|
|
236
|
+
function runTestCommand(cmd: string, cwd: string): boolean {
|
|
112
237
|
try {
|
|
113
|
-
|
|
238
|
+
execSync(cmd, {
|
|
239
|
+
cwd,
|
|
240
|
+
stdio: 'ignore',
|
|
241
|
+
timeout: 120000,
|
|
242
|
+
shell: process.env.SHELL ?? '/bin/sh',
|
|
243
|
+
});
|
|
244
|
+
return true;
|
|
114
245
|
} catch {
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const lines = fileContent.split('\n');
|
|
119
|
-
const lineIdx = finding.line! - 1;
|
|
120
|
-
if (lineIdx < 0 || lineIdx >= lines.length) {
|
|
121
|
-
return { ...base, status: 'skipped', reason: 'line out of range' };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const startIdx = Math.max(0, lineIdx - CONTEXT_LINES);
|
|
125
|
-
const endIdx = Math.min(lines.length - 1, lineIdx + CONTEXT_LINES);
|
|
126
|
-
const contextLines = lines.slice(startIdx, endIdx + 1);
|
|
127
|
-
const startLine = startIdx + 1;
|
|
128
|
-
|
|
129
|
-
const numbered = contextLines.map((l, i) => {
|
|
130
|
-
const n = startLine + i;
|
|
131
|
-
const marker = n === finding.line ? '>>>' : ' ';
|
|
132
|
-
return `${marker} ${String(n).padStart(4)}: ${l}`;
|
|
133
|
-
}).join('\n');
|
|
134
|
-
|
|
135
|
-
const prompt = [
|
|
136
|
-
`File: ${finding.file}`,
|
|
137
|
-
`Finding (line ${finding.line}): [${finding.severity.toUpperCase()}] ${finding.message}`,
|
|
138
|
-
finding.suggestion ? `Suggestion: ${finding.suggestion}` : '',
|
|
139
|
-
'',
|
|
140
|
-
'Here are the relevant lines (>>> marks the finding):',
|
|
141
|
-
'```',
|
|
142
|
-
numbered,
|
|
143
|
-
'```',
|
|
144
|
-
'',
|
|
145
|
-
`Rewrite ONLY lines ${startLine}–${endIdx + 1} to fix this finding.`,
|
|
146
|
-
'Rules:',
|
|
147
|
-
'- Output ONLY the replacement lines, no explanation, no markdown fences',
|
|
148
|
-
'- Preserve indentation and line count as much as possible',
|
|
149
|
-
'- Make the minimal change needed to fix the finding',
|
|
150
|
-
'- If the fix cannot be done safely in this context, output exactly: CANNOT_FIX',
|
|
151
|
-
].filter(Boolean).join('\n');
|
|
152
|
-
|
|
153
|
-
let rawOutput: string;
|
|
154
|
-
try {
|
|
155
|
-
const output = await engine.review({ content: prompt, kind: 'file-batch' });
|
|
156
|
-
rawOutput = output.rawOutput.trim();
|
|
157
|
-
} catch (err) {
|
|
158
|
-
return { ...base, status: 'failed', reason: `LLM error: ${err instanceof Error ? err.message : String(err)}` };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (rawOutput === 'CANNOT_FIX' || rawOutput.includes('CANNOT_FIX')) {
|
|
162
|
-
return { ...base, status: 'skipped', reason: 'LLM: cannot fix safely' };
|
|
246
|
+
return false;
|
|
163
247
|
}
|
|
164
|
-
|
|
165
|
-
// Strip markdown fences if the model added them despite instructions
|
|
166
|
-
const cleaned = rawOutput.replace(/^```[a-z]*\n?/m, '').replace(/\n?```$/m, '').trimEnd();
|
|
167
|
-
const replacementLines = cleaned.split('\n');
|
|
168
|
-
|
|
169
|
-
if (dryRun) {
|
|
170
|
-
return { ...base, status: 'fixed', reason: `(dry run) would replace lines ${startLine}–${endIdx + 1}` };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Splice replacement into file
|
|
174
|
-
const newLines = [
|
|
175
|
-
...lines.slice(0, startIdx),
|
|
176
|
-
...replacementLines,
|
|
177
|
-
...lines.slice(endIdx + 1),
|
|
178
|
-
];
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
fs.writeFileSync(absPath, newLines.join('\n'), 'utf8');
|
|
182
|
-
} catch (err) {
|
|
183
|
-
return { ...base, status: 'failed', reason: `write error: ${err instanceof Error ? err.message : String(err)}` };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return { ...base, status: 'fixed' };
|
|
187
248
|
}
|
|
249
|
+
|