reprobe 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/PLAYBOOK.md +109 -0
- package/README.md +119 -2
- package/bin/reprobe.mjs +6 -0
- package/dist/agent.d.ts +13 -0
- package/dist/agent.js +123 -0
- package/dist/case.d.ts +11 -0
- package/dist/case.js +98 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +280 -0
- package/dist/config.d.ts +100 -0
- package/dist/config.js +149 -0
- package/dist/detectors.d.ts +8 -0
- package/dist/detectors.js +136 -0
- package/dist/diagnosis.d.ts +22 -0
- package/dist/diagnosis.js +31 -0
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +113 -0
- package/dist/exec.d.ts +19 -0
- package/dist/exec.js +56 -0
- package/dist/gather.d.ts +7 -0
- package/dist/gather.js +183 -0
- package/dist/init.d.ts +12 -0
- package/dist/init.js +111 -0
- package/dist/llm.d.ts +18 -0
- package/dist/llm.js +71 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +98 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +145 -0
- package/dist/sources/langfuse.d.ts +36 -0
- package/dist/sources/langfuse.js +147 -0
- package/dist/sources/langsmith.d.ts +9 -0
- package/dist/sources/langsmith.js +130 -0
- package/dist/sources/logs.d.ts +40 -0
- package/dist/sources/logs.js +324 -0
- package/dist/sources/postgres.d.ts +22 -0
- package/dist/sources/postgres.js +209 -0
- package/dist/sources/sentry.d.ts +24 -0
- package/dist/sources/sentry.js +123 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +27 -0
- package/dist/verify.js +358 -0
- package/package.json +57 -4
package/dist/verify.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { config, firstLine } from "./config.js";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
import { redactText, redactValue } from "./redact.js";
|
|
7
|
+
/** Validate paths serialized in evidence before they can escape a worktree. */
|
|
8
|
+
export function assertSafeRelativePath(path, label) {
|
|
9
|
+
if (typeof path !== 'string' || !path || path.includes('\0') || path.includes('\\') || isAbsolute(path)) {
|
|
10
|
+
throw new Error(`unsafe ${label} path`);
|
|
11
|
+
}
|
|
12
|
+
const parts = path.split('/');
|
|
13
|
+
if (parts.some((part) => !part || part === '.' || part === '..'))
|
|
14
|
+
throw new Error(`unsafe ${label} path`);
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
export function assertSafeCheckoutPath(root, path) {
|
|
18
|
+
if (path === '.')
|
|
19
|
+
return realpathSync(root);
|
|
20
|
+
assertSafeRelativePath(path, 'checkout');
|
|
21
|
+
const canonicalRoot = realpathSync(root);
|
|
22
|
+
let cursor = canonicalRoot;
|
|
23
|
+
for (const component of path.split('/')) {
|
|
24
|
+
cursor = join(cursor, component);
|
|
25
|
+
try {
|
|
26
|
+
if (lstatSync(cursor).isSymbolicLink())
|
|
27
|
+
throw new Error(`refusing symlinked checkout path ${path}`);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (e.code !== 'ENOENT')
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const candidate = prospectiveRealpath(cursor);
|
|
35
|
+
if (!within(canonicalRoot, candidate))
|
|
36
|
+
throw new Error(`checkout path escapes worktree: ${path}`);
|
|
37
|
+
return cursor;
|
|
38
|
+
}
|
|
39
|
+
function within(parent, child) {
|
|
40
|
+
const rel = relative(parent, child);
|
|
41
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
42
|
+
}
|
|
43
|
+
function prospectiveRealpath(path) {
|
|
44
|
+
let cursor = resolve(path);
|
|
45
|
+
const tail = [];
|
|
46
|
+
while (!existsSync(cursor)) {
|
|
47
|
+
tail.unshift(cursor.slice(dirname(cursor).length + 1));
|
|
48
|
+
cursor = dirname(cursor);
|
|
49
|
+
}
|
|
50
|
+
return join(realpathSync(cursor), ...tail);
|
|
51
|
+
}
|
|
52
|
+
/** Reject paths that can redirect a cached worktree into the user's source checkout. */
|
|
53
|
+
export function assertSafeWorktreeLocation(repoPath, cachePath, worktreePath) {
|
|
54
|
+
const repo = realpathSync(repoPath);
|
|
55
|
+
if (existsSync(cachePath) && lstatSync(cachePath).isSymbolicLink())
|
|
56
|
+
throw new Error('unsafe worktree location: symlinked cache directory');
|
|
57
|
+
const cache = prospectiveRealpath(cachePath);
|
|
58
|
+
const target = prospectiveRealpath(worktreePath);
|
|
59
|
+
if (!within(cache, target) || within(repo, target) || within(target, repo))
|
|
60
|
+
throw new Error('unsafe worktree location');
|
|
61
|
+
let cursor = cache;
|
|
62
|
+
for (const component of relative(cache, target).split(sep).filter(Boolean)) {
|
|
63
|
+
cursor = join(cursor, component);
|
|
64
|
+
try {
|
|
65
|
+
if (lstatSync(cursor).isSymbolicLink())
|
|
66
|
+
throw new Error('unsafe worktree location: symlink in cache path');
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
if (e.code !== 'ENOENT')
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Test runners a replay may start. A config can add more under verify.allow. */
|
|
75
|
+
export const DEFAULT_RUNNERS = ['npx', 'npm', 'pnpm', 'yarn', 'bun', 'bunx', 'node', 'deno', 'flutter', 'dart', 'pytest', 'python', 'python3', 'uv', 'go', 'cargo', 'bundle', 'rspec', 'mvn', 'gradle', './gradlew', 'swift', 'dotnet', 'mix', 'php', 'vendor/bin/phpunit'];
|
|
76
|
+
export function assertSafeReplay(replay, allow = config.verify.allow) {
|
|
77
|
+
if (replay.cwd !== '.')
|
|
78
|
+
assertSafeRelativePath(replay.cwd, 'replay cwd');
|
|
79
|
+
for (const file of replay.files) {
|
|
80
|
+
assertSafeRelativePath(file.path, 'replay file');
|
|
81
|
+
if (file.path.split('/').some((part) => part.toLowerCase() === '.git'))
|
|
82
|
+
throw new Error('replay file cannot target git metadata');
|
|
83
|
+
}
|
|
84
|
+
for (const path of replay.requires)
|
|
85
|
+
assertSafeRelativePath(path, 'required file');
|
|
86
|
+
if (!replay.files.length)
|
|
87
|
+
throw new Error('replay has no test file');
|
|
88
|
+
if (!Array.isArray(replay.command) || !replay.command.length || replay.command.some((a) => typeof a !== 'string' || a.includes('\0')))
|
|
89
|
+
throw new Error('replay command must be a non-empty argv');
|
|
90
|
+
if (![...DEFAULT_RUNNERS, ...allow].includes(replay.command[0]))
|
|
91
|
+
throw new Error(`refusing replay command "${replay.command[0]}": not a known test runner (add it to verify.allow)`);
|
|
92
|
+
if (typeof replay.marker !== 'string' || !/^REPROBE_REPLAY:[A-Za-z0-9_-]{1,80}$/.test(replay.marker))
|
|
93
|
+
throw new Error('replay marker must look like REPROBE_REPLAY:<id>');
|
|
94
|
+
if (!replay.files.some((f) => f.content.includes(`${replay.marker}:START`)))
|
|
95
|
+
throw new Error(`no replay file prints ${replay.marker}:START`);
|
|
96
|
+
}
|
|
97
|
+
const git = (args, cwd = config.repo, runner = run) => runner('git', args, { cwd, timeoutMs: 600_000 });
|
|
98
|
+
function detachedRegistrations(output) {
|
|
99
|
+
return new Set(output.split(/\n\n/)
|
|
100
|
+
.filter((block) => /^detached$/m.test(block) && !/^branch /m.test(block) && /^HEAD [0-9a-f]{40,64}$/m.test(block))
|
|
101
|
+
.map((block) => block.match(/^worktree (.+)$/m)?.[1]).filter((p) => Boolean(p)).map((p) => prospectiveRealpath(p)));
|
|
102
|
+
}
|
|
103
|
+
/** A run that never reached its assertion is BROKEN, not FAIL: only a failing assertion proves the bug. */
|
|
104
|
+
export function classify(code, output, marker) {
|
|
105
|
+
output = output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\r\n?/g, '\n');
|
|
106
|
+
const lines = output.split('\n').map((line) => line.replace(/^\s*\d{2}:\d{2}\s+\+\d+(?:\s+-\d+)?:\s?/, '').trim());
|
|
107
|
+
const hits = (phase) => lines.filter((line) => line === `${marker}:${phase}` || line.startsWith(`${marker}:${phase} `));
|
|
108
|
+
const signal = (phase) => hits(phase).length === 1;
|
|
109
|
+
if (!marker || !signal('START'))
|
|
110
|
+
return { outcome: 'broken', detail: 'missing proof that the replay reached its assertions' };
|
|
111
|
+
const startIndex = lines.findIndex((line) => line === `${marker}:START` || line.startsWith(`${marker}:START `));
|
|
112
|
+
const terminalIndices = ['PASS', 'FAIL'].flatMap((phase) => lines.flatMap((line, index) => line === `${marker}:${phase}` || line.startsWith(`${marker}:${phase} `) ? [index] : []));
|
|
113
|
+
if (terminalIndices.length !== 1)
|
|
114
|
+
return { outcome: 'broken', detail: 'replay must emit exactly one terminal marker' };
|
|
115
|
+
if (terminalIndices.some((index) => index < startIndex))
|
|
116
|
+
return { outcome: 'broken', detail: 'replay terminal marker appeared before START' };
|
|
117
|
+
const passed = signal('PASS');
|
|
118
|
+
const failed = signal('FAIL');
|
|
119
|
+
if (code === 0)
|
|
120
|
+
return passed && !failed ? { outcome: 'pass', detail: 'replay passed' } : { outcome: 'broken', detail: 'exit 0 without proof that the replay assertions passed' };
|
|
121
|
+
if (passed || !failed)
|
|
122
|
+
return { outcome: 'broken', detail: 'non-zero exit without proof that the replay assertion failed' };
|
|
123
|
+
const diagnosticOutput = output.split(/(?:^|\n)\s*(?:- Expected|Expected:)/)[0];
|
|
124
|
+
const runtimeError = diagnosticOutput.match(/(ReferenceError: .+|SyntaxError: .+|Cannot find module .+|TypeError: \S+ is not a (?:function|constructor).*|Error: Couldn't resolve the package .+|Compilation failed.*|Failed to load ".+|Error: Not found: .+|isn't defined for the (?:class|type) .+|Undefined name .+|The getter '.+' isn't defined.+|No named parameter with the name .+)/);
|
|
125
|
+
if (runtimeError)
|
|
126
|
+
return { outcome: 'broken', detail: runtimeError[1].slice(0, 300) };
|
|
127
|
+
const diff = output.indexOf('- Expected');
|
|
128
|
+
const jestDiff = diff >= 0 ? output.slice(diff).split('\n').slice(0, 9).filter((l) => !/^\s*>?\s*\d+ \|/.test(l)).map((l) => l.trim()).filter(Boolean).join(' ') : null;
|
|
129
|
+
const normalizedOutput = output.replace(/^\s*\d{2}:\d{2}\s+\+\d+\s+-\d+:\s?/gm, '');
|
|
130
|
+
const assertion = jestDiff ??
|
|
131
|
+
(/(?:^|\n)\s*Expected: /.test(normalizedOutput) ? normalizedOutput.match(/(?:^|\n)\s*Actual: [^\n]*(?:\n\s*Which: [^\n]*)?/)?.[0].trim() : undefined) ??
|
|
132
|
+
normalizedOutput.match(/(Expected:[^\n]*\n\s*Received:[^\n]*)/)?.[1] ??
|
|
133
|
+
normalizedOutput.match(/(expect\(received\)[^\n]*)/)?.[1];
|
|
134
|
+
// The test printed its FAIL marker after reaching the assertion and the runner exited non-zero: that is the proof.
|
|
135
|
+
const said = hits('FAIL')[0].slice(`${marker}:FAIL`.length).trim();
|
|
136
|
+
return { outcome: 'fail', detail: (assertion ?? (said || `assertion failed (exit ${code})`)).replace(/\s+/g, ' ').slice(0, 300) };
|
|
137
|
+
}
|
|
138
|
+
async function worktree(sha, runner) {
|
|
139
|
+
const dir = join(config.cacheDir, 'wt', basename(config.repo), sha.slice(0, 12));
|
|
140
|
+
assertSafeWorktreeLocation(config.repo, config.cacheDir, dir);
|
|
141
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
142
|
+
if (!existsSync(join(dir, '.git'))) {
|
|
143
|
+
if (existsSync(dir))
|
|
144
|
+
throw new Error(`refusing to reuse unregistered worktree path ${dir}`);
|
|
145
|
+
const r = await git(['worktree', 'add', '--detach', dir, sha], config.repo, runner);
|
|
146
|
+
if (r.code !== 0)
|
|
147
|
+
throw new Error(`git worktree add: ${firstLine(r.stderr)}`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
if (lstatSync(dir).isSymbolicLink())
|
|
151
|
+
throw new Error(`refusing symlinked cached worktree ${dir}`);
|
|
152
|
+
const metadata = lstatSync(join(dir, '.git'));
|
|
153
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
154
|
+
throw new Error(`refusing non-file or symlinked cached worktree metadata ${dir}`);
|
|
155
|
+
const top = await git(['rev-parse', '--show-toplevel'], dir, runner);
|
|
156
|
+
const common = await git(['rev-parse', '--git-common-dir'], dir, runner);
|
|
157
|
+
const sourceCommon = await git(['rev-parse', '--git-common-dir'], config.repo, runner);
|
|
158
|
+
const gitDir = await git(['rev-parse', '--absolute-git-dir'], dir, runner);
|
|
159
|
+
const branch = await git(['symbolic-ref', '-q', 'HEAD'], dir, runner);
|
|
160
|
+
const listed = await git(['worktree', 'list', '--porcelain'], config.repo, runner);
|
|
161
|
+
if (top.code !== 0 || common.code !== 0 || sourceCommon.code !== 0 || gitDir.code !== 0 || branch.code !== 1 || listed.code !== 0 || !top.stdout.trim() || !common.stdout.trim() || !sourceCommon.stdout.trim() || !isAbsolute(gitDir.stdout.trim())) {
|
|
162
|
+
throw new Error(`refusing cached worktree with unreadable or attached git metadata ${dir}`);
|
|
163
|
+
}
|
|
164
|
+
const actualTop = top.code === 0 ? realpathSync(top.stdout.trim()) : '';
|
|
165
|
+
const actualCommon = common.code === 0 ? realpathSync(resolve(dir, common.stdout.trim())) : '';
|
|
166
|
+
const expectedCommon = sourceCommon.code === 0 ? realpathSync(resolve(config.repo, sourceCommon.stdout.trim())) : '';
|
|
167
|
+
const actualGitDir = realpathSync(gitDir.stdout.trim());
|
|
168
|
+
const linkedMetadataRoot = join(expectedCommon, 'worktrees');
|
|
169
|
+
if (actualTop !== realpathSync(dir) || actualCommon !== expectedCommon || actualGitDir === expectedCommon || actualGitDir === linkedMetadataRoot || !within(linkedMetadataRoot, actualGitDir) || !detachedRegistrations(listed.stdout).has(actualTop)) {
|
|
170
|
+
throw new Error(`refusing stale, attached, or foreign cached worktree ${dir}`);
|
|
171
|
+
}
|
|
172
|
+
const backpointer = assertSafeCheckoutPath(actualGitDir, 'gitdir');
|
|
173
|
+
if (realpathSync(resolve(actualGitDir, readFileSync(backpointer, 'utf8').trim())) !== realpathSync(join(dir, '.git'))) {
|
|
174
|
+
throw new Error(`refusing cached worktree metadata belonging to another checkout ${dir}`);
|
|
175
|
+
}
|
|
176
|
+
for (const [label, args] of [
|
|
177
|
+
['checkout', ['checkout', '--detach', '-f', '-q', sha]],
|
|
178
|
+
['reset', ['reset', '--hard', '-q', sha]],
|
|
179
|
+
['clean', ['clean', '-fdq', '-e', 'node_modules', '-e', '.dart_tool', '-e', 'build']],
|
|
180
|
+
]) {
|
|
181
|
+
const r = await git([...args], dir, runner);
|
|
182
|
+
if (r.code !== 0)
|
|
183
|
+
throw new Error(`git ${label}: ${firstLine(r.stderr || r.stdout)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return dir;
|
|
187
|
+
}
|
|
188
|
+
const LOCKFILES = [
|
|
189
|
+
['package-lock.json', ['npm', 'ci', '--ignore-scripts', '--no-audit', '--no-fund']],
|
|
190
|
+
['pnpm-lock.yaml', ['pnpm', 'install', '--frozen-lockfile', '--ignore-scripts']],
|
|
191
|
+
['yarn.lock', ['yarn', 'install', '--frozen-lockfile', '--ignore-scripts']],
|
|
192
|
+
['bun.lock', ['bun', 'install', '--frozen-lockfile', '--ignore-scripts']],
|
|
193
|
+
['bun.lockb', ['bun', 'install', '--frozen-lockfile', '--ignore-scripts']],
|
|
194
|
+
];
|
|
195
|
+
/** node_modules for `dir`, installed once per lockfile hash outside the worktree (install scripts off), then symlinked in. */
|
|
196
|
+
async function nodeModules(wt, dir, log, runner) {
|
|
197
|
+
const root = !dir || dir === '.' ? wt : assertSafeCheckoutPath(wt, dir);
|
|
198
|
+
const found = LOCKFILES.find(([lock]) => existsSync(join(root, lock)));
|
|
199
|
+
if (!found)
|
|
200
|
+
return `no lockfile in ${dir} (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock)`;
|
|
201
|
+
const [lockName, install] = found;
|
|
202
|
+
const prefix = dir === '.' || !dir ? '' : `${dir}/`;
|
|
203
|
+
const lock = assertSafeCheckoutPath(wt, `${prefix}${lockName}`);
|
|
204
|
+
const manifest = assertSafeCheckoutPath(wt, `${prefix}package.json`);
|
|
205
|
+
const manifestContents = readFileSync(manifest);
|
|
206
|
+
const hash = createHash('sha256').update(readFileSync(lock)).update('\0').update(manifestContents).digest('hex').slice(0, 12);
|
|
207
|
+
const depsPath = `deps/${(dir || 'root').replace(/[^A-Za-z0-9_-]/g, '_')}-${hash}`;
|
|
208
|
+
const deps = assertSafeCheckoutPath(config.cacheDir, depsPath);
|
|
209
|
+
const modules = assertSafeCheckoutPath(config.cacheDir, `${depsPath}/node_modules`);
|
|
210
|
+
const done = assertSafeCheckoutPath(config.cacheDir, `${depsPath}/.reprobe-installed`);
|
|
211
|
+
const linkedModules = join(root, 'node_modules');
|
|
212
|
+
let linked;
|
|
213
|
+
try {
|
|
214
|
+
linked = lstatSync(linkedModules);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
if (e.code !== 'ENOENT')
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
const linkedToExpectedModules = linked?.isSymbolicLink() && resolve(root, readlinkSync(linkedModules)) === resolve(modules);
|
|
221
|
+
if (linked && (!linkedToExpectedModules || (existsSync(linkedModules) && existsSync(modules) && realpathSync(linkedModules) !== realpathSync(modules)))) {
|
|
222
|
+
throw new Error(`refusing ${prefix}node_modules outside the owned dependency cache`);
|
|
223
|
+
}
|
|
224
|
+
if (!existsSync(done) || !existsSync(modules)) {
|
|
225
|
+
log(`installing dependencies for ${dir || '.'} lockfile ${hash} (one time, cached in ${deps})`);
|
|
226
|
+
mkdirSync(deps, { recursive: true });
|
|
227
|
+
writeFileSync(assertSafeCheckoutPath(config.cacheDir, `${depsPath}/package.json`), manifestContents);
|
|
228
|
+
copyFileSync(lock, assertSafeCheckoutPath(config.cacheDir, `${depsPath}/${lockName}`));
|
|
229
|
+
const r = await runner(install[0], install.slice(1), { cwd: deps, timeoutMs: 1_200_000 });
|
|
230
|
+
if (r.code !== 0)
|
|
231
|
+
return `${install.join(' ')} failed: ${firstLine(r.stderr || r.stdout)}`;
|
|
232
|
+
// A successful install of a zero-dependency package may leave no directory to symlink.
|
|
233
|
+
mkdirSync(modules, { recursive: true });
|
|
234
|
+
writeFileSync(done, `${new Date().toISOString()}\n`);
|
|
235
|
+
}
|
|
236
|
+
if (!linked)
|
|
237
|
+
symlinkSync(modules, linkedModules);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/** A step for `server` runs only for replays whose cwd is inside `server`; a step for `.` always runs. */
|
|
241
|
+
export const stepApplies = (stepDir, replayCwd) => !stepDir || stepDir === '.' || replayCwd === stepDir || replayCwd.startsWith(`${stepDir}/`);
|
|
242
|
+
async function setup(wt, steps, replayCwd, log, runner) {
|
|
243
|
+
for (const step of steps) {
|
|
244
|
+
if (!stepApplies('nodeModules' in step ? step.nodeModules : step.cwd, replayCwd))
|
|
245
|
+
continue;
|
|
246
|
+
if ('nodeModules' in step) {
|
|
247
|
+
// ponytail: one shared node_modules per lockfile, so concurrent verifies of different commits could race on generated code. Verify runs commits one at a time.
|
|
248
|
+
const err = await nodeModules(wt, step.nodeModules, log, runner);
|
|
249
|
+
if (err)
|
|
250
|
+
return err;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (step.cwd !== '.')
|
|
254
|
+
assertSafeRelativePath(step.cwd, 'setup cwd');
|
|
255
|
+
const cwd = assertSafeCheckoutPath(wt, step.cwd);
|
|
256
|
+
if (!existsSync(cwd))
|
|
257
|
+
continue;
|
|
258
|
+
log(`setup: ${redactText(step.run.join(' '))} in ${step.cwd}`);
|
|
259
|
+
const r = await runner(step.run[0], step.run.slice(1), { cwd, timeoutMs: 900_000, env: { CI: '1' } });
|
|
260
|
+
if (r.code !== 0)
|
|
261
|
+
return `setup ${step.run.join(' ')} failed: ${firstLine(r.stderr || r.stdout)}`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
export async function verifyReplay(caseDir, replay, commits, pack = null, log = () => { }, runner = run) {
|
|
266
|
+
assertSafeReplay(replay);
|
|
267
|
+
const results = [];
|
|
268
|
+
mkdirSync(assertSafeCheckoutPath(caseDir, 'verify'), { recursive: true });
|
|
269
|
+
for (const commit of commits) {
|
|
270
|
+
const rev = await git(['rev-parse', '--verify', '--end-of-options', `${commit}^{commit}`], config.repo, runner);
|
|
271
|
+
const sha = rev.stdout.trim();
|
|
272
|
+
const logId = /^[0-9a-f]{40,64}$/i.test(sha) ? sha.slice(0, 12) : createHash('sha256').update(commit).digest('hex').slice(0, 12);
|
|
273
|
+
const logFile = assertSafeCheckoutPath(caseDir, `verify/${logId}.log`);
|
|
274
|
+
const record = (result, contents = `${result.outcome}: ${result.detail}\n`) => {
|
|
275
|
+
writeFileSync(logFile, redactText(contents));
|
|
276
|
+
results.push(result);
|
|
277
|
+
};
|
|
278
|
+
if (rev.code !== 0) {
|
|
279
|
+
record({ commit: redactText(commit), sha: '', outcome: 'broken', detail: redactText(`unknown commit ${commit}`), logFile });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (!/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
283
|
+
record({ commit: redactText(commit), sha: '', outcome: 'broken', detail: 'git returned an invalid commit id', logFile });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
log(`${redactText(commit)} (${sha.slice(0, 10)}): preparing worktree`);
|
|
287
|
+
const wt = await worktree(sha, runner);
|
|
288
|
+
const missing = replay.requires.filter((p) => !existsSync(join(wt, p)));
|
|
289
|
+
if (missing.length) {
|
|
290
|
+
record({ commit, sha, outcome: 'not-applicable', detail: `missing at this commit: ${missing.join(', ')}`, logFile });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
for (const f of replay.files) {
|
|
294
|
+
const target = assertSafeCheckoutPath(wt, f.path);
|
|
295
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
296
|
+
if (f.mode === 'append')
|
|
297
|
+
appendFileSync(target, f.content);
|
|
298
|
+
else
|
|
299
|
+
writeFileSync(target, f.content);
|
|
300
|
+
}
|
|
301
|
+
let command = replay.command;
|
|
302
|
+
let shimNote = '';
|
|
303
|
+
const setupError = await setup(wt, config.verify.setup, replay.cwd, log, runner);
|
|
304
|
+
if (setupError) {
|
|
305
|
+
record({ commit, sha, outcome: 'broken', detail: redactText(setupError), logFile });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (pack?.prepareWorktree) {
|
|
309
|
+
const prepared = await pack.prepareWorktree(wt, replay, sha, { run: runner, git: (args, cwd = wt) => git(args, cwd, runner), safePath: assertSafeCheckoutPath, log });
|
|
310
|
+
if (prepared.notes?.length)
|
|
311
|
+
shimNote = ` [${prepared.notes.join('; ')}]`;
|
|
312
|
+
if (prepared.error) {
|
|
313
|
+
record({ commit, sha, outcome: 'broken', detail: redactText(prepared.error + shimNote), logFile });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (prepared.command)
|
|
317
|
+
command = prepared.command;
|
|
318
|
+
}
|
|
319
|
+
const cwd = assertSafeCheckoutPath(wt, replay.cwd);
|
|
320
|
+
log(`${redactText(commit)}: running ${redactText(command.join(' '))}`);
|
|
321
|
+
const r = await runner(command[0], command.slice(1), { cwd, timeoutMs: 900_000, env: { CI: '1' } });
|
|
322
|
+
const output = `${r.stdout}\n${r.stderr}`;
|
|
323
|
+
const logContents = redactText(`$ ${command.join(' ')}\n# commit ${sha}${shimNote}\n${output}`);
|
|
324
|
+
const verdict = classify(r.code, output, replay.marker);
|
|
325
|
+
record({ commit: redactText(commit), sha, outcome: verdict.outcome, detail: redactText(verdict.detail + shimNote), logFile }, logContents);
|
|
326
|
+
}
|
|
327
|
+
return redactValue(results);
|
|
328
|
+
}
|
|
329
|
+
export async function cleanWorktrees(runner = run) {
|
|
330
|
+
const root = join(config.cacheDir, 'wt', basename(config.repo));
|
|
331
|
+
if (!existsSync(root))
|
|
332
|
+
return [];
|
|
333
|
+
if (lstatSync(root).isSymbolicLink())
|
|
334
|
+
throw new Error('refusing to clean symlinked worktree cache');
|
|
335
|
+
const listed = await git(['worktree', 'list', '--porcelain'], config.repo, runner);
|
|
336
|
+
if (listed.code !== 0)
|
|
337
|
+
throw new Error(`git worktree list: ${firstLine(listed.stderr || listed.stdout)}`);
|
|
338
|
+
const registered = detachedRegistrations(listed.stdout);
|
|
339
|
+
const removed = [];
|
|
340
|
+
for (const name of readdirSync(root)) {
|
|
341
|
+
const path = join(root, name);
|
|
342
|
+
if (lstatSync(path).isSymbolicLink())
|
|
343
|
+
continue;
|
|
344
|
+
try {
|
|
345
|
+
assertSafeWorktreeLocation(config.repo, config.cacheDir, path);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!registered.has(prospectiveRealpath(path)))
|
|
351
|
+
continue;
|
|
352
|
+
const r = await git(['worktree', 'remove', '--force', path], config.repo, runner);
|
|
353
|
+
if (r.code === 0)
|
|
354
|
+
removed.push(name);
|
|
355
|
+
}
|
|
356
|
+
await git(['worktree', 'prune'], config.repo, runner);
|
|
357
|
+
return removed;
|
|
358
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reprobe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn a bug complaint into cited evidence, a root cause, and a replay test that fails on the deployed commit. Your coding agent investigates; reprobe keeps production read-only.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "codenameakshay",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/codenameakshay/reprobe.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://codenameakshay.github.io/reprobe/",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/codenameakshay/reprobe/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cli",
|
|
17
|
+
"debugging",
|
|
18
|
+
"root-cause",
|
|
19
|
+
"replay",
|
|
20
|
+
"incident",
|
|
21
|
+
"observability",
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"codex",
|
|
25
|
+
"sentry",
|
|
26
|
+
"langfuse",
|
|
27
|
+
"langsmith",
|
|
28
|
+
"postgres"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"bin": {
|
|
32
|
+
"reprobe": "bin/reprobe.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"bin",
|
|
36
|
+
"dist",
|
|
37
|
+
"PLAYBOOK.md",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.18"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"test": "node --test test/*.test.ts",
|
|
48
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^24.0.0",
|
|
52
|
+
"typescript": "^5.9.0"
|
|
53
|
+
},
|
|
54
|
+
"exports": {
|
|
55
|
+
"./types": {
|
|
56
|
+
"types": "./dist/types.d.ts"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|