mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat: shared CJK detection module (cjk.ts) Foundation for the CJK fix wave. Single source of truth for CJK ranges (Han, Hiragana, Katakana, Hangul Syllables), the slug-char string used by adjacent validators, sentence + clause delimiter sets, the 30% density threshold for word counting, and a LIKE-pattern escape helper. Replaces the inline hasCJK regex at expansion.ts:58 so four-place drift becomes impossible. countCJKAwareWords uses density threshold (per codex outside-voice C13) so a long English doc with one Japanese term stays whitespace-tokenized, not char-split. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: migration v51 + pages.chunker_version/source_path columns Schema-level support for the v0.32.7 CJK wave. Two new columns on pages: - chunker_version SMALLINT NOT NULL DEFAULT 1 — bumped to MARKDOWN_CHUNKER_VERSION (2) on every new import. The post-upgrade gbrain reindex --markdown sweep walks chunker_version < 2 to find pre-bump rows and rebuilds them. - source_path TEXT — captures the repo-relative path at import time so sync's delete/rename code can resolve frontmatter-fallback slugs (CJK / emoji / exotic-script files where the path itself doesn't derive a slug). Both columns plumbed through PageInput, partial indexes scoped to markdown-only / non-null. PGLite + Postgres parity via the standard ALTER TABLE ... IF NOT EXISTS shape. Replaces the original PR #599 plan of folding MARKDOWN_CHUNKER_VERSION into content_hash. Codex outside-voice C2 caught that as a no-op: performSync gates on actual file change, not hash-would-differ, so the fold never reached existing pages. Column + sweep is the real fix. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: CJK-aware slugify + SLUG_SEGMENT_PATTERN + adjacent validators slugifySegment now preserves Han / Hiragana / Katakana / Hangul Syllables with NFC re-normalization after the NFD-strip-accents pass so Hangul Jamo recomposes back into precomposed syllables that fall inside the whitelist. café still slugifies to cafe (regression preserved — iron rule). SLUG_SEGMENT_PATTERN (consumed by takes-holder validation) extended with CJK_SLUG_CHARS in the same commit so CJK slugs aren't rejected by adjacent validators downstream. Codex outside-voice C4 caught this exact half-fix in the original plan — leaving the pattern ASCII-only would have shipped a feature where the slugify produced 品牌圣经 but adjacent validators flagged it. src/core/operations.ts: validatePageSlug + validateFilename also extended with CJK ranges. matchesSlugAllowList is unchanged (works on string prefixes, no character class). Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: recursive chunker — MARKDOWN_CHUNKER_VERSION + CJK splitting + maxChars cap Four coordinated chunker changes for the v0.32.7 wave: - MARKDOWN_CHUNKER_VERSION = 2 exported. Folded into pages.chunker_version so the post-upgrade reindex sweep can find pre-bump pages. - countWords delegated to countCJKAwareWords from cjk.ts (30% density threshold). Below threshold: whitespace-token count (English-dominant docs stay tokenized). At/above: char count (Chinese paragraphs actually split instead of being treated as one 8192-token-overflowing word). - DELIMITERS extends L2 (sentences) with 。!? and L3 (clauses) with ;:,、. CJK punctuation now produces real chunk boundaries. - maxChars hard cap (default 6000) with sliding-window splitByChars and 500-char overlap. Catches pathological whitespace-less inputs that the word-level pipeline can't bound (pure-Han paragraphs, base64 blobs, long URLs). Applied to both single-short-chunk and merged-chunks paths. - splitOnWhitespace falls through to char-slice when ANY single "word" exceeds target chars (the greedy /\S+/g regex returns a whole CJK paragraph as one "word"; without this, the L4 fallback produces one huge piece). Pre-fix this was the silent-failure path. Tests in test/chunkers/recursive.test.ts: 9 new cases — pure Chinese, Japanese + 。, Korean Hangul, mixed CJK+English, 20KB CJK with overlap, single-short-chunk maxChars edge, pure-English regression. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: PGLite CJK keyword fallback + engine chunker_version/source_path passthrough PGLite uses websearch_to_tsquery('english') over to_tsvector('english'), which can't tokenize CJK. Pre-fix, CJK queries returned empty results on PGLite brains even with proper embeddings. searchKeyword + searchKeywordChunks now branch on hasCJK(query): - ASCII path: unchanged. websearch_to_tsquery('english') continues to drive FTS. No regression risk. - CJK path: switches to ILIKE '%' || $qLike || '%' ESCAPE '\\' over chunk_text with two distinct param bindings ($qLike escaped for the ILIKE clause, $qRaw raw for the ranking arithmetic). Empty $qRaw guard bails before binding. Bigram-frequency-count ranking via (LENGTH(chunk_text) - LENGTH(REPLACE(chunk_text, $qRaw, ''))) / LENGTH($qRaw) approximates ts_rank semantics; position-in-chunk tiebreaker so earlier matches outrank later ones at the same occurrence count. Codex outside-voice C8 caught the original plan's one-param shortcut (escaped chars can't be reused as ranking substrings) + missing ESCAPE clause + asymmetric whitespace strip. C9 corrected the FTS dialect (websearch_to_tsquery, not to_tsvector('simple')). Source-boost CASE, hard-exclude clause, visibility clause, and the DISTINCT ON (slug) page-dedup all survive on both branches. Postgres engine path stays untouched (multi-tenant Postgres deployments can install pgroonga / zhparser for CJK; out of scope for this wave). Postgres + PGLite putPage both extended to write chunker_version and source_path columns (with COALESCE(EXCLUDED.x, pages.x) so auto-link / code-reindex callers that don't supply them don't blank existing values). Tests: 8 new cases covering Chinese / Japanese / Korean substring search, bigram ranking (3-hit > 1-hit), LIKE-meta-char escape (literal % does not wildcard), English query stays on FTS path. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> Co-Authored-By: 313094319-sudo <313094319-sudo@users.noreply.github.com> * feat: import-file frontmatter-slug fallback + audit JSONL importFromFile gains a fallback branch: when slugifyPath returns empty (emoji / Thai / Arabic / exotic-script filename — including post-CJK-wave files that still don't slugify) AND the frontmatter declares a slug, the frontmatter slug becomes authoritative. Anti-spoof rule preserved unchanged: when slugifyPath produces a non-empty path slug AND the frontmatter slug claims a different one, the file is still rejected. notes/random.md cannot impersonate people/elon via frontmatter. D6=B error string when both path slug AND frontmatter slug are empty: "Filename produces no usable slug. Add a 'slug:' to the frontmatter, or rename the file to use ASCII / Chinese / Japanese / Korean characters." Honest about the actually-supported scripts. Every import now populates pages.chunker_version (set to MARKDOWN_CHUNKER_VERSION) and pages.source_path (repo-relative). These drive the post-upgrade reindex sweep + sync's delete/rename slug resolution. NEW src/core/audit-slug-fallback.ts — weekly ISO-week-rotated JSONL at ~/.gbrain/audit/slug-fallback-YYYY-Www.jsonl. Per codex C7, info events don't belong in sync-failures.jsonl (which gates bookmark advancement); separate audit surface keeps the failure-handling code unchanged. logSlugFallback emits a stderr line AND appends to the audit file (D7=D dual logging). Tests: 5 new import-file cases (小米 with no frontmatter slug, 🚀.md with frontmatter fallback, 🌟🚀.md friendly D6=B error, anti-spoof regression, chunker_version + source_path populated). 6 new audit cases covering write, weekly rotation, 7-day window, corrupt-row tolerance. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: git() helper hardening + core.quotepath=false for CJK paths git CLI emits CJK paths as quoted octal escapes (\345\223\201 ...) by default in diff --name-status output. Pre-fix, buildSyncManifest silently dropped these paths because downstream filesystem lookups saw the literal escape string. gbrain sync reported added=0 while git had the file committed. git() helper refactored: - New signature: git(repoPath, args: string[], configs?: string[]) - Config flags emit BEFORE -C and BEFORE the subcommand (git CLI requires this order) - core.quotepath=false always prepended - Future callers needing extra -c config pass configs:[]; no more inlining -c into args (the silent-future-drift footgun codex C12 flagged as a related concern) New invariant test in test/sync.test.ts pins the emit order. NEW test/e2e/sync-cjk-git.test.ts — real-git E2E in a tmpdir. Spawns real git via execFileSync, commits a Chinese-named markdown file, drives the helper through buildSyncManifest, asserts the manifest contains the UTF-8 path (not the octal-escape form). Closes the real-CLI-behavior gap that unit tests can't cover (the helper builds the right args; only an E2E proves git actually emits UTF-8 under the flag). Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: gbrain reindex --markdown sweep command NEW src/commands/reindex.ts — operator-facing markdown re-chunk sweep. Walks SELECT slug, source_path FROM pages WHERE page_kind = 'markdown' AND chunker_version < MARKDOWN_CHUNKER_VERSION in 100-row batches, ordered by id ASC so partial-completion re-runs pick up where they left off. For rows with non-null source_path: re-imports via importFromFile when the file exists on disk. For rows without (legacy pre-migration backfill): fallback to importFromContent using the stored markdown body. Flags: --markdown (target selector), --limit N, --dry-run, --json, --no-embed (offline / CI / test path that lets the chunker run without a configured AI gateway), --repo PATH. Wired into src/cli.ts dispatch table. Will also be invoked automatically by gbrain upgrade's post-upgrade hook (next commit) so chunker-version bumps reach existing markdown pages without an explicit operator action. Tests in test/reindex.test.ts: 5 cases covering dry-run, actual sweep, idempotent re-run, --limit cap, skipped-already-at-current. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> Co-Authored-By: 313094319-sudo <313094319-sudo@users.noreply.github.com> * feat: post-upgrade chunker-bump cost prompt + auto-reindex sweep Wires the chunker-version bump into gbrain upgrade so existing brains heal automatically. Three new pieces: NEW src/core/embedding-pricing.ts — EMBEDDING_PRICING map keyed provider:model (OpenAI text-embedding-3-large + 3-small + ada-002, Voyage 3-large + 3). lookupEmbeddingPrice returns 'known' or 'unknown' shape so the cost-estimate prompt can degrade gracefully for unknown providers rather than fabricate numbers (codex C3). estimateCostFromChars uses 3.5 chars/token approximation. NEW src/core/post-upgrade-reembed.ts — pure-ish functions for the cost-estimate prompt: - computeReembedEstimate: real SQL against COUNT(*) + COALESCE(SUM(LENGTH(compiled_truth)) + SUM(LENGTH(timeline)) on the chunker_version-filtered query. No phantom markdown_body column (codex C3 caught the original plan referencing nonexistent schema fields). - formatReembedPrompt: pure string formatter for the stderr line. - runPostUpgradeReembedPrompt: orchestrates the prompt + 10-second Ctrl-C window. TTY-only wait so non-TTY upgrades (CI, cron-driven, headless) don't hang. GBRAIN_NO_REEMBED=1 bails out entirely with a doctor-warning marker; GBRAIN_REEMBED_GRACE_SECONDS=0 skips the wait. src/commands/upgrade.ts: after apply-migrations runs, the new prompt fires through the gateway's configured embedding model, then invokes gbrain reindex --markdown automatically if the user proceeds. Wrapped in try-catch so a reindex failure is non-fatal — the user can re-run manually. Tests in test/upgrade-reembed-prompt.test.ts: 11 cases covering real SQL counts, unknown-provider fallback, TTY / non-TTY paths, GBRAIN_NO_REEMBED bail-out, GBRAIN_REEMBED_GRACE_SECONDS=0 skip-wait. Codex outside-voice C2 caught the original plan as a no-op (performSync doesn't re-import unchanged files just because content_hash would differ). The migration v51 column + this sweep + this prompt is the real fix that actually reaches existing pages. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * feat: doctor slug_fallback_audit check + CJK roundtrip E2E gbrain doctor learns a new slug_fallback_audit check (v0.32.7). Reads the latest week of ~/.gbrain/audit/slug-fallback-*.jsonl, counts info-severity entries from the last 7 days, surfaces the total as an ok-status line. No health-score docking; no warning. sync-failures.jsonl (which gates bookmark advancement) stays untouched — info events live in their own surface per codex C7. NEW test/e2e/cjk-roundtrip.test.ts — proves the wave delivers end- to-end. PGLite-in-memory fixture with Chinese / Japanese / Korean content. Each page: importFromContent → chunkText (CJK-aware) → searchKeyword (LIKE-branch with bigram count). Asserts every CJK query lands on its source page. ASCII regression: an English query still uses the FTS path on the same brain. Vector path skips gracefully without OPENAI_API_KEY. Co-Authored-By: vinsew <vinsew@users.noreply.github.com> * chore: bump version and changelog (v0.32.7) CJK fix wave — six layers from one root cause. Three originating PRs from @vinsew and one extracted from @313094319-sudo's #765 land together as a coherent collector. Codex outside-voice review on the plan caught four critical bugs the eng review missed (no-op re-embed, SLUG_SEGMENT_PATTERN half-fix, LIKE SQL needing two distinct param bindings, countCJKAwareWords over-splitting on English+1-CJK-term docs). All four addressed in the implementation. TODOS.md: resolved the v0.32.x PGLite CJK keyword fallback entry; filed five v0.33+ follow-ups (Postgres CJK FTS via pgroonga / wider Unicode property escapes / -z NUL git framing / CJK overlap context / other non-Latin scripts / embedding pricing refresh mechanism). Co-Authored-By: vinsew <vinsew@users.noreply.github.com> Co-Authored-By: 313094319-sudo <313094319-sudo@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: review findings — forceRechunk + source_path lookup (codex post-merge) Two critical issues caught by codex adversarial on the post-merge tree: F1 — Reindex sweep was a no-op on unchanged-source pages. importFromContent short-circuits on existing.content_hash === hash BEFORE the chunker runs, so the v0.32.7 MARKDOWN_CHUNKER_VERSION bump (and master's v0.32.2 stripFactsFence privacy strip) never reached pages whose markdown body hadn't been edited. Fix: new `forceRechunk?: boolean` option on importFromContent + importFromFile. When set, the hash short-circuit is bypassed and the page re-runs the full chunk + write pipeline. `gbrain reindex --markdown` now passes forceRechunk: true on every row. This means: - The CJK chunker bump actually reaches existing markdown pages. - Master's v0.32.2 stripFactsFence applies retroactively too — any pre-strip private fact bytes lingering in content_chunks get cleared when the v0.32.7 post-upgrade sweep runs. New test in test/reindex.test.ts seeds a page, runs the sweep, mocks a stale chunker_version=1 without changing compiled_truth, runs the sweep again, asserts chunker_version is bumped despite hash match. F4 — Sync delete/rename still used resolveSlugForPath(path) only, ignoring the new pages.source_path column added in v52. Frontmatter-fallback pages (emoji-only / Thai / Arabic filenames where slugifyPath returns empty and the slug came from the markdown frontmatter) would orphan on delete or rename because the path-derived slug doesn't match the stored slug. Fix: new exported helper resolveSlugByPathOrSourcePath(engine, path, sourceId?) queries pages.source_path first, falls back to resolveSlugForPath when no row matches. Threaded into 3 call sites in sync.ts (un-syncable modified cleanup at :531, deletes at :603, rename oldSlug at :622). Best-effort: query errors fall through to the legacy path so pre-migration brains still work. 3 new test cases in test/sync.test.ts cover: stored-slug lookup hits, fallback when no source_path row exists, and source_id scoping when two sources have the same source_path value. Codex finding #3 (reindex not in CLI_ONLY) was verified as a false positive — CLI_ONLY is the set that doesn't need an engine; reindex correctly belongs to the engine-backed dispatch. 302 wave tests pass / 0 fail. bun run verify green. * docs: update CLAUDE.md + llms-full.txt for v0.32.7 CJK fix wave CLAUDE.md Key Files: added entries for the five new modules introduced by the wave — src/core/cjk.ts (shared detection + delimiters + density threshold), src/core/audit-slug-fallback.ts (weekly JSONL), src/core/embedding-pricing.ts (post-upgrade cost lookup table), src/core/post-upgrade-reembed.ts (prompt + grace window), and src/commands/reindex.ts (chunker_version sweep with forceRechunk). Also noted src/commands/sync.ts:resolveSlugByPathOrSourcePath — the F4 codex post-merge fix that wires the new pages.source_path column into sync delete/rename so frontmatter-fallback pages don't orphan. CLAUDE.md Commands: added a v0.32.7 section covering `gbrain reindex --markdown`, the new doctor slug_fallback_audit check, PGLite CJK keyword fallback in `gbrain search`, and the post-upgrade chunker-bump cost prompt with its env-var overrides. llms-full.txt: regenerated via bun run build:llms (CI gate runs the generator on every release; commit must include the bundle). README.md: no changes needed — v0.32.7 is internal correctness across the existing pipeline, not a new skill or setup story. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: vinsew <vinsew@users.noreply.github.com> Co-authored-by: 313094319-sudo <313094319-sudo@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
563 lines
20 KiB
TypeScript
563 lines
20 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { buildSyncManifest, isSyncable, pathToSlug } from '../src/core/sync.ts';
|
|
import { buildGitInvocation } from '../src/commands/sync.ts';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.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([]);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// performSync dry-run (v0.17 regression guard for full-sync silent writes)
|
|
// ────────────────────────────────────────────────────────────────
|
|
|
|
describe('performSync dry-run never writes', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
// One PGLite per file — beforeEach wipes data only. Each test still gets a
|
|
// fresh git repo via mkdtempSync, but skips the ~20s PGLite cold-start.
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-'));
|
|
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
|
|
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'people/alice.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Alice',
|
|
'---',
|
|
'',
|
|
'Alice is a person.',
|
|
].join('\n'));
|
|
writeFileSync(join(repoPath, 'people/bob.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Bob',
|
|
'---',
|
|
'',
|
|
'Bob is another person.',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('first-sync dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
// Status + counts reflect what WOULD be imported.
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(2); // alice + bob, both syncable
|
|
expect(result.chunksCreated).toBe(0);
|
|
expect(result.embedded).toBe(0);
|
|
|
|
// DB is clean: no pages written.
|
|
expect(await engine.getPage('people/alice')).toBeNull();
|
|
expect(await engine.getPage('people/bob')).toBeNull();
|
|
|
|
// Bookmark NOT set — this is the regression the guard enforces.
|
|
expect(await engine.getConfig('sync.last_commit')).toBeNull();
|
|
expect(await engine.getConfig('sync.repo_path')).toBeNull();
|
|
});
|
|
|
|
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// First do a real sync to seed the bookmark.
|
|
const real = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
expect(real.status).toBe('first_sync');
|
|
const bookmarkAfterReal = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfterReal).not.toBeNull();
|
|
|
|
// Add a third file.
|
|
writeFileSync(join(repoPath, 'people/carol.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Carol',
|
|
'---',
|
|
'',
|
|
'Carol joins the cast.',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "add carol"', { cwd: repoPath, stdio: 'pipe' });
|
|
|
|
// Incremental sync in dry-run mode.
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(1); // carol only
|
|
expect(result.chunksCreated).toBe(0);
|
|
expect(result.embedded).toBe(0);
|
|
|
|
// carol is NOT in the DB.
|
|
expect(await engine.getPage('people/carol')).toBeNull();
|
|
// alice + bob still present from the real sync.
|
|
expect(await engine.getPage('people/alice')).not.toBeNull();
|
|
expect(await engine.getPage('people/bob')).not.toBeNull();
|
|
|
|
// Bookmark unchanged — still at the pre-carol commit.
|
|
const bookmarkAfterDry = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfterDry).toBe(bookmarkAfterReal);
|
|
});
|
|
|
|
test('full-sync (--full) dry-run does NOT write to DB or advance the bookmark', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
// Seed the bookmark so we hit the full-sync-with-bookmark path when --full is set.
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
// Clear DB so we can observe that a --full dry-run doesn't re-import.
|
|
await (engine as any).db.exec(`DELETE FROM content_chunks; DELETE FROM pages;`);
|
|
const bookmarkBefore = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkBefore).not.toBeNull();
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
full: true, // force full-sync path
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(2); // alice + bob would be imported
|
|
expect(result.chunksCreated).toBe(0);
|
|
|
|
// DB empty — full-sync dry-run did not reimport.
|
|
expect(await engine.getPage('people/alice')).toBeNull();
|
|
expect(await engine.getPage('people/bob')).toBeNull();
|
|
|
|
// Bookmark unchanged.
|
|
const bookmarkAfter = await engine.getConfig('sync.last_commit');
|
|
expect(bookmarkAfter).toBe(bookmarkBefore);
|
|
});
|
|
|
|
test('SyncResult exposes embedded count field', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
dryRun: true,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
// Structural assertion: the contract includes `embedded: number`.
|
|
expect(typeof result.embedded).toBe('number');
|
|
});
|
|
|
|
test('detached HEAD skips git pull and ingests local working-tree files', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const seeded = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
expect(seeded.status).toBe('first_sync');
|
|
|
|
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
|
|
writeFileSync(join(repoPath, 'people/detached-local.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Detached Local',
|
|
'---',
|
|
'',
|
|
'This file exists only in the detached working tree.',
|
|
].join('\n'));
|
|
|
|
const errors: string[] = [];
|
|
const originalError = console.error;
|
|
console.error = (...args: unknown[]) => {
|
|
errors.push(args.map(String).join(' '));
|
|
};
|
|
|
|
try {
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.added).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/detached-local');
|
|
} finally {
|
|
console.error = originalError;
|
|
}
|
|
|
|
expect(errors.join('\n')).toContain(`Detached HEAD on ${repoPath}; skipping git pull. Syncing from local working tree.`);
|
|
expect(errors.join('\n')).not.toContain('git pull failed');
|
|
|
|
const page = await engine.getPage('people/detached-local');
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Detached Local');
|
|
});
|
|
|
|
test('detached HEAD with --no-pull also ingests local working-tree files', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const seeded = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
expect(seeded.status).toBe('first_sync');
|
|
|
|
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
|
|
writeFileSync(join(repoPath, 'people/detached-nopull.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Detached NoPull',
|
|
'---',
|
|
'',
|
|
'Only in detached working tree, --no-pull caller.',
|
|
].join('\n'));
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
noExtract: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.added).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/detached-nopull');
|
|
|
|
const page = await engine.getPage('people/detached-nopull');
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Detached NoPull');
|
|
});
|
|
});
|
|
|
|
describe('sync regression — #132 nested transaction deadlock', () => {
|
|
test('src/commands/sync.ts does not wrap the add/modify loop in engine.transaction()', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
|
|
// Accept either of the historical loop shapes: the original inline
|
|
// `for (const path of [...filtered.added, ...filtered.modified])` or
|
|
// the v0.15.2 progress-wrapped variant where the list is hoisted into
|
|
// a local `addsAndMods` variable first.
|
|
const inlineIdx = source.indexOf('for (const path of [...filtered.added, ...filtered.modified]');
|
|
const hoistedIdx = source.indexOf('const addsAndMods = [...filtered.added, ...filtered.modified]');
|
|
const loopStart = inlineIdx !== -1 ? inlineIdx : hoistedIdx;
|
|
expect(loopStart).toBeGreaterThan(-1);
|
|
const prelude = source.slice(0, loopStart);
|
|
const lastTxIdx = prelude.lastIndexOf('engine.transaction');
|
|
if (lastTxIdx !== -1) {
|
|
const lineStart = prelude.lastIndexOf('\n', lastTxIdx) + 1;
|
|
const line = prelude.slice(lineStart, prelude.indexOf('\n', lastTxIdx));
|
|
expect(line.trim().startsWith('//')).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveSlugByPathOrSourcePath (CJK wave v0.32.7, codex F4)', () => {
|
|
let pgEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
pgEngine = new PGLiteEngine();
|
|
await pgEngine.connect({});
|
|
await pgEngine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pgEngine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await (pgEngine as any).db.exec('DELETE FROM content_chunks');
|
|
await (pgEngine as any).db.exec('DELETE FROM pages');
|
|
});
|
|
|
|
test('returns stored slug when source_path matches a row', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// Seed a frontmatter-fallback page: slug doesn't derive from path (emoji)
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('projects/launch', 'project', 'Launch', 'body', 'markdown', '🚀.md')`,
|
|
);
|
|
const slug = await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md');
|
|
expect(slug).toBe('projects/launch');
|
|
});
|
|
|
|
test('falls back to resolveSlugForPath when no source_path matches', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// No row seeded — fallback returns the path-derived slug.
|
|
const slug = await resolveSlugByPathOrSourcePath(pgEngine, 'concepts/hello-world.md');
|
|
expect(slug).toBe('concepts/hello-world');
|
|
});
|
|
|
|
test('scoped by source_id when provided', async () => {
|
|
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
|
|
// Same source_path under TWO sources — without source_id scope we'd
|
|
// get either at random. With source_id we get the right one.
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('source-a', 'A') ON CONFLICT DO NOTHING`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('source-b', 'B') ON CONFLICT DO NOTHING`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('source-a', 'slug-a/page', 'note', 'A', 'a', 'markdown', '🚀.md')`,
|
|
);
|
|
await pgEngine.executeRaw(
|
|
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
|
|
VALUES ('source-b', 'slug-b/page', 'note', 'B', 'b', 'markdown', '🚀.md')`,
|
|
);
|
|
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-a')).toBe('slug-a/page');
|
|
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-b')).toBe('slug-b/page');
|
|
});
|
|
});
|
|
|
|
describe('git() helper invocation order (CJK wave v0.32.7)', () => {
|
|
// The git CLI requires `-c key=val` to appear BEFORE the subcommand,
|
|
// and `-C path` BEFORE the subcommand too. Pin the emit order so a future
|
|
// refactor can't silently put `-c` after the subcommand and break CJK
|
|
// path emission.
|
|
|
|
test('core.quotepath=false is always emitted first', () => {
|
|
const argv = buildGitInvocation('/repo', ['diff', '--name-status']);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-C', '/repo',
|
|
'diff', '--name-status',
|
|
]);
|
|
});
|
|
|
|
test('extra configs append AFTER quotepath, BEFORE -C and subcommand', () => {
|
|
const argv = buildGitInvocation('/repo', ['diff'], ['foo=bar', 'baz=qux']);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-c', 'foo=bar',
|
|
'-c', 'baz=qux',
|
|
'-C', '/repo',
|
|
'diff',
|
|
]);
|
|
});
|
|
|
|
test('empty args produces a valid invocation', () => {
|
|
const argv = buildGitInvocation('/repo', []);
|
|
expect(argv).toEqual([
|
|
'-c', 'core.quotepath=false',
|
|
'-C', '/repo',
|
|
]);
|
|
});
|
|
});
|