| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744 |
- /**
- * 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<string> = 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<string>();
- 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<string, Language>,
- ): Set<string> {
- const out = new Set<string>();
- const defaults = defaultsOnlyIgnore();
- const visited = new Set<string>();
- 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<string> {
- 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/<name>`, 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/<module>/worktrees/<name>` — same duplication, so skip it too
- * (#945). A **submodule** checkout points into `.git/modules/<module>` (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/<name>` —
- // either the top-level repo's (`.git/worktrees/`) or, for a worktree of a
- // submodule, that submodule's gitdir (`.git/modules/<module>/worktrees/`).
- // The optional `modules/<module>` 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<string>): 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<string>, embeddedRoots?: Set<string>, 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 `<mode> <object> <stage>` 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 "<mode> <object> <stage>\t<path>"
- 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<string> | 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<string>();
- const embeddedRoots = new Set<string>();
- 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<string, Language>, 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<string[]> {
- // 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<void>(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<string>();
- // 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/<sub>/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<void> | 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<IndexResult> {
- 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<ExtractionResult> => {
- 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<Promise<void>>();
- const completed = new Map<number,
- | { ok: true; filePath: string; content: string; stats: fs.Stats; result: ExtractionResult }
- | { ok: false; filePath: string; err: unknown }>();
- 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<void> => {
- 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<void> = Promise.resolve();
- let flushError: unknown = null;
- const flushOrdered = (): Promise<void> => {
- 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<void> => {
- 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<IndexResult> {
- 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<ExtractionResult> {
- // 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<ExtractionResult> {
- // 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<void> {
- // 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<Edge & { targetKind: string; targetName: string; sourceFilePath: string; sourceLanguage: Language }>,
- validNodes: Node[]
- ): void {
- const newNodesByKindName = new Map<string, string>();
- 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<SyncResult> {
- 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<string, FileRecord>();
- 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<void>((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<void>((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<string, FileRecord>();
- 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';
|