mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(import): coerce non-string frontmatter title/slug/type (#1939) YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number; the old `(frontmatter.X as string)` cast was a compile-time lie, so downstream `.toLowerCase()` threw and (via the importer failure gate) could wedge sync indefinitely. parseMarkdown now coerces via coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the pure assessContentSanity self-protects against a non-string title. * feat(sync): bounded auto-skip failure ledger; poison file can't wedge indexing (#1939) New src/core/sync-failure-ledger.ts owns the failure store + a crash-safe, multi-source, concurrent bounded auto-skip valve. A file that fails N consecutive syncs (GBRAIN_SYNC_AUTOSKIP_AFTER, default 3) auto-skips so it can't freeze all indexing forever, while fresh failures still fail-closed and a `<head>` history-rewrite sentinel hard-blocks even with --skip-failed. - (source_id, path) keying — failures never merge across sources - success clears a path so attempts are truly consecutive - advance-before-ack ordering (a crash can't mark a file skipped while wedged) - shared applySyncFailureGate used by BOTH the incremental and full-sync gates - legacy-row normalization + duplicate collapse on load - cross-process lock + atomic temp-rename, age-based stale-lock break sync.ts re-exports the ledger for existing callers; import.ts records source-scoped and defers the bookmark to the gate under managedBookmark. * fix(doctor): sync_failures severity via one shared decision on both surfaces (#1939) Local buildChecks and remote doctorReportRemote now both route through decideSyncFailureSeverity, so a stuck bookmark escalates WARN -> FAIL consistently (oldest-open age > fail cadence, or large unresolved count), auto-skipped pages stay visible (WARN, not hidden), and the acknowledged/acknowledged_at field-split that caused drift is gone. The remote surface stays subprocess-free (file read + Date.parse only). * chore(test): add trailing newline to e5-lease-cap-ab baseline fixture * fix(sync): address adversarial review findings on the failure ledger (#1939) - #1: a parse-failed file that is later deleted/renamed-away no longer leaves a permanent open ledger row. Removed paths (filtered.deleted, renamed-from, and the "gone from disk" forward-delete skip branch) are treated as resolved so the ledger self-heals instead of aging doctor to a stuck FAIL. - #3: decideSyncFailureSeverity escalates to FAIL on OPEN (blocking) failures only — auto_skipped rows already advanced the bookmark, so they stay WARN-visible regardless of count, matching the state-machine contract. * chore: bump version and changelog (v0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document sync-failure ledger + auto-skip valve for v0.42.30.0 KEY_FILES.md: new src/core/sync-failure-ledger.ts entry (bounded auto-skip state machine, decideGateAction/decideSyncFailureSeverity/applySyncFailureGate, GBRAIN_SYNC_AUTOSKIP_AFTER); update sync.ts (failure store moved to ledger, re-exported), doctor.ts (sync_failures severity via shared rule on both surfaces), markdown.ts (coerceFrontmatterString), import.ts (managedBookmark). live-sync.md: poison-file auto-skip tricky-spot. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.31.0 (queue collision on 0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.32.0 (queue collision) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
510 lines
19 KiB
TypeScript
510 lines
19 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';
|
|
// v0.37.7.0 #1169 submodule-detection helpers. Bottom-of-file already
|
|
// aliases existsSync as `_existsSync` for other purposes; the top-of-file
|
|
// import keeps the pruneDir helper's deps near its callsite.
|
|
import { existsSync, statSync } from 'fs';
|
|
import { join as pathJoin } from 'path';
|
|
|
|
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',
|
|
// v0.36.x #878: Terraform / HCL. Closes the silent-data-loss bug where
|
|
// Terraform repos were invisible to `gbrain sync --strategy code`.
|
|
// detectCodeLanguage() returns null for these so they chunk via the
|
|
// recursive chunker (no tree-sitter grammar), which is the correct
|
|
// fallback — same path as toml / yaml without language-specific AST.
|
|
'.tf', '.tfvars', '.hcl',
|
|
// v0.41 D2 wave (#1173): SQL via tree-sitter-sql. DerekStride grammar
|
|
// chunks DDL (CREATE TABLE/FUNCTION/VIEW/INDEX) and DML (SELECT/INSERT/
|
|
// UPDATE/DELETE) as one chunk per statement. DDL chunks carry
|
|
// symbol_name + symbol_type populated for code-def; DML chunks emit
|
|
// unnamed so they don't pollute symbol search.
|
|
'.sql',
|
|
]);
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* v0.37.7.0 #1169: when callers pass `parentDir`, ALSO skip git submodule
|
|
* directories (detected by the presence of `.git` as a FILE — not a
|
|
* directory — inside the candidate dir). The `parentDir` arg is optional so
|
|
* existing callers stay back-compat; new callers (sync walker, extract
|
|
* walker) thread it through.
|
|
*/
|
|
export function pruneDir(name: string, parentDir?: 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;
|
|
// Submodule detection: a git submodule directory contains `.git` as
|
|
// a FILE (a "gitfile" pointing into the parent's .git/modules/...),
|
|
// not a directory. Best-effort: if we can't stat (e.g. cross-platform
|
|
// permission edge), fall through and treat as a normal dir.
|
|
if (parentDir) {
|
|
try {
|
|
const gitPath = pathJoin(parentDir, name, '.git');
|
|
if (existsSync(gitPath) && statSync(gitPath).isFile()) {
|
|
return false;
|
|
}
|
|
} catch {
|
|
// Stat failed — descend normally rather than silently exclude.
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Discriminator for WHY a path is not syncable. Returned by `unsyncableReason`
|
|
* so the sync cleanup loop in `commands/sync.ts` can distinguish "metafile we
|
|
* intentionally exclude" from "user removed this file from the strategy".
|
|
*
|
|
* v0.41.13 (#1433): pre-fix, the cleanup loop in performSync treated all
|
|
* unsyncable-modified paths the same and DELETED any pre-existing page for
|
|
* them. That silently dropped `log.md` / `schema.md` / `README.md` pages
|
|
* that had been indexed by older gbrain versions (or via direct put_page).
|
|
* The fix guards that loop on `unsyncableReason(...) === 'metafile'` and
|
|
* preserves those rows.
|
|
*/
|
|
export type SyncableReason =
|
|
| 'metafile'
|
|
| 'strategy'
|
|
| 'pruned-dir'
|
|
| 'include-glob-miss'
|
|
| 'exclude-glob-hit';
|
|
|
|
/**
|
|
* Canonical metafile basenames the markdown sync strategy intentionally
|
|
* skips. Exported so the cleanup-loop guard in `commands/sync.ts` can
|
|
* surface them in user-facing logs / docs without re-declaring the list.
|
|
*
|
|
* These files are append-only domain logs / index pages / boilerplate
|
|
* READMEs — not typed brain pages — by convention. A user who genuinely
|
|
* wants to index one of these basenames as a page should rename it.
|
|
*/
|
|
export const SYNC_SKIP_FILES = ['schema.md', 'index.md', 'log.md', 'README.md'] as const;
|
|
|
|
/**
|
|
* Internal classifier. Returns null when the path IS syncable, or a tagged
|
|
* SyncableReason explaining why it isn't. The single source of truth that
|
|
* both `isSyncable` (boolean) and `unsyncableReason` (tagged) call.
|
|
*
|
|
* Codex review caught the drift risk if `unsyncableReason` were an independent
|
|
* re-implementation. Funnelling both public APIs through `classifySync` means
|
|
* TypeScript enforces consistency at the compiler level.
|
|
*/
|
|
function classifySync(path: string, opts: SyncableOptions = {}): SyncableReason | null {
|
|
const strategy = opts.strategy || 'markdown';
|
|
|
|
if (!isAllowedByStrategy(path, strategy)) return 'strategy';
|
|
|
|
// 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 'pruned-dir';
|
|
|
|
// Skip meta files that aren't pages
|
|
const basename = segments[segments.length - 1] || '';
|
|
if ((SYNC_SKIP_FILES as readonly string[]).includes(basename)) return 'metafile';
|
|
|
|
if (opts.include && opts.include.length > 0 && !matchesAnyGlob(path, opts.include)) return 'include-glob-miss';
|
|
if (opts.exclude && opts.exclude.length > 0 && matchesAnyGlob(path, opts.exclude)) return 'exclude-glob-hit';
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
return classifySync(path, opts) === null;
|
|
}
|
|
|
|
/**
|
|
* Companion to `isSyncable`. Returns null when the path IS syncable, or a
|
|
* tagged `SyncableReason` explaining why it isn't. Used by the v0.41.13
|
|
* #1433 cleanup guard in `commands/sync.ts` to distinguish metafile
|
|
* exclusions (preserve any pre-existing page) from genuine "file removed
|
|
* from the strategy" cases (delete the now-stale page).
|
|
*
|
|
* Routes through the same `classifySync` as `isSyncable` so the two cannot
|
|
* drift. Identical opts contract — callers pass whatever they pass `isSyncable`.
|
|
*/
|
|
export function unsyncableReason(path: string, opts: SyncableOptions = {}): SyncableReason | null {
|
|
return classifySync(path, opts);
|
|
}
|
|
|
|
/**
|
|
* 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 ledger — moved to ./sync-failure-ledger.ts (issue #1939)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// The failure store + bounded auto-skip valve now live in a leaf module so
|
|
// they can be unit-tested in isolation and shared by both sync gates without
|
|
// a circular import. Re-exported here so existing callers that
|
|
// `await import('../core/sync.ts')` for these symbols keep working.
|
|
export {
|
|
classifyErrorCode,
|
|
summarizeFailuresByCode,
|
|
formatCodeBreakdown,
|
|
syncFailuresPath,
|
|
loadSyncFailures,
|
|
unacknowledgedSyncFailures,
|
|
recordSyncFailures,
|
|
acknowledgeSyncFailures,
|
|
recordFailures,
|
|
clearFailures,
|
|
acknowledgeFailures,
|
|
autoSkipFailures,
|
|
withLedgerLock,
|
|
resolveAutoSkipThreshold,
|
|
isSkippablePath,
|
|
decideGateAction,
|
|
decideSyncFailureSeverity,
|
|
applySyncFailureGate,
|
|
DEFAULT_SOURCE_ID,
|
|
SENTINEL_PREFIX,
|
|
DEFAULT_AUTOSKIP_AFTER,
|
|
} from './sync-failure-ledger.ts';
|
|
export type {
|
|
SyncFailure,
|
|
SyncFailureState,
|
|
AcknowledgeResult,
|
|
GateDecision,
|
|
SeverityResult,
|
|
SyncGateInput,
|
|
SyncGateOutcome,
|
|
} from './sync-failure-ledger.ts';
|