mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
fix(ship): receipt counts bind by finding id, every run kept, scoped re-check after a refused carry, READY tail, head= on the result line (GSTA-258 L4; GST-1136, GST-1168, GST-1179)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
4ec1722ecb
commit
0a1e4f0929
31 files changed
+642
-70
No files matched your search
+12
-4
@@ -27,7 +27,7 @@ import { runScriptSync } from '../lib/script-invocation';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post';
|
||||
import {
|
||||
ledgerPass, parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
|
||||
ledgerPass, parseFixTrailers, parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
|
||||
ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
|
||||
} from '../lib/review-scope';
|
||||
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
|
||||
@@ -48,7 +48,9 @@ const HELP = `gstack-review-scope — finding scope for /ship's review loop (doc
|
||||
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input);
|
||||
blocking_found also counts every CRITICAL the dispatch ledgers collected or judged
|
||||
(--run finds each review-ledger.jsonl under the run directory); unrecorded=N names
|
||||
blockers no row gives a disposition. An unreadable ledger: REVIEW_SCOPE_LEDGER
|
||||
blockers no row disposes; a row finding's ledger_id (<pass dir>/<merged id>) and each
|
||||
fix commit's Review-Finding: <id> reader=<role> trailer bind by id, and what cannot bind
|
||||
prints REVIEW_SCOPE_UNBOUND: <what>: <why> (unbound=N). An unreadable ledger: REVIEW_SCOPE_LEDGER
|
||||
|
||||
Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses
|
||||
(POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin; --branch the current branch.
|
||||
@@ -267,9 +269,15 @@ function cmdSummary(rest: string[], json: boolean): number {
|
||||
if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger');
|
||||
// The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount.
|
||||
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run <run dir> or --ledger <f>), or --no-ledger when no reader ran through gstack-review-dispatch');
|
||||
// A row's ledger_id and a fix trailer name a finding as <pass dir>/<merged id>: the ids restart in every pass's ledger.
|
||||
const keyOf = (file: string) => (id: string) => `${path.basename(path.dirname(path.resolve(file)))}/${id}`;
|
||||
let ledgers: LedgerBlocker[][];
|
||||
try { ledgers = files.map(ledgerBlockers); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
|
||||
const summary = summarizeReviewLoop(rows, since, ledgers, files.map(ledgerPass));
|
||||
try { ledgers = files.map(f => ledgerBlockers(f).map(b => ({ ...b, key: keyOf(f)(b.id) }))); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
|
||||
const log = spawnSync('git', ['log', '--first-parent', '--no-merges', `--since=${since}`, '--format=%H%x00%(trailers:key=Review-Finding,valueonly,separator=%x1f)%x1e', 'HEAD'], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (log.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `git log for Review-Finding trailers failed: ${(log.stderr ?? '').trim().split('\n')[0] ?? ''}`);
|
||||
const trailers = log.stdout.split('\x1e').flatMap(rec => { const [sha, values] = rec.trim().split('\0'); return sha && values ? parseFixTrailers(sha, values.split('\x1f').map(v => v.trim()).filter(Boolean)) : []; });
|
||||
const summary = summarizeReviewLoop(rows, since, ledgers, files.map(f => ledgerPass(f, keyOf(f))), trailers);
|
||||
for (const u of summary.unbound ?? []) process.stderr.write(`REVIEW_SCOPE_UNBOUND: ${u}\n`);
|
||||
const line = reviewLoopLine(summary);
|
||||
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
|
||||
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
|
||||
|
||||
+42
-4
@@ -49,6 +49,7 @@ import {
|
||||
} 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 } from '../lib/ship-recheck';
|
||||
import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, parseReviewTargeted, receiptExclude, receiptReviewAtHead, receiptTargetedAtHead, reviewLoopExceptionLine, targetedClassesSince } from '../lib/ship-receipt-depth';
|
||||
import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
@@ -66,7 +67,7 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
|
||||
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 run=<dir> (refused: status=refused run=-)
|
||||
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>]
|
||||
@@ -77,6 +78,8 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block
|
||||
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
|
||||
@@ -99,6 +102,8 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
|
||||
--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 (rerun the review; write alone cannot clear it); recorded as review_exclude. Carry and the queue compare that digest.
|
||||
--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.
|
||||
--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
|
||||
@@ -126,6 +131,13 @@ function jsonArg(raw: string | undefined): Record<string, unknown> | undefined {
|
||||
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;
|
||||
@@ -277,6 +289,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : 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; }
|
||||
@@ -349,7 +362,7 @@ function cmdRead(rest: string[], json: boolean): number {
|
||||
* /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 run=<dir>.
|
||||
* 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.
|
||||
*/
|
||||
@@ -416,7 +429,7 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
|
||||
};
|
||||
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} run=${dir}\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;
|
||||
}
|
||||
|
||||
@@ -648,6 +661,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
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' };
|
||||
@@ -745,7 +759,29 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
|
||||
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
|
||||
plan = planCarry({ from, head, changed, productPatchId: { from: reviewed as string, head: current as string }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff, ...(retarget ? { retargetedFrom: reviewedBase } : {}), ...(scopedReview ? { scopedReview } : {}), ...(stampedRows.length ? { stampedRows } : {}) });
|
||||
// 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 r = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor, retarget, rangeDiff: fullRange, unnamedPaths: named.length !== changedProduct.length });
|
||||
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 });
|
||||
if (!read.ok) return refused('RECEIPT_CARRY_REFUSED', `--recheck: ${read.message}`);
|
||||
recheck = `scoped re-check by ${read.record.reader} (range-diff ${r.lines} lines)`;
|
||||
} 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! })); 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> ({"from":"${from}","head":"${head}","range_diff_sha256":"${r.sha256}","reader":"<model>","verdict":"clean"}); 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 } : {}), ...(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,
|
||||
@@ -774,6 +810,8 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
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 (digest !== (receipt.review_exclude?.digest ?? 'none')) 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 reviewers read another selection than HEAD ${head.slice(0, 12)} has under the base policy, 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) };
|
||||
}
|
||||
|
||||
@@ -743,7 +743,7 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
|
||||
case "$SKILL_NAME" in
|
||||
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
|
||||
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
|
||||
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
|
||||
ship) _UA_RECORD="This skill ends with \`gstack-ship-receipt result\` (Step 22), which writes <state root>/ship/runs/<utc>-<head12>/ship-run.json (a /ship record, not a run manifest) and prints \`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>\`; a run that stops earlier prints \`GSTACK_RESULT: skill=ship status=refused run=-\` itself. The one publication allowed here: when an open PR for this branch exists and HEAD is its pushed head, update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never \`gh pr ready\`: the draft-state items there concern an early PR this run never opened); otherwise the handoff section writes the receipt to .gstack/tmp/ship-receipt.md and the parent reads it there." ;;
|
||||
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
|
||||
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
|
||||
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
**/ship's receipt counts its review findings by id, keeps every run, and a small change after review gets one reader instead of a second /ship (GSTA-258 L4; GST-1136, GST-1168, GST-1179).** The receipt's `REVIEW_LOOP` counts were wrong in a repeatable way: gbrain-evals#310's receipt said 3 blockers found and 2 fixed when 2 were found, 1 fixed and 1 deferred; gstack-evals#174 counted 16 blocking with 8 unrecorded against 9 found and 9 fixed; #3906 and #3911 showed `fixed=0` after real fixes. The summary paired findings by path:line only, so a native finding with no path:line, a row logged after its fix moved the line, or a late defer re-logged under another fingerprint all broke the count, and rows it couldn't place were dropped without a word.
|
||||
|
||||
### Changed
|
||||
- **`gstack-review-scope summary` binds by finding id.** A row finding carries `ledger_id` (`<pass dir>/<merged.jsonl id>`), each fix commit names what it fixed in a `Review-Finding: <id> reader=<role>` trailer, rows that record their head dedupe by kind, pass, head and finding ids, and a late defer counts once. Rows and findings without an id keep the path:line pairing.
|
||||
- **What can't bind is named, never a silent zero.** A row with no start token or readable time, a `pN` ledger with no review row, a `ledger_id` no ledger holds and a trailer naming nothing each print `REVIEW_SCOPE_UNBOUND: <what>: <why>`, and the line and receipt carry `unbound=N`.
|
||||
- **The native reader's own verdict line no longer collects as a blocker.** "There are three low-severity findings, all FIXABLE, and none blocks." (#3901) collected as CRITICAL beside the findings it summarized; it now collects at FIXABLE. A line that says something blocks, names a file:line or leads with FIXABLE still collects as CRITICAL.
|
||||
- **`gstack-ship-receipt carry` offers a scoped re-check (J1).** On `RECEIPT_CARRY_PRODUCT_CHANGED` with linear history against the reviewed base and a range-diff (plus the diff of the changed product paths) of at most 400 lines, carry prints `RECHECK: eligible … packet=<file>`. One reader on the packet and `carry --recheck <record.json>` rebind the receipt, unless HEAD needs a deeper review depth or another risk class. Rebased branches, retargeted stacks and anything over the cap still need /ship.
|
||||
- **Every /ship run's receipt is kept.** When `write` or `carry` replaces a different receipt, the old one's identifiers and counters move to an append-only `gstack-receipt-runs` block in the PR body (newest 30).
|
||||
- **`gstack-ship-queue ready` prints the whole READY.** It adds the receipt's `REVIEW_LOOP:` line, a `TRIAL_MERGES:` line for the lane to fill and an `Attention:` stub that opens with `reviewed by <model>` on risk-class PRs and `self-audited` on every other PR. docs/unattended.md now asks for the cross-family trail read only on risk-class PRs.
|
||||
- **The `GSTACK_RESULT:` line names the full head** (`status=complete head=<sha> run=<dir>`), so a leftover line from an earlier ship can't pass for a new head (GST-1179). `parseResult` reads the optional token.
|
||||
- **The receipt schema has `no_stop`**, one `{class, reason, evidence}` record per routine stop a run went on past (`write --no-stop`), for the five no-stop defaults that come next.
|
||||
|
||||
### Tests
|
||||
- `test/review-loop-binding.test.ts`: the GBEV-3, GEVA-223, #3906, #3892/#3903/#3911 and #3911 duplicate-row cases. On main the GBEV-3 case reproduces its receipt exactly (3 found, 2 fixed) and GEVA-223's pattern (18 found, 9 unrecorded).
|
||||
- `test/review-ledger-verdict-line.test.ts`: the captured #3901 output, with negative controls.
|
||||
- `test/ship-recheck.test.ts`: every refusal that does and doesn't qualify, the record binding, and the carry end to end.
|
||||
- `test/ship-receipt-runs.test.ts`, `test/ship-receipt-result-head.test.ts`: the runs block, the READY tail, `no_stop` validation and the `head=` token.
|
||||
@@ -461,7 +461,10 @@ puts in the receipt:
|
||||
| `review_loop.passes` | full review passes this ship ran (1 or 2; up to 3 under `review_scope: all`) |
|
||||
| `review_loop.blocking_found` | every blocker found (GST-951): the rows' blocking findings (classified, or an unclassified CRITICAL), plus each ledger CRITICAL (as collected or judged) that no row finding from the same reviewer at the same path:line accounts for (each row finding pairs with one per ledger), so an adversarial, outside-voice or not-yet-classified blocker counts and one whose newest classification is below the floor does not. An unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER`, never a count |
|
||||
| `review_loop.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 |
|
||||
| `review_loop.unbound` | what could not bind (GST-1168), each printed as `REVIEW_SCOPE_UNBOUND: <what>: <why>`: a row with no start token and no readable time, a `pN` ledger with no review row of pass N, a row finding's `ledger_id` no ledger collected, a `Review-Finding` trailer naming nothing; absent when 0 |
|
||||
| `review_loop.ledgers_read` | dispatch ledgers the count read; absent under `--no-ledger` |
|
||||
|
||||
Findings bind by id first (GST-1168). A row finding that came from a dispatched reader carries `ledger_id` (`<pass dir>/<merged.jsonl id>`, such as `p2/adversarial-native#3`) and pairs with that ledger entry whatever its path:line says, so a native finding with no path:line, or a row logged after its fix moved the line, still pairs. Each fix commit names what it fixed in a `Review-Finding: <ledger_id or fingerprint> reader=<raised_by>` trailer, and a trailer counts the finding as fixed. A late defer re-logs its finding under the same id, so it counts once. Rows that record their head dedupe by kind, pass, head and finding ids, so a logger command run twice counts once. Rows and findings without an id keep the path:line pairing above. The native reader's own verdict line ("all FIXABLE, and none blocks", with no file:line, list mark, lead label or Severity/Blocks field) collects at FIXABLE, not CRITICAL; the findings it summarizes collect on their own lines.
|
||||
| `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) |
|
||||
| `review_loop.noted` | below-floor findings listed in the PR body |
|
||||
| `review_loop.fixed` | findings fixed in this PR |
|
||||
|
||||
@@ -1591,6 +1591,12 @@ base context next to the branch's unchanged hunks, which is
|
||||
[`RECEIPT_CARRY_SCOPED_REVIEW`](troubleshooting.md#receipt-carry-scoped-review)
|
||||
until one fix-check reviewer clears the packet; a working tree with uncommitted
|
||||
changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused).
|
||||
A `RECEIPT_CARRY_PRODUCT_CHANGED` on linear history against the reviewed base
|
||||
whose range-diff, plus the diff of the changed product paths, is at most 400
|
||||
lines also prints `RECHECK: eligible … packet=<file>`: one reader on that
|
||||
packet and `carry --recheck <record.json>` rebind the receipt instead of a
|
||||
full second /ship, unless HEAD needs a deeper review depth or another risk
|
||||
class ([scoped re-check](troubleshooting.md#receipt-carry-recheck), J1).
|
||||
|
||||
**Rewritten history.** `write` records `head_tree` (the reviewed head's tree)
|
||||
and `product_patch_id` (its product patch against the base), and every carry
|
||||
|
||||
@@ -6025,6 +6025,29 @@ none of carry's other checks would refuse; carry without `--dry-run` writes it.
|
||||
In /ship Step 1, `gstack-ship-preflight` prints the route as `NEXT: carry
|
||||
route (Step 1.4)`.
|
||||
|
||||
<a id="receipt-carry-recheck"></a>
|
||||
### Scoped re-check after `RECEIPT_CARRY_PRODUCT_CHANGED` (J1)
|
||||
|
||||
**Meaning.** The product diff changed since the receipt's head, so the receipt
|
||||
can't carry as it is. When the history is linear, the base is the one the
|
||||
receipt was reviewed against, and `git range-diff` of the reviewed range
|
||||
against the current one is at most 400 lines (`RECHECK_RANGE_DIFF_CAP` in
|
||||
`lib/ship-recheck.ts`), carry also prints `RECHECK: eligible
|
||||
range_diff_lines=N range_diff_sha256=<sha> packet=<file>`: one reader on that
|
||||
range-diff can clear it instead of a full second /ship. A rebased branch
|
||||
(`RECEIPT_CARRY_TREE_MISMATCH`), a retargeted stack, an empty or unreadable
|
||||
range-diff and anything over the cap print `RECHECK: not eligible (<why>)`
|
||||
instead.
|
||||
|
||||
**Fix.** Dispatch one reader on the packet (`.git/gstack/recheck-<head12>.md`;
|
||||
`--dry-run` writes none). When it comes back clean, write
|
||||
`{"from":"<reviewed head>","head":"<HEAD>","range_diff_sha256":"<from the RECHECK line>","reader":"<model>","verdict":"clean"}`
|
||||
and rerun `gstack-ship-receipt carry ... --recheck <record.json>`. The carry
|
||||
reason records the reader and the range-diff size. Carry still refuses when
|
||||
HEAD needs a deeper review depth or a different risk class than the receipt
|
||||
recorded; that, or a blocking finding, means /ship on the new head. In /ship
|
||||
Step 1, `gstack-ship-preflight` prints this as `NEXT: carry route (Step 1.4, J1)`.
|
||||
|
||||
<a id="receipt-prose-mismatch"></a>
|
||||
### `RECEIPT_PROSE_MISMATCH`
|
||||
|
||||
|
||||
+16
-12
@@ -87,7 +87,7 @@ Not every workflow writes this line or a run directory today:
|
||||
- **`/ship`** ends with `gstack-ship-receipt result --pr <n>` (or `--body
|
||||
.gstack/tmp/ship-receipt.md` after the handoff section), which binds the
|
||||
receipt to the branch head, writes the run record and prints
|
||||
`GSTACK_RESULT: skill=ship status=complete run=<dir>`. That line and that
|
||||
`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`. That line and that
|
||||
record are the machine-readable READY evidence; [What /ship leaves for a
|
||||
parent](#ship-evidence) below is the whole contract (the refused and
|
||||
incomplete lines, the one publication an unattended run may make, the
|
||||
@@ -131,7 +131,7 @@ in the PR body (or the handoff file) to HEAD, writes
|
||||
`<state root>/ship/runs/<utc>-<head12>/` and prints the terminal line:
|
||||
|
||||
```
|
||||
GSTACK_RESULT: skill=ship status=complete run=<dir>
|
||||
GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>
|
||||
```
|
||||
|
||||
The run directory holds `ship-receipt.md` (the block, copied) and
|
||||
@@ -255,7 +255,7 @@ When the parent merges the base in or fixes a test after the receipt was
|
||||
written, it runs `gstack-ship-receipt carry --pr <n> --base <base> --dry-run`
|
||||
instead of another /ship; on `CARRY: would carry` it runs `gstack-pregate` at HEAD
|
||||
and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from <old> to <new> (<reason>)` plus the
|
||||
`GSTACK_RESULT: skill=ship status=complete run=<dir>` line means the receipt
|
||||
`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>` line means the receipt
|
||||
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 or its only
|
||||
@@ -893,15 +893,19 @@ decision or action, every evidence pointer resolves and shows what the row
|
||||
claims, and a fork or abandoned approach that shaped the result but is not
|
||||
logged gets a row. The audit corrects the log, never the story: no row is
|
||||
edited or removed, an invented or wrong row stays and a new row supersedes it
|
||||
with what actually happened. Then a reviewer on a different model family from
|
||||
the one that did the work (a Capy subagent on another family, or the outside
|
||||
voice) reads the trail and the transcript and flags what the owner should look
|
||||
at: decisions with weak or missing evidence, verification claimed without
|
||||
proof, choices that look risky in hindsight, gaps a skim would miss. The run's
|
||||
final report ends with an `Attention` section that opens with `reviewed by
|
||||
<model>` on its own line and lists each flag against its rows; `No flags` is a
|
||||
valid body, a missing model line is not. Self-review does not stand in for
|
||||
this. `decisions.jsonl` stays the typed record `gstack-artifact` validates; the
|
||||
with what actually happened. On a risk-class PR (the receipt's `review_depth`
|
||||
has class `gate` or a `risk` class: security, money, auth, secrets, user
|
||||
data), a reviewer on a different model family from the one that did the work
|
||||
(a Capy subagent on another family, or the outside voice) then reads the trail
|
||||
and the transcript and flags what the owner should look at: decisions with
|
||||
weak or missing evidence, verification claimed without proof, choices that
|
||||
look risky in hindsight, gaps a skim would miss. Self-review does not stand in
|
||||
for that read. Every other PR skips it: the run's own audit is the review. The
|
||||
run's final report ends with an `Attention` section that opens with `reviewed
|
||||
by <model>` (risk class) or `self-audited` (every other PR) on its own line
|
||||
and lists each flag against its rows; `No flags` is a valid body, a missing
|
||||
opening line is not. `gstack-ship-queue ready` prints the stub with the right
|
||||
opening line for the receipt it bound. `decisions.jsonl` stays the typed record `gstack-artifact` validates; the
|
||||
trail is the plain one a person reads, and `column -s$'\t' -t decisions.tsv`
|
||||
renders it in a terminal.
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export interface ShipReceipt {
|
||||
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
|
||||
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; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number; unrecorded?: number; ledgers_read?: number };
|
||||
review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record<string, number>; est_input_tokens?: number; unrecorded?: number; ledgers_read?: number; unbound?: number };
|
||||
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;
|
||||
@@ -125,7 +125,11 @@ export interface ShipReceipt {
|
||||
base_red?: { cases: string[]; files: string[]; base: string; lane: string; expires_at: string };
|
||||
/** The base policy's review_exclude_paths at the reviewed head (lib/review-exclude.ts); absent when the base declares none. Carry and the queue compare `digest`. */
|
||||
review_exclude?: { source: string; patterns: string[]; applied: string[]; excluded: number; excluded_bytes: number; kept: number; gate_tagged: string[]; patterns_sha256: string; list_sha256: string; digest: string };
|
||||
/** Routine stops the run recorded and went on past instead of waiting for a Captain (plan K1; GSTA-258 L5 writes them): one record per stop. */
|
||||
no_stop?: Array<{ class: NoStopClass; reason: string; evidence: string }>;
|
||||
}
|
||||
export const NO_STOP_CLASSES = ['scoped-fix-check', 'test-timeout-fix', 'pregate-flake-rerun', 'stamp-only-carry', 'autoplan-regate'] as const;
|
||||
export type NoStopClass = typeof NO_STOP_CLASSES[number];
|
||||
|
||||
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused'];
|
||||
const TERMINAL_RUN: readonly string[] = ['complete', 'reviewed', 'gate_pending', 'amend_pending', 'refused', 'incomplete'];
|
||||
@@ -142,11 +146,12 @@ export function exitTableHelp(): string {
|
||||
export function formatResult(skill: string, status: ResultStatus, run: string): string {
|
||||
return `GSTACK_RESULT: skill=${skill} status=${status} run=${run}`;
|
||||
}
|
||||
export function parseResult(text: string): { skill: string; status: ResultStatus; run: string } | undefined {
|
||||
/** A ship line may name the full head it bound (`head=<sha>`, GST-1179) between status and run. */
|
||||
export function parseResult(text: string): { skill: string; status: ResultStatus; run: string; head?: string } | undefined {
|
||||
const lines = text.split('\n').filter(l => l.startsWith('GSTACK_RESULT: '));
|
||||
const m = /^GSTACK_RESULT: skill=(\S+) status=(\S+) run=(.+)$/.exec(lines[lines.length - 1] ?? '');
|
||||
const m = /^GSTACK_RESULT: skill=(\S+) status=(\S+)(?: head=([0-9a-f]{40,64}))? run=(.+)$/.exec(lines[lines.length - 1] ?? '');
|
||||
if (!m || !RESULT_STATUSES.includes(m[2] as ResultStatus)) return;
|
||||
return { skill: m[1]!, status: m[2] as ResultStatus, run: m[3]! };
|
||||
return { skill: m[1]!, status: m[2] as ResultStatus, run: m[4]!, ...(m[3] ? { head: m[3] } : {}) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -246,7 +251,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
|
||||
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 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers_read: { type: 'integer', minimum: 0 } } },
|
||||
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 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers_read: { type: 'integer', minimum: 0 }, unbound: { type: 'integer', minimum: 0, description: 'rows, ledger ids or fix trailers gstack-review-scope summary could not bind (GST-1168)' } } },
|
||||
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, gstack_version: str,
|
||||
base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } },
|
||||
@@ -254,6 +259,7 @@ export const ARTIFACT_SCHEMAS = {
|
||||
review_targeted: { type: 'object', required: ['classes', 'rules', 'brief_sha', 'product_patch_id', 'status', 'findings', 'raised_by'], properties: { classes: { type: 'array', items: { enum: ['money', 'gate', 'auth', 'secret'] } }, rules: str, brief_sha: str, product_patch_id: str, status: { enum: ['done', 'failed', 'not-run'] }, findings: { type: 'integer', minimum: 0 }, raised_by: str } },
|
||||
review_exclude: { type: 'object', required: ['source', 'patterns', 'applied', 'excluded', 'excluded_bytes', 'kept', 'gate_tagged', 'patterns_sha256', 'list_sha256', 'digest'], properties: { source: str, patterns: strList, applied: strList, excluded: { type: 'integer', minimum: 0 }, excluded_bytes: { type: 'integer', minimum: 0 }, kept: { type: 'integer', minimum: 0 }, gate_tagged: strList, patterns_sha256: str, list_sha256: str, digest: str } },
|
||||
review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } },
|
||||
no_stop: { type: 'array', items: { type: 'object', required: ['class', 'reason', 'evidence'], properties: { class: { enum: [...NO_STOP_CLASSES] }, reason: str, evidence: 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' } } } } } },
|
||||
},
|
||||
},
|
||||
|
||||
+10
-1
@@ -100,6 +100,14 @@ const DENIED_OR_FIXED = new RegExp(`(?:(?<![A-Za-z])(?:nothing|none|no|neither|z
|
||||
export const summaryCandidate = (t: string) => ANY_LABEL.test(t) && !ANY_LABEL.test(t.replace(DENIED_OR_FIXED, ' ')) && !t.startsWith('{') && !t.startsWith('**') && !LIST_ITEM.test(t) && !HEADING.test(t) && !LEAD_LABEL.test(t)
|
||||
&& !t.match(SEVERITY_FIELD) && !t.match(STATED_BLOCKS) && [...t.matchAll(ALL_LABELS)].every(m => !!rank(m[1]))
|
||||
&& t.split(SENTENCE).filter(s => ANY_LABEL.test(s)).every(s => !PATH_LOCUS.test(s) && !EXCEPTION.test(s));
|
||||
/**
|
||||
* The native reader's own verdict line (GSTA-258 L4: #3901 "There are three low-severity findings, all FIXABLE, and none blocks."): a prose
|
||||
* line, no list item, heading, lead label, file:line, Severity or Blocks field, whose only labels are FIXABLE or INVESTIGATE and which says
|
||||
* nothing blocks. It collects at FIXABLE as written, never raised to CRITICAL; the findings it summarizes collect on their own lines.
|
||||
*/
|
||||
const NONE_BLOCKS = /(?<![A-Za-z])(?:(?:none|nothing|neither)(?:\s+of\s+(?:them|these|those))?\s+(?:blocks?|is\s+blocking|are\s+blocking|would\s+block)|no\s+(?:finding|issue|one)s?\s+(?:blocks?|would\s+block)|no\s+blocking\s+(?:defects?|findings?|issues?))(?![A-Za-z])/i;
|
||||
const quietSummary = (t: string) => ANY_LABEL.test(t) && NONE_BLOCKS.test(t) && !LOCUS.test(t) && !PATH_LOCUS.test(t) && !LIST_ITEM.test(t) && !HEADING.test(t) && !LEAD_LABEL.test(t)
|
||||
&& !t.match(SEVERITY_FIELD) && !t.match(STATED_BLOCKS) && [...t.matchAll(ALL_LABELS)].every(m => rank(m[1]) === 0);
|
||||
export function stated(raw: string): Stated | undefined {
|
||||
if (raw.startsWith('{')) return undefined;
|
||||
const severity = [...raw.matchAll(STATED_SEVERITY)].at(-1)?.[1];
|
||||
@@ -143,6 +151,7 @@ export function parseFindings(reader: string, output: string, fixableRaises = tr
|
||||
const critical = (t: string) => BLOCKS_YES.test(t) || (fixableRaises && role === 'adversarial-native' && /(?<![A-Za-z0-9])FIXABLE(?![A-Za-z0-9])/.test(t));
|
||||
const leadOf = (t: string) => { const l = LEAD_LABEL.exec(t)?.[1]; return l === undefined || !fixableRaises ? l : named(l); };
|
||||
const labelOf = (t: string) => {
|
||||
if (role === 'adversarial-native' && quietSummary(t)) return 'FIXABLE';
|
||||
const field = fieldOf(t);
|
||||
if (field === undefined) return strongest(t);
|
||||
return critical(t) ? 'CRITICAL' : strongestOf([leadOf(t), field])!;
|
||||
@@ -342,7 +351,7 @@ const realPath = (f: string) => { try { return fs.realpathSync(f); } catch { ret
|
||||
* included) so the summary can find its row.
|
||||
* A ledger, merged file or judged set it cannot read, or a judged set changed since judge, throws: the count is never a guess.
|
||||
*/
|
||||
export interface LedgerBlocker { id: string; locus: string | null }
|
||||
export interface LedgerBlocker { id: string; locus: string | null; /** The run-unique `<pass dir>/<id>` a row's ledger_id and a fix trailer name (GST-1168); the caller sets it. */ key?: string }
|
||||
function lineLocus(raw: string): string | null {
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
|
||||
+53
-14
@@ -13,6 +13,7 @@
|
||||
*
|
||||
* Pure functions here; bin/gstack-review-scope runs git and gh.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { SPECIALISTS } from './review-depth';
|
||||
import { rank, readLedger, type LedgerBlocker } from './review-ledger';
|
||||
import { parseSpecialistOutput } from './state-matrix';
|
||||
@@ -313,6 +314,8 @@ export interface ReviewLoopSummary {
|
||||
unrecorded?: number;
|
||||
/** Dispatch ledgers whose collected and judged findings were counted (gstack-review-dispatch). */
|
||||
ledgers_read?: number;
|
||||
/** What could not bind (GST-1168), each named with its reason: a row with no start token or readable time, a row finding's ledger_id no ledger holds, a Review-Finding trailer naming no finding. Never counted as zero silently. */
|
||||
unbound?: string[];
|
||||
}
|
||||
|
||||
/** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */
|
||||
@@ -376,12 +379,22 @@ function rowLocus(f: Record<string, any>): string | null {
|
||||
* When a dispatch ledger started (its first event) and whether it holds a fix-check reader's review: a fix-check run through
|
||||
* gstack-review-dispatch (the pass program's `fix-check` step) leaves this ledger even when no review-fix-check row was logged.
|
||||
*/
|
||||
export interface LedgerPass { started: number; fixCheck: boolean }
|
||||
export function ledgerPass(ledger: string): LedgerPass {
|
||||
export interface LedgerPass { started: number; fixCheck: boolean; ids?: string[]; /** The ledger's pass directory (p1, p2, fc): a pN with no review row of pass N is named unbound. */ name?: string }
|
||||
export function ledgerPass(ledger: string, key = (id: string) => id): LedgerPass {
|
||||
const events = readLedger(ledger);
|
||||
const started = Date.parse(String(events[0]?.at ?? ''));
|
||||
const fixCheck = events.some(e => e.event === 'output' && e.reader === 'fix-check' && e.tree_ok !== false && ((Array.isArray(e.ids) && e.ids.length > 0) || e.review === true));
|
||||
return { started: Number.isFinite(started) ? started : NaN, fixCheck };
|
||||
const ids = events.flatMap(e => (e.event === 'output' && Array.isArray(e.ids) ? (e.ids as string[]) : e.event === 'judgment' && e.kind === 'added' && typeof e.id === 'string' ? [e.id] : [])).map(key);
|
||||
return { started: Number.isFinite(started) ? started : NaN, fixCheck, ids, name: path.basename(path.dirname(path.resolve(ledger))) };
|
||||
}
|
||||
|
||||
/** A fix commit's `Review-Finding: <ledger_id or fingerprint> reader=<role>` trailer (receipt attribution): the finding that commit fixed. */
|
||||
export interface FixTrailer { finding: string; reader: string; commit: string }
|
||||
export function parseFixTrailers(commit: string, values: readonly string[]): FixTrailer[] {
|
||||
return values.flatMap(v => {
|
||||
const m = /^\s*(\S+)\s+reader=([a-z][a-z0-9-]*)\s*$/.exec(v);
|
||||
return m ? [{ finding: m[1]!, reader: m[2]!, commit }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,12 +413,17 @@ export function ledgerPass(ledger: string): LedgerPass {
|
||||
* decision 3.5 runs a fix-check only after the loop applied that pass's fixes, and a blocker the loop does not fix is deferred
|
||||
* or noted by a row. A blocker the last pass found stays unrecorded, and with every fix-check logged the rows decide.
|
||||
*/
|
||||
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = [], ledgerPasses: readonly LedgerPass[] = []): ReviewLoopSummary {
|
||||
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = [], ledgerPasses: readonly LedgerPass[] = [], trailers: readonly FixTrailer[] = []): ReviewLoopSummary {
|
||||
const sinceMs = Date.parse(since);
|
||||
const loopSkill = (r: Record<string, any>) => r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL;
|
||||
const inRun = (r: Record<string, any>) => r && r.via === 'ship' && Number.isFinite(rowTime(r)) && (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs);
|
||||
const loopRows = rows.filter(r => inRun(r) && (r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL));
|
||||
const unbound: string[] = rows.filter(r => r && r.via === 'ship' && loopSkill(r) && !Number.isFinite(rowTime(r)))
|
||||
.map(r => `row ${String(r.skill)} pass=${String(r.pass ?? '?')}: no start token and no readable timestamp, so it cannot be placed in this run`);
|
||||
// A repeated logger command writes the same row twice (#3911: one fix-check counted as two): rows that record their head dedupe by kind, pass, head and finding ids.
|
||||
const rowKey = (r: Record<string, any>) => JSON.stringify([r.skill, r.pass ?? null, r.commit_full ?? r.commit ?? '', (Array.isArray(r.findings) ? r.findings : []).map((f: any) => (f && typeof f === 'object' ? (typeof f.ledger_id === 'string' ? f.ledger_id : findingIdentity(f)) : '')).sort()]);
|
||||
const loopRows = [...new Map(rows.filter(r => inRun(r) && loopSkill(r)).map((r, i) => [r.commit_full || r.commit ? rowKey(r) : `#${i}`, r] as const)).values()];
|
||||
const passes = loopRows.filter(r => r.skill === 'review');
|
||||
type Seen = { locus: string | null; raiser: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean };
|
||||
type Seen = { locus: string | null; raiser: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean; ledgerId?: string };
|
||||
const seen = new Map<string, Seen>();
|
||||
const deferred = new Set<string>();
|
||||
const noted = new Set<string>();
|
||||
@@ -415,7 +433,10 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
|
||||
if (REVIEW_SCOPE_POLICIES.includes(row.review_scope)) scope = row.review_scope;
|
||||
for (const f of Array.isArray(row.findings) ? row.findings : []) {
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
const id = findingIdentity(f);
|
||||
const ledgerId = typeof f.ledger_id === 'string' && f.ledger_id ? f.ledger_id : undefined;
|
||||
// A late defer re-logs a finding an earlier row holds; without a ledger_id it is that finding when the locus and reviewer agree (GBEV-3).
|
||||
const same = !ledgerId && row.skill === LATE_DEFER_SKILL ? [...seen].find(([, s]) => s.locus !== null && s.locus === rowLocus(f) && s.raiser === raiserOf(f.raised_by))?.[0] : undefined;
|
||||
const id = ledgerId ?? same ?? findingIdentity(f);
|
||||
const prior = seen.get(id);
|
||||
const classified = typeof f.blocking === 'boolean';
|
||||
seen.set(id, {
|
||||
@@ -425,15 +446,21 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
|
||||
blocks: classified ? f.blocking : prior?.classified ? prior.blocks : !!prior?.blocks || rowBlocks(f),
|
||||
candidate: !!prior?.candidate || f.severity === undefined || rank(f.severity) === 4 || rank(f.raw_severity) === 4 || f.blocking === true,
|
||||
disposed: !!prior?.disposed || DISPOSITIONS.includes(f.action),
|
||||
...(ledgerId ?? prior?.ledgerId ? { ledgerId: ledgerId ?? prior?.ledgerId } : {}),
|
||||
});
|
||||
if (f.action === 'deferred') deferred.add(id);
|
||||
if (f.action === 'noted') noted.add(id);
|
||||
if (f.action === 'fixed' || f.action === 'auto-fixed') fixed.add(id);
|
||||
}
|
||||
}
|
||||
for (const p of ledgerPasses) {
|
||||
const n = /^p(\d+)$/.exec(p.name ?? '')?.[1];
|
||||
if (n && !passes.some(r => String(r.pass) === n)) unbound.push(`pass ${n} (ledger ${p.name}): no review-log row, so its fixes and dispositions cannot be counted`);
|
||||
}
|
||||
const known = ledgerPasses.some(p => p.ids) ? new Set(ledgerPasses.flatMap(p => p.ids ?? [])) : null;
|
||||
if (known) for (const [, s] of seen) if (s.ledgerId && !known.has(s.ledgerId)) unbound.push(`ledger_id ${s.ledgerId}: no dispatch ledger of this run collected it`);
|
||||
const paired = new Set<string>();
|
||||
let ledgerOnly = 0;
|
||||
let ledgerOnlyFixed = 0;
|
||||
const ledgerOnlyKeys: Array<{ key: string; id: string; fixed: boolean }> = [];
|
||||
const pairedIds = new Set<string>();
|
||||
const followedIds = new Set<string>();
|
||||
const lastPaired = new Map<string, number>();
|
||||
@@ -443,17 +470,29 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], since:
|
||||
for (const [n, list] of ledgers.entries()) {
|
||||
for (const b of list) {
|
||||
const by = b.id.startsWith('fix-check') ? null : raiserOf(b.id);
|
||||
const id = b.locus ? [...seen].find(([k, s]) => s.candidate && s.locus === b.locus && (!s.raiser || !by || s.raiser === by) && !paired.has(`${n}\0${k}`))?.[0] : undefined;
|
||||
if (id) { paired.add(`${n}\0${id}`); pairedIds.add(id); if (!((ledgerPasses[lastPaired.get(id) ?? -1]?.started ?? -Infinity) > (ledgerPasses[n]?.started ?? -Infinity))) lastPaired.set(id, n); } else { ledgerOnly++; if (followed(n)) ledgerOnlyFixed++; }
|
||||
const key = b.key ?? b.id;
|
||||
const open = ([k]: [string, Seen]) => !paired.has(`${n}\0${k}`);
|
||||
const id = [...seen].find(e => open(e) && (e[1].ledgerId === key || e[1].ledgerId === b.id))?.[0]
|
||||
?? (b.locus ? [...seen].find(e => open(e) && !e[1].ledgerId && e[1].candidate && e[1].locus === b.locus && (!e[1].raiser || !by || e[1].raiser === by))?.[0] : undefined);
|
||||
if (id) { paired.add(`${n}\0${id}`); pairedIds.add(id); if (!((ledgerPasses[lastPaired.get(id) ?? -1]?.started ?? -Infinity) > (ledgerPasses[n]?.started ?? -Infinity))) lastPaired.set(id, n); } else ledgerOnlyKeys.push({ key, id: b.id, fixed: followed(n) });
|
||||
}
|
||||
}
|
||||
for (const [id, n] of lastPaired) if (followed(n)) followedIds.add(id);
|
||||
for (const t of trailers) {
|
||||
const hit = [...seen].find(([k, s]) => k === t.finding || s.ledgerId === t.finding);
|
||||
if (hit) { hit[1].disposed = true; fixed.add(hit[0]); continue; }
|
||||
const only = ledgerOnlyKeys.find(l => l.key === t.finding || l.id === t.finding);
|
||||
if (only) { only.fixed = true; continue; }
|
||||
unbound.push(`trailer ${t.commit.slice(0, 12)} Review-Finding: ${t.finding} reader=${t.reader}: names no finding this run's rows or ledgers hold`);
|
||||
}
|
||||
const blockers = [...seen].filter(([k, s]) => s.blocks || (pairedIds.has(k) && !s.classified));
|
||||
for (const [k, s] of blockers) if (!s.disposed && followedIds.has(k)) { s.disposed = true; fixed.add(k); }
|
||||
const unrecorded = ledgerOnly - ledgerOnlyFixed + blockers.filter(([, s]) => !s.disposed).length;
|
||||
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blockers.length + ledgerOnly, deferred: deferred.size, noted: noted.size, fixed: fixed.size + ledgerOnlyFixed, scope };
|
||||
const ledgerOnlyFixed = ledgerOnlyKeys.filter(l => l.fixed).length;
|
||||
const unrecorded = ledgerOnlyKeys.length - ledgerOnlyFixed + blockers.filter(([, s]) => !s.disposed).length;
|
||||
const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blockers.length + ledgerOnlyKeys.length, deferred: deferred.size, noted: noted.size, fixed: fixed.size + ledgerOnlyFixed, scope };
|
||||
if (unrecorded) out.unrecorded = unrecorded;
|
||||
if (ledgers.length) out.ledgers_read = ledgers.length;
|
||||
if (unbound.length) out.unbound = unbound;
|
||||
const fixChecks = Math.max(rowFixChecks, fixCheckStarts.length);
|
||||
if (fixChecks) out.fix_checks = fixChecks;
|
||||
out.rounds = passes.length + fixChecks;
|
||||
@@ -474,6 +513,6 @@ export function dispatchTotal(d: Record<string, number> | undefined): number | u
|
||||
export function reviewLoopLine(s: ReviewLoopSummary): string {
|
||||
const total = dispatchTotal(s.dispatches);
|
||||
return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}`
|
||||
+ (s.unrecorded ? ` unrecorded=${s.unrecorded}` : '') + (s.ledgers_read ? ` ledgers_read=${s.ledgers_read}` : '')
|
||||
+ (s.unrecorded ? ` unrecorded=${s.unrecorded}` : '') + (s.unbound?.length ? ` unbound=${s.unbound.length}` : '') + (s.ledgers_read ? ` ledgers_read=${s.ledgers_read}` : '')
|
||||
+ (s.fix_checks ? ` fix_checks=${s.fix_checks}` : '') + (total !== undefined ? ` dispatches=${total}` : '') + (s.est_input_tokens !== undefined ? ` est_input_tokens=${s.est_input_tokens}` : '');
|
||||
}
|
||||
@@ -58,6 +58,10 @@ function integrate(cwd: string, base: string, r: Report): boolean {
|
||||
r.next(`carry route (Step 1.4): fix what the base merge broke (CHANGELOG, size caps), gstack-pregate --allow-repo-commands, then gstack-ship-receipt carry --pr ${n} --base ${base} --pregate .gstack/tmp/pregate.json`);
|
||||
return false;
|
||||
}
|
||||
if (line === 'CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)' && /^RECHECK: eligible /m.test(carry.stderr ?? '')) {
|
||||
r.next(`carry route (Step 1.4, J1): the product changed since the receipt within the scoped re-check cap: gstack-ship-receipt carry --pr ${n} --base ${base} writes the re-check packet; one reader reads it; gstack-pregate --allow-repo-commands, then carry again with --recheck <record.json> --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`);
|
||||
return false;
|
||||
}
|
||||
if (line === 'CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)') {
|
||||
r.next(`carry route (Step 1.4): the base edited context next to reviewed hunks: fix what the base merge broke (CHANGELOG, size caps), commit and push; then gstack-ship-receipt carry --pr ${n} --base ${base} writes the packet for that head; one fix-check reviewer reads it; gstack-pregate --allow-repo-commands, then carry again with --scoped-review <record.json> --pregate .gstack/tmp/pregate.json; a blocking finding runs Steps 2-16`);
|
||||
return false;
|
||||
|
||||
+2
-1
@@ -31,7 +31,7 @@ import type { PregateFile } from './headless-artifacts';
|
||||
import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index';
|
||||
import { ensureTmpExcluded, restamp, versionAtRef } from './restamp';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
import { extractReceipt } from './ship-receipt';
|
||||
import { extractReceipt, readyTailLines } from './ship-receipt';
|
||||
import { bindShippedReceipt } from './ship-receipt-depth';
|
||||
import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy';
|
||||
import {
|
||||
@@ -272,6 +272,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate
|
||||
receipt.line,
|
||||
...(receipt.depthLine ? [receipt.depthLine] : []),
|
||||
...(receipt.targetedLine ? [receipt.targetedLine] : []),
|
||||
...readyTailLines(receipt.ok ? receipt.receipt : undefined),
|
||||
];
|
||||
const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null;
|
||||
if (gate) lines.push(gate.line);
|
||||
|
||||
+51
-5
@@ -19,7 +19,7 @@ const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'ci_carried', 'spend_usd',
|
||||
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
|
||||
'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop',
|
||||
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red',
|
||||
'review_loop_exception', 'review_targeted', 'review_exclude', 'carry', 'base_red', 'no_stop',
|
||||
];
|
||||
|
||||
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
|
||||
@@ -92,10 +92,32 @@ export function bindReceipt(body: string, pr: string | undefined, accepted: read
|
||||
return { ok: true, receipt: r, head, line };
|
||||
}
|
||||
|
||||
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
|
||||
/**
|
||||
* Every /ship run's receipt (GSTA-258 L4, receipt attribution): when a write or carry replaces a different receipt, the old one's
|
||||
* identifiers and counters move to an append-only `gstack-receipt-runs` block (one JSON line per run, oldest first, the
|
||||
* newest RECEIPT_RUNS_KEPT kept) right after the current block, so a later replay sees each run's review loop, not only the last.
|
||||
*/
|
||||
export const RUNS_FENCE = 'gstack-receipt-runs';
|
||||
export const RECEIPT_RUNS_KEPT = 30;
|
||||
const RUNS_RE = /```gstack-receipt-runs[ \t]*\r?\n([\s\S]*?)\r?\n?```\n?/;
|
||||
export type ReceiptRun = Pick<ShipReceipt, 'head' | 'version' | 'gstack_version' | 'run' | 'review_loop' | 'review_loop_exception'> & { depth?: string; carried_from?: string };
|
||||
export function receiptRuns(body: string): ReceiptRun[] {
|
||||
const m = RUNS_RE.exec(body);
|
||||
return m ? m[1]!.split('\n').flatMap(l => { try { const o = JSON.parse(l); return o && typeof o.head === 'string' ? [o as ReceiptRun] : []; } catch { return []; } }) : [];
|
||||
}
|
||||
export function receiptRun(r: ShipReceipt): ReceiptRun {
|
||||
const { head, version, gstack_version, run, review_loop, review_loop_exception } = r;
|
||||
return JSON.parse(JSON.stringify({ head, version, gstack_version, run, review_loop, review_loop_exception, depth: r.review_depth?.depth, carried_from: r.carry?.from }));
|
||||
}
|
||||
|
||||
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows; a replaced receipt joins the runs block. */
|
||||
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
|
||||
const block = renderReceiptBlock(receipt);
|
||||
const stripped = body.replace(BLOCK_RE, '').replace(/^\s+/, '');
|
||||
const prior = extractReceipt(body).receipt;
|
||||
const runs = receiptRuns(body);
|
||||
if (prior && typeof prior.head === 'string' && JSON.stringify(prior) !== JSON.stringify(receipt)) runs.push(receiptRun(prior));
|
||||
const kept = runs.slice(-RECEIPT_RUNS_KEPT);
|
||||
const block = renderReceiptBlock(receipt) + (kept.length ? '\n```' + RUNS_FENCE + '\n' + kept.map(r => JSON.stringify(r)).join('\n') + '\n```\n' : '');
|
||||
const stripped = body.replace(BLOCK_RE, '').replace(RUNS_RE, '').replace(/^\s+/, '');
|
||||
return block + (stripped ? '\n' + stripped : '');
|
||||
}
|
||||
|
||||
@@ -136,6 +158,25 @@ export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
||||
}
|
||||
|
||||
/** A risk-class PR (R51): the receipt's review depth has class gate or a risk class. Only these get the cross-family trail read. */
|
||||
export const riskClassReceipt = (r: ShipReceipt | undefined) => r?.review_depth?.class === 'gate' || !!r?.review_depth?.risk?.length;
|
||||
|
||||
/**
|
||||
* The rest of the READY block (GSTA-258 E1: the ready output is the whole READY): the receipt's review loop, the trial-merge
|
||||
* line the lane fills, and the Attention stub, whose opening line follows the receipt's risk class (docs/unattended.md).
|
||||
*/
|
||||
export function readyTailLines(r: ShipReceipt | undefined): string[] {
|
||||
const loop = parseReviewLoop(r?.review_loop);
|
||||
const opt = (k: keyof ReviewLoopReceipt) => (loop?.[k] ? ` ${k}=${loop[k]}` : '');
|
||||
const risk = riskClassReceipt(r);
|
||||
return [
|
||||
loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt',
|
||||
'TRIAL_MERGES: <one line per open sibling pin that shares a file: its git merge-tree result and the resolution, or "none share a file">',
|
||||
'Attention:',
|
||||
risk ? `reviewed by <model on another family> (risk class: ${oneLine([r?.review_depth?.class === 'gate' ? 'gate' : '', ...(r?.review_depth?.risk ?? [])].filter(Boolean).join(', '))}; rule 27 cross-family trail read)` : 'self-audited (not a risk class: no cross-family trail read)',
|
||||
'<flags against decision-trail row numbers, or No flags>',
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// carry: the receipt follows a head whose product diff is unchanged
|
||||
@@ -181,6 +222,8 @@ export interface CarryPlanInput {
|
||||
scopedReview?: string;
|
||||
/** Stamp files the branch changed as product whose only change since the reviewed head is a stamp on the branch's own rows, the base untouched, and without which the two product patches match (GST-980). */
|
||||
stampedRows?: string[];
|
||||
/** A clean scoped re-check of the range-diff since the reviewed head (lib/ship-recheck.ts, J1): the carry reason it adds. */
|
||||
recheck?: 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 };
|
||||
@@ -249,12 +292,13 @@ export function classifyCarryPath(rel: string, extra: { metadata?: readonly stri
|
||||
* branch's own rows in a stamp file the branch also changed as product (`stampedRows`, GST-980).
|
||||
*/
|
||||
export function planCarry(input: CarryPlanInput): CarryPlan {
|
||||
if (input.productPatchId.from !== input.productPatchId.head && (input.changedProduct.length || (!input.scopedReview && !input.stampedRows?.length))) {
|
||||
if (input.productPatchId.from !== input.productPatchId.head && !input.recheck && (input.changedProduct.length || (!input.scopedReview && !input.stampedRows?.length))) {
|
||||
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
|
||||
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
|
||||
}
|
||||
const reasons: string[] = input.retargetedFrom ? [`stack parent ${input.retargetedFrom} landed`] : [];
|
||||
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
|
||||
if (input.recheck && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.recheck);
|
||||
if (input.scopedReview && input.productPatchId.from !== input.productPatchId.head) reasons.push(input.scopedReview);
|
||||
if (input.stampedRows?.length && input.productPatchId.from !== input.productPatchId.head) reasons.push(`a stamp on the branch's own rows (${input.stampedRows.join(', ')})`);
|
||||
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
|
||||
@@ -337,6 +381,8 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
|
||||
if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens');
|
||||
if (n('unrecorded')) out.unrecorded = n('unrecorded');
|
||||
if (n('ledgers_read')) out.ledgers_read = n('ledgers_read');
|
||||
const unbound = Array.isArray(v.unbound) ? v.unbound.length : n('unbound');
|
||||
if (unbound) out.unbound = unbound;
|
||||
if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) {
|
||||
const d = Object.fromEntries(Object.entries(v.dispatches as Record<string, unknown>)
|
||||
.filter(([k, c]) => /^[a-z][a-z0-9-]{0,39}$/.test(k) && typeof c === 'number' && Number.isInteger(c) && c >= 0).slice(0, 12)) as Record<string, number>;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* J1 (GSTA-258 plan H, relabeled J1): a scoped re-check after a refused receipt carry. When `gstack-ship-receipt carry` refuses
|
||||
* because the product patch changed since the reviewed head, and the change is bounded, one reader reads `git range-diff` of
|
||||
* the reviewed range against the current one instead of a full second /ship (gbrain-evals #246/#247: about 100k credits per
|
||||
* full rerun; #300's scoped reader: about 300). A clean verdict bound to that exact range-diff rebinds the receipt; the carry's
|
||||
* later checks still refuse a deeper review depth or a changed risk class, which need the full /ship.
|
||||
*
|
||||
* Qualifies: RECEIPT_CARRY_PRODUCT_CHANGED on linear history (the receipt's head is an ancestor of HEAD) against the same base.
|
||||
* Does not qualify: RECEIPT_CARRY_TREE_MISMATCH (rewritten history: the reviewed commits are gone, so there is no range to
|
||||
* diff), a retargeted stack (the reviewed range was against another base), an unreadable or empty range-diff, a range-diff
|
||||
* over RECHECK_RANGE_DIFF_CAP lines, and every RECEIPT_CARRY_REFUSED / RECEIPT_CARRY_PREGATE_STALE (those need their inputs
|
||||
* repaired, not a review).
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export const RECHECK_RANGE_DIFF_CAP = 400;
|
||||
/** rangeDiff: \`git range-diff\` of the two ranges followed by the diff of the changed product paths between the heads (a range-diff names added commits without their content). */
|
||||
export type RecheckInput = { code: string; ancestor: boolean; retarget: boolean; rangeDiff: string | null; unnamedPaths?: boolean };
|
||||
export type RecheckPlan = { ok: true; lines: number; sha256: string } | { ok: false; reason: string };
|
||||
|
||||
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
/** Whether a carry refusal may take the scoped re-check, and the range-diff a verdict must bind to. */
|
||||
export function planRecheck(i: RecheckInput): RecheckPlan {
|
||||
if (i.code !== 'RECEIPT_CARRY_PRODUCT_CHANGED') return { ok: false, reason: `${i.code} is not a product change a review can clear` };
|
||||
if (!i.ancestor) return { ok: false, reason: 'rewritten history: the reviewed head is not an ancestor of HEAD, so there is no range to diff' };
|
||||
if (i.retarget) return { ok: false, reason: 'the receipt was reviewed against another base (a retargeted stack)' };
|
||||
if (i.unnamedPaths) return { ok: false, reason: 'a changed product path could not be named, so the packet would miss it' };
|
||||
if (i.rangeDiff === null) return { ok: false, reason: 'git range-diff failed or timed out' };
|
||||
const text = i.rangeDiff.trim();
|
||||
if (!text) return { ok: false, reason: 'git range-diff is empty, so it shows nothing for a reader to check' };
|
||||
const lines = text.split('\n').length;
|
||||
if (lines > RECHECK_RANGE_DIFF_CAP) return { ok: false, reason: `git range-diff is ${lines} lines, over the scoped re-check cap of ${RECHECK_RANGE_DIFF_CAP}` };
|
||||
return { ok: true, lines, sha256: sha(text) };
|
||||
}
|
||||
|
||||
/** The packet the one reader reads: what to judge, the record to write, then the range-diff verbatim. */
|
||||
export function recheckPacket(o: { from: string; head: string; plan: Extract<RecheckPlan, { ok: true }>; rangeDiff: string }): string {
|
||||
return [
|
||||
`# Scoped re-check: the product patch changed since the reviewed head\n`,
|
||||
`reviewed ${o.from}; current ${o.head}. Below is \`git range-diff\` of the reviewed range against the current one, then the diff of the changed product paths between the two heads (${o.plan.lines} lines).`,
|
||||
'Review only what changed between the two ranges, against the blocking floor: would fail a test, break production, or lose data or secrets.',
|
||||
`Reply NO FINDINGS, or one finding per line with path:line, Severity and Blocks. A clean reply is recorded as {"from":"${o.from}","head":"${o.head}","range_diff_sha256":"${o.plan.sha256}","reader":"<model>","verdict":"clean"}.`,
|
||||
'A blocking finding means the full /ship on the current head.\n',
|
||||
'```diff', o.rangeDiff.trim(), '```', '',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface RecheckRecord { from: string; head: string; range_diff_sha256: string; reader: string; verdict: 'clean' }
|
||||
|
||||
/** A clean re-check record bound to this carry's from, head and range-diff, or why it does not stand. */
|
||||
export function readRecheck(file: string, bind: { from: string; head: string; sha256: string }): { ok: true; record: RecheckRecord } | { ok: false; message: string } {
|
||||
let v: Record<string, unknown>;
|
||||
try { v = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { ok: false, message: `${file} is not readable JSON (${(e as Error).message.split('\n')[0]})` }; }
|
||||
if (!v || typeof v !== 'object') return { ok: false, message: `${file} is not a JSON object` };
|
||||
if (v.verdict !== 'clean') return { ok: false, message: `the re-check is ${JSON.stringify(v.verdict)}, not clean: run /ship on ${bind.head.slice(0, 12)}` };
|
||||
if (typeof v.reader !== 'string' || !v.reader.trim()) return { ok: false, message: 'the re-check names no reader' };
|
||||
for (const [k, want] of [['from', bind.from], ['head', bind.head], ['range_diff_sha256', bind.sha256]] as const) {
|
||||
if (v[k] !== want) return { ok: false, message: `the re-check is for ${k} ${String(v[k] ?? 'none').slice(0, 12)}, not ${want.slice(0, 12)}` };
|
||||
}
|
||||
return { ok: true, record: v as unknown as RecheckRecord };
|
||||
}
|
||||
+1
-1
@@ -118,7 +118,7 @@ repo gates run beside CI. Unattended, push defers the pre-push offer; else its S
|
||||
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
|
||||
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
|
||||
- Step 20: a metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
|
||||
## Where each STOP sends you (paths under `<root>/ship/`)
|
||||
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ repo gates run beside CI. Unattended, push defers the pre-push offer; else its S
|
||||
- Step 18: the open-PR lookup; a title file starting `v<NEW_VERSION> `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H.
|
||||
- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report.
|
||||
- Step 20: a metrics row via `gstack-review-log`.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
- Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output.
|
||||
|
||||
## Where each STOP sends you (paths under `<root>/ship/`)
|
||||
|
||||
|
||||
+1
-1
@@ -956,7 +956,7 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -675,7 +675,7 @@ The last line printed is the result line a parent greps (docs/unattended.md):
|
||||
```
|
||||
|
||||
It binds the receipt to HEAD, writes the run record and prints `GSTACK_RESULT: skill=ship
|
||||
status=complete run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
status=complete head=<sha> run=<dir>`; `QUEUE_RECEIPT_STALE` means HEAD moved after the receipt (rewrite,
|
||||
repost, rerun). A run that STOPs earlier ends with `GSTACK_RESULT: skill=ship status=refused run=-`.
|
||||
|
||||
---
|
||||
|
||||
@@ -687,7 +687,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
(all sources, queued Steps 10–11 included) as a JSON array to
|
||||
`.gstack/tmp/review-findings-p$PASS.json` with your file-write tool, each with
|
||||
`raised_by` (`core`, `qa`, a specialist, `red-team`, `adversarial-native`,
|
||||
`outside-voice` or `fix-check`), then:
|
||||
`outside-voice` or `fix-check`) and, for a dispatched reader's finding, `ledger_id`
|
||||
(`<pass dir>/<its merged.jsonl id>`, e.g. `p2/adversarial-native#3`), then:
|
||||
`~/.claude/skills/gstack/bin/gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE --findings .gstack/tmp/review-findings-p$PASS.json --json > .gstack/tmp/review-classified-p$PASS.json`
|
||||
(omit `--range-base` on pass 1). Every Step 9.4 scratch file lives under
|
||||
`.gstack/tmp/` (gitignored): `<classified>` below is this pass's classified
|
||||
@@ -754,7 +755,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
row under its own skill (a tokenless `review` row would read UNVERIFIED):
|
||||
`~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review-late-defer","via":"ship","timestamp":"TIMESTAMP","status":"issues_found","issues_found":0,"critical":0,"informational":0,"pass":PASS,"review_scope":"SCOPE","findings":FINDINGS_JSON}'`
|
||||
with `findings` holding only the deferred records (`action: "deferred"`, `issue`,
|
||||
`locus`, `kind`, `blocking` as in item 6) and no `--finish` token.
|
||||
`locus`, `kind`, `blocking`, `ledger_id` as in item 6) and no `--finish` token. Log it in the
|
||||
program that decides the defer, with that moment's full ISO 8601 TIMESTAMP, never at Step 19.
|
||||
|
||||
4. **Finish and log this pass before choosing the next step.** Apply each A) Restore
|
||||
by reverting only the relaxation. After any fix or restore in this pass, run item
|
||||
@@ -765,7 +767,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
REVIEW_START. Missing dispatched output uses `status:"unavailable"`,
|
||||
`completed:false` and `converged:false`; fixes also require `converged:false`.
|
||||
Then commit named fixed files, if any
|
||||
(`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`).
|
||||
(`git add <fixed-files> && git commit -m "fix: pre-landing review fixes" --trailer "Review-Finding: <ledger_id or fingerprint> reader=<raised_by>"`,
|
||||
one trailer per fixed finding: the receipt counts fixes by it).
|
||||
|
||||
5. Output summary: `Pre-Landing Review: N issues — M auto-fixed, K asked (J fixed, L skipped), D deferred, O noted`
|
||||
|
||||
@@ -804,7 +807,7 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`{"fingerprint":"path:line:category","severity":"CRITICAL|INFORMATIONAL","action":"ACTION"}`.
|
||||
ACTION: `"auto-fixed"`, `"fixed"` (approved), `"skipped"` (explicit Skip),
|
||||
`"deferred"` (with `issue`, the filed number or `null`) or `"noted"`. Copy
|
||||
`locus`, `kind`, `blocking` and `raised_by` from the classified output onto every finding.
|
||||
`locus`, `kind`, `blocking`, `raised_by` and `ledger_id` from the classified output onto every finding.
|
||||
Merge revalidated invocation decisions by identity and advisory/defect kind;
|
||||
preserve `advisory`, `evidence_paths` and `helper_target`.
|
||||
Gate findings add `gate` (RH tag), `gate_id` (the tool's hunk identity), `reason`
|
||||
|
||||
@@ -96,7 +96,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
(all sources, queued Steps 10–11 included) as a JSON array to
|
||||
`.gstack/tmp/review-findings-p$PASS.json` with your file-write tool, each with
|
||||
`raised_by` (`core`, `qa`, a specialist, `red-team`, `adversarial-native`,
|
||||
`outside-voice` or `fix-check`), then:
|
||||
`outside-voice` or `fix-check`) and, for a dispatched reader's finding, `ledger_id`
|
||||
(`<pass dir>/<its merged.jsonl id>`, e.g. `p2/adversarial-native#3`), then:
|
||||
`~/.claude/skills/gstack/bin/gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE --findings .gstack/tmp/review-findings-p$PASS.json --json > .gstack/tmp/review-classified-p$PASS.json`
|
||||
(omit `--range-base` on pass 1). Every Step 9.4 scratch file lives under
|
||||
`.gstack/tmp/` (gitignored): `<classified>` below is this pass's classified
|
||||
@@ -163,7 +164,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
row under its own skill (a tokenless `review` row would read UNVERIFIED):
|
||||
`~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review-late-defer","via":"ship","timestamp":"TIMESTAMP","status":"issues_found","issues_found":0,"critical":0,"informational":0,"pass":PASS,"review_scope":"SCOPE","findings":FINDINGS_JSON}'`
|
||||
with `findings` holding only the deferred records (`action: "deferred"`, `issue`,
|
||||
`locus`, `kind`, `blocking` as in item 6) and no `--finish` token.
|
||||
`locus`, `kind`, `blocking`, `ledger_id` as in item 6) and no `--finish` token. Log it in the
|
||||
program that decides the defer, with that moment's full ISO 8601 TIMESTAMP, never at Step 19.
|
||||
|
||||
4. **Finish and log this pass before choosing the next step.** Apply each A) Restore
|
||||
by reverting only the relaxation. After any fix or restore in this pass, run item
|
||||
@@ -174,7 +176,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
REVIEW_START. Missing dispatched output uses `status:"unavailable"`,
|
||||
`completed:false` and `converged:false`; fixes also require `converged:false`.
|
||||
Then commit named fixed files, if any
|
||||
(`git add <fixed-files> && git commit -m "fix: pre-landing review fixes"`).
|
||||
(`git add <fixed-files> && git commit -m "fix: pre-landing review fixes" --trailer "Review-Finding: <ledger_id or fingerprint> reader=<raised_by>"`,
|
||||
one trailer per fixed finding: the receipt counts fixes by it).
|
||||
|
||||
5. Output summary: `Pre-Landing Review: N issues — M auto-fixed, K asked (J fixed, L skipped), D deferred, O noted`
|
||||
|
||||
@@ -213,7 +216,7 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`{"fingerprint":"path:line:category","severity":"CRITICAL|INFORMATIONAL","action":"ACTION"}`.
|
||||
ACTION: `"auto-fixed"`, `"fixed"` (approved), `"skipped"` (explicit Skip),
|
||||
`"deferred"` (with `issue`, the filed number or `null`) or `"noted"`. Copy
|
||||
`locus`, `kind`, `blocking` and `raised_by` from the classified output onto every finding.
|
||||
`locus`, `kind`, `blocking`, `raised_by` and `ledger_id` from the classified output onto every finding.
|
||||
Merge revalidated invocation decisions by identity and advisory/defect kind;
|
||||
preserve `advisory`, `evidence_paths` and `helper_target`.
|
||||
Gate findings add `gate` (RH tag), `gate_id` (the tool's hunk identity), `reason`
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('E18: a repository without a version source or CHANGELOG', () => {
|
||||
const out = path.join(repo.root, 'run');
|
||||
const result = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out]);
|
||||
expect(result.status).toBe(EXIT.ok);
|
||||
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${out}`);
|
||||
expect(result.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${out}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, 'ship-run.json'), 'utf8')).version).toBe('none');
|
||||
});
|
||||
|
||||
|
||||
@@ -165,9 +165,10 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env);
|
||||
expect(ok.status).toBe(EXIT.ok);
|
||||
const last = ok.out.trim().split('\n').at(-1)!;
|
||||
const m = /^GSTACK_RESULT: skill=ship status=complete run=(.+)$/.exec(last);
|
||||
const m = /^GSTACK_RESULT: skill=ship status=complete head=([0-9a-f]{40}) run=(.+)$/.exec(last);
|
||||
expect(m).not.toBeNull();
|
||||
const dir = m![1]!;
|
||||
expect(m![1]).toBe(head);
|
||||
const dir = m![2]!;
|
||||
expect(dir.startsWith(path.join(state, 'ship', 'runs'))).toBe(true);
|
||||
expect(dir.endsWith(`-${head.slice(0, 12)}`)).toBe(true);
|
||||
const run = JSON.parse(fs.readFileSync(path.join(dir, 'ship-run.json'), 'utf8'));
|
||||
@@ -202,13 +203,13 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
fs.writeFileSync(bodyFile, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
|
||||
const handoff = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(repo.root, 'run-out')], env);
|
||||
expect(handoff.status).toBe(EXIT.ok);
|
||||
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'run-out')}`);
|
||||
expect(handoff.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.root, 'run-out')}`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
|
||||
const relative = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', 'rel-out'], env);
|
||||
expect(relative.status).toBe(EXIT.ok);
|
||||
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.work, 'rel-out')}`);
|
||||
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.work, 'rel-out')}`);
|
||||
expect(fs.existsSync(path.join(repo.work, 'rel-out', 'ship-run.json'))).toBe(true);
|
||||
const blocker = path.join(repo.root, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'file\n');
|
||||
@@ -251,7 +252,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
const pinned = path.join(dir, 'pinned.md');
|
||||
fs.writeFileSync(pinned, renderReceiptBlock({ schema_version: 1, head: 'c'.repeat(40), base: 'main' } as any));
|
||||
const override = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'c'.repeat(40), '--out', path.join(repo.root, 'pinned-out')], env);
|
||||
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'pinned-out')}`]);
|
||||
expect([override.status, override.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete head=${'c'.repeat(40)} run=${path.join(repo.root, 'pinned-out')}`]);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
|
||||
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
|
||||
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
|
||||
@@ -260,7 +261,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
|
||||
expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']);
|
||||
expect(shortHead.err).toContain('--head must be a full hex commit id');
|
||||
const upper = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'C'.repeat(40), '--out', path.join(repo.root, 'upper-out')], env);
|
||||
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'upper-out')}`]);
|
||||
expect([upper.status, upper.out.trim()]).toEqual([EXIT.ok, `GSTACK_RESULT: skill=ship status=complete head=${'c'.repeat(40)} run=${path.join(repo.root, 'upper-out')}`]);
|
||||
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
|
||||
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
|
||||
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
|
||||
@@ -312,7 +313,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(carried.out.trim().split('\n')).toEqual([
|
||||
`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (base merge + test-only change)`,
|
||||
`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.root, 'carry-run')}`,
|
||||
`GSTACK_RESULT: skill=ship status=complete head=${head} run=${path.join(repo.root, 'carry-run')}`,
|
||||
]);
|
||||
expect(carried.err.trim()).toBe(`SHIP_RECEIPT: pr=5 head=${head.slice(0, 12)} version=- base=main passes=2 blocking_found=1 deferred=1 carried_from=${reviewed.slice(0, 12)}`);
|
||||
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
|
||||
|
||||
@@ -413,7 +413,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
expect(evalPlan).toContain('No helper writes a gate.json');
|
||||
expect(evalPlan).not.toContain('gate list');
|
||||
const ship = blocks.ship;
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=refused run=-`');
|
||||
expect(ship).not.toContain('writes no run directory');
|
||||
const records: Record<string, string> = {
|
||||
@@ -437,7 +437,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' });
|
||||
expect(ship.match(line)).toHaveLength(1);
|
||||
expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']);
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete run=<dir>`');
|
||||
expect(ship).toContain('`GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`');
|
||||
expect(ship).toContain("update that PR's body and title with the receipt through the pr-body section's existing-PR path (never pr-create, never a push, and never `gh pr ready`");
|
||||
expect(ship).toContain('the handoff section writes the receipt to .gstack/tmp/ship-receipt.md');
|
||||
for (const skill of ['review', 'qa', 'autoplan']) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* GSTA-258 L4 addendum: the collector raised the native reader's own verdict line to CRITICAL. On #3901 (pass 2, gstack 1.94
|
||||
* branch) "There are three low-severity findings, all FIXABLE, and none blocks." collected as a blocker beside the three
|
||||
* findings it summarizes, each of which already said "Severity: Low. Blocks: no". Excerpt of that captured output below.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseFindings } from '../lib/review-ledger';
|
||||
|
||||
const CAPTURED_3901_P2 = [
|
||||
'REVIEWED_TREE: 7be72149fbd6d1b8e713c7c42ef4cd9ba6373e20 /workspace/gstack',
|
||||
'',
|
||||
'I found no blocking defects in this range. There are three low-severity findings, all FIXABLE, and none blocks.',
|
||||
'',
|
||||
'### Findings',
|
||||
'',
|
||||
'1. **The remote-gate route hint ignores `--no-ci-carry`** (`lib/pregate/checks/lanes.ts`, the `route` condition). The hint prints whenever `remote_gate_runs` is declared. FIXABLE. Severity: Low. Blocks: no.',
|
||||
'',
|
||||
'Recommendation: Ship as-is.',
|
||||
].join('\n');
|
||||
|
||||
const labels = (out: string, reader = 'adversarial-native') => parseFindings(reader, out, true).map(f => f.label);
|
||||
|
||||
describe('native verdict line (collector severity inflation)', () => {
|
||||
test('the captured #3901 verdict line stays FIXABLE; the finding keeps its own Severity: Low', () => {
|
||||
expect(labels(CAPTURED_3901_P2)).toEqual(['FIXABLE', 'Low']);
|
||||
});
|
||||
|
||||
test('negative controls: a line that says something blocks, names a file:line, or leads with FIXABLE is still CRITICAL', () => {
|
||||
expect(labels('Three findings are FIXABLE, and one blocks.')).toEqual(['CRITICAL']);
|
||||
expect(labels('The token leak in log.ts:3 is FIXABLE, and none of the others blocks.')).toEqual(['CRITICAL']);
|
||||
expect(labels('FIXABLE: none blocks the merge but the cache key drops the tree.')).toEqual(['CRITICAL']);
|
||||
expect(labels('- FIXABLE, and nothing blocks')).toEqual(['CRITICAL']);
|
||||
});
|
||||
|
||||
test('a stated Blocks field on the line wins: Blocks: yes stays CRITICAL', () => {
|
||||
expect(labels('All FIXABLE, none blocks. Blocks: yes')).toEqual(['CRITICAL']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* GST-1168: the receipt's review counts bind by finding id. Each case is a shape a real /ship run produced
|
||||
* (GBEV-3 gbrain-evals#310, GEVA-223 gstack-evals#174, #3906 at 9417c7d, #3892/#3903/#3911); before the fix
|
||||
* the summary paired rows and ledgers by path:line only and dropped rows it could not place without a word.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseFixTrailers, reviewLoopLine, summarizeReviewLoop, type LedgerPass } from '../lib/review-scope';
|
||||
import type { LedgerBlocker } from '../lib/review-ledger';
|
||||
|
||||
const SINCE = '2026-11-02T10:00:00.000Z';
|
||||
const at = (min: number) => new Date(Date.parse(SINCE) + min * 60_000).toISOString();
|
||||
const row = (skill: string, min: number, findings: Record<string, unknown>[], extra: Record<string, unknown> = {}) =>
|
||||
({ skill, via: 'ship', review_scope: 'frozen', review_binding: { started_at: at(min) }, findings, ...extra });
|
||||
const blocker = (pass: string, id: string, locus: string | null): LedgerBlocker => ({ id, locus, key: `${pass}/${id}` });
|
||||
const pass = (min: number, ids: string[], fixCheck = false): LedgerPass => ({ started: Date.parse(at(min)), fixCheck, ids });
|
||||
|
||||
describe('review loop counts bind by finding id (GST-1168)', () => {
|
||||
test('GBEV-3: a deferred finding logged late counts once and a fix-check never marks it fixed', () => {
|
||||
const rows = [
|
||||
row('review', 1, [{ fingerprint: 'src/a.ts:10:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'adversarial-native', ledger_id: 'p1/adversarial-native#1' }]),
|
||||
row('review-late-defer', 30, [{ fingerprint: 'src/b.ts:20:security', severity: 'CRITICAL', action: 'deferred', issue: 311, blocking: true, raised_by: 'adversarial-native', ledger_id: 'p1/adversarial-native#2' }]),
|
||||
];
|
||||
const ledgers = [[blocker('p1', 'adversarial-native#1', 'src/a.ts:10'), blocker('p1', 'adversarial-native#2', 'src/b.ts:21')], []];
|
||||
const s = summarizeReviewLoop(rows, SINCE, ledgers, [pass(0, ['p1/adversarial-native#1', 'p1/adversarial-native#2']), pass(20, ['fc/fix-check#1'], true)]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, deferred: s.deferred, unrecorded: s.unrecorded }).toEqual({ found: 2, fixed: 1, deferred: 1, unrecorded: undefined });
|
||||
});
|
||||
|
||||
test('GEVA-223: native findings with no path:line pair with their rows by ledger_id', () => {
|
||||
const ids = Array.from({ length: 9 }, (_, i) => `adversarial-native#${i + 1}`);
|
||||
const findings = [
|
||||
...ids.map((id, i) => ({ fingerprint: `lib/x.ts:${10 * (i + 1)}:correctness`, severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'adversarial-native', ledger_id: `p1/${id}` })),
|
||||
...Array.from({ length: 6 }, (_, i) => ({ fingerprint: `lib/y.ts:${i + 1}:style`, severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by: 'specialist' })),
|
||||
];
|
||||
const s = summarizeReviewLoop([row('review', 1, findings)], SINCE, [ids.map(id => blocker('p1', id, null))], [pass(0, ids.map(id => `p1/${id}`))]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, noted: s.noted, unrecorded: s.unrecorded }).toEqual({ found: 9, fixed: 9, noted: 6, unrecorded: undefined });
|
||||
});
|
||||
|
||||
test('#3906: fixes named by Review-Finding trailers count even when no row recorded the findings', () => {
|
||||
const ledger = [1, 2, 3].map(n => blocker('p1', `red-team#${n}`, `lib/auth.ts:${25 + 15 * n}`));
|
||||
const trailers = parseFixTrailers('9417c7d0000000000000000000000000000000000', ['p1/red-team#1 reader=red-team', 'p1/red-team#2 reader=red-team', 'p1/red-team#3 reader=red-team']);
|
||||
const s = summarizeReviewLoop([row('review', 1, [])], SINCE, [ledger], [pass(0, ledger.map(b => b.key!))], trailers);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, unrecorded: s.unrecorded }).toEqual({ found: 3, fixed: 3, unrecorded: undefined });
|
||||
expect(reviewLoopLine(s)).toContain('fixed=3');
|
||||
});
|
||||
|
||||
test('#3892/#3903/#3911: a row with no start token and no readable time is named unbound, never dropped silently', () => {
|
||||
const tokenless = { skill: 'review', via: 'ship', pass: 2, timestamp: 'TIMESTAMP', findings: [{ fingerprint: 'lib/q.ts:7:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true }] };
|
||||
const s = summarizeReviewLoop([row('review', 1, []), tokenless], SINCE);
|
||||
expect(s.unbound).toEqual(['row review pass=2: no start token and no readable timestamp, so it cannot be placed in this run']);
|
||||
expect(reviewLoopLine(s)).toContain('unbound=1');
|
||||
});
|
||||
|
||||
test('a ledger_id no ledger collected and a trailer naming nothing are unbound with their names', () => {
|
||||
const rows = [row('review', 1, [{ fingerprint: 'lib/z.ts:3:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'specialist', ledger_id: 'p9/specialist#4' }])];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[]], [pass(0, [])], parseFixTrailers('abc1234def567', ['p1/targeted#2 reader=targeted', 'no-reader-field']));
|
||||
expect(s.unbound).toEqual([
|
||||
'ledger_id p9/specialist#4: no dispatch ledger of this run collected it',
|
||||
"trailer abc1234def56 Review-Finding: p1/targeted#2 reader=targeted: names no finding this run's rows or ledgers hold",
|
||||
]);
|
||||
});
|
||||
|
||||
test('#3911 at 4340ed4c: a fix-check row written twice counts once, and pass 1 with no row is named', () => {
|
||||
const fc = { skill: 'review-fix-check', via: 'ship', pass: 2, commit: '4340ed4c', review_binding: { started_at: at(40) }, findings: [{ fingerprint: 'lib/p.ts:9:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'fix-check', ledger_id: 'fc/fix-check#1' }] };
|
||||
const rows = [row('review', 20, [], { pass: 2, commit: '1111111' }), fc, { ...fc }];
|
||||
const passes = [{ ...pass(0, []), name: 'p1' }, { ...pass(20, []), name: 'p2' }, { ...pass(40, ['fc/fix-check#1'], true), name: 'fc' }];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[], [], [blocker('fc', 'fix-check#1', 'lib/p.ts:9')]], passes);
|
||||
expect({ passes: s.passes, fix_checks: s.fix_checks, found: s.blocking_found, fixed: s.fixed }).toEqual({ passes: 1, fix_checks: 1, found: 1, fixed: 1 });
|
||||
expect(s.unbound).toEqual(['pass 1 (ledger p1): no review-log row, so its fixes and dispositions cannot be counted']);
|
||||
});
|
||||
|
||||
test('rows without ledger_id keep pairing by path:line, as before', () => {
|
||||
const rows = [row('review', 1, [{ fingerprint: 'src/a.ts:10:correctness', severity: 'CRITICAL', action: 'fixed', blocking: true, raised_by: 'specialist' }])];
|
||||
const s = summarizeReviewLoop(rows, SINCE, [[blocker('p1', 'specialist/security#1', 'src/a.ts:10')]]);
|
||||
expect({ found: s.blocking_found, fixed: s.fixed, unrecorded: s.unrecorded, unbound: s.unbound }).toEqual({ found: 1, fixed: 1, unrecorded: undefined, unbound: undefined });
|
||||
});
|
||||
});
|
||||
@@ -223,7 +223,8 @@ describe('loop summary', () => {
|
||||
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
|
||||
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
|
||||
];
|
||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3 });
|
||||
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3,
|
||||
unbound: ['row review pass=?: no start token and no readable timestamp, so it cannot be placed in this run'] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* GST-1179: gstack-ship-receipt's terminal line names the full head it bound, so a leftover line from an earlier ship can't
|
||||
* pass for a new head (harness-evals' merge lane requires it from 2026-11-09T08:00Z).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, parseResult } from '../lib/headless-artifacts';
|
||||
import { renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
describe('GSTACK_RESULT head= token (GST-1179)', () => {
|
||||
test('result prints head=<full sha> between status and run, and parseResult reads it', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'ship-result-head-' }); repos.push(repo);
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, renderReceiptBlock({ schema_version: 1, head, base: 'main' } as any));
|
||||
const out = path.join(repo.root, 'run');
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['result', '--body', body, '--out', out], { GSTACK_STATE_ROOT: path.join(repo.root, 'state') });
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const line = r.out.trim().split('\n').at(-1)!;
|
||||
expect(line).toBe(`GSTACK_RESULT: skill=ship status=complete head=${head} run=${out}`);
|
||||
expect(head).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(parseResult(r.out)).toEqual({ skill: 'ship', status: 'complete', head, run: out });
|
||||
});
|
||||
|
||||
test('a line without head= still parses, with no head', () => {
|
||||
expect(parseResult('GSTACK_RESULT: skill=pregate status=complete run=.gstack/tmp/pregate.json')).toEqual({ skill: 'pregate', status: 'complete', run: '.gstack/tmp/pregate.json' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* GSTA-258 L4 receipt attribution: a PR body keeps every /ship run's receipt (append-only runs block), and the receipt
|
||||
* schema carries K1's no_stop records for L5.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { extractReceipt, orderReceipt, placeReceiptFirst, readyTailLines, receiptRuns, RECEIPT_RUNS_KEPT, validateReceipt } from '../lib/ship-receipt';
|
||||
import type { ShipReceipt } from '../lib/headless-artifacts';
|
||||
|
||||
const receipt = (head: string, loop: Partial<NonNullable<ShipReceipt['review_loop']>> = {}): ShipReceipt =>
|
||||
orderReceipt({ schema_version: 1, head, base: 'main', version: '1.94.1.0', review_loop: { passes: 1, blocking_found: 0, deferred: 0, ...loop } });
|
||||
|
||||
describe('receipt runs block', () => {
|
||||
test('a write over a different receipt keeps the old run, oldest first, after the current block', () => {
|
||||
let body = placeReceiptFirst('## Summary\nprose\n', receipt('a'.repeat(40), { passes: 2, blocking_found: 3, fixed: 3 }));
|
||||
body = placeReceiptFirst(body, receipt('b'.repeat(40)));
|
||||
body = placeReceiptFirst(body, receipt('c'.repeat(40), { passes: 1 }));
|
||||
expect(extractReceipt(body).receipt!.head).toBe('c'.repeat(40));
|
||||
expect(receiptRuns(body).map(r => [r.head[0], r.review_loop?.blocking_found])).toEqual([['a', 3], ['b', 0]]);
|
||||
expect(body.indexOf('gstack-receipt-runs')).toBeLessThan(body.indexOf('## Summary'));
|
||||
expect(body.match(/## Summary/g)!.length).toBe(1);
|
||||
});
|
||||
|
||||
test('rewriting the same receipt adds no run, and the block keeps the newest RECEIPT_RUNS_KEPT', () => {
|
||||
const same = receipt('d'.repeat(40));
|
||||
expect(receiptRuns(placeReceiptFirst(placeReceiptFirst('', same), same))).toEqual([]);
|
||||
let body = '';
|
||||
for (let i = 0; i < RECEIPT_RUNS_KEPT + 5; i++) body = placeReceiptFirst(body, receipt(i.toString(16).padStart(40, '0')));
|
||||
const runs = receiptRuns(body);
|
||||
expect(runs.length).toBe(RECEIPT_RUNS_KEPT);
|
||||
expect(runs.at(-1)!.head).toBe((RECEIPT_RUNS_KEPT + 3).toString(16).padStart(40, '0'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('READY tail (E1: the ready output is the whole READY)', () => {
|
||||
const depth = (cls: 'docs' | 'code' | 'gate', risk?: Array<'money' | 'auth' | 'secret' | 'security' | 'data'>) =>
|
||||
({ depth: 'full' as const, class: cls, runtime_lines: 10, rules: 'r', base: 'b', registry: null, gate_scan: { tagged: 0, head: 'h' }, reasons: [], ...(risk ? { risk } : {}) });
|
||||
test('a risk-class receipt asks for the cross-family read; any other PR is self-audited', () => {
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('gate') })[3]).toStartWith('reviewed by <model on another family> (risk class: gate;');
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('code', ['money']) })[3]).toContain('risk class: money');
|
||||
expect(readyTailLines({ ...receipt('f'.repeat(40)), review_depth: depth('docs') })[3]).toBe('self-audited (not a risk class: no cross-family trail read)');
|
||||
});
|
||||
test('the review loop line carries fixed, unrecorded and unbound from the receipt', () => {
|
||||
const lines = readyTailLines(receipt('f'.repeat(40), { passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, unbound: 1 }));
|
||||
expect(lines[0]).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=0 noted=0 fixed=2 unrecorded=1 unbound=1');
|
||||
expect(lines.slice(1, 3)).toEqual([expect.stringMatching(/^TRIAL_MERGES: /), 'Attention:']);
|
||||
expect(readyTailLines(undefined)[0]).toBe('REVIEW_LOOP: none in the receipt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no_stop receipt field (K1)', () => {
|
||||
test('a record per routine stop validates; an unknown class or a missing field does not', () => {
|
||||
const ok = orderReceipt({ ...receipt('e'.repeat(40)), no_stop: [{ class: 'pregate-flake-rerun', reason: 'one rerun of test/x.test.ts at the same tree', evidence: 'run 41562764513' }] });
|
||||
expect(validateReceipt(ok)).toEqual([]);
|
||||
expect(Object.keys(ok).at(-1)).toBe('no_stop');
|
||||
expect(validateReceipt({ ...ok, no_stop: [{ class: 'merge-anyway', reason: 'x', evidence: 'y' }] } as any).length).toBeGreaterThan(0);
|
||||
expect(validateReceipt({ ...ok, no_stop: [{ class: 'stamp-only-carry', reason: 'x' }] } as any).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* J1 (GSTA-258 L4): a scoped re-check after a refused receipt carry. Each refusal reason that does or does not qualify,
|
||||
* the record binding, and the carry end to end: a bounded product change prints RECHECK: eligible and writes the packet,
|
||||
* and a clean record bound to that range-diff carries the receipt with the reader named.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt';
|
||||
import { planRecheck, readRecheck, RECHECK_RANGE_DIFF_CAP } from '../lib/ship-recheck';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
const RANGE = '1: aaaaaaa ! 1: bbbbbbb feat: a=2\n @@ src/app.ts\n -export const a = 2;\n +export const a = 3;';
|
||||
|
||||
describe('planRecheck: which refusals qualify', () => {
|
||||
test('a bounded product change on linear history against the same base qualifies', () => {
|
||||
const p = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE });
|
||||
expect(p).toMatchObject({ ok: true, lines: 4 });
|
||||
});
|
||||
test.each([
|
||||
['tree mismatch', { code: 'RECEIPT_CARRY_TREE_MISMATCH', ancestor: false, retarget: false, rangeDiff: RANGE }, 'is not a product change'],
|
||||
['refused inputs', { code: 'RECEIPT_CARRY_REFUSED', ancestor: true, retarget: false, rangeDiff: RANGE }, 'is not a product change'],
|
||||
['stale pregate', { code: 'RECEIPT_CARRY_PREGATE_STALE', ancestor: true, retarget: false, rangeDiff: RANGE }, 'is not a product change'],
|
||||
['rewritten history', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: false, retarget: false, rangeDiff: RANGE }, 'rewritten history'],
|
||||
['retargeted stack', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: true, rangeDiff: RANGE }, 'another base'],
|
||||
['unnamed product path', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE, unnamedPaths: true }, 'could not be named'],
|
||||
['range-diff failed', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: null }, 'failed or timed out'],
|
||||
['empty range-diff', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: '\n' }, 'empty'],
|
||||
['over the cap', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: Array.from({ length: RECHECK_RANGE_DIFF_CAP + 1 }, (_, i) => `l${i}`).join('\n') }, 'over the scoped re-check cap'],
|
||||
])('%s does not qualify', (_name, input, why) => {
|
||||
const p = planRecheck(input);
|
||||
expect(p.ok).toBe(false);
|
||||
expect(!p.ok && p.reason).toContain(why);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecheck binds to from, head and the range-diff', () => {
|
||||
test('only a clean record for this exact range-diff stands', () => {
|
||||
const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'recheck-'));
|
||||
const bind = { from: 'a'.repeat(40), head: 'b'.repeat(40), sha256: 'c'.repeat(64) };
|
||||
const write = (o: object) => { const f = path.join(dir, `${Math.random()}.json`); fs.writeFileSync(f, JSON.stringify(o)); return f; };
|
||||
const good = { from: bind.from, head: bind.head, range_diff_sha256: bind.sha256, reader: 'anthropic/claude-opus-5-5', verdict: 'clean' };
|
||||
expect(readRecheck(write(good), bind).ok).toBe(true);
|
||||
expect(readRecheck(write({ ...good, verdict: 'blocking' }), bind)).toMatchObject({ ok: false, message: expect.stringContaining('not clean') });
|
||||
expect(readRecheck(write({ ...good, range_diff_sha256: 'd'.repeat(64) }), bind)).toMatchObject({ ok: false, message: expect.stringContaining('range_diff_sha256') });
|
||||
expect(readRecheck(write({ ...good, reader: '' }), bind)).toMatchObject({ ok: false, message: 'the re-check names no reader' });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('carry with a scoped re-check (CLI)', () => {
|
||||
test('a bounded product change prints RECHECK: eligible, keeps the refusal, and a clean record carries with the reader named', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'ship-recheck-' }); repos.push(repo);
|
||||
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n');
|
||||
repo.git('commit', '-qam', 'feat: a=2');
|
||||
const reviewed = repo.git('rev-parse', 'HEAD');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
|
||||
repo.git('commit', '-qam', 'fix: a=3');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', review_loop: { passes: 1, blocking_found: 0, deferred: 0 } } as any));
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
|
||||
expect(refused.status).toBe(EXIT.refused);
|
||||
expect(refused.out.trim().split('\n')[0]).toBe('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)');
|
||||
const m = /^RECHECK: eligible range_diff_lines=(\d+) range_diff_sha256=([0-9a-f]{64}) packet=(\S+);/m.exec(refused.err);
|
||||
expect(m).not.toBeNull();
|
||||
expect(fs.readFileSync(m![3]!, 'utf8')).toContain('export const a = 3;');
|
||||
const record = path.join(repo.root, 'recheck.json');
|
||||
fs.writeFileSync(record, JSON.stringify({ from: reviewed, head, range_diff_sha256: m![2], reader: 'anthropic/claude-opus-5-5', verdict: 'clean' }));
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', record, '--out', path.join(repo.root, 'run')], env);
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(carried.out.trim().split('\n')[0]).toBe(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (scoped re-check by anthropic/claude-opus-5-5 (range-diff ${m![1]} lines))`);
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt).toMatchObject({ head, carry: { from: reviewed } });
|
||||
const stale = path.join(repo.root, 'stale.json');
|
||||
fs.writeFileSync(stale, JSON.stringify({ from: reviewed, head, range_diff_sha256: 'f'.repeat(64), reader: 'x', verdict: 'clean' }));
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 4;\n');
|
||||
repo.git('commit', '-qam', 'fix: a=4');
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', stale], env).status).toBe(EXIT.refused);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 412,
|
||||
"why": "GSTA-258 L4: review fixes carry ledger_id and a Review-Finding trailer, late defers log at Step 11 (GST-1168), the result line names head= (GST-1179)"
|
||||
}
|
||||
Reference in new issue
Block a user