Files
gbrain/test/sync-isSyncable-shape.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

63 lines
2.9 KiB
TypeScript

/**
* v0.41.13 (#1433) — isSyncable / unsyncableReason classifier shape.
*
* The two public APIs route through the same internal `classifySync`
* helper so they cannot drift. These tests pin the contract that
* `isSyncable` returns true iff `unsyncableReason` returns null, AND
* pin the canonical SYNC_SKIP_FILES list (since the cleanup-loop guard
* at commands/sync.ts:772 keys on `unsyncableReason === 'metafile'`).
*/
import { describe, test, expect } from 'bun:test';
import {
isSyncable,
unsyncableReason,
SYNC_SKIP_FILES,
type SyncableReason,
} from '../src/core/sync.ts';
describe('#1433 — isSyncable / unsyncableReason are duals of one classifier', () => {
const cases: Array<{ path: string; expected: SyncableReason | null; note: string }> = [
{ path: 'people/alice.md', expected: null, note: 'normal markdown page' },
{ path: 'docs/guide.mdx', expected: null, note: 'mdx accepted by markdown strategy' },
{ path: 'learning-and-strategy/log.md', expected: 'metafile', note: 'log.md anywhere is metafile' },
{ path: 'wiki/schema.md', expected: 'metafile', note: 'schema.md anywhere is metafile' },
{ path: 'index.md', expected: 'metafile', note: 'top-level index.md' },
{ path: 'README.md', expected: 'metafile', note: 'top-level README' },
{ path: 'docs/README.md', expected: 'metafile', note: 'nested README' },
{ path: 'people/alice.txt', expected: 'strategy', note: '.txt rejected by markdown strategy' },
{ path: 'ops/scratch/note.md', expected: 'pruned-dir', note: 'ops/ is pruned' },
{ path: '.git/notes.md', expected: 'pruned-dir', note: 'hidden dir pruned' },
{ path: 'node_modules/foo/README.md', expected: 'pruned-dir', note: 'node_modules pruned' },
];
for (const c of cases) {
test(`${c.path}${c.expected ?? 'syncable'} (${c.note})`, () => {
const reason = unsyncableReason(c.path);
const sync = isSyncable(c.path);
expect(reason).toBe(c.expected);
expect(sync).toBe(c.expected === null);
});
}
test('include glob: path not matching include returns include-glob-miss', () => {
expect(unsyncableReason('docs/guide.md', { include: ['people/**'] })).toBe('include-glob-miss');
expect(isSyncable('docs/guide.md', { include: ['people/**'] })).toBe(false);
});
test('exclude glob: matching path returns exclude-glob-hit', () => {
expect(unsyncableReason('drafts/wip.md', { exclude: ['drafts/**'] })).toBe('exclude-glob-hit');
expect(isSyncable('drafts/wip.md', { exclude: ['drafts/**'] })).toBe(false);
});
test('SYNC_SKIP_FILES export contains the canonical four basenames', () => {
expect([...SYNC_SKIP_FILES]).toEqual(['schema.md', 'index.md', 'log.md', 'README.md']);
});
test('isSyncable(p) === (unsyncableReason(p) === null) — duality holds for all canonical cases', () => {
for (const c of cases) {
expect(isSyncable(c.path)).toBe(unsyncableReason(c.path) === null);
}
});
});