Files
gbrain/test/dream.test.ts
T
42d99b6fca v0.41.26.0 fix: dream --source + ingest junk titles + emoji-crash (supersedes #1559, #1561) (#1571)
* fix: dream --source/--source-id plumbs sourceId to runCycle (supersedes #1559)

Closes the silent-no-op class where `gbrain dream --source <id>` ran
the cycle but never wrote `last_full_cycle_at`, leaving
`gbrain doctor`'s cycle_freshness check stuck red forever.

Changes to src/commands/dream.ts:
- DreamArgs.source field; parseArgs recognizes --source <id> AND the
  --source-id alias (matches v0.37.7.0 #1167 naming across
  import/extract/graph-query)
- Argv validation: missing value → exit 2; repeated different values
  → exit 2; --source X --source-id Y conflict → exit 2; same-value
  repetition → accepted
- --help short-circuit ordering preserved with IRON-RULE comment +
  structural test guard
- runDream engine-null guard: --source requires a connected brain
- runDream resolveSourceId → archived-source guard via fetchSource
  from src/core/sources-load.ts (single-row SELECT that projects
  archived + handles pre-v0.26.5 schema via isUndefinedColumnError)
- Typed-error try/catch via isResolverUserError predicate: only
  swallows known resolver-user errors; TypeError / postgres errors
  propagate uncaught with stack trace so genuine programmer bugs
  aren't hidden behind operator-error UX
- Forwarded sourceId to runCycle; existing v0.38 writeback at
  cycle.ts:1947-1967 now actually fires
- --help text documents both flag names

Tests:
- test/dream-cli-flags.test.ts: structural assertions for new flags,
  help text, IRON-RULE comment guard, resolver/predicate wiring
- test/dream.test.ts: 13 PGLite integration cases covering happy
  path (the regression that closes PR #1559), back-compat, alias
  equivalence, all argv edge cases, engine-null, archived,
  --help short-circuit ordering, T3 typed-error propagation, and
  D5 end-to-end dream→checkCycleFreshness column-name drift guard

Plan + 11 decisions: ~/.claude/plans/system-instruction-you-are-working-starry-papert.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: judgeSignificance uses canonical safeSplitIndex (closes #1559/#1561 emoji crash)

Closes the 2026-05-24 production SYNTH_PHASE_FAIL: 🤖 (U+1F916,
surrogate pair U+D83E U+DD16) at offset 3999 in a long telegram
transcript made the raw 4000-char slice produce a lone high
surrogate; Anthropic's JSON parser rejected the payload with "no
low surrogate in string"; the synthesize phase failed.

Changes to src/core/cycle/synthesize.ts:
- judgeSignificance head+tail slice routed through safeSplitIndex
  from src/core/text-safe.ts (already imported)
- Did NOT introduce safeSliceEnd from PRs #1559+#1561 — that helper
  re-introduces the case-3 bug src/core/text-safe.ts:18-21 documents
- Did NOT touch findBoundary — master already routes through
  safeSplitIndex per the v0.42.0.0 wave

Tests in test/cycle-synthesize.test.ts:
- New describe('judgeSignificance — UTF-16 safety') block
- test.each over head boundaries (offsets 3998-4001) AND tail
  boundaries (offsets 3999-4002) for an 8001-char content with
  the robot emoji placed at each
- Primary assertion: explicit unpaired-surrogate scan over the
  captured prompt (NOT JSON.stringify per codex C-11 — V8/JSCore
  do not throw on lone surrogates, so that assertion was weak)
- Sub-8000 short-content branch case: no slicing, emoji passes
  through unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: expand error_page_title + add cloudflare_challenge_title (supersedes #1561)

Closes the bug class where scraper error pages with titles like
"Forbidden", "Access Denied", "Service Unavailable", "Robot Check",
and "Just a moment..." were slipping through the ingest gate
because the matcher only caught bare numeric codes (403/404/500...)
and "page not found". 232+ pages observed (202+ from straylight-
brain) were inflating page counts and tripping
content_sanity_audit_recent on every doctor run.

Changes to src/core/content-sanity.ts BUILT_IN_JUNK_PATTERNS:
- Expanded error_page_title regex to also catch forbidden,
  access denied, service unavailable, robot check, verify you are
  human (case-insensitive, anchored — so long-form essays about
  these topics still ingest fine)
- New cloudflare_challenge_title pattern with DISTINCT name from
  error_page_title (PR #1561 collapsed both into one name and lost
  audit signal — the new name preserves diagnosability in
  ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl and doctor's
  content_sanity_audit_recent aggregation)
- Dropped PR #1561's bare-`error` matcher — too aggressive on
  legitimate concept/taxonomy pages titled exactly "Error"

Tests:
- test/content-sanity.test.ts: pattern-count locked at 7, new
  matches via test.each, over-match regression guard (legitimate
  prose titled "How to Handle Access Denied Errors" / "Error
  Boundary in React" etc. must pass), audit-name distinctness
  pinned
- test/import-file-content-sanity.test.ts: end-to-end
  ContentSanityBlockError via importFromContent for each new
  pattern family (D6 — assessor wiring coverage, not just regex)

Out of scope, filed in TODOS.md as TODO-V13-C: gbrain pages
audit-junk-titles legacy-cleanup command. Dropped from this PR
per codex outside-voice tension (T1) for ship-and-validate-
matchers-first discipline. The 200+ pre-existing scraper pages
already in the DB will get the destructive-cleanup operator
surface after ~1 week of production observation against this
matcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.41.23.0 + CHANGELOG + follow-up TODOs

VERSION + package.json bump to 0.41.23.0.

CHANGELOG voice: ELI10 lead naming the bug ("`gbrain dream --source
<id>` finally counts as a cycle"), then per-fix detail, then a
"To take advantage of v0.41.23.0" operator-action block and itemized
changes.

TODOS.md v0.41.23.x follow-ups:
- TODO-V13-A (P2): --max-pages plumbing (PR #1559's flag, deferred
  because CycleOpts has no maxPages field today)
- TODO-V13-B (P3): --source vs --source-id flag-name unification
  across all CLI commands
- TODO-V13-C (P2): gbrain pages audit-junk-titles legacy cleanup
  (deferred for ~1 week of matcher production observation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.41.25.0 → v0.41.26.0 (leave headroom for in-flight PR)

Master shipped v0.41.23.0 + v0.41.24.0 mid-review; this branch
originally bumped to v0.41.25.0 post-merge. User flagged v0.41.26.0
to leave a slot open for another in-flight PR. No code changes;
VERSION + package.json + CHANGELOG header + "To take advantage"
section updated in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:52:36 -07:00

554 lines
22 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();
});
});
// ─── 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);
});