Files
gstack/lib/review-exclude.ts
T
Garry Tanandcapy-ai[bot] 8d276f9e31 v1.94.14.0 fix(review): review_exclude_paths keeps native binaries, NUL-bearing code and attribute-binary secret lines; exact --review-exclude docs (GST-972) (#3902)
* fix(review): review_exclude_paths keeps native binaries, NUL-bearing code and attribute-binary secret lines; exact --review-exclude docs (GST-972)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(review): GST-972 key-file fixture holds no key header the credential scan flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

attributeBinaryLines reads attribute-binary files in one git diff per argument-byte chunk instead of one per file (red-team#2). The changelog and ship-policy row say readers' own diffs still show such a kept file as binary (red-team#1, deferred as #3905).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the native-magic check covers files git reports as binary; attribute-text files are #3910

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog fragment named for the branch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: ship-policy config row names content binaries and keeps native ones (Step 14.5 audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: drop the post-review ship-policy sentence; #3910 stays in the changelog and PR notes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(review): kept reasons typed as literals for the test typecheck ratchet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review): sniff executable magic on every file git's own bytes call binary, whatever .gitattributes says; exact review_exclude docs (GST-972 round 2)

bytesBinary reads git diff --numstat with attributes from the empty tree, so a branch's '*.png diff -binary' cannot make an ELF read as text and skip the magic check (#3910). Adds 64-bit universal Mach-O magic. ship-policy's key row lists every kept class; troubleshooting and the write/queue refusal lines say a review_exclude mismatch compares the --review-exclude argument, not the review ledger.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-receipt): carry refusal names the --review-exclude digest, not what reviewers read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-03 11:01:13 +00:00

462 lines
33 KiB
TypeScript

/**
* review-exclude — the base policy's review_exclude_paths applied to one range, and the
* bounded read of each reviewer's diff (docs/ship-policy.md#review-exclude-paths, GST-508).
*
* The policy is read at the base commit the caller already resolved (loadPolicyAt), never
* from the head: a branch cannot exclude its own files. Git owns the glob dialect: every
* matched path comes from `git diff --raw -z` under one `:(top,glob)<pattern>` pathspec
* per pattern (untracked files never count), so the receipt, the plan line and what
* readers see are one set. A matched file is excluded only when it is
* content (data, docs, binary or a .log) that no rule keeps; every kept file carries one
* stable reason. Applied patterns become `:(top,exclude,glob)` pathspecs on every reader,
* and kept files under them come back through a second `:(top,literal)` command, because a
* git exclude beats any positive pathspec. That second command is bounded by its argument
* bytes: a pattern whose kept files would pass KEEP_ARG_MAX_BYTES is not applied.
*
* readSelection reads one reader's diff once, with a timeout, and counts its bytes;
* gstack-review-dispatch stops with REVIEW_DIFF_TOO_LARGE above REVIEW_DIFF_MAX_BYTES and
* with REVIEW_DIFF_UNREADABLE when git fails, before it writes a manifest or a brief.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { collectLive } from './review-collect';
import { DATA_PATH, classifyPath, isAgentProse, isDataConfig, isGatePath, type PathLines } from './review-depth';
import { secretLine } from './review-targeted';
import { cmpVersion, parseVersion } from './version-source';
import { REVIEW_EXCLUDE_KEY_VERSION, SHIP_POLICY_PATH, isUnavailable, policyUnavailableLine } from './ship-policy';
import { loadPolicyAt } from './ship-policy-at';
/** The most diff bytes one reader is handed (about 500K tokens); above it the pass stops instead of truncating. */
export const REVIEW_DIFF_MAX_BYTES = 2_000_000;
/** The most argument bytes the kept-file command may carry across all applied patterns (under Windows' 32,767-character command line). */
export const KEEP_ARG_MAX_BYTES = 24_000;
/** How long one reader's `git diff` may run. */
export const REVIEW_DIFF_TIMEOUT_MS = 120_000;
const MAX_BUFFER = 256 * 1024 * 1024;
export const KEPT_REASONS = ['policy-dir', 'gate-path', 'executable', 'agent-prose', 'config', 'gate-tagged', 'code', 'secret-line', 'named-by-code', 'consumer-scan-unavailable', 'gate-scan-unavailable', 'link'] as const;
export type KeptReason = (typeof KEPT_REASONS)[number];
/** What the receipt records and the queue and carry compare (by `digest`, never by `source`). */
export interface ExcludeRecord {
source: string; patterns: string[]; applied: string[]; skipped: Array<{ pattern: string; kept: number; bytes: number }>; unmatched: string[];
excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string;
}
export interface ActiveExclusion {
state: 'active'; record: ExcludeRecord; excluded: Set<string>; specs: string[]; keep: string[];
kept: Array<{ path: string; reason: KeptReason }>; content: Record<ContentKind, number>; references: Array<{ prefix: string; files: string[] }>;
}
export type Exclusion =
| { state: 'none' }
| { state: 'head-only'; patterns: string[]; source: string }
| { state: 'refused'; code: 'POLICY_SOURCE_UNAVAILABLE' | 'POLICY_INVALID' | 'REVIEW_DIFF_UNREADABLE'; lines: string[] }
| ActiveExclusion;
export interface ExcludeInput {
root: string;
/** The base commit whose policy applies, and the ref it was resolved from (origin/<base> or <base>). */
baseSha: string; ref: string;
/** The merge base, and the head commit; no head reads the working tree with untracked files. */
mergeBase: string; head?: string;
/** The collector's per-path lines (binary flags) and added lines (lib/review-collect.ts). */
perPath: readonly PathLines[]; added: Record<string, readonly string[]>;
/** Skill directory names (commit mode) or the repository root (live): markdown in a skill directory is code. */
classRoot: string | readonly string[];
/** Paths whose hunks the gate scan tagged; null when unknown. */
gateTagged?: ReadonlySet<string> | null;
/** The command name the refusal lines start with. */
context: string;
}
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
const literal = (p: string) => `:(top,literal)${p}`;
const argBytes = (paths: Iterable<string>) => { let n = 0; for (const p of paths) n += Buffer.byteLength(shellQuote(literal(p))) + 1; return n; };
function git(root: string, args: string[], input?: string): { ok: boolean; status: number | null; out: string; err: string } {
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, input, encoding: 'utf8', timeout: 120_000, maxBuffer: MAX_BUFFER });
return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
/** The patterns a policy text declares, or [] for anything unreadable: only a notice reads the head's copy. */
function declared(text: string | null): string[] {
try { const v = JSON.parse(text ?? 'null')?.review_exclude_paths; return Array.isArray(v) ? v.filter((p): p is string => typeof p === 'string') : []; } catch { return []; }
}
function headPatterns(i: ExcludeInput): string[] {
if (!i.head) { try { return declared(fs.readFileSync(path.join(i.root, SHIP_POLICY_PATH), 'utf8')); } catch { return []; } }
const r = git(i.root, ['show', `${i.head}:${SHIP_POLICY_PATH}`]);
return r.ok ? declared(r.out) : [];
}
/** A pattern's literal directory prefix (`receipts/` for `receipts/**`), or '' when it starts with a glob. */
export function literalPrefix(pattern: string): string {
const at = pattern.search(/[*?[]/);
const head = at < 0 ? pattern : pattern.slice(0, at);
return head.slice(0, head.lastIndexOf('/') + 1);
}
interface Matched { oldMode: string; newMode: string; oldSha: string; newSha: string }
/** Every changed path each pattern matches, with its modes, from git's raw diff; untracked files are never part of it, since no reader's git diff shows them and the receipt reads the committed head. */
function select(i: ExcludeInput, patterns: readonly string[]): { byPattern: Map<string, string[]>; files: Map<string, Matched> } | { error: string } {
const byPattern = new Map<string, string[]>();
const files = new Map<string, Matched>();
const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
for (const p of patterns) {
const spec = `:(top,glob)${p}`;
const raw = git(i.root, ['diff', '--raw', '-z', '--no-abbrev', '--no-renames', ...range, '--', spec]);
if (!raw.ok) return { error: `git diff --raw ${range.join(' ')} -- ${spec}: ${raw.err.split('\n')[0] || `exit ${raw.status}`}` };
const parts = raw.out.split('\0');
const hit: string[] = [];
for (let k = 0; k + 1 < parts.length; k += 2) {
const [oldMode, newMode, oldSha, newSha] = parts[k]!.slice(1).split(' ');
const f = parts[k + 1]!;
if (!f) continue;
hit.push(f);
if (!files.has(f)) files.set(f, { oldMode: oldMode ?? '', newMode: newMode ?? '', oldSha: oldSha ?? '', newSha: newSha ?? '' });
}
byPattern.set(p, hit);
}
return { byPattern, files };
}
/** Executable image magic: ELF, Mach-O (thin, universal and 64-bit universal; 0xcafebabe is also a Java class), PE, WebAssembly and a script's #!. */
const NATIVE_MAGIC = [[0x7f, 0x45, 0x4c, 0x46], [0xfe, 0xed, 0xfa, 0xce], [0xfe, 0xed, 0xfa, 0xcf], [0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xca, 0xfe, 0xba, 0xbf], [0x4d, 0x5a], [0x00, 0x61, 0x73, 0x6d], [0x23, 0x21]];
/**
* The matched paths git calls binary by their bytes alone: `git diff --numstat` with attributes read from the empty tree, so
* a branch's .gitattributes (`diff`, `-binary`, `text`) cannot make an executable image read as text and skip the
* magic check. null when git cannot run it (a git without --attr-source); the caller then reads every matched file's bytes.
*/
function bytesBinary(i: ExcludeInput, patterns: readonly string[]): string[] | null {
const empty = git(i.root, ['hash-object', '-t', 'tree', '--stdin'], '');
if (!empty.ok) return null;
const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
const r = git(i.root, [`--attr-source=${empty.out.trim()}`, 'diff', '--numstat', '-z', '--no-renames', ...range, '--', ...patterns.map(p => `:(top,glob)${p}`)]);
return r.ok ? r.out.split('\0').filter(l => l.startsWith('-\t-\t')).map(l => l.slice(4)) : null;
}
const nativeImage = (b: Buffer) => NATIVE_MAGIC.some(m => m.every((x, k) => b[k] === x));
/**
* The claimed paths (git calls them binary under the branch's attributes or under none: bytesBinary) whose bytes hold a NUL
* in their first 8,000 (git's own test), read from the head blob, the file on a live tree, or the old blob for a deletion.
* A branch's .gitattributes can make git call a text file binary; only real bytes make it binary content here. A binary file that starts with executable image magic also goes in `native`.
*/
function binaryContent(i: ExcludeInput, files: ReadonlyMap<string, Matched>, claimed: ReadonlySet<string>, unread: Set<string>, native: Set<string>): Set<string> {
const out = new Set<string>();
const sniff = (p: string, b: Buffer) => { if (!b.includes(0)) return; out.add(p); if (nativeImage(b)) native.add(p); };
const real = (sha: string) => /^[0-9a-f]{40,64}$/.test(sha) && !/^0+$/.test(sha);
const bySha = new Map<string, string[]>();
for (const p of claimed) {
const m = files.get(p);
if (!m) continue;
if (!i.head && m.newMode !== '000000') {
try { const fd = fs.openSync(path.join(i.root, p), 'r'); const buf = Buffer.alloc(8000); const n = fs.readSync(fd, buf, 0, 8000, 0); fs.closeSync(fd); sniff(p, buf.subarray(0, n)); } catch { unread.add(p); }
continue;
}
const sha = real(m.newSha) ? m.newSha : real(m.oldSha) ? m.oldSha : '';
if (sha) bySha.set(sha, [...(bySha.get(sha) ?? []), p]);
}
for (const [sha, paths] of bySha) {
const r = spawnSync('git', ['cat-file', 'blob', sha], { cwd: i.root, timeout: 30_000, maxBuffer: 8000 });
const read = r.status === 0 || (r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOBUFS';
const head = (r.stdout ?? Buffer.alloc(0)).subarray(0, 8000);
for (const p of paths) if (!read) unread.add(p); else sniff(p, head);
}
return out;
}
/**
* The added lines of matched files git calls binary only because of their attributes (the bytes hold no NUL): the
* collector's added lines are empty for them, so the secret-line rule reads `git diff --text` instead, which no
* .gitattributes, textconv or external diff driver changes. One git diff per KEEP_ARG_MAX_BYTES of literal pathspecs;
* git prints one section per path in byte order, so a chunk whose sections do not line up with its sorted paths, or a
* git failure, puts every path of that chunk in `unread`.
*/
function attributeBinaryLines(i: ExcludeInput, paths: readonly string[], unread: Set<string>): Record<string, string[]> {
const out: Record<string, string[]> = {};
const range = i.head ? [i.mergeBase, i.head] : [i.mergeBase];
const chunks: string[][] = [];
let bytes = Infinity;
for (const p of [...paths].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) {
const n = argBytes([p]);
if (bytes + n > KEEP_ARG_MAX_BYTES) { chunks.push([]); bytes = 0; }
chunks[chunks.length - 1]!.push(p);
bytes += n;
}
for (const chunk of chunks) {
const r = git(i.root, ['diff', '--text', '--no-textconv', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '-U0', ...range, '--', ...chunk.map(literal)]);
const sections = r.ok ? r.out.split(/^(?=diff --git )/m).filter(s => s.startsWith('diff --git ')) : [];
const aligned = sections.length === chunk.length && chunk.every((p, k) => /["\\\x00-\x1f\x7f]/.test(p) || sections[k]!.startsWith(`diff --git a/${p} b/${p}\n`));
if (!aligned) { for (const p of chunk) unread.add(p); continue; }
chunk.forEach((p, k) => { let hunk = false; out[p] = sections[k]!.split('\n').filter(l => (hunk ||= l.startsWith('@@')) && l.startsWith('+')).map(l => l.slice(1)); });
}
return out;
}
/** The kind of content a path holds when the policy may exclude it, or null for code (tests included). */
export type ContentKind = 'binary' | 'data' | 'docs' | 'log';
function contentKind(i: ExcludeInput, p: string, binary: ReadonlySet<string>): ContentKind | null {
if (binary.has(p)) return 'binary';
if (DATA_PATH.test(p) && !isDataConfig(p)) return 'data';
if (/\.log$/i.test(p)) return 'log';
return classifyPath(p, i.classRoot) === 'docs' ? 'docs' : null;
}
/**
* The only extensions a binary file may be excludable content under: images, fonts, media, documents, compressed archives
* and data blobs. Any other binary (source with a NUL, a native library or executable, a key store, a pickle, no
* extension) is code; so is one that starts with executable image magic whatever its extension.
*/
const BINARY_CONTENT_EXT = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|tiff?|heic|heif|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|ogv|webm|mov|avi|mkv|flac|aac|opus|pdf|docx|xlsx|pptx|odt|ods|odp|gz|tgz|zip|tar|bz2|xz|zst|7z|bin|dat|parquet|arrow|feather|avro|orc|npy|npz|h5|hdf5|sqlite3?|db|safetensors)$/i;
/** Why a matched file is kept by its path, mode and lines alone; null for content the policy may exclude. A binary file readers would only see as "Binary files differ" is content unless its directory, mode, gate tags, extension or magic keep it. */
function pathReason(i: ExcludeInput, p: string, m: Matched, binary: ReadonlySet<string>, native: ReadonlySet<string>): KeptReason | null {
if (/^\.(gstack|github)\//.test(p)) return 'policy-dir';
if (isGatePath(p)) return 'gate-path';
if (m.oldMode === '100755' || m.newMode === '100755') return 'executable';
if ([m.oldMode, m.newMode].some(x => x === '120000' || x === '160000')) return 'link';
if (i.gateTagged?.has(p)) return 'gate-tagged';
if (binary.has(p)) return BINARY_CONTENT_EXT.test(p) && !native.has(p) ? null : 'code';
if (isAgentProse(p)) return 'agent-prose';
if (isDataConfig(p)) return 'config';
if (contentKind(i, p, binary) === null) return 'code';
if ((i.added[p] ?? []).some(secretLine)) return 'secret-line';
return null;
}
/** A file whose full-path reference keeps excluded content: a code or test file (not docs, data, a .log or binary) outside the would-be-excluded set. */
const consumer = (i: ExcludeInput, f: string, candidates: ReadonlySet<string>) =>
!candidates.has(f) && !f.startsWith('.gstack/tmp/') && contentKind(i, f, new Set()) === null;
/** An uncapped `git grep -F -o -f` of fixed strings at the head (or the working tree) and `revs`: string -> the consumer files holding it; null when git grep failed. */
function grepFixed(i: ExcludeInput, strings: readonly string[], candidates: ReadonlySet<string>, revs: readonly string[]): Map<string, Set<string>> | null {
const found = new Map<string, Set<string>>();
if (!strings.length) return found;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-exclude-'));
try {
const list = path.join(dir, 'patterns');
fs.writeFileSync(list, strings.join('\n') + '\n');
const runs = [i.head ? { at: i.head, args: [i.head, '--'] } : { at: '', args: ['--'] }, ...revs.map(rev => ({ at: rev, args: [rev, '--'] }))];
for (const run of runs) {
const r = git(i.root, ['grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', '-f', list, ...run.args]);
if (r.status !== 0 && r.status !== 1) return null;
for (const line of r.out.split('\n')) {
const parts = line.split('\0');
const where = parts[0];
const match = parts.length > 1 ? parts[parts.length - 1] : undefined;
if (!where || !match) continue;
const file = run.at ? where.slice(run.at.length + 1) : where;
if (!consumer(i, file, candidates)) continue;
(found.get(match) ?? found.set(match, new Set()).get(match)!).add(file);
}
}
return found;
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
/**
* The base policy's exclusion for one range. `none` when neither side declares a pattern (callers then print and change
* nothing); `head-only` when only the head does (a notice, never applied); `refused` when the base policy cannot be read or
* does not validate, or git cannot list the matches; `active` otherwise. Named-by-code keeps a file only when a kept code
* file names its full repository path (never its basename: receipt trees reuse result.json thousands of times); a git grep
* failure keeps every candidate (consumer-scan-unavailable), and so does an unknown gate scan (gate-scan-unavailable).
*/
export function reviewExclude(i: ExcludeInput): Exclusion {
const load = loadPolicyAt(i.root, i.baseSha, i.ref);
if (isUnavailable(load)) return { state: 'refused', code: 'POLICY_SOURCE_UNAVAILABLE', lines: [policyUnavailableLine(i.context, load.base, load.detail)] };
const source = `${i.ref}@${i.baseSha.slice(0, 12)}`;
const base = i.ref.replace(/^origin\//, '');
if (load.errors.length) {
return { state: 'refused', code: 'POLICY_INVALID', lines: load.errors.map(e => `${i.context}: the base policy ${source}:${SHIP_POLICY_PATH} does not validate, so its review_exclude_paths cannot be read (${e.path || SHIP_POLICY_PATH}: ${e.message}); fix: run ` + '`' + `gstack-ship-policy validate --policy-from base --base ${base}` + '`' + ` and fix the file on ${base} with its own PR; nothing was planned (${e.code})`) };
}
const patterns = load.policy.review_exclude_paths;
if (!patterns.length) { const head = headPatterns(i); return head.length ? { state: 'head-only', patterns: head, source } : { state: 'none' }; }
const sel = select(i, patterns);
if ('error' in sel) return { state: 'refused', code: 'REVIEW_DIFF_UNREADABLE', lines: [`${i.context}: git could not list the files review_exclude_paths matches (${sel.error}); fix: git fetch origin ${base}, then rerun once; nothing was planned (REVIEW_DIFF_UNREADABLE)`] };
const unread = new Set<string>();
const native = new Set<string>();
const claimed = i.perPath.filter(e => e.binary && sel.files.has(e.path)).map(e => e.path);
const binary = binaryContent(i, sel.files, new Set([...claimed, ...(bytesBinary(i, patterns) ?? sel.files.keys())]), unread, native);
const text = { ...i, added: { ...i.added, ...attributeBinaryLines(i, claimed.filter(p => !binary.has(p) && !unread.has(p)), unread) } };
const reasons = new Map<string, KeptReason | null>();
for (const [p, m] of sel.files) reasons.set(p, unread.has(p) ? 'consumer-scan-unavailable' : pathReason(text, p, m, binary, native));
const candidates = new Set([...reasons].filter(([, r]) => r === null).map(([p]) => p));
const named = grepFixed(i, [...candidates].filter(p => !p.includes('\n')), candidates, [i.mergeBase]);
for (const p of candidates) {
if (i.gateTagged === null) reasons.set(p, 'gate-scan-unavailable');
else if (named === null || p.includes('\n')) reasons.set(p, 'consumer-scan-unavailable');
else if (named.has(p)) reasons.set(p, 'named-by-code');
}
const applied: string[] = [];
const skipped: ExcludeRecord['skipped'] = [];
const keep = new Set<string>();
for (const p of patterns) {
const hit = sel.byPattern.get(p) ?? [];
if (!hit.some(f => reasons.get(f) === null)) continue;
const kept = hit.filter(f => reasons.get(f) !== null);
const bytes = argBytes(new Set([...keep, ...kept]));
if (bytes > KEEP_ARG_MAX_BYTES) { skipped.push({ pattern: p, kept: kept.length, bytes }); continue; }
applied.push(p);
for (const f of kept) keep.add(f);
}
const excluded = new Set(applied.flatMap(p => (sel.byPattern.get(p) ?? []).filter(f => reasons.get(f) === null)));
const keptList = [...reasons].filter(([, r]) => r !== null).map(([p, r]) => ({ path: p, reason: r! })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const sortedExcluded = [...excluded].sort();
const content: Record<ContentKind, number> = { binary: 0, data: 0, docs: 0, log: 0 };
for (const p of sortedExcluded) content[contentKind(i, p, binary)!]++;
const prefixes = [...new Set(applied.map(literalPrefix).filter(Boolean))];
const refs = grepFixed(i, prefixes, excluded, []) ?? new Map<string, Set<string>>();
const patterns_sha256 = sha256(JSON.stringify(patterns));
const list_sha256 = sha256(JSON.stringify({ applied, excluded: sortedExcluded, kept: keptList.map(k => [k.path, k.reason]) }));
const record: ExcludeRecord = {
source, patterns: [...patterns], applied, skipped, unmatched: patterns.filter(p => !(sel.byPattern.get(p) ?? []).length),
excluded: excluded.size, excluded_bytes: excludedBytes(i, sortedExcluded, sel.files), kept: keptList.length,
gate_tagged: keptList.filter(k => k.reason === 'gate-tagged').map(k => k.path), patterns_sha256, list_sha256, digest: sha256(`${patterns_sha256}\n${list_sha256}`),
};
return {
state: 'active', record, excluded, specs: applied.map(p => `:(top,exclude,glob)${p}`), keep: [...keep].sort().map(literal), kept: keptList, content,
references: prefixes.filter(p => refs.has(p)).map(p => ({ prefix: p, files: [...refs.get(p)!].sort() })),
};
}
/** The head-side size of the excluded files: blob sizes at the head commit, file sizes on a live tree; a deletion counts 0. */
function excludedBytes(i: ExcludeInput, excluded: readonly string[], files: ReadonlyMap<string, Matched>): number {
if (!i.head) return excluded.reduce((n, p) => { try { return n + fs.statSync(path.join(i.root, p)).size; } catch { return n; } }, 0);
const shas = excluded.map(p => files.get(p)?.newSha ?? '').filter(s => /^[0-9a-f]{40,64}$/.test(s) && !/^0+$/.test(s));
if (!shas.length) return 0;
const r = git(i.root, ['cat-file', '--batch-check=%(objectsize)'], shas.join('\n') + '\n');
return r.out.split('\n').reduce((n, l) => n + (Number(l) || 0), 0);
}
/** The plan's lines for an exclusion: none prints nothing, so a repository without the key plans byte for byte as before. */
export function excludeLines(x: Exclusion, base: string): string[] {
if (x.state === 'none' || x.state === 'refused') return [];
if (x.state === 'head-only') return [`REVIEW_EXCLUDE: none (head sets review_exclude_paths; it applies after it lands on ${base}) patterns=${x.patterns.length} source=${x.source}`];
const r = x.record;
const skipped = r.skipped.length ? ` skipped=${r.skipped.map(s => `${s.pattern}:kept=${s.kept}:bytes=${s.bytes}`).join(',')} (kept files over ${KEEP_ARG_MAX_BYTES} argument bytes: pattern not applied)` : '';
const out = [`REVIEW_EXCLUDE: patterns=${r.applied.length}/${r.patterns.length} excluded=${r.excluded} bytes=${r.excluded_bytes} kept=${r.kept} content=${Object.entries(x.content).filter(([, n]) => n).map(([k, n]) => `${k}:${n}`).join(',') || 'none'} source=${r.source} digest=${r.digest}${skipped}${r.unmatched.length ? ` unmatched=${r.unmatched.join(',')}` : ''}`];
for (const k of x.kept.slice(0, 20)) out.push(`REVIEW_EXCLUDE_KEPT: ${k.reason} ${k.path}`);
if (x.kept.length > 20) out.push(`REVIEW_EXCLUDE_KEPT: +${x.kept.length - 20} more (gstack-review-plan --json lists every kept file)`);
for (const ref of x.references) out.push(`REVIEW_EXCLUDE_REFERENCE: ${ref.prefix} is named by ${ref.files.slice(0, 3).join(', ')}${ref.files.length > 3 ? ` (+${ref.files.length - 3} more)` : ''}; the files stay excluded, so readers see that code without the files it reads (REVIEW_EXCLUDE_REFERENCE)`);
return out;
}
/** The one line every brief gets under an active exclusion; it names the selection digest the receipt binds (verify's REVIEW_EXCLUDE_RECORD). */
export function disclosureLine(r: Pick<ExcludeRecord, 'source' | 'excluded' | 'excluded_bytes' | 'applied' | 'kept' | 'digest'>): string {
return `Excluded by base policy ${r.source} (selection ${r.digest}): ${r.excluded} files, ${r.excluded_bytes} bytes on this branch matching ${r.applied.join(' ') || 'no applied pattern'}. They are not in your diff; do not report on their absence. Kept despite a match: ${r.kept} files (listed in the plan).`;
}
/** The selection digest a disclosure line names, or undefined. */
export const disclosureDigest = (line: string | undefined) => line?.match(/^Excluded by base policy \S+ \(selection ([0-9a-f]{64})\)/)?.[1];
export function parseTail(tail: string | undefined): string[] {
const out: string[] = [];
const re = /'((?:[^']|'\\'')*)'|(\S+)/g;
for (let m = re.exec(tail ?? ''); m; m = re.exec(tail ?? '')) out.push(m[1] !== undefined ? m[1].replace(/'\\''/g, "'") : m[2]!);
return out;
}
export type ReaderDiff =
| { ok: true; text: string; bytes: number; byDir: Array<[string, number]> }
| { ok: false; reason: 'timeout' | 'git-exit'; exit: number | null; stderr: string; timeoutMs: number };
/** One reader's selection read once: `git diff <base> [-- pathspecs]`, then the kept-file command, with its bytes per top directory. */
export function readSelection(root: string, diffBase: string, pathspecs: readonly string[], keep: readonly string[] = [], timeoutMs = REVIEW_DIFF_TIMEOUT_MS): ReaderDiff {
let text = '';
for (const specs of [pathspecs, ...(keep.length ? [keep] : [])]) {
const tail = specs.length ? (specs[0] === '--' ? specs : ['--', ...specs]) : [];
const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff', diffBase, ...tail], { cwd: root, encoding: 'utf8', timeout: timeoutMs, maxBuffer: MAX_BUFFER });
const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ENOBUFS') { text += r.stdout ?? ''; break; }
if (code === 'ETIMEDOUT' || (r.signal && r.status === null)) return { ok: false, reason: 'timeout', exit: null, stderr: r.stderr ?? '', timeoutMs };
if (r.status !== 0) return { ok: false, reason: 'git-exit', exit: r.status, stderr: r.stderr ?? '', timeoutMs };
text += r.stdout;
}
const byDir = new Map<string, number>();
const starts = [...text.matchAll(/^diff --git a\/(\S+)/gm)];
starts.forEach((m, k) => {
const end = k + 1 < starts.length ? starts[k + 1]!.index! : text.length;
const dir = m[1]!.includes('/') ? m[1]!.split('/')[0]! : '(root)';
byDir.set(dir, (byDir.get(dir) ?? 0) + Buffer.byteLength(text.slice(m.index!, end)));
});
return { ok: true, text, bytes: Buffer.byteLength(text), byDir: [...byDir].sort((a, b) => b[1] - a[1]) };
}
/** Credential-bearing remote URLs and scp-style remotes out of git's stderr. */
export const redact = (s: string) => s.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi, '<url>').replace(/\b[\w.-]+@[\w.-]+:[^\s'"]+/g, '<url>');
/** Where the over-ceiling selection's policy stands: decides the fix the line prints. */
export interface CeilingContext { state: 'none' | 'head-only' | 'active'; base: string; source: string; floor?: string | null; headPatterns?: string[]; record?: Pick<ExcludeRecord, 'patterns' | 'skipped' | 'excluded'> }
/** The policy hunk to land, as JSON a person can paste: the patterns, and a floor at least this key's version. */
function policyJson(patterns: readonly string[], floor?: string | null): string {
const min = floor && parseVersion(floor) && cmpVersion(parseVersion(floor)!, parseVersion(REVIEW_EXCLUDE_KEY_VERSION)!) > 0 ? floor : REVIEW_EXCLUDE_KEY_VERSION;
return JSON.stringify({ review_exclude_paths: patterns, min_gstack_version: min });
}
/** Folders to suggest: the largest top directories, until what is left would fit. */
function suggest(byDir: ReadonlyArray<[string, number]>, bytes: number): string[] {
const out: string[] = [];
let left = bytes;
for (const [dir, n] of byDir) {
if (left <= REVIEW_DIFF_MAX_BYTES || out.length >= 3) break;
if (dir === '(root)') continue;
out.push(`${dir}/**`);
left -= n;
}
return out;
}
/** The REVIEW_DIFF_TOO_LARGE line and object: the reader's bytes, the largest folders and a fix chosen by where the policy stands. */
export function tooLargeLine(context: string, reader: string, d: { bytes: number; byDir: ReadonlyArray<[string, number]> }, c: CeilingContext): { line: string; json: Record<string, unknown> } {
const top = d.byDir.slice(0, 5).map(([dir, n]) => `${dir}:${n}`).join(',') || 'none';
const land = (patterns: readonly string[]) => `ask the repo owner to land ${policyJson(patterns, c.floor)} in ${SHIP_POLICY_PATH} on ${c.base} as its own PR (docs/ship-policy.md#review-exclude-paths), then bring ${c.base} into this branch and rerun once; if it is still over, split the PR`;
const dirs = suggest(d.byDir, d.bytes);
const s = c.record?.skipped[0];
const fix = c.state === 'head-only' ? `this branch declares review_exclude_paths, but only the copy on ${c.base} applies: ${land(c.headPatterns ?? [])}`
: s ? `pattern ${s.pattern} was not applied: its ${s.kept} kept files need ${s.bytes} argument bytes (more than ${KEEP_ARG_MAX_BYTES}); narrow the pattern on ${c.base} or move the kept files out of it, or split the PR`
: c.state === 'active' ? `still over after excluding ${c.record?.excluded ?? 0} files; ${dirs.length ? land([...(c.record?.patterns ?? []), ...dirs.filter(p => !c.record?.patterns.includes(p))]) : 'split the PR'}`
: dirs.length ? land(dirs) : 'split the PR';
const line = `${context}: the reader's diff is over the review ceiling (reader=${reader} bytes=${d.bytes} max=${REVIEW_DIFF_MAX_BYTES} top=${top} policy=${c.source}); fix: ${fix}; nothing was written (REVIEW_DIFF_TOO_LARGE)`;
return { line, json: { code: 'REVIEW_DIFF_TOO_LARGE', reader, bytes: d.bytes, max: REVIEW_DIFF_MAX_BYTES, top: d.byDir.slice(0, 5).map(([dir, bytes]) => ({ dir, bytes })), policy: c.source, state: c.state, fix, side_effects: 'none' } };
}
/** The REVIEW_DIFF_UNREADABLE line: timeout or git exit, the timeout, the base, a redacted first stderr line, and the whole (redacted) stderr written under `logDir`. */
export function unreadableLine(context: string, reader: string, d: Extract<ReaderDiff, { ok: false }>, diffBase: string, base: string, logDir: string): string {
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
const log = path.join(logDir, `diff-${reader.replace(/[^a-zA-Z0-9._-]/g, '_')}.stderr`);
fs.writeFileSync(log, redact(d.stderr), { mode: 0o600 });
const first = redact(d.stderr.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 200);
const fix = d.reason === 'timeout' ? `rerun once unchanged; if it times out again, split the PR or exclude its largest data folders on ${base}`
: d.exit === 128 ? `git fetch origin ${base}, then rerun once` : 'stop and ask a person: git failed in a way a rerun does not repair';
return `${context}: git could not read the reader's diff (reader=${reader} reason=${d.reason} exit=${d.exit ?? 'none'} timeout_ms=${d.timeoutMs} base=${diffBase.slice(0, 12)} stderr=${JSON.stringify(first)} log=${log}); fix: ${fix}; nothing else was written (REVIEW_DIFF_UNREADABLE)`;
}
/** The plan's review_exclude object (gstack-review-plan --json), or null for a repository without the key. */
export type PlanExclude = { state: string; patterns?: string[]; skipped?: ExcludeRecord['skipped']; excluded?: number } | null;
/**
* Where <base>'s policy stands for a REVIEW_DIFF_TOO_LARGE fix: the source commit, its min_gstack_version, and the exclusion
* state, from `known` (the plan's review_exclude, null when it had none) or, when the caller has no plan, a fresh live run.
*/
export function ceilingContext(root: string, base: string, known?: PlanExclude): CeilingContext {
const b = base.replace(/^origin\//, '');
const ref = git(root, ['rev-parse', '--verify', '-q', `origin/${b}^{commit}`]).ok ? `origin/${b}` : b;
const sha = git(root, ['rev-parse', '--verify', '-q', `${ref}^{commit}`]).out.trim();
const load = sha ? loadPolicyAt(root, sha, ref) : null;
const read = load && !isUnavailable(load) ? load : null;
const source = sha ? `${ref}@${sha.slice(0, 12)}${read && !read.present ? ` (no ${SHIP_POLICY_PATH})` : ''}` : ref;
let x = known;
if (x === undefined) {
const c = sha ? collectLive(root, b) : null;
const live = c?.mergeBase ? reviewExclude({ root, baseSha: sha, ref, mergeBase: c.mergeBase, perPath: c.perPath, added: c.added, classRoot: root, context: 'ceiling' }) : { state: 'none' as const };
x = live.state === 'active' ? { state: 'active', ...live.record } : live.state === 'head-only' ? live : null;
}
const floor = read?.policy.min_gstack_version ?? null;
if (!x) return { state: 'none', base: b, source, floor };
if (x.state === 'head-only') return { state: 'head-only', base: b, source, floor, headPatterns: x.patterns ?? [] };
return { state: 'active', base: b, source, floor, record: { patterns: x.patterns ?? [], skipped: x.skipped ?? [], excluded: x.excluded ?? 0 } };
}