feat(review): pre-existing findings marked from git and re-derived by the gate

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 16:37:24 +01:00
commit 1464605e35
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 1015 additions and 5 deletions

318
lib/review/pre-existing.mjs Normal file
View file

@ -0,0 +1,318 @@
// lib/review/pre-existing.mjs
// Pre-existing findings (review_version 1.2, fix round 4.5b2 part B): a
// finding on a line the review range did not add or change. The mark is read
// from git and never trusted as written: `lib/review/review-finalize.mjs`
// writes it (`markFindings`), and the verdict gate re-derives it
// (`preExistingCheck`) before a MAJOR leaves the answer set. A BLOCKER or a
// security finding counts in full whatever the mark (lib/review/review-answers.mjs
// `countingMajors`); every uncertain case counts as new.
//
// The line sets come from one `git diff -U0` of `scope_sha_start..scope_sha_end`
// with flags that no repo config changes (`--no-color`, `--no-ext-diff`,
// `--no-textconv`, `--no-relative`, `--find-renames`, explicit prefixes,
// `--ignore-submodules=none`, `core.quotePath=false`):
// - added or changed lines are `c..c+d-1` of each `@@ -a,b +c,d @@`;
// - a pure deletion (`d = 0`) marks lines `c` and `c+1` (both sides of it);
// - a new or binary file is `'all'`;
// - a renamed or copied file goes into `renamed` under both names.
// Any git failure, ENOBUFS, a range whose start is not a proper ancestor of its
// end, or a line the reader cannot parse (a quoted path included) is
// `{ok:false}`: then nothing is pre-existing.
//
// A finding's path and line must also be in the tree at `scope_sha_end`: the
// path, byte for byte, names a regular file there (mode 100644 or 100755, read
// once with `git ls-tree -r -z --full-tree`), and the line lies inside that
// file's line count. A path the diff does not name is otherwise taken as
// untouched; this check keeps a case variant (`A.mjs` on a case-insensitive
// disk), a subdirectory-relative path, a missing file or a line past the end of
// the file from passing as pre-existing. Any git failure here also means
// nothing is pre-existing.
//
// Known limit: `scope_sha_start` is read from review.md and is not bound to
// anything the gate checks (`scope_sha_end` is, by `checkReviewedCommit` in
// lib/review/reviewed-commit.mjs). A forged narrower start, for example a
// later commit of the same run, shrinks the range, so a line the run added can
// look pre-existing for a MAJOR finding. BLOCKER and security findings are
// never affected: they count in full whatever the mark.
//
// No CLI of its own.
import { spawnSync } from 'node:child_process';
import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs';
const MAX_BUFFER = 64 * 1024 * 1024;
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
const REGULAR_MODES = new Set(['100644', '100755']);
const TREE_ENTRY_RE = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/;
const HEADER_LINE_RE = /^(?:old mode \d+|new mode \d+|deleted file mode \d+|new file mode \d+|index [0-9a-f]+\.\.[0-9a-f]+(?: \d+)?|similarity index \d+%|dissimilarity index \d+%)$/;
// The argv git helper of lib/review/reviewed-commit.mjs:64-66 (copied, not exported there).
function git(repo, args) {
return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: MAX_BUFFER });
}
// The `notMeasured` shape of lib/review/reviewed-commit.mjs:72-75 (copied).
function notMeasured(reason) {
return { ok: false, reason: `pre-existing not measured: ${reason}` };
}
function gitFailed(what, r) {
const note = r.error?.code === 'ENOBUFS' ? 'output past the 64 MiB buffer'
: (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]);
return notMeasured(`git ${what} failed (${note || `exit ${r.status}`})`);
}
/** A frontmatter sha field as written (the `reviewedSha` reading, for any key): a full sha, else ''. */
function frontmatterSha(text, key) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text ?? ''));
const re = new RegExp(`^${key}:[ \\t]*(["']?)([^"'\\s#]*)\\1[ \\t]*(?:#.*)?$`, 'm');
const m = fm && re.exec(fm[1]);
return m && FULL_SHA_RE.test(m[2]) ? m[2] : '';
}
/**
* The review's range, read raw from the frontmatter text as `reviewedSha` reads
* `scope_sha_end`: a YAML number, `null` or a ref name is '' (no range, no mark).
* @param {string} text - review.md
* @returns {{ start: string, end: string }}
*/
export function scopeShas(text) {
return { start: frontmatterSha(text, 'scope_sha_start'), end: reviewedSha(text) };
}
/** A path as the diff prints it after its prefix; null when it is quoted (not read). */
function unprefixed(path, prefix) {
const p = path.replace(/\t$/, '');
if (p.startsWith('"') || !p.startsWith(prefix) || p.length === prefix.length) return null;
return p.slice(prefix.length);
}
/** The one path of a `diff --git a/P b/P` header (both halves equal), else null. */
function headerPath(rest) {
if ((rest.length - 5) % 2 !== 0) return null;
const n = (rest.length - 5) / 2;
const p = rest.slice(2, 2 + n);
return rest === `a/${p} b/${p}` && !p.startsWith('"') ? p : null;
}
/**
* Read a `git diff -U0` text into line sets.
* @param {string} diff
* @returns {{ok: true, files: Map<string, Set<number>|'all'>, renamed: Set<string>} | {ok: false, reason: string}}
*/
export function parseDiff(diff) {
const files = new Map();
const renamed = new Set();
let cur = null;
let minus = 0;
let plus = 0;
const bad = (line) => notMeasured(`cannot read diff line ${JSON.stringify(line.slice(0, 120))}`);
const finish = () => {
if (!cur) return null;
const path = cur.to ?? cur.plusPath ?? cur.header;
if (cur.from) renamed.add(cur.from);
if (cur.to) renamed.add(cur.to);
if (cur.deleted) return null;
if (!path) return 'no path';
if (cur.isNew || cur.binary) { files.set(path, 'all'); return null; }
const prev = files.get(path);
if (prev === 'all') return null;
const set = prev ?? new Set();
for (const n of cur.lines) set.add(n);
files.set(path, set);
return null;
};
const lines = String(diff).split('\n');
if (lines.at(-1) === '') lines.pop();
for (const line of lines) {
if (minus > 0 || plus > 0) {
if (line.startsWith('-') && minus > 0) { minus--; continue; }
if (line.startsWith('+') && plus > 0) { plus--; continue; }
if (line.startsWith('\\')) continue;
return bad(line);
}
if (line.startsWith('\\')) continue;
if (line.startsWith('diff --git ')) {
if (finish()) return bad(line);
cur = { header: headerPath(line.slice('diff --git '.length)), lines: [] };
continue;
}
if (!cur) return bad(line);
let m;
if (HEADER_LINE_RE.test(line)) {
if (line.startsWith('new file mode')) cur.isNew = true;
if (line.startsWith('deleted file mode')) cur.deleted = true;
} else if ((m = /^(rename|copy) (from|to) (.+)$/.exec(line))) {
if (m[3].startsWith('"')) return bad(line);
cur[m[2]] = m[3];
} else if (line.startsWith('--- ')) {
const p = line.slice(4);
if (p !== '/dev/null' && unprefixed(p, 'a/') === null) return bad(line);
} else if (line.startsWith('+++ ')) {
const p = line.slice(4);
if (p === '/dev/null') cur.deleted = true;
else {
const q = unprefixed(p, 'b/');
if (q === null) return bad(line);
cur.plusPath = q;
}
} else if (/^Binary files .* differ$/.test(line)) {
cur.binary = true;
} else if ((m = HUNK_RE.exec(line))) {
const b = m[2] === undefined ? 1 : Number(m[2]);
const c = Number(m[3]);
const d = m[4] === undefined ? 1 : Number(m[4]);
if (d === 0) cur.lines.push(c, c + 1);
else for (let n = c; n < c + d; n++) cur.lines.push(n);
minus = b;
plus = d;
} else {
return bad(line);
}
}
if (minus > 0 || plus > 0) return notMeasured('the diff ends inside a hunk');
if (finish()) return notMeasured('a file in the diff has no path the reader can name');
return { ok: true, files, renamed };
}
/**
* The regular files of the tree at `end`, by exact repo-root path: path -> blob id.
* @returns {{ok: true, blobs: Map<string, string>} | {ok: false, reason: string}}
*/
function regularFilesAt(repo, end) {
const r = git(repo, ['ls-tree', '-r', '-z', '--full-tree', end]);
if (r.error || r.status !== 0) return gitFailed('ls-tree', r);
const blobs = new Map();
for (const entry of r.stdout.split('\0')) {
if (entry === '') continue;
const tab = entry.indexOf('\t');
const m = tab === -1 ? null : TREE_ENTRY_RE.exec(entry.slice(0, tab));
if (!m) return notMeasured(`cannot read ls-tree entry ${JSON.stringify(entry.slice(0, 120))}`);
if (m[2] === 'blob' && REGULAR_MODES.has(m[1])) blobs.set(entry.slice(tab + 1), m[3]);
}
return { ok: true, blobs };
}
/** The line count of one blob (a last line without a newline counts); null when git fails. */
function blobLineCount(repo, blob) {
const r = spawnSync('git', ['cat-file', 'blob', blob], { cwd: repo, maxBuffer: MAX_BUFFER });
if (r.error || r.status !== 0 || !Buffer.isBuffer(r.stdout)) return null;
const b = r.stdout;
let n = 0;
for (let i = b.indexOf(10); i !== -1; i = b.indexOf(10, i + 1)) n++;
if (b.length > 0 && b[b.length - 1] !== 10) n++;
return n;
}
/**
* The lines `start..end` added or changed, per file (repo-root paths), plus
* `lineCount(path)`: the line count of a regular file at `end` under exactly
* that path, or null when there is none (or git fails). Full commit ids only;
* `start` must be a proper ancestor of `end`.
* @param {string} repo
* @param {string} start
* @param {string} end
* @returns {{ok: true, files: Map<string, Set<number>|'all'>, renamed: Set<string>, lineCount: (path: string) => number|null} | {ok: false, reason: string}}
*/
export function changedLineSets(repo, start, end) {
if (!repo) return notMeasured('no repo');
if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(end ?? ''))) {
return notMeasured('scope_sha_start and scope_sha_end must be full commit ids');
}
if (start.toLowerCase() === end.toLowerCase()) return notMeasured('an empty range (start = end) proves nothing');
const anc = git(repo, ['merge-base', '--is-ancestor', start, end]);
if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc);
if (anc.status === 1) return notMeasured(`${start.slice(0, 12)} is not an ancestor of ${end.slice(0, 12)}`);
const r = git(repo, ['-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv',
'--no-relative', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', '--ignore-submodules=none', start, end]);
if (r.error || r.status !== 0) return gitFailed('diff', r);
const sets = parseDiff(r.stdout);
if (!sets.ok) return sets;
const tree = regularFilesAt(repo, end);
if (!tree.ok) return tree;
const counts = new Map();
sets.lineCount = (path) => {
if (typeof path !== 'string' || !tree.blobs.has(path)) return null;
if (!counts.has(path)) counts.set(path, blobLineCount(repo, tree.blobs.get(path)));
return counts.get(path);
};
return sets;
}
/** A repo-relative posix path as the diff names it: no `./`, `..`, empty segment, leading `/` or backslash. */
function plainPath(p) {
return typeof p === 'string' && p !== '' && !p.includes('\\')
&& p.split('/').every((s) => s !== '' && s !== '.' && s !== '..');
}
/**
* True only when the sets are ok, `f.line >= 1`, the file is not renamed, its
* entry is not `'all'`, `f.line` is not in its set, and `f.file` is a regular
* file at `end` under exactly that path with at least `f.line` lines
* (`sets.lineCount`; sets without it are never pre-existing).
* @param {{file?: string, line?: number}} f
* @param {ReturnType<typeof changedLineSets>} sets
* @returns {boolean}
*/
export function isPreExisting(f, sets) {
if (!sets || sets.ok !== true) return false;
if (!f || !Number.isInteger(f.line) || f.line < 1 || !plainPath(f.file)) return false;
if (sets.renamed.has(f.file)) return false;
const entry = sets.files.get(f.file);
if (entry === 'all') return false;
if (entry && entry.has(f.line)) return false;
if (typeof sets.lineCount !== 'function') return false;
const n = sets.lineCount(f.file);
return Number.isInteger(n) && f.line <= n;
}
/** True when `file` has uncommitted changes, or git cannot say (the stricter side). */
function uncommitted(repo, file) {
const r = git(repo, ['status', '--porcelain=v1', '-z', '--', `:(top,literal)${file}`]);
return Boolean(r.error) || r.status !== 0 || r.stdout !== '';
}
/**
* Set `pre_existing` (a boolean) on every JSON finding of `payload`, in place.
* With no repo or no full `start`/`end`, every mark is false; a file with
* uncommitted changes is false.
* @param {{findings?: object[]}} payload
* @param {{ repo?: string, start?: string, end?: string }} range
* @returns {object} payload
*/
export function markFindings(payload, { repo, start, end } = {}) {
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
const sets = repo && start && end ? changedLineSets(repo, start, end) : notMeasured('no range');
const dirty = new Map();
for (const f of findings) {
if (!f || typeof f !== 'object') continue;
let pre = isPreExisting(f, sets);
if (pre) {
if (!dirty.has(f.file)) dirty.set(f.file, uncommitted(repo, f.file));
pre = !dirty.get(f.file);
}
f.pre_existing = pre;
}
return payload;
}
/**
* The gate's predicate for a review: true only when the finding says
* `pre_existing: true` and `changedLineSets(repo, scope_sha_start,
* scope_sha_end)` agrees through `isPreExisting`. With no repo, or no full
* `scope_sha_start`, it is always false. The sets are read once, when a marked
* finding is first asked about.
* @param {string} text - review.md
* @param {string|undefined} repo
* @returns {(f: object) => boolean}
*/
export function preExistingCheck(text, repo) {
const { start, end } = scopeShas(text);
if (!repo || !start || !end) return () => false;
let sets = null;
return (f) => {
if (f?.pre_existing !== true) return false;
if (sets === null) sets = changedLineSets(repo, start, end);
return isPreExisting(f, sets);
};
}

View file

@ -49,6 +49,7 @@ import { basename, dirname, join, resolve } from 'node:path';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { getRule } from './rule-catalogue.mjs';
import { reviewedSha } from './reviewed-commit.mjs';
import { preExistingCheck } from './pre-existing.mjs';
import {
appendApproval, isHollowAnswer, logPath, normalizeSubject, readLog, verifyLog,
} from '../util/approval-log.mjs';
@ -214,7 +215,9 @@ function latestReviewRecord(records, subject) {
/**
* Decide whether every counting MAJOR of a review is answered. Pure: the K2
* records and the review's subject (its path relative to the git top level)
* records, the review's subject (its path relative to the git top level) and
* the `preExisting` predicate (the gate's is lib/review/pre-existing.mjs
* `preExistingCheck`, re-derived from git; absent = nothing is pre-existing)
* come in `ctx`; with no subject or no records nothing is answered.
* @param {string} text - review.md
* @param {{ records?: object[], reviewSubject?: string, preExisting?: (f) => boolean, archived?: unknown }} [ctx]
@ -348,10 +351,12 @@ export function pendingFindings({ reviewPath, repo }) {
const payload = payloadOf(text);
if (!payload) return { exit: 2, reason: `${reviewPath} has no readable trailing JSON block` };
const { records, top, problem } = answerRecords(repo ?? dirname(resolve(reviewPath)));
const d = decideAnswers(text, { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined });
// The gate's pre-existing reading (lib/review/verdict-gate.mjs `decideOpenFindings`), so the list asks what the gate counts.
const preExisting = preExistingCheck(text, repo ?? top ?? undefined);
const d = decideAnswers(text, { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, preExisting });
if (d.ok) return { exit: 0, pending: [] };
const open = new Set([...d.unanswered, ...d.fixedPending]);
const pending = countingMajors(payload).filter((f) => open.has(f.id)).map((f) => ({
const pending = countingMajors(payload, { preExisting }).filter((f) => open.has(f.id)).map((f) => ({
id: f.id, title: f.title ?? '', file: f.file ?? '', line: f.line ?? null,
security: isSecurityFinding(f),
fixed_pending: d.fixedPending.includes(f.id),

View file

@ -0,0 +1,226 @@
// lib/review/review-finalize.mjs
// 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:
//
// - pre-existing: `markFindings` (lib/review/pre-existing.mjs) sets
// `pre_existing` on every JSON finding from git over
// `scope_sha_start..scope_sha_end` (read raw, as `reviewedSha` reads; a
// YAML number or `null` means no mark), writes `pre_existing_counts`, and
// writes a `## Pre-existing` list (the count, then one line per title)
// just before `## Declined to Judge`. It never moves or lowers a finding.
//
// Then, always last, the id recomputation: every id is computed again with
// `computeFindingIds` (lib/parsers/finding-id.mjs) over the final array, and
// any id that changed is rewritten with one simultaneous old -> new map,
// applied in one pass so no rewrite chains, in four places only: each JSON
// finding's `id`, the frontmatter `findings:` list, the id line of each body
// entry (its `### <id>` heading or `**ID:**` line), and each
// `**Source finding:**` line. The file is written atomically (temp + rename).
//
// CLI:
// node lib/review/review-finalize.mjs --review <p> --repo <d> --project <dir>
// --round <R> [--round-base <sha>] [--previous <p>]
// prints one JSON line; exit 0 = written, 1 = refused (a placeholder left,
// an id that cannot be computed, two findings under one id), 2 = could not
// run (bad arguments, unreadable file, no trailing JSON block). On 1 or 2
// the review is left as fill-finding-ids wrote it.
import { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { computeFindingIds } from '../parsers/finding-id.mjs';
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
import { FULL_SHA_RE } from './reviewed-commit.mjs';
import { markFindings, scopeShas } from './pre-existing.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_ROUND = 3;
const SEVERITIES = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'];
const HEX40 = /^[0-9a-f]{40}$/;
const HEX40_G = /[0-9a-f]{40}/g;
/** The review split at the trailing closed `json` block: {head, fence, body, tail}, or null. */
function splitTrailing(text) {
const last = fencedBlocks(text).at(-1);
if (!last || !last.closed || !isJsonBlock(last) || last.body === '') return null;
const bodyAt = text.lastIndexOf(last.body);
if (bodyAt <= 0) return null;
const fenceAt = text.lastIndexOf('\n', bodyAt - 2) + 1;
return {
head: text.slice(0, fenceAt),
fence: text.slice(fenceAt, bodyAt),
body: last.body,
tail: text.slice(bodyAt + last.body.length),
};
}
/** One line of prose: whitespace runs made one space, backticks removed. */
const oneLine = (s) => String(s ?? '').replace(/`/g, '').replace(/\s+/g, ' ').trim();
/** The `## Pre-existing` section for the marked findings. */
function preExistingSection(findings, counts) {
const marked = findings.filter((f) => f && f.pre_existing === true);
if (marked.length === 0) return '## Pre-existing\n\nNone.\n\n';
const rows = SEVERITIES.filter((s) => counts[s] > 0).map((s) => `| ${s} | ${counts[s]} |`);
const items = marked.map((f) => `- ${oneLine(f.title) || '(no title)'} (\`${oneLine(f.file)}:${f.line}\`)`);
return `## Pre-existing\n\nFindings on lines this range did not add or change: ${marked.length}.\n\n`
+ `| Severity | Count |\n|----------|-------|\n${rows.join('\n')}\n\n${items.join('\n')}\n\n`;
}
/** `head` with every `## Pre-existing` section taken out and `section` put just before `## Declined to Judge` (else at the end). */
function withPreExistingSection(head, section) {
const lines = head.split('\n');
const kept = [];
for (let i = 0; i < lines.length; i++) {
if (/^##\s+Pre-existing\s*$/.test(lines[i])) {
i++;
while (i < lines.length && !/^##\s/.test(lines[i])) i++;
i--;
continue;
}
kept.push(lines[i]);
}
const at = kept.findIndex((l) => /^##\s+Declined to Judge\b/.test(l));
const sectionLines = section.replace(/\n$/, '').split('\n');
if (at === -1) {
const out = kept.join('\n').replace(/\n*$/, '\n\n');
return `${out}${section}`;
}
kept.splice(at, 0, ...sectionLines);
return kept.join('\n');
}
/** Pass `pre-existing`. */
function passPreExisting(payload, { repo, text }) {
const { start, end } = scopeShas(text);
markFindings(payload, { repo, start, end });
const counts = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
for (const f of payload.findings) {
const sev = String(f?.severity ?? '').toUpperCase();
if (f && f.pre_existing === true && sev in counts) counts[sev] += 1;
}
payload.pre_existing_counts = counts;
return { headEdit: (head) => withPreExistingSection(head, preExistingSection(payload.findings, counts)), counts };
}
/** The passes, in the Architecture order; the id recomputation runs after them, always last. */
export const PASSES = Object.freeze([
{ name: 'pre-existing', run: passPreExisting },
]);
/** Rewrite the 40-hex tokens of one line through `map` (one pass: no chaining). */
const remap = (line, map) => line.replace(HEX40_G, (m) => map.get(m) ?? m);
/** `head` with ids rewritten in the frontmatter list, body id lines and Source finding lines. */
function rewriteIds(head, map) {
if (map.size === 0) return head;
const lines = head.split('\n');
let fmEnd = -1;
if (lines[0] === '---') fmEnd = lines.indexOf('---', 1);
let inList = false;
return lines.map((l, i) => {
if (i > 0 && i < fmEnd) {
if (/^findings\s*:/.test(l)) { inList = true; return l; }
if (inList && /^\s+-\s/.test(l)) return remap(l, map);
if (!/^\s/.test(l)) inList = false;
return l;
}
if (i <= fmEnd) return l;
if (/^###\s+[0-9a-f]{40}\b/.test(l) || /\*\*ID:\*\*/.test(l) || /\*\*Source finding:\*\*/.test(l)) return remap(l, map);
return l;
}).join('\n');
}
/**
* Finalize review.md text. Pure apart from the git reads of the passes.
* @param {string} text
* @param {{ repo: string }} ctx
* @returns {{exit: 0, text: string, passes: string[], pre_existing_counts: object, ids_rewritten: number} | {exit: 1|2, reason: string}}
*/
export function finalizeReview(text, { repo } = {}) {
if (/@@ID_\d+@@/.test(text)) return { exit: 1, reason: 'an @@ID_<n>@@ placeholder is left: run fill-finding-ids.mjs first' };
const parts = splitTrailing(text);
if (!parts) return { exit: 2, reason: 'no trailing closed json block' };
let payload;
try { payload = JSON.parse(parts.body); } catch (e) { return { exit: 2, reason: `the trailing JSON block does not parse: ${e.message}` }; }
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray(payload.findings)) {
return { exit: 2, reason: 'the trailing JSON block has no findings array' };
}
if (payload.findings.some((f) => !f || typeof f !== 'object')) return { exit: 1, reason: 'a finding is not an object' };
const before = payload.findings.map((f) => f.id);
const dup = before.find((id, i) => before.indexOf(id) !== i);
if (dup !== undefined) return { exit: 1, reason: `two findings carry the id ${JSON.stringify(dup)}` };
let head = parts.head;
const out = { passes: [] };
for (const pass of PASSES) {
const r = pass.run(payload, { repo, text });
if (r.headEdit) head = r.headEdit(head);
if (r.counts) out.pre_existing_counts = r.counts;
out.passes.push(pass.name);
}
// The id recomputation, always last.
const errors = [];
const ids = computeFindingIds(payload.findings, (i, e) => errors.push(`findings[${i}]: ${e.message}`));
if (errors.length > 0) return { exit: 1, reason: `cannot compute an id: ${errors.join('; ')}` };
const map = new Map();
payload.findings.forEach((f, i) => {
if (f.id !== ids[i] && typeof f.id === 'string' && HEX40.test(f.id)) map.set(f.id, ids[i]);
f.id = ids[i];
});
head = rewriteIds(head, map);
out.passes.push('ids');
const body = `${JSON.stringify(payload, null, 2)}\n`;
return { exit: 0, text: `${head}${parts.fence}${body}${parts.tail}`, ...out, ids_rewritten: map.size };
}
function writeAtomic(p, text) {
const tmp = join(dirname(p), `.${basename(p)}.${process.pid}.tmp`);
writeFileSync(tmp, text);
renameSync(tmp, p);
}
/** The CLI's work: check the arguments, read, finalize, write. */
export function finalizeFile({ review, repo, project, round, roundBase }) {
if (!review) return { exit: 2, reason: '--review is required' };
if (!repo) return { exit: 2, reason: '--repo is required' };
if (!project) return { exit: 2, reason: '--project is required' };
if (!/^[1-9]\d*$/.test(String(round ?? '')) || Number(round) > MAX_ROUND) {
return { exit: 2, reason: `--round must be an integer 1-${MAX_ROUND}` };
}
if (roundBase !== undefined && !FULL_SHA_RE.test(roundBase)) return { exit: 2, reason: '--round-base must be a full commit id' };
try {
if (!statSync(project).isDirectory()) return { exit: 2, reason: `--project ${project} is not a directory` };
} catch (e) { return { exit: 2, reason: `--project ${project}: ${e.message}` }; }
let text;
try { text = readFileSync(review, 'utf8'); } catch (e) { return { exit: 2, reason: `cannot read ${review}: ${e.message}` }; }
const r = finalizeReview(text, { repo });
if (r.exit !== 0) return r;
try { writeAtomic(review, r.text); } catch (e) { return { exit: 2, reason: `cannot write ${review}: ${e.message}` }; }
const { text: _t, ...rest } = r;
return { ...rest, written: true, path: review };
}
function opt(args, name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
let result;
try {
result = finalizeFile({
review: opt(args, '--review'), repo: opt(args, '--repo'), project: opt(args, '--project'),
round: opt(args, '--round'), roundBase: opt(args, '--round-base'),
});
} catch (e) {
result = { exit: 2, reason: `review-finalize crashed: ${e.message}` };
}
const { exit, ...rest } = result;
process.stdout.write(`${JSON.stringify(exit === 0 ? rest : { written: false, ...rest })}\n`);
process.exit(exit);
}

View file

@ -59,6 +59,7 @@ import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
import { isMain } from '../util/is-main.mjs';
import { resolveRepo } from '../util/approval-log.mjs';
import { answerRecords, decideAnswers, reviewSubject } from './review-answers.mjs';
import { preExistingCheck } from './pre-existing.mjs';
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
@ -189,6 +190,11 @@ function blockerSectionEntries(body) {
* answered (lib/review/review-answers.mjs `decideAnswers`). K2 records come
* from the top-level approval log of `repo`, else of the review's directory;
* a broken, diverged or unreadable log, or no work tree, means no records.
* A MAJOR leaves the answer set as pre-existing only when it is marked
* `pre_existing: true` and git agrees over `scope_sha_start..scope_sha_end`
* in `repo` (lib/review/pre-existing.mjs `preExistingCheck`); with no repo, or
* no full `scope_sha_start`, no finding is pre-existing. A BLOCKER or a
* security finding counts whatever the mark.
* @param {string} text - review.md
* @param {{ repo?: string, reviewPath: string }} opts
* @returns {{ exit: 0|2, reason: string, unanswered: string[], fixed_pending: string[] }}
@ -196,7 +202,7 @@ function blockerSectionEntries(body) {
export function decideOpenFindings(text, { repo, reviewPath } = {}) {
const { records, top, problem } = answerRecords(repo ?? (reviewPath ? dirname(reviewPath) : undefined));
const subject = top && reviewPath ? reviewSubject(top, reviewPath) : undefined;
const d = decideAnswers(text, { records, reviewSubject: subject });
const d = decideAnswers(text, { records, reviewSubject: subject, preExisting: preExistingCheck(text, repo) });
if (d.ok) return { exit: EXIT.PASS, reason: 'every counting MAJOR is answered', unanswered: [], fixed_pending: [] };
return {
exit: EXIT.UNDECIDED,