Files
gbrain/test/sync.test.ts
Garry Tan 8de04d3827 fix: community fix wave — 9 PRs, 8 contributors (v0.6.1) (#38)
* fix: validateSlug accepts ellipsis filenames, rejects only real path traversal

Changed regex from /\.\./ to /(^|\/)\.\.($|\/)/ so filenames with "..." (like
YouTube transcripts, TED talks, podcast titles) are no longer falsely rejected.
The old regex matched ".." anywhere as a substring. The new one only matches ".."
as a complete path component (e.g., ../foo, foo/../bar, bare ..).

Fixes 1.2% silent data loss on real-world import corpora.

Co-Authored-By: orendi84 <orendi84@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: import walker skips node_modules, handles broken symlinks, supports .mdx

Three improvements to the file walker:
- Skip node_modules directories (prevents crashes importing JS/TS projects)
- try/catch around statSync for broken symlinks (warns and continues)
- Accept .mdx files alongside .md (extends to slugifyPath and isSyncable)

Co-Authored-By: mattbratos <mattbratos@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: init exits cleanly, auto-creates pgvector, updates Supabase UI hint

Three init improvements:
- process.stdin.pause() after reading URL input (prevents event loop hang)
- Auto-run CREATE EXTENSION IF NOT EXISTS vector with fallback message
- Update Supabase session pooler navigation hint to match current dashboard UI

Co-Authored-By: changergosum <changergosum@users.noreply.github.com>
Co-Authored-By: eric-hth <eric-hth@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* perf: parallelize keyword search with embedding pipeline

Run keyword search concurrently with the embed+vector pipeline instead of
sequentially. Keyword search has no embedding dependency so it can overlap
with the OpenAI API call, saving ~200-500ms per search.

Co-Authored-By: irresi <irresi@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update Hermes Agent link to NousResearch GitHub repo

Co-Authored-By: howardpen9 <howardpen9@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add community PR wave process to CLAUDE.md

Documents the fix wave workflow: categorize, deduplicate, collector branch,
test, close with context, ship as one PR with attribution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump version and changelog (v0.6.1)

Community fix wave: 9 PRs re-implemented with full test coverage.
6 bug fixes, 1 perf improvement, 2 feature additions, 8 contributors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: migrate gstack from vendored to team mode

Remove vendored .claude/skills/gstack/ from git tracking. The global install
at ~/.claude/skills/gstack/ is the source of truth. Each developer runs
`cd ~/.claude/skills/gstack && ./setup` to set up symlink stubs locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: untrack skill symlink stubs

These are generated locally by gstack's ./setup script. Not project code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: credit community contributors in CHANGELOG

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update OpenClaw links from .com to .ai

openclaw.com is a parked page. openclaw.ai is the real product.

Co-Authored-By: joshua-morris <joshua-morris@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: orendi84 <orendi84@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: mattbratos <mattbratos@users.noreply.github.com>
Co-authored-by: changergosum <changergosum@users.noreply.github.com>
Co-authored-by: eric-hth <eric-hth@users.noreply.github.com>
Co-authored-by: irresi <irresi@users.noreply.github.com>
Co-authored-by: howardpen9 <howardpen9@users.noreply.github.com>
Co-authored-by: joshua-morris <joshua-morris@users.noreply.github.com>
2026-04-10 19:34:01 -10:00

193 lines
6.6 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { buildSyncManifest, isSyncable, pathToSlug } from '../src/core/sync.ts';
describe('buildSyncManifest', () => {
test('parses A/M/D entries from single commit', () => {
const output = `A\tpeople/new-person.md\nM\tpeople/existing-person.md\nD\tpeople/deleted-person.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/new-person.md']);
expect(manifest.modified).toEqual(['people/existing-person.md']);
expect(manifest.deleted).toEqual(['people/deleted-person.md']);
expect(manifest.renamed).toEqual([]);
});
test('parses R100 rename entries', () => {
const output = `R100\tpeople/old-name.md\tpeople/new-name.md`;
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toEqual([{ from: 'people/old-name.md', to: 'people/new-name.md' }]);
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
});
test('parses partial rename (R075)', () => {
const output = `R075\tpeople/old.md\tpeople/new.md`;
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toEqual([{ from: 'people/old.md', to: 'people/new.md' }]);
});
test('handles empty diff', () => {
const manifest = buildSyncManifest('');
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
expect(manifest.renamed).toEqual([]);
});
test('handles mixed entries with blank lines', () => {
const output = `A\tpeople/a.md\n\nM\tpeople/b.md\n\nD\tpeople/c.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/a.md']);
expect(manifest.modified).toEqual(['people/b.md']);
expect(manifest.deleted).toEqual(['people/c.md']);
});
test('skips malformed lines', () => {
const output = `A\tpeople/a.md\ngarbage line\nM\tpeople/b.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/a.md']);
expect(manifest.modified).toEqual(['people/b.md']);
});
});
describe('isSyncable', () => {
test('accepts normal .md files', () => {
expect(isSyncable('people/pedro-franceschi.md')).toBe(true);
expect(isSyncable('meetings/2026-04-03-lunch.md')).toBe(true);
expect(isSyncable('daily/2026-04-05.md')).toBe(true);
expect(isSyncable('notes.md')).toBe(true);
});
test('accepts .mdx files', () => {
expect(isSyncable('components/hero.mdx')).toBe(true);
expect(isSyncable('docs/getting-started.mdx')).toBe(true);
});
test('rejects non-.md/.mdx files', () => {
expect(isSyncable('people/photo.jpg')).toBe(false);
expect(isSyncable('config.json')).toBe(false);
expect(isSyncable('src/cli.ts')).toBe(false);
});
test('rejects files in hidden directories', () => {
expect(isSyncable('.git/config')).toBe(false);
expect(isSyncable('.obsidian/plugins.md')).toBe(false);
expect(isSyncable('people/.hidden/secret.md')).toBe(false);
});
test('rejects .raw/ sidecar directories', () => {
expect(isSyncable('people/pedro.raw/source.md')).toBe(false);
expect(isSyncable('dir/.raw/notes.md')).toBe(false);
});
test('rejects skip-list basenames', () => {
expect(isSyncable('schema.md')).toBe(false);
expect(isSyncable('index.md')).toBe(false);
expect(isSyncable('log.md')).toBe(false);
expect(isSyncable('README.md')).toBe(false);
expect(isSyncable('people/README.md')).toBe(false);
});
test('rejects ops/ directory', () => {
expect(isSyncable('ops/deploy-log.md')).toBe(false);
expect(isSyncable('ops/config.md')).toBe(false);
});
});
describe('pathToSlug', () => {
test('strips .md extension and lowercases', () => {
expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi');
});
test('normalizes to lowercase', () => {
expect(pathToSlug('People/Pedro-Franceschi.md')).toBe('people/pedro-franceschi');
});
test('strips leading slash', () => {
expect(pathToSlug('/people/pedro.md')).toBe('people/pedro');
});
test('normalizes backslash separators', () => {
expect(pathToSlug('people\\pedro.md')).toBe('people/pedro');
});
test('handles flat files', () => {
expect(pathToSlug('notes.md')).toBe('notes');
});
test('handles nested paths', () => {
expect(pathToSlug('projects/gbrain/spec.md')).toBe('projects/gbrain/spec');
});
test('adds repo prefix when provided', () => {
expect(pathToSlug('people/pedro.md', 'brain')).toBe('brain/people/pedro');
});
test('no prefix when not provided', () => {
expect(pathToSlug('people/pedro.md')).toBe('people/pedro');
});
test('handles empty string', () => {
expect(pathToSlug('')).toBe('');
});
test('handles file with only extension', () => {
expect(pathToSlug('.md')).toBe('');
});
test('slugifies spaces to hyphens', () => {
expect(pathToSlug('Apple Notes/2017-05-03 ohmygreen.md')).toBe('apple-notes/2017-05-03-ohmygreen');
});
test('strips special characters', () => {
expect(pathToSlug('notes/meeting (march 2024).md')).toBe('notes/meeting-march-2024');
});
});
describe('isSyncable edge cases', () => {
test('rejects uppercase .MD extension', () => {
// isSyncable checks path.endsWith('.md'), so .MD should fail
expect(isSyncable('people/someone.MD')).toBe(false);
});
test('rejects files with no extension', () => {
expect(isSyncable('README')).toBe(false);
});
test('accepts deeply nested .md files', () => {
expect(isSyncable('a/b/c/d/e/f/deep.md')).toBe(true);
});
test('rejects .md files inside nested hidden dirs', () => {
expect(isSyncable('docs/.internal/secret.md')).toBe(false);
});
});
describe('buildSyncManifest edge cases', () => {
test('handles tab-separated fields correctly', () => {
const output = "A\tpath/to/file.md";
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['path/to/file.md']);
});
test('handles multiple renames', () => {
const output = [
'R100\told/a.md\tnew/a.md',
'R095\told/b.md\tnew/b.md',
].join('\n');
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toHaveLength(2);
expect(manifest.renamed[0].from).toBe('old/a.md');
expect(manifest.renamed[1].from).toBe('old/b.md');
});
test('ignores unknown status codes', () => {
const output = "X\tunknown/file.md";
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
expect(manifest.renamed).toEqual([]);
});
});