From 9fe6c0183579fc87f771d473d97b7ef1591ce34b Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:00:32 +0000 Subject: [PATCH 01/17] feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase A base merge whose only product-side change is a registered output the pregate regen check proved fresh now carries the receipt. A rebased head carries when its tree is one the receipt covers and its product patch-id against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise). Docs: merge main into a reviewed branch, never rebase it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 ++ bin/gstack-ship-receipt | 185 ++++++++++++++++-------- captain/templates/CREW.md | 5 +- docs/captain.md | 8 + docs/ship-policy.md | 42 +++++- docs/troubleshooting.md | 43 ++++-- docs/unattended.md | 5 +- lib/headless-artifacts.ts | 7 +- lib/result-codes-release.ts | 13 +- lib/ship-receipt.ts | 56 +++++-- test/gstack-ship-receipt.test.ts | 4 +- test/ship-receipt-carry-rewrite.test.ts | 128 ++++++++++++++++ 12 files changed, 412 insertions(+), 97 deletions(-) create mode 100644 test/ship-receipt-carry-rewrite.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 652c7d273..f9e7f26e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. + +### Changed +- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD shows the `regen` check passing with those entries fresh. Without that evidence, and for an entry with no command or one only the branch registers, the output stays product. +- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. +- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. + +### Tests +- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a regen-fresh pregate and refuses without one. +- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. + ## [1.91.299.0] - 2026-10-28 **The Captain brief now says what `rec` does when a gate carries earlier decisions.** At a gate after the owner already answered some items, such as a round-cap gate, `gstack-owner-brief render --captain` listed each item's recommendation ("keep the plan as written", `rec resolves to d2a`) while `gstack-gate render` and `gstack-autoplan answer --reply rec` keep the carried answer (`d2b`). A Captain relaying the brief told the owner the opposite of what their one-word reply would record (#3546). The brief now takes its choices from the same resolver `gstack-gate render` uses. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d7d3940a0..e6d89eaf9 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -40,12 +40,13 @@ import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, r import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, - bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, + bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; +import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) @@ -61,10 +62,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block census --repo --since [--cohort ] the P7 adoption number over merged PRs (deduplicated by run and thread) carry --pr [--repo ] | --body [--base ] [--head ] [--pregate ] [--dry-run] [--out ] - carry the receipt to HEAD when only a base merge, tests or release metadata changed - since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record, - posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete; - RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); + carry the receipt to HEAD when only a base merge, tests, release metadata or registered + outputs the --pregate regen check proved fresh changed since its head (product patch-id + unchanged), or after a rebase onto a tree the receipt covers with the recorded product + patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and + prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED / + RECEIPT_CARRY_TREE_MISMATCH / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE); needs git >= 2.39 (RECEIPT_GIT_TOO_OLD) docs-caps [--base ] /ship Step 14.5: one DOCS_CAP: / ok|over line per docs.caps entry of the @@ -134,6 +137,20 @@ function receiptVersion(repoRoot: string, head: string, base: string): string | return 'none'; } +/** + * What a later carry across rewritten history compares: the head's tree and its + * product patch-id against the base. Both are evidence, never a gate: a head + * outside a checkout (or a base that does not resolve) records neither, and + * such a receipt then carries only along its own history. + */ +function reviewedProduct(repoRoot: string, base: string, head: string, pregateFile: string | undefined): { head_tree?: string; product_patch_id?: string } { + const tree = git(['rev-parse', '--verify', '--quiet', `${head}^{tree}`], repoRoot); + if (!tree) return {}; + const baseRef = `origin/${base.replace(/^origin\//, '')}`; + const id = productPatchAgainstBase(repoRoot, productRules(repoRoot, base, verifiedOutputs(repoRoot, base, pregateFile)), baseRef, head); + return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree }; +} + function cmdWrite(rest: string[], json: boolean): number { const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd(); const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot); @@ -194,7 +211,7 @@ function cmdWrite(rest: string[], json: boolean): number { session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, - review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), + review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } @@ -367,6 +384,61 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } }; } +/** + * Generated outputs the carry may set aside as metadata: the outputs of every + * entry with a `command` in origin/'s `.gstack/generated.json`, and only + * when the pregate passed for HEAD shows the regen check regenerating them + * fresh. Without that evidence an output is product like any other file. + */ +function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] { + if (!pregateFile) return []; + const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen'); + if (!regen || regen.status !== 'pass' || !/\bfresh\b/.test(regen.detail ?? '')) return []; + const load = loadRegistryFromBase(repoRoot, base); + if (isRegistryUnavailable(load) || load.errors.length) return []; + return load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); +} + +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } +function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { + const load = loadPolicyFromBase(repoRoot, base); + const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; + const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...outputs], templates: policy?.stamp_patterns ?? [] }; +} + +/** + * The product pathspec between two commits: every path but tests and release + * metadata, where a metadata-listed path whose change from `from` to `to` is + * not a stamp stays product (every glob that matches it leaves the exclusion). + */ +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { + const changedRaw = git(['diff', '--name-only', from, to], repoRoot); + if (changedRaw === undefined) return undefined; + const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; + const versionAt = (ref: string): string | null => { + const file = blobAt(ref, 'VERSION'); + if (file !== null) return file.trim() || null; + try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } + }; + const oldVersion = versionAt(from); + const newVersion = versionAt(to); + const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) })); + const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); + const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path); + const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); + return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] }; +} + +/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */ +function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: string, commit: string): string | { error: string } { + const mergeBase = git(['merge-base', baseRef, commit], repoRoot); + if (!mergeBase) return { error: `cannot resolve the merge base of ${baseRef} and ${commit.slice(0, 12)}` }; + const spec = productSpec(repoRoot, rules, mergeBase, commit); + if (!spec) return { error: `git diff ${mergeBase.slice(0, 12)} ${commit.slice(0, 12)} failed` }; + return patchId(repoRoot, mergeBase, commit, spec.product); +} + /** * The receipt follows HEAD when the reviewed product is unchanged * (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains @@ -384,7 +456,7 @@ function cmdCarry(rest: string[], json: boolean): number { if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; } const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase(); if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; } - const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { + const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n'); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`); return EXIT.refused; @@ -425,66 +497,63 @@ function cmdCarry(rest: string[], json: boolean): number { } const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT); if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`); - const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); - if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`); // The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped. const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree'); const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, '')); if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`); + const fromKnown = git(['cat-file', '-e', `${from}^{commit}`], repoRoot) !== undefined; + const ancestor = fromKnown && spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).status === 0; + const fromTree = fromKnown ? git(['rev-parse', `${from}^{tree}`], repoRoot) : undefined; + const covered = [...(receipt.head_tree ? [receipt.head_tree] : fromTree ? [fromTree] : []), ...(receipt.carry?.trees ?? [])]; + if (!ancestor && !covered.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed), and the receipt records no tree to compare; merge the base in next time, and run /ship now`); const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot); const baseRef = `origin/${base.replace(/^origin\//, '')}`; - const fromBase = git(['merge-base', baseRef, from], repoRoot); const headBase = git(['merge-base', baseRef, head], repoRoot); - if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); - const load = loadPolicyFromBase(repoRoot, base); - const policy = isUnavailable(load) ? null : load.policy; - const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] }; - const changedRaw = git(['diff', '--name-only', from, head], repoRoot); - if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); - // Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch. - const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; - const versionAt = (ref: string): string | null => { - const file = blobAt(ref, 'VERSION'); - if (file !== null) return file.trim() || null; - try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } - }; - const oldVersion = versionAt(from); - const newVersion = versionAt(head); - const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata]; - const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion, templates: policy?.stamp_patterns ?? [] } : undefined) })); - // Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone. - // A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion. - const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); - const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path); - const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p))); - const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)]; - const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)]; - const productFrom = patchId(repoRoot, fromBase, from, productSpec); - const productHead = patchId(repoRoot, headBase, head, productSpec); - const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); - const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec)); - const testHead = asId(patchId(repoRoot, headBase, head, testSpec)); - for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); - // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, - // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every - // path the base changed, none of which can differ between the two base diffs. - const changedProduct: string[] = []; - if (productFrom !== productHead) { - const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); - for (const p of [...touched].sort()) { - if (changedProduct.length >= CARRY_NAMED_PATHS) break; - const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra); - if (cls !== 'product') continue; - if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + if (!headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const rules = productRules(repoRoot, base, verifiedOutputs(repoRoot, base, flag(rest, '--pregate'))); + const headTree = git(['rev-parse', `${head}^{tree}`], repoRoot); + if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`); + const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head); + if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`); + let plan: ReturnType; + if (!ancestor) { + // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. + const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' }; + if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the reviewed product patch-id is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); + plan = planTreeCarry({ from, head, headTree, coveredTrees: covered, productPatchId: { recorded, head: headPatch } }); + } else { + const fromBase = git(['merge-base', baseRef, from], repoRoot); + if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const spec = productSpec(repoRoot, rules, from, head); + if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); + const { changed } = spec; + const productFrom = patchId(repoRoot, fromBase, from, spec.product); + const productHead = patchId(repoRoot, headBase, head, spec.product); + const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); + const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests)); + const testHead = asId(patchId(repoRoot, headBase, head, spec.tests)); + for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); + // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, + // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every + // path the base changed, none of which can differ between the two base diffs. + const changedProduct: string[] = []; + if (productFrom !== productHead) { + const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); + for (const p of [...touched].sort()) { + if (changedProduct.length >= CARRY_NAMED_PATHS) break; + const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); + if (cls !== 'product') continue; + if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + } + if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); } - if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); + // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. + const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); + const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; + plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); } - const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); - // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. - const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); - const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, // so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry). @@ -499,7 +568,7 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { 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 `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry); + const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index 64a01fc96..e6bdf2806 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -90,8 +90,9 @@ Homes are paths in the gstack checkout the lane checked at session start. 14. **Don't merge `BASE` into a READY PR.** Only on the controller's word after a real-code train conflict (merge, never rebase, free suite, READY again) or as part of the stamp - commit. Home: `docs/captain.md` (Merge trains), `docs/ship-policy.md` (Run a merge - train). + commit. A merge whose product diff is unchanged carries the /ship receipt; a rebase + onto a new tree costs a full /ship. Home: `docs/captain.md` (Merge trains), + `docs/ship-policy.md` (Run a merge train, Carrying a receipt). 15. **CHANGELOG entry under `## [Unreleased]` before READY;** version files stay at `BASE`'s version; the stamp touches only `STAMP_FILES`. Home: `docs/ship-policy.md` diff --git a/docs/captain.md b/docs/captain.md index b81988be2..7c0b7aadf 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -231,6 +231,14 @@ a push included. The sequence that works unattended on Capy: 10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same receipt, no second /ship. +When the base moves under a reviewed branch, merge it in; never rebase. A merge that +leaves the product diff unchanged carries the receipt with `gstack-ship-receipt carry` +(pregate at HEAD, no review rerun), and a merge whose resolution changed product code +needs a fresh /ship. A rebase carries only onto a tree the receipt already covers with +the same product diff; any other rebase is a full /ship +([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to +rebase a reviewed branch. + ## Merge trains gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e2cd9f208..8890a2f4a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -891,6 +891,16 @@ line, and the codes a refusal ends with are ## Carrying a receipt across a base merge or a test-only fix +**Merge main into a reviewed branch; never rebase it.** What happens to the +receipt when the base moves: + +| What changed since /ship | The receipt | +|---|---| +| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate regenerated fresh) | carries; no review reruns | +| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | +| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | +| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | + 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 invalidate it and cost a full re-run on identical product code. @@ -921,8 +931,13 @@ newer tentative one the stamp moved (a `*_KEY_VERSION` written as the next minor, #3512; a version older than the old one or of another width is a hand edit). A logic change in a stamped source file is product. Lockfiles are product unless the policy names -them. A base merge contributes no product -change of its own. A receipt carried twice keeps the reviewed head as +them. An output of an entry with a `command` in origin/'s +`.gstack/generated.json` is `generated`, not product, only when the +`--pregate` result passed for HEAD shows the `regen` check passing with those +entries fresh; without that evidence, or for an entry with no command or one +only the branch registers, the output is product (U1 on gstack-evals paid a +full rerun because a merge changed its generated index's diff). A base merge +contributes no product change of its own. A receipt carried twice keeps the reviewed head as `carry.from` and adds up the merges and paths. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, range_diff, covers, reruns }` with the `git range-diff` evidence, prints @@ -933,10 +948,25 @@ skill=ship status=complete` line, and its summary line ends with A changed product path, or a product patch-id that differs, is [`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed) -(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's -history (a rebase or force-push) or a working tree with uncommitted changes is -[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship` -on the new head. The queue's `ready`, `train build` and `verify --train` read the +(exit 3; the refusal names the paths), and a working tree with uncommitted +changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). + +**Rewritten history.** `write` records `head_tree` (the reviewed head's tree) +and `product_patch_id` (its product patch against the base), and every carry +rebinds both to the new head and keeps the earlier trees in `carry.trees`. +When the receipt's head is no longer in HEAD's history, the carry follows only +when HEAD's tree is one of those trees and HEAD's product patch-id against the +current base equals the recorded one; it records `carry.ancestry: rewritten` +and `range_diff: tree-equal`, and needs neither the old commit nor its +history, so a fresh clone carries too. The tree alone is not enough: a stacked +branch rebased onto main after its base closed unmerged keeps its tree while +its diff grows by the base's code, and that is `RECEIPT_CARRY_PRODUCT_CHANGED`. +Any other tree is +[`RECEIPT_CARRY_TREE_MISMATCH`](troubleshooting.md#receipt-carry-tree-mismatch), +which names HEAD's tree and the covered trees. A receipt from before +`head_tree` existed, whose head is not in this clone, is +`RECEIPT_CARRY_REFUSED`: run `/ship` on the new head. A refusal never +rewrites the body. The queue's `ready`, `train build` and `verify --train` read the carried receipt like any other. **Delta /ship: eligibility, then checks, then publication.** `/ship` Step 1 runs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..e180fbf97 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4694,26 +4694,43 @@ rebased; /ship's own rule is to merge the base in, never rebase a reviewed head. ### `RECEIPT_CARRY_REFUSED` **Meaning.** `gstack-ship-receipt carry` could not carry the PR's /ship receipt -to HEAD: the PR body has no valid receipt, the receipt's head is not an ancestor -of HEAD (a rebase or force-push dropped it), the working tree has uncommitted -or untracked changes (a carry certifies a commit, never a tree that differs from -it), or git could not compare the two product diffs or read the working tree's -status. The receipt in the body is unchanged and still names the old head, so -`gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +to HEAD: the PR body has no valid receipt, the working tree has uncommitted or +untracked changes (a carry certifies a commit, never a tree that differs from +it), the receipt's head left HEAD's history and the receipt records no tree to +compare (a receipt written before `head_tree`, whose head is not in this +clone), or git could not compare the two product diffs or read the working +tree's status. The receipt in the body is unchanged and still names the old +head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. -**Fix.** Keep the reviewed head in HEAD's history (merge the base in instead of -rebasing), commit or stash every working-tree change, then rerun +**Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is -already rewritten, run /ship on the new head. +already rewritten and the receipt predates `head_tree`, run /ship on the new +head; next time merge the base in instead of rebasing. + + +### `RECEIPT_CARRY_TREE_MISMATCH` + +**Meaning.** The branch was rebased or force-pushed after /ship, so the +receipt's head is no longer in HEAD's history, and HEAD's tree is not a tree +the receipt covers (its `head_tree` or a tree in `carry.trees`). No review saw +this code. The refusal names HEAD's tree and the covered trees; the body is +unchanged. + +**Fix.** Run /ship on the new head. A rebase carries only onto a reviewed tree +whose product diff against the new base is unchanged; merging the base in +instead keeps every later carry available +([ship-policy.md](ship-policy.md#receipt-carry)). ### `RECEIPT_CARRY_PRODUCT_CHANGED` **Meaning.** Between the receipt's head and HEAD the product diff against the -base changed: at least one path that is neither a test, release metadata nor -part of the base merge has a different patch than the reviewed head had. The -reviewed verdict does not cover HEAD, so the receipt is not carried. The -refusal names the changed product paths. +base changed: at least one path that is neither a test, release metadata, a +registered output the pregate regenerated fresh, nor part of the base merge has +a different patch than the reviewed head had. After a rebase it means HEAD has +the reviewed tree but the base moved under it, so the diff a reviewer would see +is not the reviewed one. The reviewed verdict does not cover HEAD, so the +receipt is not carried. The refusal names the changed product paths. **Fix.** Run /ship on the new head. The receipt carries only across a conflict-only base merge, a test-only change or release metadata, which the diff --git a/docs/unattended.md b/docs/unattended.md index 7418fceb4..901e11028 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -226,8 +226,9 @@ now names HEAD with a `carry` object holding the `git range-diff` evidence, and `ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop` counters are kept, because the reviewed product diff is unchanged. `CARRY: current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)` -or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product -diff changed or the reviewed head left HEAD's history, so /ship runs again; +, `(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused +result line means the product diff changed, or history was rewritten onto a +tree no review saw, so /ship runs again; `(RECEIPT_GIT_TOO_OLD)` means the machine's git predates 2.39 and cannot run `patch-id --verbatim` ([ship-policy.md](ship-policy.md#receipt-carry)). diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 4bd39e3c7..2c37e3dcc 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -99,7 +99,9 @@ export interface ShipReceipt { issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> }; /** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */ review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record; est_input_tokens?: number }; - carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }; + 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; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -219,7 +221,8 @@ export const ARTIFACT_SCHEMAS = { pregate: { ...str, description: 'pass|warn|requires-remote cleared … @ from pregate.json, current for this head' }, requires_remote: strList, approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' }, review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } }, - carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList } }, + carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, + head_tree: str, product_patch_id: str, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, }, diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 8c7e752ea..27cab8d9b 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -246,18 +246,23 @@ export const RELEASE_RESULT_CODES = { }, RECEIPT_CARRY_REFUSED: { anchor: 'receipt-carry-refused', - summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared', - fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head', + summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the working tree has uncommitted changes, the receipt head is not an ancestor of HEAD and the receipt records no tree to compare, or the product diff could not be compared', + fix: 'commit or stash working-tree changes and rerun carry; when history was rewritten and the receipt predates head_tree, run /ship on the new head (merge the base in instead of rebasing next time)', }, PROSE_LINT_FOUND: { anchor: 'prose-lint-found', summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide', fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file', }, + RECEIPT_CARRY_TREE_MISMATCH: { + anchor: 'receipt-carry-tree-mismatch', + summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code', + fix: 'run /ship on the new head; next time merge the base in instead of rebasing a reviewed branch (a rebase carries only onto a reviewed tree with the reviewed product diff)', + }, RECEIPT_CARRY_PRODUCT_CHANGED: { anchor: 'receipt-carry-product-changed', - summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD', - fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged', + summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD', + fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regenerated fresh, or the base merge, and the product patch-id is unchanged', }, DOCS_AUDIT_BLOCKED: { anchor: 'docs-audit-blocked', diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 3dd216d86..ba6a03f31 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -17,7 +17,7 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', - 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', + 'head_tree', 'product_patch_id', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ @@ -150,7 +150,8 @@ export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md /** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */ export const CARRY_NAMED_PATHS = 20; -export type CarryClass = 'product' | 'test' | 'metadata'; +/** `generated`: an output of a base-registry entry with a command, which the pregate for HEAD regenerated fresh (its inputs are product). */ +export type CarryClass = 'product' | 'test' | 'metadata' | 'generated'; export interface CarryPlanInput { /** The reviewed head the receipt names. */ from: string; head: string; @@ -164,8 +165,10 @@ export interface CarryPlanInput { mergeCommits: number; rangeDiff: string; } -export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] } -export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string }; +export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } +export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; +const CARRY_COVERS = ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit']; +const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed']; /** * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else @@ -206,9 +209,10 @@ export function stampOnlyText(change: CarryChange): boolean { * the tree receipt. `change` carries the texts; a caller without them gets the * list-only answer. */ -export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: CarryChange): CarryClass { +export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[]; outputs?: readonly string[] } = {}, change?: CarryChange): CarryClass { const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change)); if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product'; + if (matchesAny(extra.outputs ?? [], rel)) return 'generated'; if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product'; if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test'; return 'product'; @@ -230,14 +234,45 @@ export function planCarry(input: CarryPlanInput): CarryPlan { if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); + if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs'); if (!reasons.length) reasons.push(input.changed.length ? 'no product change' : 'no content change'); return { ok: true, carry: { from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none', changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff, - covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'], - reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'], + covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'linear', + }, + }; +} + +export interface TreeCarryInput { + from: string; head: string; + /** HEAD^{tree}, and every tree the receipt already covers (its head_tree plus earlier carries' trees). */ + headTree: string; coveredTrees: readonly string[]; + /** The product patch-id against the base the receipt recorded, and the one HEAD has against the current base. */ + productPatchId: { recorded: string; head: string }; +} + +/** + * The carry across rewritten history (a rebase or force-push): the receipt + * follows HEAD only when HEAD's tree is one the receipt already covers and the + * product diff against the current base has the patch-id the receipt recorded. + * The tree alone is not enough: a stacked branch rebased onto main after its + * base closed unmerged keeps its tree while its diff grows by the base's code. + */ +export function planTreeCarry(input: TreeCarryInput): CarryPlan { + if (!input.coveredTrees.includes(input.headTree)) { + return { ok: false, code: 'RECEIPT_CARRY_TREE_MISMATCH', message: `the receipt's head ${input.from.slice(0, 12)} is not an ancestor of HEAD ${input.head.slice(0, 12)} (rebased or force-pushed), and HEAD's tree ${input.headTree.slice(0, 12)} is not a tree the receipt covers (${input.coveredTrees.map(t => t.slice(0, 12)).join(', ') || 'none recorded'}); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` }; + } + if (input.productPatchId.recorded !== input.productPatchId.head) { + return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `HEAD ${input.head.slice(0, 12)} has the reviewed tree, but its product diff against the current base differs from the one reviewed at ${input.from.slice(0, 12)} (the base itself moved under it); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` }; + } + return { + ok: true, + carry: { + from: input.from, reason: 'rebase onto a reviewed tree', product_patch_id: input.productPatchId.head || 'none', + changed_paths: [], merge_commits: 0, range_diff: 'tree-equal', covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'rewritten', }, }; } @@ -247,14 +282,17 @@ export function planCarry(input: CarryPlanInput): CarryPlan { * recorded. A receipt carried before keeps the reviewed head as `from` and adds * this hop's merges and paths, so the record always names the head a review saw. */ -export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt { +export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord, at: { tree: string; fromTree?: string; productPatchId: string }): ShipReceipt { const prior = receipt.carry; const merged: CarryRecord = !prior ? carry : { ...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`, merge_commits: prior.merge_commits + carry.merge_commits, changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50), + ancestry: prior.ancestry === 'rewritten' ? 'rewritten' : carry.ancestry, }; - return orderReceipt({ ...receipt, head, carry: merged }); + const fromTree = receipt.head_tree ?? at.fromTree; + const trees = [...new Set([...(prior?.trees ?? []), ...(fromTree ? [fromTree] : [])])].filter(t => t !== at.tree).slice(-20); + return orderReceipt({ ...receipt, head, head_tree: at.tree, product_patch_id: at.productPatchId || 'none', carry: { ...merged, ...(trees.length ? { trees } : {}) } }); } /** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */ diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index e3b96d77e..b9fa23a3b 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -48,8 +48,10 @@ describe('gstack-ship-receipt write', () => { const parsed = extractReceipt(ok.out); expect(parsed.found).toBe(true); expect(parsed.errors).toEqual([]); - expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history']); + expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id']); expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } }); + expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); + expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/); expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/); const written = fs.readFileSync(body, 'utf8'); expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true); diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts new file mode 100644 index 000000000..0c14f2551 --- /dev/null +++ b/test/ship-receipt-carry-rewrite.test.ts @@ -0,0 +1,128 @@ +/** + * gstack-ship-receipt carry across rewritten history and regenerated outputs (GSTA-147 PR 1, + * docs/ship-policy.md#receipt-carry): a rebase carries only onto a tree the receipt covers with the + * product diff it recorded, and a base merge that changes only a registered output's diff carries when + * the pregate for HEAD regenerated that output fresh. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, planTreeCarry } from '../lib/ship-receipt'; +import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) => + runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]); +const carry = (repo: FixtureRepo, body: string, extra: string[] = [], cwd = repo.work) => + runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', ...extra], {}, cwd); +function moveMain(repo: FixtureRepo, files: Record, message = 'main moves'): void { + repo.git('checkout', '-q', 'main'); + for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text); + repo.git('add', '-A'); repo.git('commit', '-qm', message); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); +} + +describe('carry across a rebase', () => { + // Value: protects=a rebase onto the reviewed tree keeps its receipt (Harbor paid ~60M for a byte-identical rebase); + // fails_when=carry demands ancestry, or needs the old commit object; seam=none; no_claim=gh pr edit (--body stands in) + test('a rebase onto the tree the receipt covers carries, from a fresh clone that never saw the old head', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + const reviewed = receiptIn(body); + expect(reviewed.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); + moveMain(repo, { 'docs/README.md': '# docs moved on main\n' }); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const merged = repo.git('rev-parse', 'HEAD'); + expect(carry(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body)).toMatchObject({ head: merged, head_tree: repo.git('rev-parse', 'HEAD^{tree}'), carry: { ancestry: 'linear', trees: [reviewed.head_tree] } }); + + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + repo.git('cherry-pick', reviewed.head); + const rebased = repo.git('rev-parse', 'HEAD'); + expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(repo.git('rev-parse', `${merged}^{tree}`)); + repo.git('push', '-q', '-f', 'origin', 'feature'); + const clone = path.join(repo.root, 'clone'); + expect(spawnSync('git', ['clone', '-q', '--single-branch', '--branch', 'main', repo.origin, clone], { env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['fetch', '-q', 'origin', 'feature:feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['checkout', '-q', 'feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['cat-file', '-e', `${merged}^{commit}`], { cwd: clone, timeout: 30_000 }).status).not.toBe(0); + + const out = carry(repo, body, [], clone); + expect(out.status).toBe(EXIT.ok); + expect(out.out).toContain(`CARRY: carried from ${merged.slice(0, 12)} to ${rebased.slice(0, 12)} (rebase onto a reviewed tree)`); + expect(receiptIn(body)).toMatchObject({ head: rebased, carry: { from: reviewed.head, reason: 'base merge + rebase onto a reviewed tree', ancestry: 'rewritten', range_diff: 'tree-equal' } }); + }, 90_000); + + // Value: protects=a rebase that changed the code takes a fresh /ship; fails_when=any non-ancestor head with a receipt + // carries; seam=none; no_claim=- + test('a rebase onto a different tree is RECEIPT_CARRY_TREE_MISMATCH and leaves the receipt unchanged', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + const head = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 1;\n" }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_TREE_MISMATCH)'); + expect(out.err).toContain(`HEAD's tree ${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} is not a tree the receipt covers`); + expect(out.err).toContain(`run /ship on ${head.slice(0, 12)}`); + expect(receiptIn(body).head).toBe(reviewed); + }, 60_000); + + // Value: protects=an identical tree on a moved base is not the reviewed diff (a stacked base closed unmerged); + // fails_when=tree equality alone carries; seam=planTreeCarry; no_claim=- + test('the reviewed tree on a base that moved under it is RECEIPT_CARRY_PRODUCT_CHANGED', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + moveMain(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }); + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + repo.git('checkout', reviewed, '--', '.'); + repo.git('commit', '-qam', 'same tree, new base'); + expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(receiptIn(body).head_tree); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(out.err).toContain('has the reviewed tree, but its product diff against the current base differs'); + expect(receiptIn(body).head).toBe(reviewed); + expect(planTreeCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), headTree: 't1', coveredTrees: ['t1'], productPatchId: { recorded: 'p1', head: 'p1' } })).toMatchObject({ ok: true, carry: { ancestry: 'rewritten' } }); + }, 60_000); +}); + +describe('carry across a base merge that changes a registered output', () => { + // Value: protects=U1's rerun (+28M): a base merge that changes only a generated output's diff carries once the regen + // check proves the output fresh; fails_when=outputs carry without regen evidence, or never carry; seam=none; no_claim=gh + test('the output is product without a regen-fresh pregate and metadata with one', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n' }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + moveMain(repo, { + 'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');fs.writeFileSync('generated/digest.md',`# Digest v${VERSION}\\n`);\n", + 'generated/digest.md': '# Digest v1.0.0\n', + }); + spawnSync('git', ['merge', '-q', '--no-edit', 'origin/main'], { cwd: repo.work, env: { ...process.env, ...repo.env }, timeout: 30_000 }); + write(repo.work, 'generated/digest.md', '# Digest v1.1.0\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; regenerate digest'); + + const bare = carry(repo, body, ['--dry-run']); + expect(bare.status).toBe(EXIT.refused); + expect(bare.err).toContain('generated/digest.md'); + expect(bare.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok); + const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json'); + expect(JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen')).toMatchObject({ status: 'pass' }); + const out = carry(repo, body, ['--pregate', pregateJson]); + expect(out.status).toBe(EXIT.ok); + expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } }); + }, 120_000); +}); From e97b8573502b785a5df3e9e8e05efe55a00656d3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:03:38 +0000 Subject: [PATCH 02/17] fix: regenerate doctor anchors; type the head_tree assert Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-doctor-components.sh | 1 + test/ship-receipt-carry-rewrite.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 221e5c993..af0815ec9 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -306,6 +306,7 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; + RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts index 0c14f2551..41c038d1c 100644 --- a/test/ship-receipt-carry-rewrite.test.ts +++ b/test/ship-receipt-carry-rewrite.test.ts @@ -87,7 +87,7 @@ describe('carry across a rebase', () => { repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); repo.git('checkout', reviewed, '--', '.'); repo.git('commit', '-qam', 'same tree, new base'); - expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(receiptIn(body).head_tree); + expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(receiptIn(body).head_tree ?? ''); const out = carry(repo, body); expect(out.status).toBe(EXIT.refused); expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); From 1ec488583e9d5360e0f976020f67d03baa3abbb4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:08:53 +0000 Subject: [PATCH 03/17] feat(review): proportional /ship review depth (light, standard, full) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-review-plan | 79 ++++--- docs/review-policy.md | 99 ++++++-- lib/review-collect.ts | 137 +++++++++++ lib/review-depth.ts | 219 ++++++++++++++---- scripts/resolvers/review-army.ts | 2 +- scripts/review-dispatch-compare.ts | 23 +- ship/SKILL.md | 10 +- ship/SKILL.md.tmpl | 10 +- ship/sections/adversarial.md | 3 +- ship/sections/adversarial.md.tmpl | 3 +- ship/sections/review-army.md | 25 +- ship/sections/review-army.md.tmpl | 23 +- test/fixtures/golden/agy-ship-SKILL.md | 10 +- test/fixtures/golden/claude-ship-SKILL.md | 10 +- test/fixtures/golden/codex-ship-SKILL.md | 10 +- test/fixtures/golden/factory-ship-SKILL.md | 10 +- test/review-collect.test.ts | 110 +++++++++ test/review-depth.test.ts | 130 ++++++++++- test/review-dispatch-compare.test.ts | 2 +- test/review-plan-cli.test.ts | 48 ++++ .../ship/gsta147-review-depth.json | 4 + 21 files changed, 802 insertions(+), 165 deletions(-) create mode 100644 lib/review-collect.ts create mode 100644 test/review-collect.test.ts create mode 100644 test/size-ledger/ship/gsta147-review-depth.json diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a5424c25a..06f3e3976 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -4,20 +4,25 @@ * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] - * [--reviewed-numbers ] [--no-tiers] [--json] + * [--reviewed-numbers ] [--depth-floor standard|full] [--no-tiers] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * - * Reads the diff against the merge base with (committed, working tree and - * untracked), runs gstack-diff-scope and, unless --gate-tagged is given, - * gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per - * reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts. + * Reads the diff against the merge base with (committed, tracked uncommitted + * edits and untracked files, through lib/review-collect.ts collectLive), runs + * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints + * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and + * result codes), GATE_SCAN:, REVIEW_PLAN:, one DEPTH_ERROR: per input that failed + * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per + * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full + * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts. */ -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; -import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth'; +import { collectLive } from '../lib/review-collect'; +import { diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, type Depth, type History } from '../lib/review-depth'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) @@ -25,19 +30,23 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --base the base branch (merge base with HEAD is the diff base); required --pass full review passes already run in this invocation (default 0) --last none|full|fix-check the step that just finished (default none: plan pass 1) - --fixed-blocking the finished step fixed a blocking finding + --fixed-blocking the finished step fixed a blocking finding (raises light to standard) --fixed-below the finished step fixed findings below the floor --blocking-open a blocking finding is still open in fix scope --touched-gate a fix touched a gate-class path - --critical-found a specialist reported a CRITICAL finding (Red Team on code class) + --critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard) --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE --force a,b|all specialists forced by prompt flags (--all-specialists is all) --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed + --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) - --json the plan as JSON + --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files + +Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). A full plan +from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; const args = process.argv.slice(2); @@ -65,34 +74,17 @@ function main(): number { if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; } const pass = Number(flag('--pass') ?? '0'); if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } + const floor = flag('--depth-floor') as Depth | undefined; + if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); - const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base; - const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim(); - if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; } - const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]); - const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']); - const numstatRaw = git(['diff', '--numstat', mergeBase]); - if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; } - const scratch = (p: string) => p.startsWith('.gstack/tmp/'); - const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? '')); - const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p)); - const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort(); - const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!); - const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => { - const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)); - return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l)); - }); - const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0) - + untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0); - const added: Record = {}; - let current = ''; - for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) { - if (line.startsWith('+++ b/')) { current = line.slice(6); continue; } - if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1)); + const collected = collectLive(root, base); + if (collected.errors.length) { + for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`); + return EXIT.fail; } - for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } } + const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown'; const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 }); const scope: Record = {}; @@ -109,14 +101,25 @@ function main(): number { const force = flag('--force') === 'all' ? 'all' as const : list('--force'); const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 }); const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light'; + const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected; const plan = planReview({ - paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'), + ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') }, }); - if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; } - const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; - for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`); + const gateScan = { tagged: gateTagged, head }; + if (has('--json')) { + const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; + process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); + return EXIT.ok; + } + const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; + for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); + for (const d of plan.dispatches) { + const key = d.name.split(' ')[0]; + out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`); + if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); + } for (const s of plan.skipped) out.push(`SKIP: ${s}`); out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`); process.stdout.write(out.join('\n') + '\n'); diff --git a/docs/review-policy.md b/docs/review-policy.md index 149abc89b..c6847c73f 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -128,26 +128,75 @@ applies no edits is its exit too. ## Review depth by diff class -`gstack-review-plan --base ` classifies the diff and prints the reviewers -the next step dispatches (`REVIEW_CLASS:`, `REVIEW_PLAN:`, one `DISPATCH:` per -reviewer with its effort, one `SKIP:` per reviewer left out, `--json`). A mixed -diff takes its most restrictive class; a `gstack-diff-scope` error fails closed -to code, and code with the gate scan unavailable fails closed to gate. +`gstack-review-plan --base ` classifies the diff, picks a **depth** and prints +the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH: +runtime_lines= rules= reasons=<…> codes=<…>`, `GATE_SCAN: tagged=|unavailable +head=`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer +with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: none` when it +reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields. +A plan that fails closed to full exits 0; a diff git cannot list exits 1. -| Class | What it is | Pass 1 dispatches | +**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs** +(markdown no agent or gate reads), **tests** (test files and fixtures), **code** +(everything else, including every `SKILL.md` and template, markdown in a skill +directory, `*/sections/**`, `scripts/resolvers/**`, rule docs, checklists and bins) and +**gate** (code plus auth or migration scope, a hunk the gate scan tags, a deleted code +or test path, CI workflows, credential, state-root and the paths below). + +**Runtime lines** are the changed lines of code-class paths, minus outputs the base +commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never +runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. + +| Depth | When | Pass 1 dispatches | |---|---|---| -| **docs** | markdown no agent or gate reads: README, CHANGELOG, TODOS, how-to and explanation docs | the parent's core checklist only | -| **tests** | test files and fixtures only | core plus the testing specialist | -| **code** | everything else, including every `SKILL.md` and template, any markdown in a skill directory, `*/sections/**`, `scripts/resolvers/**`, `docs/*-policy.md`, `docs/captain.md`, `docs/unattended.md`, `docs/pregate.md`, checklists, specialists and bins | specialists by scope (as before), the native adversarial pass, the outside voice; the Red Team past 200 lines or after a CRITICAL | -| **gate** | code plus auth or migration scope, a hunk the gate scan tags (an RH rule: a relaxed threshold, a skipped test), a deleted code file, CI workflows, credential or state-root paths | code's set, plus the Red Team always, at full effort, and no hit-rate gating | +| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | +| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (testing, maintainability and security checklists, the specialists scope selects and the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | +| **standard** | more than 50 runtime lines | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything | +| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating | -**Numbers keep the outside voice.** On a docs or tests diff the outside voice is -skipped unless the diff publishes numeric claims: `gstack-review-plan` scans the -added lines of every path for percentages, money, multipliers, durations, token -or credit counts and score, rate or speed claims (`NUMBERS: yes`), and a -claim always dispatches it. The claims' fingerprint (`fingerprint=`) is passed -back as `--reviewed-numbers`; when a later fix changes a claim, only the -outside voice reruns, on the numbers. +A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to +standard for the next step (`step=standard`): its specialists read at least 50 lines +and the Red Team runs, so a raise never dispatches less than light did. A fix-check +is the same at every depth. + +**Fail closed.** Each of these makes the depth full and is listed in `errors[]` with +the input it could not read, a sanitized cause and the repair: a `gstack-diff-scope` +error, a gate scan that is unavailable, a base registry that cannot be read or is +invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat git +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/` commit for live /ship, the receipt's base sha for the queue), 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: + +```json +{ + "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", + "entries": [ + { "id": "skill-docs", "command": "bun run gen:skill-docs --host all", "outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "*/sections/*.tmpl"] } + ] +} +``` + +**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, +`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, +`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or +`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets +the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, +not file bytes, so a comment edit keeps it. + +**`--depth-floor standard|full`** (a /ship prompt flag, passed to every plan run) +raises the depth and never lowers it. + +**Numbers keep the outside voice.** At light depth the outside voice runs only when +the diff publishes numeric claims: `gstack-review-plan` scans the added lines of +prose and data files for percentages, money, multipliers, durations, token or credit +counts and score, rate or speed claims (`NUMBERS: yes`). The claims' fingerprint +(`fingerprint=`) is passed back as `--reviewed-numbers`; when a later fix changes a +claim, only the outside voice reruns, on the numbers. It always reads the whole diff. **The adversarial and outside voices run once per invocation** (Step 11, on the final tree), as the last full-pass plan lists them; after they ran, decision 2 @@ -166,6 +215,15 @@ default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs the audits and watchers at the host default too ([model-policy.md](model-policy.md#reasoning-effort)). +### Behavior changes + +- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the + core checklist and the gate scan review it. +- Light no longer runs a separate Red Team: the batched specialist carries its + prompt. At runtime 0 light runs no adversarial pass either. +- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy + diff with few runtime lines dispatches fewer specialists. + ## What the receipt records `gstack-review-scope summary --since ` reads this invocation's @@ -268,9 +326,10 @@ trailers, appended }`. ## Where this is enforced -- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. -- `ship/sections/review-army.md` Step 9: passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. -- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. +- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. +- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`). - `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)). - `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line. - `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port. diff --git a/lib/review-collect.ts b/lib/review-collect.ts new file mode 100644 index 000000000..b6c236a83 --- /dev/null +++ b/lib/review-collect.ts @@ -0,0 +1,137 @@ +/** + * review-collect — the git reads behind a review depth (docs/review-policy.md#review-depth), + * kept out of lib/review-depth.ts so the policy stays pure. collectCommit reads git + * objects only (a range between two commits, from any checkout, dirty or not); + * collectLive reads the working tree as /ship reviews it (committed, tracked + * uncommitted edits and untracked files). Both return a ClassInput with per-path + * line counts, the base commit's generated-output registry and collector errors; + * the caller adds the gate-scan result. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { GENERATED_PATH, parseRegistry } from './regen'; +import { RESULT_CODES } from './result-codes'; +import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth'; + +export interface Collected extends ClassInput { + perPath: PathLines[]; + registry: RegistryInfo; + errors: DepthError[]; + deleted: string[]; + stampOnly: string[]; + /** Added lines per path, for scanNumbers. */ + added: Record; + diffLines: number; + mergeBase: string | null; + untracked: string[]; +} + +function git(root: string, args: string[]): { ok: boolean; out: string; err: string } { + const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); + return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; +} +const scratch = (p: string) => p.startsWith('.gstack/tmp/'); +const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200); +const unavailable = (input: string, detail: string): RegistryInfo => ({ sha: null, outputs: [], error: { code: 'registry_unavailable', detail: oneLine(detail), input, repair: 'fetch the base branch (git fetch origin ) so its commit and registry resolve' } }); + +/** The registry as committed at `sha`: outputs of entries with a command (a `command: null` entry never excludes); a missing file is an empty registry. */ +export function loadRegistryAt(root: string, sha: string): RegistryInfo { + const commit = git(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); + if (!commit.ok) return unavailable(`${sha}:${GENERATED_PATH}`, `${sha} is not a commit in this repository`); + const resolved = commit.out.trim(); + const input = `${resolved.slice(0, 12)}:${GENERATED_PATH}`; + if (!git(root, ['cat-file', '-e', `${resolved}:${GENERATED_PATH}`]).ok) return { sha: resolved, outputs: [] }; + const show = git(root, ['show', `${resolved}:${GENERATED_PATH}`]); + if (!show.ok) return unavailable(input, show.err || 'git show failed'); + const { registry, errors } = parseRegistry(show.out); + const e = errors[0]; + if (e) return { sha: resolved, outputs: [], error: { code: 'registry_invalid', detail: oneLine(`${e.path || GENERATED_PATH}: ${e.message}`), input, repair: RESULT_CODES[e.code].fix, result: e.code } }; + return { sha: resolved, outputs: registry.entries.filter(x => x.command !== null).flatMap(x => x.outputs) }; +} + +/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */ +function readDiff(root: string, range: string[], errors: DepthError[]): Pick { + const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]); + const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]); + const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]); + const empty = { perPath: [], deleted: [], stampOnly: [], added: {} }; + for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) { + if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); return empty; } + } + const perPath: PathLines[] = []; + for (const rec of numstat.out.split('\0').filter(Boolean)) { + const [a, d, ...rest] = rec.split('\t'); + const p = rest.join('\t'); + if (!scratch(p)) perPath.push({ path: p, lines: (Number(a) || 0) + (Number(d) || 0), binary: a === '-' && d === '-' }); + } + const statusParts = status.out.split('\0'); + const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p)); + const added: Record = {}; + const changed: Record = {}; + let cur = ''; + let minus = ''; + let inHunk = false; + for (const line of patch.out.split('\n')) { + if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; } + if (!inHunk) { + if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, ''); + else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, ''); + else if (line.startsWith('@@')) inHunk = true; + continue; + } + if (!cur || scratch(cur) || line.startsWith('@@')) continue; + 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))); + return { perPath, deleted, stampOnly, added }; +} + +function result(diff: ReturnType, extra: Pick): Collected { + const paths = diff.perPath.map(e => e.path).sort(); + return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; +} + +/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */ +export function collectCommit(root: string, baseSha: string, headSha: string): Collected { + const errors: DepthError[] = []; + const registry = loadRegistryAt(root, baseSha); + const mb = git(root, ['merge-base', baseSha, headSha]); + if (!mb.ok) { + errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] }); + } + const mergeBase = mb.out.trim(); + const diff = readDiff(root, [mergeBase, headSha], errors); + const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]); + const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort(); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] }); +} + +/** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */ +export function collectLive(root: string, base: string): Collected { + const errors: DepthError[] = []; + const ref = git(root, ['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]).ok ? `origin/${base}` : base; + const registry = loadRegistryAt(root, ref); + const mb = git(root, ['merge-base', ref, 'HEAD']); + if (!mb.ok) { + errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] }); + } + const mergeBase = mb.out.trim(); + const diff = readDiff(root, [mergeBase], errors); + const others = git(root, ['ls-files', '--others', '--exclude-standard', '-z']); + if (!others.ok) errors.push({ code: 'numstat_failed', detail: oneLine(others.err || 'git ls-files failed'), input: 'git ls-files --others --exclude-standard', repair: 'run the command by hand and fix what git reports' }); + const untracked = others.out.split('\0').filter(p => p && !scratch(p)).sort(); + for (const p of untracked) { + let buf: Buffer; + try { buf = fs.readFileSync(path.join(root, p)); } catch { diff.perPath.push({ path: p, lines: 0, binary: true }); continue; } + const binary = buf.subarray(0, 8000).includes(0); + const text = binary ? '' : buf.toString('utf8'); + const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0; + diff.perPath.push({ path: p, lines, binary }); + if (!binary) diff.added[p] = text.split('\n'); + } + return result(diff, { root, registry, errors, mergeBase, untracked }); +} diff --git a/lib/review-depth.ts b/lib/review-depth.ts index f7331dbba..0cae65d45 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -1,17 +1,22 @@ /** * review-depth — the one policy owner for how much review a /ship run dispatches - * (docs/review-policy.md#review-depth). A pure function from the changed paths, - * gstack-diff-scope flags, the diff size, the gate scan and the pass history to a - * diff class, the next step of the loop and the reviewers that step dispatches, - * each with its reasoning-effort tier. bin/gstack-review-plan prints it; the + * (docs/review-policy.md#review-depth). A pure function from the changed paths and + * their line counts, gstack-diff-scope flags, the base registry's generated outputs, + * the gate scan and the pass history to a diff class, a review depth (light, + * standard, full), the next step of the loop and the reviewers that step + * dispatches, each with its effort tier and the pathspecs it leaves out. Git reads + * live in lib/review-collect.ts; bin/gstack-review-plan prints the plan; the * review-army resolver and the skills read the same definitions. */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { ResultCodeName } from './result-codes'; +import { matchesAny } from './ship-policy'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; +export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; export type Step = 'full' | 'fix-check' | 'done' | 'stop'; @@ -22,7 +27,12 @@ export const FULL_PASS_CAP = 2; export const SMALL_DIFF_LINES = 50; export const RED_TEAM_LINES = 200; export const LARGE_DIFF_LINES = 100; +/** The checklists light's batched specialist always reads, whatever the scope. */ +export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security']; +/** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */ +export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)'; const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 }; +const DEPTH_RANK: Record = { light: 0, standard: 1, full: 2 }; const AGENT_PROSE = [/(^|\/)SKILL\.md(\.tmpl)?$/, /(^|\/)sections\//, /^scripts\/resolvers\//, /^docs\/[^/]*-policy\.md$/, /^docs\/(captain|unattended|pregate)\.md$/, /(^|\/)DRIVER\.md(\.tmpl)?$/, /(^|\/)templates\//, /(^|\/)(AGENTS|CLAUDE|ETHOS)\.md$/, /(^|\/)specialists\//, /(^|\/)checklist\.md$/, /^\.gstack\//]; /** gstack-diff-scope's TEST_PATH_RE (the test-bootstrap rule), plus fixture directories. */ @@ -30,19 +40,41 @@ const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i; /** Release metadata a version stamp writes: never a reason for review depth on its own. */ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//]; -const GATE_PATH = [/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/]; +/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */ +const GATE_PATH = [ + /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/, + /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, +]; -/** One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, templates, any markdown in a skill directory under `root`) is code: it is gstack's product. */ -export function classifyPath(p: string, root?: string): PathClass { +/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ +export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ + SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), +})).digest('hex').slice(0, 16); + +/** + * One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, + * templates, any markdown in a skill directory) is code: it is gstack's product. `root` is the repository root + * (skill directories probed on disk) or the top-level skill directory names read from a commit. + */ +export function classifyPath(p: string, root?: string | readonly string[]): PathClass { if (RELEASE_METADATA.some(r => r.test(p))) return 'docs'; if (AGENT_PROSE.some(r => r.test(p))) return 'code'; if (TEST_PATH.some(r => r.test(p))) return 'tests'; if (!DOC_PATH.test(p)) return 'code'; const top = p.includes('/') ? p.split('/')[0]! : ''; - if (root && top && ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f)))) return 'code'; - return 'docs'; + if (!top || !root) return 'docs'; + if (typeof root === 'string') return ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f))) ? 'code' : 'docs'; + return root.includes(top) ? 'code' : 'docs'; } +export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path'; +/** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */ +export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName } +export interface PathLines { path: string; lines: number; binary: boolean } +/** The base commit's `.gstack/generated.json`: outputs of entries with a command only; `error` when it could not be read or validated. */ +export interface RegistryInfo { sha: string | null; outputs: readonly string[]; error?: DepthError } + export interface ClassInput { paths: readonly string[]; /** gstack-diff-scope output (SCOPE_* keys). */ @@ -53,23 +85,38 @@ export interface ClassInput { deleted?: readonly string[]; /** Paths whose whole change is a version stamp (package.json's version key): classified as docs. */ stampOnly?: readonly string[]; - /** The repository root: markdown inside a skill directory there is code. */ - root?: string; + /** The repository root, or the skill directory names at the reviewed commit: markdown inside a skill directory is code. */ + root?: string | readonly string[]; + /** Changed lines per path; without it a code diff counts every line as runtime. */ + perPath?: readonly PathLines[]; + registry?: RegistryInfo; + /** Collector failures (a diff git could not list): each fails the depth closed. */ + errors?: readonly DepthError[]; } -export interface ClassResult { class: DiffClass; reasons: string[] } +export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } -/** The diff's class: the most restrictive of its paths, raised to gate by any gate signal; errors fail closed. */ +const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); +const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); +const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root)); + +/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { const reasons: string[] = []; + const errors: DepthError[] = [...(i.errors ?? [])]; let cls: DiffClass = 'docs'; const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); }; if (!i.paths.length) raise('docs', 'no changed paths'); - for (const p of i.paths) { const c = (i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } + for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } const s = i.scope ?? {}; - if (s.SCOPE_ERROR) raise('code', `scope error: ${s.SCOPE_ERROR}`); + if (s.SCOPE_ERROR) { + raise('code', `scope error: ${s.SCOPE_ERROR}`); + errors.push({ code: 'scope_error', detail: sanitize(s.SCOPE_ERROR), input: 'gstack-diff-scope ', repair: 'run gstack-diff-scope by hand and fix what it reports' }); + } if (CLASS_RANK[cls] >= CLASS_RANK.tests) { - if (i.gateTagged === null) raise('gate', 'gate scan unavailable'); - else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); + if (i.gateTagged === null) { + raise('gate', 'gate scan unavailable'); + errors.push({ code: 'gate_scan_unavailable', detail: 'gstack-gate-diff reported no GATE_SUMMARY', input: 'gstack-gate-diff ', repair: 'run gstack-gate-diff and read its GATE_ERROR line, or pass the recorded scan as --gate-tagged ' }); + } else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); const deletedGuarded = (i.deleted ?? []).filter(p => classifyPath(p, i.root) !== 'docs'); if (deletedGuarded.length) raise('gate', `deletes ${deletedGuarded.length} code or test path(s)`); } @@ -77,8 +124,38 @@ export function classifyDiff(i: ClassInput): ClassResult { if (s.SCOPE_AUTH === 'true') raise('gate', 'auth paths'); if (s.SCOPE_MIGRATIONS === 'true') raise('gate', 'migrations'); for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; } + if (i.registry?.error) errors.push(i.registry.error); } - return { class: cls, reasons }; + if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null }; + let runtimeLines = 0; + for (const e of i.perPath) { + if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue; + if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" }); + else runtimeLines += e.lines; + } + return { class: cls, reasons, errors, runtimeLines }; +} + +/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */ +export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth { + const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light'; + return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d; +} + +export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string } + +function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult { + const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors }); + const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`]; + if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`); + const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))]; + return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES }; +} + +/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */ +export function depthOfCommitInput(input: ClassInput & { floor?: Depth }): DepthResult { + const c = classifyDiff(input); + return decideDepth(c, c.runtimeLines ?? 0, input.floor); } // --------------------------------------------------------------------------- @@ -153,34 +230,48 @@ export interface PlanInput extends ClassInput { force?: readonly string[] | 'all'; /** Specialists the hit-rate stats tag GATE_CANDIDATE. */ gated?: readonly string[]; - /** A specialist produced a CRITICAL finding (Red Team activation on code class). */ + /** A specialist produced a CRITICAL finding (Red Team activation on code class; raises light to standard). */ criticalFound?: boolean; /** Step 11's native adversarial pass already ran in this invocation. */ adversarialRan?: boolean; /** The numeric-claim fingerprint the outside voice last reviewed; a change reruns it. */ reviewedNumbers?: string; + /** --depth-floor: raises the depth, never lowers it. */ + floor?: Depth; } -export interface Dispatch { role: Role; name: string; effort: Effort } -export interface ReviewPlan { class: DiffClass; reasons: string[]; next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[] } +/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */ +export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string } +export interface ReviewPlan extends DepthResult { + /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */ + stepDepth: Depth; + depthReasons: string[]; + next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; +} + +const scopeWants: Partial) => boolean>> = { + performance: s => s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true', + 'data-migration': s => s.SCOPE_MIGRATIONS === 'true', + 'api-contract': s => s.SCOPE_API === 'true', + design: s => s.SCOPE_FRONTEND === 'true', + 'state-machines': s => s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined, +}; +const forcedBy = (i: PlanInput) => (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n)); /** The specialists a full pass selects for a class: class sets the eligible set, hit-rate gating applies inside it, gate class ignores gating. */ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] { const s = i.scope ?? {}; - const forced = (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n)); + const forced = forcedBy(i); if (cls === 'docs') { skipped.push('specialists (docs class)'); return SPECIALISTS.filter(forced); } if (cls === 'tests') { skipped.push('specialists except testing (tests class)'); return SPECIALISTS.filter(n => n === 'testing' || forced(n)); } const want = (n: Specialist): boolean => { if (forced(n)) return true; - if (n === 'state-machines') return s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined; + if (n === 'state-machines') return scopeWants[n]!(s); if (i.diffLines < SMALL_DIFF_LINES) return false; switch (n) { case 'testing': case 'maintainability': return true; case 'security': return s.SCOPE_AUTH === 'true' || (s.SCOPE_BACKEND === 'true' && i.diffLines > LARGE_DIFF_LINES); - case 'performance': return s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true'; - case 'data-migration': return s.SCOPE_MIGRATIONS === 'true'; - case 'api-contract': return s.SCOPE_API === 'true'; - case 'design': return s.SCOPE_FRONTEND === 'true'; case 'simplification': return i.diffLines > LARGE_DIFF_LINES; + default: return scopeWants[n]!(s); } }; return SPECIALISTS.filter(n => { @@ -190,34 +281,76 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = }); } -/** The reviewers the next step dispatches, with each role's effort tier. */ +/** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */ +const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`; +const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; + +/** The diff command a dispatched reviewer runs; `` is the literal sha the parent writes in. */ +export function diffCommand(d: Dispatch): string { + return d.exclude.length ? `git diff -- . ${d.exclude.map(shellQuote).join(' ')}` : 'git diff '; +} + +/** The reviewers the next step dispatches at its depth, with each role's effort tier and excluded pathspecs. */ export function planReview(i: PlanInput): ReviewPlan { - const { class: cls, reasons } = classifyDiff(i); + const c = classifyDiff(i); + const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor); + const { class: cls, runtimeLines } = depth; const next = nextStep(i.history); + const h = i.history; + const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound); + const stepDepth: Depth = raise ? 'standard' : depth.depth; + const depthReasons = [...depth.reasons, ...(raise ? [`history:${h.fixedBlocking ? 'fixed-blocking' : h.blockingOpen ? 'blocking-open' : 'critical-found'}`] : [])]; const skipped: string[] = []; const dispatches: Dispatch[] = []; + const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => dispatches.push({ role, name, effort, exclude, ...extra }); + const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec); + const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec); const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint; - if (next.step === 'full') { - for (const n of specialistsFor(cls, i, skipped)) dispatches.push({ role: 'specialist', name: n, effort: 'full' }); - const redTeam = cls === 'gate' || (cls === 'code' && (i.diffLines > RED_TEAM_LINES || !!i.criticalFound)); - if (redTeam) dispatches.push({ role: 'red-team', name: 'red-team', effort: 'full' }); - else skipped.push(`red-team (${cls} class${cls === 'code' ? `, ${i.diffLines} lines, no critical` : ''})`); - if (i.history.fullPasses === 0 || (!i.adversarialRan && (cls === 'code' || cls === 'gate'))) { - if (cls === 'docs' || cls === 'tests') skipped.push(`adversarial-native (${cls} class)`); - else dispatches.push({ role: 'adversarial-native', name: 'adversarial', effort: 'full' }); - if (cls === 'code' || cls === 'gate' || i.numbers.publishes) dispatches.push({ role: 'outside-voice', name: 'outside-voice', effort: 'host' }); - else skipped.push(`outside-voice (${cls} class, no numeric claims)`); + const firstVoices = h.fullPasses === 0 || !i.adversarialRan; + if (next.step === 'full' && stepDepth === 'light') { + const forced = forcedBy(i); + if (runtimeLines > 0) { + const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n)))); + add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] }); + skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)'); + if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON }); + } else { + for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full'); + skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)'); + } + if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); + else if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); + else skipped.push(`outside-voice (${cls} class, no numeric claims)`); + } else if (next.step === 'full') { + const full = stepDepth === 'full'; + const dcls: DiffClass = full ? 'gate' : cls; + const raised = !full && runtimeLines <= SMALL_DIFF_LINES; + const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0); + for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]); + const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised)); + if (redTeam) add('red-team', 'red-team', 'full'); + else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`); + const codeLike = dcls === 'code' || dcls === 'gate'; + if (h.fullPasses === 0 || (!i.adversarialRan && codeLike)) { + if (codeLike) add('adversarial-native', 'adversarial', 'full'); + else skipped.push(`adversarial-native (${dcls} class)`); + if (codeLike || i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); + else skipped.push(`outside-voice (${dcls} class, no numeric claims)`); } else { skipped.push('adversarial-native and outside-voice (run once per invocation)'); - if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' }); + if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } } else if (next.step === 'fix-check') { - dispatches.push({ role: 'fix-check', name: 'fix-check', effort: 'full' }); - if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' }); + add('fix-check', 'fix-check', 'full'); + if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } - return { class: cls, reasons, next, numbers: i.numbers, dispatches, skipped }; + return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped }; } export function reviewPlanLine(p: ReviewPlan, pass: number): string { return `REVIEW_PLAN: class=${p.class} pass=${pass} next=${p.next.step} dispatches=${p.dispatches.length} numbers=${p.numbers.publishes ? `yes(${p.numbers.claims})` : 'no'} fingerprint=${p.numbers.fingerprint}`; } + +export function reviewDepthLine(p: ReviewPlan): string { + return `REVIEW_DEPTH: ${p.depth} runtime_lines=${p.runtimeLines} rules=${p.rules} reasons=${p.depthReasons.join(',')} codes=${p.codes.join(',') || 'none'}${p.stepDepth !== p.depth ? ` step=${p.stepDepth}` : ''}`; +} diff --git a/scripts/resolvers/review-army.ts b/scripts/resolvers/review-army.ts index 1c174ac94..f4142ee0e 100644 --- a/scripts/resolvers/review-army.ts +++ b/scripts/resolvers/review-army.ts @@ -139,7 +139,7 @@ Every other specialist, including Testing, reads the full diff with fixtures. 5. Instructions: "You are a specialist code reviewer. Read the checklist at {checklist path}, then run -\`${diffBase(ctx)} && git diff "$DIFF_BASE"\` to get the ${ctx.skillName === 'ship' ? 'diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable)' : 'full diff'}. Apply the checklist against the diff. +\`${diffBase(ctx)} && git diff "$DIFF_BASE"${ctx.skillName === 'ship' ? ' ' : ''}\` to get the ${ctx.skillName === 'ship' ? 'diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable; ``: everything after `` on this reviewer\'s `DIFF_CMD:` line, verbatim; empty on `DIFF_EXCLUDE: none`)' : 'full diff'}. Apply the checklist against the diff. \`NO FINDINGS\` alone is the expected, complete result for a clean diff; never invent an item. Otherwise output one JSON object per finding per line, 400 words at most: {\\"severity\\":\\"CRITICAL|INFORMATIONAL\\",\\"confidence\\":N,\\"path\\":\\"file\\",\\"line\\":N,\\"category\\":\\"category\\",\\"summary\\":\\"description\\",\\"fix\\":\\"recommended fix\\",\\"fingerprint\\":\\"path:line:category\\",\\"specialist\\":\\"name\\"} diff --git a/scripts/review-dispatch-compare.ts b/scripts/review-dispatch-compare.ts index 966e4e1e6..d38198e64 100755 --- a/scripts/review-dispatch-compare.ts +++ b/scripts/review-dispatch-compare.ts @@ -17,7 +17,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { runScriptSync } from '../lib/script-invocation'; -import { planReview, specialistsFor, type DiffClass, type Dispatch, type History, type NumbersResult } from '../lib/review-depth'; +import { planReview, specialistsFor, type DiffClass, type History, type NumbersResult, type PathLines } from '../lib/review-depth'; const ROOT = path.resolve(import.meta.dir, '..'); const BYTES_PER_TOKEN = 4; @@ -30,7 +30,8 @@ const DEFAULT_OUT: Record = { 'outside-voice': { full: 6000, light: 6000 }, 'fix-check': { full: 4000, light: 2000 }, audit: { full: 5000, light: 2500 }, 'autoplan-voice': { full: 9000, light: 4500 }, }; -export interface Fixture { name: string; commit: string; paths: string[]; deleted: string[]; stamp_only?: string[]; scope: Record; gate_tagged: number | null; diff_lines: number; diff_bytes: number; numbers: NumbersResult } +/** per_path and registry_outputs (captured since GSTA-147) feed the runtime-line count; an older fixture counts every code-class line as runtime. */ +export interface Fixture { name: string; commit: string; paths: string[]; deleted: string[]; stamp_only?: string[]; scope: Record; gate_tagged: number | null; diff_lines: number; diff_bytes: number; numbers: NumbersResult; per_path?: PathLines[]; registry_outputs?: string[] } /** clean: pass 1 finds nothing; nits: pass 1 fixes below-floor findings; blocker: pass 1 fixes a blocker; adversarial-fix: Step 11 finds a should that is fixed; fix-regression: a pass-1 fix introduces a blocker the next step finds. */ type Scenario = 'clean' | 'nits' | 'blocker' | 'adversarial-fix' | 'fix-regression'; const SCENARIOS: Scenario[] = ['clean', 'nits', 'blocker', 'adversarial-fix', 'fix-regression']; @@ -62,7 +63,7 @@ function legacyShip(f: Fixture, sc: Scenario): Call[] { } function base(f: Fixture) { - return { paths: f.paths, deleted: f.deleted, stampOnly: f.stamp_only ?? [], scope: f.scope, gateTagged: f.gate_tagged, diffLines: f.diff_lines, numbers: f.numbers }; + return { paths: f.paths, deleted: f.deleted, stampOnly: f.stamp_only ?? [], scope: f.scope, gateTagged: f.gate_tagged, diffLines: f.diff_lines, numbers: f.numbers, perPath: f.per_path, registry: f.registry_outputs ? { sha: null, outputs: f.registry_outputs } : undefined }; } /** The new policy, step by step through lib/review-depth's own transition table. */ @@ -121,10 +122,10 @@ function capture(sha: string, name: string, out: string): number { if (r.status !== 0) throw new Error(`gstack-review-plan failed: ${r.stderr}`); const plan = JSON.parse(r.stdout); const diffBytes = Buffer.byteLength(git(['diff', `${sha}^`, sha], dir).stdout ?? ''); - const fixture: Fixture = { name, commit: sha, paths: plan.paths, deleted: plan.deleted, stamp_only: plan.stamp_only, scope: plan.scope, gate_tagged: plan.gate_tagged, diff_lines: plan.diff_lines, diff_bytes: diffBytes, numbers: plan.numbers }; + const fixture: Fixture = { name, commit: sha, paths: plan.paths, deleted: plan.deleted, stamp_only: plan.stamp_only, scope: plan.scope, gate_tagged: plan.gate_tagged, diff_lines: plan.diff_lines, diff_bytes: diffBytes, numbers: plan.numbers, per_path: plan.per_path, registry_outputs: plan.registry.outputs }; fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n'); - process.stdout.write(`CAPTURED: ${name} ${sha} class=${plan.class} paths=${plan.paths.length} lines=${plan.diff_lines} -> ${out}\n`); + process.stdout.write(`CAPTURED: ${name} ${sha} class=${plan.class} depth=${plan.depth} runtime=${plan.runtime_lines} paths=${plan.paths.length} lines=${plan.diff_lines} -> ${out}\n`); return 0; } finally { git(['worktree', 'remove', '--force', dir]); @@ -135,12 +136,13 @@ function capture(sha: string, name: string, out: string): number { export function compare(fixtures: Fixture[], out: typeof DEFAULT_OUT) { const rows: Array> = []; for (const f of fixtures) { - const cls: DiffClass = planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class; + const first = planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }); + const cls: DiffClass = first.class; for (const sc of SCENARIOS) { const legacy = legacyShip(f, sc); const next = newShip(f, sc); const lt = tokens(legacy, out); const nt = tokens(next.calls, out); - rows.push({ fixture: f.name, class: cls, scenario: sc, legacy_dispatches: legacy.length, new_dispatches: next.calls.length, legacy_tokens: lt, new_tokens: nt, ratio: Math.round((nt / lt) * 100) / 100, ending: next.ending }); + rows.push({ fixture: f.name, class: cls, depth: first.depth, runtime_lines: first.runtimeLines, scenario: sc, legacy_dispatches: legacy.length, new_dispatches: next.calls.length, legacy_tokens: lt, new_tokens: nt, ratio: Math.round((nt / lt) * 100) / 100, ending: next.ending }); } } return rows; @@ -158,8 +160,8 @@ function main(argv: string[]): number { const rows = compare(fixtures, out); const ap = fixtures.map(f => ({ fixture: f.name, runs: autoplanRuns(planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class === 'docs') })); if (argv.includes('--json')) { process.stdout.write(JSON.stringify({ tokens_source: tokFile ?? 'DEFAULT', ship: rows, autoplan: ap }) + '\n'); return 0; } - const lines = [`TOKENS: ${tokFile ? `measured (${tokFile})` : 'DEFAULT estimates (pass --tokens for measured output)'}`, '', '| fixture | class | scenario | /ship dispatches legacy → new | est. tokens legacy → new | ratio | ending |', '|---|---|---|---|---|---|---|']; - for (const r of rows) lines.push(`| ${r.fixture} | ${r.class} | ${r.scenario} | ${r.legacy_dispatches} → ${r.new_dispatches} | ${r.legacy_tokens} → ${r.new_tokens} | ${r.ratio} | ${r.ending} |`); + const lines = [`TOKENS: ${tokFile ? `measured (${tokFile})` : 'DEFAULT estimates (pass --tokens for measured output)'}`, '', '| fixture | class | depth (runtime lines) | scenario | /ship dispatches legacy → new | est. tokens legacy → new | ratio | ending |', '|---|---|---|---|---|---|---|---|']; + for (const r of rows) lines.push(`| ${r.fixture} | ${r.class} | ${r.depth} (${r.runtime_lines}) | ${r.scenario} | ${r.legacy_dispatches} → ${r.new_dispatches} | ${r.legacy_tokens} → ${r.new_tokens} | ${r.ratio} | ${r.ending} |`); lines.push('', '| fixture | /autoplan scenario | voice dispatches legacy → new |', '|---|---|---|'); for (const a of ap) for (const r of a.runs) lines.push(`| ${a.fixture} | ${r.scenario} | ${r.legacy} → ${r.next} |`); const median = (v: number[]) => { const m = Math.floor(v.length / 2); return v.length % 2 ? v[m]! : Math.round(((v[m - 1]! + v[m]!) / 2) * 100) / 100; }; @@ -168,6 +170,9 @@ function main(argv: string[]): number { const byClass = new Map(); for (const r of rows) byClass.set(String(r.class), [...(byClass.get(String(r.class)) ?? []), Number(r.ratio)].sort((x, y) => x - y)); lines.push(`SHIP_TOKEN_RATIO_BY_CLASS: ${[...byClass].map(([c, v]) => `${c} median=${median(v)} (n=${v.length})`).join('; ')}`); + const byDepth = new Map(); + for (const r of rows) byDepth.set(String(r.depth), [...(byDepth.get(String(r.depth)) ?? []), Number(r.ratio)].sort((x, y) => x - y)); + lines.push(`SHIP_TOKEN_RATIO_BY_DEPTH: ${[...byDepth].map(([d, v]) => `${d} median=${median(v)} (n=${v.length})`).join('; ')}`); process.stdout.write(lines.join('\n') + '\n'); return 0; } diff --git a/ship/SKILL.md b/ship/SKILL.md index 881cd3934..5846b96d3 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 16f65656e..810bbd990 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -68,11 +68,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 3436b5c24..620f17248 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -2,7 +2,8 @@ **Step 9's last full-pass plan governs this step:** run the native pass only on `DISPATCH: adversarial-native` and the outside voice only on -`DISPATCH: outside-voice`. Without the native line, print `ADVERSARIAL: skipped (review plan)`; +`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; +above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; with neither line, take decision 3 of Finish the adversarial phase. ## Step 11: Adversarial review diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index abb9615ae..df982e205 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -1,6 +1,7 @@ **Step 9's last full-pass plan governs this step:** run the native pass only on `DISPATCH: adversarial-native` and the outside voice only on -`DISPATCH: outside-voice`. Without the native line, print `ADVERSARIAL: skipped (review plan)`; +`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; +above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; with neither line, take decision 3 of Finish the adversarial phase. {{ADVERSARIAL_STEP}} diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 32f18e440..06419d047 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -14,13 +14,19 @@ Every repeat starts before the checklist read and captures a fresh REVIEW_START. Finish the complete review and QA before applying any fix in Step 9.4. **Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan ---base `; a repeat uses the plan run that picked it. Save its `REVIEW_PLAN:` line and -fingerprint. Dispatch exactly its `DISPATCH:` lines (specialists, Red Team, Step -11's voices), each at its `effort=` on the same model family, in place of 9.1's and -9.2's own selection rules; a `SKIP:` reviewer -logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The -core checklist, gate scan and QA always run. Item 6's row adds -`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`. +--base `, plus `--depth-floor ` on every plan run when the /ship prompt +gave `--depth-floor ` (it only raises); a repeat uses the plan run that picked it. +Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth, +`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines +(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model +family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; +`specialist batched` (light depth) is one subagent reading every checklist its +`checklists=` names, Red Team included, tagging each finding with its checklist. +A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at +`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds +`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, +`"runtime_lines"`, `"depth_rules"` (the `rules` hash) and +`"gate_scan":{"tagged":N|null,"head":""}` from those lines. **Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read in item 2 becomes `git diff $RANGE_BASE`, and every dispatched prompt (specialists, @@ -391,7 +397,7 @@ Every other specialist, including Testing, reads the full diff with fixtures. 5. Instructions: "You are a specialist code reviewer. Read the checklist at {checklist path}, then run -`DIFF_BASE= && git diff "$DIFF_BASE"` to get the diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable). Apply the checklist against the diff. +`DIFF_BASE= && git diff "$DIFF_BASE" ` to get the diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable; ``: everything after `` on this reviewer's `DIFF_CMD:` line, verbatim; empty on `DIFF_EXCLUDE: none`). Apply the checklist against the diff. `NO FINDINGS` alone is the expected, complete result for a clean diff; never invent an item. Otherwise output one JSON object per finding per line, 400 words at most: {\"severity\":\"CRITICAL|INFORMATIONAL\",\"confidence\":N,\"path\":\"file\",\"line\":N,\"category\":\"category\",\"summary\":\"description\",\"fix\":\"recommended fix\",\"fingerprint\":\"path:line:category\",\"specialist\":\"name\"} @@ -802,7 +808,8 @@ After persistence, record missing output, CYCLES and fixes in the invocation rec Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan --base --pass $PASS --last full` with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass -logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `); +logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, +`--depth-floor ` when given); its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. Other scopes apply decisions 2–4 in order. diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index 6317b79c6..704bfb6b1 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -12,13 +12,19 @@ Every repeat starts before the checklist read and captures a fresh REVIEW_START. Finish the complete review and QA before applying any fix in Step 9.4. **Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan ---base `; a repeat uses the plan run that picked it. Save its `REVIEW_PLAN:` line and -fingerprint. Dispatch exactly its `DISPATCH:` lines (specialists, Red Team, Step -11's voices), each at its `effort=` on the same model family, in place of 9.1's and -9.2's own selection rules; a `SKIP:` reviewer -logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The -core checklist, gate scan and QA always run. Item 6's row adds -`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`. +--base `, plus `--depth-floor ` on every plan run when the /ship prompt +gave `--depth-floor ` (it only raises); a repeat uses the plan run that picked it. +Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth, +`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines +(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model +family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; +`specialist batched` (light depth) is one subagent reading every checklist its +`checklists=` names, Red Team included, tagging each finding with its checklist. +A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at +`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds +`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, +`"runtime_lines"`, `"depth_rules"` (the `rules` hash) and +`"gate_scan":{"tagged":N|null,"head":""}` from those lines. **Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read in item 2 becomes `git diff $RANGE_BASE`, and every dispatched prompt (specialists, @@ -216,7 +222,8 @@ After persistence, record missing output, CYCLES and fixes in the invocation rec Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan --base --pass $PASS --last full` with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass -logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `); +logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, +`--depth-floor ` when given); its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. Other scopes apply decisions 2–4 in order. diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 7494fe0c4..59c5baac2 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 881cd3934..5846b96d3 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index c4d937f91..afd639848 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -200,11 +200,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 374d879a4..e2c3e30bc 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/review-collect.test.ts b/test/review-collect.test.ts new file mode 100644 index 000000000..f611fe57a --- /dev/null +++ b/test/review-collect.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { collectCommit, collectLive, loadRegistryAt } from '../lib/review-collect'; +import { depthOfCommitInput } from '../lib/review-depth'; + +const dirs: string[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); +const lines = (n: number, tag = 'v') => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n'; +const REGISTRY = JSON.stringify({ entries: [{ id: 'docs', command: 'bun run gen', outputs: ['*/SKILL.md'] }, { id: 'manual', command: null, outputs: ['src/hand.ts'] }] }); + +function repo(baseFiles: Record) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-collect-')); + dirs.push(dir); + const git = (...a: string[]) => { const r = spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 }); if (r.status !== 0) throw new Error(`git ${a.join(' ')}: ${r.stderr}`); return r.stdout.trim(); }; + const write = (files: Record) => { for (const [p, text] of Object.entries(files)) { const f = path.join(dir, p); if (text === null) { fs.rmSync(f); continue; } fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } }; + git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false'); + write({ 'README.md': '# x\n', ...baseFiles }); git('add', '-A'); git('commit', '-qm', 'base'); + const baseSha = git('rev-parse', 'HEAD'); + git('checkout', '-qb', 'feat'); + const commit = (files: Record) => { write(files); git('add', '-A'); git('commit', '-qm', 'feat'); return git('rev-parse', 'HEAD'); }; + return { dir, git, write, commit, baseSha }; +} + +describe('collectCommit: git objects only', () => { + 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) }); + const c = collectCommit(r.dir, r.baseSha, head); + expect(c.errors).toEqual([]); + expect(c.registry).toEqual({ sha: r.baseSha, outputs: ['*/SKILL.md'] }); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + expect([d.depth, d.runtimeLines]).toEqual(['light', 2 + 5]); + }); + test('a head-only registry entry never excludes: the registry is the base commit\'s', () => { + const r = repo({ 'src/keep.ts': lines(1) }); + const head = r.commit({ '.gstack/generated.json': JSON.stringify({ entries: [{ id: 'gen', command: 'gen', outputs: ['src/gen.ts'] }] }), 'src/gen.ts': lines(60) }); + const c = collectCommit(r.dir, r.baseSha, head); + expect(c.registry).toEqual({ sha: r.baseSha, outputs: [] }); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + expect(d.runtimeLines).toBeGreaterThanOrEqual(60); + expect(d.depth).toBe('full'); + }); + test('a rename into src/ counts the new path as runtime', () => { + const r = repo({ 'notes/a.txt': lines(30) }); + fs.mkdirSync(path.join(r.dir, 'src')); + r.git('mv', 'notes/a.txt', 'src/a.ts'); + r.git('commit', '-qm', 'mv'); + const c = collectCommit(r.dir, r.baseSha, r.git('rev-parse', 'HEAD')); + expect(c.deleted).toEqual(['notes/a.txt']); + expect(c.perPath.find(e => e.path === 'src/a.ts')).toEqual({ path: 'src/a.ts', lines: 30, binary: false }); + expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 30 }); + }); + test('a binary code-class path is an error (full), never 0 lines; a deleted test is full', () => { + const r = repo({ 'test/old.test.ts': 'x\n' }); + const head = r.commit({ 'lib/x.wasm': Buffer.from([0, 97, 115, 109, 0, 1, 2]) }); + const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 }); + expect(d.depth).toBe('full'); + expect(d.errors).toEqual([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]); + const t = repo({ 'test/old.test.ts': 'x\n', 'test/keep.test.ts': 'x\n' }); + const del = t.commit({ 'test/old.test.ts': null }); + const deleted = depthOfCommitInput({ ...collectCommit(t.dir, t.baseSha, del), gateTagged: 0 }); + expect([deleted.depth, deleted.runtimeLines, deleted.errors]).toEqual(['full', 0, []]); + }); + test('an invalid base registry is an error with its result code; an unresolvable sha is unavailable', () => { + const r = repo({ '.gstack/generated.json': '{"entries": [{"id": "x"}]}' }); + const head = r.commit({ 'src/a.ts': lines(3) }); + const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 }); + expect(d.depth).toBe('full'); + expect(d.codes).toEqual(['REGEN_REGISTRY_INVALID']); + expect(d.errors[0]).toMatchObject({ code: 'registry_invalid', result: 'REGEN_REGISTRY_INVALID' }); + expect(d.errors[0]!.repair).toContain('gstack-regen validate'); + expect(loadRegistryAt(r.dir, 'f'.repeat(40)).error?.code).toBe('registry_unavailable'); + const missing = collectCommit(r.dir, 'f'.repeat(40), head); + expect(missing.errors.map(e => e.code)).toContain('numstat_failed'); + expect(depthOfCommitInput({ ...missing, gateTagged: 0 }).depth).toBe('full'); + }); + test('from a checkout at another HEAD with a dirty tree it equals the clean result at head', () => { + const r = repo({ '.gstack/generated.json': REGISTRY, 'src/a.ts': lines(2), 'ship/SKILL.md': 'a\n', 'autoplan/SKILL.md.tmpl': 'x\n' }); + const head = r.commit({ 'src/a.ts': lines(40), 'ship/SKILL.md': lines(9), 'docs/b.md': '# b\n12% faster\n', 'autoplan/notes.md': 'n\n', 'package.json': '{"version":"1.0.1"}\n' }); + const clean = collectCommit(r.dir, r.baseSha, head); + r.git('checkout', '-q', r.baseSha); + r.write({ 'src/a.ts': lines(500, 'dirty'), 'src/untracked.ts': lines(80), 'autoplan/SKILL.md.tmpl': null }); + const dirty = collectCommit(r.dir, r.baseSha, head); + expect(dirty).toEqual(clean); + expect(depthOfCommitInput({ ...dirty, gateTagged: 0 })).toEqual(depthOfCommitInput({ ...clean, gateTagged: 0 })); + expect(clean.root).toEqual(['autoplan', 'ship']); + expect(clean.added['docs/b.md']).toEqual(['# b', '12% faster']); + }); +}); + +describe('collectLive: the working tree', () => { + test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => { + const r = repo({ 'src/a.ts': lines(1) }); + r.commit({ 'docs/a.md': '# a\n' }); + r.write({ 'src/a.ts': lines(11), 'src/new.ts': lines(20), '.gstack/tmp/x.ts': lines(500) }); + const c = collectLive(r.dir, 'main'); + expect(c.errors).toEqual([]); + expect(c.untracked).toEqual(['src/new.ts']); + expect(c.perPath.find(e => e.path === 'src/new.ts')).toEqual({ path: 'src/new.ts', lines: 20, binary: false }); + expect(c.paths).toEqual(['docs/a.md', 'src/a.ts', 'src/new.ts']); + expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 20 + 10 }); + }); + test('no merge base is a collector error the CLI refuses on', () => { + const r = repo({}); + expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']); + }); +}); diff --git a/test/review-depth.test.ts b/test/review-depth.test.ts index d8c0ab4fa..db2ea9b05 100644 --- a/test/review-depth.test.ts +++ b/test/review-depth.test.ts @@ -1,6 +1,9 @@ import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; -import { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth'; +import { classifyDiff, classifyPath, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, type DepthError, type PlanInput } from '../lib/review-depth'; const ROOT = path.join(import.meta.dir, '..'); const none = scanNumbers({}); @@ -73,9 +76,10 @@ describe('planReview', () => { const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) })); expect(roles(p)).toEqual(['outside-voice:outside-voice:host']); }); - test('tests class: testing specialist only', () => { + test('tests class: runtime 0 is light, core only (Behavior changes: no Testing specialist)', () => { const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } })); - expect(roles(p)).toEqual(['specialist:testing:full']); + expect(p.depth).toBe('light'); + expect(roles(p)).toEqual([]); }); test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => { const p = planReview(base({})); @@ -88,7 +92,7 @@ describe('planReview', () => { }); test('code class honours hit-rate gating and small-diff skip', () => { expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full'); - expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]); + expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist').map(d => d.name)).toEqual(['batched']); }); test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => { const p = planReview(base({ adversarialRan: true, history: { fullPasses: 1, last: 'full', fixedBlocking: true } })); @@ -167,3 +171,121 @@ describe('GSTA-131 coverage: Red Team and specialist arms', () => { expect(roles(planReview(base({ paths: ['docs/a.md'], force: ['security'] })))).toEqual(['specialist:security']); }); }); + +const per = (o: Record) => Object.entries(o).map(([p, lines]) => ({ path: p, lines, binary: false })); +const mixed = (o: Partial = {}) => base({ paths: Object.keys(o.perPath ? Object.fromEntries(o.perPath.map(e => [e.path, 1])) : {}), ...o }); +const err = (code: DepthError['code'], result?: DepthError['result']): DepthError => ({ code, detail: 'd', input: 'i', repair: 'r', ...(result ? { result } : {}) }); + +describe('review depth: the matrix', () => { + test('runtime 0 (tests and docs only) is light: core only, nothing dispatched', () => { + const p = planReview(mixed({ perPath: per({ 'test/a.test.ts': 300, 'docs/a.md': 40 }), diffLines: 340 })); + expect([p.depth, p.runtimeLines, p.dispatches]).toEqual(['light', 0, []]); + }); + test('runtime 1-50 is light plus one batched specialist: the fixed set, scope picks and the Red Team in one context', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 48, 'test/a.test.ts': 190, 'docs/a.md': 9 }), diffLines: 247 })); + expect([p.depth, p.runtimeLines]).toEqual(['light', 48]); + expect(p.dispatches).toEqual([ + { role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'maintainability', 'security', 'performance', 'red-team'] }, + { role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [], reason: 'backtest inconclusive (GSTA-147 D5)' }, + ]); + expect(p.skipped.join('; ')).toContain('red-team (light depth'); + expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false); + }); + test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 })); + expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]); + expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); + const ex = Object.fromEntries(p.dispatches.map(d => [d.name, d.exclude])); + expect(ex.testing).toEqual([':(exclude)README.md']); + expect(ex.maintainability).toEqual([':(exclude)README.md', ':(exclude)test/a.test.ts']); + expect([ex.adversarial, ex['outside-voice']]).toEqual([[], []]); + }); + test.each([ + ['scope error', { scope: { SCOPE_ERROR: 'boom' } }, 'scope_error', []], + ['gate scan unavailable', { gateTagged: null }, 'gate_scan_unavailable', []], + ['base registry invalid', { registry: { sha: 'abc', outputs: [], error: err('registry_invalid', 'REGEN_REGISTRY_INVALID') } }, 'registry_invalid', ['REGEN_REGISTRY_INVALID']], + ['base registry unavailable', { registry: { sha: null, outputs: [], error: err('registry_unavailable') } }, 'registry_unavailable', []], + ['numstat failure', { errors: [err('numstat_failed')] }, 'numstat_failed', []], + ['binary code path', { perPath: [...per({ 'src/app.ts': 5 }), { path: 'lib/x.wasm', lines: 0, binary: true }], paths: ['src/app.ts', 'lib/x.wasm'] }, 'binary_code_path', []], + ] as const)('%s fails closed to full with its code', (_n, o, code, codes) => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 5 }), ...(o as Partial) })); + expect(p.depth).toBe('full'); + expect(p.errors.map(e => e.code)).toContain(code); + expect(p.codes).toEqual([...codes]); + expect(roles(p)).toContain('red-team:red-team:full'); + }); + test.each([ + ['a gate path', { paths: ['src/app.ts', '.github/workflows/ci.yml'] }], + ['a gate tag', { gateTagged: 1 }], + ['a deleted test', { deleted: ['test/old.test.ts'] }], + ['an edit to the registry', { paths: ['.gstack/generated.json'] }], + ['an edit to SMALL_DIFF_LINES', { paths: ['lib/review-depth.ts'] }], + ['the collector', { paths: ['lib/review-collect.ts'] }], + ['the plan CLI', { paths: ['bin/gstack-review-plan'] }], + ['the queue', { paths: ['lib/ship-queue-train.ts'] }], + ['the Step 9 template', { paths: ['ship/sections/review-army.md.tmpl'] }], + ] as const)('%s is full', (_n, o) => { + const paths = (o as any).paths ?? ['src/app.ts']; + expect(planReview(mixed({ perPath: per(Object.fromEntries(paths.map((p: string) => [p, 2]))), ...(o as Partial) })).depth).toBe('full'); + }); + test('a registered glob output is excluded from runtime; a .tmpl edit plus its generated SKILL.md counts the tmpl only', () => { + const p = planReview(mixed({ perPath: per({ 'ship/SKILL.md.tmpl': 30, 'ship/SKILL.md': 200 }), registry: { sha: 'abc', outputs: ['*/SKILL.md'] } })); + expect([p.depth, p.runtimeLines]).toEqual(['light', 30]); + expect(p.dispatches[0]!.exclude).toEqual([':(exclude)ship/SKILL.md']); + }); + test('a path no base-registry entry names stays runtime', () => { + expect(planReview(mixed({ perPath: per({ 'src/gen.ts': 60 }), registry: { sha: 'abc', outputs: ['lib/dom.js'] } })).runtimeLines).toBe(60); + }); + test('numbers in light dispatch the outside voice with an empty exclude', () => { + const p = planReview(mixed({ perPath: per({ 'docs/bench.md': 4 }), numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) })); + expect(p.depth).toBe('light'); + expect(p.dispatches).toEqual([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]); + }); + test('a fixed blocker in light raises the next full step to the standard set, Red Team included', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 30 }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } })); + expect([p.depth, p.stepDepth]).toEqual(['light', 'standard']); + expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); + }); + test('--depth-floor raises and never lowers', () => { + expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full'); + expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard'); + expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full'); + expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard'); + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' })); + expect(p.depth).toBe('full'); + expect(p.depthReasons).toContain('floor:full'); + }); + test('depthOfCommitInput gives the same depth as the plan for the same input', () => { + const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 }; + expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] }); + expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full'); + }); +}); + +describe('review depth: diff commands and the rules hash', () => { + test('DIFF_CMD shell-quotes each pathspec; a glob character makes the pathspec literal', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10, "docs/my notes*.md": 3, "docs/it's.md": 1 }) })); + const ex = p.dispatches[0]!.exclude; + expect(ex).toEqual([':(exclude,literal)docs/my notes*.md', ":(exclude)docs/it's.md"]); + expect(diffCommand(p.dispatches[0]!)).toBe("git diff -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)docs/it'\\''s.md'"); + expect(diffCommand({ role: 'red-team', name: 'red-team', effort: 'full', exclude: [] })).toBe('git diff '); + }); + test('DEPTH_RULES hashes the deciding constants: a comment-only edit keeps it, a threshold edit changes it', () => { + expect(DEPTH_RULES).toMatch(/^[0-9a-f]{16}$/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'depth-rules-')); + try { + const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8'); + fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts')); + const hashOf = (text: string) => { + fs.writeFileSync(path.join(dir, 'review-depth.ts'), text); + fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n"); + const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); + }; + expect(hashOf('// a comment-only edit\n' + src)).toBe(DEPTH_RULES); + expect(hashOf(src.replace('SMALL_DIFF_LINES = 50', 'SMALL_DIFF_LINES = 60'))).not.toBe(DEPTH_RULES); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + diff --git a/test/review-dispatch-compare.test.ts b/test/review-dispatch-compare.test.ts index 804d9e0cf..2771d0a95 100644 --- a/test/review-dispatch-compare.test.ts +++ b/test/review-dispatch-compare.test.ts @@ -34,7 +34,7 @@ describe('scripts/review-dispatch-compare.ts', () => { const table = run(['--tokens', tok]); expect(table).toContain(`TOKENS: measured (${tok})`); expect(table).toMatch(/^SHIP_TOKEN_RATIO: median=0\.\d+ min=0\.\d+ max=\d\.\d+ \(n=10\)$/m); - expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); code median=0\.\d+ \(n=5\)$/m); + expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); gate median=0\.\d+ \(n=5\)$/m); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts index c8aedb90e..ba8d77d9b 100644 --- a/test/review-plan-cli.test.ts +++ b/test/review-plan-cli.test.ts @@ -109,3 +109,51 @@ describe('gstack-review-plan', () => { expect(ran).toContain('SKIP: adversarial-native and outside-voice (run once per invocation)'); }); }); + +describe('gstack-review-plan: review depth', () => { + const code = (n: number) => Array.from({ length: n }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n'; + test('prints REVIEW_DEPTH with runtime lines and the rules hash, and GATE_SCAN bound to the head', () => { + const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) }); + const out = run(r, []).stdout; + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m); + expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`); + expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,maintainability,security,performance,state-machines,red-team'); + expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'); + }); + test('DIFF_CMD is shell-quoted per dispatch and excludes what the reviewer does not read; DIFF_EXCLUDE: none when empty', () => { + const r = repo({ 'src/a.ts': code(80), 'docs/my notes*.md': '# n\n', 'test/a.test.ts': code(3) }); + const out = run(r, []).stdout; + expect(out).toContain("DIFF_CMD: testing git diff -- . ':(exclude,literal)docs/my notes*.md'"); + expect(out).toContain("DIFF_CMD: maintainability git diff -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)test/a.test.ts'"); + expect(out).toContain('DIFF_CMD: adversarial git diff \nDIFF_EXCLUDE: adversarial none'); + const cmd = /^DIFF_CMD: maintainability (.*)$/m.exec(out)![1]!.replace('', 'main'); + const diff = spawnSync('bash', ['-c', cmd.replace('git diff main', 'git diff --name-only main')], { cwd: r.dir, encoding: 'utf8' }); + expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts']); + }); + test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => { + const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' }); + const plan = JSON.parse(run(r, ['--json']).stdout); + expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } }); + expect(plan.rules).toMatch(/^[0-9a-f]{16}$/); + expect(plan.dispatches.find((d: any) => d.name === 'maintainability').exclude).toEqual([':(exclude)README.md']); + expect(plan.dispatches.find((d: any) => d.role === 'outside-voice').exclude).toEqual([]); + }); + test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => { + const r = repo({ 'src/a.ts': code(5) }); + expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m); + const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' }); + expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m); + expect(run(r, ['--depth-floor', 'light']).status).toBe(2); + }); + test('a conservative full plan exits 0 with its errors; a plan that cannot be built exits 1', () => { + const r = repo({ 'lib/x.wasm': '\u0000asm\u0000' }); + const res = run(r, ['--json']); + expect(res.status).toBe(0); + expect(JSON.parse(res.stdout)).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]) }); + expect(run(r, []).stdout).toContain('DEPTH_ERROR: binary_code_path input=lib/x.wasm'); + const none = spawnSync(BIN, ['--base', 'no-such-branch', '--gate-tagged', '0'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } }); + expect(none.status).toBe(1); + }); +}); + diff --git a/test/size-ledger/ship/gsta147-review-depth.json b/test/size-ledger/ship/gsta147-review-depth.json new file mode 100644 index 000000000..79b65d6cc --- /dev/null +++ b/test/size-ledger/ship/gsta147-review-depth.json @@ -0,0 +1,4 @@ +{ + "bytes": 745, + "why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap)" +} From f47aba6fe73a2be2d1303525d2fffaf9f6144de2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:37:40 +0000 Subject: [PATCH 04/17] fix: carry sets aside only outputs the regen check proved it recreates The regen check deletes each touched output in its scratch worktree before the command runs and records the ones recreated byte-identically as proven. gstack-ship-receipt carry exempts only those paths, and only when the check ran origin/'s registry at the commit the carry reads. A file under an output glob that the generator never writes stays product (pre-landing review, #3564 pass 1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 ++- bin/gstack-ship-receipt | 22 ++++++++++-------- docs/pregate.md | 6 ++++- docs/ship-policy.md | 10 ++++++--- lib/headless-artifacts.ts | 4 +++- lib/pregate/checks/regen.ts | 5 +++-- lib/regen.ts | 11 +++++++-- test/ship-receipt-carry-rewrite.test.ts | 30 ++++++++++++++++++++++++- 8 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e7f26e2..77ba60dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ **A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. ### Changed -- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD shows the `regen` check passing with those entries fresh. Without that evidence, and for an entry with no command or one only the branch registers, the output stays product. +- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product. +- **`gstack-pregate` `regen` check**: each touched output is deleted in the scratch worktree before its entry's command runs, and the ones the command recreates byte-identically are recorded as `proven`. Pass and fail are unchanged. - **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. - **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index e6d89eaf9..53391b011 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -63,7 +63,7 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block the P7 adoption number over merged PRs (deduplicated by run and thread) carry --pr [--repo ] | --body [--base ] [--head ] [--pregate ] [--dry-run] [--out ] carry the receipt to HEAD when only a base merge, tests, release metadata or registered - outputs the --pregate regen check proved fresh changed since its head (product patch-id + outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id unchanged), or after a rebase onto a tree the receipt covers with the recorded product patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED / @@ -385,18 +385,20 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr } /** - * Generated outputs the carry may set aside as metadata: the outputs of every - * entry with a `command` in origin/'s `.gstack/generated.json`, and only - * when the pregate passed for HEAD shows the regen check regenerating them - * fresh. Without that evidence an output is product like any other file. + * Generated outputs the carry may set aside as metadata: the paths the pregate + * passed for HEAD proved (its regen check deleted each touched output and the + * entry's command recreated it byte-identically), when that check ran + * origin/'s `.gstack/generated.json` at the commit this carry reads and the + * path is an output of an entry with a `command` there. Anything else is product. */ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] { if (!pregateFile) return []; const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen'); - if (!regen || regen.status !== 'pass' || !/\bfresh\b/.test(regen.detail ?? '')) return []; + if (!regen || regen.status !== 'pass' || !regen.proven?.length) return []; const load = loadRegistryFromBase(repoRoot, base); - if (isRegistryUnavailable(load) || load.errors.length) return []; - return load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); + if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return []; + const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); + return regen.proven.filter(p => matchesAny(owned, p)); } interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } @@ -445,7 +447,9 @@ function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: * reran a full /ship on identical product code after a base merge or a * test-only fix; the carry compares the product diff against the base at the * receipt's head and at HEAD by patch-id, classifies every changed path, and - * records the range-diff as evidence in the receipt it rewrites. + * records the range-diff as evidence in the receipt it rewrites. After a rebase + * or force-push it carries only onto a tree the receipt covers whose product + * patch-id against the current base is the recorded one (planTreeCarry). */ function cmdCarry(rest: string[], json: boolean): number { const bodyFile = flag(rest, '--body'); diff --git a/docs/pregate.md b/docs/pregate.md index a42b37831..2677f75b7 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -146,7 +146,11 @@ Two stages with separate clocks, both printed: ~2.5 s for `regen` and under 200 ms for each of the others): - `regen`: every registry entry whose inputs or outputs are touched is fresh - (`gstack-regen check` in a scratch worktree). + (`gstack-regen check` in a scratch worktree). Each touched output is deleted + before its entry's command runs; the ones the command recreates + byte-identically are recorded as the check's `proven` paths, which + `gstack-ship-receipt carry` reads. An output it does not recreate is neither + stale nor proven. - `secrets`: `gstack-redact` over the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in the `secret` category, fails; other MEDIUM findings warn. Cannot be downgraded. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 8890a2f4a..e767904bb 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -933,9 +933,13 @@ edit). A logic change in a stamped source file is product. Lockfiles are product unless the policy names them. An output of an entry with a `command` in origin/'s `.gstack/generated.json` is `generated`, not product, only when the -`--pregate` result passed for HEAD shows the `regen` check passing with those -entries fresh; without that evidence, or for an entry with no command or one -only the branch registers, the output is product (U1 on gstack-evals paid a +`--pregate` result passed for HEAD lists it under the `regen` check's +`proven` paths (the check deleted the touched output in its scratch worktree +and the command recreated it byte-identically) and that check ran the same +registry, origin/ at the commit the carry reads. A file that matches an +output glob but that the command never writes is not proven. Without that +evidence, or for an entry with no command or one only the branch registers, +the output is product (U1 on gstack-evals paid a full rerun because a merge changed its generated index's diff). A base merge contributes no product change of its own. A receipt carried twice keeps the reviewed head as `carry.from` and adds up the merges and paths. The carried receipt records diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 2c37e3dcc..a42338611 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -69,6 +69,8 @@ export interface PregateCheck { stage?: 'preflight' | 'tests'; inputs?: Record; fix?: string; code?: string; lines?: string[]; // Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence. cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' }; + /** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */ + proven?: string[]; } /** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */ export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> } @@ -205,7 +207,7 @@ export const ARTIFACT_SCHEMAS = { type: 'object', required: ['schema_version', 'tree', 'checks'], properties: { schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num, - checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } }, + checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } }, head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str, requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str, substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } }, diff --git a/lib/pregate/checks/regen.ts b/lib/pregate/checks/regen.ts index dd63dd003..4aef9c75e 100644 --- a/lib/pregate/checks/regen.ts +++ b/lib/pregate/checks/regen.ts @@ -23,7 +23,7 @@ function run(ctx: PregateContext): CheckResult { fix: 'rerun with --allow-repo-commands (what /ship passes)', lines: commands.map(c => `COMMAND: ${c.id}=${JSON.stringify(c.command)} outputs=${c.outputs.join(',')}`), }; } - const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), env: ctx.env, timeoutMs: ctx.timeoutMs }); + const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), prove: ctx.touched.map(t => t.path), env: ctx.env, timeoutMs: ctx.timeoutMs }); const lines = results.map(renderEntry); const stale = results.filter(r => r.status === 'stale'); const failed = results.filter(r => r.status === 'failed'); @@ -32,7 +32,8 @@ function run(ctx: PregateContext): CheckResult { if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix }; if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') }; if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') }; - return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` }; + const proven = results.flatMap(r => r.proven ?? []).sort(); + return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, ...(proven.length ? { proven } : {}), detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` }; } export const regenCheck: CheckDef = { id: 'regen', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: true, run }; diff --git a/lib/regen.ts b/lib/regen.ts index 2a8538647..6654c854e 100644 --- a/lib/regen.ts +++ b/lib/regen.ts @@ -164,6 +164,8 @@ export interface EntryResult { exit?: number | null; stderr?: string; ms: number; /** The exact command an author runs to refresh the entry. */ fix: string; + /** `check` with `prove`, on a fresh entry: the deleted outputs the command recreated byte-identically (an output it never writes is not proven). */ + proven?: string[]; } /** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */ @@ -214,6 +216,8 @@ export interface RunRegenOptions { only?: string[]; /** Restrict to entries whose inputs or outputs intersect these paths. */ touched?: string[]; + /** `check`: outputs to delete from the scratch before their entry's command runs; the ones it recreates byte-identically come back as `proven`. */ + prove?: readonly string[]; env?: NodeJS.ProcessEnv; timeoutMs?: number; } @@ -240,16 +244,19 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s if (!e.command) { results.push({ id: e.id, command: null, status: 'listed', changed: [], outside: [], ms: 0, fix: fixFor(e) }); continue; } const cwd = o.mode === 'check' ? scratch!.dir : o.repoRoot; const before = o.mode === 'write' ? snapshotFiles(o.repoRoot) : null; + const probe = o.mode === 'check' ? (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p))) : []; + for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true }); const run = runCommand(cwd, e.command, o.env ?? process.env, timeout); const after = before ? diffSnapshots(before, snapshotFiles(o.repoRoot)) : changedPaths(cwd); - const changed = after.filter(p => matchesAny(e.outputs, p)); + const unwritten = probe.filter(p => !fs.existsSync(path.join(cwd, p))); + const changed = after.filter(p => matchesAny(e.outputs, p) && !unwritten.includes(p)); const outside = after.filter(p => !matchesAny(e.outputs, p) && !matchesAny(o.registry.entries.flatMap(x => x.outputs), p)); const ms = Date.now() - started; if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; } if (o.mode === 'check') { const differs = changed.length > 0 || outside.length > 0; if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); - else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) }); + else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(probe.length && !differs ? { proven: probe.filter(p => !unwritten.includes(p)) } : {}) }); // Reset the scratch so the next entry's diff is its own. git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); } else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) }); diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts index 41c038d1c..83009c4a0 100644 --- a/test/ship-receipt-carry-rewrite.test.ts +++ b/test/ship-receipt-carry-rewrite.test.ts @@ -120,9 +120,37 @@ describe('carry across a base merge that changes a registered output', () => { expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok); const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json'); - expect(JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen')).toMatchObject({ status: 'pass' }); + const pregate = JSON.parse(fs.readFileSync(pregateJson, 'utf8')); + const regen = pregate.checks.find((c: { id: string }) => c.id === 'regen'); + expect(regen).toMatchObject({ status: 'pass', proven: ['generated/digest.md'] }); + const headRegistry = path.join(repo.root, 'pregate-head-registry.json'); + regen.inputs.registry = 'head (unreviewed)'; + fs.writeFileSync(headRegistry, JSON.stringify(pregate)); + const foreign = carry(repo, body, ['--pregate', headRegistry]); + expect(foreign.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(foreign.err).toContain('generated/digest.md'); const out = carry(repo, body, ['--pregate', pregateJson]); expect(out.status).toBe(EXIT.ok); expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } }); }, 120_000); + + // Value: protects=a hand-written file under an output glob is reviewed code (red team, #3564 pass 1); fails_when=a + // regen pass that never wrote the path sets it aside as generated; seam=none; no_claim=- + test('a file that matches an output glob but that the generator never writes stays product', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + commit(repo, { 'generated/extra.md': '# hand-written after review\n' }); + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok); + const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json'); + const regen = JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen'); + expect(regen.status).toBe('pass'); + expect(regen.proven ?? []).not.toContain('generated/extra.md'); + const out = carry(repo, body, ['--pregate', pregateJson]); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain('generated/extra.md'); + expect(receiptIn(body).head).toBe(reviewed); + }, 120_000); }); From d4c68de6351d4d0bb600983f0e4aa52438d19c25 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:48:10 +0000 Subject: [PATCH 05/17] feat(ship-receipt): record review depth, check it in the queue, structured review-loop exception (#3559) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .gstack/ship-policy.json | 2 +- CHANGELOG.md | 15 ++ bin/gstack-doctor-components.sh | 3 + bin/gstack-ship-receipt | 37 ++++- docs/review-policy.md | 7 + docs/ship-policy.md | 36 +++- docs/troubleshooting.md | 44 +++++ lib/headless-artifacts.ts | 9 +- lib/result-codes-release.ts | 15 ++ lib/ship-queue-train.ts | 6 +- lib/ship-queue.ts | 6 +- lib/ship-receipt-depth.ts | 147 +++++++++++++++++ lib/ship-receipt.ts | 4 +- ship/sections/pr-body.md | 3 + ship/sections/pr-body.md.tmpl | 3 + test/gstack-ship-receipt.test.ts | 4 +- test/ship-receipt-depth.test.ts | 154 ++++++++++++++++++ .../ship/gsta147-review-depth.json | 4 +- 18 files changed, 483 insertions(+), 16 deletions(-) create mode 100644 lib/ship-receipt-depth.ts create mode 100644 test/ship-receipt-depth.test.ts diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index d188eced9..d50acbc38 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -14,5 +14,5 @@ "pr_count": { "default_pr_count": 1, "split_requires_owner": true }, "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, - "min_gstack_version": "1.91.146.0" + "min_gstack_version": "1.91.299.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e7f26e2..295d897c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +**/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it. + +### Added +- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed blocker or a CRITICAL raises light to standard for the rest of the invocation. +- **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559). +- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, or a body that dropped its line, is `QUEUE_RECEIPT_EXCEPTION_INVALID`. A receipt with neither new field predates depth receipts and is accepted as legacy. + +### Changed +- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). +- **`.gstack/ship-policy.json`**: `min_gstack_version` rises from 1.91.146.0 to 1.91.299.0. + +### Tests +- `test/ship-receipt-depth.test.ts`: write records the depth bound to HEAD and refuses another `--head`. The queue refuses light on a standard head, a current receipt with no depth and an unbound gate scan, and accepts a legacy receipt. A plain test change carries and rebinds the scan, while a skipped test refuses the carry. The review-loop exception is refused without a fix-check, with a foreign head or without its body line, and the queue refuses a body that dropped the line. +- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. + **A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. ### Changed diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index af0815ec9..dda793216 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -286,6 +286,8 @@ gstack_result_anchor() { QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; + QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;; + QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;; QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;; @@ -306,6 +308,7 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; + REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;; RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index e6d89eaf9..2a615333a 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -36,7 +36,7 @@ import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; -import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; +import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, @@ -44,6 +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 { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -83,6 +84,10 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out) --docs ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome; blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim) + --review-loop-exception ({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). --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -153,8 +158,14 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi function cmdWrite(rest: string[], json: boolean): number { const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd(); - const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot); + const head = flag(rest, '--head') ?? checkedOut; if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; } + const headSha = git(['rev-parse', '--verify', '--quiet', `${head}^{commit}`], repoRoot); + if (checkedOut && headSha !== checkedOut) { + process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${checkedOut.slice(0, 12)}; the receipt binds the reviewed tree, so check out ${head.slice(0, 12)} and write there; nothing written`) + '\n'); + return EXIT.fail; + } const receiptFile = flag(rest, '--receipt'); const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null; let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; @@ -182,6 +193,19 @@ function cmdWrite(rest: string[], json: boolean): number { return EXIT.refused; } } + let loopException: ReturnType | undefined; + if (flag(rest, '--review-loop-exception') !== undefined) { + const refuse = (why: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'REVIEW_LOOP_EXCEPTION_INVALID', `${why}; nothing written`) + '\n'); return EXIT.fail; }; + try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); } + if (!loopException.ok) return refuse(loopException.message); + let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {} + if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check'); + const fc = loopException.exception.fix_check.head; + if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`); + const line = reviewLoopExceptionLine(loopException.exception); + if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); + } + const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') }); if (approval.kind === 'invalid') { for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n'); @@ -212,6 +236,7 @@ function cmdWrite(rest: string[], json: boolean): number { policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), + gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } @@ -568,7 +593,13 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { 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 `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); + let depthField: Partial = {}; + if (receipt.review_depth) { + 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) } }; + } + const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/docs/review-policy.md b/docs/review-policy.md index c6847c73f..d9e87dd77 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -248,6 +248,13 @@ specialist stats they record). The summary line appends `fix_checks=`, `dispatches=` and `est_input_tokens=` only when they were recorded, so an old loop prints the old line. +Beside the counters, `write` records `review_depth` (the depth, runtime lines, +rules hash, base and registry commits, and the gate scan bound to the head) and +the queue refuses a receipt below the depth its head needs +([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner +accepted after a fix-check edit goes in `review_loop_exception`, never in prose +under `REVIEW_LOOP:`. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 8890a2f4a..f93d655f0 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -777,7 +777,8 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, -`pregate`, `issues`, `approval`, `docs`, `review_loop`, `carry`), validated +`pregate`, `issues`, `approval`, `docs`, `review_loop`, `review_depth`, +`review_loop_exception`, `carry`), validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -812,6 +813,39 @@ unreadable base policy is `POLICY_SOURCE_UNAVAILABLE`); one (`absent/ over` for a path the tree lacks), exit 1 on any `over`, whose repair is trimming the file, never raising the cap in the same PR. +**`review_depth` and `gstack_version`.** `write` records the gstack that +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/`, 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= required= +runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`; +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 +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). + +**`review_loop_exception`.** The owner's acceptance of a stop after a +fix-check edit under frozen scope (#3559), from `write --review-loop-exception +`: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`. +It needs `--review-loop` with `fix_checks` of 1 or more, a `fix_check.head` +that is the receipt head or its ancestor, and, with `--body`, the line +`Review-loop exception: accepted by via : (fix-check at , findings)` +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 +[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid). + **Prose against the receipt.** `gstack-ship-receipt read --pr --check-prose` (or `--body `) compares every counter the body quotes with the receipt's integers under `review_loop`, `gate` and `ci`: `passes=2`, `passes: 2` and diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e180fbf97..0566be1f5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4434,6 +4434,36 @@ written for the pinned head is the binding one and `verify --train` accepts it. `gstack-ship-queue ready --pr --screen|--no-screen`. A lane never stamps to fix this: the stamp comes only when the slot is called. + +### `QUEUE_RECEIPT_DEPTH_RAISED` + +**Meaning.** `ready` or `train build` recomputed the review depth the head +needs from git (the range against `origin/`, the base commit's +generated-output registry, and the receipt's gate scan when it names this +head) and the receipt's `review_depth.depth` is below it, or the receipt comes +from a gstack that records `gstack_version` but carries no `review_depth`. The +`REVIEW_DEPTH:` line prints the receipt's depth, the required depth, runtime +lines, the class, the reasons and the inputs (`base@`, `registry@`, +`gate_scan@` or `missing`). A gate scan recorded for another head counts as +missing, which makes the head need `full`. The receipt is unchanged. + +**Fix.** Run /ship on the head the READY names; it reviews at the depth +`gstack-review-plan` plans for that head and writes a receipt that records it. +Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-depth)). + + +### `QUEUE_RECEIPT_EXCEPTION_INVALID` + +**Meaning.** The receipt records an owner exception (`docs.exception` or +`review_loop_exception`) that the queue cannot accept: the record does not +parse, a review-loop exception names no fix-check (`review_loop.fix_checks` +is 0 or missing), or the PR body no longer carries the exception's line word +for word (`Documentation exception: …` or `Review-loop exception: …`). + +**Fix.** Restore the line in the PR body exactly as `write` printed it, or run +/ship again and record the owner's acceptance with `--docs` or +`--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)). + ### `QUEUE_SCREEN_STALE_REF` @@ -4707,6 +4737,20 @@ head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. already rewritten and the receipt predates `head_tree`, run /ship on the new head; next time merge the base in instead of rebasing. + +### `REVIEW_LOOP_EXCEPTION_INVALID` + +**Meaning.** `gstack-ship-receipt write --review-loop-exception` refused the +record and wrote nothing: it is not `{ accepted_by, date, via, reason, +fix_check: { head, findings[, files] } }`, `--review-loop` is missing or counts +no fix-check, `fix_check.head` is neither the receipt head nor its ancestor, +or the `--body` file lacks the `Review-loop exception: …` line. + +**Fix.** Record the owner's exact words and the fix-check the stop followed, +pass the `gstack-review-scope summary` file as `--review-loop`, put the printed +line in the PR body, and write again +([ship-policy.md](ship-policy.md#ship-receipt)). + ### `RECEIPT_CARRY_TREE_MISMATCH` diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 2c37e3dcc..d387f38f8 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -102,6 +102,11 @@ 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). */ + 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. */ + review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -222,7 +227,9 @@ export const ARTIFACT_SCHEMAS = { approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' }, review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } }, carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, - head_tree: str, product_patch_id: str, + head_tree: str, product_patch_id: str, gstack_version: str, + review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } }, + review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, }, diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 27cab8d9b..18a20b106 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -148,6 +148,16 @@ export const RELEASE_RESULT_CODES = { summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)', fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head', }, + QUEUE_RECEIPT_DEPTH_RAISED: { + anchor: 'queue-receipt-depth-raised', + summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all', + fix: 'run /ship on the head the READY names; the REVIEW_DEPTH line shows the receipt’s depth, the required depth, its reasons and inputs, and the receipt is left unchanged', + }, + QUEUE_RECEIPT_EXCEPTION_INVALID: { + anchor: 'queue-receipt-exception-invalid', + summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim', + fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception', + }, QUEUE_SCREEN_STALE_REF: { anchor: 'queue-screen-stale-ref', summary: 'GitHub’s refs/pull//merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success', @@ -254,6 +264,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide', fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file', }, + REVIEW_LOOP_EXCEPTION_INVALID: { + anchor: 'review-loop-exception-invalid', + summary: 'write --review-loop-exception got an exception that is malformed, follows no fix-check (review_loop.fix_checks is 0 or missing), names a fix-check head that is not the receipt head or its ancestor, or is missing from the PR body', + fix: 'pass { accepted_by, date, via, reason, fix_check: { head, findings } } with the owner’s words, the --review-loop summary that counts the fix-check, and the printed "Review-loop exception: …" line in the body', + }, RECEIPT_CARRY_TREE_MISMATCH: { anchor: 'receipt-carry-tree-mismatch', summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code', diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index e44c46a5b..5485f9b20 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -22,7 +22,7 @@ import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy'; import { loadBasePolicy, type QueueContext } from './ship-queue'; -import { bindReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; import { stackTestLine, stackTests } from './ship-queue-stack-tests'; @@ -351,8 +351,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) { return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs `, 'none'); } - const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]); - if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); + const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]); + if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 166a9a24b..e4588ea13 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -31,7 +31,8 @@ import type { PregateFile } from './headless-artifacts'; import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; import { ensureTmpExcluded, restamp, versionAtRef } from './restamp'; import type { ResultCodeName } from './result-codes'; -import { bindReceipt, extractReceipt } from './ship-receipt'; +import { extractReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy'; import { QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic, @@ -254,7 +255,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`; } else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`; } - const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); + const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); const attempt = currentAttempt(ctx.repoRoot, o.pr); const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head); const lines = [ @@ -268,6 +269,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate pregateLine, gateLine(policy, attempt), receipt.line, + ...(receipt.depthLine ? [receipt.depthLine] : []), ]; const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null; if (gate) lines.push(gate.line); diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts new file mode 100644 index 000000000..e2c22ee02 --- /dev/null +++ b/lib/ship-receipt-depth.ts @@ -0,0 +1,147 @@ +/** + * ship-receipt-depth — what the /ship receipt says about how deeply the + * 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_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 + * (lib/review-collect.ts) and refuses a receipt below it. Kept beside + * lib/ship-receipt.ts so that module stays under the size ratchet. + */ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import type { ShipReceipt } from './headless-artifacts'; +import { collectCommit } from './review-collect'; +import { classifyPath, depthOfCommitInput, type Depth } from './review-depth'; +import { runScriptSync } from './script-invocation'; +import { bindReceipt, type ReceiptBindingCode } from './ship-receipt'; +import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs'; + +export type ReceiptDepth = NonNullable; +export type ReviewLoopException = NonNullable; +const RANK: Record = { 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 short = (s: string | null | undefined) => (s ? s.slice(0, 12) : 'none'); + +function git(root: string, args: string[]): { ok: boolean; out: string } { + const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 }); + return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; +} + +/** + * 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`. + */ +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 = {}; + 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}`; + 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 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) }; +} + +export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need]; +export const deeper = (a: Depth, b: Depth): Depth => (RANK[a] >= RANK[b] ? a : b); + +/** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */ +export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } { + const bad = (message: string) => ({ ok: false as const, message }); + if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_loop_exception must be { accepted_by, date, via, reason, fix_check: { head, findings[, files] } }'); + const v = value as Record; + for (const key of Object.keys(v)) if (!['accepted_by', 'date', 'via', 'reason', 'fix_check'].includes(key)) return bad(`review_loop_exception: unknown key ${JSON.stringify(key)}`); + for (const key of ['accepted_by', 'date', 'via', 'reason']) if (!text(v[key])) return bad(`review_loop_exception.${key} must be a non-empty one-line string`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(v.date as string)) return bad('review_loop_exception.date must be YYYY-MM-DD'); + const f = v.fix_check as Record | null; + if (!f || typeof f !== 'object' || Array.isArray(f)) return bad('review_loop_exception.fix_check must be { head, findings[, files] }'); + for (const key of Object.keys(f)) if (!['head', 'findings', 'files'].includes(key)) return bad(`review_loop_exception.fix_check: unknown key ${JSON.stringify(key)}`); + if (!sha(f.head)) return bad('review_loop_exception.fix_check.head must be the full commit the fix-check reviewed'); + if (typeof f.findings !== 'number' || !Number.isInteger(f.findings) || f.findings < 0) return bad('review_loop_exception.fix_check.findings must be an integer >= 0 (what the fix-check reported)'); + if (f.files !== undefined && (!Array.isArray(f.files) || !f.files.every(text))) return bad('review_loop_exception.fix_check.files must be a list of paths'); + const files = (f.files as string[] | undefined)?.slice(0, 50); + return { ok: true, exception: { accepted_by: (v.accepted_by as string).trim(), date: v.date as string, via: (v.via as string).trim(), reason: (v.reason as string).trim(), fix_check: { head: f.head, findings: f.findings, ...(files?.length ? { files } : {}) } } }; +} + +/** The line the PR body must carry, word for word, while the exception stands. */ +export function reviewLoopExceptionLine(e: ReviewLoopException): string { + return `Review-loop exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason} (fix-check at ${e.fix_check.head.slice(0, 12)}, ${e.fix_check.findings} finding${e.fix_check.findings === 1 ? '' : 's'})`; +} + +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[] { + const out: string[] = []; + const raw = r as ShipReceipt & { docs?: unknown }; + if (raw.docs !== undefined) { + const d = parseReceiptDocs(raw.docs); + if (!d.ok) out.push(`docs: ${d.message}`); + else { const line = docsExceptionLine(d.docs); if (line && !carries(body, line)) out.push(`the body does not carry the documentation exception line verbatim: ${JSON.stringify(line)}`); } + } + if (r.review_loop_exception !== undefined) { + const e = parseReviewLoopException(r.review_loop_exception); + 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)'); + 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)}`); + } + } + return out; +} + +export interface DepthVerdict { ok: boolean; line: string; message?: string } + +/** + * The depth the bound head needs against `baseRef`, recomputed from git + * objects; the recorded gate scan counts only when it names the receipt's + * head, otherwise the scan is unavailable and the head needs full. A receipt + * with no review_depth and no gstack_version predates depth receipts and is + * accepted as legacy; one from a writer that records depth but lacks it is not. + */ +export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string): DepthVerdict { + 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 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}`; + if (have && !depthBelow(have, need.depth)) return { ok: true, line }; + const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one'; + const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; '); + return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; +} + +/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept. */ +export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]): + | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: 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); + 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 }; + return { ...b, depthLine: d.line }; +} + diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index ba6a03f31..02f64abd8 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -17,7 +17,8 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', - 'head_tree', 'product_patch_id', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', + 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', + 'review_loop_exception', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ @@ -122,6 +123,7 @@ export function receiptSummaryLine(r: ShipReceipt): string { const loop = parseReviewLoop(r.review_loop); if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}` + (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : '')); + if (r.review_depth && typeof r.review_depth.depth === 'string') parts.push(`depth=${r.review_depth.depth}`); if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`); return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`; } diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 414abef37..a19f4aee7 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -227,6 +227,9 @@ rm -f .gstack/tmp/review-loop.json `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the receipt carries it as `docs`, and a `blocked` status without the owner's exception, or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written. +When the owner accepted a stop after a fix-check edit, add +`--review-loop-exception @` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`) +and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its `REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 55103556d..c9c727af0 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -210,6 +210,9 @@ rm -f .gstack/tmp/review-loop.json `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the receipt carries it as `docs`, and a `blocked` status without the owner's exception, or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written. +When the owner accepted a stop after a fix-check edit, add +`--review-loop-exception @` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`) +and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its `REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index b9fa23a3b..15ac078b2 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -44,11 +44,11 @@ describe('gstack-ship-receipt write', () => { '--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge', '--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]); expect(ok.status).toBe(EXIT.ok); - expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3`); + expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3 depth=light`); const parsed = extractReceipt(ok.out); expect(parsed.found).toBe(true); expect(parsed.errors).toEqual([]); - expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id']); + expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']); expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } }); expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/); diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts new file mode 100644 index 000000000..a22ea057d --- /dev/null +++ b/test/ship-receipt-depth.test.ts @@ -0,0 +1,154 @@ +/** + * The /ship receipt's review depth and review-loop exception (GSTA-147 PR 2, + * docs/review-policy.md#review-depth, #3559): write records the depth gstack + * computes at the checked-out head, the queue recomputes the depth the head + * needs and refuses a receipt below it, a carry rebinds the gate scan or + * refuses when the head needs more, and the review-loop exception is checked + * like the documentation exception. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { bindShippedReceipt, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) => + runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n'; +const rewrite = (body: string, edit: (r: Record) => void) => { + const text = fs.readFileSync(body, 'utf8'); + const r = extractReceipt(text).receipt as Record; + edit(r); + fs.writeFileSync(body, text.replace(/```gstack-ship-receipt[\s\S]*?\n```\n/, renderReceiptBlock(r as any))); + return fs.readFileSync(body, 'utf8'); +}; + +describe('write records the review depth at the checked-out head', () => { + // Value: protects=the receipt names the depth and a gate scan bound to the head it is written for, and never binds a + // tree it did not review; fails_when=write records no depth, or --head accepts a commit other than HEAD; seam=none + test('a light code diff records depth, runtime lines, the base and the gate scan at HEAD; --head of another commit writes nothing', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const older = commit(repo, { 'lib/util.js': lines(3) }); + const head = commit(repo, { 'lib/more.js': lines(2) }); + const body = path.join(repo.root, 'body.md'); + const ok = writeReceipt(repo, body); + expect(ok.status).toBe(EXIT.ok); + expect(ok.err).toContain('depth=light'); + const r = receiptIn(body); + expect(r.gstack_version).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + const base = repo.git('rev-parse', 'origin/main'); + expect(r.review_depth).toMatchObject({ depth: 'light', class: 'code', base, registry: base, gate_scan: { tagged: 0, head } }); + expect(r.review_depth!.runtime_lines).toBeGreaterThanOrEqual(5); + expect(r.review_depth!.runtime_lines).toBeLessThanOrEqual(50); + const other = path.join(repo.root, 'other.md'); + const bad = writeReceipt(repo, other, ['--head', older]); + expect(bad.status).toBe(EXIT.fail); + expect(bad.err).toContain(`--head ${older.slice(0, 12)} is not the checked-out HEAD ${head.slice(0, 12)}`); + expect(bad.err).toContain('(RECEIPT_INVALID)'); + expect(fs.existsSync(other)).toBe(false); + }, 60_000); +}); + +describe('the queue refuses a receipt below the depth its head needs', () => { + // Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do; + // fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt + test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/big.js': lines(80) }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 }); + const base = repo.git('rev-parse', 'origin/main').slice(0, 12); + const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); + const good = bind(fs.readFileSync(body, 'utf8')); + expect(good.ok).toBe(true); + expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code'); + + const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; })); + expect(low.ok).toBe(false); + if (low.ok) return; + expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80'); + expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`); + expect(low.message).toContain('runtime=80>50'); + expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`); + + const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); })); + expect(unbound.ok).toBe(false); + expect(!unbound.ok && unbound.depthLine).toContain('required=full'); + expect(!unbound.ok && unbound.message).toContain('gate_scan_unavailable'); + + const none = bind(rewrite(body, r => { delete r.review_depth; })); + expect(!none.ok && none.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(!none.ok && none.message).toContain('records no review_depth though its writer records one'); + + const legacy = rewrite(body, r => { delete r.gstack_version; }); + 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); +}); + +describe('carry and the review depth', () => { + // Value: protects=a carried receipt keeps a gate scan bound to the head it now names, and a carry whose new head needs a + // deeper review (a skipped test) is refused; fails_when=carry keeps the old scan, or carries a gate-tagged test change; seam=none + test('a plain test change carries and rebinds the gate scan; a skipped test raises the depth and refuses the carry', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth!.depth).toBe('light'); + const plain = commit(repo, { 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" }); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'light', gate_scan: { tagged: 0, head: plain } }); + const skipped = commit(repo, { 'test/util.test.js': "test.skip('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" }); + const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(refused.status).toBe(EXIT.refused); + expect(refused.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)'); + expect(refused.err).toContain(`HEAD ${skipped.slice(0, 12)} needs full review depth`); + expect(refused.err).toContain("above the receipt's light"); + expect(receiptIn(body).head).toBe(plain); + }, 90_000); +}); + +describe('the review-loop exception (#3559)', () => { + // Value: protects=an accepted stop after a fix-check is a structured record the queue checks, not prose under REVIEW_LOOP; + // fails_when=write accepts it without a fix-check, with a foreign head or without the body line, or the queue ignores a + // body that dropped the line; seam=bindShippedReceipt + test('write refuses an exception with no fix-check, a foreign head or no body line, records it otherwise, and the queue checks the line', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/util.js': lines(3) }); + const body = path.join(repo.root, 'body.md'); + const exception = { accepted_by: 'Garry', date: '2026-10-28', via: 'GSTA-147 relay', reason: 'fix-check edit is a comment; scope frozen', fix_check: { head, findings: 1 } }; + const arg = (e: unknown) => ['--review-loop-exception', JSON.stringify(e)]; + const loop = (n: number) => ['--review-loop', JSON.stringify({ passes: 2, blocking_found: 1, deferred: 0, fix_checks: n })]; + fs.writeFileSync(body, '## What changed\n'); + for (const [extra, why] of [ + [[...arg(exception)], 'needs --review-loop with fix_checks >= 1'], + [[...arg(exception), ...loop(0)], 'needs --review-loop with fix_checks >= 1'], + [[...arg({ ...exception, fix_check: { head: 'a'.repeat(40), findings: 1 } }), ...loop(1)], 'is not ' + head.slice(0, 12) + ' or an ancestor of it'], + [[...arg({ ...exception, extra: 1 }), ...loop(1)], 'unknown key "extra"'], + [[...arg(exception), ...loop(1)], 'does not carry the exception line verbatim'], + ] as Array<[string[], string]>) { + const out = writeReceipt(repo, body, extra); + expect(out.status).toBe(EXIT.fail); + expect(out.err).toContain('(REVIEW_LOOP_EXCEPTION_INVALID;'); + expect(out.err).toContain(why); + } + expect(extractReceipt(fs.readFileSync(body, 'utf8')).found).toBe(false); + const line = reviewLoopExceptionLine(exception); + expect(line).toBe(`Review-loop exception: accepted by Garry 2026-10-28 via GSTA-147 relay: fix-check edit is a comment; scope frozen (fix-check at ${head.slice(0, 12)}, 1 finding)`); + fs.writeFileSync(body, `## What changed\n\n${line}\n`); + expect(writeReceipt(repo, body, [...arg(exception), ...loop(1)]).status).toBe(EXIT.ok); + expect(receiptIn(body).review_loop_exception).toEqual(exception); + expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]).ok).toBe(true); + 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'); + }, 90_000); +}); diff --git a/test/size-ledger/ship/gsta147-review-depth.json b/test/size-ledger/ship/gsta147-review-depth.json index 79b65d6cc..f583f9ecc 100644 --- a/test/size-ledger/ship/gsta147-review-depth.json +++ b/test/size-ledger/ship/gsta147-review-depth.json @@ -1,4 +1,4 @@ { - "bytes": 745, - "why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap)" + "bytes": 1008, + "why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap); the pr-body section's --review-loop-exception instruction (#3559)" } From bf9a417d542d976dfd9f36ec9e65e35cf790cc46 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 23:54:37 +0000 Subject: [PATCH 06/17] fix: prove generated outputs in a second regen run; literal names only The regen verdict comes from the unmodified run again, so a generator that reads its own registered output (gen-skill-docs and qa/templates/functional-report-template.md) passes as before; the proof is a second run with the touched outputs deleted, and its failure only means not proven. The carry ignores a proven path with glob syntax, since it uses the paths as pathspec globs (pre-landing review, #3564 pass 2). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- bin/gstack-ship-receipt | 5 +++-- docs/pregate.md | 11 +++++----- docs/ship-policy.md | 2 +- docs/troubleshooting.md | 3 ++- lib/regen.ts | 27 ++++++++++++++++--------- lib/result-codes-release.ts | 2 +- lib/ship-receipt.ts | 2 +- test/gstack-regen.test.ts | 17 +++++++++++++++- test/ship-receipt-carry-rewrite.test.ts | 15 ++++++++------ 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ba60dc3..e3e7b2ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,12 @@ ### Changed - **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product. -- **`gstack-pregate` `regen` check**: each touched output is deleted in the scratch worktree before its entry's command runs, and the ones the command recreates byte-identically are recorded as `proven`. Pass and fail are unchanged. +- **`gstack-pregate` `regen` check**: a fresh entry with touched outputs runs its command a second time in the scratch worktree with those outputs deleted, and records the ones it recreates byte-identically as `proven`. The verdict still comes from the first run, so a generator that reads its own output passes as before and proves nothing. - **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. - **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. ### Tests -- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a regen-fresh pregate and refuses without one. +- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product. - `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. ## [1.91.299.0] - 2026-10-28 diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 53391b011..d3e2c342b 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -389,7 +389,8 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr * passed for HEAD proved (its regen check deleted each touched output and the * entry's command recreated it byte-identically), when that check ran * origin/'s `.gstack/generated.json` at the commit this carry reads and the - * path is an output of an entry with a `command` there. Anything else is product. + * path is an output of an entry with a `command` there. A proven name with glob + * syntax is product too: the carry uses these paths as pathspec globs. */ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] { if (!pregateFile) return []; @@ -398,7 +399,7 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u const load = loadRegistryFromBase(repoRoot, base); if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return []; const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); - return regen.proven.filter(p => matchesAny(owned, p)); + return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); } interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } diff --git a/docs/pregate.md b/docs/pregate.md index 2677f75b7..11740fcea 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -146,11 +146,12 @@ Two stages with separate clocks, both printed: ~2.5 s for `regen` and under 200 ms for each of the others): - `regen`: every registry entry whose inputs or outputs are touched is fresh - (`gstack-regen check` in a scratch worktree). Each touched output is deleted - before its entry's command runs; the ones the command recreates - byte-identically are recorded as the check's `proven` paths, which - `gstack-ship-receipt carry` reads. An output it does not recreate is neither - stale nor proven. + (`gstack-regen check` in a scratch worktree). A fresh entry with touched + outputs then runs once more with those outputs deleted; the ones the command + recreates byte-identically are the check's `proven` paths, which + `gstack-ship-receipt carry` reads. The verdict comes from the first run: an + output the second run does not recreate, or a second run that fails, is + simply not proven. - `secrets`: `gstack-redact` over the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in the `secret` category, fails; other MEDIUM findings warn. Cannot be downgraded. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e767904bb..8e31b960e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -896,7 +896,7 @@ receipt when the base moves: | What changed since /ship | The receipt | |---|---| -| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate regenerated fresh) | carries; no review reruns | +| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns | | a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e180fbf97..b82dd8f86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4726,7 +4726,8 @@ instead keeps every later carry available **Meaning.** Between the receipt's head and HEAD the product diff against the base changed: at least one path that is neither a test, release metadata, a -registered output the pregate regenerated fresh, nor part of the base merge has +registered output the pregate's regen check proved (listed under `proven`, +checked against the same base registry), nor part of the base merge has a different patch than the reviewed head had. After a rebase it means HEAD has the reviewed tree but the base moved under it, so the diff a reviewer would see is not the reviewed one. The reviewed verdict does not cover HEAD, so the diff --git a/lib/regen.ts b/lib/regen.ts index 6654c854e..4760e5573 100644 --- a/lib/regen.ts +++ b/lib/regen.ts @@ -164,7 +164,7 @@ export interface EntryResult { exit?: number | null; stderr?: string; ms: number; /** The exact command an author runs to refresh the entry. */ fix: string; - /** `check` with `prove`, on a fresh entry: the deleted outputs the command recreated byte-identically (an output it never writes is not proven). */ + /** `check` with `prove`, on a fresh entry: the deleted outputs the proof run recreated byte-identically (an output it never writes, or a proof run that failed, proves nothing). */ proven?: string[]; } @@ -216,7 +216,7 @@ export interface RunRegenOptions { only?: string[]; /** Restrict to entries whose inputs or outputs intersect these paths. */ touched?: string[]; - /** `check`: outputs to delete from the scratch before their entry's command runs; the ones it recreates byte-identically come back as `proven`. */ + /** `check`: outputs to prove on a fresh entry: a second run with each one deleted first, the ones it recreates byte-identically come back as `proven`. The verdict comes from the first run alone. */ prove?: readonly string[]; env?: NodeJS.ProcessEnv; timeoutMs?: number; } @@ -244,21 +244,30 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s if (!e.command) { results.push({ id: e.id, command: null, status: 'listed', changed: [], outside: [], ms: 0, fix: fixFor(e) }); continue; } const cwd = o.mode === 'check' ? scratch!.dir : o.repoRoot; const before = o.mode === 'write' ? snapshotFiles(o.repoRoot) : null; - const probe = o.mode === 'check' ? (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p))) : []; - for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true }); const run = runCommand(cwd, e.command, o.env ?? process.env, timeout); const after = before ? diffSnapshots(before, snapshotFiles(o.repoRoot)) : changedPaths(cwd); - const unwritten = probe.filter(p => !fs.existsSync(path.join(cwd, p))); - const changed = after.filter(p => matchesAny(e.outputs, p) && !unwritten.includes(p)); + const changed = after.filter(p => matchesAny(e.outputs, p)); const outside = after.filter(p => !matchesAny(e.outputs, p) && !matchesAny(o.registry.entries.flatMap(x => x.outputs), p)); const ms = Date.now() - started; if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; } if (o.mode === 'check') { const differs = changed.length > 0 || outside.length > 0; - if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); - else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(probe.length && !differs ? { proven: probe.filter(p => !unwritten.includes(p)) } : {}) }); // Reset the scratch so the next entry's diff is its own. - git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); + const reset = () => { git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); }; + reset(); + if (differs && install) { results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); continue; } + // The proof is a second run apart from the verdict: each touched output deleted first, the ones recreated + // byte-identically are proven, and a failure (a generator that reads its own output) proves nothing. + const probe = differs ? [] : (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p))); + let proven: string[] | undefined; + if (probe.length) { + for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true }); + const proof = runCommand(cwd, e.command, o.env ?? process.env, timeout); + const redo = changedPaths(cwd); + proven = proof.exit === 0 ? probe.filter(p => !redo.includes(p)) : []; + reset(); + } + results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(proven ? { proven } : {}) }); } else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) }); } } finally { scratch?.remove(); } diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 27cab8d9b..51eccaa22 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -262,7 +262,7 @@ export const RELEASE_RESULT_CODES = { RECEIPT_CARRY_PRODUCT_CHANGED: { anchor: 'receipt-carry-product-changed', summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD', - fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regenerated fresh, or the base merge, and the product patch-id is unchanged', + fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged', }, DOCS_AUDIT_BLOCKED: { anchor: 'docs-audit-blocked', diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index ba6a03f31..aa86abf11 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -150,7 +150,7 @@ export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md /** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */ export const CARRY_NAMED_PATHS = 20; -/** `generated`: an output of a base-registry entry with a command, which the pregate for HEAD regenerated fresh (its inputs are product). */ +/** `generated`: an output of a base-registry entry with a command, which the pregate for HEAD proved its command recreates (its inputs are product). */ export type CarryClass = 'product' | 'test' | 'metadata' | 'generated'; export interface CarryPlanInput { /** The reviewed head the receipt names. */ diff --git a/test/gstack-regen.test.ts b/test/gstack-regen.test.ts index f718d6ec9..3f36b5084 100644 --- a/test/gstack-regen.test.ts +++ b/test/gstack-regen.test.ts @@ -9,7 +9,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { missingDependencies, parseRegistry, selectEntries, validateRegistry } from '../lib/regen'; +import { missingDependencies, parseRegistry, runRegen, selectEntries, validateRegistry } from '../lib/regen'; import { BASE_REGISTRY, cleanup, commit, makeFixtureRepo, readFile, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -135,6 +135,21 @@ describe('gstack-regen (bin)', () => { expect(parsed.wtree).toMatch(/^[0-9a-f]{40}$/); }); + // Value: protects=the receipt carry's generated class (only outputs a generator recreates) and a generator that reads + // its own registered output (gen-skill-docs and qa/templates/functional-report-template.md); fails_when=the proof + // deletes outputs before the verdict run, or proves a path the command never wrote; seam=runRegen; no_claim=- + test('prove: a writer proves its touched output; a generator that reads its own output stays fresh and proves nothing', () => { + const registryOf = (repo: FixtureRepo) => parseRegistry(fs.readFileSync(path.join(repo.work, '.gstack/generated.json'), 'utf8')).registry; + const writer = fixture(); + commit(writer, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n', 'generated/notes.md': '# hand-written\n' }); + expect(runRegen({ repoRoot: writer.work, registry: registryOf(writer), mode: 'check', only: ['digest'], prove: ['generated/digest.md', 'generated/notes.md', 'src/version.js'] }).results) + .toMatchObject([{ id: 'digest', status: 'fresh', proven: ['generated/digest.md'] }]); + const selfRead = fixture({ registry: { entries: [{ id: 'self', command: 'node selfread.js', outputs: ['generated/**'], inputs: ['selfread.js'] }] }, baseFiles: { 'selfread.js': "const fs=require('fs');fs.writeFileSync('generated/digest.md',fs.readFileSync('generated/digest.md','utf8'));\n" } }); + commit(selfRead, { 'generated/digest.md': '# hand edit\n' }); + expect(runRegen({ repoRoot: selfRead.work, registry: registryOf(selfRead), mode: 'check', prove: ['generated/digest.md'] }).results) + .toMatchObject([{ id: 'self', status: 'fresh', proven: [] }]); + }); + test('a failing command is failed (exit 1, REGEN_COMMAND_FAILED, status incomplete); an output outside the declared globs is stale', () => { const repo = fixture({ registry: { entries: [{ id: 'boom', command: 'node -e "process.exit(7)"', outputs: ['generated/**'] }, { id: 'leak', command: 'node -e "require(\'fs\').writeFileSync(\'docs/extra.md\',\'x\')"', outputs: ['generated/**'] }] } }); const r = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']); diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts index 83009c4a0..c98a8ebf1 100644 --- a/test/ship-receipt-carry-rewrite.test.ts +++ b/test/ship-receipt-carry-rewrite.test.ts @@ -123,12 +123,15 @@ describe('carry across a base merge that changes a registered output', () => { const pregate = JSON.parse(fs.readFileSync(pregateJson, 'utf8')); const regen = pregate.checks.find((c: { id: string }) => c.id === 'regen'); expect(regen).toMatchObject({ status: 'pass', proven: ['generated/digest.md'] }); - const headRegistry = path.join(repo.root, 'pregate-head-registry.json'); - regen.inputs.registry = 'head (unreviewed)'; - fs.writeFileSync(headRegistry, JSON.stringify(pregate)); - const foreign = carry(repo, body, ['--pregate', headRegistry]); - expect(foreign.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); - expect(foreign.err).toContain('generated/digest.md'); + const tampered = path.join(repo.root, 'pregate-tampered.json'); + const label = regen.inputs.registry; + for (const [registry, proven] of [['head (unreviewed)', ['generated/digest.md']], [label, ['generated/*']]]) { + Object.assign(regen, { proven, inputs: { ...regen.inputs, registry } }); + fs.writeFileSync(tampered, JSON.stringify(pregate)); + const refused = carry(repo, body, ['--pregate', tampered]); + expect(refused.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(refused.err).toContain('generated/digest.md'); + } const out = carry(repo, body, ['--pregate', pregateJson]); expect(out.status).toBe(EXIT.ok); expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } }); From 3b90122ef2ef1b3b4319e182d55e77a2d86e73a4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 00:02:31 +0000 Subject: [PATCH 07/17] docs: receipt evidence keys and carry record fields; carry refusal fix line Documentation audit for #3564 (/document-release, audit ship3564-docs-1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 7 +++++-- docs/troubleshooting.md | 4 +++- docs/unattended.md | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 8e31b960e..61e0059ef 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -777,7 +777,8 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, -`pregate`, `issues`, `approval`, `docs`, `review_loop`, `carry`), validated +`head_tree`, `product_patch_id`, `pregate`, `issues`, `approval`, `docs`, +`review_loop`, `carry`), validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -944,7 +945,9 @@ full rerun because a merge changed its generated index's diff). A base merge contributes no product change of its own. A receipt carried twice keeps the reviewed head as `carry.from` and adds up the merges and paths. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, -range_diff, covers, reruns }` with the `git range-diff` evidence, prints +range_diff, covers, reruns, ancestry, trees }` (`ancestry` is `linear` +along the receipt's own history, `rewritten` once any hop was a rebase; +`trees` lists the earlier covered trees) with the `git range-diff` evidence, prints `CARRY: carried from to ()` and the `GSTACK_RESULT: skill=ship status=complete` line, and its summary line ends with `carried_from=`. `CARRY: current` means the receipt already names HEAD; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b82dd8f86..f0f574faf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4734,7 +4734,9 @@ is not the reviewed one. The reviewed verdict does not cover HEAD, so the receipt is not carried. The refusal names the changed product paths. **Fix.** Run /ship on the new head. The receipt carries only across a -conflict-only base merge, a test-only change or release metadata, which the +conflict-only base merge, a test-only change, release metadata, a registered +output the pregate's regen check proved, or a rebase onto a covered tree with +the recorded product diff, which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). diff --git a/docs/unattended.md b/docs/unattended.md index 901e11028..8ebbc5791 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -225,8 +225,8 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from now names HEAD with a `carry` object holding the `git range-diff` evidence, and `ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop` counters are kept, because the reviewed product diff is unchanged. `CARRY: -current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)` -, `(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused +current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, +`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product diff changed, or history was rewritten onto a tree no review saw, so /ship runs again; `(RECEIPT_GIT_TOO_OLD)` means the machine's git predates 2.39 and cannot run From 4ad8a6bfd925a57ab9be2e134a7bfee50de0c310 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 00:53:12 +0000 Subject: [PATCH 08/17] 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 --head , 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> --- bin/gstack-diff-scope | 21 +++++++++++ bin/gstack-ship-receipt | 13 +++++-- docs/review-policy.md | 2 +- docs/ship-policy.md | 24 ++++++++----- lib/headless-artifacts.ts | 2 +- lib/review-collect.ts | 2 +- lib/ship-receipt-depth.ts | 63 +++++++++++++++++++++------------ test/diff-scope.test.ts | 21 +++++++++++ test/review-collect.test.ts | 9 +++++ test/ship-receipt-depth.test.ts | 53 +++++++++++++++++++++++++++ 10 files changed, 172 insertions(+), 38 deletions(-) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 2fa10b41d..3ec81cf09 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -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 --head → the committed range ... 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 diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 2214af5d3..6d50425b1 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -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 ({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 (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . 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 `); } let depthField: Partial = {}; - 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) } }; diff --git a/docs/review-policy.md b/docs/review-policy.md index d9e87dd77..bafc25869 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -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/` commit for live /ship, the receipt's base sha for the queue), and only +`origin/` commit for live /ship and the current base ref's commit for the queue: `origin/` in `ready`, `/` 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: diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 60bd1a6b7..ad60544a3 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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/`, with the -gate scan run on that head. `--head` must be the checked-out HEAD +computed from git objects for the range against `origin/` +(`gstack-diff-scope --head ` 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= required= +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= required= runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`; -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 --check-prose` diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index c9498174a..c82ba790c 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -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. */ diff --git a/lib/review-collect.ts b/lib/review-collect.ts index b6c236a83..3846df0f3 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -84,7 +84,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick 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 }; } diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index e2c22ee02..8cff3b954 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -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 = { 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 { + 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 = {}; + 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 = {}; - 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 }; diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 9597362a9..e33469c1a 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -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 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); diff --git a/test/review-collect.test.ts b/test/review-collect.test.ts index f611fe57a..0c86ac6ef 100644 --- a/test/review-collect.test.ts +++ b/test/review-collect.test.ts @@ -25,6 +25,15 @@ function repo(baseFiles: Record) { } 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) }); diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts index a22ea057d..1a7e2e8fd 100644 --- a/test/ship-receipt-depth.test.ts +++ b/test/ship-receipt-depth.test.ts @@ -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); }); From 45d9835d853a1d568c647cfc94a7180a771c2196 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 00:57:44 +0000 Subject: [PATCH 09/17] docs: CHANGELOG for the review fixes; defer two plan items to TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 +++++--- TODOS.md | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e0c09f0..8fd59954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,17 @@ ### Added - **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed blocker or a CRITICAL raises light to standard for the rest of the invocation. - **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559). -- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, or a body that dropped its line, is `QUEUE_RECEIPT_EXCEPTION_INVALID`. A receipt with neither new field predates depth receipts and is accepted as legacy. +- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, a body that dropped its line, or a fix-check head that is not the bound head or its ancestor is `QUEUE_RECEIPT_EXCEPTION_INVALID`. The queue reads the same committed range as the writer and trusts a recorded gate scan only when it names the full bound commit. A receipt with neither new field predates depth receipts and is accepted as legacy. ### Changed -- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). +- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). It also 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, since the queue would refuse each of them. Tracked edits in the checkout make the write-time gate scan unavailable, which records `full`. +- **`gstack-diff-scope --head `**: the scope signals for the committed range only, with no working-tree or untracked files; an unknown sha is `SCOPE_ERROR=no_head` and a missing merge base `SCOPE_ERROR=no_merge_base`. - **`.gstack/ship-policy.json`**: `min_gstack_version` rises from 1.91.146.0 to 1.91.299.0. ### Tests - `test/ship-receipt-depth.test.ts`: write records the depth bound to HEAD and refuses another `--head`. The queue refuses light on a standard head, a current receipt with no depth and an unbound gate scan, and accepts a legacy receipt. A plain test change carries and rebinds the scan, while a skipped test refuses the carry. The review-loop exception is refused without a fix-check, with a foreign head or without its body line, and the queue refuses a body that dropped the line. -- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. +- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. A `package.json` version line that also carries other keys is not stamp-only. +- `test/diff-scope.test.ts`: `--head` reads the committed range and ignores working-tree edits, and refuses an unknown sha. **A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. diff --git a/TODOS.md b/TODOS.md index d2be7ab44..edcf57447 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,11 @@ ## NEXT PRIORITY +### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit) + +- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1. +- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1. + ### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review) - **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3. From ed93c9f3aa83e1bf4206162f933ba4cae3799cc1 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:19:15 +0000 Subject: [PATCH 10/17] feat(ship-receipt): carry a stacked child after its parent lands (#3570) When the carry base differs from the receipt's (a stack parent landed and the PR was retargeted), the receipt carries only when HEAD's product diff against the base equals the range reviewed against the parent branch. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 + bin/gstack-ship-receipt | 19 ++++- docs/ship-policy.md | 14 ++++ docs/troubleshooting.md | 5 +- lib/ship-receipt.ts | 4 +- test/ship-receipt-carry-squash.test.ts | 108 +++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 test/ship-receipt-carry-squash.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e7b2ea3..c557d34ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ - **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. - **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. +- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base ` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/` for an older receipt. Equal carries with the reason `stack parent landed`; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19). + ### Tests +- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in. A child edited after the merge and a parent that never landed refuse. An older receipt falls back to `origin/` and refuses once that branch is deleted. - `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product. - `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d3e2c342b..18e5c5045 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -542,8 +542,23 @@ function cmdCarry(rest: string[], json: boolean): number { // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every // path the base changed, none of which can differ between the two base diffs. + // A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the + // parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one + // against origin/ when it still resolves) must be HEAD's whole product diff against the base: the parent + // fully landed and the child is unchanged. Anything else fails closed. + const reviewedBase = receipt.base.replace(/^origin\//, ''); + const retarget = reviewedBase !== base.replace(/^origin\//, ''); + let reviewed = productFrom; + if (retarget) { + const parentRef = `origin/${reviewedBase}`; + const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) + : git(['rev-parse', '--verify', '--quiet', `${parentRef}^{commit}`], repoRoot) ? productPatchAgainstBase(repoRoot, rules, parentRef, from) : { error: `the receipt records no product patch-id and ${parentRef} no longer resolves` }; + if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); + reviewed = recorded; + } const changedProduct: string[] = []; - if (productFrom !== productHead) { + if (retarget && reviewed !== productHead) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); + else if (productFrom !== productHead && !retarget) { const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); for (const p of [...touched].sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; @@ -557,7 +572,7 @@ function cmdCarry(rest: string[], json: boolean): number { // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); + plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: productHead as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); } if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 61e0059ef..b954d0bfe 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -901,6 +901,20 @@ receipt when the base moves: | a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | | a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | | any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | +| a stacked PR whose parent landed (squash or merge), retargeted to the base with the base merged in, HEAD's product diff exactly the one reviewed against the parent branch | carries (reason `stack parent landed`) | + +**Stacked PRs (#3570).** A child PR's receipt is written against its parent +branch, so its `base` names that branch and its `product_patch_id` is the +child's own range. After the parent lands, the PR is retargeted to the base, and +/ship's carry check runs with `--base `. When that base differs from the +receipt's, the carry compares the reviewed range (the recorded +`product_patch_id`, or for an older receipt the range against +`origin/` while that branch still resolves) with HEAD's whole product +diff against the base. Equal means the parent fully landed and the child is +unchanged, so the receipt carries. A parent that has not landed, a child edited +since, or a parent that landed with changes the child never merged all leave a +different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older +receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`. 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0f574faf..3bb588677 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4730,7 +4730,10 @@ registered output the pregate's regen check proved (listed under `proven`, checked against the same base registry), nor part of the base merge has a different patch than the reviewed head had. After a rebase it means HEAD has the reviewed tree but the base moved under it, so the diff a reviewer would see -is not the reviewed one. The reviewed verdict does not cover HEAD, so the +is not the reviewed one. For a stacked PR retargeted to the base after its +parent landed, it means HEAD's product diff against the base is not the range +reviewed against the parent branch: the parent has not fully landed, or the +branch changed since. The reviewed verdict does not cover HEAD, so the receipt is not carried. The refusal names the changed product paths. **Fix.** Run /ship on the new head. The receipt carries only across a diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index aa86abf11..afc77370a 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -164,6 +164,8 @@ export interface CarryPlanInput { changedProduct: string[]; mergeCommits: number; rangeDiff: string; + /** The base the receipt was reviewed against when it differs from the carry's (a stack parent that landed, #3570): productPatchId.from is then the recorded diff against it. */ + retargetedFrom?: string; } export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; @@ -230,7 +232,7 @@ export function planCarry(input: CarryPlanInput): CarryPlan { const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS); return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` }; } - const reasons: string[] = []; + const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : []; if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); diff --git a/test/ship-receipt-carry-squash.test.ts b/test/ship-receipt-carry-squash.test.ts new file mode 100644 index 000000000..8c00defea --- /dev/null +++ b/test/ship-receipt-carry-squash.test.ts @@ -0,0 +1,108 @@ +/** + * gstack-ship-receipt carry for a stacked child after its parent squash-merges (#3570, + * docs/ship-policy.md#receipt-carry): the receipt reviewed the child's own range against the parent + * branch, and that range must be HEAD's whole product diff against the base the PR now targets. + * Anything else (the parent not landed, the child changed) fails closed. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); +const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n"; + +/** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */ +function stack(repo: FixtureRepo): { parent: string; child: string; body: string } { + repo.git('checkout', '-q', '-b', 'parent'); + const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub'); + repo.git('push', '-q', 'origin', 'parent'); + repo.git('checkout', '-q', 'feature'); + repo.git('reset', '-q', '--hard', parent); + const child = commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: four retries'); + const body = path.join(repo.root, 'body.md'); + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]); + expect(w.status).toBe(EXIT.ok); + return { parent, child, body }; +} +/** Land `files` on main as one commit (the parent's squash merge; none when the parent never lands), then an unrelated main commit, and merge main into the child. */ +function landOnMain(repo: FixtureRepo, files: Record): void { + repo.git('checkout', '-q', 'main'); + if (Object.keys(files).length) { + for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text); + repo.git('add', '-A'); repo.git('commit', '-qm', 'squash: parent (#7)'); + } + write(repo.work, 'docs/README.md', '# docs after the parent\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'main moves on'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); +} + +describe('carry after a stack parent squash-merges (#3570)', () => { + // Value: protects=a stacked child keeps its receipt when its parent squash-merges and main is merged in (gstack-evals#19 + // paid a full /ship for it); fails_when=carry compares the child's whole diff from the old fork point; seam=none; + // no_claim=gh pr edit (--body stands in) + test('the child carries when the range it was reviewed for is HEAD\'s whole product diff against main', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + const reviewed = receiptIn(body).product_patch_id; + landOnMain(repo, { 'lib/util.js': SUB }); + const head = repo.git('rev-parse', 'HEAD'); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.ok); + expect(out.out).toContain(`CARRY: carried from ${child.slice(0, 12)} to ${head.slice(0, 12)}`); + const r = receiptIn(body); + expect(r.head).toBe(head); + expect(r.carry!.reason).toBe('stack parent parent landed + base merge'); + expect(r.product_patch_id).toBe(reviewed); + }, 90_000); + + // Value: protects=a receipt written before product_patch_id still carries while the parent branch resolves, and refuses by + // name once it is gone; fails_when=the fallback reads another ref or carries with no reviewed diff; seam=none; no_claim=- + test('without a recorded product patch-id the child range comes from origin/, and a deleted parent branch refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { parent, child, body } = stack(repo); + landOnMain(repo, { 'lib/util.js': SUB }); + const { product_patch_id: _dropped, head_tree: _tree, ...legacy } = receiptIn(body); + fs.writeFileSync(body, renderReceiptBlock(legacy as any)); + expect(receiptIn(body).product_patch_id).toBeUndefined(); + repo.git('push', '-q', 'origin', '--delete', 'parent'); + repo.git('fetch', '-q', '--prune', 'origin'); + const gone = carry(repo, body); + expect(gone.status).toBe(EXIT.refused); + expect(gone.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)'); + expect(gone.err).toContain('the receipt was reviewed against parent, not main, and its reviewed product diff is unavailable'); + expect(receiptIn(body).head).toBe(child); + repo.git('push', '-q', 'origin', `${parent}:refs/heads/parent`); + repo.git('fetch', '-q', 'origin'); + expect(carry(repo, body).status).toBe(EXIT.ok); + }, 90_000); + + // Value: protects=fail closed: a child changed after the parent merged, or a parent that never reached main, takes a fresh + // /ship; fails_when=the comparison ignores the child's remainder or the parent's absence; seam=none; no_claim=- + test('a product edit on the child after the merge, or a parent that never landed, refuses with RECEIPT_CARRY_PRODUCT_CHANGED', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + landOnMain(repo, { 'lib/util.js': SUB }); + commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 5;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: five'); + const edited = carry(repo, body); + expect(edited.status).toBe(EXIT.refused); + expect(edited.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + + const other = makeFixtureRepo(); repos.push(other); + const s = stack(other); + landOnMain(other, {}); + const abandoned = carry(other, s.body); + expect(abandoned.status).toBe(EXIT.refused); + expect(abandoned.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(abandoned.err).toContain('is not the one reviewed against parent (the parent has not fully landed on main'); + expect(receiptIn(s.body).head).toBe(s.child); + }, 120_000); +}); From 59ef9d922887dbeb52e63a7be334ee84e7ac30a4 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:28:31 +0000 Subject: [PATCH 11/17] docs: queue and carry refusal lists name the depth and exception codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/ship-policy.md | 24 ++++++++++++++++++------ docs/troubleshooting.md | 28 ++++++++++++++++++++-------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd59954b..9e986bf9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it. ### Added -- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed blocker or a CRITICAL raises light to standard for the rest of the invocation. +- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed or open blocker, or a CRITICAL, raises light to standard for the rest of the invocation. - **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559). - **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, a body that dropped its line, or a fix-check head that is not the bound head or its ancestor is `QUEUE_RECEIPT_EXCEPTION_INVALID`. The queue reads the same committed range as the writer and trusts a recorded gate scan only when it names the full bound commit. A receipt with neither new field predates depth receipts and is accepted as legacy. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index ad60544a3..bd848b5a2 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -221,8 +221,11 @@ It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD is not the PR's pushed head, CI at that exact head is not green, or the pregate result is not a current pass, and (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that -head: /ship runs before every READY ([details](#ship-before-ready)). Send the -block as printed. +head: /ship runs before every READY ([details](#ship-before-ready)). It also +refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below +what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner +exception the receipt records does not stand ([the ship receipt](#ship-receipt)). +Send the block as printed. **2. Lane behind another PR: gate ahead.** Gate the tree the queue will have once the predecessor lands, while it is still open: @@ -370,12 +373,17 @@ slot. The queue enforces this at every step from the PR body alone: - `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR); the informational - `SHIP_RECEIPT:` line still prints. There is no flag around it. + `SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED` + (with a `REVIEW_DEPTH:` line) when the receipt records a review depth below + what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner + exception it records does not stand ([the ship receipt](#ship-receipt)). + There is no flag around it. - `train build` refuses a pin whose PR has no current receipt with the same codes, naming the PR, before anything is written, pushed or dispatched, `--dry-run` included. -- `verify --train` refuses the slot with the same codes before printing - `MERGE_CONDITION`. +- `verify --train` refuses the slot with `QUEUE_RECEIPT_MISSING` or + `QUEUE_RECEIPT_STALE` before printing `MERGE_CONDITION`; it does not + recompute the review depth. **The stamp keeps the pin's receipt.** The slot's stamp commit changes only stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs @@ -478,6 +486,9 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship receipt for the pin; see [/ship before READY](#ship-before-ready)), +`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the +receipt's review depth is below what the pin needs, or an owner exception it +records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names @@ -836,7 +847,8 @@ 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), 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_depth`, or when an owner exception no longer stands, each as +[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). **`review_loop_exception`.** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559), from `write --review-loop-exception diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c5e0520a5..95bb3650a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2900,10 +2900,12 @@ for an older PR, write it once by hand with the same command. ### `RECEIPT_INVALID` **Meaning.** The block exists but does not validate against the `ship-receipt` -schema (`gstack-artifact schema ship-receipt`). +schema (`gstack-artifact schema ship-receipt`). `gstack-ship-receipt write` +also refuses with this code, and writes nothing, when `--head` is not the +checked-out HEAD: the receipt binds the tree the review read. **Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the -block. +block. For the `--head` refusal, check out that commit and write there. ### `EXECUTION_UNKNOWN` @@ -4438,7 +4440,8 @@ to fix this: the stamp comes only when the slot is called. ### `QUEUE_RECEIPT_DEPTH_RAISED` **Meaning.** `ready` or `train build` recomputed the review depth the head -needs from git (the range against `origin/`, the base commit's +needs from git (the range against the base ref, `origin/` in `ready` +and `/` in `train build`, the base commit's generated-output registry, and the receipt's gate scan when it names this head) and the receipt's `review_depth.depth` is below it, or the receipt comes from a gstack that records `gstack_version` but carries no `review_depth`. The @@ -4457,8 +4460,10 @@ Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-de **Meaning.** The receipt records an owner exception (`docs.exception` or `review_loop_exception`) that the queue cannot accept: the record does not parse, a review-loop exception names no fix-check (`review_loop.fix_checks` -is 0 or missing), or the PR body no longer carries the exception's line word -for word (`Documentation exception: …` or `Review-loop exception: …`). +is 0 or missing), its `fix_check.head` is neither the bound head nor an +ancestor of it (not checked after a carry across rewritten history), or the PR +body no longer carries the exception's line word for word +(`Documentation exception: …` or `Review-loop exception: …`). **Fix.** Restore the line in the PR body exactly as `write` printed it, or run /ship again and record the owner's acceptance with `--docs` or @@ -4729,13 +4734,20 @@ untracked changes (a carry certifies a commit, never a tree that differs from it), the receipt's head left HEAD's history and the receipt records no tree to compare (a receipt written before `head_tree`, whose head is not in this clone), or git could not compare the two product diffs or read the working -tree's status. The receipt in the body is unchanged and still names the old -head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +tree's status. It also refuses when an owner exception the receipt records +no longer stands, and, for a receipt from a gstack that records review +depth, when HEAD needs a deeper review than the receipt records, `--head` is +not the checked-out HEAD, or the receipt records `gstack_version` without +`review_depth` ([ship-policy.md](ship-policy.md#ship-receipt)). The receipt in +the body is unchanged and still names the old head, so `gstack-ship-queue +ready` refuses `QUEUE_RECEIPT_STALE`. **Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is already rewritten and the receipt predates `head_tree`, run /ship on the new -head; next time merge the base in instead of rebasing. +head; next time merge the base in instead of rebasing. For a depth or +exception refusal, run /ship on the new head (or check out `--head` and carry +there). ### `REVIEW_LOOP_EXCEPTION_INVALID` From 8b9c89306c7f41e163c4c73a84c9b520417a5859 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:34:10 +0000 Subject: [PATCH 12/17] fix: pre-landing review fixes A retargeted carry compared the reviewed range with a patch-id whose pathspec came from the reviewed head..HEAD range, so a parent's package.json change that never reached the base dropped out and the receipt carried. Compare with HEAD's whole product patch-id against the base (the one write records), rebind the receipt's base on a retarget carry, and cover the parent-metadata, landed- with-changes and test-only-child cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 +- bin/gstack-ship-receipt | 23 +++++----- docs/ship-policy.md | 6 ++- test/ship-receipt-carry-squash.test.ts | 62 ++++++++++++++++++++++++-- 4 files changed, 78 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c557d34ec..73d8d586b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ - **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. - **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. -- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base ` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/` for an older receipt. Equal carries with the reason `stack parent landed`; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19). +- **Stacked PRs** (Fixes #3570): after a stack parent lands and the child is retargeted to the base with the base merged in, `carry --base ` compares HEAD's product diff against the base with the range the receipt reviewed against the parent branch. That is the recorded `product_patch_id`, or the range against `origin/` for an older receipt. HEAD's side is its whole product diff against the base, so a parent's `package.json` change that never reached the base refuses too. Equal carries with the reason `stack parent landed` and rebinds the receipt's `base` to the base; anything else refuses. Until now every stacked child paid a full /ship once its parent merged (gstack-evals#19). ### Tests -- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in. A child edited after the merge and a parent that never landed refuse. An older receipt falls back to `origin/` and refuses once that branch is deleted. +- `test/ship-receipt-carry-squash.test.ts`: a child carries after its parent squash-merges and main is merged in, names main as its base, and carries again across a later main merge. A test-only child carries. A child edited after the merge, a parent that never landed, a parent `package.json` change that never reached main, and a merge that keeps the child's tree over the landed parent refuse. An older receipt falls back to `origin/` and refuses once that branch is deleted. - `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product. - `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 18e5c5045..1cb309dfa 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -521,6 +521,8 @@ function cmdCarry(rest: string[], json: boolean): number { if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`); const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head); if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`); + const reviewedBase = receipt.base.replace(/^origin\//, ''); + const retarget = reviewedBase !== base.replace(/^origin\//, ''); let plan: ReturnType; if (!ancestor) { // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. @@ -539,15 +541,10 @@ function cmdCarry(rest: string[], json: boolean): number { const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests)); const testHead = asId(patchId(repoRoot, headBase, head, spec.tests)); for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); - // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, - // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every - // path the base changed, none of which can differ between the two base diffs. // A stacked child whose parent landed on the base (#3570): the receipt reviewed the child's own range against the // parent branch, and the PR now targets the base. That reviewed range (the recorded product patch-id, else the one - // against origin/ when it still resolves) must be HEAD's whole product diff against the base: the parent - // fully landed and the child is unchanged. Anything else fails closed. - const reviewedBase = receipt.base.replace(/^origin\//, ''); - const retarget = reviewedBase !== base.replace(/^origin\//, ''); + // against origin/ when it still resolves) must be HEAD's whole product diff against the base, computed + // the way write records it (headPatch): the parent fully landed and the child is unchanged. Anything else fails closed. let reviewed = productFrom; if (retarget) { const parentRef = `origin/${reviewedBase}`; @@ -556,9 +553,13 @@ function cmdCarry(rest: string[], json: boolean): number { if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the receipt was reviewed against ${reviewedBase}, not ${base}, and its reviewed product diff is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); reviewed = recorded; } + const current = retarget ? headPatch : productHead; const changedProduct: string[] = []; - if (retarget && reviewed !== productHead) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); + if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); else if (productFrom !== productHead && !retarget) { + // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, + // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every + // path the base changed, none of which can differ between the two base diffs. const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); for (const p of [...touched].sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; @@ -572,7 +573,7 @@ function cmdCarry(rest: string[], json: boolean): number { // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: productHead as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); + plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}) }); } if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, @@ -588,7 +589,9 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { 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 `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); + // The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on. + const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}) }; + const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index b954d0bfe..be440f640 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -910,8 +910,10 @@ child's own range. After the parent lands, the PR is retargeted to the base, and receipt's, the carry compares the reviewed range (the recorded `product_patch_id`, or for an older receipt the range against `origin/` while that branch still resolves) with HEAD's whole product -diff against the base. Equal means the parent fully landed and the child is -unchanged, so the receipt carries. A parent that has not landed, a child edited +diff against the base, computed the way `write` records it, so a parent's +release-metadata change that never reached the base counts too. Equal means +the parent fully landed and the child is unchanged, so the receipt carries +and from then on names the base it was carried to. A parent that has not landed, a child edited since, or a parent that landed with changes the child never merged all leave a different diff and refuse with `RECEIPT_CARRY_PRODUCT_CHANGED`. An older receipt whose parent branch is gone refuses with `RECEIPT_CARRY_REFUSED`. diff --git a/test/ship-receipt-carry-squash.test.ts b/test/ship-receipt-carry-squash.test.ts index 8c00defea..a42485703 100644 --- a/test/ship-receipt-carry-squash.test.ts +++ b/test/ship-receipt-carry-squash.test.ts @@ -17,14 +17,16 @@ const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8') const carry = (repo: FixtureRepo, body: string) => runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); const SUB = "exports.add = (a, b) => a + b;\nexports.sub = (a, b) => a - b;\n"; +const RETRY4 = "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n"; + /** main <- parent (lib/util.js) <- feature (lib/retry.js, its /ship receipt written against the parent branch). */ -function stack(repo: FixtureRepo): { parent: string; child: string; body: string } { +function stack(repo: FixtureRepo, parentFiles: Record = { 'lib/util.js': SUB }, childFiles: Record = { 'lib/retry.js': RETRY4 }): { parent: string; child: string; body: string } { repo.git('checkout', '-q', '-b', 'parent'); - const parent = commit(repo, { 'lib/util.js': SUB }, 'parent: sub'); + const parent = commit(repo, parentFiles, 'parent: sub'); repo.git('push', '-q', 'origin', 'parent'); repo.git('checkout', '-q', 'feature'); repo.git('reset', '-q', '--hard', parent); - const child = commit(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }, 'child: four retries'); + const child = commit(repo, childFiles, 'child: four retries'); const body = path.join(repo.root, 'body.md'); const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '8', '--base', 'parent', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body]); expect(w.status).toBe(EXIT.ok); @@ -61,6 +63,16 @@ describe('carry after a stack parent squash-merges (#3570)', () => { expect(r.head).toBe(head); expect(r.carry!.reason).toBe('stack parent parent landed + base merge'); expect(r.product_patch_id).toBe(reviewed); + expect(r.base).toBe('main'); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'docs/CHANGES.md': '# main moves again\n' }, 'main moves again'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const again = carry(repo, body); + expect(again.status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge + base merge'); + expect(receiptIn(body).product_patch_id).toBe(reviewed); }, 90_000); // Value: protects=a receipt written before product_patch_id still carries while the parent branch resolves, and refuses by @@ -82,6 +94,19 @@ describe('carry after a stack parent squash-merges (#3570)', () => { repo.git('push', '-q', 'origin', `${parent}:refs/heads/parent`); repo.git('fetch', '-q', 'origin'); expect(carry(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge'); + }, 90_000); + + // Value: protects=a child with no product change of its own (product_patch_id none) still carries once the parent lands; + // fails_when=the recorded 'none' is compared as a literal id; seam=none; no_claim=- + test('a test-only child carries after its parent lands', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { body } = stack(repo, { 'lib/util.js': SUB }, { 'test/retry.test.js': "const test = require('node:test');\ntest('retries', () => {});\n" }); + expect(receiptIn(body).product_patch_id).toBe('none'); + landOnMain(repo, { 'lib/util.js': SUB }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.ok); + expect(receiptIn(body).carry!.reason).toBe('stack parent parent landed + base merge'); }, 90_000); // Value: protects=fail closed: a child changed after the parent merged, or a parent that never reached main, takes a fresh @@ -105,4 +130,35 @@ describe('carry after a stack parent squash-merges (#3570)', () => { expect(abandoned.err).toContain('is not the one reviewed against parent (the parent has not fully landed on main'); expect(receiptIn(s.body).head).toBe(s.child); }, 120_000); + + // Value: protects=fail closed on the parent's release-metadata paths: a package.json change (a dependency and a postinstall + // script) the parent made but main never received is product in HEAD's diff against main, though the child never touched it; + // fails_when=the comparison takes its pathspec from the reviewed head..HEAD range, where package.json is unchanged; seam=none; + // no_claim=- + test('a parent package.json change that never reached main refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const pkg = JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node --test test/*.test.js', generate: 'node gen.js', postinstall: 'node gen.js' }, dependencies: { leftpad: '1.0.0' } }, null, 2) + '\n'; + const { child, body } = stack(repo, { 'lib/util.js': SUB, 'package.json': pkg }); + landOnMain(repo, { 'lib/util.js': SUB }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + }, 90_000); + + // Value: protects=the docs row "a parent that landed with changes the child never merged" refuses; fails_when=a merge that + // keeps the child's tree (-s ours) over the landed parent reads as unchanged; seam=none; no_claim=- + test('a parent that landed with changes the child never merged refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const { child, body } = stack(repo); + repo.git('checkout', '-q', 'main'); + commit(repo, { 'lib/util.js': SUB + "exports.mul = (a, b) => a * b;\n" }, 'squash: parent with a review fix (#7)'); + repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); + repo.git('merge', '-q', '--no-edit', '-s', 'ours', 'origin/main'); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(receiptIn(body).head).toBe(child); + }, 90_000); }); From e9ec264358f0043feff6bf4522895c63769a1fe2 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:37:09 +0000 Subject: [PATCH 13/17] test: approval-classes summary-line regexes allow the receipt's depth field Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/approval-classes.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/approval-classes.test.ts b/test/approval-classes.test.ts index a1a903564..c4f36504d 100644 --- a/test/approval-classes.test.ts +++ b/test/approval-classes.test.ts @@ -198,7 +198,7 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION expect(changes.err).toMatch(/requests changes; it is not an approval/); const ok = write(repo, `## Summary\n\nApproved by garrytan 2026-10-16 via ${COMMENT}\n`, [], ghApi(repo, { [COMMENT_API]: { user: { login: 'garrytan' }, body: `Approved: ${token}. Preview first, never at upgrade.` } })); expect(ok.status, ok.err).toBe(EXIT.ok); - expect(ok.err).toMatch(/^SHIP_RECEIPT: .* approval=verified$/m); + expect(ok.err).toMatch(/^SHIP_RECEIPT: .* approval=verified(?: depth=(?:light|standard|full))?$/m); const receipt = JSON.parse(ok.out); expect(receipt.approval).toMatchObject({ status: 'checked', source: expect.stringMatching(/^origin\/main@[0-9a-f]{12}:\.gstack\/approval-classes\.json$/) }); expect(receipt.approval.classes).toEqual([{ class: 'data-repair', scope: token, effects: ['src/commands/repair/accepted-takes.ts:1'], citation: 'verified', approved_by: 'garrytan', date: '2026-10-16', via: COMMENT, binding: 'token', evidence: 'comment by garrytan' }]); @@ -256,7 +256,7 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION expect(bare.err).toMatch(/GBRA-60: unresolved \(GBRA-60 is neither a plan sha nor a GitHub comment or review URL\); an unverified citation must attest "for data-repair@[0-9a-f]{12}".*\(APPROVAL_CITATION_MISSING\)$/m); const attested = write(repo, '', ['--approval', `Approved by Garry 2026-10-16 via GBRA-60 for ${token}`]); expect(attested.status, attested.err).toBe(EXIT.ok); - expect(attested.err).toMatch(/^SHIP_RECEIPT: .* approval=unverified:data-repair$/m); + expect(attested.err).toMatch(/^SHIP_RECEIPT: .* approval=unverified:data-repair(?: depth=(?:light|standard|full))?$/m); expect(JSON.parse(attested.out).approval.classes[0]).toMatchObject({ citation: 'unverified', binding: 'attested', via: 'GBRA-60', evidence: expect.stringMatching(/^unresolved: /) }); commit(repo, { 'src/commands/repair/orphans.ts': 'export const y = 2;\n' }, 'more repair'); const stale = write(repo, '', ['--approval', `Approved by Garry 2026-10-16 via GBRA-60 for ${token}`]); @@ -280,12 +280,12 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION const w = write(repo, ''); expect(w.status, w.err).toBe(EXIT.ok); expect(JSON.parse(w.out).approval).toEqual({ source: expect.stringMatching(/^origin\/main@[0-9a-f]{12}:/), status: 'checked', classes: [], report_only: ['migrations db/migrate/0250_x.sql:1 (added)'] }); - expect(w.err).toMatch(/approval=none$/m); + expect(w.err).toMatch(/approval=none(?: depth=(?:light|standard|full))?$/m); repo.git('remote', 'remove', 'origin'); const gone = write(repo, ''); expect(gone.status, gone.err).toBe(EXIT.ok); expect(gone.err).toMatch(/^APPROVAL_CLASSES: unavailable \(origin\/main is not a resolvable commit\); approval classes were not checked$/m); - expect(gone.err).toMatch(/approval=unchecked$/m); + expect(gone.err).toMatch(/approval=unchecked(?: depth=(?:light|standard|full))?$/m); expect(JSON.parse(gone.out).approval).toMatchObject({ status: 'unavailable', classes: [] }); }, 30_000); }); From 12759e0b18f2f829e506286e63b3f0bb422f4f38 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:40:24 +0000 Subject: [PATCH 14/17] docs: troubleshooting names the stacked-retarget carry refusals Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/troubleshooting.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3bb588677..e59cf3aeb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4698,14 +4698,18 @@ to HEAD: the PR body has no valid receipt, the working tree has uncommitted or untracked changes (a carry certifies a commit, never a tree that differs from it), the receipt's head left HEAD's history and the receipt records no tree to compare (a receipt written before `head_tree`, whose head is not in this -clone), or git could not compare the two product diffs or read the working -tree's status. The receipt in the body is unchanged and still names the old -head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +clone), the PR was retargeted to the base after its stack parent landed but +the receipt records no `product_patch_id` and `origin/` no longer +resolves, so the reviewed range cannot be recomputed, or git could not +compare the two product diffs or read the working tree's status. The receipt +in the body is unchanged and still names the old head, so +`gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. **Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is already rewritten and the receipt predates `head_tree`, run /ship on the new -head; next time merge the base in instead of rebasing. +head; next time merge the base in instead of rebasing. For a retargeted stacked +PR whose older receipt lost its parent branch, run /ship on the new head. ### `RECEIPT_CARRY_TREE_MISMATCH` @@ -4734,12 +4738,14 @@ is not the reviewed one. For a stacked PR retargeted to the base after its parent landed, it means HEAD's product diff against the base is not the range reviewed against the parent branch: the parent has not fully landed, or the branch changed since. The reviewed verdict does not cover HEAD, so the -receipt is not carried. The refusal names the changed product paths. +receipt is not carried. The refusal names the changed product paths, or for a +retargeted stacked PR the parent branch and the base. **Fix.** Run /ship on the new head. The receipt carries only across a conflict-only base merge, a test-only change, release metadata, a registered -output the pregate's regen check proved, or a rebase onto a covered tree with -the recorded product diff, which the +output the pregate's regen check proved, a rebase onto a covered tree with +the recorded product diff, or a stacked PR's retarget once its parent fully +landed on the base, which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). From f4897744fb5d88525b6544d0e01dbb73ac1cca97 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 01:47:32 +0000 Subject: [PATCH 15/17] fix(ship-receipt): summary line prints depth before approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-receipt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 0b0943919..25ba416e4 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -119,11 +119,11 @@ export function receiptSummaryLine(r: ShipReceipt): string { if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`); if (r.session_kind) parts.push(`session_kind=${r.session_kind}`); if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`); + if (r.review_depth && typeof r.review_depth.depth === 'string') parts.push(`depth=${r.review_depth.depth}`); if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`); const loop = parseReviewLoop(r.review_loop); if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}` + (loop.fix_checks ? ` fix_checks=${loop.fix_checks}` : '') + (loop.dispatches ? ` dispatches=${Object.values(loop.dispatches).reduce((a, c) => a + c, 0)}` : '')); - if (r.review_depth && typeof r.review_depth.depth === 'string') parts.push(`depth=${r.review_depth.depth}`); if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`); return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`; } From 621baec9b8e50a74f7475b98860289ba354f32df Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 02:00:13 +0000 Subject: [PATCH 16/17] Revert "test: approval-classes summary-line regexes allow the receipt's depth field" This reverts commit e9ec264358f0043feff6bf4522895c63769a1fe2. --- test/approval-classes.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/approval-classes.test.ts b/test/approval-classes.test.ts index c4f36504d..a1a903564 100644 --- a/test/approval-classes.test.ts +++ b/test/approval-classes.test.ts @@ -198,7 +198,7 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION expect(changes.err).toMatch(/requests changes; it is not an approval/); const ok = write(repo, `## Summary\n\nApproved by garrytan 2026-10-16 via ${COMMENT}\n`, [], ghApi(repo, { [COMMENT_API]: { user: { login: 'garrytan' }, body: `Approved: ${token}. Preview first, never at upgrade.` } })); expect(ok.status, ok.err).toBe(EXIT.ok); - expect(ok.err).toMatch(/^SHIP_RECEIPT: .* approval=verified(?: depth=(?:light|standard|full))?$/m); + expect(ok.err).toMatch(/^SHIP_RECEIPT: .* approval=verified$/m); const receipt = JSON.parse(ok.out); expect(receipt.approval).toMatchObject({ status: 'checked', source: expect.stringMatching(/^origin\/main@[0-9a-f]{12}:\.gstack\/approval-classes\.json$/) }); expect(receipt.approval.classes).toEqual([{ class: 'data-repair', scope: token, effects: ['src/commands/repair/accepted-takes.ts:1'], citation: 'verified', approved_by: 'garrytan', date: '2026-10-16', via: COMMENT, binding: 'token', evidence: 'comment by garrytan' }]); @@ -256,7 +256,7 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION expect(bare.err).toMatch(/GBRA-60: unresolved \(GBRA-60 is neither a plan sha nor a GitHub comment or review URL\); an unverified citation must attest "for data-repair@[0-9a-f]{12}".*\(APPROVAL_CITATION_MISSING\)$/m); const attested = write(repo, '', ['--approval', `Approved by Garry 2026-10-16 via GBRA-60 for ${token}`]); expect(attested.status, attested.err).toBe(EXIT.ok); - expect(attested.err).toMatch(/^SHIP_RECEIPT: .* approval=unverified:data-repair(?: depth=(?:light|standard|full))?$/m); + expect(attested.err).toMatch(/^SHIP_RECEIPT: .* approval=unverified:data-repair$/m); expect(JSON.parse(attested.out).approval.classes[0]).toMatchObject({ citation: 'unverified', binding: 'attested', via: 'GBRA-60', evidence: expect.stringMatching(/^unresolved: /) }); commit(repo, { 'src/commands/repair/orphans.ts': 'export const y = 2;\n' }, 'more repair'); const stale = write(repo, '', ['--approval', `Approved by Garry 2026-10-16 via GBRA-60 for ${token}`]); @@ -280,12 +280,12 @@ describe('gstack-ship-receipt write enforces declared classes (APPROVAL_CITATION const w = write(repo, ''); expect(w.status, w.err).toBe(EXIT.ok); expect(JSON.parse(w.out).approval).toEqual({ source: expect.stringMatching(/^origin\/main@[0-9a-f]{12}:/), status: 'checked', classes: [], report_only: ['migrations db/migrate/0250_x.sql:1 (added)'] }); - expect(w.err).toMatch(/approval=none(?: depth=(?:light|standard|full))?$/m); + expect(w.err).toMatch(/approval=none$/m); repo.git('remote', 'remove', 'origin'); const gone = write(repo, ''); expect(gone.status, gone.err).toBe(EXIT.ok); expect(gone.err).toMatch(/^APPROVAL_CLASSES: unavailable \(origin\/main is not a resolvable commit\); approval classes were not checked$/m); - expect(gone.err).toMatch(/approval=unchecked(?: depth=(?:light|standard|full))?$/m); + expect(gone.err).toMatch(/approval=unchecked$/m); expect(JSON.parse(gone.out).approval).toMatchObject({ status: 'unavailable', classes: [] }); }, 30_000); }); From 33f29a326513de00d7132f29f02f91a6e3f54629 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 03:10:09 +0000 Subject: [PATCH 17/17] test: assert a retarget carry rebinds review_depth to the new base and binds at the queue; docs: split the --head carry advice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/troubleshooting.md | 3 ++- test/ship-receipt-carry-squash.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 294c68025..e87ff0236 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4749,7 +4749,8 @@ ready` refuses `QUEUE_RECEIPT_STALE`. already rewritten and the receipt predates `head_tree`, run /ship on the new head; next time merge the base in instead of rebasing. For a retargeted stacked PR whose older receipt lost its parent branch, or a depth or exception -refusal, run /ship on the new head (or check out `--head` and carry there). +refusal, run /ship on the new head. When `--head` is not the checked-out HEAD, +check it out and carry there. ### `REVIEW_LOOP_EXCEPTION_INVALID` diff --git a/test/ship-receipt-carry-squash.test.ts b/test/ship-receipt-carry-squash.test.ts index a42485703..2c763e7b3 100644 --- a/test/ship-receipt-carry-squash.test.ts +++ b/test/ship-receipt-carry-squash.test.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { bindShippedReceipt } from '../lib/ship-receipt-depth'; import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -64,6 +65,9 @@ describe('carry after a stack parent squash-merges (#3570)', () => { expect(r.carry!.reason).toBe('stack parent parent landed + base merge'); expect(r.product_patch_id).toBe(reviewed); expect(r.base).toBe('main'); + expect(r.review_depth!.base).toBe(repo.git('rev-parse', 'origin/main')); + expect(r.review_depth!.gate_scan.head).toBe(head); + expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '8', [head]).ok).toBe(true); repo.git('checkout', '-q', 'main'); commit(repo, { 'docs/CHANGES.md': '# main moves again\n' }, 'main moves again'); repo.git('push', '-q', 'origin', 'main');