klyro 0.1.17 → 0.1.19
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/dist/agent/anthropic-adapter.js +2 -8
- package/dist/agent/provider-adapter.js +21 -2
- package/dist/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.js +169 -8
- package/dist/cli/eval.js +15 -0
- package/dist/cli/repl.js +23 -0
- package/dist/cli/run.d.ts +1 -0
- package/dist/cli/run.js +17 -0
- package/dist/cli/slash/parser.d.ts +2 -0
- package/dist/cli/slash/parser.js +2 -1
- package/dist/index.js +2 -0
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +33 -11
- package/dist/policy/path-guard.js +5 -7
- package/dist/policy/secret-redactor.js +6 -2
- package/dist/tools/fs/apply-patch.js +5 -5
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/search/grep.d.ts +20 -0
- package/dist/tools/search/grep.js +7 -0
- package/dist/tools/shell/background.js +25 -1
- package/dist/tools/shell/shell-exec.js +7 -0
- package/dist/verification/baseline.d.ts +17 -0
- package/dist/verification/baseline.js +115 -0
- package/dist/verification/classify.d.ts +22 -0
- package/dist/verification/classify.js +108 -0
- package/dist/verification/detect.js +175 -10
- package/dist/verification/engine.js +14 -3
- package/dist/verification/registry.d.ts +23 -0
- package/dist/verification/registry.js +130 -0
- package/dist/verification/scoped.d.ts +22 -0
- package/dist/verification/scoped.js +219 -0
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import * as path from 'node:path';
|
|
|
9
9
|
import * as crypto from 'node:crypto';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
|
-
import {
|
|
12
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
13
13
|
import { safe } from '../normalize.js';
|
|
14
14
|
const InputSchema = z.object({
|
|
15
15
|
path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
|
|
@@ -26,7 +26,7 @@ export const writeFileTool = defineTool({
|
|
|
26
26
|
renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
|
|
27
27
|
execute: async (input, ctx) => {
|
|
28
28
|
return safe(async () => {
|
|
29
|
-
const { resolved } =
|
|
29
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
30
30
|
const parent = path.dirname(resolved);
|
|
31
31
|
await fs.mkdir(parent, { recursive: true });
|
|
32
32
|
// 3.2: if file exists and was not read this session, warn but allow with diff
|
|
@@ -31,6 +31,26 @@ export declare const grepTool: import("../types.js").Tool<{
|
|
|
31
31
|
maxResults?: number | undefined;
|
|
32
32
|
contextLines?: number | undefined;
|
|
33
33
|
}, {
|
|
34
|
+
readonly ok: false;
|
|
35
|
+
readonly error: {
|
|
36
|
+
readonly code: "INVALID_INPUT";
|
|
37
|
+
readonly message: "Pattern too long (max 200 chars)";
|
|
38
|
+
};
|
|
39
|
+
pattern?: undefined;
|
|
40
|
+
hits?: undefined;
|
|
41
|
+
truncated?: undefined;
|
|
42
|
+
searchedFiles?: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
readonly ok: false;
|
|
45
|
+
readonly error: {
|
|
46
|
+
readonly code: "INVALID_INPUT";
|
|
47
|
+
readonly message: "Pattern rejected (potential ReDoS)";
|
|
48
|
+
};
|
|
49
|
+
pattern?: undefined;
|
|
50
|
+
hits?: undefined;
|
|
51
|
+
truncated?: undefined;
|
|
52
|
+
searchedFiles?: undefined;
|
|
53
|
+
} | {
|
|
34
54
|
readonly ok: false;
|
|
35
55
|
readonly error: {
|
|
36
56
|
readonly code: "INVALID_INPUT";
|
|
@@ -24,6 +24,13 @@ export const grepTool = defineTool({
|
|
|
24
24
|
inputSchema: InputSchema,
|
|
25
25
|
execute: async (input, ctx) => {
|
|
26
26
|
return safe(async () => {
|
|
27
|
+
if (input.pattern.length > 200) {
|
|
28
|
+
return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern too long (max 200 chars)' } };
|
|
29
|
+
}
|
|
30
|
+
// reject catastrophic backtracking patterns (nested quantifiers like (a+)+ )
|
|
31
|
+
if (/\([^)]*\+[^)]*\)\+|\(\.\*\)\*|\{[0-9]+,[0-9]*\}\s*\+/.test(input.pattern)) {
|
|
32
|
+
return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern rejected (potential ReDoS)' } };
|
|
33
|
+
}
|
|
27
34
|
const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
|
|
28
35
|
let re;
|
|
29
36
|
try {
|
|
@@ -4,7 +4,28 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
const jobs = new Map();
|
|
6
6
|
let counter = 0;
|
|
7
|
+
const MAX_JOBS = 5;
|
|
8
|
+
const JOB_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
function pruneJobs() {
|
|
10
|
+
if (jobs.size <= MAX_JOBS)
|
|
11
|
+
return;
|
|
12
|
+
const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
|
|
13
|
+
for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
|
|
14
|
+
try {
|
|
15
|
+
j.proc.kill('SIGKILL');
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignore */ }
|
|
18
|
+
jobs.delete(j.id);
|
|
19
|
+
}
|
|
20
|
+
for (const j of [...jobs.values()]) {
|
|
21
|
+
if (Date.now() - j.start > JOB_TTL_MS && j.proc.exitCode !== null)
|
|
22
|
+
jobs.delete(j.id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
7
25
|
export function startBackground(command, cwd) {
|
|
26
|
+
pruneJobs();
|
|
27
|
+
if (jobs.size >= MAX_JOBS)
|
|
28
|
+
throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
|
|
8
29
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
9
30
|
const proc = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
10
31
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
@@ -13,7 +34,10 @@ export function startBackground(command, cwd) {
|
|
|
13
34
|
job.output = job.output.slice(-1_000_000); });
|
|
14
35
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
15
36
|
job.output = job.output.slice(-1_000_000); });
|
|
16
|
-
proc.on('close', () => {
|
|
37
|
+
proc.on('close', () => {
|
|
38
|
+
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
39
|
+
jobs.delete(id); }, JOB_TTL_MS);
|
|
40
|
+
});
|
|
17
41
|
return id;
|
|
18
42
|
}
|
|
19
43
|
export function getOutput(id, filter) {
|
|
@@ -77,6 +77,13 @@ const DANGEROUS_PATTERNS = [
|
|
|
77
77
|
{ pattern: /curl.*\|\s*(sh|bash|zsh|python|python3|perl|ruby|php)/i, reason: 'curl|sh to unknown host' },
|
|
78
78
|
{ pattern: /wget.*\|\s*(sh|bash|python|perl|ruby)/i, reason: 'wget|sh pipe' },
|
|
79
79
|
{ pattern: /rm\s+-rf\s+--no-preserve-root\s+\//, reason: 'recursive delete --no-preserve-root' },
|
|
80
|
+
// Shell metacharacter escapes — block command substitution and chaining of dangerous cmds
|
|
81
|
+
{ pattern: /\$\(/, reason: 'command substitution $()' },
|
|
82
|
+
{ pattern: /`[^`]*`/, reason: 'command substitution via backticks' },
|
|
83
|
+
{ pattern: /\|\s*bash\b|\|\s*sh\b/, reason: 'pipe to shell' },
|
|
84
|
+
{ pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
85
|
+
{ pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
86
|
+
{ pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
80
87
|
];
|
|
81
88
|
export const shellExecTool = defineTool({
|
|
82
89
|
name: 'shell_exec',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Baseline run (pre-existing failure cache per HEAD)
|
|
3
|
+
* Before first edit, run primary verifier and cache result keyed by git HEAD.
|
|
4
|
+
* Used to distinguish introduced vs pre-existing failures (6.4 classify).
|
|
5
|
+
*/
|
|
6
|
+
export interface BaselineResult {
|
|
7
|
+
head: string;
|
|
8
|
+
command: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
capturedAt: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function getBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
|
|
16
|
+
export declare function runBaseline(cwd: string, command?: string, timeoutMs?: number): Promise<BaselineResult | null>;
|
|
17
|
+
export declare function ensureBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Baseline run (pre-existing failure cache per HEAD)
|
|
3
|
+
* Before first edit, run primary verifier and cache result keyed by git HEAD.
|
|
4
|
+
* Used to distinguish introduced vs pre-existing failures (6.4 classify).
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as crypto from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { primaryVerifyCommand } from './registry.js';
|
|
11
|
+
async function gitHead(cwd) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawn('git', ['rev-parse', 'HEAD'], { cwd, shell: false });
|
|
14
|
+
let out = '';
|
|
15
|
+
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
16
|
+
child.on('close', () => resolve(out.trim() || 'no-head'));
|
|
17
|
+
child.on('error', () => resolve('no-head'));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function baselinePath(cwd, head) {
|
|
21
|
+
const safe = head.slice(0, 12) || 'no-head';
|
|
22
|
+
return path.join(cwd, '.klyro', 'baselines', `${safe}.json`);
|
|
23
|
+
}
|
|
24
|
+
export async function getBaseline(cwd, command) {
|
|
25
|
+
const cmd = command ?? primaryVerifyCommand(cwd);
|
|
26
|
+
if (!cmd)
|
|
27
|
+
return null;
|
|
28
|
+
const head = await gitHead(cwd);
|
|
29
|
+
const p = baselinePath(cwd, head);
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (parsed.head === head && parsed.command === cmd)
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
catch { /* miss */ }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export async function runBaseline(cwd, command, timeoutMs = 90_000) {
|
|
40
|
+
const cmd = command ?? primaryVerifyCommand(cwd);
|
|
41
|
+
if (!cmd)
|
|
42
|
+
return null;
|
|
43
|
+
const head = await gitHead(cwd);
|
|
44
|
+
const p = baselinePath(cwd, head);
|
|
45
|
+
// if cached, return
|
|
46
|
+
const cached = await getBaseline(cwd, cmd);
|
|
47
|
+
if (cached)
|
|
48
|
+
return cached;
|
|
49
|
+
const result = await runCmd(cwd, cmd, timeoutMs);
|
|
50
|
+
const baseline = {
|
|
51
|
+
head,
|
|
52
|
+
command: cmd,
|
|
53
|
+
ok: result.ok,
|
|
54
|
+
exitCode: result.exitCode,
|
|
55
|
+
stdout: result.stdout.slice(0, 8000),
|
|
56
|
+
stderr: result.stderr.slice(0, 8000),
|
|
57
|
+
capturedAt: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
61
|
+
const tmp = `${p}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2));
|
|
63
|
+
fs.renameSync(tmp, p);
|
|
64
|
+
}
|
|
65
|
+
catch { /* ignore */ }
|
|
66
|
+
return baseline;
|
|
67
|
+
}
|
|
68
|
+
const MAX_BASELINE_BYTES = 256 * 1024;
|
|
69
|
+
function cap(cur, chunk) {
|
|
70
|
+
if (cur.length >= MAX_BASELINE_BYTES)
|
|
71
|
+
return cur;
|
|
72
|
+
const n = cur + chunk;
|
|
73
|
+
return n.length > MAX_BASELINE_BYTES ? n.slice(0, MAX_BASELINE_BYTES) + '\n... [truncated]' : n;
|
|
74
|
+
}
|
|
75
|
+
function runCmd(cwd, command, timeoutMs) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
78
|
+
let stdout = '';
|
|
79
|
+
let stderr = '';
|
|
80
|
+
let done = false;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
if (done)
|
|
83
|
+
return;
|
|
84
|
+
done = true;
|
|
85
|
+
try {
|
|
86
|
+
child.kill();
|
|
87
|
+
}
|
|
88
|
+
catch { /* ignore */ }
|
|
89
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
child.stdout.on('data', (b) => { stdout = cap(stdout, b.toString()); });
|
|
92
|
+
child.stderr.on('data', (b) => { stderr = cap(stderr, b.toString()); });
|
|
93
|
+
child.on('close', (code) => {
|
|
94
|
+
if (done)
|
|
95
|
+
return;
|
|
96
|
+
done = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
const exit = typeof code === 'number' ? code : -1;
|
|
99
|
+
resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
|
|
100
|
+
});
|
|
101
|
+
child.on('error', (err) => {
|
|
102
|
+
if (done)
|
|
103
|
+
return;
|
|
104
|
+
done = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
export async function ensureBaseline(cwd, command) {
|
|
111
|
+
const existing = await getBaseline(cwd, command);
|
|
112
|
+
if (existing)
|
|
113
|
+
return existing;
|
|
114
|
+
return runBaseline(cwd, command);
|
|
115
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Failure } from './detect.js';
|
|
2
|
+
import type { BaselineResult } from './baseline.js';
|
|
3
|
+
export type FailureClass = 'introduced' | 'pre_existing' | 'flaky' | 'env';
|
|
4
|
+
export declare function classifyFailure(current: {
|
|
5
|
+
failure?: Failure;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
}, baseline: BaselineResult | null, flakyRerunOk?: boolean): FailureClass;
|
|
9
|
+
export declare function rerunOnce(cwd: string, command: string, timeoutMs?: number): Promise<boolean>;
|
|
10
|
+
export interface RepairContext {
|
|
11
|
+
failingTests: {
|
|
12
|
+
file: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}[];
|
|
15
|
+
hunks: string;
|
|
16
|
+
blame: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function gatherRepairContext(cwd: string, failure: Failure | undefined): Promise<RepairContext>;
|
|
19
|
+
export declare function guardRepair(diff: string, editedFiles: string[]): {
|
|
20
|
+
blocked: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.4 — Repair classifier + context gather + guard
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
export function classifyFailure(current, baseline, flakyRerunOk) {
|
|
8
|
+
const combined = (current.stderr + '\n' + current.stdout).toLowerCase();
|
|
9
|
+
// env: missing binary, network, permission, no such file, EACCES etc
|
|
10
|
+
if (/enoent|command not found|no such file|network|econn|etimedout|eacces|permission denied|env/.test(combined) && /error/i.test(combined)) {
|
|
11
|
+
// only if not a real test failure but env
|
|
12
|
+
if (!current.failure || current.failure.type === 'unknown')
|
|
13
|
+
return 'env';
|
|
14
|
+
// even test failures can be env if message hints
|
|
15
|
+
if (/enoent|not found/.test(combined))
|
|
16
|
+
return 'env';
|
|
17
|
+
}
|
|
18
|
+
if (flakyRerunOk)
|
|
19
|
+
return 'flaky';
|
|
20
|
+
if (baseline && !baseline.ok) {
|
|
21
|
+
const baseRaw = (baseline.stderr + '\n' + baseline.stdout);
|
|
22
|
+
// if current failure files overlap baseline failure raw, treat as pre-existing
|
|
23
|
+
if (current.failure && baseline.stdout + baseline.stderr) {
|
|
24
|
+
const curPaths = new Set(current.failure.files.map((f) => f.path).filter(Boolean));
|
|
25
|
+
const baseContains = [...curPaths].some((p) => baseRaw.includes(p));
|
|
26
|
+
if (baseContains)
|
|
27
|
+
return 'pre_existing';
|
|
28
|
+
// also if same exit code and type, likely pre-existing
|
|
29
|
+
if (baseRaw.includes(current.failure.files[0]?.message ?? '') && baseRaw.length > 0)
|
|
30
|
+
return 'pre_existing';
|
|
31
|
+
}
|
|
32
|
+
// if baseline failed and current also fails with similar raw length, assume pre-existing
|
|
33
|
+
if (current.failure && baseline.stderr.length > 0 && combined.includes(baseline.stderr.slice(0, 200).toLowerCase()))
|
|
34
|
+
return 'pre_existing';
|
|
35
|
+
}
|
|
36
|
+
return 'introduced';
|
|
37
|
+
}
|
|
38
|
+
export async function rerunOnce(cwd, command, timeoutMs = 45_000) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
41
|
+
let done = false;
|
|
42
|
+
const t = setTimeout(() => { if (!done) {
|
|
43
|
+
done = true;
|
|
44
|
+
try {
|
|
45
|
+
child.kill();
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
resolve(false);
|
|
49
|
+
} }, timeoutMs);
|
|
50
|
+
child.on('close', (code) => { if (done)
|
|
51
|
+
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
52
|
+
child.on('error', () => { if (done)
|
|
53
|
+
return; done = true; clearTimeout(t); resolve(false); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function gatherRepairContext(cwd, failure) {
|
|
57
|
+
const failingTests = [];
|
|
58
|
+
if (failure) {
|
|
59
|
+
for (const f of failure.files.slice(0, 3)) {
|
|
60
|
+
if (!f.path)
|
|
61
|
+
continue;
|
|
62
|
+
const full = path.join(cwd, f.path);
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(full, 'utf-8');
|
|
65
|
+
// slice around failing line if available
|
|
66
|
+
const lines = raw.split('\n');
|
|
67
|
+
const start = Math.max(0, (f.line ?? 1) - 20);
|
|
68
|
+
const end = Math.min(lines.length, (f.line ?? 1) + 20);
|
|
69
|
+
failingTests.push({ file: f.path, content: lines.slice(start, end).join('\n') });
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// hunks: git diff --stat + --unified=2 for changed files
|
|
75
|
+
const hunks = await execCapture(cwd, 'git diff --stat && echo "---" && git diff -U2 2>&1 | head -n 300');
|
|
76
|
+
const blame = failure?.files[0]?.path
|
|
77
|
+
? await execCapture(cwd, `git blame "${failure.files[0].path}" 2>&1 | head -n 20`)
|
|
78
|
+
: '';
|
|
79
|
+
return { failingTests, hunks, blame };
|
|
80
|
+
}
|
|
81
|
+
function execCapture(cwd, cmd) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const child = spawn(cmd, { cwd, shell: true, env: process.env });
|
|
84
|
+
let out = '';
|
|
85
|
+
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
86
|
+
child.stderr.on('data', (b) => { out += b.toString(); });
|
|
87
|
+
child.on('close', () => resolve(out.slice(0, 4000)));
|
|
88
|
+
child.on('error', () => resolve(''));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Guard: does diff touch assertions or add skips?
|
|
92
|
+
const ASSERT_RE = /(?:expect\s*\(|assert\.|assert\(|should\.|chai\.)/;
|
|
93
|
+
const SKIP_RE = /(?:\.skip\(|\.todo\(|xdescribe\(|xit\(|xtest\(|@pytest\.mark\.skip|pytest\.skip|:\s*skip\b)/i;
|
|
94
|
+
export function guardRepair(diff, editedFiles) {
|
|
95
|
+
if (!diff)
|
|
96
|
+
return { blocked: false };
|
|
97
|
+
const addedLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++'));
|
|
98
|
+
const touchesAssert = addedLines.some((l) => ASSERT_RE.test(l));
|
|
99
|
+
const addsSkip = addedLines.some((l) => SKIP_RE.test(l));
|
|
100
|
+
if (touchesAssert && addedLines.length < 20) {
|
|
101
|
+
// touching assertions in a small diff likely means editing test expectations — require approval
|
|
102
|
+
return { blocked: true, reason: 'repair touches test assertions — requires explicit approval (edit expectations directly is discouraged)' };
|
|
103
|
+
}
|
|
104
|
+
if (addsSkip) {
|
|
105
|
+
return { blocked: true, reason: 'repair adds skip/todo — requires explicit approval (skipping tests is not a fix)' };
|
|
106
|
+
}
|
|
107
|
+
return { blocked: false };
|
|
108
|
+
}
|
|
@@ -11,39 +11,178 @@
|
|
|
11
11
|
* the raw output; we just pre-structure the high-signal lines.
|
|
12
12
|
*/
|
|
13
13
|
const TS_LINE = /^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
|
|
14
|
+
const TSC_LINE2 = /^(.+?):(\d+):(\d+)\s+-\s+error\s+(TS\d+):\s+(.+)$/;
|
|
14
15
|
const TEST_FAIL = /^\s*✘|FAIL\s+(\S+)|✗\s+(\S+)|×\s+(.+?)\s/;
|
|
15
|
-
const LINT_LINE = /^(.+?)\s*$/;
|
|
16
16
|
const RUNTIME_LINE = /^(?:Error|TypeError|ReferenceError):\s+(.+)$/;
|
|
17
|
+
// 6.2 — dedicated patterns for each runner/linter/compiler
|
|
18
|
+
const PYTEST_FAIL = /FAILED\s+(\S+?)(?:::\S+)?\s+-\s+(.+)/;
|
|
19
|
+
const PYTEST_ASSERT = /AssertionError:\s*(.+)/;
|
|
20
|
+
const GO_FAIL = /^---\s+FAIL:\s+(\S+)\s+\((.+)\)/;
|
|
21
|
+
const GO_PKG_FAIL = /^FAIL\s+(\S+)\s/;
|
|
22
|
+
const CARGO_FAIL = /^test\s+(\S+)\s+\.\.\.\s+FAILED/;
|
|
23
|
+
const CARGO_PANIC = /thread\s+'(.+?)'\s+panicked at\s+'(.+?)',\s+(.+?):(\d+):(\d+)/;
|
|
24
|
+
const MOCHA_FAIL = /^\s*\d+\)\s+(.+)$/;
|
|
25
|
+
const JUNIT_FAIL = /<(?:failure|error)[\s>]/;
|
|
26
|
+
const ESLINT_LINE = /^(.+?):(\d+):(\d+):\s+(error|warning)\s+(.+?)\s+\((.+?)\)\s*$/;
|
|
27
|
+
const RUFF_LINE = /^(.+?):(\d+):(\d+):\s+([A-Z]\d+)\s+(.+)$/;
|
|
28
|
+
const MYPY_LINE = /^(.+?):(\d+):\s+error:\s+(.+?)\s+\[(.+?)\]\s*$/;
|
|
29
|
+
const GCC_LINE = /^(.+?):(\d+):(\d+):\s+(?:fatal\s+)?error:\s+(.+)$/;
|
|
30
|
+
const VITE_ERROR = /ERROR\s+in\s+(.+)|Module build failed.+/;
|
|
31
|
+
const DOTNET_CS = /^(.+?)\((\d+),(\d+)\):\s+error\s+(CS\d+):\s+(.+)$/;
|
|
32
|
+
const GENERIC_FILE_LINE = /^(.+?):(\d+)(?::(\d+))?:\s*(.+)$/;
|
|
17
33
|
export function detect(stdout, stderr, exitCode) {
|
|
18
34
|
const combined = stderr + '\n' + stdout;
|
|
19
35
|
if (exitCode === 0) {
|
|
20
36
|
return { type: 'unknown', files: [], raw: combined, exitCode };
|
|
21
37
|
}
|
|
22
|
-
// TypeScript tsc
|
|
38
|
+
// TypeScript tsc (both formats)
|
|
23
39
|
const tsMatches = [];
|
|
24
40
|
for (const line of combined.split(/\r?\n/)) {
|
|
25
|
-
|
|
41
|
+
let m = TS_LINE.exec(line);
|
|
26
42
|
if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
|
|
27
43
|
tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
m = TSC_LINE2.exec(line);
|
|
47
|
+
if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
|
|
48
|
+
tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
49
|
+
}
|
|
50
|
+
const dm = DOTNET_CS.exec(line);
|
|
51
|
+
if (dm && dm[1] && dm[2] && dm[3] && dm[4] && dm[5]) {
|
|
52
|
+
tsMatches.push({ path: dm[1], line: Number(dm[2]), column: Number(dm[3]), code: dm[4], message: dm[5] });
|
|
28
53
|
}
|
|
29
54
|
}
|
|
30
55
|
if (tsMatches.length > 0)
|
|
31
|
-
return { type: 'type', files: tsMatches, raw: combined, exitCode };
|
|
32
|
-
//
|
|
33
|
-
if (
|
|
56
|
+
return { type: 'type', files: dedupe(tsMatches), raw: combined, exitCode };
|
|
57
|
+
// JUnit XML
|
|
58
|
+
if (JUNIT_FAIL.test(combined)) {
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
61
|
+
if (JUNIT_FAIL.test(line))
|
|
62
|
+
files.push({ path: '', message: line.trim() });
|
|
63
|
+
}
|
|
64
|
+
// also extract file:line fallback
|
|
65
|
+
if (files.length === 0)
|
|
66
|
+
return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
|
|
67
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
68
|
+
}
|
|
69
|
+
// pytest
|
|
70
|
+
if (/FAILED\s+\S+|AssertionError|pytest/.test(combined)) {
|
|
71
|
+
const files = [];
|
|
72
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
73
|
+
let m = PYTEST_FAIL.exec(line);
|
|
74
|
+
if (m && m[1]) {
|
|
75
|
+
files.push({ path: m[1], message: m[2] ?? line.trim() });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
m = PYTEST_ASSERT.exec(line);
|
|
79
|
+
if (m) {
|
|
80
|
+
files.push({ path: '', message: m[0] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (files.length > 0)
|
|
85
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
86
|
+
}
|
|
87
|
+
// go test
|
|
88
|
+
if (/---\s+FAIL:|FAIL\s+\S+/.test(combined)) {
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
91
|
+
let m = GO_FAIL.exec(line);
|
|
92
|
+
if (m && m[1]) {
|
|
93
|
+
files.push({ path: m[1], message: line.trim() });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
m = GO_PKG_FAIL.exec(line);
|
|
97
|
+
if (m && m[1]) {
|
|
98
|
+
files.push({ path: m[1], message: line.trim() });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (files.length > 0)
|
|
102
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
103
|
+
}
|
|
104
|
+
// cargo test
|
|
105
|
+
if (/test result: FAILED|FAILED.*cargo|panicked at/.test(combined)) {
|
|
106
|
+
const files = [];
|
|
107
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
108
|
+
let m = CARGO_FAIL.exec(line);
|
|
109
|
+
if (m && m[1]) {
|
|
110
|
+
files.push({ path: m[1], message: line.trim() });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
m = CARGO_PANIC.exec(line);
|
|
114
|
+
if (m && m[3] && m[4] && m[5]) {
|
|
115
|
+
files.push({ path: m[3], line: Number(m[4]), column: Number(m[5]), message: m[2] ?? line.trim() });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (files.length > 0)
|
|
119
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
120
|
+
}
|
|
121
|
+
// Generic test runner (vitest/jest/mocha)
|
|
122
|
+
if (/Test Suites:|FAIL\s|Tests:.*failed|✘|✗|×\s+/.test(combined) || MOCHA_FAIL.test(combined)) {
|
|
34
123
|
const files = [];
|
|
35
124
|
for (const line of combined.split(/\r?\n/)) {
|
|
36
125
|
const m = TEST_FAIL.exec(line);
|
|
37
126
|
if (m) {
|
|
38
127
|
const target = m[1] ?? m[2] ?? m[3] ?? line.trim();
|
|
39
128
|
files.push({ path: target, message: line.trim() });
|
|
129
|
+
continue;
|
|
40
130
|
}
|
|
131
|
+
const mm = MOCHA_FAIL.exec(line);
|
|
132
|
+
if (mm && mm[1])
|
|
133
|
+
files.push({ path: '', message: mm[1] });
|
|
41
134
|
}
|
|
42
|
-
|
|
135
|
+
if (files.length > 0)
|
|
136
|
+
return { type: 'test', files: dedupe(files), raw: combined, exitCode };
|
|
137
|
+
// fallback: any FAIL line
|
|
138
|
+
if (/FAIL/.test(combined))
|
|
139
|
+
return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
|
|
43
140
|
}
|
|
44
|
-
// ESLint
|
|
45
|
-
|
|
46
|
-
|
|
141
|
+
// ESLint
|
|
142
|
+
{
|
|
143
|
+
const es = [];
|
|
144
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
145
|
+
const m = ESLINT_LINE.exec(line);
|
|
146
|
+
if (m && m[1] && m[2] && m[3] && m[5])
|
|
147
|
+
es.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[6], message: m[5] });
|
|
148
|
+
}
|
|
149
|
+
if (es.length > 0)
|
|
150
|
+
return { type: 'lint', files: dedupe(es), raw: combined, exitCode };
|
|
151
|
+
}
|
|
152
|
+
// ruff / flake8
|
|
153
|
+
{
|
|
154
|
+
const rf = [];
|
|
155
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
156
|
+
const m = RUFF_LINE.exec(line);
|
|
157
|
+
if (m && m[1] && m[2] && m[3] && m[4] && m[5])
|
|
158
|
+
rf.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
|
|
159
|
+
}
|
|
160
|
+
if (rf.length > 0)
|
|
161
|
+
return { type: 'lint', files: dedupe(rf), raw: combined, exitCode };
|
|
162
|
+
}
|
|
163
|
+
// mypy
|
|
164
|
+
{
|
|
165
|
+
const mp = [];
|
|
166
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
167
|
+
const m = MYPY_LINE.exec(line);
|
|
168
|
+
if (m && m[1] && m[2] && m[3] && m[4])
|
|
169
|
+
mp.push({ path: m[1], line: Number(m[2]), code: m[4], message: m[3] });
|
|
170
|
+
}
|
|
171
|
+
if (mp.length > 0)
|
|
172
|
+
return { type: 'type', files: dedupe(mp), raw: combined, exitCode };
|
|
173
|
+
}
|
|
174
|
+
// gcc/clang, vite/webpack
|
|
175
|
+
{
|
|
176
|
+
const gc = [];
|
|
177
|
+
for (const line of combined.split(/\r?\n/)) {
|
|
178
|
+
const m = GCC_LINE.exec(line);
|
|
179
|
+
if (m && m[1] && m[2] && m[3] && m[4])
|
|
180
|
+
gc.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), message: m[4] });
|
|
181
|
+
else if (VITE_ERROR.test(line))
|
|
182
|
+
gc.push({ path: '', message: line.trim() });
|
|
183
|
+
}
|
|
184
|
+
if (gc.length > 0)
|
|
185
|
+
return { type: 'build', files: dedupe(gc), raw: combined, exitCode };
|
|
47
186
|
}
|
|
48
187
|
// Runtime exception.
|
|
49
188
|
const lines = combined.split(/\r?\n/);
|
|
@@ -53,10 +192,36 @@ export function detect(stdout, stderr, exitCode) {
|
|
|
53
192
|
}
|
|
54
193
|
// Generic build.
|
|
55
194
|
if (/error/i.test(combined)) {
|
|
195
|
+
const gf = extractGeneric(combined, 5);
|
|
196
|
+
if (gf.length > 0)
|
|
197
|
+
return { type: 'build', files: gf, raw: combined, exitCode };
|
|
56
198
|
return { type: 'build', files: extractFirstLines(combined, 5), raw: combined, exitCode };
|
|
57
199
|
}
|
|
58
200
|
return { type: 'unknown', files: extractFirstLines(combined, 3), raw: combined, exitCode };
|
|
59
201
|
}
|
|
202
|
+
function dedupe(files) {
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const f of files) {
|
|
206
|
+
const key = `${f.path}:${f.line ?? ''}:${f.column ?? ''}:${f.message}`;
|
|
207
|
+
if (!seen.has(key)) {
|
|
208
|
+
seen.add(key);
|
|
209
|
+
out.push(f);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function extractGeneric(s, n) {
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const line of s.split(/\r?\n/)) {
|
|
217
|
+
const m = GENERIC_FILE_LINE.exec(line);
|
|
218
|
+
if (m && m[1] && m[2] && m[4])
|
|
219
|
+
out.push({ path: m[1], line: Number(m[2]), column: m[3] ? Number(m[3]) : undefined, message: m[4] });
|
|
220
|
+
if (out.length >= n)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
60
225
|
function extractFirstLines(s, n) {
|
|
61
226
|
return s
|
|
62
227
|
.split(/\r?\n/)
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { detect, summarize } from './detect.js';
|
|
12
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
13
|
+
const MAX_VERIFY_BYTES = 256 * 1024;
|
|
14
|
+
function appendCapped(current, chunk) {
|
|
15
|
+
if (current.length >= MAX_VERIFY_BYTES)
|
|
16
|
+
return current;
|
|
17
|
+
const next = current + chunk;
|
|
18
|
+
return next.length > MAX_VERIFY_BYTES ? next.slice(0, MAX_VERIFY_BYTES) + '\n... [truncated]' : next;
|
|
19
|
+
}
|
|
12
20
|
export async function verify(opts) {
|
|
13
21
|
const timeout = opts.timeoutMs ?? 5 * 60 * 1000;
|
|
14
22
|
return new Promise((resolve) => {
|
|
@@ -30,8 +38,8 @@ export async function verify(opts) {
|
|
|
30
38
|
failure: { type: 'runtime', files: [], raw, exitCode: -1 },
|
|
31
39
|
});
|
|
32
40
|
}, timeout);
|
|
33
|
-
child.stdout.on('data', (b) => { stdout
|
|
34
|
-
child.stderr.on('data', (b) => { stderr
|
|
41
|
+
child.stdout.on('data', (b) => { stdout = appendCapped(stdout, b.toString()); });
|
|
42
|
+
child.stderr.on('data', (b) => { stderr = appendCapped(stderr, b.toString()); });
|
|
35
43
|
child.on('close', (code) => {
|
|
36
44
|
if (done)
|
|
37
45
|
return;
|
|
@@ -53,5 +61,8 @@ export function diagnosticForModel(result) {
|
|
|
53
61
|
return 'Verification passed.';
|
|
54
62
|
if (!result.failure)
|
|
55
63
|
return `Verification failed with exit ${result.exitCode}.`;
|
|
56
|
-
|
|
64
|
+
// redact raw before summarizing so secrets don't enter transcript
|
|
65
|
+
const redactedRaw = redact(result.failure.raw);
|
|
66
|
+
const redactedFailure = { ...result.failure, raw: redactedRaw, files: result.failure.files.map((f) => ({ ...f, message: redact(f.message) })) };
|
|
67
|
+
return summarize(redactedFailure);
|
|
57
68
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.1 — Verifier registry
|
|
3
|
+
* Detects available verifiers from project heuristics (package.json / Makefile / pyproject / Cargo).
|
|
4
|
+
* Each verifier is a named command that can be run via `verify` engine.
|
|
5
|
+
*/
|
|
6
|
+
export type VerifierKind = 'tests' | 'typecheck' | 'lint' | 'build' | 'format-check' | 'custom';
|
|
7
|
+
export interface Verifier {
|
|
8
|
+
id: VerifierKind;
|
|
9
|
+
label: string;
|
|
10
|
+
command: string;
|
|
11
|
+
priority: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function detectVerifiers(cwd: string): Verifier[];
|
|
14
|
+
export declare function primaryVerifyCommand(cwd: string): string | null;
|
|
15
|
+
export interface VerifySettings {
|
|
16
|
+
commands?: string[];
|
|
17
|
+
onEdit?: boolean;
|
|
18
|
+
afterChange?: boolean;
|
|
19
|
+
beforeDone?: boolean;
|
|
20
|
+
maxRepairs?: number;
|
|
21
|
+
requireVerify?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function resolveVerifySettings(raw: Record<string, unknown> | undefined): VerifySettings;
|