mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 10:18:32 +01:00
fix: pre-landing review fixes (hex object ids, stamp_sensitive_gates and base-branch refusals, one substitution type, docs)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
3732bbb3bf
commit
727eb6c137
6 files changed
+144
-48
No files matched your search
+3
-3
@@ -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 <file>` 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 <file>` 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=<head> stamp-only=<n>` 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 <policy>; stamp-only: <path> (<rule>), …`, `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.
|
||||
|
||||
+25
-6
@@ -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=<gated>
|
||||
tests=<n> accepted-for=<head> stamp-only=<files> receipt=<file>
|
||||
tests=<n> accepted-for=<head> stamp-only=<file count> receipt=<file>
|
||||
sha256=<digest>`, and pregate.json records the substitution beside it:
|
||||
`substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`,
|
||||
the policy label (`origin/<base>@<sha>:.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 <gated sha> # the gated commit's tree must be here
|
||||
gstack-pregate --allow-repo-commands # on the stamped head: records pregate.json/remote_gate uncleared
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-15
@@ -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 <file>` 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/<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/<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)'}`);
|
||||
|
||||
@@ -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/<base>@<sha>:.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 <usd> --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 <its sha>) 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('; ');
|
||||
|
||||
@@ -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<!-- fixture-stamp: 1.0.7 -->\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);
|
||||
});
|
||||
Reference in new issue
Block a user