Files
gstack/lib/lane-ownership.ts
Garry Tanandcapy-ai[bot] 5d647455f2 v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) (#3123)
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E)

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

* fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence

The spec --execute spawn captures `gstack-autoplan contract` before piping
the archive, so a lane never starts without the contract; the C9 fixture
installs its gstack-like checkout (bin, lib, a contract stub) at every
host's global root. Review Step 5.8 keeps the pinned
'without claiming specialist coverage' wording.

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-10-11 03:10:06 +00:00

111 lines
6.8 KiB
TypeScript

/**
* lane-ownership — which planned files an in-flight lane already changes
* (plan E2). A coordinator running several lanes gives every shared file one
* owner; `bin/gstack-lane-check` takes the files one lane plans to touch plus
* the in-flight branches and pull requests, diffs each against its merge base
* with the base branch, and reports every intersection with the PR number,
* the branch and the hunk count in that file. A branch that cannot be
* resolved is reported (`LANE_REF_UNAVAILABLE`), never assumed clear. Planned
* paths may be globs (`lib/**`); `*` and `**` are the only wildcards.
*/
import { spawnSync } from 'node:child_process';
import { globToRegExp } from './ship-policy';
export interface LaneRef { branch: string; pr: number | null; ref: string }
export interface LaneFile { path: string; hunks: number; added: number; deleted: number }
export interface LaneDiff { lane: LaneRef; head: string | null; base: string | null; files: LaneFile[]; error?: string }
export interface Intersection { planned: string; file: string; lane: LaneRef; hunks: number }
export interface LaneCheck {
schema_version: 1; base: string; planned: string[]; lanes: LaneDiff[]; intersections: Intersection[];
unresolved: LaneRef[]; verdict: 'clear' | 'conflicts' | 'unresolved';
}
function git(cwd: string, args: string[], opts: { maxBuffer?: number } = {}): { ok: boolean; out: string } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024 });
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, '') };
}
/** Resolve a PR number to its head branch through `gh`; null when gh cannot answer. */
export function prHeadBranch(cwd: string, pr: number): { branch: string } | { error: string } {
const r = spawnSync('gh', ['pr', 'view', String(pr), '--json', 'headRefName,state'], { cwd, encoding: 'utf8', timeout: 60_000 });
if (r.status !== 0) return { error: (r.stderr ?? '').trim() || `gh pr view ${pr} failed` };
try {
const parsed = JSON.parse(r.stdout) as { headRefName?: string; state?: string };
if (!parsed.headRefName) return { error: `PR #${pr} has no head branch` };
return { branch: parsed.headRefName };
} catch (e: any) { return { error: `PR #${pr}: ${e.message}` }; }
}
/** The branch's ref to diff: a fetched remote-tracking ref when present, else the local branch. */
export function resolveRef(cwd: string, branch: string, remote = 'origin'): string | null {
for (const candidate of [branch.startsWith(`${remote}/`) ? branch : `${remote}/${branch}`, branch]) {
if (git(cwd, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`]).ok) return candidate;
}
return null;
}
/** Files a lane changes against its merge base with `base`, each with its hunk count. */
export function diffLane(cwd: string, lane: LaneRef, base: string): LaneDiff {
const head = git(cwd, ['rev-parse', lane.ref]);
if (!head.ok) return { lane, head: null, base: null, files: [], error: `${lane.ref} is not a commit` };
const mb = git(cwd, ['merge-base', base, lane.ref]);
if (!mb.ok) return { lane, head: head.out, base: null, files: [], error: `no merge base between ${base} and ${lane.ref}` };
const numstat = git(cwd, ['diff', '--numstat', `${mb.out}..${lane.ref}`]);
if (!numstat.ok) return { lane, head: head.out, base: mb.out, files: [], error: `git diff failed for ${lane.ref}` };
const files: LaneFile[] = [];
for (const line of numstat.out.split('\n').filter(Boolean)) {
const [added, deleted, ...rest] = line.split('\t');
const file = rest.join('\t');
const hunkDiff = git(cwd, ['diff', '-U0', `${mb.out}..${lane.ref}`, '--', file]);
const hunks = hunkDiff.ok ? hunkDiff.out.split('\n').filter(l => l.startsWith('@@')).length : 0;
files.push({ path: file, hunks: Math.max(hunks, 1), added: added === '-' ? 0 : Number(added), deleted: deleted === '-' ? 0 : Number(deleted) });
}
return { lane, head: head.out, base: mb.out, files };
}
export interface LaneCheckOptions { cwd: string; planned: string[]; branches?: string[]; prs?: number[]; base?: string; remote?: string; resolvePr?: (pr: number) => { branch: string } | { error: string } }
export function laneCheck(o: LaneCheckOptions): LaneCheck {
const remote = o.remote ?? 'origin';
const base = o.base ?? (resolveRef(o.cwd, 'HEAD', remote) && git(o.cwd, ['rev-parse', '--abbrev-ref', `${remote}/HEAD`]).ok
? git(o.cwd, ['rev-parse', '--abbrev-ref', `${remote}/HEAD`]).out : `${remote}/main`);
const planned = [...new Set(o.planned.map(p => p.trim()).filter(Boolean))];
const lanes: LaneDiff[] = [];
const unresolved: LaneRef[] = [];
const refs: LaneRef[] = (o.branches ?? []).map(b => ({ branch: b, pr: null, ref: b }));
for (const pr of o.prs ?? []) {
const r = (o.resolvePr ?? (n => prHeadBranch(o.cwd, n)))(pr);
if ('error' in r) { const lane = { branch: `#${pr}`, pr, ref: `#${pr}` }; unresolved.push(lane); lanes.push({ lane, head: null, base: null, files: [], error: r.error }); continue; }
const existing = refs.find(x => x.branch === r.branch);
if (existing) existing.pr = pr; else refs.push({ branch: r.branch, pr, ref: r.branch });
}
for (const lane of refs) {
const ref = resolveRef(o.cwd, lane.branch, remote);
if (!ref) { unresolved.push(lane); lanes.push({ lane, head: null, base: null, files: [], error: `${lane.branch} is not fetched (git fetch ${remote} ${lane.branch})` }); continue; }
const diff = diffLane(o.cwd, { ...lane, ref }, base);
if (diff.error) unresolved.push(diff.lane);
lanes.push(diff);
}
const matchers = planned.map(p => ({ planned: p, re: globToRegExp(p) }));
const intersections: Intersection[] = [];
for (const lane of lanes) for (const f of lane.files) for (const m of matchers) {
if (m.re.test(f.path)) intersections.push({ planned: m.planned, file: f.path, lane: lane.lane, hunks: f.hunks });
}
const verdict = intersections.length ? 'conflicts' : unresolved.length ? 'unresolved' : 'clear';
return { schema_version: 1, base, planned, lanes, intersections, unresolved, verdict };
}
export function renderLaneCheck(c: LaneCheck): string {
const lines: string[] = [];
for (const lane of c.lanes) {
const id = lane.lane.pr !== null ? `PR #${lane.lane.pr} (${lane.lane.branch})` : lane.lane.branch;
lines.push(lane.error ? `LANE: ${id} unresolved: ${lane.error}` : `LANE: ${id} head=${lane.head!.slice(0, 12)} files=${lane.files.length}`);
}
for (const x of c.intersections) {
const id = x.lane.pr !== null ? `PR #${x.lane.pr} (${x.lane.branch})` : x.lane.branch;
lines.push(`LANE_CONFLICT: ${x.file} planned=${x.planned} in-flight=${id} hunks=${x.hunks}`);
}
lines.push(`LANE_CHECK: ${c.verdict} planned=${c.planned.length} lanes=${c.lanes.length} intersections=${c.intersections.length} unresolved=${c.unresolved.length} base=${c.base}`);
return lines.join('\n') + '\n';
}