674 lines
34 KiB
JavaScript
674 lines
34 KiB
JavaScript
// lib/review/review-answers.mjs
|
|
// The operator's answers to a review's counting MAJOR findings (review_version
|
|
// 1.2, `## Answers`). A WARN opens the merge lock and the release check only
|
|
// when every counting MAJOR is answered; the verdict gate
|
|
// (lib/review/verdict-gate.mjs `decideOpenFindings`) is the one place that
|
|
// rule is decided, and it reads this module.
|
|
//
|
|
// A counting MAJOR is answered only when all of these hold:
|
|
// - its `## Answers` row says `accepted` with a reason that is not hollow
|
|
// (lib/util/approval-log.mjs `isHollowAnswer`);
|
|
// - it is not a security finding (a `security` catalogue key, or a finding
|
|
// from the `security-review` engine): those are never accepted;
|
|
// - the latest K2 record of kind `review` with subject
|
|
// `<review path relative to the git top level>#<id>` carries
|
|
// `answerSha(...)` over the review's CURRENT trailing JSON block and
|
|
// `scope_sha_end`, so an answer goes stale with the review it answers;
|
|
// - the row's Record is that record's hash.
|
|
// A `fixed` row never answers: the finding stays open as fixed-pending.
|
|
// A carried row (Carried from `review-round-<run>-<n>.md#<old id>`, written by
|
|
// `carry` from round 2) answers a carried MAJOR only when all of these hold:
|
|
// - the finding is carried (`carried_from`) and, like the old finding in
|
|
// the archive, no security finding; the reason is not hollow;
|
|
// - `<run>` is the first 12 hex of this review's `scope_sha_start`, and `<n>`
|
|
// is lower than this review's `round` (no `round`: no carried link), so an
|
|
// answer from another run, or from a later round, is never carried;
|
|
// - the archive is a regular file beside review.md (read through
|
|
// `ctx.archived`), of the same run, and holds the old finding, which
|
|
// matches this one by the carry rule (lib/review/review-rounds.mjs
|
|
// `sameFinding`);
|
|
// - the archive's own `## Answers` row for the old id is that answer as
|
|
// given there (`accepted`, Carried from `-`, the same reason and Record);
|
|
// - the link is this finding's own carry chain: with `<n>` = round - 1 the
|
|
// old id is the finding's `carried_from`; with an older `<n>` the run's
|
|
// archive of round - 1 (its `round` field that number) holds the finding
|
|
// `carried_from` names, whose own row has the same link, Record and
|
|
// reason and is itself bound by these rules (one answer never answers
|
|
// another occurrence of the same class);
|
|
// - the K2 record with that hash is the latest of kind `review` for
|
|
// `<review path>#<old id>`, and its sha256 is `answerSha` over the
|
|
// archive's trailing JSON block, its `scope_sha_end`, the old id and the
|
|
// reason.
|
|
// Otherwise the MAJOR is unanswered.
|
|
// BLOCKER findings are never answered (the gate stops on them before this).
|
|
//
|
|
// The answer log is the top-level one: `logPath(answerLogRepo(dir))`, whatever
|
|
// `--repo` or the review's directory names (lib/review/reviewed-commit.mjs
|
|
// accepts a commit that only appends `review` records to it, R6).
|
|
//
|
|
// A reason is always stored, hashed and compared normalized (`normalizeReason`),
|
|
// so no reason can break the `## Answers` table or open a fence before the
|
|
// trailing JSON block. The K2 log keeps its trust limits: it records that the
|
|
// answer step ran against this content; it does not sign who answered.
|
|
//
|
|
// CLI (2 is never green):
|
|
// node lib/review/review-answers.mjs pending --review <p> [--repo <d>] --json
|
|
// lists the open counting MAJORs; exit 0 = nothing to ask, 1 = a list,
|
|
// 2 = could not run
|
|
// node lib/review/review-answers.mjs record --review <p> --finding <id>
|
|
// --answer accepted|fixed --repo <d> --round <R> --store <git|local>
|
|
// --trailer "Co-Authored-By: <name> <<email>>" (reason on stdin)
|
|
// exit 0 = recorded (a failed commit is reported as `committed: false`
|
|
// with its reason), 1 = refused (bad trailer, empty or hollow reason, an id
|
|
// that is not a counting MAJOR, a BLOCKER, `accepted` on a security
|
|
// finding, `fixed` while no `### Step R<n>` under `## Remediation Steps`
|
|
// names the finding as its `Source finding:`), 2 = could not run
|
|
// node lib/review/review-answers.mjs carry --review <p> --previous <archive>
|
|
// --repo <d>
|
|
// after finalize, from round 2: for each carried MAJOR whose previous
|
|
// review holds a valid `accepted` row, writes its row with the link to the
|
|
// archive where it was given and that answer's record hash (no K2 record
|
|
// is appended, nothing is committed); exit 0 = done (`written`, `skipped`),
|
|
// 1 = refused (the previous review is not this run's earlier archive beside
|
|
// review.md), 2 = could not run
|
|
//
|
|
// The accept shape (an answer bound to the reviewed text, never to a BLOCKER)
|
|
// follows lib/validators/plan-gate.mjs `acceptOpen`.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { accessSync, constants as fsConstants, lstatSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
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, scopeShas } from './pre-existing.mjs';
|
|
import { archiveName, frontmatterField, runId, sameFinding } from './review-rounds.mjs';
|
|
import {
|
|
appendApproval, isHollowAnswer, logPath, normalizeSubject, readLog, verifyLog,
|
|
} from '../util/approval-log.mjs';
|
|
import { commitArtifacts, trailerProblem } from '../util/artifact-commit.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
export const ANSWER_VALUES = Object.freeze(['accepted', 'fixed']);
|
|
export const ANSWERS_HEADER = '| Finding | Answer | Reason | Record | Carried from |';
|
|
const ANSWERS_SEPARATOR = '|---|---|---|---|---|';
|
|
const MAX_REASON = 300;
|
|
const HEX40_RE = /^[0-9a-f]{40}$/;
|
|
// Copied from lib/validators/review-validator.mjs (ANSWER_FINDING_RE, SHA256_RE,
|
|
// CARRIED_FROM_RE; not exported there).
|
|
const ANSWER_FINDING_RE = /^([0-9a-f]{40})(?:#\d+)?$/;
|
|
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
const CARRIED_FROM_RE = /^review-round-[0-9a-f]{12}-\d+\.md#[0-9a-f]{40}$/;
|
|
const LINK_RE = /^(review-round-([0-9a-f]{12})-(\d+)\.md)#([0-9a-f]{40})$/;
|
|
const ARCHIVE_NAME_RE = /^review-round-([0-9a-f]{12})-(\d+)\.md$/;
|
|
|
|
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
const isMajor = (f) => String(f?.severity ?? '').toUpperCase() === 'MAJOR';
|
|
const isBlocker = (f) => String(f?.severity ?? '').toUpperCase() === 'BLOCKER';
|
|
|
|
/** The trailing fenced block when it is a closed `json` block, else null. */
|
|
function trailingBlock(text) {
|
|
const last = fencedBlocks(String(text ?? '')).at(-1);
|
|
return last && last.closed && isJsonBlock(last) ? last : null;
|
|
}
|
|
|
|
/**
|
|
* sha256 of the trailing JSON block's body (the one reader the gate uses,
|
|
* findings-schema `fencedBlocks`), or null when the last block is not a
|
|
* closed `json` block.
|
|
* @param {string} text
|
|
* @returns {string|null}
|
|
*/
|
|
export function findingsBlockSha(text) {
|
|
const last = trailingBlock(text);
|
|
return last ? sha256(last.body) : null;
|
|
}
|
|
|
|
/**
|
|
* The sha256 a K2 `review` record carries for one answer: the canonical JSON
|
|
* of {blockSha, scopeShaEnd, findingId, answer, reason}, in that key order.
|
|
* @returns {string}
|
|
*/
|
|
export function answerSha({ blockSha, scopeShaEnd, findingId, answer, reason }) {
|
|
return sha256(JSON.stringify({ blockSha, scopeShaEnd, findingId, answer, reason }));
|
|
}
|
|
|
|
/**
|
|
* A reason as it is stored, hashed and compared: backticks removed, `|` made
|
|
* `/`, every whitespace run (newlines included) one space, trimmed, cut to 300
|
|
* characters.
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeReason(text) {
|
|
return String(text ?? '').replace(/`/g, '').replace(/\|/g, '/').replace(/\s+/g, ' ').trim()
|
|
.slice(0, MAX_REASON).trim();
|
|
}
|
|
|
|
/** The lines of the `## Answers` section (heading excluded), with their indexes; null when absent. */
|
|
function answersSection(lines) {
|
|
const at = lines.findIndex((l) => /^##\s+Answers\s*$/.test(l));
|
|
if (at === -1) return null;
|
|
let end = at + 1;
|
|
while (end < lines.length && !/^##\s/.test(lines[end]) && !/^\s{0,3}(`{3,}|~{3,})/.test(lines[end])) end++;
|
|
return { at, end };
|
|
}
|
|
|
|
/**
|
|
* Parse the `## Answers` table (the review_version 1.2 grammar, read as
|
|
* lib/validators/review-validator.mjs reads it): a header of exactly five
|
|
* columns, a separator, then one row per finding. An `#n` suffix on the id is
|
|
* stripped; a second row for the same id is an error; a non-pipe line is ignored.
|
|
* @param {string} body
|
|
* @returns {{ rows: Array<{id, answer, reason, record, carriedFrom}>, errors: string[] }}
|
|
*/
|
|
export function parseAnswers(body) {
|
|
const lines = String(body ?? '').split('\n');
|
|
const rows = [];
|
|
const errors = [];
|
|
const sec = answersSection(lines);
|
|
if (sec === null) return { rows, errors };
|
|
const table = lines.slice(sec.at + 1, sec.end).map((l) => l.trim()).filter((l) => l.startsWith('|'));
|
|
if (table.length === 0) return { rows, errors };
|
|
if (table[0] !== ANSWERS_HEADER) {
|
|
errors.push(`## Answers header must be exactly "${ANSWERS_HEADER}", got ${JSON.stringify(table[0])}`);
|
|
return { rows, errors };
|
|
}
|
|
if (table.length < 2 || !/^\|(?:\s*:?-{3,}:?\s*\|){5}$/.test(table[1])) {
|
|
errors.push('## Answers table has no separator line of five columns under its header');
|
|
return { rows, errors };
|
|
}
|
|
const seen = new Set();
|
|
table.slice(2).forEach((line, i) => {
|
|
const where = `## Answers row ${i + 1}`;
|
|
if (!line.endsWith('|')) { errors.push(`${where} does not end with "|"`); return; }
|
|
const cells = line.slice(1, -1).split('|').map((c) => c.trim());
|
|
if (cells.length !== 5) { errors.push(`${where} has ${cells.length} cells, not 5`); return; }
|
|
const [finding, answer, reason, record, carriedFrom] = cells;
|
|
const m = finding.match(ANSWER_FINDING_RE);
|
|
if (!m) errors.push(`${where}: Finding ${JSON.stringify(finding)} is not a 40-char hex id (optionally #n)`);
|
|
if (!ANSWER_VALUES.includes(answer)) errors.push(`${where}: Answer must be accepted|fixed, got ${JSON.stringify(answer)}`);
|
|
if (answer === 'accepted' && reason === '') errors.push(`${where}: an accepted answer needs a non-empty Reason`);
|
|
if (!SHA256_RE.test(record)) errors.push(`${where}: Record ${JSON.stringify(record)} is not a 64-char hex record hash`);
|
|
if (carriedFrom !== '-' && !CARRIED_FROM_RE.test(carriedFrom)) {
|
|
errors.push(`${where}: Carried from must be "-" or review-round-<run12>-<n>.md#<id>, got ${JSON.stringify(carriedFrom)}`);
|
|
}
|
|
if (m) {
|
|
if (seen.has(m[1])) { errors.push(`${where}: a second row for finding ${m[1]} (at most one row per finding)`); return; }
|
|
seen.add(m[1]);
|
|
rows.push({ id: m[1], answer, reason, record, carriedFrom });
|
|
}
|
|
});
|
|
return { rows, errors };
|
|
}
|
|
|
|
/**
|
|
* A security finding: its catalogue category is `security`, or it came from
|
|
* the `security-review` engine. Never accepted, never left out as pre-existing.
|
|
*/
|
|
export function isSecurityFinding(f) {
|
|
return getRule(f?.rule_key)?.category === 'security' || f?.engine === 'security-review';
|
|
}
|
|
|
|
/**
|
|
* Every MAJOR finding (case-insensitive) that counts toward the lock. A finding
|
|
* is left out only when `preExisting(f)` is true and it is not a security finding.
|
|
* @param {{findings?: object[]}} payload
|
|
* @param {{ preExisting?: (f: object) => boolean }} [opts]
|
|
* @returns {object[]}
|
|
*/
|
|
export function countingMajors(payload, { preExisting = () => false } = {}) {
|
|
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
|
|
return findings.filter((f) => f && typeof f === 'object' && isMajor(f)
|
|
&& !(preExisting(f) === true && !isSecurityFinding(f)));
|
|
}
|
|
|
|
/** The parsed trailing JSON block, or null. */
|
|
function payloadOf(text) {
|
|
const last = trailingBlock(text);
|
|
if (!last) return null;
|
|
try {
|
|
const p = JSON.parse(last.body);
|
|
return p && typeof p === 'object' && !Array.isArray(p) ? p : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** The review body (frontmatter cut off), as the gate's reader sees it. */
|
|
function bodyOf(text) {
|
|
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(String(text ?? ''));
|
|
return m ? String(text).slice(m[0].length) : String(text ?? '');
|
|
}
|
|
|
|
/** Finding IDs named by `Source finding:` lines under `## Remediation Steps` step headings. */
|
|
// Copied from lib/validators/review-validator.mjs (remediationStepSources; not exported there).
|
|
function remediationStepSources(body) {
|
|
const start = body.search(/^##\s+Remediation Steps\b/m);
|
|
if (start === -1) return new Set();
|
|
const rest = body.slice(start).replace(/^##\s+Remediation Steps[^\n]*\n/, '');
|
|
const next = rest.search(/^##\s+/m);
|
|
const section = next === -1 ? rest : rest.slice(0, next);
|
|
const ids = new Set();
|
|
for (const step of section.split(/^###\s+Step\s+R\d+\b/m).slice(1)) {
|
|
const m = step.match(/Source finding:\**\s*`?([0-9a-f]{40})`?/);
|
|
if (m) ids.add(m[1]);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
/** The latest K2 record of kind `review` for `subject`, or null. */
|
|
function latestReviewRecord(records, subject) {
|
|
for (let i = records.length - 1; i >= 0; i--) {
|
|
const r = records[i];
|
|
if (r && r.kind === 'review' && r.subject === subject) return r;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The archived-review reader for `dir` (the review's folder): an archive name
|
|
* `review-round-<run>-<n>.md` gives its text when it is a regular file there,
|
|
* anything else gives null.
|
|
* @param {string} dir
|
|
* @returns {(name: string) => string|null}
|
|
*/
|
|
export function archivedReader(dir) {
|
|
return (name) => {
|
|
if (!dir || !ARCHIVE_NAME_RE.test(String(name))) return null;
|
|
const p = join(dir, name);
|
|
try {
|
|
if (!lstatSync(p).isFile()) return null;
|
|
return readFileSync(p, 'utf8');
|
|
} catch { return null; }
|
|
};
|
|
}
|
|
|
|
/** Is `row` the answer `answer` to finding `id`, bound by the latest K2 record to `text` as it is? */
|
|
function directBound(text, id, row, answer, { records, reviewSubject }) {
|
|
const blockSha = findingsBlockSha(text);
|
|
const scopeShaEnd = reviewedSha(text) || null;
|
|
if (!row || !blockSha || !scopeShaEnd || !reviewSubject) return false;
|
|
const reason = normalizeReason(row.reason);
|
|
const rec = latestReviewRecord(Array.isArray(records) ? records : [], `${reviewSubject}#${id}`);
|
|
return Boolean(rec) && rec.hash === row.record
|
|
&& rec.sha256 === answerSha({ blockSha, scopeShaEnd, findingId: id, answer, reason });
|
|
}
|
|
|
|
/** An accepted reason that is a reason: not empty, not hollow. */
|
|
const realReason = (row) => {
|
|
const reason = normalizeReason(row?.reason);
|
|
return reason !== '' && !isHollowAnswer(reason);
|
|
};
|
|
|
|
/**
|
|
* Is the carried row `row` of finding `f` in review `text` bound to an
|
|
* accepted answer in this run's earlier archive (the rule in the header)?
|
|
*/
|
|
function carriedBound(text, f, row, { records, reviewSubject, archived }) {
|
|
if (!row || row.answer !== 'accepted' || typeof archived !== 'function' || !reviewSubject) return false;
|
|
if (typeof f?.carried_from !== 'string' || isSecurityFinding(f) || !realReason(row)) return false;
|
|
const m = LINK_RE.exec(String(row.carriedFrom));
|
|
if (!m) return false;
|
|
const [, name, run, n, oldId] = m;
|
|
const { start } = scopeShas(text);
|
|
if (!start || run !== runId(start)) return false;
|
|
const round = frontmatterField(text, 'round');
|
|
if (!/^\d+$/.test(round ?? '') || Number(n) >= Number(round)) return false;
|
|
const old = archived(name);
|
|
if (typeof old !== 'string') return false;
|
|
const oldStart = scopeShas(old).start;
|
|
if (!oldStart || runId(oldStart) !== run) return false;
|
|
const oldF = (payloadOf(old)?.findings ?? []).find((x) => x && typeof x === 'object' && x.id === oldId);
|
|
if (!oldF || isSecurityFinding(oldF) || !sameFinding(oldF, f)) return false;
|
|
const oldRow = parseAnswers(bodyOf(old)).rows.find((r) => r.id === oldId);
|
|
if (!oldRow || oldRow.answer !== 'accepted' || oldRow.carriedFrom !== '-' || oldRow.record !== row.record
|
|
|| normalizeReason(oldRow.reason) !== normalizeReason(row.reason)) return false;
|
|
if (!directBound(old, oldId, oldRow, 'accepted', { records, reviewSubject })) return false;
|
|
// The link is bound to this finding's own carry chain, so an answer to one
|
|
// occurrence never answers another occurrence of the same class.
|
|
const before = Number(round) - 1;
|
|
if (Number(n) === before) return oldId === f.carried_from;
|
|
// An older answer reached this round through the previous one: the run's
|
|
// round-(round-1) archive holds the finding this one was carried from, with
|
|
// the same link, record and reason, itself bound the same way.
|
|
const prev = archived(archiveName(run, before));
|
|
if (typeof prev !== 'string' || frontmatterField(prev, 'round') !== String(before)) return false;
|
|
const prevF = (payloadOf(prev)?.findings ?? []).find((x) => x && typeof x === 'object' && x.id === f.carried_from);
|
|
const prevRow = parseAnswers(bodyOf(prev)).rows.find((r) => r.id === f.carried_from);
|
|
if (!prevF || !prevRow || prevRow.carriedFrom !== row.carriedFrom || prevRow.record !== row.record
|
|
|| normalizeReason(prevRow.reason) !== normalizeReason(row.reason)) return false;
|
|
return carriedBound(prev, prevF, prevRow, { records, reviewSubject, archived });
|
|
}
|
|
|
|
/**
|
|
* Decide whether every counting MAJOR of a review is answered. Pure: the K2
|
|
* records, the review's subject (its path relative to the git top level), the
|
|
* `preExisting` predicate (the gate's is lib/review/pre-existing.mjs
|
|
* `preExistingCheck`, re-derived from git; absent = nothing is pre-existing)
|
|
* and the `archived` reader for carried rows (`archivedReader`; absent = no
|
|
* carried row answers) 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?: (name: string) => string|null }} [ctx]
|
|
* A review with no readable trailing JSON block is `review-not-measured:`
|
|
* (not ok), never ok.
|
|
* @returns {{ok: true} | {ok: false, unanswered: string[], fixedPending: string[], reason: string}}
|
|
*/
|
|
export function decideAnswers(text, { records = [], reviewSubject, preExisting = () => false, archived } = {}) {
|
|
const payload = payloadOf(text);
|
|
// A review whose findings cannot be read is never answered (fail closed).
|
|
if (!payload) {
|
|
return { ok: false, unanswered: [], fixedPending: [], reason: 'review-not-measured: no readable trailing JSON block' };
|
|
}
|
|
const majors = countingMajors(payload, { preExisting });
|
|
if (majors.length === 0) return { ok: true };
|
|
const { rows } = parseAnswers(bodyOf(text));
|
|
const rowOf = new Map(rows.map((r) => [r.id, r]));
|
|
const unanswered = [];
|
|
const fixedPending = [];
|
|
const ctx = { records, reviewSubject, archived };
|
|
for (const f of majors) {
|
|
const row = rowOf.get(f.id);
|
|
if (row && row.carriedFrom !== '-') {
|
|
// A carried row answers only through its link.
|
|
if (carriedBound(text, f, row, ctx)) continue;
|
|
unanswered.push(f.id);
|
|
continue;
|
|
}
|
|
const bound = (answer) => directBound(text, f.id, row, answer, ctx);
|
|
if (row && row.answer === 'accepted' && !isSecurityFinding(f) && realReason(row) && bound('accepted')) continue;
|
|
if (row && row.answer === 'fixed' && bound('fixed')) { fixedPending.push(f.id); continue; }
|
|
unanswered.push(f.id);
|
|
}
|
|
if (unanswered.length === 0 && fixedPending.length === 0) return { ok: true };
|
|
const reason = unanswered.length === 0
|
|
? `review-unanswered: fixed-pending ${fixedPending.length}`
|
|
: `review-unanswered: ${unanswered.length} MAJOR without an answer`;
|
|
return { ok: false, unanswered, fixedPending, reason };
|
|
}
|
|
|
|
/**
|
|
* The repo whose top-level approval log holds the answers: the git top level
|
|
* of `dir` (real path), or null outside a work tree.
|
|
* @param {string} dir
|
|
* @returns {string|null}
|
|
*/
|
|
export function answerLogRepo(dir) {
|
|
if (!dir) return null;
|
|
const r = spawnSync('git', ['-C', resolve(dir), 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
if (r.status !== 0 || !r.stdout.trim()) return null;
|
|
try { return realpathSync(r.stdout.trim()); } catch { return null; }
|
|
}
|
|
|
|
/** A review's K2 subject path: relative to the top level when inside it. */
|
|
export function reviewSubject(top, reviewPath) {
|
|
return normalizeSubject(top, resolve(reviewPath));
|
|
}
|
|
|
|
/**
|
|
* The K2 records of the top-level log, or none (with why) when there is no
|
|
* work tree or the log is broken, diverged or unreadable.
|
|
* @returns {{ records: object[], problem: string|null, top: string|null }}
|
|
*/
|
|
export function answerRecords(dir) {
|
|
const top = answerLogRepo(dir);
|
|
if (!top) return { records: [], problem: 'no git work tree for the review', top: null };
|
|
const v = verifyLog({ repo: top });
|
|
if (v.exit !== 0) return { records: [], problem: `approval log does not verify (${v.status}: ${v.detail ?? ''})`, top };
|
|
const log = readLog(top);
|
|
if (log.error || log.broken) return { records: [], problem: 'approval log does not verify', top };
|
|
return { records: log.records, problem: null, top };
|
|
}
|
|
|
|
/** Write `text` to `p` through a temporary file in the same directory and a rename. */
|
|
function writeAtomic(p, text) {
|
|
const tmp = join(dirname(p), `.${basename(p)}.${process.pid}.tmp`);
|
|
writeFileSync(tmp, text);
|
|
renameSync(tmp, p);
|
|
}
|
|
|
|
/**
|
|
* The review text with `row` written into `## Answers` (replacing the row for
|
|
* the same finding id). An absent section is created just before the trailing
|
|
* JSON block, after `## Remediation Steps`. Returns null when the review has no
|
|
* trailing JSON block to place it before.
|
|
*/
|
|
export function withAnswerRow(text, id, row) {
|
|
const lines = String(text).split('\n');
|
|
const sec = answersSection(lines);
|
|
if (sec) {
|
|
const table = [];
|
|
for (let i = sec.at + 1; i < sec.end; i++) if (lines[i].trim().startsWith('|')) table.push(i);
|
|
if (table.length >= 2 && lines[table[0]].trim() === ANSWERS_HEADER) {
|
|
const same = table.slice(2).find((i) => {
|
|
const cell = lines[i].trim().slice(1).split('|')[0]?.trim() ?? '';
|
|
const m = cell.match(ANSWER_FINDING_RE);
|
|
return m && m[1] === id;
|
|
});
|
|
if (same !== undefined) lines[same] = row;
|
|
else lines.splice(table.at(-1) + 1, 0, row);
|
|
return lines.join('\n');
|
|
}
|
|
if (table.length === 0) {
|
|
lines.splice(sec.at + 1, 0, '', ANSWERS_HEADER, ANSWERS_SEPARATOR, row);
|
|
return lines.join('\n');
|
|
}
|
|
return null;
|
|
}
|
|
const last = trailingBlock(text);
|
|
if (!last || last.body === '') return null;
|
|
const bodyAt = String(text).lastIndexOf(last.body);
|
|
if (bodyAt <= 0) return null;
|
|
const fenceAt = String(text).lastIndexOf('\n', bodyAt - 2) + 1;
|
|
const before = String(text).slice(0, fenceAt).replace(/\n*$/, '\n\n');
|
|
return `${before}## Answers\n\n${ANSWERS_HEADER}\n${ANSWERS_SEPARATOR}\n${row}\n\n${String(text).slice(fenceAt)}`;
|
|
}
|
|
|
|
/**
|
|
* Open counting MAJORs of the review at `reviewPath`, read against the
|
|
* top-level log of `repo` (else the review's directory).
|
|
*/
|
|
export function pendingFindings({ reviewPath, repo }) {
|
|
let text;
|
|
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) {
|
|
return { exit: 2, reason: `cannot read ${reviewPath}: ${e.message}` };
|
|
}
|
|
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)));
|
|
// The gate's pre-existing reading (lib/review/verdict-gate.mjs `decideOpenFindings`), so the list asks what the gate counts.
|
|
// A finding with `carried_from` is never pre-existing there either.
|
|
const pre = preExistingCheck(text, repo ?? top ?? undefined);
|
|
const preExisting = (f) => !f.carried_from && pre(f);
|
|
const d = decideAnswers(text, {
|
|
records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, preExisting,
|
|
archived: archivedReader(dirname(resolve(reviewPath))),
|
|
});
|
|
if (d.ok) return { exit: 0, pending: [] };
|
|
const open = new Set([...d.unanswered, ...d.fixedPending]);
|
|
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),
|
|
detail: String(f.detail || f.recommended_action || ''),
|
|
}));
|
|
return { exit: 1, pending, reason: d.reason, ...(problem ? { log: problem } : {}) };
|
|
}
|
|
|
|
/**
|
|
* Record one answer: refuse, or append the K2 record, write the row and commit
|
|
* the top-level log.
|
|
*/
|
|
export function recordAnswer({ reviewPath, finding, answer, repo, round, store, trailer, reason: rawReason }) {
|
|
const refuse = (reason) => ({ exit: 1, recorded: false, reason });
|
|
const cannot = (reason) => ({ exit: 2, recorded: false, reason });
|
|
if (!reviewPath) return cannot('--review is required');
|
|
if (!repo) return cannot('--repo is required');
|
|
if (!ANSWER_VALUES.includes(answer)) return cannot(`--answer must be one of ${ANSWER_VALUES.join('|')}`);
|
|
if (!/^[1-9]\d*$/.test(String(round ?? ''))) return cannot('--round must be a positive integer');
|
|
if (!['git', 'local'].includes(store)) return cannot('--store must be one of git|local');
|
|
const bad = trailerProblem(trailer);
|
|
if (bad) return refuse(bad);
|
|
const id = String(finding ?? '').trim();
|
|
if (!HEX40_RE.test(id)) return refuse(`--finding ${JSON.stringify(finding ?? '')} is not a 40-char hex finding id`);
|
|
const reason = normalizeReason(rawReason);
|
|
if (reason === '') return refuse('the reason is empty: an answer needs the operator\'s reason in plain words');
|
|
if (isHollowAnswer(reason)) return refuse(`the reason "${reason}" is a hollow answer, not a reason`);
|
|
|
|
let text;
|
|
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) { return cannot(`cannot read ${reviewPath}: ${e.message}`); }
|
|
const payload = payloadOf(text);
|
|
if (!payload || !Array.isArray(payload.findings)) return cannot(`${reviewPath} has no readable trailing JSON block`);
|
|
const f = payload.findings.find((x) => x && x.id === id);
|
|
if (f && isBlocker(f)) return refuse(`${id} is a BLOCKER: a BLOCKER is never answered, it is fixed`);
|
|
if (!f || !countingMajors(payload).some((x) => x.id === id)) return refuse(`${id} is not a counting MAJOR of this review`);
|
|
if (answer === 'accepted' && isSecurityFinding(f)) {
|
|
return refuse(`${id} is a security finding: it is never accepted, only fixed`);
|
|
}
|
|
if (answer === 'fixed' && !remediationStepSources(bodyOf(text)).has(id)) {
|
|
return refuse(`${id} has no "### Step R<n>" with "Source finding: ${id}" under ## Remediation Steps: write its R-step first`);
|
|
}
|
|
const blockSha = findingsBlockSha(text);
|
|
const scopeShaEnd = reviewedSha(text);
|
|
if (!blockSha) return cannot(`${reviewPath}: the last fenced block is not a closed json block`);
|
|
if (!scopeShaEnd) return cannot(`${reviewPath} has no scope_sha_end that is a commit sha`);
|
|
|
|
const top = answerLogRepo(repo);
|
|
if (!top) return cannot(`${repo} is not inside a git work tree: no approval log to write`);
|
|
const subject = `${reviewSubject(top, reviewPath)}#${id}`;
|
|
// Place the row first (a placeholder hash), so nothing is appended to the
|
|
// log for an answer whose row cannot be written.
|
|
const rowWith = (hash) => `| ${id} | ${answer} | ${reason} | ${hash} | - |`;
|
|
if (withAnswerRow(text, id, rowWith('0'.repeat(64))) === null) {
|
|
return cannot(`${reviewPath}: the ## Answers table could not be placed (a malformed ## Answers section or no trailing JSON block); nothing appended`);
|
|
}
|
|
// The atomic write needs to create a file in the review's directory.
|
|
try { accessSync(dirname(resolve(reviewPath)), fsConstants.W_OK); } catch (e) {
|
|
return cannot(`cannot write ${reviewPath}: ${e.message}; nothing appended`);
|
|
}
|
|
const app = appendApproval({
|
|
repo: top, kind: 'review', subject, sha: answerSha({ blockSha, scopeShaEnd, findingId: id, answer, reason }),
|
|
});
|
|
if (!app.appended) return { exit: app.exit, recorded: false, reason: app.reason };
|
|
|
|
const next = withAnswerRow(text, id, rowWith(app.record.hash));
|
|
const res = {
|
|
recorded: true, finding: id, answer, reason, record: app.record.hash, subject, log: logPath(top),
|
|
};
|
|
if (next === null) {
|
|
return { ...res, exit: 2, review_written: false, committed: false, commit_reason: 'the ## Answers table could not be placed; the K2 record is appended' };
|
|
}
|
|
try { writeAtomic(reviewPath, next); } catch (e) {
|
|
return { ...res, exit: 2, review_written: false, committed: false, commit_reason: `cannot write ${reviewPath}: ${e.message}` };
|
|
}
|
|
const c = commitArtifacts({ repo: top, store, message: `docs(voyage): review answers (round ${round})`, trailer });
|
|
return {
|
|
...res, exit: 0, review_written: true, committed: c.committed === true,
|
|
...(c.commit ? { commit: c.commit } : {}),
|
|
...(c.committed ? {} : { commit_reason: c.reason ?? 'not committed' }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* From round 2, after finalize: carry the previous review's valid `accepted`
|
|
* answers to the carried MAJORs of this review (see the header). Writes rows
|
|
* only; appends no K2 record and commits nothing.
|
|
*/
|
|
export function carryAnswers({ reviewPath, previousPath, repo }) {
|
|
const refuse = (reason) => ({ exit: 1, written: [], reason });
|
|
const cannot = (reason) => ({ exit: 2, written: [], reason });
|
|
if (!reviewPath) return cannot('--review is required');
|
|
if (!previousPath) return cannot('--previous is required');
|
|
if (!repo) return cannot('--repo is required');
|
|
let text;
|
|
try { text = readFileSync(reviewPath, 'utf8'); } catch (e) { return cannot(`cannot read ${reviewPath}: ${e.message}`); }
|
|
const dir = dirname(resolve(reviewPath));
|
|
const name = basename(previousPath);
|
|
const m = ARCHIVE_NAME_RE.exec(name);
|
|
if (!m || dirname(resolve(previousPath)) !== dir) {
|
|
return refuse(`--previous ${previousPath} is not an archive review-round-<run>-<n>.md beside ${reviewPath}`);
|
|
}
|
|
const { start } = scopeShas(text);
|
|
const round = frontmatterField(text, 'round');
|
|
if (!start || m[1] !== runId(start)) return refuse(`--previous ${name} is not an archive of this review's run`);
|
|
if (!/^\d+$/.test(round ?? '') || Number(m[2]) >= Number(round)) {
|
|
return refuse(`--previous ${name} is not an earlier round than this review's (round: ${round ?? 'none'})`);
|
|
}
|
|
const archived = archivedReader(dir);
|
|
const prevText = archived(name);
|
|
if (typeof prevText !== 'string') return cannot(`cannot read --previous ${previousPath} as a regular file`);
|
|
const payload = payloadOf(text);
|
|
const prevPayload = payloadOf(prevText);
|
|
if (!payload || !Array.isArray(payload.findings)) return cannot(`${reviewPath} has no readable trailing JSON block`);
|
|
if (!prevPayload || !Array.isArray(prevPayload.findings)) return cannot(`${previousPath} has no readable trailing JSON block`);
|
|
const { records, top, problem } = answerRecords(repo);
|
|
const ctx = { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined, archived };
|
|
const prevRows = new Map(parseAnswers(bodyOf(prevText)).rows.map((r) => [r.id, r]));
|
|
const written = [];
|
|
const skipped = [];
|
|
let next = text;
|
|
for (const f of payload.findings) {
|
|
if (!f || typeof f !== 'object' || !isMajor(f) || typeof f.carried_from !== 'string') continue;
|
|
const row = prevRows.get(f.carried_from);
|
|
if (!row || row.answer !== 'accepted') continue;
|
|
const oldF = prevPayload.findings.find((x) => x && typeof x === 'object' && x.id === row.id);
|
|
let link = null;
|
|
if (row.carriedFrom === '-') {
|
|
// Given in the previous review itself.
|
|
const ok = oldF && !isSecurityFinding(oldF) && !isSecurityFinding(f) && realReason(row)
|
|
&& sameFinding(oldF, f) && directBound(prevText, row.id, row, 'accepted', ctx);
|
|
if (ok) link = `${name}#${row.id}`;
|
|
} else if (oldF && carriedBound(prevText, oldF, row, ctx)) {
|
|
// Carried into the previous review: the link to where it was given
|
|
// holds when that finding also matches this one.
|
|
const [, archive, , , givenId] = LINK_RE.exec(row.carriedFrom);
|
|
const given = (payloadOf(archived(archive))?.findings ?? []).find((x) => x && x.id === givenId);
|
|
if (given && sameFinding(given, f)) link = row.carriedFrom;
|
|
}
|
|
if (link === null) { skipped.push(f.id); continue; }
|
|
const placed = withAnswerRow(next, f.id, `| ${f.id} | accepted | ${normalizeReason(row.reason)} | ${row.record} | ${link} |`);
|
|
if (placed === null) return cannot(`${reviewPath}: the ## Answers table could not be placed; nothing written`);
|
|
next = placed;
|
|
written.push(f.id);
|
|
}
|
|
if (written.length > 0) {
|
|
try { writeAtomic(reviewPath, next); } catch (e) { return cannot(`cannot write ${reviewPath}: ${e.message}`); }
|
|
}
|
|
return { exit: 0, written, skipped, ...(problem ? { log: problem } : {}) };
|
|
}
|
|
|
|
// ---- CLI ----
|
|
|
|
function opt(args, name) {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const [cmd, ...args] = process.argv.slice(2);
|
|
let result;
|
|
try {
|
|
if (cmd === 'pending') {
|
|
const reviewPath = opt(args, '--review');
|
|
result = reviewPath ? pendingFindings({ reviewPath, repo: opt(args, '--repo') }) : { exit: 2, reason: '--review is required' };
|
|
} else if (cmd === 'record') {
|
|
let reason = '';
|
|
try { reason = readFileSync(0, 'utf8'); } catch { reason = ''; }
|
|
result = recordAnswer({
|
|
reviewPath: opt(args, '--review'), finding: opt(args, '--finding'), answer: opt(args, '--answer'),
|
|
repo: opt(args, '--repo'), round: opt(args, '--round'), store: opt(args, '--store'),
|
|
trailer: opt(args, '--trailer'), reason,
|
|
});
|
|
} else if (cmd === 'carry') {
|
|
result = carryAnswers({ reviewPath: opt(args, '--review'), previousPath: opt(args, '--previous'), repo: opt(args, '--repo') });
|
|
} else {
|
|
process.stderr.write('Usage: review-answers.mjs pending|record|carry — see the header of lib/review/review-answers.mjs\n');
|
|
process.exit(2);
|
|
}
|
|
} catch (e) {
|
|
result = { exit: 2, reason: `review-answers crashed: ${e.message}` };
|
|
}
|
|
const { exit, ...rest } = result;
|
|
process.stdout.write(JSON.stringify(rest) + '\n');
|
|
process.exit(exit);
|
|
}
|