feat(review): the code-correctness reviewer checks the repo's tracked instruction files

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 20:47:15 +01:00
commit fcba232ccd
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 807 additions and 17 deletions

View file

@ -2,8 +2,7 @@
// One deterministic pass over review.md after the coordinator and after
// lib/review/fill-finding-ids.mjs, on both paths (review_version 1.2, fix
// round 4.5b2 part B). Its passes run in the Architecture order (engine,
// instruction, carry, pre-existing, policy); later steps insert theirs at
// their places in PASSES. Today it runs engine, carry, pre-existing and policy:
// instruction, carry, pre-existing, policy), each at its place in PASSES:
//
// - engine, always the first pass: `putBack` (lib/review/engine-compare.mjs)
// compares the engine payloads Phase 4.7 saved in the project folder
@ -18,6 +17,22 @@
// the frontmatter list, counts, a verdict that is only raised, and, as a
// BLOCKER, its `### Step R<n>`. An engine finding in `out_of_scope` is
// not compared (it stays declined, `engine:out-of-scope`).
// - instruction, right after engine: a finding under `CLAUDE_MD_DEVIATION`
// or `CLAUDE_MD_STALE` whose `instruction_ref` is missing or names a file
// outside the repo's tracked instruction set (lib/review/instruction-scope.mjs
// `instructionSet` at `scope_sha_end`, for the paths changed over
// `scope_sha_start..scope_sha_end`) leaves the JSON findings, the
// frontmatter list, `counts` and its `### ` entry under
// `## Findings (MINOR|SUGGESTION)`, and goes to `declined_to_judge` with
// `instruction-out-of-set` and its `instruction_ref` (the cited file, or
// null); `## Declined to Judge` gets one line for it, by title, naming
// the cited file. A git failure or no range is an empty set, so every
// such finding is declined. Only a MINOR or SUGGESTION that is not a
// security finding moves; one whose entry is not found exactly once
// there, that an R-step names or that finalize added stays reported
// (stricter). The same breach reported by an engine and a reviewer
// (same triplet) was already merged by the coordinator's dedup, which
// keeps the higher severity.
// - carry (round 2 and later, `--previous <archive>`): `carry`
// (lib/review/review-rounds.mjs) keeps every previous finding that this
// round gives no positive evidence of being resolved, with `carried_from`;
@ -90,6 +105,8 @@ import { computeFindingIds } from '../parsers/finding-id.mjs';
import { fencedBlocks, isJsonBlock, validateReviewerOutput } from './findings-schema.mjs';
import { FULL_SHA_RE } from './reviewed-commit.mjs';
import { markFindings, scopeShas } from './pre-existing.mjs';
import { changedPaths, instructionSet } from './instruction-scope.mjs';
import { isSecurityFinding } from './review-answers.mjs';
import { ENGINES } from './engine-findings.mjs';
import { carry, frontmatterField, MAX_REVIEW_ROUNDS, runId } from './review-rounds.mjs';
import { applyPolicy, readPolicy } from './review-policy.mjs';
@ -220,6 +237,94 @@ function passEngine(payload, ctx) {
return { headEdit: (head) => withVerdict(head, r.raisedVerdict) };
}
// ---- pass: instruction -------------------------------------------------------------
const INSTRUCTION_KEYS = new Set(['CLAUDE_MD_DEVIATION', 'CLAUDE_MD_STALE']);
// One of coordinator-contract `NOTED_REASONS`: the gate accepts it on a MINOR or SUGGESTION only.
const OUT_OF_SET = 'instruction-out-of-set';
const INSTRUCTION_MOVABLE = new Set(['MINOR', 'SUGGESTION']);
/**
* The repo's tracked instruction files at `scope_sha_end` for the paths
* changed over the review's range (`changedPaths`, the list of Phase 5's
* `git diff --name-only`, deleted paths included); an empty set when either
* cannot be read.
*/
function instructionFilesOf(repo, text) {
const { start, end } = scopeShas(text);
const changed = changedPaths(repo, start, end);
if (!changed.ok) return { measured: false, files: new Set() };
const r = instructionSet(repo, end, changed.paths);
return r.ok ? { measured: true, files: new Set(r.files) } : { measured: false, files: new Set() };
}
/** One `## Declined to Judge` line for a finding the instruction filter moved. */
function instructionLine(d) {
const cited = typeof d.instruction_ref === 'string' && oneLine(d.instruction_ref) !== ''
? oneLine(d.instruction_ref) : 'no instruction file';
return `- ${oneLine(d.file) || '(no file)'}:${d.line ?? '?'} ${oneLine(d.rule_key) || '(no rule_key)'} — ${OUT_OF_SET}, cites ${cited} (${oneLine(d.title) || '(no title)'})`;
}
/**
* Pass `instruction`, right after engine: a CLAUDE.md-key finding that cites
* no file of the instruction set goes to Declined to Judge.
*/
function passInstruction(payload, ctx) {
const keyed = payload.findings.filter((f) => INSTRUCTION_KEYS.has(f.rule_key));
if (keyed.length === 0) return {};
if (payload.declined_to_judge !== undefined && !Array.isArray(payload.declined_to_judge)) {
ctx.instruction = { measured: false, moved: 0 };
return {};
}
const set = instructionFilesOf(ctx.repo, ctx.text);
const lines = ctx.head.split('\n');
const fmEnd = frontmatterEnd(lines);
const named = new Set(remediationSteps(lines).steps.map((st) => st.source).filter(Boolean));
const take = keyed.filter((f) => (typeof f.instruction_ref !== 'string' || !set.files.has(f.instruction_ref))
&& INSTRUCTION_MOVABLE.has(f.severity) && !isSecurityFinding(f)
&& typeof f.id === 'string' && HEX40.test(f.id) && !named.has(f.id) && !ctx.added.has(f)
&& movableEntry(lines, fmEnd, f.id) !== null);
ctx.instruction = { measured: set.measured, moved: take.length };
if (take.length === 0) return {};
const declined = take.map((f) => ({
file: f.file ?? null,
line: f.line ?? null,
rule_key: f.rule_key ?? null,
severity: f.severity ?? null,
title: f.title ?? null,
...(f.engine !== undefined ? { engine: f.engine } : {}),
instruction_ref: typeof f.instruction_ref === 'string' ? f.instruction_ref : null,
reason: OUT_OF_SET,
}));
const takeSet = new Set(take);
payload.findings = payload.findings.filter((f) => !takeSet.has(f));
payload.declined_to_judge = [...(payload.declined_to_judge ?? []), ...declined];
ctx.recount = true;
const ids = take.map((f) => f.id);
return {
headEdit: (head) => {
let out = head.split('\n');
const fe = frontmatterEnd(out);
for (const id of ids) {
const e = movableEntry(out, fe, id);
if (e) out.splice(e.at, e.end - e.at);
}
out = emptySectionsSayNone(out);
const at = out.findIndex((l) => DECLINED_RE.test(l));
if (at !== -1) {
for (let i = sectionEnd(out, at) - 1; i > at; i--) {
if (out[i].trim() !== 'None.') continue;
out.splice(i, 1);
if (out[i] === '' && out[i - 1] === '') out.splice(i, 1);
}
}
out = appendToSection(out, DECLINED_RE, '## Declined to Judge', declined.map(instructionLine),
[/^##\s+Remediation Summary\b/, /^##\s+Remediation Steps\b/, /^##\s+Answers\s*$/]);
return out.join('\n');
},
};
}
// ---- pass: carry -------------------------------------------------------------------
const LATER_SECTIONS = [/^##\s+Suppressed Findings\b/, /^##\s+Pre-existing\s*$/, /^##\s+Resolved since round\b/,
@ -425,6 +530,7 @@ function passPolicy(payload, ctx) {
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
export const PASSES = Object.freeze([
{ name: 'engine', run: passEngine },
{ name: 'instruction', run: passInstruction },
{ name: 'carry', run: passCarry },
{ name: 'pre-existing', run: passPreExisting },
{ name: 'policy', run: passPolicy },
@ -669,7 +775,7 @@ export function finalizeReview(text, { repo, round, roundBase = null, previous =
return {
exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size,
carried: ctx.added.size, resolved: payload.resolved ?? [], ran: payload.ran, r_steps_added: steps.added,
policy: ctx.policy ?? null, put_back: ctx.engine?.put_back ?? 0,
policy: ctx.policy ?? null, put_back: ctx.engine?.put_back ?? 0, instruction: ctx.instruction ?? null,
};
}