voyage/lib/review/findings-schema.mjs
Kjell Tore Guttormsen 10ac9c5231
fix(review): round 10 of the release review of 6.3.0, for 6.3.1
Round 10 (0f400d3..a1e0e50, WARN: 0 BLOCKER, 11 MAJOR): six findings, by
operator choice. Each began with a test that failed on a1e0e50 on its
assertion, every case on its own; suite 2236 (2234/0/2).

- Files: a new line inside a description opens a new entry when it opens
  with a plain file with a slash, or with one alone on the line or followed
  by a colon or a dash ("src/cli.mjs", "package.json", "Makefile - a
  target"); "child_process.spawnSync, path.join", "Node.js 22 only" and
  "index.js and the CLI" stay description (ecd9da6f).
- Files: a numbered list item (`1.` or `1)`) is an entry, its marker
  dropped like `-` (b4215783).
- Files: a backticked capitalised name is a file only alone in its entry
  before any description; `Parser` inside one is not (39a57fee).
- Files: a backticked span that is no path (`x()`:) starts no description
  with its colon (882cc82a); a plain file with a colon starts it wherever it
  stands ("the entry cli.mjs: wires parse", a6bd9c8c).
- Measured over all 939 Files: fields in the .md files under the
  marketplace and the test repo (560 of them in the 48 plan.md files outside
  worktrees): 0 read differently from a1e0e50, tests-only changed in 0.
- The trailing-json fence reader is built on plan-schema's fenceTracker:
  a ~~~ fence is a fence, so a ```json line inside one no longer swallows
  the review's own block or hides its BLOCKER ids (0d94c5fe). This also
  closes a gate hole in 6.3.0 and a1e0e50: a last block written as ~~~json
  holding a BLOCKER, after a clean ```json block, passed with exit 0; now 2.
- Kept on purpose: a fence line indented four spaces or more still counts
  in that reader (fenceTracker's new anyIndent). Markdown's three-space
  rule made a BLOCKER block inside a list item, four spaces in, invisible,
  and the gate passed it with exit 0 (seen while fixing, before commit); a
  ```json line in a code block indented four spaces fails the gate closed.
  Both are pinned in the 0d94c5fe test.
- The fieldPaths doc names rounds 1-10 (it said 1-7).

Not in this round (operator 2026-10-11): F8 754723c1 (an info string
" json" fails closed, as in 6.3.0), F9 88de9e6b, F10 164c5bd0, F11
e70b28b7 (dc3a467e). F1 878e3960: this title carries a scope; the three
earlier commits are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:36:17 +02:00

349 lines
15 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, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { issue, fail } from '../util/result.mjs';
import { RULE_KEYS, SEVERITY_VALUES } from './rule-catalogue.mjs';
import { fenceTracker } from '../parsers/plan-schema.mjs';
import { isMain } from '../util/is-main.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',
]);
// The fenced blocks of a Markdown text, in order, read as Markdown reads a
// fence (plan-schema `fenceTracker`, the one fence rule): an opening line of
// three or more backticks or tildes, closed only by a line of the same
// character at least as long as the opening run (round 9: any three closed any
// opener, so a four-backtick example of a json block split there). A backtick
// opener's info string holds no backtick. Inside a ~~~ block a ```json line
// opens nothing (round 10: it swallowed the real block, and the review
// validator lost every BLOCKER id). A block never closed runs to the end of the
// text (`closed: false`). Indented or not, a fence counts: a fence inside a
// list item can sit four spaces in, and a gate that skipped it would pass a
// BLOCKER written there; the cost is that a ```json line in a code block
// indented four spaces fails the gate closed (exit 2), never open. A `\r`
// before a line break is no part of the line, so a CRLF file reads as its LF
// twin. A JSON string cannot hold a line break, so three backticks inside one
// ("a fenced ```bash block") never end a block, and three backticks after text
// on the same line are no fence (release review of 6.3.0, rounds 7-10).
// `info` is the info string as written, trailing blanks dropped; `body` is the
// text between the fence lines, as written.
// One reader for the verdict gate and every trailing-json reader: the review
// validator, the synthesis digest, fill-finding-ids, recurrence, fidelity-diff.
const FENCE_INFO = /^[ \t]*(?:`{3,}|~{3,})(.*)$/;
/**
* @param {string} text
* @returns {Array<{info: string, body: string, closed: boolean}>}
*/
export function fencedBlocks(text) {
const blocks = [];
if (typeof text !== 'string') return blocks;
const fence = fenceTracker({ anyIndent: true });
let open = null;
for (let at = 0; at <= text.length;) {
const nl = text.indexOf('\n', at);
const end = nl === -1 ? text.length : nl;
const line = text.slice(at, end).replace(/\r$/, '');
const wasInside = fence.inside;
fence.feed(line);
if (!wasInside && fence.inside) {
open = { info: FENCE_INFO.exec(line)[1].replace(/[ \t]+$/, ''), start: end + 1 };
} else if (wasInside && !fence.inside) {
blocks.push({ info: open.info, body: text.slice(open.start, at), closed: true });
open = null;
}
if (nl === -1) break;
at = nl + 1;
}
if (open !== null) blocks.push({ info: open.info, body: text.slice(open.start), closed: false });
return blocks;
}
/** A `json` block: its info string is exactly `json` (any case). */
export function isJsonBlock(block) {
return block.info.toLowerCase() === 'json';
}
/**
* Extract the inner body of the LAST closed `json` block in `text`. The
* contract pins the JSON block as the LAST fence so prose above it never
* confuses the parser.
* @param {string} text
* @returns {string|null} the JSON source, or null if no json fence is present.
*/
export function extractFindingsBlock(text) {
const json = fencedBlocks(text).filter((b) => b.closed && isJsonBlock(b));
return json.length > 0 ? json[json.length - 1].body : null;
}
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 readLineCount(root, file) {
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;
}
}
/**
* Line counts for one validation, each file read once however often it is
* cited: a reviewer that cited the 3,000-line commands/trekexecute.md 40
* times read it 40 times, again on every re-ask (release review of 6.3.0,
* round 7). The map lives for one call, so an edited file is read afresh.
*/
function lineCounter(repoRoot) {
const root = resolve(repoRoot);
const counts = new Map();
return (file) => {
if (!counts.has(file)) counts.set(file, readLineCount(root, file));
return counts.get(file);
};
}
function checkLineRange(finding, index, lineCount, errors) {
if (!finding || typeof finding.file !== 'string' || !Number.isInteger(finding.line) || finding.line <= 0) return;
const n = lineCount(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.',
));
}
// The same check for a `path:line` (or `path:a-b`) a reviewer writes in prose:
// a finding's title/detail/recommended_action/brief_ref, and the text around
// the JSON block. code-correctness-reviewer wrote "`tests/contract.sh:119`
// relies on git" about a 33-line file, and nothing looked (voyage-lekerepo
// trial 2, 2026-10-08). In a finding's fields a line past the end of a file
// that exists is an error (the re-ask can fix the block); in the prose around
// the block it is a warning (see validateReviewerOutput). A path absent from
// the tree is not judged. The path needs an extension, so `localhost:8080` and
// `host.example:443` are no citation of anything in the tree; a path glued to
// a `\` or `:` (a Windows path) is not read as one relative to the root.
const PROSE_CITE = /(?<![\w./\\:-])((?:\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)*\.[A-Za-z0-9]+):(\d+)(?:[-–—](\d+))?(?![\w])/g;
const PROSE_FIELDS = Object.freeze(['title', 'detail', 'recommended_action', 'brief_ref']);
function checkProseCitations(text, where, lineCount, errors, seen) {
if (typeof text !== 'string' || text === '') return;
PROSE_CITE.lastIndex = 0;
let m;
while ((m = PROSE_CITE.exec(text)) !== null) {
const line = Math.max(Number(m[2]), m[3] === undefined ? 0 : Number(m[3]));
if (line <= 0) continue;
const n = lineCount(m[1]);
if (n === null || line <= n) continue;
const key = `${where}\u0000${m[0]}`;
if (seen.has(key)) continue;
seen.add(key);
errors.push(issue(
'FINDING_PROSE_LINE_OUT_OF_RANGE',
`${where} cites ${m[0]}, but ${m[1]} has ${n} lines`,
'Cite the line number in the file itself (Read it), not the line in the diff or patch. A line that is no ' +
'longer in the file is cited with its revision, `<rev>:<path>:<line>` (`v6.2.0:hooks/x.mjs:120`), which ' +
'is not judged against today\'s file.',
));
}
}
function checkFindingProse(finding, index, lineCount, errors, seen) {
if (!finding || typeof finding !== 'object') return;
for (const field of PROSE_FIELDS) {
checkProseCitations(finding[field], `findings[${index}].${field}`, lineCount, errors, seen);
}
}
/**
* @param {unknown} payload
* @param {{ repoRoot?: string }} [opts] - with a repo root, cited lines are checked against the files:
* each finding's `file:line`, and every `path:line` in its prose fields
*/
export function validateFindings(payload, opts = {}) {
return validatePayload(payload, opts.repoRoot ? lineCounter(opts.repoRoot) : null);
}
// One line counter for one validation: the JSON block and the prose around it share it.
function validatePayload(payload, lineCount) {
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 };
}
const seen = new Set();
for (let i = 0; i < payload.findings.length; i++) {
validateFinding(payload.findings[i], i, errors);
if (lineCount) {
checkLineRange(payload.findings[i], i, lineCount, errors);
checkFindingProse(payload.findings[i], i, lineCount, errors, seen);
}
}
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}`));
}
const lineCount = opts.repoRoot ? lineCounter(opts.repoRoot) : null;
const r = validatePayload(parsed, lineCount);
if (!lineCount) return r;
// The prose around the block (the text outside the LAST json fence; the
// block itself was checked field by field above) is a WARNING, not an error:
// the bounded re-ask asks for the JSON block only, which cannot correct that
// prose, and nothing downstream reads it — an error there could only stop a
// review over text no one uses (independent review, 2026-10-08).
const at = rawText.lastIndexOf(block);
const found = [];
checkProseCitations(rawText.slice(0, at) + rawText.slice(at + block.length), 'reviewer prose', lineCount, found, new Set());
return found.length === 0 ? r : { ...r, warnings: [...r.warnings, ...found] };
}
// ---- CLI shim ----------------------------------------------------------------
function isDir(p) {
try { return statSync(p).isDirectory(); } catch { return false; }
}
if (isMain(import.meta.url)) {
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);
// A missing value, a flag in its place, or a root that is not a directory
// would switch the line check off without a word: a usage error instead.
const badRoot = rootIdx >= 0 && (!repoRoot || repoRoot.startsWith('--') || !isDir(repoRoot));
if (!filePath || badRoot) {
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);
}