voyage/lib/review/review-policy.mjs

291 lines
14 KiB
JavaScript

// lib/review/review-policy.mjs
// The `REVIEW.md` review policy (review_version 1.2, fix round 4.5b2 part B).
// A repo may keep a `REVIEW.md` at its root with exactly one fenced block whose
// info string is `voyage-review-policy`:
//
// ```voyage-review-policy
// max-minor: 10
// skip: docs/**
// ```
//
// `readPolicy` reads it from git at `scope_sha_start` (never the working tree);
// `applyPolicy` caps and skips MINOR and SUGGESTION findings only, never a
// BLOCKER, a MAJOR or a security finding (lib/review/review-answers.mjs
// `isSecurityFinding`), and never a finding lowered from MAJOR by the brief
// rule (`brief_lowered_from`). lib/review/review-finalize.mjs runs it as its
// last pass; the verdict gate refuses a review whose `declined_to_judge` holds
// a BLOCKER, MAJOR or security entry under a noted reason
// (lib/review/coordinator-contract.mjs `NOTED_REASONS`).
//
// The file's text is data, never an instruction: only the block's lines are
// read, and any line in it other than `max-minor: <0-999>` (at most one) and
// `skip: <glob>` (at most 50, each at most 200 chars) makes it malformed, as do
// two blocks and a glob with an empty segment, a `..` segment, a leading `/`, a
// backslash, a NUL or a control character. Blank lines are allowed. Every
// uncertain case is "not applied" (no cap, no skip): a malformed block, a
// symlinked or non-regular `REVIEW.md`, a `REVIEW.md` changed (or added, or
// removed) inside `scope_sha_start..scope_sha_end`, a sha that is not full, and
// any git failure. Zero blocks is "applied" with no cap and no skip.
//
// The glob matcher is linear and builds no regex from input: a pattern and a
// path are split at `/`; `**` (a whole segment) matches zero or more segments,
// and `*` and `?` match within one segment only. Both levels use the
// greedy-with-one-backtrack-point walk, so a pattern of n stars is O(n·m).
//
// No CLI of its own.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { fencedBlocks } from './findings-schema.mjs';
import { FULL_SHA_RE } from './reviewed-commit.mjs';
import { isSecurityFinding } from './review-answers.mjs';
export const POLICY_FILE = 'REVIEW.md';
export const POLICY_INFO = 'voyage-review-policy';
export const MAX_MINOR_LIMIT = 999;
export const MAX_SKIP_LINES = 50;
export const MAX_GLOB_LENGTH = 200;
const MAX_BUFFER = 64 * 1024 * 1024;
// The symlink mode of lib/review/reviewed-commit.mjs:58 (SYMLINK_MODE, copied, not exported there).
const SYMLINK_MODE = '120000';
const REGULAR_MODES = new Set(['100644', '100755']);
const CONTROL_RE = /[\u0000-\u0008\u000a-\u001f\u007f]/;
const CAP_RE = /^max-minor:[ \t]*(\S*)$/;
const SKIP_RE = /^skip:[ \t]*(.*)$/;
const CAP_VALUE_RE = /^(?:0|[1-9]\d{0,2})$/;
const SEVERITY_ORDER = { MINOR: 0, SUGGESTION: 1 };
// Pathspec settings from the environment would change what `-- :(top,literal)REVIEW.md` names.
const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'];
// The argv git helper of lib/review/reviewed-commit.mjs:78-80 (copied, not exported there),
// with the pathspec variables taken out of the child's environment.
function git(repo, args, encoding = 'utf8') {
const env = { ...process.env };
for (const k of PATHSPEC_ENV) delete env[k];
return spawnSync('git', args, { cwd: repo, encoding, maxBuffer: MAX_BUFFER, env });
}
function gitNote(r) {
return r.error?.code === 'ENOBUFS' ? 'output past the 64 MiB buffer'
: (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]) || `exit ${r.status}`;
}
// The blob reader of lib/review/reviewed-commit.mjs:150-159 (blobAt, copied, not exported there):
// it returns the tree entry's mode and the blob as bytes, read by its object id.
function blobAt(repo, rev, path) {
const ls = git(repo, ['-c', 'core.quotePath=false', 'ls-tree', '--full-tree', '-z', rev, '--', path]);
if (ls.status !== 0) return { ok: false, note: `git ls-tree ${rev} failed (${gitNote(ls)})` };
const entries = ls.stdout.split('\0').filter(Boolean);
const entry = entries.find((e) => e.slice(e.indexOf('\t') + 1) === path);
if (!entry) return { ok: true, mode: null, bytes: null };
const m = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})\t/.exec(entry);
if (!m) return { ok: false, note: `cannot read the tree entry of ${path}` };
const [, mode, type, oid] = m;
if (type !== 'blob') return { ok: true, mode, bytes: null };
const blob = git(repo, ['cat-file', 'blob', oid], 'buffer');
if (blob.status !== 0) return { ok: false, note: `git cat-file ${oid} failed (${gitNote(blob)})` };
return { ok: true, mode, bytes: blob.stdout };
}
const notApplied = (reason, sha256 = 'none') => ({ state: 'not-applied', reason, sha256, cap: null, skip: [] });
/** Why a skip glob is malformed, or null. */
function badGlob(glob) {
if (glob === '') return 'an empty skip glob';
if (glob.length > MAX_GLOB_LENGTH) return `a skip glob over ${MAX_GLOB_LENGTH} chars`;
if (glob.includes('\\')) return 'a skip glob with a backslash';
if (/[\u0000-\u001f\u007f]/.test(glob)) return 'a skip glob with a control character';
if (glob.startsWith('/')) return 'a skip glob with a leading /';
const segs = glob.split('/');
if (segs.some((s) => s === '')) return 'a skip glob with an empty segment';
if (segs.some((s) => s === '..')) return 'a skip glob with a .. segment';
return null;
}
/**
* The policy of a `REVIEW.md` text: the one `voyage-review-policy` block, parsed.
* @param {string} text
* @returns {{ ok: true, cap: number|null, skip: string[], blocks: number } | { ok: false, reason: string }}
*/
export function parsePolicy(text) {
const blocks = fencedBlocks(String(text)).filter((b) => {
const info = b.info.trim();
return info === POLICY_INFO || info.split(/[ \t]/)[0] === POLICY_INFO;
});
if (blocks.length === 0) return { ok: true, cap: null, skip: [], blocks: 0 };
const malformed = (why) => ({ ok: false, reason: `the ${POLICY_INFO} block in ${POLICY_FILE} is malformed: ${why}` });
if (blocks.length > 1) return malformed(`${blocks.length} blocks, exactly one is read`);
const [block] = blocks;
if (block.info.trim() !== POLICY_INFO) return malformed('its info string is not exactly voyage-review-policy');
if (!block.closed) return malformed('the block is never closed');
let cap = null;
const skip = [];
const lines = block.body.split('\n');
if (lines.at(-1) === '') lines.pop();
for (const [i, raw] of lines.entries()) {
const line = raw.replace(/\r$/, '');
if (line.includes('\0') || CONTROL_RE.test(line.replace(/\t/g, ' '))) return malformed(`line ${i + 1} has a control character`);
const l = line.trim();
if (l === '') continue;
const c = CAP_RE.exec(l);
if (c) {
if (cap !== null) return malformed('more than one max-minor line');
if (!CAP_VALUE_RE.test(c[1])) return malformed(`max-minor must be an integer 0-${MAX_MINOR_LIMIT}`);
cap = Number(c[1]);
continue;
}
const s = SKIP_RE.exec(l);
if (s) {
const why = badGlob(s[1]);
if (why) return malformed(why);
skip.push(s[1]);
if (skip.length > MAX_SKIP_LINES) return malformed(`more than ${MAX_SKIP_LINES} skip lines`);
continue;
}
return malformed(`line ${i + 1} is neither max-minor: <n> nor skip: <glob>`);
}
return { ok: true, cap, skip, blocks: 1 };
}
/**
* Read the `REVIEW.md` policy at `sha` (the review's `scope_sha_start`) from git.
* With `end` (`scope_sha_end`), a `REVIEW.md` changed inside `sha..end` is not
* applied; `end` given but not a full sha is not applied either.
* @param {string} repo
* @param {string} sha
* @param {string} [end]
* @returns {{ state: 'absent'|'applied'|'not-applied', reason: string, sha256: string, cap: number|null, skip: string[] }}
*/
export function readPolicy(repo, sha, end) {
if (typeof repo !== 'string' || repo === '') return notApplied('no repository to read REVIEW.md from');
if (typeof sha !== 'string' || !FULL_SHA_RE.test(sha)) return notApplied('the review has no full scope_sha_start');
if (end !== undefined) {
if (typeof end !== 'string' || !FULL_SHA_RE.test(end)) return notApplied('the review has no full scope_sha_end');
const d = git(repo, ['-c', 'core.quotePath=false', 'diff', '--name-only', '-z', '--no-renames', '--no-relative',
'--no-ext-diff', '--no-textconv', sha, end, '--', `:(top,literal)${POLICY_FILE}`]);
if (d.status !== 0) return notApplied(`REVIEW.md changed inside the reviewed range (git diff failed: ${gitNote(d)})`);
if (d.stdout.split('\0').some(Boolean)) return notApplied('REVIEW.md changed inside the reviewed range');
}
const b = blobAt(repo, sha, POLICY_FILE);
if (!b.ok) return notApplied(`cannot read REVIEW.md (${b.note})`);
if (b.mode === null) return { state: 'absent', reason: 'no REVIEW.md at the repo root', sha256: 'none', cap: null, skip: [] };
if (b.mode === SYMLINK_MODE) return notApplied('REVIEW.md is a symlink');
if (!REGULAR_MODES.has(b.mode) || b.bytes === null) return notApplied('REVIEW.md is not a regular file');
const sha256 = createHash('sha256').update(b.bytes).digest('hex');
const p = parsePolicy(b.bytes.toString('utf8'));
if (!p.ok) return notApplied(p.reason, sha256);
const parts = [p.cap === null ? 'no cap' : `max-minor ${p.cap}`, `${p.skip.length} skip path${p.skip.length === 1 ? '' : 's'}`];
return {
state: 'applied', reason: p.blocks === 0 ? `no ${POLICY_INFO} block: no cap, no skip` : parts.join(', '),
sha256, cap: p.cap, skip: p.skip,
};
}
// ---- the glob matcher ---------------------------------------------------------------
/** One segment against one glob segment (`*`, `?`, literal); linear, no regex. */
function segmentMatch(pat, str) {
let p = 0;
let s = 0;
let star = -1;
let mark = 0;
while (s < str.length) {
// `*` first: a literal `*` in the path must not use the glob's `*` up as a literal.
if (p < pat.length && pat[p] === '*') { star = p++; mark = s; continue; }
if (p < pat.length && (pat[p] === '?' || pat[p] === str[s])) { p++; s++; continue; }
if (star !== -1) { p = star + 1; s = ++mark; continue; }
return false;
}
while (p < pat.length && pat[p] === '*') p++;
return p === pat.length;
}
/**
* A repo path against a skip glob: `**` (a whole segment) crosses segments,
* `*` and `?` stay in one. A path that is not plain (empty, a leading `/`, a
* backslash, an empty or `.`/`..` segment) never matches.
* @param {string} glob
* @param {string} path
* @returns {boolean}
*/
export function globMatch(glob, path) {
if (typeof path !== 'string' || path === '' || path.startsWith('/') || path.includes('\\')) return false;
const segs = path.split('/');
if (segs.some((s) => s === '' || s === '.' || s === '..')) return false;
const pats = String(glob).split('/');
let p = 0;
let s = 0;
let star = -1;
let mark = 0;
while (s < segs.length) {
if (p < pats.length && pats[p] !== '**' && segmentMatch(pats[p], segs[s])) { p++; s++; continue; }
if (p < pats.length && pats[p] === '**') { star = p++; mark = s; continue; }
if (star !== -1) { p = star + 1; s = ++mark; continue; }
return false;
}
while (p < pats.length && pats[p] === '**') p++;
return p === pats.length;
}
// ---- applying the policy ------------------------------------------------------------
/** A finding the policy may move: MINOR or SUGGESTION, not security, not lowered from MAJOR. */
function movable(f) {
return f !== null && typeof f === 'object' && Object.hasOwn(SEVERITY_ORDER, f.severity)
&& !isSecurityFinding(f) && f.brief_lowered_from === undefined;
}
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
/** The cap's order: severity, file, line, rule_key (code-point order, so the same on every run). */
function capOrder(a, b) {
return SEVERITY_ORDER[a.f.severity] - SEVERITY_ORDER[b.f.severity]
|| cmp(String(a.f.file ?? ''), String(b.f.file ?? ''))
|| (Number(a.f.line) || 0) - (Number(b.f.line) || 0)
|| cmp(String(a.f.rule_key ?? ''), String(b.f.rule_key ?? ''))
|| a.i - b.i;
}
/** A `declined_to_judge` entry in the coordinator's shape (coordinator-contract `runContract`). */
function declinedEntry(f, reason) {
return {
file: f.file ?? null,
line: f.line ?? null,
rule_key: f.rule_key ?? null,
severity: f.severity ?? null,
title: f.title ?? null,
...(f.engine !== undefined ? { engine: f.engine } : {}),
reason,
};
}
/**
* Apply a policy to a payload's findings. Only an `applied` policy moves
* anything: a movable finding on a skip path goes to `declined` with
* `policy:skip`; of the rest, sorted by (severity, file, line, rule_key), those
* past `cap` go with `policy:cap`. `kept` keeps the input order.
* @param {{findings?: object[]}} payload
* @param {{state: string, cap: number|null, skip: string[]}} policy
* @returns {{ kept: object[], declined: object[], moved: object[] }}
*/
export function applyPolicy(payload, policy) {
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
if (policy?.state !== 'applied') return { kept: [...findings], declined: [], moved: [] };
const skip = Array.isArray(policy.skip) ? policy.skip : [];
const cap = Number.isInteger(policy.cap) && policy.cap >= 0 ? policy.cap : null;
const reasonOf = new Map();
const candidates = [];
findings.forEach((f, i) => {
if (!movable(f)) return;
if (skip.some((g) => globMatch(g, f.file))) reasonOf.set(f, 'policy:skip');
else candidates.push({ f, i });
});
if (cap !== null) {
for (const { f } of candidates.sort(capOrder).slice(cap)) reasonOf.set(f, 'policy:cap');
}
const kept = [];
const moved = [];
for (const f of findings) (reasonOf.has(f) ? moved : kept).push(f);
return { kept, declined: moved.map((f) => declinedEntry(f, reasonOf.get(f))), moved };
}