diff --git a/src/commands/import.ts b/src/commands/import.ts index d9b160a4d..241bcff28 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -12,6 +12,7 @@ import { isMarkdownFilePath, isImageFilePath as isImageFilePathFromSync, pruneDir, + SYNC_SKIP_FILES, type SyncStrategy, } from '../core/sync.ts'; import { sortNewestFirst } from '../core/sort-newest-first.ts'; @@ -493,12 +494,28 @@ interface CollectOpts { * The first-sync walker historically admitted them on markdown too when * `GBRAIN_EMBEDDING_MULTIMODAL=true`. Codex (C5) flagged the contradiction * — preserve the walker semantic explicitly. + * + * Closes #345: exclude `SYNC_SKIP_FILES` metafiles + * (`README.md` / `index.md` / `log.md` / `schema.md` / `RESOLVER.md`). + * Incremental `sync` skips these via `isSyncable`, but the bulk-import + * walker only filtered by extension — so a directory import imported every + * directory README as a page, titled by its folder ("People", "Companies", + * …). Those index-titled pages then trigram-corrupt fuzzy entity resolution + * (any `people/X` slug matches the "People" page) and inflate orphan count. + * Funnel both admission paths through the same metafile exclusion so import + * and sync agree on what is a page. */ function isCollectibleForWalker( path: string, strategy: SyncStrategy, multimodalOn: boolean, ): boolean { + // Metafiles are directory scaffolding (READMEs / index / log / schema / + // resolver), not typed brain pages — same exclusion `sync`'s `isSyncable` + // applies. Guards both the FS-walk and the git-fast-path collection routes. + const basename = path.split('/').pop() || ''; + if ((SYNC_SKIP_FILES as readonly string[]).includes(basename)) return false; + switch (strategy) { case 'code': return isCodeFilePath(path); diff --git a/src/core/sync.ts b/src/core/sync.ts index c2c46a2e1..a0d4856f0 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -324,10 +324,18 @@ export type SyncableReason = * surface them in user-facing logs / docs without re-declaring the list. * * These files are append-only domain logs / index pages / boilerplate - * READMEs — not typed brain pages — by convention. A user who genuinely - * wants to index one of these basenames as a page should rename it. + * READMEs / the master filing decision-tree — not typed brain pages — by + * convention. A user who genuinely wants to index one of these basenames as + * a page should rename it. + * + * `RESOLVER.md` is the brain's master routing/decision-tree config file. The + * recommended-schema docs group it with `schema.md` / `index.md` / `log.md` + * as a structural document ("a document … plus schema.md and RESOLVER.md … + * that tells the agent how the brain is structured"), NOT searchable content. + * It was the lone structural sibling missing from this list, so it leaked + * into the index as a content page (slug `resolver`). */ -export const SYNC_SKIP_FILES = ['schema.md', 'index.md', 'log.md', 'README.md'] as const; +export const SYNC_SKIP_FILES = ['schema.md', 'index.md', 'log.md', 'README.md', 'RESOLVER.md'] as const; /** * Internal classifier. Returns null when the path IS syncable, or a tagged diff --git a/test/import-metafile-skip.test.ts b/test/import-metafile-skip.test.ts new file mode 100644 index 000000000..bc5184d6d --- /dev/null +++ b/test/import-metafile-skip.test.ts @@ -0,0 +1,67 @@ +/** + * Closes #345: the bulk-import walker must skip SYNC_SKIP_FILES metafiles + * (README.md / index.md / log.md / schema.md / RESOLVER.md), the same way + * incremental `sync` (isSyncable) does. + * + * Root cause this locks: a directory-import pass imported every directory + * README.md as a page (titled "People", "Companies", …), because + * collectSyncableFiles only filtered by extension. Those index-titled pages + * then trigram-corrupted fuzzy entity resolution and inflated orphan count. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { join, basename } from 'path'; +import { tmpdir } from 'os'; +import { collectSyncableFiles } from '../src/commands/import.ts'; + +let tmp: string; + +function write(relPath: string, content: string): void { + const full = join(tmp, relPath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'gbrain-import-metafile-')); +}); +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('collectSyncableFiles metafile exclusion (closes #345)', () => { + function seed(): void { + write('people/example-person.md', '# Example Person\n'); + write('people/README.md', '# People\n\nOne page per person.\n'); + write('companies/README.md', '# Companies\n'); + write('README.md', '# Brain\n'); + write('index.md', '# Brain Index\n'); + write('log.md', '# Brain Log\n'); + write('schema.md', '# Brain Schema\n'); + write('RESOLVER.md', '# Brain Resolver\n'); + } + + test('FS-walk path excludes README/index/log/schema/RESOLVER, keeps real pages', () => { + seed(); + const got = collectSyncableFiles(tmp).map(f => basename(f)); + expect(got).toContain('example-person.md'); + for (const meta of ['README.md', 'index.md', 'log.md', 'schema.md', 'RESOLVER.md']) { + expect(got).not.toContain(meta); + } + }); + + test('git-fast-path also excludes metafiles', () => { + seed(); + execFileSync('git', ['-C', tmp, 'init', '-q'], { stdio: 'ignore' }); + execFileSync('git', ['-C', tmp, 'add', '-A'], { stdio: 'ignore' }); + const got = collectSyncableFiles(tmp).map(f => basename(f)); + expect(got).toContain('example-person.md'); + expect(got.filter(n => n === 'README.md')).toHaveLength(0); + expect(got).not.toContain('index.md'); + expect(got).not.toContain('log.md'); + expect(got).not.toContain('schema.md'); + expect(got).not.toContain('RESOLVER.md'); + }); +}); diff --git a/test/sync-isSyncable-shape.test.ts b/test/sync-isSyncable-shape.test.ts index 4511d297c..44b29c2c8 100644 --- a/test/sync-isSyncable-shape.test.ts +++ b/test/sync-isSyncable-shape.test.ts @@ -25,6 +25,8 @@ describe('#1433 — isSyncable / unsyncableReason are duals of one classifier', { 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: 'RESOLVER.md', expected: 'metafile', note: 'top-level master routing config (closes #345)' }, + { path: 'brain/RESOLVER.md', expected: 'metafile', note: 'RESOLVER.md anywhere is metafile (closes #345)' }, { 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' }, @@ -50,8 +52,8 @@ describe('#1433 — isSyncable / unsyncableReason are duals of one classifier', 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('SYNC_SKIP_FILES export contains the canonical structural metafiles', () => { + expect([...SYNC_SKIP_FILES]).toEqual(['schema.md', 'index.md', 'log.md', 'README.md', 'RESOLVER.md']); }); test('isSyncable(p) === (unsyncableReason(p) === null) — duality holds for all canonical cases', () => {