/** * Extraction Orchestrator * * Coordinates file scanning, parsing, and database storage. */ import * as fs from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { execFileSync } from 'child_process'; import { Language, FileRecord, ExtractionResult, ExtractionError, Node, Edge, UnresolvedReference, ReferenceKind, } from '../types'; import { QueryBuilder } from '../db/queries'; import { extractFromSource } from './tree-sitter'; import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool'; import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer'; import { materializeKernelResult } from './kernel'; import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars'; import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config'; import { isCodeGraphDataDir } from '../directory'; import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot, normalizePath } from '../utils'; import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; /** * Number of files to read in parallel during indexing. * File reads are I/O-bound; batching overlaps I/O wait with CPU parse work. */ const FILE_IO_BATCH_SIZE = 10; /** * How many files the `sync()` reconcile processes between cooperative yields to * the event loop. The reconcile runs two O(files) loops of synchronous `fs` * calls (existsSync for removals, statSync for adds/mods); on a very large repo * (~100k files) an un-yielded run wedges the main thread for minutes, which both * trips the liveness watchdog (it SIGKILLs a process whose loop stops turning) * and blocks the first MCP tool call behind the catch-up gate (issue #905). * Yielding every N files keeps the socket, the watchdog heartbeat, and any * concurrent read query responsive while the reconcile runs. */ const SYNC_RECONCILE_YIELD_INTERVAL = 1000; // PARSER_RESET_INTERVAL moved to parse-worker.ts (runs in worker thread) /** * Maximum time (ms) to wait for a single file to parse in the worker thread. * If tree-sitter hangs or WASM runs out of memory, this prevents the entire * indexing run from freezing. The worker is restarted after a (hard) timeout. * Env-overridable via CODEGRAPH_PARSE_TIMEOUT_MS for slow storage (#1231). */ const PARSE_TIMEOUT_MS = resolveParseTimeoutMs(process.env.CODEGRAPH_PARSE_TIMEOUT_MS); /** * Number of files to parse before recycling the worker thread. * WASM linear memory can grow but NEVER shrink (WebAssembly spec limitation). * The only way to reclaim tree-sitter's WASM heap is to destroy the entire * V8 isolate by terminating the worker thread and spawning a fresh one. * This interval balances memory usage against the cost of reloading grammars. */ const WORKER_RECYCLE_INTERVAL = 250; /** * Progress callback for indexing operations */ export interface IndexProgress { phase: 'scanning' | 'parsing' | 'storing' | 'resolving' | 'linking'; current: number; total: number; currentFile?: string; } /** * Result of an indexing operation */ export interface IndexResult { success: boolean; filesIndexed: number; filesSkipped: number; filesErrored: number; /** * How many indexable files the scan discovered — the ground truth the * indexed/skipped/errored tallies must add up to. A shortfall means files * were silently dropped mid-pipeline (e.g. a killed worker under load) and * the index is PARTIAL; callers surface that rather than trusting the * counts. Only set by full-index runs (indexAll), not indexFiles/sync. */ filesDiscovered?: number; nodesCreated: number; edgesCreated: number; errors: ExtractionError[]; durationMs: number; } /** * Result of a sync operation */ export interface SyncResult { filesChecked: number; filesAdded: number; filesModified: number; filesRemoved: number; nodesUpdated: number; durationMs: number; changedFilePaths?: string[]; } /** * Calculate SHA256 hash of file contents */ export function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } /** * Skip files larger than this (bytes). Generated bundles, minified JS, and * vendored blobs blow the WASM heap and the worker-recycle budget for no useful * symbols. 1 MB covers essentially all hand-written source. */ const MAX_FILE_SIZE = 1024 * 1024; /** * Directory names that are dependency, build, cache, or tooling output across the * languages/frameworks CodeGraph supports — curated from the canonical * github/gitignore templates. Excluded by default so the graph reflects your code, * not third-party noise, without requiring a `.gitignore` (issue #407). The * exclusion applies uniformly (git or not, tracked or not); the only opt-in is an * explicit `.gitignore` negation (e.g. `!vendor/`). First-party-prone or generic * names (`packages`, `lib`, `app`, `bin`, `src`, `deps`, `env`, `tmp`, `storage`, * `Library`) are deliberately NOT listed, to avoid ever hiding real source. * * Only dirs that actually contain *indexable source* (or are enormous) earn a slot * — IDE/state dirs like `.idea`/`.vs` are omitted because CodeGraph indexes only * recognized source extensions, so they produce no symbols regardless. */ const DEFAULT_IGNORE_DIRS: ReadonlySet = new Set([ // JS / TS — dependency directories 'node_modules', 'bower_components', 'jspm_packages', 'web_modules', '.yarn', '.pnpm-store', // JS / TS — framework & bundler build / cache / deploy output '.next', '.nuxt', '.svelte-kit', '.turbo', '.vite', '.parcel-cache', '.angular', '.docusaurus', 'storybook-static', '.vinxi', '.nitro', 'out-tsc', '.vercel', '.netlify', '.wrangler', // Build output (common across ecosystems) 'dist', 'build', 'out', '.output', // Test / coverage 'coverage', '.nyc_output', // Python '__pycache__', '__pypackages__', '.venv', 'venv', '.pixi', '.pdm-build', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.tox', '.nox', '.hypothesis', '.ipynb_checkpoints', '.eggs', // Rust / JVM (Maven, Gradle, Scala) 'target', '.gradle', // .NET 'obj', // Vendored deps (Go, PHP/Composer, Ruby/Bundler) 'vendor', // Swift / iOS '.build', 'Pods', 'Carthage', 'DerivedData', '.swiftpm', // Dart / Flutter '.dart_tool', '.pub-cache', // Native (Android NDK, C/C++ deps) '.cxx', '.externalNativeBuild', 'vcpkg_installed', // Scala tooling '.bloop', '.metals', // Lua / Luau (LuaRocks) 'lua_modules', '.luarocks', // Delphi / RAD Studio IDE backups (duplicate .pas source — would double-count) '__history', '__recovery', // Generic cache '.cache', ]); /** * Android resource directory types. A `res/` tree holds ONLY non-code resources — * layouts, drawables, value bags (strings/colors/styles), menus, navigation * graphs — split into one typed subdirectory per kind, optionally density/locale/ * version-qualified (`values-es`, `drawable-hdpi`, `layout-v21`, …). None of it * yields an extractable code symbol, yet on an Android app it DOMINATES the tree * (one report: 26k XML files = 97% of the project, 0 symbols), bloating the DB, * slowing indexing, and skewing both the file count and `codegraph_explore` * results (#1047). So these are excluded by default. The structure is * self-identifying — a non-Android project has no `res/layout/` etc., so it's * untouched — and the only XML that DOES produce symbols (MyBatis mappers) lives * under `src/main/resources/`, never `res/`, so nothing useful is dropped. * `res/raw/` is deliberately NOT here: it holds arbitrary bundled assets that can * be code-ish (a `.sql` schema, a `.js`), so we leave it indexed. Override any of * these with a `.gitignore` negation (e.g. `!res/values/`). */ const ANDROID_RES_TYPES: readonly string[] = [ 'anim', 'animator', 'color', 'drawable', 'font', 'layout', 'menu', 'mipmap', 'navigation', 'transition', 'values', 'xml', ]; /** Gitignore-style patterns for the `ignore` matcher: the dirs above plus a few globs. */ const DEFAULT_IGNORE_PATTERNS: string[] = [ ...Array.from(DEFAULT_IGNORE_DIRS, (d) => `${d}/`), '*.egg-info/', // Python packaging metadata 'cmake-build-*/', // CLion / CMake build trees 'bazel-*/', // Bazel output symlink trees // Android resource dirs at any depth, with their qualifier variants (#1047). ...ANDROID_RES_TYPES.map((t) => `**/res/${t}*/`), ]; /** True if `buf` decodes as strict UTF-8 (no invalid byte sequences). */ function isValidUtf8(buf: Buffer): boolean { try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true; } catch { return false; } } /** * Read a `.gitignore` and return patterns safe to hand to the `ignore` matcher — * never throwing, even when the file isn't real gitignore text. Two failure * modes, both seen in the wild (issue #682): * * - The file isn't valid UTF-8 — e.g. transparently encrypted in place by * corporate DLP / endpoint-security software, leaving a UTF-16 header plus * ciphertext. None of it is meaningful patterns, so the whole file is skipped. * - The file is text but a single line can't be compiled to a regex by the * `ignore` library — `\\[` and friends throw "Unterminated character class". * Crucially the throw is LAZY (at match time, not `.add()`), so it would * otherwise escape mid-scan. That one pattern is dropped; the rest are kept. * * Either way a warning that NAMES the file is logged (the reporter couldn't tell * which `.gitignore` was at fault) and indexing continues instead of aborting. * Returns '' when there's nothing usable. */ function readGitignorePatterns(giPath: string): string { let buf: Buffer; try { buf = fs.readFileSync(giPath); } catch { return ''; // unreadable (permissions / race) — treat as absent } // A NUL byte never appears in real gitignore text, and a fatal UTF-8 decode // catches the rest. Such a file isn't ignore patterns at all. if (buf.includes(0) || !isValidUtf8(buf)) { logWarn( 'Ignoring a .gitignore that is not valid UTF-8 text — it may have been encrypted ' + 'in place by endpoint-security software. Indexing continues without it.', { file: giPath }, ); return ''; } const content = buf.toString('utf-8'); // Fast path: one `.ignores()` call forces the library to compile EVERY rule, // so if it doesn't throw, the whole file is safe to use verbatim. try { ignore().add(content).ignores('.codegraph-probe'); return content; } catch { // Fall through: a line is uncompilable — keep the good ones, drop the bad. } const kept: string[] = []; let dropped = 0; for (const line of content.split(/\r?\n/)) { try { ignore().add(line).ignores('.codegraph-probe'); kept.push(line); } catch { dropped++; } } if (dropped > 0) { logWarn( `Skipped ${dropped} unparseable pattern(s) in a .gitignore; the rest are applied.`, { file: giPath }, ); } return kept.join('\n'); } /** * An `ignore` matcher seeded with the built-in defaults, merged with the project's * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default. Shared * by both enumeration paths so behavior is identical with or without git — and so * the defaults apply to tracked files too (committing a dependency dir doesn't make * it project code; the explicit `.gitignore` negation is the only opt-in). */ export function buildDefaultIgnore(rootDir: string): Ignore { const ig = ignore().add(DEFAULT_IGNORE_PATTERNS); const rootGitignore = path.join(rootDir, '.gitignore'); if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore)); return ig; } /** * Defaults-only ignore matcher (no root `.gitignore` merged). Used wherever the * parent repo's own ignore rules must NOT apply — inside embedded child repos, * whose gitignore semantics their own `git ls-files` already enforced (#514). */ function defaultsOnlyIgnore(): Ignore { return ignore().add(DEFAULT_IGNORE_PATTERNS); } /** * Matcher for the project's `codegraph.json` `includeIgnored` patterns — the * explicit opt-in to index embedded git repos living inside gitignored * directories (#622, #699). Returns `null` when the project opted in nothing, * which is the zero-config DEFAULT: `.gitignore` is then fully respected and a * gitignored directory (even one holding nested repos) is never walked or * indexed (#970, #976). Built once per scan/sync/scope operation from the scan * root and threaded down — never global, so multi-project daemons stay isolated. */ function loadIncludeIgnoredMatcher(rootDir: string): Ignore | null { const patterns = loadIncludeIgnoredPatterns(rootDir); return patterns.length > 0 ? ignore().add(patterns) : null; } /** * Matcher for the project's `codegraph.json` `exclude` patterns — paths to keep * OUT of the index even when git-tracked, which `.gitignore` cannot do (#999). * The escape hatch for a committed vendor/theme/SDK directory. Returns `null` * when nothing is excluded (the zero-config default → no overhead). Matched * against project-root-relative paths, so it applies uniformly across the whole * workspace, including inside embedded repos (excluding `static/` means gone * everywhere). Built once per scan/sync/scope operation from the scan root. */ function loadExcludeMatcher(rootDir: string): Ignore | null { const patterns = loadExcludePatterns(rootDir); return patterns.length > 0 ? ignore().add(patterns) : null; } /** * Matcher for the project's `codegraph.json` `include` patterns — first-party * source to force INTO the index even when `.gitignore` drops it (the general * whitelist `includeIgnored` never was — that one only revives *embedded git * repos*). The case it exists for: a project under a second VCS (SVN/Perforce) * `.gitignore`s its own real source so it stays out of Git, yet we still want it * indexed. Returns `null` when nothing is force-included (the zero-config * default → no overhead, no extra walk). Built once per scan/sync/scope * operation from the scan root. */ function loadIncludeMatcher(rootDir: string): Ignore | null { const patterns = loadIncludePatterns(rootDir); return patterns.length > 0 ? ignore().add(patterns) : null; } /** Glob metacharacters that end the static (literal) prefix of an `include` pattern. */ const GLOB_META = /[*?[\]{}!]/; /** * The static directory prefix of each `include` pattern — the literal leading * path up to the first glob segment — trailing-slashed, used to (a) walk only * the opted-in subtrees in `collectIncludedFiles` and (b) let `ScopeIgnore` keep * the watcher descending toward them. `Tools/` stays `Tools/`; a recursive * `Tools/**` glob yields `Tools/`; `src/local/file.ts` yields `src/local/` (the * file's dir); a pattern that starts with a glob (like a leading `**`) yields * `''`, meaning "no static root — walk the whole tree". Duplicates and roots * nested under a broader root are collapsed so each subtree is walked once. */ function includeStaticRoots(patterns: string[]): string[] { const roots = new Set(); for (const pattern of patterns) { let p = pattern.replace(/^\/+/, ''); const trailingSlash = p.endsWith('/'); if (trailingSlash) p = p.slice(0, -1); const segs = p.split('/').filter(Boolean); const lead: string[] = []; for (const s of segs) { if (GLOB_META.test(s)) break; lead.push(s); } const hadWildcard = lead.length < segs.length; // A wholly-literal pattern with no trailing slash names a file (or a dir we // can't tell apart) — drop its last segment so we walk the containing dir // and let the matcher pick the file. A trailing slash or a glob means the // remaining `lead` is already the directory to walk. if (!hadWildcard && !trailingSlash && lead.length > 0) lead.pop(); if (lead.length === 0) { roots.clear(); roots.add(''); return ['']; // a top-level glob forces a whole-tree walk; nothing narrower matters } roots.add(lead.join('/') + '/'); } // Collapse roots nested under a broader one (e.g. drop `a/b/` if `a/` is present). const all = [...roots]; return all.filter((r) => !all.some((other) => other !== r && r.startsWith(other))); } /** * Actively discover the source files an `include` whitelist forces in. `git * ls-files` never lists gitignored files, so a filtered filesystem walk of just * the opted-in subtrees (`includeStaticRoots`) is the only way to find them. * Returns project-root-relative, normalized source-file paths. * * A file is collected when it MATCHES `include`, is NOT hit by `exclude` (an * explicit exclude always wins), is a recognized source file, and does not live * under a built-in default-ignored dir (`node_modules`, `dist`, …), `.git`, or * CodeGraph's data dir — those are never resurfaced, mirroring `ScopeIgnore`. * `.gitignore` is deliberately NOT consulted: overriding it is the whole point. */ function collectIncludedFiles( rootDir: string, include: Ignore, exclude: Ignore | null, roots: string[], overrides: Record, ): Set { const out = new Set(); const defaults = defaultsOnlyIgnore(); const visited = new Set(); const consider = (abs: string, rel: string, isDir: boolean): void => { if (isDir) { if (defaults.ignores(rel + '/')) return; // never node_modules/dist/… via include // An explicit `exclude` always wins over `include`; prune the whole subtree // here so a large excluded dir (a committed frontend's own vendored deps, // build output, …) is never walked — the per-file guard below still catches // anything a directory pattern doesn't, so this is a pure efficiency win. if (exclude && exclude.ignores(rel + '/')) return; walk(abs); } else { if (defaults.ignores(rel)) return; if (!include.ignores(rel)) return; if (exclude && exclude.ignores(rel)) return; if (!isSourceFile(rel, overrides)) return; out.add(rel); } }; function walk(absDir: string): void { let realDir: string; try { realDir = fs.realpathSync(absDir); } catch { return; } if (visited.has(realDir)) return; // symlink-cycle guard visited.add(realDir); let entries: fs.Dirent[]; try { entries = fs.readdirSync(absDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name === '.git' || isCodeGraphDataDir(entry.name)) continue; const abs = path.join(absDir, entry.name); const rel = normalizePath(path.relative(rootDir, abs)); if (!rel || rel.startsWith('..')) continue; if (entry.isSymbolicLink()) { try { const st = fs.statSync(fs.realpathSync(abs)); consider(abs, rel, st.isDirectory()); } catch { // broken symlink — skip } continue; } consider(abs, rel, entry.isDirectory()); } } for (const root of roots) { walk(root === '' ? rootDir : path.join(rootDir, root)); } return out; } /** * The included source files (`codegraph.json` `include`) for a scan root, or an * empty set when nothing is force-included. Centralizes loading the matcher, * roots, exclude, and overrides so both enumeration paths (git and filesystem * walk) add the same files. */ function collectIncludedFilesForRoot(rootDir: string): Set { const include = loadIncludeMatcher(rootDir); if (!include) return new Set(); const roots = includeStaticRoots(loadIncludePatterns(rootDir)); return collectIncludedFiles(rootDir, include, loadExcludeMatcher(rootDir), roots, loadExtensionOverrides(rootDir)); } /** * `git ls-files --directory` collapses a wholly-untracked/ignored directory into * one entry — and when the command's own cwd is such a directory (the indexed * root is itself a git-ignored subdir of an enclosing repo), git emits the * literal `./` meaning "this entire directory". That sentinel is not a real * nested path: feeding it to the `ignore` matcher throws ("path should be a * `path.relative()`d string, but got "./""), which used to abort `buildScopeIgnore` * and so break the MCP daemon's watcher/auto-sync on connect; and joining it back * onto `repoDir` would just re-point at the cwd. Drop it wherever we consume * `--directory` output. (#936) */ function isWholeCwdEntry(entry: string): boolean { return entry === './' || entry === '.' || entry === ''; } /** * List the gitignored DIRECTORIES of a repo (collapsed, trailing-slash form), * relative to `repoDir`. These are invisible to every other `git ls-files` / * `git status` mode — and in a multi-repo workspace they are exactly where the * nested project repos live (a super-repo `.gitignore`s its child repos to keep * `git status` quiet; that does not make them third-party code). (#514) */ function listIgnoredDirs(repoDir: string): string[] { try { const out = execFileSync( 'git', ['ls-files', '-z', '-o', '-i', '--exclude-standard', '--directory'], { cwd: repoDir, encoding: 'utf-8' as const, timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'], windowsHide: true } ); return out.split('\0').filter((e) => e.endsWith('/') && !isWholeCwdEntry(e)); } catch { return []; } } /** Max directory depth searched below an ignored dir for nested `.git` roots. */ const EMBEDDED_REPO_SEARCH_DEPTH = 4; /** Max directories examined per search — a huge ignored data dir must never stall a scan/sync. */ const EMBEDDED_REPO_SEARCH_ENTRIES = 2000; /** * Classify a directory's `.git` entry for embedded-repo discovery. * * - A `.git` **directory** is an embedded clone — distinct first-party code a * super-repo merely hides from git; index it (#193, #514). * - A `.git` **file** is a pointer (`gitdir: …`). A git **worktree** points into * the host repo's own `.git/worktrees/`, so it is a second working view * of a repo CodeGraph already indexes — indexing it just duplicates the whole * graph N times; skip it (#848). A **submodule worktree** points into * `.git/modules//worktrees/` — same duplication, so skip it too * (#945). A **submodule** checkout points into `.git/modules/` (no * `worktrees/` segment) and is distinct code, so index it as before. * * Returns `'none'` when there is no `.git` entry here. */ function classifyGitDir(absDir: string): 'embedded' | 'worktree' | 'none' { let st: fs.Stats; try { st = fs.statSync(path.join(absDir, '.git')); } catch { return 'none'; } if (st.isDirectory()) return 'embedded'; if (!st.isFile()) return 'none'; try { const gitdir = fs.readFileSync(path.join(absDir, '.git'), 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim(); // A worktree's gitdir lives under some repo's `.git/worktrees/` — // either the top-level repo's (`.git/worktrees/`) or, for a worktree of a // submodule, that submodule's gitdir (`.git/modules//worktrees/`). // The optional `modules/` segment covers the submodule case (#945). // Match both separators so a Windows-style pointer is recognized too. if (gitdir && /(^|[\\/])\.git[\\/](modules[\\/][^\\/]+[\\/])?worktrees[\\/]/.test(gitdir)) return 'worktree'; } catch { // Unreadable `.git` pointer — fall back to the prior "index it" behavior. } return 'embedded'; } /** * Find git repositories nested under `absDir` (inclusive), shallow bounded BFS. * Stops descending at each repo root found — contents belong to that repo's own * enumeration. Skips default-ignored dirs (`node_modules` can contain `.git` * from npm git-dependencies — that never makes it project code) and CodeGraph * data dirs. Depth- and entry-capped so a huge ignored tree can't stall the scan. */ function findNestedGitRepos(absDir: string, relPrefix: string): string[] { const found: string[] = []; const defaults = defaultsOnlyIgnore(); const queue: Array<{ abs: string; rel: string; depth: number }> = [ { abs: absDir, rel: relPrefix, depth: 0 }, ]; let examined = 0; while (queue.length > 0) { const { abs, rel, depth } = queue.shift()!; if (++examined > EMBEDDED_REPO_SEARCH_ENTRIES) { logDebug('Embedded-repo search entry cap hit — deeper repos (if any) not discovered', { under: relPrefix }); break; } const cls = classifyGitDir(abs); if (cls === 'worktree') { continue; // a git worktree duplicates an already-indexed repo (#848) — skip } if (cls === 'embedded') { found.push(rel); continue; // its own git handles everything below } if (depth >= EMBEDDED_REPO_SEARCH_DEPTH) continue; let entries: fs.Dirent[]; try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name === '.git' || isCodeGraphDataDir(entry.name)) continue; const childRel = rel + entry.name + '/'; if (defaults.ignores(childRel)) continue; queue.push({ abs: path.join(abs, entry.name), rel: childRel, depth: depth + 1 }); } } return found; } /** * Workspace-scope ignore matcher. Ordinary paths get the root's matcher * (built-in defaults + root `.gitignore`); paths inside an EMBEDDED repo get * that repo's own matcher (defaults + its root `.gitignore`) — the parent's * `.gitignore` hides a child repo from git, not from the index (#514). A * directory path (trailing slash) that is an ANCESTOR of an embedded root is * never ignored, so directory-pruning callers (the Linux per-directory * watcher) still descend to reach the embedded repos. * * Single source of truth for indexer and watcher scope — they must not diverge. */ export class ScopeIgnore { private embedded: Array<{ root: string; matcher: Ignore }>; private defaults: Ignore = defaultsOnlyIgnore(); constructor( private rootMatcher: Ignore, embedded: Array<{ root: string; matcher: Ignore }>, /** * Project `codegraph.json` `exclude` patterns (#999), matched against the * full root-relative path. Wins over everything else — an explicit user * exclude applies even to tracked files and even inside embedded repos. */ private exclude: Ignore | null = null, /** * Project `codegraph.json` `include` patterns — first-party source forced * INTO the index despite `.gitignore`. When a path matches, it is NOT * ignored (so the watcher watches it), overriding `.gitignore`/`rootMatcher` * — but never `exclude` (checked first) and never a built-in default-ignored * dir. `includeRoots` are the static prefixes so a gitignored ANCESTOR * directory of an included subtree still isn't pruned by the directory * walker/watcher. */ private include: Ignore | null = null, private includeRoots: string[] = [], ) { // Longest root first so paths in nested embedded repos hit the innermost matcher. this.embedded = [...embedded].sort((a, b) => b.root.length - a.root.length); } ignores(rel: string): boolean { // User `exclude` (#999) is checked first and against the full root-relative // path: it must drop git-TRACKED paths (which `.gitignore` can't) and apply // everywhere, including ancestors of embedded repos. if (this.exclude && this.exclude.ignores(rel)) return true; // User `include`: force first-party source in despite `.gitignore`. Never // resurfaces a built-in default-ignored dir (node_modules/dist/…), so an // include pattern can't accidentally pull in dependency/build trees. if (this.include && !this.defaults.ignores(rel)) { if (rel.endsWith('/')) { // A directory on (or leading to) an included subtree must stay walkable // so the watcher/walker descends to reach the forced-in files. if (this.includeRoots.some((r) => r.startsWith(rel) || rel.startsWith(r))) return false; } else if (this.include.ignores(rel)) { return false; } } for (const { root, matcher } of this.embedded) { if (rel.startsWith(root)) { const inner = rel.slice(root.length); if (inner === '') return false; // Built-in defaults apply to the FULL path uniformly (#407) — an // embedded repo inside node_modules (an npm git-dependency) must stay // excluded even though its own rules wouldn't ignore its files. return this.defaults.ignores(rel) || matcher.ignores(inner); } } // Never prune a directory that leads to an embedded repo. if (rel.endsWith('/') && this.embedded.some(({ root }) => root.startsWith(rel))) { return false; } return this.rootMatcher.ignores(rel); } } /** * Build the workspace-scope matcher. When the caller already knows the * embedded roots (the scanner discovers them during collection), pass them to * skip rediscovery; otherwise they're discovered here (the watcher path). */ export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable): ScopeIgnore { const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir); const include = loadIncludeMatcher(rootDir); return new ScopeIgnore( buildDefaultIgnore(rootDir), roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })), loadExcludeMatcher(rootDir), include, include ? includeStaticRoots(loadIncludePatterns(rootDir)) : [], ); } /** * Whether an embedded repo found as a tracked gitlink (mode 160000, #1031/#1033) * must be SKIPPED rather than indexed. A gitlink is tracked, so `.gitignore` * can't untrack it — but the discovery passes for it must still honor the same * scope rules as every other path, or a gitignored reference/data dir full of * `git add`ed clones gets pulled into the index against the user's stated intent * (#1065). Two reasons to skip: * 1. It sits in a built-in default-ignored location — an npm git-dependency * under `node_modules` is never project code; not even an explicit opt-in * revives it (matches `findIgnoredEmbeddedRepos`). * 2. The parent repo's own `.gitignore` covers its path and the project did * NOT opt that path in via `codegraph.json` `includeIgnored`. The gitignore * rule is the user's stated intent to keep that path out of scope, exactly * as for an UNtracked embedded repo — respect it by default, opt back in * with `includeIgnored` (#514, #970, #976). * `relDir` is repoDir-relative (trailing-slashed); `prefix` is repoDir's * scan-root-relative path so the `includeIgnored` pattern is matched on the full * scan-root-relative path. `defaults` is `defaultsOnlyIgnore()` and `repoIgnore` * is `buildDefaultIgnore(repoDir)` (defaults + the repo's own `.gitignore`), * both passed in so they're built once per repo level rather than per gitlink. */ function gitlinkEmbeddedRepoSkipped( relDir: string, prefix: string, defaults: Ignore, repoIgnore: Ignore, includeIgnored: Ignore | null, ): boolean { if (defaults.ignores(relDir)) return true; // default-ignored — never index, opt-in can't revive if (!repoIgnore.ignores(relDir)) return false; // not ignored at all — index as before (#1031/#1033) // Gitignored by the repo's own rules — skip unless the project opted it in. return !includeIgnored?.ignores(normalizePath(prefix + relDir)); } /** * Standalone discovery of every embedded repo root under `rootDir` (relative, * trailing-slashed) — the untracked kind (#193) always, and the gitignored kind * (#514) only for directories the project opted in via `codegraph.json` * `includeIgnored` (#622, #699); otherwise `.gitignore` is respected and they * are not discovered (#970, #976). Recursive (an embedded repo can embed further * repos). Returns [] for non-git roots: the filesystem walk handles nested repos * there already. */ export function discoverEmbeddedRepoRoots(rootDir: string): string[] { try { execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); } catch { return []; } const out: string[] = []; const defaults = defaultsOnlyIgnore(); const includeIgnored = loadIncludeIgnoredMatcher(rootDir); const visit = (repoAbs: string, prefix: string): void => { const candidates: string[] = []; try { const o = execFileSync( 'git', ['ls-files', '-z', '-o', '--exclude-standard', '--directory'], { cwd: repoAbs, encoding: 'utf-8', timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); for (const e of o.split('\0')) { if (e.endsWith('/') && !isWholeCwdEntry(e) && !defaults.ignores(e)) { candidates.push(...findNestedGitRepos(path.join(repoAbs, e), e)); } } } catch { /* untracked listing failed — ignored-side discovery still runs */ } // Unexpanded gitlinks (mode 160000) with a real checkout on disk — embedded // repos `git add`ed without `.gitmodules`, or submodules not active here. The // untracked listing above can't see them (they're tracked), so find them the // same way collectGitFiles does, keeping watcher scope == indexer scope. // (#1031, #1033) try { const staged = execFileSync( 'git', ['ls-files', '-z', '-s', '--recurse-submodules'], { cwd: repoAbs, encoding: 'utf-8', timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); const repoIgnore = buildDefaultIgnore(repoAbs); for (const entry of staged.split('\0')) { if (!entry || entry.slice(0, 6) !== '160000') continue; const tab = entry.indexOf('\t'); if (tab === -1) continue; const rel = entry.slice(tab + 1); const relDir = rel.endsWith('/') ? rel : rel + '/'; // A gitlink under a gitignored path is respected (not indexed) unless the // project opted it in — same rule as the untracked-ignored kind (#1065). if (gitlinkEmbeddedRepoSkipped(relDir, prefix, defaults, repoIgnore, includeIgnored)) continue; if (classifyGitDir(path.join(repoAbs, rel)) === 'embedded') candidates.push(relDir); } } catch { /* staged listing failed — other discovery still runs */ } candidates.push(...findIgnoredEmbeddedRepos(repoAbs, includeIgnored, prefix)); for (const rel of candidates) { const full = normalizePath(prefix + rel); out.push(full); visit(path.join(repoAbs, rel), full); } }; visit(rootDir, ''); return out; } /** * Cap on how many skipped gitignored repos the CLI hint enumerates — a huge * gitignored data dir full of clones must never turn the hint scan into a long * walk. Enough to make the point; the caller says "+N more" past this. */ const UNINDEXED_IGNORED_REPO_HINT_CAP = 100; /** * The INVERSE of the gitignored side of {@link discoverEmbeddedRepoRoots}: * nested git repositories under a gitignored directory that the project has NOT * opted into via `codegraph.json` `includeIgnored`. These are real repos the * default `init`/`index` deliberately skips because `.gitignore` excludes them * (#970, #976) — most visibly the "super-repo `.gitignore`s its child repos" * layout (#1156), where `init` at the parent correctly indexes ~nothing while * `init` inside each child works. The CLI uses this to turn that silent empty * index into an actionable hint: it names the skipped repos and offers to opt * them in. Paths are `rootDir`-relative and trailing-slashed (valid * `includeIgnored` patterns as-is). Returns `[]` for a non-git root (a * filesystem walk already descends into nested repos there), skips built-in * default-ignored dirs (`node_modules`, …), and is bounded so it never stalls * on a giant ignored tree. */ export function findUnindexedIgnoredRepos(rootDir: string): string[] { try { execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); } catch { return []; } const defaults = defaultsOnlyIgnore(); const includeIgnored = loadIncludeIgnoredMatcher(rootDir); const repos: string[] = []; for (const dir of listIgnoredDirs(rootDir)) { if (defaults.ignores(dir)) continue; // node_modules etc. — never project code if (includeIgnored?.ignores(normalizePath(dir))) continue; // already opted in — nothing to nag about for (const repo of findNestedGitRepos(path.join(rootDir, dir), dir)) { // Per-repo opt-in check, mirroring findIgnoredEmbeddedRepos: a child // pattern (`repos/a/`) doesn't match the parent dir above but DOES // cover this repo — it's indexed, so don't nag about it (#1295). if (includeIgnored?.ignores(normalizePath(repo))) continue; repos.push(repo); if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos; } } return repos; } /** * Discover embedded repos hidden by `repoDir`'s OWN gitignore rules: for each * gitignored directory, search for nested `.git` roots. Returns repo paths * relative to `repoDir`, trailing-slashed. * * OPT-IN ONLY. Walking into a gitignored directory contradicts what every other * tool (and CodeGraph's own `git ls-files` foundation) does — `.gitignore` * excludes. So this returns `[]` unless the project opted the directory in via * `codegraph.json` `includeIgnored`; without that, a gitignored dir — including * a huge reference/data dir full of nested clones — is left untouched (#970, * #976). When opted in, it restores the super-repo-of-clones behavior (#622, * #699). `prefix` is the scan-root-relative path of `repoDir`, so a pattern like * `services/` opts that whole subtree in at any recursion depth. Built-in * default excludes (`node_modules`, …) are always skipped. */ function findIgnoredEmbeddedRepos(repoDir: string, includeIgnored: Ignore | null, prefix: string): string[] { if (!includeIgnored) return []; const defaults = defaultsOnlyIgnore(); const repos: string[] = []; for (const dir of listIgnoredDirs(repoDir)) { if (defaults.ignores(dir)) continue; const nested = findNestedGitRepos(path.join(repoDir, dir), dir); if (includeIgnored.ignores(normalizePath(prefix + dir))) { // The whole ignored dir is opted in — every nested repo under it counts. repos.push(...nested); } else { // A single gitignore rule often covers the PARENT of the opted-in // repos: `.gitignore: /repos/` lists `repos/` as ONE ignored entry, // while `includeIgnored: ["repos/a/"]` (the CLI hint's own suggested // spelling) names the child — which never matches the parent path, so // the opt-in silently did nothing (#1295). Match each nested repo // root individually so both spellings work. The walk is bounded // (depth/entry caps in findNestedGitRepos) and only runs when // includeIgnored is configured at all. repos.push(...nested.filter((r) => includeIgnored.ignores(normalizePath(prefix + r)))); } } return repos; } /** * Collect git-visible files (tracked + untracked, .gitignore-respected) from the * git repository rooted at `repoDir`, adding each to `files` with `prefix` * prepended so paths stay relative to the original scan root. * * Recurses into embedded git repositories — nested repos that are NOT submodules * (independent clones living inside the workspace, common in CMake "super-repo" * layouts). The parent repo's `git ls-files` cannot see into them: tracked output * skips them entirely, and untracked output reports them only as an opaque * "subdir/" entry (trailing slash) rather than expanding their files. Each * embedded repo is its own git boundary, so we re-run `git ls-files` inside it. * (See issue #193.) GITIGNORED embedded repos are invisible even to that; they * are discovered separately via `findIgnoredEmbeddedRepos` (#514) but ONLY for * directories the project opted in through `codegraph.json` `includeIgnored` * (`includeIgnored` here, threaded from the scan root) — by default `.gitignore` * is respected and they stay out (#970, #976). Every embedded repo root (however * found) is recorded in `embeddedRoots` so callers can exempt its files from the * parent's own gitignore rules. */ function collectGitFiles(repoDir: string, prefix: string, files: Set, embeddedRoots?: Set, includeIgnored: Ignore | null = null): void { const gitOpts = { cwd: repoDir, encoding: 'utf-8' as const, timeout: 30000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] as ['pipe', 'pipe', 'pipe'], windowsHide: true }; // Tracked files. --recurse-submodules pulls in files from active submodules, // which the index would otherwise represent only as a commit pointer. // Without this, monorepos using submodules index 0 files. (See issue #147.) // Note: --recurse-submodules only supports -c/--cached and --stage modes — it // can't be combined with -o, so untracked files are gathered separately below. // // We use --stage (-s) rather than -c so each entry carries its file mode. That // lets us spot gitlink entries (mode 160000) that --recurse-submodules did NOT // expand: a nested repo `git add`ed without a `.gitmodules` entry, or a // submodule that isn't active/initialized in this checkout. Such a gitlink // falls through every pass — it's tracked, so the untracked `-o` listing below // never reports it, and --recurse-submodules only expands ACTIVE submodules — // so its source would be silently skipped, leaving only the super-repo's own // files indexed. We collect those gitlinks here and recurse into them below. // (An active submodule is expanded inline by --recurse-submodules and so never // surfaces as a 160000 entry — only the unhandled gitlinks do.) (#1031, #1033) // // -z gives NUL-separated, unquoted output so non-ASCII (e.g. CJK) paths // survive verbatim. Without it git octal-escapes and double-quotes such paths // (the core.quotepath default), and the quoted form never matches a real file // on disk → those files are silently dropped from the index. (#541) With -s the // path follows a TAB after the ` ` prefix. const gitlinkRels: string[] = []; const tracked = execFileSync('git', ['ls-files', '-z', '-s', '--recurse-submodules'], gitOpts); for (const entry of tracked.split('\0')) { if (!entry) continue; const tab = entry.indexOf('\t'); if (tab === -1) continue; // --stage always emits " \t" const rel = entry.slice(tab + 1); if (entry.slice(0, 6) === '160000') { gitlinkRels.push(rel); // an unexpanded gitlink — recursed into below, not a source file itself continue; } files.add(normalizePath(prefix + rel)); } // Untracked files (submodules manage their own untracked state). Embedded git // repos surface here as a single "subdir/" entry that git refuses to descend // into — recurse into those as their own repos so their source gets indexed. const untracked = execFileSync('git', ['ls-files', '-z', '-o', '--exclude-standard'], gitOpts); for (const rel of untracked.split('\0')) { if (!rel) continue; if (rel.endsWith('/')) { // git only emits a trailing-slash directory entry for an embedded repo. // Guard with a .git check anyway, and skip anything else exactly as git // itself skips it (we never descend into a non-repo opaque dir). Never // descend into default-ignored locations — an embedded repo inside // node_modules is an npm git-dependency, not project code. const childDir = path.join(repoDir, rel); // A git worktree surfaces here as an opaque untracked dir too — skip it, // it's a duplicate working view of an already-indexed repo (#848). if (classifyGitDir(childDir) === 'embedded' && !defaultsOnlyIgnore().ignores(rel)) { embeddedRoots?.add(normalizePath(prefix + rel)); collectGitFiles(childDir, prefix + rel, files, embeddedRoots, includeIgnored); } continue; } files.add(normalizePath(prefix + rel)); } // Gitlink entries (mode 160000) that --recurse-submodules left unexpanded — // an embedded repo `git add`ed without `.gitmodules`, or a submodule not // active/initialized in this checkout. When such a gitlink has a real working // tree on disk it is distinct first-party code we must index as its own // embedded repo: the tracked pass skipped its contents and the untracked pass // never sees it (it's tracked, not "other"). A gitlink with no checkout on disk // (an uninitialized submodule — empty dir, no `.git`) has nothing to index and // is left alone, as is a submodule worktree (a duplicate view, #945). (#1031, #1033) if (gitlinkRels.length > 0) { const defaults = defaultsOnlyIgnore(); const repoIgnore = buildDefaultIgnore(repoDir); for (const rel of gitlinkRels) { const relDir = rel.endsWith('/') ? rel : rel + '/'; // A gitlink under a gitignored path is respected (not indexed) unless the // project opted it in via `includeIgnored` — keep tracked gitlinks under // the same scope rule as the untracked-ignored kind below (#1065). if (gitlinkEmbeddedRepoSkipped(relDir, prefix, defaults, repoIgnore, includeIgnored)) continue; const childDir = path.join(repoDir, rel); // 'embedded' = a real .git checkout on disk; 'worktree' and 'none' are skipped. if (classifyGitDir(childDir) !== 'embedded') continue; embeddedRoots?.add(normalizePath(prefix + relDir)); collectGitFiles(childDir, prefix + relDir, files, embeddedRoots, includeIgnored); } } // Embedded repos hidden by THIS repo's ignore rules (`/packages/` in a // super-repo .gitignore) never appear in any listing above. By default they // stay hidden — `.gitignore` is respected (#970, #976). They are recursed into // only when the project opted the directory in via `codegraph.json` // `includeIgnored` (#622, #699), which `findIgnoredEmbeddedRepos` enforces. for (const rel of findIgnoredEmbeddedRepos(repoDir, includeIgnored, prefix)) { embeddedRoots?.add(normalizePath(prefix + rel)); collectGitFiles(path.join(repoDir, rel), prefix + rel, files, embeddedRoots, includeIgnored); } } /** * Get all files visible to git (tracked + untracked but not ignored). * Respects .gitignore at all levels (root, subdirectories) and descends into * embedded (nested, non-submodule) git repos. Returns null on failure * (non-git project) so callers can fall back to a filesystem walk. */ function getGitVisibleFiles(rootDir: string): Set | null { try { // Check if the project directory is gitignored by a parent repo. // When rootDir lives inside a parent git repo that ignores it, // `git ls-files` returns nothing — fall back to filesystem walk. const gitRoot = execFileSync( 'git', ['rev-parse', '--show-toplevel'], { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ).trim(); if (path.resolve(gitRoot) !== path.resolve(rootDir)) { try { // git check-ignore exits 0 if the path IS ignored, 1 if not execFileSync( 'git', ['check-ignore', '-q', path.resolve(rootDir)], { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); // Directory is gitignored by parent repo — fall back to filesystem walk return null; } catch { // Not ignored — safe to use git ls-files } } const files = new Set(); const embeddedRoots = new Set(); collectGitFiles(rootDir, '', files, embeddedRoots, loadIncludeIgnoredMatcher(rootDir)); // Apply built-in default ignores uniformly — to tracked files too, since // committing a dependency/build dir doesn't make it project code. A // `.gitignore` negation (e.g. `!vendor/`) is the explicit opt-in. (issue #407) // Files inside an EMBEDDED repo are matched against that repo's own rules, // not the parent's: the parent's .gitignore hides the child repo from git, // not from the index. (#514) const ig = buildScopeIgnore(rootDir, embeddedRoots); const visible = new Set([...files].filter((f) => !ig.ignores(f))); // Force-include first-party source the project whitelisted in // `codegraph.json` `include`. These are gitignored, so `git ls-files` never // listed them above — discover them directly off disk and add them. (The // common SVN+Git dual-VCS case: source committed to SVN, gitignored out of // Git, but still wanted in the graph.) for (const f of collectIncludedFilesForRoot(rootDir)) visible.add(f); return visible; } catch { return null; } } /** * Result of git-based change detection. * Returns null when git is unavailable (non-git project or command failure), * signaling the caller to fall back to full filesystem scan. */ interface GitChanges { modified: string[]; // M, MM, AM — files to re-hash + re-index added: string[]; // ?? — new untracked files to index deleted: string[]; // D — files to remove from DB } /** * Use `git status` to detect changed files instead of scanning every file. * Returns null on failure so callers fall back to full scan. * * Recurses into embedded repos — the untracked kind (#193: the parent's status * collapses them to an opaque `?? subdir/` entry) always, and the gitignored * kind (#514: they never appear in the parent's status at all) only for * directories opted in via `codegraph.json` `includeIgnored` (#622, #699) — * running `git status` inside each, so changes in a multi-repo workspace sync * without a full rescan. By default a gitignored dir is left alone, matching the * full-index scan (#970, #976). Deleting an ENTIRE embedded repo dir is the one * case this cannot see (the child status that would report the deletions is gone * with it); a full `codegraph index` reconciles that. */ function getGitChangedFiles(rootDir: string): GitChanges | null { try { const changes: GitChanges = { modified: [], added: [], deleted: [] }; // Custom extension → language overrides from the project's codegraph.json, // so change detection sees the same custom-extension files the full index does. const overrides = loadExtensionOverrides(rootDir); collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir)); return changes; } catch { return null; } } function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void { const output = execFileSync( 'git', ['status', '--porcelain', '--no-renames'], { cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); // This repo's own ignore rules — built-in defaults (#407) plus its .gitignore. // Change detection must exclude the SAME files the full index does, but git // status hides neither: it ignores nothing for *tracked* paths, and the // built-in defaults aren't gitignore at all. Without this filter a committed // vendor/ dir, or a tracked file under a .gitignored dir, surfaces here as a // change — so `codegraph status` (which reads getChangedFiles) reports a // pending edit the full index never tracks and `sync` never clears. Matching // repo-relative `rel` at each recursion level mirrors getGitVisibleFiles' // ScopeIgnore: every embedded repo is judged by ITS OWN rules, never the // parent's. (#766) const ig = buildDefaultIgnore(repoDir); const untrackedDirs: string[] = []; for (const line of output.split('\n')) { if (line.length < 4) continue; // Minimum: "XY file" const statusCode = line.substring(0, 2); const rel = normalizePath(line.substring(3)); // Untracked directory entries (trailing slash) may hide an embedded repo — // collect for the recursion below instead of treating as a file. if (statusCode === '??' && rel.endsWith('/')) { untrackedDirs.push(rel); continue; } const filePath = normalizePath(prefix + rel); if (!isSourceFile(filePath, overrides)) continue; if (statusCode.includes('D')) { // Deletions stay unfiltered: getChangedFiles acts on one only when the // path is already tracked in the DB, where removal is always correct — and // that lets a newly-excluded dir's stale rows clean themselves up. (#766) out.deleted.push(filePath); continue; } // Added (`??`) / modified files inside an excluded dir must not enter the // index — match against the repo-relative path, same as the full scan. (#766) if (ig.ignores(rel)) continue; // User `codegraph.json` `exclude` (#999) is project-root-relative, so it's // matched against the full path — sync must not re-add a tracked file the // full index now keeps out. Deletions above stay unfiltered so a file that // WAS indexed before an exclude was added still cleans itself out. if (exclude && exclude.ignores(filePath)) continue; if (statusCode === '??') { out.added.push(filePath); } else { // M, MM, AM, A (staged), etc. — treat as modified out.modified.push(filePath); } } // Recurse embedded repos found under untracked dirs (at the dir itself or // nested deeper). Gitignored dirs are walked only for the directories the // project opted in via `includeIgnored`; by default `.gitignore` is respected // and they are left alone (#970, #976), mirroring the full-index scan. for (const rel of untrackedDirs) { for (const repoRel of findNestedGitRepos(path.join(repoDir, rel), rel)) { collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, overrides, includeIgnored, exclude); } } for (const rel of findIgnoredEmbeddedRepos(repoDir, includeIgnored, prefix)) { collectGitStatus(path.join(repoDir, rel), prefix + rel, out, overrides, includeIgnored, exclude); } } /** * Recursively scan a directory for source files. * * In git repos, uses `git ls-files` (inherently respects .gitignore at all * levels), then keeps files with a supported source extension. For non-git * projects, falls back to a filesystem walk that parses .gitignore itself. */ export function scanDirectory( rootDir: string, onProgress?: (current: number, file: string) => void ): string[] { // Custom extension → language overrides from the project's codegraph.json. const overrides = loadExtensionOverrides(rootDir); // Fast path: use git to get all visible files (respects .gitignore everywhere) const gitFiles = getGitVisibleFiles(rootDir); if (gitFiles) { const files: string[] = []; let count = 0; for (const filePath of gitFiles) { if (isSourceFile(filePath, overrides)) { files.push(filePath); count++; onProgress?.(count, filePath); } } return files; } // Fallback: walk filesystem for non-git projects return scanDirectoryWalk(rootDir, onProgress); } /** * Async variant of scanDirectory that yields to the event loop periodically, * allowing worker threads to receive and render progress messages. */ export async function scanDirectoryAsync( rootDir: string, onProgress?: (current: number, file: string) => void ): Promise { // Custom extension → language overrides from the project's codegraph.json. const overrides = loadExtensionOverrides(rootDir); const gitFiles = getGitVisibleFiles(rootDir); if (gitFiles) { const files: string[] = []; let count = 0; for (const filePath of gitFiles) { if (isSourceFile(filePath, overrides)) { files.push(filePath); count++; onProgress?.(count, filePath); // Yield every 100 files so worker threads can render progress if (count % 100 === 0) { await new Promise(r => setImmediate(r)); } } } return files; } return scanDirectoryWalk(rootDir, onProgress); } /** * Filesystem walk fallback for non-git projects. */ function scanDirectoryWalk( rootDir: string, onProgress?: (current: number, file: string) => void ): string[] { const files: string[] = []; let count = 0; const visitedDirs = new Set(); // Custom extension → language overrides from the project's codegraph.json. const overrides = loadExtensionOverrides(rootDir); // A .gitignore matcher scoped to the directory that declared it. Patterns in // a nested .gitignore are relative to that directory, so we keep the dir // alongside the matcher and test paths relative to it — mirroring how git // applies .gitignore files at every level. interface ScopedIgnore { dir: string; ig: Ignore; } const loadIgnore = (dir: string): ScopedIgnore | null => { const giPath = path.join(dir, '.gitignore'); if (!fs.existsSync(giPath)) return null; // readGitignorePatterns is defensive: a non-UTF-8 (DLP-encrypted) or // uncompilable .gitignore is skipped/filtered with a warning, never thrown // (issue #682) — so the per-file `.ignores()` calls below can't crash. const patterns = readGitignorePatterns(giPath); return patterns ? { dir, ig: ignore().add(patterns) } : null; }; const isIgnored = (fullPath: string, isDir: boolean, matchers: ScopedIgnore[]): boolean => { for (const { dir, ig } of matchers) { let rel = normalizePath(path.relative(dir, fullPath)); if (!rel || rel.startsWith('..')) continue; // not under this matcher's dir if (isDir) rel += '/'; // dir-only rules (e.g. `build/`) only match with the slash if (ig.ignores(rel)) return true; } return false; }; function walk(dir: string, matchers: ScopedIgnore[]): void { let realDir: string; try { realDir = fs.realpathSync(dir); } catch { logDebug('Skipping unresolvable directory', { dir }); return; } if (visitedDirs.has(realDir)) { logDebug('Skipping already-visited directory (symlink cycle)', { dir, realDir }); return; } visitedDirs.add(realDir); // This directory's own .gitignore (if present) applies to everything below it. // The root's .gitignore is already merged into the seeded base matcher (so a // negation there can override a built-in default), so skip it here. const own = dir === rootDir ? null : loadIgnore(dir); const active = own ? [...matchers, own] : matchers; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { logDebug('Skipping unreadable directory', { dir, error: String(error) }); return; } for (const entry of entries) { // Never descend into git internals or any CodeGraph data directory // (the active one or a sibling another environment created — #636). if (entry.name === '.git' || isCodeGraphDataDir(entry.name)) continue; const fullPath = path.join(dir, entry.name); const relativePath = normalizePath(path.relative(rootDir, fullPath)); if (entry.isSymbolicLink()) { try { const realTarget = fs.realpathSync(fullPath); const stat = fs.statSync(realTarget); if (stat.isDirectory()) { if (!isIgnored(fullPath, true, active)) { walk(fullPath, active); } } else if (stat.isFile()) { if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { files.push(relativePath); count++; onProgress?.(count, relativePath); } } } catch { logDebug('Skipping broken symlink', { path: fullPath }); } continue; } if (entry.isDirectory()) { if (!isIgnored(fullPath, true, active)) { walk(fullPath, active); } } else if (entry.isFile()) { if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { files.push(relativePath); count++; onProgress?.(count, relativePath); } } } } // Seed a base matcher with the built-in default ignores (merged with the root // .gitignore so a negation can override). Nested .gitignores still layer per-dir. const baseMatchers: ScopedIgnore[] = [{ dir: rootDir, ig: buildDefaultIgnore(rootDir) }]; // Project `codegraph.json` `exclude` patterns (#999), rooted at the project so // `isIgnored` matches them against root-relative paths — same coverage the // git path gets via ScopeIgnore, for non-git projects. const exclude = loadExcludeMatcher(rootDir); if (exclude) baseMatchers.push({ dir: rootDir, ig: exclude }); walk(rootDir, baseMatchers); // Force-include first-party source whitelisted in `codegraph.json` `include` // — the walk above honours `.gitignore`, so anything gitignored was dropped; // add it back here (deduped). Mirrors the git path's union. const included = collectIncludedFilesForRoot(rootDir); if (included.size > 0) { const seen = new Set(files); for (const f of included) { if (!seen.has(f)) { files.push(f); seen.add(f); } } } return files; } /** * Resurrect a resolution edge that is about to be dropped (its target symbol * was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved * reference that created it, read from the refName/refKind stamp * `createEdges` writes into edge metadata. Inserted as status='pending', the * ref is consumed by the same sync's resolution sweep: it rebinds to an * alternative definition if one exists, or parks as status='failed' where the * #1240 retry finds it if the symbol later reappears. * * Returns null — drop silently, the pre-#1240 behavior — for edges without a * refName stamp (created before the stamp existed, or synthesized): rebuilding * a ref from the target's plain node name would strip the receiver/qualifier * context the original text carried (`h.greet` → `greet`) and could rebind * somewhere a full re-index never would. Silent beats wrong. */ function resurrectRefFromDroppedEdge( e: Edge & { sourceFilePath: string; sourceLanguage: Language } ): UnresolvedReference | null { const refName = e.metadata?.refName; if (typeof refName !== 'string' || refName.length === 0) return null; const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind; return { fromNodeId: e.source, referenceName: refName, referenceKind: refKind, line: e.line ?? 0, column: e.column ?? 0, filePath: e.sourceFilePath, language: e.sourceLanguage, }; } /** * Extraction orchestrator */ export class ExtractionOrchestrator { private rootDir: string; private queries: QueryBuilder; /** * Names of frameworks detected for this project, populated by indexAll(). * Passed to extractFromSource so framework-specific extractors (route nodes, * middleware, etc.) run after the tree-sitter pass. Cleared if detection * hasn't run yet so single-file re-index paths can detect on the spot. */ private detectedFrameworkNames: string[] | null = null; constructor(rootDir: string, queries: QueryBuilder) { this.rootDir = rootDir; this.queries = queries; } /** * Build a filesystem-backed ResolutionContext sufficient for framework * detection. Graph-query methods (getNodesByName etc.) return empty because * the DB hasn't been populated yet, but detect() only uses readFile, * fileExists, and getAllFiles, so that's fine. */ private buildDetectionContext(files: string[]): ResolutionContext { const rootDir = this.rootDir; return { getNodesInFile: () => [], getNodesByName: () => [], getNodesByQualifiedName: () => [], getNodesByKind: () => [], getNodesByLowerName: () => [], getImportMappings: () => [], getAllFiles: () => files, getProjectRoot: () => rootDir, fileExists: (relativePath: string) => { const full = validatePathWithinRoot(rootDir, relativePath); if (!full) return false; try { return fs.existsSync(full); } catch { return false; } }, readFile: (relativePath: string) => { const full = validatePathWithinRoot(rootDir, relativePath); if (!full) return null; try { return fs.readFileSync(full, 'utf-8'); } catch { return null; } }, // Monorepo support — needed by framework detect()s that probe // subpackage manifests (e.g. fabric-view looking at // packages//package.json when the root manifest is just a // workspace declaration). Matches the resolver-context shape. listDirectories: (relativePath: string) => { const target = relativePath === '.' || relativePath === '' ? rootDir : path.join(rootDir, relativePath); try { return fs .readdirSync(target, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } catch { return []; } }, }; } /** * Detect frameworks on demand using the current scanned files (or a fresh * scan if none are provided). Cached on the orchestrator so repeat calls * inside a single run don't re-scan. */ private ensureDetectedFrameworks(files?: string[]): string[] { if (this.detectedFrameworkNames !== null) return this.detectedFrameworkNames; const fileList = files ?? scanDirectory(this.rootDir); const context = this.buildDetectionContext(fileList); this.detectedFrameworkNames = detectFrameworks(context).map((r) => r.name); return this.detectedFrameworkNames; } /** * Index all files in the project */ async indexAll( onProgress?: (progress: IndexProgress) => void, signal?: AbortSignal, verbose?: boolean, // Writer-side backstop for deferred WAL checkpointing (#1231): returns // null in the normal case, or a promise to await (at this safe, // between-transactions boundary) when the WAL has outrun the off-thread // checkpointer past its hard cap. See db/wal-valve.ts. walBackpressure?: () => Promise | null, // Fresh-DB store offload (perf): when set, per-file store bundles are // applied by a dedicated writer thread instead of the main thread. Only // passed for a COMPLETELY fresh database, where the main thread performs // no reads/writes during the parse loop, so one writer applying bundles // in file order preserves the #1015 determinism exactly. storeWriterOpts?: { dbPath: string; fastInit: boolean } | null ): Promise { const tGrammar = Date.now(); await initGrammars(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] grammar-init: ${Date.now() - tGrammar}ms`); const startTime = Date.now(); const errors: ExtractionError[] = []; let filesIndexed = 0; let filesSkipped = 0; let filesErrored = 0; let totalNodes = 0; let totalEdges = 0; // Custom extension → language overrides from the project's codegraph.json. // Threaded into language detection so custom-extension files load the right // grammar and store under the mapped language. const overrides = loadExtensionOverrides(this.rootDir); const log = verbose ? (msg: string) => { console.log(`[worker] ${msg}`); } : (_msg: string) => {}; // Phase 1: Scan for files onProgress?.({ phase: 'scanning', current: 0, total: 0, }); // Phase attribution to stderr (same opt-in as the synthesis timings): // early-run 5-10s single stalls were observed on 95k-file repos but never // attributed — these labels settle scan vs framework-detect vs grammars. const tScan = Date.now(); const files = await scanDirectoryAsync(this.rootDir, (current, file) => { onProgress?.({ phase: 'scanning', current, total: 0, currentFile: file, }); }); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`); // Detect frameworks once per indexAll run using the scanned file list. // Names are passed to each parse call so framework-specific extractors // (route nodes, middleware, etc.) run after the tree-sitter pass. // Framework detection is reset each run so adding e.g. requirements.txt // between runs is picked up without restarting the process. this.detectedFrameworkNames = null; const tFw = Date.now(); const frameworkNames = this.ensureDetectedFrameworks(files); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] framework-detect: ${Date.now() - tFw}ms`); if (signal?.aborted) { return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Aborted', severity: 'error' }], durationMs: Date.now() - startTime, }; } // Phase 2: Parse files in a worker thread (keeps main thread unblocked for UI) const total = files.length; let processed = 0; // Emit parsing phase immediately so the progress bar appears during worker setup. // The yield lets the shimmer worker flush the phase transition to stdout before // the main thread starts synchronous grammar detection work. onProgress?.({ phase: 'parsing', current: 0, total, }); await new Promise(resolve => setImmediate(resolve)); // Detect needed languages and load grammars in the parse worker const neededLanguages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded when c is needed if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) { neededLanguages.push('cpp'); } // Parse files on a pool of worker threads (keeps the main thread free for UI // and uses every core). Falls back to in-process parsing when the compiled // worker is unavailable (e.g. running from source in tests). const parseWorkerPath = path.join(__dirname, 'parse-worker.js'); const useWorker = fs.existsSync(parseWorkerPath); let pool: ParseWorkerPool | null = null; if (useWorker) { // CODEGRAPH_PARSE_WORKERS: explicit worker count; 1 = the old single-worker // behaviour (the conservative rollback). Unset → clamp(cores-1, 1, 8), // with cores from availableParallelism — cpuset/affinity-honest, where // os.cpus() enumerates the host's CPUs and spawned 8 wasm workers (and // their grammar heaps) inside a 2-CPU container for zero extra // throughput (§7a.1). Floored so a 2-core box still gets 2 workers: // parse is worker-side CPU, and 1 worker measured 34% slower than the // old oversubscribed pool on the kernel-scale 2-cpuset envelope // (493s vs 369s) — main + store-worker don't fill the second core. const poolSize = resolveParsePoolSize(process.env.CODEGRAPH_PARSE_WORKERS, Math.max(3, os.availableParallelism())); // Read each needed grammar's WASM ONCE here and hand the bytes to every // worker, so spawns/respawns load grammars from memory instead of // re-reading them from disk (#1231: on an HDD, respawn re-reads amplify // the very I/O contention that caused the respawn). const grammarBuffers = await readGrammarWasmBytes(neededLanguages); pool = new ParseWorkerPool({ languages: neededLanguages, size: poolSize, workerScriptPath: parseWorkerPath, recycleInterval: WORKER_RECYCLE_INTERVAL, parseTimeoutMs: PARSE_TIMEOUT_MS, log, grammarBuffers, }); log(`Parse worker pool: ${poolSize} worker(s)`); // Bulk index: every core will be needed — spawn the whole pool now so // worker boot overlaps the first read batches instead of trickling in // behind queue-pressure growth. pool.prewarm(); } else { // In-process fallback: load grammars locally and parse on the main thread. await loadGrammarsForLanguages(neededLanguages); } // Dedicated store writer thread (fresh DB only — see the parameter doc). // Same availability rule as the parse pool: needs the compiled worker // (absent when running from source in tests → main-thread fallback). const storeWorkerPath = path.join(__dirname, 'store-worker.js'); let storeWriter: StoreWriter | null = null; if ( storeWriterOpts && process.env.CODEGRAPH_NO_STORE_WORKER !== '1' && fs.existsSync(storeWorkerPath) ) { // Deliberately NOT awaiting ready(): worker_threads delivers messages in // order, so bundles posted while the worker is still booting queue // behind 'open'. A boot failure surfaces at the first drain() — same // propagation point as a store error. storeWriter = new StoreWriter(storeWorkerPath, storeWriterOpts.dbPath, storeWriterOpts.fastInit); log('Store writer thread active'); } /** Queue-depth bound for un-acked bundles (bundles hold whole node/edge arrays). */ const STORE_WRITER_WINDOW = 64; /** * Parse one file: on the pool when available (the promise REJECTS on a worker * crash/timeout — the caller records it and the retry pass re-attempts), or * in-process synchronously as the no-worker fallback. The language is resolved * here on the main thread, where the codegraph.json overrides are loaded. */ const parseFile = (filePath: string, content: string): Promise => { const language = detectLanguage(filePath, content, overrides); if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, frameworkNames)); return pool.requestParse({ filePath, content, language, frameworkNames }); }; // --- Bounded rolling-window dispatch, ordered commit --- // Reads stay batched/parallel; parses run concurrently across the pool; the // SQLite store stays on the main thread (it isn't thread-safe). Crucially we // COMMIT results in original file order, not parse-completion order: the // resolution phase (run after indexing) resolves an ambiguous reference to one // of several same-named candidates by the nodes' DB insertion order, so a // stable commit order keeps the resulting graph deterministic — byte-identical // to the single-worker path — instead of drifting with parse timing. The // `completed` buffer holds at most ~windowSize out-of-order results, so memory // stays bounded. const windowSize = pool ? Math.max(4, pool.size * 2) : 1; const inFlight = new Set>(); const completed = new Map(); let nextSeq = 0; // file-order sequence assigned at dispatch let nextToStore = 0; // cursor: next sequence to commit let aborted = false; // Yielder for the in-order commit path: a single giant generated file's // store is otherwise one unyielding multi-second transaction span on the // main thread (5–14s single stalls measured on llvm-project), starving // the #850 watchdog heartbeat on slow hardware. const commitYield = createYielder(); const storeResult = async (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): Promise => { processed++; // WAL hard-cap backstop: between files (never mid-transaction), pause // the store until the off-thread checkpoint catches up. Resolves to // null in the normal case — a single size check, no cost. const bp = walBackpressure?.(); if (bp) await bp; // Kernel deferred-decode results carry table sizes in kernelCounts // (their object arrays are empty — decode happens at the store). const nodeCount = result.kernelCounts?.nodes ?? result.nodes.length; const edgeCount = result.kernelCounts?.edges ?? result.edges.length; // Store: on the writer thread when active (fresh DB — bundles applied // in the same file order this chain dispatches them), else on the main // thread (SQLite connections are per-thread). if (nodeCount > 0 || result.errors.length === 0) { const language = detectLanguage(filePath, content, overrides); if (storeWriter) { if (result.kernelBuffers) { // Buffers go to the writer as-is; the worker decodes + finalizes. // The main thread's only per-file work stays O(1) + the content hash. storeWriter.send({ kernel: true, filePath, language, buffers: result.kernelBuffers, file: this.buildFileRecord(filePath, content, language, stats, nodeCount, result.errors), }); } else { storeWriter.send(this.buildFreshStoreBundle(filePath, content, language, stats, result)); } await storeWriter.waitBelow(STORE_WRITER_WINDOW); } else { const materialized = materializeKernelResult(result, filePath, language); await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield); } } if (result.errors.length > 0) { for (const err of result.errors) { if (!err.filePath) err.filePath = filePath; } errors.push(...result.errors); } if (nodeCount > 0) { filesIndexed++; totalNodes += nodeCount; totalEdges += edgeCount; } else if (result.errors.some((e) => e.severity === 'error')) { filesErrored++; } else { // Files with no symbols but no errors (yaml, twig, properties) are // tracked at the file level — count them as indexed so the CLI doesn't // misleadingly report "No files found to index". const lang = detectLanguage(filePath, content, overrides); if (isFileLevelOnlyLanguage(lang)) { filesIndexed++; } else { filesSkipped++; } } onProgress?.({ phase: 'parsing', current: processed, total, currentFile: filePath }); }; const recordParseFailure = (filePath: string, err: unknown): void => { processed++; filesErrored++; errors.push({ message: err instanceof Error ? err.message : String(err), filePath, severity: 'error', code: 'parse_error', }); onProgress?.({ phase: 'parsing', current: processed, total }); }; // Commit buffered parses to the DB in file order, advancing the cursor over // contiguous completed results. Runs after each parse settles (and once more // after the drain). storeResult is now async (it yields between chunked // inserts), so commits are SERIALIZED on a promise chain — concurrent parse // completions append to the chain instead of interleaving mid-store, which // preserves both the file-order commit invariant (#1015: resolution // disambiguates same-named candidates by insertion order) and the // single-writer discipline for SQLite. Errors are recorded and re-thrown // at the drain, matching the old synchronous propagation. let flushChain: Promise = Promise.resolve(); let flushError: unknown = null; const flushOrdered = (): Promise => { flushChain = flushChain.then(async () => { if (aborted || flushError) return; try { while (completed.has(nextToStore)) { const item = completed.get(nextToStore)!; completed.delete(nextToStore); nextToStore++; if (item.ok) await storeResult(item.filePath, item.content, item.stats, item.result); else recordParseFailure(item.filePath, item.err); } } catch (err) { flushError = err; } }); return flushChain; }; // Dispatch one file's parse (parses run concurrently across the pool), tagged // with its file-order sequence so flushOrdered commits results in order. The // backpressure below bounds how far parsing runs ahead of the in-order commit. const feed = async (filePath: string, content: string, stats: fs.Stats): Promise => { const seq = nextSeq++; const p = (async () => { try { const result = await parseFile(filePath, content); completed.set(seq, { ok: true, filePath, content, stats, result }); } catch (parseErr) { completed.set(seq, { ok: false, filePath, err: parseErr }); } flushOrdered(); })(); const tracked = p.finally(() => { inFlight.delete(tracked); }); inFlight.add(tracked); // Backpressure on the dispatched-but-not-yet-committed count (in-flight + // buffered), not just in-flight: a slow file sitting at the commit cursor // lets later parses finish and buffer, which would otherwise grow without // bound. Wait for parses to settle (each may advance the cursor) until the // window has room. When nothing is in flight but the window is still full, // the async commit chain is what's behind — await it so the cursor // advances (buffered items hold whole file contents, so this bound is // load-bearing for memory). while (nextSeq - nextToStore >= windowSize) { if (inFlight.size > 0) await Promise.race(inFlight); else await flushOrdered(); } }; const tParseLoop = Date.now(); for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) { if (signal?.aborted) { aborted = true; break; } const batch = files.slice(i, i + FILE_IO_BATCH_SIZE); // Read files in parallel (with path validation before any I/O) const fileContents = await Promise.all( batch.map(async (fp) => { try { // Indexing read: follow in-root symlinks the directory walk already // descended into (the `../` guard still applies) so files reached // via an in-root symlink-to-outside still index (#935). const fullPath = validatePathWithinRoot(this.rootDir, fp, { allowSymlinkEscape: true }); if (!fullPath) { logWarn('Path traversal blocked in batch reader', { filePath: fp }); return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') }; } const content = await fsp.readFile(fullPath, 'utf-8'); const stats = await fsp.stat(fullPath); return { filePath: fp, content, stats, error: null as Error | null }; } catch (err) { return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: err as Error }; } }) ); // Dispatch each readable file into the bounded parse window; the window // stores results on the main thread as they arrive. for (const { filePath, content, stats, error } of fileContents) { if (signal?.aborted) { aborted = true; break; } if (error || content === null || stats === null) { processed++; filesErrored++; errors.push({ message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, filePath, severity: 'error', code: 'read_error', }); onProgress?.({ phase: 'parsing', current: processed, total }); continue; } // Honour MAX_FILE_SIZE. Without this check, vendored generated // headers, minified bundles, and other multi-MB files get indexed, // wasting WASM heap and the worker recycle budget on inputs with no // useful symbols. The single-file extractFile path already enforces // this; the bulk path used to silently skip the check. if (stats.size > MAX_FILE_SIZE) { processed++; filesSkipped++; errors.push({ message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, filePath, severity: 'warning', code: 'size_exceeded', }); onProgress?.({ phase: 'parsing', current: processed, total }); continue; } // Parse on the pool (main thread stays unblocked). Errors/timeouts are // handled inside feed() → recordParseFailure, feeding the retry pass. await feed(filePath, content, stats); } if (aborted) break; } // Drain parses still in flight (skip on abort — we tear down below instead), // then commit any results the cursor hasn't reached yet. if (!aborted) { await Promise.all(inFlight); await flushOrdered(); if (flushError) { if (storeWriter) await storeWriter.close(); throw flushError; } // All bundles are posted; wait for the writer to apply them, then close // its connection BEFORE any main-thread DB work below (retry pass, // resolution) so exactly one connection writes at a time. if (storeWriter) { try { await storeWriter.drain(); } finally { await storeWriter.close(); storeWriter = null; } } } if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] parse-loop: ${Date.now() - tParseLoop}ms`); if (signal?.aborted || aborted) { if (storeWriter) await storeWriter.close(); if (pool) await pool.destroy(); return { success: false, filesIndexed, filesSkipped, filesErrored, filesDiscovered: total, nodesCreated: totalNodes, edgesCreated: totalEdges, errors: [{ message: 'Aborted', severity: 'error' }, ...errors], durationMs: Date.now() - startTime, }; } // Report 100% so the progress bar doesn't hang at 99% onProgress?.({ phase: 'parsing', current: total, total, }); // Yield so the shimmer worker's buffered stdout writes can flush. // Worker thread stdout is proxied through the main thread's event loop, // so synchronous work here blocks the animation from rendering. await new Promise(resolve => setImmediate(resolve)); // Retry pass: files that failed due to WASM memory corruption may succeed // on a fresh worker with a clean heap. Recycle before each attempt so // every file gets the absolute cleanest WASM state possible. Timeouts are // retried too (#1231): most are main-thread-stall artifacts, not slow // parses, and this pass parses one file at a time with the store strictly // after each parse resolves, so the stall window can't recur here. const retryableErrors = errors.filter( (e) => e.code === 'parse_error' && e.filePath && (e.message.includes('Worker exited') || e.message.includes('memory access out of bounds') || e.message.includes('timed out')) ); if (retryableErrors.length > 0 && pool) { log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors or timeouts...`); // Fresh WASM heaps for the retry phase. A retry that still crashes its // worker makes the pool respawn it, so later retries keep landing on clean // workers too. pool.recycleAll(); const stillFailing: typeof retryableErrors = []; for (const errEntry of retryableErrors) { const filePath = errEntry.filePath!; if (signal?.aborted) break; let content: string; try { const fullPath = validatePathWithinRoot(this.rootDir, filePath); if (!fullPath) continue; content = await fsp.readFile(fullPath, 'utf-8'); } catch { continue; } let result: ExtractionResult; try { result = await parseFile(filePath, content); } catch { stillFailing.push(errEntry); continue; } if (result.nodes.length > 0 || result.errors.length === 0) { const language = detectLanguage(filePath, content, overrides); const stats = await fsp.stat(path.join(this.rootDir, filePath)); await this.storeExtractionResult(filePath, content, language, stats, result, commitYield); const idx = errors.indexOf(errEntry); if (idx >= 0) errors.splice(idx, 1); filesErrored--; filesIndexed++; totalNodes += result.nodes.length; totalEdges += result.edges.length; log(`Retry OK: ${filePath} (${result.nodes.length} nodes)`); } } // Last resort: for files that still crash on a clean worker, strip // comment-only lines to reduce WASM memory pressure. Many compiler // test files are 90%+ comments (CHECK directives) that don't contribute // code nodes but consume parser memory. if (stillFailing.length > 0) { log(`${stillFailing.length} files still failing — retrying with comments stripped...`); pool.recycleAll(); for (const errEntry of stillFailing) { const filePath = errEntry.filePath!; if (signal?.aborted) break; let fullContent: string; try { const fullPath = validatePathWithinRoot(this.rootDir, filePath); if (!fullPath) continue; fullContent = await fsp.readFile(fullPath, 'utf-8'); } catch { continue; } // Strip lines that are entirely comments (preserving line numbers // by replacing with empty lines so node positions stay correct) const stripped = fullContent .split('\n') .map(line => /^\s*\/\//.test(line) ? '' : line) .join('\n'); let result: ExtractionResult; try { result = await parseFile(filePath, stripped); } catch { continue; } if (result.nodes.length > 0 || result.errors.length === 0) { const language = detectLanguage(filePath, fullContent, overrides); const stats = await fsp.stat(path.join(this.rootDir, filePath)); await this.storeExtractionResult(filePath, fullContent, language, stats, result, commitYield); const idx = errors.indexOf(errEntry); if (idx >= 0) errors.splice(idx, 1); filesErrored--; filesIndexed++; totalNodes += result.nodes.length; totalEdges += result.edges.length; log(`Retry (stripped) OK: ${filePath} (${result.nodes.length} nodes)`); } } } } // Shut down the parse worker pool. if (pool) await pool.destroy(); return { success: filesIndexed > 0 || errors.filter((e) => e.severity === 'error').length === 0, filesIndexed, filesSkipped, filesErrored, filesDiscovered: total, nodesCreated: totalNodes, edgesCreated: totalEdges, errors, durationMs: Date.now() - startTime, }; } /** * Index specific files */ async indexFiles(filePaths: string[]): Promise { const startTime = Date.now(); const errors: ExtractionError[] = []; let filesIndexed = 0; let filesSkipped = 0; let filesErrored = 0; let totalNodes = 0; let totalEdges = 0; for (const filePath of filePaths) { const result = await this.indexFile(filePath); if (result.errors.length > 0) { errors.push(...result.errors); } if (result.nodes.length > 0) { filesIndexed++; totalNodes += result.nodes.length; totalEdges += result.edges.length; } else if (result.errors.some((e) => e.severity === 'error')) { filesErrored++; } else { const tracked = this.queries.getFileByPath(filePath); if (tracked && isFileLevelOnlyLanguage(tracked.language)) { filesIndexed++; } else { filesSkipped++; } } } return { success: filesIndexed > 0 || errors.filter((e) => e.severity === 'error').length === 0, filesIndexed, filesSkipped, filesErrored, nodesCreated: totalNodes, edgesCreated: totalEdges, errors, durationMs: Date.now() - startTime, }; } /** * Index a single file */ async indexFile(relativePath: string): Promise { // Indexing read: follow in-root symlinks (the `../` guard still applies), #935. const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true }); if (!fullPath) { return { nodes: [], edges: [], unresolvedReferences: [], errors: [{ message: `Path traversal blocked: ${relativePath}`, filePath: relativePath, severity: 'error', code: 'path_traversal' }], durationMs: 0, }; } // Read file content and stats let content: string; let stats: fs.Stats; try { stats = await fsp.stat(fullPath); content = await fsp.readFile(fullPath, 'utf-8'); } catch (error) { return { nodes: [], edges: [], unresolvedReferences: [], errors: [ { message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, filePath: relativePath, severity: 'error', code: 'read_error', }, ], durationMs: 0, }; } return this.indexFileWithContent(relativePath, content, stats); } /** * Index a single file with pre-read content and stats. * Used by the parallel batch reader to avoid redundant file I/O. */ async indexFileWithContent( relativePath: string, content: string, stats: fs.Stats ): Promise { // Prevent `../` traversal; follow in-root symlinks like the directory walk (#935). const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true }); if (!fullPath) { logWarn('Path traversal blocked in indexFileWithContent', { relativePath }); return { nodes: [], edges: [], unresolvedReferences: [], errors: [{ message: 'Path traversal blocked', filePath: relativePath, severity: 'error', code: 'path_traversal' }], durationMs: 0, }; } // Check file size if (stats.size > MAX_FILE_SIZE) { return { nodes: [], edges: [], unresolvedReferences: [], errors: [ { message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, filePath: relativePath, severity: 'warning', code: 'size_exceeded', }, ], durationMs: 0, }; } // Detect language (honoring the project's codegraph.json extension overrides) const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir)); if (!isLanguageSupported(language)) { return { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0, }; } // Extract from source. Use cached framework names if indexAll has run, // otherwise detect on the spot so single-file re-index paths still emit // route nodes / middleware / etc. const frameworkNames = this.ensureDetectedFrameworks(); const result = extractFromSource(relativePath, content, language, frameworkNames); // Store in database if (result.nodes.length > 0 || result.errors.length === 0) { await this.storeExtractionResult(relativePath, content, language, stats, result, createYielder()); } return result; } /** * Store extraction result in database */ private async storeExtractionResult( filePath: string, content: string, language: Language, stats: fs.Stats, result: ExtractionResult, onYield?: MaybeYield ): Promise { // Bulk inserts run in bounded sub-transactions with a yield between, so a // giant generated file (tens of thousands of symbols) can't block the // event loop — and the #850 watchdog heartbeat — for the whole store. // The file was NEVER one atomic transaction (each insert call has its // own), and the files-table record still lands last, so crash recovery // is unchanged: a partially-stored file has no record and re-indexes. const STORE_CHUNK = 2000; const contentHash = hashContent(content); // Check if file already exists and hasn't changed const existingFile = this.queries.getFileByPath(filePath); if (existingFile && existingFile.contentHash === contentHash) { return; // No changes } // Snapshot incoming cross-file edges BEFORE deleting this file's nodes. // `deleteFile` cascades to delete every edge whose source OR target is a // node in this file (edges.FK ... ON DELETE CASCADE). Edges whose SOURCE is // in this file are re-emitted by the extractor below, but edges whose SOURCE // is in a *different* (unchanged) file are not — they would be silently // dropped, which is issue #899: re-indexing a callee file severs `calls`/ // `references` edges from callers that import it via module-attribute // access (`pkg.mod.fn(...)`). // // We snapshot the edge plus the target node's (name, kind) so we can // re-resolve to the re-indexed target's NEW id. Node ids are // `sha256(filePath:kind:name:line)`, so any line shift in the callee file // (e.g. a docstring-only edit above the symbol) changes every target id and // a naive re-insert by old id would silently drop every edge. Matching by // (filePath, kind, name) is stable across line shifts; if the symbol was // renamed/removed, no match is found and the edge stays dropped (correct). const crossFileIncomingEdges = existingFile ? this.queries.getCrossFileIncomingEdgesWithTarget(filePath) : []; // Delete existing data for this file if (existingFile) { this.queries.deleteFile(filePath); } // Filter out nodes with missing required fields before insertion. // This prevents FK violations when edges reference nodes that would // be silently skipped by insertNode() (see issue #42). const validNodes = result.nodes.filter((n) => n.id && n.kind && n.name && n.filePath && n.language); const insertedIds = new Set(validNodes.map((n) => n.id)); const validEdges = result.edges.filter( (e) => insertedIds.has(e.source) && insertedIds.has(e.target) ); const validRefs = result.unresolvedReferences .filter((ref) => insertedIds.has(ref.fromNodeId)) .map((ref) => ({ ...ref, filePath: ref.filePath ?? filePath, language: ref.language ?? language, })); // Fast path for the common case (everything fits one chunk): the whole // file — nodes, edges, refs, file record — lands in ONE transaction with // no event-loop yields in between. Giant generated files keep the chunked // + yielding path below so the #850 watchdog heartbeat stays serviced. const fitsOneChunk = validNodes.length <= STORE_CHUNK && validEdges.length <= STORE_CHUNK && validRefs.length <= STORE_CHUNK; if (fitsOneChunk) { // Snapshot/re-resolution of cross-file incoming edges (below) still runs // for the sync path; on a fresh bulk index crossFileIncomingEdges is []. this.queries.storeFileBundle({ nodes: validNodes, edges: validEdges, refs: validRefs, file: { path: filePath, contentHash, language, size: stats.size, modifiedAt: stats.mtimeMs, indexedAt: Date.now(), nodeCount: result.nodes.length, errors: result.errors.length > 0 ? result.errors : undefined, }, }); if (crossFileIncomingEdges.length > 0) { this.reattachCrossFileEdges(crossFileIncomingEdges, validNodes); } return; } // Insert nodes (chunked — see STORE_CHUNK above) for (let i = 0; i < validNodes.length; i += STORE_CHUNK) { this.queries.insertNodes(validNodes.slice(i, i + STORE_CHUNK)); await onYield?.(); } // Filter edges to only reference nodes that were actually inserted if (validEdges.length > 0) { for (let i = 0; i < validEdges.length; i += STORE_CHUNK) { this.queries.insertEdges(validEdges.slice(i, i + STORE_CHUNK)); await onYield?.(); } } // Re-insert cross-file incoming edges snapshotted before the delete, // re-resolving each edge's target to the re-indexed node's new id by // (filePath, kind, name). Node ids include the source line, so any line // shift in the callee file (e.g. a docstring-only edit above the symbol) // changes every target id and a naive re-insert by old id would drop them // all. `insertEdges` still filters to endpoints that exist. This closes // the #899 edge-drop on `sync`. // // Edges whose callee (target) was renamed/removed during the re-index (no // match in `newNodesByKindName`) are not silently dropped anymore: each is // resurrected as its ORIGINAL unresolved ref (stamped on the edge as // metadata.refName/refKind at creation) so the same sync's resolution // sweep can rebind it to an alternative definition elsewhere, or park it // as status='failed' to be retried when the symbol reappears — the // removal-side counterpart of #1240. Edges without refName (built before // the stamp existed, or synthesized) still drop silently: reconstructing // a ref from the target's plain name would strip receiver/qualifier // context and risk a rebind a full re-index would never make. if (crossFileIncomingEdges.length > 0) { this.reattachCrossFileEdges(crossFileIncomingEdges, validNodes); } // Insert unresolved references in batch with denormalized filePath/language for (let i = 0; i < validRefs.length; i += STORE_CHUNK) { this.queries.insertUnresolvedRefsBatch(validRefs.slice(i, i + STORE_CHUNK)); await onYield?.(); } // Insert file record const fileRecord: FileRecord = { path: filePath, contentHash, language, size: stats.size, modifiedAt: stats.mtimeMs, indexedAt: Date.now(), nodeCount: result.nodes.length, errors: result.errors.length > 0 ? result.errors : undefined, }; this.queries.upsertFile(fileRecord); } /** * Build one file's store bundle for the FRESH-DB path: no existing-file * check, no cross-file edge snapshot (both are re-index concerns — a fresh * database has neither). Filters mirror storeExtractionResult exactly. */ /** The FileRecord for a fresh-index store (nodeCount is the PRE-filter count). */ private buildFileRecord( filePath: string, content: string, language: Language, stats: fs.Stats, nodeCount: number, resultErrors: ExtractionResult['errors'] ): FileRecord { return { path: filePath, contentHash: hashContent(content), language, size: stats.size, modifiedAt: stats.mtimeMs, indexedAt: Date.now(), nodeCount, errors: resultErrors.length > 0 ? resultErrors : undefined, }; } private buildFreshStoreBundle( filePath: string, content: string, language: Language, stats: fs.Stats, result: ExtractionResult ): StoreBundle { return finalizeStoreBundle( result, filePath, language, this.buildFileRecord(filePath, content, language, stats, result.nodes.length, result.errors) ); } /** * Re-attach cross-file incoming edges snapshotted before a re-index delete * (#899): re-resolve each edge's target to the re-indexed node's new id by * (kind, name); targets that vanished are resurrected as their original * unresolved ref (#1240's removal-side counterpart) when the edge carries * its refName stamp. */ private reattachCrossFileEdges( crossFileIncomingEdges: Array, validNodes: Node[] ): void { const newNodesByKindName = new Map(); for (const n of validNodes) { newNodesByKindName.set(`${n.kind}\0${n.name}`, n.id); } const reinserted: Edge[] = []; const resurrected: UnresolvedReference[] = []; for (const e of crossFileIncomingEdges) { const newTargetId = newNodesByKindName.get(`${e.targetKind}\0${e.targetName}`); if (newTargetId) { reinserted.push({ source: e.source, target: newTargetId, kind: e.kind, metadata: e.metadata, line: e.line, column: e.column, provenance: e.provenance }); } else { const ref = resurrectRefFromDroppedEdge(e); if (ref) resurrected.push(ref); } } if (reinserted.length > 0) { this.queries.insertEdges(reinserted); } if (resurrected.length > 0) { this.queries.insertUnresolvedRefsBatch(resurrected); } } /** * Sync the index with the current file state. * * Change detection is filesystem-based, never git: a (size, mtime) stat * pre-filter skips unchanged files, then a content-hash compare confirms real * changes. This works in non-git projects and catches committed changes from * `git pull`/`checkout`/`merge`/`rebase` that `git status` cannot see. */ async sync(onProgress?: (progress: IndexProgress) => void): Promise { await initGrammars(); // Initialize WASM runtime (grammars loaded lazily below) const startTime = Date.now(); let filesChecked = 0; let filesAdded = 0; let filesModified = 0; let filesRemoved = 0; let nodesUpdated = 0; const changedFilePaths: string[] = []; onProgress?.({ phase: 'scanning', current: 0, total: 0, }); const filesToIndex: string[] = []; // === Filesystem reconcile (git-independent) === // The source of truth for "what changed" is the filesystem vs the indexed // state — never git. We enumerate the current source files and reconcile // each against the DB. A cheap (size, mtime) stat pre-filter skips unchanged // files without reading or hashing them, so the expensive read+hash+parse // only runs for files that actually changed. This catches edits/adds/deletes // whether or not the project uses git, and crucially also catches committed // changes from `git pull`/`checkout`/`merge`/`rebase` — which `git status` // cannot see, because the working tree is clean afterward. const tSyncScan = Date.now(); const currentFiles = await scanDirectoryAsync(this.rootDir); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`); filesChecked = currentFiles.length; const currentSet = new Set(currentFiles); const tTracked = Date.now(); const trackedFiles = this.queries.getAllFiles(); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-tracked-load: ${Date.now() - tTracked}ms (${trackedFiles.length} tracked)`); const trackedMap = new Map(); for (const f of trackedFiles) { trackedMap.set(f.path, f); } // Removals: tracked in the DB but no longer a present source file. Check the // filesystem directly — `scanDirectory` (via `git ls-files`) still lists a // file deleted from disk but not yet staged, so set membership alone misses it. // `reconcileChecks` drives the cooperative yield shared with the adds/mods loop // below (see SYNC_RECONCILE_YIELD_INTERVAL / issue #905). let reconcileChecks = 0; for (const tracked of trackedFiles) { if (!currentSet.has(tracked.path) || !fs.existsSync(path.join(this.rootDir, tracked.path))) { // Before the cascade deletes them, resurrect incoming cross-file // resolution edges as their original refs (#1240 removal case): the // callers live in files this sync will NOT revisit, so this is their // only chance to rebind to an alternative definition — or to park as // failed until the symbol reappears somewhere. (A deleted file whose // CALLERS are also being deleted is fine: their nodes cascade later // in this loop and take the resurrected rows with them.) const incoming = this.queries.getCrossFileIncomingEdgesWithTarget(tracked.path); if (incoming.length > 0) { const resurrected = incoming .map((e) => resurrectRefFromDroppedEdge(e)) .filter((r): r is UnresolvedReference => r !== null); if (resurrected.length > 0) { this.queries.insertUnresolvedRefsBatch(resurrected); } } this.queries.deleteFile(tracked.path); filesRemoved++; } if (++reconcileChecks % SYNC_RECONCILE_YIELD_INTERVAL === 0) { await new Promise((resolve) => setImmediate(resolve)); } } // Adds / modifications. for (const filePath of currentFiles) { // Same cooperative yield as the removals loop — this is the other O(files) // synchronous-stat loop that wedges the main thread on a large repo (#905). // Yield at the top of the body so the `continue` fast-paths below still hit it. if (++reconcileChecks % SYNC_RECONCILE_YIELD_INTERVAL === 0) { await new Promise((resolve) => setImmediate(resolve)); } const fullPath = path.join(this.rootDir, filePath); const tracked = trackedMap.get(filePath); // Cheap pre-filter: an already-indexed file whose size AND mtime both match // the DB is unchanged — skip it without reading or hashing. (A content // change that preserves both exactly is the blind spot every mtime-based // incremental tool accepts; `index --force` is the escape hatch. Git bumps // mtime on every file it writes during checkout/merge, so pulls are caught.) if (tracked) { try { const stat = fs.statSync(fullPath); if (stat.size === tracked.size && Math.floor(stat.mtimeMs) === Math.floor(tracked.modifiedAt)) { continue; } } catch (error) { logDebug('Skipping unstattable file during sync', { filePath, error: String(error) }); continue; } } // New, or size/mtime changed — read + hash to confirm a real content change. let content: string; try { content = fs.readFileSync(fullPath, 'utf-8'); } catch (error) { logDebug('Skipping unreadable file during sync', { filePath, error: String(error) }); continue; } const contentHash = hashContent(content); if (!tracked) { filesToIndex.push(filePath); changedFilePaths.push(filePath); filesAdded++; } else if (tracked.contentHash !== contentHash) { filesToIndex.push(filePath); changedFilePaths.push(filePath); filesModified++; } } // Load only grammars needed for changed files if (filesToIndex.length > 0) { const overrides = loadExtensionOverrides(this.rootDir); const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) { neededLanguages.push('cpp'); } await loadGrammarsForLanguages(neededLanguages); } // Index changed files const total = filesToIndex.length; for (let i = 0; i < filesToIndex.length; i++) { const filePath = filesToIndex[i]!; onProgress?.({ phase: 'parsing', current: i + 1, total, currentFile: filePath, }); const result = await this.indexFile(filePath); nodesUpdated += result.nodes.length; } return { filesChecked, filesAdded, filesModified, filesRemoved, nodesUpdated, durationMs: Date.now() - startTime, changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined, }; } /** * Get files that have changed since last index. * Uses git status as a fast path when available, falling back to full scan. */ getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } { const gitChanges = getGitChangedFiles(this.rootDir); if (gitChanges) { // === Git fast path === const added: string[] = []; const modified: string[] = []; const removed: string[] = []; // Deleted files — only report if tracked in DB for (const filePath of gitChanges.deleted) { const tracked = this.queries.getFileByPath(filePath); if (tracked) { removed.push(filePath); } } // Modified + added files — read + hash, compare with DB. Untracked (`??`) // files stay untracked in git even after indexing, so they must be // hash-compared like modified files instead of always counting as added — // otherwise status reports them as pending forever. (See issue #206.) for (const filePath of [...gitChanges.modified, ...gitChanges.added]) { const fullPath = path.join(this.rootDir, filePath); let content: string; try { content = fs.readFileSync(fullPath, 'utf-8'); } catch (error) { logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) }); continue; } const contentHash = hashContent(content); const tracked = this.queries.getFileByPath(filePath); if (!tracked) { added.push(filePath); } else if (tracked.contentHash !== contentHash) { modified.push(filePath); } } return { added, modified, removed }; } // === Fallback: full scan (non-git project or git failure) === const currentFiles = new Set(scanDirectory(this.rootDir)); const trackedFiles = this.queries.getAllFiles(); // Build Map for O(1) lookups const trackedMap = new Map(); for (const f of trackedFiles) { trackedMap.set(f.path, f); } const added: string[] = []; const modified: string[] = []; const removed: string[] = []; // Find removed files for (const tracked of trackedFiles) { if (!currentFiles.has(tracked.path)) { removed.push(tracked.path); } } // Find added and modified files for (const filePath of currentFiles) { const fullPath = path.join(this.rootDir, filePath); let content: string; try { content = fs.readFileSync(fullPath, 'utf-8'); } catch (error) { logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) }); continue; } const contentHash = hashContent(content); const tracked = trackedMap.get(filePath); if (!tracked) { added.push(filePath); } else if (tracked.contentHash !== contentHash) { modified.push(filePath); } } return { added, modified, removed }; } } // Re-export useful types and functions export { extractFromSource } from './tree-sitter'; export { detectLanguage, isSourceFile, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './grammars';