mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
When Step 14.5 audits twice in one /ship run (an audit, then a base merge and restamp, then the re-audit), receipt write reset only the last audit's files, so a file the first audit edited and committed stayed in the review_exclude selection and write refused the reviewed digest (gbrain #8678). gstack-docs-candidate snapshot now takes --run (the invocation record), --pre on a post snapshot (records the files the audit edited, before and after) and --carry (the run's earlier post snapshot, refused from another run). Write resets every file the run's audits edited to its content before them when HEAD holds what the last of them left; a file no audit edited, an edit after an audit and a carried audit of another run still refuse. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
1017 lines
88 KiB
TypeScript
Executable File
1017 lines
88 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-ship-receipt — the handoff record, identifiers first (plan C5).
|
|
*
|
|
* gstack-ship-receipt write [--pr <n>] [--head <sha>] [--base <ref>] [--version <X>] [--gated-tree <sha>]
|
|
* [--predecessor <sha>] [--gate <json|@file>] [--ci <json|@file>] [--spend-usd <n>]
|
|
* [--session-kind <k>] [--artifacts-consumed <n>/<m>] [--run <id>] [--thread <id>]
|
|
* [--receipt <tree-receipt.json>] [--policy <label>] [--preregistration <text>] [--history <text>]
|
|
* [--pregate <pregate.json>] [--issues <json|@file>] [--approval <line>]... [--approval-run <dir>]...
|
|
* [--base-red <pause.json>]
|
|
* [--out <file>] [--body <pr-body.md>] [--json]
|
|
* gstack-ship-receipt read --pr <n> [--repo <owner/repo>] [--check-prose] [--json]
|
|
* gstack-ship-receipt read --body <file> [--json]
|
|
* gstack-ship-receipt result (--pr <n> [--repo <owner/repo>] | --body <file>) [--head <sha>] [--out <dir>] [--json]
|
|
* gstack-ship-receipt census --repo <owner/repo> --since <tag|YYYY-MM-DD> [--cohort <file>] [--limit <n>] [--json]
|
|
* gstack-ship-receipt carry (--pr <n> [--repo <owner/repo>] | --body <file>) [--base <ref>] [--head <sha>] [--pregate <pregate.json>] [--dry-run] [--out <dir>] [--json]
|
|
* gstack-ship-receipt docs-caps [--base <ref>] [--policy <ship-policy.json>] [--json]
|
|
*
|
|
* `write` validates against `gstack-artifact schema ship-receipt`, prints the
|
|
* fenced block (and SHIP_RECEIPT: <identifiers> on stderr), writes it to --out,
|
|
* and with --body makes it the first section of that PR body file.
|
|
* `result` is /ship's terminal step: it binds the receipt to HEAD, writes the
|
|
* run record under <state>/ship/runs/ (ship-run.json: a /ship record, not a
|
|
* `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and
|
|
* prints GSTACK_RESULT: skill=ship. A gh failure prints status=incomplete.
|
|
* `carry` rebinds an earlier head's receipt to HEAD when the product diff
|
|
* against the base is unchanged (a conflict-only base merge, a test-only fix,
|
|
* release metadata), records the evidence in the receipt's `carry` object,
|
|
* posts the body and ends with the same terminal line; a product change is
|
|
* RECEIPT_CARRY_PRODUCT_CHANGED and nothing is rewritten.
|
|
* Query commands: no GSTACK_RESULT line. Logic: lib/ship-receipt.ts.
|
|
*/
|
|
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 { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
|
|
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
|
|
import { versionAtRef } from '../lib/restamp';
|
|
import { branchSides, stampOnlyLine, type BranchSide } from '../lib/stamp-only';
|
|
import { runScriptSync } from '../lib/script-invocation';
|
|
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
|
import { exceptionConsumerProblem } from '../lib/pregate/flaky-exception';
|
|
import { installedGstackVersion, isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy';
|
|
import { approvalForReceipt } from '../lib/approval-citations';
|
|
import {
|
|
CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS,
|
|
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, landedStackParent, 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 { comparePath, contextPacket, PACKET_SECTION_CAP, readScopedReview, touchedPaths } from '../lib/ship-patch';
|
|
import { planRecheck, readRecheck, recheckPacket, recheckRecordTemplate } from '../lib/ship-recheck';
|
|
import { carryExcludeBinds, deeper, depthBelow, exceptionHead, exceptionLoopProblem, exceptionProblems, parseReviewLoopException, parseReviewTargeted, plainDocsSince, platformReceiptDepth, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince, writeExcludeBinds } from '../lib/ship-receipt-depth';
|
|
import { RISK_CLASSES, type RiskClass } from '../lib/review-risk';
|
|
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
|
|
import { resolveStateRoot } from '../lib/state-root';
|
|
import { readPause, receiptBaseRed } from '../lib/base-red';
|
|
import { readRuledNotes, ruledNoteText } from '../lib/ruled-notes';
|
|
import { laneExceptionBodyLines } from '../lib/test-lane-exception';
|
|
import { readMechanicalLines } from '../lib/ship-mechanical';
|
|
import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen';
|
|
|
|
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
|
|
|
|
write [flags] compose, validate and print the block; --body <file> puts it first in that PR body;
|
|
--base-red <pause.json> makes it conditional (gstack-base-red classify --pause)
|
|
read --pr <n> [--repo <r>] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID)
|
|
read --body <file> parse the block from a file
|
|
read … --check-prose also compare every counter the body quotes (passes=2, 1 deferred, passed: 41)
|
|
with the receipt's review_loop, gate and ci integers: RECEIPT_PROSE_MISMATCH (exit 1)
|
|
result --pr <n> [--repo <r>] | --body <file> [--head <sha>] [--out <dir>]
|
|
/ship's terminal line: binds the receipt to HEAD (QUEUE_RECEIPT_MISSING / QUEUE_RECEIPT_STALE),
|
|
writes <state>/ship/runs/<utc>-<head12>/{ship-run.json,ship-receipt.md} and prints
|
|
GSTACK_RESULT: skill=ship status=complete head=<full sha> run=<dir> (refused: status=refused run=-)
|
|
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, 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);
|
|
a product change on linear history whose range-diff is within the re-check cap prints
|
|
RECHECK: eligible … packet=<file>: one reader reads it, then --recheck <record.json> (J1);
|
|
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
|
|
reviewed policy (origin/<base>'s .gstack/ship-policy.json; --policy <file> reads that file
|
|
instead); any over (or absent) file exits 1 (DOCS_AUDIT_BLOCKED)
|
|
|
|
write flags: --pr --head --base --version --gated-tree --predecessor --gate <json|@file> --ci <json|@file> --spend-usd
|
|
--session-kind --artifacts-consumed <n>/<m> --run --thread --receipt <tree-receipt.json> --policy --preregistration
|
|
--history --queue-mode --pregate <pregate.json> (C8: refused unless current, green and every requires-remote obligation
|
|
carries a receipt: PREGATE_STALE / PREGATE_LANE_FAILED / PREGATE_INCOMPLETE / PREGATE_REMOTE_UNCLEARED) --issues <json|@file>
|
|
(C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line)
|
|
--approval <"Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>]"> (repeatable; the --body file's lines count too)
|
|
--review-loop <json|@file> ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out)
|
|
--docs <json|@file> ({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 <json|@file> ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a
|
|
stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the
|
|
"Review-loop exception: …" line verbatim; or second_pass: {head, deferred: [<finding id>]} in place of fix_check for a stop after
|
|
the second full pass with a finding left open, needing passes >= 2 and deferred >= 1; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and
|
|
review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an
|
|
unreadable input records full).
|
|
--review-exclude <digest|none> (the digest= on gstack-review-dispatch verify's REVIEW_EXCLUDE_RECORD: line, which the reviewers' briefs
|
|
name; none without that line): must equal the digest HEAD has under the base policy's review_exclude_paths, else
|
|
RECEIPT_INVALID field=review_exclude and nothing is written; recorded as review_exclude (HEAD's). Write, carry and the queue also bind
|
|
a digest from an earlier base policy when it gives the PR's files the same selection as HEAD's: the same applied patterns,
|
|
excluded files and kept files with their reasons (GST-1446; write rebuilds it from the policies the base held, prints
|
|
REVIEW_EXCLUDE_POLICY_MOVED:, and refuses when none does; an omitted --review-exclude is never rebuilt).
|
|
The check binds the digest to the base policy, not to a review: write does not read the review ledger, so the expected=
|
|
digest a refusal prints passes too. After a refusal, rerun the review and pass the digest its verify prints.
|
|
--docs-post <audit-id>-post.json (Step 14.5's post snapshot, the file finish takes): the reviewed --review-exclude digest also binds
|
|
when the only change to the selection since the review is files under review_exclude_paths that the snapshot names as uncommitted,
|
|
with the content HEAD holds, each excluded or kept only because code names it (GST-1429); prints REVIEW_EXCLUDE_DOCS_AUDIT: and
|
|
records HEAD's selection. Any other change, or an unreadable snapshot, refuses as above. --docs-pre <Prepare snapshot> (the same
|
|
audit's first snapshot) narrows the audit's files to those whose content it changed, so uncommitted docs the review read stay.
|
|
When a run audits twice, the last audit's post snapshot carries the earlier audit (gstack-docs-candidate snapshot --run --pre
|
|
--carry): files the earlier audit edited reset too, to their content before it, when HEAD holds what it left; a carried audit of
|
|
another run refuses (GST-1616).
|
|
--no-stop <json|@file> (a JSON array of {class, reason, evidence}: each routine stop the run recorded and went on past, class one of
|
|
scoped-fix-check, test-timeout-fix, pregate-flake-rerun, stamp-only-carry, autoplan-regate); recorded as no_stop.
|
|
--declare-risk <classes> (the same list gstack-review-plan --declare-risk took): joins review_depth's risk and only raises
|
|
its depth; review_depth.declared and .detected record both lists (GST-1129). An unknown class is RECEIPT_INVALID.
|
|
--platform-review <id> (NEXT: platform's PLATFORM_REVIEW: id): records review_depth platform with capy-ai[bot]'s review <id> at HEAD;
|
|
refused (RECEIPT_INVALID) unless the base policy has review.platform on, HEAD needs light review of a docs or tests diff with
|
|
no numeric, results or prereg claims, and that review is on the PR at HEAD. review_depth.mode records lean, panel or platform.
|
|
--review-targeted <json|@file> ({classes, rules, brief_sha, status, findings, raised_by, head}: gstack-review-dispatch verify
|
|
--targeted-out); recorded as review_targeted bound to the receipt's product_patch_id, and refused (RECEIPT_INVALID) when the
|
|
commit the reader read (head) is not an ancestor or a change since it carries a targeted class (gate scan included). write and carry
|
|
refuse a receipt whose head needs a targeted class (money, gate, auth, secret) that no done pass covers, as the queue's
|
|
targeted verdict does (#3859) (RECEIPT_INVALID, RECEIPT_CARRY_REFUSED).
|
|
--approval-run <autoplan run dir> (repeatable; resolves plan-sha citations) (W2: a class declared in origin/<base>'s
|
|
.gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING)
|
|
--out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
|
|
version source at HEAD ("none" when HEAD has no version source, no policy stamp file and no CHANGELOG), session_kind=$GSTACK_SESSION_KIND (gstack-session-kind), tree/gate_reuse/gated_tree/predecessor
|
|
from --receipt when given.
|
|
--json: JSON only on stdout.
|
|
${exitTableHelp()}`;
|
|
|
|
function flag(args: string[], name: string): string | undefined {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
}
|
|
function flags(args: string[], name: string): string[] {
|
|
return args.flatMap((a, i) => (a === name && args[i + 1] !== undefined ? [args[i + 1]!] : []));
|
|
}
|
|
function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
|
|
if (raw === undefined) return undefined;
|
|
const text = raw.startsWith('@') ? fs.readFileSync(raw.slice(1), 'utf8') : raw;
|
|
const parsed = JSON.parse(text);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('expected a JSON object');
|
|
return parsed;
|
|
}
|
|
/** --no-stop <json|@file>: the routine stops this run recorded and went on past (K1), a JSON array of {class, reason, evidence}. */
|
|
function noStopArg(raw: string | undefined): ShipReceipt['no_stop'] {
|
|
if (raw === undefined) return undefined;
|
|
const parsed = JSON.parse(raw.startsWith('@') ? fs.readFileSync(raw.slice(1), 'utf8') : raw);
|
|
if (!Array.isArray(parsed)) throw new Error('--no-stop expects a JSON array of {class, reason, evidence}');
|
|
return parsed.length ? parsed : undefined;
|
|
}
|
|
function git(args: string[], cwd?: string): string | undefined {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 * 1024 });
|
|
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
}
|
|
function gh(args: string[]): { ok: boolean; out: string; err: string } {
|
|
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
|
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
|
|
}
|
|
|
|
/**
|
|
* The receipt's `version`: the version source at HEAD (the pinned path, else
|
|
* VERSION, else package.json#version). A repository with none of those, no
|
|
* policy stamp file and no CHANGELOG at HEAD has no release version to record,
|
|
* and the receipt says so with `"none"` (docs/ship-policy.md#ship-receipt);
|
|
* `read`, `result` and the queue accept it like any other version string.
|
|
* Only an absent source is `"none"`: a VERSION that exists but holds no
|
|
* parsable version, a pin that escapes the repository, or an unreadable base
|
|
* policy keeps the existing no-version refusal.
|
|
*/
|
|
function receiptVersion(repoRoot: string, head: string, base: string): string | undefined {
|
|
const atHead = versionAtRef(repoRoot, head);
|
|
if ('version' in atHead) return atHead.version;
|
|
if (!/ does not exist at /.test(atHead.error)) return undefined;
|
|
const show = (rel: string) => git(['show', `${head}:${rel}`], repoRoot);
|
|
try { const v = JSON.parse(show('package.json') ?? 'null')?.version; if (typeof v === 'string' && v.trim()) return v.trim(); } catch { /* not a manifest */ }
|
|
const load = loadPolicyFromBase(repoRoot, base);
|
|
if (isUnavailable(load)) return undefined;
|
|
const changelogs = [...new Set([load.policy.changelog, 'CHANGELOG.md'].filter((p): p is string => !!p))];
|
|
if (stampFilesInTree(repoRoot, head, load.policy.stamp_files).length || changelogs.some(p => show(p) !== undefined)) return undefined;
|
|
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 };
|
|
}
|
|
|
|
/** The lanes CI carried at the checked-out tree under origin/<base>'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
|
|
function ciCarried(base: string): ShipReceipt['ci_carried'] {
|
|
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 });
|
|
try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; }
|
|
}
|
|
|
|
function cmdWrite(rest: string[], json: boolean): number {
|
|
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
|
|
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;
|
|
if (!sessionKind) {
|
|
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-session-kind'), [], { encoding: 'utf8', timeout: 15_000 });
|
|
sessionKind = r.status === 0 ? r.stdout.trim() || undefined : undefined;
|
|
}
|
|
let pregate: PregateField | undefined;
|
|
const pregateFile = flag(rest, '--pregate');
|
|
if (pregateFile) {
|
|
const read = pregateFieldFor(repoRoot, pregateFile, head);
|
|
if (!read.ok) { for (const e of read.errors) process.stderr.write(resultLine('gstack-ship-receipt write', e.code, `${e.message}; nothing written`) + '\n'); return EXIT.fail; }
|
|
pregate = read.field;
|
|
}
|
|
const pause = flag(rest, '--base-red') ? readPause(flag(rest, '--base-red')!) : undefined;
|
|
if (typeof pause === 'string') { process.stderr.write(`gstack-ship-receipt write: --base-red: ${pause}; nothing written\n`); return EXIT.usage; }
|
|
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
|
|
const bodyFile = flag(rest, '--body');
|
|
const bodyText = bodyFile && fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, 'utf8').replace(/```gstack-ship-receipt[\s\S]*?\n```/g, '') : '';
|
|
let docs: ReturnType<typeof parseReceiptDocs> | undefined;
|
|
if (flag(rest, '--docs') !== undefined) {
|
|
try { docs = parseReceiptDocs(jsonArg(flag(rest, '--docs'))); } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: --docs: ${e.message}\n`); return EXIT.usage; }
|
|
if (!docs.ok) { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', docs.code, `${docs.message}; nothing written`) + '\n'); return docs.code === 'DOCS_AUDIT_BLOCKED' ? EXIT.refused : EXIT.fail; }
|
|
const exceptionLine = docsExceptionLine(docs.docs);
|
|
if (bodyFile && exceptionLine && !bodyText.split(/\r?\n/).some(l => l.trim() === exceptionLine)) {
|
|
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'DOCS_AUDIT_BLOCKED', `${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(exceptionLine)}; nothing written`) + '\n');
|
|
return EXIT.refused;
|
|
}
|
|
}
|
|
let loopException: ReturnType<typeof parseReviewLoopException> | 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 {}
|
|
const loopProblem = exceptionLoopProblem(loopException.exception, parseReviewLoop(loop));
|
|
if (loopProblem) return refuse(`--review-loop-exception ${loopProblem}`);
|
|
const fc = exceptionHead(loopException.exception);
|
|
if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`${loopException.exception.fix_check ? 'fix_check' : 'second_pass'}.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 ruledNotes = readRuledNotes(repoRoot);
|
|
const unlisted = bodyFile ? ruledNotes.map(n => `Ruled note: ${ruledNoteText(n)}`).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
|
if (unlisted) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ruled note ${JSON.stringify(unlisted)} (gstack-approvals rulings prints each line the body carries); nothing written`) + '\n'); return EXIT.fail; }
|
|
const carried = ciCarried(base);
|
|
const unlistedException = bodyFile ? laneExceptionBodyLines(carried).find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
|
if (unlistedException) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedException)} (accept's TEST_LANE_EXCEPTION: line; the body carries it as one line); nothing written`) + '\n'); return EXIT.fail; }
|
|
const mechanical = readMechanicalLines(repoRoot, git(['rev-parse', `${head}^{tree}`], repoRoot) ?? '');
|
|
const unlistedRetake = bodyFile ? mechanical.find(l => !bodyText.split(/\r?\n/).some(b => b.trim() === l)) : undefined;
|
|
if (unlistedRetake) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${bodyFile} does not list ${JSON.stringify(unlistedRetake)} (finish's MECHANICAL: lines; the body carries each as one line); nothing written`) + '\n'); return EXIT.fail; }
|
|
const declared = (flag(rest, '--declare-risk') ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (rest.includes('--declare-risk') && (!declared.length || declared.some(c => !(RISK_CLASSES as readonly string[]).includes(c)))) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--declare-risk ${flag(rest, '--declare-risk') ?? ''}: each class is one of ${RISK_CLASSES.join(', ')}; nothing written`) + '\n'); return EXIT.fail; }
|
|
const review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head, declared as RiskClass[]);
|
|
let reviewDepth = review.depth;
|
|
const platformId = flag(rest, '--platform-review');
|
|
if (platformId !== undefined) {
|
|
const p = platformReceiptDepth(repoRoot, base, reviewDepth, headSha ?? head, platformId, flag(rest, '--pr'));
|
|
if (!p.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${p.message}; nothing written`) + '\n'); return EXIT.fail; }
|
|
reviewDepth = p.depth;
|
|
}
|
|
if (review.exclude.state === 'refused') { for (const l of review.exclude.lines) process.stderr.write(`${l}; nothing written\n`); return EXIT.refused; }
|
|
const excludeNow = review.exclude.state === 'active' ? review.exclude.record : null;
|
|
const excludeGiven = flag(rest, '--review-exclude') ?? 'none';
|
|
const excludeBound = writeExcludeBinds(repoRoot, base, headSha ?? head, review, flag(rest, '--review-exclude'), flag(rest, '--docs-post'), flag(rest, '--docs-pre'));
|
|
if (!excludeBound.ok) {
|
|
process.stderr.write(`gstack-ship-receipt write: field=review_exclude expected=${excludeNow?.digest ?? 'none'} recorded=${excludeGiven} source=${excludeNow?.source ?? `origin/${base} (no review_exclude_paths)`}: the --review-exclude argument names another selection than ${base}'s review_exclude_paths gives ${head.slice(0, 12)} (write compares the argument, not the review ledger)${flag(rest, '--review-exclude') === undefined ? '; no --review-exclude was given, so write compared none' : ''}${excludeBound.why ? `; the docs-audit exception does not apply: ${excludeBound.why}` : ''}; fix: when only Step 14.5's docs audit changed files under review_exclude_paths after the review, pass its --docs-post <audit-id>-post.json and --docs-pre <its Prepare snapshot>; otherwise rerun /ship's review on ${head.slice(0, 12)} (gstack-review-dispatch pass --base ${base}), run gstack-review-dispatch verify --ledger <ledger> and write with the --review-exclude <d> its REVIEW_EXCLUDE_RECORD: line prints (none when it prints no such line); nothing written (RECEIPT_INVALID)\n`);
|
|
return EXIT.fail;
|
|
}
|
|
if (excludeBound.line) process.stderr.write(excludeBound.line + '\n');
|
|
const product = reviewedProduct(repoRoot, base, head, pregateFile);
|
|
let targeted: ReturnType<typeof parseReviewTargeted> | undefined;
|
|
if (flag(rest, '--review-targeted') !== undefined) {
|
|
try { targeted = parseReviewTargeted(jsonArg(flag(rest, '--review-targeted')), product.product_patch_id ?? 'none', read => reviewedProduct(repoRoot, base, read, pregateFile).product_patch_id, read => targetedClassesSince(repoRoot, read, headSha ?? head, base), read => plainDocsSince(repoRoot, read, headSha ?? head, base)); } catch (e: any) { targeted = { ok: false, message: `--review-targeted: ${e.message}` }; }
|
|
}
|
|
if (targeted && !targeted.ok) { process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targeted.message}; nothing written`) + '\n'); return EXIT.fail; }
|
|
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');
|
|
return EXIT.fail;
|
|
}
|
|
if (approval.kind === 'error' && approval.code) { process.stderr.write(resultLine('gstack-ship-receipt write', approval.code, `${approval.message}; nothing written`) + '\n'); return EXIT.fail; }
|
|
if (approval.kind === 'error') { process.stderr.write(`gstack-ship-receipt write: approval classes: ${approval.message}; nothing written\n`); return EXIT.fail; }
|
|
if (approval.kind === 'missing') {
|
|
for (const m of approval.missing) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CITATION_MISSING', `${m.class} (${m.token}; effects in ${m.files.join(', ')}): ${m.reasons.join('; ')}`) + '\n');
|
|
process.stderr.write('gstack-ship-receipt write: nothing written\n');
|
|
return EXIT.fail;
|
|
}
|
|
if (approval.kind === 'ok' && approval.note) process.stderr.write(approval.note + '\n');
|
|
let input: Partial<ShipReceipt> & { docs?: unknown };
|
|
try {
|
|
const issuesFile = jsonArg(flag(rest, '--issues'));
|
|
const loopRaw = jsonArg(flag(rest, '--review-loop'));
|
|
const reviewLoop = loopRaw ? parseReviewLoop(loopRaw) : undefined;
|
|
if (loopRaw && !reviewLoop) throw new Error('--review-loop needs integer passes, blocking_found and deferred (gstack-review-scope summary --out <file>, then @<file>)');
|
|
input = {
|
|
schema_version: 1, pr: flag(rest, '--pr') !== undefined ? Number(flag(rest, '--pr')) : undefined, head,
|
|
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
|
|
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
|
|
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
|
|
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: carried,
|
|
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
|
|
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 } : {}), ...product,
|
|
gstack_version: installedGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, ruled_notes: ruledNotes.length ? ruledNotes : undefined, mechanical: mechanical.length ? mechanical : undefined, review_targeted: targeted?.ok ? targeted.targeted : undefined,
|
|
base_red: pause ? receiptBaseRed(pause) : undefined,
|
|
review_exclude: receiptExclude(review.exclude),
|
|
no_stop: noStopArg(flag(rest, '--no-stop')),
|
|
};
|
|
} 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; }
|
|
const receipt = orderReceipt(input);
|
|
const errors = validateReceipt(receipt);
|
|
if (errors.length) {
|
|
for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${e.message} at ${e.path}`) + '\n');
|
|
process.stderr.write('gstack-ship-receipt write: nothing written\n');
|
|
return EXIT.fail;
|
|
}
|
|
const targetedVerdict = receiptTargetedAtHead(repoRoot, `origin/${base.replace(/^origin\//, '')}`, receipt, receipt.head);
|
|
if (targetedVerdict.line.includes('(unchecked: ')) process.stderr.write(targetedVerdict.line + '\n');
|
|
if (!targetedVerdict.ok) {
|
|
process.stderr.write(`${targetedVerdict.line}\n${resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `${targetedVerdict.message}; run the targeted reader on HEAD and pass its record (--review-targeted @<gstack-review-dispatch verify --targeted-out file>); nothing written`)}\n`);
|
|
return EXIT.fail;
|
|
}
|
|
const body = bodyFile;
|
|
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
|
|
if (missing.length) {
|
|
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'ISSUE_LINKS_MISSING', `${body} lacks ${missing.map(l => JSON.stringify(l)).join(', ')}`) + '\n');
|
|
return EXIT.fail;
|
|
}
|
|
const block = renderReceiptBlock(receipt);
|
|
const out = flag(rest, '--out');
|
|
if (out) fs.writeFileSync(out, block);
|
|
if (body) fs.writeFileSync(body, placeReceiptFirst(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt));
|
|
process.stderr.write(receiptSummaryLine(receipt) + '\n');
|
|
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : block);
|
|
return EXIT.ok;
|
|
}
|
|
|
|
function cmdRead(rest: string[], json: boolean): number {
|
|
const bodyFile = flag(rest, '--body');
|
|
const pr = flag(rest, '--pr');
|
|
let body: string;
|
|
if (bodyFile) body = fs.readFileSync(bodyFile, 'utf8');
|
|
else if (pr) {
|
|
const repo = flag(rest, '--repo');
|
|
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body']);
|
|
if (!r.ok) { process.stderr.write(`gstack-ship-receipt read: gh pr view failed: ${r.err}\n`); return EXIT.fail; }
|
|
try { body = JSON.parse(r.out).body ?? ''; } catch { process.stderr.write('gstack-ship-receipt read: gh returned no JSON body\n'); return EXIT.fail; }
|
|
} else { process.stderr.write(`gstack-ship-receipt read: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
|
|
const ex = extractReceipt(body);
|
|
if (!ex.found) {
|
|
process.stderr.write(resultLine('gstack-ship-receipt read', 'RECEIPT_MISSING', pr ? `PR #${pr}` : bodyFile!) + '\n');
|
|
if (json) process.stdout.write(JSON.stringify({ found: false, errors: [] }) + '\n');
|
|
return EXIT.fail;
|
|
}
|
|
if (ex.errors.length) {
|
|
for (const e of ex.errors) process.stderr.write(resultLine('gstack-ship-receipt read', 'RECEIPT_INVALID', `${e.message} at ${e.path}`) + '\n');
|
|
if (json) process.stdout.write(JSON.stringify({ found: true, valid: false, errors: ex.errors, raw: ex.raw }) + '\n');
|
|
return EXIT.fail;
|
|
}
|
|
process.stderr.write(receiptSummaryLine(ex.receipt!) + '\n');
|
|
if (rest.includes('--check-prose')) {
|
|
const prose = checkReceiptProse(ex.receipt as unknown as Record<string, unknown>, body);
|
|
process.stderr.write(proseLine(prose) + '\n');
|
|
if (prose.mismatches.length) {
|
|
process.stderr.write(resultLineWithDocs('gstack-ship-receipt read', 'RECEIPT_PROSE_MISMATCH', prose.mismatches.map(m => `${m.field} body=${m.prose} receipt=${m.receipt}`).join(', ')) + '\n');
|
|
if (json) process.stdout.write(JSON.stringify({ found: true, valid: true, receipt: ex.receipt, prose }) + '\n');
|
|
return EXIT.fail;
|
|
}
|
|
if (json) { process.stdout.write(JSON.stringify({ found: true, valid: true, receipt: ex.receipt, prose }) + '\n'); return EXIT.ok; }
|
|
}
|
|
process.stdout.write(json ? JSON.stringify(ex.receipt) + '\n' : renderReceiptBlock(ex.receipt!));
|
|
return EXIT.ok;
|
|
}
|
|
|
|
/**
|
|
* /ship's terminal line (docs/unattended.md#the-terminal-line). The receipt in
|
|
* the PR body (or the handoff file) must bind to HEAD: then the run record
|
|
* goes under <state>/ship/runs/ with the head, the PR and the receipt, and
|
|
* the last stdout line is GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>.
|
|
* A missing or stale receipt is status=refused run=- (exit 3), so a parent
|
|
* never has to parse the PR body to know whether /ship finished at this head.
|
|
*/
|
|
function cmdResult(rest: string[], json: boolean): number {
|
|
const bodyFile = flag(rest, '--body');
|
|
const prFlag = flag(rest, '--pr');
|
|
if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) { process.stderr.write(`gstack-ship-receipt result: --pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}\n`); return EXIT.usage; }
|
|
const pr = prFlag;
|
|
if (prFlag !== undefined && flag(rest, '--body') !== undefined) { process.stderr.write('gstack-ship-receipt result: pass --pr <n> (the PR body, checked against its pushed head) or --body <file> (the handoff receipt), not both\n'); return EXIT.usage; }
|
|
const repoRoot = git(['rev-parse', '--show-toplevel']);
|
|
const head = (flag(rest, '--head') ?? (repoRoot ? git(['rev-parse', 'HEAD'], repoRoot) : undefined))?.toLowerCase();
|
|
const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE', message: string) => {
|
|
process.stderr.write(resultLineWithDocs('gstack-ship-receipt result', code, message) + '\n');
|
|
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : 'GSTACK_RESULT: skill=ship status=refused run=-\n');
|
|
return EXIT.refused;
|
|
};
|
|
if (!head) { process.stderr.write('gstack-ship-receipt result: --head <sha> is required outside a git repository\n'); return EXIT.usage; }
|
|
if (!/^[0-9a-f]{40,64}$/i.test(head)) { process.stderr.write(`gstack-ship-receipt result: --head must be a full hex commit id, got ${JSON.stringify(head.slice(0, 40))}\n`); return EXIT.usage; }
|
|
const incomplete = (why: string) => {
|
|
process.stderr.write(`gstack-ship-receipt result: ${why}\n`);
|
|
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
|
|
return EXIT.fail;
|
|
};
|
|
let body: string;
|
|
let source: string;
|
|
if (bodyFile) {
|
|
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist; /ship did not write its receipt`);
|
|
try { body = fs.readFileSync(bodyFile, 'utf8'); } catch (e) { return incomplete(`cannot read ${bodyFile}: ${e instanceof Error ? e.message : String(e)}`); }
|
|
source = path.resolve(bodyFile);
|
|
} else if (pr) {
|
|
const repo = flag(rest, '--repo');
|
|
const r = gh(['pr', 'view', pr, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
|
|
if (!r.ok) return incomplete(`gh pr view failed: ${r.err.trim()}`);
|
|
let pushed: unknown;
|
|
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
|
|
if (typeof pushed !== 'string' || !/^[0-9a-f]{12,64}$/i.test(pushed)) return incomplete(`gh pr view returned no headRefOid for PR #${pr}`);
|
|
if (!pushed.toLowerCase().startsWith(head.toLowerCase())) {
|
|
return refused('QUEUE_RECEIPT_STALE', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push it, then rerun gstack-ship-receipt result`);
|
|
}
|
|
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
|
|
} else { process.stderr.write(`gstack-ship-receipt result: --pr <n> or --body <file> is required\n${HELP}\n`); return EXIT.usage; }
|
|
const bound = bindReceipt(body, pr, [head], { conditional: true });
|
|
if (!bound.ok) return refused(bound.code, bound.message);
|
|
const red = bound.receipt.base_red;
|
|
if (red) process.stderr.write(`SHIP_PAUSED: base-red cases=${red.cases.length} base=${red.base.slice(0, 12)} lane=${red.lane} expires=${red.expires_at}; gstack-base-red wait --pause <file>, then /ship --resume <record>\n`);
|
|
return writeRunRecord({ receipt: bound.receipt, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line: bound.line, incomplete, status: red ? 'incomplete' : 'complete' });
|
|
}
|
|
|
|
/** The run record under <state>/ship/runs/ and the terminal line, shared by `result` and `carry`. */
|
|
function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | undefined; source: string; repoRoot: string | undefined; out: string | undefined; json: boolean; line: string; incomplete: (why: string) => number; extra?: Record<string, unknown>; status?: 'complete' | 'incomplete' }): number {
|
|
const status = a.status ?? 'complete';
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const dir = a.out ? path.resolve(a.out) : path.join(resolveStateRoot(), 'ship', 'runs', `${stamp}-${a.head.slice(0, 12)}`);
|
|
const receiptPath = path.join(dir, 'ship-receipt.md');
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(receiptPath, renderReceiptBlock(a.receipt));
|
|
} catch (e) { return a.incomplete(`cannot write the run record under ${dir}: ${e instanceof Error ? e.message : String(e)}`); }
|
|
const run = {
|
|
schema_version: 1, skill: 'ship', status, head: a.head, pr: a.receipt.pr ?? (a.pr ? Number(a.pr) : null), base: a.receipt.base, version: a.receipt.version ?? null,
|
|
queue_mode: a.receipt.queue_mode ?? null, session_kind: a.receipt.session_kind ?? process.env.GSTACK_SESSION_KIND ?? null,
|
|
receipt: receiptPath, receipt_source: a.source, branch: a.repoRoot ? git(['rev-parse', '--abbrev-ref', 'HEAD'], a.repoRoot) ?? null : null, created_at: new Date().toISOString(),
|
|
...(a.extra ?? {}),
|
|
};
|
|
try { fs.writeFileSync(path.join(dir, 'ship-run.json'), JSON.stringify(run, null, 2) + '\n'); } catch (e) { return a.incomplete(`cannot write ${path.join(dir, 'ship-run.json')}: ${e instanceof Error ? e.message : String(e)}`); }
|
|
process.stderr.write(a.line + '\n');
|
|
process.stdout.write(a.json ? JSON.stringify({ ...run, run: dir }) + '\n' : `GSTACK_RESULT: skill=ship status=${status} head=${a.head} run=${dir}\n`);
|
|
return status === 'complete' ? EXIT.ok : EXIT.fail;
|
|
}
|
|
|
|
/**
|
|
* One diff's patch-id over a pathspec; empty when the diff is empty. `--verbatim`
|
|
* keeps whitespace in the hash (the default and `--stable` strip it, so a space
|
|
* moved in a shell line would read as the same patch) while still ignoring the
|
|
* hunk line numbers a base merge shifts.
|
|
*/
|
|
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | { error: string } {
|
|
const firstLine = (s: string | null | undefined, fallback: string) => (s ?? '').trim().split('\n')[0]?.slice(0, 200) || fallback;
|
|
const root = path.resolve(repoRoot, git(['rev-parse', '--git-path', 'gstack-patch-id'], repoRoot)?.trim() || '.git/gstack-patch-id');
|
|
sweepStalePatchDirs(root);
|
|
let dir: string;
|
|
try { fs.mkdirSync(root, { recursive: true }); dir = fs.mkdtempSync(path.join(root, 'run-')); } catch (e) { return { error: `cannot create a temp file under ${root} (${firstLine((e as Error).message, 'mkdtemp failed')}); make that directory writable` }; }
|
|
const file = path.join(dir, 'diff.patch');
|
|
let fd: number | undefined;
|
|
try {
|
|
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', `--output=${file}`, from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000 });
|
|
if (diff.status !== 0) return { error: `git diff ${from.slice(0, 12)} ${to.slice(0, 12)}: ${firstLine(diff.stderr, diff.signal ? `killed by ${diff.signal}` : `exit ${diff.status}`)}` };
|
|
fd = fs.openSync(file, 'r');
|
|
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 120_000 });
|
|
if (id.status !== 0) return { error: `git patch-id --verbatim: ${firstLine(id.stderr, id.signal ? `killed by ${id.signal}` : `exit ${id.status}`)}` };
|
|
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** A carry killed mid patch-id (preflight's timeout) skips patchId's `finally`; its product diff is removed by the next run once an hour old. */
|
|
let swept = false;
|
|
function sweepStalePatchDirs(root: string): void {
|
|
if (swept) return;
|
|
swept = true;
|
|
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
let names: string[] = [];
|
|
try { names = fs.readdirSync(root).filter(n => n.startsWith('run-')); } catch { return; }
|
|
for (const n of names) {
|
|
const p = path.join(root, n);
|
|
try { if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true }); } catch { /* another run removed it */ }
|
|
}
|
|
}
|
|
|
|
/** `git patch-id --verbatim` exists from git 2.39 (docs/ship-policy.md#receipt-carry); an older git is a named refusal, never a silent fallback to the whitespace-stripping default. */
|
|
const CARRY_MIN_GIT = '2.39';
|
|
function gitVersionBelow(repoRoot: string, minimum: string): string | null {
|
|
const raw = git(['--version'], repoRoot) ?? '';
|
|
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(raw);
|
|
if (!m) return raw || 'unknown';
|
|
const [minMajor, minMinor] = minimum.split('.').map(Number) as [number, number];
|
|
const major = Number(m[1]); const minor = Number(m[2]);
|
|
return major > minMajor || (major === minMajor && minor >= minMinor) ? null : m[0];
|
|
}
|
|
|
|
type PregateField = { pregate: string; requires_remote?: string[]; flaky_exception?: ShipReceipt['flaky_exception'] };
|
|
/** A pregate result file → the receipt's pregate field, verified current for this checkout's tree (shared by write and carry). */
|
|
function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: true; field: PregateField } | { ok: false; errors: Array<{ code: string; message: string }> } {
|
|
const read = readPregateFile(file);
|
|
if (!read.file) return { ok: false, errors: read.errors.map(e => ({ code: e.code, message: `${e.message} at ${e.path}` })) };
|
|
const v = verifyPregate(repoRoot, read.file);
|
|
if (!v.ok) return { ok: false, errors: [{ code: v.code!, message: v.message ?? 'pregate is not current' }] };
|
|
const foreign = exceptionConsumerProblem(read.file, head);
|
|
if (foreign) return { ok: false, errors: [{ code: 'PREGATE_REMOTE_UNCLEARED', message: foreign }] };
|
|
const remote = read.file.requires_remote ?? [];
|
|
const exceptions = read.file.exceptions ?? [];
|
|
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, flaky_exception: exceptions.length ? exceptions.map(e => ({ lane: e.lane, runs: e.runs, cases: e.cases, grant: e.grant, by: e.by })) : 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[]; fragments?: string }; metadata: string[]; templates: readonly string[]; placeholders: 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 fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments };
|
|
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [], placeholders: policy?.stamp_placeholders ?? [] };
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
* With `branch` (the paths the branch itself changed against the base, and its
|
|
* base..head ranges), only the branch's own non-stamp changes demote a glob: a
|
|
* touched path whose change is not a stamp on either side, or between the two
|
|
* heads. A path only a base merge brought in has the same patch against the base
|
|
* at both heads, and demoting its glob would pull a matching path the branch did
|
|
* change, such as a CHANGELOG fragment next to the base's changelog.d/README.md
|
|
* (GST-343, #3606), into the product patch-id at one head only.
|
|
*/
|
|
function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet<string>; sides: ReadonlyArray<readonly [string, string]>; own: (rel: string) => BranchSide | undefined }): { changed: Array<{ path: string; class: ReturnType<typeof classifyCarryPath> }>; product: string[]; tests: string[]; demoted: string[]; ownRows: 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 versions = new Map<string, string | null>();
|
|
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
|
|
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
|
|
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, placeholders: rules.placeholders, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
|
|
// A stamp file the branch changed on neither side beyond a stamp against that side's base (back at the base's content
|
|
// after a changelog-fragment conversion, or only its version moved) holds none of the branch's product: whatever else
|
|
// differs between the two heads came from the base (GST-1210).
|
|
// The same `ls-tree` entry (mode and object id, empty when absent) is an unchanged side; a failed read never is.
|
|
const entryAt = (ref: string, p: string) => git(['ls-tree', ref, '--', p], repoRoot);
|
|
const sameEntry = (a: string, b: string, p: string) => { const x = entryAt(a, p); return x !== undefined && x === entryAt(b, p); };
|
|
const ownStampOnly = (p: string) => !!branch?.touched.has(p) && stamped(p) && branch.sides.every(([a, b]) => sameEntry(a, b, p) || classify(p, a, b) === 'metadata');
|
|
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: ownStampOnly(p) ? 'metadata' as const : classify(p, from, to) }));
|
|
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
|
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
|
|
: [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product')));
|
|
const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p)));
|
|
// A demoted path whose from-to change is, line for line, a stamp on the branch's own rows (named by their position in
|
|
// `from`), a regular file with the reviewed mode at both heads (GST-980): never the whole-text version swap.
|
|
const ownRows = demoted.filter(p => {
|
|
const side = branch?.own(p);
|
|
const mode = (ref: string) => /^(100644|100755) blob /.exec(git(['ls-tree', ref, '--', p], repoRoot) ?? '')?.[1];
|
|
const before = blobAt(from, p), after = blobAt(to, p), oldVersion = version(from), newVersion = version(to);
|
|
if (!side || before === null || after === null || !oldVersion || !newVersion || !mode(from) || mode(from) !== mode(to)) return false;
|
|
const was = before.split('\n');
|
|
const now = after.split('\n');
|
|
const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]);
|
|
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1, rules.placeholders));
|
|
});
|
|
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows };
|
|
}
|
|
|
|
/** 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. 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');
|
|
const pr = flag(rest, '--pr');
|
|
if (pr !== undefined && !/^[1-9]\d*$/.test(pr)) { process.stderr.write(`gstack-ship-receipt carry: --pr must be a PR number, got ${JSON.stringify(pr.slice(0, 40))}\n`); return EXIT.usage; }
|
|
if ((pr === undefined) === (bodyFile === undefined)) { process.stderr.write(`gstack-ship-receipt carry: pass --pr <n> or --body <file>, not both\n${HELP}\n`); return EXIT.usage; }
|
|
const repoRoot = git(['rev-parse', '--show-toplevel']);
|
|
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' | 'QUEUE_RECEIPT_CONDITIONAL' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_SCOPED_REVIEW' | '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;
|
|
};
|
|
const incomplete = (why: string) => {
|
|
process.stderr.write(`gstack-ship-receipt carry: ${why}\n`);
|
|
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'incomplete', run: '-', message: why }) + '\n' : 'GSTACK_RESULT: skill=ship status=incomplete run=-\n');
|
|
return EXIT.fail;
|
|
};
|
|
const repo = flag(rest, '--repo');
|
|
let body: string;
|
|
let source: string;
|
|
if (bodyFile) {
|
|
if (!fs.existsSync(bodyFile)) return refused('QUEUE_RECEIPT_MISSING', `${bodyFile} does not exist`);
|
|
body = fs.readFileSync(bodyFile, 'utf8');
|
|
source = path.resolve(bodyFile);
|
|
} else {
|
|
const r = gh(['pr', 'view', pr!, ...(repo ? ['--repo', repo] : []), '--json', 'body,headRefOid']);
|
|
if (!r.ok) return incomplete(`gh pr view failed: ${r.err}`);
|
|
let pushed: unknown;
|
|
try { const v = JSON.parse(r.out); body = v.body ?? ''; pushed = v.headRefOid; } catch { return incomplete('gh returned no JSON body'); }
|
|
if (typeof pushed === 'string' && /^[0-9a-f]{12,64}$/i.test(pushed) && !pushed.toLowerCase().startsWith(head)) {
|
|
return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} is not PR #${pr}'s pushed head ${pushed.slice(0, 12)}; push first, then carry`);
|
|
}
|
|
source = `PR #${pr}${repo ? ` (${repo})` : ''}`;
|
|
}
|
|
const ex = extractReceipt(body);
|
|
if (!ex.found) return refused('QUEUE_RECEIPT_MISSING', `${source} carries no ship receipt; run /ship`);
|
|
if (!ex.receipt || ex.errors.length) return refused('QUEUE_RECEIPT_MISSING', `${source}'s ship receipt is invalid (${ex.errors[0]?.message ?? 'invalid'}); run /ship`);
|
|
const receipt = ex.receipt;
|
|
if (pr !== undefined && receipt.pr !== undefined && String(receipt.pr) !== pr) return refused('RECEIPT_CARRY_REFUSED', `the receipt names pr=${receipt.pr}, not this PR`);
|
|
if (receipt.base_red) return refused('QUEUE_RECEIPT_CONDITIONAL', 'the receipt is paused on a red base, so there is nothing passing to carry; /ship --resume <record> re-runs Step 5 and writes a full receipt');
|
|
const from = receipt.head.toLowerCase();
|
|
if (!/^[0-9a-f]{40,64}$/.test(from)) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${receipt.head.slice(0, 12)} is not a full commit id`);
|
|
if (from === head) {
|
|
process.stderr.write(receiptSummaryLine(receipt) + '\n');
|
|
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'current', head, carry: null }) + '\n' : `CARRY: current (the receipt already names ${head.slice(0, 12)})\n`);
|
|
return EXIT.ok;
|
|
}
|
|
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`);
|
|
// 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 headBase = git(['merge-base', baseRef, head], repoRoot);
|
|
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}`);
|
|
const reviewedBase = receipt.base.replace(/^origin\//, '');
|
|
const retarget = reviewedBase !== base.replace(/^origin\//, '');
|
|
let plan: ReturnType<typeof planCarry>;
|
|
let scopedDryRun: string | undefined;
|
|
let recheck: string | undefined;
|
|
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)`);
|
|
// The reviewed range runs from the fork point, or from a stack parent inside it that has since landed on the base: a
|
|
// receipt written against the base while stacked reviewed the parent's hunks too, and the squash moved them into the
|
|
// base (GST-1199). A parent counts only when merging it into the current base changes no product path.
|
|
const measure = (rangeBase: string) => {
|
|
const listed = [touchedPaths(repoRoot, rangeBase, from), touchedPaths(repoRoot, headBase, head), rangeBase === fromBase ? new Set<string>() : touchedPaths(repoRoot, fromBase, from)];
|
|
if (!listed[0] || !listed[1] || !listed[2]) return `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`;
|
|
// From a stack parent, a path whose reviewed content is the base's (an earlier base merge brought it in) is base
|
|
// context, not reviewed product, unless HEAD changed it against the base (GST-1214).
|
|
// A pathspec also names everything beneath a path, so a path with any compared path under it (a file the base turned
|
|
// into a directory) stays in.
|
|
const compared = [...listed[0], ...listed[1]];
|
|
const baseMerged = rangeBase === fromBase ? [] : [...listed[0]].filter(p => !listed[2]!.has(p) && !listed[1]!.has(p) && !compared.some(q => q.startsWith(`${p}/`)));
|
|
const touched = new Set([...listed[0], ...listed[1]].filter(p => !baseMerged.includes(p)));
|
|
const found = productSpec(repoRoot, rules, from, head, { touched, sides: [[rangeBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
|
|
if (!found) return `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`;
|
|
const skip = baseMerged.map(p => `:(exclude,literal)${p}`);
|
|
const spec = { ...found, product: [...found.product, ...skip], tests: [...found.tests, ...skip] };
|
|
const ids = [patchId(repoRoot, rangeBase, from, spec.product), patchId(repoRoot, headBase, head, spec.product)];
|
|
for (const r of ids) if (typeof r !== 'string') return `git could not compute the product patch-id at one of the heads: ${r.error}`;
|
|
return { rangeBase, touched, spec, productFrom: ids[0] as string, productHead: ids[1] as string };
|
|
};
|
|
let measured = measure(fromBase);
|
|
if (typeof measured === 'string') return refused('RECEIPT_CARRY_REFUSED', measured);
|
|
const stackParent = !retarget && measured.productFrom !== measured.productHead ? landedStackParent(repoRoot, { rangeBase: fromBase, reviewed: from, currentBase: headBase }, measured.spec.product) : null;
|
|
if (stackParent) measured = measure(stackParent);
|
|
if (typeof measured === 'string') return refused('RECEIPT_CARRY_REFUSED', measured);
|
|
const { rangeBase, touched, spec, productFrom, productHead } = measured;
|
|
const { changed } = spec;
|
|
const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : '');
|
|
const testFrom = asId(patchId(repoRoot, rangeBase, from, spec.tests));
|
|
const testHead = asId(patchId(repoRoot, headBase, head, spec.tests));
|
|
// 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/<receipt base> 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}`;
|
|
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 current = retarget ? headPatch : productHead;
|
|
const changedProduct: string[] = [];
|
|
let walkCut = false;
|
|
const contextPaths: string[] = [];
|
|
const stampOnly: string[] = [];
|
|
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 walk stops there, and it reads 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 touched path with the same blob at both heads and at both bases has
|
|
// the same patch, so only paths that moved on one side are compared (GST-448: 6000 touched paths). A path whose only difference is base context next to unchanged hunks
|
|
// (lib/ship-patch.ts) needs a scoped review instead of the loop.
|
|
const sides = { reviewedBase: rangeBase, reviewed: from, currentBase: headBase, current: head };
|
|
const moved = [touchedPaths(repoRoot, from, head), touchedPaths(repoRoot, rangeBase, headBase)];
|
|
if (moved.some(l => !l)) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`);
|
|
const [movedHead, movedBase] = moved as Set<string>[];
|
|
// A stamp file the branch changed as product (its own row) whose reviewed-to-current change is a stamp on its own lines
|
|
// and that the base did not touch holds no unreviewed change, so the comparison leaves its bytes out (GST-980).
|
|
for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) {
|
|
if (changedProduct.length >= CARRY_NAMED_PATHS) { walkCut = true; break; }
|
|
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
|
if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p);
|
|
if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) {
|
|
const masked = Object.values(sides).map(ref => {
|
|
const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; }
|
|
});
|
|
const [rb, rv, cb, cv] = masked;
|
|
if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p);
|
|
else stampOnly.push(p);
|
|
continue;
|
|
}
|
|
if (cls !== 'product') continue;
|
|
const verdict = comparePath(repoRoot, sides, p);
|
|
if (verdict === 'changed') changedProduct.push(p);
|
|
else if (verdict === 'context') contextPaths.push(p);
|
|
}
|
|
const left = [...contextPaths, ...stampOnly];
|
|
if (!changedProduct.length && !left.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)');
|
|
else if (!changedProduct.length) {
|
|
const rest = [...spec.product, ...left.map(p => `:(exclude,literal)${p}`)];
|
|
const restFrom = patchId(repoRoot, rangeBase, from, rest);
|
|
const restHead = patchId(repoRoot, headBase, head, rest);
|
|
if (typeof restFrom !== 'string' || typeof restHead !== 'string' || restFrom !== restHead) changedProduct.push(`(a product path outside ${left.join(', ')}; compare the two base diffs without them)`);
|
|
}
|
|
}
|
|
const stampedRows = changedProduct.length ? [] : stampOnly;
|
|
let scopedReview: string | undefined;
|
|
const packetBody = !changedProduct.length && contextPaths.length ? contextPacket(repoRoot, { reviewedBase: rangeBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : null;
|
|
if (packetBody?.over.length) changedProduct.push(...packetBody.over.map(p => `${p} (too large for the scoped review: over ${PACKET_SECTION_CAP} lines in a section)`));
|
|
if (packetBody && !changedProduct.length) {
|
|
const record = flag(rest, '--scoped-review');
|
|
if (!record && rest.includes('--dry-run')) {
|
|
scopedDryRun = `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: carry without --dry-run writes the scoped-review packet for one fix-check reviewer, then carry --scoped-review <record.json>; nothing written`;
|
|
scopedReview = `base context moved (scoped review pending: ${contextPaths.join(', ')})`;
|
|
} else if (!record) {
|
|
const gitPath = git(['rev-parse', '--git-path', `gstack/scoped-review-${head.slice(0, 12)}.md`], repoRoot);
|
|
const packet = path.resolve(repoRoot, gitPath ?? `.git/gstack/scoped-review-${head.slice(0, 12)}.md`);
|
|
try {
|
|
fs.mkdirSync(path.dirname(packet), { recursive: true });
|
|
fs.writeFileSync(packet, packetBody.text);
|
|
} catch (e) {
|
|
return refused('RECEIPT_CARRY_REFUSED', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}, but the scoped-review packet ${packet} cannot be written (${(e as Error).message.split('\n')[0]}); make its directory writable, then rerun carry`);
|
|
}
|
|
return refused('RECEIPT_CARRY_SCOPED_REVIEW', `base context moved next to the reviewed hunks in ${contextPaths.join(', ')}: one fix-check reviewer reads ${packet}, then rerun carry with --scoped-review <record.json> ({"head":"${head}","paths":[…],"reviewer":"<model>","verdict":"clean"}); a blocking finding means /ship on ${head.slice(0, 12)}`);
|
|
}
|
|
if (record) {
|
|
const review = readScopedReview(record, { head }, contextPaths);
|
|
if (!review.ok) return refused('RECEIPT_CARRY_REFUSED', `--scoped-review: ${review.message}`);
|
|
scopedReview = `base context moved (scoped review by ${review.review.reviewer}: ${contextPaths.join(', ')})`;
|
|
}
|
|
}
|
|
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', `${rangeBase}..${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')}`;
|
|
// J1: a bounded product change since the reviewed head gets one reader on the range-diff instead of a full /ship (lib/ship-recheck.ts).
|
|
if (changedProduct.length) {
|
|
const named = changedProduct.filter(p => !p.startsWith('(') && !p.includes(' ('));
|
|
const inter = named.length ? spawnSync('git', ['diff', '--no-color', '--no-ext-diff', from, head, '--', ...named.map(p => `:(literal)${p}`)], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }) : null;
|
|
const fullRange = rangeRaw.status === 0 && inter?.status === 0 ? `${rangeRaw.stdout.trim()}\n${inter.stdout.trim()}` : null;
|
|
const numstat = named.length ? spawnSync('git', ['diff', '--numstat', from, head, '--', ...named.map(p => `:(literal)${p}`)], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }) : null;
|
|
const binary = numstat?.status !== 0 || (numstat?.stdout ?? '').split('\n').some(l => l.startsWith('-\t-\t'));
|
|
// Moved base context composes into the same packet, bound to range_base (GST-1561).
|
|
const context = contextPaths.length ? contextPacket(repoRoot, { reviewedBase: rangeBase, reviewed: from, currentBase: headBase, current: head }, contextPaths) : undefined;
|
|
const bound = context ? { rangeBase, context: context.text } : {};
|
|
const r = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor, retarget, rangeDiff: fullRange, unnamedPaths: named.length !== changedProduct.length, legacy: !receipt.review_depth, walkCut, contextPaths: contextPaths.length, context, binary });
|
|
const record = flag(rest, '--recheck');
|
|
if (!r.ok) process.stderr.write(`RECHECK: not eligible (${r.reason}); run /ship on ${head.slice(0, 12)}\n`);
|
|
else if (record) {
|
|
const read = readRecheck(record, { from, head, sha256: r.sha256, ...bound });
|
|
if (!read.ok) return refused('RECEIPT_CARRY_REFUSED', `--recheck: ${read.message}`);
|
|
recheck = `scoped re-check by ${read.record.reader} (range-diff ${r.lines} lines${context ? `; base context moved in ${contextPaths.join(', ')}; range_base ${rangeBase.slice(0, 12)}` : ''})`;
|
|
} else {
|
|
let where = 'not written (dry run)';
|
|
if (!rest.includes('--dry-run')) {
|
|
const packet = path.resolve(repoRoot, git(['rev-parse', '--git-path', `gstack/recheck-${head.slice(0, 12)}.md`], repoRoot) ?? `.git/gstack/recheck-${head.slice(0, 12)}.md`);
|
|
try { fs.mkdirSync(path.dirname(packet), { recursive: true }); fs.writeFileSync(packet, recheckPacket({ from, head, plan: r, rangeDiff: fullRange!, ...bound })); where = packet; }
|
|
catch (e) { where = `unwritable: ${(e as Error).message.split('\n')[0]}`; }
|
|
}
|
|
process.stderr.write(`RECHECK: eligible range_diff_lines=${r.lines} range_diff_sha256=${r.sha256} packet=${where}; one reader reads it, then carry --recheck <record.json> (${recheckRecordTemplate({ from, head, sha256: r.sha256, ...bound })}); a blocking finding means /ship on ${head.slice(0, 12)}\n`);
|
|
}
|
|
}
|
|
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 } : {}), ...(stackParent ? { stackParent } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}), ...(recheck ? { recheck } : {}) });
|
|
}
|
|
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).
|
|
const pregateFile = flag(rest, '--pregate');
|
|
let pregateField: PregateField | undefined;
|
|
if (pregateFile) {
|
|
const local = git(['rev-parse', 'HEAD'], repoRoot)?.trim();
|
|
if (local !== head) return refused('RECEIPT_CARRY_PREGATE_STALE', `--pregate verifies this checkout's tree, but the checkout is at ${local?.slice(0, 12) ?? 'no commit'}, not the carried head ${head.slice(0, 12)}; check out ${head.slice(0, 12)} and run gstack-pregate there`);
|
|
const read = pregateFieldFor(repoRoot, pregateFile, head);
|
|
if (!read.ok) return refused('RECEIPT_CARRY_PREGATE_STALE', `--pregate ${pregateFile}: ${read.errors[0]?.code ?? 'invalid'} ${read.errors[0]?.message ?? ''}`.trim());
|
|
pregateField = read.field;
|
|
} 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>`);
|
|
}
|
|
let depthField: Partial<ShipReceipt> = {};
|
|
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 review = receiptReviewAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head);
|
|
const now = review.depth;
|
|
if (review.exclude.state === 'refused') return refused('RECEIPT_CARRY_REFUSED', `${review.exclude.lines.join('; ')}; run /ship on ${head.slice(0, 12)} once the base policy reads`);
|
|
const digest = review.exclude.state === 'active' ? review.exclude.record.digest : 'none';
|
|
if (!carryExcludeBinds(review.exclude, receipt)) return refused('RECEIPT_CARRY_REFUSED', `review_exclude changed: recorded=${receipt.review_exclude?.digest ?? 'none'} now=${digest} source=${review.exclude.state === 'active' ? review.exclude.record.source : `${base} (no review_exclude_paths)`}; the receipt's digest is the --review-exclude argument write was given, and it names another selection than HEAD ${head.slice(0, 12)} has under the base policy and the two give the PR's files different selections (carry compares those, not the review ledger), so carry does not apply; run /ship on ${head.slice(0, 12)}`);
|
|
const riskOf = (d: { class: string; risk?: readonly string[] }) => `${d.class}:${[...(d.risk ?? [])].sort().join(',')}`;
|
|
if (recheck && riskOf(now) !== riskOf(receipt.review_depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} is risk class ${riskOf(now)}, not the reviewed ${riskOf(receipt.review_depth)}, so a scoped re-check cannot stand in for review; run /ship on ${head.slice(0, 12)}`);
|
|
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) }, review_exclude: receiptExclude(review.exclude) };
|
|
}
|
|
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
|
|
// A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030).
|
|
// A head with no version source keeps the recorded one (write --version named it).
|
|
const version = receipt.version === undefined ? undefined : (receiptVersion(repoRoot, head, base) ?? receipt.version);
|
|
const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
|
|
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 targetedVerdict = receiptTargetedAtHead(repoRoot, baseRef, carried, head);
|
|
if (!targetedVerdict.ok) return refused('RECEIPT_CARRY_REFUSED', `${targetedVerdict.line}: ${targetedVerdict.message}, so the queue refuses it too (#3859); run /ship on ${head.slice(0, 12)}`);
|
|
const newBody = placeReceiptFirst(body, carried);
|
|
const line = receiptSummaryLine(carried);
|
|
if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun);
|
|
if (rest.includes('--dry-run')) {
|
|
process.stderr.write(line + '\n');
|
|
process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'dry-run', head, carry: plan.carry }) + '\n' : `CARRY: would carry from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason}); nothing written\n`);
|
|
return EXIT.ok;
|
|
}
|
|
if (bodyFile) fs.writeFileSync(bodyFile, newBody);
|
|
else {
|
|
const tmp = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'gstack-carry-')), 'body.md');
|
|
fs.writeFileSync(tmp, newBody, { mode: 0o600 });
|
|
const edit = gh(['pr', 'edit', pr!, ...(repo ? ['--repo', repo] : []), '--body-file', tmp]);
|
|
fs.rmSync(path.dirname(tmp), { recursive: true, force: true });
|
|
if (!edit.ok) return incomplete(`gh pr edit failed: ${edit.err}; the receipt was not carried`);
|
|
}
|
|
process.stdout.write(`CARRY: carried from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason})\n`);
|
|
return writeRunRecord({ receipt: carried, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line, incomplete, extra: { carried_from: carried.carry!.from, carry_reason: carried.carry!.reason } });
|
|
}
|
|
|
|
function sinceDate(repo: string, since: string): string | undefined {
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(since)) return since.slice(0, 10);
|
|
const local = git(['log', '-1', '--format=%cI', since]);
|
|
if (local) return local.slice(0, 10);
|
|
const r = gh(['api', `repos/${repo}/commits/${since}`, '--jq', '.commit.committer.date']);
|
|
return r.ok && r.out.trim() ? r.out.trim().slice(0, 10) : undefined;
|
|
}
|
|
|
|
function cmdCensus(rest: string[], json: boolean): number {
|
|
const repo = flag(rest, '--repo');
|
|
const since = flag(rest, '--since');
|
|
if (!repo || !since) { process.stderr.write(`gstack-ship-receipt census: --repo <owner/repo> and --since <tag|date> are required\n${HELP}\n`); return EXIT.usage; }
|
|
const date = sinceDate(repo, since);
|
|
if (!date) { process.stderr.write(`gstack-ship-receipt census: cannot resolve --since ${since} to a date (not a local tag, and gh could not read it)\n`); return EXIT.fail; }
|
|
const limit = flag(rest, '--limit') ?? '500';
|
|
const r = gh(['pr', 'list', '--repo', repo, '--state', 'merged', '--search', `merged:>=${date}`, '--limit', limit, '--json', 'number,body,mergedAt,title']);
|
|
if (!r.ok) { process.stderr.write(`gstack-ship-receipt census: gh pr list failed: ${r.err}\n`); return EXIT.fail; }
|
|
let prs: CensusPr[];
|
|
try { prs = JSON.parse(r.out); } catch { process.stderr.write('gstack-ship-receipt census: gh returned no JSON\n'); return EXIT.fail; }
|
|
const cohortFile = flag(rest, '--cohort');
|
|
const cohort = cohortFile ? fs.readFileSync(cohortFile, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')) : null;
|
|
const census = computeCensus(repo, date, prs, cohort);
|
|
process.stdout.write(json ? JSON.stringify(census) + '\n' : renderCensus(census));
|
|
return EXIT.ok;
|
|
}
|
|
|
|
/**
|
|
* /ship Step 14.5's doc-cap check: every `docs.caps` entry of the reviewed
|
|
* policy (origin/<base>'s .gstack/ship-policy.json, like every other policy
|
|
* read here; the branch cannot raise its own cap) against the working tree,
|
|
* one DOCS_CAP: line each; any `over` (an absent file counts) exits 1 so the
|
|
* audit blocks before the verification gate. `--policy <file>` reads that
|
|
* file instead, for a repository bootstrapping its policy.
|
|
*/
|
|
function cmdDocsCaps(rest: string[], json: boolean): number {
|
|
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
|
|
const policyFile = flag(rest, '--policy');
|
|
let caps: NonNullable<ShipPolicy['docs']>['caps'] = [];
|
|
let source: string;
|
|
if (policyFile !== undefined) {
|
|
if (!fs.existsSync(policyFile)) { process.stderr.write(`gstack-ship-receipt docs-caps: ${policyFile} does not exist\n`); return EXIT.usage; }
|
|
let raw: unknown;
|
|
try { raw = JSON.parse(fs.readFileSync(policyFile, 'utf8')); } catch (e: any) { process.stderr.write(`gstack-ship-receipt docs-caps: ${policyFile} is not JSON: ${e.message}\n`); return EXIT.fail; }
|
|
const errors = validatePolicy(raw).filter(e => e.path === 'docs' || e.path.startsWith('docs.'));
|
|
if (errors.length) { for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt docs-caps', e.code, `${e.message} at ${e.path}`) + '\n'); return EXIT.fail; }
|
|
caps = (raw as Partial<ShipPolicy>).docs?.caps ?? [];
|
|
source = policyFile;
|
|
} else {
|
|
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
|
|
const load = loadPolicyFromBase(repoRoot, base);
|
|
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine('gstack-ship-receipt docs-caps', base, `${load.detail}; pass --policy <file> to check caps without the base`) + '\n'); return EXIT.refused; }
|
|
const errors = load.errors.filter(e => e.path === 'docs' || e.path.startsWith('docs.'));
|
|
if (errors.length) { for (const e of errors) process.stderr.write(resultLine('gstack-ship-receipt docs-caps', e.code, `${e.message} at ${e.path} (${load.source.label})`) + '\n'); return EXIT.fail; }
|
|
caps = load.policy.docs?.caps ?? [];
|
|
source = load.source.label;
|
|
}
|
|
const results = checkDocsCaps(caps, rel => { try { const st = fs.statSync(path.join(repoRoot, rel)); return st.isFile() ? st.size : null; } catch { return null; } });
|
|
const over = results.filter(r => r.verdict === 'over');
|
|
const lines = results.length ? results.map(docsCapLine) : ['DOCS_CAP: none declared'];
|
|
process.stdout.write(json ? JSON.stringify({ policy: source, caps: results, over: over.length }) + '\n' : lines.join('\n') + '\n');
|
|
if (!over.length) return EXIT.ok;
|
|
process.stderr.write(resultLineWithDocs('gstack-ship-receipt docs-caps', 'DOCS_AUDIT_BLOCKED', `${over.length} of ${results.length} documentation cap${results.length === 1 ? '' : 's'} over: ${over.map(r => r.path).join(', ')}`) + '\n');
|
|
return EXIT.fail;
|
|
}
|
|
|
|
function main(argv: string[]): number {
|
|
const [cmd, ...rest] = argv;
|
|
const json = rest.includes('--json');
|
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
|
if (cmd === 'write') return cmdWrite(rest, json);
|
|
if (cmd === 'read') return cmdRead(rest, json);
|
|
if (cmd === 'result') return cmdResult(rest, json);
|
|
if (cmd === 'census') return cmdCensus(rest, json);
|
|
if (cmd === 'carry') return cmdCarry(rest, json);
|
|
if (cmd === 'docs-caps') return cmdDocsCaps(rest, json);
|
|
process.stderr.write(`gstack-ship-receipt: unknown command ${cmd}\n${HELP}\n`);
|
|
return EXIT.usage;
|
|
}
|
|
|
|
process.exitCode = main(process.argv.slice(2));
|