291 lines
14 KiB
JavaScript
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 };
|
|
}
|