v1.94.1.0 stamp: merge main 4ec1722ecb into #3893 (land)

This commit is contained in:
garrytan committed 2026-11-03 04:03:06 +00:00
commit adcf98741c
66 files changed
+2392 -193

No files matched your search

+3
View File
@@ -27,6 +27,9 @@
"inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'",
"skipped": "bun --no-env-file --config=/dev/null -e 'import { isPaidTestFile } from \"./test/helpers/paid-test-set.ts\"; import * as fs from \"node:fs\"; for (const f of fs.readFileSync(process.env.PREGATE_FILES_FILE, \"utf8\").split(/\\r?\\n/)) if (f && isPaidTestFile(f)) console.log(f);'"
},
"ship_lanes_from": {
"bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" }
},
"warn": [],
"strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] }
}
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## [1.94.1.0] - 2026-11-03
### Fixed
- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. The first argument of a lookup call (`get`, `getenv`, `read`, `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name or a snake_case key of two or three words now counts as a name. Any other call still reports a password-shaped literal (`make_password("orange_monkey_pass")`, `bcrypt.hashpw(...)`), and so do a later argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`), a real key in quotes or in a `.env` line, and a kebab passphrase (`decrypt("prod-signing-key")`). The entropy bar is unchanged.
## [1.94.0.0] - 2026-11-03
- CI is now the one full test run. When a verified CI run already passed at the exact, clean tree, `gstack-pregate`'s tests stage and /ship's test lanes use it instead of running the suite again (GSTA-258, R65 item 1). The tests stage looks up the newest verified run at HEAD under the base branch's `run_profiles` by itself (`CI_CARRY: accepted …`; `--no-ci-carry` turns it off). A /ship lane carries only when the base branch's `.gstack/pregate.json` maps its exact command in the new `ship_lanes_from` key, and only when the run's `tree.json` covers the lane's inventory; gstack maps `bun run test` to its Free Tests run. A carried lane prints `LANE: <label> carried run=<id>/<attempt>` and is recorded as an explicit CI-sourced evidence row (`gstack-evidence record-ci`), never as a local run. Dirty trees, fork runs, stale runs and a newer red or running run never carry.
- A lane that already passed at the same tree, command and runtime is reused instead of rerun (`gstack-evidence run --reuse`, the default from `gstack-ship-preflight` and `finish`): a newer failure vetoes an older pass, and the reused row keeps its original timestamp, so the 24-hour age limit still measures the real run.
- /ship runs beside CI: on a repo that maps its lanes, Phase 2 pushes early (`gstack-ship-release push --early` runs the secrets scan and the sealed guard first), `finish` defers a mapped lane it cannot carry yet (`EVIDENCE: DEFERRED`), and the new `gstack-ship-release accept` carries it from the green run at the final pushed head and reruns the pregate there. `gstack-ci-carry find|wait` checks or waits for that run, and the ship receipt records `ci_carried`. See docs/pregate.md#ci-carry.
- On a small machine, the remote gate obligation clears from the same carried run (through `remote_gate_runs` and its inventory check) when the files left over are only ones the gate skips, such as gstack's paid evals. Before, that remainder made the gate report `tests=0` and `clear --run` refuse it as `paid_only`, so nothing could clear it.
- On gstack itself, a green Free Tests run at the same tree is now the default way the remote gate obligation clears, and the Ubicloud VM (`gstack-pregate --stage tests --remote`) is an opt-in. The uncleared obligation names the CI route first: push, `gstack-ci-carry wait --base main`, then rerun `gstack-pregate --allow-repo-commands`. On a branch behind main, dispatch Free Tests on the branch (`gh workflow run free-tests.yml --ref <branch>`), because a `pull_request` run tests the merge commit, another tree.
- `gstack-evidence record-ci` now finds and verifies the CI run itself (`record-ci --label <L> --base <base> -- <command>`), so no caller can hand it run ids or a test count. `finish` writes its preflight-only pregate run to its own file, so the full `.gstack/tmp/pregate.json` stays in place until `accept` replaces it.
## [1.93.42.0] - 2026-11-03
**The stamp checks name the branch's lines by position, and the carry and finish accept a kept VERSION claim (GST-979, GST-980).** After GST-963, three gaps were left in the checks that accept a stamp commit, all found by the #3862 reviewers. A base line whose text duplicated a line the branch added got the branch's fork-version bound, so a hand edit of that base copy passed the tree receipt. When a train slot kept its claimed VERSION, a base line moved down to that version also read as a stamp. And the /ship receipt carry and finish refused the fork-version row move the restamp now makes: the carry kept the branch-added row in both product patch-ids (`RECEIPT_CARRY_PRODUCT_CHANGED`), and with a kept VERSION claim the carry and finish read every change as product or behavior.
### Fixed
- **`lib/stamp-only.ts`:** `BranchSide` carries the positions of the branch's lines, and `stampOnlyLine` takes the removed line's position. Only a line at a branch-added position gets the fork-version bound. When the version does not move, only such a line may change.
- **`lib/restamp.ts`:** `parseUnifiedDiff` records each removed line's position on the old side, which the tree receipt, the train checks and finish's `stampedFiles` pass along.
- **`lib/ship-receipt.ts`, `lib/ship-bin-common.ts`:** `stampOnlyText` and `stampedFiles` accept a kept VERSION claim for the branch's own rows and nothing else.
- **`bin/gstack-ship-receipt` carry:** a stamp file the branch changed as product, whose only change since the reviewed head is a stamp on its own rows and which the base did not touch, is left out of the product comparison. The carry records `a stamp on the branch's own rows (<file>)` as the reason. It reads that file line by line. Only lines at the branch's own positions may move, and the file must keep its reviewed mode, so a version edit on a base line of the same file or a mode change still refuses, as does a product edit anywhere else.
### Tests
- `test/tree-receipt-stamp-sensitive.test.ts`: the bound holds only at the branch line's position, and a kept VERSION refuses a base line moved down.
- `test/restamp-mentions.test.ts`: a hand move of a base row whose text the branch duplicates reads `TREE: changed`, and the carry follows the fork-version row stamp but still refuses a product edit beside it, a hand version edit on a base line of the same file, and a mode change.
- `test/gstack-ship-receipt-docs.test.ts`, `test/ship-rereview-triggers.test.ts`: the carry classifier and finish accept the kept-claim row move and refuse a base row moved the same way.
On main, five of the seven new tests fail. The other two, the carry's refusals of a base-line version edit and a mode change, pass on main, which refuses every such carry; they fail on the broader first version of this fix.
## [1.93.41.0] - 2026-11-03
**Codex outside reviews report their token usage (#2948).** Codex already emits token counts in its `--json` events, and gstack used to throw them away. When `lib/outside-review-result.ts` is given an events file, it now prints `OUTSIDE_USAGE: input=… cached_input=… output=… reasoning_output=…` (only the counters Codex reported), or `OUTSIDE_USAGE: unknown`, on every verdict.
### Changed
- **Every Codex outside voice that runs `codex exec --json`** (/review, /ship, /autoplan, the plan reviews) now prints one more stdout line, `OUTSIDE_USAGE:`, after `VERDICT:` and `FINDINGS:`.
- **One usage parser** (`usageFromEvents`, now in `lib/outside-review-result.ts` and re-exported by `lib/outside-voice-runner.ts`). A cumulative `total_token_usage` snapshot wins; without one, the latest `turn.completed` usage does, because Codex reports the session's running total there. It adds cached, cache-write, reasoning and total counters when the events carry them.
Thanks to @omicron-soft for the report. Storing usage in review-log rows, the native `codex review` path and a token column in /ship come in a follow-up.
## [1.93.40.0] - 2026-11-03
### Fixed
- **/ship and /review exploratory QA no longer write reports into your project (GST-969).** /ship Step 9.2.1 and /review Step 4.7 used to put its QA reports, deadline file and captured evidence in `.gstack/qa-reports/` inside the repository under review. In a project that doesn't ignore `.gstack/`, those files showed up as untracked changes, so they could land in the PR and change the tree the review certified. The new `gstack-qa-evidence report-dir` command creates a fresh run directory under `.gstack/tmp/qa-reports/` and adds `/.gstack/tmp/` to the repository's git exclude file, and both QA steps now use it. It refuses a symlinked `.gstack`, a `.gitignore` rule that un-ignores `.gstack/tmp`, and a directory outside a git repository. /qa keeps its `.gstack/qa-reports` output directory.
## [1.93.39.0] - 2026-11-03
**/ship on Capy stops an outside voice that hangs and records it as missing, never as converged or as an unavailable Codex CLI (GST-1104).** TRUT-7's /ship run had two outside voices end with no result (gpt-6.1-sol stopped after 20 minutes; grok-4.7 used 16.9M tokens before it was stopped), and its Step 11 row read `outside_provider: codex`, `outside_status: unavailable`, `converged: true`.
### Fixed
- **`lib/capy-review-pass.js`**: an outside voice still running with no output after `input.stuckMs` (20 minutes by default) is stopped. One that hangs, finishes empty, or stops or fails without leaving findings is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`); one that stops or fails after writing findings is collected normally, so its findings are kept, and Step 11 goes on to judge the native voice instead of stopping the pass with nothing recorded. Other readers still stop the pass as before.
- **`lib/capy-review-pass.js`**: Step 11 returns an `OUTSIDE_RECORD:` line with `outside_provider: host-subagent`, the model that ran and its status, for the review-log rows to copy instead of the template's `codex`. When the voice was unavailable, the line tells the parent to write that voice its own row first (`status: unavailable`, `completed` and `converged` false), so the native row's convergence no longer stands in for it.
- **`docs/unattended.md`**: "The outside voice on Capy" describes the stop, the unavailable record and the new fields.
## [1.93.38.0] - 2026-11-03
- **Review collector and blocker count follow what the reader said (GST-1031).** A native adversarial finding that states `Blocks: no` and one Severity now collects once, at that severity, on every review class; a numbered finding heading keeps its body (located or numbered bullets, a bold subheading, a located paragraph) so its Severity line no longer splits off as a second CRITICAL record. A closing summary such as "so nothing is CRITICAL" is marked `summary_candidate`, and lead judgment may lower it with a reason that `judge` records as `dismissed`. A CRITICAL whose reader writes the field `Blocks: no` no longer counts as a blocker in `REVIEW_LOOP`'s ledger count (classify does the same for a finding that carries `stated`; Step 9.4 does not copy it yet). Decision 3.5's fix-check pass takes a `review-fix-check` start token even when given `logStart: ["review"]`, so it is never counted as a pass. `gstack-ship-release finish` routes a `scripts/harness-version.json` edit that only records non-behavioral pins at the same version as release metadata.
## [1.93.37.0] - 2026-11-03
**Eval planning prices Claude Haiku 5.5, GPT-6 Luna and Gemini 4 Argon, and the Codex docs name the current OpenAI tiers (#3640, #2943).** Before, `gstack-eval-plan arms --arms claude-haiku-5-5` stopped with EVAL_PRICE_MISSING because `lib/pricing.ts` had no row for it. docs/hosts.md and the /codex example still named gpt-5.6-sol as the current Sol model.
+2 -1
View File
@@ -191,7 +191,8 @@ the new defaults.
| Run one free test file while repairing | `bun test <file>` | Free, seconds | Never bare `bun test` for the suite |
| Run full free acceptance before publishing | `bun run test` | Free, a few minutes | Bun 1.4.2 |
| Run the full free suite from a small machine | `bun run test:ubicloud` | Free suite on a billed 16-vCPU VM, about 5 minutes | `UBICLOUD_API_KEY`, `UBI_OWNER` |
| Clear pregate's test tier on a machine under 8 cores ([remote gate](docs/pregate.md#remote-gate)) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Clear pregate's test tier on a machine under 8 cores from CI, the default on gstack ([CI carry](docs/pregate.md#ci-carry)) | push, `gstack-ci-carry wait --base main`, then `gstack-pregate --allow-repo-commands` (a branch behind main first runs `gh workflow run free-tests.yml --ref <branch>`) | Free; reuses the green Free Tests run at your pushed tree | Pushed branch, `gh` |
| Clear pregate's test tier on a machine under 8 cores when no CI run covers your tree ([remote gate](docs/pregate.md#remote-gate), opt-in) | `gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands` (same command keyed or keyless) | Keyed: one billed 16-vCPU VM, about 7 minutes and $0.06 for the whole free tier, worst case $0.38; keyless: free, local parallel shards, practical for small selections only (98 files took 103 s on 4 cores) | Keyed: `UBICLOUD_API_KEY`, `UBI_OWNER`; keyless: nothing |
| Run the curated Windows-safe subset | `bun run test:windows` (CI: six `windows-latest` jobs, nightly on main; PRs run the shorter `windows-smoke` check) | Free | Windows, Git Bash |
| Refresh the Windows duration seed | `gh workflow run windows-free-tests.yml --ref <branch> -f record_durations=true`, then commit the `free-test-durations-windows` artifact | Free CI runners | Pushed branch, `gh` with workflow rights |
| Run a native Windows qualification campaign | `gh workflow run native-qualification.yml --ref <branch> -f mode=<mode>` ([modes](docs/TESTING_INTERNALS.md#windows-free-lane)) | Free CI runners | Pushed branch, `gh` with workflow rights |
+1 -1
View File
@@ -1 +1 @@
1.93.37.0
1.94.1.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.93.37.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.94.1.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry).
*
* gstack-ci-carry find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
* gstack-ci-carry wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
*
* find: the newest verified run of each profile (default: every profile the base branch's
* ship_lanes_from names) at HEAD's clean tree. With --command, the base branch's mapping for that exact lane
* command supplies the profiles and the inventory the runs must cover (a pin-file command, so it needs
* --allow-repo-commands). Prints CI_CARRY: accepted <receipt> (exit 0) or CI_CARRY: none (<reason>) (exit 1).
* wait: polls the newest run of the profile's workflow at HEAD until it completes (default timeout 1800 s),
* prints CI_WAIT: green|red|running|none, and on green runs find. Exit 0 green and carried, 1 red or not
* carried, 3 still running or no run. Logic: lib/ci-carry.ts; the run checks are lib/run-evidence.ts.
*/
import { spawnSync } from 'node:child_process';
import { findCarry, waitForRun } from '../lib/ci-carry';
import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config';
import { buildRunCheck } from '../lib/pregate/run-check';
import { runInventory } from '../lib/run-evidence';
import { wtreeFingerprint } from '../lib/script-invocation';
import { resolveBaseBranch } from '../lib/ship-policy';
const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree instead of running the suite again (docs/pregate.md#ci-carry)
find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
CI_CARRY: accepted <receipt> (exit 0) or CI_CARRY: none (<reason>) (exit 1)
wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
poll the newest run at HEAD (default 1800 s, every 20 s): CI_WAIT: green|red|running|none, then find on green;
exit 0 carried, 1 red or not carried, 3 still running or no run
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only. Only a clean, committed tree
carries; fork runs, stale runs, a newer red or running run on HEAD and a tree.json that misses an inventory
file never do. Exit 2 is usage.`;
const argv = process.argv.slice(2);
const cmd = argv[0];
const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : []));
const flag = (n: string) => all(n).at(-1);
const out = (s: string) => process.stdout.write(`${s}\n`);
const usage = (m: string) => { process.stderr.write(`gstack-ci-carry: ${m}\n`); process.exit(2); };
if (!cmd || argv.includes('--help') || argv.includes('-h')) { out(HELP); process.exit(cmd ? 0 : 2); }
if (cmd !== 'find' && cmd !== 'wait') usage(`unknown command ${cmd}; run gstack-ci-carry --help`);
const git = (args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
const root = git(['rev-parse', '--show-toplevel']);
if (!root) usage('not inside a git repository');
const base = resolveBaseBranch(root, flag('--base'));
const loaded = loadConfigFromBase(root, base);
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: origin/${base}'s .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
const command = flag('--command');
const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined;
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); }
if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands');
const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))];
if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile <name>)`); process.exit(1); }
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base);
if ('error' in check) { out(`CI_CARRY: none (profiles_unavailable: ${check.error})`); process.exit(1); }
const head = git(['rev-parse', 'HEAD']);
const tree = git(['rev-parse', 'HEAD^{tree}']);
function find(): number {
const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) });
if (argv.includes('--json')) out(JSON.stringify(v));
else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`);
return v.ok ? 0 : 1;
}
if (cmd === 'find') process.exit(find());
const timeout = Number(flag('--timeout') ?? '1800');
const interval = Number(flag('--interval') ?? '20');
if (!(timeout >= 0 && timeout <= 21_600) || !(interval >= 1 && interval <= 600)) usage('--timeout is 0-21600 seconds and --interval 1-600');
const profile = check.profiles[profiles[0]!];
if (!profile) usage(`${profiles[0]} is not a run_profiles name on origin/${base}`);
const w = await waitForRun(check.deps, { workflow: profile!.workflow, events: profile!.events, head, timeoutMs: timeout * 1000, intervalMs: interval * 1000, sleep: ms => Bun.sleep(ms), clock: Date.now });
out(`CI_WAIT: ${w.state} ${profiles[0]} ${'run' in w ? `run=${w.run.id}/${w.run.run_attempt}${w.state === 'red' ? ` failing=${w.failing.join(',') || '-'}` : ''}` : w.reason}`);
process.exit(w.state === 'green' ? find() : w.state === 'red' ? 1 : 3);
+162 -4
View File
@@ -3,7 +3,9 @@
* gstack-evidence — verification-evidence ledger: the mechanical arm of /ship's
* IRON LAW ("no completion claims without fresh verification evidence").
*
* gstack-evidence run --label <L> -- <cmd...>
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
* gstack-evidence carried [--json]
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
* [--max-age <hours>] [--allow-paths <csv>]
* gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]...
@@ -51,6 +53,21 @@
* normalization — the same convention as bin/gstack-verify-gate (which hashes
* for TRUST; this ledger hashes for FRESHNESS).
*
* `run --reuse` (GSTA-258, what gstack-ship-preflight and finish pass): before
* running, the newest row for this label, command hash and working-tree
* fingerprint decides. It is reused only when it passed with a counted run
* (tests_ran > 0) under the same execution identity (bun and node versions,
* OS, architecture and runner image; lockfiles and pins are tree content) within --max-age
* hours (default 24); a newer failure on that tree vetoes an older pass. The
* reuse appends a copy that keeps the original timestamp, so check's max age
* still measures the real run.
*
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
* rows at the current tree for the ship receipt.
*
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
* A synced record citing an unsynced log would grade FRESH on a machine where
* the log doesn't exist.
@@ -73,6 +90,10 @@ import { resultLine } from "../lib/result-codes";
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
import { versionAtRef } from "../lib/restamp";
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
import { findCarry } from "../lib/ci-carry";
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
import { buildRunCheck } from "../lib/pregate/run-check";
import { runInventory } from "../lib/run-evidence";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
const LOG_MAX_BYTES = 2 * 1024 * 1024;
@@ -93,6 +114,16 @@ interface EvidenceRecord {
wtree_changed?: string[];
log_path?: string;
redacted?: boolean;
/** sha256 of the runtime the run had (identityDigest). */
identity?: string;
/** The original ts of the row a `run --reuse` copied. */
reused_from?: string;
/** "ci" for a lane CI carried (record-ci); absent for a local run. */
source?: "ci";
runs?: string[];
profiles?: string[];
/** The base policy that mapped a carried lane (record-ci), as `origin/<base>@<sha>:.gstack/pregate.json`. */
policy?: string;
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
tests_ran?: number | "unknown";
}
@@ -369,7 +400,15 @@ async function cmdRun(argv: string[]): Promise<number> {
const sep = argv.indexOf("--");
if (li >= 0 && li + 1 < argv.length && (sep < 0 || li < sep)) label = argv[li + 1];
if (sep < 0 || sep + 1 >= argv.length) {
console.error("usage: gstack-evidence run --label <L> -- <cmd...>");
console.error("usage: gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>");
return 2;
}
const opts = argv.slice(0, sep);
const reuse = opts.includes("--reuse") && !opts.includes("--no-reuse");
const maxAgeAt = opts.indexOf("--max-age");
const reuseMaxAge = maxAgeAt >= 0 ? Number(opts[maxAgeAt + 1]) : 24;
if (!Number.isFinite(reuseMaxAge) || reuseMaxAge <= 0) {
console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(opts[maxAgeAt + 1])}`);
return 2;
}
const cmdArgv = argv.slice(sep + 1);
@@ -388,6 +427,7 @@ async function cmdRun(argv: string[]): Promise<number> {
} catch (e: any) {
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
}
if (reuse && paths && reused(paths.file, label, cmdSha, reuseMaxAge)) return 0;
const log = paths ? openLog(paths.logsDir, label, cmdSha) : undefined;
// Fingerprint the content BEFORE the child runs: a working-tree edit made
@@ -525,6 +565,7 @@ function record(
const wtreeAfter = currentWtree();
if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) {
rec.wtree = wtreeAfter;
rec.identity = identityDigest();
} else if (wtreeBefore || wtreeAfter) {
const changed = wtreeBefore && wtreeAfter ? (git(["diff", "--name-only", wtreeBefore, wtreeAfter]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean) : [];
if (changed.length) rec.wtree_changed = changed.slice(0, 5);
@@ -545,6 +586,118 @@ function record(
}
}
/**
* sha256 of the runtime a lane ran under: the bun and node on PATH, OS, architecture and runner image. The lockfiles and
* pins the execution identity also names (lib/evidence-bundle.ts) are working-tree content, so the fingerprint binds them.
*/
function identityDigest(): string {
const version = (b: string) => {
const r = spawnSync(b, ["--version"], { encoding: "utf-8", timeout: 15000 });
return r.status === 0 ? (r.stdout || "").trim().replace(/^v/, "") || null : null;
};
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
}
/**
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
*/
function reused(file: string, label: string, cmdSha: string, maxAgeHours: number): boolean {
const wtree = currentWtree();
if (!wtree) return false;
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(file);
} catch {
return false;
}
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
const age = Date.now() - Date.parse(newest.ts);
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
if (newest.identity !== identityDigest()) return false;
try {
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
} catch (e: any) {
warn(`ledger append failed (${e?.message ?? e}) — running the command instead`);
return false;
}
console.error(`gstack-evidence: reused label=${label} tree=${wtree.slice(0, 12)} from=${newest.ts}`);
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${newest.tests_ran} log=${newest.log_path ?? "-"}`);
return true;
}
/**
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
*/
function cmdRecordCi(argv: string[]): number {
const sep = argv.indexOf("--");
const opts = sep < 0 ? argv : argv.slice(0, sep);
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_");
const base = get("--base");
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
if (!label || !base || !command || opts.length !== 4) {
console.error("usage: gstack-evidence record-ci --label <L> --base <base> -- <command>");
return 2;
}
const none = (reason: string, detail: string) => {
console.error(`CARRY_NONE: ${reason}: ${detail}`);
console.error(`gstack-evidence record-ci: label=${label} not carried; nothing recorded`);
return 1;
};
const cwd = process.cwd();
const loaded = loadConfigFromBase(cwd, base);
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
const from = loaded.config.ship_lanes_from;
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
const m = from[command]!;
const check = buildRunCheck(cwd, null, true, base);
if ("error" in check) return none("profiles_unavailable", check.error);
const head = git(["rev-parse", "HEAD"]);
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, inventory: () => runInventory(m.inventory, cwd) });
if (!v.ok) return none(v.reason, v.detail);
let paths: ReturnType<typeof ledgerPath>;
try {
paths = ledgerPath();
mkdirpSync(paths.dir);
} catch (e: any) {
console.error(`gstack-evidence record-ci: ledger setup failed (${e?.message ?? e}); nothing recorded`);
return 1;
}
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,
};
appendJsonl(paths.file, rec, { mode: 0o600 });
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`);
return 0;
}
/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */
function cmdCarried(argv: string[]): number {
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(ledgerPath().file);
} catch {
records = [];
}
const wtree = currentWtree();
const latest = new Map<string, EvidenceRecord>();
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))
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
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;
}
function cmdCheck(argv: string[]): number {
// Parse: repeated --label, each optionally followed (anywhere later) by its
// own --expect-cmd; pairing is positional — an --expect-cmd binds to the most
@@ -605,7 +758,8 @@ function cmdCheck(argv: string[]): number {
}
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
@@ -756,6 +910,10 @@ const [, , sub, ...rest] = process.argv;
try {
if (sub === "run") {
process.exit(await cmdRun(rest));
} else if (sub === "record-ci") {
process.exit(cmdRecordCi(rest));
} else if (sub === "carried") {
process.exit(cmdCarried(rest));
} else if (sub === "check") {
process.exit(cmdCheck(rest));
} else if (sub === "bundle") {
@@ -765,7 +923,7 @@ try {
} else if (sub === "verify") {
process.exit(cmdVerify(rest));
} else {
console.error("usage: gstack-evidence run|check|bundle|ancestor|verify ...");
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
process.exit(2);
}
} catch (e: any) {
+6 -2
View File
@@ -4,7 +4,7 @@
*
* gstack-pregate [run] [--stage preflight|tests|all] [--only <check,..>] [--base <branch>]
* [--allow-repo-commands] [--policy-from base|head] [--out <pregate.json>]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
* [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
* gstack-pregate verify [--out <pregate.json>] [--json] publication check: current tree, no fail/incomplete, obligations cleared
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
@@ -94,6 +94,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
--from-run <id|url> a Free Tests run on this exact tree, verified against the base branch's run profile for this OS
whose workflow holds a local lane (docs/pregate.md#from-run): the selected test files its tree.json lists count as passed and
are recorded with its receipt; the rest still run. A refused run prints REUSE: refused and runs everything
--no-ci-carry without --from-run, the tests stage looks up the newest verified CI run at HEAD's clean tree under
those profiles itself and uses it (CI_CARRY: accepted <receipt> or CI_CARRY: none (<reason>);
docs/pregate.md#ci-carry); this flag skips that lookup
--explain print each selected test's provenance and each uncertain mapping
--json one JSON object on stdout; a refusal before any check runs (unavailable base, invalid pin
file, WTREE_UNAVAILABLE) is {ok:false,status:'refused',code,detail,fix,docs} with exit 3,
@@ -112,7 +115,7 @@ function flag(args: string[], name: string): string | undefined { const i = args
/** Flags each verb accepts: `[flags that take a value, bare flags]`. Anything else is usage (exit 2), never a silent default run. */
const VERB_FLAGS: Record<string, [string[], string[]]> = {
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote']],
run: [['--stage', '--only', '--base', '--policy-from', '--out', '--timeout', '--stall-timeout', '--spend-cap', '--run', '--from-run'], ['--allow-repo-commands', '--explain', '--json', '--remote', '--no-ci-carry']],
verify: [['--out'], ['--json']],
clear: [['--remote', '--run-url', '--run', '--bundle-lane', '--receipt', '--out'], ['--allow-repo-commands']],
lanes: [[], ['--explain', '--json']],
@@ -179,6 +182,7 @@ async function cmdRun(root: string, rest: string[], json: boolean): Promise<numb
}
const ctx = loaded.ctx;
if (fromRun !== undefined) ctx.fromRun = { ref: fromRun, check: buildRunCheck(root, null, allow, base) };
if (rest.includes('--no-ci-carry')) ctx.ciCarry = false;
ensureTmpExcluded(root);
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
+2 -2
View File
@@ -414,8 +414,8 @@ function main(): number {
const judged = flag('--judged');
if (!merged || !judged) { process.stderr.write('gstack-review-dispatch judge: needs --merged <file> and --judged <file>\n'); return EXIT.usage; }
const r = recordJudgment(ledger, readJsonl<MergedLine>(merged), readJsonl<JudgedLine>(judged), judged, merged);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one)\n`);
process.stdout.write(r.ok ? `JUDGMENT: ok added=${r.added.length} promoted=${r.promoted.length} folded=${r.folded.length}${r.dismissed.length ? ` dismissed=${r.dismissed.length}` : ''}\n`
: `JUDGMENT_REJECTED: dropped=${JSON.stringify(r.dropped)} downgraded=${JSON.stringify(r.downgraded)} unexplained=${JSON.stringify(r.unexplained)} invalid=${JSON.stringify(r.invalid)} (lead judgment may add or promote a finding, never drop or downgrade one; a summary_candidate line may be lowered with a reason)\n`);
return r.ok ? EXIT.ok : EXIT.fail;
}
if (cmd === 'verify') {
+32 -8
View File
@@ -37,7 +37,7 @@ import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { branchSides, type BranchSide } from '../lib/stamp-only';
import { branchSides, stampOnlyLine, type BranchSide } from '../lib/stamp-only';
import { runScriptSync } from '../lib/script-invocation';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
@@ -172,6 +172,12 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi
return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
}
/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
function ciCarried(): ShipReceipt['ci_carried'] {
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 });
try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; }
}
function cmdWrite(rest: string[], json: boolean): number {
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
const checkedOut = git(['rev-parse', 'HEAD'], repoRoot);
@@ -262,7 +268,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')),
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(),
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'),
@@ -516,7 +522,7 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[]
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
* (GST-343, #3606), into the product patch-id at one head only.
*/
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[] } | undefined {
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[]; demoted: string[]; ownRows: string[] } | undefined {
const changedRaw = git(['diff', '--name-only', from, to], repoRoot);
if (changedRaw === undefined) return undefined;
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
@@ -534,7 +540,19 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
: [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product')));
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] };
// A demoted path whose from-to change is, line for line, a stamp on the branch's own rows (named by their position in
// `from`), a regular file with the reviewed mode at both heads (GST-980): never the whole-text version swap.
const ownRows = demoted.filter(p => {
const side = branch?.own(p);
const mode = (ref: string) => /^(100644|100755) blob /.exec(git(['ls-tree', ref, '--', p], repoRoot) ?? '')?.[1];
const before = blobAt(from, p), after = blobAt(to, p), oldVersion = version(from), newVersion = version(to);
if (!side || before === null || after === null || !oldVersion || !newVersion || !mode(from) || mode(from) !== mode(to)) return false;
const was = before.split('\n');
const now = after.split('\n');
const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]);
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1));
});
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows };
}
/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */
@@ -665,6 +683,7 @@ function cmdCarry(rest: string[], json: boolean): number {
const current = retarget ? headPatch : productHead;
const changedProduct: string[] = [];
const contextPaths: string[] = [];
const stampOnly: string[] = [];
if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`);
else if (productFrom !== productHead && !retarget) {
// planCarry names at most CARRY_NAMED_PATHS differing paths, so the walk stops there, and it reads only the paths the
@@ -676,22 +695,27 @@ function cmdCarry(rest: string[], json: boolean): number {
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, fromBase, headBase)];
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
const [movedHead, movedBase] = moved as Set<string>[];
// A stamp file the branch changed as product (its own row) whose reviewed-to-current change is a stamp on its own lines
// and that the base did not touch holds no unreviewed change, so the comparison leaves its bytes out (GST-980).
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
if (changedProduct.length >= CARRY_NAMED_PATHS) break;
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p);
if (cls !== 'product') continue;
const verdict = comparePath(repoRoot, sides, p);
if (verdict === 'changed') changedProduct.push(p);
else if (verdict === 'context') contextPaths.push(p);
}
if (!changedProduct.length && !contextPaths.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
const left = [...contextPaths, ...stampOnly];
if (!changedProduct.length && !left.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
else if (!changedProduct.length) {
const rest = [...spec.product, ...contextPaths.map(p => `:(exclude,literal)${p}`)];
const rest = [...spec.product, ...left.map(p => `:(exclude,literal)${p}`)];
const restFrom = patchId(repoRoot, fromBase, from, rest);
const restHead = patchId(repoRoot, headBase, head, rest);
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${contextPaths.join(', ')}; compare the two base diffs without them)`);
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${left.join(', ')}; compare the two base diffs without them)`);
}
}
const stampedRows = changedProduct.length ? [] : stampOnly;
let scopedReview: string | undefined;
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
@@ -721,7 +745,7 @@ function cmdCarry(rest: string[], json: boolean): number {
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}) });
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}) });
}
if (!plan.ok) return refused(plan.code, plan.message);
// Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD,
@@ -1,2 +0,0 @@
### Fixed
- **The secrets check no longer reads a file name or dict key inside a call as a credential (GST-1064).** `key = json.loads(files.get("key.json", ""))` failed pregate with `PREGATE_SECRETS` (`env.kv`): eight distinct letters in `"key.json"` reach the entropy bar, so the call's argument looked like a secret. So did `token = resp.json().get("access_token")`. The first argument of a lookup call (`get`, `getenv`, `read`, `load`, `loads`, `open`, `pop`, `fetch`) that is a lowercase file name or a snake_case key of two or three words now counts as a name. Any other call still reports a password-shaped literal (`make_password("orange_monkey_pass")`, `bcrypt.hashpw(...)`), and so do a later argument or fallback (`os.getenv("SECRET_KEY", "orange_monkey_pass")`), a real key in quotes or in a `.env` line, and a kebab passphrase (`decrypt("prod-signing-key")`). The entropy bar is unchanged.
+117 -2
View File
@@ -90,6 +90,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
| `remote_gate_runs` | `{ profiles, inventory, skipped? }`: the run profiles that together clear `pregate.json/remote_gate`, the command that prints the remote gate's test files, one per line, which their runs must cover, and an optional command that prints which selected files the gate itself never runs (see [run evidence](#run-evidence)) |
| `ship_lanes_from` | maps each exact /ship lane command to `{ profiles, inventory }`: the run profiles whose verified runs at HEAD's clean tree carry that lane instead of running it, and the command listing its test files, which their runs must cover (see [CI carry](#ci-carry)); read from the base branch only |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
@@ -173,7 +174,7 @@ The list lives in its own file, not in `pregate.json`, because older pregate rea
```
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--explain] [--json]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--from-run <id|url>] [--no-ci-carry] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
```
@@ -493,8 +494,15 @@ the selected local files as a `requires-remote` obligation with the command to
run, `pregate.json/remote_gate` (the declared command) or
`test_backend/<backend>` (gstack's `scripts/ubicloud/ubi-runner.sh run --src
<repo> -- '<runner> <files>'`). Without either, the tier runs locally under
the supervision above. Close the obligation one of three ways:
the supervision above. Close the obligation one of four ways:
- by default, when the base declares `remote_gate_runs`, let CI clear it: the
tests stage clears it on its own from a verified run at this exact tree
([CI carry](#ci-carry)), and the uncleared obligation's detail names that
route first (`push, then gstack-ci-carry wait --base <base> and rerun
gstack-pregate --allow-repo-commands`, the whole pregate, so the record keeps
its preflight checks) and the VM only as the opt-in (under `--no-ci-carry` it
names the VM route); or
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
--receipt <file>` with the gate's output, validated as `--remote` validates it
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt; a receipt
@@ -907,6 +915,113 @@ as a usage error. It changes nothing in the preflight stage: with `--stage all`
the preflight checks, the secrets scan included, run as before, and
`--stage tests` runs no preflight check in that invocation, with or without it.
<a id="ci-carry"></a>
### CI carry: pregate and /ship use CI's run instead of running again (GSTA-258)
CI is the one full test run. When a verified run already passed at the exact
tree being tested, pregate's tests stage and /ship's test lanes use it instead
of running the suite a second time. `lib/ci-carry.ts` `findCarry` decides, and
it accepts a run only when all of these hold:
- The working tree is HEAD's committed tree (the `gstack-wtree` fingerprint
equals `HEAD^{tree}`), read before the lookup and again after it. A dirty
tree, an untracked file or an edit made during the lookup never carries.
- The newest of HEAD's runs (up to 10) under each named profile passes
`verifyRun` above: same-repository head (a fork run never counts), the
profile's workflow, event and jobs, the newest attempt, no newer red or
running run on HEAD, within `max_age_hours`, and a `tree.json` naming HEAD's
tree. A `pull_request` run counts only when the pull request already
contained its base.
- Every run executed tests, and with an inventory command, the runs'
`tree.json` files cover every file it prints. Each run is read once more
afterward (`run_changed`).
Security and advisory scans, the sealed guard, guards and every preflight-stage
check always run; only test lanes carry.
**The tests stage.** Without `--from-run`, when the base branch declares
`run_profiles`, `gstack-pregate` looks up the newest verified run at HEAD under
the profiles `--from-run` would use and applies it as `--from-run` does: the
selected local files its `tree.json` lists count as passed, and only the rest
run. On a small machine a selection the run covers records no remote gate
obligation. It prints `CI_CARRY: accepted <receipt>` or `CI_CARRY: none
(<reason>)` on stderr; `--no-ci-carry` skips the lookup.
When files are left over, the remote gate obligation still clears from the
same run when the base declares `remote_gate_runs` naming that run's profile:
the run must pass every `clear --run` check, with the carried files counted
as selected files. So a remainder of only files the gate's `skipped` command
names (gstack's paid evals, which the gate itself reports as
`PREGATE_REMOTE_PAID_ONLY` with `tests=0`) clears with `skipped=<n>` in the
receipt instead of becoming an obligation nothing can clear. The line is
`CI_CARRY: cleared pregate.json/remote_gate <receipt>`, or `CI_CARRY:
pregate.json/remote_gate stays uncleared (<reason>)` when a check refuses, for
example a gate inventory file missing from the run's `tree.json`.
**gstack's own gate.** gstack's `.gstack/pregate.json` declares
`remote_gate_runs` with the `ci-reuse/free-tests` profile, so on gstack a green
Free Tests run at the same tree is how the remote gate obligation clears, and
the Ubicloud VM (`gstack-pregate --stage tests --remote`) is an opt-in for when
no such run exists. The order after a push: wait for Free Tests
(`gstack-ci-carry wait --base main`), then `gstack-pregate
--allow-repo-commands`. A `pull_request` run tests GitHub's merge commit, so on
a branch that does not contain the base tip it tested another tree and
`CI_CARRY` says `none (no_verified_run: … tree_mismatch)`: dispatch Free Tests
on the branch
(`gh workflow run free-tests.yml --ref <branch>`), which tests HEAD's own tree,
and wait again. The preflight stage (generated-file freshness, secrets, strays
and the other mechanical checks) and the sealed guard are unchanged and always
run here. The macOS and Windows lane
obligations still clear with `gstack-pregate clear --remote <lane> --run-url
<url>` from their CI runs.
**/ship lanes: `ship_lanes_from`.** A lane carries only when the base branch's
`.gstack/pregate.json` maps its exact command (the evidence ledger binds the
command's sha256; a label alone never carries) to the profiles whose runs carry
it and the command that lists its test files:
```json
"ship_lanes_from": {
"bun run test": { "profiles": ["ci-reuse/free-tests"], "inventory": "bun --no-env-file --config=/dev/null run scripts/test-free-shards.ts --list | sed -n 's/^ //p'" }
}
```
A carried lane prints `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`
and writes an explicit CI-sourced ledger row (`gstack-evidence record-ci
--label <label> --base <base> -- <command>`, which finds and verifies the run
itself, so no caller hands it run ids or a test count: `source: "ci"`, its runs
and profiles, the run's finish time as `ts`, bound to the clean tree), never a
fictitious local run. `gstack-evidence check` grades it
like any row and names `source=ci runs=…`, so `finish`'s FRESH check and the
24-hour age limit apply to the CI run's own time. A mapped lane that cannot
carry prints `CI_CARRY: none label=<label> (<reason>)` and runs here. Every
other lane runs through `gstack-evidence run --reuse`: the newest row for its
label, command and working tree is reused when it passed with a counted run
under the same runtime (bun and node versions, OS, architecture, runner image)
within 24 hours; a newer failure on that tree vetoes an older pass, and the
reused copy keeps the original timestamp. The cache is this machine's ledger
only; across machines, CI is the trust boundary.
**Finish, push and accept.** `gstack-ship-release finish` commits the release
metadata, so the stamped tree has no CI run yet. A mapped lane that is not
already FRESH is deferred (`EVIDENCE: DEFERRED`), and the pregate runs only its
preflight stage on the stamped tree, written to its own file so the full
`.gstack/tmp/pregate.json` stays in place until `accept` replaces it. `gstack-ship-release push --early` (the
Phase 2 push that lets CI run beside the review) runs the secrets scan and the
sealed guard before it pushes. After the final push,
`gstack-ship-release accept --base <base> --lane …` waits (bounded by `--wait`)
for the run at the pushed HEAD, stops on a red or still-running run, carries
each mapped lane from the green run at that exact tree, reuses or runs the rest,
checks every lane FRESH and reruns the pregate there, whose tests stage carries
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>`).
**`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
prints `CI_WAIT: green|red|running|none`, then `find`'s verdict on green.
<a id="ci-reuse"></a>
### Main pushes reuse the merged pull request's run
+3 -1
View File
@@ -139,7 +139,9 @@ harness-generic; only the thread and the PR tool are Capy.
default branch.
4. **Run `/ship` unattended** with the `GSTACK_LAUNCH` assignments
exported: it runs the pregate (on a machine under 8 cores the test tier
needs `gstack-pregate --stage tests --remote`, about $0.06 on a Ubicloud VM
clears from a verified CI run at the pushed tree when the base declares
`remote_gate_runs` ([CI carry](pregate.md#ci-carry)); otherwise it needs
`gstack-pregate --stage tests --remote`, about $0.06 on a Ubicloud VM
or free locally for small selections), both reviews, the gate-integrity
list, the docs audit, and writes the ship receipt into the PR body
(`gstack-ship-receipt write --pr <n>`). With `queue_mode:
+18 -9
View File
@@ -601,22 +601,30 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
second line for the same field starts another: `Severity` sets its label (a label leading the finding stays its floor),
`Blocks: yes` makes it CRITICAL and `Blocks: no` never lowers it; an inline
`Blocks: yes` is CRITICAL too, and with no finding above it is one. The native
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167) unless collect `--manifest` names this
ledger's pass and its plan is the code or docs class with only `runtime=<n>` depth reasons (no `risk:`, `error:`,
`history:` or `floor:` reason). On such a plan a native finding's own `Severity` field decides and FIXABLE beside it
raises nothing once it also states `Blocks: no`; a native FIXABLE finding without `Blocks: no`, with no `Severity` field,
or whose field names only FIXABLE or INVESTIGATE still collects as CRITICAL (GST-987). An unnumbered, unlabeled bullet with no file:line
adversarial voice's FIXABLE collects as CRITICAL (normalizer v2, GSTA-167), except that on every class a native
finding that states `Blocks: no` and one `Severity` field collects at that field (GST-1031 ruling A); a native FIXABLE
finding without `Blocks: no`, with no `Severity` field, or whose field names only FIXABLE or INVESTIGATE still collects
as CRITICAL. On a code or docs plan with only `runtime=<n>` depth reasons (collect `--manifest` naming this ledger's
pass; GST-987) FIXABLE also raises nothing through a label heading or a leading tag. A numbered finding heading keeps
every unlabeled line under it (located or numbered bullets, a bold subheading, a located paragraph) until its
`Severity` or `Blocks` line comes, so those fields never split off as a second record (GST-1031). An unnumbered, unlabeled bullet with no file:line
under a paragraph finding whose Severity or Blocks line has not come yet is that finding's detail, so the field line after it
lands on the finding; a numbered item is its own finding.
A negated severity word still collects at its label ("found nothing CRITICAL", "No P0/P1 issues"), so a reader's
closing summary is a known phantom: it stays at that label in the judged set, lead judgment can fold it only into a
finding at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost
closing summary is a known phantom: it collects at that label. When it is a summary candidate (no lead label, no
`Severity` or `Blocks` field, and no file:line or exception clause in any sentence naming a label: "so nothing is
CRITICAL", "the test covers the CRITICAL fix"), collect marks it `summary_candidate` and lead judgment may lower it
with a reason, which judge records as `dismissed` (GST-1031); otherwise lead judgment can fold it only into a finding
at least as severe, and it counts toward what blocks. GST-766 skipped negated labels and lost
real blockers named in an exception clause ("No CRITICAL issues except the token leak in log.ts:3"); GSTA-167
reverted that. Each prose finding's line also
records the Severity and Blocks its own text states as `stated`, beside the collected label and never in its place;
a classified finding that carries `stated` shows it in its deferred-review issue next to the label ("CRITICAL (the
reviewer wrote Severity: Low, Blocks: no)"). /ship's Step 9.4 does not copy `stated` into its findings file yet: that
instruction lives in a reviewer brief that changes only after a no-loss replay. What blocks is unchanged.
instruction lives in a reviewer brief that changes only after a no-loss replay. A CRITICAL whose own text writes the
field `Blocks: no` (colon required, and no `Blocks: yes`) is not counted as a blocker by the ledger count, nor by the
review-log count or classify when the finding carries `stated` (GST-1031); lead judgment that raises such a line above
its label counts it again.
A phantom finding costs a fold; a lost one costs the gate.
A reply with no finding counts as a review only with the clean marker its own brief asks for: `NO FINDINGS` alone on a
line for the specialists, the batched specialist, the targeted reader, the Red Team and fix-check, the `Recommendation:`
@@ -690,7 +698,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends:
working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout.
A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and
dispatches. Step 11's voices take the `adversarial-review` start
token by default, and a `logStart` list without it stops before anything runs.
token by default, and a `logStart` list without it stops before anything runs. Decision 3.5's fix-check pass turns a
`review` start into `review-fix-check`, so its row is never a pass (GST-1031).
## Where this is enforced
+2 -1
View File
@@ -1503,6 +1503,7 @@ receipt when the base moves:
| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) |
| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) |
| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent <branch> landed`) |
| a stamp on the branch's own rows in a stamp file the branch also changed as product: every changed line at a branch-added position, the file's mode unchanged, the base not touching it, the rest of the product diff unchanged | carries (reason `a stamp on the branch's own rows (<file>)`) |
**Stacked PRs (#3570).** A child PR's receipt is written against its parent
branch, so its `base` names that branch and its `product_patch_id` is the
@@ -1673,7 +1674,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A base line whose text duplicates a line the branch added gets the branch's bound too, because the checks match branch lines by text, not position (GST-979). The /ship receipt carry can still refuse the move (`RECEIPT_CARRY_PRODUCT_CHANGED`), and when the branch keeps its VERSION claim, carry and finish still route the move as behavior (GST-980).
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and so does one of the fork-point version itself (a row the branch wrote at the version it forked from instead of a next version is its own stale stamp; a branch-added mention older than the fork point stays), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (<file>: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff <since>..<base> for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. The checks that accept a stamp commit read the same bound as the writer (`stampOnlyLine` with the branch's side in `lib/stamp-only.ts`): the tree receipt behind `stamp`, `verify` and the gate-ahead replay, the merge train's stamp and verify checks (each reading the slot's predecessor, the gated commit's first parent, as the base), the /ship receipt carry, and finish's route each take a moved version on a line the branch added at or above the fork-point version, and on any other line at or above the gated head's VERSION. A branch line is named by its position in the gated file, so a base line whose text duplicates a line the branch added keeps the gated head's bound (GST-979). When the stamp keeps the VERSION (a slot that keeps its claim), only the branch's own lines may move, so a base line moved by hand is refused. The carry follows a stamp on the branch's own rows in a stamp file the branch also changed as product when every changed line of that file sits at a branch-added position, the file keeps its mode, the base did not touch it and the rest of the product diff is unchanged (`a stamp on the branch's own rows` in the carry reason, GST-980).
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
+7 -3
View File
@@ -146,7 +146,9 @@ record. Another successful fetch alone cannot certify corrupted history.
Outside reviews send your diff, plan or question to a second AI provider. Their
verdict comes from `lib/outside-review-result.ts`, which prints
`VERDICT: clean|findings|unverified|unavailable`. A `findings` verdict with a
`VERDICT: clean|findings|unverified|unavailable`. When given an events file, it
also prints `OUTSIDE_USAGE:` with the token counters Codex reported, or
`OUTSIDE_USAGE: unknown` when it reported none. A `findings` verdict with a
P0 or P1 finding blocks exactly like a native P0/P1. `unverified` and
`unavailable` are missing coverage: /ship and /review continue, show the gap in
the readiness dashboard and the PR body, and never count it as a pass.
@@ -5987,8 +5989,10 @@ retargeted stacked PR the parent branch and the base.
conflict-only base merge, a test-only change, release metadata, a registered
output the pregate's regen check proved, a rebase onto a covered tree with
the recorded product diff, a stacked PR's retarget once its parent fully
landed on the base, or base context next to unchanged hunks after a clean
scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the
landed on the base, a stamp on the branch's own rows in a stamp file it also
changed as product (every changed line at a branch-added position, the mode
unchanged, the base not touching the file), or base context next to unchanged
hunks after a clean scoped review ([`RECEIPT_CARRY_SCOPED_REVIEW`](#receipt-carry-scoped-review)), which the
`carry` object in the carried receipt records with the product patch-id and
the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)).
+13 -3
View File
@@ -216,8 +216,9 @@ review-log row is the same row every Step 11 pass writes
(the outside provider that reviewed, with the model recorded, for the
cross-family subagent; `codex` for completed Codex CLI output, as
`ship/sections/adversarial.md` prescribes),
`outside_provider` (the provider the host would have used, as the generated
prose names it), `outside_status` (`completed` for a finished outside pass;
`outside_provider` (on Capy, `host-subagent`, with `outside_model`, both copied
from the pass program's `OUTSIDE_RECORD:` line; elsewhere the provider the
generated prose names), `outside_status` (`completed` for a finished outside pass;
`unavailable` when neither the CLI nor a subagent completed, the cause on the
`gstack-outside-voice run:` stderr line and in `<out>.meta.json` rather than
in the row; `disabled` under
@@ -237,7 +238,16 @@ when `input.models['outside-voice']` is empty and the lane model is
rounds ran. Any other lane must name a model. An outside voice from the same
provider as the lane model or the native voice stops the pass (`STOP:`) before
either voice is created, including on a rerun, and each outside voice prints an
`OUTSIDE_VOICE:` line saying whether it is the certified configuration.
`OUTSIDE_VOICE:` line saying whether it is the certified configuration. An
outside voice still running with no output after `input.stuckMs` (20 minutes by
default) is stopped, and one that stops or fails without leaving findings, or
finishes empty, is collected as unavailable coverage (`COLLECT_UNAVAILABLE:`)
instead of stopping the pass, so Step 11 goes on to judge the native voice. A
stopped or failed outside voice whose reply holds findings is collected like any
finished one, so its findings are kept; one whose reply holds none is unavailable. That outside voice then gets its own
row (`source: "host-subagent"`, `status: "unavailable"`, `completed` and
`converged` false, written without `--finish` before the native row), so a
missing voice never reads as converged or as an unavailable Codex CLI.
### The carry across a base merge or a test-only fix
+49 -7
View File
@@ -1,10 +1,12 @@
// gstack /ship review pass program for Capy (lib/capy-review-pass.js, GSTA-167 calls PR 2).
// The body of an async function (tools, sleep, now, input), loaded verbatim by the loader in
// ship/DRIVER.md from `gstack-review-dispatch program`; never retyped. One call runs one pass (decision 3.5's
// fix-check with its plan's voices included) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`,
// fix-check with its plan's voices included, whose `review` start becomes `review-fix-check`, never a pass: GST-1031) or Step 11's voices from the manifest `gstack-review-dispatch pass` writes: render, chunked brief transport, `sent`,
// intent before create, create, poll, collect. It returns 200 s after the loader's first line
// (input.startedAt) at most; the next call resumes from the manifest. A failed, stuck or ambiguous
// reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped.
// reader, a brief mismatch or a failing bin returns `STOP:` for the parent; nothing is dropped. The outside
// voice is the exception (GST-1104): one that hangs past stuckMs is stopped, and one that hangs, ends empty, or
// stops or fails without findings is collected as unavailable coverage (one that left findings is collected); Step 11 returns OUTSIDE_RECORD: for its review-log rows.
// Once Phase 2 has started <run dir>/parts (the parent of input.out), each poll or finished pass also
// returns the next phase's section parts that fit its 32 KB (`gstack-ship-parts next`, GSTA-167 C5);
// after a return that never reached the parent, rerun with input.resendParts to get that batch again.
@@ -124,6 +126,16 @@ const familyStop = (readers) => {
return null;
};
const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_');
const UNAVAILABLE = 'unavailable: ';
const recordUnavailable = async (m, r, why) => {
const file = m.out + '/out/' + safe(r.reader) + '.unavailable.txt';
await tools.capy.write({ path: file, content: 'outside voice (' + modelOf(r).model_id + ') ' + why + '\n' });
const c = await sh(bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf) + ' --unavailable ' + q(why));
if (c.code !== 0) return stop('collect --unavailable ' + r.reader + ' exited ' + c.code + ': ' + c.out.slice(-500));
lines.push(c.out.trim());
await mark(r.reader, 'collected', { output: file, error: UNAVAILABLE + why });
return null;
};
const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team'];
const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before';
@@ -145,8 +157,9 @@ try {
if (start.exit_code !== 0) return stop('cannot read the directory readers on the shared machine start in: ' + got.slice(-300) + '; no start token was taken and no reader was dispatched');
if (got !== 'NOT_A_CHECKOUT' && got !== want) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + " (the thread's working directory) on this branch, with a fresh --out");
}
const starts = input.stage !== 'voices' && m.next === 'fix-check' ? logStart.map((s) => (s.split(' ')[0] === 'review' ? 'review-fix-check' + s.slice(6) : s)) : logStart;
const seen = {};
for (const s of logStart) {
for (const s of starts) {
const nth = (seen[s] = (seen[s] || 0) + 1);
const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : '');
if ((m.log_starts || {})[key]) continue;
@@ -185,20 +198,44 @@ try {
for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'created')) {
const t = byPath.get(r.path);
if (!t) return stop(r.reader + "'s subagent " + r.path + ' is missing from the subagent list');
if (t.status === 'failed' || t.status === 'stopped') { await mark(r.reader, 'failed', { error: t.status }); return stop(r.reader + ' (subagent ' + r.path + ') ' + t.status + '; its review is incomplete, so the pass cannot certify coverage'); }
const outside = r.role === 'outside-voice';
const ended = t.status === 'failed' || t.status === 'stopped';
if (ended && !outside) { await mark(r.reader, 'failed', { error: t.status }); return stop(r.reader + ' (subagent ' + r.path + ') ' + t.status + '; its review is incomplete, so the pass cannot certify coverage'); }
const stuck = async () => r.state_at && (await now()) - Date.parse(r.state_at) > stuckMs;
const g = t.status === 'done' || t.status === 'idle' ? await tools.subagent.get({ path: r.path, transcript: 'none' }) : null;
const g = t.status === 'done' || t.status === 'idle' || ended ? await tools.subagent.get({ path: r.path, transcript: 'none' }) : null;
if (ended && !(g && g.summary)) {
const s = await recordUnavailable(m, r, t.status + ' with no result');
if (s) return s;
continue;
}
if (!g || !g.summary) {
if (t.status === 'done') {
const seen = (emptyDone[r.reader] ??= await now());
if ((await now()) - seen >= emptyGraceMs) return stop(r.reader + ' (subagent ' + r.path + ') finished with no output');
if ((await now()) - seen < emptyGraceMs) continue;
if (!outside) return stop(r.reader + ' (subagent ' + r.path + ') finished with no output');
const s = await recordUnavailable(m, r, 'finished with no output');
if (s) return s;
continue;
}
if (await stuck()) return stop(r.reader + ' (subagent ' + r.path + ') still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output');
if (!(await stuck())) continue;
const why = 'still ' + t.status + ' after ' + Math.round(stuckMs / 60000) + ' minutes with no output';
if (!outside) return stop(r.reader + ' (subagent ' + r.path + ') ' + why);
await tools.subagent.stop({ paths: [r.path] });
const s = await recordUnavailable(m, r, why + '; stopped');
if (s) return s;
continue;
}
const file = m.out + '/out/' + safe(r.reader) + '.txt';
await tools.capy.write({ path: file, content: g.summary });
if (ended) {
const why = t.status + ' before finishing its review';
const u = await sh(bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf) + ' --unavailable ' + q(why));
if (u.code === 0) {
lines.push(u.out.trim());
await mark(r.reader, 'collected', { output: file, error: UNAVAILABLE + why });
continue;
}
}
const c = await sh(r.prompt_file ? bin + ' mark --manifest ' + q(mf) + ' --reader ' + q(r.reader) + ' --state collected --output ' + q(file)
: bin + ' collect --ledger ' + q(m.ledger) + roleArgs(r) + ' --output ' + q(file) + ' --merged ' + q(m.merged) + ' --manifest ' + q(mf));
if (c.code !== 0) return stop((r.prompt_file ? 'mark ' : 'collect ') + r.reader + ' exited ' + c.code + ': ' + c.out.slice(-500));
@@ -265,6 +302,11 @@ try {
const o = await sh('cat ' + q(r.output));
outs.push('OUTPUT: ' + r.reader + ' file=' + r.output, o.out.trimEnd());
}
for (const r of done.filter((x) => x.role === 'outside-voice')) {
const lost = String(r.error || '').startsWith(UNAVAILABLE);
outs.push('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":' + JSON.stringify(modelOf(r).model_id) + ',"outside_status":"' + (lost ? 'unavailable' : 'completed') + '" (Step 11\'s review-log rows carry these fields, never the template\'s codex'
+ (lost ? '; first write this voice its own adversarial-review row with "source":"host-subagent","status":"unavailable","completed":false,"converged":false and no --finish, then the native row' : '') + ')');
}
if (noteExit !== 0) return finish([...outs, 'STOP: gstack-ship-budget note exited ' + noteExit + ' (its lines above say why); judge nothing further until the budget allows it']);
return await withParts([...outs, input.stage === 'voices' ? 'NEXT: judge the voices' : input.step === '14.5' ? 'NEXT: apply the docs audit (Step 14.5)' : 'NEXT: judge (Phase 4)']);
} catch (e) {
+132
View File
@@ -0,0 +1,132 @@
/**
* ci-carry: CI is the one full test run (GSTA-258, R65 item 1). When a verified
* CI run already passed at the exact tree /ship or pregate is about to test,
* they use it instead of running the suite again.
*
* `findCarry` looks up the newest verified run of each named run profile (the
* base branch's `.gstack/pregate.json` `run_profiles`) for HEAD and accepts it
* only when every rule holds:
*
* the working tree is HEAD's committed tree (gstack-wtree), before and after
* the lookup, so admitted dirty or untracked work never carries;
* each run passes verifyRun (same-repository head, the profile's workflow,
* event and jobs, the newest attempt, no newer red or running run on HEAD,
* max age, the run's own tree.json naming HEAD's tree);
* every run executed tests;
* with an inventory command, the runs' tree.json files cover every file it
* prints (a lane carries only what CI provably ran);
* every run still stands after the inventory ran (runChanged).
*
* Anything else returns a refusal with a reason code, and the caller runs the
* suite as before. Security and advisory scans and the sealed guard are never
* carried: callers carry test lanes only.
*
* waitForRun polls the newest run of one workflow on HEAD until it completes,
* bounded by a timeout, and returns green, red with the failing jobs, still
* running, or none.
*/
import { runChanged, 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' | 'run_changed' | 'tree_changed' | 'api_error';
export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number; receipt: string } | CarryRefused;
/** How many of HEAD's runs per workflow are tried, newest first. */
const CANDIDATES = 10;
const no = (reason: CarryRefusal, detail: string): CarryRefused => ({ ok: false, reason, detail });
const newestFirst = (a: ApiRun, b: ApiRun) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || b.id - a.id;
/** The newest run of `profile` on `head` that verifies; a refusal lists each candidate's reason, newest first. */
function newestVerified(src: RunSource, name: string, profile: RunProfile, head: string): { ok: true; evidence: RunEvidence } | CarryRefused {
const listed = src.deps.runsForHead(profile.workflow, head).filter(r => profile.events.includes(r.event)).sort(newestFirst).slice(0, CANDIDATES);
if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`);
const refusals: string[] = [];
for (const run of listed) {
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now });
if (v.ok) return v;
refusals.push(`run ${run.id}: ${v.reason}`);
}
return no('no_verified_run', `${name}: ${refusals.join('; ')}`);
}
/**
* 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.
*/
export function findCarry(src: RunSource, o: { profiles: string[]; head: string; tree: string; wtree: () => string | null; inventory?: () => string[] | { error: string } }): CarryVerdict {
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');
const runs: RunEvidence[] = [];
try {
for (const name of o.profiles) {
const profile = src.profiles[name];
if (!profile) return no('no_profile', `${name} is not a run_profiles name on the base branch`);
const v = newestVerified(src, name, profile, o.head);
if (!v.ok) return v;
runs.push(v.evidence);
}
const other = runs.find(e => e.tree !== o.tree);
if (other) return no('tree_mismatch', `run ${other.run_id} tested tree ${other.tree.slice(0, 12)}, not ${o.tree.slice(0, 12)}`);
const empty = runs.find(e => !e.files.length || e.tests === 0);
if (empty) return no('no_tests', `run ${empty.run_id}'s tree.json ${empty.files.length ? 'records 0 tests' : 'lists no test files'}`);
if (o.inventory) {
const listed = o.inventory();
if ('error' in listed) return no('inventory_failed', listed.error);
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
const missing = uncoveredFiles(listed, runs);
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
}
for (const e of runs) {
const changed = runChanged(src.deps, e.run_id, e.run_attempt);
if (changed) return no('run_changed', changed);
}
} catch (e) {
return no('api_error', `reading runs failed: ${String((e as Error)?.message ?? e).slice(0, 300)}`);
}
const after = o.wtree();
if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`);
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)}` };
}
export type WaitVerdict = { state: 'green'; run: ApiRun } | { state: 'red'; run: ApiRun; failing: string[] } | { state: 'running'; run: ApiRun } | { state: 'none'; reason: string };
const PASSING = ['success', 'skipped', 'neutral'];
/** Poll the newest `workflow` run on `head` (among `events`) until it completes or `timeoutMs` passes. */
export async function waitForRun(deps: RunDeps, o: { workflow: string; events: string[]; head: string; timeoutMs: number; intervalMs: number; sleep: (ms: number) => Promise<void>; clock: () => number }): Promise<WaitVerdict> {
const end = o.clock() + o.timeoutMs;
for (;;) {
const newest = deps.runsForHead(o.workflow, o.head).filter(r => o.events.includes(r.event)).sort(newestFirst)[0];
if (newest?.status === 'completed') {
if (newest.conclusion === 'success') return { state: 'green', run: newest };
const failing = deps.jobs(newest.id, newest.run_attempt).filter(j => !PASSING.includes(j.conclusion ?? '')).map(j => j.name);
return { state: 'red', run: newest, failing };
}
if (o.clock() >= end) return newest ? { state: 'running', run: newest } : { state: 'none', reason: `no ${o.workflow} run on ${o.head.slice(0, 12)} after ${Math.round(o.timeoutMs / 1000)} s` };
await o.sleep(Math.max(1, Math.min(o.intervalMs, end - o.clock())));
}
}
/** `ship_lanes_from` (base branch only): exact lane command → the profiles whose runs carry it and the command listing its test files. */
export interface ShipLaneMapping { profiles: string[]; inventory: string }
/** `<path>: <message>` errors for `ship_lanes_from`; empty when valid. Shared with lib/pregate/config.ts. */
export function shipLanesErrors(raw: unknown, profileNames: readonly string[]): Array<{ path: string; message: string }> {
if (raw === undefined) return [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'ship_lanes_from', message: 'must be an object of exact lane command → { profiles: [run_profiles names], inventory: command }' }];
const errors: Array<{ path: string; message: string }> = [];
const oneLine = (s: unknown) => typeof s === 'string' && !!s.trim() && s.length <= 512 && !/[\0\r\n]/.test(s);
for (const [command, m] of Object.entries(raw as Record<string, unknown>)) {
const at = `ship_lanes_from[${JSON.stringify(command)}]`;
if (!oneLine(command)) errors.push({ path: at, message: 'the key must be the exact lane command: one line under 512 characters' });
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push({ path: at, message: 'must be { profiles, inventory }' }); continue; }
const o = m as Record<string, unknown>;
for (const k of Object.keys(o)) if (k !== 'profiles' && k !== 'inventory') errors.push({ path: `${at}.${k}`, message: 'unknown key; valid keys: profiles, inventory' });
if (!Array.isArray(o.profiles) || !o.profiles.length || o.profiles.some(p => typeof p !== 'string')) errors.push({ path: `${at}.profiles`, message: 'must be a non-empty array of run_profiles names' });
else for (const p of o.profiles as string[]) if (!profileNames.includes(p)) errors.push({ path: `${at}.profiles`, message: `${JSON.stringify(p)} is not a run_profiles name` });
if (!oneLine(o.inventory)) errors.push({ path: `${at}.inventory`, message: 'must be one command line under 512 characters that prints the lane\'s test files, one per line' });
}
return errors;
}
+3
View File
@@ -97,6 +97,8 @@ export interface PregateFile {
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 }>;
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;
@@ -239,6 +241,7 @@ 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 } } },
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,
+40 -2
View File
@@ -297,6 +297,43 @@ const LEGACY_REASONS: Partial<Record<GateReason, string>> = {
review_refused: 'review refused',
};
export interface TokenUsage {
input_tokens?: number; cached_input_tokens?: number; cache_write_input_tokens?: number;
output_tokens?: number; reasoning_output_tokens?: number; total_tokens?: number; usd: number | 'unknown';
}
const USAGE_COUNTERS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'] as const;
/**
* Token usage from `codex exec --json` events, read loosely. A cumulative
* `total_token_usage` snapshot (info, payload.info or msg.info) wins, latest first;
* without one, the latest `usage` object does: codex exec fills turn.completed.usage
* with the session's running total, so summing turns would overcount. Only the
* counters the events carry are reported.
*/
export function usageFromEvents(events: string): TokenUsage {
let cumulative: Record<string, unknown> | undefined;
let latest: Record<string, unknown> = {};
for (const line of events.split('\n')) {
if (!line.trim()) continue;
let obj: any;
try { obj = JSON.parse(line); } catch { continue; }
const snapshot = obj?.info?.total_token_usage ?? obj?.payload?.info?.total_token_usage ?? obj?.msg?.info?.total_token_usage;
if (snapshot && typeof snapshot === 'object') { cumulative = snapshot; continue; }
if (!obj?.usage || typeof obj.usage !== 'object') continue;
latest = obj.usage;
}
const source = cumulative ?? latest;
const usage: TokenUsage = { usd: 'unknown' };
for (const key of USAGE_COUNTERS) if (typeof source[key] === 'number') usage[key] = source[key] as number;
return usage;
}
/** The OUTSIDE_USAGE line: counters the events carried, or `unknown` when they carried none. */
export function formatUsageLine(usage: TokenUsage): string {
const parts = USAGE_COUNTERS.filter(key => usage[key] !== undefined).map(key => `${key.replace(/_tokens$/, '')}=${usage[key]}`);
return `OUTSIDE_USAGE: ${parts.length ? parts.join(' ') : 'unknown'}`;
}
/** Text-only compatibility shape for direct importers (claude-code/SKILL.md.tmpl) and the two-argument CLI. */
export function validateOutsideReview(text: string, gate: OutsideGate): { completed: boolean; reason?: string; score?: number; gate?: 'pass' | 'fail' } {
const checked = classifyOutsideReview({ text, gate });
@@ -347,12 +384,13 @@ if (import.meta.main) {
if (!result.completed) { console.error(`Outside review unavailable: ${result.reason}; missing coverage.`); process.exit(1); }
process.exit(0);
}
const checked = classifyOutsideReview({ text, gate: gate as OutsideGate, exit,
stderr: await readOptional(flags['--stderr']), events: await readOptional(flags['--events']) });
const events = await readOptional(flags['--events']);
const checked = classifyOutsideReview({ text, gate: gate as OutsideGate, exit, stderr: await readOptional(flags['--stderr']), events });
const label = flags['--label'] ?? 'Outside review';
console.log(`VERDICT: ${checked.verdict}`);
console.log(`FINDINGS: ${checked.findings.highest ?? (checked.verdict === 'findings' ? 'unspecified' : 'none')}`);
if (checked.unparsed) console.log(`UNPARSED: ${checked.unparsed}`);
if (flags['--events'] !== undefined) console.log(formatUsageLine(usageFromEvents(events)));
if (checked.reason) {
console.log(`REASON: ${checked.reason}`);
console.error(gateOutcomeLine(label, checked.reason, checked.detail));
+4 -17
View File
@@ -18,7 +18,9 @@
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { classifyOutsideReview, type OutsideGate, type OutsideReviewClassification } from './outside-review-result';
import { classifyOutsideReview, usageFromEvents, type OutsideGate, type OutsideReviewClassification, type TokenUsage } from './outside-review-result';
export { usageFromEvents };
export type RunnerId = 'codex-cli' | 'api' | 'host-subagent';
export const RUNNER_IDS: readonly RunnerId[] = ['codex-cli', 'api', 'host-subagent'];
@@ -43,7 +45,7 @@ export interface RunnerRequest {
binDir?: string;
}
export type RunnerStatus = 'completed' | 'unverified' | 'unavailable' | 'failed' | 'timeout' | 'cancelled';
export interface RunnerUsage { input_tokens?: number; output_tokens?: number; usd: number | 'unknown' }
export type RunnerUsage = TokenUsage;
export interface RunnerResult {
schema_version: 1; runner: RunnerId; status: RunnerStatus; model: string | null; family: ModelFamily;
capabilities: RunnerCapabilities; input_sha256: string; output_sha256: string | null; output: string | null;
@@ -104,21 +106,6 @@ function classify(text: string, gate: OutsideGate, extra: { stderr?: string; exi
return { status, verdict };
}
/** Token usage from `codex exec --json` events, read loosely: any object carrying usage counters. */
export function usageFromEvents(events: string): RunnerUsage {
let input: number | undefined; let output: number | undefined;
for (const line of events.split('\n')) {
if (!line.trim()) continue;
let obj: any;
try { obj = JSON.parse(line); } catch { continue; }
const usage = obj?.usage ?? obj?.info?.total_token_usage ?? obj?.payload?.info?.total_token_usage ?? obj?.msg?.info?.total_token_usage;
if (!usage || typeof usage !== 'object') continue;
if (typeof usage.input_tokens === 'number') input = usage.input_tokens;
if (typeof usage.output_tokens === 'number') output = usage.output_tokens;
}
return { ...(input !== undefined ? { input_tokens: input } : {}), ...(output !== undefined ? { output_tokens: output } : {}), usd: 'unknown' };
}
// ---------------------------------------------------------------------------
// codex-cli: today's path, as one process this module can cancel
// ---------------------------------------------------------------------------
+12 -4
View File
@@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
import { verifyFromRun } from '../../run-evidence';
import { verifyFromRun, type RunProfile } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -533,7 +533,10 @@ async function runSelected(ctx: PregateContext, sel: Selection, base: CheckResul
const smallMachine = ctx.cores < SMALL_MACHINE_CORES && sel.localTests.length > 0 && (ctx.config.remote_gate || ctx.testBackend !== 'local');
if (smallMachine) {
const obligation = remoteObligation(ctx, sel);
return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands` };
const route = obligation.lane === REMOTE_GATE_LANE && ctx.config.remote_gate_runs && ctx.ciCarry !== false
? `a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base ${ctx.base} and rerun gstack-pregate --allow-repo-commands (a pull_request run of a branch behind its base tested the merge, another tree: dispatch the workflow on the branch); or opt in to the VM: gstack-pregate --remote --spend-cap <usd> --allow-repo-commands`
: `run it, then ${clearHint(obligation.lane)}, or gstack-pregate --remote --spend-cap <usd> --allow-repo-commands`;
return { ...base, status: 'requires-remote', remote: [...sel.remote, obligation], lines: [...lines, `requires-remote ${obligation.lane}: ${ctx.cores} cores (under ${SMALL_MACHINE_CORES}); ${sel.localTests.length} local test file(s) recorded for the remote gate, not run here`, `run: ${obligation.command}`], detail: `${sel.localTests.length} local test file(s) recorded as requires-remote ${obligation.lane} (${ctx.cores} cores, under ${SMALL_MACHINE_CORES}); ${route}` };
}
if (!ctx.allowRepoCommands) return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', detail: `${sel.localTests.length} local test file(s) selected but not run (pass --allow-repo-commands)`, fix: 'rerun with --allow-repo-commands (what /ship passes)' };
if (sel.localTests.length === 0) return { ...base, status: sel.remote.length ? 'requires-remote' : 'pass', detail: `no local lane; ${sel.remote.length} requires-remote obligation(s)` };
@@ -624,6 +627,12 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
return mode === 'no-policy' ? withStarter(ctx, sel, result) : withNoLaneWarning(ctx, sel, result);
}
/** The base profiles a run may verify under here: this machine's OS, and a workflow that holds a lane this machine runs. */
export function fromRunProfiles(ctx: PregateContext, profiles: Record<string, RunProfile>): string[] {
const os = ({ linux: 'Linux', darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[ctx.platform];
return Object.entries(profiles).filter(([, p]) => p.os === os && ctx.lanes.some(l => l.tier !== 'platform' && l.id.startsWith(`${p.workflow}/`))).map(([n]) => n);
}
/**
* `--from-run`: verify the run against the base branch's profiles for this machine's OS whose workflow holds a lane this
* machine runs (not a platform lane), on exactly this tree; the selected local test files its tree.json lists are the
@@ -631,8 +640,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
*/
function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: string[]; receipt?: string } {
const { ref, check } = ctx.fromRun!;
const os = ({ linux: 'Linux', darwin: 'macOS', win32: 'Windows' } as Record<string, string>)[ctx.platform];
const names = 'error' in check ? [] : Object.entries(check.profiles).filter(([, p]) => p.os === os && ctx.lanes.some(l => l.tier !== 'platform' && l.id.startsWith(`${p.workflow}/`))).map(([n]) => n);
const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles);
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
const ran = new Set(v.evidence.files);
+12 -4
View File
@@ -46,6 +46,9 @@
* tree, the command listing the gate's test files they must
* cover, and the command naming the selected files the gate
* itself never runs (gstack: paid files)
* ship_lanes_from exact /ship lane command → `{ profiles, inventory }`: the
* run profiles whose verified runs at HEAD's tree carry that
* lane, and the command listing its test files (lib/ci-carry.ts)
* allowlist `{ finding, paths?, pattern?, reason }` entries the `secrets`
* check accepts as benign: a gstack-redact finding id or a
* non-secret category, scoped by path globs and/or a regex
@@ -67,6 +70,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { shipLanesErrors, type ShipLaneMapping } from '../ci-carry';
import type { ResultCodeName } from '../result-codes';
import { runProfileErrors, type RemoteGateRuns, type RunProfile } from '../run-evidence';
import { sealedErrors } from '../sealed-guard';
@@ -102,15 +106,16 @@ export interface PregateConfig {
allowlist: AllowlistEntry[];
run_profiles: Record<string, RunProfile>;
remote_gate_runs: RemoteGateRuns | null;
ship_lanes_from: Record<string, ShipLaneMapping>;
}
export interface Guard { id: string; when: string[]; command: string; fix?: string; timeout_s?: number }
export interface AllowlistEntry { finding: string; paths?: string[]; pattern?: string; reason: string }
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, timeout_s: null, remote_gate: null, allowlist: [], run_profiles: {}, remote_gate_runs: null };
export const DEFAULT_CONFIG: PregateConfig = { runner: null, dependencies: {}, empty_lanes: [], selection: {}, warn: [], strays: { allow: [] }, local: [], guards: [], serial_patterns: DEFAULT_SERIAL_PATTERNS, price_file: null, stall_timeout_s: null, timeout_s: null, remote_gate: null, allowlist: [], run_profiles: {}, remote_gate_runs: null, ship_lanes_from: {} };
export interface ConfigError { code: ResultCodeName; path: string; message: string }
export const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist', 'run_profiles', 'remote_gate_runs'];
const KEYS = ['runner', 'dependencies', 'empty_lanes', 'selection', 'warn', 'strays', 'local', 'guards', 'serial_patterns', 'price_file', 'sealed', 'stall_timeout_s', 'timeout_s', 'remote_gate', 'allowlist', 'run_profiles', 'remote_gate_runs', 'ship_lanes_from'];
const LANE_ID = /^[A-Za-z0-9_.-]+\.ya?ml\/[A-Za-z0-9_-]+$/;
const MAX_GUARDS = 32;
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
@@ -202,6 +207,8 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'remote_gate', message: why });
}
for (const e of runProfileErrors(obj.run_profiles, obj.remote_gate_runs)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: e.path, message: e.message });
const profileNames = obj.run_profiles && typeof obj.run_profiles === 'object' ? Object.keys(obj.run_profiles) : [];
for (const e of shipLanesErrors(obj.ship_lanes_from, profileNames)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: e.path, message: e.message });
if ('guards' in obj) guardErrors(obj.guards, errors);
if ('allowlist' in obj) allowlistErrors(obj.allowlist, errors);
if ('sealed' in obj) for (const e of sealedErrors(obj.sealed)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: e.path, message: e.message });
@@ -273,7 +280,7 @@ export function parseConfig(text: string, knownChecks: readonly string[] = []):
const errors = validateConfig(raw, knownChecks);
if (errors.length) return { config: DEFAULT_CONFIG, errors, declaresLanes: false };
const o = raw as Partial<PregateConfig>;
return { declaresLanes: LANE_KEYS.some(k => k in o), config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null }, errors: [] };
return { declaresLanes: LANE_KEYS.some(k => k in o), config: { $schema: o.$schema, runner: o.runner ?? null, dependencies: o.dependencies ?? {}, empty_lanes: o.empty_lanes ?? [], selection: o.selection ?? {}, warn: o.warn ?? [], strays: { allow: o.strays?.allow ?? [] }, local: o.local ?? [], guards: o.guards ?? [], serial_patterns: o.serial_patterns ?? DEFAULT_SERIAL_PATTERNS, price_file: o.price_file ?? null, stall_timeout_s: o.stall_timeout_s ?? null, timeout_s: o.timeout_s ?? null, remote_gate: o.remote_gate ?? null, allowlist: o.allowlist ?? [], run_profiles: o.run_profiles ?? {}, remote_gate_runs: o.remote_gate_runs ?? null, ship_lanes_from: o.ship_lanes_from ?? {} }, errors: [] };
}
export type ConfigLoad = { config: PregateConfig; errors: ConfigError[]; present: boolean; label: string; kind: 'base' | 'head' | 'user' | 'none'; /** sha256 of the user file's bytes (kind 'user' only). */ digest?: string; /** The policy has a LANE_KEYS key; absent under kind 'none'. */ declaresLanes?: boolean } | { unavailable: true; base: string; detail: string };
@@ -329,7 +336,7 @@ export function loadConfigFromUser(file: string, knownChecks: readonly string[]
// `sealed` (the pre-push guard), `price_file` (gstack-eval-plan) and the run profiles (`clear --run`) read only the git copies, so a non-null value in the user file would look active and do nothing.
let raw: unknown = null;
try { raw = JSON.parse(text); } catch { /* parseConfig reported it */ }
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file', 'run_profiles', 'remote_gate_runs'].filter(k => { const v = (raw as Record<string, unknown>)[k]; return v != null && !(k === 'run_profiles' && typeof v === 'object' && Object.keys(v).length === 0); }) : [];
const ignored = raw && typeof raw === 'object' && !Array.isArray(raw) ? ['sealed', 'price_file', 'run_profiles', 'remote_gate_runs', 'ship_lanes_from'].filter(k => { const v = (raw as Record<string, unknown>)[k]; return v != null && !(k === 'run_profiles' && typeof v === 'object' && Object.keys(v).length === 0); }) : [];
// parseConfig names the repo copy for a JSON error; this file is not it.
const errors = [...load.errors.map(e => e.path === PREGATE_CONFIG_PATH ? { ...e, path: file } : e), ...ignored.map(k => ({ code: 'PREGATE_CONFIG_INVALID' as const, path: k, message: `the user-owned policy covers gstack-pregate run only; ${k} is read from ${PREGATE_CONFIG_PATH} on the base branch` }))];
return { ...load, errors, present: true, label: `user ${file}`, kind: 'user', digest: createHash('sha256').update(text).digest('hex') };
@@ -358,6 +365,7 @@ export function explainConfig(): string {
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
'remote_gate_runs: { profiles, inventory, skipped? }; the run profiles that together clear the remote gate obligation, the command listing the gate\'s test files they must cover, and an optional command printing which selected files (PREGATE_FILES_FILE) the gate itself does not run, which then need no run (docs/pregate.md#run-evidence)',
'ship_lanes_from: exact /ship lane command → { profiles, inventory }; a verified run of each profile at HEAD\'s clean tree whose tree.json covers the inventory carries the lane instead of running it again (docs/pregate.md#ci-carry)',
`allowlist: [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted. finding is a gstack-redact id (env.kv, google.api_key, pii.phone.e164, …) or a category (${ALLOWLISTABLE_CATEGORIES.join(', ')}); paths are globs; pattern is a regex matched at the finding's position in the added line; one of the two is required, reason always. Never ${NON_ALLOWLISTABLE.join(' or ')} or engine.* (docs/pregate.md#allowlist)`,
'no .gstack/pregate.json on the base: <state root>/projects/<slug>/pregate.json is read instead (state root: GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin data dir, else ~/.gstack, per docs/state-root.md; run --explain prints the resolved path; outside the repo, so a PR cannot reach it; a path inside the checkout is ignored)',
].join('\n') + '\n';
+57 -1
View File
@@ -10,6 +10,7 @@
* land: `guards` (field wave CD1); ratchets, lint, links, hermetic, patches
* and integrator later.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -17,7 +18,8 @@ import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSub
import { wtreeFingerprint, type WtreeResult } from '../script-invocation';
import { resolveStateRoot } from '../state-root';
import { guardsCheck } from './checks/guards';
import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem, treeMismatch } from './checks/lanes';
import { findCarry } 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';
import { secretsCheck } from './checks/secrets';
@@ -26,6 +28,7 @@ import { timingCheck } from './checks/timing';
import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './config';
import { sha256, withFingerprint } from './context';
import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt';
import { buildRunCheck } from './run-check';
import { resolveBaseBranch } from '../ship-policy';
import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence';
import type { CheckDef, CheckResult, PregateContext, Stage } from './types';
@@ -47,8 +50,40 @@ export function applyDowngrades(ctx: PregateContext, result: CheckResult, def: C
return { ...result, status: 'warn', detail: `${result.detail} (downgraded to warn by ${ctx.configLabel})` };
}
/**
* The tests stage's automatic `--from-run` (GSTA-258): when the base branch declares run profiles and no run was named,
* the newest verified CI run at HEAD's clean tree under a profile for this OS becomes `ctx.fromRun`, so its tree.json
* files count as passed and only the rest run (on a small machine, an all-covered selection records no remote gate
* obligation). Returns the CI_CARRY line, or null when the base declares no profiles. Security scans, guards and the
* preflight stage are never carried.
*/
export function autoCarry(ctx: PregateContext): string | null {
if (ctx.fromRun || ctx.ciCarry === false || !Object.keys(ctx.config.run_profiles).length) return null;
const none = (why: string) => `CI_CARRY: none (${why.length > 300 ? `${why.slice(0, 300)}…` : why})`;
const tree = git(ctx.repoRoot, ['rev-parse', 'HEAD^{tree}']);
if (!tree || ctx.wtree !== tree) return none(`dirty_tree: the working tree ${ctx.wtree.slice(0, 12)} is not HEAD's committed tree; only a clean, committed tree carries`);
const check = buildRunCheck(ctx.repoRoot, null, ctx.allowRepoCommands, ctx.base);
if ('error' in check) return none(`profiles_unavailable: ${check.error}`);
const names = fromRunProfiles(ctx, check.profiles);
if (!names.length) return none('no_profile: no base run profile for this OS covers a lane this machine runs');
const wtree = () => { const fp = wtreeFingerprint({ cwd: ctx.repoRoot }); return 'tree' in fp ? fp.tree : null; };
const refusals: string[] = [];
for (const name of names) {
const v = findCarry(check, { profiles: [name], head: ctx.head, tree, wtree });
if (v.ok) { ctx.fromRun = { ref: String(v.runs[0]!.run_id), check }; return `CI_CARRY: accepted ${v.receipt}`; }
refusals.push(`${v.reason}: ${v.detail}`);
}
return none(refusals.join(' | '));
}
const git = (cwd: string, args: string[]): string | null => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000 });
return r.status === 0 ? r.stdout.trim() || null : null;
};
export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<PregateFile> {
const selected = CHECKS.filter(c => (o.stage === 'all' || c.stage === o.stage) && (!o.only || o.only.includes(c.id)));
if (selected.some(c => c.id === 'lanes')) { const line = autoCarry(ctx); if (line) ctx.progress(line); }
const checks: CheckResult[] = [];
const clocks: Record<Stage, number> = { preflight: 0, tests: 0 };
for (const def of selected) {
@@ -68,9 +103,30 @@ export async function runPregate(ctx: PregateContext, o: RunOptions): Promise<Pr
requires_remote: remote,
};
file.verdict = verdictOf(file);
const gateLine = carryGate(ctx, file);
if (gateLine) ctx.progress(gateLine);
return file;
}
/**
* After a verified run carried part of the tests stage, that run clears the remote gate obligation under `clear --run`'s
* own checks (remote_gate_runs profiles, inventory coverage, skipped files). The obligation is checked as the carried
* files plus the rest: without that, a remainder of only gate-skipped files (gstack's paid evals) is refused paid_only,
* and the gate itself reports tests=0 for it, so nothing could ever clear it.
*/
function carryGate(ctx: PregateContext, file: PregateFile): string | null {
const gate = file.requires_remote?.find(r => r.lane === REMOTE_GATE_LANE && !r.receipt);
const carried = file.checks.find(c => c.id === 'lanes')?.from_run?.files;
if (!gate || !carried?.length || !ctx.fromRun || !('inventory' in ctx.fromRun.check)) return null;
const check = ctx.fromRun.check as RunCheck;
if (!check.gateRuns) return null;
const view: PregateFile = { ...file, requires_remote: file.requires_remote!.map(r => (r === gate ? { ...r, tests: [...new Set([...carried, ...r.tests])] } : r)) };
const d = clearByRuns(view, REMOTE_GATE_LANE, [ctx.fromRun.ref], check, ctx.repoRoot);
if (!('receipt' in d)) return `CI_CARRY: ${REMOTE_GATE_LANE} stays uncleared (${d.message})`;
clearObligation(file, REMOTE_GATE_LANE, d.receipt, 'ci-carry', d.substitution);
return `CI_CARRY: cleared ${REMOTE_GATE_LANE} ${d.receipt}`;
}
/** `pass`, `warn`, `requires-remote`, `incomplete` or `fail`: the worst status across checks, with uncleared obligations counted. */
export function verdictOf(file: PregateFile): string {
const statuses = new Set(file.checks.map(c => c.status));
+2
View File
@@ -64,6 +64,8 @@ export interface PregateContext {
policyFrom: 'base' | 'head';
/** `--from-run`: a Free Tests run whose tree.json files count as passed for this tree (lanes, lib/run-evidence.ts verifyFromRun), with the base branch's run check or why it could not be built. */
fromRun?: { ref: string; check: RunSource | { error: string } };
/** `--no-ci-carry`: false skips the tests stage's automatic --from-run (lib/ci-carry.ts findCarry at HEAD's clean tree). */
ciCarry?: boolean;
/** `--remote`: run the declared remote gate now, under this spend cap. */
remote: { spendCapUsd: number } | null;
env: NodeJS.ProcessEnv;
+37 -1
View File
@@ -388,7 +388,37 @@ function materialize(root: string, source: string) {
next: `Include every reportLinks entry in the Markdown report, and report the overall status as ${verdict.status}${verdict.open.length ? ` (open: ${verdict.open.join('; ')})` : ''}; this verdict is final. Next, write the Markdown report beside evidence.json.` };
}
const QA_EVIDENCE_USAGE = 'capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
/** A fresh report root for caller QA (/review and /ship) under the repository's git-excluded .gstack/tmp, so probes never change the reviewed tree. */
function reportDir(): Record<string, any> {
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 5000 });
if (top.status !== 0 || !top.stdout.trim()) throw new QaEvidenceError('report-dir needs a git work tree');
const repoRoot = fs.realpathSync(top.stdout.trim());
const exclude = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', timeout: 5000 });
if (exclude.status !== 0 || !exclude.stdout.trim()) throw new QaEvidenceError('report-dir cannot locate the git exclude file');
const excludeFile = path.resolve(exclude.stdout.trim());
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
const excluded = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : '';
if (!excluded.split(/\r?\n/).includes('/.gstack/tmp/')) fs.appendFileSync(excludeFile, `${excluded && !excluded.endsWith('\n') ? '\n' : ''}/.gstack/tmp/\n`);
const parent = path.join(repoRoot, '.gstack', 'tmp', 'qa-reports');
for (const dir of [path.join(repoRoot, '.gstack'), path.dirname(parent), parent]) {
try {
if (fs.lstatSync(dir).isSymbolicLink()) throw new QaEvidenceError(`report-dir refuses the linked ${path.relative(repoRoot, dir)}`);
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error;
fs.mkdirSync(dir, { mode: 0o700 });
}
}
const ignored = spawnSync('git', ['check-ignore', '-q', path.join(parent, 'report.md')], { cwd: repoRoot, encoding: 'utf8', timeout: 5000 });
if (ignored.status !== 0) throw new QaEvidenceError('report-dir: .gstack/tmp is not git-ignored here (a .gitignore rule overrides info/exclude)');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
for (let n = 1; ; n++) {
const dir = path.join(parent, `run-${stamp}${n > 1 ? `-${n}` : ''}`);
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error: any) { if (error?.code === 'EEXIST') continue; throw error; }
return { action: 'report-dir', status: 'complete', reportDir: qaEvidenceRoot(dir), exitCode: 0 };
}
}
const QA_EVIDENCE_USAGE = 'report-dir (prints a fresh git-excluded report root for /review and /ship QA) | capture ROOT ID [--public] --deadline FILE|--timeout-ms MS [--after PREVIOUS_CAPTURE --hypothesis TEXT] -- COMMAND ARGS (--after publishes checkpoint ID linking PREVIOUS_CAPTURE to this probe; required after the first complete capture unless a checkpoint was published) | checkpoint ROOT ID CAPTURE OBSERVATION_COMMAND HYPOTHESIS NEXT_COMMAND | checkpoint ROOT ID INTENT_FILE | materialize ROOT ANNOTATIONS (annotations: {evidence: [{capture, command, contract, expected, classification: pass|superseded|product-defect|fail|setup-blocked|blocked|inconclusive}], limits: [..]}; revision, runtime, cwd and learning are filled in)';
export async function qaEvidenceMain(args: string[]): Promise<number> {
return withQaReceiptOutput(false, 'qa-evidence-receipt', value => value.event === 'observation'
@@ -400,6 +430,12 @@ export async function qaEvidenceMain(args: string[]): Promise<number> {
emit('stdout', { action: 'help', status: 'complete', usage: QA_EVIDENCE_USAGE, exitCode: 0 });
return 0;
}
if (action === 'report-dir' && args.length === 1) {
let created: Record<string, any>;
try { created = reportDir(); } catch (error: any) { throw error instanceof QaEvidenceError ? error : new QaEvidenceError(`report-dir failed: ${error?.code ?? 'unknown error'}`); }
emit('stdout', created);
return 0;
}
const root = qaEvidenceRoot(reportRoot);
let receipt: Record<string, any>;
const publicOutput = action === 'capture' && rest[1] === '--public';
+10 -5
View File
@@ -244,7 +244,7 @@ function allowedOutput(plan: Plan, root: string, rel: string): boolean {
return matchesAny(p.stamp_paths, rel) || matchesAny(p.stamp_files, rel) || matchesAny(p.release_outputs, rel);
}
interface Hunk { removed: string[]; added: string[]; at: number; addedAt: number[] }
interface Hunk { removed: string[]; added: string[]; at: number; addedAt: number[]; removedAt: number[] }
export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R'; modeChanged?: boolean }
/**
@@ -252,13 +252,15 @@ export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status
* File headers come only before a file's first hunk: inside a hunk every `-`/`+` line is content, so a
* body line that begins `--` or `++` (`---`/`+++` in the diff) is never dropped as a header.
* `addedAt` holds each added line's 1-based position on the new side, counted through any context
* line the hunk carries (`diff.interHunkContext` can join hunks under `--unified=0`).
* line the hunk carries (`diff.interHunkContext` can join hunks under `--unified=0`), and `removedAt` each removed
* line's position on the old side, counted the same way.
*/
export function parseUnifiedDiff(text: string): FileDiff[] {
const files: FileDiff[] = [];
let cur: FileDiff | null = null;
let hunk: Hunk | null = null;
let cursor = 0;
let oldCursor = 0;
for (const line of text.split('\n')) {
if (line.startsWith('diff --git ')) {
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
@@ -271,10 +273,13 @@ export function parseUnifiedDiff(text: string): FileDiff[] {
else if (!hunk && (line.startsWith('old mode ') || line.startsWith('new mode '))) cur.modeChanged = true;
else if (line.startsWith('rename to ')) cur.status = 'R';
else if (line.startsWith('Binary files')) cur.binary = true;
else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0), addedAt: [] }; cursor = hunk.at; cur.hunks.push(hunk); }
else if (hunk && line.startsWith('-')) hunk.removed.push(line.slice(1));
else if (line.startsWith('@@')) {
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)/.exec(line);
hunk = { removed: [], added: [], at: Number(m?.[2] ?? 0), addedAt: [], removedAt: [] }; cursor = hunk.at; oldCursor = Number(m?.[1] ?? 0); cur.hunks.push(hunk);
}
else if (hunk && line.startsWith('-')) { hunk.removed.push(line.slice(1)); hunk.removedAt.push(oldCursor); oldCursor += 1; }
else if (hunk && line.startsWith('+')) { hunk.added.push(line.slice(1)); hunk.addedAt.push(cursor); cursor += 1; }
else if (hunk && line.startsWith(' ')) cursor += 1;
else if (hunk && line.startsWith(' ')) { cursor += 1; oldCursor += 1; }
}
return files;
}
+51 -18
View File
@@ -59,7 +59,6 @@ const NUMBERED_HEAD = /^(?:#{1,6}\s+|\*\*\s*)\d+[.)]\s/;
const stronger = (a: string, b: string) => ((rank(a) ?? 0) >= (rank(b) ?? 0) ? a : b);
/** A prose voice's `Blocks: yes` (GSTA-167 normalizer v2): the finding is CRITICAL whatever its label; `Blocks: no` never lowers one. */
const BLOCKS_YES = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*yes(?![A-Za-z0-9])/i;
const BLOCKS_NO = /(?<![A-Za-z0-9])Blocks:?\**:?\s*\**\s*no(?![A-Za-z0-9-])/i;
const ALL_LABELS = new RegExp(ANY_LABEL.source, 'g');
/** A file:line locus ("auth.ts:9"): prose naming one after a finding's fields starts the next finding. */
const LOCUS = /[\w./-]+\.\w+:\d+/;
@@ -84,14 +83,32 @@ const SEVERITY_FIELD = new RegExp(`(?:^(?:[-*+>]\\s+|\\d+[.)]\\s+)?\\**\\s*Sever
const FIELD_LABELS = new RegExp(ANY_LABEL.source, 'gi');
const LEAD_LABEL = new RegExp(`^(?:[^\\p{L}\\p{N}\\s]\\s*|\\d+[.)]\\s*)*(?:\\w[\\w./-]*:\\d+(?:[-–:,]\\s*\\d+)*[^\\p{L}\\p{N}\\s]*\\s+(?:[^\\p{L}\\p{N}\\s]\\s*)*)?(${LABELS})(?=[*_\`\\])]|\\s*[:|—–]|\\s+-\\s|\\s*$|(?<=[A-Z]{3})[\\s,(.])`, 'u');
const STATED_BLOCKS = /(?<![A-Za-z0-9])Blocks\**\s*:?\s*\**\s*(yes|no)(?![A-Za-z0-9])/gi;
function stated(raw: string): Stated | undefined {
/** A prose reader's `Blocks` field as it can lower a count (GST-1031): its colon required, so prose ("blocks no other writer") never does, and a JSON finding (no Blocks field in its schema) never has one. */
const BLOCKS_FIELD = /(?<![A-Za-z0-9])Blocks\**\s*:\s*\**\s*(yes|no)(?![A-Za-z0-9-])/gi;
export const statesNoBlocks = (raw: string) => { if (raw.startsWith('{') || BLOCKS_YES.test(raw)) return false; const said = [...raw.matchAll(BLOCKS_FIELD)].map(m => m[1]!.toLowerCase()); return said.length > 0 && !said.includes('yes'); };
/**
* A summary candidate (GST-1031): a prose finding whose every severity word is denied ("so nothing is CRITICAL", "No CRITICAL issues")
* or names a fix ("the test covers the CRITICAL fix"), with no lead label, no Severity or Blocks field, no FIXABLE or INVESTIGATE tag,
* and no file:line or exception clause in any sentence naming a label. A defect called CRITICAL ("this is CRITICAL") is none. It still collects at its label (a blocker named in another sentence stays on record, GST-435); lead
* judgment may lower it with a reason, which judge records as dismissed. "No CRITICAL issues except the token leak in log.ts:3" is no candidate.
*/
const SENTENCE = /(?<=[.!?])\s+/;
const EXCEPTION = /(?<![A-Za-z])(?:except|but|other than|apart from|aside from|besides|unless|save for)(?![A-Za-z])/i;
const PATH_LOCUS = /(?:[\w.-]+\/)+[\w.-]+:\d+|[\w.-]+\.\w+:\d+/;
const LABEL_LIST = `[*\`]*(?:${LABELS})[*\`]*(?:\\s*(?:,|/|\\bor\\b|\\band\\b|\\bnor\\b)\\s*[*\`]*(?:${LABELS})[*\`]*)*(?![A-Za-z0-9])`;
const DENIED_OR_FIXED = new RegExp(`(?:(?<![A-Za-z])(?:nothing|none|no|neither|zero)(?:\\s+(?:of\\s+)?(?:these|them|this|it|other|else|further|new|remaining|the\\s+\\w+|\\w+\\s+findings?|findings?|issues?))?\\s+(?:else\\s+)?(?:(?:is|are|was|were|would\\s+be|at)\\s+)?${LABEL_LIST}(?=\\s*(?:issues?|findings?|problems?|regressions?|bugs?|defects?|blockers?|concerns?|items?|severity|impact|remains?|to\\s+report|here|found|introduced|[.;:,!?)—–-]|$))|(?<![A-Za-z])(?:cover(?:s|ed)?|fix(?:es|ed)?|address(?:es|ed)?|resolve[sd]?|close[sd]?)\\s+(?:the|that|this)\\s+${LABEL_LIST}\\s+(?:fix|finding|issue|bug|defect|blocker)(?:e?s)?(?![A-Za-z]))`, 'gi');
export const summaryCandidate = (t: string) => ANY_LABEL.test(t) && !ANY_LABEL.test(t.replace(DENIED_OR_FIXED, ' ')) && !t.startsWith('{') && !t.startsWith('**') && !LIST_ITEM.test(t) && !HEADING.test(t) && !LEAD_LABEL.test(t)
&& !t.match(SEVERITY_FIELD) && !t.match(STATED_BLOCKS) && [...t.matchAll(ALL_LABELS)].every(m => !!rank(m[1]))
&& t.split(SENTENCE).filter(s => ANY_LABEL.test(s)).every(s => !PATH_LOCUS.test(s) && !EXCEPTION.test(s));
export function stated(raw: string): Stated | undefined {
if (raw.startsWith('{')) return undefined;
const severity = [...raw.matchAll(STATED_SEVERITY)].at(-1)?.[1];
const blocks = [...raw.matchAll(STATED_BLOCKS)].at(-1)?.[1]?.toLowerCase();
const said = [...raw.matchAll(BLOCKS_FIELD)].map(m => m[1]!.toLowerCase());
const blocks = said.includes('yes') || (said.length && BLOCKS_YES.test(raw)) ? 'yes' : said.at(-1);
return severity || blocks ? { ...(severity ? { severity } : {}), ...(blocks ? { blocks } : {}) } : undefined;
}
export interface MergedLine { id: string; reader: string; label: string; stated?: Stated; raw: string; output_sha256: string }
export interface MergedLine { id: string; reader: string; label: string; stated?: Stated; summary_candidate?: true; raw: string; output_sha256: string }
/**
* One reader's findings as written: a JSON object per line (severity kept as written); else each prose line carrying a severity or
@@ -104,8 +121,11 @@ export interface MergedLine { id: string; reader: string; label: string; stated?
* starts the next finding, except an unlabeled, unnumbered bullet with no file:line under a paragraph finding whose fields have not
* come yet, which is its detail and joins its text (GSTA-196: 4 findings with detail bullets collected as 8). An inline
* `Blocks: yes` is CRITICAL too. The native voice's FIXABLE collects as CRITICAL; other labels stay as written for lead judgment. When in doubt the parser merges rather than drops: a lost blocker costs the gate.
* With fixableRaises false (a code or docs review with no risk, error, history or floor reason, GST-987) a native FIXABLE finding that
* states `Blocks: no` collects at its own Severity field; one with no Blocks: no, no Severity field or only a FIXABLE field stays CRITICAL.
* A numbered finding heading keeps every unlabeled line under it, located or listed or bold, until its fields come (GST-1031), so its
* Severity and Blocks never split off as a second record; a prose line that only names a severity in passing is a summary (summaryLine).
* A native FIXABLE finding that states `Blocks: no` and one Severity field collects at that field on every class (GST-1031 ruling A);
* one with no Blocks: no, no Severity field or only a FIXABLE field stays CRITICAL. fixableRaises false (a code or docs review with no
* risk, error, history or floor reason, GST-987) also drops FIXABLE's raise from label headings and leads.
*/
export function parseFindings(reader: string, output: string, fixableRaises = true): Array<{ label: string; raw: string }> {
type Finding = { label: string; raw: string; fields: Set<string>; list: boolean; lead?: string; fixable?: boolean; heading?: boolean };
@@ -155,6 +175,7 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr
}
const heading = LABEL_HEADING.exec(t);
if (heading) { section = fixableRaises ? named(heading[1]!) : heading[1]!; current = null; head = ''; continue; }
if (current?.heading && !current.fields.size && !label && !blocks && !/^#{1,6}\s/.test(t) && !NUMBERED_HEAD.test(t) && !t.startsWith('{')) { current.raw += ` | ${t}`; continue; }
if (!indented && HEADING.test(t)) { section = null; current = null; }
if (t.startsWith('{')) {
try {
@@ -188,8 +209,13 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr
}
if (!fresh && !current && (!LIST_ITEM.test(head) || locus)) head = t;
}
const unblocked = (f: Finding) => fixableRaises || role !== 'adversarial-native' || !(f.fixable || f.label.toUpperCase() === 'FIXABLE' || /(?<![A-Za-z0-9])FIXABLE(?![A-Za-z0-9])/.test(f.raw)) || BLOCKS_NO.test(f.raw);
return out.map(f => ({ label: unblocked(f) ? f.label : 'CRITICAL', raw: f.raw }));
const unblocked = (f: Finding) => fixableRaises || role !== 'adversarial-native' || !(f.fixable || f.label.toUpperCase() === 'FIXABLE' || /(?<![A-Za-z0-9])FIXABLE(?![A-Za-z0-9])/.test(f.raw)) || statesNoBlocks(f.raw);
const relaxed = fixableRaises && role === 'adversarial-native' ? parseFindings(reader, output, false) : undefined;
const statesSeverity = (raw: string) => { const ls = [...raw.matchAll(SEVERITY_FIELD)].flatMap(m => (m[1] ?? m[2]!).match(FIELD_LABELS)!); return ls.length === 1 && !!rank(ls[0]); };
return out.map((f, i) => {
const own = relaxed?.length === out.length && relaxed[i]!.raw === f.raw && statesNoBlocks(f.raw) && statesSeverity(f.raw) ? relaxed[i]!.label : undefined;
return { label: own ?? (unblocked(f) ? f.label : 'CRITICAL'), raw: f.raw };
});
}
/**
@@ -232,7 +258,7 @@ export function collect(ledger: string, reader: string, output: string, mergedFi
const wrongTree = !unavailable && tree !== undefined && !tree.ok;
const lines = unavailable || wrongTree ? [] : parseFindings(reader, output, raises).map((f, i) => {
const s = stated(f.raw);
return { id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, ...(s ? { stated: s } : {}), raw: f.raw, output_sha256: sha };
return { id: `${reader}#${prior ? `${prior + 1}.` : ''}${i + 1}`, reader, label: f.label, ...(s ? { stated: s } : {}), ...(summaryCandidate(f.raw) ? { summary_candidate: true as const } : {}), raw: f.raw, output_sha256: sha };
});
const review = !unavailable && !wrongTree && (lines.length > 0 || hasCleanMarker(reader, output));
fs.appendFileSync(mergedFile, lines.map(l => JSON.stringify(l) + '\n').join(''), { mode: 0o600 });
@@ -249,13 +275,13 @@ export function readJsonl<T>(file: string): T[] {
}
export interface JudgedLine { id: string; severity: string; reason?: string; duplicate_of?: string }
export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[] }
export interface Judgment { ok: boolean; dropped: string[]; downgraded: string[]; unexplained: string[]; invalid: string[]; added: string[]; promoted: string[]; folded: string[]; dismissed: string[] }
/** The lead-judgment rule: every merged finding survives at its severity or above; additions and promotions carry a reason. */
/** The lead-judgment rule: every merged finding survives at its severity or above, except a summary candidate lowered with a reason (dismissed); additions and promotions carry a reason. */
export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment {
const byId = new Map(judged.map(j => [j.id, j]));
const mergedIds = new Set(merged.map(m => m.id));
const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [] };
const res: Judgment = { ok: true, dropped: [], downgraded: [], unexplained: [], invalid: [], added: [], promoted: [], folded: [], dismissed: [] };
for (const j of judged) if (rank(j.severity) === null) res.invalid.push(j.id);
for (const m of merged) {
const j = byId.get(m.id);
@@ -266,7 +292,7 @@ export function judge(merged: MergedLine[], judged: JudgedLine[]): Judgment {
const from = rank(m.label) ?? 0;
const to = rank(target.severity);
if (to === null) continue;
if (to < from) res.downgraded.push(m.id);
if (to < from) (!j.duplicate_of && j.reason?.trim() && summaryCandidate(m.raw) ? res.dismissed : res.downgraded).push(m.id);
else if (j.duplicate_of) res.folded.push(m.id);
else if (to > from) { res.promoted.push(m.id); if (!j.reason?.trim()) res.unexplained.push(m.id); }
}
@@ -286,8 +312,9 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud
if (res.ok) {
for (const id of res.added) appendEvent(ledger, { event: 'judgment', kind: 'added', id, to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
for (const id of res.promoted) appendEvent(ledger, { event: 'judgment', kind: 'promoted', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
for (const id of res.dismissed) appendEvent(ledger, { event: 'judgment', kind: 'dismissed', id, from: labels.get(id), to: byId.get(id)!.severity, reason: byId.get(id)!.reason });
}
appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length });
appendEvent(ledger, { event: 'judge', ok: res.ok, judged: path.resolve(judgedFile), judged_sha256: promptHash(fs.readFileSync(judgedFile, 'utf8')), merged: path.resolve(mergedFile), merged_ids: merged.map(m => m.id), dropped: res.dropped, downgraded: res.downgraded, unexplained: res.unexplained, invalid: res.invalid, added: res.added.length, promoted: res.promoted.length, folded: res.folded.length, dismissed: res.dismissed.length });
return res;
}
@@ -307,7 +334,8 @@ const treeHolds = (render: LedgerEvent | undefined, reply: string | undefined) =
const realPath = (f: string) => { try { return fs.realpathSync(f); } catch { return path.resolve(f); } };
/**
* The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged
* The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL and whose reader did not state
* `Blocks: no` (GST-1031: the count keys on Blocks; lead judgment raising such a line above its label counts it), raised to its judged
* severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded
* duplicate does not. Every reader counts the same way, whatever it is and whether or not Step 9.4 classified its findings.
* Each carries its locus (a JSON finding's path:line, else the first path:line its text names, an extensionless bin/ path
@@ -331,12 +359,16 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] {
text.split('\n').filter(l => l.trim()).map(l => { try { return JSON.parse(l); } catch { return fail(`${what} has a line that is not JSON`); } });
const events = jsonLines(read(ledger, 'ledger'), 'the ledger') as LedgerEvent[];
const ranks = new Map<string, number>();
const unblocking = new Map<string, number>();
const raws = new Map<string, string>();
const loci = new Map<string, string | null>();
for (const o of events.filter(e => e.event === 'output' && Array.isArray(e.ids) && e.ids.length)) {
const lines = jsonLines(read(o.merged, `merged file of ${String(o.reader)}`), String(o.merged)) as MergedLine[];
for (const id of o.ids as string[]) {
const line = lines.find(l => l.id === id) ?? fail(`collected id ${id} is missing from ${String(o.merged)}`);
ranks.set(id, rank(line.label) ?? 0);
if (statesNoBlocks(line.raw)) unblocking.set(id, rank(line.label) ?? 0);
raws.set(id, line.raw);
loci.set(id, lineLocus(line.raw));
}
}
@@ -346,11 +378,12 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] {
if (promptHash(text) !== verdict.judged_sha256) fail(`judged set ${String(verdict.judged)} changed since judge`);
for (const j of jsonLines(text, String(verdict.judged)) as JudgedLine[]) {
if (j.duplicate_of) { ranks.delete(j.id); continue; }
ranks.set(j.id, Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0));
const dismissed = !!j.reason?.trim() && summaryCandidate(raws.get(j.id) ?? '');
ranks.set(j.id, dismissed ? rank(j.severity) ?? 0 : Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0));
if (!loci.has(j.id)) loci.set(j.id, lineLocus(j.reason ?? ''));
}
}
return [...ranks].filter(([, r]) => r >= 4).map(([id]) => ({ id, locus: loci.get(id) ?? null }));
return [...ranks].filter(([id, r]) => r >= 4 && !(r <= (unblocking.get(id) ?? -1))).map(([id]) => ({ id, locus: loci.get(id) ?? null }));
}
/**
@@ -480,7 +513,7 @@ export function verify(ledger: string): { rows: VerifyRow[]; clean: boolean; bri
const forged = differs.filter(id => judgedIds.has(id));
if (forged.length) notes.push(`merged_line_differs=${JSON.stringify(forged)}`);
const status = !v.ok ? 'REJECTED' : notes.length ? 'MISMATCH' : 'OK';
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${notes.length ? ' ' + notes.join(' ') : ''}` });
rows.push({ kind: 'JUDGE', reader: '-', status, detail: `dropped=${JSON.stringify(v.dropped)} downgraded=${JSON.stringify(v.downgraded)} added=${v.added} promoted=${v.promoted} folded=${v.folded}${v.dismissed ? ` dismissed=${v.dismissed}` : ''}${notes.length ? ' ' + notes.join(' ') : ''}` });
} else if (events.some(e => e.event === 'output')) rows.push({ kind: 'JUDGE', reader: '-', status: 'MISSING', detail: 'outputs were collected but no judged set was checked' });
const clean = episodes.length > 0 && rows.every(r => ['MATCH', 'OK', 'VOIDED', 'UNAVAILABLE'].includes(r.status));
return { rows, clean, briefs: episodes.length, voided, unavailable: rows.filter(r => r.kind === 'BRIEF' && r.status === 'UNAVAILABLE').length };
+8 -6
View File
@@ -29,7 +29,7 @@ export const HUNK_CONTEXT_LINES = 3;
export interface ScopeFinding {
fingerprint?: string; path?: string; line?: number; severity?: string; category?: string; summary?: string;
problem?: string; fix?: string; specialist?: string; advisory?: boolean; test_stub?: string;
/** The severity and Blocks the reviewer's own text stated, from its merged.jsonl line (GST-766); shown, never used to classify. */
/** The severity and Blocks the reviewer's own text stated, from its merged.jsonl line (GST-766); `Blocks: no` keeps a CRITICAL below the floor (GST-1031). */
stated?: { severity?: string; blocks?: string };
/** The changed line that causes a defect reported elsewhere (a broken caller, a consumer of a removed export); the locus follows it. */
cause?: { path?: string; line?: number };
@@ -180,11 +180,13 @@ export function kindOf(finding: ScopeFinding): Kind {
/**
* The blocking floor: a CRITICAL defect (a test that would fail, a production
* break, a security or data-loss defect). Advisories, test gaps, wording and
* INFORMATIONAL items are below it and never trigger another pass.
* break, a security or data-loss defect) its reviewer did not mark `Blocks: no`
* (GST-1031). Advisories, test gaps, wording and INFORMATIONAL items are below
* it and never trigger another pass.
*/
export const statesNoBlock = (f: Record<string, any>) => String(f.stated?.blocks ?? '').toLowerCase() === 'no';
export function isBlocking(finding: ScopeFinding, kind = kindOf(finding)): boolean {
return kind === 'defect' && String(finding.severity ?? '').toUpperCase() === 'CRITICAL';
return kind === 'defect' && String(finding.severity ?? '').toUpperCase() === 'CRITICAL' && !statesNoBlock(finding);
}
export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks: HunkMap; rangeHunks: HunkMap | null; context?: number }
@@ -355,8 +357,8 @@ function rowDispatches(row: Record<string, any>): Array<{ role: string; bytes: n
/** Row actions that record what happened to a finding. */
const DISPOSITIONS = ['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', 'kept', 'restored', 'open'];
/** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect (logged before Step 9.4's classify). */
const rowBlocks = (f: Record<string, any>) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4);
/** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect its reviewer did not mark Blocks: no (logged before Step 9.4's classify). */
const rowBlocks = (f: Record<string, any>) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4 && !statesNoBlock(f));
/** The reviewer behind a row's raised_by or a ledger id: every specialist is one; the lead's additions and unknowns name none. */
function raiserOf(raiser: unknown): string | null {
const role = typeof raiser === 'string' ? raiser.split(/[/#]/)[0]!.trim() : '';
+51 -13
View File
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fragmentSlug } from './changelog-fragments';
import { isConfigUnavailable, loadConfigFromBase } from './pregate/config';
import { parseUnifiedDiff, versionAtRef } from './restamp';
import { branchSides, stampOnlyLine } from './stamp-only';
import { matchesAny } from './ship-policy';
@@ -111,7 +112,8 @@ export function releasePaths(policy: Record<string, any> | null, cwd: string, re
* A moved version is floored at `ref`'s VERSION, except on a line the branch added itself (gstack-restamp's own rule:
* added since the merge base with origin/<base>, and not added by the base since): after a lost slot and a merge of the
* base its stale stamp is older than `ref`'s VERSION, so it must be at or newer than the fork point's version instead,
* the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963).
* the bound gstack-restamp's stamp_paths stage uses (GST-895; the fork version itself since GST-963). A branch line is
* named by its position, and with a kept VERSION only such a line may move (GST-979, GST-980).
*/
export function stampedFiles(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
const globs = Array.isArray(policy?.stamp_files) ? policy.stamp_files.filter((s: unknown): s is string => typeof s === 'string') : [];
@@ -120,14 +122,14 @@ export function stampedFiles(policy: Record<string, any> | null, cwd: string, re
if (!('version' in before)) return [];
let after = '';
try { after = extractVersion(fs.readFileSync(path.join(cwd, before.rel), 'utf8'), before.rel); } catch { return []; }
if (!after || after === before.version) return [];
if (!after) return [];
const templates = Array.isArray(policy?.stamp_patterns) ? policy.stamp_patterns.filter((s: unknown): s is string => typeof s === 'string') : [];
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', ref]);
if (diff.status !== 0) return [];
const side = branchSides(cwd, `origin/${base}`, 'HEAD', before.version, ref);
const stamp = (rel: string, removed: string, added: string): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel));
const stamp = (rel: string, removed: string, added: string, at: number): boolean => stampOnlyLine(removed, added, before.version, after, templates, side(rel), at);
return parseUnifiedDiff(diff.stdout).filter(f => f.status === 'M' && !f.binary && !f.modeChanged && matchesAny(globs, f.path) && f.hunks.length > 0 &&
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!)))).map(f => f.path);
f.hunks.every(h => h.removed.length === h.added.length && h.removed.every((line, i) => stamp(f.path, line, h.added[i]!, h.removedAt[i]!)))).map(f => f.path);
}
/** Under `changelog_fragments`, the branch's own Step 13 entry `<dir>/<branch-slug>.md` (docs/ship-policy.md#changelog-fragments); null without the key. */
@@ -169,26 +171,62 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
return cpus <= 4 ? 1 : Infinity;
}
/** Run each lane through gstack-evidence run, at most `concurrency` at a time, and read its recorded line; results keep the lanes' order. */
export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> {
const out: { label: string; exit: number | null; testsRan: string; log: string }[] = [];
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string }
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key, byte for byte (the ledger binds its sha256). */
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, { profiles: string[]; inventory: string }> {
const loaded = loadConfigFromBase(cwd, base);
const map = new Map<string, { profiles: string[]; inventory: string }>();
if (isConfigUnavailable(loaded) || loaded.errors.length) return map;
const from = loaded.config.ship_lanes_from;
for (const l of list) if (Object.prototype.hasOwnProperty.call(from, l.command)) map.set(l.label, from[l.command]!);
return map;
}
/**
* A mapped lane carried from CI at HEAD's clean tree: gstack-evidence record-ci finds and verifies the run (lib/ci-carry.ts)
* and records the CI-sourced ledger row; or why it was not (`carryNone`), so the caller runs it.
*/
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 = / tests_ran=(\d+) log=- source=ci runs=(\S+)/.exec(rec.stderr);
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[1]!, log: '-', carried: got[2]! };
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
}
/**
* Run each lane, at most `concurrency` at a time; results keep the lanes' order. With `base`, a lane the base branch maps in
* `ship_lanes_from` is carried from a verified CI run at this exact tree when one exists. Every other lane runs through
* gstack-evidence run --reuse (a pass at this tree, command and runtime is reused; `reuse: false` forces a run).
*/
export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity, opts: { base?: string; reuse?: boolean } = {}): Promise<LaneResult[]> {
const out: LaneResult[] = [];
const mapped = opts.base ? mappedLanes(cwd, opts.base, list) : new Map<string, { profiles: string[]; inventory: string }>();
let next = 0;
const worker = async () => {
while (next < list.length) {
const i = next++;
const l = list[i]!;
const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]);
const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
out[i] = { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' };
const m = mapped.get(l.label);
const carry = m ? carryLane(cwd, opts.base!, l) : null;
if (carry?.carried) { out[i] = carry; continue; }
const r = await binAsync(cwd, 'gstack-evidence', ['run', ...(opts.reuse === false ? [] : ['--reuse']), '--label', l.label, '--', l.command]);
const rec = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
const reused = /gstack-evidence: reused label=\S+ tree=\S+ from=(\S+)/.exec(r.stderr)?.[1];
out[i] = { label: l.label, exit: r.status, testsRan: rec?.[2] ?? 'unknown', log: rec?.[3] ?? '-', ...(reused ? { reused } : {}), ...(carry?.carryNone ? { carryNone: carry.carryNone } : {}) };
}
};
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, worker));
return out;
}
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone. */
export function laneLine(l: { label: string; exit: number | null; testsRan: string; log: string }): { line: string; ok: boolean } {
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */
export function laneLine(l: LaneResult): { line: string; ok: boolean } {
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true };
const zero = l.exit === 0 && l.testsRan === '0';
const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass';
return { line: `LANE: ${l.label} ${state} exit=${l.exit} tests_ran=${l.testsRan} log=${l.log}`, ok: l.exit === 0 && !zero };
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);
+8 -5
View File
@@ -10,7 +10,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { classifyDiff } from './review-depth';
import { audit } from './ship-audit';
import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
import { bin, carryNoneLine, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, 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)
@@ -23,8 +23,10 @@ export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Ste
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), LANES, one LANE line per lane,
then NEXT: lines. STOP: names what the agent must
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), LANES, one LANE line per lane
(a lane the base maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact clean tree:
LANE: <label> carried run=<id>/<attempt> tests_ran=<n>, else CI_CARRY: none (<reason>) and it runs; any other lane that
passed at this tree, command and runtime is reused: reused=<original time>), then NEXT: lines. STOP: names what the agent must
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|package\.json)$/;
@@ -151,8 +153,9 @@ async function tests(cwd: string, base: string, list: { label: string; command:
}
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
r.add(run.line);
const results = (await runLanes(cwd, list, run.concurrency)).map(laneLine);
for (const l of results) r.add(l.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); }
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');
+14 -8
View File
@@ -16,7 +16,7 @@ import { stampOnlyLine, type BranchSide } from './stamp-only';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd',
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red',
@@ -131,6 +131,7 @@ export function receiptSummaryLine(r: ShipReceipt): string {
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`
+ (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.rounds !== undefined ? ` rounds=${loop.rounds}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : ''));
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
if (Array.isArray(r.ci_carried) && r.ci_carried.length) parts.push(`ci_carried=${r.ci_carried.map(c => `${c.lane}:${c.run}/${c.attempt}`).join(',')}`);
if (r.base_red && typeof r.base_red.base === 'string') parts.push(`base_red=${r.base_red.base.slice(0, 12)}`);
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
}
@@ -178,6 +179,8 @@ export interface CarryPlanInput {
retargetedFrom?: string;
/** A clean scoped review of product paths whose only difference is base context next to unchanged hunks (lib/ship-patch.ts, GST-384): the carry reason it adds. */
scopedReview?: string;
/** Stamp files the branch changed as product whose only change since the reviewed head is a stamp on the branch's own rows, the base untouched, and without which the two product patches match (GST-980). */
stampedRows?: string[];
}
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string };
@@ -195,22 +198,23 @@ export interface CarryChange { before: string | null; after: string | null; oldV
* old text once every new version in it is the old one, or when every changed
* line is a stamp-form rewrite to the new version (`stampOnlyLine`: the old
* version or a newer tentative `*_KEY_VERSION` the slot stamp moved, #3512, and
* on a line the branch added itself, `side`, the fork version or newer, GST-963): the rule
* on a line the branch added itself, `side`, the fork version or newer, GST-963; with a kept VERSION only such a line,
* GST-980): the rule
* the tree receipt applies to the same `stamp_paths`, `stamp_files` and
* `release_outputs` before the train accepts a stamp commit. No version
* change, or a file missing on either side, is not a stamp.
* change outside the branch's own lines, or a file missing on either side, is not a stamp.
*/
export function stampOnlyText(change: CarryChange): boolean {
const { before, after, oldVersion, newVersion } = change;
if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false;
if (before === null || after === null || !oldVersion || !newVersion || (oldVersion === newVersion && !change.side)) return false;
// Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's.
const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before) return true;
if (oldVersion !== newVersion && after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before) return true;
const was = before.split('\n');
const now = after.split('\n');
if (was.length !== now.length) return false;
const changed = was.map((line, i) => i).filter(i => was[i] !== now[i]);
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side));
return changed.length > 0 && changed.every(i => stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, change.templates ?? [], change.side, i + 1));
}
/**
@@ -241,16 +245,18 @@ export function classifyCarryPath(rel: string, extra: { metadata?: readonly stri
* conflict-only base merge, a test-only fix or a release-metadata change
* leaves behind. A product patch that differs is RECEIPT_CARRY_PRODUCT_CHANGED,
* whatever else changed, unless every difference is base context next to unchanged hunks
* and a scoped review of those hunks came back clean (`scopedReview`).
* and a scoped review of those hunks came back clean (`scopedReview`), or a stamp on the
* branch's own rows in a stamp file the branch also changed as product (`stampedRows`, GST-980).
*/
export function planCarry(input: CarryPlanInput): CarryPlan {
if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || !input.scopedReview)) {
if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || (!input.scopedReview && !input.stampedRows?.length))) {
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
}
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
if (input.scopedReview && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.scopedReview);
if (input.stampedRows?.length && input.productPatchId.from !== input.productPatchId.head) reasons.push(`a stamp on the branch's own rows (${input.stampedRows.join(', ')})`);
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');
if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs');
+106 -6
View File
@@ -19,7 +19,9 @@ import { parseReviewLoopException, type ReviewLoopException } from './ship-recei
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 { bin, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, releasePaths, Report, runLanes, sh, shipPolicy, short, stampedFiles } from './ship-bin-common';
import { waitForRun } from './ci-carry';
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, short, stampedFiles } from './ship-bin-common';
export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (ship/SKILL.md)
@@ -33,8 +35,15 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
each lane's receipt run once if stale) and the pregate on the stamped tree; --reviewed-base (the merge base
saved with the reviewed tree) routes paths only the base moved as base=, and an adjacent base edit as context=;
--review-loop-exception (the record write takes) routes paths the branch touched at fix_check.head (narrowed to fix_check.files when listed) and changed since as accepted=
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved]
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u
A lane the base branch maps in .gstack/pregate.json ship_lanes_from is carried from a verified CI run at this exact
tree, else deferred (EVIDENCE: DEFERRED): the pregate then runs its preflight stage here and its tests stage in accept
push --branch <branch-name> [--gate-open <n>] [--gate-open-approved] [--early [--base <base>]]
Step 17 after its guard block: the saved-branch check, the open-gate check, the sealed guard, the idempotency check, git push -u;
--early (the Phase 2 push before review, so CI runs beside it) also runs pregate's secrets scan on the commits first
accept --base <base> --lane <label>=<command>... [--wait <s>]
after push: final-tree CI acceptance. Waits up to --wait seconds (default 170) for the CI run of each mapped profile at
the pushed HEAD, carries each mapped lane from that run (a clean tree equal to the run's), reuses or runs the rest,
checks every lane FRESH and reruns the pregate on the final tree (its tests stage carries the same run)
At most 30 lines each; STOP: names what the agent must decide or repair, and nothing after it ran.
Exit codes: 0 ok · 2 usage · 3 STOP`;
@@ -160,6 +169,20 @@ function commitRelease(cwd: string, release: string[], trailer: string | undefin
return true;
}
/**
* A harness pin edit that only records changed harness files as non-behavioral at the same version and patterns (GST-1031): release
* metadata, like a version stamp. A bump, a pattern change or an entry without its reason is behavior.
*/
const HARNESS_PIN = 'scripts/harness-version.json';
function harnessRepin(cwd: string, from: string, to: string, p: string): boolean {
if (p !== HARNESS_PIN) return false;
const read = (tree: string): any => { const s = git(cwd, ['show', `${tree}:${p}`]); try { return s.status === 0 ? JSON.parse(s.stdout) : null; } catch { return null; } };
const a = read(from), b = read(to);
if (!a?.files || !b?.files || typeof b.files !== 'object' || a.version !== b.version || JSON.stringify(a.patterns) !== JSON.stringify(b.patterns)) return false;
return Object.entries(b.files as Record<string, any>).every(([f, e]) => JSON.stringify(a.files[f]) === JSON.stringify(e)
|| (e?.since === b.version && typeof e?.nonBehavioral === 'string' && e.nonBehavioral.trim().length >= 10));
}
/** Step 16 stage 2's route. With --reviewed-base, a path whose branch patch is the reviewed one counts as base (lib/ship-patch.ts). */
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'accepted', string[]>;
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: { docsRechecked: boolean; contextReviewed?: string; loop?: ReviewLoopException }, r: Report): Routed | null {
@@ -184,7 +207,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], accepted: [] };
for (const p of [...changedSet].sort()) {
const verdict = sides ? (touched.has(p) ? comparePath(cwd, sides, p) : 'same') : 'changed';
by[verdict === 'same' ? 'base' : release.includes(p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
by[verdict === 'same' ? 'base' : release.includes(p) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
}
const gated = gateEditedPaths(cwd, reviewed, now, by.tests) ?? new Set(by.tests);
by.behavior.push(...by.tests.filter(p => gated.has(p)));
@@ -352,10 +375,19 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
if (!list.length && !has('--no-lanes-approved')) return r.stop('no test lanes: require Step 5\'s explicit untested-scope approval (rerun with --no-lanes-approved) or run Steps 5-15');
const allow = evidenceExempt(release).join(',');
const check = (l: { label: string; command: string }) => bin(cwd, 'gstack-evidence', ['check', '--label', l.label, '--expect-cmd', l.command, '--max-age', '24', '--allow-paths', allow], 60_000).stdout;
const mapped = mappedLanes(cwd, base, list);
const deferred: string[] = [];
for (const l of list) {
let e = check(l);
if (!/EVIDENCE: FRESH/.test(e)) {
const ran = laneLine((await runLanes(cwd, [l]))[0]!);
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(`EVIDENCE: DEFERRED label=${l.label} (${short([res.carryNone ?? 'not carried'], 1).slice(0, 160)}): CI at the pushed head carries it in accept`);
deferred.push(l.label);
continue;
}
const ran = laneLine(res);
r.add(ran.line);
if (!ran.ok) return r.stop(`lane ${l.label} failed or ran zero tests: STOP publication; run Steps 5-15 from Step 5's triage, then Step 16 stage 1`);
e = check(l);
@@ -364,6 +396,15 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
r.add(line.replace(/ log=\S+/, ''));
if (!/EVIDENCE: FRESH/.test(line)) return r.stop(`lane ${l.label} is not FRESH after one rerun: Step 16 stage 4 recovery`);
}
const laneArgs = list.map(l => `--lane '${l.label}=${l.command.replace(/'/g, `'\\''`)}'`).join(' ');
if (deferred.length) {
const out = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'gstack/pregate-finish-preflight.json']).stdout.trim() || '.git/gstack/pregate-finish-preflight.json');
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--stage', 'preflight', '--out', out], 3_600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
r.add(`PREGATE: preflight ${verdict} on the stamped tree (the tests stage runs in accept, carried from CI)`);
if (!/^(pass|warn)/.test(verdict)) return r.stop(`pregate preflight ${verdict} on the stamped tree: fix it, then rerun finish`);
return r.next(`Step 16 stage 5, then Step 17's guard block and gstack-ship-release push; then gstack-ship-release accept --base ${base} ${laneArgs} carries ${deferred.join(', ')} from CI at the pushed head${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
}
const v = bin(cwd, 'gstack-pregate', ['verify'], 60_000);
if (v.status === 0) r.add('PREGATE: verify ok on the stamped tree');
else {
@@ -375,6 +416,57 @@ export async function finish(argv: readonly string[], cwd: string): Promise<Repo
return r.next(`Step 16 stage 5: paste these results and the two-line honesty inventory, then run Step 17's guard block and gstack-ship-release push${loop ? '; Step 18 records the same exception with gstack-ship-receipt write --review-loop-exception' : ''}`);
}
/**
* Final-tree CI acceptance (design fix A1): after push, each mapped lane is carried only from a green run at the pushed
* HEAD's exact, clean tree; the rest are reused or run; every lane must be FRESH, and the pregate reruns on that tree.
*/
export async function accept(argv: readonly string[], cwd: string): Promise<Report> {
const { flag } = flagReader(argv);
const base = flag('--base')!;
const list = lanes(argv) as { label: string; command: string }[];
const r = new Report();
const branch = git(cwd, ['branch', '--show-current']).stdout.trim();
if (!branch) return r.stop('detached HEAD: accept needs the feature branch');
const head = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
if (remote.status !== 0) return r.stop(`cannot read origin/${branch} (${firstLine(remote.stderr)}): restore access, then rerun accept`);
if (remote.stdout.split(/\s/)[0] !== head) return r.stop(`HEAD ${head.slice(0, 12)} is not origin/${branch}: run gstack-ship-release push first; accept carries only a run at the pushed head`);
const mapped = mappedLanes(cwd, base, list);
if (!mapped.size) return r.stop(`no --lane command is a key of origin/${base}'s .gstack/pregate.json ship_lanes_from: nothing comes from CI, so finish runs every lane here`);
const runCheck = buildRunCheck(cwd, null, true, base);
if ('error' in runCheck) return r.stop(`run profiles unavailable (${runCheck.error}): carry needs them; fix it, or run the lanes here with gstack-ship-preflight --tests-only`);
const waitS = Number(flag('--wait') ?? '170');
if (!Number.isFinite(waitS) || waitS < 0 || waitS > 3600) return r.stop(`--wait ${flag('--wait')} is not 0-3600 seconds`);
for (const name of [...new Set([...mapped.values()].flatMap(m => m.profiles))]) {
const p = runCheck.profiles[name];
if (!p) return r.stop(`ship_lanes_from names ${name}, which is not a run_profiles entry on origin/${base}`);
const w = await waitForRun(runCheck.deps, { workflow: p.workflow, events: p.events, head, timeoutMs: waitS * 1000, intervalMs: 15_000, sleep: ms => new Promise(res => setTimeout(res, ms)), clock: Date.now });
r.add(`CI_WAIT: ${w.state} ${name} ${'run' in w ? `run=${w.run.id}/${w.run.run_attempt}` : w.reason}`);
if (w.state === 'running') return r.stop(`${p.workflow} run ${w.run.id} at ${head.slice(0, 12)} is still running: rerun accept (or gstack-ci-carry wait --base ${base} in the background, then accept)`);
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`);
}
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`); }
const ran = laneLine(res);
r.add(ran.line);
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 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');
}
export function push(argv: readonly string[], cwd: string): Report {
const { flag, has } = flagReader(argv);
const r = new Report();
@@ -397,6 +489,13 @@ export function push(argv: readonly string[], cwd: string): Report {
r.add(`SEALED: ${sealed.status === 0 ? 'clear' : sealed.status === 1 ? 'refused' : `not checked (exit ${sealed.status})`}`);
if (sealed.status === 1) return r.stop(`sealed output would be published: ${firstLine(sealed.stderr)}; remove it from the commits, then repeat Step 16`);
if (sealed.status !== 0) return r.stop(`the sealed-output guard did not run (exit ${sealed.status}: ${firstLine(sealed.stderr || sealed.stdout)}); repair it, then rerun push`);
if (has('--early')) {
const out = path.resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'gstack/pregate-early-push.json']).stdout.trim() || '.git/gstack/pregate-early-push.json');
const scan = bin(cwd, 'gstack-pregate', ['--stage', 'preflight', '--only', 'secrets', '--out', out, ...(flag('--base') ? ['--base', flag('--base')!] : [])], 600_000);
const verdict = /^PREGATE: (.+?) tree=/m.exec(scan.stdout + scan.stderr)?.[1] ?? `unreadable (exit ${scan.status})`;
r.add(`SECRETS: ${verdict} (early push)`);
if (scan.status !== 0 || !/^(pass|warn)/.test(verdict)) return r.stop(`the secrets scan did not pass before the early push (${firstLine(scan.stderr) || verdict}): remove the finding from the commits, then rerun push --early`);
}
const local = git(cwd, ['rev-parse', 'HEAD']).stdout.trim();
const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000);
if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`);
@@ -417,11 +516,12 @@ export async function release(argv: readonly string[], cwd: string): Promise<{ t
const usage = (m: string) => ({ text: `gstack-ship-release: ${m}\n`, exit: 2 });
if (cmd !== 'push' && !flag('--base')) return usage(`${cmd} needs --base <base>`);
if (cmd === 'push' && !flag('--branch')) return usage('push needs --branch <the branch Step 1 saved>');
if (cmd === 'accept' && !(lanes(rest) as unknown[]).length) return usage('accept needs at least one --lane <label>=<command> (the Step 5 lanes)');
if (typeof lanes(rest) === 'string') return usage(lanes(rest) as string);
const answer = flag('--answer');
if (has('--answer') && (!answer?.trim() || answer.startsWith('--') || /[\r\n]/.test(answer) || answer.length > 200)) return usage('--answer needs the owner\'s words: one non-empty line of at most 200 characters');
if (cmd === 'finish' && (!flag('--reviewed-tree') || !flag('--docs-post'))) return usage('finish needs --reviewed-tree <the tree saved before Step 12> and --docs-post <Step 14.5\'s post-audit snapshot>');
const r = cmd === 'version' ? version(rest, cwd) : cmd === 'finish' ? await finish(rest, cwd) : cmd === 'push' ? push(rest, cwd) : null;
const r = cmd === 'version' ? version(rest, cwd) : cmd === 'finish' ? await finish(rest, cwd) : cmd === 'push' ? push(rest, cwd) : cmd === 'accept' ? await accept(rest, cwd) : null;
if (!r) return usage(`unknown command ${cmd}; run gstack-ship-release --help`);
return { text: r.render(), exit: r.exit };
}
+17 -10
View File
@@ -20,18 +20,25 @@ import { cmpVersion, parseVersion, versionWidth } from './version-source';
* one of another width (`tentativeMentions`: a 3-digit dependency pin in a
* 4-digit repo is never a candidate, so moving it is a hand edit). A line that
* moves nothing, or changes anything beyond those mentions, is not one either.
* With `side`, a removed line the branch added itself is bounded by its fork
* point's version instead, the stage's bound for its own lines (GST-895,
* GST-963): finish's stampedFiles, the tree receipt and the receipt carry all
* pass the same side.
* With `side`, a removed line the branch added itself, named by its 1-based
* position `at` in the side's file (GST-979: a base line with the same text
* is not the branch's), is bounded by its fork point's version instead, the
* stage's bound for its own lines (GST-895, GST-963): finish's stampedFiles,
* the tree receipt and the receipt carry all pass the same side. When the
* version does not move (a slot that keeps its claimed VERSION), only such a
* branch line may change: the stage moves nothing else then.
*/
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide): boolean {
export function stampOnlyLine(removed: string, added: string, oldV: string, newV: string, templates: readonly string[] = [], side?: BranchSide, at?: number): boolean {
if (removed === added) return false;
return stampOnlyWithin(removed, added, oldV, newV, templates) || (!!side?.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates));
if (oldV !== newV && stampOnlyWithin(removed, added, oldV, newV, templates)) return true;
return !!side && at !== undefined && side.at.has(at) && side.added.has(removed) && stampOnlyWithin(removed, added, side.fork, newV, templates);
}
/** Lines the branch added to one file (since its merge base with the base, less the lines the base added since) and its fork point's version. */
export interface BranchSide { fork: string; added: ReadonlySet<string> }
/**
* Lines the branch added to one file (since its merge base with the base, less the lines the base added since): their
* text and their 1-based positions in the side's file, and its fork point's version.
*/
export interface BranchSide { fork: string; added: ReadonlySet<string>; at: ReadonlySet<number> }
/**
* Each file's BranchSide for `head` against `baseRef`, read lazily and cached: the lines come from merge-base..`to`
@@ -48,8 +55,8 @@ export function branchSides(repoRoot: string, baseRef: string, head: string, fal
let side: BranchSide | undefined;
if (since) {
try {
const added = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel)?.text;
if (added?.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added };
const own = branchAddedLines(repoRoot, since, [rel], to, branchAddedLines(repoRoot, since, [rel], baseRef)).get(rel);
if (own?.text.size) side = { fork: (fork ??= forkVersion(repoRoot, baseRef, fallback, head)), added: own.text, at: own.at };
} catch {}
}
sides.set(rel, side);
+1 -1
View File
@@ -86,7 +86,7 @@ function stampOnlyHunks(hunks: FileDiff['hunks'], oldV: string, newV: string, te
for (let i = 0; i < h.removed.length; i++) {
const r = h.removed[i]!;
const a = h.added[i]!;
if (!stampOnlyLine(r, a, oldV, newV, templates, side)) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
if (!stampOnlyLine(r, a, oldV, newV, templates, side, h.removedAt[i])) return { why: `${JSON.stringify(r.trim().slice(0, 60))} -> ${JSON.stringify(a.trim().slice(0, 60))}`, line: h.at + i };
}
}
return null;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.93.37",
"version": "1.94.1",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+1 -1
View File
@@ -19,7 +19,7 @@ Missing or unreadable assets, prerequisites or permission block affected probes,
## 1. Charter and preflight
Reuse resolved REPORT_DIR; otherwise own a fresh `.gstack/qa-reports` subdirectory.
Reuse resolved REPORT_DIR; otherwise use the `reportDir` `EVIDENCE_TOOL report-dir` prints.
Write a **charter** per behavior: contract, risk, entrypoint, isolation, exit condition, source, commands and inputs. Save charters as Markdown in the report.
For /review and /ship, no plan/server is required.
+1 -1
View File
@@ -66,7 +66,7 @@ full contents. If either required Read is missing, complete it now before Charte
## 1. Charter and preflight
Reuse resolved REPORT_DIR; otherwise own a fresh \`.gstack/qa-reports\` subdirectory.
${reportOnly ? 'Reuse resolved REPORT_DIR; otherwise own a fresh \`.gstack/qa-reports\` subdirectory.' : 'Reuse resolved REPORT_DIR; otherwise use the \`reportDir\` \`EVIDENCE_TOOL report-dir\` prints.'}
Write a **charter** per behavior: contract, risk, entrypoint, isolation, exit condition, source, commands and inputs. Save charters as Markdown in the report.
${reportOnly ? '' : `For /review and /ship, no plan/server is required.
+8 -9
View File
@@ -7,8 +7,8 @@ Read this, not `ship/SKILL.md`, when a parent agent runs /ship
the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FILE>`), never
`export $(...)`, `env`, `printenv` or a bare `set` in a tool fence.
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a
script) that runs the bins, reads, dispatches and waits, returning only result lines and reader
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a script)
that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs, printed verbatim. The model keeps only lead judgment, fixes, CHANGELOG, PR prose,
gates, the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
@@ -21,7 +21,7 @@ start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Releas
version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (every phase: the next act).
**Reviewers are the sections' own.** Every reviewer brief, its severity and confidence rules
and gates come from its section verbatim (placeholders filled); this file
and gates come from its section verbatim; this file
never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render`
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs at low effort on a small model,
Step 14.5 once on Sonnet 5.5; findings go to the PR body.
@@ -60,17 +60,15 @@ Install check, this file.
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (AGENTS.md or CLAUDE.md test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Step 6.5 runs only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map,
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read the todos and
commits files and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is
`needed`.
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`; read a section only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
@@ -112,7 +110,8 @@ Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewe
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed, ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
+8 -9
View File
@@ -5,8 +5,8 @@ Read this, not `ship/SKILL.md`, when a parent agent runs /ship
the `GSTACK_LAUNCH` assignments your installer printed (`. <its GSTACK_LAUNCH_FILE>`), never
`export $(...)`, `env`, `printenv` or a bare `set` in a tool fence.
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a
script) that runs the bins, reads, dispatches and waits, returning only result lines and reader
**Rules.** Each phase below is one call: one program (Capy: an execute program; else a script)
that runs the bins, reads, dispatches and waits, returning only result lines and reader
outputs, printed verbatim. The model keeps only lead judgment, fixes, CHANGELOG, PR prose,
gates, the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names
the next act. Read a section only when a phase or the list at the end sends you, never
@@ -19,7 +19,7 @@ start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Releas
version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** (every phase: the next act).
**Reviewers are the sections' own.** Every reviewer brief, its severity and confidence rules
and gates come from its section verbatim (placeholders filled); this file
and gates come from its section verbatim; this file
never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render`
file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs at low effort on a small model,
Step 14.5 once on Sonnet 5.5; findings go to the PR body.
@@ -58,17 +58,15 @@ Install check, this file.
## 2. Preflight, tests, evals and reads: Steps 0.9, 1, 2, 3, 4, 5, 6, 6.5, 10
`gstack-ship-budget start --record <rec> --mode observe [--token-cap <n>] --reading R --credits C`, then
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (AGENTS.md or CLAUDE.md test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. A lane passes on `tests_ran: N` with exit 0; `tests_ran: 0` is ZERO-RUN, a failed lane; `unknown`
`gstack-ship-preflight --base <base> --lane <label>=<command>...` (repo test commands). `CARRY: carried` ends the run on its `GSTACK_RESULT:`
line. `ship_lanes_from`: push early (`sections/tests.md#ci-carry`). A lane passes on `tests_ran: N` with exit 0; `0` is ZERO-RUN (failed); `unknown`
is `count unavailable`. In-branch failures stop; after a repair rerun `--tests-only`.
`requires-remote` needs `gstack-pregate clear` before Step 18. When prompts, templates, judges or eval
harness changed, start the eval selector and pre-merge command (detached past 30 min); else
record `evals: none affected`. Step 6.5 runs only if `gstack-greptile-early detect` finds
Greptile. Then `gstack-ship-preflight --audit --base <base> --out <run dir>` writes the diff once
(`CONTEXT:`) and the TODOS.md items naming touched paths (`TODOS:`), and prints the coverage map,
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`. Programs read the todos and
commits files and `sections/test-coverage.md` or `sections/plan-completion.md` only when its reader is
`needed`.
plan candidates and `READER: coverage=<needed|none> plan=<needed|none>`; read a section only when its reader is `needed`.
Never read TODOS.md or the full diff into the parent. A `PERSIST:` hint never replaces Step 9.
## 3. Audits and review: Steps 7, 8, 9, 9.1, 9.2, 9.2.1
@@ -110,7 +108,8 @@ Commit in bisectable chunks; `gstack-ship-release finish --base <base> --reviewe
--docs-post <post> --lane ... [--build <cmd>] --trailer '<t>'`: `ROUTE:`, `DOCS:`, `EVIDENCE:` (FRESH;
STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` and each `tests_ran`. Then
`gstack-ship-release push --branch <branch> [--gate-open <n>]`: `REDACT_PREPUSH:`, `SEALED:`, `PUSH:`
(pushed, ALREADY_PUSHED). Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
(pushed, ALREADY_PUSHED). `EVIDENCE: DEFERRED`: the `accept` its `NEXT:` names; final-head
repo gates run beside CI. Unattended, push defers the pre-push offer; else its STOP runs Step 17's guard block (`HOOK_STATE:`, `HOOKS_IN_GIT_DIR:`, `PREPUSH_PROMPTED:`). Never force-push.
## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22
+16
View File
@@ -222,6 +222,22 @@ after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a sta
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. Pass each as `--lane <label>=<command>`; use the same lane labels and exact commands again in Step 16.
<a id="ci-carry"></a>
**CI is the one full test run.** When `origin/<base>`'s `.gstack/pregate.json` maps a lane's exact
command in `ship_lanes_from` (gstack maps `bun run test`), that lane is carried from a verified CI run
at this exact, clean tree instead of running here: `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`.
Before the first push run only targeted tests and the repro (the full suite too when the diff touches
merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
`scripts/lib/shard-engine.ts`). Push early so CI runs beside the review: commit, `gstack-ship-release push
--branch <branch> --early` (sealed and secrets scans first), open a draft PR, then `gstack-ci-carry wait` in
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
label=<l> (<reason>)` names why a mapped lane ran here (a dirty tree, no green run at HEAD yet, a fork or stale
run, a newer red run, a tree.json that misses an inventory file). Any other lane that already passed at this
tree, command and runtime is reused (`reused=<time>`), never run twice. In Step 16, `finish` defers a mapped
lane it cannot carry (`EVIDENCE: DEFERRED`); after the push, `gstack-ship-release accept --base <base>
--lane …` carries it from the green run at the final head and reruns the pregate there.
**If no applicable test suite exists:** Name the untested scope. AskUserQuestion:
A) Add tests (recommended), B) Ship with this named testing
gap, or C) Stop. Reuse an actual prior B answer only for the same scope and
+16
View File
@@ -18,6 +18,22 @@ after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a sta
Use the project's test commands discovered in Step 4 or documented in CLAUDE.md/AGENTS.md. Run every applicable suite; do not assume Rails or Vitest. Pass each as `--lane <label>=<command>`; use the same lane labels and exact commands again in Step 16.
<a id="ci-carry"></a>
**CI is the one full test run.** When `origin/<base>`'s `.gstack/pregate.json` maps a lane's exact
command in `ship_lanes_from` (gstack maps `bun run test`), that lane is carried from a verified CI run
at this exact, clean tree instead of running here: `LANE: <label> carried run=<id>/<attempt> tests_ran=<n>`.
Before the first push run only targeted tests and the repro (the full suite too when the diff touches
merge-check code: `lib/ship-*`, `lib/pregate/*`, the ship and pregate bins,
`scripts/lib/shard-engine.ts`). Push early so CI runs beside the review: commit, `gstack-ship-release push
--branch <branch> --early` (sealed and secrets scans first), open a draft PR, then `gstack-ci-carry wait` in
the background; once it prints `CI_WAIT: green`, rerun `--tests-only` and the mapped lane is carried. A red
run is a failed lane: triage it below. Batch review fixes into one commit and one push. `CI_CARRY: none
label=<l> (<reason>)` names why a mapped lane ran here (a dirty tree, no green run at HEAD yet, a fork or stale
run, a newer red run, a tree.json that misses an inventory file). Any other lane that already passed at this
tree, command and runtime is reused (`reused=<time>`), never run twice. In Step 16, `finish` defers a mapped
lane it cannot carry (`EVIDENCE: DEFERRED`); after the push, `gstack-ship-release accept --base <base>
--lane …` carries it from the green run at the final head and reruns the pregate there.
**If no applicable test suite exists:** Name the untested scope. AskUserQuestion:
A) Add tests (recommended), B) Ship with this named testing
gap, or C) Stop. Reuse an actual prior B answer only for the same scope and
+111
View File
@@ -0,0 +1,111 @@
/**
* lib/ci-carry.ts (GSTA-258 piece 1; design fixes A2, A3): CI's verified run at HEAD's exact, clean tree stands in
* for running a lane again. The dependencies are fakes, so nothing here reaches GitHub.
*/
import { describe, expect, test } from 'bun:test';
import { findCarry, shipLanesErrors, waitForRun } from '../lib/ci-carry';
import { validateConfig } from '../lib/pregate/config';
import { receiptSummaryLine, validateReceipt } from '../lib/ship-receipt';
import type { ApiJob, ApiRun, RunDeps, RunProfile, RunSource, TreeRecord } from '../lib/run-evidence';
const REPO = 'garrytan/gstack';
const HEAD = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
const NOW = new Date('2026-11-02T12:00:00Z');
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-tests'], artifact: 'tested-tree', os: 'Linux' };
const run = (id: number, over: Partial<ApiRun> = {}): ApiRun => ({
id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: HEAD, path: '.github/workflows/free-tests.yml',
created_at: `2026-11-02T1${id % 10}:00:00Z`.replace('T1', 'T0'), updated_at: '2026-11-02T11:30:00Z', repository: { full_name: REPO }, head_repository: { full_name: REPO }, ...over,
});
const record = (id: number, over: Partial<TreeRecord> = {}): TreeRecord => ({ run_id: id, run_attempt: 1, event: 'workflow_dispatch', sha: HEAD, tree: TREE, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 40, os: 'Linux', ...over });
function source(runs: ApiRun[], records: Record<number, TreeRecord> = {}, jobs: ApiJob[] = [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-11-02T11:30:00Z' }]): RunSource {
const deps: RunDeps = {
run: id => { const r = runs.find(x => x.id === id); if (!r) throw new Error('HTTP 404'); return r; },
runsForHead: (_wf, sha) => runs.filter(r => r.head_sha === sha),
jobs: () => jobs,
artifact: id => { if (!records[id]) throw new Error('no artifact'); return records[id]!; },
commitTree: sha => (sha === HEAD ? TREE : null),
};
return { repo: REPO, deps, profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW };
}
const clean = () => TREE;
const carry = (src: RunSource, over: Partial<Parameters<typeof findCarry>[1]> = {}) => findCarry(src, { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, ...over });
const why = (v: ReturnType<typeof findCarry>) => (v.ok ? 'ok' : v.reason);
describe('findCarry', () => {
// Value: protects=a green verified run at this exact tree carries, with a receipt naming it; fails_when=a verified run is ignored or its count is lost;
// why_new=GSTA-258 piece 1; seam=lib/run-evidence.ts verifyRun; no_claim=GitHub's API shape (ghRunDeps)
test('the newest verified run at HEAD carries, and the receipt names its run and tree', () => {
const v = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, inventory: () => ['test/a.test.ts', 'test/b.test.ts'] });
expect(v.ok).toBe(true);
if (!v.ok) return;
expect(v.tests).toBe(40);
expect(v.runs.map(e => e.run_id)).toEqual([4]);
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb tests=40 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
});
// Value: protects=A2, a carry binds to the working-tree fingerprint before and after; fails_when=a dirty tree or a tree edited during the lookup carries
test('a dirty working tree, or one that changes while runs are read, never carries', () => {
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => 'c'.repeat(40) }))).toBe('dirty_tree');
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => null }))).toBe('dirty_tree');
let reads = 0;
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => (reads++ ? 'c'.repeat(40) : TREE) }))).toBe('tree_changed');
});
// Value: protects=A3, a lane carries only what CI provably ran; fails_when=a tree.json missing an inventory file still carries
test('the inventory must be covered by the runs\' tree.json files', () => {
const src = source([run(4)], { 4: record(4) });
const v = carry(src, { inventory: () => ['test/a.test.ts', 'test/c.test.ts'] });
expect(why(v)).toBe('inventory_uncovered');
expect(v.ok ? '' : v.detail).toContain('test/c.test.ts');
expect(why(carry(src, { inventory: () => ({ error: 'exited 1' }) }))).toBe('inventory_failed');
expect(why(carry(src, { inventory: () => [] }))).toBe('inventory_empty');
});
// Value: protects=fork runs, newer red or running runs, stale runs and empty runs never carry; fails_when=any of them is accepted
test('no run, a fork run, a newer red or running run, a stale run and a run with no tests are refused', () => {
expect(why(carry(source([])))).toBe('no_run');
expect(why(carry(source([run(4, { head_repository: { full_name: 'evil/gstack' } })], { 4: record(4) })))).toBe('no_verified_run');
const red = carry(source([run(4), run(6, { conclusion: 'failure' })], { 4: record(4), 6: record(6) }));
expect(why(red)).toBe('no_verified_run');
expect(red.ok ? '' : red.detail).toContain('run 6: run_not_success; run 4: newer_run_vetoes');
expect(why(carry(source([run(4), run(6, { status: 'in_progress', conclusion: null })], { 4: record(4) })))).toBe('no_verified_run');
expect(why(carry(source([run(4)], { 4: record(4) }, [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-10-30T00:00:00Z' }])))).toBe('no_verified_run');
expect(why(carry(source([run(4)], { 4: record(4, { files: [] }) })))).toBe('no_tests');
expect(why(carry(source([run(4)], { 4: record(4) }), { profiles: ['nope'] }))).toBe('no_profile');
});
});
describe('waitForRun', () => {
const clockAt = () => { let t = 0; return { clock: () => t, sleep: async (ms: number) => { t += ms; } }; };
// Value: protects=the bounded wait reports green, red with failing jobs, still running and none; fails_when=it waits forever or calls a red run green
test('green, red with its failing jobs, still running at the deadline, and none', async () => {
const o = (runs: ApiRun[], jobs?: ApiJob[]) => waitForRun(source(runs, {}, jobs).deps, { workflow: 'free-tests.yml', events: PROFILE.events, head: HEAD, timeoutMs: 60_000, intervalMs: 20_000, ...clockAt() });
expect((await o([run(4)])).state).toBe('green');
const red = await o([run(4, { conclusion: 'failure' })], [{ name: 'free-tests', conclusion: 'failure' }, { name: 'typecheck', conclusion: 'success' }]);
expect(red).toMatchObject({ state: 'red', failing: ['free-tests'] });
expect((await o([run(4, { status: 'in_progress', conclusion: null })])).state).toBe('running');
expect((await o([])).state).toBe('none');
});
});
describe('ship_lanes_from config and the ci_carried receipt field', () => {
// Value: protects=A3, the mapping names real profiles and one inventory command; fails_when=a typo or a label-shaped entry validates
test('ship_lanes_from validates its profiles and inventory', () => {
const profiles = { 'ci-reuse/free-tests': PROFILE };
expect(validateConfig({ run_profiles: profiles, ship_lanes_from: { 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'bun run list' } } })).toEqual([]);
expect(shipLanesErrors({ 'bun run test': { profiles: ['nope'], inventory: 'x' } }, ['ci-reuse/free-tests']).map(e => e.message)).toEqual(['"nope" is not a run_profiles name']);
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'a\nb' } }, ['ci-reuse/free-tests'])[0]!.path).toBe('ship_lanes_from["bun run test"].inventory');
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'] , inventory: 'x', label: 'free' } }, ['ci-reuse/free-tests'])[0]!.message).toContain('unknown key');
expect(shipLanesErrors(['bun run test'], [])[0]!.path).toBe('ship_lanes_from');
});
// Value: protects=the receipt and its SHIP_RECEIPT line name the carried runs; fails_when=ci_carried is dropped or malformed entries validate
test('the receipt records ci_carried and its summary line names each run', () => {
const receipt = { schema_version: 1 as const, head: HEAD, base: 'main', ci_carried: [{ lane: 'free', run: 41, attempt: 2, tree: TREE }] };
expect(validateReceipt(receipt)).toEqual([]);
expect(receiptSummaryLine(receipt)).toContain('ci_carried=free:41/2');
expect(validateReceipt({ ...receipt, ci_carried: [{ lane: 'free', run: '41' }] }).length).toBeGreaterThan(0);
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* gstack-evidence reuse and CI-sourced rows (GSTA-258 pieces 1-2, design fixes A4 and A5).
*
* run --reuse: a lane that passed at this exact working tree, command and execution identity never runs again;
* the newest matching row decides (a newer failure vetoes), the reused row keeps its original timestamp and the
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row for a run it verified itself, never a fictitious local run.
*/
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findFilesBySuffix, gitIn } from './helpers/scratch-repo';
import { runBin } from './helpers/run-bin';
setDefaultTimeout(60_000);
const EVIDENCE = path.join(import.meta.dir, '..', 'bin', 'gstack-evidence');
let home: string;
let repo: string;
let counter: string;
const run = (args: string[]) => runBin(EVIDENCE, args, { cwd: repo, env: { GSTACK_HOME: home } });
const ledger = () => findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')[0]!;
const rows = (): any[] => fs.readFileSync(ledger(), 'utf8').trim().split('\n').map(l => JSON.parse(l));
const rewrite = (edit: (r: any[]) => any[]) => fs.writeFileSync(ledger(), edit(rows()).map(r => JSON.stringify(r)).join('\n') + '\n');
const executions = () => (fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').length : 0);
const lane = (pass: number, fail = 0) => `printf x >> '${counter}'; printf ' ${pass} pass\\n ${fail} fail\\nRan ${pass + fail} tests across 1 file. [1.00ms]\\n' >&2; exit ${fail ? 1 : 0}`;
const wtree = () => runBin(path.join(import.meta.dir, '..', 'bin', 'gstack-wtree'), [], { cwd: repo }).stdout.trim();
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-home-'));
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-repo-'));
counter = path.join(home, 'executions');
gitIn(repo, 'init -q -b main');
fs.writeFileSync(path.join(repo, 'src.txt'), 'v1\n');
gitIn(repo, 'add src.txt');
gitIn(repo, 'commit -q -m init');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
describe('gstack-evidence run --reuse (A5)', () => {
// Value: protects=a lane that passed at this tree never reruns, and the reuse is visible; fails_when=--reuse runs the suite again, or reports a pass it never recorded;
// why_new=GSTA-258 piece 2; seam=the ledger and the wtree fingerprint; no_claim=cross-machine reuse (CI carries that)
test('a pass at the same tree, command and identity is reused with its original timestamp and count', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
const first = rows().at(-1);
const again = run(['run', '--reuse', '--label', 'unit', '--', lane(4)]);
expect(again.status).toBe(0);
expect(executions()).toBe(1);
expect(again.stderr).toContain(`gstack-evidence: reused label=unit tree=${wtree().slice(0, 12)} from=${first.ts}`);
expect(again.stderr).toMatch(/gstack-evidence: recorded label=unit exit=0 tests_ran=4 log=\S+/);
const copy = rows().at(-1);
expect(copy.ts).toBe(first.ts);
expect(copy.reused_from).toBe(first.ts);
expect(copy.identity).toBe(first.identity);
expect(typeof first.identity).toBe('string');
expect(run(['check', '--label', 'unit']).stdout).toContain('EVIDENCE: FRESH label=unit exit=0 tests_ran=4');
});
// Value: protects=only the newest matching row decides; fails_when=an older pass is reused under a newer failure on the same tree
test('a newer failure on the same tree vetoes an older pass; without --reuse nothing is reused', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
expect(executions()).toBe(3);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(5);
});
// Value: protects=reuse binds to the exact command, tree, count and identity; fails_when=a changed command, an edited file, a zero-run or unknown row, or another runtime reuses
test('another command, an edited tree, a zero-run or unknown count, or another identity runs again', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(2);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, tests_ran: 0 })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(4);
rewrite(r => r.map(x => ({ ...x, tests_ran: 'unknown' })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(5);
rewrite(r => r.map(x => ({ ...x, identity: 'f'.repeat(64) })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
expect(executions()).toBe(6);
});
// Value: protects=the max age applies to the original pass, never to a refreshed copy; fails_when=reuse makes an old pass look new
test('a pass older than --max-age is not reused, and a reused copy keeps the age check honest', () => {
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 30 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(2);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 2 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--max-age', '1', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 3 * 3600_000).toISOString() })));
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
expect(executions()).toBe(3);
expect(run(['check', '--label', 'unit', '--max-age', '2']).stdout).toContain('EVIDENCE: STALE label=unit');
});
});
describe('gstack-evidence record-ci and carried (A4)', () => {
const ciRow = () => {
const tree = wtree();
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'] };
};
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
// fails_when=a check cannot tell a CI row from a local run, or a stale carry from an earlier head reaches ci_carried
test('a CI-sourced row is FRESH for its exact command, and carried lists it only at its tree', () => {
expect(run(['run', '--label', 'unit', '--', lane(2)]).status).toBe(0);
fs.appendFileSync(ledger(), JSON.stringify(ciRow()) + '\n');
const chk = run(['check', '--label', 'free', '--expect-cmd', 'bun run test', '--max-age', '24']);
expect(chk.status).toBe(0);
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
expect(chk.stdout).toContain('source=ci runs=41/2');
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
gitIn(repo, 'commit -qam two');
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
});
// Value: protects=record-ci finds and verifies the CI run itself, so no caller supplies run ids, a test count or a tree (/ship pass 1 red-team#1);
// fails_when=the forged call from that finding writes a FRESH carried row; seam=no origin, so the base's ship_lanes_from is unreadable
test('record-ci takes no run facts from its caller and records nothing without a verified run', () => {
const forged = run(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', wtree(), '--tests', '999', '--finished', new Date().toISOString(), '--', 'bun run test']);
expect(forged.status).toBe(2);
expect(forged.stderr).toContain('usage: gstack-evidence record-ci --label <L> --base <base> -- <command>');
const unverified = run(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
expect(unverified.status).toBe(1);
expect(unverified.stderr).toMatch(/^CARRY_NONE: config_unavailable: /m);
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
});
});
+6
View File
@@ -0,0 +1,6 @@
REVIEWED_TREE: 4d6a982968de8e11d2665976fc91587730a4767d /home/user/.capy/work/gst986-wt
{"severity":"INFORMATIONAL","path":"docs/troubleshooting.md","line":3307,"summary":"The RECEIPT_INVALID entry still describes the old AND rule: it says write refuses when the read commit's product patch is not the receipt's AND a later change carries a targeted class. The fixed code at lib/ship-receipt-depth.ts:153 refuses on either one. So a user who hits the refusal after a docs-only or TODOS.md commit (the Step 14 and 14.5 commits) finds no matching cause in this entry. docs/review-policy.md and the changelog fragment were updated; this entry was not."}
{"severity":"INFORMATIONAL","path":"lib/ship-receipt-depth.ts","line":153,"summary":"The fix list calls this main's rule, but it isn't. On origin/main, parseReviewTargeted(value, productPatchId) takes no head and binds any record. So under this change, every money, gate, auth or secret ship whose Step 14 or 14.5 commits after the Step 11 read now stops at Step 19 with RECEIPT_INVALID. That is the same outcome pass 1's Red Team flagged. Step 19's prose (ship/sections/pr-body.md:247, 'fix that input file and rerun') doesn't say to rerun the targeted reader on HEAD; only the tool's own error message does. The guard fails closed, and the code deliberately defers the docs-only case to GST-1031, so this is not data loss. It is a known extra stop in unattended /ship runs."}
{"severity":"INFORMATIONAL","path":"lib/ship-receipt-depth.ts","line":153,"summary":"When the product patches are equal and only classesSince refuses (a test edit after the read that skips a case), the message prints the same patch id twice: 'whose product patch X is not this receipt's X'. This was already true before the fix and the fix kept it. The message misstates the cause; the decision itself is correct."}
I found nothing at the blocking floor. The OR guard fails closed in the right places: it refuses when the product patch differs, when classesSince returns null (unreadable), and when a class shows up after the read. Its only production caller, bin/gstack-ship-receipt:227, passes the same arguments as before. The test change does replace the old docs-binds assertion with a refusal, but that matches the intended new rule; it doesn't weaken coverage. The new no-money-token regression test covers the CRITICAL fix. I reran the three focused files at this tree: 34 pass, 0 fail, 403 expect() calls.
+34
View File
@@ -0,0 +1,34 @@
I reviewed the range base..HEAD, one commit that changes four files. I read every non-test file in full.
## Findings
**1. The family check refuses two models in one call even when no task is judged (check.ts:165-169). FIXABLE.**
The family check runs whether or not the run uses a judge. Elsewhere the judge is used only when `judged` is true.
Before this commit the call passed. Now every judge stops with E_POLICY:
- the default judge clashes with the second model;
- the other judges clash with the first model.
So the two models can no longer run in one call, even though the judge would never be called. It fails closed with a clear message, and two calls work around it.
Fix: `else if (judged && sameFamily.length)`, plus a test with an unjudged set.
Severity: Medium. Blocks: no.
**2. max_price now uses the long-prompt rates for every call, short prompts included (prices.ts:75, set at proxy.ts:513). FIXABLE, with part to INVESTIGATE.**
`routing` sets max_price from the price row alone, whatever the prompt length.
Today the only endpoints are standard, fast and ultrafast.
Two more problems:
- The commit assumes the router checks max_price against long-tier rates.
- The new doc comment gives the wrong reason.
Fix: build max_price per request.
Severity: Low. Blocks: no.
**3. The docs' third-family judge barely exists (docs/eval.md:125, policy.ts:125-129). INVESTIGATE.**
Only weak judges have price rows, so in practice the remedy is two calls.
Also, call.ts still picks the default judge by provider, not by family.
Severity: Low. Blocks: no.
Recommendation: Fix the unconditional family check at check.ts:169, because gating it on `judged` is a one-line change.
+1
View File
@@ -42,6 +42,7 @@ const KEPT: Record<string, string> = {
'review-*': '/ship review loop records',
'fix-check-*': '/ship fix-check inputs',
'tasks-*': 'gstack-tasks rows files (bin/gstack-tasks)',
'qa-reports': 'caller QA report runs from gstack-qa-evidence report-dir (lib/qa-evidence.ts)',
'autoplan': 'autoplan phase artifacts (autoplan/SKILL.md.tmpl)',
'eval-bisect': '/investigate eval bisect cache',
'price-gate': 'gstack-eval-plan price receipts',
+18 -1
View File
@@ -225,12 +225,29 @@ describe('carry follow-ups: git floor, named refusal, branch-touched walk, digit
const mine = "export const MINE_KEY_VERSION = '1.2.3.0';";
const before = `${mine}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n`;
const moved = (text: string) => ({ before, after: text, oldVersion: '1.2.4.0', newVersion: '1.2.5.0', templates });
const side = { fork: '1.2.3.0', added: new Set([mine]) };
const side = { fork: '1.2.3.0', added: new Set([mine]), at: new Set([1]) };
expect(stampOnlyText({ ...moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))), side })).toBe(true);
expect(stampOnlyText(moved(before.replace(mine, mine.replace('1.2.3.0', '1.2.5.0'))))).toBe(false);
expect(stampOnlyText({ ...moved(before.replace("BASE_KEY_VERSION = '1.2.3.0'", "BASE_KEY_VERSION = '1.2.5.0'")), side })).toBe(false);
});
// Value: protects=the carry reads the branch's own line by position and, with a kept VERSION claim, accepts only the
// branch's own row moved from the fork version (GST-979, GST-980); fails_when=stampOnlyText refuses every change at
// equal versions or takes a duplicated base line as the branch's; seam=lib/ship-receipt.ts stampOnlyText
test('stampOnlyText reads the branch line by position, and with a kept VERSION moves only the branch\'s own row (GST-979, GST-980)', () => {
const templates = ["_KEY_VERSION = '{version}'"];
const key = (v: string) => `export const MINE_KEY_VERSION = '${v}';`;
const before = `${key('1.2.3.0')}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${key('1.2.3.0')}\n`;
const side = { fork: '1.2.3.0', added: new Set([key('1.2.3.0')]), at: new Set([3]) };
const lines = (a: string, c: string) => `${a}\nexport const BASE_KEY_VERSION = '1.2.3.0';\n${c}\n`;
const change = (after: string, oldVersion: string, newVersion: string) => stampOnlyText({ before, after, oldVersion, newVersion, templates, side });
expect(change(lines(key('1.2.3.0'), key('1.2.5.0')), '1.2.4.0', '1.2.5.0')).toBe(true);
expect(change(lines(key('1.2.5.0'), key('1.2.3.0')), '1.2.4.0', '1.2.5.0')).toBe(false);
expect(change(lines(key('1.2.3.0'), key('1.2.4.0')), '1.2.4.0', '1.2.4.0')).toBe(true);
expect(change(lines(key('1.2.4.0'), key('1.2.3.0')), '1.2.4.0', '1.2.4.0')).toBe(false);
expect(stampOnlyText({ before, after: lines(key('1.2.3.0'), key('1.2.4.0')), oldVersion: '1.2.4.0', newVersion: '1.2.4.0', templates })).toBe(false);
});
test('planCarry names at most CARRY_NAMED_PATHS paths', () => {
const many = Array.from({ length: CARRY_NAMED_PATHS + 3 }, (_, i) => `src/f${i}.ts`);
const plan = planCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), changed: many.map(p => ({ path: p, class: 'product' as const })), productPatchId: { from: 'x', head: 'y' }, testPatchId: { from: '', head: '' }, changedProduct: many, mergeCommits: 0, rangeDiff: '' });
+45
View File
@@ -0,0 +1,45 @@
/**
* A fake gh for the CI-carry tests (GSTA-258): serves the Actions run, jobs, artifact and download reads
* lib/run-evidence.ts ghRunDeps makes from a world file, and answers every other gh call with nothing.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
export interface FakeWorld {
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
trees: Record<string, Record<string, unknown>>;
}
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
export const fakeGhScript = (worldFile: string) => `exec bun --no-env-file --config=/dev/null '${import.meta.path}' '${worldFile}' "$@"`;
if (import.meta.main) {
const [worldFile, ...a] = process.argv.slice(2);
const world: FakeWorld = fs.existsSync(worldFile!) ? JSON.parse(fs.readFileSync(worldFile!, 'utf8')) : { runs: [], jobs: {}, trees: {} };
const lines = (list: unknown[]) => process.stdout.write(list.map(x => JSON.stringify(x)).join('\n') + (list.length ? '\n' : ''));
if (a[0] === 'api') {
const jq = a.indexOf('--jq');
const p = a.filter((x, i) => i > 0 && x !== '--paginate' && i !== jq && i !== jq + 1)[0] ?? '';
const [route, query = ''] = p.split('?');
const q = new URLSearchParams(query);
let m: RegExpExecArray | null;
if ((m = /\/actions\/runs\/(\d+)$/.exec(route!))) {
const run = world.runs.find(r => r.id === Number(m![1]));
if (!run) { process.stderr.write('HTTP 404'); process.exit(1); }
process.stdout.write(JSON.stringify(run));
} else if ((m = /\/actions\/workflows\/([^/]+)\/runs$/.exec(route!))) {
lines(world.runs.filter(r => r.head_sha === q.get('head_sha') && r.path === `.github/workflows/${m![1]}`));
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/\d+\/jobs$/.exec(route!))) {
lines(world.jobs[m[1]!] ?? []);
} else if ((m = /\/actions\/runs\/(\d+)\/artifacts$/.exec(route!))) {
lines(world.trees[m[1]!] ? [{ name: q.get('name'), size_in_bytes: 200, expired: false }] : []);
} else { process.stderr.write(`fake gh: no route for ${p}`); process.exit(1); }
} else if (a[0] === 'run' && a[1] === 'download') {
const tree = world.trees[a[2]!];
if (!tree) process.exit(1);
const dir = a[a.indexOf('--dir') + 1]!;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'tree.json'), JSON.stringify(tree));
}
}
+31 -1
View File
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { classifyOutsideReview, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result';
import { classifyOutsideReview, usageFromEvents, validateOutsideReview, type OutsideGate } from '../lib/outside-review-result';
import { gateOutcomeLine, type GateReason } from '../lib/gate-outcomes';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -262,6 +262,36 @@ describe('B1: a review whose sandbox could not start is unavailable, not clean',
});
});
describe('Codex token usage from --json events (#2948)', () => {
const turn = (usage: Record<string, number>) => JSON.stringify({ type: 'turn.completed', usage });
test('one turn: every counter the event carries, nothing invented', () => {
expect(usageFromEvents(turn({ input_tokens: 12016, cached_input_tokens: 0, cache_write_input_tokens: 12013, output_tokens: 5, reasoning_output_tokens: 0 })))
.toEqual({ input_tokens: 12016, cached_input_tokens: 0, cache_write_input_tokens: 12013, output_tokens: 5, reasoning_output_tokens: 0, usd: 'unknown' });
});
test('the latest turn wins (codex reports the running session total); malformed and unrelated lines are skipped', () => {
const events = [turn({ input_tokens: 100, output_tokens: 10 }), 'not json', '{"type":"item.completed","item":{"type":"agent_message"}}', turn({ input_tokens: 150, output_tokens: 15, reasoning_output_tokens: 2 })].join('\n');
expect(usageFromEvents(events)).toEqual({ input_tokens: 150, output_tokens: 15, reasoning_output_tokens: 2, usd: 'unknown' });
});
test('a cumulative total_token_usage snapshot wins over per-turn usage, latest first', () => {
const snap = (n: number) => JSON.stringify({ msg: { type: 'token_count', info: { total_token_usage: { input_tokens: n, output_tokens: n / 10, total_tokens: n * 1.1 } } } });
expect(usageFromEvents([turn({ input_tokens: 7 }), snap(100), snap(300)].join('\n'))).toEqual({ input_tokens: 300, output_tokens: 30, total_tokens: 330, usd: 'unknown' });
});
test('the CLI prints OUTSIDE_USAGE only when given --events, on every verdict', () => {
const events = path.join(TMP, 'usage-events.jsonl');
fs.writeFileSync(events, turn({ input_tokens: 1200, cached_input_tokens: 200, output_tokens: 340 }) + '\n');
const withEvents = cli(['--events', events, 'review', file('No issues found.')]);
expect(withEvents.stdout).toContain('OUTSIDE_USAGE: input=1200 cached_input=200 output=340\n');
const empty = path.join(TMP, 'no-usage.jsonl');
fs.writeFileSync(empty, '{"type":"thread.started"}\n');
expect(cli(['--events', empty, 'review', file('')]).stdout).toContain('OUTSIDE_USAGE: unknown\n');
expect(cli(['--verdict', 'review', file('No issues found.')]).stdout).not.toContain('OUTSIDE_USAGE');
});
});
describe('a mid-run Codex usage limit is unavailable (quota_exhausted); a mid-run 429 is unavailable (rate_limited)', () => {
const LINE = "ERROR: You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Oct 10th, 2026 2:55 AM.";
const RATE_429 = 'ERROR: {"type":"error","status":429,"error":{"type":"rate_limit_exceeded","message":"Rate limit reached"}}';
+98
View File
@@ -0,0 +1,98 @@
/**
* CI carry and the remote gate (GSTA-258 A): after a verified CI run carries part of the tests stage on a small
* machine, the files left over can all be ones the remote gate never runs (gstack's paid evals). The gate itself then
* reports tests=0 (PREGATE_REMOTE_PAID_ONLY) and `clear --run` refuses paid_only, so the obligation could never clear.
* The carried run clears it instead, through remote_gate_runs and its inventory check. The run API is a fake.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import type { PregateFile } from '../lib/headless-artifacts';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
import type { ApiRun, RunProfile } from '../lib/run-evidence';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const LANE = 'pregate.json/remote_gate';
const NOW = new Date('2026-11-02T12:00:00Z');
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
const PAID = 'test/nightly/retry-heavy.test.js';
const FREE = ['test/retry.test.js', 'test/util.test.js'];
function fixture(): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
repos.push(repo);
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
return repo;
}
/** Run 100 of free-tests.yml at HEAD, green, its tree.json listing `files`; the gate's inventory is FREE and its skipped command names PAID. */
function runCheck(repo: FixtureRepo, files: string[]): RunCheck {
const head = repo.git('rev-parse', 'HEAD');
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free-tests.yml', created_at: '2026-11-02T10:00:00Z', updated_at: '2026-11-02T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
return {
repo: 'fx/fx', profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW,
gateRuns: { profiles: ['ci-reuse/free-tests'], inventory: 'INVENTORY', skipped: 'SKIPPED' },
inventory: (command, _tree, selected) => (command === 'SKIPPED' ? (selected ?? []).filter(f => f === PAID) : FREE),
deps: {
run: () => api, runsForHead: (_wf, sha) => (sha === head ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: head, head_sha: head, tree: repo.git('rev-parse', 'HEAD^{tree}'), os: [LOCAL_OS], files, tests: files.length * 4 }),
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
},
} as RunCheck;
}
async function carried(repo: FixtureRepo, check: RunCheck): Promise<{ file: PregateFile; progress: string[] }> {
const progress: string[] = [];
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
loaded.ctx.fromRun = { ref: '100', check };
return { file: await runPregate(loaded.ctx, { stage: 'tests' }), progress };
}
const gate = (file: PregateFile) => file.requires_remote?.find(r => r.lane === LANE);
describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate on a small machine', () => {
// Value: protects=a carried run whose remainder is only gate-skipped (paid) files leaves no obligation nothing can clear;
// fails_when=the remainder stays a remote_gate obligation that the gate reports tests=0 for and clear --run refuses paid_only;
// why_new=GSTA-258 A, the PREGATE_REMOTE_PAID_ONLY dead end seen at 0fba5533 (112 paid files after run 41554380563); seam=runPregate with a fake run API
test('the carried run clears the gate when only skipped files remain, and the receipt counts them', async () => {
const repo = fixture();
const check = runCheck(repo, FREE);
const { file, progress } = await carried(repo, check);
const obligation = gate(file)!;
expect(obligation.tests).toEqual([PAID]);
expect(clearDecision({ ...file, requires_remote: [{ ...obligation, receipt: null }] }, LANE, { runs: ['100'], evidence: check }, repo.work)).toMatchObject({ refused: 'PREGATE_RECEIPT_INVALID', message: expect.stringContaining('reason=paid_only') });
expect([obligation.receipt, obligation.cleared_by]).toEqual([expect.stringMatching(new RegExp(`^remote-gate tree=${file.tree.slice(0, 12)} tests=8 skipped=1 runs=100/1 `)), 'ci-carry']);
expect(progress).toContain(`CI_CARRY: cleared ${LANE} ${obligation.receipt}`);
expect(file.verdict).toBe('requires-remote cleared');
}, 120_000);
// Value: protects=the carry clears the gate only under clear --run's own checks; fails_when=a run that misses an inventory file clears it
test('a run that misses a gate inventory file leaves the gate uncleared and says why', async () => {
const repo = fixture();
const { file, progress } = await carried(repo, runCheck(repo, ['test/util.test.js']));
expect(gate(file)?.receipt).toBeNull();
expect(progress).toContainEqual(expect.stringMatching(new RegExp(`^CI_CARRY: ${LANE} stays uncleared \\(REUSE: refused reason=inventory_uncovered`)));
}, 120_000);
// Value: protects=when the base declares remote_gate_runs, the uncleared gate names the CI route first and the VM only as an opt-in;
// fails_when=the obligation sends an agent to the paid VM while a CI run at the same tree would clear it; why_new=GSTA-258 A, plan choice F1
test('with remote_gate_runs on the base, an uncleared gate names the CI route first and the VM as the opt-in', async () => {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: { 'ci-reuse/free-tests': PROFILE }, remote_gate_runs: { profiles: ['ci-reuse/free-tests'], inventory: 'echo test/retry.test.js' } } });
repos.push(repo);
commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = 2;\n' }, 'touch retry');
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
expect(gate(file)?.receipt).toBeNull();
const detail = file.checks.find(c => c.id === 'lanes')?.detail ?? '';
expect(detail).toContain(`a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base main and rerun gstack-pregate --allow-repo-commands`);
expect(detail.indexOf('gstack-ci-carry wait')).toBeLessThan(detail.indexOf('opt in to the VM: gstack-pregate --remote --spend-cap <usd> --allow-repo-commands'));
loaded.ctx.ciCarry = false;
const off = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')?.detail ?? '';
expect(off).not.toContain('gstack-ci-carry wait');
expect(off).toContain('run it, then gstack-pregate clear --remote pregate.json/remote_gate');
}, 120_000);
});
+4 -4
View File
@@ -50,12 +50,12 @@ describe('normalizer v2 as collected: Blocks: yes is CRITICAL, Blocks: no never
// Value: protects=the rule round 5's G1 was scored under, on the path production runs (gstack-review-dispatch collect);
// fails_when=Blocks: yes collects below CRITICAL or Blocks: no lowers a label; seam=parseFindings
test.each([
['FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', 'CRITICAL'],
['FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no', 'Medium'],
['INVESTIGATE: odd lock in b.ts:9\nSeverity: Low\nBlocks: yes', 'CRITICAL'],
['INVESTIGATE: odd lock in b.ts:9\nBlocks: no', 'INVESTIGATE'],
['FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no', 'CRITICAL'],
['1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no', 'CRITICAL'],
['- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no', 'CRITICAL'],
['FIXABLE: race in a.ts:3. Severity: Medium. Blocks: no', 'Medium'],
['1. FIXABLE: race in a.ts:3 — Severity: Low, Blocks: no', 'Low'],
['- FIXABLE: race in a.ts:3\n- Severity: Medium\n- Blocks: no', 'Medium'],
] as const)('native %j -> %s', (output, want) => expect(parseFindings('adversarial-native', output).map(g => g.label)).toEqual([want]));
test.each([
['1. Drops the last row (q.ts:7). Severity: Critical. Blocks: no', 'Critical'],
+104
View File
@@ -0,0 +1,104 @@
import { afterAll, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { RESOLVERS } from '../scripts/resolvers';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
const BIN = path.resolve(import.meta.dir, '../bin');
const ROOT = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qa-report-dir-'));
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const IDENTITY = { GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com' };
function product() {
const root = fs.mkdtempSync(path.join(ROOT, 'product-'));
const git = (...args: string[]) => {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 10_000, env: { ...process.env, ...IDENTITY } });
expect(r.status, r.stderr).toBe(0);
return r.stdout;
};
git('init', '-q');
fs.writeFileSync(path.join(root, 'app.ts'), 'export const x = 1;\n');
git('add', 'app.ts');
git('commit', '-q', '-m', 'seed');
const tool = (name: string, ...args: string[]) => {
const r = spawnSync(process.execPath, [path.join(BIN, name), ...args], { cwd: root, encoding: 'utf8', timeout: 15_000 });
expect(r.error, r.error?.message).toBeUndefined();
return r;
};
const tree = () => git('status', '--porcelain', '--untracked-files=all', '--ignored=no') + git('ls-files', '--others', '--exclude-standard');
return { root, tool, tree };
}
function reportDir(f: ReturnType<typeof product>): string {
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status, r.stderr).toBe(0);
return JSON.parse(r.stdout.trim().slice('QA_EVIDENCE '.length)).reportDir;
}
test('the /ship QA report root leaves a product tree without a .gstack ignore rule unchanged', () => {
const f = product();
expect(f.tree()).toBe('');
const dir = reportDir(f);
expect(path.relative(f.root, dir).split(path.sep).slice(0, 3)).toEqual(['.gstack', 'tmp', 'qa-reports']);
const deadline = path.join(dir, 'deadline.json');
expect(f.tool('gstack-qa-deadline', 'start', deadline, '300').status).toBe(0);
const capture = f.tool('gstack-qa-evidence', 'capture', dir, '001', '--public', '--deadline', deadline, '--', process.execPath, '-e', 'console.log(JSON.stringify({ ok: true }))');
expect(capture.status, capture.stderr).toBe(0);
fs.writeFileSync(path.join(dir, 'report.md'), '## Exploratory QA\n');
expect(f.tree()).toBe('');
});
test('each report-dir call owns a fresh run so a second QA run never overwrites the first', () => {
const f = product();
const first = reportDir(f);
const second = reportDir(f);
expect(second).not.toBe(first);
expect(fs.readdirSync(first)).toEqual([]);
expect(fs.readdirSync(second)).toEqual([]);
});
test('report-dir refuses a linked .gstack instead of creating directories through it', () => {
const f = product();
const elsewhere = fs.mkdtempSync(path.join(ROOT, 'elsewhere-'));
fs.symlinkSync(elsewhere, path.join(f.root, '.gstack'));
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir refuses the linked .gstack');
expect(fs.readdirSync(elsewhere)).toEqual([]);
});
test('report-dir refuses when a product .gitignore un-ignores .gstack/tmp', () => {
const f = product();
fs.writeFileSync(path.join(f.root, '.gitignore'), '!/.gstack/tmp/\n!/.gstack/tmp/**\n');
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('.gstack/tmp is not git-ignored here');
});
test('report-dir names the filesystem cause when .gstack is not a directory', () => {
const f = product();
fs.writeFileSync(path.join(f.root, '.gstack'), 'not a directory\n');
const r = f.tool('gstack-qa-evidence', 'report-dir');
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir failed: ENOTDIR');
});
test('report-dir refuses outside a git work tree instead of guessing a location', () => {
const outside = fs.mkdtempSync(path.join(ROOT, 'plain-'));
const r = spawnSync(process.execPath, [path.join(BIN, 'gstack-qa-evidence'), 'report-dir'], { cwd: outside, encoding: 'utf8', timeout: 15_000, env: { ...process.env, GIT_CEILING_DIRECTORIES: ROOT } });
expect(r.status).toBe(2);
expect(r.stderr).toContain('report-dir needs a git work tree');
expect(fs.readdirSync(outside)).toEqual([]);
});
test('caller QA without a resolved REPORT_DIR (/review, /ship) falls back to report-dir, not the product tree', () => {
const line = 'Reuse resolved REPORT_DIR; otherwise use the `reportDir` `EVIDENCE_TOOL report-dir` prints.';
const ctx: TemplateContext = { host: 'claude', skillName: 'qa', tmplPath: '', paths: HOST_PATHS.claude };
expect(RESOLVERS.QA_EXPLORATORY(ctx)).toContain(line);
expect(fs.readFileSync(path.resolve(import.meta.dir, '../qa/sections/exploratory.md'), 'utf8')).toContain(line);
expect(fs.readFileSync(path.resolve(import.meta.dir, '../qa/SKILL.md.tmpl'), 'utf8')).toContain('Use the directory as `REPORT_DIR`');
});
+102
View File
@@ -329,4 +329,106 @@ describe('a branch-added row left at the fork version after the base moved (GST-
expect(receipt.out).toContain("id: 'base_row'");
expect(receipt.out).not.toContain("id: 'mine'");
});
// Value: protects=a base row whose text a branch row duplicates keeps the old-version floor in the tree receipt
// (GST-979, the #3862 outside voice); fails_when=the side matches by text, so a hand move of the base copy reads as
// same-modulo-stamps; seam=lib/stamp-only.ts branchSides positions
test('a base row whose text the branch duplicates still refuses a hand move; the branch copy\'s own move passes (GST-979)', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-dup-row-', policy: receiptPolicy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-dup');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.3.0', 'base_row')]));
repo.git('commit', '-qam', 'dup: the same row text at the fork version');
const gated = repo.git('rev-parse', 'HEAD');
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')]));
repo.git('commit', '-qam', 'stamp dup');
const own = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(own.out, own.err).toContain('TREE: same-modulo-stamps');
write(repo, NOTICE, notice([row('1.2.5.0', 'base_row'), '', '', row('1.2.5.0', 'base_row')]));
repo.git('commit', '-qam', 'move the base copy by hand');
const hand = runBin(repo, 'gstack-tree-receipt', ['--gated', gated, '--base', 'main']);
expect(hand.out, hand.err).toContain('TREE: changed');
});
// Value: protects=the receipt carry follows a stamp that moves the branch's own fork-version row, the move the writer
// makes since GST-963 (GST-980); fails_when=the carry keeps the branch-added row's bytes in both product patch-ids and
// refuses RECEIPT_CARRY_PRODUCT_CHANGED; seam=bin/gstack-ship-receipt carry walk
test('the carry follows a stamp that moves the branch\'s own fork-version row, and still refuses a product edit beside it (GST-980)', () => {
const repo = makeFixtureRepo({ prefix: 'restamp-carry-row-', policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
fs.rmSync(path.join(repo.work, 'package-lock.json'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-carry');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), '', row('1.2.3.0', 'mine')]));
repo.git('commit', '-qam', 'mine: claims 1.2.4.0 with a row at the fork version');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(w.status, w.err).toBe(EXIT.ok);
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]));
repo.git('commit', '-qam', 'stamp 1.2.5.0');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.err).not.toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
expect(carried.out, carried.err).toContain('CARRY: carried');
write(repo, 'src.ts', 'export const x = 1;\n');
repo.git('add', '-A'); repo.git('commit', '-qm', 'a product change after the stamp');
const changed = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(changed.status).toBe(EXIT.refused);
expect(changed.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
});
/** The GST-980 carry state: a receipt written at the claim 1.2.4.0 with the branch's own row, then the stamp to 1.2.5.0, uncommitted. */
function carryAfterStamp(prefix: string, baseRows: string[], mine: string, stamped: string[]): { repo: FixtureRepo; body: string } {
const repo = makeFixtureRepo({ prefix, policy: policy(true) }); repos.push(repo);
write(repo, NOTICE, notice(baseRows));
fs.rmSync(path.join(repo.work, 'package-lock.json'));
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows, no lockfile'); repo.git('push', '-q', 'origin', 'main');
makePr(repo, 'pr-carry');
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp 1.2.4.0');
write(repo, NOTICE, notice([...baseRows, '', mine]));
repo.git('commit', '-qam', 'mine: a row of its own');
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\n');
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '3', '--base', 'main', '--session-kind', 'x', '--body', body]);
expect(w.status, w.err).toBe(EXIT.ok);
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
expect(readFile(repo, NOTICE)).toBe(notice(stamped));
return { repo, body };
}
// Value: protects=the carry leaves out only a stamp on the branch's own rows, never a version edit on a base line of
// the same file (GST-980 review); fails_when=the carry trusts the whole-text version swap, so a hand move of a base
// line's version mention rides along with the row stamp; seam=bin/gstack-ship-receipt productSpec ownRows
test('the carry refuses a hand version edit on a base line of a stamp file whose own row it follows (GST-980)', () => {
const min = "export const MIN = '1.2.4.0';";
const { repo, body } = carryAfterStamp('restamp-carry-base-line-', [row('1.2.3.0', 'base_row'), min], row('1.2.4.0', 'mine'),
[row('1.2.3.0', 'base_row'), min, '', row('1.2.5.0', 'mine')]);
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), min.replace('1.2.4.0', '1.2.5.0'), '', row('1.2.5.0', 'mine')]));
repo.git('commit', '-qam', 'stamp 1.2.5.0, and move the base pin by hand');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.status, carried.out).toBe(EXIT.refused);
expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
});
// Value: protects=a mode change rides on no stamp: the carry leaves out a stamp file only when its mode is the
// reviewed one (GST-980 review); fails_when=the left-out stamp file drops its mode line from both patch-ids;
// seam=bin/gstack-ship-receipt productSpec ownRows
test('the carry refuses a mode change on a stamp file whose own row it follows (GST-980)', () => {
const { repo, body } = carryAfterStamp('restamp-carry-mode-', [row('1.2.3.0', 'base_row')], row('1.2.3.0', 'mine'),
[row('1.2.3.0', 'base_row'), '', row('1.2.5.0', 'mine')]);
fs.chmodSync(path.join(repo.work, NOTICE), 0o755);
repo.git('commit', '-qam', 'stamp 1.2.5.0, and make the file executable');
expect(repo.git('ls-tree', 'HEAD', '--', NOTICE)).toStartWith('100755 ');
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
expect(carried.status, carried.out).toBe(EXIT.refused);
expect(carried.err).toContain('RECEIPT_CARRY_PRODUCT_CHANGED');
});
});
+9 -5
View File
@@ -440,7 +440,10 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
['CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)', [['CRITICAL', 'CRITICAL: x.ts:4 drops rows (pass 1 had Severity: Low)']]],
])('fix-check shape %j', (output, want) => expect(parseFindings('outside-voice', output).map(f => [f.label, f.raw])).toEqual(want));
test('the native voice\'s FIXABLE collects as CRITICAL under its role with or without a dispatch name', () => {
for (const reader of ['adversarial-native', 'adversarial-native/adversarial']) expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no').map(f => f.label)).toEqual(['CRITICAL']);
for (const reader of ['adversarial-native', 'adversarial-native/adversarial']) {
expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings(reader, 'FIXABLE: race in a.ts:3\nSeverity: Medium\nBlocks: no').map(f => f.label)).toEqual(['Medium']);
}
expect(parseFindings('outside-voice', 'FIXABLE: race in a.ts:3\nSeverity: Medium').map(f => f.label)).toEqual(['Medium']);
});
test('a non-prefix Blocks: yes under a labeled finding raises it to CRITICAL', () => {
@@ -529,7 +532,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
test('unlabeled detail bullets and the Severity line after them stay on the open paragraph finding', () => {
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/gsta-196-native.txt'), 'utf8');
expect(parseFindings('adversarial-native', native).map(f => [f.label, f.raw.slice(0, 12), f.raw.endsWith('| Severity: ' + (f.label === 'High' ? 'High' : 'Low') + '. Blocks: no.')]))
.toEqual([['High', '**1. A PR in', true], ['CRITICAL', '**2. A case ', true], ['CRITICAL', '**3. Local `', true], ['Low', '**4. No trai', true]]);
.toEqual([['High', '**1. A PR in', true], ['Low', '**2. A case ', true], ['Low', '**3. Local `', true], ['Low', '**4. No trai', true]]);
expect(parseFindings('outside-voice', '1. FIXABLE: race in a.ts:3\n- Unlabeled sibling at b.ts:9\nSeverity: Medium').map(f => f.label)).toEqual(['FIXABLE', 'Medium']);
expect(parseFindings('outside-voice', '**Retry storm in r.ts:2 (INVESTIGATE).**\n- detail\n- High: a second defect in s.ts:4\nSeverity: Medium').map(f => f.label)).toEqual(['INVESTIGATE', 'High']);
expect(parseFindings('outside-voice', 'Low: cache TTL mismatch (cache.ts:4)\n\n2. Token never expires (auth.ts:9)\nSeverity: High\nBlocks: yes').map(f => [f.label, f.raw]))
@@ -548,7 +551,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/gsta-196-native.txt'), 'utf8');
const r = collect(path.join(d, 'l.jsonl'), 'adversarial-native', native, path.join(d, 'm.jsonl'));
expect(r.lines.map(l => [l.label, l.stated])).toEqual([
['High', { severity: 'High', blocks: 'no' }], ['CRITICAL', { severity: 'Low', blocks: 'no' }], ['CRITICAL', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]);
['High', { severity: 'High', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]);
expect(fs.readFileSync(path.join(d, 'm.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l).stated)).toEqual(r.lines.map(l => l.stated));
const json = collect(path.join(d, 'l2.jsonl'), 'fix-check', '{"severity":"INFORMATIONAL","path":"a.ts","line":3,"summary":"Severity: High is the wrong word here"}\n', path.join(d, 'm2.jsonl'));
expect(json.lines.map(l => [l.label, l.stated])).toEqual([['INFORMATIONAL', undefined]]);
@@ -584,7 +587,8 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
for (const [line, label] of kept) expect([line, parseFindings('outside-voice', line).map(f => f.label)]).toEqual([line, [label]]);
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-152-p1-adversarial-native.txt'), 'utf8');
expect(parseFindings('adversarial-native', native).map(f => f.label)).toEqual(['CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL', 'INVESTIGATE', 'INVESTIGATE', 'CRITICAL']);
expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low. Blocks: no').map(f => f.label)).toEqual(['Low']);
expect(parseFindings('adversarial-native', '**1. Race in a.ts:3 (`FIXABLE`).**\nSeverity: Low.').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('red-team', '1. Race in a.ts:3 (FIXABLE).').map(f => f.label)).toEqual(['FIXABLE']);
expect(parseFindings('adversarial-native', 'No rate limit is FIXABLE (login.ts:9)').map(f => f.label)).toEqual(['CRITICAL']);
});
@@ -626,7 +630,7 @@ describe('gstack-review-dispatch sent, collect, judge and verify: nothing change
test('item layouts collect as on main; the later-paragraph split and indented label headings are known deferred shapes', () => {
const native = fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/harn15-ship2-p2-adversarial-native.txt'), 'utf8');
const found = parseFindings('adversarial-native', native);
expect(found.map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'CRITICAL', 'Low']);
expect(found.map(f => f.label)).toEqual(['Medium', 'Low', 'Low', 'Low', 'Low', 'Low']);
expect(parseFindings('outside-voice', '1. **Race on the lock** (a.ts:3)\n - the writer holds no lock\n\n Fix: take it first. Severity: High. Blocks: no.').map(f => f.label)).toEqual(['High']);
expect(parseFindings('outside-voice', '1. Lock race (a.ts:3)\n\n Fix: take it first. Blocks: yes\n2. Cache drift (c.ts:4)').map(f => f.label)).toEqual(['CRITICAL']);
expect(parseFindings('red-team', '1. Unescaped query\n {"severity":"critical","summary":"Low-level race in q.ts"}').map(f => [f.label, f.raw]))
+4 -4
View File
@@ -18,7 +18,7 @@ describe('native FIXABLE by review class (GST-987)', () => {
// no_claim=which class a diff belongs to (lib/review-depth.ts decides)
test('#141 native #4 and #6 collect at their own Severity on a code-class plan', () => {
expect(parseFindings('adversarial-native', NATIVE, fixableRaises(CODE)).map(f => f.label)).toEqual(['Medium', 'Low']);
expect(parseFindings('adversarial-native', NATIVE).map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL']);
expect(parseFindings('adversarial-native', NATIVE).map(f => f.label)).toEqual(['Medium', 'Low']);
});
test('gate class, any risk reason, a depth error or an unreadable plan keeps normalizer v2', () => {
for (const lines of [plan('gate', 'gate-class,runtime=9'), plan('code', 'risk:money,runtime=9'), plan('code', 'risk:data,runtime=9'), plan('code', 'error:DIFF,runtime=9'), plan('code', 'runtime=9,history:fixed-blocking'), plan('code', 'runtime=9,floor:full'), plan('code', ''), plan('unknown', 'runtime=9'), [CODE[0]!], [], undefined])
@@ -47,7 +47,7 @@ describe('native FIXABLE by review class (GST-987)', () => {
['### 1. Race in a.ts:3 — FIXABLE\n\nBody.\n\n**Severity:** Medium\n**Blocks:** no\n\n### 2. Drift in b.ts:9 — FIXABLE\n\nBody.\n\n**Severity:** Low\n**Blocks:** no'],
])('a numbered finding heading keeps its body paragraph and fields (#149) %#', (output) => {
expect(parseFindings('adversarial-native', output, false).map(f => f.label)).toEqual(['Medium', 'Low']);
expect(parseFindings('adversarial-native', output, true).map(f => f.label)).toEqual(['CRITICAL', 'CRITICAL']);
expect(parseFindings('adversarial-native', output, true).map(f => f.label)).toEqual(['Medium', 'Low']);
});
test('a labeled or located paragraph after a numbered heading stays its own finding (#149 joins only plain body text)', () => {
for (const raises of [true, false]) {
@@ -74,9 +74,9 @@ describe('native FIXABLE by review class (GST-987)', () => {
};
expect(labels(['--manifest', manifest])).toEqual(['Medium', 'Low']);
expect(fs.readFileSync(ledger, 'utf8')).toContain('"fixable_raises":false');
expect(labels([])).toEqual(['CRITICAL', 'CRITICAL']);
expect(labels([])).toEqual(['Medium', 'Low']);
expect(fs.readFileSync(ledger, 'utf8')).not.toContain('fixable_raises');
expect(labels(['--manifest', foreign])).toEqual(['CRITICAL', 'CRITICAL']);
expect(labels(['--manifest', foreign])).toEqual(['Medium', 'Low']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('verify re-derives a code-class collect with the class it was collected under', () => {
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { promptHash } from '../lib/review-brief';
import { collect, judge, ledgerBlockers, parseFindings, readJsonl, recordJudgment, summaryCandidate, verify } from '../lib/review-ledger';
import { isBlocking, summarizeReviewLoop } from '../lib/review-scope';
const ROOT = path.resolve(import.meta.dir, '..');
const fixture = (name: string) => fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger', name), 'utf8');
const labels = (reader: string, output: string, raises: boolean) => parseFindings(reader, output, raises).map(f => f.label);
const HEAD = '**1. The family check refuses two models in one call (check.ts:165-169). FIXABLE.**';
describe('one record per native finding at its stated severity (GST-1031 case 1, ruling A)', () => {
// Value: protects=a native finding whose reader states Severity and Blocks: no collects once, at that severity, on every class, and a
// heading keeps its body (located or numbered bullets, a bold subheading, a located paragraph) until its fields arrive;
// fails_when=the heading collects CRITICAL beside a separate severity record, or the gate class raises a Blocks: no finding;
// why_new=GEVA-196 (gstack-evals #153) and GSTA-150 GST-1027 looped Steps 9-11 at about 12M tokens a hit; seam=parseFindings;
// no_claim=which class a diff belongs to (lib/review-depth.ts decides)
test.each([true, false])('the GEVA-196 shape collects Medium, Low, Low with raises=%p', (raises) => {
const found = parseFindings('adversarial-native', fixture('gst-1031-native.txt'), raises);
expect(found.map(f => f.label)).toEqual(['Medium', 'Low', 'Low']);
expect(found.map(f => f.raw.startsWith('**'))).toEqual([true, true, true]);
});
test.each([
['located bullets', [HEAD, 'The check runs always.', '', 'Now every judge stops:', '- the default judge clashes in policy.ts:12;', '- the other judges clash in call.ts:40.', '', 'Fix: gate it on judged.', 'Severity: Medium. Blocks: no.']],
['numbered bullets', [HEAD, 'The check runs always.', '', 'Now every judge stops:', '1. the default judge clashes;', '2. the other judges clash.', '', 'Severity: Medium. Blocks: no.']],
['a bold subheading', [HEAD, 'The check runs always.', '', '**Why it matters**', 'So the two models cannot share a call.', 'Severity: Medium. Blocks: no.']],
['a located paragraph', [HEAD, 'The check runs always.', '', 'The caller in call.ts:40 picks the judge by provider.', 'Severity: Medium. Blocks: no.']],
])('a heading keeps its body with %s: one record on both classes', (_shape, lines) => {
for (const raises of [true, false]) expect(labels('adversarial-native', (lines as string[]).join('\n'), raises)).toEqual(['Medium']);
});
test('negative controls: no Blocks: no, Blocks: yes, no Severity field, or a labeled body line keep the raise or their own record', () => {
for (const raises of [true, false]) {
expect(labels('adversarial-native', [HEAD, 'Body.', 'Severity: Medium.'].join('\n'), raises)).toEqual(['CRITICAL']);
expect(labels('adversarial-native', [HEAD, 'Body.', 'Severity: Medium. Blocks: yes.'].join('\n'), raises)).toEqual(['CRITICAL']);
expect(labels('adversarial-native', [HEAD, 'Body.', 'Blocks: no.'].join('\n'), raises)).toEqual(['CRITICAL']);
expect(labels('adversarial-native', [HEAD, '', '- High: the caller in call.ts:40 leaks the key.', '', 'Severity: Medium. Blocks: no.'].join('\n'), raises)).toEqual(['CRITICAL', 'High']);
}
expect(labels('adversarial-native', '## FIXABLE\n1. Leak in a.ts:3\n2. Race in b.ts:4. Blocks: no', true)).toEqual(['CRITICAL', 'CRITICAL']);
});
test('collect records the stated severity beside the one record', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031-'));
const c = collect(path.join(dir, 'l.jsonl'), 'adversarial-native', fixture('gst-1031-native.txt'), path.join(dir, 'm.jsonl'));
expect(c.lines.map(l => [l.label, l.stated])).toEqual([['Medium', { severity: 'Medium', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }], ['Low', { severity: 'Low', blocks: 'no' }]]);
fs.rmSync(dir, { recursive: true, force: true });
});
});
describe('a closing summary is a candidate lead judgment may dismiss on record (GST-1031 case 5)', () => {
// Value: protects=a sentence that names a severity with no file:line and no exception clause collects as a summary_candidate, which
// judge lets lead judgment lower with a reason (recorded as dismissed) so it stops counting as a blocker, while it still collects at
// its label; fails_when=GSTA-212's "nothing is CRITICAL" or GST-986 r2's "covers the CRITICAL fix" can only be cleared by a
// hand-edited row, a candidate lowered without a reason passes judge, or GST-766's exception-clause line becomes a candidate;
// why_new=both stopped /ship with NEXT: stop; seam=collect, judge, ledgerBlockers; no_claim=that a dismissal is right (it is on record)
const dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031s-'));
test('the negated and the plain summary sentence are candidates; dismissed with a reason they leave the blocker count', () => {
for (const [reader, output, id] of [['fix-check', 'None of these would fail a test or let a real consumer through on input the fix was about, so nothing is CRITICAL.\n', 'fix-check#1'], ['fix-check', fixture('gst-1031-fix-check.txt'), 'fix-check#4']]) {
const d = dir();
const ledger = path.join(d, 'l.jsonl');
const merged = path.join(d, 'm.jsonl');
fs.writeFileSync(path.join(d, 'out.txt'), output!);
const c = collect(ledger, reader!, output!, merged, path.join(d, 'out.txt'));
expect(c.lines.filter(l => l.summary_candidate).map(l => [l.id, l.label])).toEqual([[id, 'CRITICAL']]);
const write = (reason?: string) => { fs.writeFileSync(path.join(d, 'j.jsonl'), c.lines.map(l => JSON.stringify({ id: l.id, severity: 'INFORMATIONAL', ...(l.id === id && reason ? { reason } : {}) })).join('\n') + '\n'); return recordJudgment(ledger, c.lines, readJsonl(path.join(d, 'j.jsonl')), path.join(d, 'j.jsonl'), merged); };
expect(write()).toMatchObject({ ok: false, downgraded: [id], dismissed: [] });
expect(write('closing summary: it names no defect')).toMatchObject({ ok: true, downgraded: [], dismissed: [id] });
expect(ledgerBlockers(ledger)).toEqual([]);
expect(verify(ledger).rows.find(r => r.kind === 'JUDGE')!.status).toBe('OK');
fs.rmSync(d, { recursive: true, force: true });
}
});
test('coordinated denials are candidates', () => {
for (const line of ['No CRITICAL or HIGH findings remain.', 'Nothing CRITICAL or HIGH remains.', 'No CRITICAL/HIGH findings.', 'No CRITICAL, HIGH or MEDIUM issues.', 'Nothing at CRITICAL severity remains.', 'No CRITICAL — the fix is sound.'])
expect([line, summaryCandidate(line)]).toEqual([line, true]);
});
test('negative controls: a locus, an exception clause, a lead label, a field or a JSON finding is no candidate and keeps its label', () => {
for (const line of ['No CRITICAL issues except the token leak in log.ts:3.', 'Nothing else is CRITICAL, but the retry loop never ends.', 'The token leak in log.ts:3 is CRITICAL.',
'The leak in bin/gstack-log:3 is CRITICAL.', 'CRITICAL: the retry loop never ends.', 'The retry loop never ends. Severity: High', 'Not checked. Blocks: no. CRITICAL', '{"severity":"CRITICAL","summary":"x"}',
'The retry loop in the upload handler never ends, which is CRITICAL.', 'Path traversal in the download route lets any user read arbitrary files; this is CRITICAL.',
'The session token is written to the debug log on every request, a CRITICAL leak.', 'Nothing guards the write path the failure of which is CRITICAL',
'There is no CRITICAL lock around the refund write, so two workers double-pay.', 'The patch closes the CRITICAL window only on Linux; on macOS the race remains.']) {
expect([line, summaryCandidate(line)]).toEqual([line, false]);
expect(parseFindings('fix-check', line).length).toBe(1);
}
const lowered = judge([{ id: 'r#1', reader: 'r', label: 'CRITICAL', raw: 'No CRITICAL issues except the token leak in log.ts:3.', output_sha256: 'x' }], [{ id: 'r#1', severity: 'INFORMATIONAL', reason: 'summary' }]);
expect(lowered).toMatchObject({ ok: false, downgraded: ['r#1'] });
});
});
describe('the blocker count keys on Blocks (GST-1031 case 3)', () => {
// Value: protects=a CRITICAL its reader marked Blocks: no is not counted as a blocker by the ledger count, the row count or Step
// 9.4's classify, while Blocks: yes, an unstated Blocks and a lead promotion still block; fails_when=REVIEW_LOOP reports a
// Blocks: no note as found or unrecorded, or a Blocks: yes CRITICAL stops blocking; why_new=ggstack#52: a lane hand-added
// fixed rows to get a receipt; seam=ledgerBlockers, summarizeReviewLoop, isBlocking; no_claim=what the reader meant
const OUT = '1. Drops rows in q.ts:7. Severity: Critical. Blocks: no.\n\n2. Leaks a token in log.ts:3. Severity: Critical. Blocks: yes.\n\n3. Races in a.ts:9. Severity: Critical.\n\n4. Slows down in b.ts:2. Severity: Medium. Blocks: no.\n';
const run = (judged?: string) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031b-'));
const ledger = path.join(dir, 'l.jsonl');
const merged = path.join(dir, 'm.jsonl');
fs.writeFileSync(path.join(dir, 'out.txt'), OUT);
collect(ledger, 'outside-voice', OUT, merged, path.join(dir, 'out.txt'));
if (judged !== undefined) {
fs.writeFileSync(path.join(dir, 'j.jsonl'), judged);
fs.appendFileSync(ledger, JSON.stringify({ event: 'judge', ok: true, judged: path.join(dir, 'j.jsonl'), judged_sha256: promptHash(judged) }) + '\n');
}
const ids = ledgerBlockers(ledger).map(b => b.id);
fs.rmSync(dir, { recursive: true, force: true });
return ids;
};
test('ledgerBlockers skips a Blocks: no CRITICAL and keeps Blocks: yes, unstated and a lead promotion', () => {
expect(run()).toEqual(['outside-voice#2', 'outside-voice#3']);
const promote = ['outside-voice#1', 'outside-voice#2', 'outside-voice#3'].map(id => JSON.stringify({ id, severity: 'CRITICAL' })).join('\n') + '\n' + JSON.stringify({ id: 'outside-voice#4', severity: 'CRITICAL', reason: 'it loses rows' }) + '\n';
expect(run(promote)).toEqual(['outside-voice#2', 'outside-voice#3', 'outside-voice#4']);
});
test('a verb "blocks no" is no Blocks field: the located CRITICAL still counts, on the ledger and in a native finding', () => {
for (const out of ['CRITICAL: The lock in queue.ts:40 blocks no other writer, so two passes lose findings.\n', '1. **CRITICAL** — cache.ts:12 evicts on read; it blocks no request with a stale token.\n', '- CRITICAL src/auth.ts:9 token check is skipped; it blocks no attacker.\n']) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031v-'));
const c = collect(path.join(d, 'l.jsonl'), 'outside-voice', out, path.join(d, 'm.jsonl'));
expect([out, c.lines[0]!.stated?.blocks, ledgerBlockers(path.join(d, 'l.jsonl')).map(b => b.id)]).toEqual([out, undefined, ['outside-voice#1']]);
fs.rmSync(d, { recursive: true, force: true });
}
for (const raises of [true, false]) expect(labels('adversarial-native', '**1. Race in a.ts:3. FIXABLE.**\nIt blocks no attacker.\nSeverity: Low.', raises)).toEqual(['CRITICAL']);
const q = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031y-'));
const y = collect(path.join(q, 'l.jsonl'), 'outside-voice', 'CRITICAL: auth.ts:9 skips authentication. The previous note said `Blocks: no`. Blocks yes.\n', path.join(q, 'm.jsonl'));
expect([y.lines[0]!.stated?.blocks, ledgerBlockers(path.join(q, 'l.jsonl')).map(b => b.id)]).toEqual(['yes', ['outside-voice#1']]);
fs.rmSync(q, { recursive: true, force: true });
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031j-'));
collect(path.join(d, 'l.jsonl'), 'specialist', JSON.stringify({ severity: 'CRITICAL', path: 'lib/auth.ts', line: 9, summary: 'the old guard, which a reader marked `Blocks: no`, is gone so any caller gets admin' }) + '\n', path.join(d, 'm.jsonl'));
expect(ledgerBlockers(path.join(d, 'l.jsonl')).map(b => b.id)).toEqual(['specialist#1']);
fs.rmSync(d, { recursive: true, force: true });
});
test('classify and the row count key on Blocks, and a raw holding both Blocks: yes and no blocks', () => {
expect(isBlocking({ severity: 'CRITICAL', stated: { severity: 'Critical', blocks: 'no' } })).toBe(false);
expect(isBlocking({ severity: 'CRITICAL', stated: { severity: 'Critical', blocks: 'yes' } })).toBe(true);
expect(isBlocking({ severity: 'CRITICAL' })).toBe(true);
const row = (stated?: object) => ({ skill: 'review', via: 'ship', timestamp: '2026-11-02T00:00:00Z', findings: [{ path: 'q.ts', line: 7, severity: 'CRITICAL', ...(stated ? { stated } : {}) }] });
expect(summarizeReviewLoop([row({ blocks: 'no' })], '2026-11-01T00:00:00Z').blocking_found).toBe(0);
expect(summarizeReviewLoop([row()], '2026-11-01T00:00:00Z')).toMatchObject({ blocking_found: 1, unrecorded: 1 });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1031c-'));
const both = collect(path.join(dir, 'l.jsonl'), 'outside-voice', 'Leaks a token in log.ts:3. Severity: Critical. Blocks: yes. Retry is fine. Blocks: no.\n', path.join(dir, 'm.jsonl'));
expect(both.lines.map(l => l.stated?.blocks)).toEqual(['yes']);
fs.rmSync(dir, { recursive: true, force: true });
});
});
+3 -2
View File
@@ -21,7 +21,8 @@ describe('parseFindings is never weaker than main (GST-976 invariant)', () => {
// why_new=three GST-976 review rounds each found a shape the row tables missed; seam=parseFindings; no_claim=that main's label was
// right (it is the floor), or coverage of stored private reader outputs (checked outside the repo). GST-987: the gate class (and every
// risk class) keeps this rule; outside it the native voice's FIXABLE beside Blocks: no no longer counts as a raise, so such a fielded
// finding collects at its field unless a lead, a label heading or Blocks: yes raises it
// finding collects at its field unless a lead, a label heading or Blocks: yes raises it. GST-1031 ruling A: the gate and risk classes
// drop that raise too, so on every class FIXABLE raises only a finding that does not state Blocks: no
test.each([['gate', true], ['code', false]] as const)('every pinned input, reader fixture and generated field shape (%s class)', (_cls, raises) => {
const { entries, shapes } = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/review-ledger/main-labels.json'), 'utf8'));
const { bodies, fields: tails, joins, readers, generated } = shapes;
@@ -37,7 +38,7 @@ describe('parseFindings is never weaker than main (GST-976 invariant)', () => {
findings++;
const fields = [...f.raw.matchAll(FIELD)].map(m => (m[1] ?? m[2])!);
const single = fields.length === 1 && fields[0]!.match(LABEL)!.length === 1 ? fields[0]!.match(LABEL)![0] : undefined;
const raised = /Blocks:?\**:?\s*\**\s*yes/i.test(f.raw) || (e.reader.startsWith('adversarial-native') && /FIXABLE/.test(f.raw) && (raises || !/Blocks:?\**:?\s*\**\s*no\b/i.test(f.raw))) || (raises ? LEAD : LEAD_NO_FIXABLE).test(f.raw) || HEADING.test(output);
const raised = /Blocks:?\**:?\s*\**\s*yes/i.test(f.raw) || (e.reader.startsWith('adversarial-native') && /FIXABLE/.test(f.raw) && !/Blocks:?\**:?\s*\**\s*no\b/i.test(f.raw)) || (raises ? LEAD : LEAD_NO_FIXABLE).test(f.raw) || HEADING.test(output);
const ok = single === undefined ? (rank(f.label) ?? 0) >= (rank(e.main[i]) ?? 0)
: (rank(f.label) ?? 0) >= (rank(single) ?? 0) && (raised || (rank(f.label) ?? 0) === (rank(single) ?? 0));
if (!ok) bad.push(`${e.reader} main=${e.main[i]} got=${f.label} field=${single ?? '-'} ${JSON.stringify(f.raw.slice(0, 100))}`);
+108 -5
View File
@@ -144,18 +144,20 @@ function treeLine(dir: string): string {
return tree.status === 0 && top.status === 0 ? `REVIEWED_TREE: ${tree.stdout.trim()} ${top.stdout.trim()}\n` : '';
}
const briefDir = (prompt: string) => /Workspace: the checkout under review is (.+?)\. Your shell and file tools/.exec(prompt)?.[1] ?? '';
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; reportFrom?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean } = {}) {
function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) => string; reportFrom?: (title: string) => string; fail?: (title: string) => boolean; reject?: (title: string) => boolean; createThrows?: (title: string) => 'before' | 'after' | undefined; pollsToFinish?: number; lateSummary?: boolean; hang?: (title: string) => boolean; stopped?: (title: string) => boolean; keepSummary?: boolean; epoch?: number } = {}) {
let clock = 0;
const subs = new Map<string, Sub>();
const creates: Sub[] = [];
const starts: number[][] = [];
const stops: string[] = [];
const reply = (s: Sub) => treeLine(o.reportFrom?.(s.title) ?? briefDir(s.prompt)) + (o.output?.(s.title) ?? cleanReply(s.title));
const advance = () => {
for (const s of subs.values()) {
if (s.late && !s.summary) { s.summary = reply(s); continue; }
if (s.status !== 'working') continue;
if (s.status !== 'working' || o.hang?.(s.title)) continue;
if (++s.polls < (o.pollsToFinish ?? 1)) continue;
if (o.fail?.(s.title)) s.status = 'failed';
if (o.stopped?.(s.title)) { s.status = 'stopped'; if (o.keepSummary) s.summary = reply(s); }
else if (o.fail?.(s.title)) { s.status = 'failed'; if (o.keepSummary) s.summary = reply(s); }
else if (o.lateSummary && !s.late) { s.status = 'done'; s.late = true; }
else { s.status = 'done'; s.summary = reply(s); }
}
@@ -185,10 +187,11 @@ function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string)
start: async ({ indices, model }: { indices: number[]; model?: unknown }) => { starts.push(indices); for (const i of indices) { const s = subs.get(String(i))!; s.status = 'working'; if (model) s.model = model; } return { started: indices.map(index => ({ index })), rejected: [] }; },
list: async () => { advance(); return { tasks: [...subs].map(([p, s]) => ({ path: p, title: s.title, status: s.status })), total: subs.size }; },
get: async ({ path: p }: { path: string }) => ({ path: p, status: subs.get(p)!.status, summary: subs.get(p)!.summary }),
stop: async ({ paths }: { paths: string[] }) => { stops.push(...paths); for (const p of paths) subs.get(p)!.status = 'stopped'; return { results: paths.map(p => ({ path: p, ok: true })) }; },
},
thread: { get: async () => ({ cost: { tokens: 1000 + clock, credits: 1 + clock / 1000 } }) },
};
return { tools, subs, creates, starts, sleep: async (ms: number) => { clock += ms; }, now: async () => 1_000_000 + clock };
return { tools, subs, creates, starts, stops, sleep: async (ms: number) => { clock += ms; }, now: async () => (o.epoch ?? 1_000_000) + clock };
}
const PROGRAM = fs.readFileSync(path.join(ROOT, 'lib/capy-review-pass.js'), 'utf8');
@@ -362,7 +365,9 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(await runPass(h, { ...base, stage: 'voices', laneModel: undefined })).toContain("STOP: the outside voice needs input.models['outside-voice']");
expect(h.creates.some(c => c.title.startsWith('ship x7p1 ') && /adversarial|outside/.test(c.title))).toBe(false);
const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } };
expect(await runPass(h, { ...base, stage: 'voices', models })).toContain('NEXT: judge the voices');
const voices = await runPass(h, { ...base, stage: 'voices', models });
expect(voices).toContain('NEXT: judge the voices');
expect(voices).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"completed"');
expect(h.creates.find(c => c.title === 'ship x7p1 outside-voice')!.model).toEqual(models['outside-voice']);
expect(h.creates.find(c => c.title === 'ship x7p1 adversarial-native')!.model).toEqual(models['adversarial-native']);
}, 240_000);
@@ -489,6 +494,89 @@ describe('Capy pass program (fake host, real bins)', () => {
for (const t of Object.keys(named.models)) if (t !== 'ship x17p1 outside-voice') expect(over.models[t]).toEqual(named.models[t]);
}, 300_000);
// Value: protects=GST-1104 (TRUT-7, truther#1159): an outside voice that runs past the stuck limit, or ends stopped or failed with no result, is stopped and recorded as unavailable coverage under its own runner and model, and Step 11 goes on to judge the native voice; fails_when=the stuck subagent is left running (TRUT-7 p2 burned 16.9M tokens before anyone stopped it), the pass stops with nothing recorded, or the returned record names no host-subagent runner so the review-log row falls back to the template's codex;
// why_new=GST-1104; seam=lib/capy-review-pass.js reader poll; no_claim=which outside model Step 11 picks
test('an outside voice that hangs or stops with no result is stopped and recorded unavailable as the host subagent, and Step 11 goes on (GST-1104)', async () => {
for (const [pass, o] of [['x30p1', { hang: (t: string) => t.endsWith('outside-voice') }], ['x31p1', { stopped: (t: string) => t.endsWith('outside-voice') }], ['x32p1', { fail: (t: string) => t.endsWith('outside-voice') }]] as const) {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
const h = host(dir, env, { ...o, epoch: Date.now() });
const base = { base: 'main', out, pass, planFlags: ['--depth-floor', 'full'] };
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
let res = '';
for (let i = 0; i < 6; i++) {
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
if (!res.includes('NEXT: poll')) break;
}
expect(res).not.toContain('STOP:');
expect(res).toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
expect(res).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"unavailable"');
expect(res).toContain('first write this voice its own adversarial-review row with "source":"host-subagent","status":"unavailable","completed":false,"converged":false and no --finish, then the native row');
expect(res).toContain('NEXT: judge the voices');
if ('hang' in o) {
const token = /REVIEW_LOG_START: adversarial-review token=(\S+)/.exec(res)![1]!;
const record = /OUTSIDE_RECORD: (\S+)/.exec(res)![1]!;
const log = (row: string, a: string[] = []) => spawnSync(path.join(BIN, 'gstack-review-log'), [row, ...a], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 });
expect(log('{"skill":"adversarial-review","status":"unavailable","source":"host-subagent",' + record + ',"phase":"adversarial","completed":false,"converged":false}').status).toBe(0);
expect(log('{"skill":"adversarial-review","status":"clean","source":"in-host",' + record + ',"phase":"adversarial","completed":true,"converged":true}', ['--finish', token]).status).toBe(0);
const rows = spawnSync(path.join(BIN, 'gstack-review-read'), [], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).stdout.split('---CONFIG---')[0]!.trim().split('\n').map(l => JSON.parse(l));
expect(rows.map(r => [r.source, r.outside_provider, r.outside_status, r.converged])).toEqual([['host-subagent', 'host-subagent', 'unavailable', false], ['in-host', 'host-subagent', 'unavailable', true]]);
expect(rows[1].review_freshness).toMatchObject({ status: 'CURRENT' });
}
const ov = [...h.subs].find(([, s]) => s.title === 'ship ' + pass + ' outside-voice')!;
expect(ov[1].status).not.toBe('working');
expect(h.stops).toEqual('hang' in o ? [ov[0]] : []);
const v = bin(dir, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]);
expect(v.stdout).toMatch(/^BRIEF outside-voice UNAVAILABLE/m);
expect(v.stdout).not.toContain('MISMATCH');
expect(v.stdout).toContain('unavailable=1');
}
}, 600_000);
// Value: protects=GST-1104 review finding: an outside voice that wrote its report and then ended stopped or failed is collected from its summary, never replaced by unavailable coverage; fails_when=the pass records the voice unavailable without reading the summary, silently dropping its findings;
// why_new=GST-1104 /ship review (three readers); seam=lib/capy-review-pass.js reader poll; no_claim=what the voice found
test('an outside voice that ends stopped or failed with a report is collected, not marked unavailable (GST-1104)', async () => {
for (const [pass, o] of [['x33p1', { stopped: (t: string) => t.endsWith('outside-voice') }], ['x34p1', { fail: (t: string) => t.endsWith('outside-voice') }]] as const) {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
const h = host(dir, env, { ...o, keepSummary: true, output: (t: string) => (t.endsWith('outside-voice') ? '1. **A guard fails open** - `src/a.ts:1` accepts any input. Severity: Medium. Blocks: no.\n\nRecommendation: fix the guard because it fails open' : cleanReply(t)), epoch: Date.now() });
const base = { base: 'main', out, pass, planFlags: ['--depth-floor', 'full'] };
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
let res = '';
for (let i = 0; i < 6; i++) {
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
if (!res.includes('NEXT: poll')) break;
}
expect(res).not.toContain('STOP:');
expect(res).not.toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
expect(res).toContain('OUTSIDE_RECORD: "outside_provider":"host-subagent","outside_model":"openai/gpt-6.1-sol","outside_status":"completed"');
expect(res).toContain('NEXT: judge the voices');
}
}, 600_000);
// Value: protects=GST-1104 review finding: an outside voice that ends stopped or failed with a summary that is not a review is recorded unavailable and Step 11 goes on; fails_when=collect's COLLECT_NOT_REVIEWED stops the pass and every rerun stops the same way;
// why_new=GST-1104 /ship review round 2; seam=lib/capy-review-pass.js reader collect; no_claim=what the summary says
test('an outside voice that ends stopped with a non-review summary is recorded unavailable, not a stuck pass (GST-1104)', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
const h = host(dir, env, { stopped: (t: string) => t.endsWith('outside-voice'), keepSummary: true, output: (t: string) => (t.endsWith('outside-voice') ? 'Stopped before I could read the diff.' : cleanReply(t)), epoch: Date.now() });
const base = { base: 'main', out, pass: 'x35p1', planFlags: ['--depth-floor', 'full'] };
expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)');
let res = '';
for (let i = 0; i < 6; i++) {
res = await runPass(h, { ...base, stage: 'voices', stuckMs: 60_000 });
if (!res.includes('NEXT: poll')) break;
}
expect(res).not.toContain('STOP:');
expect(res).toContain('COLLECT_UNAVAILABLE: reader=outside-voice');
expect(res).toContain('"outside_status":"unavailable"');
expect(res).toContain('NEXT: judge the voices');
const v = bin(dir, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]);
expect(v.stdout).toMatch(/^BRIEF outside-voice UNAVAILABLE/m);
expect(v.stdout).not.toContain('MISMATCH');
expect(v.stdout).not.toContain('NOT_REVIEWED');
}, 300_000);
test('an audit reader marked first settles before any specialist starts; its output is returned, never merged', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
@@ -706,6 +794,21 @@ describe('Capy pass program (fake host, real bins)', () => {
expect(v).toMatch(/^OUTPUT fix-check MATCH /m);
}, 240_000);
// Value: protects=decision 3.5's fix-check takes a review-fix-check start token even when the parent passes the Capy skill's
// logStart ["review"], so its row is never a pass and a review_loop_exception can follow it; fails_when=the fix-check pass logs a
// review start; why_new=ggstack#52 (GST-1031): a scoped fix-check logged as review counted as a second pass; seam=the pass program;
// no_claim=what the parent writes in the row
test('a fix-check pass given logStart ["review"] takes the review-fix-check start', async () => {
const { dir, env } = repo();
const files = path.join(dir, '.state/fc');
fs.mkdirSync(files, { recursive: true });
const fc: Record<string, string> = {};
for (const [k, v] of [['fix_list', '1. src/a.ts: guard v80\n'], ['hunks', 'diff --git a/src/a.ts b/src/a.ts\n+export const v81 = 81;\n'], ['tests', 'bun test: 3 pass, 0 fail\n']]) { fc[k] = path.join(files, k + '.txt'); fs.writeFileSync(fc[k]!, v!); }
const res = await runPass(host(dir, env), { base: 'main', out: path.join(dir, '.state/run/fcr'), pass: 'x52fc', planFlags: ['--pass', '1', '--last', 'full', '--fixed-below'], fixCheck: fc, logStart: ['review'] });
expect(res).toMatch(/REVIEW_LOG_START: review-fix-check token=\S+/);
expect(res).not.toMatch(/REVIEW_LOG_START: review token=/);
}, 240_000);
test('Step 11 records one start per row it logs, even when two rows share a skill (native and outside adversarial-review)', async () => {
const { dir, env } = repo();
const out = path.join(dir, '.state/run/p1');
+2 -2
View File
@@ -107,8 +107,8 @@ exec ${quote(Bun.which('cat')!)} "$@"
const findings = (id: string) => `${id}: current findings\nNo issues found.\nRecommendation: fix ${id} because this is the current finding.\n`;
// INV-1: the verdict-form validator prints its verdict before the completed status.
const unavailable = (id: string) => `${findings(id)}VERDICT: unavailable\nFINDINGS: none\nREASON: execution_failed\n`;
const completed = (id: string) => `${findings(id)}VERDICT: clean\nFINDINGS: none\nOUTSIDE_STATUS: completed provider=codex host=claude\n`;
const unavailable = (id: string) => `${findings(id)}VERDICT: unavailable\nFINDINGS: none\nOUTSIDE_USAGE: unknown\nREASON: execution_failed\n`;
const completed = (id: string) => `${findings(id)}VERDICT: clean\nFINDINGS: none\nOUTSIDE_USAGE: unknown\nOUTSIDE_STATUS: completed provider=codex host=claude\n`;
test('fresh shells retain the current stderr and clean its exact temporary file', () => {
const f = fixture();
+194
View File
@@ -0,0 +1,194 @@
/**
* CI-carried lanes end to end (GSTA-258 pieces 1, 2 and 4; design fixes A1-A4): gstack-ship-preflight, gstack-ship-release
* finish, push --early and accept, gstack-pregate's automatic --from-run and gstack-ci-carry, against a fixture repository
* whose gh is a fake serving one Actions world, so nothing reaches GitHub.
*/
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 { findFilesBySuffix } from './helpers/scratch-repo';
import { lineOf, PREFLIGHT, 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 x >> ../free-runs; printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
const UNIT = "printf x >> ../unit-runs; printf ' 2 pass\\n 0 fail\\nRan 2 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 BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } } }, 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',
};
const fx = () => { const f = shipFixture(BASE); fixtures.push(f); return f; };
const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').length : 0; };
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
/** One run of free.yml at HEAD (id 7 unless named), green with a tree.json naming HEAD's tree, unless overridden. */
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }> } = {}): void {
const head = f.git('rev-parse', 'HEAD').stdout.trim();
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
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' }, ...over.run };
const w: FakeWorld = {
runs: [run, ...((over.others ?? []).map(o => ({ ...run, ...o })))] as FakeWorld['runs'],
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }] },
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
};
const file = path.join(f.root, 'world.json');
fs.writeFileSync(file, JSON.stringify(w));
f.gh(fakeGhScript(file));
}
const preflight = (f: ShipFx, lanes = [`free=${FREE}`, `unit=${UNIT}`]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', ...lanes.flatMap(l => ['--lane', l])]);
const lane = (out: string, label: string) => out.split('\n').find(l => l.startsWith(`LANE: ${label} `)) ?? '';
describe('Step 5 lanes: CI carry and the tree cache', () => {
// Value: protects=a lane the base maps is carried from CI at this exact tree and never runs; any other lane runs once and is reused at the same tree;
// fails_when=the free suite reruns locally beside a green CI run, or a passed lane reruns at an unchanged tree; why_new=GSTA-258 pieces 1-2; seam=fake gh, gstack-evidence ledger
test('the mapped lane is carried from the green run; the other lane runs once, then is reused', () => {
const f = fx();
world(f);
const first = preflight(f);
expect(first.status).toBe(0);
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
expect(lane(first.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+$/);
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
const again = preflight(f);
expect(lane(again.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+ reused=\S+$/);
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
const chk = f.run(path.join(BIN, 'gstack-evidence'), ['check', '--label', 'free', '--expect-cmd', FREE]);
expect(chk.stdout).toContain('source=ci runs=7/1');
});
// Value: protects=A2/A3 and verifyRun's refusals: a dirty tree, an uncovered inventory, a fork run and a newer red run never carry, and a label alone never maps;
// fails_when=any of them carries; seam=lib/ci-carry.ts findCarry through runLanes
test('a dirty tree, an uncovered inventory, a fork run, a newer red run or a changed command runs the lane here', () => {
const f = fx();
const cases: Array<[string, () => void, string[]?]> = [
['dirty_tree', () => { world(f); f.write({ 'src/b.ts': 'export const b = 3;\n' }); }],
['inventory_uncovered', () => { f.git('checkout', '--', 'src/b.ts'); world(f, { tree: { files: ['test/a.test.ts'] } }); }],
['fork_run', () => world(f, { run: { head_repository: { full_name: 'evil/fx' } } })],
['newer_run_vetoes', () => world(f, { others: [{ id: 9, conclusion: 'failure', created_at: at(2), run_started_at: at(2) }] })],
];
let expected = 0;
for (const [reason, setup] of cases) {
setup();
const out = preflight(f, [`free=${FREE}`]).stdout;
expect([reason, out]).toEqual([reason, expect.stringContaining('CI_CARRY: none label=free (')]);
expect(out).toContain(reason);
expect(lane(out, 'free')).toMatch(/^LANE: free pass exit=0 tests_ran=3 /);
expected += lane(out, 'free').includes('reused=') ? 0 : 1;
expect(runs(f, 'free-runs')).toBe(expected);
}
world(f);
const other = preflight(f, [`free=${FREE} `]).stdout;
expect(other).not.toContain('CI_CARRY');
expect(lane(other, 'free')).not.toContain('carried');
});
// Value: protects=a CI-sourced ledger row exists only for a run gstack-evidence record-ci found and verified itself (/ship pass 1 red-team#1);
// fails_when=caller-supplied run ids, test counts or a tree make a FRESH carried row, or an unmapped command or a missing run records one; seam=fake gh
test('record-ci verifies the run itself: forged facts, an unmapped command or no run at HEAD record nothing', () => {
const f = fx();
const ev = (args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
const forged = ev(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), '--tests', '999', '--finished', at(1), '--', FREE]);
expect(forged.status).toBe(2);
world(f, { run: { head_sha: '0'.repeat(40) } });
const noRun = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([noRun.status, noRun.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: no_run: /m)]);
world(f);
const unmapped = ev(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
expect([unmapped.status, unmapped.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1 policy=origin/main@')]);
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/);
});
});
describe('finish, push --early and accept (A1)', () => {
const finishArgs = (f: ShipFx, reviewed: string, post: string) => ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', `free=${FREE}`];
const snapshot = (f: ShipFx) => {
const out = path.join(f.root, 'post.json');
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
return out;
};
const unattended = { GSTACK_SESSION_KIND: 'unattended' };
// Value: protects=finish never runs a mapped lane locally on the stamped tree: it defers it to accept and runs only pregate's preflight stage; accept carries
// it from the green run at the pushed head and reruns the pregate there; fails_when=the stamped tree reruns the suite, or accept trusts a run at another tree;
// why_new=design fix A1; seam=fake gh, gstack-evidence, gstack-pregate
test('finish defers the mapped lane; accept carries it from the green run at the pushed head', () => {
const f = fx();
world(f);
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
const post = snapshot(f);
const pregateFile = path.join(f.work, '.gstack/tmp/pregate.json');
const before = fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null;
const fin = f.run(RELEASE, finishArgs(f, reviewed, post));
expect(fin.stdout).toMatch(/^EVIDENCE: DEFERRED label=free \(/m);
expect(lineOf(fin.stdout, 'PREGATE')).toMatch(/^PREGATE: preflight (pass|warn) on the stamped tree/);
expect(fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null).toBe(before);
expect(fin.stdout).toContain('gstack-ship-release accept --base main --lane');
expect(fin.status).toBe(0);
expect(runs(f, 'free-runs')).toBe(0);
const notPushed = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(notPushed.stdout).toContain('is not origin/feat: run gstack-ship-release push first');
const push = f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], unattended);
expect(lineOf(push.stdout, 'SECRETS')).toMatch(/^SECRETS: (pass|warn) \(early push\)$/);
expect(lineOf(push.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
world(f, { run: { status: 'in_progress', conclusion: null } });
const running = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(running.status).toBe(3);
expect(running.stdout).toContain('CI_WAIT: running ci/free run=7/1');
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
const red = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(red.stdout).toContain('STOP: free.yml run 7 failed (suite)');
world(f);
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
expect(ok.stdout).toContain('CI_WAIT: green ci/free run=7/1');
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=12 .* source=ci runs=7\/1$/);
expect(ok.stdout).toContain('CI_CARRY: accepted ci-carry tree=');
expect(ok.stdout).toContain('NEXT: Step 18');
expect(ok.status).toBe(0);
expect(runs(f, 'free-runs')).toBe(0);
const carried = JSON.parse(f.run(path.join(BIN, 'gstack-evidence'), ['carried', '--json']).stdout);
expect(carried).toEqual([{ lane: 'free', run: 7, attempt: 1, tree: f.git('rev-parse', 'HEAD^{tree}').stdout.trim() }]);
});
});
describe('gstack-pregate tests stage and gstack-ci-carry', () => {
// Value: protects=the tests stage carries CI at HEAD's clean tree on its own, and --no-ci-carry or a dirty tree turns it off; fails_when=pregate reruns what CI ran, or carries a dirty tree;
// why_new=GSTA-258 piece 1; seam=fake gh; no_claim=a small machine's remote gate obligation (covered by from-run's tests)
test('the tests stage accepts the run on its own, and --no-ci-carry or a dirty tree does not', () => {
const f = fx();
world(f);
const pregate = (extra: string[] = []) => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main', ...extra]);
expect(pregate().stderr).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} tests=12 runs=7\/1 profiles=ci\/free /m);
expect(pregate(['--no-ci-carry']).stderr).not.toContain('CI_CARRY');
f.write({ 'scratch.txt': 'x\n' });
expect(pregate().stderr).toContain('CI_CARRY: none (dirty_tree');
});
// Value: protects=the CLI finds and waits on the same evidence; fails_when=find or wait accepts a red run or prints no verdict
test('gstack-ci-carry find and wait', () => {
const f = fx();
world(f);
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
const found = cli(['find', '--base', 'main']);
expect([found.status, found.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=/)]);
const mapped = cli(['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']);
expect(mapped.status).toBe(0);
expect(cli(['find', '--base', 'main', '--command', 'bun run test', '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
expect(cli(['wait', '--base', 'main', '--timeout', '0']).stdout).toContain('CI_WAIT: green ci/free run=7/1');
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
const red = cli(['wait', '--base', 'main', '--timeout', '0']);
expect([red.status, red.stdout.trim()]).toEqual([1, 'CI_WAIT: red ci/free run=7/1 failing=suite']);
expect(cli(['bogus']).status).toBe(2);
});
});
+23
View File
@@ -308,6 +308,29 @@ describe('gstack-ship-release push: branch names', () => {
});
});
describe('GST-1031: a non-behavioral harness re-pin after review is release metadata', () => {
// Value: protects=a scripts/harness-version.json edit after review that only records changed harness files as non-behavioral at the same
// version routes as metadata, so a clean round 2 is not sent back through 5-11.5, while a version bump, a pattern change or an entry
// without its reason stays behavior; fails_when=the re-pin stops finish, or a bump or bare re-pin routes as metadata;
// why_new=GSTA-150 GST-1027 (icm_01M6ERWWBZ3VWXZY07F2W5ZSFB); seam=route; no_claim=that the recorded reason is true
const pin = (version: number, entry: Record<string, unknown>, patterns = ['test/helpers/x.ts']) => JSON.stringify({ version, patterns, files: { 'test/helpers/x.ts': entry } }) + '\n';
const finish = (after: string) => {
const f = fx();
f.write({ 'scripts/harness-version.json': pin(6, { blob: 'a'.repeat(40), since: 6 }) }); f.commit('pin');
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'scripts/harness-version.json': after }); f.commit('re-pin');
return f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE]).stdout;
};
test('a non-behavioral re-pin is metadata; a bump, a pattern change or an entry without its reason is behavior', () => {
const repin = finish(pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }));
expect(lineOf(repin, 'ROUTE')).toMatch(/behavior=0 docs=0 metadata=1/);
expect(repin).not.toContain('STOP: behavior');
for (const after of [pin(7, { blob: 'b'.repeat(40), since: 7 }), pin(6, { blob: 'b'.repeat(40), since: 6 }), pin(6, { blob: 'b'.repeat(40), since: 6, nonBehavioral: 'comment-only edit: no verdict can change' }, ['test/helpers/**'])])
expect(finish(after)).toContain('STOP: behavior, tests or build inputs changed since review (scripts/harness-version.json)');
});
});
describe('GSTA-167 part 4a: the post-review route stays path-only', () => {
// Value: protects=F1/F2, a data file edited after review sends the ship back through review even when no code names it,
// because the route never applies the data rule; fails_when=a JSON edit after review takes the docs-rechecked route; seam=classifyPath
+18
View File
@@ -248,6 +248,24 @@ describe('a restamp after a lost slot (GST-895, GBRA-218, gbrain #8134)', () =>
const e = finish(edited.f, edited.reviewed, edited.post, ['--reviewed-base', edited.reviewedBase]);
expect([e.status, lineOf(e.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
// Value: protects=with a kept VERSION claim, the restamp's move of the branch's own fork-version row routes as metadata,
// and a base row moved the same way still stops (GST-980); fails_when=stampedFiles returns nothing at equal versions
// and finish routes the row as behavior; seam=lib/ship-bin-common.ts stampedFiles
test('a kept VERSION claim: the moved fork-version row is metadata; a moved base row stops (GST-980)', () => {
const kept = (rows: [string, string][]) => {
const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base']) },
{ VERSION: '1.0.1.0\n', 'src/notice.ts': notice(['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.0.0', 'mine']) });
const reviewed = wtree(f);
const post = snapshot(f);
f.write({ 'src/notice.ts': notice(...rows) }); f.commit('stamp, VERSION kept');
return finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]);
};
const own = kept([['0.9.0.0', 'old'], ['1.0.0.0', 'base'], ['1.0.1.0', 'mine']]);
expect(lineOf(own.stdout, 'ROUTE')).toEqual(expect.stringMatching(/ behavior=0 docs=0 metadata=1 tests=0 /));
const base = kept([['0.9.0.0', 'old'], ['1.0.1.0', 'base'], ['1.0.0.0', 'mine']]);
expect([base.status, lineOf(base.stdout, 'STOP')]).toEqual([3, behaviorStop]);
});
});
describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => {
@@ -0,0 +1,4 @@
{
"bytes": 1463,
"why": "ship: CI-carry, finish/accept split and early push (GSTA-258 piece A)"
}
+16
View File
@@ -105,6 +105,22 @@ describe('tree receipt: stamps, stamp-sensitive gates and negative fixtures', ()
expect(width.out).toMatch(/^TREE: changed \(lib\/policy\.ts\)$/m);
});
// Value: protects=the fork-version bound reads the branch's own line by its position, so a base copy of the same text
// is still floored at the old version, and when the slot keeps its VERSION only the branch's own lines may move
// (GST-979); fails_when=stampOnlyLine matches the branch's lines by text alone or takes the old-version floor at equal
// versions; seam=lib/stamp-only.ts stampOnlyLine
test('the fork-version bound holds only at the branch line\'s own position, and a kept VERSION moves only the branch\'s own lines (GST-979)', () => {
const tpl = ["since: '{version}'"];
const row = " { since: '1.2.3.0', id: 'x' },";
const moved = row.replace('1.2.3.0', '1.2.5.0');
const side = { fork: '1.2.3.0', added: new Set([row]), at: new Set([10]) };
expect(stampOnlyLine(row, moved, '1.2.4.0', '1.2.5.0', tpl, side, 10)).toBe(true);
expect(stampOnlyLine(row, moved, '1.2.4.0', '1.2.5.0', tpl, side, 2)).toBe(false);
const mine = { fork: '1.2.3.0', added: new Set(["mine since: '1.2.3.0'"]), at: new Set([5]) };
expect(stampOnlyLine("base since: '1.3.0.0'", "base since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 1)).toBe(false);
expect(stampOnlyLine("mine since: '1.2.3.0'", "mine since: '1.2.4.0'", '1.2.4.0', '1.2.4.0', tpl, mine, 5)).toBe(true);
});
test('negative fixture: a dependency pin equal to the old version is `changed`, not a stamp, even inside stamp_paths', () => {
const repo = fixture();
const g = gated(repo);