Ordre 5 (wave 1, P11 + P5), review_version 1.1. - K7: `verdict: ALLOW | WARN | BLOCK` is required in review.md (strict error, soft warning for 1.0 reviews). New CLI lib/review/verdict-gate.mjs: 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide (never green); a verdict greener than the file's own BLOCKERs or JSON block is exit 2. Realpath main guard (the old file:// guard exits 0 under a symlink - measured). - No finding is struck for length: succinctness trims only MINOR/SUGGESTION text (catalogue tier), keeps BLOCKER/MAJOR whole, counts trims; the coordinator lists what it declined to judge. - Claude Code's /code-review and /security-review run as the engine via the Skill tool (never --fix/--comment/--post/ultra); lib/review/engine-findings.mjs turns their findings into a reviewer payload; two catalogue keys (CODE_REVIEW_DEFECT, SECURITY_REVIEW_VULNERABILITY), catalogue 12 -> 14. An engine that did not run forbids ALLOW. - Every BLOCKER is written as a numbered `### Step R<n>` with its source finding (strict error REVIEW_MISSING_REMEDIATION_STEP). - P5: lib/review/recurrence.mjs - a finding class in two review.md files (current + git history, no new store) becomes one proposal, routed by the catalogue's memory_route; written only between Voyage markers in .claude/rules/voyage-review-lessons.md under a size budget, CLAUDE.md gets a pointer only; the operator approves every line (Phase 8.7). - Workflow substrate: catalogue enum in step; a missing reviewer or engine forbids ALLOW there too (it returned ALLOW on zero findings before). - Fixtures review-run-A/B said WARN over a BLOCKER; now BLOCK. Borrowed and credited in code: Superpowers (declined-to-judge), ai-native-sdlc (cap only nits; mistake/fix/check), Spec Kit (managed block; converge steps), Pocock (retro routing), Kiro (verdict file + gate; evidence per lesson), Cherny (engine -> brief control order; review -> memory; budget). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.9 KiB
JavaScript
184 lines
6.9 KiB
JavaScript
// 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 } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
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}
|
||
*/
|
||
export function validateFindings(payload) {
|
||
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);
|
||
}
|
||
|
||
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) {
|
||
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);
|
||
}
|
||
|
||
// ---- 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 filePath = args.find((a) => !a.startsWith('--'));
|
||
if (!filePath) {
|
||
process.stderr.write('Usage: findings-schema.mjs [--json] <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'));
|
||
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);
|
||
}
|