Files
gstack/lib/review-collect.ts
3d99c22497 v1.92.76.0 wave(review): #3809 #3813 #3826 #3831 #3838 (#3848)
* fix(retro): keep retro history in the state root, shared across worktrees (#3749)

/retro saved snapshots under .context/retros inside the working tree, so a run
in a linked worktree saw none of the main checkout's history, reported "First
retro recorded", and wrote a snapshot that git worktree remove deletes.

gstack-retro-metrics --history [--next <date>] now resolves the history
directory as <state root>/projects/<slug>/retros (the slug is keyed on the git
common dir, so every worktree shares it), moves any legacy .context/retros from
this worktree and the main checkout into it, lists prior snapshots and prints
the next snapshot path. Step 12/13 and the gbrain prior-retros query use it.

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

* fix(gate-diff): read a bare base as origin/<base> in gate-diff and diff-scope (#3650)

gstack-gate-diff and gstack-diff-scope resolved a bare base such as main to
the local branch whenever it shared history with HEAD. A local main that is
behind origin/main (a linked worktree, or a Capy clone, where local main
never moves) then set the merge base, so /ship's gate scan and specialist
scope also read commits already upstream. Both now prefer origin/<base>
when it resolves, the base /ship fetches and merges against.

Fixes #3650

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

* fix(retro): make history migration safe against symlinks and concurrent runs (#3749 review)

Review pass 1 findings: a .context/retros that resolves outside its worktree
(or to the shared history itself) is left alone, so migration can no longer
delete the history it points at; moves are no-clobber with retry; a copy
identical to any same-date snapshot collapses; RETRO_FILE is reserved as {}
so overlapping runs in two worktrees never share a path; --next must be a
YYYY-MM-DD date; a missing option value no longer loops forever. Step 12
names RETRO_HISTORY_ERROR instead of reading it as a stale helper.

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

* docs(changelog): fragment for the #3749 retro history fix

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

* fix: pre-landing review fixes (diff-scope reads slash-named bases through origin too)

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

* fix(retro): trim Step 12/13 prose back under the parity skeleton cap

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

* fix: Step 11 review fixes (a fork branch, a colliding tag or another remote never replaces the base)

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

* fix(retro): keep today="<today>" in Step 12 so the judge excerpt keeps both date inputs

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

* docs(changelog): describe the fork, tag and remote guards (#3650)

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

* test(retro): compare #3749 history paths with forward slashes so the Windows shard passes

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

* test(review): review-plan and gate-diff plan the same with a stale local main or a shadowing tag (GST-883, failing first)

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

* fix(review): gate-diff and review-plan resolve a branch base to origin/<base> by full ref name; review-plan scans the merge base its own diff used (GST-883)

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

* docs(changelog): GST-883 fragment

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

* test(review-risk): hostile-input time bounds for every risk pattern; the R51 rm and rmSync rules fail (GST-898)

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

* fix(review-risk): the R51 rm and rmSync rules run in linear time (GST-898)

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

* test(review-packets-removed): repin lib/review-risk.ts on the GST-898 verdict-identity replay

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

* fix(review-risk): padding cannot push recursive: true out of the rmSync window; repin on the GST-898 no-loss replay

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

* docs(changelog): GST-898 fragment wording

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

* test(review): planner misreads: SVG chart as runtime, session data as auth, bare base in diff-scope and receipt depth (GST-911, GST-884, failing first)

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

* fix(review): a drawing-only SVG is data and a scripted one is security; session data files are not auth; diff-scope and the receipt depth read origin/<base> by full ref name (GST-911, GST-884)

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

* fix(review): the SVG drawing check refuses CSS escapes and reads zero-padded character references; the security class reads a file's changed SVG lines joined (GST-911 review pass 1)

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

* test(review): a stray quote cannot hide an external href in a drawing SVG (GST-911 review pass 2, failing first)

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

* fix(review): the SVG drawing check reads every name= without consuming its value, so a stray quote cannot hide an external href (GST-911 review pass 2)

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

* test(review): CSS in a drawing SVG holds only declarations and url(#id): image-set(), image(), element() keep it code (GST-911 Step 11, failing first)

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

* fix(review): a drawing SVG's CSS holds declarations only, no @ rule and no function but url(#id), which closes image-set(), image(), element() and every other CSS load (GST-911 Step 11)

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

* test(review): a style element's CSS is read past comments and CDATA, unclosed style tags are refused in linear time, and every drawing verdict holds at 1 MB and 8 MB (GST-911, failing first)

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

* fix(review): walk a style element's CSS with indexOf, dropping comments and reading CDATA, so no hidden </style> ends it early and unclosed style tags refuse in linear time (GST-911)

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

* docs(review-policy): the linear-time claim covers R51's rm and rmSync rules (GST-898)

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

* docs(review-policy): state the rmSync padding rule exactly (GST-898)

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

* fix(review-depth): linear url() refusal in svgDrawing (wave review-2, red-team CRITICAL)

SVG_REFUSED's url() entry backtracked quadratically on url( followed by a
long whitespace run (80k spaces: 820 ms; 1 MB: over 230 s), and
svgDrawing runs in collectLive and in collectCommit, which ship-queue
ready, train build and land call with no timeout. The rewritten entry
refuses exactly what #3838's pattern refused.

Repin lib/review-depth.ts under no-loss replay GST-911-redos
(verdict identity: 60,000 seeded strings, the edges and every SVG blob
in history, zero differences).

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

* fix: pre-landing review fixes (wave review-2, pass 1)

- bin/gstack-diff-scope: _resolve_base takes a related local <base> before
  another remote's twin, the order resolveBase uses, so the two tools pick
  the same base for a bare name when origin/<base> is unrelated.
- lib/review-depth.ts: a drawing's DOCTYPE names no document but W3C's SVG
  DTD; SYSTEM and other PUBLIC identifiers keep the SVG code.

Repin lib/review-depth.ts under no-loss replay GST-911-redos (the DOCTYPE
change only keeps more SVGs code, so it drops no reviewer).

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

* fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11)

Exported only so test/review-risk-redos.test.ts can reach the url() entry;
a frozen readonly copy keeps any importer from changing verdicts while
DEPTH_RULES still reports the original rules.

Repin lib/review-depth.ts under no-loss replay GST-911-redos (no pattern
changed).

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

* Revert "fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11)"

The fix-check (wave review-2) showed Object.freeze is shallow: each RegExp
can still be changed in place, so the freeze did not close
adversarial-native#1, and it moved the tree past the one the Step 11
voices reviewed. The finding ships as an open note (its reviewer rated it
Low, not blocking; no production caller mutates the list).

Restores the lib/review-depth.ts pin under no-loss replay GST-911-redos.

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

* docs(changelog): wave review-2 entry for the integrator fixes

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

* docs(review-policy): a drawing's DOCTYPE names no outside document (wave review-2 docs audit)

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>
Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com>
2026-11-02 12:16:51 +00:00

220 lines
14 KiB
TypeScript

/**
* review-collect — the git reads behind a review depth (docs/review-policy.md#review-depth),
* kept out of lib/review-depth.ts so the policy stays pure. collectCommit reads git
* objects only (a range between two commits, from any checkout, dirty or not);
* collectLive reads the working tree as /ship reviews it (committed, tracked
* uncommitted edits and untracked files). Both return a ClassInput with per-path
* line counts, the base commit's generated-output registry and collector errors;
* the caller adds the gate-scan result.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GENERATED_PATH, parseRegistry } from './regen';
import { RESULT_CODES } from './result-codes';
import { classifyPath, dataCandidate, svgCandidate, svgDrawing, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth';
export interface Collected extends ClassInput {
perPath: PathLines[];
registry: RegistryInfo;
errors: DepthError[];
deleted: string[];
stampOnly: string[];
/** Paths the diff creates (untracked files included): their added lines are the whole file. */
created: string[];
/** Added lines per path, for scanNumbers. */
added: Record<string, string[]>;
/** Deleted lines per path, for the targeted classes (lib/review-targeted.ts). */
removed: Record<string, string[]>;
diffLines: number;
mergeBase: string | null;
untracked: string[];
consumed: string[] | 'all';
drawings: string[];
}
function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } {
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
const scratch = (p: string) => p.startsWith('.gstack/tmp/');
const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200);
const unavailable = (input: string, detail: string): RegistryInfo => ({ sha: null, outputs: [], error: { code: 'registry_unavailable', detail: oneLine(detail), input, repair: 'fetch the base branch (git fetch origin <base>) so its commit and registry resolve' } });
/** The registry as committed at `sha`: outputs of entries with a command (a `command: null` entry never excludes); a missing file is an empty registry. */
export function loadRegistryAt(root: string, sha: string): RegistryInfo {
const commit = git(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]);
if (!commit.ok) return unavailable(`${sha}:${GENERATED_PATH}`, `${sha} is not a commit in this repository`);
const resolved = commit.out.trim();
const input = `${resolved.slice(0, 12)}:${GENERATED_PATH}`;
if (!git(root, ['cat-file', '-e', `${resolved}:${GENERATED_PATH}`]).ok) return { sha: resolved, outputs: [] };
const show = git(root, ['show', `${resolved}:${GENERATED_PATH}`]);
if (!show.ok) return unavailable(input, show.err || 'git show failed');
const { registry, errors } = parseRegistry(show.out);
const e = errors[0];
if (e) return { sha: resolved, outputs: [], error: { code: 'registry_invalid', detail: oneLine(`${e.path || GENERATED_PATH}: ${e.message}`), input, repair: RESULT_CODES[e.code].fix, result: e.code } };
return { sha: resolved, outputs: registry.entries.filter(x => x.command !== null).flatMap(x => x.outputs) };
}
/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */
function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Collected, 'perPath' | 'deleted' | 'stampOnly' | 'created' | 'added' | 'removed'> {
const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]);
const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]);
const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]);
const empty = { perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} };
for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) {
if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' }); return empty; }
}
const perPath: PathLines[] = [];
for (const rec of numstat.out.split('\0').filter(Boolean)) {
const [a, d, ...rest] = rec.split('\t');
const p = rest.join('\t');
if (!scratch(p)) perPath.push({ path: p, lines: (Number(a) || 0) + (Number(d) || 0), binary: a === '-' && d === '-' });
}
const statusParts = status.out.split('\0');
const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p));
const added: Record<string, string[]> = {};
const removed: Record<string, string[]> = {};
const changed: Record<string, string[]> = {};
const created: string[] = [];
let cur = '';
let minus = '';
let inHunk = false;
for (const line of patch.out.split('\n')) {
if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; }
if (!inHunk) {
if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, '');
else if (line.startsWith('+++ ')) { cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, ''); if (!minus && cur && !scratch(cur)) created.push(cur); }
else if (line.startsWith('@@')) inHunk = true;
continue;
}
if (!cur || scratch(cur) || line.startsWith('@@')) continue;
if (line[0] === '+') (added[cur] ??= []).push(line.slice(1));
if (line[0] === '-') (removed[cur] ??= []).push(line.slice(1));
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
}
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
return { perPath, deleted, stampOnly, created, added, removed };
}
/** More data candidates than this keep every data file code: the consumer scan is not run. */
export const CONSUMER_SCAN_CAP = 2000;
/** How many data files deep a chain of data naming data is followed (a manifest naming a data file) before the rest count as consumed. */
export const CONSUMER_SCAN_DEPTH = 4;
/**
* The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names
* by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs,
* so a consumer the change removes still counts. A data file named only by another data file is consumed when that one
* is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is
* a collector error, which fails the depth closed.
*/
export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' {
const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot) || svgCandidate(p, classRoot)).sort();
if (candidates.length > CONSUMER_SCAN_CAP) return 'all';
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
const named = new Map<string, Set<string>>();
const direct = new Set<string>();
const probed = new Set<string>();
let probe = candidates;
for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) {
const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]);
const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])];
const next = new Set<string>();
for (const run of runs) {
const r = git(root, ['grep', '-I', '-F', '-o', '--null', '--no-line-number', '--no-column', '--no-color', ...patterns, ...run.args]);
if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F <data basenames> ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; }
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 (scratch(file) || classifyPath(file, classRoot) === 'docs') continue;
for (const p of probe) {
if (file === p || !match.includes(base(p))) continue;
if (!dataCandidate(file, classRoot)) { direct.add(p); continue; }
(named.get(file) ?? named.set(file, new Set()).get(file)!).add(p);
if (!probed.has(file) && !probe.includes(file)) next.add(file);
}
}
}
for (const p of probe) probed.add(p);
probe = [...next];
}
const consumed = new Set([...direct, ...probe]);
for (let grew = true; grew;) {
grew = false;
for (const [file, targets] of named) if (consumed.has(file)) for (const p of targets) if (!consumed.has(p)) { consumed.add(p); grew = true; }
}
return candidates.filter(p => consumed.has(p));
}
/** The largest SVG whose text is read for the drawing check; a larger one stays code. */
export const DRAWING_READ_CAP = 8 * 1024 * 1024;
/** The changed SVG candidates whose whole text, read by `read` (null when it cannot be read), is a drawing (lib/review-depth.ts svgDrawing). */
function drawingsAmong(perPath: readonly PathLines[], classRoot: string | readonly string[], read: (p: string) => Buffer | null): string[] {
return perPath.filter(e => !e.binary && svgCandidate(e.path, classRoot)).map(e => e.path).filter(p => {
const buf = read(p);
return !!buf && buf.length <= DRAWING_READ_CAP && !buf.includes(0) && svgDrawing(buf.toString('utf8'));
}).sort();
}
function result(diff: ReturnType<typeof readDiff>, extra: Pick<Collected, 'root' | 'registry' | 'errors' | 'mergeBase' | 'untracked' | 'consumed'> & { drawings?: string[] }): Collected {
const paths = diff.perPath.map(e => e.path).sort();
return { ...diff, drawings: [], ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) };
}
/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */
export function collectCommit(root: string, baseSha: string, headSha: string): Collected {
const errors: DepthError[] = [];
const registry = loadRegistryAt(root, baseSha);
const mb = git(root, ['merge-base', baseSha, headSha]);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin <base>) and rerun' });
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase, headSha], errors);
const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]);
const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort();
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors);
const drawings = drawingsAmong(diff.perPath, skillDirs, p => {
const blob = spawnSync('git', ['cat-file', 'blob', `${headSha}:${p}`], { cwd: root, timeout: 60_000, maxBuffer: DRAWING_READ_CAP + 1 });
return blob.status === 0 && !blob.error ? blob.stdout : null;
});
return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed, drawings });
}
/** The working tree against the merge base with origin/<base> (or <base>), untracked files counted per path; the registry is the base ref's commit. */
export function collectLive(root: string, base: string): Collected {
const errors: DepthError[] = [];
const ref = [`refs/remotes/origin/${base}`, `refs/heads/${base}`].find(r => git(root, ['rev-parse', '--verify', '-q', `${r}^{commit}`]).ok) ?? base;
const registry = loadRegistryAt(root, ref);
const mb = git(root, ['merge-base', ref, 'HEAD']);
if (!mb.ok) {
errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` });
return result({ perPath: [], deleted: [], stampOnly: [], created: [], added: {}, removed: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' });
}
const mergeBase = mb.out.trim();
const diff = readDiff(root, [mergeBase], errors);
const others = git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
if (!others.ok) errors.push({ code: 'numstat_failed', detail: oneLine(others.err || 'git ls-files failed'), input: 'git ls-files --others --exclude-standard', repair: 'run the command by hand and fix what git reports' });
const untracked = others.out.split('\0').filter(p => p && !scratch(p)).sort();
for (const p of untracked) {
let buf: Buffer;
try { buf = fs.readFileSync(path.join(root, p)); } catch { diff.perPath.push({ path: p, lines: 0, binary: true }); continue; }
const binary = buf.subarray(0, 8000).includes(0);
const text = binary ? '' : buf.toString('utf8');
const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
diff.perPath.push({ path: p, lines, binary });
if (!binary) { diff.added[p] = text.split('\n'); diff.created.push(p); }
}
const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors);
const drawings = drawingsAmong(diff.perPath, root, p => {
try { return fs.statSync(path.join(root, p)).size <= DRAWING_READ_CAP ? fs.readFileSync(path.join(root, p)) : null; } catch { return null; }
});
return result(diff, { root, registry, errors, mergeBase, untracked, consumed, drawings });
}