Files
gbrain/test/brain-writer-walk-prune.test.ts

216 lines
10 KiB
TypeScript

/**
* v0.38.2.0 — descent-time pruning regression suite.
*
* The original bug (PR #1287 reported, this PR fixes): `gbrain doctor` hung
* indefinitely on a 216K-page brain because the frontmatter walker descended
* into every node_modules / .git / .obsidian / *.raw / ops subtree on disk
* and let `isSyncable` filter at the leaf — paying the IO cost of stat'ing
* hundreds of thousands of vendor entries that were never going to be parsed.
*
* Why output-based tests don't catch this: `isSyncable` rejects the
* vendor-tree files at the leaf, so a test that just asserts "no bad
* markdown reported" passes BOTH before and after Fix 1 (codex outside-voice
* C6). The load-bearing assertion is `walker did NOT DESCEND` — fired by
* the new `visitDir` test seam.
*
* This file covers both walkers that were missing pruneDir:
* - brain-writer.ts:walkDir (driven by scanBrainSources / doctor)
* - frontmatter.ts:collectFiles (driven by `gbrain frontmatter validate`)
*/
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, sep } from 'path';
import { tmpdir } from 'os';
import { scanBrainSources, walkDir } from '../src/core/brain-writer.ts';
import { collectFiles } from '../src/commands/frontmatter.ts';
/**
* Build a separator-prefixed path fragment for suffix/substring predicates.
* The walkers emit native-separator paths, so a hardcoded '/' fragment makes
* the positive assertions fail on Windows AND — worse — makes every negative
* `toBe(false)` guard pass vacuously, proving nothing. Byte-identical to the
* old '/foo/bar' literals on POSIX.
*/
const seg = (...parts: string[]) => sep + join(...parts);
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'walk-prune-'));
// Real syncable files under regular dirs — walker MUST descend here.
mkdirSync(join(root, 'people'), { recursive: true });
writeFileSync(join(root, 'people', 'alice.md'), '---\ntitle: Alice\n---\n\nbody\n');
mkdirSync(join(root, 'concepts', 'subdir'), { recursive: true });
writeFileSync(join(root, 'concepts', 'subdir', 'thing.md'), '---\ntitle: Thing\n---\n\nbody\n');
// Vendor / hidden / generated trees — walker MUST NOT descend.
mkdirSync(join(root, 'node_modules', 'fake-pkg'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'fake-pkg', 'README.md'), '# Should not be visited\n');
mkdirSync(join(root, '.git', 'objects'), { recursive: true });
writeFileSync(join(root, '.git', 'config'), '[core]\n');
mkdirSync(join(root, '.obsidian'), { recursive: true });
writeFileSync(join(root, '.obsidian', 'workspace.json'), '{}');
mkdirSync(join(root, 'people', 'pedro.raw'), { recursive: true });
writeFileSync(join(root, 'people', 'pedro.raw', 'source.md'), '---\ntitle: should not visit\n---\n');
// ops/ is ORDINARY content (#2404) — walker MUST descend (it used to be
// wrongly pruned, silently excluding user runbooks / ops/tasks).
mkdirSync(join(root, 'ops', 'logs'), { recursive: true });
writeFileSync(join(root, 'ops', 'logs', 'run.md'), '---\ntitle: Run\n---\n\nbody\n');
// Nested node_modules — must also be pruned, not just at the root.
mkdirSync(join(root, 'people', 'tools', 'node_modules', 'inner'), { recursive: true });
writeFileSync(join(root, 'people', 'tools', 'node_modules', 'inner', 'a.md'), '---\ntitle: nope\n---\n');
// Git-submodule pattern: a dir containing `.git` as a FILE (gitfile).
mkdirSync(join(root, 'people', 'submod'), { recursive: true });
writeFileSync(join(root, 'people', 'submod', '.git'), 'gitdir: ../../.git/modules/submod\n');
writeFileSync(join(root, 'people', 'submod', 'README.md'), '---\ntitle: submod page\n---\n');
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
describe('walkDir (brain-writer.ts) — descent-time pruning', () => {
test('does NOT descend into node_modules at any depth', () => {
const visited: string[] = [];
walkDir(root, () => {}, (dir) => visited.push(dir));
expect(visited.some(d => d.includes(seg('node_modules')))).toBe(false);
});
test('does NOT descend into .git', () => {
const visited: string[] = [];
walkDir(root, () => {}, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith(seg('.git')) || d.includes(seg('.git') + sep))).toBe(false);
});
test('does NOT descend into .obsidian (dot-prefix heuristic)', () => {
const visited: string[] = [];
walkDir(root, () => {}, (dir) => visited.push(dir));
expect(visited.some(d => d.includes(seg('.obsidian')))).toBe(false);
});
test('does NOT descend into *.raw sidecar dirs', () => {
const visited: string[] = [];
walkDir(root, () => {}, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith('.raw'))).toBe(false);
});
test('does NOT descend into git submodule directories (.git as FILE)', () => {
const visited: string[] = [];
walkDir(root, () => {}, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith(seg('people', 'submod')))).toBe(false);
});
test('DOES descend into regular subdirs and visits .md files there', () => {
const visited: string[] = [];
const files: string[] = [];
walkDir(root, (f) => { files.push(f); }, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith(seg('people')))).toBe(true);
expect(visited.some(d => d.endsWith(seg('concepts', 'subdir')))).toBe(true);
// ops/ is ordinary content — descended, not pruned (#2404).
expect(visited.some(d => d.endsWith(seg('ops', 'logs')))).toBe(true);
expect(files.some(f => f.endsWith(seg('people', 'alice.md')))).toBe(true);
expect(files.some(f => f.endsWith(seg('concepts', 'subdir', 'thing.md')))).toBe(true);
expect(files.some(f => f.endsWith(seg('ops', 'logs', 'run.md')))).toBe(true);
// And explicitly does NOT visit the file under node_modules.
expect(files.some(f => f.includes(seg('node_modules') + sep))).toBe(false);
});
test('regression: pre-v0.38.2.0 walker would have descended into node_modules and stat\'d every entry', () => {
// This is the load-bearing assertion. If a future contributor removes
// the `pruneDir(name, dir)` gate in walkDir, this test fails because
// visitDir would be called with node_modules paths.
const descents: string[] = [];
walkDir(root, () => {}, (d) => descents.push(d));
const vendor = descents.filter(d => /[\\/](node_modules|\.git|\.obsidian)([\\/]|$)/.test(d) || /\.raw$/.test(d));
expect(vendor).toEqual([]);
});
});
describe('collectFiles (frontmatter.ts) — descent-time pruning parity', () => {
test('does NOT descend into node_modules at any depth', () => {
const visited: string[] = [];
collectFiles(root, (dir) => visited.push(dir));
expect(visited.some(d => d.includes(seg('node_modules')))).toBe(false);
});
test('does NOT descend into .git, .obsidian, or *.raw', () => {
const visited: string[] = [];
collectFiles(root, (dir) => visited.push(dir));
expect(visited.some(d => d.includes(seg('.git')))).toBe(false);
expect(visited.some(d => d.includes(seg('.obsidian')))).toBe(false);
expect(visited.some(d => d.endsWith('.raw'))).toBe(false);
});
test('DOES descend into ops/ — ordinary content, not a vendor tree (#2404)', () => {
const visited: string[] = [];
collectFiles(root, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith(seg('ops')) || d.includes(seg('ops') + sep))).toBe(true);
const files = collectFiles(root);
expect(files.some(f => f.endsWith(seg('ops', 'logs', 'run.md')))).toBe(true);
});
test('does NOT descend into git submodule directories', () => {
const visited: string[] = [];
collectFiles(root, (dir) => visited.push(dir));
expect(visited.some(d => d.endsWith(seg('people', 'submod')))).toBe(false);
});
test('DOES collect .md files under regular subdirs', () => {
const files = collectFiles(root);
expect(files.some(f => f.endsWith(seg('people', 'alice.md')))).toBe(true);
expect(files.some(f => f.endsWith(seg('concepts', 'subdir', 'thing.md')))).toBe(true);
expect(files.some(f => f.includes(seg('node_modules') + sep))).toBe(false);
expect(files.some(f => f.includes(seg('.git') + sep))).toBe(false);
expect(files.some(f => f.includes('.raw' + sep))).toBe(false);
});
test('single-file target returns that file unchanged (no walk)', () => {
const target = join(root, 'people', 'alice.md');
const files = collectFiles(target);
expect(files).toEqual([target]);
});
});
describe('frontmatter walkers — git-visible file parity', () => {
test('collectFiles respects .git/info/exclude like sync/import', () => {
const repo = mkdtempSync(join(tmpdir(), 'frontmatter-git-visible-'));
try {
execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' });
mkdirSync(join(repo, 'people'), { recursive: true });
mkdirSync(join(repo, 'local-skills'), { recursive: true });
writeFileSync(join(repo, '.git', 'info', 'exclude'), 'local-skills/\n');
writeFileSync(join(repo, 'people', 'alice.md'), '---\ntitle: Alice\n---\n\nbody\n');
writeFileSync(join(repo, 'local-skills', 'SKILL.md'), '---\nname: bad\n# malformed frontmatter\n');
const files = collectFiles(repo).map((f) => f.replace(repo + sep, ''));
expect(files).toContain(join('people', 'alice.md'));
expect(files).not.toContain(join('local-skills', 'SKILL.md'));
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test('scanBrainSources ignores git-excluded malformed markdown', async () => {
const repo = mkdtempSync(join(tmpdir(), 'frontmatter-audit-git-visible-'));
try {
execFileSync('git', ['init'], { cwd: repo, stdio: 'ignore' });
mkdirSync(join(repo, 'people'), { recursive: true });
mkdirSync(join(repo, 'local-skills'), { recursive: true });
writeFileSync(join(repo, '.git', 'info', 'exclude'), 'local-skills/\n');
writeFileSync(join(repo, 'people', 'alice.md'), '---\ntitle: Alice\n---\n\nbody\n');
writeFileSync(join(repo, 'local-skills', 'SKILL.md'), '---\nname: bad\n# malformed frontmatter\n');
const engine = {
executeRaw: async () => [{ id: 'repo', local_path: repo }],
} as any;
const report = await scanBrainSources(engine, { sourceId: 'repo' });
expect(report.total).toBe(0);
expect(report.per_source[0].files_scanned).toBe(1);
expect(report.per_source[0].sample).toEqual([]);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
});