voyage/lib/review/findings-schema.mjs
Kjell Tore Guttormsen 609eb35015
fix(review): the review keeps the findings that are right
voyage-lekerepo end-to-end trial (2026-10-06), report points 1, 5, 11:

- engine-findings: recommended_action is "Fix <file:line> so this no longer
  happens: <failure scenario>", not "Resolve: <title>" (8 of 8 engine
  findings were set aside as actionability:empty); the coordinator never
  sets an engine finding aside for Actionability.
- findings-schema --repo-root: a cited line past the end of an existing
  file is FINDING_LINE_OUT_OF_RANGE, so Phase 5's bounded re-ask fixes it
  before the coordinator sees it. The reviewers' instruction said "the
  actual line number from the diff"; it now says the file's own line,
  never the patch's.
- fill-finding-ids.mjs: the coordinator (no shell) writes @@ID_<n>@@ and
  trekreview Phase 7 computes them over (file, line, rule_key); an
  unresolved placeholder or a wrong 40-hex id fails. The 4-field id with
  `title` the docs named is gone; the code always used three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:48:13 +02:00

225 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/review/findings-schema.mjs
// Reviewer-output JSON schema contract for /trekreview Phase 5 (NW1).
//
// brief-conformance-reviewer and code-correctness-reviewer each emit a trailing
// fenced `json` block of shape:
//
// { "reviewer": "<name>", "findings": [ { id, severity, rule_key, file, line,
// brief_ref, title, detail,
// recommended_action }, ... ] }
//
// This module codifies that contract so main can VALIDATE each reviewer's JSON
// (not merely JSON.parse it) and re-ask on *schema* failure as well as parse
// failure, replacing the fragile "parse the last json block" contract that used
// to live in commands/trekreview.md (the :202–204 prose).
//
// Load-bearing fields (the downstream dedup triplet + verdict severity) are hard
// errors: file, rule_key, severity, line. Unknown rule_keys are errors too — the
// catalogue is the contract. Descriptive fields (title/detail/recommended_action/
// brief_ref) and unknown top-level keys are tolerated (forward-compat, mirroring
// review-validator.mjs).
//
// 3-layer pattern (Content → Raw-text → CLI shim) mirroring the other validators.
import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve, sep } from 'node:path';
import { issue, fail } from '../util/result.mjs';
import { RULE_KEYS, SEVERITY_VALUES } from './rule-catalogue.mjs';
// The fields main + the coordinator depend on. Descriptive fields are not here
// on purpose: a missing recommended_action should not trigger a re-ask.
export const FINDING_REQUIRED_FIELDS = Object.freeze([
'severity',
'rule_key',
'file',
'line',
]);
// Last fenced ```json … ``` block in a reviewer's output. The contract pins the
// JSON block as the LAST fence so prose above it never confuses the parser.
const JSON_FENCE_GLOBAL = /```json[ \t]*\r?\n([\s\S]*?)```/gi;
/**
* Extract the inner body of the LAST fenced `json` block in `text`.
* @param {string} text
* @returns {string|null} the JSON source, or null if no json fence is present.
*/
export function extractFindingsBlock(text) {
if (typeof text !== 'string') return null;
JSON_FENCE_GLOBAL.lastIndex = 0;
let last = null;
let m;
while ((m = JSON_FENCE_GLOBAL.exec(text)) !== null) {
last = m[1];
}
return last;
}
function validateFinding(finding, index, errors) {
const loc = `findings[${index}]`;
if (finding === null || typeof finding !== 'object' || Array.isArray(finding)) {
errors.push(issue('FINDING_NOT_OBJECT', `${loc} is not an object`, undefined, loc));
return;
}
if (typeof finding.file !== 'string' || finding.file.length === 0) {
errors.push(issue('FINDING_MISSING_FILE', `${loc}.file must be a non-empty string`, undefined, loc));
}
if (typeof finding.rule_key !== 'string' || finding.rule_key.length === 0) {
errors.push(issue('FINDING_MISSING_RULE_KEY', `${loc}.rule_key must be a non-empty string`, undefined, loc));
} else if (!RULE_KEYS.has(finding.rule_key)) {
errors.push(issue(
'FINDING_UNKNOWN_RULE_KEY',
`${loc}.rule_key "${finding.rule_key}" is not in the rule catalogue`,
'Use a rule_key from lib/review/rule-catalogue.mjs',
loc,
));
}
if (typeof finding.severity !== 'string' || !SEVERITY_VALUES.includes(finding.severity)) {
errors.push(issue(
'FINDING_BAD_SEVERITY',
`${loc}.severity must be one of ${SEVERITY_VALUES.join('|')}, got ${JSON.stringify(finding.severity)}`,
undefined,
loc,
));
}
if (typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0) {
errors.push(issue(
'FINDING_BAD_LINE',
`${loc}.line must be an integer ≥ 0, got ${JSON.stringify(finding.line)}`,
'Use 0 for file-scoped findings without a specific line.',
loc,
));
}
}
/**
* Validate an already-parsed reviewer-output payload against the schema.
* Accumulates every error (so a re-ask can name all problems at once).
* @param {unknown} payload
* @returns {import('../util/result.mjs').Result}
*/
// A reviewer that reads the diff cites the line in the PATCH, not in the file:
// the voyage-lekerepo trial (2026-10-06) cited tests/cli.test.mjs:194 for a
// 124-line file, the coordinator then set the only brief-anchored MAJOR aside,
// and the claim itself was right. With a repo root, a line past the end of a
// file that exists is an error, so Phase 5's bounded re-ask fixes it before the
// coordinator ever sees it. A file absent from the tree is not judged here.
function lineCount(repoRoot, file) {
const root = resolve(repoRoot);
const abs = resolve(root, file);
if (abs !== root && !abs.startsWith(root + sep)) return null;
try {
if (!statSync(abs).isFile()) return null;
const text = readFileSync(abs, 'utf-8');
if (text === '') return 0;
return text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length;
} catch {
return null;
}
}
function checkLineRange(finding, index, repoRoot, errors) {
if (!finding || typeof finding.file !== 'string' || !Number.isInteger(finding.line) || finding.line <= 0) return;
const n = lineCount(repoRoot, finding.file);
if (n === null || finding.line <= n) return;
errors.push(issue(
'FINDING_LINE_OUT_OF_RANGE',
`findings[${index}] cites ${finding.file}:${finding.line}, but the file has ${n} lines`,
'Cite the line number in the file itself (Read it), not the line in the diff or patch.',
));
}
/**
* @param {unknown} payload
* @param {{ repoRoot?: string }} [opts] - with a repo root, cited lines are checked against the files
*/
export function validateFindings(payload, opts = {}) {
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
return fail(issue('FINDINGS_NOT_OBJECT', `Reviewer output must be a JSON object, got ${Array.isArray(payload) ? 'array' : typeof payload}`));
}
const errors = [];
const warnings = [];
if (typeof payload.reviewer !== 'string' || payload.reviewer.length === 0) {
warnings.push(issue('FINDINGS_MISSING_REVIEWER', 'Reviewer output should carry a non-empty "reviewer" name'));
}
if (!Array.isArray(payload.findings)) {
errors.push(issue('FINDINGS_NOT_ARRAY', `Field "findings" must be an array, got ${typeof payload.findings}`));
return { valid: false, errors, warnings, parsed: payload };
}
for (let i = 0; i < payload.findings.length; i++) {
validateFinding(payload.findings[i], i, errors);
if (opts.repoRoot) checkLineRange(payload.findings[i], i, opts.repoRoot, errors);
}
return { valid: errors.length === 0, errors, warnings, parsed: payload };
}
/**
* Validate a reviewer's raw output text: extract the last json fence, parse it,
* then schema-validate. Parse-stage failures get stable codes so they flow
* through the same bounded re-ask path as schema failures.
* @param {string} rawText
* @returns {import('../util/result.mjs').Result}
*/
export function validateReviewerOutput(rawText, opts = {}) {
const block = extractFindingsBlock(rawText);
if (block === null) {
return fail(issue(
'FINDINGS_NO_JSON_BLOCK',
'No trailing fenced ```json block found in reviewer output',
'Reviewers must end their output with a single ```json findings block.',
));
}
let parsed;
try {
parsed = JSON.parse(block);
} catch (e) {
return fail(issue('FINDINGS_PARSE_ERROR', `Reviewer JSON block did not parse: ${e.message}`));
}
return validateFindings(parsed, opts);
}
// ---- CLI shim ----------------------------------------------------------------
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const rootIdx = args.indexOf('--repo-root');
const repoRoot = rootIdx >= 0 ? args[rootIdx + 1] : undefined;
// Without the flag nothing is skipped (rootIdx + 1 would be 0: the file).
const skipIdx = rootIdx >= 0 ? rootIdx + 1 : -1;
const filePath = args.find((a, i) => !a.startsWith('--') && i !== skipIdx);
if (!filePath || (rootIdx >= 0 && !repoRoot)) {
process.stderr.write('Usage: findings-schema.mjs [--json] [--repo-root <dir>] <reviewer-output.txt|.md>\n');
process.exit(2);
}
if (!existsSync(filePath)) {
process.stderr.write(`findings-schema: file not found: ${filePath}\n`);
process.exit(2);
}
const r = validateReviewerOutput(readFileSync(filePath, 'utf-8'), { repoRoot });
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`findings-schema: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}