Files
gbrain/src/core/sync.ts
T
4446e9f9d2 v0.35.5.0 fix wave: bootstrap + orphans + think MCP + worktree + walker (#1111)
* fix(bootstrap): extend probes for files/oauth_clients/sources.archived* + add MIGRATIONS introspection guard

Adds 7 new forward-reference probes to applyForwardReferenceBootstrap on
both engines, closes the column-only forward-ref class via a new
MIGRATIONS-source introspection contract test.

New probes:
- files.source_id + files.page_id (v18 forward refs)
- oauth_clients.source_id + oauth_clients.federated_read (v60+v61+v65)
- sources.archived + archived_at + archive_expires_at (v34 promoted from JSONB)

The sources.archived* columns are the codex-flagged class: they're added
inline in v34's CREATE TABLE definition but `CREATE TABLE IF NOT EXISTS
sources` is a no-op on pre-v34 brains, so downstream visibility filters
(search/list_pages) trip on old brains. needsPagesBootstrap now folds
archive columns into its CREATE TABLE so pre-v0.18 brains get a v34-shape
sources in one go; needsSourcesArchive then only fires on the pre-v34
case (sources exists, archive cols don't).

Closes the structural bug class via test/helpers/extract-added-columns.ts:
reads src/core/migrate.ts as text and extracts every ALTER TABLE ADD
COLUMN. The new contract test asserts every (table, column) pair is
covered by EITHER the bootstrap's ALTER TABLE statements, the bootstrap's
CREATE TABLE definitions, OR the schema blob's CREATE TABLE bodies. The
column-only class (no index, no FK; just an inline CREATE TABLE column
the schema blob can't add to existing tables) is now caught at PR time.

Source-text introspection catches all three migration shapes uniformly:
- top-level `sql:` field
- `sqlFor.postgres` / `sqlFor.pglite` overrides
- handler-body `engine.runMigration(N, \`ALTER TABLE ...\`)` (v34 shape)

Pre-existing parseBaseTableColumns parser bug fixed: now strips `--` line
comments and `/* ... */` blocks before identifying column names. Without
this, a column preceded by a comment was silently dropped. Catches
pages.page_kind and others that were silently uncovered.

13 columns added by migrations but not in PGLITE_SCHEMA_SQL are exempted
with a unified rationale: they have no schema-blob forward reference;
migration handles all upgrade paths cleanly. Refreshing the schema blob
is a separate concern.

Issues closed: #1018 (v60 oauth_clients), #974 (files.source_id/page_id),
#820 (v0.13.0 migration files.page_id cascade); pre-empts the
sources.archived class before any pre-v34 brain trips on it.

Tests:
- 9 cases in test/schema-bootstrap-coverage.test.ts (5 existing + 4 new)
- helper-level unit tests cover SQL shape variants (IF NOT EXISTS,
  quoted identifiers, ALTER TABLE IF EXISTS ONLY, multi-statement)
- planted-bug regression verifies the gate actually catches new uncovered
  columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(orphans): filter soft-deleted pages on both candidate and link-source sides

Closes #1021. The v0.26.5 soft-delete invariant requires that
findOrphanPages exclude both:
  1. Candidate pages that are themselves soft-deleted
  2. Inbound links from soft-deleted source pages

Pre-fix, findOrphanPages had no deleted_at filter at all. Soft-deleted
pages with no inbound links were counted as orphans (inflating counts).
Pre-codex-tension-D11, only the candidate-side filter was planned.
Codex C11 caught the second case: a live page that has ONE inbound link
from a soft-deleted source page was hidden from orphan results — the
link still existed in the links table, the EXISTS subquery saw it, the
page looked "linked." Now the inner JOIN on pages enforces
src.deleted_at IS NULL.

Three regression tests pin the contract:
- soft-deleted page with no inbound → NOT orphan
- live page with ONLY inbound link from soft-deleted source → IS orphan
- live page with live inbound → NOT orphan (smoke check that the new
  filters don't break unchanged behavior)

Engine parity: same SQL shape on both Postgres and PGLite engines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(think): route runThink through gateway.chat adapter (closes #952)

Pre-fix, runThink instantiated `new Anthropic()` directly and read
ANTHROPIC_API_KEY from process.env. Claude Desktop's stdio MCP launch
doesn't inherit shell env, so `gbrain config set anthropic_api_key sk-...`
(writes to ~/.gbrain/config.json) never reached the SDK and every MCP
think call degraded to "no LLM available."

The adapter routes through gateway.chat() — the canonical seam per
CLAUDE.md. Gateway reads the API key from gbrain config OR env, picks
up prompt caching, rate-leases, retry, and the test seam
(__setChatTransportForTests) that v0.31.12 established.

Per plan-eng-review D10 (cross-model tension with codex C7+C8+C9+C10),
four spec points landed:

  1. Drop `new Anthropic()` direct path entirely. Every non-stub LLM
     call from runThink routes through gateway.

  2. Real availability check (NOT a false-positive `getChatModel()`
     truthy). `tryBuildGatewayClient` probes both the recipe (resolveRecipe
     throws AIConfigError on unknown providers) AND the API key (reads
     process.env + loadConfig at the gbrain config layer for parity with
     gateway's own auth resolution). Returns null on miss; runThink takes
     the graceful "no LLM available" early-return preserving the legacy
     NO_ANTHROPIC_API_KEY warning signal.

  3. Model-id normalization. resolveModel returns bare anthropic ids
     (claude-opus-4-7); gateway.chat needs provider:model. Adapter
     auto-prefixes anthropic: when the id is bare. Provider:model strings
     pass through unchanged.

  4. Response-shape conversion. ChatResult → Anthropic.Message via
     chatResultToMessage. mapStopReason translates gateway's
     provider-neutral stop reasons (end / length / tool_calls / refusal /
     content_filter / other) to Anthropic's stop_reason ('end_turn' /
     'max_tokens' / 'tool_use'); refusal/content_filter/other fall through
     to end_turn (no Anthropic equivalent). Usage tokens pass through.

`opts.client` injection preserved (test seam — see ThinkLLMClient).
`opts.stubResponse` preserved (pure-test escape).

Tests:
  - test/think-gateway-adapter.test.ts (9 cases): response shape, stop
    reason mapping, model-id normalization (bare + prefixed), provider
    unknown returns null, ANTHROPIC_API_KEY absent returns null
    (regression for legacy graceful degradation), hasAnthropicKey reads
    process.env correctly. Uses withEnv per the test-isolation contract.
  - test/think-pipeline.serial.test.ts (17 existing cases): unchanged;
    the graceful-degradation case at line 213 still produces the
    NO_ANTHROPIC_API_KEY warning because tryBuildGatewayClient returns
    null when no key is configured, taking the legacy early-return path.

Closes #952.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): distinguish git worktree from submodule via path-segment match (closes #889)

Pre-fix, `manageGitignore` treated every `.git`-as-file as a submodule
and skipped gitignore management. Both submodules AND worktrees use
`.git` as a file (not a directory), so the legacy
`statSync.isFile()` check couldn't discriminate. Worktrees got
misclassified as submodules and their .gitignore wasn't managed.

Per plan-eng-review D4 (chose path-segment match over absolute-vs-
relative path heuristic): the gitdir path contains:
  - `/modules/<name>` for submodules (skip — managed by parent repo)
  - `/worktrees/<name>` for worktrees (MANAGE — first-class repo)

Both are documented Git internal layouts, stable across all 4
{relative, absolute} × {modules, worktrees} combinations including the
absorbed-submodule edge case from `git submodule absorbgitdirs` (where
the submodule's gitdir flips to an absolute path).

Malformed `.git` file (no `gitdir:` prefix, IO error) → MANAGE, preserving
the pre-#889 catch{} fail-closed-toward-managing semantics.

Tests (5 new + 1 regression renamed):
  - REGRESSION: submodule relative gitdir/modules/ → skip (D49 contract)
  - absorbed submodule absolute gitdir/modules/ → skip (edge case)
  - CRITICAL: worktree absolute gitdir/worktrees/ → MANAGE (closes #889)
  - worktree relative gitdir/worktrees/ → MANAGE
  - malformed .git file → MANAGE (preserves catch behavior)
  - regular .git directory → MANAGE (existing smoke)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(walkers): pruneDir helper + descent-time exclusion + transcript predicate (closes #923, #202)

Per plan-eng-review D12 (cross-model tension with codex C12+C13), three
structural changes:

1. Extract `pruneDir(name)` helper in src/core/sync.ts. Returns false for
   directory names walkers must NEVER descend into: `node_modules` (latent
   bug — no leading dot), dot-prefix dirs (`.git`, `.obsidian`, `.raw`,
   `.cache`, etc.), `ops`, and `*.raw` sidecar dirs (gbrain convention —
   `people/pedro.raw/` holds raw source for pedro.md). Walkers consult it
   at descent time BEFORE recursion, saving the IO cost of walking entire
   vendor / hidden / sidecar subtrees only to filter them at file-emit time.

2. `isSyncable` itself gains the same exclusion set (via pruneDir on each
   path segment). Closes the latent bug where node_modules markdown files
   slipped through: `node_modules/some-pkg/README.md` returned true pre-fix
   because the legacy dot-prefix check only blocked `.node_modules` (with
   a leading dot), not the actual `node_modules`. CRITICAL regression test
   in test/sync.test.ts pins the contract per IRON RULE.

3. Two walkers rewritten to use pruneDir at descent + per-walker file
   predicate at emit:
   - `walkMarkdownFiles` (src/commands/extract.ts): pruneDir + isSyncable
     ({strategy:'markdown'}). Pre-fix this walker had ONLY an ad-hoc
     dot-prefix exclusion and didn't call isSyncable at all — descended
     into node_modules, emitted markdown files from there, ignored README/
     ops/.raw filters.
   - `listTextFiles` (src/core/cycle/transcript-discovery.ts): pruneDir +
     own .txt/.md predicate. DOES NOT use isSyncable({strategy:'markdown'})
     because transcripts accept .txt and don't share markdown sync's
     README/ops exclusions (codex C12). Also made RECURSIVE — pre-fix
     it walked only the top dir, so transcripts in `corpus/2026/` were
     invisible (codex C14 — descent-time pruning is the right shape but
     the test would have passed vacuously on a non-recursive walker).

Verified blast radius before adding node_modules: every existing
isSyncable caller (sync.ts:558-561 sync filter, frontmatter.ts:264 validate,
brain-writer.ts:305 reverse-write, import.ts:454 import filter) wants
node_modules excluded — this is a latent-bug fix, not a behavior change
for any legitimate caller.

Tests:
- 7 new isSyncable cases including the node_modules CRITICAL regression
- 6 new pruneDir cases (node_modules, dot-prefix, ops, *.raw, content
  dirs that should pass, empty-string default)
- Existing extract.test.ts + extract-fs.test.ts unchanged and passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(todos): file v0.36.x follow-ups for runThink rewrite + Supabase bootstrap parity

Two follow-up TODOs filed during the v0.36 dreamy-thompson wave:

1. runThink full rewrite (D5+D7 from plan-eng-review): drop the
   ThinkLLMClient indirection now that v0.36 routes through gateway.chat.
   12+ tests need migration to __setChatTransportForTests. Blocked by
   this wave landing.

2. Supabase parity test for applyForwardReferenceBootstrap (codex C6
   residual): real Docker Postgres E2E catches schema correctness but
   not Supabase pooler/direct-pool routing. The probe uses this.sql but
   PostgresEngine.initSchema chooses a DDL connection; the divergence
   has caused multiple historical wedges (#699, #820 lineage).

Both entries include full context per the CLAUDE.md TODOS-format spec
(what, why, pros, cons, blocked-by, plan reference).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bootstrap): thread DDL connection through applyForwardReferenceBootstrap

Codex adversarial review during /ship caught a P1: initSchema selected a
DDL connection, took pg_advisory_lock(42) on it, but
applyForwardReferenceBootstrap used `this.sql` (the instance pool) inside.
Bootstrap probes ran outside the lock scope on a different connection.

Failure mode: two concurrent gbrain instances could BOTH enter the
bootstrap block on Supabase transaction-pooler setups because the
advisory lock was held on a different connection than the one running
ALTER TABLE. The pooler's statement_timeout could also kill the probes
mid-flight without affecting the lock-holder, leaving an inconsistent
schema state.

Fix: applyForwardReferenceBootstrap now accepts an optional connection
parameter. initSchema passes the DDL conn (the one holding the lock).
this.sql remains the fallback for any unit-test path that calls bootstrap
directly. PGLite engine doesn't need this change — single connection,
no pooler.

This was pre-existing (every prior probe used this.sql), but the v0.36
wave is explicitly about fixing the Supabase upgrade-wedge class. Codex's
position was correct: don't ship the wave with the underlying connection
mismatch still there. The Supabase parity TEST FIXTURE follow-up remains
on TODOS.md (test infra needed to PROVE the fix works under real pooler
topology), but the bug itself is closed.

15/15 bootstrap tests pass. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Six-correctness-fix wave: bootstrap forward-ref class (4 issues + 1 pre-empt),
orphans soft-delete leak (both sides), runThink → gateway.chat adapter,
git worktree vs submodule discriminator, walker pruneDir + descent-time
exclusion, plus a Codex-P1 catch during /ship that threaded the DDL
connection through applyForwardReferenceBootstrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md for v0.35.5.0 backend correctness wave

Fold v0.35.5.0 file-level annotations into CLAUDE.md:
- postgres-engine.ts + pglite-engine.ts: 7 new applyForwardReferenceBootstrap
  probes (files.source_id/page_id, oauth_clients.source_id/federated_read,
  sources.archived/archived_at/archive_expires_at) + DDL connection threading
- test/schema-bootstrap-coverage.test.ts: new MIGRATIONS-source introspection
  guard + parseBaseTableColumns comment-stripping fix
- src/core/sync.ts: new pruneDir helper + manageGitignore worktree
  discriminator
- src/core/think/index.ts (new entry): runThink gateway adapter for MCP
  stdio key resolution
- src/core/operations.ts (new entry): findOrphanPages soft-delete filter

Regenerate llms-full.txt via bun run build:llms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:02:45 -07:00

614 lines
22 KiB
TypeScript

/**
* Sync utilities — pure functions for git diff parsing, filtering, and slug management.
*
* SYNC DATA FLOW:
* git diff --name-status -M LAST..HEAD
* │
* buildSyncManifest() → parse A/M/D/R lines
* │
* isSyncable() → filter to .md pages only
* │
* pathToSlug() → convert file paths to page slugs
*/
import { CJK_SLUG_CHARS } from './cjk.ts';
export interface SyncManifest {
added: string[];
modified: string[];
deleted: string[];
renamed: Array<{ from: string; to: string }>;
}
export interface RawManifestEntry {
action: 'A' | 'M' | 'D' | 'R';
path: string;
oldPath?: string;
}
export type SyncStrategy = 'markdown' | 'code' | 'auto';
interface SyncableOptions {
strategy?: SyncStrategy;
include?: string[];
exclude?: string[];
}
// v0.19.0 shipped a 9-extension allowlist (ts/tsx/js/jsx/mjs/cjs/py/rb/go). The
// chunker already supports ~35 extensions via detectCodeLanguage but the sync
// classifier dropped every other language on the floor — Rust/Java/C#/C++/etc.
// files never reached the chunker on a normal repo sync, making v0.19.0's
// "165 languages" claim aspirational (codex F1). v0.20.0 Layer 2 (1a) rewrites
// isCodeFilePath to delegate to detectCodeLanguage so the sync classifier
// matches the chunker's actual coverage.
//
// Kept as-is for now for `isAllowedByStrategy` fast-path + tests that
// structurally reference it. Derived from the chunker's language map at
// module load, not hardcoded.
const CODE_EXTENSIONS = new Set<string>([
'.ts', '.tsx', '.mts', '.cts',
'.js', '.jsx', '.mjs', '.cjs',
'.py',
'.rb',
'.go',
'.rs',
'.java',
'.cs',
'.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh',
'.c', '.h',
'.php',
'.swift',
'.kt', '.kts',
'.scala', '.sc',
'.lua',
'.ex', '.exs',
'.elm',
'.ml', '.mli',
'.dart',
'.zig',
'.sol',
'.sh', '.bash',
'.css',
'.html', '.htm',
'.vue',
'.json',
'.yaml', '.yml',
'.toml',
]);
/**
* Parse the output of `git diff --name-status -M LAST..HEAD` into structured entries.
*
* Input format (tab-separated):
* A path/to/new-file.md
* M path/to/modified-file.md
* D path/to/deleted-file.md
* R100 old/path.md new/path.md
*/
export function buildSyncManifest(gitDiffOutput: string): SyncManifest {
const manifest: SyncManifest = {
added: [],
modified: [],
deleted: [],
renamed: [],
};
const lines = gitDiffOutput.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parts = trimmed.split('\t');
if (parts.length < 2) continue;
const action = parts[0];
const path = parts[parts.length === 3 ? 2 : 1]; // For renames, new path is 3rd column
if (action === 'A') {
manifest.added.push(path);
} else if (action === 'M') {
manifest.modified.push(path);
} else if (action === 'D') {
manifest.deleted.push(parts[1]);
} else if (action.startsWith('R')) {
// Rename: R100\told-path\tnew-path
const oldPath = parts[1];
const newPath = parts[2];
if (oldPath && newPath) {
manifest.renamed.push({ from: oldPath, to: newPath });
}
}
}
return manifest;
}
export function isCodeFilePath(path: string): boolean {
const lower = path.toLowerCase();
for (const ext of CODE_EXTENSIONS) {
if (lower.endsWith(ext)) return true;
}
return false;
}
/**
* v0.27.1: image extensions are admitted only when the multimodal config
* gate is on. The runtime gate flips through `process.env.GBRAIN_EMBEDDING_MULTIMODAL`
* which loadConfigWithEngine populates from the DB plane after engine connect
* (or env directly when the operator overrides). When the gate is off,
* existing brains keep their current "markdown + code only" sync behavior.
*/
export function isImageFilePath(path: string): boolean {
const lower = path.toLowerCase();
return (
lower.endsWith('.png') ||
lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.gif') ||
lower.endsWith('.webp') ||
lower.endsWith('.heic') ||
lower.endsWith('.heif') ||
lower.endsWith('.avif')
);
}
export function isMarkdownFilePath(path: string): boolean {
return path.endsWith('.md') || path.endsWith('.mdx');
}
function isMultimodalEnabled(): boolean {
return process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true';
}
function isAllowedByStrategy(path: string, strategy: SyncStrategy): boolean {
if (strategy === 'markdown') return isMarkdownFilePath(path);
if (strategy === 'code') return isCodeFilePath(path);
// 'auto' / default: markdown + code, plus images when multimodal is on.
return (
isMarkdownFilePath(path) ||
isCodeFilePath(path) ||
(isMultimodalEnabled() && isImageFilePath(path))
);
}
function globToRegex(pattern: string): RegExp {
let regex = '^';
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === '*') {
const next = pattern[i + 1];
if (next === '*') {
// `**/` matches zero or more path segments (including zero, so `src/**/*.ts`
// matches `src/foo.ts` as well as `src/a/b/foo.ts`). Collapse `**/` →
// `(?:.*/)?`. A bare `**` not followed by `/` matches any chars.
if (pattern[i + 2] === '/') {
regex += '(?:.*/)?';
i += 2;
} else {
regex += '.*';
i++;
}
} else {
regex += '[^/]*';
}
continue;
}
if (ch === '?') { regex += '[^/]'; continue; }
if ('\\.[]{}()+-^$|'.includes(ch)) { regex += `\\${ch}`; continue; }
regex += ch;
}
regex += '$';
return new RegExp(regex);
}
function matchesAnyGlob(path: string, patterns?: string[]): boolean {
if (!patterns || patterns.length === 0) return false;
const normalized = path.replace(/\\/g, '/');
return patterns.some((pattern) => globToRegex(pattern).test(normalized));
}
/**
* Directory names that walkers must NEVER descend into. Used at descent
* time (before recursion) to prune entire subtrees — saves the IO cost of
* walking thousands of vendor / generated / hidden files only to filter
* them at file-emit time. Used by every walker in gbrain (sync, extract,
* transcript-discovery, etc.).
*
* Pattern: dirname matching at single path-segment granularity. Walkers
* call `pruneDir(entry.name)` on each subdirectory before recursing.
*
* `node_modules` lacks a leading dot so the dot-prefix exclusion in
* isSyncable below doesn't catch it; explicit entry here closes the
* latent walker bug (#923, #202).
*/
const PRUNE_DIR_NAMES = new Set<string>([
'node_modules',
'.raw',
'ops',
]);
/**
* Should this directory be descended into? Returns `false` for vendor / hidden /
* generated dirs that walkers should skip BEFORE recursing. Catches
* `node_modules` (latent bug — no leading dot), dot-prefix dirs (`.git`,
* `.obsidian`, `.raw`, `.cache`, etc. via the leading-dot heuristic), and the
* explicit `PRUNE_DIR_NAMES` set above.
*
* `name` is a single path segment (basename of the directory entry), NOT a
* full path. Walkers consult this on each subdirectory entry during recursion.
*/
export function pruneDir(name: string): boolean {
if (!name) return true;
if (name.startsWith('.')) return false;
if (PRUNE_DIR_NAMES.has(name)) return false;
// `.raw` is the literal directory name; `*.raw` is the gbrain sidecar
// convention (e.g. `people/pedro.raw/` holds raw source for pedro.md).
// Both forms should be skipped at descent time.
if (name.endsWith('.raw')) return false;
return true;
}
/**
* Filter a file path to determine if it should be synced to GBrain.
* Strategy-aware: 'markdown' (default) = .md/.mdx only, 'code' = code files only, 'auto' = both.
*/
export function isSyncable(path: string, opts: SyncableOptions = {}): boolean {
const strategy = opts.strategy || 'markdown';
if (!isAllowedByStrategy(path, strategy)) return false;
// Skip every path segment that pruneDir would block walkers from descending
// into. Catches hidden dirs (`.git`, `.obsidian`), `.raw/` sidecars,
// `node_modules/` (latent bug fix), and `ops/` at any depth.
const segments = path.split('/');
if (segments.some(p => !pruneDir(p))) return false;
// Skip meta files that aren't pages
const skipFiles = ['schema.md', 'index.md', 'log.md', 'README.md'];
const basename = segments[segments.length - 1] || '';
if (skipFiles.includes(basename)) return false;
if (opts.include && opts.include.length > 0 && !matchesAnyGlob(path, opts.include)) return false;
if (opts.exclude && opts.exclude.length > 0 && matchesAnyGlob(path, opts.exclude)) return false;
return true;
}
/**
* Character class for the lowercase-canonical form of a slug segment after
* slugifySegment() has run. Lowercase letters, digits, dots, underscores,
* hyphens. Exposed so adjacent code (e.g. takes-fence holder validation,
* v0.32 EXP-4) can reuse the actual repo slug grammar instead of inventing
* a stricter parallel one and emitting false-positive warnings on legitimate
* `companies/acme.io` / `people/foo_bar` slugs (codex review #3).
*
* Pattern is the inner character class only (no anchors); callers wrap it
* in `^...$` or compose it with prefixes like `(?:people|companies)/...`.
*/
export const SLUG_SEGMENT_PATTERN = new RegExp(`[a-z0-9._\\-${CJK_SLUG_CHARS}]+`);
/**
* Slugify a single path segment: lowercase, strip special chars, spaces → hyphens.
* CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) are preserved (v0.32.7).
* NFC re-normalize after the NFD-strip-accents pass so Hangul Jamo recomposes back
* into precomposed syllables that fall inside the whitelist.
*/
const SLUGIFY_KEEP_RE = new RegExp(`[^a-z0-9.\\s_\\-${CJK_SLUG_CHARS}]`, 'g');
export function slugifySegment(segment: string): string {
return segment
.normalize('NFD') // Decompose accented chars
.replace(/[\u0300-\u036f]/g, '') // Strip accent marks
.normalize('NFC') // Recompose Hangul Jamo back to Syllables (v0.32.7)
.toLowerCase()
.replace(SLUGIFY_KEEP_RE, '') // Keep alnum, dots, spaces, _-, and CJK (v0.32.7)
.replace(/[\s]+/g, '-') // Spaces → hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Strip leading/trailing hyphens
}
/**
* Slugify a file path: strip .md, normalize separators, slugify each segment.
*
* Examples:
* Apple Notes/2017-05-03 ohmygreen.md → apple-notes/2017-05-03-ohmygreen
* people/alice-smith.md → people/alice-smith
* notes/v1.0.0.md → notes/v1.0.0
*/
export function slugifyPath(filePath: string): string {
let path = filePath.replace(/\.mdx?$/i, '');
path = path.replace(/\\/g, '/');
path = path.replace(/^\.?\//, '');
return path.split('/').map(slugifySegment).filter(Boolean).join('/');
}
/**
* Slugify a code file path: flatten into a single slug segment with dots → hyphens.
* e.g. 'src/core/chunkers/code.ts' → 'src-core-chunkers-code-ts'
*/
export function slugifyCodePath(filePath: string): string {
let path = filePath.replace(/\\/g, '/');
path = path.replace(/^\.?\//, '');
return path
.split('/')
.map(segment => slugifySegment(segment.replace(/\./g, '-')))
.filter(Boolean)
.join('-');
}
/**
* Convert a repo-relative file path to a GBrain page slug.
*/
export function pathToSlug(
filePath: string,
repoPrefix?: string,
options: { pageKind?: 'markdown' | 'code' } = {},
): string {
const pageKind = options.pageKind || 'markdown';
let slug = pageKind === 'code' ? slugifyCodePath(filePath) : slugifyPath(filePath);
if (repoPrefix) slug = `${repoPrefix}/${slug}`;
return slug.toLowerCase();
}
/**
* v0.20.0 Cathedral II Layer 1a (SP-5 fix) — centralized slug dispatcher.
*
* Before Cathedral II, `importFromFile` / `importCodeFile` chose between
* `slugifyPath` and `slugifyCodePath` inline, but the sync delete/rename
* paths in `performSync` always called `pathToSlug(path)` with the default
* pageKind='markdown'. For a 9-extension-wide code classifier this was
* mostly correct (code files were rare), but Layer 1a widens the classifier
* to ~35 extensions and without this dispatcher, deleting or renaming a
* Rust/Java/Ruby/etc. file would try to delete the wrong slug (the
* markdown-style slug) and leave the real code-slug page orphaned forever.
*
* Every sync-path caller that used to pick a pageKind manually should now
* call resolveSlugForPath — it derives the right slug shape from
* isCodeFilePath(), which in turn derives from the chunker's language map.
* Central dispatch means new extensions added to the chunker automatically
* flow through without touching the sync code path.
*/
export function resolveSlugForPath(filePath: string, repoPrefix?: string): string {
const pageKind = isCodeFilePath(filePath) ? 'code' : 'markdown';
return pathToSlug(filePath, repoPrefix, { pageKind });
}
// ─────────────────────────────────────────────────────────────────
// Sync failure tracking — Bug 9
// ─────────────────────────────────────────────────────────────────
//
// When a sync run catches a per-file parse error (YAML with unquoted
// colons, malformed frontmatter, etc.), we record it here instead of just
// logging and moving on. Three goals:
// 1. Gate the sync.last_commit bookmark advance in all three sync paths
// (incremental, full/runImport, `gbrain import` git continuity).
// 2. Give users a visible record of what failed, with the commit hash
// they can use to re-attempt after fixing the source file.
// 3. Let `gbrain sync --skip-failed` acknowledge a known-bad set so
// repos with many broken files aren't permanently stuck.
import { existsSync as _existsSync, readFileSync as _readFileSync, appendFileSync as _appendFileSync, mkdirSync as _mkdirSync } from 'fs';
import { join as _joinPath } from 'path';
import { gbrainPath as _gbrainPath } from './config.ts';
import { createHash as _createHash } from 'crypto';
export interface SyncFailure {
path: string;
error: string;
/** Structured error code extracted from the error message. */
code?: string;
commit: string;
line?: number;
ts: string;
acknowledged?: boolean;
acknowledged_at?: string;
}
/**
* Best-effort extraction of a structured error code from a sync failure
* message. Matches known ParseValidationCode patterns (SLUG_MISMATCH,
* YAML_PARSE, etc.) and common DB / timeout errors. Returns 'UNKNOWN'
* when no pattern matches.
*
* Order matters: DB-layer errors are checked BEFORE YAML-layer ones so
* Postgres `duplicate key value violates unique constraint` doesn't get
* mislabeled as a YAML duplicate-key. Frontmatter patterns key off the
* canonical messages emitted by `collectValidationErrors()` in markdown.ts.
*/
export function classifyErrorCode(errorMsg: string): string {
// SLUG_MISMATCH: thrown by importFromFile() at src/core/import-file.ts:374.
if (/slug.*does not match|SLUG_MISMATCH/i.test(errorMsg)) return 'SLUG_MISMATCH';
// DB-layer errors come BEFORE the YAML duplicate-key check. Postgres unique-
// constraint violations contain "duplicate key" but are not a YAML problem.
if (/duplicate key value violates unique constraint|DB_DUPLICATE_KEY/i.test(errorMsg)) {
return 'DB_DUPLICATE_KEY';
}
if (/canceling statement due to statement timeout|STATEMENT_TIMEOUT/i.test(errorMsg)) {
return 'STATEMENT_TIMEOUT';
}
// YAML / frontmatter patterns. These match either the canonical message
// strings in src/core/markdown.ts (collectValidationErrors) or the literal
// ParseValidationCode token, so they fire whether the caller stores the
// message or just the code.
if (/YAML parse failed|YAML_PARSE/i.test(errorMsg)) return 'YAML_PARSE';
if (/YAMLException|duplicated mapping key|YAML_DUPLICATE_KEY/i.test(errorMsg)) {
return 'YAML_DUPLICATE_KEY';
}
if (/File is empty or whitespace-only|Frontmatter must start with ---|MISSING_OPEN/i.test(errorMsg)) {
return 'MISSING_OPEN';
}
if (/No closing --- delimiter|Heading at line .* found inside frontmatter|MISSING_CLOSE/i.test(errorMsg)) {
return 'MISSING_CLOSE';
}
if (/Frontmatter block is empty|EMPTY_FRONTMATTER/i.test(errorMsg)) return 'EMPTY_FRONTMATTER';
if (/Content contains null bytes|NULL_BYTES|null byte/i.test(errorMsg)) return 'NULL_BYTES';
if (/Nested double quotes|NESTED_QUOTES/i.test(errorMsg)) return 'NESTED_QUOTES';
// Generic fallbacks.
if (/invalid UTF-?8|INVALID_UTF8/i.test(errorMsg)) return 'INVALID_UTF8';
// v0.22.12 additions: covers the four real production sites in src/core/import-file.ts
// (lines 199, 347, 352, 401) that previously bucketed to UNKNOWN.
if (/file too large|content too large|FILE_TOO_LARGE/i.test(errorMsg)) return 'FILE_TOO_LARGE';
if (/skipping symlink|symlink|SYMLINK_NOT_ALLOWED/i.test(errorMsg)) return 'SYMLINK_NOT_ALLOWED';
// v0.32 takes-v2 additions: malformed fence rows + holder-grammar failures.
// TAKES_TABLE_MALFORMED and TAKES_ROW_NUM_COLLISION are produced by
// parseTakesFence (src/core/takes-fence.ts); TAKES_HOLDER_INVALID lands
// in v0.32 (EXP-4) when a holder doesn't match the world|brain|people/...|
// companies/... grammar. Wired into sync-failures.jsonl by the v0_28_0
// migration's phaseBBackfill (one-time backfill emission).
if (/TAKES_TABLE_MALFORMED|TAKES_ROW_NUM_COLLISION|TAKES_FENCE_UNBALANCED/i.test(errorMsg)) {
return 'TAKES_TABLE_MALFORMED';
}
if (/TAKES_HOLDER_INVALID/i.test(errorMsg)) return 'TAKES_HOLDER_INVALID';
return 'UNKNOWN';
}
/** Group failures by error code and return a sorted summary. */
export function summarizeFailuresByCode(
failures: Array<{ error: string; code?: string }>,
): Array<{ code: string; count: number }> {
const counts: Record<string, number> = {};
for (const f of failures) {
const code = f.code ?? classifyErrorCode(f.error);
counts[code] = (counts[code] ?? 0) + 1;
}
return Object.entries(counts)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => ({ code, count }));
}
/**
* Format a code-grouped summary as a human-readable multi-line string for
* stderr / doctor output. Accepts either raw failures (which are summarized
* internally) or an already-summarized `{code, count}[]` shape (the return
* value of `summarizeFailuresByCode` or `AcknowledgeResult.summary`).
* Returns an empty string when the input is empty.
*/
export function formatCodeBreakdown(
input: Array<{ error: string; code?: string }> | Array<{ code: string; count: number }>,
): string {
// Distinguish by shape: summary entries have a numeric `count`. Empty array
// returns '' from either branch — both paths produce a 0-length join.
const summary =
input.length > 0 && typeof (input[0] as { count?: unknown }).count === 'number'
? (input as Array<{ code: string; count: number }>)
: summarizeFailuresByCode(input as Array<{ error: string; code?: string }>);
return summary.map(s => ` ${s.code}: ${s.count}`).join('\n');
}
function _failuresDir(): string {
return _gbrainPath();
}
export function syncFailuresPath(): string {
return _joinPath(_failuresDir(), 'sync-failures.jsonl');
}
function _hashError(msg: string): string {
return _createHash('sha256').update(msg).digest('hex').slice(0, 12);
}
function _dedupKey(f: { path: string; commit: string; error: string }): string {
return `${f.path}|${f.commit}|${_hashError(f.error)}`;
}
/**
* Read the failures JSONL, skipping malformed lines with a warning to stderr.
* Returns empty array if the file doesn't exist.
*/
export function loadSyncFailures(): SyncFailure[] {
const path = syncFailuresPath();
if (!_existsSync(path)) return [];
const raw = _readFileSync(path, 'utf-8');
const out: SyncFailure[] = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
out.push(JSON.parse(trimmed) as SyncFailure);
} catch {
console.warn(`[sync-failures] skipping malformed line: ${trimmed.slice(0, 120)}`);
}
}
return out;
}
/**
* Append failure entries to the JSONL. Dedups by (path, commit, error-hash) —
* the same file failing with the same error on the same commit writes ONCE
* to the log, not once per sync run.
*/
export function recordSyncFailures(
failures: Array<{ path: string; error: string; line?: number }>,
commit: string,
): void {
if (failures.length === 0) return;
const existing = loadSyncFailures();
const seen = new Set(existing.map(f => _dedupKey(f)));
_mkdirSync(_failuresDir(), { recursive: true });
const now = new Date().toISOString();
for (const f of failures) {
const entry: SyncFailure = {
path: f.path,
error: f.error,
code: classifyErrorCode(f.error),
commit,
line: f.line,
ts: now,
};
if (seen.has(_dedupKey(entry))) continue;
_appendFileSync(syncFailuresPath(), JSON.stringify(entry) + '\n');
seen.add(_dedupKey(entry));
}
}
export interface AcknowledgeResult {
count: number;
summary: Array<{ code: string; count: number }>;
}
/**
* Mark all unacknowledged failures as acknowledged. Used by
* `gbrain sync --skip-failed`. Returns count and a structured summary
* grouped by error code so the operator can see *why* files were skipped.
*
* We do not delete — acknowledged entries stay as historical record so
* doctor can still show them under a "previously skipped" bucket.
*/
export function acknowledgeSyncFailures(): AcknowledgeResult {
const entries = loadSyncFailures();
if (entries.length === 0) return { count: 0, summary: [] };
const now = new Date().toISOString();
let changed = 0;
const newlyAcked: SyncFailure[] = [];
const updated = entries.map(e => {
if (e.acknowledged) return e;
changed++;
// Backfill code for entries that predate the code field.
const code = e.code ?? classifyErrorCode(e.error);
const acked = { ...e, code, acknowledged: true, acknowledged_at: now };
newlyAcked.push(acked);
return acked;
});
if (changed === 0) return { count: 0, summary: [] };
_mkdirSync(_failuresDir(), { recursive: true });
const fd = require('fs').writeFileSync;
fd(syncFailuresPath(), updated.map(e => JSON.stringify(e)).join('\n') + '\n');
return {
count: changed,
summary: summarizeFailuresByCode(newlyAcked),
};
}
/** Return only unacknowledged failures. */
export function unacknowledgedSyncFailures(): SyncFailure[] {
return loadSyncFailures().filter(f => !f.acknowledged);
}