318 lines
14 KiB
JavaScript
318 lines
14 KiB
JavaScript
// lib/review/pre-existing.mjs
|
|
// Pre-existing findings (review_version 1.2, fix round 4.5b2 part B): a
|
|
// finding on a line the review range did not add or change. The mark is read
|
|
// from git and never trusted as written: `lib/review/review-finalize.mjs`
|
|
// writes it (`markFindings`), and the verdict gate re-derives it
|
|
// (`preExistingCheck`) before a MAJOR leaves the answer set. A BLOCKER or a
|
|
// security finding counts in full whatever the mark (lib/review/review-answers.mjs
|
|
// `countingMajors`); every uncertain case counts as new.
|
|
//
|
|
// The line sets come from one `git diff -U0` of `scope_sha_start..scope_sha_end`
|
|
// with flags that no repo config changes (`--no-color`, `--no-ext-diff`,
|
|
// `--no-textconv`, `--no-relative`, `--find-renames`, explicit prefixes,
|
|
// `--ignore-submodules=none`, `core.quotePath=false`):
|
|
// - added or changed lines are `c..c+d-1` of each `@@ -a,b +c,d @@`;
|
|
// - a pure deletion (`d = 0`) marks lines `c` and `c+1` (both sides of it);
|
|
// - a new or binary file is `'all'`;
|
|
// - a renamed or copied file goes into `renamed` under both names.
|
|
// Any git failure, ENOBUFS, a range whose start is not a proper ancestor of its
|
|
// end, or a line the reader cannot parse (a quoted path included) is
|
|
// `{ok:false}`: then nothing is pre-existing.
|
|
//
|
|
// A finding's path and line must also be in the tree at `scope_sha_end`: the
|
|
// path, byte for byte, names a regular file there (mode 100644 or 100755, read
|
|
// once with `git ls-tree -r -z --full-tree`), and the line lies inside that
|
|
// file's line count. A path the diff does not name is otherwise taken as
|
|
// untouched; this check keeps a case variant (`A.mjs` on a case-insensitive
|
|
// disk), a subdirectory-relative path, a missing file or a line past the end of
|
|
// the file from passing as pre-existing. Any git failure here also means
|
|
// nothing is pre-existing.
|
|
//
|
|
// Known limit: `scope_sha_start` is read from review.md and is not bound to
|
|
// anything the gate checks (`scope_sha_end` is, by `checkReviewedCommit` in
|
|
// lib/review/reviewed-commit.mjs). A forged narrower start, for example a
|
|
// later commit of the same run, shrinks the range, so a line the run added can
|
|
// look pre-existing for a MAJOR finding. BLOCKER and security findings are
|
|
// never affected: they count in full whatever the mark.
|
|
//
|
|
// No CLI of its own.
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { FULL_SHA_RE, reviewedSha } from './reviewed-commit.mjs';
|
|
|
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/;
|
|
const REGULAR_MODES = new Set(['100644', '100755']);
|
|
const TREE_ENTRY_RE = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
const HEADER_LINE_RE = /^(?:old mode \d+|new mode \d+|deleted file mode \d+|new file mode \d+|index [0-9a-f]+\.\.[0-9a-f]+(?: \d+)?|similarity index \d+%|dissimilarity index \d+%)$/;
|
|
|
|
// The argv git helper of lib/review/reviewed-commit.mjs:64-66 (copied, not exported there).
|
|
function git(repo, args) {
|
|
return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: MAX_BUFFER });
|
|
}
|
|
|
|
// The `notMeasured` shape of lib/review/reviewed-commit.mjs:72-75 (copied).
|
|
function notMeasured(reason) {
|
|
return { ok: false, reason: `pre-existing not measured: ${reason}` };
|
|
}
|
|
|
|
function gitFailed(what, r) {
|
|
const note = r.error?.code === 'ENOBUFS' ? 'output past the 64 MiB buffer'
|
|
: (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]);
|
|
return notMeasured(`git ${what} failed (${note || `exit ${r.status}`})`);
|
|
}
|
|
|
|
/** A frontmatter sha field as written (the `reviewedSha` reading, for any key): a full sha, else ''. */
|
|
function frontmatterSha(text, key) {
|
|
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text ?? ''));
|
|
const re = new RegExp(`^${key}:[ \\t]*(["']?)([^"'\\s#]*)\\1[ \\t]*(?:#.*)?$`, 'm');
|
|
const m = fm && re.exec(fm[1]);
|
|
return m && FULL_SHA_RE.test(m[2]) ? m[2] : '';
|
|
}
|
|
|
|
/**
|
|
* The review's range, read raw from the frontmatter text as `reviewedSha` reads
|
|
* `scope_sha_end`: a YAML number, `null` or a ref name is '' (no range, no mark).
|
|
* @param {string} text - review.md
|
|
* @returns {{ start: string, end: string }}
|
|
*/
|
|
export function scopeShas(text) {
|
|
return { start: frontmatterSha(text, 'scope_sha_start'), end: reviewedSha(text) };
|
|
}
|
|
|
|
/** A path as the diff prints it after its prefix; null when it is quoted (not read). */
|
|
function unprefixed(path, prefix) {
|
|
const p = path.replace(/\t$/, '');
|
|
if (p.startsWith('"') || !p.startsWith(prefix) || p.length === prefix.length) return null;
|
|
return p.slice(prefix.length);
|
|
}
|
|
|
|
/** The one path of a `diff --git a/P b/P` header (both halves equal), else null. */
|
|
function headerPath(rest) {
|
|
if ((rest.length - 5) % 2 !== 0) return null;
|
|
const n = (rest.length - 5) / 2;
|
|
const p = rest.slice(2, 2 + n);
|
|
return rest === `a/${p} b/${p}` && !p.startsWith('"') ? p : null;
|
|
}
|
|
|
|
/**
|
|
* Read a `git diff -U0` text into line sets.
|
|
* @param {string} diff
|
|
* @returns {{ok: true, files: Map<string, Set<number>|'all'>, renamed: Set<string>} | {ok: false, reason: string}}
|
|
*/
|
|
export function parseDiff(diff) {
|
|
const files = new Map();
|
|
const renamed = new Set();
|
|
let cur = null;
|
|
let minus = 0;
|
|
let plus = 0;
|
|
const bad = (line) => notMeasured(`cannot read diff line ${JSON.stringify(line.slice(0, 120))}`);
|
|
const finish = () => {
|
|
if (!cur) return null;
|
|
const path = cur.to ?? cur.plusPath ?? cur.header;
|
|
if (cur.from) renamed.add(cur.from);
|
|
if (cur.to) renamed.add(cur.to);
|
|
if (cur.deleted) return null;
|
|
if (!path) return 'no path';
|
|
if (cur.isNew || cur.binary) { files.set(path, 'all'); return null; }
|
|
const prev = files.get(path);
|
|
if (prev === 'all') return null;
|
|
const set = prev ?? new Set();
|
|
for (const n of cur.lines) set.add(n);
|
|
files.set(path, set);
|
|
return null;
|
|
};
|
|
const lines = String(diff).split('\n');
|
|
if (lines.at(-1) === '') lines.pop();
|
|
for (const line of lines) {
|
|
if (minus > 0 || plus > 0) {
|
|
if (line.startsWith('-') && minus > 0) { minus--; continue; }
|
|
if (line.startsWith('+') && plus > 0) { plus--; continue; }
|
|
if (line.startsWith('\\')) continue;
|
|
return bad(line);
|
|
}
|
|
if (line.startsWith('\\')) continue;
|
|
if (line.startsWith('diff --git ')) {
|
|
if (finish()) return bad(line);
|
|
cur = { header: headerPath(line.slice('diff --git '.length)), lines: [] };
|
|
continue;
|
|
}
|
|
if (!cur) return bad(line);
|
|
let m;
|
|
if (HEADER_LINE_RE.test(line)) {
|
|
if (line.startsWith('new file mode')) cur.isNew = true;
|
|
if (line.startsWith('deleted file mode')) cur.deleted = true;
|
|
} else if ((m = /^(rename|copy) (from|to) (.+)$/.exec(line))) {
|
|
if (m[3].startsWith('"')) return bad(line);
|
|
cur[m[2]] = m[3];
|
|
} else if (line.startsWith('--- ')) {
|
|
const p = line.slice(4);
|
|
if (p !== '/dev/null' && unprefixed(p, 'a/') === null) return bad(line);
|
|
} else if (line.startsWith('+++ ')) {
|
|
const p = line.slice(4);
|
|
if (p === '/dev/null') cur.deleted = true;
|
|
else {
|
|
const q = unprefixed(p, 'b/');
|
|
if (q === null) return bad(line);
|
|
cur.plusPath = q;
|
|
}
|
|
} else if (/^Binary files .* differ$/.test(line)) {
|
|
cur.binary = true;
|
|
} else if ((m = HUNK_RE.exec(line))) {
|
|
const b = m[2] === undefined ? 1 : Number(m[2]);
|
|
const c = Number(m[3]);
|
|
const d = m[4] === undefined ? 1 : Number(m[4]);
|
|
if (d === 0) cur.lines.push(c, c + 1);
|
|
else for (let n = c; n < c + d; n++) cur.lines.push(n);
|
|
minus = b;
|
|
plus = d;
|
|
} else {
|
|
return bad(line);
|
|
}
|
|
}
|
|
if (minus > 0 || plus > 0) return notMeasured('the diff ends inside a hunk');
|
|
if (finish()) return notMeasured('a file in the diff has no path the reader can name');
|
|
return { ok: true, files, renamed };
|
|
}
|
|
|
|
/**
|
|
* The regular files of the tree at `end`, by exact repo-root path: path -> blob id.
|
|
* @returns {{ok: true, blobs: Map<string, string>} | {ok: false, reason: string}}
|
|
*/
|
|
function regularFilesAt(repo, end) {
|
|
const r = git(repo, ['ls-tree', '-r', '-z', '--full-tree', end]);
|
|
if (r.error || r.status !== 0) return gitFailed('ls-tree', r);
|
|
const blobs = new Map();
|
|
for (const entry of r.stdout.split('\0')) {
|
|
if (entry === '') continue;
|
|
const tab = entry.indexOf('\t');
|
|
const m = tab === -1 ? null : TREE_ENTRY_RE.exec(entry.slice(0, tab));
|
|
if (!m) return notMeasured(`cannot read ls-tree entry ${JSON.stringify(entry.slice(0, 120))}`);
|
|
if (m[2] === 'blob' && REGULAR_MODES.has(m[1])) blobs.set(entry.slice(tab + 1), m[3]);
|
|
}
|
|
return { ok: true, blobs };
|
|
}
|
|
|
|
/** The line count of one blob (a last line without a newline counts); null when git fails. */
|
|
function blobLineCount(repo, blob) {
|
|
const r = spawnSync('git', ['cat-file', 'blob', blob], { cwd: repo, maxBuffer: MAX_BUFFER });
|
|
if (r.error || r.status !== 0 || !Buffer.isBuffer(r.stdout)) return null;
|
|
const b = r.stdout;
|
|
let n = 0;
|
|
for (let i = b.indexOf(10); i !== -1; i = b.indexOf(10, i + 1)) n++;
|
|
if (b.length > 0 && b[b.length - 1] !== 10) n++;
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* The lines `start..end` added or changed, per file (repo-root paths), plus
|
|
* `lineCount(path)`: the line count of a regular file at `end` under exactly
|
|
* that path, or null when there is none (or git fails). Full commit ids only;
|
|
* `start` must be a proper ancestor of `end`.
|
|
* @param {string} repo
|
|
* @param {string} start
|
|
* @param {string} end
|
|
* @returns {{ok: true, files: Map<string, Set<number>|'all'>, renamed: Set<string>, lineCount: (path: string) => number|null} | {ok: false, reason: string}}
|
|
*/
|
|
export function changedLineSets(repo, start, end) {
|
|
if (!repo) return notMeasured('no repo');
|
|
if (!FULL_SHA_RE.test(String(start ?? '')) || !FULL_SHA_RE.test(String(end ?? ''))) {
|
|
return notMeasured('scope_sha_start and scope_sha_end must be full commit ids');
|
|
}
|
|
if (start.toLowerCase() === end.toLowerCase()) return notMeasured('an empty range (start = end) proves nothing');
|
|
const anc = git(repo, ['merge-base', '--is-ancestor', start, end]);
|
|
if (anc.error || (anc.status !== 0 && anc.status !== 1)) return gitFailed('merge-base --is-ancestor', anc);
|
|
if (anc.status === 1) return notMeasured(`${start.slice(0, 12)} is not an ancestor of ${end.slice(0, 12)}`);
|
|
const r = git(repo, ['-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv',
|
|
'--no-relative', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', '--ignore-submodules=none', start, end]);
|
|
if (r.error || r.status !== 0) return gitFailed('diff', r);
|
|
const sets = parseDiff(r.stdout);
|
|
if (!sets.ok) return sets;
|
|
const tree = regularFilesAt(repo, end);
|
|
if (!tree.ok) return tree;
|
|
const counts = new Map();
|
|
sets.lineCount = (path) => {
|
|
if (typeof path !== 'string' || !tree.blobs.has(path)) return null;
|
|
if (!counts.has(path)) counts.set(path, blobLineCount(repo, tree.blobs.get(path)));
|
|
return counts.get(path);
|
|
};
|
|
return sets;
|
|
}
|
|
|
|
/** A repo-relative posix path as the diff names it: no `./`, `..`, empty segment, leading `/` or backslash. */
|
|
function plainPath(p) {
|
|
return typeof p === 'string' && p !== '' && !p.includes('\\')
|
|
&& p.split('/').every((s) => s !== '' && s !== '.' && s !== '..');
|
|
}
|
|
|
|
/**
|
|
* True only when the sets are ok, `f.line >= 1`, the file is not renamed, its
|
|
* entry is not `'all'`, `f.line` is not in its set, and `f.file` is a regular
|
|
* file at `end` under exactly that path with at least `f.line` lines
|
|
* (`sets.lineCount`; sets without it are never pre-existing).
|
|
* @param {{file?: string, line?: number}} f
|
|
* @param {ReturnType<typeof changedLineSets>} sets
|
|
* @returns {boolean}
|
|
*/
|
|
export function isPreExisting(f, sets) {
|
|
if (!sets || sets.ok !== true) return false;
|
|
if (!f || !Number.isInteger(f.line) || f.line < 1 || !plainPath(f.file)) return false;
|
|
if (sets.renamed.has(f.file)) return false;
|
|
const entry = sets.files.get(f.file);
|
|
if (entry === 'all') return false;
|
|
if (entry && entry.has(f.line)) return false;
|
|
if (typeof sets.lineCount !== 'function') return false;
|
|
const n = sets.lineCount(f.file);
|
|
return Number.isInteger(n) && f.line <= n;
|
|
}
|
|
|
|
/** True when `file` has uncommitted changes, or git cannot say (the stricter side). */
|
|
function uncommitted(repo, file) {
|
|
const r = git(repo, ['status', '--porcelain=v1', '-z', '--', `:(top,literal)${file}`]);
|
|
return Boolean(r.error) || r.status !== 0 || r.stdout !== '';
|
|
}
|
|
|
|
/**
|
|
* Set `pre_existing` (a boolean) on every JSON finding of `payload`, in place.
|
|
* With no repo or no full `start`/`end`, every mark is false; a file with
|
|
* uncommitted changes is false.
|
|
* @param {{findings?: object[]}} payload
|
|
* @param {{ repo?: string, start?: string, end?: string }} range
|
|
* @returns {object} payload
|
|
*/
|
|
export function markFindings(payload, { repo, start, end } = {}) {
|
|
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
|
|
const sets = repo && start && end ? changedLineSets(repo, start, end) : notMeasured('no range');
|
|
const dirty = new Map();
|
|
for (const f of findings) {
|
|
if (!f || typeof f !== 'object') continue;
|
|
let pre = isPreExisting(f, sets);
|
|
if (pre) {
|
|
if (!dirty.has(f.file)) dirty.set(f.file, uncommitted(repo, f.file));
|
|
pre = !dirty.get(f.file);
|
|
}
|
|
f.pre_existing = pre;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* The gate's predicate for a review: true only when the finding says
|
|
* `pre_existing: true` and `changedLineSets(repo, scope_sha_start,
|
|
* scope_sha_end)` agrees through `isPreExisting`. With no repo, or no full
|
|
* `scope_sha_start`, it is always false. The sets are read once, when a marked
|
|
* finding is first asked about.
|
|
* @param {string} text - review.md
|
|
* @param {string|undefined} repo
|
|
* @returns {(f: object) => boolean}
|
|
*/
|
|
export function preExistingCheck(text, repo) {
|
|
const { start, end } = scopeShas(text);
|
|
if (!repo || !start || !end) return () => false;
|
|
let sets = null;
|
|
return (f) => {
|
|
if (f?.pre_existing !== true) return false;
|
|
if (sets === null) sets = changedLineSets(repo, start, end);
|
|
return isPreExisting(f, sets);
|
|
};
|
|
}
|