ship accept: a lanes-only head policy gates the final tree; owner-granted test-lane exception (GST-1595) (#4061)

* ship accept: a lanes-only head policy gates the final tree; name bun-hidden JUnit suites; owner-granted test-lane exception (GST-1595)

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

* fix: pre-landing review fixes

Each excepted file is preflighted on its own and must run a counted test; the preflighted files count toward the lane's tests_ran; grants are kept per head and file set; lanesOnlyHeadPolicy compares .gstack/generated.json with the fork copy.

Review-Finding: v1/outside-voice#1 reader=outside-voice
Review-Finding: v1/outside-voice#2 reader=outside-voice
Review-Finding: v1/outside-voice#3 reader=outside-voice
Review-Finding: v1/adversarial-native#2 reader=adversarial-native
Review-Finding: v1/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: lanesOnlyHeadPolicy compares generated.json with the base tip again

A --policy-from head run loads the regen registry from the branch (lib/pregate/context.ts), so a branch whose .gstack/generated.json differs from main's tip keeps the base policy; comparing with the fork copy would gate under a stale registry.

Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: troubleshooting entries for the accept head policy and test-lane exceptions (GST-1595)

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-06 10:16:00 +00:00
1 parent 3c7ff030b2
commit 05114c8049
20 files changed
+624 -48

No files matched your search

+24 -3
View File
@@ -9,6 +9,7 @@
* gstack-approvals render [--no-heading] [--full] [--branch <name>]
* gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled> [--json]
* gstack-approvals rulings [--branch <name>]
* gstack-approvals lane-exception --head <pushed head> --files <f1,f2> --reason <why> --words <exact words> --by <who granted> [--json]
* gstack-approvals list [--json] [--branch <name>]
* gstack-approvals sync [--branch <name>]
* gstack-approvals path [--branch <name>]
@@ -26,6 +27,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes';
import { resultLine } from '../lib/result-codes';
import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception';
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
@@ -39,6 +41,11 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs
counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body
list it; refused inside this checkout's open /ship run
rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry
lane-exception --head <the pushed head the owner granted at> --files <the uncovered inventory files, comma-separated>
--reason <why, one line> --words <the owner's exact words> --by <who granted>
the owner's grant that a CI-carried lane counts although exactly those files are in no run's tree.json
(docs/ship-policy.md#test-lane-exceptions); gstack-evidence record-ci binds it only at that head, for that exact
set, after a local preflight of those files passes; refused inside this checkout's open /ship run
render [--no-heading] [--full] the ## Approvals section for a PR body or report; a public repository, or one whose
visibility gh cannot read, gets only "Approved: yes|no (record <id>)", because a PR description keeps
its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private
@@ -53,7 +60,7 @@ Example:
${exitTableHelp()}`;
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head']);
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files']);
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
const flags: Record<string, string> = {};
@@ -121,9 +128,22 @@ function main(argv: string[]): number {
const json = bools.has('--json');
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
if (cmd === 'add' || cmd === 'import' || cmd === 'rule') {
if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception') {
let rows;
if (cmd === 'rule') {
if (cmd === 'lane-exception') {
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
const files = [...new Set((flags['--files'] ?? '').split(',').map(f => f.trim().replace(/^(\.\/)+/, '')).filter(Boolean))].sort();
const reason = (flags['--reason'] ?? '').trim();
if (!files.length || files.some(f => /\s/.test(f)) || !reason || /[\x00-\x1f\x7f]/.test(reason)) return invalid('lane-exception needs --files <the uncovered inventory files, comma-separated, no spaces> and --reason <why, one line>');
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
if (!flags['--head'] || head.status !== 0) return invalid('lane-exception needs --head <the pushed head the owner granted at, a commit in this repository>');
if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a test-lane exception is the owner\'s, so --by names who granted it');
const open = openShipRun(process.cwd());
if (open) return invalid(`/ship run ${open} is open on this checkout: a test-lane exception is the owner's, never the lane's own runner's; record it from the owner's session`);
const built = buildRow({ ...testLaneExceptionRow(head.stdout.trim(), files, reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
} else if (cmd === 'rule') {
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
const finding = (flags['--finding'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim();
if (!finding || /\s/.test(finding) || !reason) return invalid('rule needs --finding <the finding\'s fingerprint, no spaces> and --reason <why it ships as a note>');
@@ -138,6 +158,7 @@ function main(argv: string[]): number {
rows = [built];
} else if (cmd === 'add') {
if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' });
if (TEST_LANE_EXCEPTION_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a test_lane_exception scope is recorded with gstack-approvals lane-exception' });
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
if ('code' in built) return fail(cmd, built);
rows = [built];
+35 -7
View File
@@ -100,6 +100,7 @@ import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/
import { DEFAULT_MAX_AGE_HOURS, runChanged } from "../lib/run-evidence";
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
import { buildRunCheck } from "../lib/pregate/run-check";
import { readTestLaneExceptions, testLaneExceptionText, type BoundLaneException } from "../lib/test-lane-exception";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
const LOG_MAX_BYTES = 2 * 1024 * 1024;
@@ -140,6 +141,8 @@ interface EvidenceRecord {
not_owed?: string;
/** The per_file run of the inventory files CI skipped entirely; not_owed: the ones that skipped every test here too (GST-1314). */
skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown"; not_owed?: string[] };
/** The owner's test-lane exception that let a lane carry with uncovered inventory files, and the preflight of exactly them here (GST-1595). */
exception?: BoundLaneException;
}
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
@@ -721,8 +724,9 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
console.error("usage: gstack-evidence record-ci --label <L> --base <base> -- <command>");
return 2;
}
const none = (reason: string, detail: string) => {
const none = (reason: string, detail: string, diagnosis: string[] = []) => {
console.error(`CARRY_NONE: ${reason}: ${detail}`);
for (const d of diagnosis) console.error(`CARRY_DIAGNOSIS: ${d}`);
console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`);
return 1;
};
@@ -738,8 +742,29 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
const tree = git(["rev-parse", "HEAD^{tree}"]);
if (!head || !tree) return none("no_head", "HEAD is unreadable");
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
if (!v.ok) return none(v.reason, v.detail);
const grants = readTestLaneExceptions(cwd);
const atHead = grants.filter((g) => g.head === head);
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd), ...(atHead.length ? { excepted: atHead } : {}) });
const elsewhere = grants.filter((g) => g.head !== head).map((g) => `${g.approval} at ${g.head.slice(0, 12)}`);
if (!v.ok) return none(v.reason, `${v.detail}${v.reason === "inventory_uncovered" && !atHead.length && elsewhere.length ? `; test-lane exception ${elsewhere.join(", ")} binds another head, not HEAD ${head.slice(0, 12)}` : ""}`, v.diagnosis);
let exception: BoundLaneException | undefined;
const grant = atHead.find((g) => g.approval === v.exceptedBy);
if (v.excepted?.length && grant) {
const preLabel = `${label}.excepted`;
console.error(`gstack-evidence record-ci: test-lane exception ${grant.approval}: preflighting ${v.excepted.length} uncovered file(s) here at ${head.slice(0, 12)}, one at a time (label=${preLabel})`);
let ran = 0;
for (const file of v.excepted) {
const preCommand = perFileCommand(m.per_file!, [file]);
const exit = await cmdRun(["--label", preLabel, "--", preCommand]);
let rows: EvidenceRecord[] = [];
try { rows = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { rows = []; }
const n = rows.findLast((r) => r.label === preLabel && r.source === undefined && r.cmd_sha256 === sha256(preCommand) && r.wtree === tree)?.tests_ran ?? "unknown";
if (exit !== 0 || typeof n !== "number" || n <= 0) return none("exception_preflight_failed", `test-lane exception ${grant.approval}'s preflight of ${file} ${exit !== 0 ? `exited ${exit}` : `ran ${n} tests`} here (label=${preLabel}); the exception binds only when each of ${v.excepted.join(", ")} runs and passes at HEAD`);
ran += n;
}
exception = { ...grant, preflight: { label: preLabel, tests_ran: ran } };
}
const testsRan = exception && typeof v.tests === "number" ? v.tests + v.excepted!.length : v.tests;
let here: EvidenceRecord["skipped_here"];
if (v.remainder?.length) {
const hereLabel = `${label}.skipped`;
@@ -760,11 +785,13 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
}
here = { label: hereLabel, files: v.remainder, tests_ran: ran, ...(notOwed.length ? { not_owed: notOwed } : {}) };
if (notOwed.length) console.error(`gstack-evidence record-ci: not owed here: ${notOwed.length} remainder file(s) skipped every test on CI and here too (env- or tool-gated by design): ${notOwed.join(", ")}`);
}
if (here || exception) {
for (const e of v.runs) {
const changed = runChanged(check.deps, e.run_id, e.run_attempt);
if (changed) return none("run_changed", changed);
}
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`);
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${here?.label ?? exception!.preflight.label} ran`);
}
let paths: ReturnType<typeof ledgerPath>;
try {
@@ -777,11 +804,12 @@ async function cmdRecordCi(argv: string[]): Promise<number> {
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
const rec: EvidenceRecord = {
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}),
commit: head, tree, dirty: false, wtree: tree, tests_ran: testsRan, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}), ...(exception ? { exception } : {}),
};
appendJsonl(paths.file, rec, { mode: 0o600 });
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`);
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${testsRan}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}${here.not_owed ? ` not_owed_here=${here.not_owed.length}` : ""}` : ""}`);
if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`);
if (exception) console.error(`TEST_LANE_EXCEPTION: ${testLaneExceptionText(exception)}`);
return 0;
}
@@ -804,7 +832,7 @@ function cmdCarried(argv: string[]): number {
for (const r of records) latest.set(r.label, r);
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)
&& gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: wtree }).verdict === "FRESH")
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree!, ...(r.exception ? { exception: r.exception } : {}) }; }));
if (argv.includes("--json")) console.log(JSON.stringify(carried));
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
return 0;
+5 -1
View File
@@ -57,6 +57,7 @@ import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
import { resolveStateRoot } from '../lib/state-root';
import { readPause, receiptBaseRed } from '../lib/base-red';
import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes';
import { laneExceptionBodyLines } from '../lib/test-lane-exception';
import { readMechanicalLines } from '../lib/ship-mechanical';
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
@@ -265,6 +266,9 @@ function cmdWrite(rest: string[], json: boolean): number {
const ruledNotes = readRuledNotes(repoRoot);
const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
const carried = ciCarried(base);
const unlistedException = bodyFile ? laneExceptionBodyLines(carried).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlistedException) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedException)} (accept's TEST_LANE_EXCEPTION: line; the body carries it as one line); nothing written`) + '\n'); return EXIT.fail; }
const mechanical = readMechanicalLines(repoRoot, git(['rev-parse', `${head}^{tree}`], repoRoot) ?? '');
const unlistedRetake = bodyFile ? mechanical.find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
if (unlistedRetake) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedRetake)} (finish's MECHANICAL: lines; the body carries each as one line); nothing written`) + '\n'); return EXIT.fail; }
@@ -317,7 +321,7 @@ function cmdWrite(rest: string[], json: boolean): number {
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base),
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: carried,
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
@@ -0,0 +1 @@
- **`/ship` accept gates under a branch's own pregate policy when it only adds test lanes, says why a carried lane's test file is missing from CI's JUnit tree, and takes an owner-granted test-lane exception.** On gstack-evals #240 a PR that added a runtime file and its own lane line passed preflight under `--policy-from head`, then `gstack-ship-release accept` reran the pregate under the base policy and refused with `PREGATE_NO_LANE`. `accept`, and `finish` when it reruns the whole pregate, now pass `--policy-from head` when the branch's `.gstack/pregate.json` differs from the copy it forked from in nothing but added test lanes and `.gstack/generated.json` is unchanged, printing `POLICY: head …`; a removed or shrunk lane, or any other changed key, keeps the base policy with `POLICY: base … (the branch's copy <what differs> …)`. The same PR then stopped on `inventory_uncovered`: bun registers an imported test file's tests under the importer, so its JUnit suite never appears. The refusal now adds `CARRY_DIAGNOSIS: bun registers <imported> under <importer>; its JUnit suite won't appear`, and the owner can record `gstack-approvals lane-exception --head <pushed head> --files <the uncovered files> --reason … --words … --by …` (refused inside the lane's open /ship run, by gstack, or through `add`). `gstack-evidence record-ci` binds it only at that head for exactly those files, after a local preflight of them with the lane's `per_file` runner passes; `accept` prints `TEST_LANE_EXCEPTION:`, the receipt's `ci_carried` entry records the grant and the preflight, and `gstack-ship-receipt write --body` refuses a body without the `Test-lane exception: …` line. See docs/pregate.md#accept-head-policy and docs/ship-policy.md#test-lane-exceptions. (GST-1595)
+40 -1
View File
@@ -39,7 +39,24 @@ read from the branch under `--policy-from head`. So an allowlist entry or a
`[]` declaration a branch adds counts only once it is on the base, and a lane it
adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line
says so when the branch edits the file
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). An unresolvable base exits 3 with
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)).
<a id="accept-head-policy"></a>/ship's reruns on the final tree (`gstack-ship-release accept`, and `finish`
when it reruns the whole pregate on the stamped tree) choose the policy themselves
(GST-1595). When the branch's `pregate.json` differs from the copy it forked from
(the base tip's when the fork has none) in nothing but added test lanes, and
`.gstack/generated.json` matches the base tip's copy (a head-policy run loads the
regen registry from the branch, so a copy that main changed after the fork keeps
the base policy), they pass `--policy-from head` and print
`POLICY: head (unreviewed) .gstack/pregate.json: adds <n> test lane(s) over
origin/<base> and changes nothing else, so the gate is only stricter`. So a PR
that adds a runtime file and its own lane line passes `accept` without a
lanes-only PR first (gstack-evals #240). Any other difference (a removed or
shrunk lane, a `[]` declaration, `warn`, `timeout_s`, `ship_lanes_from`, any
other key, a changed `generated.json`, a file that does not parse) keeps the
base policy as before, with `POLICY: base origin/<base>:.gstack/pregate.json (the
branch's copy <what differs>, so it does not count here; land it on <base>
first)`. A branch that leaves the file alone prints no `POLICY:` line.
An unresolvable base exits 3 with
`POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute
paths, at most 64 per list); commands are one line under 512 bytes.
@@ -1202,6 +1219,28 @@ the same run. `gstack-ship-receipt write` records the carried runs as
`ci_carried: [{ lane, run, attempt, tree }]`, and the `SHIP_RECEIPT:` line names
them (`ci_carried=<lane>:<run>/<attempt>`).
<a id="carry-imported-tests"></a>**An inventory file CI's JUnit tree never lists (GST-1595).** bun registers
the tests of a test file another test file imports under the importer, so the
imported file's own JUnit suite never appears in `tree.json` and the carry
refuses with `inventory_uncovered`. For each such file another inventory file
imports by a relative path (`import`, `import()`, `require`), the refusal adds
`CARRY_DIAGNOSIS: bun registers <imported> under <importer>; its JUnit suite
won't appear` (`lib/ci-carry.ts` `importedTestLines`), printed by `gstack-evidence
record-ci`, `gstack-ship-preflight` and `accept`; the remote gate's
`inventory_uncovered` reason carries the same words. The fix is usually to move
the shared code out of the `.test.` file. When that can't happen in this PR,
the owner can grant a [test-lane exception](ship-policy.md#test-lane-exceptions)
for exactly those files at the pushed head: `record-ci` then runs each of them
here, one file at a time, with the entry's `per_file` runner (label
`<lane>.excepted`) and carries the lane only when every file's run passes with a
counted test; the lane's `tests_ran` counts those files beside the ones CI
covered. A failed or zero-test run of any file refuses with
`exception_preflight_failed`, and a grant at another head or for another file
set binds nothing. The CI row and the receipt's `ci_carried` entry
keep the grant and the preflight (`exception`), and `accept` prints
`TEST_LANE_EXCEPTION: <files> at <head12>: preflight <label> ran <n> test(s)
here; granted by <who> at <time>: <reason> (approval <id>)`.
**`gstack-ci-carry`.** `find [--profile <name>]… [--command <exact lane command>
--allow-repo-commands]` prints the verdict for HEAD; `wait [--timeout <s>]`
polls the newest run at HEAD until it completes (run it in the background) and
+32
View File
@@ -1645,6 +1645,38 @@ Pre-Landing Review and the PR body, `gstack-ship-receipt write` records them as
`ruled_notes` (each with its `head`) and refuses a `--body` that does not carry each
line word for word, and the READY block prints each as `RULED_NOTE:`.
<a id="test-lane-exceptions"></a>
**Test-lane exceptions (GST-1595).** A lane `accept` carries from CI refuses with
`inventory_uncovered` when an inventory file is in no run's `tree.json`; when bun
hid it under an importing test file, the refusal says so
([pregate.md](pregate.md#carry-imported-tests)). The owner, never the lane, can
grant that the lane counts anyway at one pushed head, for exactly those files,
through the same approval record as a ruling:
```bash
gstack-approvals lane-exception --head <pushed head> --files <f1,f2> \
--reason '<why, one line>' --words '<the owner's exact words>' --by '<who granted>'
```
The row is an approved `gstack-approvals` row with scope `test_lane_exception <head>
<files>` and ask `test_lane_exception at <head>: <files>: <reason>`
(`lib/test-lane-exception.ts`). `lane-exception` refuses a head that is not a
commit, a file with whitespace, a multi-line reason, a `--by` naming gstack and
any grant while this checkout holds an open /ship run's marker, and `add` refuses
the scope, so the lane's own runner cannot write one; a row by gstack is ignored
wherever it came from. `gstack-evidence record-ci` binds a grant only when its
head is HEAD and its files are exactly the uncovered set, and only after a local
preflight of each of those files, one at a time, with the lane's `per_file` runner
passes with a counted test at that head (a lane with no `per_file` runner cannot
use one). Grants are kept per head and file set (the newest wins), so two lanes at
one head each bind their own. Nothing about it is
hidden: `accept` prints `TEST_LANE_EXCEPTION: …` and names the body line in its
`NEXT:`, the receipt's `ci_carried` entry records `exception` (head, files, by,
reason, time, approval id and the preflight's label and test count),
`gstack-ship-receipt write --body` refuses a body that does not carry
`Test-lane exception: …` word for word, and the READY block prints each as
`TEST_LANE_EXCEPTION:`. A new push needs a new grant at the new head.
`gstack-review-scope summary --since <REVIEW_SINCE> --run <run dir>` reads the branch's review-log
rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
+31 -6
View File
@@ -1940,8 +1940,17 @@ The guide is `capy/skill/gstack/captain.md`.
`--by`), holds a value outside its enum (`--outcome approved|rejected|pending|standing`,
a `--date` that is not a real `YYYY-MM-DD` day) or exceeds a length cap (ask, scope and
pending 300 characters, words 2,000). Exit 2. Nothing was written.
`rule` and `lane-exception` refuse with this code too: a `--head` that is not
a commit in the repository, a reason that is missing or holds a newline or
control character, a `--by` naming gstack, any record while this checkout
holds an open /ship run, and for `lane-exception` a `--files` list that is
empty or holds whitespace. `add` refuses a `ruled_note` or
`test_lane_exception` scope ([ruled notes](ship-policy.md#ruled-notes),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Rerun with every required flag inside the caps the line names.
**Fix.** Rerun with every required flag inside the caps the line names. A
ruling or a test-lane exception is the owner's: record it from the owner's
session, outside the lane's /ship run.
<a id="approval-record-secret"></a>
### `gstack-approvals: ... (APPROVAL_RECORD_SECRET)`
@@ -3496,10 +3505,17 @@ scan tags included), or whose product patch is not the receipt's and the change
since it is more than plain docs. A docs-only change after the read carries the
record only when it changes no binary file, adds no numeric claim, touches no
results, prereg or eval path, and names no money, security, secret or auth topic; any other docs change
needs the reader again (GST-1195).
needs the reader again (GST-1195). With `--body`, write also refuses, writing
nothing, a body that does not carry each line the receipt requires as one line,
word for word: every `Ruled note: …` line `gstack-approvals rulings` prints,
finish's `MECHANICAL:` lines as `Mechanical retake: …`, and accept's
`TEST_LANE_EXCEPTION:` lines as `Test-lane exception: …`
([ruled notes](ship-policy.md#ruled-notes),
[test-lane exceptions](ship-policy.md#test-lane-exceptions)).
**Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the
block. For the `--head` refusal, check out that commit and write there. For a
block. For a missing body line, add the line the refusal quotes to the body and
write again. For the `--head` refusal, check out that commit and write there. For a
targeted refusal, run the targeted reader on HEAD, `gstack-review-dispatch verify
--targeted-out .gstack/tmp/targeted.json`, and write again with
`--review-targeted @.gstack/tmp/targeted.json`.
@@ -4146,6 +4162,14 @@ two routes:
An allowlist entry or a `[]` declaration the branch adds to its own
`pregate.json` counts only once it is on the base.
/ship's own reruns on the final tree (`gstack-ship-release accept`, and
`finish` when it reruns the whole pregate) pass `--policy-from head`
themselves when the branch's copy differs from the one it forked from in
nothing but added test lanes and `.gstack/generated.json` matches the base
tip's, and print `POLICY: head (unreviewed) …`; any other difference keeps the
base policy and prints `POLICY: base … (the branch's copy <what differs> …)`
([accept's policy choice](pregate.md#accept-head-policy), GST-1595).
<a id="pregate-head-policy-loosens"></a>
### `PREGATE_HEAD_POLICY_LOOSENS`
@@ -6536,9 +6560,10 @@ body that names a counter the receipt records (`passes=3` against
against `gate.passed: 41`) with a different value. The receipt is written from
the run; the prose was typed, so a reader of the body would learn a number the
run did not produce. The `PROSE:` line names each field with both values. The
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`
and `Documentation exception: …` lines the receipt's exceptions require, and
the inside of a closed code fence are never read as claims.
`## Approvals` table `gstack-approvals` renders, the `Review-loop exception: …`,
`Documentation exception: …`, `Ruled note: …`, `Mechanical retake: …` and
`Test-lane exception: …` lines the receipt requires, and the inside of a
closed code fence are never read as claims.
**Fix.** Correct the body to the receipt's number. When the receipt is the
stale one (the run was repeated), rerun `/ship` so the receipt is rewritten
+59 -13
View File
@@ -27,18 +27,21 @@
* bounded by a timeout, and returns green, red with the failing jobs, still
* running, or none.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { normalPath, runChanged, runInventory, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RunDeps, type RunEvidence, type RunProfile, type RunSource } from './run-evidence';
export type CarryRefusal = 'dirty_tree' | 'no_profile' | 'no_run' | 'no_verified_run' | 'tree_mismatch' | 'no_tests'
| 'inventory_failed' | 'inventory_empty' | 'inventory_uncovered' | 'inventory_skipped' | 'run_changed' | 'tree_changed' | 'api_error';
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
/** `diagnosis`: one line per uncovered inventory file another inventory file imports (importedTestLines). */
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string; diagnosis?: string[] };
/**
* `tests` never claims a whole run's count for a narrow lane: with an inventory it is the number of the lane's inventory files
* the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only
* without either (the whole run carried) the runs' test count. `remainder`: inventory files every run skipped entirely
* (tree.json `skipped_files`), which the caller must run here with the lane's per_file runner before the lane counts.
*/
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; receipt: string } | CarryRefused;
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; remainder?: string[]; excepted?: string[]; exceptedBy?: string; receipt: string } | CarryRefused;
/** How many of HEAD's runs per workflow are tried, newest first. */
const CANDIDATES = 10;
@@ -63,11 +66,17 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
* The CI evidence for HEAD's tree under `profiles` (one verified run each), or why there is none. `wtree` reads the
* working-tree fingerprint (before the lookup and after it); `inventory` lists the files the runs must cover, or `jobs`
* names the jobs that must have succeeded in each run's newest attempt (a lane with no test files; no test count needed).
* `excepted` holds the owner's test-lane exceptions at HEAD (lib/test-lane-exception.ts): when the inventory files no run lists
* are exactly one grant's files, they come back as `excepted` (with that grant's `exceptedBy`) for the caller to preflight here
* with the per_file runner instead of refusing the lane (GST-1595).
*/
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean }): CarryVerdict {
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean;
importers?: (missing: string[], listed: string[]) => string[]; excepted?: { files: string[]; approval: string }[] }): CarryVerdict {
const jobs = o.jobs ?? [];
let covered: number | undefined;
let remainder: string[] = [];
let excused: string[] = [];
let exceptedBy: string | undefined;
const before = o.wtree();
if (before !== o.tree) return no('dirty_tree', `the working tree (${before ? before.slice(0, 12) : 'fingerprint unavailable'}) is not HEAD's committed tree ${o.tree.slice(0, 12)}; only a clean, committed tree carries`);
if (!o.profiles.length) return no('no_profile', 'no run profile is named');
@@ -90,10 +99,19 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
const absent = uncoveredFiles(listed, runs, 'covered');
const some = (fs: string[]) => `${fs.slice(0, 5).join(', ')}${fs.length > 5 ? ', …' : ''}`;
if (absent.length) return no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}`);
remainder = uncoveredFiles(listed, runs, 'uncovered');
const grants = o.excepted ?? [];
const granted = absent.length ? grants.find(g => sameFiles(absent, g.files)) : undefined;
if (absent.length && (!granted || !o.perFile)) {
const grant = !grants.length ? '' : granted ? `; test-lane exception ${granted.approval} names them, but the lane declares no per_file runner to preflight them here`
: `; test-lane exception ${grants.map(g => `${g.approval} names ${some(g.files)}`).join('; ')}, not exactly these`;
const diagnosis = o.importers?.(absent, listed) ?? [];
return { ...no('inventory_uncovered', `${absent.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${some(absent)}${grant}`), ...(diagnosis.length ? { diagnosis } : {}) };
}
excused = absent;
exceptedBy = granted?.approval;
remainder = uncoveredFiles(listed, runs, 'uncovered').filter(f => !excused.includes(f));
if (remainder.length && !o.perFile) return no('inventory_skipped', `${remainder.length} of ${listed.length} inventory file(s) skipped every test in CI and the lane declares no per_file runner: ${some(remainder)}`);
covered = new Set(listed.map(normalPath)).size - remainder.length;
covered = new Set(listed.map(normalPath)).size - remainder.length - excused.length;
}
for (const e of runs) {
const changed = runChanged(src.deps, e.run_id, e.run_attempt);
@@ -110,7 +128,7 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined;
return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` };
}
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''} ${runReceiptFields(runs, src.now)}` };
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', ...(remainder.length ? { remainder } : {}), ...(excused.length ? { excepted: excused, exceptedBy } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered}${remainder.length ? ` skipped_here=${remainder.length}` : ''}${excused.length ? ` excepted=${excused.length} approval=${exceptedBy}` : ''} ${runReceiptFields(runs, src.now)}` };
const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` };
}
@@ -150,23 +168,51 @@ export function laneMapping(from: Record<string, ShipLaneMapping>, command: stri
}
/** findCarry's coverage rule for one mapping: its inventory (a pin-file command run in `cwd`, with a per_file runner or not) or its named jobs. */
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean } =>
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file };
export const mappingCoverage = (m: ShipLaneMapping, cwd: string): { inventory?: () => string[] | { error: string }; jobs?: string[]; perFile?: boolean; importers?: (missing: string[], listed: string[]) => string[] } =>
m.jobs ? { jobs: m.jobs } : { inventory: () => runInventory(m.inventory!, cwd), perFile: !!m.per_file, importers: (missing, listed) => importedTestLines(cwd, missing, listed) };
const sameFiles = (a: string[], b: string[]) => { const x = new Set(a.map(normalPath)), y = new Set(b.map(normalPath)); return x.size === y.size && [...x].every(f => y.has(f)); };
const IMPORT_SPEC = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*['"](\.{1,2}\/[^'"\n]+)['"]/g;
const RESOLVE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.js'];
/**
* One line per file in `missing` (test files no run's tree.json lists) that another file in `candidates` imports by a
* relative path: bun registers an imported test file's tests under the importing file, so the imported file's own JUnit
* suite never appears (GST-1595, gstack-evals #240). Read from the working tree in `cwd`; an unreadable file is skipped.
*/
export function importedTestLines(cwd: string, missing: string[], candidates: string[]): string[] {
const want = new Map(missing.map(f => [normalPath(f), f]));
const lines = new Map<string, string>();
for (const importer of candidates) {
const from = normalPath(importer);
let text: string;
try { text = fs.readFileSync(path.join(cwd, from), 'utf8'); } catch { continue; }
for (const m of text.matchAll(IMPORT_SPEC)) {
const target = path.posix.normalize(path.posix.join(path.posix.dirname(from), m[1]!));
const hit = RESOLVE_SUFFIXES.map(s => want.get(target + s)).find(Boolean);
if (hit && normalPath(hit) !== from && !lines.has(hit)) lines.set(hit, `bun registers ${hit} under ${importer}; its JUnit suite won't appear`);
}
}
return missing.flatMap(f => lines.get(f) ?? []);
}
/**
* What `gstack-evidence record-ci` reported, read from its own last record line onward (the per_file remainder's output comes
* first, so a test printing look-alike lines cannot change the reading): the carried runs, count, unit and remainder, any
* not-owed reason, or the last CARRY_NONE reason.
*/
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; none?: string } {
export function recordCiResult(stderr: string): { runs?: string; tests?: string; files?: boolean; notOwed?: string; here?: string; exception?: string; none?: string; diagnosis?: string[] } {
const lines = stderr.split('\n');
const at = lines.findLastIndex(x => x.startsWith('gstack-evidence: recorded label=') && x.includes(' log=- source=ci runs='));
const none = lines.findLast(x => x.startsWith('CARRY_NONE: '))?.slice('CARRY_NONE: '.length);
if (at < 0) return none ? { none } : {};
const noneAt = lines.findLastIndex(x => x.startsWith('CARRY_NONE: '));
const none = noneAt < 0 ? undefined : lines[noneAt]!.slice('CARRY_NONE: '.length);
const diagnosis = noneAt < 0 ? [] : lines.slice(noneAt + 1).filter(x => x.startsWith('CARRY_DIAGNOSIS: ')).map(x => x.slice('CARRY_DIAGNOSIS: '.length));
if (at < 0) return none ? { none, ...(diagnosis.length ? { diagnosis } : {}) } : {};
const exception = lines.slice(at + 1).find(x => x.startsWith('TEST_LANE_EXCEPTION: '))?.slice('TEST_LANE_EXCEPTION: '.length);
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(lines[at]!);
const here = / skipped_here=(\d+) tests_ran_here=(\S+)( not_owed_here=\d+)?$/.exec(lines[at]!);
const notOwed = lines.slice(at + 1).find(x => x.startsWith('gstack-evidence: not owed: '))?.slice('gstack-evidence: not owed: '.length);
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}${here[3] ?? ''}` } : {}) };
return { runs: got?.[3], tests: got?.[1], files: !!got?.[2], ...(notOwed ? { notOwed } : {}), ...(here ? { here: `skipped_here=${here[1]} file(s) tests_ran_here=${here[2]}${here[3] ?? ''}` } : {}), ...(exception ? { exception } : {}) };
}
/** The per_file runner command for `files`: `{files}` replaced by the paths, each anchored with ./ (Bun reads a bare relative path as a substring filter) and single-quoted for bash. */
+5 -2
View File
@@ -14,6 +14,7 @@ import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ResultCodeName } from './result-codes';
import type { BoundLaneException } from './test-lane-exception';
import { withFileLock } from './spend-ledger';
export type Severity = 'Critical' | 'High' | 'Medium' | 'Low' | 'Informational';
@@ -102,7 +103,7 @@ export interface ShipReceipt {
schema_version: 1; head: string; base: string; pr?: number; version?: string; gated_tree?: string;
gate?: Record<string, unknown>; ci?: Record<string, unknown>; spend_usd?: number | string; predecessor?: string;
/** GSTA-258: the lanes CI carried at this head's tree, one entry per run (gstack-evidence carried). */
ci_carried?: Array<{ lane: string; run: number; attempt: number; tree: string }>;
ci_carried?: Array<{ lane: string; run: number; attempt: number; tree: string; exception?: BoundLaneException }>;
session_kind?: string; artifacts_consumed?: string; run?: string;
// C-stamp (C3/C5/C7): the tree receipt's verdicts and the policy provenance, all optional.
thread?: string; tree?: string; gate_reuse?: string; policy?: string; queue_mode?: string; preregistration?: string; history?: string;
@@ -257,7 +258,9 @@ export const ARTIFACT_SCHEMAS = {
properties: {
schema_version: { const: 1 }, pr: { type: 'integer' }, head: str, version: str, base: str, gated_tree: str,
gate: { type: 'object' }, ci: { type: 'object' }, spend_usd: { type: ['number', 'string'] }, predecessor: str,
ci_carried: { type: 'array', items: { type: 'object', required: ['lane', 'run', 'attempt', 'tree'], properties: { lane: str, run: { type: 'integer', minimum: 1 }, attempt: { type: 'integer', minimum: 1 }, tree: str } } },
ci_carried: { type: 'array', items: { type: 'object', required: ['lane', 'run', 'attempt', 'tree'], properties: { lane: str, run: { type: 'integer', minimum: 1 }, attempt: { type: 'integer', minimum: 1 }, tree: str,
exception: { type: 'object', required: ['head', 'files', 'by', 'reason', 'at', 'approval', 'preflight'], properties: { head: str, files: strList, by: str, reason: str, at: str, approval: str,
preflight: { type: 'object', required: ['label', 'tests_ran'], properties: { label: str, tests_ran: { type: 'integer', minimum: 1 } } } } } } } },
session_kind: str, artifacts_consumed: { ...str, description: '<n>/<m>' }, run: str,
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
+34
View File
@@ -13,6 +13,9 @@
* compared: its guard reads the base branch's copy itself.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GENERATED_PATH } from '../regen';
import { PREGATE_CONFIG_PATH, loadConfigFromHead, parseConfig, type ConfigLoad, type PregateConfig } from './config';
type Loaded = Exclude<ConfigLoad, { unavailable: true }>;
@@ -70,3 +73,34 @@ export function headPolicy(repoRoot: string, trusted: Loaded, mergeBase: string,
explain: Object.entries(adds).map(([glob, tests]) => `head policy adds: dependencies[${JSON.stringify(glob)}] → ${tests.join(', ')}`),
};
}
/**
* Whether a /ship run on the final tree (gstack-ship-release accept, and finish's full pregate rerun) gates under the
* branch's own policy (GST-1595): only when the base branch carries a policy and the branch's file differs from the copy
* it forked from (else the base tip's) in nothing but added test lanes, with GENERATED_PATH equal to the base tip's (a
* head-policy run loads the regen registry from the branch, context.ts), so the gate only gets stricter. Any other difference keeps the base policy, as before. `line` names the policy and why; it is absent
* when the branch leaves the policy alone.
*/
export function lanesOnlyHeadPolicy(repoRoot: string, base: string): { head: boolean; line?: string } {
const ref = `origin/${base.replace(/^origin\//, '')}`;
const show = (rev: string, file: string) => { const r = spawnSync('git', ['show', `${rev}:${file}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 && !r.error ? r.stdout : null; };
const read = (file: string) => { try { return fs.readFileSync(path.join(repoRoot, file), 'utf8'); } catch { return null; } };
const json = (text: string | null): Record<string, unknown> | null => { try { const v = text === null ? null : JSON.parse(text); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } };
const tip = show(ref, PREGATE_CONFIG_PATH), headText = read(PREGATE_CONFIG_PATH);
const fork = spawnSync('git', ['merge-base', ref, 'HEAD'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).stdout?.trim();
const trusted = json((fork && show(fork, PREGATE_CONFIG_PATH)) || tip), head = json(headText);
if (headText === tip || (trusted && head && same(trusted, head))) return { head: false };
const keep = (why: string) => ({ head: false, line: `POLICY: base ${ref}:${PREGATE_CONFIG_PATH} (the branch's copy ${why}, so it does not count here; land it on ${ref.slice(7)} first)` });
if (!tip || !trusted) return keep(`has no reviewed copy on ${ref} to add lanes to`);
if (!head) return keep(headText === null ? 'is deleted' : 'does not parse as a JSON object');
const { dependencies: refDeps = {}, ...refRest } = trusted, { dependencies: headDeps = {}, ...headRest } = head;
const other = [...new Set([...Object.keys(refRest), ...Object.keys(headRest)])].filter(k => !same(refRest[k], headRest[k])).sort();
if (other.length) return keep(`also changes ${other.join(', ')}`);
const lists = (v: unknown): v is Record<string, string[]> => !!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every(t => Array.isArray(t) && t.every(x => typeof x === 'string'));
if (!lists(refDeps) || !lists(headDeps)) return keep('has a dependencies entry that is not a list of test files');
const { loosens, adds } = headPolicyDiff({ dependencies: refDeps } as PregateConfig, { dependencies: headDeps } as PregateConfig, ref.slice(7));
if (loosens.length) return keep(loosens.join('; '));
if (show(ref, GENERATED_PATH) !== read(GENERATED_PATH)) return keep(`comes with a changed ${GENERATED_PATH}`);
const n = Object.keys(adds).length;
return { head: true, line: `POLICY: head (unreviewed) ${PREGATE_CONFIG_PATH}: adds ${n} test lane${n === 1 ? '' : 's'} over ${ref} and changes nothing else, so the gate is only stricter` };
}
+3 -2
View File
@@ -18,7 +18,7 @@ import { ARTIFACT_SCHEMAS, checkSchema, type PregateException, type PregateFile,
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
import { resolveStateRoot } from '../state-root';
import { guardsCheck } from './checks/guards';
import { findCarry } from '../ci-carry';
import { findCarry, importedTestLines } from '../ci-carry';
import { clearHint, fromRunProfiles, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
import { literalsCheck } from './checks/literals';
import { regenCheck } from './checks/regen';
@@ -456,7 +456,8 @@ function clearByRuns(file: PregateFile, lane: string, refs: string[], check: Run
for (const f of listed) required.add(f);
}
const missing = uncoveredFiles([...required], evidence);
if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}); ${lane} stays uncleared` };
const imported = missing.length ? importedTestLines(repoRoot, missing, [...required]) : [];
if (missing.length) return { refused: 'PREGATE_RECEIPT_INVALID', message: `REUSE: refused reason=${isGate ? 'inventory_uncovered' : 'tests_uncovered'} — ${missing.length} of the ${required.size} test file(s) ${lane} must cover appear in no run's tree.json (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''})${imported.length ? `; ${imported.join('; ')}` : ''}; ${lane} stays uncleared` };
const granted = red ? flakyDecision(file, lane, check, red, flaky!, repoRoot) : null;
if (granted && 'refused' in granted) return granted;
for (const e of evidence) {
+5 -5
View File
@@ -182,7 +182,7 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
return cpus <= 4 ? 1 : Infinity;
}
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string; here?: string }
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; diagnosis?: string[]; notOwed?: string; here?: string; exception?: string }
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, ShipLaneMapping> {
@@ -201,8 +201,8 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
const got = recordCiResult(rec.stderr);
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}) };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}` };
if (rec.status === 0 && got.runs) return { label: l.label, exit: 0, testsRan: got.files ? `${got.tests} (files)` : got.tests!, log: '-', carried: got.runs, ...(got.notOwed ? { notOwed: got.notOwed } : {}), ...(got.here ? { here: got.here } : {}), ...(got.exception ? { exception: got.exception } : {}) };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: got.none ?? `record_failed: ${firstLine(rec.stderr)}`, ...(got.diagnosis ? { diagnosis: got.diagnosis } : {}) };
}
/**
@@ -240,5 +240,5 @@ export function laneLine(l: LaneResult): { line: string; ok: boolean } {
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}${l.reused ? ` reused=${l.reused}` : ''}`, ok: l.exit === 0 && !zero };
}
/** The CI_CARRY line for a mapped lane that ran here instead. */
export const carryNoneLine = (l: LaneResult) => (l.carryNone ? `CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})` : null);
/** The CI_CARRY line for a mapped lane that ran here instead, then a CARRY_DIAGNOSIS line per uncovered file another test file imports. */
export const carryNoneLines = (l: LaneResult): string[] => (l.carryNone ? [`CI_CARRY: none label=${l.label} (${l.carryNone.length > 220 ? `${l.carryNone.slice(0, 220)}…` : l.carryNone})`, ...(l.diagnosis ?? []).map(d => `CARRY_DIAGNOSIS: ${d}`)] : []);
+2 -2
View File
@@ -15,7 +15,7 @@ import { buildRunCheck } from './pregate/run-check';
import { classifyDiff } from './review-depth';
import { wtreeFingerprint } from './script-invocation';
import { audit } from './ship-audit';
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
import { bin, carryNoneLines, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, shipRunMarker, short } from './ship-bin-common';
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
@@ -203,7 +203,7 @@ async function tests(cwd: string, base: string, list: { label: string; command:
r.add(run.line);
const ran = await runLanes(cwd, list, run.concurrency, { base });
const results = ran.map(laneLine);
for (const [i, l] of results.entries()) { const none = carryNoneLine(ran[i]!); if (none) r.add(none); r.add(l.line); }
for (const [i, l] of results.entries()) { for (const none of carryNoneLines(ran[i]!)) r.add(none); r.add(l.line); }
const bad = results.filter(l => !l.ok);
if (bad.length) { r.stop(`${bad.length} lane(s) failed or ran zero tests: Step 5 triage (read each log; a free-suite shard failure reruns its file list per measure.md); in-branch failures stop the ship`); return; }
r.next('Step 6: select eval suites for prompt-related changes with the project\'s selector, then Step 7');
+3 -1
View File
@@ -10,6 +10,7 @@ import { RECEIPT_FENCE } from './ship-receipt';
import { parseReviewLoopException, reviewLoopExceptionLine } from './ship-receipt-depth';
import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs';
import { ruledNoteText, type RuledNote } from './ruled-notes';
import { laneExceptionBodyLines } from './test-lane-exception';
export interface ProseClaim { field: string; prose: number; receipt: number }
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
@@ -96,7 +97,7 @@ function withoutApprovalsBlocks(body: string): string {
/**
* The body without the exception lines the receipt's own exceptions require: the `Review-loop exception: …` line
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …` and `Mechanical retake: …` line (GST-1537, GST-1560). write and the receipt check want each word for word,
* (GST-977), the `Documentation exception: …` line (GST-983) and each `Ruled note: …`, `Mechanical retake: …` and `Test-lane exception: …` line (GST-1537, GST-1560, GST-1595). write and the receipt check want each word for word,
* and each quotes the owner's reason (`outside 1 and 2 skip detector`), not this PR's counters. Only a line whose
* trimmed text is a rendered line, the test write applies, is left out; the same words inside a longer line stay
* prose. A marker line takes its place so the text on either side never joins into a claim.
@@ -110,6 +111,7 @@ function withoutExceptionLines(body: string, receipt: Record<string, unknown>):
if (docsLine) lines.add(docsLine);
if (Array.isArray(receipt.ruled_notes)) for (const n of receipt.ruled_notes) lines.add(`Ruled note: ${ruledNoteText(n as RuledNote)}`);
if (Array.isArray(receipt.mechanical)) for (const l of receipt.mechanical) if (typeof l === 'string') lines.add(l);
for (const l of laneExceptionBodyLines(receipt.ci_carried)) lines.add(l);
if (!lines.size) return body;
return body.split(/(?<=\n)/).map(l => (lines.has(l.trim()) ? '(exception line)\n' : l)).join('');
}
+2
View File
@@ -13,6 +13,7 @@ import { FRAGMENT_README } from './changelog-fragments';
import { renderIssueLines } from './issue-links';
import { matchesAny } from './ship-policy';
import { ruledNoteText } from './ruled-notes';
import { laneExceptionBodyLines } from './test-lane-exception';
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
import { stampOnlyLine, type BranchSide } from './stamp-only';
@@ -175,6 +176,7 @@ export function readyTailLines(r: ShipReceipt | undefined): string[] {
return [
loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt',
...(r?.ruled_notes ?? []).map(n => `RULED_NOTE: ${oneLine(ruledNoteText(n))}`),
...laneExceptionBodyLines(r?.ci_carried).map(l => `TEST_LANE_EXCEPTION: ${oneLine(l.slice('Test-lane exception: '.length))}`),
'TRIAL_MERGES: <one line per open sibling pin that shares a file: its git merge-tree result and the resolution, or "none share a file">',
'Attention:',
legacy ? 'reviewed by <model on another family> (risk class unknown: the receipt records no review_depth; rule 27 cross-family trail read)'
+12 -5
View File
@@ -27,9 +27,10 @@ import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs';
import { resolveStateRoot } from './state-root';
import { bumpVersion, cmpVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
import { waitForRun } from './ci-carry';
import { lanesOnlyHeadPolicy } from './pregate/head-policy';
import { PREPUSH_CHECK_IDS } from './pregate/index';
import { buildRunCheck } from './pregate/run-check';
import { bin, carryLane, carryNoneLine, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
import { bin, carryLane, carryNoneLines, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common';
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
@@ -532,7 +533,9 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
const v = bin(cwd, 'gstack-pregate', ['verify'], 60_000);
if (v.status === 0) r.add('PREGATE: verify ok on the stamped tree');
else {
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
const policy = lanesOnlyHeadPolicy(cwd, base);
if (policy.line) r.add(policy.line);
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, ...(policy.head ? ['--policy-from', 'head'] : [])], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: ${verdict} (rerun on the stamped tree)`);
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the stamped tree: fix it${/uncleared/.test(verdict) ? ', or gstack-pregate clear with the remote run URL' : ''}, then rerun finish`);
@@ -570,25 +573,29 @@ export async function accept(argv: readonly string[], cwd: string): Promise<Repo
if (w.state === 'red') return r.stop(`${p.workflow} run ${w.run.id} failed (${short(w.failing, 4) || 'no failing job named'}): Step 5 triage on its logs; fix, commit, push, then accept again`);
if (w.state === 'none') return r.stop(`${w.reason}: push the branch (a pull_request or workflow_dispatch run must exist at HEAD), then accept again`);
}
const exceptions: string[] = [];
for (const l of list) {
const m = mapped.get(l.label);
const res = m ? carryLane(cwd, base, l) : (await runLanes(cwd, [l], Infinity, { base }))[0]!;
if (m && !res.carried) { r.add(carryNoneLine(res)!); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
if (m && !res.carried) { for (const line of carryNoneLines(res)) r.add(line); return r.stop(`lane ${l.label} was not carried from the green run (${short([res.carryNone ?? ''], 1).slice(0, 120)}): fix the cause, or run it here with gstack-ship-preflight --tests-only`); }
const ran = laneLine(res);
r.add(ran.line);
if (res.exception) { r.add(`TEST_LANE_EXCEPTION: ${res.exception}`); exceptions.push(res.exception); }
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: Step 5 triage, then push and accept again`);
const e = bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24'], 60_000).stdout;
const line = /^EVIDENCE: .*$/m.exec(e)?.[0] ?? `EVIDENCE: unreadable label=${l.label}`;
r.add(line.replace(/ log=\S+/, ''));
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH at the pushed head: Step 16 stage 4 recovery`);
}
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base], 3_600_000);
const policy = lanesOnlyHeadPolicy(cwd, base);
if (policy.line) r.add(policy.line);
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, ...(policy.head ? ['--policy-from', 'head'] : [])], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
const carried = /^CI_CARRY: .*$/m.exec(pg.stdout + pg.stderr)?.[0];
if (carried) r.add(carried.length > 240 ? `${carried.slice(0, 240)}…` : carried);
r.add(`PREGATE: ${verdict} (final tree)`);
if (!/^(pass|warn|requires-remote cleared)/.test(verdict)) return r.stop(`pregate ${verdict} on the final tree: fix it, then rerun accept`);
return r.next('Step 18: the receipt records ci_carried from the ledger');
return r.next(`Step 18: the receipt records ci_carried from the ledger${exceptions.length ? `; the PR body carries ${exceptions.map(e => JSON.stringify(`Test-lane exception: ${e}`)).join(' and ')} verbatim, one line each, or the receipt write refuses` : ''}`);
}
/**
+56
View File
@@ -0,0 +1,56 @@
/**
* Owner-granted test-lane exceptions (GST-1595, docs/ship-policy.md#test-lane-exceptions): the owner's grant that a lane
* carries from CI although some of its inventory files are in no run's tree.json (bun registers an imported test file's
* tests under the importer, so the imported file's JUnit suite never appears). A grant is one approved gstack-approvals
* row (`gstack-approvals lane-exception`), scope `test_lane_exception <head> <files>` and ask
* `test_lane_exception at <head>: <files>: <reason>`, where head is the full sha of the pushed head the owner granted at and
* files are exactly the uncovered files, comma-separated. It binds only at that head and only for that exact set, and
* `gstack-evidence record-ci` counts it only after a local preflight of exactly those files passes there. Like a ruled note
* (lib/ruled-notes.ts), a row by gstack itself is no grant, and the CLI refuses inside the checkout's open /ship run, so the
* lane that needs the exception cannot write it for itself.
*/
import { locate, readRecord, type ApprovalRow, type Env } from './approval-record';
import { GSTACK_ACTOR } from './ruled-notes';
export interface TestLaneException { head: string; files: string[]; by: string; reason: string; at: string; approval: string }
/** What the ledger's CI row and the receipt's ci_carried entry keep: the grant plus the preflight that backed it. */
export interface BoundLaneException extends TestLaneException { preflight: { label: string; tests_ran: number } }
const SCOPE = /^test_lane_exception ([0-9a-f]{40}) (\S+)$/;
const ASK = /^test_lane_exception at ([0-9a-f]{40}): (\S+): (\S(?:.*\S)?)$/;
export const TEST_LANE_EXCEPTION_SCOPE = /^\s*test_lane_exception\b/;
export const testLaneExceptionRow = (head: string, files: string[], reason: string) =>
({ scope: `test_lane_exception ${head} ${files.join(',')}`, ask: `test_lane_exception at ${head}: ${files.join(',')}: ${reason}` });
/** The approved grants, one per head and file set (the newest wins), so each lane at a head keeps its own grant. */
export function testLaneExceptions(rows: readonly ApprovalRow[]): TestLaneException[] {
const byGrant = new Map<string, TestLaneException>();
for (const r of rows) {
const scope = SCOPE.exec(r.scope), ask = ASK.exec(r.ask);
if (r.outcome !== 'approved' || !scope || !ask || ask[1] !== scope[1] || ask[2] !== scope[2] || GSTACK_ACTOR.test(r.by)) continue;
const files = scope[2]!.split(',').filter(Boolean).sort(), key = `${scope[1]} ${files.join(',')}`;
byGrant.delete(key);
byGrant.set(key, { head: scope[1]!, files, by: r.by, reason: ask[3]!, at: r.recorded_at, approval: r.id });
}
return [...byGrant.values()];
}
/** The grants in the checked-out branch's approval record (and its mirror). */
export function readTestLaneExceptions(cwd: string, env: Env = process.env): TestLaneException[] {
const loc = locate({ cwd, env });
return 'code' in loc ? [] : testLaneExceptions(readRecord(loc).rows);
}
/** The `Test-lane exception: …` lines a receipt's ci_carried entries require in the PR body, one per grant. */
export function laneExceptionBodyLines(carried: unknown): string[] {
const list = Array.isArray(carried) ? carried : [];
return [...new Set(list.flatMap(c => {
const e = (c as { exception?: BoundLaneException } | null)?.exception;
return e && Array.isArray(e.files) && typeof e.head === 'string' && e.preflight ? [`Test-lane exception: ${testLaneExceptionText(e)}`] : [];
}))];
}
/** The text after `TEST_LANE_EXCEPTION: ` in accept's output and after `Test-lane exception: ` in the PR body. */
export const testLaneExceptionText = (e: BoundLaneException) =>
`${e.files.join(', ')} at ${e.head.slice(0, 12)}: preflight ${e.preflight.label} ran ${e.preflight.tests_ran} test(s) here; granted by ${e.by} at ${e.at}: ${e.reason} (approval ${e.approval})`;
+8
View File
@@ -407,6 +407,14 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/ship-finish-ruled-notes.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-approvals, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42139467391)',
},
{
file: 'test/ship-accept-head-policy.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-evidence and gstack-pregate (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
},
{
file: 'test/ship-accept-lane-exception.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-evidence, gstack-approvals and gstack-ship-receipt (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-ci-carry.test.ts, which windows-latest fails at the first bin spawn (windows-scoped-shard, run 41607840098)',
},
{
file: 'test/ship-mechanical-retake.test.ts',
reason: 'drives bin/gstack-ship-release, gstack-review-plan, gstack-ship-receipt, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-finish-stack-parent.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 42156939158)',
+87
View File
@@ -0,0 +1,87 @@
/**
* GST-1595 (1): gstack-ship-release accept reruns gstack-pregate on the final tree under the branch's own
* .gstack/pregate.json only when that file adds test lanes and changes nothing else, so the gate gets stricter, never
* looser; any other difference keeps the base policy. gstack-evals #240's shape: a PR that adds a runtime file plus its
* own lane line passed preflight under --policy-from head, then refused at accept with PREGATE_NO_LANE.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(120_000);
const fixtures: ShipFx[] = [];
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
const TESTS = ['test/a.test.ts', 'test/new.test.ts'];
const DEPS = { 'src/a.ts': ['test/a.test.ts'], 'src/b.ts': ['test/a.test.ts'], 'test/**': [] };
const policy = (deps: Record<string, string[]>, extra: Record<string, unknown> = {}) => JSON.stringify({
run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: `printf '${TESTS.join('\\n')}\\n'` } }, dependencies: deps, ...extra,
}, null, 2) + '\n';
const passing = "import { expect, test } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n";
const BASE = {
'.gstack/pregate.json': policy(DEPS),
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
'test/a.test.ts': passing,
};
/** The #240 shape: a new runtime file, its own test, and the lane line that maps one to the other. */
const PR = { 'src/b.ts': 'export const b = 2;\n', 'src/new.ts': 'export const fresh = 1;\n', 'test/new.test.ts': passing };
const ADDS = { ...DEPS, 'src/new.ts': ['test/new.test.ts'] };
function fx(headPolicy: string): ShipFx {
const f = shipFixture(BASE, { ...PR, '.gstack/pregate.json': headPolicy });
fixtures.push(f);
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] }, trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: TESTS, tests: 2, os: 'Linux' } } };
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
return f;
}
const lanesCheck = (f: ShipFx) => JSON.parse(fs.readFileSync(path.join(f.work, '.gstack/tmp/pregate.json'), 'utf8')).checks.find((c: { id: string }) => c.id === 'lanes');
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
describe('accept: a lanes-only head policy gates the final tree (GST-1595)', () => {
// Value: protects=a PR that adds a runtime file and its own lane line passes accept, gated by that lane; fails_when=accept reruns pregate under the base
// policy and refuses with PREGATE_NO_LANE (gstack-evals #240); why_new=GEVA-2 had to land a lanes-only PR first; seam=fake gh, gstack-pregate
test('a head policy that only adds the new file\'s lane: accept passes under it', () => {
const f = fx(policy(ADDS));
const ok = accept(f);
expect([ok.status, lineOf(ok.stdout, 'PREGATE'), lanesCheck(f).status], ok.stdout).toEqual([0, expect.stringMatching(/^PREGATE: (pass|warn) \(final tree\)$/), 'pass']);
expect(lineOf(ok.stdout, 'POLICY')).toBe('POLICY: head (unreviewed) .gstack/pregate.json: adds 1 test lane over origin/main and changes nothing else, so the gate is only stricter');
expect(ok.stdout).toContain('NEXT: Step 18');
});
// Value: protects=accept never gates under a stale branch registry: --policy-from head loads .gstack/generated.json from the branch
// (lib/pregate/context.ts loadRegistryFromHead), so a branch whose copy differs from main's tip keeps the base policy;
// fails_when=lanesOnlyHeadPolicy compares generated.json with the fork copy and picks the head policy; seam=lanesOnlyHeadPolicy
test('main changes .gstack/generated.json after the fork: accept keeps the base policy', () => {
const f = fx(policy(ADDS));
for (const step of [['checkout', '-q', 'main'], ['pull', '-q', 'origin', 'main']]) expect(f.git(...step).status).toBe(0);
fs.mkdirSync(path.join(f.work, '.gstack'), { recursive: true });
fs.writeFileSync(path.join(f.work, '.gstack/generated.json'), JSON.stringify({ $schema: 'https://github.com/garrytan/gstack/docs/pregate.md#generated-v1', entries: [] }, null, 2) + '\n');
for (const step of [['add', '-A'], ['commit', '-qm', 'main: generated registry'], ['push', '-q', 'origin', 'main'], ['checkout', '-q', 'feat'], ['fetch', '-q', 'origin']]) expect(f.git(...step).status).toBe(0);
const out = accept(f);
expect(lineOf(out.stdout, 'POLICY'), out.stdout).toBe("POLICY: base origin/main:.gstack/pregate.json (the branch's copy comes with a changed .gstack/generated.json, so it does not count here; land it on main first)");
});
// Value: protects=the trust boundary: a head policy that drops, shrinks or loosens anything stays unread, and accept refuses as before;
// fails_when=accept runs a branch's looser policy; seam=lib/pregate/head-policy.ts lanesOnlyHeadPolicy
test.each([
['removes a lane', policy({ ...ADDS, 'src/a.ts': [] }), 'dependencies["src/a.ts"] drops test/a.test.ts'],
['loosens a check', policy(ADDS, { warn: ['lanes'] }), 'also changes warn'],
['adds a lane and changes the timeout', policy(ADDS, { timeout_s: 30 }), 'also changes timeout_s'],
['adds a lane and changes the carry map', JSON.stringify({ ...JSON.parse(policy(ADDS)), ship_lanes_from: {} }), 'also changes ship_lanes_from'],
])('a head policy that %s keeps the base policy, and accept refuses', (_what, head, why) => {
const f = fx(head);
const out = accept(f);
expect(lineOf(out.stdout, 'POLICY')).toBe(`POLICY: base origin/main:.gstack/pregate.json (the branch's copy ${why}, so it does not count here; land it on main first)`);
expect([out.status, lineOf(out.stdout, 'STOP'), lanesCheck(f).code]).toEqual([3, 'STOP: pregate fail on the final tree: fix it, then rerun accept', 'PREGATE_NO_LANE']);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* GST-1595 (2): a CI-carried lane whose inventory has a file no run's tree.json lists. bun registers an imported test
* file's tests under the importer, so the imported file's JUnit suite never appears (gstack-evals #240). accept names
* that pattern, and an owner-granted test-lane exception (gstack-approvals lane-exception, the trust path of ruled notes)
* lets the lane carry once a local preflight of exactly those files passes at the exact head; the receipt and PR body
* carry it. The lane itself can never write the grant.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { appendRows, buildRow, locate } from '../lib/approval-record';
import { importedTestLines } from '../lib/ci-carry';
import { EXIT } from '../lib/headless-artifacts';
import { extractReceipt } from '../lib/ship-receipt';
import { testLaneExceptionRow } from '../lib/test-lane-exception';
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
setDefaultTimeout(120_000);
const BIN = path.dirname(RELEASE);
const fixtures: ShipFx[] = [];
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
const FREE = "printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
const HELPERS = 'test/helpers.test.ts';
const PASSING = "import { expect, test } from 'bun:test';\ntest('helpers', () => expect(1).toBe(1));\n";
const FAILING = "import { expect, test } from 'bun:test';\ntest('helpers', () => expect(1).toBe(2));\n";
const BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: 'ls test/*.test.ts', per_file: 'bun --no-env-file test {files}' } } }, null, 2) + '\n',
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
'test/a.test.ts': "import './helpers.test';\nimport { expect, test } from 'bun:test';\ntest('a', () => expect(1).toBe(1));\n",
[HELPERS]: PASSING,
};
const NO_MIRROR = { GSTACK_APPROVALS_MIRROR: '0' };
const BUN_PLACED = `CARRY_DIAGNOSIS: bun registers ${HELPERS} under test/a.test.ts; its JUnit suite won't appear`;
/** A pushed branch whose green CI run's tree.json lists test/a.test.ts only: bun put the helpers' suite under it. */
function fx(feat: Record<string, string> = {}, ciFiles = ['test/a.test.ts']): { f: ShipFx; head: string } {
const f = shipFixture(BASE, { 'src/b.ts': 'export const b = 2;\n', ...feat });
fixtures.push(f);
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const at = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
const w: FakeWorld = { runs: [run] as FakeWorld['runs'], jobs: { 7: [{ name: 'suite', conclusion: 'success', completed_at: at(5) }] },
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree: f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), files: ciFiles, tests: 2, os: 'Linux' } } };
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify(w));
f.gh(fakeGhScript(path.join(f.root, 'world.json')));
expect(f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], { GSTACK_SESSION_KIND: 'unattended' }).status).toBe(0);
return { f, head };
}
const accept = (f: ShipFx) => f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0'], NO_MIRROR);
const grant = (f: ShipFx, head: string, files = HELPERS, by = 'Garry (via GStack Minder)') => f.run(path.join(BIN, 'gstack-approvals'), ['lane-exception', '--head', head, '--files', files,
'--reason', 'bun registers the helpers suite under test/a.test.ts', '--words', 'grant it for that head', '--by', by, '--json'], NO_MIRROR);
describe('inventory_uncovered names the bun import pattern (GST-1595)', () => {
// Value: protects=the operator learns why a file is missing from tree.json instead of chasing CI; fails_when=accept stops on inventory_uncovered
// without naming the importer; why_new=GEVA-2 spent most of a day on #240 finding it; seam=gstack-evidence record-ci CARRY_DIAGNOSIS
test('accept stops and names the importing test file', () => {
const { f } = fx();
const out = accept(f);
expect([out.status, lineOf(out.stdout, 'CI_CARRY')]).toEqual([3, `CI_CARRY: none label=free (inventory_uncovered: 1 of 2 inventory file(s) are in no run's tree.json: ${HELPERS})`]);
expect(out.stdout.split('\n')).toContain(BUN_PLACED);
});
// Value: protects=the import scan resolves relative specifiers with and without extensions, require and dynamic import, and names nothing it
// cannot tie to a missing file; fails_when=a missing file is blamed on the wrong importer or a self-import; seam=lib/ci-carry.ts importedTestLines
test('importedTestLines resolves import, require and import() by relative path', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imported-tests-'));
try {
const files: Record<string, string> = {
'test/a.test.ts': "import { x } from './shared/b.test';\n",
'test/c.test.js': "const y = require('../test/shared/d.test.js');\n",
'test/e.test.ts': "await import('./f.test.ts');\nimport '../lib/unrelated';\n",
'test/shared/b.test.ts': '', 'test/shared/d.test.js': '', 'test/f.test.ts': "import './f.test';\n", 'test/g.test.ts': '',
};
for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); }
expect(importedTestLines(dir, ['test/shared/b.test.ts', './test/shared/d.test.js', 'test/f.test.ts', 'test/g.test.ts'], Object.keys(files))).toEqual([
'bun registers test/shared/b.test.ts under test/a.test.ts; its JUnit suite won\'t appear',
'bun registers ./test/shared/d.test.js under test/c.test.js; its JUnit suite won\'t appear',
'bun registers test/f.test.ts under test/e.test.ts; its JUnit suite won\'t appear',
]);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
});
describe('an owner-granted test-lane exception (GST-1595)', () => {
// Value: protects=a granted exception at the pushed head lets the lane carry after a local preflight of exactly those files, and nothing about
// it is hidden: accept, the ledger, the receipt and the PR body all carry it; fails_when=the grant is ignored, the preflight is skipped, or the
// receipt writes without the body line; why_new=#240 had no exception path at all; seam=record-ci, accept, gstack-ship-receipt write
test('a grant at the head with a passing preflight binds, and the receipt and PR body carry it', () => {
const { f, head } = fx();
expect(grant(f, head).status).toBe(0);
const ok = accept(f);
expect([ok.status, lineOf(ok.stdout, 'LANE')], ok.stdout).toEqual([0, 'LANE: free carried run=7/1 tests_ran=2 (files)']);
const line = lineOf(ok.stdout, 'TEST_LANE_EXCEPTION');
expect(line).toMatch(new RegExp(`^TEST_LANE_EXCEPTION: ${HELPERS} at ${head.slice(0, 12)}: preflight free\\.excepted ran 1 test\\(s\\) here; granted by Garry \\(via GStack Minder\\) at \\S+Z: bun registers the helpers suite under test/a\\.test\\.ts \\(approval a-[0-9a-f]{12}\\)$`));
const bodyLine = `Test-lane exception: ${line.slice('TEST_LANE_EXCEPTION: '.length)}`;
expect(lineOf(ok.stdout, 'NEXT')).toContain(JSON.stringify(bodyLine));
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free /);
const body = path.join(f.root, 'body.md');
const write = () => f.run(path.join(BIN, 'gstack-ship-receipt'), ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], NO_MIRROR);
fs.writeFileSync(body, '## Summary\n\nAdds b.\n');
const short = write();
expect([short.status, short.stderr]).toEqual([EXIT.fail, expect.stringContaining(`does not list ${JSON.stringify(bodyLine)}`)]);
fs.writeFileSync(body, `## Summary\n\nAdds b.\n\n${bodyLine}\n`);
const written = write();
expect(written.status, written.stderr).toBe(EXIT.ok);
const receipt = extractReceipt(written.stdout);
expect(receipt.errors).toEqual([]);
expect(receipt.receipt?.ci_carried).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1,
exception: expect.objectContaining({ head, files: [HELPERS], by: 'Garry (via GStack Minder)', preflight: { label: 'free.excepted', tests_ran: 1 } }) })]);
});
// Value: protects=each excepted file must run and pass on its own, a lane CI covered none of still counts its preflighted files, and two
// grants at one head each keep their own file set; fails_when=a passing file masks a zero-test one in one aggregate run, a fully excepted
// lane grades ZERO-RUN, or a later grant at the same head replaces the one that matches; seam=record-ci's preflight and testLaneExceptions
test('a zero-test excepted file refuses even beside a passing one', () => {
const { f, head } = fx({ 'test/empty.test.ts': 'export const empty = true;\n' });
expect(grant(f, head, `${HELPERS},test/empty.test.ts`).status).toBe(0);
const out = accept(f);
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([3, expect.stringMatching(/exception_preflight_failed: test-lane exception a-[0-9a-f]{12}'s preflight of test\/empty\.test\.ts (exited \d+|ran 0 tests) here/)]);
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
});
test('a lane whose every inventory file is excepted carries with its preflighted files counted', () => {
const { f, head } = fx({}, ['test/other.test.ts']);
expect(grant(f, head, `test/a.test.ts,${HELPERS}`).status).toBe(0);
const ok = accept(f);
expect([ok.status, lineOf(ok.stdout, 'LANE'), lineOf(ok.stdout, 'EVIDENCE')], ok.stdout).toEqual([0, 'LANE: free carried run=7/1 tests_ran=2 (files)', expect.stringMatching(/^EVIDENCE: FRESH label=free /)]);
});
test('a later grant at the same head for another file set leaves the matching grant bound', () => {
const { f, head } = fx();
const first = grant(f, head);
expect(first.status).toBe(0);
expect(grant(f, head, `${HELPERS},test/other.test.ts`).status).toBe(0);
const ok = accept(f);
expect([ok.status, lineOf(ok.stdout, 'TEST_LANE_EXCEPTION')], ok.stdout).toEqual([0, expect.stringContaining(`(approval ${JSON.parse(first.stdout).added[0]})`)]);
});
// Value: protects=the grant binds one head, one exact file set and a passing preflight only; fails_when=a grant made at an earlier head, a grant
// for other files, or a failing local run lets the lane carry; seam=findCarry's excepted set and record-ci's preflight
test.each([
['granted at another head', {}, (f: ShipFx) => f.git('rev-parse', 'HEAD~1').stdout.trim(), HELPERS, /binds another head, not HEAD [0-9a-f]{12}/],
['granted for another file set', {}, (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim(), `${HELPERS},test/other.test.ts`, /names test\/helpers\.test\.ts, test\/other\.test\.ts, not exactly these/],
['whose preflight fails here', { [HELPERS]: FAILING }, (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim(), HELPERS, /^CI_CARRY: none label=free \(exception_preflight_failed: test-lane exception a-[0-9a-f]{12}'s preflight of test\/helpers\.test\.ts exited 1 here/],
] as const)('a grant %s refuses', (_what, feat, at, files, why) => {
const { f } = fx(feat);
expect(grant(f, at(f), files).status).toBe(0);
const out = accept(f);
expect([out.status, lineOf(out.stdout, 'CI_CARRY')], out.stdout).toEqual([3, expect.stringMatching(why)]);
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
});
// Value: protects=the lane cannot grant itself the exception; fails_when=gstack-approvals writes a grant inside the open /ship run, through add, or
// by gstack, or record-ci binds a row gstack wrote; seam=gstack-approvals lane-exception and testLaneExceptions
test('a lane-written grant refuses: inside the open /ship run, through add, by gstack, or a row gstack wrote', () => {
const { f, head } = fx();
const marker = path.resolve(f.work, f.git('rev-parse', '--git-path', 'gstack-ship-run').stdout.trim());
fs.writeFileSync(marker, 'ship-1595-abc\n');
const inRun = grant(f, head);
expect([inRun.status, inRun.stderr]).toEqual([EXIT.usage, expect.stringContaining('/ship run ship-1595-abc is open on this checkout')]);
fs.rmSync(marker);
const add = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', 'exception', '--words', 'yes', '--by', 'Garry', '--scope', `test_lane_exception ${head} ${HELPERS}`], NO_MIRROR);
expect([add.status, add.stderr]).toEqual([EXIT.usage, expect.stringContaining('gstack-approvals lane-exception')]);
const self = grant(f, head, HELPERS, 'gstack ship lane');
expect([self.status, self.stderr]).toEqual([EXIT.usage, expect.stringContaining('--by names gstack')]);
const loc = locate({ cwd: f.work, env: { ...process.env, GSTACK_STATE_ROOT: f.state, GSTACK_HOME: f.state, ...NO_MIRROR } });
const row = buildRow({ ...testLaneExceptionRow(head, [HELPERS], 'the lane says so'), words: 'granted', by: 'gstack ship lane' }, 'feat');
if ('code' in loc || 'code' in row) throw new Error('fixture record unavailable');
expect(appendRows(loc, [row])).not.toHaveProperty('code');
expect(JSON.parse(f.run(path.join(BIN, 'gstack-approvals'), ['list', '--json'], NO_MIRROR).stdout).rows.map((r: { by: string }) => r.by)).toEqual(['gstack ship lane']);
const out = accept(f);
expect([out.status, lineOf(out.stdout, 'CI_CARRY')]).toEqual([3, expect.stringContaining('inventory_uncovered')]);
expect(lineOf(out.stdout, 'TEST_LANE_EXCEPTION')).toBe('');
});
});