@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/lsp.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
4
|
+
|
|
5
|
+
// LSP DiagnosticSeverity values (spec §3.16.1)
|
|
6
|
+
const DSev = { Error: 1, Warning: 2, Information: 3 } as const;
|
|
7
|
+
|
|
8
|
+
export function findingToUri(filePath: string, cwd: string): string {
|
|
9
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
10
|
+
// file:// with three slashes on Unix, four on Windows (file:///C:/...)
|
|
11
|
+
return `file://${abs.startsWith('/') ? '' : '/'}${abs}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function findingToDiagnostic(f: Finding): {
|
|
15
|
+
range: { start: { line: number; character: number }; end: { line: number; character: number } };
|
|
16
|
+
severity: number;
|
|
17
|
+
source: string;
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
} {
|
|
21
|
+
const line = Math.max(0, (f.line ?? 1) - 1); // LSP is 0-indexed; findings are 1-indexed
|
|
22
|
+
return {
|
|
23
|
+
range: { start: { line, character: 0 }, end: { line, character: 999 } },
|
|
24
|
+
severity: f.severity === 'critical' ? DSev.Error : f.severity === 'warning' ? DSev.Warning : DSev.Information,
|
|
25
|
+
source: 'guardrail',
|
|
26
|
+
code: f.id,
|
|
27
|
+
message: f.suggestion ? `${f.message}\n\n${f.suggestion}` : f.message,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function groupByUri(findings: Finding[], cwd: string): Map<string, Finding[]> {
|
|
32
|
+
const map = new Map<string, Finding[]>();
|
|
33
|
+
for (const f of findings) {
|
|
34
|
+
const uri = findingToUri(f.file, cwd);
|
|
35
|
+
const arr = map.get(uri) ?? [];
|
|
36
|
+
arr.push(f);
|
|
37
|
+
map.set(uri, arr);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function encodeMessage(body: object): Buffer {
|
|
43
|
+
const json = JSON.stringify(body);
|
|
44
|
+
const byteLen = Buffer.byteLength(json, 'utf8');
|
|
45
|
+
return Buffer.from(`Content-Length: ${byteLen}\r\n\r\n${json}`, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse as many complete LSP messages as possible from `buf`. Returns parsed objects and remaining bytes. */
|
|
49
|
+
export function parseMessages(buf: Buffer): { messages: unknown[]; remaining: Buffer } {
|
|
50
|
+
const messages: unknown[] = [];
|
|
51
|
+
let remaining = buf;
|
|
52
|
+
|
|
53
|
+
while (remaining.length > 0) {
|
|
54
|
+
const headerEnd = remaining.indexOf('\r\n\r\n');
|
|
55
|
+
if (headerEnd < 0) break;
|
|
56
|
+
|
|
57
|
+
const headers = remaining.slice(0, headerEnd).toString('utf8');
|
|
58
|
+
const match = headers.match(/Content-Length:\s*(\d+)/i);
|
|
59
|
+
if (!match) { remaining = remaining.slice(headerEnd + 4); continue; }
|
|
60
|
+
|
|
61
|
+
const contentLength = parseInt(match[1]!, 10);
|
|
62
|
+
const bodyStart = headerEnd + 4;
|
|
63
|
+
if (remaining.length < bodyStart + contentLength) break;
|
|
64
|
+
|
|
65
|
+
const body = remaining.slice(bodyStart, bodyStart + contentLength).toString('utf8');
|
|
66
|
+
remaining = remaining.slice(bodyStart + contentLength);
|
|
67
|
+
|
|
68
|
+
try { messages.push(JSON.parse(body)); } catch { /* skip malformed */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { messages, remaining };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runLsp(options: { cwd?: string } = {}): Promise<void> {
|
|
75
|
+
const cwd = options.cwd ?? process.cwd();
|
|
76
|
+
const cacheFile = path.join(cwd, '.guardrail-cache', 'findings.json');
|
|
77
|
+
|
|
78
|
+
let initialized = false;
|
|
79
|
+
let didShutdown = false;
|
|
80
|
+
|
|
81
|
+
function send(msg: object): void {
|
|
82
|
+
process.stdout.write(encodeMessage(msg));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function notify(method: string, params: object): void {
|
|
86
|
+
send({ jsonrpc: '2.0', method, params });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function respond(id: number | string | null, result: unknown): void {
|
|
90
|
+
send({ jsonrpc: '2.0', id, result });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function respondError(id: number | string | null, code: number, message: string): void {
|
|
94
|
+
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readFindings(): Finding[] {
|
|
98
|
+
if (!fs.existsSync(cacheFile)) return [];
|
|
99
|
+
try { return JSON.parse(fs.readFileSync(cacheFile, 'utf8')) as Finding[]; }
|
|
100
|
+
catch { return []; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function publishAll(findings: Finding[]): void {
|
|
104
|
+
const byUri = groupByUri(findings, cwd);
|
|
105
|
+
for (const [uri, ff] of byUri) {
|
|
106
|
+
notify('textDocument/publishDiagnostics', {
|
|
107
|
+
uri,
|
|
108
|
+
diagnostics: ff.map(findingToDiagnostic),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function publishForUri(uri: string, findings: Finding[]): void {
|
|
114
|
+
notify('textDocument/publishDiagnostics', {
|
|
115
|
+
uri,
|
|
116
|
+
diagnostics: findings.filter(f => findingToUri(f.file, cwd) === uri).map(findingToDiagnostic),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Watch cache dir so editors see diagnostics update after a guardrail run
|
|
121
|
+
let watcher: fs.FSWatcher | null = null;
|
|
122
|
+
|
|
123
|
+
function startWatching(): void {
|
|
124
|
+
const dir = path.dirname(cacheFile);
|
|
125
|
+
if (!fs.existsSync(dir)) return;
|
|
126
|
+
try {
|
|
127
|
+
watcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
|
|
128
|
+
if (filename === 'findings.json' && initialized) publishAll(readFindings());
|
|
129
|
+
});
|
|
130
|
+
} catch { /* watch unavailable */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type LspMessage = { jsonrpc: string; id?: number | string; method?: string; params?: unknown };
|
|
134
|
+
|
|
135
|
+
function handle(msg: LspMessage): void {
|
|
136
|
+
const { id, method, params } = msg;
|
|
137
|
+
if (!method) return; // response, ignore
|
|
138
|
+
|
|
139
|
+
switch (method) {
|
|
140
|
+
case 'initialize':
|
|
141
|
+
respond(id!, {
|
|
142
|
+
capabilities: {
|
|
143
|
+
textDocumentSync: { openClose: true, change: 1 /* full */ },
|
|
144
|
+
},
|
|
145
|
+
serverInfo: { name: 'guardrail', version: '4.1.0' },
|
|
146
|
+
});
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case 'initialized':
|
|
150
|
+
initialized = true;
|
|
151
|
+
startWatching();
|
|
152
|
+
publishAll(readFindings());
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'textDocument/didOpen':
|
|
156
|
+
case 'textDocument/didChange': {
|
|
157
|
+
const p = params as { textDocument?: { uri?: string } } | undefined;
|
|
158
|
+
const uri = p?.textDocument?.uri;
|
|
159
|
+
if (uri) publishForUri(uri, readFindings());
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'textDocument/didClose':
|
|
164
|
+
// Keep diagnostics visible after close
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case 'shutdown':
|
|
168
|
+
didShutdown = true;
|
|
169
|
+
watcher?.close();
|
|
170
|
+
respond(id!, null);
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
case 'exit':
|
|
174
|
+
process.exit(didShutdown ? 0 : 1);
|
|
175
|
+
break;
|
|
176
|
+
|
|
177
|
+
case '$/cancelRequest':
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
if (id !== undefined) respondError(id, -32601, `Method not found: ${method}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Frame-aware stdin reader
|
|
186
|
+
let buf = Buffer.alloc(0);
|
|
187
|
+
process.stdin.on('data', (chunk: Buffer) => {
|
|
188
|
+
buf = Buffer.concat([buf, chunk]);
|
|
189
|
+
const { messages, remaining } = parseMessages(buf);
|
|
190
|
+
buf = remaining as Buffer<ArrayBuffer>;
|
|
191
|
+
for (const msg of messages) handle(msg as LspMessage);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
process.stdin.on('end', () => {
|
|
195
|
+
watcher?.close();
|
|
196
|
+
process.exit(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return new Promise<void>(() => { /* event-loop keeps us alive */ });
|
|
200
|
+
}
|
package/src/cli/mcp.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// src/cli/mcp.ts
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
8
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
9
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
10
|
+
import type { GuardrailConfig } from '../core/config/types.ts';
|
|
11
|
+
import { handleReviewDiff } from '../core/mcp/handlers/review-diff.ts';
|
|
12
|
+
import { handleScanFiles } from '../core/mcp/handlers/scan-files.ts';
|
|
13
|
+
import { handleGetFindings } from '../core/mcp/handlers/get-findings.ts';
|
|
14
|
+
import { handleFixFinding } from '../core/mcp/handlers/fix-finding.ts';
|
|
15
|
+
import { handleValidateFix } from '../core/mcp/handlers/validate-fix.ts';
|
|
16
|
+
import { handleGetCapabilities } from '../core/mcp/handlers/get-capabilities.ts';
|
|
17
|
+
|
|
18
|
+
export async function runMcp(options: { cwd?: string; configPath?: string } = {}): Promise<void> {
|
|
19
|
+
const cwd = options.cwd ?? process.cwd();
|
|
20
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
21
|
+
|
|
22
|
+
let config: GuardrailConfig = { configVersion: 1 };
|
|
23
|
+
if (fs.existsSync(configPath)) {
|
|
24
|
+
const loaded = await loadConfig(configPath);
|
|
25
|
+
if (loaded) config = loaded;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Determine adapter name and options from config
|
|
29
|
+
const engineRef = (config as unknown as Record<string, unknown>).reviewEngine;
|
|
30
|
+
const ref =
|
|
31
|
+
typeof engineRef === 'string'
|
|
32
|
+
? engineRef
|
|
33
|
+
: (engineRef as { adapter?: string } | undefined)?.adapter ?? 'auto';
|
|
34
|
+
const engineOptions =
|
|
35
|
+
typeof engineRef === 'object' && engineRef !== null
|
|
36
|
+
? (engineRef as { options?: Record<string, unknown> }).options
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
const engine = await loadAdapter<ReviewEngine>({ point: 'review-engine', ref, options: engineOptions });
|
|
40
|
+
const adapterName = engine.name;
|
|
41
|
+
|
|
42
|
+
const server = new Server(
|
|
43
|
+
{ name: 'guardrail', version: '1.0.0' },
|
|
44
|
+
{ capabilities: { tools: {} } },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
48
|
+
tools: [
|
|
49
|
+
{
|
|
50
|
+
name: 'review_diff',
|
|
51
|
+
description: 'Review git-changed files against a base ref. Returns structured findings.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
base: { type: 'string', description: 'Base ref to diff against (default: upstream or HEAD~1)' },
|
|
56
|
+
cwd: { type: 'string', description: 'Working directory (default: process.cwd())' },
|
|
57
|
+
static_only: { type: 'boolean', description: 'Skip LLM review, run static rules only' },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'scan_files',
|
|
63
|
+
description: 'Review specific files or directories. Does not require git.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
required: ['files'],
|
|
67
|
+
properties: {
|
|
68
|
+
files: { type: 'array', items: { type: 'string' }, description: 'File or directory paths to scan' },
|
|
69
|
+
cwd: { type: 'string' },
|
|
70
|
+
ask: { type: 'string', description: 'Targeted question, e.g. "is there SQL injection risk?"' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'get_findings',
|
|
76
|
+
description: 'Return findings from a prior review_diff or scan_files run by run_id.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
required: ['run_id'],
|
|
80
|
+
properties: {
|
|
81
|
+
run_id: { type: 'string' },
|
|
82
|
+
severity: { type: 'string', enum: ['critical', 'warning', 'note'], description: 'Minimum severity to include' },
|
|
83
|
+
cwd: { type: 'string' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'fix_finding',
|
|
89
|
+
description: 'Apply an LLM-generated fix for a specific finding. Validates file checksum before applying.',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
required: ['run_id', 'finding_id'],
|
|
93
|
+
properties: {
|
|
94
|
+
run_id: { type: 'string' },
|
|
95
|
+
finding_id: { type: 'string' },
|
|
96
|
+
cwd: { type: 'string' },
|
|
97
|
+
dry_run: { type: 'boolean', description: 'Return patch without applying' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'validate_fix',
|
|
103
|
+
description: 'Run the configured testCommand and return pass/fail.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
cwd: { type: 'string' },
|
|
108
|
+
files: { type: 'array', items: { type: 'string' } },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'get_capabilities',
|
|
114
|
+
description: 'Return adapter, enabled rules, and workspace metadata.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: { cwd: { type: 'string' } },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
124
|
+
const { name, arguments: args = {} } = request.params;
|
|
125
|
+
const a = args as Record<string, unknown>;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
let result: unknown;
|
|
129
|
+
switch (name) {
|
|
130
|
+
case 'review_diff':
|
|
131
|
+
result = await handleReviewDiff(
|
|
132
|
+
{
|
|
133
|
+
base: a['base'] as string | undefined,
|
|
134
|
+
cwd: a['cwd'] as string | undefined,
|
|
135
|
+
static_only: a['static_only'] as boolean | undefined,
|
|
136
|
+
},
|
|
137
|
+
config,
|
|
138
|
+
engine,
|
|
139
|
+
);
|
|
140
|
+
break;
|
|
141
|
+
case 'scan_files':
|
|
142
|
+
result = await handleScanFiles(
|
|
143
|
+
{
|
|
144
|
+
files: a['files'] as string[],
|
|
145
|
+
cwd: a['cwd'] as string | undefined,
|
|
146
|
+
ask: a['ask'] as string | undefined,
|
|
147
|
+
},
|
|
148
|
+
config,
|
|
149
|
+
engine,
|
|
150
|
+
);
|
|
151
|
+
break;
|
|
152
|
+
case 'get_findings':
|
|
153
|
+
result = await handleGetFindings({
|
|
154
|
+
run_id: a['run_id'] as string,
|
|
155
|
+
severity: a['severity'] as 'critical' | 'warning' | 'note' | undefined,
|
|
156
|
+
cwd: a['cwd'] as string | undefined,
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
case 'fix_finding':
|
|
160
|
+
result = await handleFixFinding(
|
|
161
|
+
{
|
|
162
|
+
run_id: a['run_id'] as string,
|
|
163
|
+
finding_id: a['finding_id'] as string,
|
|
164
|
+
cwd: a['cwd'] as string | undefined,
|
|
165
|
+
dry_run: a['dry_run'] as boolean | undefined,
|
|
166
|
+
},
|
|
167
|
+
config,
|
|
168
|
+
engine,
|
|
169
|
+
);
|
|
170
|
+
break;
|
|
171
|
+
case 'validate_fix':
|
|
172
|
+
result = await handleValidateFix(
|
|
173
|
+
{
|
|
174
|
+
cwd: a['cwd'] as string | undefined,
|
|
175
|
+
files: a['files'] as string[] | undefined,
|
|
176
|
+
},
|
|
177
|
+
config,
|
|
178
|
+
);
|
|
179
|
+
break;
|
|
180
|
+
case 'get_capabilities':
|
|
181
|
+
result = await handleGetCapabilities(
|
|
182
|
+
{ cwd: a['cwd'] as string | undefined },
|
|
183
|
+
config,
|
|
184
|
+
adapterName,
|
|
185
|
+
);
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
|
|
190
|
+
isError: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
196
|
+
const code = (err as { code?: string }).code ?? 'unknown_error';
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: 'text' as const, text: JSON.stringify({ error: msg, code }) }],
|
|
199
|
+
isError: true,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const transport = new StdioServerTransport();
|
|
205
|
+
await server.connect(transport);
|
|
206
|
+
}
|
package/src/cli/pr-comment.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { runSafe } from '../core/shell.ts';
|
|
2
2
|
import type { RunResult } from '../core/pipeline/run.ts';
|
|
3
|
-
import type {
|
|
3
|
+
import type { GuardrailConfig } from '../core/config/types.ts';
|
|
4
4
|
import type { GitContext } from '../core/detect/git-context.ts';
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
|
-
const COMMENT_MARKER = '<!--
|
|
9
|
+
const COMMENT_MARKER = '<!-- guardrail-review -->';
|
|
10
10
|
|
|
11
11
|
function readVersion(): string {
|
|
12
12
|
try {
|
|
@@ -42,7 +42,7 @@ function findExistingCommentId(pr: number, cwd: string): number | null {
|
|
|
42
42
|
/** Format a RunResult into a markdown PR comment. */
|
|
43
43
|
export function formatComment(
|
|
44
44
|
result: RunResult,
|
|
45
|
-
config:
|
|
45
|
+
config: GuardrailConfig,
|
|
46
46
|
gitCtx: GitContext,
|
|
47
47
|
touchedFileCount: number,
|
|
48
48
|
): string {
|
|
@@ -108,9 +108,9 @@ export function formatComment(
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
if (result.totalCostUSD !== undefined) {
|
|
111
|
-
lines.push(`*Cost: $${result.totalCostUSD.toFixed(4)} · ${result.durationMs}ms · [@delegance/
|
|
111
|
+
lines.push(`*Cost: $${result.totalCostUSD.toFixed(4)} · ${result.durationMs}ms · [@delegance/guardrail](https://github.com/axledbetter/guardrail) v${readVersion()}*`);
|
|
112
112
|
} else {
|
|
113
|
-
lines.push(`*${result.durationMs}ms · [@delegance/
|
|
113
|
+
lines.push(`*${result.durationMs}ms · [@delegance/guardrail](https://github.com/axledbetter/guardrail) v${readVersion()}*`);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
return lines.join('\n');
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export interface PrDescOptions {
|
|
6
|
+
base?: string;
|
|
7
|
+
post?: boolean;
|
|
8
|
+
yes?: boolean;
|
|
9
|
+
output?: string;
|
|
10
|
+
_gitDiff?: string;
|
|
11
|
+
_branchName?: string;
|
|
12
|
+
_cachedFindings?: Finding[];
|
|
13
|
+
_reviewEngine?: {
|
|
14
|
+
review(input: { content: string; kind: string }): Promise<{ rawOutput: string }>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrDescResult {
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
prUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function truncateDiff(diff: string, charLimit = 6000): string {
|
|
25
|
+
if (diff.length <= charLimit) return diff;
|
|
26
|
+
const remaining = diff.length - charLimit;
|
|
27
|
+
return `${diff.slice(0, charLimit)}[...truncated ${remaining} chars]`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function summarizeFindings(findings: Finding[], max = 10): string {
|
|
31
|
+
if (findings.length === 0) return 'None';
|
|
32
|
+
const order: Record<string, number> = { critical: 0, error: 1, warning: 2, info: 3 };
|
|
33
|
+
const sorted = [...findings].sort(
|
|
34
|
+
(a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9),
|
|
35
|
+
);
|
|
36
|
+
return sorted
|
|
37
|
+
.slice(0, max)
|
|
38
|
+
.map(f => `- [${f.severity.toUpperCase()}] ${f.file}:${f.line ?? '?'} — ${f.message}`)
|
|
39
|
+
.join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseDescription(raw: string): { title: string; body: string } {
|
|
43
|
+
const titleMatch = raw.match(/^Title:\s*(.+)$/m);
|
|
44
|
+
const title = titleMatch ? titleMatch[1]!.trim() : 'chore: update';
|
|
45
|
+
const sepIdx = raw.indexOf('\n\n---\n');
|
|
46
|
+
const body = sepIdx !== -1 ? raw.slice(sepIdx + 5).trim() : raw.replace(/^Title:.*\n?/m, '').trim();
|
|
47
|
+
return { title, body };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runPrDesc(options: PrDescOptions): Promise<PrDescResult> {
|
|
51
|
+
const branchName = options._branchName ?? getBranchName();
|
|
52
|
+
const diff = options._gitDiff ?? getGitDiff(options.base);
|
|
53
|
+
|
|
54
|
+
if (!diff.trim()) {
|
|
55
|
+
process.stdout.write('No changes detected\n');
|
|
56
|
+
return { title: 'No changes detected', body: '' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const findings = options._cachedFindings ?? loadCachedFindings();
|
|
60
|
+
const prompt = buildPrompt(branchName, truncateDiff(diff), summarizeFindings(findings));
|
|
61
|
+
|
|
62
|
+
const engine = options._reviewEngine ?? (await resolveEngine() as unknown as { review(input: { content: string; kind: string }): Promise<{ rawOutput: string }> });
|
|
63
|
+
const { rawOutput } = await engine.review({ content: prompt, kind: 'pr-diff' });
|
|
64
|
+
const { title, body } = parseDescription(rawOutput);
|
|
65
|
+
|
|
66
|
+
const formatted = `Title: ${title}\n\n---\n${body}`;
|
|
67
|
+
|
|
68
|
+
if (options.output) {
|
|
69
|
+
fs.writeFileSync(options.output, formatted, 'utf8');
|
|
70
|
+
} else {
|
|
71
|
+
process.stdout.write(formatted + '\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.post) {
|
|
75
|
+
return createPr(title, body, options.yes ?? false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { title, body };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getBranchName(): string {
|
|
82
|
+
try {
|
|
83
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
return 'unknown';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getGitDiff(base?: string): string {
|
|
90
|
+
try {
|
|
91
|
+
const ref = base ?? getUpstreamBase();
|
|
92
|
+
return execSync(`git diff ${ref}...HEAD`, { encoding: 'utf8' });
|
|
93
|
+
} catch {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getUpstreamBase(): string {
|
|
99
|
+
try {
|
|
100
|
+
return execSync(
|
|
101
|
+
'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
|
|
102
|
+
{ encoding: 'utf8' },
|
|
103
|
+
).trim();
|
|
104
|
+
} catch {
|
|
105
|
+
return 'HEAD~1';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadCachedFindings(): Finding[] {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(fs.readFileSync('.guardrail-cache/findings.json', 'utf8')) as Finding[];
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildPrompt(branch: string, diff: string, findingsSummary: string): string {
|
|
118
|
+
return `Generate a pull request description with three sections:
|
|
119
|
+
|
|
120
|
+
## Summary
|
|
121
|
+
<3-5 bullet points describing what changed and why>
|
|
122
|
+
|
|
123
|
+
## Changes
|
|
124
|
+
<grouped by file/area, concise>
|
|
125
|
+
|
|
126
|
+
## Test Plan
|
|
127
|
+
<checklist of what to verify before merging>
|
|
128
|
+
|
|
129
|
+
Branch: ${branch}
|
|
130
|
+
Diff:
|
|
131
|
+
${diff}
|
|
132
|
+
|
|
133
|
+
Guardrail findings in this diff:
|
|
134
|
+
${findingsSummary}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function resolveEngine() {
|
|
138
|
+
const { loadAdapter } = await import('../adapters/loader.ts');
|
|
139
|
+
const { loadConfig } = await import('../core/config/loader.ts');
|
|
140
|
+
const configPath = process.env.GUARDRAIL_CONFIG ?? 'guardrail.config.yaml';
|
|
141
|
+
let engineRef = 'auto';
|
|
142
|
+
try {
|
|
143
|
+
const cfg = await loadConfig(configPath);
|
|
144
|
+
const rev = (cfg as { reviewEngine?: unknown }).reviewEngine;
|
|
145
|
+
if (rev) engineRef = typeof rev === 'string' ? rev : (rev as { adapter: string }).adapter;
|
|
146
|
+
} catch {
|
|
147
|
+
// no config file — fall back to auto
|
|
148
|
+
}
|
|
149
|
+
return loadAdapter({ point: 'review-engine', ref: engineRef });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function createPr(title: string, body: string, yes: boolean): Promise<PrDescResult> {
|
|
153
|
+
if (!yes) {
|
|
154
|
+
process.stdout.write('\nCreate PR with this description? [y/N] ');
|
|
155
|
+
const answer = await new Promise<string>(resolve => {
|
|
156
|
+
process.stdin.setEncoding('utf8');
|
|
157
|
+
process.stdin.once('data', (chunk: string) => resolve(chunk.split('\n')[0] ?? ''));
|
|
158
|
+
});
|
|
159
|
+
if (!answer.toLowerCase().startsWith('y')) return { title, body };
|
|
160
|
+
}
|
|
161
|
+
const result = spawnSync('gh', ['pr', 'create', '--title', title, '--body', body], { encoding: 'utf8' });
|
|
162
|
+
if (result.status !== 0) {
|
|
163
|
+
throw new Error(`gh pr create failed: ${result.stderr?.trim() || result.error?.message || 'unknown error'}`);
|
|
164
|
+
}
|
|
165
|
+
const prUrl = result.stdout.trim();
|
|
166
|
+
process.stdout.write(`\nPR created: ${prUrl}\n`);
|
|
167
|
+
return { title, body, prUrl };
|
|
168
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { runSafe } from '../core/shell.ts';
|
|
2
2
|
import type { Finding } from '../core/findings/types.ts';
|
|
3
3
|
|
|
4
|
-
const REVIEW_MARKER = '<!--
|
|
4
|
+
const REVIEW_MARKER = '<!-- guardrail-inline -->';
|
|
5
5
|
|
|
6
6
|
function getRepoNwo(cwd: string): string | null {
|
|
7
7
|
const raw = runSafe('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { cwd });
|
|
@@ -50,7 +50,7 @@ export async function postReviewComments(
|
|
|
50
50
|
runSafe('gh', [
|
|
51
51
|
'api', `repos/${nwo}/pulls/${pr}/reviews/${existingId}/dismissals`,
|
|
52
52
|
'--method', 'PUT',
|
|
53
|
-
'--field', 'message=Superseded by updated
|
|
53
|
+
'--field', 'message=Superseded by updated guardrail review',
|
|
54
54
|
], { cwd });
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -87,6 +87,6 @@ function formatFindingBody(f: Finding): string {
|
|
|
87
87
|
: '💡 **Note**';
|
|
88
88
|
const lines = [`${sev} — ${f.message}`];
|
|
89
89
|
if (f.suggestion) lines.push(`\n> **Suggestion:** ${f.suggestion}`);
|
|
90
|
-
lines.push(`\n*[@delegance/
|
|
90
|
+
lines.push(`\n*[@delegance/guardrail](https://github.com/axledbetter/guardrail)*`);
|
|
91
91
|
return lines.join('');
|
|
92
92
|
}
|