Files
gstack/test/ship-control-flow.test.ts
Garry Tanandcapy-ai[bot] 5225e35cc6 v1.92.42.0 fix(ship): notes-only review passes bind; carry handles large diffs; scoped carry route (GST-430, GST-448, GST-442, GST-444) (#3694)
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship driver: trim two phrases for headroom with #3662

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: regenerate ship goldens

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pass-2 review fixes

A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251)

gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: no test edit skips review; guard packet writes; exclusive attempt state

A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: attempt prints gstack's install command; carry checks the product diff outside context paths

The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs

A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(unattended): carry's scoped-review refusal and what it keeps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals

- A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the
  blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account
  for the claimed issue and critical counts (GST-430).
- The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442).
- patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448).
- Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: pin the Step 16 --docs-requested route with its --docs-result

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept

- deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot
  dispose of a gate finding the unattended logger forces open (outside-voice#1).
- reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses;
  noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL.
- patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT

- isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition.
- reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so
  interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: restore bin/gstack-ship-receipt after the main merge

The merge commit b10e2db wrote a truncated copy of the file. This is the
three-way merge of both sides with the refused() union: main's codes plus
RECEIPT_CARRY_SCOPED_REVIEW.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship: trim a Step 3 sentence so the skeleton fits maxSkeletonBytes with train 77

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship: keep the no-edit rule in the trimmed Step 3 sentence; regenerate the ship goldens

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 21:26:59 +00:00

370 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { generateAdversarialStep } from '../scripts/resolvers/outside-voice-steps';
import { generatePlanCompletionGateShip } from '../scripts/resolvers/plan-gates';
import { generateReviewDashboard } from '../scripts/resolvers/review-dashboard';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { expectMentions } from './helpers/prompt-structure';
const read = (file: string) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8');
const compact = (text: string) => text.replace(/\s+/g, ' ');
const entry = read('ship/SKILL.md.tmpl');
const start = entry.indexOf('### Ship control flow');
const end = entry.indexOf('{{SECTION_INDEX:ship}}');
const controller = entry.slice(start, end);
const review = compact(read('ship/sections/review-army.md.tmpl'));
const adversarial = compact(generateAdversarialStep({ host: 'claude', skillName: 'ship', tmplPath: '', paths: HOST_PATHS.claude }));
const gate = compact(entry.slice(entry.indexOf('## Step 16:'), entry.indexOf('## Step 17:')));
describe('ship source controller', () => {
test('documentation freshness covers selected code paths as well as release metadata', () => {
const docs = compact(read('ship/sections/documentation.md.tmpl'));
// Meaning: the snapshot hashes selected release (code) paths, not only release metadata.
expect(docs).toContain('hashes of release paths, generated outputs and docs');
expect(docs).toContain('`--select <path>` narrows release paths');
const freshness = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
expectMentions(freshness, [['never', 'qualifies', 'decision']], 'freshness');
});
test('changed documentation inputs permit only the remaining bounded re-audit', () => {
const docs = compact(read('ship/sections/documentation.md.tmpl'));
expect(docs.indexOf('## Blocked recovery')).toBeGreaterThan(-1);
expect(docs).toMatch(/never a third attempt/i);
const freshness = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
expect(freshness).toContain('**An attempt remains, with changed inputs or an available repair:**');
expect(freshness).toMatch(/\*\*Otherwise:\*\* stop unless the user accepts/i);
expect(freshness).not.toContain('**No attempt remains or no repair is available:**');
});
test('the review plan drives the repeat, the fix-check binds under its own skill, and Step 11.5 accepts a fix-check or a planned adversarial skip', () => {
const decide = review.slice(review.indexOf('### Decide whether to repeat Step 9'), review.indexOf('**Required-probe parent gate:**'));
for (const flag of ['--fixed-blocking', '--fixed-below', '--blocking-open', '--touched-gate', '--critical-found', '--adversarial-ran', '--reviewed-numbers <fp>']) expect(decide).toContain(flag);
expect(decide).toContain('its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4.');
expect(decide).toContain('`gstack-review-log --start review-fix-check` token');
expect(decide).toContain('`"skill":"review-fix-check"` with `--finish <token>`');
expect(compact(read('ship/sections/adversarial.md.tmpl'))).toContain("Step 9's last full-pass plan governs this step");
const bind = compact(entry.slice(entry.indexOf('## Step 11.5:'), entry.indexOf('## Step 12:')));
expect(bind).toContain("last 3.5 `review-fix-check`");
expect(bind).toContain('none after `ADVERSARIAL: skipped`');
expect(bind).toContain('`review_freshness` `CURRENT`');
});
test('Step 1 carries a receipt only after a dry run and a pregate at HEAD', () => {
const carry = compact(entry.slice(entry.indexOf('4. **Carry check.**'), entry.indexOf('5. Display historical readiness')));
const order = ['carry --pr <n> --base <base> --dry-run', '`CARRY: would carry`', 'skip review', '`gstack-pregate --allow-repo-commands`', 'carry without `--dry-run`', '`--pregate .gstack/tmp/pregate.json`', '`CARRY: carried` binds HEAD', 'A failed check, HEAD moving after pregate, `current`, or `refused` with no `NEXT: carry route` re-runs Steps 2–16.'];
const at = order.map(t => carry.indexOf(t));
expect(at.every(i => i >= 0)).toBe(true);
expect(at).toEqual([...at].sort((a, b) => a - b));
});
test('repairs use one ordered work list without a return stack', () => {
const text = compact(controller);
expect(compact(entry)).toContain('**Next steps:**');
expect(text).toContain('5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5');
expect(text).toMatch(/initial-plus-one limit never resets/i);
});
test('the roadmap and recovery groups define ownership and zero-edit eligibility', () => {
const text = compact(entry);
expect(text).toContain('integrate (1–3) → test and review (4–11.5) → prepare the release (12–15) → verify frozen content (16) → push and publish (17–21)');
expectMentions(text, [['not', 'permission', 'children']], 'text');
expect(review).toContain('**No edits in this pass:**');
expectMentions(review, [['only', 'continue', 'clears']], 'review');
expectMentions(review, [['block', 'failed/unavailable', 'continuation']], 'review');
expectMentions(review, [['cannot', 'test/security', 'independent']], 'review');
expectMentions(text, [['never', 'approvals', 'expands']], 'text');
expect(text).not.toContain('eligible zero-edit pass');
expect(text).not.toContain('the same waiver');
expect(text).not.toContain("the controller's detour");
});
test('distribution discovery is a shortlist, not a manifest-only artifact decision', () => {
const distribution = compact(entry.slice(entry.indexOf('## Step 2:'), entry.indexOf('## Step 3:')));
expect(distribution).toContain('**New artifact without a pipeline');
expect(distribution).toContain('AskUserQuestion');
expect(distribution).toMatch(/do not publish a release during `\/ship`/i);
});
test('queue qualification exhaustively distinguishes online, git fallback and unusable output', () => {
const version = compact(entry.slice(entry.indexOf('## Step 12:'), entry.indexOf('{{SECTION:changelog}}')));
const qualify = version.indexOf('**Qualify first:**');
const usable = version.indexOf('**Usable candidate:**');
const missing = version.indexOf('**No usable candidate:**');
expect(qualify).toBeGreaterThan(0);
expect(usable).toBeGreaterThan(qualify);
expect(missing).toBeGreaterThan(usable);
expect(version).toContain('`NEW_VERSION=CANDIDATE_VERSION`');
expectMentions(version, [['only', 'approval', 'existing']], 'version');
expect(version).toContain('`>= NEW_VERSION`');
expect(version.slice(missing)).toContain('`BUMP_LEVEL`');
expect(version.slice(missing)).toContain('`currentVersion`');
});
test.each(['home', 'override', 'plugin'])('the actual nudge shell honors %s roots, repeat suppression and enabled tuning', mode => {
const root = mkdtempSync(join(tmpdir(), 'gstack-ship-nudge-'));
try {
const home = join(root, 'home');
const state = mode === 'home' ? join(home, '.gstack') : join(root, 'state root');
mkdirSync(home, { recursive: true });
const nudge = entry.slice(entry.indexOf('## Step 21:'), entry.indexOf('## Section self-check'));
const block = nudge.match(/```bash\n([\s\S]*?)\n```/);
expect(block).not.toBeNull();
const script = block![1].replaceAll('~/.claude/skills/gstack', JSON.stringify(resolve(import.meta.dir, '..')));
const run = () => spawnSync('bash', ['-c', script], {
cwd: root,
env: {
PATH: process.env.PATH,
HOME: home,
TMPDIR: join(root, 'tmp'),
...(mode === 'override' ? { GSTACK_HOME: state } : {}),
...(mode === 'plugin' ? { CLAUDE_PLUGIN_ROOT: join(root, 'gstack'), CLAUDE_PLUGIN_DATA: state } : {}),
},
encoding: 'utf8',
timeout: 10_000,
});
const marker = join(state, '.plan-tune-nudge-shown');
const first = run();
expect(first.status).toBe(0);
expect(first.stdout).toContain('Run /plan-tune to opt in');
expect(existsSync(marker)).toBe(true);
if (mode !== 'home') expect(existsSync(join(home, '.gstack', '.plan-tune-nudge-shown'))).toBe(false);
const repeated = run();
expect(repeated.status).toBe(0);
expect(repeated.stdout).toBe('');
rmSync(marker);
writeFileSync(join(state, 'config.yaml'), 'question_tuning: true\n');
const enabled = run();
expect(enabled.status).toBe(0);
expect(enabled.stdout).toBe('');
expect(existsSync(marker)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('nested repairs resume the unfinished outer range before its destination', () => {
const text = compact(controller);
expect(text).toContain('`9 → 10 → 11` before 11.5');
expect(text).toContain('`5 → 6 → 7 → 8 → 9 → 10 → 11 → 11.5`');
expect(text).not.toContain('Enter Step 9 before REVIEW_START capture and full review');
});
test('shared foreground dispatch stays mandatory at all three owned dispatch sites', () => {
const coverage = read('ship/sections/test-coverage.md.tmpl');
const shared = coverage.indexOf('### Shared subagent dispatch');
expect(shared).toBeGreaterThan(0);
expect(shared).toBeLessThan(coverage.indexOf('Dispatch the audit through Agent'));
const contract = compact(coverage.slice(shared, coverage.indexOf('Dispatch the audit through Agent')));
expect(contract).toContain('use the Agent tool with `run_in_background: false`');
expectMentions(contract, [['do not', 'instead', 'invoke']], 'contract');
for (const section of ['test-coverage', 'plan-completion', 'greptile']) {
const text = read(`ship/sections/${section}.md.tmpl`);
expect(text).toContain('shared foreground-dispatch rule');
expect(text).toContain('run_in_background: false');
expect(text).not.toContain('{{FOREGROUND_DISPATCH_NOTE}}');
}
expect(entry).not.toContain('### Shared subagent dispatch');
for (const section of ['plan-completion', 'greptile']) {
expect(read(`ship/sections/${section}.md.tmpl`)).toContain("Step 7's shared foreground-dispatch rule");
}
});
test('ship dashboard displays actual historical records without a repeated sample panel', () => {
const ctx = { host: 'claude' as const, skillName: 'ship', tmplPath: '', paths: HOST_PATHS.claude };
const ship = generateReviewDashboard(ctx);
expect(ship).toContain('REVIEW READINESS DASHBOARD');
expect(ship).toContain('| Review | Runs | Last run | Status | Required |');
expectMentions(ship, [['only', 'required', 'review']], 'ship');
expect(ship).toContain('{actual status and reason}');
expect(ship).toContain('VERDICT: {CLEARED or NOT CLEARED} — {reason}');
expect(ship).not.toContain('+====================================================================+');
const review = generateReviewDashboard({ ...ctx, skillName: 'review' });
expect(review).toContain('+====================================================================+');
});
test('coverage fallback settles the child and still applies the coverage gate', () => {
const text = compact(read('ship/sections/test-coverage.md.tmpl'));
const fallback = text.slice(text.indexOf('**Audit failure:**'), text.indexOf('{{TEST_COVERAGE_GATE_SHIP}}'));
expectMentions(fallback, [['confirm', 'stopped', 'running']], 'fallback');
expectMentions(fallback, [['does not', 'coverage', 'bypass']], 'fallback');
expect(text).not.toContain('partial results are better than none');
});
test('no-plan gate skips only the audit and retains verification and the remaining section', () => {
const text = generatePlanCompletionGateShip({ host: 'claude', skillName: 'ship', tmplPath: '', paths: HOST_PATHS.claude });
const noPlan = compact(text.slice(text.indexOf('**No plan file found:**')));
expectMentions(noPlan, [['only', 'completion', 'audit']], 'noPlan');
expect(noPlan).toContain('Step 8.1');
expect(noPlan).toMatch(/Step 9 QA still runs/i);
expect(noPlan).not.toContain('Skip entirely');
});
test('binding separates record selection, three snapshots and probe outcomes', () => {
const text = compact(entry.slice(entry.indexOf('## Step 11.5:'), entry.indexOf('## Step 12:')));
const steps = ['1. **Select', '2. **Compare', '3. **Preserve', '4. **Save'];
const positions = steps.map(step => text.indexOf(step));
expect(positions.every(position => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a,b) => a-b));
expectMentions(text, [['must', 'snapshots', 'three']], 'text');
expectMentions(text, [['does not', 'failed', 'probes']], 'text');
});
test('late classification and docs freshness are ordered decisions, not metadata assumptions', () => {
const text = compact(entry.slice(entry.indexOf('## Step 16:'), entry.indexOf('## Step 17:')));
expect(text).toContain('**Behavior, tests or build inputs changed:**');
expect(text).toContain('**Only authored docs or release metadata changed:**');
expect(text).toContain('**No changes, or the docs-only checks still support the plan:**');
expect(text).toContain('| Continue to stage 4');
expect(text).not.toContain('Every listed change below is metadata:');
});
test('one early controller owns detours without replacing local gates', () => {
expect(start).toBeGreaterThan(entry.indexOf('# Ship:'));
expect(start).toBeLessThan(end);
expect(end).toBeLessThan(entry.indexOf('{{BASE_BRANCH_DETECT}}'));
expect(entry.match(/### Ship control flow/g)).toHaveLength(1);
const text = compact(controller);
expect(text).toMatch(/the \*\*parent\*\* running \/ship, own advancement/i);
expectMentions(text, [['stop', 'askuserquestion', 'repairs']], 'text');
expectMentions(text, [['never', 'unlisted', 'range']], 'text');
expectMentions(text, [['does not', 'ending', 'range']], 'text');
expect(text).not.toContain('| At step |');
expect(entry.match(/Ship control flow/g)).toHaveLength(1);
expect(review).toMatch(/captures a fresh REVIEW_START/);
});
test('distribution and bootstrap detours end at their exact forward resume', () => {
const merge = compact(entry.slice(entry.indexOf('## Step 3:'), entry.indexOf('{{SECTION:tests}}')));
const tests = compact(read('ship/sections/tests.md.tmpl'));
expect(tests).toContain('A) Add tests (recommended)');
expectMentions(tests, [['not', 'declining', 'bootstrap']], 'tests');
});
test('missing dispatched output outranks the cycle cap, fixes and zero-edit continuation', () => {
const decisions = review.slice(review.indexOf('### Decide whether to repeat Step 9'));
const names = ['1. **Dispatched reviewer output missing:**', '2. **Third fixing cycle reached (`CYCLES >= 3`):**', '3. **Fixes applied below the cap:**', '4. **No edits in this pass:**'];
const positions = names.map(name => decisions.indexOf(name));
expect(positions.every(position => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
const missing = decisions.slice(positions[0], positions[1]);
expect(missing).toMatch(/^1\. \*\*Dispatched reviewer output missing:\*\* stop\b/i);
expectMentions(missing, [['cannot', 'dispatched', 'coverage']], 'missing');
const cap = decisions.slice(positions[1], positions[2]);
expect(cap).toMatch(/\bstop\b/i);
expect(cap).toContain('`converged:false`');
expectMentions(cap, [['do not', 'fourth', 'fixing']], 'cap');
const fixes = decisions.slice(positions[2], positions[3]);
expectMentions(fixes, [['before', 'affected', 'pending']], 'fixes');
expectMentions(fixes, [['approval', 'pre-existing', 'verified']], 'fixes');
expectMentions(decisions.slice(positions[3]), [['only', 'continue', 'clears']], 'decisions.slice(positions[3])');
expectMentions(review, [['never', 'inconclusive', 'required']], 'review');
});
test('comment fixes resume triage and preserve settled approvals and replies', () => {
const section = compact(read('ship/sections/greptile.md.tmpl'));
expectMentions(review, [['before', 'complete', 'applying']], 'review');
expectMentions(section, [['no', 'continue', 'queued']], 'section');
expectMentions(section, [['does not', 'optional', 'triage']], 'section');
expect(section).not.toContain('return to Step 9');
});
test('native recovery has one corrected attempt and cannot borrow outside completion', () => {
const finish = adversarial.slice(adversarial.indexOf('### Finish the adversarial phase'));
const recovery = finish.slice(finish.indexOf('1. **Required native'), finish.indexOf('2. **Fixes queued'));
expectMentions(recovery, [['stop', 'confirm', 'stopped']], 'recovery');
expectMentions(recovery, [['cannot', 'outside-provider', 'replace']], 'recovery');
expectMentions(recovery, [['only', 'prerequisite', 'correction']], 'recovery');
expect(recovery).toContain('PASS_START');
expectMentions(recovery, [['ask', 'blocked', 'repair']], 'recovery');
const queued = finish.slice(finish.indexOf('2. **Fixes queued'), finish.indexOf('3. **Native complete'));
expectMentions(queued, [['before', 'pending', 'insert']], 'queued');
expect(queued).toMatch(/not recovery retries/i);
expect(finish.slice(finish.indexOf('3. **Native complete'))).toContain('then continue to Step 11.5');
});
test.each(ALL_HOST_CONFIGS.map(({ name }) => name))('%s cannot skip Step 11.5 or change standalone review control flow', host => {
const ctx = { host, skillName: 'ship', tmplPath: '', paths: HOST_PATHS[host] };
const ship = generateAdversarialStep(ctx);
const finish = compact(ship.slice(ship.indexOf('### Finish the adversarial phase')));
const branches = ['Required native review incomplete', 'Fixes queued after native completion', 'Native complete with no queued fixes']
.map(label => finish.indexOf(label));
expect(branches.every(index => index >= 0)).toBe(true);
expect(branches).toEqual([...branches].sort((a, b) => a - b));
expectMentions(finish, [['cannot', 'outside-provider', 'replace']], 'finish');
expect(finish).toContain('Step 11.5');
expect(finish).not.toContain('proceed to Step 12');
expect(finish).not.toContain('return to Step 9');
const review = generateAdversarialStep({ ...ctx, skillName: 'review' });
expect(review).not.toContain('Ship control flow');
expect(review).not.toContain('Step 11.5');
expectMentions(review, [['do not', 'repair', 'start']], 'review');
});
test('Step 11.5 verifies original record identity before any release write', () => {
const bindingStart = entry.indexOf('## Step 11.5:');
expect(bindingStart).toBeGreaterThan(entry.indexOf('{{SECTION:adversarial}}'));
expect(bindingStart).toBeLessThan(entry.indexOf('## Step 12:'));
const binding = compact(entry.slice(bindingStart, entry.indexOf('## Step 12:')));
for (const field of ['skill:"review"', 'via:"ship"', 'skill:"adversarial-review"',
'review_binding.state', 'verified', 'review_binding.start_wtree', 'review_binding.end_wtree']) expect(binding).toContain(field);
expectMentions(binding, [['never', 'attach', 'tokens']], 'binding');
expectMentions(binding, [['does not', 'failed', 'probes']], 'binding');
expect(binding).toContain('`9 → 10 → 11 → 11.5` before Step 12');
});
test('late build and behavior changes complete bounded ranges before docs', () => {
const build = gate.slice(gate.indexOf('### 1.'), gate.indexOf('### 2.'));
expect(build).toMatch(/repeat stage 1/i);
expect(build).toMatch(/continue to stage 2/i);
const behavior = gate.slice(gate.indexOf('1. **Behavior'), gate.indexOf('2. **Only authored'));
expect(behavior).toContain('Insert `5–11.5 → 12–14 → 16` before the pending Step 17');
expect(behavior).toMatch(/excludes Step 14\.5/i);
expectMentions(behavior, [['before', 'rebuild', 'compare']], 'behavior');
const plan = gate.slice(gate.indexOf('2. **Only authored'), gate.indexOf('3. **No changes'));
expectMentions(plan, [['only', 'decision', 'return']], 'plan');
expectMentions(plan, [['never', 'yourself', 'counts']], 'plan');
});
test('docs freshness has bounded re-audit and exact-content exception routes', () => {
const docs = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
expect(docs).toContain('**An attempt remains, with changed inputs or an available repair:** insert `14.5 → 15 → 16` before Step 17');
expectMentions(docs, [['stop', 'documentation', 'unwaivable']], 'docs');
expect(docs).toMatch(/never run a third audit/i);
expect(docs).toContain('`Documentation: blocked`');
expect(docs).toMatch(/never silently refresh hashes/i);
});
// Value: protects=finish's scoped-review STOP and --docs-requested carry are routes Step 16 names, so a driver that reads
// the template instead of the STOP text keeps the GST-384/GST-391 saving; fails_when=stage 2 sends every tests=/context=
// STOP to the full loop or stage 3 offers only a re-audit or a named risk (GST-444); seam=none; no_claim=the bins' own STOP text
test('Step 16 routes the scoped-review STOP and the --docs-requested fix (GST-444)', () => {
const behavior = gate.slice(gate.indexOf('1. **Behavior'), gate.indexOf('2. **Only authored'));
expect(behavior).toContain('A STOP naming a scoped-review packet instead takes its fix-check and `--context-reviewed` rerun; a blocking finding is this item.');
const docs = gate.slice(gate.indexOf('### 3.'), gate.indexOf('### 4.'));
expect(docs).toContain('**A fix the audit\'s result named:** rerun finish with `--docs-requested <path> --docs-result <json>`.');
});
test('tests, absent test approval and new writes reopen final verification', () => {
const tests = gate.slice(gate.indexOf('### 4.'), gate.indexOf('### 5.'));
expect(tests).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i);
expectMentions(tests, [['no', 'including', 'no-tests']], 'tests');
expectMentions(tests, [['does not', 'authorize', 'attempt']], 'tests');
expectMentions(gate, [['before', 'complete', 'restart']], 'gate');
});
test('push failure kinds and publication races have distinct resumes', () => {
const push = compact(entry.slice(entry.indexOf('## Step 17:'), entry.indexOf('## Step 18:')));
expectMentions(push, [['no', 'publication', 'fails']], 'push');
expectMentions(push, [['before', 'returning', 'steps']], 'push');
expect(push).toMatch(/never rewrite history/i);
expect(push).toMatch(/never bypass failed guards/i);
const publication = compact(read('ship/sections/pr-body.md.tmpl'));
expect(publication).toMatch(/repeat Step 18's identity\/title preparation/i);
expectMentions(publication, [['fresh body', 'before', 'publishing']], 'publication');
});
});