mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain told the user the operation succeeded when it didn't. * #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught error and surface `[dream] WARNING: could not connect to DB (...)` on stderr before falling through to filesystem-only phases. runDream(null) no-DB fallback preserved. * #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md / index.md / README.md pages on every re-sync. Refactor isSyncable through private classifySync helper; expose unsyncableReason (companion returning the same tagged reason) and SYNC_SKIP_FILES named export. Cleanup loop guards on reason === 'metafile' before deleting. * #1434 — `gbrain sync` without --source on single-vault brains routed to source_id='default' (zero pages) and silently failed. Add resolver tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent wins). Wire runSync + runImport to call resolveSourceWithTier unconditionally so the tier actually fires. Stderr nudge on tier hit; suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1. * #1309 — overlapping ingest roots created duplicate pages. New BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with identity-based posture: SKIP when frontmatter.id matches (true external duplicate), WARN-ALWAYS on content_hash collision with different/missing fm.id, FAIL CLOSED on lookup error. Migration v95 adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite plain CREATE). * #1436 — MCP fuzzy get_page returned slug candidates from sources outside caller's scope. resolveSlugs signature extended with {sourceId?, sourceIds?} matching the sourceScopeOpts helper output; operations.ts threads it through. Both engines preserve unscoped back-compat for internal CLI callers. Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id ASC, chunk_id ASC) in both engines. Caught while wiring the index above — basis-vector eval fixtures with tied scores depend on planner row order, which any new index on pages could flip. Pins eval-replay-gate ranking determinism against future index changes. Per codex review of the original plan: caught 6 load-bearing gaps that the engineering review missed (runSync bypass, #1436 misclassified as fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete filter missing, tier-ordering contradiction). All folded in pre-merge. Tests: 65 new wave cases across 7 new files + 1 extended; all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.13.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
/**
|
|
* v0.37.7.0 — resolveSourceWithTier() tier-attribution variant tests.
|
|
*
|
|
* Mirrors the 6-tier priority chain from source-resolver.test.ts but
|
|
* asserts the returned `tier` label matches the winning tier.
|
|
* Powers `gbrain sources current` so users can verify both the
|
|
* resolved source AND the reason it resolved.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
resolveSourceWithTier,
|
|
SOURCE_TIER_NAMES,
|
|
type SourceTier,
|
|
} from '../src/core/source-resolver.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
// Stub engine same shape as source-resolver.test.ts
|
|
function makeStub(
|
|
registeredSources: string[],
|
|
paths: Array<{ id: string; local_path: string }>,
|
|
defaultKey: string | null,
|
|
): BrainEngine {
|
|
return {
|
|
kind: 'pglite',
|
|
executeRaw: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {
|
|
if (sql.includes('SELECT id FROM sources WHERE id = $1')) {
|
|
const target = params?.[0];
|
|
return (registeredSources.includes(target as string)
|
|
? [{ id: target } as unknown as T]
|
|
: []);
|
|
}
|
|
if (sql.includes('SELECT id, local_path FROM sources')) {
|
|
return paths as unknown as T[];
|
|
}
|
|
return [];
|
|
},
|
|
getConfig: async (key: string) => (key === 'sources.default' ? defaultKey : null),
|
|
} as unknown as BrainEngine;
|
|
}
|
|
|
|
describe('SOURCE_TIER_NAMES ordering matches resolveSourceId priority', () => {
|
|
test('canonical order is 1=flag → 7=seed_default with sole_non_default at 5.5', () => {
|
|
// v0.41.13 (#1434): tier 5.5 `sole_non_default` slots between brain_default
|
|
// and seed_default. Explicit user intent (sources.default config) wins
|
|
// over the auto-routing; seed terminal still loses to anything.
|
|
expect(SOURCE_TIER_NAMES).toEqual([
|
|
'flag',
|
|
'env',
|
|
'dotfile',
|
|
'local_path',
|
|
'brain_default',
|
|
'sole_non_default',
|
|
'seed_default',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 1 (flag)', () => {
|
|
test('explicit flag returns tier=flag with detail naming the value', async () => {
|
|
const engine = makeStub(['default', 'dept-x'], [], null);
|
|
const result = await resolveSourceWithTier(engine, 'dept-x', '/tmp');
|
|
expect(result.source_id).toBe('dept-x');
|
|
expect(result.tier).toBe('flag');
|
|
expect(result.detail).toContain('--source dept-x');
|
|
});
|
|
|
|
test('rejects unregistered explicit source', async () => {
|
|
const engine = makeStub(['default'], [], null);
|
|
await expect(resolveSourceWithTier(engine, 'ghost', '/tmp')).rejects.toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 2 (env)', () => {
|
|
test('GBRAIN_SOURCE env returns tier=env when no flag', async () => {
|
|
const engine = makeStub(['default', 'wiki'], [], null);
|
|
await withEnv({ GBRAIN_SOURCE: 'wiki' }, async () => {
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('wiki');
|
|
expect(result.tier).toBe('env');
|
|
expect(result.detail).toBe('GBRAIN_SOURCE=wiki');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 3 (dotfile)', () => {
|
|
let scratchDir: string;
|
|
beforeEach(() => {
|
|
scratchDir = mkdtempSync(join(tmpdir(), 'gbrain-tier-dotfile-'));
|
|
});
|
|
afterEach(() => {
|
|
rmSync(scratchDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('.gbrain-source dotfile in CWD returns tier=dotfile', async () => {
|
|
writeFileSync(join(scratchDir, '.gbrain-source'), 'team-alpha\n');
|
|
const engine = makeStub(['default', 'team-alpha'], [], null);
|
|
const result = await resolveSourceWithTier(engine, null, scratchDir);
|
|
expect(result.source_id).toBe('team-alpha');
|
|
expect(result.tier).toBe('dotfile');
|
|
expect(result.detail).toBe('.gbrain-source');
|
|
});
|
|
|
|
test('dotfile in ancestor directory walks up to find it', async () => {
|
|
writeFileSync(join(scratchDir, '.gbrain-source'), 'team-alpha\n');
|
|
const nested = join(scratchDir, 'a', 'b', 'c');
|
|
mkdirSync(nested, { recursive: true });
|
|
const engine = makeStub(['default', 'team-alpha'], [], null);
|
|
const result = await resolveSourceWithTier(engine, null, nested);
|
|
expect(result.tier).toBe('dotfile');
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 4 (local_path)', () => {
|
|
test('registered source whose local_path contains CWD returns tier=local_path', async () => {
|
|
const engine = makeStub(
|
|
['default', 'gstack'],
|
|
[{ id: 'gstack', local_path: '/work/gstack' }],
|
|
null,
|
|
);
|
|
const result = await resolveSourceWithTier(engine, null, '/work/gstack/src');
|
|
expect(result.source_id).toBe('gstack');
|
|
expect(result.tier).toBe('local_path');
|
|
expect(result.detail).toContain('/work/gstack');
|
|
});
|
|
|
|
test('longest-prefix wins on nested registered sources', async () => {
|
|
const engine = makeStub(
|
|
['default', 'parent', 'child'],
|
|
[
|
|
{ id: 'parent', local_path: '/work' },
|
|
{ id: 'child', local_path: '/work/sub' },
|
|
],
|
|
null,
|
|
);
|
|
const result = await resolveSourceWithTier(engine, null, '/work/sub/file');
|
|
expect(result.source_id).toBe('child');
|
|
expect(result.tier).toBe('local_path');
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 5 (brain_default)', () => {
|
|
test('sources.default config returns tier=brain_default', async () => {
|
|
const engine = makeStub(['default', 'dept-x'], [], 'dept-x');
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp/no-dotfile-here');
|
|
expect(result.source_id).toBe('dept-x');
|
|
expect(result.tier).toBe('brain_default');
|
|
expect(result.detail).toContain('sources.default');
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — tier 6 (seed_default)', () => {
|
|
test('no other signals returns tier=seed_default with no detail', async () => {
|
|
const engine = makeStub(['default'], [], null);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp/no-dotfile-here');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('seed_default');
|
|
expect(result.detail).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('resolveSourceWithTier — priority assertion', () => {
|
|
test('flag wins over env wins over dotfile wins over default', async () => {
|
|
// Set up a stub where ALL tiers could resolve. Assert the
|
|
// higher-priority one wins.
|
|
const engine = makeStub(
|
|
['default', 'flag-src', 'env-src', 'dot-src', 'default-src'],
|
|
[],
|
|
'default-src',
|
|
);
|
|
await withEnv({ GBRAIN_SOURCE: 'env-src' }, async () => {
|
|
// Flag highest priority
|
|
const r1 = await resolveSourceWithTier(engine, 'flag-src', '/tmp');
|
|
expect(r1.tier).toBe('flag');
|
|
// Without flag → env
|
|
const r2 = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(r2.tier).toBe('env');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Typecheck-only assertion: SourceTier is the union of SOURCE_TIER_NAMES.
|
|
const _exhaustiveCheck: SourceTier = 'flag';
|
|
void _exhaustiveCheck;
|