@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/scan.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
4
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
5
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
6
|
+
import { runReviewPhase } from '../core/pipeline/review-phase.ts';
|
|
7
|
+
import { detectStack } from '../core/detect/stack.ts';
|
|
8
|
+
import { loadIgnoreRules, parseConfigIgnore, applyIgnoreRules } from '../core/ignore/index.ts';
|
|
9
|
+
import { saveCachedFindings } from '../core/persist/findings-cache.ts';
|
|
10
|
+
import type { GuardrailConfig } from '../core/config/types.ts';
|
|
11
|
+
import { detectLLMKey, LLM_KEY_HINTS } from '../core/detect/llm-key.ts';
|
|
12
|
+
|
|
13
|
+
const C = {
|
|
14
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
15
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m',
|
|
16
|
+
};
|
|
17
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
18
|
+
|
|
19
|
+
const IGNORED_DIRS = new Set([
|
|
20
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'coverage',
|
|
21
|
+
'.guardrail-cache', '.autopilot', '__pycache__', '.venv', 'vendor',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const CODE_EXTS = new Set([
|
|
25
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
26
|
+
'.py', '.go', '.rb', '.rs', '.java', '.kt', '.swift',
|
|
27
|
+
'.c', '.cpp', '.h', '.cs', '.php',
|
|
28
|
+
'.sql', '.sh', '.bash', '.yaml', '.yml', '.json', '.toml',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function collectFiles(target: string, cwd: string): string[] {
|
|
32
|
+
const abs = path.isAbsolute(target) ? target : path.resolve(cwd, target);
|
|
33
|
+
if (!fs.existsSync(abs)) return [];
|
|
34
|
+
|
|
35
|
+
const stat = fs.statSync(abs);
|
|
36
|
+
if (stat.isFile()) return [abs];
|
|
37
|
+
|
|
38
|
+
const results: string[] = [];
|
|
39
|
+
function walk(dir: string) {
|
|
40
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
42
|
+
const full = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
walk(full);
|
|
45
|
+
} else if (entry.isFile() && CODE_EXTS.has(path.extname(entry.name))) {
|
|
46
|
+
results.push(full);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(abs);
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectAllFiles(cwd: string): string[] {
|
|
55
|
+
return collectFiles(cwd, cwd);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ScanCommandOptions {
|
|
59
|
+
cwd?: string;
|
|
60
|
+
configPath?: string;
|
|
61
|
+
targets?: string[]; // explicit paths/dirs to scan
|
|
62
|
+
all?: boolean; // scan entire codebase
|
|
63
|
+
ask?: string; // targeted question to inject into review prompt
|
|
64
|
+
focus?: 'security' | 'logic' | 'performance' | 'brand' | 'all';
|
|
65
|
+
dryRun?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runScan(options: ScanCommandOptions = {}): Promise<number> {
|
|
69
|
+
const cwd = options.cwd ?? process.cwd();
|
|
70
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
71
|
+
|
|
72
|
+
let config: GuardrailConfig = { configVersion: 1 };
|
|
73
|
+
if (fs.existsSync(configPath)) {
|
|
74
|
+
const loaded = await loadConfig(configPath);
|
|
75
|
+
if (loaded) config = loaded;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Collect files
|
|
79
|
+
let files: string[];
|
|
80
|
+
if (options.all) {
|
|
81
|
+
files = collectAllFiles(cwd);
|
|
82
|
+
} else if (options.targets && options.targets.length > 0) {
|
|
83
|
+
files = options.targets.flatMap(t => collectFiles(t, cwd));
|
|
84
|
+
} else {
|
|
85
|
+
console.error(fmt('red', '[scan] Specify a path, --all, or use `guardrail run` for git-changed files'));
|
|
86
|
+
console.error(fmt('dim', ' Examples:'));
|
|
87
|
+
console.error(fmt('dim', ' guardrail scan src/auth/'));
|
|
88
|
+
console.error(fmt('dim', ' guardrail scan --all'));
|
|
89
|
+
console.error(fmt('dim', ' guardrail scan --ask "is there SQL injection?" src/db/'));
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Deduplicate
|
|
94
|
+
files = [...new Set(files)];
|
|
95
|
+
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
console.log(fmt('yellow', '[scan] No code files found at the specified path(s)'));
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (options.dryRun) {
|
|
102
|
+
console.log(fmt('bold', `[scan] Would scan ${files.length} file(s):`));
|
|
103
|
+
for (const f of files) console.log(fmt('dim', ` ${path.relative(cwd, f)}`));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Auto-detect stack if not in config
|
|
108
|
+
if (!config.stack) {
|
|
109
|
+
config = { ...config, stack: detectStack(cwd) ?? undefined };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build review engine
|
|
113
|
+
if (!detectLLMKey().hasKey) {
|
|
114
|
+
console.error(fmt('red', '[scan] No LLM API key — set one of:'));
|
|
115
|
+
for (const { name, url, note } of LLM_KEY_HINTS) {
|
|
116
|
+
const suffix = note ? ` (${note})` : '';
|
|
117
|
+
console.error(fmt('dim', ` ${name.padEnd(18)} ${url}${suffix}`));
|
|
118
|
+
}
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const engineRef = typeof config.reviewEngine === 'string' ? config.reviewEngine
|
|
122
|
+
: (config.reviewEngine?.adapter ?? 'auto');
|
|
123
|
+
let engine: ReviewEngine;
|
|
124
|
+
try {
|
|
125
|
+
engine = await loadAdapter<ReviewEngine>({
|
|
126
|
+
point: 'review-engine',
|
|
127
|
+
ref: engineRef,
|
|
128
|
+
options: typeof config.reviewEngine === 'object' ? config.reviewEngine.options as Record<string, unknown> : undefined,
|
|
129
|
+
});
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error(fmt('red', `[scan] Could not load review engine: ${err instanceof Error ? err.message : String(err)}`));
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const focusLabel = options.focus && options.focus !== 'all' ? options.focus : null;
|
|
136
|
+
const relFiles = files.map(f => path.relative(cwd, f));
|
|
137
|
+
|
|
138
|
+
console.log('');
|
|
139
|
+
const scopeDesc = options.all ? 'entire codebase' : relFiles.slice(0, 3).join(', ') + (relFiles.length > 3 ? ` +${relFiles.length - 3} more` : '');
|
|
140
|
+
console.log(fmt('bold', `[guardrail scan]`) + fmt('dim', ` ${files.length} file(s) — ${scopeDesc}`));
|
|
141
|
+
if (options.ask) console.log(fmt('dim', ` question: ${options.ask}`));
|
|
142
|
+
if (focusLabel) console.log(fmt('dim', ` focus: ${focusLabel}`));
|
|
143
|
+
console.log('');
|
|
144
|
+
|
|
145
|
+
// Build a focused git summary / prompt context
|
|
146
|
+
const focusHint = buildFocusHint(options.ask, focusLabel);
|
|
147
|
+
|
|
148
|
+
const result = await runReviewPhase({
|
|
149
|
+
touchedFiles: relFiles,
|
|
150
|
+
engine,
|
|
151
|
+
config,
|
|
152
|
+
cwd,
|
|
153
|
+
gitSummary: focusHint,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Apply ignore rules
|
|
157
|
+
const ignoreRules = [...loadIgnoreRules(cwd), ...parseConfigIgnore(config.ignore)];
|
|
158
|
+
const findings = applyIgnoreRules(result.findings, ignoreRules);
|
|
159
|
+
|
|
160
|
+
// Print results
|
|
161
|
+
if (findings.length === 0 && options.ask && result.rawOutputs && result.rawOutputs.length > 0) {
|
|
162
|
+
// --ask returned prose rather than structured findings — surface raw response
|
|
163
|
+
console.log(fmt('cyan', `Answer:`));
|
|
164
|
+
for (const raw of result.rawOutputs) {
|
|
165
|
+
// Strip markdown fences and the ## Findings / ## Review Summary headers if present
|
|
166
|
+
const cleaned = raw.replace(/^##\s+Review Summary\s*\n/gm, '').replace(/^##\s+Findings\s*\n/gm, '').trim();
|
|
167
|
+
console.log(cleaned);
|
|
168
|
+
}
|
|
169
|
+
console.log('');
|
|
170
|
+
} else if (findings.length === 0) {
|
|
171
|
+
console.log(fmt('green', '✓ No findings'));
|
|
172
|
+
} else {
|
|
173
|
+
const critical = findings.filter(f => f.severity === 'critical');
|
|
174
|
+
const warnings = findings.filter(f => f.severity === 'warning');
|
|
175
|
+
const notes = findings.filter(f => f.severity === 'note');
|
|
176
|
+
|
|
177
|
+
if (critical.length > 0) {
|
|
178
|
+
console.log(fmt('red', `🚨 ${critical.length} critical`));
|
|
179
|
+
for (const f of critical) {
|
|
180
|
+
const loc = f.file && f.file !== '<unspecified>' ? fmt('dim', `${f.file}${f.line ? `:${f.line}` : ''}`) + ' ' : '';
|
|
181
|
+
console.log(` ${loc}${f.message}`);
|
|
182
|
+
if (f.suggestion) console.log(fmt('dim', ` → ${f.suggestion}`));
|
|
183
|
+
}
|
|
184
|
+
console.log('');
|
|
185
|
+
}
|
|
186
|
+
if (warnings.length > 0) {
|
|
187
|
+
console.log(fmt('yellow', `⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}`));
|
|
188
|
+
for (const f of warnings) {
|
|
189
|
+
const loc = f.file && f.file !== '<unspecified>' ? fmt('dim', `${f.file}${f.line ? `:${f.line}` : ''}`) + ' ' : '';
|
|
190
|
+
console.log(` ${loc}${f.message}`);
|
|
191
|
+
if (f.suggestion) console.log(fmt('dim', ` → ${f.suggestion}`));
|
|
192
|
+
}
|
|
193
|
+
console.log('');
|
|
194
|
+
}
|
|
195
|
+
if (notes.length > 0) {
|
|
196
|
+
console.log(fmt('dim', `ℹ ${notes.length} note${notes.length !== 1 ? 's' : ''}`));
|
|
197
|
+
for (const f of notes) {
|
|
198
|
+
const loc = f.file && f.file !== '<unspecified>' ? `${f.file}${f.line ? `:${f.line}` : ''} ` : '';
|
|
199
|
+
console.log(fmt('dim', ` ${loc}${f.message}`));
|
|
200
|
+
}
|
|
201
|
+
console.log('');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Persist findings so `guardrail fix` can read them
|
|
206
|
+
saveCachedFindings(cwd, findings);
|
|
207
|
+
|
|
208
|
+
if (result.costUSD !== undefined) {
|
|
209
|
+
console.log(fmt('dim', ` $${result.costUSD.toFixed(4)} · ${result.durationMs}ms`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const fixable = findings.filter(f => f.severity === 'critical' || f.severity === 'warning');
|
|
213
|
+
if (fixable.length > 0) {
|
|
214
|
+
console.log(fmt('dim', ` → run \`guardrail fix\` to auto-fix ${fixable.length} finding${fixable.length !== 1 ? 's' : ''}`));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return findings.some(f => f.severity === 'critical') ? 1 : 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildFocusHint(ask: string | undefined, focus: string | null): string {
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
if (ask) {
|
|
223
|
+
parts.push(
|
|
224
|
+
`TARGETED QUESTION (required): The reviewer specifically wants to know: "${ask}". ` +
|
|
225
|
+
`You MUST answer this question using the structured findings format. ` +
|
|
226
|
+
`Even if no issues are found, output at least one ### [NOTE] finding that directly answers the question.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (focus === 'security') parts.push('Focus: security vulnerabilities, auth issues, injection risks, data exposure');
|
|
230
|
+
if (focus === 'logic') parts.push('Focus: logic bugs, incorrect behavior, edge cases, null handling, async errors');
|
|
231
|
+
if (focus === 'performance') parts.push('Focus: performance issues, N+1 queries, blocking I/O, memory leaks');
|
|
232
|
+
return parts.join(' | ');
|
|
233
|
+
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -5,9 +5,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { detectProject } from './detector.ts';
|
|
6
6
|
import { runHook } from './hook.ts';
|
|
7
7
|
import { runDoctor } from './preflight.ts';
|
|
8
|
+
import { detectLLMKey, LLM_KEY_NAMES } from '../core/detect/llm-key.ts';
|
|
8
9
|
|
|
9
10
|
const PASS = '\x1b[32m✓\x1b[0m';
|
|
10
11
|
const WARN = '\x1b[33m!\x1b[0m';
|
|
12
|
+
const DIM = (t: string) => `\x1b[2m${t}\x1b[0m`;
|
|
13
|
+
const BOLD = (t: string) => `\x1b[1m${t}\x1b[0m`;
|
|
14
|
+
const CYAN = (t: string) => `\x1b[36m${t}\x1b[0m`;
|
|
11
15
|
|
|
12
16
|
const PRESET_LABELS: Record<string, string> = {
|
|
13
17
|
'nextjs-supabase': 'Next.js + Supabase',
|
|
@@ -15,19 +19,69 @@ const PRESET_LABELS: Record<string, string> = {
|
|
|
15
19
|
'rails-postgres': 'Ruby on Rails + PostgreSQL',
|
|
16
20
|
'python-fastapi': 'Python FastAPI',
|
|
17
21
|
'go': 'Go + PostgreSQL',
|
|
22
|
+
'generic': 'Generic (no stack-specific assumptions)',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ProfileName = 'security-strict' | 'team' | 'solo';
|
|
26
|
+
|
|
27
|
+
const PROFILES: Record<ProfileName, { label: string; overlay: string }> = {
|
|
28
|
+
'security-strict': {
|
|
29
|
+
label: 'Security Strict',
|
|
30
|
+
overlay: [
|
|
31
|
+
'staticRules:',
|
|
32
|
+
' - hardcoded-secrets',
|
|
33
|
+
' - npm-audit',
|
|
34
|
+
' - package-lock-sync',
|
|
35
|
+
' - sql-injection',
|
|
36
|
+
' - missing-auth',
|
|
37
|
+
' - ssrf',
|
|
38
|
+
' - insecure-redirect',
|
|
39
|
+
'policy:',
|
|
40
|
+
' failOn: warning',
|
|
41
|
+
' newOnly: false',
|
|
42
|
+
].join('\n'),
|
|
43
|
+
},
|
|
44
|
+
'team': {
|
|
45
|
+
label: 'Team',
|
|
46
|
+
overlay: [
|
|
47
|
+
'staticRules:',
|
|
48
|
+
' - hardcoded-secrets',
|
|
49
|
+
' - npm-audit',
|
|
50
|
+
' - package-lock-sync',
|
|
51
|
+
' - sql-injection',
|
|
52
|
+
' - missing-auth',
|
|
53
|
+
' - ssrf',
|
|
54
|
+
' - insecure-redirect',
|
|
55
|
+
'policy:',
|
|
56
|
+
' failOn: critical',
|
|
57
|
+
' newOnly: false',
|
|
58
|
+
].join('\n'),
|
|
59
|
+
},
|
|
60
|
+
'solo': {
|
|
61
|
+
label: 'Solo Dev',
|
|
62
|
+
overlay: [
|
|
63
|
+
'staticRules:',
|
|
64
|
+
' - hardcoded-secrets',
|
|
65
|
+
' - npm-audit',
|
|
66
|
+
'policy:',
|
|
67
|
+
' failOn: critical',
|
|
68
|
+
' newOnly: false',
|
|
69
|
+
].join('\n'),
|
|
70
|
+
},
|
|
18
71
|
};
|
|
19
72
|
|
|
20
73
|
export interface SetupOptions {
|
|
21
74
|
cwd?: string;
|
|
22
75
|
force?: boolean;
|
|
23
76
|
skipHook?: boolean;
|
|
77
|
+
profile?: ProfileName;
|
|
24
78
|
}
|
|
25
79
|
|
|
26
80
|
function presetSearchPaths(name: string, cwd: string): string[] {
|
|
27
81
|
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
28
82
|
return [
|
|
29
|
-
path.join(pkgRoot, 'presets', name, '
|
|
30
|
-
path.join(cwd, 'node_modules', '@delegance', 'claude-autopilot', 'presets', name, '
|
|
83
|
+
path.join(pkgRoot, 'presets', name, 'guardrail.config.yaml'),
|
|
84
|
+
path.join(cwd, 'node_modules', '@delegance', 'claude-autopilot', 'presets', name, 'guardrail.config.yaml'),
|
|
31
85
|
];
|
|
32
86
|
}
|
|
33
87
|
|
|
@@ -40,24 +94,35 @@ function findPresetConfig(name: string, cwd: string): string | null {
|
|
|
40
94
|
|
|
41
95
|
export async function runSetup(options: SetupOptions = {}): Promise<void> {
|
|
42
96
|
const cwd = options.cwd ?? process.cwd();
|
|
43
|
-
const dest = path.join(cwd, '
|
|
97
|
+
const dest = path.join(cwd, 'guardrail.config.yaml');
|
|
44
98
|
|
|
45
99
|
if (fs.existsSync(dest) && !options.force) {
|
|
46
|
-
throw new Error('
|
|
100
|
+
throw new Error('guardrail.config.yaml already exists — use --force to overwrite');
|
|
47
101
|
}
|
|
48
102
|
|
|
49
|
-
console.log('
|
|
103
|
+
console.log(`\n${BOLD('[guardrail setup]')} ${DIM(cwd)}\n`);
|
|
104
|
+
console.log(`${BOLD('Detecting project…')}\n`);
|
|
50
105
|
|
|
51
106
|
const detection = detectProject(cwd);
|
|
52
107
|
const label = PRESET_LABELS[detection.preset] ?? detection.preset;
|
|
53
108
|
|
|
54
109
|
if (detection.confidence === 'high') {
|
|
55
|
-
console.log(` ${PASS} ${label}
|
|
110
|
+
console.log(` ${PASS} Stack: ${label}`);
|
|
111
|
+
console.log(` ${PASS} Evidence: ${DIM(detection.evidence)}`);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(` ${WARN} Stack: ${label} ${DIM('(low confidence — fallback preset)')}`);
|
|
114
|
+
console.log(` ${DIM(detection.evidence)}`);
|
|
115
|
+
console.log(` ${DIM('Edit guardrail.config.yaml to switch presets if needed')}`);
|
|
116
|
+
}
|
|
117
|
+
console.log(` ${PASS} Test command: ${DIM(detection.testCommand)}`);
|
|
118
|
+
|
|
119
|
+
const { hasKey, preferred } = detectLLMKey();
|
|
120
|
+
if (hasKey) {
|
|
121
|
+
console.log(` ${PASS} LLM API key: detected (${preferred})`);
|
|
56
122
|
} else {
|
|
57
|
-
console.log(` ${WARN}
|
|
58
|
-
console.log(`
|
|
123
|
+
console.log(` ${WARN} LLM API key: not found`);
|
|
124
|
+
console.log(` ${DIM(`Set one of: ${LLM_KEY_NAMES.join(', ')}`)}`);
|
|
59
125
|
}
|
|
60
|
-
console.log(` ${PASS} Test command: ${detection.testCommand}`);
|
|
61
126
|
|
|
62
127
|
const presetConfigPath = findPresetConfig(detection.preset, cwd);
|
|
63
128
|
if (!presetConfigPath) {
|
|
@@ -66,20 +131,61 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
|
|
|
66
131
|
|
|
67
132
|
let presetContent = await fsAsync.readFile(presetConfigPath, 'utf8');
|
|
68
133
|
presetContent = presetContent.trimEnd() + `\ntestCommand: "${detection.testCommand}"\n`;
|
|
134
|
+
|
|
135
|
+
// Apply profile overlay if specified
|
|
136
|
+
if (options.profile) {
|
|
137
|
+
const profile = PROFILES[options.profile];
|
|
138
|
+
if (profile) {
|
|
139
|
+
console.log(` ${PASS} Profile: ${profile.label}`);
|
|
140
|
+
// Profile overlay replaces staticRules + policy sections from preset
|
|
141
|
+
presetContent = presetContent
|
|
142
|
+
.replace(/^staticRules:.*?(?=^\w|\z)/ms, '')
|
|
143
|
+
.replace(/^policy:.*?(?=^\w|\z)/ms, '');
|
|
144
|
+
presetContent = presetContent.trimEnd() + `\n${profile.overlay}\n`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
69
148
|
await fsAsync.writeFile(dest, presetContent, 'utf8');
|
|
70
|
-
console.log(` ${PASS} Created autopilot.config.yaml`);
|
|
71
149
|
|
|
150
|
+
console.log(`\n${BOLD('Config written to guardrail.config.yaml:')}\n`);
|
|
151
|
+
for (const line of presetContent.trimEnd().split('\n')) {
|
|
152
|
+
console.log(` ${DIM(line)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let hookInstalled = false;
|
|
72
156
|
if (!options.skipHook) {
|
|
73
157
|
const hookCode = await runHook('install', { cwd, silent: true });
|
|
74
|
-
|
|
75
|
-
|
|
158
|
+
hookInstalled = hookCode === 0;
|
|
159
|
+
if (hookInstalled) {
|
|
160
|
+
console.log(`\n ${PASS} Pre-push git hook installed`);
|
|
76
161
|
} else {
|
|
77
|
-
console.log(
|
|
162
|
+
console.log(`\n ${WARN} Hook install failed (run: npx guardrail hook install)`);
|
|
78
163
|
}
|
|
79
164
|
}
|
|
80
165
|
|
|
81
|
-
console.log('\
|
|
166
|
+
console.log('\nChecking prerequisites…');
|
|
82
167
|
await runDoctor();
|
|
83
168
|
|
|
84
|
-
console.log('
|
|
169
|
+
console.log(`\n${BOLD('Next steps:')}\n`);
|
|
170
|
+
if (!hasKey) {
|
|
171
|
+
console.log(` 1. ${CYAN('Set an LLM API key')} — guardrail needs one to review code:`);
|
|
172
|
+
console.log(` export ANTHROPIC_API_KEY=sk-ant-... # https://console.anthropic.com/`);
|
|
173
|
+
console.log(` export OPENAI_API_KEY=sk-... # https://platform.openai.com/api-keys`);
|
|
174
|
+
console.log(` export GROQ_API_KEY=gsk_... # https://console.groq.com/keys (free)\n`);
|
|
175
|
+
console.log(` 2. ${CYAN('Review your changes:')}`);
|
|
176
|
+
console.log(` npx guardrail run --base main\n`);
|
|
177
|
+
console.log(` 3. ${CYAN('Scan any path directly:')}`);
|
|
178
|
+
console.log(` npx guardrail scan src/auth/\n`);
|
|
179
|
+
} else {
|
|
180
|
+
console.log(` ${CYAN('Review git-changed files:')}`);
|
|
181
|
+
console.log(` npx guardrail run --base main\n`);
|
|
182
|
+
console.log(` ${CYAN('Scan any path (no git needed):')}`);
|
|
183
|
+
console.log(` npx guardrail scan src/auth/\n`);
|
|
184
|
+
console.log(` ${CYAN('Ask a targeted question:')}`);
|
|
185
|
+
console.log(` npx guardrail scan --ask "is there SQL injection here?" src/db/\n`);
|
|
186
|
+
if (!hookInstalled && !options.skipHook) {
|
|
187
|
+
console.log(` ${CYAN('Install pre-push hook (auto-runs before git push):')}`);
|
|
188
|
+
console.log(` npx guardrail hook install\n`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
85
191
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
5
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
6
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
7
|
+
import { findCoverageGaps } from '../core/test-gen/coverage-analyzer.ts';
|
|
8
|
+
import { detectTestFramework } from '../core/test-gen/framework-detector.ts';
|
|
9
|
+
import { writeGeneratedTest, buildGenerationPrompt } from '../core/test-gen/test-writer.ts';
|
|
10
|
+
|
|
11
|
+
const C = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', dim: '\x1b[2m', bold: '\x1b[1m', cyan: '\x1b[36m' };
|
|
12
|
+
|
|
13
|
+
export interface TestGenOptions {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
configPath?: string;
|
|
16
|
+
targets?: string[];
|
|
17
|
+
base?: string;
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
verify?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runTestGen(options: TestGenOptions = {}): Promise<number> {
|
|
23
|
+
const cwd = options.cwd ?? process.cwd();
|
|
24
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
25
|
+
|
|
26
|
+
let config = { configVersion: 1 as const, testCommand: null as string | null };
|
|
27
|
+
if (fs.existsSync(configPath)) {
|
|
28
|
+
try {
|
|
29
|
+
const loaded = await loadConfig(configPath);
|
|
30
|
+
if (loaded) config = loaded as typeof config;
|
|
31
|
+
} catch {
|
|
32
|
+
// proceed with defaults if config fails to load
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Collect files to analyze
|
|
37
|
+
let files: string[];
|
|
38
|
+
if (options.targets && options.targets.length > 0) {
|
|
39
|
+
files = options.targets.map(t => path.isAbsolute(t) ? t : path.resolve(cwd, t));
|
|
40
|
+
} else {
|
|
41
|
+
// Fall back to git-changed files
|
|
42
|
+
try {
|
|
43
|
+
const base = options.base ?? 'HEAD~1';
|
|
44
|
+
const out = execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { cwd, encoding: 'utf8' });
|
|
45
|
+
files = out.trim().split('\n').filter(Boolean).map(f => path.resolve(cwd, f));
|
|
46
|
+
} catch {
|
|
47
|
+
console.error(`${C.red}[test-gen] No targets specified and git diff failed. Pass a path: guardrail test-gen src/${C.reset}`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`${C.bold}[test-gen]${C.reset} Analyzing ${files.length} file(s)...`);
|
|
53
|
+
const gaps = findCoverageGaps(files);
|
|
54
|
+
|
|
55
|
+
if (gaps.length === 0) {
|
|
56
|
+
console.log(`${C.green}[test-gen] No coverage gaps found${C.reset}`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const gap of gaps) {
|
|
61
|
+
const rel = path.relative(cwd, gap.file);
|
|
62
|
+
const covered = gap.exports.length;
|
|
63
|
+
console.log(` ${C.cyan}${rel}${C.reset} ${covered} uncovered export(s): ${gap.exports.join(', ')}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.dryRun) {
|
|
67
|
+
console.log(`\n${C.yellow}[test-gen] Dry run — not generating tests${C.reset}`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Load review engine for generation
|
|
72
|
+
const engineRef = (config as { reviewEngine?: unknown }).reviewEngine ?? 'auto';
|
|
73
|
+
let engine: Awaited<ReturnType<typeof loadAdapter>>;
|
|
74
|
+
try {
|
|
75
|
+
engine = await loadAdapter({ point: 'review-engine', ref: engineRef as string });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`${C.red}[test-gen] Could not load review engine: ${err}${C.reset}`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const framework = detectTestFramework(cwd);
|
|
82
|
+
const written: string[] = [];
|
|
83
|
+
|
|
84
|
+
for (const gap of gaps) {
|
|
85
|
+
let sourceContent: string;
|
|
86
|
+
try { sourceContent = fs.readFileSync(gap.file, 'utf8'); } catch { continue; }
|
|
87
|
+
|
|
88
|
+
const prompt = buildGenerationPrompt(gap, sourceContent, framework);
|
|
89
|
+
|
|
90
|
+
process.stdout.write(` Generating ${path.relative(cwd, gap.testFile)}... `);
|
|
91
|
+
try {
|
|
92
|
+
const result = await (engine as unknown as ReviewEngine).review({ content: prompt, kind: 'spec', context: { cwd } });
|
|
93
|
+
|
|
94
|
+
// Extract code block if wrapped in markdown
|
|
95
|
+
let code = result.rawOutput.trim();
|
|
96
|
+
const fenceMatch = code.match(/```(?:typescript|ts|javascript|js)?\n([\s\S]*?)```/);
|
|
97
|
+
if (fenceMatch) code = fenceMatch[1]!.trim();
|
|
98
|
+
|
|
99
|
+
const testPath = writeGeneratedTest(gap, code);
|
|
100
|
+
written.push(testPath);
|
|
101
|
+
console.log(`${C.green}done${C.reset}`);
|
|
102
|
+
|
|
103
|
+
// Verify mode
|
|
104
|
+
if (options.verify && config.testCommand) {
|
|
105
|
+
try {
|
|
106
|
+
const [cmd, ...cmdArgs] = config.testCommand.split(/\s+/);
|
|
107
|
+
execFileSync(cmd!, cmdArgs, { cwd, stdio: 'ignore', timeout: 60_000 });
|
|
108
|
+
} catch {
|
|
109
|
+
fs.unlinkSync(testPath);
|
|
110
|
+
written.pop();
|
|
111
|
+
console.log(` ${C.yellow} ↳ tests failed — reverted${C.reset}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.log(`${C.red}failed: ${err}${C.reset}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (written.length > 0) {
|
|
120
|
+
console.log(`\n${C.green}[test-gen] Generated ${written.length} test file(s):${C.reset}`);
|
|
121
|
+
for (const f of written) console.log(` ${path.relative(cwd, f)}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return 0;
|
|
125
|
+
}
|