mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* test: synced JSON that does not parse is never committed and is reported (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): hold a synced JSON file that does not parse; --profile and doctor report it (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): read JSON index blobs with one cat-file batch, no python subprocess (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): block instead of committing a one-sided merge when a held file is mid-merge (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(brain-sync): merge-block status names the real recovery and waiting files (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): JSON holds in the --status, held-files and two-machine sections (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(brain-sync): #3704 fixtures write valid JSON to developer-profile.json (#3763) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(brain-sync): keep the reviewed JSON paragraph wording after the main merge (#3763) 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>
299 lines
17 KiB
TypeScript
299 lines
17 KiB
TypeScript
/**
|
|
* 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('<BIN>/').split(`${other}/bin/`).join('<OTHER>/').split(bashPath(f.base)).join('<BASE>');
|
|
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`');
|
|
});
|
|
});
|