diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d642bc7a..7e0440e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,15 @@ ## [Unreleased] -**A remote gate that ran ahead of the stamp now clears /ship's small-machine obligation; gbrain stops paying a second full gate per PR.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt ` accepts a `PREGATE_REMOTE:` receipt whose tree differs from pregate.json's tree only in the repo's declared stamp paths. gstack proves it itself: a `git diff` of the two trees, every differing file classified by the tree receipt's own rule (`lib/tree-receipt.ts` `classifyFile` under `.gstack/ship-policy.json` on the base branch: `stamp_files`, `stamp_patterns`, and the built-in version-file, manifest-version, lockfile-root-version and changelog-heading forms), so the pregate verdict and `gstack-tree-receipt`'s can never disagree; there is no second stamp list. gbrain's rule lets a head reuse a full gate whose tree differs only in release stamps, but /ship's Step 12 restamp always changed those files, so no gate-ahead run could satisfy `remote_gate` and every gbrain PR ran the whole gate again after /ship (#7621, #7674, #7681: 17–37 min, 64–128 vCPU each). +**A remote gate that ran ahead of the stamp now clears /ship's small-machine obligation; gbrain stops paying a second full gate per PR.** `gstack-pregate clear --remote pregate.json/remote_gate --receipt ` accepts a `PREGATE_REMOTE:` receipt whose tree differs from pregate.json's tree only in the repo's declared stamp paths. gstack proves it itself: a `git diff` of the two trees, every differing file classified by the tree receipt's own rule (`lib/tree-receipt.ts` `classifyFile` under `.gstack/ship-policy.json` on the base branch: `stamp_files`, `stamp_patterns`, and the built-in version-file, manifest-version, lockfile-root-version and changelog-heading forms), so the pregate verdict and `gstack-tree-receipt`'s can never disagree; there is no second stamp list, and its gate rule holds too: a policy whose `stamp_sensitive_gates` names the test lane (`tests`, as gstack's own policy does) refuses the substitution, because the tree receipt never marks that lane reusable. The policy is the ship's own base branch's (`origin/HEAD`); a pregate.json recorded against another base is refused. gbrain's rule lets a head reuse a full gate whose tree differs only in release stamps, but /ship's Step 12 restamp always changed those files, so no gate-ahead run could satisfy `remote_gate` and every gbrain PR ran the whole gate again after /ship (#7621, #7674, #7681: 17–37 min, 64–128 vCPU each). ### Added -- **`lib/pregate/remote-receipt.ts`** (`substituteStampOnlyTree`): both trees must be objects here (the gated commit fetched, the head tree `bin/gstack-wtree` wrote); a path outside the rule, a stamp path whose hunk is not the exact old → new rewrite, an unfetched gated tree, an unreadable base policy or equal versions refuse with `PREGATE_RECEIPT_INVALID` and a fix naming the offending path (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`). The tests, failed and exit checks on the receipt are unchanged. +- **`lib/pregate/remote-receipt.ts`** (`substituteStampOnlyTree`): both trees must be objects here (the gated commit fetched, the head tree `bin/gstack-wtree` wrote); a path outside the rule, a stamp path whose hunk is not the exact old → new rewrite, an unfetched or non-hex gated tree, an unreadable base policy or equal versions refuse with `PREGATE_RECEIPT_INVALID` and a fix naming the offending path (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`). The tests, failed and exit checks on the receipt are unchanged. - **The substitution is recorded.** The receipt string gains `accepted-for= stamp-only=` after `tests=`, and pregate.json's obligation carries `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`; `clear` prints `PREGATE_RECEIPT_SUBSTITUTED: gated tree … accepted for tree … under ; stamp-only: (), …`, `verify` repeats it on its ok line, and a substitution whose `accepted_for` is not pregate.json's tree is `PREGATE_REMOTE_UNCLEARED`. - **Docs:** [docs/pregate.md#remote-gate-stamp-only](docs/pregate.md#remote-gate-stamp-only) (what is accepted, what is refused, how a consuming repo declares its stamp paths: `.gstack/ship-policy.json` on the base branch, the file the tree receipt already reads, and the four-command sequence that reuses a gate-ahead run); the `PREGATE_RECEIPT_INVALID` and `PREGATE_REMOTE_UNCLEARED` troubleshooting anchors. ### Changed -- `lib/tree-receipt.ts` exports `classifyFile` (the function is unchanged) so pregate reuses the rule instead of restating it; `PregateRemote` in `lib/headless-artifacts.ts` gains the optional `substitution` field and its schema entry. +- `lib/tree-receipt.ts` exports `classifyFile` (the function is unchanged) so pregate reuses the rule instead of restating it; `PregateRemote` in `lib/headless-artifacts.ts` gains the optional `substitution` field (`PregateReceiptSubstitution`) and its schema entry. Both trees must be 40- or 64-hex object ids before any git call (a symbolic `HEAD^{tree}` in a receipt is refused, never recorded as the gated tree), and `clearDecision` now takes the repository root. ### Tests - `test/pregate-remote-stamp-only.test.ts`: accept (five stamp files under a `stamp_files` + `stamp_patterns` policy, the recorded substitution equal to `gstack-tree-receipt`'s file verdicts on the same pair, `verify` ok), the unchanged same-tree path, refusals (a path outside the rule, a stamp path with a description edit, an unfetched gated tree, an unreadable receipt file, zero tests, a failure), `verify` on a misapplied substitution, and `clearDecision` without a repository root. diff --git a/docs/pregate.md b/docs/pregate.md index fbbd80448..79c8b4260 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -376,10 +376,18 @@ receipt for H when the two trees differ only as a stamp does: (`(was X)`, `since: X`, `vX`), and the version file, `package.json`'s `version`, a lockfile's root version keys and a CHANGELOG re-heading have their own built-in rules. There is no second list and no looser rule here: - the verdict `clear` reaches on a pair is the verdict `gstack-tree-receipt - --gated G --current H` reaches. + the file verdicts `clear` reaches on a pair are the verdicts + `gstack-tree-receipt --gated G --current H` reaches, and its gate rule holds + too: a policy whose `stamp_sensitive_gates` names the test lane (`tests`, as + gstack's own does because its tests read VERSION and the agents digest) is + refused outright, since the tree receipt never marks such a lane reusable. + The policy is the one on this repository's base branch (`origin/HEAD`, as + `regate` reads it) at its current tip, never a branch pregate.json names: a + pregate.json recorded against another base is refused. What the receipt does + not compare, exactly as a same-tree receipt does not, is the gate's execution + identity (Bun, OS, lockfiles); that stays the evidence bundle's job. - Accepted, the obligation's receipt reads `remote-gate tree= - tests= accepted-for= stamp-only= receipt= + tests= accepted-for= stamp-only= receipt= sha256=`, and pregate.json records the substitution beside it: `substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`, the policy label (`origin/@:.gstack/ship-policy.json`) and every @@ -393,9 +401,14 @@ receipt for H when the two trees differ only as a stamp does: path: any differing path outside the rule (`lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)`), a stamp path whose hunk is not the stamp rewrite (`plugin.json (stamp_files: "…description…" -> "…", line 4)`), - a gated tree that is not an object here (fetch the gated commit first), a - base branch whose policy cannot be read, or two trees at the same version - (a `stamp_files` hunk needs an old → new version to rewrite). The obligation stays uncleared; rerun + a gated tree that is not a 40- or 64-hex object id or not an object here + (fetch the gated commit first), or a + base branch whose policy cannot be read. The version rule is the tree + receipt's: a `stamp_files`, `stamp_paths` or `release_outputs` hunk is a stamp + only as the rewrite from the gated tree's version to the head's (so two trees + at the same version refuse such a hunk as `other`), while the built-in + version-file, manifest, lockfile and CHANGELOG rules compare content and do + not check the direction of the version move. The obligation stays uncleared; rerun the gate on the head, or make the extra change a stamp under the repo's rule. @@ -406,6 +419,12 @@ file the tree receipt and restamp already read; nothing is declared in [gbrain worked configs](#gbrain-worked-configs). The sequence that reuses a gate-ahead run, on a machine under 8 cores: +The gate must have run on a clean, committed checkout: `PREGATE_TREE` is the +`bin/gstack-wtree` fingerprint, which equals the commit's tree only when nothing +was uncommitted or untracked there, and `clear` can compare only trees that are +objects in this repository. A receipt whose tree is not here is refused, never +guessed. + ```bash git fetch origin # the gated commit's tree must be here gstack-pregate --allow-repo-commands # on the stamped head: records pregate.json/remote_gate uncleared diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index 2b135254d..20f5c8e16 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -365,9 +365,12 @@ export function parseRemoteReport(output: string): RemoteReport | null { return m ? { tree: m[1]!, tests: Number(m[2]), failed: Number(m[3]), spent: m[4] } : null; } +/** The tree-mismatch clause `--remote`, `clear --receipt` and the stamp-only substitution (lib/pregate/remote-receipt.ts) all open with. */ +export const treeMismatch = (tested: string, tree: string) => `the remote gate tested tree ${tested.slice(0, 12)}, not ${tree.slice(0, 12)}`; + /** Why a remote gate report does not clear the obligation for `tree` (`--remote` and `clear --receipt`), or null when it does. */ export function remoteReportProblem(report: RemoteReport, tree: string): string | null { - if (report.tree !== tree) return `the remote gate tested tree ${report.tree.slice(0, 12)}, not ${tree.slice(0, 12)}`; + if (report.tree !== tree) return treeMismatch(report.tree, tree); if (report.tests === 0) return 'the remote gate reported 0 tests'; if (report.failed > 0) return `the remote gate reported ${report.failed} failed of ${report.tests}`; return null; diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index c35347e1f..9c3dbc569 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -13,10 +13,10 @@ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateRemote, type ValidationError } from '../headless-artifacts'; +import { ARTIFACT_SCHEMAS, checkSchema, type PregateFile, type PregateReceiptSubstitution, type PregateRemote, type ValidationError } from '../headless-artifacts'; import { wtreeFingerprint, type WtreeResult } from '../script-invocation'; import { guardsCheck } from './checks/guards'; -import { clearHint, lanesCheck, parseRemoteReport, REMOTE_GATE_LANE, remoteReportProblem } from './checks/lanes'; +import { clearHint, 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'; @@ -24,7 +24,7 @@ import { straysCheck } from './checks/strays'; import { timingCheck } from './checks/timing'; import { NON_DOWNGRADABLE, userPolicyAbsent } from './config'; import { sha256, withFingerprint } from './context'; -import { baseBranchOf, describeSubstitution, substituteStampOnlyTree, type StampOnlySubstitution } from './remote-receipt'; +import { baseBranchOf, describeSubstitution, substituteStampOnlyTree } from './remote-receipt'; import { resolveBaseBranch } from '../ship-policy'; import type { CheckDef, CheckResult, PregateContext, Stage } from './types'; @@ -169,21 +169,21 @@ function userPolicyChange(file: PregateFile): string | null { } export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string } -export type ClearDecision = { receipt: string; attested: boolean; substitution?: StampOnlySubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; +export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string }; /** * What `clear` may record for one obligation. A `pregate.json/remote_gate` * obligation needs the gate's own report (`--receipt ` holding its * PREGATE_REMOTE: line), validated as `--remote` validates it against the - * tested tree; a run URL is not checkable there. With `repoRoot`, a report - * whose tree is a stamp-only predecessor of pregate.json's tree (the gate ran + * tested tree; a run URL is not checkable there. A report whose tree is a + * stamp-only predecessor of pregate.json's tree in `repoRoot` (the gate ran * ahead of the stamp; lib/pregate/remote-receipt.ts) is accepted for this - * tree and the substitution recorded; without it, or when any differing path - * is not a stamp, the tree mismatch refuses as before. A `test_backend/` - * obligation has no machine-checkable receipt, so its run URL is recorded as - * attested. Platform lanes take a run URL or an evidence-bundle lane label. + * tree and the substitution recorded; when any differing path is not a stamp, + * the tree mismatch refuses as before. A `test_backend/` obligation + * has no machine-checkable receipt, so its run URL is recorded as attested. + * Platform lanes take a run URL or an evidence-bundle lane label. */ -export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot?: string): ClearDecision { +export function clearDecision(file: PregateFile, lane: string, input: ClearInput, repoRoot: string): ClearDecision { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) return { refused: 'usage', message: `no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}` }; @@ -194,9 +194,13 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput catch (e: any) { return { refused: 'PREGATE_RECEIPT_INVALID', message: `cannot read ${input.receiptPath}: ${e.message}` }; } const report = parseRemoteReport(text); if (!report) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${input.receiptPath} has no PREGATE_REMOTE: line; ${lane} stays uncleared` }; - let substitution: StampOnlySubstitution | undefined; - if (report.tree !== file.tree && repoRoot) { - const s = substituteStampOnlyTree(repoRoot, baseBranchOf(file.base) ?? resolveBaseBranch(repoRoot), report.tree, file.tree); + let substitution: PregateReceiptSubstitution | undefined; + if (report.tree !== file.tree) { + // The stamp rule is the ship's own base branch's policy (origin/HEAD, as regate reads it), never a branch the artifact names. + const base = resolveBaseBranch(repoRoot); + const recorded = baseBranchOf(file.base); + if (recorded && recorded !== base) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${treeMismatch(report.tree, file.tree)}; pregate.json was recorded against origin/${recorded}, not this repository's base origin/${base}, so the stamp rule that would class the difference is not the base's; ${lane} stays uncleared`, fix: `rerun gstack-pregate --base ${base}, then clear again` }; + const s = substituteStampOnlyTree(repoRoot, base, report.tree, file.tree); if ('problem' in s) return { refused: 'PREGATE_RECEIPT_INVALID', message: `${s.problem}; ${lane} stays uncleared`, fix: s.fix }; substitution = s.substitution; } @@ -216,7 +220,7 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput } /** Record the receipt that clears one obligation (a run URL, an evidence-bundle lane label, or a remote gate report, with the stamp-only substitution when one was accepted). */ -export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: StampOnlySubstitution): PregateFile { +export function clearObligation(file: PregateFile, lane: string, receipt: string, by: string, substitution?: PregateReceiptSubstitution): PregateFile { const list = file.requires_remote ?? []; const target = list.find(r => r.lane === lane); if (!target) throw new Error(`no requires-remote obligation named ${lane}; obligations: ${list.map(r => r.lane).join(', ') || '(none)'}`); diff --git a/lib/pregate/remote-receipt.ts b/lib/pregate/remote-receipt.ts index bb95f5566..5cebb89f4 100644 --- a/lib/pregate/remote-receipt.ts +++ b/lib/pregate/remote-receipt.ts @@ -19,7 +19,10 @@ * every differing file is a stamp; a path outside the rule, or a stamp path * whose hunk is not the exact old → new rewrite, refuses it with the path * named. There is no second stamp list here: a verdict this module reaches is - * the verdict `gstack-tree-receipt` reaches on the same pair. + * the verdict `gstack-tree-receipt` reaches on the same pair, including its + * gate rule: a repo whose `stamp_sensitive_gates` names the test lane (`tests`, + * as gstack's own policy does, because its tests read VERSION and the digest) + * is refused, since the tree receipt never marks such a lane reusable. * * Both trees must be objects in this repository (the gated tree is the * gated commit's tree once that commit is fetched; the head tree is the @@ -27,24 +30,22 @@ * here cannot be diffed and is refused, never trusted. */ import { spawnSync } from 'node:child_process'; +import type { PregateReceiptSubstitution } from '../headless-artifacts'; import { parseUnifiedDiff, versionAtRef } from '../restamp'; import { isUnavailable, loadPolicyFromBase, SHIP_POLICY_PATH } from '../ship-policy'; import { classifyFile } from '../tree-receipt'; +import { treeMismatch } from './checks/lanes'; -/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head. */ -export interface StampOnlySubstitution { - /** The tree the remote gate tested (its PREGATE_REMOTE line). */ - gated_tree: string; - /** The tree pregate.json recorded, which the receipt is accepted for. */ - accepted_for: string; - /** The policy that classed the differing files: `origin/@:.gstack/ship-policy.json`, or the defaults when the base has none. */ - policy: string; - /** Every path that differs between the two trees, with the tree receipt rule that classed it as a stamp. */ - files: Array<{ path: string; rule: string }>; -} +/** What `clear` records when a remote_gate receipt from a stamp-only predecessor tree is accepted for the head (the pregate.json shape, lib/headless-artifacts.ts). */ +export type StampOnlySubstitution = PregateReceiptSubstitution; export type SubstitutionResult = { substitution: StampOnlySubstitution } | { problem: string; fix: string }; +/** A git object id as the remote gate and bin/gstack-wtree print it: nothing symbolic (`HEAD^{tree}`) or option-shaped reaches git. */ +const OBJECT_ID = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/; +/** The `stamp_sensitive_gates` labels that name the test tier the remote gate runs: gstack-evidence's `tests` lane, or the obligation itself. */ +const STAMP_SENSITIVE_TEST_LANES = new Set(['tests', 'pregate.json/remote_gate', 'remote_gate']); + function git(cwd: string, args: string[]): string | null { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? (r.stdout ?? '').replace(/\n$/, '') : null; @@ -59,30 +60,34 @@ export function baseBranchOf(base: string | undefined): string | null { /** * Whether `gatedTree` differs from `headTree` only as a stamp does, under the * base branch's ship policy. Returns the substitution to record, or the - * problem (the first clause is the plain tree mismatch, so a caller's message - * reads the same as before) with the fix. + * problem (opening with the same tree-mismatch clause `remoteReportProblem` + * prints) with the fix. */ export function substituteStampOnlyTree(repoRoot: string, base: string, gatedTree: string, headTree: string): SubstitutionResult { const short = (t: string) => t.slice(0, 12); - const mismatch = `the remote gate tested tree ${short(gatedTree)}, not ${short(headTree)}`; + const mismatch = treeMismatch(gatedTree, headTree); const rerun = `rerun the remote gate on tree ${short(headTree)} and pass its output with --receipt, or run gstack-pregate --stage tests --remote --spend-cap --allow-repo-commands`; for (const [label, tree] of [['gated', gatedTree], ['head', headTree]] as const) { - if (git(repoRoot, ['cat-file', '-t', tree]) !== 'tree') { + if (!OBJECT_ID.test(tree)) return { problem: `${mismatch}; the ${label} tree ${JSON.stringify(tree.slice(0, 40))} is not a git object id, so it cannot be compared`, fix: rerun }; + if (git(repoRoot, ['cat-file', '-t', '--end-of-options', tree]) !== 'tree') { return { problem: `${mismatch}; the ${label} tree ${short(tree)} is not an object in this repository, so the two trees cannot be compared`, fix: label === 'gated' ? `fetch the commit the remote gate tested (git fetch origin ) so its tree is here, then clear again; or ${rerun}` : rerun }; } } const loaded = loadPolicyFromBase(repoRoot, base); if (isUnavailable(loaded)) return { problem: `${mismatch}; the ship policy on origin/${loaded.base} could not be read (${loaded.detail}), so the stamp rule is unknown`, fix: `git fetch origin ${loaded.base}, then clear again; or ${rerun}` }; if (loaded.errors.length) return { problem: `${mismatch}; ${SHIP_POLICY_PATH} on ${loaded.source.label} is invalid (${loaded.errors.map(e => `${e.path}: ${e.message}`).join('; ')}), so the stamp rule is unknown`, fix: `fix the policy on the base branch (gstack-ship-policy validate), then clear again; or ${rerun}` }; + // The tree receipt never marks a stamp-sensitive lane reusable: a repo whose tests read VERSION or generated bytes says so here, and its test tier is what the remote gate ran. + const sensitive = loaded.policy.stamp_sensitive_gates.filter(g => STAMP_SENSITIVE_TEST_LANES.has(g)); + if (sensitive.length) return { problem: `${mismatch}, and ${loaded.source.label} lists the test lane as stamp-sensitive (stamp_sensitive_gates: ${sensitive.join(', ')}), so a run on another tree never covers this one`, fix: rerun }; const gv = versionAtRef(repoRoot, gatedTree); const cv = versionAtRef(repoRoot, headTree); const versionRel = 'rel' in cv ? cv.rel : 'rel' in gv ? gv.rel : 'VERSION'; const oldV = 'version' in gv ? gv.version : null; const newV = 'version' in cv ? cv.version : null; - const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', gatedTree, headTree]); + const diff = git(repoRoot, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--end-of-options', gatedTree, headTree]); if (diff === null) return { problem: `${mismatch}; git diff ${short(gatedTree)} ${short(headTree)} failed`, fix: rerun }; const files = parseUnifiedDiff(diff).map(f => classifyFile(repoRoot, f, gatedTree, headTree, loaded.policy, versionRel, oldV, newV)); - if (!files.length) return { problem: `${mismatch}; the trees differ but git diff lists no file (a mode-only or submodule change)`, fix: rerun }; + if (!files.length) return { problem: `${mismatch}; the trees differ but git diff produced no file entry to classify`, fix: rerun }; const changed = files.filter(f => f.verdict === 'changed'); if (changed.length) { const named = changed.map(c => `${c.path} (${c.rule}${c.residual ? `: ${c.residual}` : ''}${c.line ? `, line ${c.line}` : ''})`).join('; '); diff --git a/test/pregate-remote-stamp-only.test.ts b/test/pregate-remote-stamp-only.test.ts index b39c3a0df..d6a15b05d 100644 --- a/test/pregate-remote-stamp-only.test.ts +++ b/test/pregate-remote-stamp-only.test.ts @@ -194,19 +194,84 @@ describe('clear --receipt accepts a stamp-only predecessor tree (gate ahead of t expect(r.err).toContain('PREGATE_REMOTE_UNCLEARED'); }, 120_000); - test("without a repository root clearDecision keeps the plain tree mismatch; baseBranchOf reads pregate.json's base field", async () => { + test('a same-tree receipt after a substituted clear drops the recorded substitution', async () => { + const { repo, gatedTree } = gatedRepo(); + stamp(repo); + const file = await pregateAt(repo); + expect(clear(repo, ['--receipt', receiptFile(repo, gatedTree)]).status).toBe(EXIT.ok); + expect(pregateJson(repo).requires_remote[0].substitution).toBeDefined(); + expect(clear(repo, ['--receipt', receiptFile(repo, file.tree)]).status).toBe(EXIT.ok); + expect(pregateJson(repo).requires_remote[0].substitution).toBeUndefined(); + const verify = runBin(repo, 'gstack-pregate', ['verify']); + expect(verify.status).toBe(EXIT.ok); + expect(verify.out).not.toContain('stamp-only'); + }, 120_000); + + test('a symbolic tree, a head tree that is not here, a mode-only difference and a stamp file at the wrong version are refused', async () => { + const { repo, gatedTree } = gatedRepo(); + stamp(repo); + const file = await pregateAt(repo); + const symbolic = clear(repo, ['--receipt', receiptFile(repo, 'HEAD^{tree}')]); + expect(symbolic.status).toBe(EXIT.fail); + expect(symbolic.err).toContain(`the remote gate tested tree HEAD^{tree}, not ${file.tree.slice(0, 12)}; the gated tree "HEAD^{tree}" is not a git object id, so it cannot be compared; pregate.json/remote_gate stays uncleared`); + expect(pregateJson(repo).requires_remote[0].receipt).toBeNull(); + const headGone = substituteStampOnlyTree(repo.work, 'main', gatedTree, 'f00d'.repeat(10)); + expect('problem' in headGone && headGone.problem).toContain('the head tree f00df00df00d is not an object in this repository'); + const upper = substituteStampOnlyTree(repo.work, 'main', gatedTree.toUpperCase(), file.tree); + expect('problem' in upper && upper.problem).toContain('is not a git object id'); + // A mode-only change leaves git diff --unified=0 with a file header and no hunk: classifyFile's mode rule, a change. + fs.chmodSync(path.join(repo.work, 'lib/util.js'), 0o755); + const modeHead = commit(repo, {}, 'mode only'); + const mode = substituteStampOnlyTree(repo.work, 'main', gatedTree, repo.git('rev-parse', `${modeHead}^{tree}`)); + expect('problem' in mode && mode.problem).toContain('lib/util.js (mode: file mode changed'); + fs.chmodSync(path.join(repo.work, 'lib/util.js'), 0o644); + commit(repo, {}, 'mode back'); + // A stamp file rewritten to a version other than the head's is not the old -> new rewrite. + const wrongHead = commit(repo, { 'README.md': '# fixture\n\n' }, 'README at the wrong version'); + const wrong = substituteStampOnlyTree(repo.work, 'main', gatedTree, repo.git('rev-parse', `${wrongHead}^{tree}`)); + expect('problem' in wrong && wrong.problem).toContain('README.md (stamp_files: '); + expect('problem' in wrong && wrong.problem).toContain('1.0.7'); + }, 120_000); + + test("the policy branch comes from pregate.json's base field, else origin/HEAD; an invalid base policy refuses", async () => { const { repo, gatedTree } = gatedRepo(); stamp(repo); const file = await pregateAt(repo); - const d = clearDecision(file, LANE, { receiptPath: receiptFile(repo, gatedTree) }); - expect(d).toEqual({ refused: 'PREGATE_RECEIPT_INVALID', message: `the remote gate tested tree ${gatedTree.slice(0, 12)}, not ${file.tree.slice(0, 12)}; pregate.json/remote_gate stays uncleared` }); expect(baseBranchOf(file.base)).toBe('main'); expect(baseBranchOf('origin/release/2026@0123456789ab')).toBe('release/2026'); expect(baseBranchOf(undefined)).toBeNull(); expect(baseBranchOf('main')).toBeNull(); - const s = substituteStampOnlyTree(repo.work, 'main', gatedTree, file.tree); - expect('substitution' in s && s.substitution.files).toEqual(STAMP_FILES); const noBase = substituteStampOnlyTree(repo.work, 'no-such-branch', gatedTree, file.tree); expect('problem' in noBase && noBase.problem).toContain('the ship policy on origin/no-such-branch could not be read'); + // No base field: clearDecision uses origin/HEAD's branch (main here) and still accepts; a base field naming another branch refuses. + const { base: _b, ...noField } = file; + const d = clearDecision(noField as typeof file, LANE, { receiptPath: receiptFile(repo, gatedTree) }, repo.work); + expect('substitution' in d && d.substitution?.files).toEqual(STAMP_FILES); + const other = clearDecision({ ...file, base: 'origin/release@0123456789ab' }, LANE, { receiptPath: receiptFile(repo, gatedTree) }, repo.work); + expect('refused' in other && other.message).toContain(`pregate.json was recorded against origin/release, not this repository's base origin/main`); + expect('refused' in other && other.fix).toBe('rerun gstack-pregate --base main, then clear again'); + // A policy that lists the test lane as stamp-sensitive (gstack's own does) refuses the substitution: that lane never reuses across a stamp. + repo.git('checkout', '-q', 'main'); + commit(repo, { '.gstack/ship-policy.json': JSON.stringify({ ...POLICY, stamp_sensitive_gates: ['tests'] }, null, 2) + '\n' }, 'tests read the stamps'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('fetch', '-q', 'origin'); + const sensitive = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]); + expect(sensitive.status).toBe(EXIT.fail); + expect(sensitive.err).toContain(`not ${file.tree.slice(0, 12)}, and origin/main@`); + expect(sensitive.err).toContain(':.gstack/ship-policy.json lists the test lane as stamp-sensitive (stamp_sensitive_gates: tests), so a run on another tree never covers this one; pregate.json/remote_gate stays uncleared'); + expect(pregateJson(repo).requires_remote[0].receipt).toBeNull(); + // An invalid policy on the base refuses rather than classifying under the defaults. + repo.git('checkout', '-q', 'main'); + commit(repo, { '.gstack/ship-policy.json': '{ "stamp_files": "VERSION" }\n' }, 'break the policy'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('fetch', '-q', 'origin'); + const broken = clear(repo, ['--receipt', receiptFile(repo, gatedTree)]); + expect(broken.status).toBe(EXIT.fail); + expect(broken.err).toContain('.gstack/ship-policy.json on origin/main@'); + expect(broken.err).toContain('is invalid ('); + expect(broken.err).toContain('fix: fix the policy on the base branch (gstack-ship-policy validate)'); + expect(pregateJson(repo).requires_remote[0].receipt).toBeNull(); }, 120_000); });