follow-up restructure.

- loop until review pass
- review now comes prior to commit
This commit is contained in:
2026-07-20 10:29:15 -04:00
parent 6aa3f6bd9f
commit 519b12b3d9
8 changed files with 1092 additions and 493 deletions

211
src/review.ts Normal file
View File

@@ -0,0 +1,211 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ReviewResult, ReviewFinding, ReviewVerdict } from "./types";
import { REVIEW_PATTERN } from "./constants";
import { ensureDir, writeFileSafe } from "./utils";
// ─── Extract Structured Review ──────────────────────────────────────────────
/**
* Extract a structured review verdict from the review agent's output text.
* Mirrors extractReflection() — parses a `## REVIEW VERDICT` block emitted at
* the end of the response.
*
* The raw text is preserved on the ReviewResult so the expanded (Ctrl+O) view
* can still render the full free-form prose. Returns null when no verdict
* block is found (caller falls back to free-form text handling).
*/
export function extractReview(
output: string,
taskId: string,
commitHash: string,
): ReviewResult | null {
const match = output.match(REVIEW_PATTERN);
if (!match) return null;
const block = match[1];
const verdict = extractVerdict(block);
if (!verdict) return null; // verdict is the one required field
const summary = extractField(block, "SUMMARY") ?? "";
const findings = extractFindings(block);
return {
taskId,
verdict,
summary: summary || verdictLabel(verdict),
findings,
commitHash,
rawText: output.trim(),
timestamp: new Date().toISOString(),
};
}
function extractVerdict(block: string): ReviewVerdict | null {
const raw = extractField(block, "VERDICT");
if (!raw) return null;
const v = raw.toLowerCase().trim();
if (v === "pass" || v === "warn" || v === "fail") return v;
// Tolerate common synonyms
if (v === "warning" || v === "minor") return "warn";
if (v === "fail" || v === "failing" || v === "blocker") return "fail";
if (v === "ok" || v === "passing" || v === "approve") return "pass";
return null;
}
// Allowlisted static regexes — `field` is always a known literal, but we use
// a static map rather than string interpolation so there's no dynamic regex
// construction at all (`new RegExp` from a variable trips ReDoS linters).
const FIELD_PATTERNS: Record<string, RegExp> = {
VERDICT: /VERDICT:\s*(.+?)$/im,
SUMMARY: /SUMMARY:\s*(.+?)$/im,
};
function extractField(block: string, field: string): string | null {
const regex = FIELD_PATTERNS[field.toUpperCase()];
if (!regex) return null;
const match = block.match(regex);
return match ? match[1].trim() : null;
}
/**
* Parse FINDINGS: lines into structured ReviewFinding objects.
* Each finding line is expected as:
* - [severity] [file:line] message
* where severity is one of blocker|warning|nit|info.
* Falls back gracefully — an unparseable line becomes an info-severity
* finding with the raw line as the message.
*/
function extractFindings(block: string): ReviewFinding[] {
// Match the FINDINGS: header, then capture all following bullet lines.
const regex = /FINDINGS:\s*\n((?:[-*]\s+.+\n?)+)/i;
const match = block.match(regex);
if (!match) return [];
const lines = match[1]
.split("\n")
.map((l) => l.replace(/^[-*]\s*/, "").trim())
.filter(Boolean);
const findings: ReviewFinding[] = [];
const severityRe = /^\[(blocker|warning|warn|nit|info)\]\s*(.*)$/i;
for (const line of lines) {
const sm = line.match(severityRe);
if (sm) {
let sev = sm[1].toLowerCase();
if (sev === "warn") sev = "warning";
const rest = sm[2].trim();
const { file, line: lineNum, message } = parseFileRef(rest);
findings.push({
severity: sev as ReviewFinding["severity"],
file,
line: lineNum,
message,
});
} else {
// No severity bracket — treat as info
const { file, line: lineNum, message } = parseFileRef(line);
findings.push({ severity: "info", file, line: lineNum, message });
}
}
return findings;
}
/** Parse an optional `file:line` prefix from a finding message. */
function parseFileRef(rest: string): {
file?: string;
line?: number;
message: string;
} {
const m = rest.match(/^([\w./-]+):(\d+)\s*[-—]?\s*(.*)$/);
if (m) {
return { file: m[1], line: Number(m[2]), message: m[3].trim() || rest };
}
return { message: rest };
}
function verdictLabel(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "Commit satisfies the task requirements.";
case "warn":
return "Commit passes with minor issues worth addressing.";
case "fail":
return "Commit does not satisfy the task requirements.";
}
}
// ─── Save / Load Structured Reviews ─────────────────────────────────────────
/**
* Save a structured review as JSON alongside (or instead of) the markdown
* body. Mirrors saveReflectionToFile's per-loop layout so a repo can hold
* many loops without collisions:
* .ralpi/reviews/<prdKey>/<taskId>.json
*/
export function saveReviewToFile(
sourceDir: string,
reviewsDir: string,
review: ReviewResult,
prdKey: string,
): string {
const dir = path.join(sourceDir, reviewsDir, prdKey);
ensureDir(dir);
const filePath = path.join(dir, `${review.taskId}.json`);
writeFileSafe(filePath, JSON.stringify(review, null, 2));
return filePath;
}
/**
* Load a structured review from disk.
*/
export function loadReview(
sourceDir: string,
reviewsDir: string,
taskId: string,
prdKey: string,
): ReviewResult | null {
const filePath = path.join(sourceDir, reviewsDir, prdKey, `${taskId}.json`);
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ReviewResult;
} catch {
return null;
}
}
// ─── Formatting ──────────────────────────────────────────────────────────────
/** Verdict glyph for compact display in chat headers / widgets. */
export function verdictGlyph(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "✓";
case "warn":
return "⚠";
case "fail":
return "✗";
}
}
/** Short label: "PASS · 0 findings", "WARN · 2 findings", "FAIL · 3 findings" */
export function verdictSummary(review: ReviewResult): string {
const n = review.findings.length;
const noun = n === 1 ? "finding" : "findings";
return `${review.verdict.toUpperCase()} · ${n} ${noun}`;
}
/**
* Format findings as an indented markdown tree for the expanded view.
*/
export function formatFindings(review: ReviewResult): string {
if (review.findings.length === 0) return "(no findings)";
const lines: string[] = [];
for (const f of review.findings) {
const loc = f.file ? (f.line ? `${f.file}:${f.line}` : f.file) : "";
lines.push(` - [${f.severity}]${loc ? ` ${loc}` : ""}${f.message}`);
}
return lines.join("\n");
}