feat(review): a WARN opens the lock only when every counting MAJOR is answered
This commit is contained in:
parent
ce64851a0d
commit
ce7dbc27a9
8 changed files with 1044 additions and 64 deletions
466
lib/review/review-answers.mjs
Normal file
466
lib/review/review-answers.mjs
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
// 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.
|
||||
// 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), 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, 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 {
|
||||
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 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 ?? '');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* 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]
|
||||
* 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 } = {}) {
|
||||
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 blockSha = findingsBlockSha(text);
|
||||
const scopeShaEnd = reviewedSha(text) || null;
|
||||
const { rows } = parseAnswers(bodyOf(text));
|
||||
const rowOf = new Map(rows.map((r) => [r.id, r]));
|
||||
const unanswered = [];
|
||||
const fixedPending = [];
|
||||
for (const f of majors) {
|
||||
const row = rowOf.get(f.id);
|
||||
const bound = (answer) => {
|
||||
if (!row || !blockSha || !scopeShaEnd || !reviewSubject) return false;
|
||||
const reason = normalizeReason(row.reason);
|
||||
const rec = latestReviewRecord(Array.isArray(records) ? records : [], `${reviewSubject}#${f.id}`);
|
||||
return Boolean(rec) && rec.hash === row.record
|
||||
&& rec.sha256 === answerSha({ blockSha, scopeShaEnd, findingId: f.id, answer, reason });
|
||||
};
|
||||
if (row && row.answer === 'accepted' && !isSecurityFinding(f)) {
|
||||
const reason = normalizeReason(row.reason);
|
||||
if (reason !== '' && !isHollowAnswer(reason) && 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)));
|
||||
const d = decideAnswers(text, { records, reviewSubject: top ? reviewSubject(top, reviewPath) : undefined });
|
||||
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) => ({
|
||||
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`);
|
||||
}
|
||||
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' }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
process.stderr.write('Usage: review-answers.mjs pending|record — 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);
|
||||
}
|
||||
|
|
@ -23,9 +23,20 @@
|
|||
// `--repo <dir>`, a check that could not look (reason
|
||||
// `review-not-measured: …`: the path is missing, it is no git
|
||||
// repository, or a git call failed; M4) — printed as it is, never
|
||||
// as `review-stale`.
|
||||
// as `review-stale`, or an ALLOW/WARN review with a counting MAJOR
|
||||
// that is not answered (reason `review-unanswered: <n> MAJOR without
|
||||
// an answer`, or `review-unanswered: fixed-pending <n>` when every
|
||||
// open one is answered `fixed`; `decideOpenFindings`, read against
|
||||
// the top-level approval log of `--repo`, else of the review file's
|
||||
// git work tree, else no records). The `--json` output carries
|
||||
// `unanswered` and `fixed_pending` id arrays.
|
||||
// Exit 2 is NEVER green.
|
||||
//
|
||||
// The pure `decideVerdict` stays text-only: it never reads the answers. The
|
||||
// two callers that add the open-findings check are `decideVerdictFile` (this
|
||||
// CLI) and scripts/release-check.mjs `checkReview`. The check never keys on
|
||||
// the verdict label: an ALLOW over a MAJOR is counted as a WARN is.
|
||||
//
|
||||
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
||||
// that is GREENER than the file's own findings (ALLOW/WARN over a BLOCKER, or a
|
||||
// frontmatter verdict the trailing JSON block disagrees with) is exit 2 as well
|
||||
|
|
@ -39,11 +50,15 @@
|
|||
// own design — neither source blocks on its own.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname } from 'node:path';
|
||||
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
|
||||
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
||||
import { illegalBriefLowering } from './coordinator-contract.mjs';
|
||||
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';
|
||||
|
||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||
|
||||
|
|
@ -169,9 +184,38 @@ function blockerSectionEntries(body) {
|
|||
return (section.match(/^###\s+\S/gm) ?? []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-findings check: every counting MAJOR of an ALLOW/WARN review must be
|
||||
* 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.
|
||||
* @param {string} text - review.md
|
||||
* @param {{ repo?: string, reviewPath: string }} opts
|
||||
* @returns {{ exit: 0|2, reason: string, unanswered: string[], fixed_pending: string[] }}
|
||||
*/
|
||||
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 });
|
||||
if (d.ok) return { exit: EXIT.PASS, reason: 'every counting MAJOR is answered', unanswered: [], fixed_pending: [] };
|
||||
return {
|
||||
exit: EXIT.UNDECIDED,
|
||||
reason: problem ? `${d.reason} (${problem})` : d.reason,
|
||||
unanswered: d.unanswered,
|
||||
fixed_pending: d.fixedPending,
|
||||
};
|
||||
}
|
||||
|
||||
function insideWorkTree(dir) {
|
||||
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
|
||||
return r.status === 0 && r.stdout.trim() === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide on a review.md path. With `repo`, an ALLOW/WARN review must also be
|
||||
* of the commit being merged there (R6); BLOCK and exit 2 are returned as they are.
|
||||
* of the commit being merged there (R6); BLOCK and exit 2 are returned as they
|
||||
* are. Then every counting MAJOR must be answered (`decideOpenFindings`),
|
||||
* read against `repo`, else the review's git work tree, else no records.
|
||||
* @param {string} filePath
|
||||
* @param {{ repo?: string }} [opts]
|
||||
*/
|
||||
|
|
@ -184,12 +228,20 @@ export function decideVerdictFile(filePath, { repo } = {}) {
|
|||
return undecided(`cannot read ${filePath}: ${e.message}`);
|
||||
}
|
||||
const decided = decideVerdict(text);
|
||||
if (repo === undefined || decided.exit !== EXIT.PASS) return decided;
|
||||
const sha = reviewedSha(text);
|
||||
if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict);
|
||||
const commit = checkReviewedCommit({ repo, sha });
|
||||
if (!commit.ok) return undecided(commit.reason, decided.verdict);
|
||||
return decided;
|
||||
if (decided.exit !== EXIT.PASS) return decided;
|
||||
if (repo !== undefined) {
|
||||
const sha = reviewedSha(text);
|
||||
if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict);
|
||||
const commit = checkReviewedCommit({ repo, sha });
|
||||
if (!commit.ok) return undecided(commit.reason, decided.verdict);
|
||||
}
|
||||
const here = dirname(filePath);
|
||||
const openRepo = repo !== undefined ? repo : (insideWorkTree(here) ? resolveRepo(undefined, here) : undefined);
|
||||
const open = decideOpenFindings(text, { repo: openRepo, reviewPath: filePath });
|
||||
if (open.exit !== EXIT.PASS) {
|
||||
return { ...undecided(open.reason, decided.verdict), unanswered: open.unanswered, fixed_pending: open.fixed_pending };
|
||||
}
|
||||
return { ...decided, unanswered: [], fixed_pending: [] };
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
|
|
|
|||
|
|
@ -241,19 +241,38 @@ const HOLLOW = [
|
|||
'ok', 'okay', 'sure', 'fine', 'kjør på', 'kjor pa',
|
||||
];
|
||||
|
||||
/** @returns {{ approved: boolean, reason: 'explicit'|'empty'|'hollow'|'not-explicit' }} */
|
||||
// AskUserQuestion's own guidance puts «(Recommended)» after the label it
|
||||
// recommends, so the operator's real «Approve (Recommended)» was refused in the
|
||||
// voyage-lekerepo trial (2026-10-06). The suffix is stripped; what is left must
|
||||
// still be an explicit approval on its own.
|
||||
const RECOMMENDED_SUFFIX = /\s*\((?:recommended|anbefalt)\)\s*$/u;
|
||||
|
||||
export function isExplicitApproval(answer) {
|
||||
const t = String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '')
|
||||
function normalizeAnswer(answer) {
|
||||
return String(answer ?? '').trim().toLowerCase().replace(/[\s.!]+$/u, '')
|
||||
.replace(RECOMMENDED_SUFFIX, '').replace(/[\s.!]+$/u, '').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `text` is a hollow answer («sounds good», «whatever you think», …):
|
||||
* the HOLLOW list, read the way isExplicitApproval reads it. The empty answer
|
||||
* is not hollow here; a caller that refuses it says so on its own.
|
||||
* @param {string} text
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isHollowAnswer(text) {
|
||||
return hollowNormalized(normalizeAnswer(text));
|
||||
}
|
||||
|
||||
function hollowNormalized(t) {
|
||||
return t !== '' && HOLLOW.some((h) => t === h || (h.includes(' ') && t.includes(h)));
|
||||
}
|
||||
|
||||
/** @returns {{ approved: boolean, reason: 'explicit'|'empty'|'hollow'|'not-explicit' }} */
|
||||
export function isExplicitApproval(answer) {
|
||||
const t = normalizeAnswer(answer);
|
||||
if (!t) return { approved: false, reason: 'empty' };
|
||||
if (EXPLICIT.has(t)) return { approved: true, reason: 'explicit' };
|
||||
if (HOLLOW.some((h) => t === h || (h.includes(' ') && t.includes(h)))) return { approved: false, reason: 'hollow' };
|
||||
if (hollowNormalized(t)) return { approved: false, reason: 'hollow' };
|
||||
return { approved: false, reason: 'not-explicit' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const DEFAULT_MESSAGE = 'docs(voyage): record approval';
|
|||
const TRAILER_RE = /^Co-Authored-By: [^\s<>{}](?:[^<>{}\n]*[^\s<>{}])? <[^\s<>@]+@[^\s<>]+>$/;
|
||||
|
||||
/** @returns {string|null} why `trailer` is refused, or null when it is valid */
|
||||
function trailerProblem(trailer) {
|
||||
export function trailerProblem(trailer) {
|
||||
if (typeof trailer !== 'string' || trailer === '') {
|
||||
return '--trailer "Co-Authored-By: <name> <<email>>" is required — nothing committed';
|
||||
}
|
||||
|
|
|
|||
55
lib/util/doors.mjs
Normal file
55
lib/util/doors.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// lib/util/doors.mjs
|
||||
// The `### Doors` list of a CHANGELOG release section: every delivery says
|
||||
// whether it is a one-way door (cannot be walked back without a cost to users)
|
||||
// or a two-way door (can). Moved here from scripts/release-check.mjs, which
|
||||
// re-exports these names, because lib never imports scripts and the review
|
||||
// evidence writer reads the door too. Door types are credited to
|
||||
// mattpocock/skills@c55ee46 (idea only, no text copied).
|
||||
|
||||
export const DOOR_LINE_RE = /^- (one-way|two-way): \S/;
|
||||
|
||||
/**
|
||||
* The CHANGELOG section for `version`, heading line excluded, or null. Matches
|
||||
* `## v1.2.3 …`, `## [1.2.3] …` and `## 1.2.3 …` exactly: 1.1.0 is not 1.10.0
|
||||
* and not 1.1.0-pre.
|
||||
*/
|
||||
export function releaseSection(text, version) {
|
||||
if (typeof text !== 'string' || !version) return null;
|
||||
const lines = text.split('\n');
|
||||
const headingVersion = (line) => {
|
||||
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^##\s/.test(lines[i]) && headingVersion(lines[i]) === version) { start = i + 1; break; }
|
||||
}
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (/^##\s/.test(lines[i])) { end = i; break; }
|
||||
}
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `### Doors` list of a release section. Top-level `- ` lines must read
|
||||
* `- one-way: …` or `- two-way: …`; indented lines continue the entry above.
|
||||
* @returns {{ found: boolean, entries: Array<{door: string, text: string}>, bad: string[] }}
|
||||
*/
|
||||
export function parseDoors(section) {
|
||||
const lines = String(section ?? '').split('\n');
|
||||
const start = lines.findIndex((l) => /^###\s+Doors\s*$/.test(l));
|
||||
if (start === -1) return { found: false, entries: [], bad: [] };
|
||||
const entries = [];
|
||||
const bad = [];
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^#{2,3}\s/.test(line)) break;
|
||||
if (!line.startsWith('- ')) continue;
|
||||
const m = line.match(DOOR_LINE_RE);
|
||||
if (m) entries.push({ door: m[1], text: line.slice(m[0].length - 1).trim() });
|
||||
else bad.push(line);
|
||||
}
|
||||
return { found: true, entries, bad };
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@
|
|||
// other than HEAD
|
||||
// suite `npm test` exits 0 at HEAD (run for real, never read from a log)
|
||||
// review lib/review/verdict-gate.mjs on the release's review.md: BLOCK
|
||||
// stops (1), undecided stops (2)
|
||||
// stops (1), undecided stops (2); an ALLOW/WARN with a counting
|
||||
// MAJOR not answered in the top-level approval log
|
||||
// (`decideOpenFindings`, `review-unanswered:`) is not ready (1)
|
||||
// coverage the review is of THIS history: its `scope_sha_end` is HEAD or an
|
||||
// ancestor of HEAD, and the commits after it touch only the release
|
||||
// bookkeeping (the CHANGELOG, the version in the version files,
|
||||
|
|
@ -39,11 +41,12 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
|
||||
import { decideVerdict, decideOpenFindings } from '../lib/review/verdict-gate.mjs';
|
||||
import { reviewedSha } from '../lib/review/reviewed-commit.mjs';
|
||||
import { commandEnv } from '../lib/util/command-env.mjs';
|
||||
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
import { releaseSection, parseDoors } from '../lib/util/doors.mjs';
|
||||
|
||||
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
|
|
@ -76,55 +79,12 @@ export function withoutVersion(text) {
|
|||
}
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
const DOOR_LINE_RE = /^- (one-way|two-way): \S/;
|
||||
|
||||
const git = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
|
||||
/**
|
||||
* The CHANGELOG section for `version`, heading line excluded, or null. Matches
|
||||
* `## v1.2.3 …`, `## [1.2.3] …` and `## 1.2.3 …` exactly: 1.1.0 is not 1.10.0
|
||||
* and not 1.1.0-pre.
|
||||
*/
|
||||
export function releaseSection(text, version) {
|
||||
if (typeof text !== 'string' || !version) return null;
|
||||
const lines = text.split('\n');
|
||||
const headingVersion = (line) => {
|
||||
const m = line.match(/^##\s+\[?v?([^\]\s(]+)\]?/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^##\s/.test(lines[i]) && headingVersion(lines[i]) === version) { start = i + 1; break; }
|
||||
}
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (/^##\s/.test(lines[i])) { end = i; break; }
|
||||
}
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `### Doors` list of a release section. Top-level `- ` lines must read
|
||||
* `- one-way: …` or `- two-way: …`; indented lines continue the entry above.
|
||||
* @returns {{ found: boolean, entries: Array<{door: string, text: string}>, bad: string[] }}
|
||||
*/
|
||||
export function parseDoors(section) {
|
||||
const lines = String(section ?? '').split('\n');
|
||||
const start = lines.findIndex((l) => /^###\s+Doors\s*$/.test(l));
|
||||
if (start === -1) return { found: false, entries: [], bad: [] };
|
||||
const entries = [];
|
||||
const bad = [];
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^#{2,3}\s/.test(line)) break;
|
||||
if (!line.startsWith('- ')) continue;
|
||||
const m = line.match(DOOR_LINE_RE);
|
||||
if (m) entries.push({ door: m[1], text: line.slice(m[0].length - 1).trim() });
|
||||
else bad.push(line);
|
||||
}
|
||||
return { found: true, entries, bad };
|
||||
}
|
||||
// The CHANGELOG door helpers live in lib/util/doors.mjs (lib never imports
|
||||
// scripts); re-exported here under the same names for the callers of this module.
|
||||
export { DOOR_LINE_RE, releaseSection, parseDoors } from '../lib/util/doors.mjs';
|
||||
|
||||
// The +/- lines of a unified diff, read inside its hunks only: a content line
|
||||
// that starts with "++ " or "-- " looks like a file header but is not one.
|
||||
|
|
@ -201,7 +161,7 @@ function reviewPath(root, review) {
|
|||
return { path: null, error: `--review ${review} names two files: ${inRepo} (in --repo) and ${fromHere} (from here); pass an absolute path` };
|
||||
}
|
||||
|
||||
function checkReview(reviewPath) {
|
||||
function checkReview(reviewPath, repo) {
|
||||
if (!reviewPath) return { check: { id: 'review', status: 'error', detail: 'no --review given — the release has no review verdict to read (K7)' }, text: null };
|
||||
if (!existsSync(reviewPath)) return { check: { id: 'review', status: 'error', detail: `${reviewPath} does not exist — no review, no release` }, text: null };
|
||||
let text;
|
||||
|
|
@ -211,6 +171,14 @@ function checkReview(reviewPath) {
|
|||
const v = decideVerdict(text);
|
||||
if (v.exit === 2) return { check: { id: 'review', status: 'error', detail: `verdict gate could not decide: ${v.reason}` }, text: null };
|
||||
if (v.exit === 1) return { check: { id: 'review', status: 'fail', detail: `review verdict BLOCK — ${v.reason}` }, text };
|
||||
// Every counting MAJOR must be answered, read against the same top-level
|
||||
// approval log the verdict gate reads (lib/review/verdict-gate.mjs
|
||||
// `decideOpenFindings`): unanswered is not ready; any other exit 2 is undecided.
|
||||
const open = decideOpenFindings(text, { repo, reviewPath });
|
||||
if (open.exit === 2 && String(open.reason).startsWith('review-unanswered:')) {
|
||||
return { check: { id: 'review', status: 'fail', detail: `review verdict ${v.verdict} with open findings — ${open.reason}` }, text };
|
||||
}
|
||||
if (open.exit !== 0) return { check: { id: 'review', status: 'error', detail: `the open-findings check could not decide: ${open.reason}` }, text: null };
|
||||
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +274,7 @@ export function releaseCheck({ repo, review, version: explicitVersion, suiteTime
|
|||
const where = reviewPath(root, review);
|
||||
const { check: rev, text } = where.error
|
||||
? { check: { id: 'review', status: 'error', detail: where.error }, text: null }
|
||||
: checkReview(where.path);
|
||||
: checkReview(where.path, root);
|
||||
const coverage = checkCoverage(root, text);
|
||||
const door = checkDoor(root, version);
|
||||
const undecided = [tree, rev, coverage, door].filter((c) => c.status === 'error');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
|
|
@ -336,3 +337,420 @@ test('keep: a lessons-only commit after the review still passes', () => {
|
|||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
// ---- [ANS] / [RLS]: fix round 4.5b2 part B, Step 4 ----
|
||||
// The verdict gate and the release check keep a WARN closed until every
|
||||
// counting MAJOR is answered. Every answer a tagged test needs is written by
|
||||
// `review-answers.mjs record` through its CLI; the only hand-written rows are
|
||||
// the ones a case is about. `keep:` cases hold at the base and stay untagged.
|
||||
|
||||
const ANSWERS = join(ROOT, 'lib', 'review', 'review-answers.mjs');
|
||||
const RELEASE = join(ROOT, 'scripts', 'release-check.mjs');
|
||||
const TRAILER = 'Co-Authored-By: Claude Test <noreply@example.invalid>';
|
||||
const ANS_REL = '.claude/projects/2026-11-02-a/review.md';
|
||||
const OTHER_REL = '.claude/projects/2026-11-02-b/review.md';
|
||||
const ANSWERS_HEAD = '| Finding | Answer | Reason | Record | Carried from |\n|---|---|---|---|---|';
|
||||
const fid = (n) => String(n).padStart(40, 'b');
|
||||
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
/**
|
||||
* A review of `sha`. `findings` are [{severity, rule_key?, engine?}]; ids are
|
||||
* fid(1), fid(2), … The trailing block's body is exactly `${json}\n`.
|
||||
*/
|
||||
function ansReview({ sha, verdict = 'WARN', version = '1.2', findings = [{ severity: 'MAJOR' }] }) {
|
||||
const list = findings.map((f, i) => ({
|
||||
id: fid(i + 1), severity: f.severity, rule_key: f.rule_key ?? 'MISSING_TEST', ...(f.engine ? { engine: f.engine } : {}),
|
||||
file: 'a.mjs', line: i + 1, title: `t${i + 1}`, detail: `d${i + 1}`, recommended_action: 'Add a test.',
|
||||
}));
|
||||
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
||||
for (const f of list) counts[f.severity] += 1;
|
||||
const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2);
|
||||
const steps = list.filter((f) => f.severity === 'BLOCKER')
|
||||
.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\nSource finding: ${f.id}\n`).join('\n');
|
||||
return {
|
||||
json,
|
||||
text: `---
|
||||
type: trekreview
|
||||
review_version: "${version}"
|
||||
task: "t"
|
||||
slug: s
|
||||
project_dir: .claude/projects/2026-11-02-a/
|
||||
brief_path: .claude/projects/2026-11-02-a/brief.md
|
||||
scope_sha_end: ${sha}
|
||||
reviewed_files_count: 1
|
||||
verdict: ${verdict}
|
||||
findings:
|
||||
${list.map((f) => ` - ${f.id}`).join('\n')}
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | deep-review | default |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
${steps || 'None.\n'}
|
||||
## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Put hand-written `## Answers` rows just before the trailing JSON block. */
|
||||
function withRows(text, rows) {
|
||||
return text.replace(/```json\n(?=[\s\S]*```\n$)/, `## Answers\n\n${ANSWERS_HEAD}\n${rows.join('\n')}\n\n\`\`\`json\n`);
|
||||
}
|
||||
|
||||
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
|
||||
|
||||
function newRepo(prefix) {
|
||||
const base = mkdtempSync(join(tmpdir(), prefix));
|
||||
made.push(base);
|
||||
const repo = join(base, 'repo');
|
||||
mkdirSync(repo, { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
// The review files are local artifacts (artifact_store: local): never tracked, never shown.
|
||||
appendFileSync(join(repo, '.git', 'info', 'exclude'), '.claude/projects/\n');
|
||||
return { base, repo };
|
||||
}
|
||||
|
||||
/**
|
||||
* A repo with one reviewed commit and a review of it at ANS_REL in the project
|
||||
* dir (`sub` puts the project in a subdirectory). `files` adds files to the
|
||||
* reviewed commit.
|
||||
*/
|
||||
function ansFixture({ sub = '', files = {}, ...reviewOpts } = {}) {
|
||||
const { base, repo } = newRepo('review-answers-ans-');
|
||||
const dir = sub ? join(repo, sub) : repo;
|
||||
putIn(dir, 'a.mjs', 'export const a = 1;\n');
|
||||
for (const [rel, text] of Object.entries(files)) putIn(dir, rel, text);
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', 'feat: the work');
|
||||
const reviewed = g(repo, 'rev-parse', 'HEAD');
|
||||
const reviewPath = join(dir, ...ANS_REL.split('/'));
|
||||
const built = ansReview({ sha: reviewed, ...reviewOpts });
|
||||
putIn(dir, ANS_REL, built.text);
|
||||
const logFile = join(repo, ...LOG_RELPATH.split('/'));
|
||||
const readLog = () => (existsSync(logFile) ? readFileSync(logFile, 'utf8').split('\n').filter(Boolean) : []);
|
||||
return {
|
||||
base, repo, dir, reviewed, reviewPath, logFile, json: built.json, readLog,
|
||||
read: (p = reviewPath) => readFileSync(p, 'utf8'),
|
||||
write: (text, p = reviewPath) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text); },
|
||||
record: ({ finding = fid(1), answer = 'accepted', reason = 'the old behaviour is kept on purpose', trailer = TRAILER, repoArg = dir, review = reviewPath } = {}) => {
|
||||
const args = [ANSWERS, 'record', '--review', review, '--finding', finding, '--answer', answer,
|
||||
'--repo', repoArg, '--round', '1', '--store', 'local'];
|
||||
if (trailer !== null) args.push('--trailer', trailer);
|
||||
return spawnSync(process.execPath, args, { input: reason, encoding: 'utf8', env: childEnv() });
|
||||
},
|
||||
gate: (extra = ['--repo', dir], p = reviewPath) => spawnSync(process.execPath, [GATE, '--json', p, ...extra], { encoding: 'utf8', env: childEnv() }),
|
||||
/** A K2 review record for `id`, appended by the approval-log CLI and committed (hand-written rows only). */
|
||||
logAnswer: (id, sha) => {
|
||||
const r = spawnSync(process.execPath, [APPROVAL_LOG, 'append', '--kind', 'review', '--subject', `${ANS_REL}#${id}`,
|
||||
'--sha256', sha, '--repo', repo], { cwd: dir, encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 0, `approval-log append: ${r.stdout}${r.stderr}`);
|
||||
g(repo, 'add', '--', LOG_RELPATH);
|
||||
g(repo, 'commit', '-q', '-m', 'docs(voyage): review answers (round 1)');
|
||||
return JSON.parse(r.stdout).record.hash;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertUnanswered(r, re = /^review-unanswered:/) {
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), re, r.stdout);
|
||||
}
|
||||
|
||||
test('[ANS] an unanswered MAJOR exits 2 with a reason starting review-unanswered: and the count', () => {
|
||||
const fx = ansFixture({ findings: [{ severity: 'MAJOR' }, { severity: 'MINOR' }] });
|
||||
const r = fx.gate();
|
||||
assertUnanswered(r, /^review-unanswered: 1 MAJOR without an answer/);
|
||||
assert.deepEqual(parse(r).unanswered, [fid(1)]);
|
||||
assert.deepEqual(parse(r).fixed_pending, []);
|
||||
});
|
||||
|
||||
test('[ANS] an accepted answer with a reason, bound to this review, exits 0', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const rec = fx.record({ reason: 'the old behaviour is kept on purpose' });
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
const last = JSON.parse(fx.readLog().at(-1));
|
||||
assert.equal(last.kind, 'review');
|
||||
assert.equal(last.subject, `${ANS_REL}#${fid(1)}`);
|
||||
assert.ok(fx.read().includes(`| ${fid(1)} | accepted | the old behaviour is kept on purpose | ${last.hash} | - |`), fx.read());
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('[ANS] a fixed answer keeps exit 2 with fixed-pending', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const rec = fx.record({ answer: 'fixed', reason: 'fixed in step R1' });
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
const r = fx.gate();
|
||||
assertUnanswered(r, /^review-unanswered: fixed-pending 1/);
|
||||
assert.deepEqual(parse(r).fixed_pending, [fid(1)]);
|
||||
});
|
||||
|
||||
test('[ANS] a hollow reason (sounds good) is refused by record with exit 1', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const before = fx.read();
|
||||
for (const reason of ['sounds good', 'Looks good.', '', ' \n ']) {
|
||||
const r = fx.record({ reason });
|
||||
assert.equal(r.status, 1, `${JSON.stringify(reason)}: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(parse(r).recorded, false, r.stdout);
|
||||
assert.deepEqual(fx.readLog(), [], `${JSON.stringify(reason)}: nothing appended`);
|
||||
assert.equal(fx.read(), before, `${JSON.stringify(reason)}: review.md unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ANS] record without --trailer, or with an unfilled {model}, is refused with exit 1 and appends nothing', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const before = fx.read();
|
||||
for (const trailer of [null, 'Co-Authored-By: Claude {model} <noreply@anthropic.com>']) {
|
||||
const r = fx.record({ trailer });
|
||||
assert.equal(r.status, 1, `${trailer}: ${r.stdout}${r.stderr}`);
|
||||
assert.match(String(parse(r).reason), /--trailer/, r.stdout);
|
||||
assert.deepEqual(fx.readLog(), [], `${trailer}: nothing appended`);
|
||||
assert.equal(fx.read(), before, `${trailer}: review.md unchanged`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ANS] record commits the top-level log with the round message and the trailer', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const rec = fx.record();
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
assert.equal(parse(rec).committed, true, rec.stdout);
|
||||
const msg = g(fx.repo, 'log', '-1', '--format=%B');
|
||||
assert.match(msg, /^docs\(voyage\): review answers \(round 1\)/);
|
||||
assert.ok(msg.includes(TRAILER), msg);
|
||||
const status = g(fx.repo, 'status', '--porcelain');
|
||||
assert.ok(!status.includes('approvals.jsonl'), `the log is committed: ${status}`);
|
||||
});
|
||||
|
||||
test('[ANS] accepted on a security MAJOR (a MAJOR-tier key with engine security-review) is refused by record', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture({ findings: [{ severity: 'MAJOR', rule_key: 'PLACEHOLDER_IN_CODE', engine: 'security-review' }] });
|
||||
const before = fx.read();
|
||||
const r = fx.record({ reason: 'the risk is accepted for this release' });
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
assert.match(String(parse(r).reason), /security/, r.stdout);
|
||||
assert.deepEqual(fx.readLog(), []);
|
||||
assert.equal(fx.read(), before);
|
||||
});
|
||||
|
||||
test('[ANS] a hand-written accepted row on a security MAJOR with a valid K2 record still exits 2', () => {
|
||||
const fx = ansFixture({ findings: [{ severity: 'MAJOR', rule_key: 'PLACEHOLDER_IN_CODE', engine: 'security-review' }, { severity: 'MAJOR' }] });
|
||||
// The binding, computed here from its definition: sha256 of the canonical JSON
|
||||
// {blockSha, scopeShaEnd, findingId, answer, reason}, blockSha the sha256 of the trailing block's body.
|
||||
const blockSha = sha256(`${fx.json}\n`);
|
||||
const reason = 'the risk is accepted for this release';
|
||||
const bind = (id) => sha256(JSON.stringify({ blockSha, scopeShaEnd: fx.reviewed, findingId: id, answer: 'accepted', reason }));
|
||||
const rows = [fid(1), fid(2)].map((id) => `| ${id} | accepted | ${reason} | ${fx.logAnswer(id, bind(id))} | - |`);
|
||||
fx.write(withRows(fx.read(), rows));
|
||||
const r = fx.gate();
|
||||
assertUnanswered(r);
|
||||
// Control: the same hand-written binding answers the MAJOR that is not a security finding.
|
||||
assert.deepEqual(parse(r).unanswered, [fid(1)], r.stdout);
|
||||
});
|
||||
|
||||
test('[ANS] a reason holding |, a newline and a backtick fence is stored normalized, and the gate then exits 0', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const rec = fx.record({ reason: 'keeps the old API | callers rely on it\n```js\nfence\n```' });
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
const stored = 'keeps the old API / callers rely on it js fence';
|
||||
assert.equal(parse(rec).reason, stored, rec.stdout);
|
||||
assert.ok(fx.read().includes(`| ${fid(1)} | accepted | ${stored} | `), fx.read());
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('[ANS] an answer bound to another review exits 2', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
assert.equal(fx.record().status, 0);
|
||||
assert.equal(fx.gate().status, 0, 'control: the answered review passes');
|
||||
const other = join(fx.dir, ...OTHER_REL.split('/'));
|
||||
fx.write(fx.read(), other);
|
||||
assertUnanswered(fx.gate(['--repo', fx.dir], other));
|
||||
});
|
||||
|
||||
test('[ANS] a review whose JSON block or scope_sha_end changed after the answer exits 2', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
assert.equal(fx.record().status, 0);
|
||||
const answered = fx.read();
|
||||
assert.equal(fx.gate().status, 0, 'control: the answered review passes');
|
||||
fx.write(answered.replace('"detail": "d1"', '"detail": "d1, reworded"'));
|
||||
assertUnanswered(fx.gate());
|
||||
// The review now names the answer-log commit (HEAD), so R6 passes and the binding decides.
|
||||
const head = g(fx.repo, 'rev-parse', 'HEAD');
|
||||
assert.notEqual(head, fx.reviewed);
|
||||
fx.write(answered.replace(`scope_sha_end: ${fx.reviewed}`, `scope_sha_end: ${head}`));
|
||||
assertUnanswered(fx.gate());
|
||||
});
|
||||
|
||||
test('[ANS] an R-step section added above the block after the answers keeps exit 0', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
assert.equal(fx.record().status, 0);
|
||||
const text = fx.read();
|
||||
assert.ok(text.includes('## Answers'), text);
|
||||
fx.write(text.replace('## Answers', `### Step R1: fix t1\n\nSource finding: ${fid(1)}\n\n## Answers`));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('[ANS] a row with no K2 record exits 2', () => {
|
||||
const fx = ansFixture();
|
||||
fx.write(withRows(fx.read(), [`| ${fid(1)} | accepted | the old behaviour is kept on purpose | ${hex('7')} | - |`]));
|
||||
assertUnanswered(fx.gate());
|
||||
});
|
||||
|
||||
test('[ANS] a project in a subdirectory: record --repo <subdir> writes the top-level log, and the gate exits 0 with --repo <subdir>, --repo <top level> and no --repo', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture({ sub: 'plugin' });
|
||||
const rec = fx.record({ repoArg: fx.dir });
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
assert.ok(existsSync(fx.logFile), 'the top-level log is written');
|
||||
assert.ok(!existsSync(join(fx.dir, ...LOG_RELPATH.split('/'))), 'no log under the project prefix');
|
||||
assert.equal(JSON.parse(fx.readLog().at(-1)).subject, `plugin/${ANS_REL}#${fid(1)}`);
|
||||
const status = g(fx.repo, 'status', '--porcelain');
|
||||
assert.ok(!status.includes('approvals.jsonl'), `the log is committed: ${status}`);
|
||||
for (const extra of [['--repo', fx.dir], ['--repo', fx.repo], []]) {
|
||||
const r = fx.gate(extra);
|
||||
assert.equal(r.status, 0, `${JSON.stringify(extra)}: ${r.stdout}${r.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[ANS] a 1.1 WARN review with a MAJOR exits 2', () => {
|
||||
const fx = ansFixture({ version: '1.1' });
|
||||
assertUnanswered(fx.gate());
|
||||
});
|
||||
|
||||
test('[ANS] an ALLOW review over an unanswered MAJOR exits 2', () => {
|
||||
const fx = ansFixture({ verdict: 'ALLOW' });
|
||||
assertUnanswered(fx.gate());
|
||||
});
|
||||
|
||||
test('[ANS] a review in a non-git directory with a MAJOR exits 2', () => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'review-answers-nogit-'));
|
||||
made.push(base);
|
||||
// Control: the directory is in no git work tree.
|
||||
assert.notEqual(spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: base, encoding: 'utf8', env: childEnv() }).status, 0);
|
||||
const p = join(base, 'review.md');
|
||||
writeFileSync(p, ansReview({ sha: 'a'.repeat(40) }).text);
|
||||
const r = spawnSync(process.execPath, [GATE, '--json', p], { encoding: 'utf8', env: childEnv() });
|
||||
assertUnanswered(r);
|
||||
});
|
||||
|
||||
test('keep: a BLOCKER exits 1 whatever the answers', () => {
|
||||
const fx = ansFixture({ verdict: 'BLOCK', findings: [{ severity: 'BLOCKER' }, { severity: 'MAJOR' }] });
|
||||
fx.write(withRows(fx.read(), [fid(1), fid(2)].map((id) => `| ${id} | accepted | kept on purpose | ${hex('7')} | - |`)));
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
test('keep: a review of an older commit reports review-stale (not review-unanswered)', () => {
|
||||
const fx = ansFixture();
|
||||
putIn(fx.dir, 'a.mjs', 'export const a = 2;\n');
|
||||
g(fx.repo, 'add', '-A');
|
||||
g(fx.repo, 'commit', '-q', '-m', 'fix: after the review');
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /review-stale/);
|
||||
assert.doesNotMatch(r.stdout, /review-unanswered/);
|
||||
});
|
||||
|
||||
test('[RLS] scripts/release-check.mjs reports not ready (exit 1) for the unanswered review and ready for the answered one, from the same K2 log', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture({
|
||||
files: {
|
||||
'package.json': `${JSON.stringify({ name: 'fx', version: '1.0.1', scripts: { test: 'node -e "process.exit(0)"' } }, null, 2)}\n`,
|
||||
'.claude-plugin/plugin.json': `${JSON.stringify({ name: 'fx', version: '1.0.1' })}\n`,
|
||||
'CHANGELOG.md': '# Changelog\n\n## v1.0.1 — 2026-11-02 — fixture\n\n### Doors\n\n- two-way: the fixture change — revert it.\n',
|
||||
},
|
||||
});
|
||||
const other = join(fx.dir, ...OTHER_REL.split('/'));
|
||||
fx.write(fx.read(), other);
|
||||
const rec = fx.record();
|
||||
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
|
||||
const check = (review) => spawnSync(process.execPath, [RELEASE, '--repo', fx.dir, '--review', review, '--json'],
|
||||
{ encoding: 'utf8', env: childEnv() });
|
||||
const open = check(other);
|
||||
assert.equal(open.status, 1, open.stdout + open.stderr);
|
||||
const rev = parse(open).checks.find((c) => c.id === 'review');
|
||||
assert.equal(rev.status, 'fail', open.stdout);
|
||||
assert.match(rev.detail, /review-unanswered:/);
|
||||
const answered = check(fx.reviewPath);
|
||||
assert.equal(answered.status, 0, answered.stdout + answered.stderr);
|
||||
});
|
||||
|
||||
// ---- fix round 1 of Step 4 (controller findings 1 and 2) ----
|
||||
// Untagged: they were red against the Step 4 build, not at the plan's base.
|
||||
|
||||
test('a review with no readable trailing JSON block is not measured: decideAnswers is not ok and decideOpenFindings exits 2', async () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const { decideAnswers } = await import(ANSWERS);
|
||||
const { decideOpenFindings } = await import(GATE);
|
||||
const { text, json } = ansReview({ sha: 'a'.repeat(40) });
|
||||
const block = `\`\`\`json\n${json}\n\`\`\`\n`;
|
||||
assert.ok(text.endsWith(block), 'control: the fixture ends with its JSON block');
|
||||
const head = text.slice(0, -block.length);
|
||||
const cases = {
|
||||
'no block': head,
|
||||
'an unclosed block': `${head}\`\`\`json\n${json}\n`,
|
||||
'a non-json last block': `${text}\n\`\`\`yaml\nverdict: WARN\n\`\`\`\n`,
|
||||
'an unparseable block': `${head}\`\`\`json\n{ "verdict": "WARN", \n\`\`\`\n`,
|
||||
};
|
||||
const base = mkdtempSync(join(tmpdir(), 'review-answers-unread-'));
|
||||
made.push(base);
|
||||
const reviewPath = join(base, 'review.md');
|
||||
for (const [name, t] of Object.entries(cases)) {
|
||||
const d = decideAnswers(t, { records: [], reviewSubject: 'review.md' });
|
||||
assert.equal(d.ok, false, `${name}: decideAnswers must not be ok`);
|
||||
assert.match(String(d.reason), /^review-not-measured:/, `${name}: ${d.reason}`);
|
||||
assert.deepEqual(d.unanswered, [], name);
|
||||
assert.deepEqual(d.fixedPending, [], name);
|
||||
const o = decideOpenFindings(t, { reviewPath });
|
||||
assert.equal(o.exit, 2, `${name}: decideOpenFindings must exit 2, got ${o.exit} (${o.reason})`);
|
||||
assert.match(String(o.reason), /^review-not-measured:/, `${name}: ${o.reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('record on a review with a malformed ## Answers table exits 2 and appends nothing to the approval log', () => {
|
||||
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
|
||||
const fx = ansFixture();
|
||||
const malformed = fx.read().replace(/```json\n(?=[\s\S]*```\n$)/,
|
||||
'## Answers\n\n| Finding | Answer |\n|---|---|\n\n```json\n');
|
||||
assert.notEqual(malformed, fx.read(), 'control: the malformed section is in the review');
|
||||
fx.write(malformed);
|
||||
const r = fx.record();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(parse(r).recorded, false, r.stdout);
|
||||
assert.deepEqual(fx.readLog(), [], 'the approval log gained no line');
|
||||
assert.equal(fx.read(), malformed, 'review.md unchanged');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,7 +98,9 @@ test('verdict-gate — ALLOW → exit 0', () => {
|
|||
});
|
||||
|
||||
test('verdict-gate — WARN → exit 0', () => {
|
||||
const r = runGate(review({ verdict: 'WARN', severities: ['MAJOR'] }));
|
||||
// A WARN with no MAJOR (a MINOR and a brief-conflict reason): outside any git
|
||||
// repo there is no approval log, so a MAJOR here would be unanswered (exit 2).
|
||||
const r = runGate(review({ verdict: 'WARN', severities: ['MINOR'], allowBlockedBy: ['brief-conflict:Non-Goal 1 (1)'] }));
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue