mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 10:36:05 -10:00
Review exists to stop bad changes from merging at the lowest cost in human attention, and nothing measured either. #1 asks for a corpus built from our own history and a replay that asks, of each pull request that introduced a bug we later fixed, whether the reviewer would have caught it before merge. scripts/review-eval.mjs, one command, no model spend in the corpus half: - corpus: walks non-merge fix commits, blames each changed old line to the commit that introduced it, resolves that commit to its pull request, and keeps the case only when the replay is actually possible. Four checks do the filtering, and each drop is counted rather than skipped silently: the pull request head must be fetched from refs/pull/N/head; the introducing commit must be an ancestor of that head (a "(#1234)" subject can name another repository's pull request); the blamed line must exist at that head; and the line must be one the pull request itself added, compared against base..head, so a fix that touches pre-existing code is not scored as a missed defect. Oversized pull requests and non-code paths are excluded, both counted. - replay: runs a named reviewer over the corpus. `hosted-floor` calls the control plane's deterministic reviewer in-process (free, no model); `cli` runs the released binary's `codewhale review --json` with an optional money cap that stops the run and says so; `skill` runs the `/review` instructions through `exec`. Each case is written as its own JSON record so a rerun resumes past finished work and a partial run is auditable. - grade: recall at a line window and at same-file, claims and words per case (the attention-cost inputs), wall time, estimated cost from usage and an optional per-million rate, and a failure count. `report` diffs two grades so a later issue can state its delta on the same corpus. Checks: node --check passes; the corpus was built over 3363 fix commits and 15774 hunks from 2026-07-01 and the free deterministic-floor baseline was graded over it. The model-backed baselines for `cli` and `skill` are NOT run: #1 requires a founder-approved route and budget, and there is no approved eval budget, so this stops at the free baseline and asks rather than spending. Refs CW-7503, codewhale-agent #1
919 lines
32 KiB
JavaScript
919 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
|
|
const SCHEMA = "codewhale.review-eval.corpus/1";
|
|
const RUN_SCHEMA = "codewhale.review-eval.run/1";
|
|
const GRADE_SCHEMA = "codewhale.review-eval.grade/1";
|
|
const DEFAULT_WINDOW_LINES = 3;
|
|
|
|
const CODE_EXT = /\.(?:rs|mjs|cjs|js|jsx|ts|tsx|py|go|java|kt|kts|swift|c|cc|cpp|h|hpp|cs|rb|php|scala|zig|lua|sh|bash|zsh|sql|toml|ya?ml|json|wl|cu)$/i;
|
|
const SKIP_PATH = /(?:^|\/)(?:CHANGELOG|Cargo\.lock|package-lock\.json|pnpm-lock\.yaml|bun\.lockb?)(?:\.|$)|(?:^|\/)(?:target|node_modules|dist|build|\.git)\//;
|
|
|
|
function usage() {
|
|
return [
|
|
"review-eval — escaped-defect corpus and reviewer replay for Codewhale review",
|
|
"",
|
|
" node scripts/review-eval.mjs corpus --repo <path> [--since YYYY-MM-DD] [--until YYYY-MM-DD]",
|
|
" [--limit N] [--cache <path>] [--out <path>] [--fetch]",
|
|
" node scripts/review-eval.mjs replay --corpus <path> --reviewer cli|hosted-floor|skill --out <dir>",
|
|
" [--binary codewhale] [--model M] [--provider P] [--max-cases N]",
|
|
" [--money-cap-usd X] [--hosted-root <codewhale-platform>]",
|
|
" node scripts/review-eval.mjs grade --corpus <path> --run <dir> [--window 3] [--out <path>]",
|
|
" node scripts/review-eval.mjs report --grade <path> [--baseline <path>]",
|
|
" node scripts/review-eval.mjs all --repo <path> --reviewer cli --out <dir> [corpus/replay/grade flags]",
|
|
"",
|
|
"Exit codes: 0 ok, 1 usage or IO error, 2 budget cap reached (partial run retained).",
|
|
].join("\n");
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "help";
|
|
const flags = {};
|
|
for (let index = command === "help" ? 0 : 1; index < argv.length; index += 1) {
|
|
const token = argv[index];
|
|
if (!token.startsWith("--")) continue;
|
|
const [rawKey, inline] = token.slice(2).split("=", 2);
|
|
const key = rawKey.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
|
|
if (inline !== undefined) {
|
|
flags[key] = inline;
|
|
continue;
|
|
}
|
|
const next = argv[index + 1];
|
|
if (next && !next.startsWith("--")) {
|
|
flags[key] = next;
|
|
index += 1;
|
|
} else {
|
|
flags[key] = true;
|
|
}
|
|
}
|
|
return { command, flags };
|
|
}
|
|
|
|
function git(repo, args, { allowFailure = false } = {}) {
|
|
const result = spawnSync("git", ["-C", repo, ...args], {
|
|
encoding: "utf8",
|
|
maxBuffer: 512 * 1024 * 1024,
|
|
});
|
|
if (result.status !== 0) {
|
|
if (allowFailure) return "";
|
|
throw new Error(`git ${args.join(" ")} failed: ${String(result.stderr || "").trim()}`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
function gitBuffer(repo, args) {
|
|
return spawnSync("git", ["-C", repo, ...args], { maxBuffer: 512 * 1024 * 1024 }).stdout;
|
|
}
|
|
|
|
function nullSeparated(text) {
|
|
return String(text || "").split("\u0000").filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
function readCache(path) {
|
|
if (!path || !existsSync(path)) return { commits: {}, prs: {} };
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
return { commits: parsed.commits || {}, prs: parsed.prs || {} };
|
|
} catch {
|
|
return { commits: {}, prs: {} };
|
|
}
|
|
}
|
|
|
|
function writeCache(path, cache) {
|
|
if (!path) return;
|
|
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
writeFileSync(path, JSON.stringify(cache));
|
|
}
|
|
|
|
function parseHunks(diffText) {
|
|
const hunks = [];
|
|
let path = "";
|
|
let oldPath = "";
|
|
for (const line of String(diffText || "").split("\n")) {
|
|
if (line.startsWith("diff --git ")) {
|
|
path = "";
|
|
oldPath = "";
|
|
continue;
|
|
}
|
|
if (line.startsWith("--- ")) {
|
|
oldPath = line.slice(4).replace(/^a\//, "");
|
|
continue;
|
|
}
|
|
if (line.startsWith("+++ ")) {
|
|
const next = line.slice(4).trim();
|
|
path = next === "/dev/null" ? "" : next.replace(/^b\//, "");
|
|
continue;
|
|
}
|
|
const header = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
if (!header) continue;
|
|
hunks.push({
|
|
path: path || oldPath,
|
|
oldStart: Number(header[1]),
|
|
oldCount: Number(header[2] ?? 1),
|
|
newStart: Number(header[3]),
|
|
newCount: Number(header[4] ?? 1),
|
|
});
|
|
}
|
|
return hunks;
|
|
}
|
|
|
|
function addedLineSet(repo, base, head) {
|
|
const diff = git(repo, ["diff", "--no-color", "-U0", base, head], { allowFailure: true });
|
|
const added = new Map();
|
|
let path = "";
|
|
let newLine = 0;
|
|
for (const line of diff.split("\n")) {
|
|
if (line.startsWith("+++ ")) {
|
|
const next = line.slice(4).trim();
|
|
path = next === "/dev/null" ? "" : next.replace(/^b\//, "");
|
|
continue;
|
|
}
|
|
const header = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
if (header) {
|
|
newLine = Number(header[1]);
|
|
continue;
|
|
}
|
|
if (!path) continue;
|
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
if (!added.has(path)) added.set(path, new Set());
|
|
added.get(path).add(newLine);
|
|
newLine += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith(" ") || line === "") continue;
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function looksReviewable(path) {
|
|
if (!path || SKIP_PATH.test(path)) return false;
|
|
if (/\.(?:md|mdx|txt|lock|snap)$/i.test(path)) return false;
|
|
return CODE_EXT.test(path);
|
|
}
|
|
|
|
function commitSubject(repo, sha) {
|
|
return git(repo, ["show", "-s", "--format=%s", sha], { allowFailure: true }).trim();
|
|
}
|
|
|
|
function prFromSubject(subject) {
|
|
const squash = String(subject || "").match(/^(.*)\(#(\d+)\)\s*$/);
|
|
if (squash) return { pr: Number(squash[2]), via: "squash" };
|
|
const merge = String(subject || "").match(/^Merge pull request #(\d+) /);
|
|
if (merge) return { pr: Number(merge[1]), via: "merge" };
|
|
return null;
|
|
}
|
|
|
|
function prBaseFor(repo, record, sha) {
|
|
if (record.base_sha) return record.base_sha;
|
|
const anchor = record.merge_sha || sha;
|
|
const base = git(repo, ["rev-parse", `${anchor}^1`], { allowFailure: true }).trim();
|
|
if (base) record.base_sha = base;
|
|
return base || "";
|
|
}
|
|
|
|
function prSize(repo, base, head) {
|
|
if (!base || !head) return null;
|
|
const numstat = git(repo, ["diff", "--numstat", base, head], { allowFailure: true });
|
|
if (!numstat) return null;
|
|
let files = 0;
|
|
let lines = 0;
|
|
for (const row of numstat.split("\n")) {
|
|
const match = row.match(/^(\d+|-)\t(\d+|-)\t/);
|
|
if (!match) continue;
|
|
files += 1;
|
|
lines += Number(match[1] === "-" ? 0 : match[1]) + Number(match[2] === "-" ? 0 : match[2]);
|
|
}
|
|
return { files, lines };
|
|
}
|
|
|
|
function firstParentMerge(repo, sha, branch) {
|
|
const merges = git(repo, ["rev-list", "--merges", "--ancestry-path", `${sha}..${branch}`], {
|
|
allowFailure: true,
|
|
})
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
const oldest = merges.length ? merges[merges.length - 1] : "";
|
|
return oldest ? [oldest] : [];
|
|
}
|
|
|
|
function prForCommit(repo, sha, branch, cache) {
|
|
if (cache.commits[sha]) return cache.commits[sha];
|
|
const own = prFromSubject(commitSubject(repo, sha));
|
|
if (own) {
|
|
cache.commits[sha] = own;
|
|
return own;
|
|
}
|
|
const merges = firstParentMerge(repo, sha, branch)
|
|
for (const merge of merges) {
|
|
const found = prFromSubject(commitSubject(repo, merge));
|
|
if (found) {
|
|
const record = { pr: found.pr, via: "ancestry", merge_sha: merge };
|
|
cache.commits[sha] = record;
|
|
return record;
|
|
}
|
|
}
|
|
cache.commits[sha] = { pr: null, via: null };
|
|
return cache.commits[sha];
|
|
}
|
|
|
|
function fetchPullRef(repo, prNumber) {
|
|
const ref = `refs/cw-eval/pr-${prNumber}`;
|
|
const exists = spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", ref], {
|
|
encoding: "utf8",
|
|
});
|
|
if (exists.status === 0) return ref;
|
|
const fetched = spawnSync(
|
|
"git",
|
|
["-C", repo, "fetch", "--no-tags", "origin", `refs/pull/${prNumber}/head:${ref}`],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (fetched.status !== 0) return "";
|
|
return ref;
|
|
}
|
|
|
|
function fileLinesAt(repo, sha, path) {
|
|
const out = git(repo, ["show", `${sha}:${path}`], { allowFailure: true });
|
|
return out === "" ? null : out.split("\n");
|
|
}
|
|
|
|
function locateAnchor(repo, headSha, path, text, nearLine) {
|
|
const lines = fileLinesAt(repo, headSha, path);
|
|
if (!lines) return null;
|
|
const needle = String(text || "").trim();
|
|
if (!needle) return null;
|
|
const hits = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (lines[index].trim() === needle) hits.push(index + 1);
|
|
}
|
|
if (!hits.length) return null;
|
|
hits.sort((left, right) => Math.abs(left - nearLine) - Math.abs(right - nearLine));
|
|
return { line: hits[0], ambiguous: hits.length > 1 };
|
|
}
|
|
|
|
function buildCorpus(flags) {
|
|
const repo = resolve(String(flags.repo || "."));
|
|
const branch = String(flags.branch || "origin/main");
|
|
const since = String(flags.since || "2026-08-01");
|
|
const until = flags.until ? String(flags.until) : "";
|
|
const limit = flags.limit ? Number(flags.limit) : 0;
|
|
const maxHunksPerFix = flags.maxHunksPerFix ? Number(flags.maxHunksPerFix) : 8;
|
|
const maxPrFiles = flags.maxFiles ? Number(flags.maxFiles) : 40;
|
|
const maxPrLines = flags.maxLines ? Number(flags.maxLines) : 4000;
|
|
const cache = readCache(flags.cache ? resolve(String(flags.cache)) : "");
|
|
const stats = {
|
|
fix_commits_scanned: 0,
|
|
hunks_scanned: 0,
|
|
blamed_lines: 0,
|
|
sized_prs: 0,
|
|
pr_lines_total: 0,
|
|
dropped: {
|
|
no_blame: 0,
|
|
no_pr: 0,
|
|
not_reviewable: 0,
|
|
pr_too_large: 0,
|
|
pr_mismatch: 0,
|
|
no_head_sha: 0,
|
|
anchor_not_found: 0,
|
|
anchor_not_introduced: 0,
|
|
anchor_ambiguous: 0,
|
|
same_pr: 0,
|
|
duplicate_case: 0,
|
|
},
|
|
};
|
|
const logArgs = ["log", "--no-merges", "--grep=^fix", "--since", since, "--format=%H"];
|
|
if (until) logArgs.push("--until", until);
|
|
logArgs.push(branch);
|
|
const fixShas = git(repo, logArgs).split("\n").filter(Boolean);
|
|
const cases = new Map();
|
|
|
|
for (const fixSha of fixShas) {
|
|
if (limit && stats.fix_commits_scanned >= limit) break;
|
|
stats.fix_commits_scanned += 1;
|
|
const diff = git(repo, ["show", "-U0", "--format=", "--no-color", fixSha], { allowFailure: true });
|
|
const hunks = parseHunks(diff)
|
|
.filter((hunk) => hunk.oldCount > 0 && looksReviewable(hunk.path))
|
|
.slice(0, maxHunksPerFix);
|
|
if (!hunks.length) continue;
|
|
stats.hunks_scanned += hunks.length;
|
|
const fixSubject = commitSubject(repo, fixSha);
|
|
for (const hunk of hunks) {
|
|
const blame = git(
|
|
repo,
|
|
["blame", "--porcelain", `-L${hunk.oldStart},${hunk.oldStart + hunk.oldCount - 1}`, `${fixSha}^`, "--", hunk.path],
|
|
{ allowFailure: true },
|
|
);
|
|
if (!blame) {
|
|
stats.dropped.no_blame += 1;
|
|
continue;
|
|
}
|
|
const entries = [];
|
|
let current = null;
|
|
for (const line of blame.split("\n")) {
|
|
const header = line.match(/^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/);
|
|
if (header) {
|
|
current = {
|
|
sha: header[1],
|
|
originalLine: Number(header[2]),
|
|
finalLine: Number(header[3]),
|
|
text: "",
|
|
filename: hunk.path,
|
|
};
|
|
entries.push(current);
|
|
continue;
|
|
}
|
|
if (current && line.startsWith("filename ")) current.filename = line.slice(9);
|
|
if (current && line.startsWith("\t")) {
|
|
stats.blamed_lines += 1;
|
|
current.text = line.slice(1);
|
|
}
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.text.trim()) continue;
|
|
const found = prForCommit(repo, entry.sha, branch, cache);
|
|
if (!found || !found.pr) {
|
|
stats.dropped.no_pr += 1;
|
|
continue;
|
|
}
|
|
const pr = Number(found.pr);
|
|
const key = `${pr}:${entry.filename}:${entry.originalLine}`;
|
|
if (cases.has(key)) {
|
|
stats.dropped.duplicate_case += 1;
|
|
continue;
|
|
}
|
|
if (flags.fetch) fetchPullRef(repo, pr);
|
|
const headRef = flags.fetch ? `refs/cw-eval/pr-${pr}` : "";
|
|
const headSha = headRef
|
|
? git(repo, ["rev-parse", headRef], { allowFailure: true }).trim()
|
|
: "";
|
|
// A "(#NNNN)" subject or an ancestry walk can name a pull request in a
|
|
// different repository of this history, so the introducing commit must
|
|
// be proven to be on that pull request's head before it is a case. A
|
|
// pull request whose head cannot be fetched is not replayable at all,
|
|
// and admitting it would put a case in the corpus that no reviewer can
|
|
// ever be shown.
|
|
if (!headSha) {
|
|
stats.dropped.no_head_sha += 1;
|
|
cases.set(`bad:${pr}`, true);
|
|
continue;
|
|
}
|
|
const contained = spawnSync(
|
|
"git",
|
|
["-C", repo, "merge-base", "--is-ancestor", entry.sha, headSha],
|
|
);
|
|
if (contained.status !== 0) {
|
|
stats.dropped.pr_mismatch += 1;
|
|
cases.set(`bad:${pr}`, true);
|
|
continue;
|
|
}
|
|
let size = null;
|
|
if (headSha && !cases.has(`size:${pr}`)) {
|
|
const base = prBaseFor(repo, found, entry.sha);
|
|
size = prSize(repo, base, headSha);
|
|
cases.set(`size:${pr}`, size);
|
|
if (size && (size.files > maxPrFiles || size.lines > maxPrLines)) {
|
|
stats.dropped.pr_too_large += 1;
|
|
cases.set(`large:${pr}`, true);
|
|
}
|
|
}
|
|
if (cases.get(`large:${pr}`)) continue;
|
|
if (size) {
|
|
stats.sized_prs += 1;
|
|
stats.pr_lines_total += size.lines;
|
|
}
|
|
let introduced = null;
|
|
if (headSha) {
|
|
const base = prBaseFor(repo, found, entry.sha);
|
|
if (base) introduced = addedLineSet(repo, base, headSha);
|
|
}
|
|
const anchor = headSha
|
|
? locateAnchor(repo, headSha, entry.filename, entry.text, entry.originalLine)
|
|
: null;
|
|
if (headSha && !anchor) {
|
|
stats.dropped.anchor_not_found += 1;
|
|
continue;
|
|
}
|
|
if (anchor?.ambiguous) stats.dropped.anchor_ambiguous += 1;
|
|
if (introduced) {
|
|
const lines = introduced.get(entry.filename);
|
|
const target = anchor ? anchor.line : entry.originalLine;
|
|
if (!lines || !lines.has(target)) {
|
|
stats.dropped.anchor_not_introduced += 1;
|
|
continue;
|
|
}
|
|
}
|
|
cases.set(key, {
|
|
id: `${pr}:${entry.filename}:${entry.originalLine}`,
|
|
pr,
|
|
pr_source: found.via,
|
|
head_sha: headSha || null,
|
|
merge_sha: found.merge_sha || null,
|
|
introduced_by: entry.sha,
|
|
fix: {
|
|
sha: fixSha,
|
|
subject: fixSubject,
|
|
path: hunk.path,
|
|
line: entry.originalLine,
|
|
},
|
|
anchor: anchor
|
|
? { path: entry.filename, line: anchor.line, text: entry.text }
|
|
: { path: entry.filename, line: entry.originalLine, text: entry.text },
|
|
anchor_ambiguous: Boolean(anchor?.ambiguous),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
writeCache(flags.cache ? resolve(String(flags.cache)) : "", cache);
|
|
const byPr = new Map();
|
|
for (const entry of cases.values()) {
|
|
if (!entry || !entry.pr || typeof entry.pr !== "number") continue;
|
|
const existing = byPr.get(entry.pr);
|
|
if (!existing) {
|
|
byPr.set(entry.pr, { ...entry, anchors: [entry.anchor], fixes: [entry.fix] });
|
|
continue;
|
|
}
|
|
existing.anchors.push(entry.anchor);
|
|
existing.fixes.push(entry.fix);
|
|
}
|
|
const corpus = {
|
|
schema: SCHEMA,
|
|
generated_at: new Date().toISOString(),
|
|
repo: {
|
|
path: repo,
|
|
remote: git(repo, ["config", "--get", "remote.origin.url"], { allowFailure: true }).trim(),
|
|
branch,
|
|
head: git(repo, ["rev-parse", branch], { allowFailure: true }).trim(),
|
|
},
|
|
window: { since, until: until || null },
|
|
stats,
|
|
cases: [...byPr.values()].sort((left, right) => left.pr - right.pr),
|
|
};
|
|
const out = flags.out ? resolve(String(flags.out)) : join(process.cwd(), "review-eval-corpus.json");
|
|
mkdirSync(dirname(out), { recursive: true });
|
|
writeFileSync(out, JSON.stringify(corpus, null, 2));
|
|
process.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
out,
|
|
cases: corpus.cases.length,
|
|
prs: corpus.cases.length,
|
|
stats,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
return corpus;
|
|
}
|
|
|
|
function parseCliPayload(stdout) {
|
|
const text = String(stdout || "");
|
|
const start = text.indexOf("{");
|
|
if (start < 0) return null;
|
|
for (let end = text.length; end > start; end -= 1) {
|
|
if (text[end - 1] !== "}") continue;
|
|
try {
|
|
return JSON.parse(text.slice(start, end));
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function claimsFromStructured(review) {
|
|
if (!review || typeof review !== "object") return [];
|
|
const claims = [];
|
|
for (const issue of Array.isArray(review.issues) ? review.issues : []) {
|
|
claims.push({
|
|
path: typeof issue?.path === "string" ? issue.path : "",
|
|
line: Number.isInteger(issue?.line) ? issue.line : null,
|
|
severity: String(issue?.severity || ""),
|
|
body: [issue?.title, issue?.description].filter(Boolean).join(" — "),
|
|
origin: "issue",
|
|
});
|
|
}
|
|
for (const suggestion of Array.isArray(review.suggestions) ? review.suggestions : []) {
|
|
claims.push({
|
|
path: typeof suggestion?.path === "string" ? suggestion.path : "",
|
|
line: Number.isInteger(suggestion?.line) ? suggestion.line : null,
|
|
severity: "suggestion",
|
|
body: String(suggestion?.suggestion || ""),
|
|
origin: "suggestion",
|
|
});
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
function prFiles(pr, repoSlug) {
|
|
const result = spawnSync(
|
|
"gh",
|
|
["api", "--paginate", `repos/${repoSlug}/pulls/${pr}/files?per_page=100`],
|
|
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
|
);
|
|
if (result.status !== 0) return [];
|
|
const pages = [];
|
|
let buffer = "";
|
|
for (const line of String(result.stdout || "").split("\n")) {
|
|
buffer += line;
|
|
try {
|
|
const parsed = JSON.parse(buffer);
|
|
if (Array.isArray(parsed)) pages.push(...parsed);
|
|
buffer = "";
|
|
} catch {
|
|
buffer += "\n";
|
|
}
|
|
}
|
|
return pages;
|
|
}
|
|
|
|
function prView(pr, repoSlug) {
|
|
const result = spawnSync(
|
|
"gh",
|
|
["pr", "view", String(pr), "--repo", repoSlug, "--json", "number,title,body,headRefOid,baseRefOid,files"],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (result.status !== 0) return null;
|
|
try {
|
|
return JSON.parse(result.stdout);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function repoSlugFor(corpus) {
|
|
const remote = String(corpus?.repo?.remote || "");
|
|
const match = remote.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/);
|
|
return match ? match[1] : "";
|
|
}
|
|
|
|
function runCliCase(entry, corpus, flags) {
|
|
const slug = repoSlugFor(corpus);
|
|
const binary = String(flags.binary || "codewhale");
|
|
const args = ["review", "--pr", String(entry.pr)];
|
|
if (slug) args.push("--repo", slug);
|
|
args.push("--json", "--max-passes", String(flags.maxPasses || 1));
|
|
if (flags.model) args.push("--model", String(flags.model));
|
|
if (flags.provider) args.push("--provider", String(flags.provider));
|
|
const started = Date.now();
|
|
const result = spawnSync(binary, args, {
|
|
cwd: resolve(String(flags.workspace || corpus.repo.path)),
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
timeout: Number(flags.timeoutSeconds || 900) * 1000,
|
|
env: process.env,
|
|
});
|
|
const wallMs = Date.now() - started;
|
|
const payload = parseCliPayload(result.stdout);
|
|
return {
|
|
status: result.status,
|
|
wall_ms: wallMs,
|
|
payload,
|
|
claims: payload?.review ? claimsFromStructured(payload.review) : [],
|
|
usage: payload?.usage || null,
|
|
stop_reason: payload?.stop_reason || null,
|
|
summary: payload?.review?.summary || "",
|
|
overall: payload?.review?.overall_assessment || "",
|
|
stderr: String(result.stderr || "").slice(-4000),
|
|
error: result.error ? String(result.error.message || result.error) : null,
|
|
};
|
|
}
|
|
|
|
async function runHostedFloorCase(entry, corpus, flags, moduleCache) {
|
|
const root = String(flags.hostedRoot || "");
|
|
if (!root) throw new Error("hosted-floor replay needs --hosted-root <codewhale-platform checkout>");
|
|
const modulePath = join(
|
|
resolve(root),
|
|
"services/control-plane/src/integrations/github/pr-review.mjs",
|
|
);
|
|
if (!existsSync(modulePath)) throw new Error(`hosted review module not found at ${modulePath}`);
|
|
moduleCache.modules ??= new Map();
|
|
if (!moduleCache.modules.has(modulePath)) {
|
|
moduleCache.modules.set(modulePath, await import(`file://${modulePath}`));
|
|
}
|
|
const module = moduleCache.modules.get(modulePath);
|
|
const view = prView(entry.pr, repoSlugFor(corpus));
|
|
if (!view) return { status: 1, wall_ms: 0, claims: [], error: "gh_pr_view_failed" };
|
|
const started = Date.now();
|
|
const comments = await module.buildPullRequestReviewComments({
|
|
files: prFiles(entry.pr, repoSlugFor(corpus)),
|
|
pullRequest: { number: view.number, title: view.title, headSha: view.headRefOid },
|
|
});
|
|
return {
|
|
status: 0,
|
|
wall_ms: Date.now() - started,
|
|
claims: comments.map((comment) => ({
|
|
path: comment.path,
|
|
line: comment.line,
|
|
side: comment.side,
|
|
severity: comment.severity,
|
|
body: comment.body,
|
|
origin: "hosted-floor",
|
|
})),
|
|
usage: null,
|
|
};
|
|
}
|
|
|
|
function runSkillCase(entry, corpus, flags) {
|
|
const skillPath = join(
|
|
resolve(String(flags.workspace || corpus.repo.path)),
|
|
"crates/tui/assets/skills/review/SKILL.md",
|
|
);
|
|
const skill = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : "";
|
|
const slug = repoSlugFor(corpus);
|
|
const view = prView(entry.pr, slug);
|
|
const diff = spawnSync("gh", ["pr", "diff", String(entry.pr), "--repo", slug], {
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
});
|
|
const prompt = [
|
|
skill,
|
|
"",
|
|
"---",
|
|
`Pull request #${entry.pr} in ${slug}`,
|
|
`Title: ${view?.title || ""}`,
|
|
"Description:",
|
|
String(view?.body || "").slice(0, 4000),
|
|
"",
|
|
"Diff:",
|
|
String(diff.stdout || "").slice(0, Number(flags.maxChars || 200000)),
|
|
].join("\n");
|
|
const binary = String(flags.binary || "codewhale");
|
|
const args = ["exec", "--json"];
|
|
if (flags.model) args.push("--model", String(flags.model));
|
|
if (flags.provider) args.push("--provider", String(flags.provider));
|
|
args.push(prompt);
|
|
const started = Date.now();
|
|
const result = spawnSync(binary, args, {
|
|
cwd: resolve(String(flags.workspace || corpus.repo.path)),
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
timeout: Number(flags.timeoutSeconds || 900) * 1000,
|
|
});
|
|
const payload = parseCliPayload(result.stdout);
|
|
const text = String(payload?.result || payload?.content || result.stdout || "");
|
|
return {
|
|
status: result.status,
|
|
wall_ms: Date.now() - started,
|
|
claims: claimsFromMarkdown(text),
|
|
usage: payload?.usage || null,
|
|
raw_excerpt: text.slice(0, 4000),
|
|
};
|
|
}
|
|
|
|
function claimsFromMarkdown(text) {
|
|
const claims = [];
|
|
const linePattern = /`?([\w./-]+\.\w+):(\d+)`?/g;
|
|
for (const block of String(text || "").split(/\n(?=\s*(?:\d+[.)]|[-*])\s)/)) {
|
|
const match = block.match(linePattern);
|
|
if (!match) continue;
|
|
const [, path, line] = match[0].match(/`?([\w./-]+\.\w+):(\d+)`?/) || [];
|
|
if (!path) continue;
|
|
claims.push({ path, line: Number(line), severity: "", body: block.trim().slice(0, 1200), origin: "skill" });
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
async function replay(flags) {
|
|
const corpus = JSON.parse(readFileSync(resolve(String(flags.corpus)), "utf8"));
|
|
const reviewer = String(flags.reviewer || "cli");
|
|
const outDir = resolve(String(flags.out));
|
|
mkdirSync(outDir, { recursive: true });
|
|
const maxCases = flags.maxCases ? Number(flags.maxCases) : corpus.cases.length;
|
|
const moneyCap = flags.moneyCapUsd ? Number(flags.moneyCapUsd) : 0;
|
|
const moduleCache = {};
|
|
const runs = [];
|
|
let spend = 0;
|
|
for (const entry of corpus.cases.slice(0, maxCases)) {
|
|
if (moneyCap > 0 && spend >= moneyCap) {
|
|
process.stderr.write(`money cap ${moneyCap} USD reached after ${runs.length} case(s)\n`);
|
|
break;
|
|
}
|
|
const started = new Date().toISOString();
|
|
let record;
|
|
try {
|
|
if (reviewer === "cli") record = runCliCase(entry, corpus, flags);
|
|
else if (reviewer === "hosted-floor") record = await runHostedFloorCase(entry, corpus, flags, moduleCache);
|
|
else if (reviewer === "skill") record = runSkillCase(entry, corpus, flags);
|
|
else throw new Error(`unknown reviewer ${reviewer}`);
|
|
} catch (error) {
|
|
record = { status: 1, wall_ms: 0, claims: [], error: String(error?.message || error) };
|
|
}
|
|
const cost = estimateCost(record.usage, flags);
|
|
spend += cost;
|
|
const runRecord = {
|
|
id: entry.id,
|
|
pr: entry.pr,
|
|
reviewer,
|
|
started_at: started,
|
|
status: record.status,
|
|
wall_ms: record.wall_ms,
|
|
claims: record.claims || [],
|
|
usage: record.usage || null,
|
|
estimated_cost_usd: Number(cost.toFixed(6)),
|
|
stop_reason: record.stop_reason || null,
|
|
summary: record.summary || "",
|
|
overall: record.overall || "",
|
|
error: record.error || null,
|
|
stderr: record.stderr || "",
|
|
raw_excerpt: record.raw_excerpt || "",
|
|
};
|
|
writeFileSync(join(outDir, `${entry.pr}.json`), JSON.stringify(runRecord, null, 2));
|
|
runs.push(runRecord);
|
|
process.stderr.write(
|
|
`${reviewer} ${entry.id} status=${record.status} claims=${runRecord.claims.length} ${record.wall_ms}ms\n`,
|
|
);
|
|
}
|
|
const manifest = {
|
|
schema: RUN_SCHEMA,
|
|
reviewer,
|
|
corpus: resolve(String(flags.corpus)),
|
|
model: flags.model || null,
|
|
provider: flags.provider || null,
|
|
generated_at: new Date().toISOString(),
|
|
money_cap_usd: moneyCap || null,
|
|
cases_attempted: runs.length,
|
|
estimated_cost_usd: Number(spend.toFixed(6)),
|
|
cases: runs.map((run) => run.id),
|
|
};
|
|
writeFileSync(join(outDir, "run.json"), JSON.stringify(manifest, null, 2));
|
|
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
return moneyCap > 0 && spend >= moneyCap ? 2 : 0;
|
|
}
|
|
|
|
function estimateCost(usage, flags) {
|
|
const inputRate = flags.inputUsdPerMTok ? Number(flags.inputUsdPerMTok) : 0;
|
|
const outputRate = flags.outputUsdPerMTok ? Number(flags.outputUsdPerMTok) : 0;
|
|
if (!usage) return 0;
|
|
const input = Number(usage.input_tokens || 0) + Number(usage.cache_read_input_tokens || 0);
|
|
const output = Number(usage.output_tokens || 0);
|
|
return (input / 1_000_000) * inputRate + (output / 1_000_000) * outputRate;
|
|
}
|
|
|
|
function matchCase(entry, claims, windowLines) {
|
|
let fileMatch = null;
|
|
let lineMatch = null;
|
|
for (const claim of claims) {
|
|
if (!claim.path) continue;
|
|
const anchors = Array.isArray(entry.anchors) ? entry.anchors : [entry.anchor];
|
|
for (const anchor of anchors) {
|
|
if (!anchor?.path) continue;
|
|
if (claim.path === anchor.path || claim.path.endsWith(`/${anchor.path}`) || anchor.path.endsWith(`/${claim.path}`)) {
|
|
fileMatch ??= claim;
|
|
if (Number.isInteger(claim.line) && Math.abs(claim.line - anchor.line) <= windowLines) {
|
|
lineMatch ??= claim;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { fileMatch, lineMatch };
|
|
}
|
|
|
|
function words(text) {
|
|
return String(text || "").trim().split(/\s+/).filter(Boolean).length;
|
|
}
|
|
|
|
function grade(flags) {
|
|
const corpus = JSON.parse(readFileSync(resolve(String(flags.corpus)), "utf8"));
|
|
const runDir = resolve(String(flags.run));
|
|
const manifestPath = join(runDir, "run.json");
|
|
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : { reviewer: "unknown" };
|
|
const windowLines = flags.window ? Number(flags.window) : DEFAULT_WINDOW_LINES;
|
|
const rows = [];
|
|
for (const entry of corpus.cases) {
|
|
const file = join(runDir, `${entry.pr}.json`);
|
|
if (!existsSync(file)) continue;
|
|
const run = JSON.parse(readFileSync(file, "utf8"));
|
|
const claims = Array.isArray(run.claims) ? run.claims : [];
|
|
const { fileMatch, lineMatch } = matchCase(entry, claims, windowLines);
|
|
const matchedClaims = claims.filter((claim) =>
|
|
(Array.isArray(entry.anchors) ? entry.anchors : [entry.anchor]).some(
|
|
(anchor) =>
|
|
anchor?.path &&
|
|
Number.isInteger(claim.line) &&
|
|
(claim.path === anchor.path || claim.path.endsWith(`/${anchor.path}`)) &&
|
|
Math.abs(claim.line - anchor.line) <= windowLines,
|
|
),
|
|
);
|
|
rows.push({
|
|
id: entry.id,
|
|
pr: entry.pr,
|
|
status: run.status,
|
|
claims: claims.length,
|
|
words: claims.reduce((total, claim) => total + words(claim.body), 0),
|
|
caught: Boolean(lineMatch),
|
|
caught_file: Boolean(fileMatch),
|
|
caught_by: lineMatch ? { path: lineMatch.path, line: lineMatch.line, severity: lineMatch.severity } : null,
|
|
claims_in_region: matchedClaims.length,
|
|
precise_share: claims.length ? Number((matchedClaims.length / claims.length).toFixed(3)) : null,
|
|
wall_ms: run.wall_ms || 0,
|
|
cost_usd: run.estimated_cost_usd || 0,
|
|
error: run.error || null,
|
|
});
|
|
}
|
|
const attempted = rows.length;
|
|
const caught = rows.filter((row) => row.caught).length;
|
|
const caughtFile = rows.filter((row) => row.caught_file).length;
|
|
const claimsTotal = rows.reduce((total, row) => total + row.claims, 0);
|
|
const wordsTotal = rows.reduce((total, row) => total + row.words, 0);
|
|
const summary = {
|
|
schema: GRADE_SCHEMA,
|
|
reviewer: manifest.reviewer,
|
|
model: manifest.model || null,
|
|
corpus: resolve(String(flags.corpus)),
|
|
run: runDir,
|
|
window_lines: windowLines,
|
|
graded_at: new Date().toISOString(),
|
|
metrics: {
|
|
cases_attempted: attempted,
|
|
cases_with_claims: rows.filter((row) => row.claims > 0).length,
|
|
recall_at_window: attempted ? Number((caught / attempted).toFixed(4)) : null,
|
|
recall_same_file: attempted ? Number((caughtFile / attempted).toFixed(4)) : null,
|
|
claims_total: claimsTotal,
|
|
claims_in_region: rows.reduce((total, row) => total + row.claims_in_region, 0),
|
|
words_total: wordsTotal,
|
|
claims_per_case: attempted ? Number((claimsTotal / attempted).toFixed(2)) : null,
|
|
words_per_case: attempted ? Math.round(wordsTotal / attempted) : null,
|
|
wall_ms_total: rows.reduce((total, row) => total + row.wall_ms, 0),
|
|
wall_ms_mean: attempted ? Math.round(rows.reduce((total, row) => total + row.wall_ms, 0) / attempted) : null,
|
|
cost_usd_total: Number(rows.reduce((total, row) => total + row.cost_usd, 0).toFixed(6)),
|
|
cost_usd_per_case: attempted
|
|
? Number((rows.reduce((total, row) => total + row.cost_usd, 0) / attempted).toFixed(6))
|
|
: null,
|
|
failures: rows.filter((row) => row.status !== 0).length,
|
|
},
|
|
rows,
|
|
};
|
|
const out = flags.out ? resolve(String(flags.out)) : join(runDir, "grade.json");
|
|
writeFileSync(out, JSON.stringify(summary, null, 2));
|
|
process.stdout.write(`${JSON.stringify({ out, ...summary.metrics }, null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
|
|
function report(flags) {
|
|
const current = JSON.parse(readFileSync(resolve(String(flags.grade)), "utf8"));
|
|
const lines = [];
|
|
const header = ["metric", current.reviewer, flags.baseline ? "baseline" : "", flags.baseline ? "delta" : ""].filter(Boolean);
|
|
lines.push(header.join(" | "));
|
|
const baseline = flags.baseline
|
|
? JSON.parse(readFileSync(resolve(String(flags.baseline)), "utf8"))
|
|
: null;
|
|
const keys = [
|
|
"cases_attempted",
|
|
"recall_at_window",
|
|
"recall_same_file",
|
|
"claims_total",
|
|
"claims_per_case",
|
|
"words_per_case",
|
|
"cost_usd_per_case",
|
|
"wall_ms_mean",
|
|
"failures",
|
|
];
|
|
for (const key of keys) {
|
|
const value = current.metrics?.[key];
|
|
const base = baseline?.metrics?.[key];
|
|
const delta = baseline && typeof value === "number" && typeof base === "number"
|
|
? (value - base).toFixed(4)
|
|
: "";
|
|
lines.push([key, value ?? "", baseline ? base ?? "" : "", delta].filter((cell) => cell !== "").join(" | "));
|
|
}
|
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
return 0;
|
|
}
|
|
|
|
async function main() {
|
|
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
if (command === "help" || flags.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return 0;
|
|
}
|
|
if (command === "corpus") {
|
|
buildCorpus(flags);
|
|
return 0;
|
|
}
|
|
if (command === "replay") return replay(flags);
|
|
if (command === "grade") return grade(flags);
|
|
if (command === "report") return report(flags);
|
|
if (command === "all") {
|
|
const outDir = resolve(String(flags.out));
|
|
const corpusPath = join(outDir, "corpus.json");
|
|
flags.out = corpusPath;
|
|
buildCorpus({ ...flags, fetch: flags.fetch ?? true });
|
|
const runDir = join(outDir, `run-${flags.reviewer || "cli"}`);
|
|
const replayCode = await replay({ ...flags, corpus: corpusPath, out: runDir });
|
|
grade({ ...flags, corpus: corpusPath, run: runDir, out: join(runDir, "grade.json") });
|
|
return replayCode;
|
|
}
|
|
process.stderr.write(`${usage()}\n`);
|
|
return 1;
|
|
}
|
|
|
|
main().then(
|
|
(code) => process.exit(code ?? 0),
|
|
(error) => {
|
|
process.stderr.write(`${error?.stack || error}\n`);
|
|
process.exit(1);
|
|
},
|
|
);
|