@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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function resolveWorkspace(cwd?: string): string {
|
|
5
|
+
return fs.realpathSync(cwd ?? process.cwd());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function assertInWorkspace(workspace: string, filePath: string): string {
|
|
9
|
+
const resolvedWorkspace = fs.realpathSync(workspace);
|
|
10
|
+
const abs = path.isAbsolute(filePath)
|
|
11
|
+
? filePath
|
|
12
|
+
: path.resolve(resolvedWorkspace, filePath);
|
|
13
|
+
|
|
14
|
+
let resolved: string;
|
|
15
|
+
try {
|
|
16
|
+
resolved = fs.realpathSync(abs);
|
|
17
|
+
} catch {
|
|
18
|
+
// File doesn't exist yet — check the directory
|
|
19
|
+
const dir = path.dirname(abs);
|
|
20
|
+
let resolvedDir: string;
|
|
21
|
+
try {
|
|
22
|
+
resolvedDir = fs.realpathSync(dir);
|
|
23
|
+
} catch {
|
|
24
|
+
// Parent directory doesn't exist — resolve what we can
|
|
25
|
+
resolvedDir = path.resolve(dir);
|
|
26
|
+
}
|
|
27
|
+
resolved = path.join(resolvedDir, path.basename(abs));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const root = resolvedWorkspace.endsWith(path.sep) ? resolvedWorkspace : resolvedWorkspace + path.sep;
|
|
31
|
+
if (!resolved.startsWith(root) && resolved !== resolvedWorkspace) {
|
|
32
|
+
throw new Error(`Path "${filePath}" is outside workspace "${workspace}"`);
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { Finding } from '../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const BASELINE_FILE = '.guardrail-baseline.json';
|
|
6
|
+
|
|
7
|
+
export interface BaselineEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
file: string;
|
|
10
|
+
line?: number;
|
|
11
|
+
severity: string;
|
|
12
|
+
message: string;
|
|
13
|
+
pinnedAt: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Baseline {
|
|
18
|
+
version: 1;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
note?: string;
|
|
22
|
+
entries: BaselineEntry[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stable key for matching a finding against a baseline entry. */
|
|
26
|
+
function baselineKey(f: { id: string; file: string; line?: number }): string {
|
|
27
|
+
return `${f.id}::${f.file}::${f.line ?? ''}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function baselineFilePath(cwd: string, overridePath?: string): string {
|
|
31
|
+
return overridePath
|
|
32
|
+
? path.isAbsolute(overridePath) ? overridePath : path.join(cwd, overridePath)
|
|
33
|
+
: path.join(cwd, BASELINE_FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadBaseline(cwd: string, overridePath?: string): Baseline | null {
|
|
37
|
+
const p = baselineFilePath(cwd, overridePath);
|
|
38
|
+
if (!fs.existsSync(p)) return null;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')) as Baseline;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveBaseline(cwd: string, findings: Finding[], options: { note?: string; overridePath?: string } = {}): Baseline {
|
|
47
|
+
const existing = loadBaseline(cwd, options.overridePath);
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const baseline: Baseline = {
|
|
50
|
+
version: 1,
|
|
51
|
+
createdAt: existing?.createdAt ?? now,
|
|
52
|
+
updatedAt: now,
|
|
53
|
+
note: options.note ?? existing?.note,
|
|
54
|
+
entries: findings.map(f => ({
|
|
55
|
+
id: f.id,
|
|
56
|
+
file: f.file,
|
|
57
|
+
line: f.line,
|
|
58
|
+
severity: f.severity,
|
|
59
|
+
message: f.message,
|
|
60
|
+
pinnedAt: now,
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
const p = baselineFilePath(cwd, options.overridePath);
|
|
64
|
+
const tmp = p + '.tmp';
|
|
65
|
+
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2), 'utf8');
|
|
66
|
+
fs.renameSync(tmp, p);
|
|
67
|
+
return baseline;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearBaseline(cwd: string, overridePath?: string): void {
|
|
71
|
+
const p = baselineFilePath(cwd, overridePath);
|
|
72
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BaselineFilterResult {
|
|
76
|
+
newFindings: Finding[];
|
|
77
|
+
baselinedFindings: Finding[];
|
|
78
|
+
baselinedCount: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns findings NOT present in the baseline (new findings only). */
|
|
82
|
+
export function filterBaselined(findings: Finding[], baseline: Baseline): BaselineFilterResult {
|
|
83
|
+
const pinned = new Set(baseline.entries.map(baselineKey));
|
|
84
|
+
const newFindings: Finding[] = [];
|
|
85
|
+
const baselinedFindings: Finding[] = [];
|
|
86
|
+
for (const f of findings) {
|
|
87
|
+
if (pinned.has(baselineKey(f))) {
|
|
88
|
+
baselinedFindings.push(f);
|
|
89
|
+
} else {
|
|
90
|
+
newFindings.push(f);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { newFindings, baselinedFindings, baselinedCount: baselinedFindings.length };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface BaselineDiff {
|
|
97
|
+
added: Finding[]; // in current but not in baseline
|
|
98
|
+
resolved: BaselineEntry[]; // in baseline but not in current
|
|
99
|
+
unchanged: Finding[]; // in both
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Diff current findings against a baseline snapshot. */
|
|
103
|
+
export function diffAgainstBaseline(current: Finding[], baseline: Baseline): BaselineDiff {
|
|
104
|
+
const currentKeys = new Set(current.map(baselineKey));
|
|
105
|
+
const baselineKeys = new Set(baseline.entries.map(baselineKey));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
added: current.filter(f => !baselineKeys.has(baselineKey(f))),
|
|
109
|
+
resolved: baseline.entries.filter(e => !currentKeys.has(baselineKey(e))),
|
|
110
|
+
unchanged: current.filter(f => baselineKeys.has(baselineKey(f))),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import type { Finding } from '../findings/types.ts';
|
|
4
4
|
|
|
5
|
-
const CACHE_DIR = '.
|
|
5
|
+
const CACHE_DIR = '.guardrail-cache';
|
|
6
6
|
const CACHE_FILE = 'findings.json';
|
|
7
7
|
|
|
8
8
|
function cacheFilePath(cwd: string): string {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { Finding } from '../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const TRIAGE_FILE = '.guardrail-triage.json';
|
|
6
|
+
|
|
7
|
+
export type TriageState = 'accepted-risk' | 'false-positive';
|
|
8
|
+
|
|
9
|
+
export interface TriageEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
file: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
state: TriageState;
|
|
14
|
+
reason?: string;
|
|
15
|
+
triagedAt: string;
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TriageStore {
|
|
20
|
+
version: 1;
|
|
21
|
+
entries: TriageEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function triageFilePath(cwd: string): string {
|
|
25
|
+
return path.join(cwd, TRIAGE_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function entryKey(e: { id: string; file: string; line?: number }): string {
|
|
29
|
+
return `${e.id}::${e.file}::${e.line ?? ''}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadTriage(cwd: string): TriageStore {
|
|
33
|
+
const p = triageFilePath(cwd);
|
|
34
|
+
if (!fs.existsSync(p)) return { version: 1, entries: [] };
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(p, 'utf8')) as TriageStore;
|
|
37
|
+
} catch {
|
|
38
|
+
return { version: 1, entries: [] };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveTriage(cwd: string, store: TriageStore): void {
|
|
43
|
+
const p = triageFilePath(cwd);
|
|
44
|
+
const tmp = p + '.tmp';
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8');
|
|
46
|
+
fs.renameSync(tmp, p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function addTriageEntry(
|
|
50
|
+
cwd: string,
|
|
51
|
+
finding: Finding,
|
|
52
|
+
state: TriageState,
|
|
53
|
+
options: { reason?: string; expiresInDays?: number } = {},
|
|
54
|
+
): void {
|
|
55
|
+
const store = loadTriage(cwd);
|
|
56
|
+
const key = entryKey(finding);
|
|
57
|
+
store.entries = store.entries.filter(e => entryKey(e) !== key);
|
|
58
|
+
const entry: TriageEntry = {
|
|
59
|
+
id: finding.id,
|
|
60
|
+
file: finding.file,
|
|
61
|
+
line: finding.line,
|
|
62
|
+
state,
|
|
63
|
+
reason: options.reason,
|
|
64
|
+
triagedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
if (options.expiresInDays !== undefined) {
|
|
67
|
+
const exp = new Date();
|
|
68
|
+
exp.setDate(exp.getDate() + options.expiresInDays);
|
|
69
|
+
entry.expiresAt = exp.toISOString();
|
|
70
|
+
}
|
|
71
|
+
store.entries.push(entry);
|
|
72
|
+
saveTriage(cwd, store);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function removeTriageEntry(cwd: string, ids: string[]): number {
|
|
76
|
+
const store = loadTriage(cwd);
|
|
77
|
+
const before = store.entries.length;
|
|
78
|
+
store.entries = store.entries.filter(e => !ids.some(id => e.id === id || e.id.startsWith(id)));
|
|
79
|
+
saveTriage(cwd, store);
|
|
80
|
+
return before - store.entries.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function clearExpiredEntries(cwd: string): number {
|
|
84
|
+
const store = loadTriage(cwd);
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
const before = store.entries.length;
|
|
87
|
+
store.entries = store.entries.filter(e => !e.expiresAt || e.expiresAt > now);
|
|
88
|
+
saveTriage(cwd, store);
|
|
89
|
+
return before - store.entries.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface TriageFilterResult {
|
|
93
|
+
active: Finding[];
|
|
94
|
+
triaged: Finding[];
|
|
95
|
+
triageCount: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function filterTriaged(findings: Finding[], store: TriageStore): TriageFilterResult {
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
const activeKeys = new Set(
|
|
101
|
+
store.entries
|
|
102
|
+
.filter(e => !e.expiresAt || e.expiresAt > now)
|
|
103
|
+
.map(entryKey),
|
|
104
|
+
);
|
|
105
|
+
const active: Finding[] = [];
|
|
106
|
+
const triaged: Finding[] = [];
|
|
107
|
+
for (const f of findings) {
|
|
108
|
+
if (activeKeys.has(entryKey(f))) triaged.push(f);
|
|
109
|
+
else active.push(f);
|
|
110
|
+
}
|
|
111
|
+
return { active, triaged, triageCount: triaged.length };
|
|
112
|
+
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { Finding, FixAttempt, FixStatus } from '../findings/types.ts';
|
|
2
|
+
import type { GuardrailConfig } from '../config/types.ts';
|
|
3
|
+
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
2
4
|
import { dedupFindings, findingContentKey } from '../findings/dedup.ts';
|
|
3
5
|
|
|
4
6
|
export interface StaticRule {
|
|
5
7
|
name: string;
|
|
6
8
|
severity: 'critical' | 'warning' | 'note';
|
|
7
|
-
check(touchedFiles: string[]): Promise<Finding[]>;
|
|
9
|
+
check(touchedFiles: string[], config?: Record<string, unknown>): Promise<Finding[]>;
|
|
8
10
|
autofix?(finding: Finding): Promise<FixStatus>;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export interface StaticRulesPhaseInput {
|
|
12
14
|
touchedFiles: string[];
|
|
13
15
|
rules: StaticRule[];
|
|
16
|
+
config?: GuardrailConfig;
|
|
17
|
+
engine?: ReviewEngine;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export interface StaticRulesPhaseResult {
|
|
@@ -24,7 +28,7 @@ export interface StaticRulesPhaseResult {
|
|
|
24
28
|
export async function runStaticRulesPhase(input: StaticRulesPhaseInput): Promise<StaticRulesPhaseResult> {
|
|
25
29
|
const start = Date.now();
|
|
26
30
|
|
|
27
|
-
const preFixFindings = dedupFindings(await runAllChecks(input.rules, input.touchedFiles));
|
|
31
|
+
const preFixFindings = dedupFindings(await runAllChecks(input.rules, input.touchedFiles, input.config, input.engine));
|
|
28
32
|
|
|
29
33
|
const fixAttempts: FixAttempt[] = [];
|
|
30
34
|
let anyFixApplied = false;
|
|
@@ -52,7 +56,7 @@ export async function runStaticRulesPhase(input: StaticRulesPhaseInput): Promise
|
|
|
52
56
|
// findings always returns preFixFindings so callers have a complete record;
|
|
53
57
|
// fixAttempts + re-check set-difference tells them what was resolved.
|
|
54
58
|
const postFixFindings = anyFixApplied
|
|
55
|
-
? dedupFindings(await runAllChecks(input.rules, input.touchedFiles))
|
|
59
|
+
? dedupFindings(await runAllChecks(input.rules, input.touchedFiles, input.config, input.engine))
|
|
56
60
|
: preFixFindings;
|
|
57
61
|
|
|
58
62
|
const postFixKeys = new Set(postFixFindings.map(findingContentKey));
|
|
@@ -69,9 +73,18 @@ export async function runStaticRulesPhase(input: StaticRulesPhaseInput): Promise
|
|
|
69
73
|
return { phase: 'static-rules', status, findings: preFixFindings, fixAttempts, durationMs: Date.now() - start };
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
async function runAllChecks(
|
|
76
|
+
async function runAllChecks(
|
|
77
|
+
rules: StaticRule[],
|
|
78
|
+
files: string[],
|
|
79
|
+
config?: GuardrailConfig,
|
|
80
|
+
engine?: ReviewEngine,
|
|
81
|
+
): Promise<Finding[]> {
|
|
82
|
+
const ruleConfig: Record<string, unknown> = {
|
|
83
|
+
...(config ? (config as unknown as Record<string, unknown>) : {}),
|
|
84
|
+
_engine: engine,
|
|
85
|
+
};
|
|
73
86
|
const all: Finding[] = [];
|
|
74
|
-
for (const rule of rules) all.push(...(await rule.check(files)));
|
|
87
|
+
for (const rule of rules) all.push(...(await rule.check(files, ruleConfig)));
|
|
75
88
|
return all;
|
|
76
89
|
}
|
|
77
90
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
2
2
|
import type { Finding } from '../findings/types.ts';
|
|
3
|
-
import type {
|
|
3
|
+
import type { GuardrailConfig } from '../config/types.ts';
|
|
4
4
|
import { buildReviewChunks, type ReviewChunk } from '../chunking/index.ts';
|
|
5
|
+
import { GuardrailError } from '../errors.ts';
|
|
6
|
+
import { hasFrontendFiles, loadDesignContext } from '../ui/design-context-loader.ts';
|
|
5
7
|
|
|
6
8
|
export interface ReviewPhaseResult {
|
|
7
9
|
phase: 'review';
|
|
8
10
|
status: 'pass' | 'warn' | 'fail' | 'skip';
|
|
9
11
|
findings: Finding[];
|
|
12
|
+
rawOutputs?: string[];
|
|
10
13
|
costUSD?: number;
|
|
11
14
|
usage?: { input: number; output: number };
|
|
12
15
|
durationMs: number;
|
|
@@ -15,32 +18,55 @@ export interface ReviewPhaseResult {
|
|
|
15
18
|
export interface ReviewPhaseInput {
|
|
16
19
|
touchedFiles: string[];
|
|
17
20
|
engine: ReviewEngine;
|
|
18
|
-
config:
|
|
21
|
+
config: GuardrailConfig;
|
|
19
22
|
cwd?: string;
|
|
20
23
|
gitSummary?: string;
|
|
24
|
+
designSchema?: string;
|
|
21
25
|
budgetRemainingUSD?: number;
|
|
22
26
|
base?: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
interface ChunkResult {
|
|
26
30
|
findings: Finding[];
|
|
31
|
+
rawOutput: string;
|
|
27
32
|
inputTokens: number;
|
|
28
33
|
outputTokens: number;
|
|
29
34
|
costUSD: number;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
function backoffMs(attempt: number, strategy: 'exp' | 'linear' | 'none'): number {
|
|
38
|
+
if (strategy === 'none') return 0;
|
|
39
|
+
if (strategy === 'linear') return attempt * 2000;
|
|
40
|
+
return Math.min(Math.pow(2, attempt) * 1000, 32000); // exp: 1s, 2s, 4s, 8s … 32s cap
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function reviewChunkWithRetry(chunk: ReviewChunk, input: ReviewPhaseInput): Promise<ChunkResult> {
|
|
44
|
+
const strategy = input.config.chunking?.rateLimitBackoff ?? 'exp';
|
|
45
|
+
const maxAttempts = strategy === 'none' ? 1 : 4;
|
|
46
|
+
|
|
47
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
const output = await input.engine.review({
|
|
50
|
+
content: chunk.content,
|
|
51
|
+
kind: chunk.kind,
|
|
52
|
+
context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary, designSchema: input.designSchema },
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
findings: output.findings,
|
|
56
|
+
rawOutput: output.rawOutput,
|
|
57
|
+
inputTokens: output.usage?.input ?? 0,
|
|
58
|
+
outputTokens: output.usage?.output ?? 0,
|
|
59
|
+
costUSD: output.usage?.costUSD ?? 0,
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const isRateLimit = err instanceof GuardrailError && err.code === 'rate_limit';
|
|
63
|
+
const isLast = attempt === maxAttempts - 1;
|
|
64
|
+
if (!isRateLimit || isLast) throw err;
|
|
65
|
+
const delay = backoffMs(attempt + 1, strategy);
|
|
66
|
+
await new Promise(r => setTimeout(r, delay));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error('unreachable');
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
/** Run up to `limit` promises concurrently, preserving result order. */
|
|
@@ -71,18 +97,28 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
71
97
|
return { phase: 'review', status: 'skip', findings: [], durationMs: Date.now() - start };
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
let designSchema: string | undefined;
|
|
101
|
+
if (hasFrontendFiles(input.touchedFiles) && input.config.brand?.componentLibrary) {
|
|
102
|
+
const loaded = loadDesignContext(
|
|
103
|
+
input.config.brand.componentLibrary,
|
|
104
|
+
input.cwd ?? process.cwd(),
|
|
105
|
+
);
|
|
106
|
+
if (loaded) designSchema = loaded;
|
|
107
|
+
}
|
|
108
|
+
const enrichedInput: ReviewPhaseInput = designSchema ? { ...input, designSchema } : input;
|
|
109
|
+
|
|
74
110
|
const chunks = await buildReviewChunks({
|
|
75
|
-
touchedFiles:
|
|
76
|
-
strategy:
|
|
77
|
-
chunking:
|
|
78
|
-
engine:
|
|
79
|
-
cwd:
|
|
80
|
-
protectedPaths:
|
|
81
|
-
base:
|
|
111
|
+
touchedFiles: enrichedInput.touchedFiles,
|
|
112
|
+
strategy: enrichedInput.config.reviewStrategy ?? 'auto',
|
|
113
|
+
chunking: enrichedInput.config.chunking,
|
|
114
|
+
engine: enrichedInput.engine,
|
|
115
|
+
cwd: enrichedInput.cwd,
|
|
116
|
+
protectedPaths: enrichedInput.config.protectedPaths,
|
|
117
|
+
base: enrichedInput.base,
|
|
82
118
|
});
|
|
83
119
|
|
|
84
|
-
const parallelism =
|
|
85
|
-
const budgetUSD =
|
|
120
|
+
const parallelism = enrichedInput.config.chunking?.parallelism ?? 3;
|
|
121
|
+
const budgetUSD = enrichedInput.budgetRemainingUSD;
|
|
86
122
|
|
|
87
123
|
// For budget tracking we still need to enforce it — run serially if budget set,
|
|
88
124
|
// parallel otherwise (budget check between serial chunks is the safe path).
|
|
@@ -93,7 +129,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
93
129
|
let budgetExceeded = false;
|
|
94
130
|
for (const chunk of chunks) {
|
|
95
131
|
if (spent >= budgetUSD) { budgetExceeded = true; break; }
|
|
96
|
-
const r = await
|
|
132
|
+
const r = await reviewChunkWithRetry(chunk, enrichedInput);
|
|
97
133
|
spent += r.costUSD;
|
|
98
134
|
chunkResults.push(r);
|
|
99
135
|
}
|
|
@@ -109,20 +145,22 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
109
145
|
protectedPath: false,
|
|
110
146
|
createdAt: new Date().toISOString(),
|
|
111
147
|
}],
|
|
112
|
-
inputTokens: 0, outputTokens: 0, costUSD: 0,
|
|
148
|
+
rawOutput: '', inputTokens: 0, outputTokens: 0, costUSD: 0,
|
|
113
149
|
});
|
|
114
150
|
}
|
|
115
151
|
} else {
|
|
116
|
-
chunkResults = await pMap(chunks, chunk =>
|
|
152
|
+
chunkResults = await pMap(chunks, chunk => reviewChunkWithRetry(chunk, enrichedInput), parallelism);
|
|
117
153
|
}
|
|
118
154
|
|
|
119
155
|
let totalInputTokens = 0;
|
|
120
156
|
let totalOutputTokens = 0;
|
|
121
157
|
let totalCostUSD = 0;
|
|
122
158
|
const allFindings: Finding[] = [];
|
|
159
|
+
const allRawOutputs: string[] = [];
|
|
123
160
|
|
|
124
161
|
for (const r of chunkResults) {
|
|
125
162
|
allFindings.push(...r.findings);
|
|
163
|
+
if (r.rawOutput) allRawOutputs.push(r.rawOutput);
|
|
126
164
|
totalInputTokens += r.inputTokens;
|
|
127
165
|
totalOutputTokens += r.outputTokens;
|
|
128
166
|
totalCostUSD += r.costUSD;
|
|
@@ -136,6 +174,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
136
174
|
phase: 'review',
|
|
137
175
|
status,
|
|
138
176
|
findings: allFindings,
|
|
177
|
+
rawOutputs: allRawOutputs.length > 0 ? allRawOutputs : undefined,
|
|
139
178
|
costUSD: totalCostUSD > 0 ? totalCostUSD : undefined,
|
|
140
179
|
usage: totalInputTokens > 0 ? { input: totalInputTokens, output: totalOutputTokens } : undefined,
|
|
141
180
|
durationMs: Date.now() - start,
|
package/src/core/pipeline/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { GuardrailConfig } from '../config/types.ts';
|
|
2
2
|
import type { StaticRule } from '../phases/static-rules.ts';
|
|
3
3
|
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
4
4
|
import type { Finding } from '../findings/types.ts';
|
|
@@ -13,12 +13,13 @@ export type PhaseResult = StaticRulesPhaseResult | TestsPhaseResult | ReviewPhas
|
|
|
13
13
|
|
|
14
14
|
export interface RunInput {
|
|
15
15
|
touchedFiles: string[];
|
|
16
|
-
config:
|
|
16
|
+
config: GuardrailConfig;
|
|
17
17
|
reviewEngine?: ReviewEngine;
|
|
18
18
|
staticRules?: StaticRule[];
|
|
19
19
|
cwd?: string;
|
|
20
20
|
gitSummary?: string;
|
|
21
21
|
base?: string;
|
|
22
|
+
skipReview?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface RunResult {
|
|
@@ -29,33 +30,64 @@ export interface RunResult {
|
|
|
29
30
|
durationMs: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export async function
|
|
33
|
+
export async function runGuardrail(input: RunInput): Promise<RunResult> {
|
|
33
34
|
const start = Date.now();
|
|
34
35
|
const phases: PhaseResult[] = [];
|
|
35
36
|
let totalCostUSD: number | undefined;
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
const pipelineCfg = input.config.pipeline ?? {};
|
|
39
|
+
// Default true: when the user wires up a review engine they expect it to actually run.
|
|
40
|
+
// Skipping LLM review on static-fail is exactly the "silent skip" behavior the v4.0
|
|
41
|
+
// reviewer flagged — the bugs the LLM is best at often ride alongside one a static
|
|
42
|
+
// rule already caught.
|
|
43
|
+
const runReviewOnStaticFail = pipelineCfg.runReviewOnStaticFail !== false;
|
|
44
|
+
const runReviewOnTestFail = pipelineCfg.runReviewOnTestFail === true;
|
|
45
|
+
|
|
46
|
+
// Static-rules phase — tests always run afterward, regardless of status. The
|
|
47
|
+
// runReviewOnStaticFail flag only gates the LLM review phase (matching its name);
|
|
48
|
+
// skipping tests on a static-fail would be surprising and asymmetric with
|
|
49
|
+
// runReviewOnTestFail which only gates the review phase too.
|
|
50
|
+
let staticFailed = false;
|
|
38
51
|
if (input.staticRules && input.staticRules.length > 0) {
|
|
39
52
|
const result = await runStaticRulesPhase({
|
|
40
53
|
touchedFiles: input.touchedFiles,
|
|
41
54
|
rules: input.staticRules,
|
|
55
|
+
config: input.config,
|
|
56
|
+
engine: input.reviewEngine,
|
|
42
57
|
});
|
|
43
58
|
phases.push(result);
|
|
44
|
-
|
|
59
|
+
staticFailed = result.status === 'fail';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// skipReview short-circuit: skip tests and review phases entirely
|
|
63
|
+
if (input.skipReview) {
|
|
64
|
+
const allFindings = phases.flatMap(p => p.findings ?? []);
|
|
65
|
+
const hasCritical = allFindings.some(f => f.severity === 'critical');
|
|
66
|
+
return {
|
|
67
|
+
status: hasCritical ? 'fail' : 'pass',
|
|
68
|
+
phases,
|
|
69
|
+
allFindings,
|
|
70
|
+
totalCostUSD: undefined,
|
|
71
|
+
durationMs: Date.now() - start,
|
|
72
|
+
};
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
// Tests phase —
|
|
75
|
+
// Tests phase — always runs (unless skipReview above). The runReviewOnTestFail
|
|
76
|
+
// flag only gates the subsequent review phase.
|
|
48
77
|
const testsResult = await runTestsPhase({
|
|
49
78
|
touchedFiles: input.touchedFiles,
|
|
50
79
|
testCommand: input.config.testCommand,
|
|
51
80
|
cwd: input.cwd,
|
|
52
81
|
});
|
|
53
82
|
phases.push(testsResult);
|
|
54
|
-
|
|
83
|
+
const testsFailed = testsResult.status === 'fail';
|
|
55
84
|
|
|
56
|
-
// Review phase (optional — only when engine is provided)
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
// Review phase (optional — only when engine is provided, not gated off by flags)
|
|
86
|
+
const skipReviewDueToStatic = staticFailed && !runReviewOnStaticFail;
|
|
87
|
+
const skipReviewDueToTests = testsFailed && !runReviewOnTestFail;
|
|
88
|
+
if (input.reviewEngine && !skipReviewDueToStatic && !skipReviewDueToTests) {
|
|
89
|
+
const costCfg = input.config.cost as { maxPerRun?: number; budgetUSD?: number } | undefined;
|
|
90
|
+
const budgetUSD = costCfg?.maxPerRun ?? costCfg?.budgetUSD;
|
|
59
91
|
const reviewResult = await runReviewPhase({
|
|
60
92
|
touchedFiles: input.touchedFiles,
|
|
61
93
|
engine: input.reviewEngine,
|
package/src/core/runtime/lock.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { GuardrailError } from '../errors.ts';
|
|
4
4
|
|
|
5
5
|
export interface LockHandle {
|
|
6
6
|
release(): Promise<void>;
|
|
@@ -16,7 +16,7 @@ export function acquireLock(runId: string, lockDir = '.claude'): LockHandle {
|
|
|
16
16
|
{ flag: 'wx' }
|
|
17
17
|
);
|
|
18
18
|
} catch (err) {
|
|
19
|
-
throw new
|
|
19
|
+
throw new GuardrailError('Another autopilot run holds the lock', {
|
|
20
20
|
code: 'concurrency_lock',
|
|
21
21
|
details: { lockPath, cause: err instanceof Error ? err.message : String(err) },
|
|
22
22
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { GuardrailError } from '../errors.ts';
|
|
4
4
|
|
|
5
5
|
export type PipelineStep =
|
|
6
6
|
| 'plan' | 'worktree' | 'implement' | 'migrate' | 'validate'
|
|
@@ -72,7 +72,7 @@ export async function loadRunState(runId: string, runsDir?: string): Promise<Run
|
|
|
72
72
|
try {
|
|
73
73
|
return JSON.parse(await fs.readFile(file, 'utf8')) as RunState;
|
|
74
74
|
} catch (err) {
|
|
75
|
-
throw new
|
|
75
|
+
throw new GuardrailError(`Run state not found: ${runId}`, {
|
|
76
76
|
code: 'user_input',
|
|
77
77
|
details: { runId, file, cause: err instanceof Error ? err.message : String(err) },
|
|
78
78
|
});
|