/** * v0.32.8 — multi-source bug class regression suite. * * Pins the behaviors that were silently broken pre-v0.32.8 when a brain has * more than one source. Pre-fix, every cycle phase and extract pass called * slug-only engine methods inside a loop over pages and silently defaulted * to source_id='default' for every non-default-source page. * * Fixture: 2 sources ('default' + 'media-corpus') with overlapping slugs. * - people/alice exists in BOTH * - concepts/widget exists ONLY in default * - media/x/post-123 exists ONLY in media-corpus * * Coverage targets (one test per bug site): * 1. listAllPageRefs returns one row per (slug, source_id), ordered. * 2. extract-takes processes BOTH alice rows independently. * 3. integrity scan covers BOTH sources. * 4. extract-links F10 cross-source resolution: * a. alice@media-corpus → widget falls back to default * b. alice@media-corpus → post-123 stays in media-corpus * c. alice@default → ghost-slug records nothing (skip) * 5. validateSourceId rejects path-traversal attempts. * 6. Reverse-write disk layout: .sources//.md for non-default. * * PGLite in-memory — no DATABASE_URL required. Canonical R3+R4 pattern. */ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; import { resetPgliteState } from '../helpers/reset-pglite.ts'; import { validateSourceId } from '../../src/core/utils.ts'; import { extractTakesFromDb } from '../../src/core/cycle/extract-takes.ts'; let engine: PGLiteEngine; beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({} as never); await engine.initSchema(); }); afterAll(async () => { if (engine) await engine.disconnect(); }); beforeEach(async () => { await resetPgliteState(engine); // Seed second source row. Default source is seeded by resetPgliteState. await engine.executeRaw( `INSERT INTO sources (id, name, config) VALUES ('media-corpus', 'media-corpus', '{}'::jsonb) ON CONFLICT (id) DO NOTHING`, ); // Overlapping-slug seed. await engine.putPage('people/alice', { type: 'person', title: 'Alice (default)', compiled_truth: 'Default-source alice page.', }, { sourceId: 'default' }); await engine.putPage('people/alice', { type: 'person', title: 'Alice (media-corpus)', compiled_truth: 'Media-corpus alice page.', }, { sourceId: 'media-corpus' }); // Distinct slugs per source. await engine.putPage('concepts/widget', { type: 'concept', title: 'Widget', compiled_truth: 'Default-source-only widget.', }, { sourceId: 'default' }); await engine.putPage('media/x/post-123', { type: 'media', title: 'Post 123', compiled_truth: 'Media-corpus-only post.', }, { sourceId: 'media-corpus' }); }); describe('multi-source bug class', () => { test('listAllPageRefs returns one row per (slug, source_id), ordered (F11)', async () => { const refs = await engine.listAllPageRefs(); // 4 rows: alice@default, alice@media-corpus, widget@default, post-123@media-corpus expect(refs.length).toBe(4); // Sorted by (source_id, slug) const ordered = refs.map(r => `${r.source_id}::${r.slug}`); expect(ordered).toEqual([ 'default::concepts/widget', 'default::people/alice', 'media-corpus::media/x/post-123', 'media-corpus::people/alice', ]); }); test('getPage with sourceId picks the right (source, slug) row', async () => { const aliceDefault = await engine.getPage('people/alice', { sourceId: 'default' }); const aliceMedia = await engine.getPage('people/alice', { sourceId: 'media-corpus' }); expect(aliceDefault?.title).toBe('Alice (default)'); expect(aliceMedia?.title).toBe('Alice (media-corpus)'); expect(aliceDefault?.source_id).toBe('default'); expect(aliceMedia?.source_id).toBe('media-corpus'); }); test('extract-takes processes both alice pages independently', async () => { // Re-seed with takes fences so extract-takes has something to find. // Fence markers come from src/core/takes-fence.ts (``). const TAKES_BODY = ` | # | claim | kind | who | weight | since | source | | --- | --- | --- | --- | --- | --- | --- | | 1 | Alice founded the thing | fact | garry | 0.9 | 2024-01-01 | | `; await engine.putPage('people/alice', { type: 'person', title: 'Alice (default)', compiled_truth: 'Default alice.\n' + TAKES_BODY, }, { sourceId: 'default' }); await engine.putPage('people/alice', { type: 'person', title: 'Alice (media-corpus)', compiled_truth: 'Media alice.\n' + TAKES_BODY, }, { sourceId: 'media-corpus' }); const result = await extractTakesFromDb(engine, {}); // Pre-fix: only one of the two alice rows had takes extracted because // the loop matched by bare slug. Post-fix: both rows get processed. // Each alice has 1 take; widget + post-123 have none. expect(result.pagesWithTakes).toBe(2); expect(result.takesUpserted).toBe(2); }); test('listPages filters correctly with PageFilters.sourceId', async () => { const onlyDefault = await engine.listPages({ sourceId: 'default', limit: 100 }); const onlyMedia = await engine.listPages({ sourceId: 'media-corpus', limit: 100 }); expect(onlyDefault.length).toBe(2); expect(onlyMedia.length).toBe(2); for (const p of onlyDefault) expect(p.source_id).toBe('default'); for (const p of onlyMedia) expect(p.source_id).toBe('media-corpus'); }); test('addLinksBatch with from/to_source_id targets the right rows (F4)', async () => { // Link alice@media-corpus → post-123@media-corpus (in-source link). const inserted = await engine.addLinksBatch([ { from_slug: 'people/alice', to_slug: 'media/x/post-123', link_type: 'mentions', link_source: 'markdown', from_source_id: 'media-corpus', to_source_id: 'media-corpus', }, ]); expect(inserted).toBe(1); const links = await engine.getLinks('people/alice', { sourceId: 'media-corpus' }); expect(links.length).toBe(1); expect(links[0].to_slug).toBe('media/x/post-123'); // alice@default should have NO links — they're scoped to media-corpus. const defaultLinks = await engine.getLinks('people/alice', { sourceId: 'default' }); expect(defaultLinks.length).toBe(0); }); test('validateSourceId rejects path traversal (F6)', () => { // v0.38 (codex P1-D + eng E2): regex TIGHTENED from permissive // ^[a-z0-9_-]+$ to strict kebab ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$. // Underscores no longer allowed at the path-safety gate (matches // what sources-ops always rejected at creation time). 'jarvis_memory' // was a v0.32.8 permissive-regex test case; now lives in the // rejected set. Path-traversal rejection unchanged. // // Allowed (strict kebab): expect(() => validateSourceId('default')).not.toThrow(); expect(() => validateSourceId('media-corpus')).not.toThrow(); expect(() => validateSourceId('jarvis-memory')).not.toThrow(); expect(() => validateSourceId('abc123')).not.toThrow(); expect(() => validateSourceId('a')).not.toThrow(); // Rejected — path traversal / unsafe chars: expect(() => validateSourceId('..')).toThrow(); expect(() => validateSourceId('../etc')).toThrow(); expect(() => validateSourceId('foo/bar')).toThrow(); expect(() => validateSourceId('foo bar')).toThrow(); expect(() => validateSourceId('Default')).toThrow(); // uppercase expect(() => validateSourceId('.hidden')).toThrow(); expect(() => validateSourceId('')).toThrow(); // Rejected — strict regex additions (v0.38): expect(() => validateSourceId('jarvis_memory')).toThrow(); // underscores expect(() => validateSourceId('snake_case')).toThrow(); expect(() => validateSourceId('-leading')).toThrow(); // edge hyphen expect(() => validateSourceId('trailing-')).toThrow(); const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars expect(() => validateSourceId(tooLong)).toThrow(); }); test('reverse-write disk layout uses .sources//.md for non-default (F6)', () => { // Pure-function test of the disk-path computation embedded in // reverseWriteRefs (patterns.ts + synthesize.ts). We don't drive the // full dream cycle here — that requires LLM + subagent infra. Instead // we replicate the path computation and verify the file layout matches // what the production code would write. const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-multi-source-disk-')); try { const computePath = (source_id: string, slug: string): string => source_id === 'default' ? join(tmpDir, `${slug}.md`) : join(tmpDir, '.sources', source_id, `${slug}.md`); const defaultPath = computePath('default', 'people/alice'); const mediaPath = computePath('media-corpus', 'people/alice'); // The two paths must NOT collide. expect(defaultPath).not.toBe(mediaPath); expect(mediaPath).toContain('.sources/media-corpus/'); // Actually write to both paths to prove disk separation. mkdirSync(join(tmpDir, 'people'), { recursive: true }); mkdirSync(join(tmpDir, '.sources', 'media-corpus', 'people'), { recursive: true }); writeFileSync(defaultPath, 'default alice'); writeFileSync(mediaPath, 'media-corpus alice'); expect(readFileSync(defaultPath, 'utf8')).toBe('default alice'); expect(readFileSync(mediaPath, 'utf8')).toBe('media-corpus alice'); expect(existsSync(defaultPath)).toBe(true); expect(existsSync(mediaPath)).toBe(true); } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); });