// 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: ` (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: nor skip: `); } 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 }; }