mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
241 lines
14 KiB
TypeScript
241 lines
14 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as os from 'node:os';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { generatePlanCompletionAuditReview, generatePlanCompletionAuditShip, generatePlanCompletionGateShip, generatePlanVerificationExec } from '../scripts/resolvers/plan-gates';
|
||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||
|
||
const SHIP_DIR = path.join(__dirname, '..', 'ship');
|
||
|
||
describe('authored ship-only plan verification handoff', () => {
|
||
const ctx = { skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', host: 'claude' as const, paths: HOST_PATHS.claude };
|
||
test('local execution-only checks remain required in Step 8.1/9 outside implementation counts', () => {
|
||
const audit = generatePlanCompletionAuditShip(ctx);
|
||
const extraction = audit.slice(audit.indexOf('### Actionable Item Extraction'), audit.indexOf('### Verification Mode')).replace(/\s+/g, ' ');
|
||
expect(extraction).toMatch(/execution-only verification/i);
|
||
expect(extraction).toContain('Step 8.1/9');
|
||
expect(extraction).toMatch(/outside implementation counts/i);
|
||
expect(extraction).toMatch(/never DONE from static inspection/);
|
||
expect(extraction).toMatch(/do not waive those checks/i);
|
||
expect(fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md.tmpl'), 'utf8')).toContain('exactly these seven fields');
|
||
const gate = generatePlanCompletionGateShip(ctx);
|
||
expect(gate).toContain('Any NOT DONE items');
|
||
expect(gate).toMatch(/per-item confirmation/i);
|
||
});
|
||
test('review-mode extraction retains its existing categories and has no ship-only routing', () => {
|
||
const audit = generatePlanCompletionAuditReview({ ...ctx, skillName: 'review' });
|
||
expect(audit).toContain('**Test requirements:**');
|
||
expect(audit).not.toMatch(/execution-only verification/i);
|
||
expect(audit).not.toContain('Step 8.1/9');
|
||
expect(audit).toContain('EXTERNAL-STATE');
|
||
});
|
||
test('preparation hands every explicit check to the report-only execution owner before Fix-First', () => {
|
||
const text = generatePlanVerificationExec(ctx).replace(/\s+/g, ' ');
|
||
expect(text).toContain('Collect now; execute in Step 9');
|
||
expect(text).toMatch(/do not invoke an entire QA skill or start probes here/i);
|
||
for (const heading of ['Verification', 'Test plan', 'Testing', 'How to test', 'Manual testing']) {
|
||
expect(text).toContain(`\`${heading}\``);
|
||
}
|
||
expect(text).toContain('Step 8.2');
|
||
expect(text).toContain('Handoff to Step 9.2.1');
|
||
expect(text).toMatch(/before Fix-First/);
|
||
expect(text).toMatch(/never silently waive/i);
|
||
expect(text).toMatch(/noninteractive runs return blocked/i);
|
||
expect(text).toContain('VERIFY_RESULT=pass only if all selected items pass, skipped only if none exist, otherwise fail');
|
||
expect(text).toContain('`## Verification Results`');
|
||
expect(text.indexOf('Collect now')).toBeLessThan(text.indexOf('Handoff to Step 9.2.1'));
|
||
expect(text.indexOf('Handoff to Step 9.2.1')).toBeLessThan(text.indexOf('After execution'));
|
||
});
|
||
test('the authored parent validates all seven fields and settles failed children before fallback', () => {
|
||
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md.tmpl'), 'utf8');
|
||
const parent = audit.slice(audit.indexOf('**Parent processing:**')).replace(/\s+/g, ' ');
|
||
expect(parent).toMatch(/valid LAST-line JSON, use the audit-failure fallback/i);
|
||
expect(parent).toContain('exactly the seven declared fields');
|
||
expect(parent).toContain('classification sum equals `total_items`');
|
||
expect(parent).toContain('a string `summary`');
|
||
expect(parent).toMatch(/missing, extra or invalid fields fail/i);
|
||
expect(parent).toMatch(/no-plan\/no-actionable reports retain zero counts/i);
|
||
expect(parent).toContain('~10 minutes');
|
||
expect(parent).toMatch(/stop any live child and confirm it stopped before an inline audit/i);
|
||
expect(parent).toMatch(/never race a late result/i);
|
||
expect(parent).toMatch(/if that also fails, AskUserQuestion/i);
|
||
expect(parent).toContain('Stop and fix the audit (recommended/default)');
|
||
const contract = audit.split('\n').find(line => line.startsWith('{"total_items":N,'))!;
|
||
expect(Object.keys(JSON.parse(contract.replace(/:N([,}])/g, ':0$1'))).sort())
|
||
.toEqual(['total_items', 'done', 'changed', 'partial', 'not_done', 'unverifiable', 'summary'].sort());
|
||
expect(generatePlanCompletionGateShip(ctx)).toContain('Only PARTIAL items (no NOT DONE, no UNVERIFIABLE)');
|
||
expect(generatePlanCompletionGateShip(ctx)).toMatch(/not blocking/i);
|
||
});
|
||
});
|
||
|
||
// Carved (v2 plan T9): the Plan Completion gate moved into sections/plan-completion.md.
|
||
// Read the skeleton + sections union so these invariants follow the content.
|
||
function readShipUnion(): string {
|
||
let t = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
const secDir = path.join(SHIP_DIR, 'sections');
|
||
if (fs.existsSync(secDir)) {
|
||
for (const f of fs.readdirSync(secDir).sort()) {
|
||
if (f.endsWith('.md')) t += '\n' + fs.readFileSync(path.join(secDir, f), 'utf8');
|
||
}
|
||
}
|
||
return t;
|
||
}
|
||
|
||
describe('ship/SKILL.md — Plan Completion gate invariants', () => {
|
||
const skill = readShipUnion();
|
||
|
||
test('Path concreteness rule: filesystem-pathed items must be test -f checked', () => {
|
||
expect(skill).toContain('**Path concreteness rule.**');
|
||
expect(skill).toMatch(/concrete filesystem path/);
|
||
expect(skill).toMatch(/classified DONE or NOT DONE based on `\[ -f/);
|
||
});
|
||
|
||
test('Validator detection: project package.json validate-* scripts are auto-run', () => {
|
||
expect(skill).toContain('**Validator detection.**');
|
||
expect(skill).toMatch(/package\.json/);
|
||
expect(skill).toMatch(/validate-\*/);
|
||
});
|
||
|
||
test('Per-item UNVERIFIABLE confirmation: blanket-confirm is forbidden', () => {
|
||
expect(skill).toMatch(/per-item confirmation/i);
|
||
expect(skill).toMatch(/do not use a single AskUserQuestion to blanket-confirm/i);
|
||
});
|
||
|
||
test('Subagent failure: fail-closed, not silent fail-open', () => {
|
||
expect(skill).not.toMatch(/Never block \/ship on subagent failure\.\s*$/m);
|
||
// The audit-failure fallback still forbids a silent fail-open (meaning, not the old incident ID).
|
||
const fallback = skill.slice(skill.indexOf('**Audit-failure fallback:**'), skill.indexOf('**Audit-failure fallback:**') + 800);
|
||
expect(fallback).toMatch(/fail[- ]open/i);
|
||
expect(skill).toMatch(/Stop and fix the audit/);
|
||
});
|
||
|
||
test('parent rejects audit errors and malformed counts instead of treating them as no plan', () => {
|
||
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md'), 'utf8');
|
||
expect(audit.indexOf('**Parent processing:**')).toBeGreaterThan(-1);
|
||
expect(audit.indexOf('**Parent processing:**')).toBeLessThan(audit.indexOf('**Audit-failure fallback:**'));
|
||
const parent = audit.slice(audit.indexOf('**Parent processing:**'), audit.indexOf('**Audit-failure fallback:**')).replace(/\s+/g, ' ');
|
||
expect(parent).toContain('seven declared fields');
|
||
expect(parent).toMatch(/audit-failure fallback/i);
|
||
});
|
||
|
||
test('successful plan audits use the declared seven-field contract without an error field', () => {
|
||
const audit = fs.readFileSync(path.join(SHIP_DIR, 'sections/plan-completion.md'), 'utf8');
|
||
const line = audit.split('\n').find(line => line.startsWith('{"total_items":N,'));
|
||
expect(line).toBeDefined();
|
||
const contract = JSON.parse(line!.replace(/:N([,}])/g, ':0$1'));
|
||
expect(Object.keys(contract).sort()).toEqual(['total_items', 'done', 'changed', 'partial', 'not_done', 'unverifiable', 'summary'].sort());
|
||
expect(contract).not.toHaveProperty('error');
|
||
expect(audit).toContain('exactly these seven fields on the LAST LINE');
|
||
expect(audit).not.toContain('A non-null `error`');
|
||
});
|
||
|
||
test('CONTENT-SHAPE dispatch invokes validator before falling back to UNVERIFIABLE', () => {
|
||
expect(skill).toMatch(/CONTENT-SHAPE in another repo.*validator/s);
|
||
expect(skill).toMatch(/passing validator promotes the item from UNVERIFIABLE to DONE/);
|
||
});
|
||
|
||
test('approved deferrals reach Step 14 without duplicating earlier P0 entries', () => {
|
||
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
const todos = entry.slice(entry.indexOf('## Step 14:'), entry.indexOf('## Step 15:'));
|
||
expect(todos).toContain('Add approved deferrals');
|
||
expect(todos).toMatch(/Step 2[^\n]+P1/);
|
||
expect(todos).toMatch(/Step 8[^\n]+P1[^\n]+plan/);
|
||
expect(todos).toMatch(/Step 5[^\n]+P0[^\n]+deduplicate/);
|
||
expect(todos.indexOf('Add approved deferrals')).toBeLessThan(todos.indexOf('Detect completed TODOs'));
|
||
expect(todos.replace(/\s+/g, ' ')).toMatch(/retain unsaved follow-ups in Step 19's PR summary/i);
|
||
});
|
||
|
||
test('CHANGELOG uses the normal workflow without checkpoint context or squash prerequisites', () => {
|
||
const changelog = fs.readFileSync(path.join(SHIP_DIR, 'sections/changelog.md'), 'utf8');
|
||
expect(changelog).toContain('**Write the CHANGELOG entry**');
|
||
expect(changelog).not.toMatch(/WIP:|gstack-context|checkpoint|squash|Step 15\.0/);
|
||
});
|
||
|
||
test('live evidence recovery distinguishes bookkeeping failure from stale inputs', () => {
|
||
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
const gate = entry.slice(entry.indexOf('## Step 16:'), entry.indexOf('## Step 17:'));
|
||
const text = gate.replace(/\s+/g, ' ');
|
||
expect(text).toContain('STALE/MISSING');
|
||
expect(text).toContain('| Only receipt storage/readback failed |');
|
||
expect(text).toContain('**ledger unavailable**, never FRESH');
|
||
const storage = text.slice(text.indexOf('| Only receipt storage/readback failed |')).split('|')[2];
|
||
expect(storage).not.toContain('gstack-evidence run');
|
||
expect(storage).toMatch(/without that proof, use STALE\/MISSING/i);
|
||
expect(text).toMatch(/\*\*New, changed or unwaived test failure:\*\* stop publication\. Run Steps 5–15/i);
|
||
expect(text).toMatch(/reuse waivers only for the same verified pre-existing failures/i);
|
||
expect(text).toMatch(/make evidence STALE/);
|
||
});
|
||
|
||
test('ship contract precedes base detection and fresh remote facts precede distribution decisions', () => {
|
||
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
expect(entry.indexOf('# Ship:')).toBeLessThan(entry.indexOf('## Step 0:'));
|
||
const preflight = entry.slice(entry.indexOf('## Step 1:'), entry.indexOf('## Step 2:'));
|
||
expect(preflight).toContain('git fetch origin <base>');
|
||
expect(preflight).toMatch(/fetch fails[^\n]+\bstop\b/i);
|
||
expect(entry).not.toContain('auto-generate and commit, or flag');
|
||
expect(entry).toContain('Step 15 commits those tests');
|
||
});
|
||
|
||
test('bisectable commits proceed directly to verification without rewriting existing history', () => {
|
||
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
const commit = entry.slice(entry.indexOf('## Step 15:'), entry.indexOf('## Step 16:'));
|
||
expect(commit).toMatch(/bisectable commits/i);
|
||
expect(commit).toContain('continue to Step 16');
|
||
expect(commit).toMatch(/never create an empty commit/i);
|
||
expect(commit).not.toMatch(/checkpoint|WIP|squash|git rebase|git reset/);
|
||
expect(entry).not.toMatch(/Step 15\.[012]/);
|
||
});
|
||
|
||
test('a rejected push stops publication and routes changed content back through verification', () => {
|
||
const entry = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md'), 'utf8');
|
||
const push = entry.slice(entry.indexOf('## Step 17:'), entry.indexOf('## Step 20:'));
|
||
const recovery = push.replace(/\s+/g, ' ');
|
||
expect(push).toMatch(/push fails[^\n]+\bstop\b/i);
|
||
expect(recovery).toContain('**Non-fast-forward push:**');
|
||
expect(recovery).toContain('Run Steps 5–16 before returning to Step 17');
|
||
expect(recovery).toMatch(/never rewrite history/i);
|
||
expect(recovery).toContain('**Authentication, hook or network failure:**');
|
||
expect(recovery).toMatch(/repeat Step 16 even if content is unchanged/i);
|
||
expect(push).toMatch(/never force.push/i);
|
||
expect(push).toMatch(/only a successful push/i);
|
||
});
|
||
});
|
||
|
||
test('push idempotency requires the live remote SHA and fails closed on transport errors', () => {
|
||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-push-state-'));
|
||
const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
||
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
||
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.invalid' };
|
||
const git = (...args: string[]) => {
|
||
const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: 30_000 });
|
||
if (r.status !== 0) throw new Error(r.stderr || String(r.error));
|
||
};
|
||
try {
|
||
git('init', '-b', 'feature');
|
||
fs.writeFileSync(path.join(cwd, 'app'), 'base\n');
|
||
git('add', 'app'); git('commit', '-m', 'base');
|
||
const remote = path.join(cwd, '.git/remote.git');
|
||
git('init', '--bare', remote); git('remote', 'add', 'origin', remote);
|
||
const source = fs.readFileSync(path.join(SHIP_DIR, 'SKILL.md.tmpl'), 'utf8');
|
||
const block = source.slice(source.indexOf('**Idempotency check:** Check if the branch'))
|
||
.match(/```bash\n([\s\S]*?)\n```/)![1].replaceAll('<branch-name>', 'feature');
|
||
const inspect = () => {
|
||
const r = spawnSync('bash', ['-c', block], { cwd, env, encoding: 'utf8', timeout: 30_000 });
|
||
if (r.error) throw new Error(`idempotency check did not run: ${r.error.message}; stderr: ${r.stderr}`);
|
||
return r;
|
||
};
|
||
expect(inspect().stdout).toContain('PUSH_NEEDED');
|
||
git('push', '-u', 'origin', 'feature');
|
||
expect(inspect().stdout).toContain('ALREADY_PUSHED');
|
||
fs.appendFileSync(path.join(cwd, 'app'), 'new\n'); git('commit', '-am', 'new');
|
||
expect(inspect().stdout).toContain('PUSH_NEEDED');
|
||
git('push', 'origin', 'feature');
|
||
fs.renameSync(remote, remote + '-offline');
|
||
const unavailable = inspect();
|
||
expect(unavailable.status).toBe(1);
|
||
expect(unavailable.stdout).not.toContain('ALREADY_PUSHED');
|
||
expect(unavailable.stdout).toContain('BLOCKED');
|
||
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
|
||
}, 120_000);
|