Files
gbrain/test/operations-fuzzy-source-scope.test.ts
T
f56cc619ba v0.41.13.0 fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) (#1456)
* 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>
2026-05-25 19:53:59 -07:00

117 lines
5.3 KiB
TypeScript

/**
* v0.41.13 (#1436) — MCP fuzzy `get_page` resolver scopes by source.
*
* Pre-fix bug (infiniteGameExp): the `get_page` op handler in
* operations.ts called `ctx.engine.resolveSlugs(slug)` with no source
* filter. When the caller's MCP context bound them to a specific
* source (or a federated_read array), the fuzzy resolver could return
* candidates from sources they shouldn't see. The handler then loaded
* each candidate via `getPage(..., sourceOpts)` which IS scoped — so
* the visible failure mode was "fuzzy returned a candidate but exact
* lookup 404'd it." The bigger concern is that the candidate slug
* leaks via the `ambiguous_slug` error envelope.
*
* Fix: thread `sourceScopeOpts(ctx)` into the `resolveSlugs(slug, opts)`
* call. Engine method signature accepts `{sourceId?, sourceIds?}` —
* field names match `sourceScopeOpts` output so callers spread cleanly.
* Unscoped behavior preserved when both fields are undefined (back-compat
* for internal CLI callers).
*
* This test seeds the same slug under two source_ids, runs the fuzzy
* resolver under each context, and asserts the right candidates surface.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 60_000);
beforeEach(async () => {
await resetPgliteState(engine);
// Register two sources we can isolate against. addSource via raw SQL is
// cleaner here than going through runSources (less argv plumbing).
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('alpha', 'alpha', '/tmp/alpha') ON CONFLICT (id) DO NOTHING`);
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
// Seed the SAME slug under both sources. Pre-fix, fuzzy resolveSlugs
// would return both candidates regardless of which source the caller
// was scoped to.
await engine.putPage('people/alice', {
type: 'person',
title: 'Alice (alpha)',
compiled_truth: 'Alpha-source Alice page.',
frontmatter: { type: 'person' },
}, { sourceId: 'alpha' });
await engine.putPage('people/alice', {
type: 'person',
title: 'Alice (beta)',
compiled_truth: 'Beta-source Alice page.',
frontmatter: { type: 'person' },
}, { sourceId: 'beta' });
});
describe('#1436 — resolveSlugs honors source scope', () => {
test('opts.sourceId scopes exact match to a single source', async () => {
const alphaHit = await engine.resolveSlugs('people/alice', { sourceId: 'alpha' });
expect(alphaHit).toEqual(['people/alice']);
const betaHit = await engine.resolveSlugs('people/alice', { sourceId: 'beta' });
expect(betaHit).toEqual(['people/alice']);
// Both return the same slug, but the calling op will then load the
// page under the SAME sourceId, so cross-source leak is closed.
});
test('opts.sourceIds (federated_read array) restricts to the listed sources', async () => {
const both = await engine.resolveSlugs('people/alice', { sourceIds: ['alpha', 'beta'] });
expect(both).toEqual(['people/alice']);
const alphaOnly = await engine.resolveSlugs('people/alice', { sourceIds: ['alpha'] });
expect(alphaOnly).toEqual(['people/alice']);
});
test('unscoped call (no opts) preserves pre-fix back-compat for internal callers', async () => {
// Internal CLI callers (gbrain query --resolve, etc.) walk every source.
const unscoped = await engine.resolveSlugs('people/alice');
expect(unscoped).toEqual(['people/alice']);
});
test('opts.sourceId for a source with NO matching slug returns empty', async () => {
// Register a third source with nothing in it.
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('gamma', 'gamma', '/tmp/gamma') ON CONFLICT (id) DO NOTHING`);
const gamma = await engine.resolveSlugs('people/alice', { sourceId: 'gamma' });
expect(gamma).toEqual([]);
});
test('fuzzy match honors source scope', async () => {
// 'people/alic' (typo) should fuzzy-resolve in each scope but only
// see candidates in that source. With our seed of just two
// 'people/alice' rows, both scopes should each return a single match.
const alphaFuzzy = await engine.resolveSlugs('people/alic', { sourceId: 'alpha' });
expect(alphaFuzzy.length).toBeGreaterThan(0);
expect(alphaFuzzy[0]).toBe('people/alice');
const gamma = await engine.resolveSlugs('people/alic', { sourceId: 'gamma' });
expect(gamma).toEqual([]);
});
test('soft-deleted rows are excluded from fuzzy candidates', async () => {
// Delete the alpha row; resolveSlugs should NOT return its slug
// anymore under scope:alpha. Beta row stays visible.
await engine.softDeletePage('people/alice', { sourceId: 'alpha' });
const alpha = await engine.resolveSlugs('people/alice', { sourceId: 'alpha' });
expect(alpha).toEqual([]);
const beta = await engine.resolveSlugs('people/alice', { sourceId: 'beta' });
expect(beta).toEqual(['people/alice']);
});
});