mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
* 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>
370 lines
23 KiB
TypeScript
370 lines
23 KiB
TypeScript
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');
|
||
});
|
||
});
|