mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* test: symlink-walker tests use a non-metafile probe (README now skipped by design, #2315) The import walker deliberately skips README/metafiles since #2315 (closing #345); the symlink-hardening tests used README.md as their probe file and went red on the intersection. Probe with notes.md instead; test intent (cycle hardening + strategy filter) unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: grant security-events write to the OSV caller job — reusable workflow requires it at startup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
165 lines
7.0 KiB
TypeScript
165 lines
7.0 KiB
TypeScript
/**
|
|
* v0.31.2 walker hardening regression tests.
|
|
*
|
|
* Closes the bug class where `gbrain sync --strategy code` could hang on
|
|
* repos with self-referencing symlinks (the gstack `.claude/skills/gstack
|
|
* -> repo-root` dev pattern was the trigger). Pins:
|
|
*
|
|
* 1. Self-referencing symlink does not loop.
|
|
* 2. Symlink chain through real dirs does not loop (inode-set defense).
|
|
* 3. MAX_WALK_DEPTH=32 bailout is structural backstop.
|
|
* 4. Strategy filter (code/markdown/auto) admits the right files.
|
|
* 5. Dot-prefixed dirs (.git/.claude/.raw) and node_modules still skipped.
|
|
* 6. Multimodal preservation under markdown-strategy (codex C5).
|
|
* 7. Deterministic ordering — runImport's index-based resume depends on it
|
|
* (codex C8).
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { collectSyncableFiles } from '../src/commands/import.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
let tmp: string;
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'gbrain-walker-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('collectSyncableFiles symlink + cycle hardening', () => {
|
|
test('1. self-referencing symlink does not loop', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
writeFileSync(join(tmp, 'notes.md'), '# top\n');
|
|
// Symlink "loop" inside tempdir pointing back to itself.
|
|
symlinkSync(tmp, join(tmp, 'loop'));
|
|
|
|
const t0 = Date.now();
|
|
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
const ms = Date.now() - t0;
|
|
|
|
expect(ms).toBeLessThan(1000); // would hang if walker followed the loop
|
|
expect(files).toContain(join(tmp, 'notes.md'));
|
|
expect(files.every(f => !f.includes('/loop/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('2. symlink chain through real dirs does not loop', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
// a/ contains a real subdir b/, which contains a symlink "back" -> a.
|
|
// The lstat skip catches "back" before recursion. If somehow it
|
|
// missed, the inode-cycle Map catches the second visit to a/.
|
|
mkdirSync(join(tmp, 'a/b'), { recursive: true });
|
|
writeFileSync(join(tmp, 'a/b/leaf.md'), 'leaf\n');
|
|
symlinkSync(join(tmp, 'a'), join(tmp, 'a/b/back'));
|
|
|
|
const t0 = Date.now();
|
|
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
const ms = Date.now() - t0;
|
|
|
|
expect(ms).toBeLessThan(2000);
|
|
expect(files).toContain(join(tmp, 'a/b/leaf.md'));
|
|
});
|
|
});
|
|
|
|
test('3. max-depth bailout terminates pathological deep trees', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
// Build a 40-level real directory tree. 32 is the default cap; the
|
|
// file at the deepest level is intentionally past the bailout so we
|
|
// can assert it is NOT collected (and the walker still terminates).
|
|
let cur = tmp;
|
|
for (let i = 0; i < 40; i++) {
|
|
cur = join(cur, `d${i}`);
|
|
mkdirSync(cur);
|
|
}
|
|
writeFileSync(join(cur, 'deep.md'), 'deep\n');
|
|
writeFileSync(join(tmp, 'shallow.md'), 'shallow\n');
|
|
|
|
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
|
|
expect(files).toContain(join(tmp, 'shallow.md'));
|
|
expect(files.every(f => !f.includes('/d35/'))).toBe(true); // past depth 32
|
|
});
|
|
});
|
|
|
|
test('4. strategy filter admits the right files', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
writeFileSync(join(tmp, 'notes.md'), '# r\n');
|
|
writeFileSync(join(tmp, 'foo.ts'), '// f\n');
|
|
writeFileSync(join(tmp, 'bar.py'), '# b\n');
|
|
|
|
const code = collectSyncableFiles(tmp, { strategy: 'code' });
|
|
const markdown = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
const auto = collectSyncableFiles(tmp, { strategy: 'auto' });
|
|
|
|
expect(code.map(f => f.split('/').pop()).sort()).toEqual(['bar.py', 'foo.ts']);
|
|
expect(markdown.map(f => f.split('/').pop())).toEqual(['notes.md']);
|
|
expect(auto.map(f => f.split('/').pop()).sort()).toEqual(['bar.py', 'foo.ts', 'notes.md']);
|
|
});
|
|
});
|
|
|
|
test('5. dot-prefixed dirs and node_modules still skipped', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
writeFileSync(join(tmp, 'real.md'), 'r\n');
|
|
mkdirSync(join(tmp, '.git'));
|
|
writeFileSync(join(tmp, '.git/HEAD'), 'ref: refs/heads/main\n');
|
|
mkdirSync(join(tmp, '.claude/skills'), { recursive: true });
|
|
writeFileSync(join(tmp, '.claude/skills/SKILL.md'), 's\n');
|
|
mkdirSync(join(tmp, 'node_modules/foo'), { recursive: true });
|
|
writeFileSync(join(tmp, 'node_modules/foo/index.md'), 'no\n');
|
|
|
|
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
const names = files.map(f => f.replace(tmp, ''));
|
|
|
|
expect(names).toContain('/real.md');
|
|
expect(names.every(n => !n.startsWith('/.git'))).toBe(true);
|
|
expect(names.every(n => !n.startsWith('/.claude'))).toBe(true);
|
|
expect(names.every(n => !n.startsWith('/node_modules'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('6. multimodal preservation under markdown strategy (codex C5)', async () => {
|
|
writeFileSync(join(tmp, 'r.md'), 'r\n');
|
|
writeFileSync(join(tmp, 'p.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
writeFileSync(join(tmp, 'j.jpg'), Buffer.from([0xff, 0xd8, 0xff]));
|
|
|
|
// Off → markdown only.
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
const off = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
expect(off.map(f => f.split('/').pop()).sort()).toEqual(['r.md']);
|
|
});
|
|
|
|
// On → markdown + images (preserves v0.27.1 F2 collectMarkdownFiles
|
|
// behavior; codex C5 carve-out).
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: 'true' }, () => {
|
|
const on = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
expect(on.map(f => f.split('/').pop()).sort()).toEqual(['j.jpg', 'p.png', 'r.md']);
|
|
});
|
|
});
|
|
|
|
test('7. deterministic ordering — two walks return identical arrays (codex C8)', async () => {
|
|
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
|
|
// runImport's checkpoint resume at import.ts:68-74 is index-based
|
|
// against a sorted file list. Unstable order skips the wrong files
|
|
// on resume.
|
|
writeFileSync(join(tmp, 'b.md'), 'b\n');
|
|
writeFileSync(join(tmp, 'a.md'), 'a\n');
|
|
mkdirSync(join(tmp, 'sub'));
|
|
writeFileSync(join(tmp, 'sub/c.md'), 'c\n');
|
|
|
|
const first = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
const second = collectSyncableFiles(tmp, { strategy: 'markdown' });
|
|
|
|
expect(first).toEqual(second);
|
|
// Sorted: a.md, b.md, sub/c.md (lexicographic on absolute paths).
|
|
expect(first.map(f => f.replace(tmp, ''))).toEqual([
|
|
'/a.md', '/b.md', '/sub/c.md',
|
|
]);
|
|
});
|
|
});
|
|
});
|