Files
gbrain/test/code-scoped-source-resolve.test.ts
T
248fb7a90f v0.41.38.0 fix: code-callers/callees honor .gbrain-source pin + gbrain dream runs on postgres engines (#1666)
* fix(code-callers/callees): honor .gbrain-source pin via full source-resolution chain

code-callers and code-callees called resolveDefaultSource directly, which only
knew "1 source -> use it, else multiple_sources_ambiguous" and ignored the
.gbrain-source pin (and env / local_path / brain_default / sole_non_default
tiers). On a multi-source brain they errored even when a pin clearly selected
one source, while code-def/code-refs "worked" only because they never scope by
source at all.

New shared helper resolveScopedSourceOrThrow(engine, cwd) in sources-ops.ts runs
the full resolveSourceWithTier chain and applies the ambiguity guard ONLY when
nothing matched (tier seed_default). Both commands route through it; an explicit
--source/--all-sources still overrides. Adds source_id + scope to the JSON
envelope, a stderr nudge on the sole_non_default tier (matches sync/import), a
zero-result "try --all-sources" hint, and clean exit-2 handling for a bad pin.

Tests: test/code-scoped-source-resolve.test.ts (8 helper cases incl. dotfile
pin, env/brain_default/sole_non_default tiers, ambiguity, bad pin) and
test/code-callers-pin.serial.test.ts (9 CLI-wiring cases via process.chdir).

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

* fix(dream): run against postgres engines (skip filesystem-only phases when no checkout)

gbrain dream hard-failed with "No brain directory found" on a postgres/Supabase
brain with no local checkout, so the DB-only maintenance phases (notably
resolve_symbol_edges, the call-graph builder) could never run. doctor even
recommended `gbrain dream --source <id>`, a command that couldn't run.

- dream.ts: resolveBrainDir returns string|null (order: --dir -> resolved
  source's local_path -> sync.repo_path -> null); runDream owns the both-null
  (no checkout AND no engine) exit 1.
- cycle.ts: CycleOpts.brainDir is string|null; resolveSourceForDir null-tolerant;
  the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip
  with reason 'no_brain_dir' when there's no checkout; DB phases run.
  cycleSourceId = opts.sourceId ?? resolveSourceForDir(...) scopes the per-source
  DB phases (extract_facts/extract_atoms/calibration) correctly even on a
  checkout-less brain (previously they scoped to 'default' while the cycle
  stamped the requested source fresh — a freshness stamp that lied). deriveStatus
  counts resolved/ambiguous edges as work so an edges-only cycle reports 'ok'.
- jobs.ts: the autopilot-cycle handler passes null (not cwd '.') when no repo is
  configured, so the queued cycle follows the same no_brain_dir contract.

Tests: test/dream-postgres.test.ts (8: null-brainDir path, --source scope
regression, edges->ok, both-null exit) + test/jobs-autopilot-cycle-braindir.test.ts
(1: handler passes null -> FS phases skip).

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

* chore: bump version and changelog (v0.41.38.0)

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

* fix(review): scope dream brainDir to --source; null fallback for phase handlers; quarantine heavy PGLite tests

Addresses pre-landing review findings (codex P1/P2):
- dream.ts resolveBrainDir: when --source resolves but that source has no
  on-disk checkout, return null (DB-only) instead of falling through to the
  global sync.repo_path. That global path belongs to the default/unscoped
  brain; running FS phases against it while DB phases + the last_full_cycle_at
  stamp target the requested source mixed scopes (codex P1). Adds a regression
  test (--source repo-a + a configured global sync.repo_path → brain_dir null).
- jobs.ts makePhaseHandler: fall back to null (not cwd '.') when no repo is
  configured, matching the autopilot-cycle handler + gbrain dream. A direct
  phase job (synthesize/patterns) on a checkout-less brain now skips FS phases
  as no_brain_dir instead of running against the worker cwd (codex P2).
- Move dream-postgres + jobs-autopilot-cycle-braindir tests to *.serial.test.ts
  (they run full runCycle passes; the serial pass avoids parallel-shard PGLite
  cold-start contention).

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

* docs(todos): record v0.41.38.0 dream-postgres / source-pin follow-ups

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

* docs: update CLAUDE.md Key Files for v0.41.38.0 (dream-postgres + source pin)

- dream.ts entry: resolveBrainDir returns string|null; checkout-less postgres
  runs DB phases + skips FS phases (no_brain_dir); --source-with-no-checkout
  doesn't borrow a different source's global repo.
- cycle.ts entry: CycleOpts.brainDir nullable; cycleSourceId per-source scope;
  deriveStatus counts edges; jobs.ts handlers pass null not '.'.
- Regenerated llms-full.txt (bun run build:llms).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:58:40 -07:00

187 lines
6.6 KiB
TypeScript

/**
* v0.41.30.0 — resolveScopedSourceOrThrow resolution rule (BUG 1).
*
* code-callers / code-callees used to call resolveDefaultSource directly,
* which only knew "1 source → use it, else multiple_sources_ambiguous" and
* ignored the .gbrain-source pin. The new helper runs the full 7-tier chain
* (flag → env → dotfile → local_path → brain_default → sole_non_default →
* seed_default) and only applies the ambiguity guard on the no-signal
* seed_default tier.
*
* This drives the helper directly with an explicit cwd (hermetic). The CLI
* end-to-end wiring (process.cwd() + chdir) lives in
* test/code-callers-pin.serial.test.ts.
*
* PGLite in-memory, no DATABASE_URL.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resolveScopedSourceOrThrow, SourceResolutionError } from '../src/core/sources-ops.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function addSource(id: string, localPath: string | null): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[id, localPath],
);
}
/** A throwaway directory NOT under any registered source's local_path and
* with no .gbrain-source, so the resolver falls through to the DB tiers. */
function cleanCwd(): string {
return mkdtempSync(join(tmpdir(), 'gbrain-scoped-clean-'));
}
describe('resolveScopedSourceOrThrow', () => {
test('single-source brain: returns default via seed_default tier (no throw)', async () => {
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('default');
expect(r.tier).toBe('seed_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('.gbrain-source pin resolves on a multi-source brain (THE bug)', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-pin-'));
writeFileSync(join(cwd, '.gbrain-source'), 'repo-a\n');
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('dotfile');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('no pin + no signal + multi-source → multiple_sources_ambiguous', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = cleanCwd();
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(caught).toBeInstanceOf(SourceResolutionError);
if (caught instanceof SourceResolutionError) {
expect(caught.code).toBe('multiple_sources_ambiguous');
expect(caught.availableSources).toContain('repo-a');
expect(caught.availableSources).toContain('repo-b');
}
});
test('default + one non-default source with local_path → sole_non_default tier', async () => {
await addSource('repo-a', '/fake/a');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('sole_non_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('GBRAIN_SOURCE env tier wins on a multi-source brain', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: 'repo-b' }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-b');
expect(r.tier).toBe('env');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('brain_default (sources.default config) tier wins when no pin/env/cwd-match', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
await engine.setConfig('sources.default', 'repo-a');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('brain_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('zero sources → no_sources throw', async () => {
await engine.executeRaw(`DELETE FROM sources WHERE id = 'default'`, []);
const cwd = cleanCwd();
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(caught).toBeInstanceOf(SourceResolutionError);
if (caught instanceof SourceResolutionError) {
expect(caught.code).toBe('no_sources');
}
});
test('bad .gbrain-source pin (nonexistent source) throws a resolver user error', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-badpin-'));
writeFileSync(join(cwd, '.gbrain-source'), 'does-not-exist\n');
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
// Bad pin surfaces as a plain Error from assertSourceExists, NOT a
// SourceResolutionError — the command layer maps this to a clean exit 2.
expect(caught).toBeInstanceOf(Error);
expect(caught).not.toBeInstanceOf(SourceResolutionError);
expect((caught as Error).message).toContain('does-not-exist');
});
});