Files
gstack/test/autoplan-scope.test.ts
Garry Tanandcapy-ai[bot] 148d28ea81 feat(ship): /ship and /autoplan size their review to the diff (GSTA-131) (#3527)
* wip(GSTA-131): review depth, fix-check transitions, dispatch counters, carry pregate, autoplan round policy

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

* wip(GSTA-131): reviewers stay at full effort; audits light

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

* feat(ship): Step 9 dispatches what gstack-review-plan plans; fix-check after below-floor fixes; cap two full passes plus one fix-check

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

* test: review-dispatch-compare has a free lane; review-depth fixtures registered free; resumed max_rounds case off the old default

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

* fix(GSTA-131): ship review pass-1 repairs: fix-check binds under its own skill, Step 11.5 accepts a fix-check or a planned adversarial skip, delta carry with a pregate, carry --pregate pinned to the checkout, quiet-after-cap revise regates, skill-dir markdown and rule docs classed code, gate signals from tests class up, plan fails closed on git errors, per-class compare ratios

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

* fix(GSTA-131): ship review pass-2 repairs: Step 11 follows the last full-pass plan, Important Rules restored, ship goldens regenerated, true per-class medians, review-policy transitions and once-per-invocation voices, docs/** assets stay docs-class, CLI and fix-check binding tests

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

* docs(changelog): GSTA-131 review depth, fix-check, delta /ship and autoplan rounds

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

* docs: ship-policy cap and counters, review-policy /review cycle cap, unattended passes (docs audit)

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

* fix(review): 400-word findings cap fits the /review size pins (five host pins lowered by 2); CHANGELOG max_rounds wording

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

* docs(changelog): gate integrity names the size-ledger entry and the lowered /review pins

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

* test: the round-budget case asserts the default through ROUNDS output only (pregate literals heuristic)

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

* test: curate ship-receipt-carry-pregate out of the Windows lane like its --pregate siblings

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

* docs(changelog): end the Windows curation sentence instead of a mid-sentence colon

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

* fix(ship): one decision each for the review-loop exits, the planned adversarial skip and Step 16's TODOS rerun (screen judge repair)

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

* fix(ship): fix-check repairs: pin the renamed Step 11 heading, keep the below-floor exit at the cap, name the plan run a repeat reuses, print the native skip on outside-only plans

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-10-28 05:45:54 +00:00

133 lines
8.2 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.
/**
* Field wave item 11: /autoplan scope keys on what the Implementation plan
* changes. UI needs a rendered surface (a rendered file path, or a UI term
* next to a visual noun); bare UI words and Review-record prose never add the
* Design phase. Provenance is per axis (flag, marker, detected), `--no-dx`
* turns DX off, a first-line scope marker in the plan is read (never
* written), and the interactive path reads the same detector through
* `gstack-autoplan-snapshot.ts scope`. Runner-level cases run on the
* simulated Capy host (test/helpers/capy-host.ts).
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { detectScope, detectUiScope, phaseOrder, sectionScopeOff } from '../lib/autoplan-prompts';
import { capyHost } from './helpers/capy-host';
import { AUTOPLAN, ROOT, env, readRun } from './helpers/autoplan-run-fixture';
const host = capyHost({ verifiedBun: '1.4.2' });
afterAll(() => host.cleanup());
let n = 0;
const planFile = (body: string, record = '', firstLine = '') => {
const dir = path.join(host.base, `plan-${++n}`);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'plan.md');
fs.writeFileSync(file, `${firstLine ? `${firstLine}\n` : ''}# Plan ${n}\n\n## Implementation plan\n\n${body}\n\n## Review record\n\n${record}\n`);
return file;
};
const hostEnv = () => env({ ...host.env, PATH: host.launchPath, GSTACK_SESSION_KIND: 'unattended', GSTACK_STATE_ROOT: host.stateRoot, GSTACK_HOME: host.stateRoot });
const next = (args: string[]) => spawnSync('bun', [AUTOPLAN, 'next', ...args], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 120_000 });
const snapshotScope = (file: string) => {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-autoplan-snapshot.ts'), 'scope', file], { cwd: ROOT, env: hostEnv(), encoding: 'utf8', timeout: 60_000 });
expect(r.status, r.stderr).toBe(0);
return JSON.parse(r.stdout);
};
const CLI_PROSE = 'The sync component reads frontmatter; a malformed form is held. The doctor screen output (terminal) lists each held file with a status line.';
const DX_PROSE = 'Add a CLI flag --since to the sync command and document the flag in the command help.';
describe('UI evidence is a rendered surface in the Implementation plan', () => {
test('non-visual CLI prose with UI words is weak evidence: no Design phase, sections still load', () => {
const r = detectUiScope(CLI_PROSE);
expect(r.uiRequired).toBe(false);
expect(r.uiMatches).toBe(3);
expect(r.uiEvidence).toEqual({ terms: [{ term: 'component', count: 1 }, { term: 'screen', count: 1 }, { term: 'form', count: 1 }], visual: [], files: [] });
const scope = detectScope(planFile(CLI_PROSE), ROOT, {});
expect(scope.ui).toBe(false);
expect(phaseOrder(scope)).not.toContain('design');
expect(sectionScopeOff('ui', scope)).toBe(false);
});
test('UI words only in the Review record give ui=false (Review record prose is not scanned)', () => {
const scope = detectScope(planFile('Retry the webhook worker with backoff in lib/worker.ts.', 'The Settings page layout gets a new button and a modal; the sidebar nav moves.'), ROOT, {});
expect(scope.ui).toBe(false);
expect(scope.ui_matches).toBe(0);
});
test('a rendered file path, quoted or backticked, gives ui=true and is printed as evidence', () => {
expect(detectUiScope('Edit src/components/Settings.tsx to add the toggle.').uiEvidence.files).toEqual(['src/components/Settings.tsx']);
expect(detectUiScope('Render "app/views/home/index.html.erb" with the new partial.').uiRequired).toBe(true);
expect(detectUiScope('Move the colors into `web/theme.scss`.').uiEvidence.files).toEqual(['web/theme.scss']);
expect(detectUiScope('Touch lib/pages.ts and docs/components.md only.').uiRequired).toBe(false);
expect(detectScope(planFile('Edit src/components/Settings.tsx to add a retry toggle.'), ROOT, {}).ui).toBe(true);
});
test('a UI term next to a visual noun is UI; the same term far from one is not', () => {
const near = detectUiScope('Add a retry button to the settings page.');
expect(near.uiRequired).toBe(true);
expect(near.uiEvidence.visual).toEqual(['button~page']);
expect(detectUiScope('The status layout of the JSON report is stable.').uiRequired).toBe(false);
expect(detectUiScope('Add a button. Write a long unrelated paragraph about the queue and its retry budget and the man page.').uiRequired).toBe(false);
});
});
describe('per-axis provenance, --no-dx and the plan marker', () => {
test('--no-ui alone labels a detected DX as detected, not flag-forced', () => {
const scope = detectScope(planFile(DX_PROSE), ROOT, { ui: false });
expect(scope.dx).toBe(true);
expect(scope.sources).toEqual({ ui: 'flag', dx: 'detected' });
});
test('a first-line marker with no CLI flags sets scope from the marker, and the source plan is untouched', () => {
const file = planFile('Edit src/components/Settings.tsx to add a retry toggle.', '', '<!-- gstack-autoplan-scope: --no-ui --developer-tool -->');
const before = fs.readFileSync(file);
const scope = detectScope(file, ROOT, {});
expect(scope).toMatchObject({ ui: false, dx: true, developer_tool: true, sources: { ui: 'marker', dx: 'marker' }, pin: null });
expect(fs.readFileSync(file).equals(before)).toBe(true);
expect(() => detectScope(planFile('x', '', '<!-- gstack-autoplan-scope: --bogus -->'), ROOT, {})).toThrow(/unknown scope flag --bogus/);
});
test('runner: CLI flags print SCOPE evidence and a SCOPE_PIN hint, never write the plan, and persist in run.json', () => {
const file = planFile(`${DX_PROSE} ${CLI_PROSE}`);
const before = fs.readFileSync(file);
const out = path.join(host.base, 'run-pin');
const r = next(['--out', out, '--plan', file, '--no-ui', '--developer-tool']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^SCOPE: ui=no \(terms: component×1, screen×1, form×1; visual: none; files: none\) dx=yes \(detected: [^)]*flag×2[^)]*\) docs_only=no \(detected\) source=ui:flag,dx:flag$/m);
expect(r.stdout).toContain(`SCOPE_PIN: add <!-- gstack-autoplan-scope: --no-ui --developer-tool --> as the first line of ${file} to keep these flags`);
expect(fs.readFileSync(file).equals(before)).toBe(true);
expect(readRun(out).scope).toMatchObject({ cli_flags: ['--no-ui', '--developer-tool'], marker: null, sources: { ui: 'flag', dx: 'flag' } });
expect(r.stdout).toContain('source=ui:flag,dx:flag; checklist=');
});
test('runner: a marker that already says the CLI flags prints no SCOPE_PIN', () => {
const file = planFile(DX_PROSE, '', '<!-- gstack-autoplan-scope: --developer-tool --no-ui -->');
const r = next(['--out', path.join(host.base, 'run-pinned'), '--plan', file, '--no-ui', '--developer-tool']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^SCOPE: .* source=ui:flag,dx:flag$/m);
expect(r.stdout).not.toContain('SCOPE_PIN:');
});
test('runner: --no-dx removes the DX phase from a plan whose terms detect it', () => {
const r = next(['--out', path.join(host.base, 'run-nodx'), '--plan', planFile(DX_PROSE), '--no-ui', '--no-dx']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^PHASE: ceo snapshot=[0-9a-f]{64} order=ceo,eng$/m);
expect(r.stdout).toMatch(/^SCOPE: ui=no .* dx=no .* source=ui:flag,dx:flag$/m);
});
});
describe('interactive parity: one detector', () => {
test('snapshot scope returns uiRequired with its evidence', () => {
expect(snapshotScope(planFile('Edit src/components/Settings.tsx.'))).toMatchObject({ uiRequired: true, uiEvidence: { files: ['src/components/Settings.tsx'] } });
expect(snapshotScope(planFile(CLI_PROSE, 'The Settings page layout gets a new button.'))).toMatchObject({ uiRequired: false, uiMatches: 3 });
});
test('autoplan Step 2 takes UI scope from the returned uiRequired instead of a prose grep', () => {
const step2 = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md.tmpl'), 'utf8').split('### Step 2: Read context')[1]!.split('### Step 3:')[0]!;
expect(step2).toContain('`uiRequired`');
expect(step2).not.toMatch(/grep the plan/i);
expect(fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf8')).toContain('`uiRequired`');
});
});