mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
fix: pre-landing review fixes (depth scan binding, queue scope, carry checks)
- carry refuses a --head other than the checked-out HEAD, a receipt with gstack_version but no review_depth, and an owner exception that no longer stands; a dirty or foreign checkout makes the write-time gate scan unavailable (full) - the queue binds a gate scan only to the full bound commit and recomputes the scope signals with gstack-diff-scope <base> --head <sha>, the same range the writer uses - the queue checks the review-loop exception's fix-check ancestry - a package.json version line counts as stamp-only only when it is a bare version key - docs and help name what review_depth is and which registry the queue reads Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2b42ec597e
commit
4ad8a6bfd9
10 files changed
+172
-38
No files matched your search
@@ -2,6 +2,9 @@
|
||||
# gstack-diff-scope — categorize what changed in the diff against a base branch
|
||||
# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
|
||||
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
|
||||
# Or: gstack-diff-scope <base> --head <sha> → the committed range <base>...<sha> only
|
||||
# (no working tree, no untracked files): what a receipt writer and the
|
||||
# merge queue compute for one commit, from any checkout (GSTA-147).
|
||||
#
|
||||
# Output contract (#2526 — all-false must be distinguishable from "we could
|
||||
# not look" and from "nothing matched"):
|
||||
@@ -31,6 +34,8 @@ _default_base() {
|
||||
echo "main"
|
||||
}
|
||||
BASE="${1:-$(_default_base)}"
|
||||
HEAD_REF=""
|
||||
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
|
||||
|
||||
FRONTEND=false
|
||||
BACKEND=false
|
||||
@@ -79,6 +84,21 @@ _collect() {
|
||||
[ -n "$f" ] && FILES_LIST+=("$f")
|
||||
done
|
||||
}
|
||||
if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then
|
||||
if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_head"
|
||||
echo "# --head '${HEAD_REF}' is not a commit in this repository"
|
||||
exit 2
|
||||
fi
|
||||
if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_merge_base"
|
||||
echo "# '${BASE}' and '${HEAD_REF}' share no merge base"
|
||||
exit 2
|
||||
fi
|
||||
_collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null)
|
||||
else
|
||||
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
|
||||
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
|
||||
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
|
||||
@@ -87,6 +107,7 @@ _collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
|
||||
# Untracked files: a brand-new component/migration/test is exactly what a
|
||||
# reviewer should see, and /ship commits it in Step 15 regardless.
|
||||
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ "${#FILES_LIST[@]}" -eq 0 ]; then
|
||||
_print_flags
|
||||
|
||||
+10
-3
@@ -44,7 +44,7 @@ import {
|
||||
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
import { deeper, depthBelow, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
|
||||
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth';
|
||||
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
|
||||
@@ -87,7 +87,8 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
|
||||
--review-loop-exception <json|@file> ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a
|
||||
stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the
|
||||
"Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
|
||||
review_depth (gstack-review-plan --json at HEAD; --head must be HEAD, and no plan means nothing written).
|
||||
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
|
||||
unreadable input records full).
|
||||
--approval-run <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
|
||||
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
|
||||
--out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
|
||||
@@ -599,7 +600,13 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate <pregate.json>`);
|
||||
}
|
||||
let depthField: Partial<ShipReceipt> = {};
|
||||
if (receipt.review_depth) {
|
||||
const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0;
|
||||
const problems = exceptionProblems(body, receipt, isAncestor);
|
||||
if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`);
|
||||
if (receipt.review_depth || receipt.gstack_version) {
|
||||
if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`);
|
||||
const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase();
|
||||
if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`);
|
||||
const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
|
||||
if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`);
|
||||
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } };
|
||||
|
||||
@@ -166,7 +166,7 @@ invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat
|
||||
could not produce, and a binary code-class path (it is never counted as 0 lines).
|
||||
|
||||
**The registry rule.** Only the base commit's registry counts (the merge base's
|
||||
`origin/<base>` commit for live /ship, the receipt's base sha for the queue), and only
|
||||
`origin/<base>` commit for live /ship and the current base ref's commit for the queue: `origin/<base>` in `ready`, `<remote>/<base>` in `train build`), and only
|
||||
entries with a `command`, which pregate's regen check proves for that tree. An entry
|
||||
added on the branch never excludes anything; `command: null` entries, vendored code
|
||||
and lockfiles stay runtime. To adopt it, commit an entry like this on the base branch:
|
||||
|
||||
+15
-9
@@ -818,21 +818,25 @@ repair is trimming the file, never raising the cap in the same PR.
|
||||
wrote the receipt and the review depth at the checked-out head
|
||||
([review-policy.md#review-depth](review-policy.md#review-depth)):
|
||||
`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`,
|
||||
computed from git objects for the range against `origin/<base>`, with the
|
||||
gate scan run on that head. `--head` must be the checked-out HEAD
|
||||
computed from git objects for the range against `origin/<base>`
|
||||
(`gstack-diff-scope <base> --head <sha>` for the scope signals), with the gate
|
||||
scan run on that head. `--head` must be the checked-out HEAD
|
||||
(`RECEIPT_INVALID` otherwise), so the record and `head_tree` describe the
|
||||
tree the review read. `gstack-ship-queue ready` and `train build` recompute
|
||||
the depth the head needs and print `REVIEW_DEPTH: receipt=<d> required=<d>
|
||||
tree the review read; tracked edits in the checkout make the scan unavailable,
|
||||
which records `full`. `gstack-ship-queue ready` and `train build` recompute
|
||||
the depth the head needs from the same range and scope and print `REVIEW_DEPTH: receipt=<d> required=<d>
|
||||
runtime_lines=<n> class=<c> reasons=<…> inputs=base@<sha>,registry@<sha>,gate_scan@<sha|missing>`;
|
||||
a recorded gate scan counts only when it names the bound head, otherwise the
|
||||
scan is unavailable and the head needs `full`. A receipt below the required
|
||||
a recorded gate scan counts only when its `head` is the full bound commit,
|
||||
otherwise the scan is unavailable and the head needs `full`. A receipt below the required
|
||||
depth, or one whose writer records `gstack_version` but no `review_depth`, is
|
||||
[`QUEUE_RECEIPT_DEPTH_RAISED`](troubleshooting.md#queue-receipt-depth-raised):
|
||||
the receipt is left unchanged and the fix is /ship on that head. A receipt with
|
||||
neither field predates depth receipts and is accepted as legacy. A carry
|
||||
recomputes the depth at the new head, rebinds the gate scan, and refuses when
|
||||
the new head needs a deeper review than the receipt records (a test change that
|
||||
skips a test raises the diff to gate class).
|
||||
skips a test raises the diff to gate class), when `--head` is not the
|
||||
checked-out HEAD, when the receipt records `gstack_version` without
|
||||
`review_depth`, or when an owner exception no longer stands.
|
||||
|
||||
**`review_loop_exception`.** The owner's acceptance of a stop after a
|
||||
fix-check edit under frozen scope (#3559), from `write --review-loop-exception
|
||||
@@ -843,8 +847,10 @@ that is the receipt head or its ancestor, and, with `--body`, the line
|
||||
word for word; otherwise
|
||||
[`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid)
|
||||
and nothing is written. The queue checks both owner exceptions the same way
|
||||
before READY and train build: the record must parse and the body must still
|
||||
carry its line, or the receipt is
|
||||
before READY and train build: the record must parse, the body must still
|
||||
carry its line, and the fix-check head must be the bound head or its ancestor
|
||||
(after a carry across rewritten history the old fix-check commit is off the
|
||||
branch, so that check is skipped), or the receipt is
|
||||
[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid).
|
||||
|
||||
**Prose against the receipt.** `gstack-ship-receipt read --pr <n> --check-prose`
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface ShipReceipt {
|
||||
carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' };
|
||||
/** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */
|
||||
head_tree?: string; product_patch_id?: string;
|
||||
/** The gstack that wrote the receipt, and the review depth gstack-review-plan computed at the reviewed head (lib/ship-receipt-depth.ts). */
|
||||
/** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */
|
||||
gstack_version?: string;
|
||||
review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] };
|
||||
/** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */
|
||||
|
||||
@@ -84,7 +84,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick<Col
|
||||
if (line[0] === '+') (added[cur] ??= []).push(line.slice(1));
|
||||
if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line);
|
||||
}
|
||||
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:/.test(l)));
|
||||
const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l)));
|
||||
return { perPath, deleted, stampOnly, added };
|
||||
}
|
||||
|
||||
|
||||
+40
-23
@@ -3,9 +3,11 @@
|
||||
* review read the head, and what the queue checks against it
|
||||
* (docs/review-policy.md#review-depth, docs/ship-policy.md#ship-receipt).
|
||||
*
|
||||
* `review_depth`: written by `gstack-ship-receipt write` from
|
||||
* gstack-review-plan at the reviewed head (depth, runtime lines, the rules
|
||||
* hash, the base and registry commits, the gate scan bound to that head).
|
||||
* `review_depth`: the depth the reviewed head needs, recomputed by
|
||||
* `gstack-ship-receipt write` (receiptDepthAtHead: the committed range from
|
||||
* git objects, gstack-diff-scope on that range and the gate scan of the clean
|
||||
* checkout at that head), with the runtime lines, the rules hash, the base and
|
||||
* registry commits and the gate scan bound to the head.
|
||||
* `review_loop_exception`: the owner's acceptance of a stop after a
|
||||
* fix-check edit under frozen scope (#3559), shaped like the docs exception.
|
||||
* The queue recomputes the depth the head needs from git objects
|
||||
@@ -26,6 +28,7 @@ export type ReviewLoopException = NonNullable<ShipReceipt['review_loop_exception
|
||||
const RANK: Record<Depth, number> = { light: 0, standard: 1, full: 2 };
|
||||
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v);
|
||||
const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v);
|
||||
const BIN_DIR = path.join(import.meta.dir, '..', 'bin');
|
||||
const short = (s: string | null | undefined) => (s ? s.slice(0, 12) : 'none');
|
||||
|
||||
function git(root: string, args: string[]): { ok: boolean; out: string } {
|
||||
@@ -33,29 +36,37 @@ function git(root: string, args: string[]): { ok: boolean; out: string } {
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
||||
}
|
||||
|
||||
/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */
|
||||
function scopeOfRange(root: string, binDir: string, base: string, head: string): Record<string, string> {
|
||||
const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const scope: Record<string, string> = {};
|
||||
for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
|
||||
if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`;
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* The depth record for `head` (the checked-out commit), computed the way the
|
||||
* queue recomputes it: the range from git objects (collectCommit), plus
|
||||
* gstack-diff-scope and, for a tests or code diff, the gate scan of this
|
||||
* checkout, which therefore names `head`.
|
||||
* The depth record for `head`: the range from git objects (collectCommit) and
|
||||
* its diff-scope, as the queue recomputes them, plus, for a tests or code diff,
|
||||
* the gate scan of this checkout. The scan reads the working tree, so it counts
|
||||
* only when the checkout is `head` with no tracked edits; otherwise it is
|
||||
* unavailable and the depth fails closed to full.
|
||||
*/
|
||||
export function receiptDepthAtHead(repoRoot: string, base: string, binDir: string, head: string): ReceiptDepth {
|
||||
const branch = base.replace(/^origin\//, '');
|
||||
const baseSha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}^{commit}`]);
|
||||
const c = collectCommit(repoRoot, baseSha.ok ? baseSha.out : branch, head);
|
||||
const run = (bin: string, timeout: number) => runScriptSync(path.join(binDir, bin), [branch], { cwd: repoRoot, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024 });
|
||||
const scope: Record<string, string> = {};
|
||||
const sr = run('gstack-diff-scope', 60_000);
|
||||
for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; }
|
||||
if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`;
|
||||
const baseRev = baseSha.ok ? baseSha.out : branch;
|
||||
const c = collectCommit(repoRoot, baseRev, head);
|
||||
const scope = scopeOfRange(repoRoot, binDir, baseRev, head);
|
||||
let tagged: number | null = 0;
|
||||
if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) {
|
||||
const g = run('gstack-gate-diff', 120_000);
|
||||
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
|
||||
tagged = g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
||||
const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok;
|
||||
const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null;
|
||||
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? '');
|
||||
tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
||||
}
|
||||
const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged });
|
||||
return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseSha.ok ? baseSha.out : branch, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) };
|
||||
return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) };
|
||||
}
|
||||
|
||||
export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need];
|
||||
@@ -86,8 +97,11 @@ export function reviewLoopExceptionLine(e: ReviewLoopException): string {
|
||||
|
||||
const carries = (body: string, line: string) => body.split(/\r?\n/).some(l => l.trim() === line);
|
||||
|
||||
/** The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim. */
|
||||
export function exceptionProblems(body: string, r: ShipReceipt): string[] {
|
||||
/**
|
||||
* The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim.
|
||||
* `isAncestor`, when given, also requires the review-loop exception's fix-check head to be the receipt head or its ancestor.
|
||||
*/
|
||||
export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (commit: string) => boolean): string[] {
|
||||
const out: string[] = [];
|
||||
const raw = r as ShipReceipt & { docs?: unknown };
|
||||
if (raw.docs !== undefined) {
|
||||
@@ -100,6 +114,7 @@ export function exceptionProblems(body: string, r: ShipReceipt): string[] {
|
||||
if (!e.ok) out.push(e.message);
|
||||
else {
|
||||
if (!r.review_loop?.fix_checks) out.push('review_loop_exception needs review_loop.fix_checks >= 1 (the stop it accepts follows a fix-check)');
|
||||
if (isAncestor && !isAncestor(e.exception.fix_check.head)) out.push(`review_loop_exception.fix_check.head ${e.exception.fix_check.head.slice(0, 12)} is not the receipt head or an ancestor of it`);
|
||||
const line = reviewLoopExceptionLine(e.exception);
|
||||
if (!carries(body, line)) out.push(`the body does not carry the review-loop exception line verbatim: ${JSON.stringify(line)}`);
|
||||
}
|
||||
@@ -120,9 +135,10 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
|
||||
const rd = r.review_depth;
|
||||
if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' };
|
||||
const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
||||
const c = collectCommit(root, baseSha.ok ? baseSha.out : baseRef, head);
|
||||
const scanBound = !!rd && rd.gate_scan.tagged !== null && head.startsWith(rd.gate_scan.head);
|
||||
const need = depthOfCommitInput({ ...c, gateTagged: scanBound ? rd!.gate_scan.tagged : null });
|
||||
const baseRev = baseSha.ok ? baseSha.out : baseRef;
|
||||
const c = collectCommit(root, baseRev, head);
|
||||
const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase();
|
||||
const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null });
|
||||
const have = rd?.depth;
|
||||
const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`;
|
||||
const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`;
|
||||
@@ -138,7 +154,8 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string,
|
||||
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } {
|
||||
const b = bindReceipt(body, pr, accepted);
|
||||
if (!b.ok) return b;
|
||||
const problems = exceptionProblems(body, b.receipt);
|
||||
const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok;
|
||||
const problems = exceptionProblems(body, b.receipt, ancestor);
|
||||
if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line };
|
||||
const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head);
|
||||
if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line };
|
||||
|
||||
@@ -367,6 +367,27 @@ describe('uncommitted work is visible (#2299)', () => {
|
||||
expect(vars.SCOPE_BACKEND).toBe('true');
|
||||
});
|
||||
|
||||
// Value: protects=--head scopes one committed range from any checkout, the input the receipt writer and the merge queue
|
||||
// share; fails_when=--head still reads the working tree or untracked files, or an unknown head reads as all-false; seam=none
|
||||
test('--head <sha> scopes only the committed range: working-tree and untracked files are ignored, an unknown head is an error', () => {
|
||||
const dir = createRepo(['lib/auth.ts']);
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' }).stdout.toString().trim();
|
||||
mkdirSync(join(dir, 'db', 'migrate'), { recursive: true });
|
||||
writeFileSync(join(dir, 'db', 'migrate', '001.rb'), '# untracked\n');
|
||||
writeFileSync(join(dir, 'styles.css'), 'a{}\n');
|
||||
const scope = (args: string[]) => spawnSync('bash', [SCRIPT, 'main', ...args], { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
||||
const ranged = scope(['--head', head]);
|
||||
expect(ranged.status).toBe(0);
|
||||
const out = ranged.stdout.toString();
|
||||
expect(out).toContain('SCOPE_AUTH=true');
|
||||
expect(out).toContain('SCOPE_MIGRATIONS=false');
|
||||
expect(out).toContain('SCOPE_FRONTEND=false');
|
||||
expect(runScopeFull(dir).vars.SCOPE_MIGRATIONS).toBe('true');
|
||||
const unknown = scope(['--head', 'f'.repeat(40)]);
|
||||
expect(unknown.status).toBe(2);
|
||||
expect(unknown.stdout.toString()).toContain('SCOPE_ERROR=no_head');
|
||||
});
|
||||
|
||||
test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => {
|
||||
const dir = createRepo(['docs/M2 — Notes.md']);
|
||||
const { vars, status } = runScopeFull(dir);
|
||||
|
||||
@@ -25,6 +25,15 @@ function repo(baseFiles: Record<string, string | Buffer>) {
|
||||
}
|
||||
|
||||
describe('collectCommit: git objects only', () => {
|
||||
// Value: protects=only a bare version bump in package.json is release metadata; fails_when=a version line that also
|
||||
// carries scripts counts as stamp-only (docs class, light); seam=none
|
||||
test('a package.json version line that also carries scripts is not stamp-only; a bare version bump is', () => {
|
||||
const r = repo({ 'package.json': '{\n "name": "x",\n "version": "1.0.0"\n}\n' });
|
||||
const bare = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.1"\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, bare).stampOnly).toEqual(['package.json']);
|
||||
const riding = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.2", "scripts": { "postinstall": "node x.js" }\n}\n' });
|
||||
expect(collectCommit(r.dir, r.baseSha, riding).stampOnly).toEqual([]);
|
||||
});
|
||||
test('a registered glob output is excluded, a command:null entry is not, and a .tmpl edit counts once', () => {
|
||||
const r = repo({ '.gstack/generated.json': REGISTRY, 'ship/SKILL.md.tmpl': 'a\n', 'ship/SKILL.md': 'a\n' });
|
||||
const head = r.commit({ 'ship/SKILL.md.tmpl': 'a\nb\nc\n', 'ship/SKILL.md': lines(120), 'src/hand.ts': lines(5) });
|
||||
|
||||
@@ -91,6 +91,33 @@ describe('the queue refuses a receipt below the depth its head needs', () => {
|
||||
const verdict = receiptDepthVerdict(repo.work, 'origin/main', extractReceipt(legacy).receipt!, head);
|
||||
expect(verdict).toEqual({ ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' });
|
||||
}, 60_000);
|
||||
|
||||
// Value: protects=the queue's required depth keeps the scope signals the writer used, and a gate scan binds only the full
|
||||
// bound commit; fails_when=the queue recomputes without diff-scope (auth reads light) or binds an empty or short scan head
|
||||
// by prefix; seam=bindShippedReceipt
|
||||
test('an auth path needs full at the queue though the receipt says light; an empty or short gate scan head binds nothing', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const head = commit(repo, { 'lib/auth.js': lines(3) });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', class: 'gate' });
|
||||
const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]);
|
||||
const light = bind(rewrite(body, r => { r.review_depth.depth = 'light'; }));
|
||||
expect(!light.ok && light.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED');
|
||||
expect(!light.ok && light.depthLine).toContain('receipt=light required=full');
|
||||
expect(!light.ok && light.depthLine).toContain('class=gate');
|
||||
|
||||
const plain = makeFixtureRepo(); repos.push(plain);
|
||||
const plainHead = commit(plain, { 'lib/util.js': lines(3) });
|
||||
const plainBody = path.join(plain.root, 'body.md');
|
||||
expect(writeReceipt(plain, plainBody).status).toBe(EXIT.ok);
|
||||
for (const scanHead of ['', plainHead.slice(0, 12)]) {
|
||||
const r = bindShippedReceipt(plain.work, 'origin/main', rewrite(plainBody, x => { x.review_depth.gate_scan.head = scanHead; }), '7', [plainHead]);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(!r.ok && r.depthLine).toContain('required=full');
|
||||
expect(!r.ok && r.depthLine).toContain('gate_scan@missing');
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('carry and the review depth', () => {
|
||||
@@ -114,6 +141,27 @@ describe('carry and the review depth', () => {
|
||||
expect(refused.err).toContain("above the receipt's light");
|
||||
expect(receiptIn(body).head).toBe(plain);
|
||||
}, 90_000);
|
||||
|
||||
// Value: protects=a carry never binds a scan of one checkout to another commit, and never rebinds a receipt the queue would
|
||||
// refuse; fails_when=carry --head from another checkout carries a skipped test at light, or a current receipt without
|
||||
// review_depth carries; seam=none
|
||||
test('carry --head from another checkout refuses; a receipt with gstack_version but no review_depth refuses', () => {
|
||||
const repo = makeFixtureRepo(); repos.push(repo);
|
||||
const reviewed = commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('a', () => {});\n" });
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
expect(writeReceipt(repo, body).status).toBe(EXIT.ok);
|
||||
const skipped = commit(repo, { 'test/util.test.js': "test.skip('a', () => {});\n" });
|
||||
repo.git('checkout', '-q', reviewed);
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', skipped]);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toContain(`--head ${skipped.slice(0, 12)} is not the checked-out HEAD ${reviewed.slice(0, 12)}`);
|
||||
expect(receiptIn(body).head).toBe(reviewed);
|
||||
repo.git('checkout', '-q', skipped);
|
||||
rewrite(body, x => { delete x.review_depth; });
|
||||
const noDepth = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']);
|
||||
expect(noDepth.status).toBe(EXIT.refused);
|
||||
expect(noDepth.err).toContain('records gstack_version but no review_depth');
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe('the review-loop exception (#3559)', () => {
|
||||
@@ -150,5 +198,10 @@ describe('the review-loop exception (#3559)', () => {
|
||||
const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]);
|
||||
expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!dropped.ok && dropped.message).toContain('does not carry the review-loop exception line verbatim');
|
||||
const foreign = { ...exception, fix_check: { head: 'f'.repeat(40), findings: 1 } };
|
||||
const forged = rewrite(body, x => { x.review_loop_exception = foreign; }).replace(line, reviewLoopExceptionLine(foreign));
|
||||
const offBranch = bindShippedReceipt(repo.work, 'origin/main', forged, '7', [head]);
|
||||
expect(!offBranch.ok && offBranch.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID');
|
||||
expect(!offBranch.ok && offBranch.message).toContain('is not the receipt head or an ancestor of it');
|
||||
}, 90_000);
|
||||
});
|
||||
Reference in new issue
Block a user