/** * gstack-doctor: one row per readiness check, each ok / warn / not configured * / fail with a fix line when not ok; only fail makes the exit non-zero; fix * lines name helpers by absolute install-root path; the paid Codex model probe * runs only with --live. Also pins the `./setup --status` Codex row and its * trailing doctor path. Every run uses a fixture install root (copies of the * real helpers plus a stub gstack-codex-probe), a fixture state root, stub * bun/codex/claude on PATH and an explicit HOME. Scripts run through explicit * bash argv, so the file is in the curated Windows lane. */ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { useDoctorCleanup, REPO, BROWSE_BIN, write, bashPath, probeCalls, iso, syncOn, makeFixture, doctor, type Fixture } from './helpers/doctor-fixture'; useDoctorCleanup(); describe('gstack-doctor', () => { test('a healthy fixture: every core row ok, exit 0, and no paid probe', () => { const f = makeFixture(); const r = doctor(f); expect(r.status, r.out).toBe(0); for (const name of ['install root', 'state root', 'bun', 'hooks', 'browse bundle']) expect(r.row(name).state, `${name}\n${r.out}`).toBe('ok'); expect(r.row('install root').detail).toContain('claude global current'); expect(r.row('state root').detail).toContain('GSTACK_STATE_ROOT'); expect(r.row('bun').detail).toContain('1.4.2'); expect(r.row('codex').state).toBe('ok'); expect(r.row('codex').detail).toContain('gpt-stub-5 (source: codex config.toml)'); expect(r.row('codex probe').state).toBe('warn'); expect(r.row('codex probe').detail).toContain('not probed'); expect(r.row('codex probe').fix).toBe(`${bashPath(f.root)}/bin/gstack-doctor --live (one short paid Codex call)`); expect(r.row('artifacts sync').state).toBe('not configured'); expect(r.row('claude code').detail).toContain('2.1.292'); expect(r.row('guard log').detail).toBe('no guard decisions logged'); expect(probeCalls(f)).not.toContain('probe-model'); expect(probeCalls(f)).toContain('select-model exec'); expect(r.out).toMatch(/\d+ ok, \d+ warn, \d+ not configured, 0 fail/); }); test('each DX-8 state appears, and only fail makes the exit non-zero', () => { const f = makeFixture(); const warn = doctor(f, [], { STUB_BUN_VERSION: '1.4.0' }); expect(warn.row('bun').state).toBe('warn'); expect(warn.row('bun').fix).toContain('bun upgrade'); expect(warn.status, warn.out).toBe(0); write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); const off = doctor(f); expect(off.row('codex').state).toBe('not configured'); expect(off.row('codex').detail).toContain('outside reviews in /review, /ship, /autoplan and /codex'); expect(off.row('codex').fix).toBe(`${bashPath(f.root)}/bin/gstack-config set codex_reviews enabled`); expect(off.status, off.out).toBe(0); expect(probeCalls(f)).not.toContain('probe-model'); const fail = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' }); expect(fail.row('bun').state).toBe('fail'); expect(fail.row('bun').fix).toContain('bun upgrade'); expect(fail.status, fail.out).toBe(1); expect(fail.out).toMatch(/, 1 fail/); }); test('Codex: absent CLI is not configured, unauthenticated warns, a failed self-locate fails', () => { const f = makeFixture(); fs.rmSync(path.join(f.stub, 'codex')); const absent = doctor(f); expect(absent.row('codex').state).toBe('not configured'); expect(absent.row('codex').detail).toContain('Codex CLI not installed'); expect(absent.row('codex').fix).toContain('npm install -g @openai/codex'); expect(absent.status).toBe(0); const g = makeFixture(); const unauthed = doctor(g, [], { STUB_AUTH_FAIL: '1' }); expect(unauthed.row('codex').state).toBe('warn'); expect(unauthed.row('codex').detail).toContain('not_authed'); expect(unauthed.row('codex').fix).toContain('codex login'); expect(unauthed.status).toBe(0); const broken = doctor(g, [], { STUB_SELECT_FAIL: '1' }); expect(broken.row('codex').state).toBe('fail'); expect(broken.row('codex').detail).toContain('helper_unavailable'); expect(broken.row('codex').fix).toContain(`${bashPath(g.root)}/bin/gstack-codex-probe select-model exec`); expect(broken.status).toBe(1); }); test('the cached probe is reported with its age and is never re-run without --live', () => { const f = makeFixture(); const now = Math.floor(Date.now() / 1000); write(path.join(f.state, '.codex-model-probe'), `MODEL_OK ${now - 7300} 123456789\n`); const cached = doctor(f); expect(cached.row('codex probe').state).toBe('ok'); expect(cached.row('codex probe').detail).toBe('cached MODEL_OK, 2h ago (not re-run; --live probes now)'); write(path.join(f.state, '.codex-model-probe'), `MODEL_QUOTA_EXHAUSTED ${now - 125} 123456789\nCodex says: try again at 5pm sk-should-not-print\n`); const quota = doctor(f); expect(quota.row('codex probe').state).toBe('warn'); expect(quota.row('codex probe').detail).toContain('cached MODEL_QUOTA_EXHAUSTED, 2m ago'); expect(quota.out).not.toContain('sk-should-not-print'); write(path.join(f.state, '.codex-model-probe'), `$(rm -rf ~) ${now} x\n`); expect(doctor(f).row('codex probe').detail).toContain('unrecognized'); expect(probeCalls(f)).not.toContain('probe-model'); }); test('--live runs probe-model once and maps its exit to the codex probe row', () => { const f = makeFixture(); const live = doctor(f, ['--live']); expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1); expect(live.row('codex probe').state).toBe('ok'); expect(live.row('codex probe').detail).toContain('live probe: ok'); const unusable = doctor(f, ['--live'], { STUB_PROBE_EXIT: '1', STUB_PROBE_STATE: 'model_unusable' }); expect(unusable.row('codex probe').state).toBe('warn'); expect(unusable.row('codex probe').detail).toContain('model_unusable'); const brokenInstall = doctor(f, ['--live'], { STUB_PROBE_EXIT: '2', STUB_PROBE_STATE: 'broken_install' }); expect(brokenInstall.row('codex probe').state).toBe('fail'); expect(brokenInstall.status).toBe(1); }); test('a hook shim that does not parse fails the hooks row', () => { const f = makeFixture(); write(path.join(f.root, 'autoplan/bin/phase-publication-hook'), '#!/bin/sh\nif then\n', 0o755); const r = doctor(f); expect(r.row('hooks').state).toBe('fail'); expect(r.row('hooks').detail).toContain('autoplan/bin/phase-publication-hook'); expect(r.row('hooks').fix).toContain('./setup'); expect(r.status).toBe(1); }); test('artifacts sync: a hostile status clamps to unknown and is never echoed', () => { const f = makeFixture(); syncOn(f, { status: 'AKIAIOSFODNN7EXAMPLE leaked', message: 'blocked: AKIAIOSFODNN7EXAMPLE in /secret/path.md', ts: iso(10) }); const r = doctor(f); expect(r.row('artifacts sync').state).toBe('warn'); expect(r.row('artifacts sync').detail).toBe('status unknown'); expect(r.row('artifacts sync').fix).toBe(`${bashPath(f.root)}/bin/gstack-brain-sync --status`); expect(r.out).not.toContain('AKIA'); expect(r.out).not.toContain('/secret/path.md'); expect(r.out).not.toContain('.brain-sync-status.json'); }); test('artifacts sync: a synced JSON file with conflict markers warns by name (#3763)', () => { const f = makeFixture(); syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); write(path.join(f.state, 'developer-profile.json'), '{\n<<<<<<< HEAD\n "v": 1\n=======\n "v": 2\n>>>>>>> origin/main\n}\n'); const r = doctor(f); expect(r.row('artifacts sync').state).toBe('warn'); expect(r.row('artifacts sync').detail).toBe('developer-profile.json holds git conflict markers and does not parse'); write(path.join(f.state, 'developer-profile.json'), '{"v": 2}\n'); expect(doctor(f).row('artifacts sync').state).toBe('ok'); }); test('artifacts sync: a JSON hold is not attributed to the secret scanner (#3763)', () => { const f = makeFixture(); syncOn(f, { status: 'held', message: 'held', held_count: 1, held: [{ path: 'developer-profile.json', rule: 'invalid-json', dependents: [], fixes: ['repair', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); const r = doctor(f); expect(r.row('artifacts sync').state).toBe('warn'); expect(r.row('artifacts sync').detail).toBe('status held (1 synced file(s) are not valid JSON; 1 held in all)'); }); test('artifacts sync: held, then ok', () => { const f = makeFixture(); syncOn(f, { status: 'held', message: 'held 2 file(s): sk-ant-secret', held_count: 2, held: [{ path: 'projects/p/notes.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }, { path: 'projects/p/b.md', rule: 'anthropic_key', dependents: [], fixes: ['edit', 'skip'] }], drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); const held = doctor(f); expect(held.row('artifacts sync').state).toBe('warn'); expect(held.row('artifacts sync').detail).toBe('status held (2 file(s) held by the secret scanner)'); expect(held.out).not.toContain('sk-ant'); syncOn(f, { status: 'ok', message: 'pushed 3 file(s)', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); expect(doctor(f).row('artifacts sync').state).toBe('ok'); }); test('artifacts sync: case-colliding paths warn with the clamped names, held or not', () => { const f = makeFixture(); syncOn(f, { status: 'collision', message: 'held projects/myapp/x.md', case_held: 1, case_names: 'MyApp,myapp', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); const held = doctor(f); expect(held.row('artifacts sync').state).toBe('warn'); expect(held.row('artifacts sync').detail).toBe('status collision; artifact paths differ only by letter case (MyApp,myapp), see docs/troubleshooting.md#artifact-case-collision'); expect(held.out).not.toContain('projects/myapp'); syncOn(f, { status: 'ok', message: 'pushed 1 file(s)', case_held: 0, case_names: 'OLD,old', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); expect(doctor(f).row('artifacts sync').state).toBe('warn'); syncOn(f, { status: 'ok', message: 'pushed 1 file(s)', case_held: 0, case_names: '', drainable: 0, last_drain_at: iso(60), last_push_at: iso(60) }); expect(doctor(f).row('artifacts sync').state).toBe('ok'); }); test('artifacts sync: stale push and stale drain warn, a fresh push clears them', () => { const f = makeFixture(); syncOn(f, { status: 'ok', message: 'x', drainable: 4, last_drain_at: iso(60), last_push_at: iso(90_000) }); const stalePush = doctor(f).row('artifacts sync'); expect(stalePush.state).toBe('warn'); expect(stalePush.detail).toContain('no push for 24h while 4 record(s) wait'); write(path.join(f.state, '.brain-queue.d/1-1-a.json'), '{}\n'); syncOn(f, { status: 'idle', message: 'x', drainable: 0, last_drain_at: iso(200_000), last_push_at: iso(60) }); const staleDrain = doctor(f).row('artifacts sync'); expect(staleDrain.state).toBe('warn'); expect(staleDrain.detail).toContain('no drain for 24h while 1 record(s) are queued'); syncOn(f, { status: 'ok', message: 'pushed' }); expect(doctor(f).row('artifacts sync').state).toBe('ok'); }); test('triage rows: Claude Code absent, largest journal, last five schema-1 guard codes', () => { const f = makeFixture(); fs.rmSync(path.join(f.stub, 'claude')); expect(doctor(f).row('claude code').state).toBe('not configured'); expect(doctor(f).row('session journal').detail).toBe('no session journals for this project'); const cwd = process.platform === 'win32' ? spawnSync('bash', ['-c', 'pwd -W'], { cwd: f.work, encoding: 'utf8', timeout: 10_000 }).stdout.trim() : f.work; const project = path.join(f.home, '.claude/projects', cwd.replace(/[^A-Za-z0-9]/g, '-')); write(path.join(project, 'a.jsonl'), 'x'.repeat(1000)); write(path.join(project, 'b.jsonl'), 'x'.repeat(3 * 1024 * 1024)); const journal = doctor(f).row('session journal'); expect(journal.state).toBe('ok'); expect(journal.detail).toBe('largest of 2: 3.0 MiB'); const line = (schema: unknown, code: string | null, decision = 'deny') => JSON.stringify({ schema, ts: '2026-10-07T00:00:00Z', decision, ...(code ? { reason_code: code } : {}), path: 'journal', cc_version: '2.1.292', disposition: 'hard' }); write(path.join(f.state, 'analytics/autoplan-guard.jsonl'), [ line(1, 'code_one'), line(2, 'future_schema'), line(1, 'code_two'), line(1, null, 'allow'), 'not json', line(1, 'code_three'), line(1, 'code_four'), line('1', 'string_schema'), line(1, 'code_five'), line(1, 'code_six'), ].join('\n') + '\n'); const guard = doctor(f).row('guard log'); expect(guard.state).toBe('ok'); expect(guard.detail).toBe('7 decisions (6 denied); last codes: code_two, code_three, code_four, code_five, code_six'); }); test('every fix line names gstack helpers by absolute install-root path', () => { const f = makeFixture(); write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); fs.rmSync(path.join(f.root, BROWSE_BIN)); const other = path.join(f.base, 'other'); write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, '1', 'false', 'committed', 'x'].join('\t') + '\n'); const r = doctor(f, [], { STUB_BUN_VERSION: '1.3.2' }); const fixes = r.out.split('\n').filter(l => l.trimStart().startsWith('fix: ')); expect(fixes.length).toBeGreaterThanOrEqual(5); expect(r.row('install root').fix).toContain(`${other}/bin/gstack-doctor`); const bin = `${bashPath(f.root)}/bin/`; for (const fix of fixes) { const scrubbed = fix.split(bin).join('/').split(`${other}/bin/`).join('/').split(bashPath(f.base)).join(''); for (const m of scrubbed.matchAll(/gstack-[a-z]/g)) expect(/<(BIN|OTHER)>\/$/.test(scrubbed.slice(0, m.index)), fix).toBe(true); } expect(r.row('browse bundle').state).toBe('warn'); expect(r.row('browse bundle').detail).toContain('/browse, /qa, /qa-only, /design-review, /make-pdf, /pair-agent'); }); test('usage errors exit 2', () => { const f = makeFixture(); expect(doctor(f, ['--bogus']).status).toBe(2); const help = doctor(f, ['--help']); expect(help.status).toBe(0); expect(help.out).toContain('--live'); }); }); describe('./setup --status Codex row', () => { const status = (f: Fixture) => spawnSync('bash', [path.join(f.root, 'setup'), '--status'], { cwd: f.work, encoding: 'utf8', env: f.env, timeout: 60_000 }); test('reports install, version, codex_reviews and the cached probe, then ends with the doctor path', () => { const f = makeFixture(); write(path.join(f.state, '.codex-model-probe'), `MODEL_UNUSABLE ${Math.floor(Date.now() / 1000) - 600} 1\n`); const r = status(f); expect(r.status, r.stderr).toBe(0); expect(r.stdout).toContain('Codex: installed (codex-cli 0.130.0); codex_reviews=enabled; model probe: MODEL_UNUSABLE 10m ago'); expect(r.stdout.trimEnd().split('\n').at(-1)).toBe(`Readiness check: ${bashPath(f.root)}/bin/gstack-doctor`); expect(probeCalls(f)).toBe(''); }); test('without Codex the row says so, and nothing is written', () => { const f = makeFixture(); fs.rmSync(path.join(f.stub, 'codex')); write(path.join(f.state, 'config.yaml'), 'codex_reviews: disabled\n'); const before = fs.readdirSync(f.state).sort(); const r = status(f); expect(r.stdout).toContain('Codex: not installed (codex_reviews=disabled;'); expect(fs.readdirSync(f.state).sort()).toEqual(before); }); }); describe('bug-report template and README (DX-2, DX-12)', () => { const template = fs.readFileSync(path.join(REPO, '.github/ISSUE_TEMPLATE/bug_report.md'), 'utf8'); test('the template asks for the doctor output with the Claude Code path and a lookup for other hosts', () => { expect(template).toContain('~/.claude/skills/gstack/bin/gstack-doctor'); expect(template).toContain('./setup --status'); expect(template).toContain('/bin/gstack-paths --get GSTACK_STATE_ROOT'); }); test('the template names no bare gstack- command', () => { for (const m of template.matchAll(/gstack-[a-z]/g)) expect(template.slice(0, m.index).endsWith('/bin/'), template.slice(m.index! - 40, m.index! + 20)).toBe(true); }); test('README troubleshooting points at the doctor by absolute path', () => { const readme = fs.readFileSync(path.join(REPO, 'README.md'), 'utf8'); const section = readme.slice(readme.indexOf('## Troubleshooting')); expect(section).toContain('`~/.claude/skills/gstack/bin/gstack-doctor`'); }); });