mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.306.0 ship-receipt: carry across regenerated outputs and onto a reviewed tree after a rebase (#3564)
* 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> * fix: regenerate doctor anchors; type the head_tree assert Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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/<base>'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> * 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> * 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> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f4f16e83e0
commit
de4c694d91
20 files changed
+515
-113
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.306.0] - 2026-10-29
|
||||
|
||||
**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/<base>'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**: 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 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.305.0] - 2026-10-29
|
||||
|
||||
**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.305.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.306.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -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' ;;
|
||||
|
||||
+133
-59
@@ -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 <r> --since <tag|date> [--cohort <file>]
|
||||
the P7 adoption number over merged PRs (deduplicated by run and thread)
|
||||
carry --pr <n> [--repo <r>] | --body <file> [--base <ref>] [--head <sha>] [--pregate <pregate.json>] [--dry-run] [--out <dir>]
|
||||
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 (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 /
|
||||
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 <ref>] /ship Step 14.5: one DOCS_CAP: <path> <bytes>/<max> 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,13 +384,73 @@ 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 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/<base>'s `.gstack/generated.json` at the commit this carry reads and the
|
||||
* 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 [];
|
||||
const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen');
|
||||
if (!regen || regen.status !== 'pass' || !regen.proven?.length) return [];
|
||||
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 => !/[*?[\]\\]/.test(p) && matchesAny(owned, p));
|
||||
}
|
||||
|
||||
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<typeof classifyCarryPath> }>; 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
|
||||
* 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');
|
||||
@@ -384,7 +461,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 +502,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<typeof planCarry>;
|
||||
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 +573,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 <pregate.json>`);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -146,7 +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).
|
||||
(`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.
|
||||
|
||||
+45
-8
@@ -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.
|
||||
|
||||
@@ -891,6 +892,16 @@ line, and the codes a refusal ends with are
|
||||
<a id="receipt-carry"></a>
|
||||
## 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'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`) |
|
||||
|
||||
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,11 +932,22 @@ 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/<base>'s
|
||||
`.gstack/generated.json` is `generated`, not product, only when the
|
||||
`--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/<base> 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
|
||||
`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 <old> to <new> (<reason>)` and the `GSTACK_RESULT:
|
||||
skill=ship status=complete` line, and its summary line ends with
|
||||
`carried_from=<old12>`. `CARRY: current` means the receipt already names HEAD;
|
||||
@@ -933,10 +955,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
|
||||
|
||||
+34
-14
@@ -4694,29 +4694,49 @@ 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 <n>` 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.
|
||||
|
||||
<a id="receipt-carry-tree-mismatch"></a>
|
||||
### `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)).
|
||||
|
||||
<a id="receipt-carry-product-changed"></a>
|
||||
### `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'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
|
||||
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)).
|
||||
|
||||
|
||||
+4
-3
@@ -225,9 +225,10 @@ 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)`
|
||||
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;
|
||||
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
|
||||
`patch-id --verbatim` ([ship-policy.md](ship-policy.md#receipt-carry)).
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface PregateCheck {
|
||||
stage?: 'preflight' | 'tests'; inputs?: Record<string, string>; 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 }> }
|
||||
@@ -99,7 +101,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<string, number>; 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'];
|
||||
@@ -203,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 } } } } } } } },
|
||||
@@ -219,7 +223,8 @@ export const ARTIFACT_SCHEMAS = {
|
||||
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> 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' } } } } } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
+19
-3
@@ -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 proof run recreated byte-identically (an output it never writes, or a proof run that failed, proves nothing). */
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -248,10 +252,22 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s
|
||||
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) });
|
||||
// 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(); }
|
||||
|
||||
@@ -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 regen check proved, or the base merge, and the product patch-id is unchanged',
|
||||
},
|
||||
DOCS_AUDIT_BLOCKED: {
|
||||
anchor: 'docs-audit-blocked',
|
||||
|
||||
+47
-9
@@ -17,7 +17,7 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
||||
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
'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 proved its command recreates (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. */
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.305",
|
||||
"version": "1.91.306",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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<string, string>, 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');
|
||||
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 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 } });
|
||||
}, 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);
|
||||
});
|
||||
Reference in new issue
Block a user