Files
gbrain/test/dream.test.ts
T
60125ee626 feat(dream): --once for one-shot phase runs without toggling config gates (takeover of #2983) (#3031)
* feat(dream): --once for one-shot phase runs without toggling config gates

Fixes the "toggle enabled true, run, toggle back to false" workaround
that gbrain doctor's extract_atoms_backlog message implicitly
recommends and that #2860's reporter had to script around: with an
external orchestrator running `gbrain dream --phase patterns` on a
cadence outside the autopilot, the only way to run patterns once was
`config set dream.patterns.enabled true` -> run -> `config set ...
false`. A crash between steps left the flag stuck true, and the
autopilot (which polls the same flag) re-enqueued patterns every
cycle -- 119 LLM jobs / ~$400 over 24h before it was caught.

Root cause: `--phase X` only controls which phase FUNCTION cycle.ts
calls; it does not bypass that phase's own `dream.<phase>.enabled` /
`cycle.<phase>.enabled` config read. Each gated phase (patterns,
synthesize, conversation_facts_backfill, enrich_thin, skillopt) reads
its enabled flag internally and skips regardless of how the phase was
selected -- confirmed by reading each phase module, not assumed.
extract_atoms/synthesize_concepts are a DIFFERENT mechanism entirely
(pack-declaration via packDeclaresPhase, not a config .enabled read)
and already have a working one-shot escape hatch: `--drain`. The
existing doctor message for extract_atoms already says `--phase
extract_atoms --drain --window 120`, so no doctor text needed
updating there -- verified by reading src/commands/doctor.ts directly
rather than assuming the paraphrase in the issue was literal.

Design: `gbrain dream --phase <name> --once`. Requires an explicit
--phase (bare --once is a usage error, exit 2) so it can never
force-enable every disabled phase at once in a full/default cycle --
that would recreate the same unbounded-spend risk the flag exists to
prevent. Threaded through CycleOpts as `onceForPhase?: CyclePhase`
(the literal phase name, not a boolean) so the bypass can never leak
to a phase other than the one named, even if a future programmatic
caller passes a wider `phases` array than the CLI does. Never reads
or writes config -- the phase still evaluates its .enabled gate every
call; --once only overrides the boolean OUTCOME for that one
invocation, mirroring the existing --unsafe-bypass-dream-guard /
--input precedents (stderr warning at the bypass point, no new
config-touching code path).

Rejected alternatives (documented per task instructions):
- Making explicit --phase X always bypass .enabled: breaking change
  for existing crons that rely on the disabled flag as a cheap no-op;
  an upgrade would silently start running LLM/write phases.
- A new subcommand: adds a whole dispatch/help/arg surface that
  internally routes through the same override anyway.
- Extending --once to also bypass packDeclaresPhase for
  extract_atoms/synthesize_concepts: conflates two different gating
  mechanisms (config toggle vs. pack membership) under one flag;
  extract_atoms already has --drain, which is purpose-built for its
  batched/windowed execution model.

Design was cross-validated by an independent second-model review
(external design consultation) before implementation; its
recommendation to also update the extract_atoms doctor message to
`--once` was NOT adopted because that phase has no .enabled gate to
bypass -- doing so would be a documented no-op, contradicted by
reading src/commands/doctor.ts:3264 directly.

Tests: 9 new (structural CLI-flag wiring in dream-cli-flags.test.ts;
a real PGLite E2E test in dream-patterns-pglite.test.ts proving the
bypass fires AND that dream.patterns.enabled is never written; 4
runCycle-level tests in cycle.serial.test.ts proving onceForPhase
does not leak across phases). Verified 6 of 9 fail against the
pre-fix source (via git stash of source-only changes) to confirm
they're meaningful regressions, not tautologies.

Closes #2860

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(dream): --help short-circuits before --once usage validation

Codex review finding (P2): `gbrain dream --help --once` (no --phase)
called process.exit(2) from the new --once usage-error check inside
parseArgs before runDream's documented IRON RULE ("--help
short-circuits BEFORE any engine-bearing work") ever got a chance to
run -- parseArgs computes ALL its validations unconditionally before
runDream checks opts.help. Repo precedent for this ordering already
exists as a pinned regression test (test/dream.test.ts's "--help
--source whatever prints help and exits 0").

Fix: compute wantsHelp once in parseArgs and exempt the --once
validation when it's set, mirroring that precedent. Added the same
class of pinned tests here: bare `--once` still exits 2 with the
usage hint, `--help --once` prints help and exits 0, and a real
--phase patterns --once run against a PGLite engine proves the
bypass actually fires (falls through to insufficient_evidence
instead of disabled) without writing dream.patterns.enabled. Also
fixed the structural test in dream-cli-flags.test.ts that asserted
the exact pre-fix guard-condition source text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(dream): --once must require an EXPLICIT --phase, not a derived one

Codex review finding (P3): the --once validation checked the derived
`phase` value, but `phase` gets defaulted implicitly by --input
(implies --phase synthesize) and --drain (implies --phase
extract_atoms) BEFORE that check ran. So `gbrain dream --input <f>
--once` and `gbrain dream --drain --once` both slipped past the
"explicit --phase required" contract silently -- and --once became a
true no-op in both cases: --drain returns from runDream before
onceForPhase is ever read (the drain path doesn't call runCycle at
all), and --input already bypasses the synthesize enabled-gate on its
own via the existing opts.inputFile check, so onceForPhase would
never even be consulted.

Fix: capture `phaseWasExplicit = phaseIdx !== -1` at the very top of
parseArgs, before the --input/--drain defaulting blocks run, and
validate --once against that instead of the derived `phase`. Updated
the usage-error message and --help text to say "an explicit --phase"
so a user hitting this understands why `--input ... --once` doesn't
count.

Tests: 2 new pins in test/dream.test.ts exercising runDream directly
(--input <file> --once exits 2; --drain --once exits 2), plus a
structural test in dream-cli-flags.test.ts pinning that
phaseWasExplicit is captured before both implicit-defaulting blocks.
Updated the two existing structural/behavioral tests whose literal
guard-condition / error-message assertions changed shape.

Verified: dream-cli-flags.test.ts 27/27, dream.test.ts 31/31,
typecheck clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: masashiono0611 <masashi.ono.0611@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-07-21 12:29:35 -07:00

667 lines
28 KiB
TypeScript
Raw 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.
/**
* Unit tests for src/commands/dream.ts — CLI alias over runCycle.
*
* dream is intentionally thin. These tests exercise the CLI surface
* (argv parsing, brainDir resolution, output format, exit codes)
* against a REAL runCycle + real library calls, backed by an
* in-memory PGLite engine.
*
* Why no mocks: `mock.module` in bun is process-global and leaks
* across test files (a stub of ../src/commands/orphans.ts breaks
* every test that imports shouldExclude/deriveDomain/formatOrphansText).
* Testing against real calls is honest and mock-leak-free.
*
* What this test file does NOT cover: the exhaustive dryRun-×-phases-×-
* lock matrix, which test/core/cycle.test.ts handles (in isolation).
* Here we only verify that dream.ts routes args correctly.
*/
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execSync } from 'child_process';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runDream } from '../src/commands/dream.ts';
// ─── Helpers ───────────────────────────────────────────────────────
/** Make an empty, engine-backed PGLite brain. */
async function makePGLite() {
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
return engine;
}
/** Make an empty git repo. Lint/backlinks have nothing to scan → status=clean. */
function makeGitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-dream-repo-'));
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email t@t.co', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name t', { cwd: dir, stdio: 'pipe' });
// Commit an empty .gitkeep so rev-parse HEAD succeeds.
require('fs').writeFileSync(join(dir, '.gitkeep'), '');
execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
return dir;
}
// ─── brainDir resolution ───────────────────────────────────────────
describe('runDream — brainDir resolution', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('explicit --dir takes precedence over engine config', async () => {
await engine.setConfig('sync.repo_path', '/configured/dir');
const report = await runDream(engine, ['--dir', repo, '--json']);
expect(report).toBeTruthy();
if (report) expect(report.brain_dir).toBe(repo);
});
test('no --dir + engine-configured: uses engine.getConfig("sync.repo_path")', async () => {
await engine.setConfig('sync.repo_path', repo);
const report = await runDream(engine, ['--json']);
expect(report).toBeTruthy();
if (report) expect(report.brain_dir).toBe(repo);
});
test('no --dir + engine=null exits 1', async () => {
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(null, []);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(spy).toHaveBeenCalledWith(1);
spy.mockRestore();
errSpy.mockRestore();
});
test('--dir pointing at nonexistent path exits 1', async () => {
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(null, ['--dir', '/does/not/exist/hopefully']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(spy).toHaveBeenCalledWith(1);
spy.mockRestore();
errSpy.mockRestore();
});
});
// ─── Phase selection (single-phase runs stay fast) ─────────────────
describe('runDream — --phase <name> restricts the cycle', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('--phase lint produces a report with exactly one phase = lint', async () => {
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) {
expect(report.phases.length).toBe(1);
expect(report.phases[0].phase).toBe('lint');
}
});
test('--phase orphans produces a report with exactly one phase = orphans', async () => {
const report = await runDream(engine, ['--dir', repo, '--phase', 'orphans', '--json']);
expect(report).toBeTruthy();
if (report) {
expect(report.phases.length).toBe(1);
expect(report.phases[0].phase).toBe('orphans');
}
});
test('--phase garbage exits 1 with an error message', async () => {
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(null, ['--dir', repo, '--phase', 'garbage']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(errSpy).toHaveBeenCalled();
spy.mockRestore();
errSpy.mockRestore();
});
});
// ─── --once (issue #2860) ───────────────────────────────────────────
describe('runDream — --once (issue #2860)', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000);
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('bare --once (no --phase) exits 2 with a usage hint', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--dir', repo, '--once']);
throw new Error('expected runDream to exit');
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
// Codex P3 finding: --input implicitly sets `phase = 'synthesize'` and
// --drain implicitly sets `phase = 'extract_atoms'` — an EARLIER version
// of the --once validation checked the derived `phase` value, which was
// already non-null by the time it ran, so both of these silently slipped
// past the "explicit --phase required" contract (and --once became a
// no-op: --drain returns before onceForPhase is ever read; --input
// already bypasses the synthesize gate on its own). The fix validates
// against `phaseWasExplicit` (whether the user actually typed --phase)
// instead of the derived value.
test('--input <file> --once (no explicit --phase) exits 2 — an implied phase does not count', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--dir', repo, '--input', '/tmp/gbrain-2860-nonexistent.txt', '--once']);
throw new Error('expected runDream to exit');
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
test('--drain --once (no explicit --phase) exits 2 — an implied phase does not count', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--dir', repo, '--drain', '--once']);
throw new Error('expected runDream to exit');
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
// Codex review finding: --help must short-circuit BEFORE the bare---once
// usage error, matching the same IRON RULE pinned above for --source
// ("--help --source whatever prints help and exits 0").
test('--help --once (no --phase) prints help and exits 0, not the usage error', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const result = await runDream(engine, ['--help', '--once']);
expect(result).toBeUndefined();
} catch (e: any) {
throw new Error('--help with --once should NOT exit; got: ' + e.message);
}
expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls.flat().join(' ')).toMatch(/Usage: gbrain dream/);
exitSpy.mockRestore();
logSpy.mockRestore();
});
test('--phase patterns --once bypasses dream.patterns.enabled=false without writing config', async () => {
await engine.setConfig('dream.patterns.enabled', 'false');
const report = await runDream(engine, ['--dir', repo, '--phase', 'patterns', '--once', '--json']);
expect(report).toBeTruthy();
if (report) {
expect(report.phases.length).toBe(1);
// Bypassed 'disabled' → falls through to the next gate. No
// reflections seeded, so it reports insufficient_evidence, not
// 'disabled' — proving --once actually forced the run.
expect(report.phases[0].phase).toBe('patterns');
expect((report.phases[0].details as { reason?: string }).reason).toBe('insufficient_evidence');
}
expect(await engine.getConfig('dream.patterns.enabled')).toBe('false');
});
test('--phase patterns (no --once) with dream.patterns.enabled=false still skips as disabled', async () => {
await engine.setConfig('dream.patterns.enabled', 'false');
const report = await runDream(engine, ['--dir', repo, '--phase', 'patterns', '--json']);
expect(report).toBeTruthy();
if (report) {
expect((report.phases[0].details as { reason?: string }).reason).toBe('disabled');
}
});
});
// ─── Output format ─────────────────────────────────────────────────
describe('runDream — output format', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('--json emits parsable CycleReport JSON with schema_version', async () => {
const lines: string[] = [];
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
logSpy.mockRestore();
const parsed = JSON.parse(lines.join('\n'));
expect(parsed.schema_version).toBe('1');
expect(parsed).toHaveProperty('status');
expect(parsed).toHaveProperty('phases');
expect(parsed).toHaveProperty('totals');
});
test('human output for clean status mentions "Brain is healthy"', async () => {
const lines: string[] = [];
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
// Single-phase lint run on a clean repo → status=clean.
await runDream(engine, ['--dir', repo, '--phase', 'lint']);
logSpy.mockRestore();
expect(lines.join('\n')).toContain('Brain is healthy');
});
});
// ─── Dry-run propagation ───────────────────────────────────────────
describe('runDream — dry-run propagates through to runCycle', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('--dry-run produces a report where no DB-mutating work happened', async () => {
// Before: empty pages table.
const { rows: before } = await (engine as any).db.query('SELECT COUNT(*)::int AS n FROM pages');
expect(before[0].n).toBe(0);
await runDream(engine, ['--dir', repo, '--dry-run', '--json']);
// After dry-run: still 0 pages. The cycle ran but wrote nothing.
const { rows: after } = await (engine as any).db.query('SELECT COUNT(*)::int AS n FROM pages');
expect(after[0].n).toBe(0);
});
});
// ─── Exit-code semantics ───────────────────────────────────────────
describe('runDream — exit-code semantics', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('clean/ok/partial statuses do not call process.exit', async () => {
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('UNEXPECTED_EXIT'); });
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
// ─── v0.41.13: --source / --source-id wiring (supersedes PR #1559) ────
//
// Covers:
// - argv repetition + conflict rules (parseArgs path)
// - engine-null guard (D1 from eng review)
// - assertSourceExists propagation (resolveSourceId path)
// - archived-source guard (D2)
// - --source-id alias equivalence (D3)
// - --help short-circuit ordering (C-8)
// - typed-error propagation (T3: TypeError must NOT be swallowed)
// - end-to-end dream→doctor (D5): writeback flips cycle_freshness
// - back-compat regression: bare `gbrain dream` writes no per-source stamp
describe('runDream — --source / --source-id (v0.41.13)', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
async function seedSource(id: string, archived: boolean = false): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ($1, $2, $3, '{}'::jsonb, $4, NOW())
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path, archived = EXCLUDED.archived`,
[id, id, repo, archived],
);
}
async function readLastFullCycleAt(sourceId: string): Promise<string | null> {
const sources = await engine.listAllSources();
const s = sources.find(x => x.id === sourceId);
if (!s) return null;
const raw = (s.config as any)?.last_full_cycle_at;
return typeof raw === 'string' ? raw : null;
}
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000);
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
// ─── parseArgs: --source missing / conflict / repetition ────────────
test('--source with no value exits 2 with usage hint', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--source.*missing value/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
test('--source-id with no value exits 2 with usage hint', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source-id']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--source-id.*missing value/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
test('--source X --source Y (repeated, different values) exits 2', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source', 'foo', '--source', 'bar']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/specify --source once/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
test('--source X --source X (repeated, same value) is accepted', async () => {
await seedSource('alpha');
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--source', 'alpha', '--source', 'alpha', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
}, 60_000);
test('--source X --source-id Y (conflict) exits 2', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source', 'foo', '--source-id', 'bar']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(2);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/use --source OR --source-id, not both/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
// ─── Help short-circuit ordering (C-8 IRON RULE) ────────────────────
test('--help --source whatever prints help and exits 0 (no engine-null error)', async () => {
// engine null + --source set would normally exit 1, but --help short-circuits first.
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const result = await runDream(null, ['--help', '--source', 'anything']);
expect(result).toBeUndefined();
} catch (e: any) {
throw new Error('--help with --source should NOT exit; got: ' + e.message);
}
expect(exitSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls.flat().join(' ')).toMatch(/Usage: gbrain dream/);
exitSpy.mockRestore();
logSpy.mockRestore();
});
// ─── Engine-null guard (D1) ─────────────────────────────────────────
test('engine=null + --source set exits 1 with "requires a connected brain"', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(null, ['--source', 'whatever']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/requires a connected brain/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
// ─── Unknown source (resolveSourceId throw) ─────────────────────────
test('--source <unknown> exits 1 with assertSourceExists hint', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source', 'no-such-source']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(1);
const errOut = errSpy.mock.calls.flat().join(' ');
expect(errOut).toMatch(/Source "no-such-source" not found/);
expect(errOut).toMatch(/gbrain sources list/);
exitSpy.mockRestore();
errSpy.mockRestore();
});
// ─── Archived source guard (D2) ─────────────────────────────────────
test('--source <archived> exits 1 and leaves last_full_cycle_at untouched', async () => {
await seedSource('archived-thing', /* archived = */ true);
const before = await readLastFullCycleAt('archived-thing');
expect(before).toBeNull();
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runDream(engine, ['--source', 'archived-thing']);
} catch (e: any) {
expect(e.message).toBe('EXIT');
}
expect(exitSpy).toHaveBeenCalledWith(1);
const errOut = errSpy.mock.calls.flat().join(' ');
expect(errOut).toMatch(/source archived-thing is archived/);
expect(errOut).toMatch(/gbrain sources restore archived-thing/);
const after = await readLastFullCycleAt('archived-thing');
expect(after).toBeNull(); // archived guard prevents writeback
exitSpy.mockRestore();
errSpy.mockRestore();
});
// ─── Happy path: --source writes last_full_cycle_at (the bug fix) ───
test('--source <existing> writes last_full_cycle_at on success (PR #1559 regression)', async () => {
await seedSource('media-corpus');
const before = await readLastFullCycleAt('media-corpus');
expect(before).toBeNull();
const t0 = Date.now();
const report = await runDream(engine, ['--dir', repo, '--source', 'media-corpus', '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
const after = await readLastFullCycleAt('media-corpus');
expect(after).not.toBeNull();
const writtenMs = new Date(after!).getTime();
expect(writtenMs).toBeGreaterThanOrEqual(t0);
expect(writtenMs).toBeLessThanOrEqual(Date.now() + 1000);
}, 60_000);
// ─── Back-compat: bare `gbrain dream` does NOT write per-source stamp ─
test('gbrain dream (no --source) leaves all sources untouched (back-compat regression)', async () => {
await seedSource('alpha');
await seedSource('beta');
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
expect(await readLastFullCycleAt('alpha')).toBeNull();
expect(await readLastFullCycleAt('beta')).toBeNull();
}, 60_000);
// ─── --source-id alias equivalence (D3) ─────────────────────────────
test('--source-id <existing> is equivalent to --source (writes timestamp)', async () => {
await seedSource('beta');
const before = await readLastFullCycleAt('beta');
expect(before).toBeNull();
const report = await runDream(engine, ['--dir', repo, '--source-id', 'beta', '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
const after = await readLastFullCycleAt('beta');
expect(after).not.toBeNull();
}, 60_000);
// ─── T3: TypeError MUST propagate (not swallowed by predicate-gated catch) ─
test('non-resolver-user errors propagate uncaught (T3)', async () => {
// Monkey-patch executeRaw to throw a synthetic TypeError on the
// assertSourceExists SELECT. The typed-error catch in runDream only
// matches resolver-user-error message shapes; a TypeError thrown
// from any source-resolution path must bubble up with its original
// stack trace (proving real programmer bugs are NOT hidden behind
// operator-error UX).
await seedSource('gamma');
const original = (engine as any).executeRaw.bind(engine);
let restored = false;
try {
// Throw a TypeError on the source-lookup SELECT that assertSourceExists runs.
// Other executeRaw calls (used by engine internals during cycle) keep
// working so the test exercises ONLY the resolution-path failure.
(engine as any).executeRaw = async (sql: string, params?: unknown[]) => {
if (typeof sql === 'string' && /FROM\s+sources\s+WHERE\s+id\s*=/i.test(sql)) {
throw new TypeError('synthetic-test-bug');
}
return original(sql, params);
};
await expect(
runDream(engine, ['--dir', repo, '--source', 'gamma', '--phase', 'lint', '--json'])
).rejects.toThrow(/synthetic-test-bug/);
} finally {
(engine as any).executeRaw = original;
restored = true;
}
expect(restored).toBe(true);
});
});
// ─── v0.41.13 D5: end-to-end dream → checkCycleFreshness parity ───────
//
// Closes the column-rename drift class: if a future PR renames
// last_full_cycle_at on one side but not the other, both isolated
// tests stay green but production breaks. This exercises the FULL
// chain through the exact seam both sides consume.
describe('runDream → checkCycleFreshness end-to-end (D5)', () => {
let repo: string;
let engine: InstanceType<typeof PGLiteEngine>;
beforeEach(async () => {
repo = makeGitRepo();
engine = await makePGLite();
}, 60_000);
afterEach(async () => {
if (engine) await engine.disconnect();
rmSync(repo, { recursive: true, force: true });
}, 60_000);
test('stale source becomes fresh after dream --source (column-name drift guard)', async () => {
// Seed source with last_full_cycle_at backdated 25h (above warn floor).
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ('gamma', 'gamma', $1, jsonb_build_object('last_full_cycle_at', $2::text), false, NOW())`,
[repo, stale],
);
// Doctor sees stale (warn or fail; we only care that it's NOT ok)
const { checkCycleFreshness } = await import('../src/commands/doctor.ts');
const beforeCheck = await checkCycleFreshness(engine);
expect(beforeCheck.status).not.toBe('ok');
// Run dream against the stale source.
const report = await runDream(engine, ['--dir', repo, '--source', 'gamma', '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
// Doctor now sees fresh.
const afterCheck = await checkCycleFreshness(engine);
expect(afterCheck.status).toBe('ok');
}, 60_000);
});