index.ts 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814
  1. /**
  2. * CodeGraph
  3. *
  4. * A local-first code intelligence system that builds a semantic
  5. * knowledge graph from any codebase.
  6. */
  7. import * as path from 'path';
  8. import {
  9. Node,
  10. NodeKind,
  11. Edge,
  12. FileRecord,
  13. ExtractionResult,
  14. Subgraph,
  15. TraversalOptions,
  16. SearchOptions,
  17. SearchResult,
  18. SegmentMatch,
  19. Context,
  20. GraphStats,
  21. TaskInput,
  22. TaskContext,
  23. BuildContextOptions,
  24. FindRelevantContextOptions,
  25. } from './types';
  26. import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
  27. import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
  28. import { QueryBuilder } from './db/queries';
  29. import {
  30. isInitialized,
  31. createDirectory,
  32. removeDirectory,
  33. validateDirectory,
  34. } from './directory';
  35. import {
  36. ExtractionOrchestrator,
  37. IndexProgress,
  38. IndexResult,
  39. SyncResult,
  40. extractFromSource,
  41. initGrammars,
  42. } from './extraction';
  43. import {
  44. ReferenceResolver,
  45. createResolver,
  46. ResolutionResult,
  47. } from './resolution';
  48. import { GraphTraverser, GraphQueryManager } from './graph';
  49. import { ContextBuilder, createContextBuilder } from './context';
  50. import { Mutex, FileLock } from './utils';
  51. import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
  52. import { EXTRACTION_VERSION } from './extraction/extraction-version';
  53. import { getCodeGraphDir } from './directory';
  54. import { deriveProjectNameTokens } from './search/query-utils';
  55. import { CodeGraphPackageVersion } from './mcp/version';
  56. import { segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments';
  57. import { createYielder } from './resolution/cooperative-yield';
  58. import { minRefsForPool } from './resolution/resolver-pool';
  59. // Re-export types for consumers
  60. export * from './types';
  61. // Storage building blocks for embedded/SDK consumers that drive the graph
  62. // directly (open a DB, run prepared queries) rather than through the CodeGraph
  63. // facade. Exposed from the package entry so they no longer require deep imports
  64. // into dist/ (issue #354).
  65. export { getDatabasePath, DatabaseConnection } from './db';
  66. export { QueryBuilder } from './db/queries';
  67. export {
  68. getCodeGraphDir,
  69. isInitialized,
  70. findNearestCodeGraphRoot,
  71. CODEGRAPH_DIR,
  72. } from './directory';
  73. export { IndexProgress, IndexResult, SyncResult } from './extraction';
  74. export { detectLanguage, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './extraction';
  75. export { ResolutionResult } from './resolution';
  76. export {
  77. CodeGraphError,
  78. FileError,
  79. ParseError,
  80. DatabaseError,
  81. SearchError,
  82. VectorError,
  83. ConfigError,
  84. Logger,
  85. setLogger,
  86. getLogger,
  87. silentLogger,
  88. defaultLogger,
  89. } from './errors';
  90. export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
  91. export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
  92. export { MCPServer } from './mcp';
  93. /**
  94. * Options for initializing a new CodeGraph project
  95. */
  96. export interface InitOptions {
  97. /** Whether to run initial indexing after init */
  98. index?: boolean;
  99. /** Progress callback for indexing */
  100. onProgress?: (progress: IndexProgress) => void;
  101. }
  102. /**
  103. * Options for opening an existing CodeGraph project
  104. */
  105. export interface OpenOptions {
  106. /** Whether to run sync if files have changed */
  107. sync?: boolean;
  108. /** Whether to run in read-only mode */
  109. readOnly?: boolean;
  110. }
  111. /**
  112. * Options for indexing
  113. */
  114. export interface IndexOptions {
  115. /** Progress callback */
  116. onProgress?: (progress: IndexProgress) => void;
  117. /** Abort signal for cancellation */
  118. signal?: AbortSignal;
  119. /** Enable verbose logging (worker lifecycle, memory, timeouts) */
  120. verbose?: boolean;
  121. }
  122. /**
  123. * Main CodeGraph class
  124. *
  125. * Provides the primary interface for interacting with the code knowledge graph.
  126. */
  127. export class CodeGraph {
  128. private db: DatabaseConnection;
  129. private queries: QueryBuilder;
  130. private projectRoot: string;
  131. // Assigned via wireLayers() from the constructor (and again on reopen) — the
  132. // `!` tells TS these are definitely set even though the assignment is one
  133. // method call away from the constructor body.
  134. private orchestrator!: ExtractionOrchestrator;
  135. private resolver!: ReferenceResolver;
  136. private graphManager!: GraphQueryManager;
  137. private traverser!: GraphTraverser;
  138. private contextBuilder!: ContextBuilder;
  139. // Mutex for preventing concurrent indexing operations (in-process)
  140. private indexMutex = new Mutex();
  141. // File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
  142. private fileLock: FileLock;
  143. // File watcher for auto-sync on file changes
  144. private watcher: FileWatcher | null = null;
  145. private constructor(
  146. db: DatabaseConnection,
  147. queries: QueryBuilder,
  148. projectRoot: string
  149. ) {
  150. this.db = db;
  151. this.queries = queries;
  152. this.projectRoot = projectRoot;
  153. this.fileLock = new FileLock(
  154. path.join(getCodeGraphDir(projectRoot), 'codegraph.lock')
  155. );
  156. this.wireLayers();
  157. }
  158. /**
  159. * (Re)build the query/extraction/graph layers over the current `this.queries`
  160. * (which wraps `this.db`). Factored out of the constructor so `reopenIfReplaced`
  161. * can rebuild them against a fresh connection without duplicating the wiring.
  162. * The path-based `fileLock` is independent of the DB handle, so it stays put.
  163. */
  164. private wireLayers(): void {
  165. // Down-weight the project name as a query term in search ranking — it names
  166. // the whole repo, not a symbol, so it has no discriminative value (#720).
  167. try {
  168. this.queries.setProjectNameTokens(deriveProjectNameTokens(this.projectRoot));
  169. } catch {
  170. // Best-effort: ranking still works without it.
  171. }
  172. this.orchestrator = new ExtractionOrchestrator(this.projectRoot, this.queries);
  173. this.resolver = createResolver(this.projectRoot, this.queries);
  174. this.graphManager = new GraphQueryManager(this.queries);
  175. this.traverser = new GraphTraverser(this.queries);
  176. this.contextBuilder = createContextBuilder(
  177. this.projectRoot,
  178. this.queries,
  179. this.traverser
  180. );
  181. }
  182. /**
  183. * Heal a stale database handle in place. If `.codegraph/` was removed and
  184. * recreated at the SAME path while this instance held the DB open — a git
  185. * worktree removed and re-added, or `rm -rf .codegraph` + `codegraph init` —
  186. * our open fd points at the now-unlinked inode and can never see the new
  187. * index, so every query returns the pre-removal snapshot until the process
  188. * restarts (#925). When that's detected, open the live file at the same path,
  189. * rebuild the query layers, and swap them IN PLACE, so every holder of this
  190. * instance (the MCP daemon's default project, cached projectPath connections)
  191. * heals without a restart. Returns true iff it reopened.
  192. *
  193. * POSIX-only in practice: `isReplacedOnDisk` never fires on Windows (an open
  194. * file can't be unlinked there, and st_ino is unreliable).
  195. */
  196. reopenIfReplaced(): boolean {
  197. if (!this.db.isReplacedOnDisk()) return false;
  198. const dbPath = this.db.getPath();
  199. // Open the live file FIRST — if that throws (e.g. mid-recreate), the old
  200. // handle stays in place and the caller retries on the next query, rather
  201. // than leaving this instance with no connection at all.
  202. const fresh = DatabaseConnection.open(dbPath);
  203. const stale = this.db;
  204. this.db = fresh;
  205. this.queries = new QueryBuilder(fresh.getDb());
  206. this.wireLayers();
  207. // Releasing the dead handle also frees the leaked db/-wal/-shm fds that were
  208. // pinning the unlinked inode (#925).
  209. try { stale.close(); } catch { /* the old inode is gone; closing just frees fds */ }
  210. return true;
  211. }
  212. // ===========================================================================
  213. // Lifecycle Methods
  214. // ===========================================================================
  215. /**
  216. * Initialize a new CodeGraph project
  217. *
  218. * Creates the .CodeGraph directory, database, and configuration.
  219. *
  220. * @param projectRoot - Path to the project root directory
  221. * @param options - Initialization options
  222. * @returns A new CodeGraph instance
  223. */
  224. static async init(projectRoot: string, options: InitOptions = {}): Promise<CodeGraph> {
  225. await initGrammars();
  226. const resolvedRoot = path.resolve(projectRoot);
  227. // Check if already initialized
  228. if (isInitialized(resolvedRoot)) {
  229. throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
  230. }
  231. // Create directory structure
  232. createDirectory(resolvedRoot);
  233. // Initialize database
  234. const dbPath = getDatabasePath(resolvedRoot);
  235. const db = DatabaseConnection.initialize(dbPath);
  236. const queries = new QueryBuilder(db.getDb());
  237. const instance = new CodeGraph(db, queries, resolvedRoot);
  238. // Run initial indexing if requested
  239. if (options.index) {
  240. await instance.indexAll({ onProgress: options.onProgress });
  241. }
  242. return instance;
  243. }
  244. /**
  245. * Initialize synchronously (without indexing)
  246. */
  247. static initSync(projectRoot: string): CodeGraph {
  248. const resolvedRoot = path.resolve(projectRoot);
  249. // Check if already initialized
  250. if (isInitialized(resolvedRoot)) {
  251. throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
  252. }
  253. // Create directory structure
  254. createDirectory(resolvedRoot);
  255. // Initialize database
  256. const dbPath = getDatabasePath(resolvedRoot);
  257. const db = DatabaseConnection.initialize(dbPath);
  258. const queries = new QueryBuilder(db.getDb());
  259. return new CodeGraph(db, queries, resolvedRoot);
  260. }
  261. /**
  262. * Open an existing CodeGraph project
  263. *
  264. * @param projectRoot - Path to the project root directory
  265. * @param options - Open options
  266. * @returns A CodeGraph instance
  267. */
  268. static async open(projectRoot: string, options: OpenOptions = {}): Promise<CodeGraph> {
  269. await initGrammars();
  270. const resolvedRoot = path.resolve(projectRoot);
  271. // Check if initialized
  272. if (!isInitialized(resolvedRoot)) {
  273. throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
  274. }
  275. // Validate directory structure
  276. const validation = validateDirectory(resolvedRoot);
  277. if (!validation.valid) {
  278. throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
  279. }
  280. // Open database
  281. const dbPath = getDatabasePath(resolvedRoot);
  282. const db = DatabaseConnection.open(dbPath);
  283. const queries = new QueryBuilder(db.getDb());
  284. const instance = new CodeGraph(db, queries, resolvedRoot);
  285. // Sync if requested
  286. if (options.sync) {
  287. await instance.sync();
  288. }
  289. return instance;
  290. }
  291. /**
  292. * Rebuild the project's database from scratch and return a fresh, empty
  293. * instance — the "same result as a fresh init" semantics that `codegraph
  294. * index` documents.
  295. *
  296. * Unlike `open()` followed by `clear()`, this DISCARDS the existing
  297. * `.codegraph/codegraph.db` (and its `-wal`/`-shm` sidecars) before
  298. * re-initializing, instead of opening the old database and DELETE-ing every
  299. * row. On a large or pre-fix poisoned index — e.g. an old graph that scanned
  300. * an ignored gitlink corpus (#1065) into ~1.6M nodes with a multi-GB WAL —
  301. * the per-row `nodes_fts` delete-trigger churn blocks the main thread long
  302. * enough to trip the #850 liveness watchdog before indexing even starts, so a
  303. * full re-index could never recover the bad state (#1067). Discarding the
  304. * files is O(1) regardless of size, reclaims the disk, and sidesteps opening
  305. * (and running migrations against) the poisoned database entirely.
  306. */
  307. static async recreate(projectRoot: string): Promise<CodeGraph> {
  308. await initGrammars();
  309. const resolvedRoot = path.resolve(projectRoot);
  310. // Check if initialized — recreate REBUILDS an existing project; it is not a
  311. // first-time `init`.
  312. if (!isInitialized(resolvedRoot)) {
  313. throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
  314. }
  315. const dbPath = getDatabasePath(resolvedRoot);
  316. try {
  317. removeDatabaseFiles(dbPath);
  318. } catch (err) {
  319. // POSIX unlinks an open file fine; this fires mainly on Windows when a
  320. // live daemon/MCP server still holds the database. Turn the raw EBUSY into
  321. // an actionable instruction instead of a generic failure.
  322. const reason = err instanceof Error ? err.message : String(err);
  323. throw new Error(
  324. `Could not rebuild the index — the database file is in use (${reason}). ` +
  325. `Stop any running CodeGraph MCP server/daemon for this project and retry, ` +
  326. `or remove the ${getCodeGraphDir(resolvedRoot)} directory and run "codegraph init".`
  327. );
  328. }
  329. // Re-create an empty, freshly-schema'd database at the same path.
  330. const db = DatabaseConnection.initialize(dbPath);
  331. const queries = new QueryBuilder(db.getDb());
  332. return new CodeGraph(db, queries, resolvedRoot);
  333. }
  334. /**
  335. * Open synchronously (without sync)
  336. */
  337. static openSync(projectRoot: string): CodeGraph {
  338. const resolvedRoot = path.resolve(projectRoot);
  339. // Check if initialized
  340. if (!isInitialized(resolvedRoot)) {
  341. throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
  342. }
  343. // Validate directory structure
  344. const validation = validateDirectory(resolvedRoot);
  345. if (!validation.valid) {
  346. throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
  347. }
  348. // Open database
  349. const dbPath = getDatabasePath(resolvedRoot);
  350. const db = DatabaseConnection.open(dbPath);
  351. const queries = new QueryBuilder(db.getDb());
  352. return new CodeGraph(db, queries, resolvedRoot);
  353. }
  354. /**
  355. * Check if a directory has been initialized as a CodeGraph project
  356. */
  357. static isInitialized(projectRoot: string): boolean {
  358. return isInitialized(path.resolve(projectRoot));
  359. }
  360. /**
  361. * Close the CodeGraph instance and release resources
  362. */
  363. close(): void {
  364. this.unwatch();
  365. // Release file lock if held
  366. this.fileLock.release();
  367. this.db.close();
  368. }
  369. /**
  370. * Get the project root directory
  371. */
  372. getProjectRoot(): string {
  373. return this.projectRoot;
  374. }
  375. // ===========================================================================
  376. // Indexing
  377. // ===========================================================================
  378. /**
  379. * Index all files in the project
  380. *
  381. * Uses a mutex to prevent concurrent indexing operations.
  382. */
  383. async indexAll(options: IndexOptions = {}): Promise<IndexResult> {
  384. return this.indexMutex.withLock(async () => {
  385. try {
  386. this.fileLock.acquire();
  387. } catch {
  388. return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
  389. }
  390. // Defer WAL auto-checkpointing for the whole bulk run (#1231): the
  391. // default 1000-page interval re-writes hot pages into the main DB file
  392. // over and over — ~95% of all disk I/O during a bulk index, and a
  393. // 19+min → 45s difference on HDD-class storage. The valve bounds WAL
  394. // growth by backfilling PASSIVEly on a worker thread (never blocking
  395. // the writer or the #850 watchdog heartbeat); runMaintenance below does
  396. // the final fold-up before the interval is restored in the finally.
  397. // Kill switch: CODEGRAPH_NO_WAL_DEFER=1. Non-WAL journal modes (some
  398. // network filesystems) have no WAL to defer — skip.
  399. // Fast-init: on a COMPLETELY fresh DB, trade crash-durability for speed
  400. // during the bulk build (journal in memory, no fsync). Safe because the
  401. // DB is disposable until the index completes — index_state stays
  402. // 'indexing' and a crashed init is re-run from scratch; existing DBs
  403. // (re-index/sync) never take this path. Kill switch:
  404. // CODEGRAPH_NO_FAST_INIT=1 (same pattern as CODEGRAPH_NO_WAL_DEFER).
  405. const freshDb = this.queries.getNodeAndEdgeCount().nodes === 0;
  406. const fastInit = process.env.CODEGRAPH_NO_FAST_INIT !== '1' && freshDb;
  407. if (fastInit) {
  408. try {
  409. this.db.getDb().pragma('journal_mode = MEMORY');
  410. this.db.getDb().pragma('synchronous = OFF');
  411. } catch { /* keep WAL */ }
  412. }
  413. const deferWal = !fastInit && process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
  414. let walValve: WalCheckpointValve | null = null;
  415. let priorAutocheckpoint = 1000;
  416. // Set when the fastInit+pool path below defers autocheckpointing, so the
  417. // finally knows to restore the interval on that path too.
  418. let restoreAutocheckpoint = false;
  419. if (deferWal) {
  420. priorAutocheckpoint = this.db.getWalAutocheckpoint();
  421. this.db.setWalAutocheckpoint(0);
  422. walValve = new WalCheckpointValve(
  423. this.db,
  424. resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB, this.db.getDbFileSizeBytes()),
  425. undefined,
  426. options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
  427. );
  428. walValve.start();
  429. }
  430. try {
  431. const before = this.queries.getNodeAndEdgeCount();
  432. // Mark the index as in-flight BEFORE any writes: a run killed
  433. // mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
  434. // marker behind, so `codegraph status` can tell a truncated index
  435. // from a completed one instead of silently serving partial results.
  436. try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ }
  437. // Segment vocabulary starts empty and is repopulated by the node write
  438. // path as every file (re-)indexes below — so a full index is also the
  439. // orphan-cleanup pass for names deleted since the last one.
  440. try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ }
  441. // Bulk FTS mode for the mass-insert phase: drop the per-row FTS sync
  442. // triggers, rebuild nodes_fts once from the nodes table afterwards.
  443. // Crash inside the window is healed on the next DatabaseConnection.open.
  444. this.db.beginBulkNodeLoad();
  445. let result: IndexResult;
  446. try {
  447. result = await this.orchestrator.indexAll(
  448. options.onProgress,
  449. options.signal,
  450. options.verbose,
  451. walValve ? () => walValve!.backpressure() : undefined,
  452. // Store-writer offload is fresh-DB-only: with any pre-existing
  453. // data the store path must read (existing-file checks, cross-file
  454. // edge snapshots) and delete, which belongs on one thread.
  455. freshDb ? { dbPath: this.db.getPath(), fastInit } : null
  456. );
  457. } finally {
  458. const tFts = Date.now();
  459. this.db.endBulkNodeLoad();
  460. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] fts-rebuild: ${Date.now() - tFts}ms`);
  461. }
  462. // Fold the parse phase's WAL BEFORE the first post-parse reads
  463. // (resolver re-init and resolution both read on the main thread):
  464. // paging a bulk-write-sized WAL there is what blew the #850
  465. // watchdog's 60s window in the #1231 repro. Off-thread + awaited,
  466. // so the event loop keeps turning.
  467. if (walValve) await walValve.foldNow();
  468. // Re-detect frameworks now that the index is populated. The resolver
  469. // is constructed with createResolver() before any files exist, so
  470. // framework resolvers whose detect() consults the indexed file list
  471. // (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking
  472. // for both Swift and ObjC files) all return false on that initial pass
  473. // and silently drop themselves. Re-initializing here gives them a
  474. // chance to see the actual project before resolution runs.
  475. if (result.success && result.filesIndexed > 0) {
  476. const tReinit = Date.now();
  477. this.resolver.initialize();
  478. // Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
  479. // before resolution so updated names show up in subsequent reads.
  480. this.resolver.runPostExtract();
  481. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolver-reinit: ${Date.now() - tReinit}ms`);
  482. }
  483. // Resolve references to create call/import/extends edges
  484. if (result.success && result.filesIndexed > 0) {
  485. // Get count without loading all refs into memory
  486. const unresolvedCount = this.queries.getUnresolvedReferencesCount();
  487. // Fast-init leaves the DB in memory-journal (rollback) mode, where
  488. // the parallel resolver pool's read connections would contend with
  489. // the main writer's exclusive commits. When the pool will actually
  490. // run (enough pending refs), restore WAL BEFORE resolution so
  491. // readers never block the writer; otherwise stay in the fast mode
  492. // until the finally — sequential resolution has no readers.
  493. if (fastInit && unresolvedCount >= minRefsForPool()) {
  494. try {
  495. this.db.getDb().pragma('synchronous = NORMAL');
  496. this.db.getDb().pragma('journal_mode = WAL');
  497. // Defer auto-checkpointing for the resolution phase, same
  498. // rationale as the deferWal path above: at the default 1000-page
  499. // interval, the persist loop's edge inserts + ref deletes make
  500. // SQLite re-write hot B-tree pages into the main DB file inline
  501. // on the writer over and over (#1231's pathology — measured as
  502. // ~58% of the resolution phase on a 255k-ref repo). The valve
  503. // bounds WAL growth off-thread; runMaintenance does the final
  504. // fold and the finally restores the interval.
  505. priorAutocheckpoint = this.db.getWalAutocheckpoint();
  506. this.db.setWalAutocheckpoint(0);
  507. restoreAutocheckpoint = true;
  508. walValve = new WalCheckpointValve(
  509. this.db,
  510. undefined,
  511. undefined,
  512. options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
  513. );
  514. walValve.start();
  515. } catch { /* keep current mode; resolution still works sequentially */ }
  516. }
  517. options.onProgress?.({
  518. phase: 'resolving',
  519. current: 0,
  520. total: unresolvedCount,
  521. });
  522. const tResolve = Date.now();
  523. await this.resolveReferencesBatched(
  524. (current, total) => {
  525. options.onProgress?.({
  526. phase: 'resolving',
  527. current,
  528. total,
  529. });
  530. },
  531. (done, totalPasses) => {
  532. options.onProgress?.({
  533. phase: 'linking',
  534. current: done,
  535. total: totalPasses,
  536. });
  537. },
  538. walValve ? () => walValve!.backpressure() : undefined
  539. );
  540. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolution: ${Date.now() - tResolve}ms`);
  541. // Second pass: chained calls whose method lives on a supertype the
  542. // receiver conforms to (protocol-extension / inherited / default-
  543. // interface). Needs the implements/extends edges the main pass just
  544. // built, so it runs after resolution (#750).
  545. const tChained = Date.now();
  546. await this.resolver.resolveChainedCallsViaConformance();
  547. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] chainedConformance: ${Date.now() - tChained}ms`);
  548. // Same lifecycle for `this.<member>` callback registrations whose
  549. // member is inherited from a supertype (#808).
  550. const tDeferred = Date.now();
  551. await this.resolver.resolveDeferredThisMemberRefs();
  552. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] deferredThisMember: ${Date.now() - tDeferred}ms`);
  553. }
  554. // Refresh planner stats + checkpoint the WAL after bulk writes.
  555. // Off-thread (worker connection): on a multi-GB index this is minutes
  556. // of IO, and inline it starved the #850 watchdog AFTER a fully
  557. // successful index. Never load-bearing for correctness.
  558. if (result.success && result.filesIndexed > 0) {
  559. const tMaint = Date.now();
  560. // Quiesce the valve first so its in-flight checkpoint and the
  561. // maintenance checkpoint don't contend for the checkpointer lock
  562. // (the loser would silently no-op and leave the WAL unfolded).
  563. if (walValve) { walValve.stop(); await walValve.drain(); }
  564. await this.db.runMaintenance();
  565. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] maintenance: ${Date.now() - tMaint}ms`);
  566. }
  567. // The orchestrator only sees extraction-phase counts; resolution and
  568. // synthesizer edges (often >50% of the graph on JVM repos) come later.
  569. // Recompute against the DB so the CLI summary reports the true totals.
  570. if (result.success && result.filesIndexed > 0) {
  571. const tCount = Date.now();
  572. const after = this.queries.getNodeAndEdgeCount();
  573. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] count-recompute: ${Date.now() - tCount}ms`);
  574. result.nodesCreated = after.nodes - before.nodes;
  575. result.edgesCreated = after.edges - before.edges;
  576. }
  577. // Stamp the index with the engine that built it, so `codegraph status`
  578. // and `codegraph upgrade` can recommend a re-index when the running
  579. // engine produces richer extraction than the one on disk. Only on a
  580. // real full index — a sync touches a subset, so it must NOT advance the
  581. // extraction stamp (the bulk would still be stale). See extraction-version.ts.
  582. if (result.success && result.filesIndexed > 0) {
  583. try {
  584. this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion);
  585. this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION));
  586. } catch { /* metadata is advisory — never fail an index over it */ }
  587. }
  588. // Reconcile the scan's ground truth against what the pipeline
  589. // accounted for. A shortfall means files were silently dropped
  590. // (observed in the wild: a run under heavy load came up 37 files
  591. // short with no error) — record it and tell the user, don't let the
  592. // index pass as complete.
  593. try {
  594. if (!result.success) {
  595. this.queries.setMetadata('index_state', 'failed');
  596. } else {
  597. const accounted = result.filesIndexed + result.filesSkipped + result.filesErrored;
  598. const discovered = result.filesDiscovered;
  599. const shortfall = discovered !== undefined ? discovered - accounted : 0;
  600. if (discovered !== undefined && shortfall > 0) {
  601. this.queries.setMetadata('index_state', 'partial');
  602. this.queries.setMetadata('index_files_discovered', String(discovered));
  603. this.queries.setMetadata('index_files_accounted', String(accounted));
  604. result.errors.push({
  605. message: `Index is missing ${shortfall} of ${discovered} discovered files (indexed ${result.filesIndexed}, skipped ${result.filesSkipped}, errored ${result.filesErrored}). The index is PARTIAL — re-run \`codegraph index\`.`,
  606. severity: 'warning',
  607. code: 'index_partial',
  608. });
  609. } else {
  610. this.queries.setMetadata('index_state', 'complete');
  611. if (discovered !== undefined) {
  612. this.queries.setMetadata('index_files_discovered', String(discovered));
  613. this.queries.setMetadata('index_files_accounted', String(accounted));
  614. }
  615. }
  616. }
  617. } catch { /* metadata is advisory — never fail an index over it */ }
  618. return result;
  619. } finally {
  620. // Restore the auto-checkpoint interval AFTER the fold-up above so the
  621. // next ordinary write doesn't inherit a giant inline checkpoint. On
  622. // the error path the WAL may still be large; correctness is unchanged
  623. // (SQLite replays the WAL on the next open) and the follow-up write
  624. // that folds it is the known cost of a failed run.
  625. if (walValve) { walValve.stop(); await walValve.drain(); }
  626. if (deferWal || restoreAutocheckpoint) {
  627. try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
  628. }
  629. if (fastInit) {
  630. // Back to the durable defaults; journal_mode=WAL folds the MEMORY
  631. // journal state into a normal WAL-mode database file.
  632. try {
  633. this.db.getDb().pragma('synchronous = NORMAL');
  634. this.db.getDb().pragma('journal_mode = WAL');
  635. } catch { /* connection may be closing */ }
  636. }
  637. this.fileLock.release();
  638. }
  639. });
  640. }
  641. /**
  642. * Index specific files
  643. *
  644. * Uses a mutex to prevent concurrent indexing operations.
  645. */
  646. async indexFiles(filePaths: string[]): Promise<IndexResult> {
  647. return this.indexMutex.withLock(async () => {
  648. try {
  649. this.fileLock.acquire();
  650. } catch {
  651. return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
  652. }
  653. try {
  654. return this.orchestrator.indexFiles(filePaths);
  655. } finally {
  656. this.fileLock.release();
  657. }
  658. });
  659. }
  660. /**
  661. * Sync with current file state (incremental update)
  662. *
  663. * Uses a mutex to prevent concurrent indexing operations.
  664. */
  665. async sync(options: IndexOptions = {}): Promise<SyncResult> {
  666. return this.indexMutex.withLock(async () => {
  667. try {
  668. this.fileLock.acquire();
  669. } catch {
  670. return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
  671. }
  672. // Defer WAL auto-checkpointing for the whole incremental run, exactly
  673. // as indexAll does for the bulk path (#1231): sync's store loop and its
  674. // resolution passes churn the same FTS + secondary-index hot pages, and
  675. // at the default 1000-page cadence the inline checkpoints re-write them
  676. // over and over — on HDD-class storage a 7-file sync took 2 minutes at
  677. // 0-2% CPU (#1248). The cost scales with the EXISTING database size,
  678. // not the change size, so small syncs on big indexes hurt most. The
  679. // valve bounds WAL growth off-thread; runMaintenance at the end does
  680. // the final fold-up before the interval is restored in the finally.
  681. // Same kill switch as indexAll: CODEGRAPH_NO_WAL_DEFER=1. Idle valve
  682. // cost is one timer, so watcher-frequency syncs stay cheap.
  683. const deferWal = process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
  684. let walValve: WalCheckpointValve | null = null;
  685. let priorAutocheckpoint = 1000;
  686. if (deferWal) {
  687. priorAutocheckpoint = this.db.getWalAutocheckpoint();
  688. this.db.setWalAutocheckpoint(0);
  689. walValve = new WalCheckpointValve(
  690. this.db,
  691. resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB, this.db.getDbFileSizeBytes()),
  692. undefined,
  693. options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
  694. );
  695. walValve.start();
  696. }
  697. try {
  698. // Captured BEFORE the sync runs: the sync's own incremental writes
  699. // populate vocab rows for the files it touches, so an end-of-sync
  700. // emptiness check would see "non-empty" and skip the backfill forever,
  701. // leaving every unchanged file's names unsegmented.
  702. const vocabWasEmpty = (() => {
  703. try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
  704. })();
  705. const result = await this.orchestrator.sync(options.onProgress);
  706. // Fold the store phase's WAL BEFORE the post-store reads below
  707. // (resolution reads on the main thread) — same rationale as
  708. // indexAll's fold between store and resolution.
  709. if (walValve) await walValve.foldNow();
  710. // Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on
  711. // every sync that touched files so edits to `app.module.ts` propagate
  712. // to controllers in unchanged files. The pass is idempotent and cheap
  713. // (regex over *.module.ts only).
  714. if (result.filesAdded > 0 || result.filesModified > 0) {
  715. this.resolver.runPostExtract();
  716. } else if (result.filesRemoved > 0) {
  717. // A pure-removal sync still resolves refs below — the deletion path
  718. // resurrects the removed file's incoming edges as pending refs
  719. // (#1240 removal case) and the orphan sweep consumes them. In a
  720. // long-lived process (daemon) the resolver's name caches were
  721. // warmed against the pre-removal graph; drop them so resolution
  722. // sees the post-removal state. (runPostExtract above clears caches
  723. // itself, so the changed-files branch is already covered.)
  724. this.resolver.clearCaches();
  725. }
  726. // Resolve references if files were updated
  727. const filesChanged = result.filesAdded > 0 || result.filesModified > 0;
  728. if (filesChanged) {
  729. if (result.changedFilePaths) {
  730. // Scope resolution to changed files (git fast path — bounded set)
  731. const tRefLoad = Date.now();
  732. const unresolvedRefs = this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths);
  733. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-ref-load: ${Date.now() - tRefLoad}ms (${unresolvedRefs.length} refs)`);
  734. options.onProgress?.({
  735. phase: 'resolving',
  736. current: 0,
  737. total: unresolvedRefs.length,
  738. });
  739. this.resolver.resolveAndPersist(unresolvedRefs, (current, total) => {
  740. options.onProgress?.({
  741. phase: 'resolving',
  742. current,
  743. total,
  744. });
  745. });
  746. // Retry previously-failed refs the changed files may now satisfy
  747. // (#1240). Scoped resolution above only re-resolves refs FROM the
  748. // changed files — but when a changed file gains an export/symbol,
  749. // refs in UNCHANGED files that failed against the old graph can
  750. // now resolve, and nothing else ever revisits them (their rows
  751. // were parked as status='failed' by an earlier completed pass).
  752. // Look them up by the symbol names the changed files now carry
  753. // and re-resolve just that set. On a sync where no failed ref
  754. // matches, this is one indexed lookup.
  755. const tRetry = Date.now();
  756. const retryable = this.queries.getRetryableFailedReferences(
  757. this.queries.getNodeNamesByFiles(result.changedFilePaths)
  758. );
  759. if (retryable.length > 0) {
  760. options.onProgress?.({
  761. phase: 'resolving',
  762. current: 0,
  763. total: retryable.length,
  764. });
  765. await this.resolver.resolveAndPersistListYielding(retryable);
  766. options.onProgress?.({
  767. phase: 'resolving',
  768. current: retryable.length,
  769. total: retryable.length,
  770. });
  771. }
  772. if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-failed-ref-retry: ${Date.now() - tRetry}ms (${retryable.length} refs)`);
  773. } else {
  774. // No git info — use batched resolution to avoid OOM
  775. const unresolvedCount = this.queries.getUnresolvedReferencesCount();
  776. options.onProgress?.({
  777. phase: 'resolving',
  778. current: 0,
  779. total: unresolvedCount,
  780. });
  781. await this.resolveReferencesBatched(
  782. (current, total) => {
  783. options.onProgress?.({
  784. phase: 'resolving',
  785. current,
  786. total,
  787. });
  788. },
  789. (done, totalPasses) => {
  790. options.onProgress?.({
  791. phase: 'linking',
  792. current: done,
  793. total: totalPasses,
  794. });
  795. }
  796. );
  797. }
  798. }
  799. // Orphan sweep (#1187). A resolution pass that dies mid-run — the #850
  800. // daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves
  801. // the refs it never reached in unresolved_refs, and the git-scoped fast
  802. // path above never revisits them (it reads only the changed files'
  803. // rows). Those files' call edges were then missing PERMANENTLY, with
  804. // nothing to see except a too-small blast radius, until a full
  805. // re-index. A completed pass takes every row it processed out of the
  806. // PENDING set (resolved rows are deleted, unresolvable ones parked as
  807. // status='failed' for the #1240 retry above), so any pending row now
  808. // is such an orphan — or a row from an older engine's scoped pass.
  809. // Grind them down with the batched resolver; this also makes a bare
  810. // `codegraph sync` the recovery command for a wedged index. On a
  811. // healthy index this is one COUNT query.
  812. const orphanCount = this.queries.getUnresolvedReferencesCount();
  813. if (orphanCount > 0) {
  814. options.onProgress?.({
  815. phase: 'resolving',
  816. current: 0,
  817. total: orphanCount,
  818. });
  819. await this.resolveReferencesBatched(
  820. (current, total) => {
  821. options.onProgress?.({
  822. phase: 'resolving',
  823. current,
  824. total,
  825. });
  826. },
  827. (done, totalPasses) => {
  828. options.onProgress?.({
  829. phase: 'linking',
  830. current: done,
  831. total: totalPasses,
  832. });
  833. }
  834. );
  835. }
  836. if (filesChanged || orphanCount > 0) {
  837. // Second pass: chained calls whose method lives on a supertype the
  838. // receiver conforms to (protocol-extension / inherited). Needs the
  839. // implements/extends edges built above (#750).
  840. await this.resolver.resolveChainedCallsViaConformance();
  841. // Same lifecycle for `this.<member>` callback registrations whose
  842. // member is inherited from a supertype (#808).
  843. await this.resolver.resolveDeferredThisMemberRefs();
  844. }
  845. // Refresh planner stats + checkpoint the WAL after bulk writes.
  846. // Off-thread — see indexAll's call site.
  847. if (filesChanged || result.filesRemoved > 0 || orphanCount > 0) {
  848. await this.db.runMaintenance();
  849. }
  850. // Heal the segment vocabulary on indexes built before the table
  851. // existed (upgrade path): incremental writes above only cover changed
  852. // files, so a vocab that was empty when this sync STARTED means the
  853. // bulk was never segmented — backfill it (INSERT OR IGNORE, so the
  854. // rows the sync just wrote are fine). Batched + yielding — sync can
  855. // run on the daemon's liveness-watchdog thread (#850/#1091).
  856. try {
  857. if (vocabWasEmpty && this.queries.getNodeAndEdgeCount().nodes > 0) {
  858. await this.rebuildNameSegmentVocab();
  859. }
  860. } catch { /* vocab is advisory — never fail a sync over it */ }
  861. return result;
  862. } finally {
  863. // Mirror indexAll's teardown: stop the valve, then restore the
  864. // auto-checkpoint interval (runMaintenance above already folded the
  865. // WAL on the success path; on the error path SQLite replays it on
  866. // the next open).
  867. if (walValve) { walValve.stop(); await walValve.drain(); }
  868. if (deferWal) {
  869. try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
  870. }
  871. this.fileLock.release();
  872. }
  873. });
  874. }
  875. /**
  876. * Check if an indexing operation is currently in progress
  877. */
  878. isIndexing(): boolean {
  879. return this.indexMutex.isLocked();
  880. }
  881. // ===========================================================================
  882. // File Watching
  883. // ===========================================================================
  884. /**
  885. * Start watching for file changes and auto-syncing.
  886. *
  887. * Uses native OS file events (FSEvents on macOS, inotify on Linux 19+,
  888. * ReadDirectoryChangesW on Windows) with debouncing to avoid thrashing.
  889. *
  890. * @param options - Watch options (debounce delay, callbacks)
  891. * @returns true if watching started successfully
  892. */
  893. watch(options: WatchOptions = {}): boolean {
  894. if (this.watcher?.isActive()) return true;
  895. this.watcher = new FileWatcher(
  896. this.projectRoot,
  897. async () => {
  898. const result = await this.sync();
  899. // sync() returns this exact zero-shape iff it failed to acquire the
  900. // file lock (a real empty sync always has filesChecked > 0 because
  901. // scanDirectory ran). Surface that to the watcher as a typed error
  902. // so it keeps pendingFiles + reschedules instead of clearing them
  903. // (#449).
  904. if (result.filesChecked === 0 && result.durationMs === 0) {
  905. throw new LockUnavailableError();
  906. }
  907. const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved;
  908. return { filesChanged, durationMs: result.durationMs };
  909. },
  910. options
  911. );
  912. return this.watcher.start();
  913. }
  914. /**
  915. * Stop watching for file changes.
  916. */
  917. unwatch(): void {
  918. if (this.watcher) {
  919. this.watcher.stop();
  920. this.watcher = null;
  921. }
  922. }
  923. /**
  924. * Check if the file watcher is active.
  925. */
  926. isWatching(): boolean {
  927. return this.watcher?.isActive() ?? false;
  928. }
  929. /**
  930. * True once live watching has permanently degraded (OS watch-resource
  931. * exhaustion, or a write lock held past the retry budget) and auto-sync is
  932. * disabled until the next {@link watch} call. Distinct from `!isWatching()`:
  933. * a stopped/never-started watcher is inactive but NOT degraded. MCP tools use
  934. * this to surface a whole-index "results may be stale" notice, since
  935. * `getPendingFiles()` goes empty once watching stops (#876).
  936. */
  937. isWatcherDegraded(): boolean {
  938. return this.watcher?.isDegraded() ?? false;
  939. }
  940. /** The reason live watching degraded, or null if it is healthy (#876). */
  941. getWatcherDegradedReason(): string | null {
  942. return this.watcher?.getDegradedReason() ?? null;
  943. }
  944. /**
  945. * Files seen by the file watcher since the last successful sync —
  946. * the per-file "stale" signal MCP tools attach to responses so an agent
  947. * can fall back to {@link Read} for just the affected file without
  948. * waiting for a debounced sync to complete (issue #403).
  949. *
  950. * Returns an empty list when the watcher isn't active, or no events have
  951. * arrived. Each entry includes `firstSeenMs` and `lastSeenMs` (wall-clock
  952. * `Date.now()` values) so callers can render "edited Nms ago", plus an
  953. * `indexing` flag indicating whether the in-flight sync (if any) will
  954. * absorb that file.
  955. */
  956. getPendingFiles(): PendingFile[] {
  957. return this.watcher?.getPendingFiles() ?? [];
  958. }
  959. /**
  960. * Resolves once the file watcher has installed its watch set. Useful for
  961. * tests that need a deterministic boundary before asserting on
  962. * `getPendingFiles()`. Resolves immediately when no watcher is active.
  963. */
  964. waitUntilWatcherReady(timeoutMs?: number): Promise<void> {
  965. return this.watcher ? this.watcher.waitUntilReady(timeoutMs) : Promise.resolve();
  966. }
  967. /**
  968. * Get files that have changed since last index
  969. */
  970. getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
  971. return this.orchestrator.getChangedFiles();
  972. }
  973. /**
  974. * Most recent index timestamp (ms since epoch) across all tracked files, or
  975. * null when nothing is indexed yet. Lets library consumers check index
  976. * freshness without shelling out to `codegraph status --json`. (#329)
  977. */
  978. getLastIndexedAt(): number | null {
  979. return this.queries.getLastIndexedAt();
  980. }
  981. /**
  982. * Completeness of the last full index run. `'complete'` is the only good
  983. * state. `'indexing'` after the fact means a run was killed mid-index (OOM,
  984. * SIGKILL, liveness watchdog) and the on-disk index is truncated;
  985. * `'partial'` means the run finished but silently dropped files
  986. * (discovered > indexed+skipped+errored); `'failed'` means it reported
  987. * failure. `null` = index predates this marker. Surfaced by
  988. * `codegraph status`.
  989. */
  990. getIndexState(): 'indexing' | 'complete' | 'partial' | 'failed' | null {
  991. const raw = this.queries.getMetadata('index_state');
  992. return raw === 'indexing' || raw === 'complete' || raw === 'partial' || raw === 'failed'
  993. ? raw
  994. : null;
  995. }
  996. /**
  997. * Which engine built the current index: the package version + extraction
  998. * version stamped at the last full `indexAll`. Either field is null for an
  999. * index built before stamping existed (treated as stale). See
  1000. * `extraction-version.ts` and `isIndexStale()`.
  1001. */
  1002. getIndexBuildInfo(): { version: string | null; extractionVersion: number | null } {
  1003. const version = this.queries.getMetadata('indexed_with_version');
  1004. const ev = this.queries.getMetadata('indexed_with_extraction_version');
  1005. const parsed = ev != null ? parseInt(ev, 10) : NaN;
  1006. return { version, extractionVersion: Number.isFinite(parsed) ? parsed : null };
  1007. }
  1008. /**
  1009. * True when the on-disk index was built by an engine whose extraction is
  1010. * older than the one now running — i.e. a re-index would add data a migration
  1011. * can't backfill. False when there's no index yet (nothing to refresh) or the
  1012. * stamp is current. This is the signal behind `codegraph status`'s re-index
  1013. * hint and `codegraph upgrade`'s reminder.
  1014. */
  1015. isIndexStale(): boolean {
  1016. if (this.queries.getLastIndexedAt() == null) return false;
  1017. const { extractionVersion } = this.getIndexBuildInfo();
  1018. return extractionVersion == null || extractionVersion < EXTRACTION_VERSION;
  1019. }
  1020. /**
  1021. * Extract nodes and edges from source code (without storing)
  1022. */
  1023. extractFromSource(filePath: string, source: string): ExtractionResult {
  1024. return extractFromSource(filePath, source);
  1025. }
  1026. // ===========================================================================
  1027. // Reference Resolution
  1028. // ===========================================================================
  1029. /**
  1030. * Resolve unresolved references and create edges
  1031. *
  1032. * This method takes unresolved references from extraction and attempts
  1033. * to resolve them using multiple strategies:
  1034. * - Framework-specific patterns (React, Express, Laravel)
  1035. * - Import-based resolution
  1036. * - Name-based symbol matching
  1037. */
  1038. resolveReferences(onProgress?: (current: number, total: number) => void): ResolutionResult {
  1039. // Get all unresolved references from the database
  1040. const unresolvedRefs = this.queries.getUnresolvedReferences();
  1041. return this.resolver.resolveAndPersist(unresolvedRefs, onProgress);
  1042. }
  1043. /**
  1044. * Resolve references in batches to keep memory bounded on large codebases.
  1045. * Processes chunks of unresolved refs, persisting results after each batch.
  1046. */
  1047. async resolveReferencesBatched(
  1048. onProgress?: (current: number, total: number) => void,
  1049. onSynthesisProgress?: (done: number, total: number) => void,
  1050. // The WAL valve's writer-side backstop, threaded into the batch loop's
  1051. // pool-idle boundaries. Without it the valve's only lever during
  1052. // resolution is timer-driven passive checkpoints, which the pool's
  1053. // continuous reads keep perpetually partial — the WAL then accretes the
  1054. // whole phase's write volume (22GB on a 4.6GB DB at kernel scale).
  1055. backpressure?: () => Promise<void> | null
  1056. ): Promise<ResolutionResult> {
  1057. return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
  1058. dbPath: this.db.getPath(),
  1059. // Bulk-edge-load hooks: on big runs the resolver drops the non-unique
  1060. // edge indexes for the batch loop and recreates them before synthesis
  1061. // (which reads kind-keyed). Concurrent readers (a daemon serving this
  1062. // project mid-index) stay CORRECT during the window — target/kind reads
  1063. // just degrade to scans until the recreate.
  1064. bulkEdgeLoad: {
  1065. begin: () => this.db.beginBulkEdgeLoad(),
  1066. end: () => this.db.endBulkEdgeLoad(),
  1067. },
  1068. backpressure,
  1069. });
  1070. }
  1071. /**
  1072. * References extracted but never attempted by a resolution pass. Zero on a
  1073. * healthy index — a completed pass consumes every pending row (resolving it
  1074. * or parking it as failed, #1240). Non-zero at rest means a pass was
  1075. * interrupted mid-run (killed indexer, crash — #1187), so some files' call
  1076. * edges are missing; the next `sync` sweeps them.
  1077. */
  1078. getPendingReferenceCount(): number {
  1079. return this.queries.getUnresolvedReferencesCount();
  1080. }
  1081. /**
  1082. * Get detected frameworks in the project
  1083. */
  1084. getDetectedFrameworks(): string[] {
  1085. return this.resolver.getDetectedFrameworks();
  1086. }
  1087. /**
  1088. * Re-initialize the resolver (useful after adding new files)
  1089. */
  1090. reinitializeResolver(): void {
  1091. this.resolver.initialize();
  1092. }
  1093. // ===========================================================================
  1094. // Graph Statistics
  1095. // ===========================================================================
  1096. /**
  1097. * Get statistics about the knowledge graph
  1098. */
  1099. getStats(): GraphStats {
  1100. const stats = this.queries.getStats();
  1101. stats.dbSizeBytes = this.db.getSize();
  1102. return stats;
  1103. }
  1104. /**
  1105. * Active SQLite backend for this project's connection (`node-sqlite` — Node's
  1106. * built-in real-SQLite module). Surfaced via `codegraph status` and the
  1107. * `codegraph_status` MCP tool alongside the effective journal mode.
  1108. */
  1109. getBackend(): import('./db').SqliteBackend {
  1110. return this.db.getBackend();
  1111. }
  1112. /**
  1113. * The journal mode actually in effect ('wal', 'delete', …). 'wal' means
  1114. * readers never block on a concurrent writer; anything else means they can,
  1115. * which is the precondition for the "database is locked" failures in issue
  1116. * #238. Surfaced via `codegraph status` and the `codegraph_status` MCP tool.
  1117. */
  1118. getJournalMode(): string {
  1119. return this.db.getJournalMode();
  1120. }
  1121. // ===========================================================================
  1122. // Node Operations
  1123. // ===========================================================================
  1124. /**
  1125. * Get a node by ID
  1126. */
  1127. getNode(id: string): Node | null {
  1128. return this.queries.getNodeById(id);
  1129. }
  1130. /**
  1131. * Get all nodes in a file
  1132. */
  1133. getNodesInFile(filePath: string): Node[] {
  1134. return this.queries.getNodesByFile(filePath);
  1135. }
  1136. /**
  1137. * Get all nodes of a specific kind
  1138. */
  1139. getNodesByKind(kind: Node['kind']): Node[] {
  1140. return this.queries.getNodesByKind(kind);
  1141. }
  1142. /**
  1143. * Get ALL nodes with an exact name (direct index lookup, not FTS-ranked/capped).
  1144. * Used to enumerate every overload of a heavily-overloaded name so the specific
  1145. * definition the caller wants is never dropped below a search cut.
  1146. */
  1147. getNodesByName(name: string): Node[] {
  1148. return this.queries.getNodesByName(name);
  1149. }
  1150. /** Nodes whose name starts with `prefix` (index range scan, capped). */
  1151. getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
  1152. return this.queries.getNodesByNamePrefix(prefix, limit);
  1153. }
  1154. /**
  1155. * Nodes whose name CONTAINS `substring` (LIKE scan, ASCII-case-insensitive,
  1156. * shortest-first). The camel-infix lookup FTS can't do — `profileInfo`
  1157. * inside `getProfileInfoV2` is one FTS token (#1196).
  1158. */
  1159. getNodesByNameSubstring(
  1160. substring: string,
  1161. options: { kinds?: NodeKind[]; limit?: number; excludePrefix?: boolean } = {}
  1162. ): Node[] {
  1163. return this.queries
  1164. .findNodesByNameSubstring(substring, options)
  1165. .map((r) => r.node);
  1166. }
  1167. /**
  1168. * Search nodes by text
  1169. */
  1170. searchNodes(query: string, options?: SearchOptions): SearchResult[] {
  1171. return this.queries.searchNodes(query, options);
  1172. }
  1173. /**
  1174. * Graph-derived prompt matching for the front-load hook's MEDIUM tier:
  1175. * which indexed symbols do these prose words name? "state machine des
  1176. * commandes" → `OrderStateMachine`, in any human language whose technical
  1177. * nouns are Latin script — no keyword list involved.
  1178. *
  1179. * Precision comes from the repo's own naming statistics, not vocabulary:
  1180. * - CO-OCCURRENCE: ≥2 words that are segments of the SAME name ("state" +
  1181. * "machine" → OrderStateMachine) is strong evidence and always qualifies.
  1182. * - RARITY: a single matched word qualifies only when its segment is
  1183. * discriminative here (≤ {@link SEGMENT_RARITY_CEILING} distinct names) —
  1184. * "checkout" in a shop backend yes, "state" in a react app no.
  1185. * Every candidate is re-verified against `nodes` before being returned
  1186. * (vocab rows are proposals; deletions leave orphans by design), so a
  1187. * returned symbol is guaranteed to exist right now.
  1188. */
  1189. getSegmentMatches(words: string[], limit: number = 6): SegmentMatch[] {
  1190. if (words.length === 0) return [];
  1191. // Variant → original word (plural folding), for coverage accounting.
  1192. const variantToWord = new Map<string, string>();
  1193. for (const word of words) {
  1194. for (const variant of segmentLookupVariants(word)) {
  1195. if (!variantToWord.has(variant)) variantToWord.set(variant, word);
  1196. }
  1197. }
  1198. const variants = [...variantToWord.keys()];
  1199. // Tier A: co-occurrence. The SQL folds variants back to their original
  1200. // word (#1146), so minWords=2 means two distinct PROMPT WORDS — a name
  1201. // matching both `service` and `services` can't tie with (or crowd past
  1202. // the LIMIT) a genuine two-word match. The JS re-check below recomputes
  1203. // the fold from live segments as the honesty layer.
  1204. const variantPairs = [...variantToWord.entries()].map(([segment, word]) => ({ segment, word }));
  1205. const candidates: Array<{ name: string; matchedWords: Set<string> }> = [];
  1206. for (const hit of this.queries.getSegmentCoOccurrence(variantPairs, 2, 24)) {
  1207. const matched = this.wordsMatchingName(hit.name, variantToWord);
  1208. if (matched.size >= 2) candidates.push({ name: hit.name, matchedWords: matched });
  1209. }
  1210. // Tier B: single rare word. Only when co-occurrence found nothing — a
  1211. // co-occurring name is categorically stronger evidence — and under
  1212. // stricter rules, because one word is thin: the word must be ≥5 chars
  1213. // (measured FPs: "this", "typo"); the segment must appear in AT LEAST TWO
  1214. // names (a concept the codebase is about clusters across names —
  1215. // CheckoutService/CheckoutController — while a prose coincidence is a
  1216. // singleton: measured FP "deploy to PRODUCTION" → the one name
  1217. // matchesNonProductionDir); and the candidate name must have ≥2 segments
  1218. // (a bare common verb matching a bare function name — "write" → `write` —
  1219. // is prose coincidence, not the user naming a symbol).
  1220. if (candidates.length === 0) {
  1221. const singleWordVariants = variants.filter((v) => variantToWord.get(v)!.length >= 5);
  1222. const counts = this.queries.getSegmentNameCounts(singleWordVariants);
  1223. const rare = [...counts.entries()]
  1224. .filter(([, n]) => n >= 2 && n <= CodeGraph.SEGMENT_RARITY_CEILING)
  1225. .sort((a, b) => a[1] - b[1])
  1226. .slice(0, 2);
  1227. for (const [variant] of rare) {
  1228. const word = variantToWord.get(variant)!;
  1229. for (const name of this.queries.getNamesForSegment(variant, 12)) {
  1230. if (splitIdentifierSegments(name).length < 2) continue;
  1231. candidates.push({ name, matchedWords: new Set([word]) });
  1232. }
  1233. }
  1234. }
  1235. // Verify against nodes (the honesty gate) and pick a representative
  1236. // definition per name. A name whose only nodes are file/import kind has
  1237. // no real definition to point at — surfacing the import statement instead
  1238. // reads as a matched symbol but isn't one (#1144) — so it's skipped, the
  1239. // same way an orphaned vocab row is. (Import names no longer enter the
  1240. // vocab at write time, but rows written before that exclusion persist
  1241. // until the next full index.)
  1242. const out: SegmentMatch[] = [];
  1243. const seen = new Set<string>();
  1244. candidates.sort((a, b) => b.matchedWords.size - a.matchedWords.size || a.name.length - b.name.length);
  1245. for (const candidate of candidates) {
  1246. if (out.length >= limit) break;
  1247. if (seen.has(candidate.name)) continue;
  1248. seen.add(candidate.name);
  1249. const nodes = this.queries.getNodesByName(candidate.name);
  1250. if (nodes.length === 0) continue; // orphaned vocab row — name no longer exists
  1251. const rep = nodes.find((n) => n.kind !== 'file' && n.kind !== 'import');
  1252. if (!rep) continue; // no real definition — don't surface an import/file as one
  1253. out.push({
  1254. name: candidate.name,
  1255. kind: rep.kind,
  1256. filePath: rep.filePath,
  1257. startLine: rep.startLine ?? 0,
  1258. matchedWords: [...candidate.matchedWords].sort(),
  1259. });
  1260. }
  1261. return out;
  1262. }
  1263. /** A single word ("state") can match hundreds of names in a big repo — that
  1264. * is noise, not signal. Ceiling for the single-word tier; co-occurrence is
  1265. * exempt because two words on one name is already discriminative. */
  1266. private static readonly SEGMENT_RARITY_CEILING = 25;
  1267. /** Which of the prompt's original words match `name`'s segments (via
  1268. * variants). Segments are recomputed in JS — a name-keyed vocab lookup
  1269. * would scan the (segment, name) primary key. */
  1270. private wordsMatchingName(name: string, variantToWord: Map<string, string>): Set<string> {
  1271. const segments = new Set(splitIdentifierSegments(name));
  1272. const matched = new Set<string>();
  1273. for (const [variant, word] of variantToWord) {
  1274. if (segments.has(variant)) matched.add(word);
  1275. }
  1276. return matched;
  1277. }
  1278. /**
  1279. * One-shot upgrade heal for callers that open the graph WITHOUT syncing —
  1280. * concretely the prompt hook, whose MEDIUM tier reads the segment
  1281. * vocabulary: a database migrated from before the vocab table existed
  1282. * starts with it empty, and the only other backfill lives inside `sync()`,
  1283. * which such callers never run (#1142). Returns true when the vocab is
  1284. * usable (already populated — the overwhelmingly common one-SELECT case —
  1285. * or healed here); false when it isn't (empty graph, or another process
  1286. * holds the index lock — that process's own sync heals it).
  1287. */
  1288. async healSegmentVocabIfEmpty(): Promise<boolean> {
  1289. const empty = (() => {
  1290. try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
  1291. })();
  1292. if (!empty) return true;
  1293. if (this.queries.getNodeAndEdgeCount().nodes === 0) return false;
  1294. return this.indexMutex.withLock(async () => {
  1295. try {
  1296. this.fileLock.acquire();
  1297. } catch {
  1298. return false; // an index/sync is running — it backfills the vocab itself
  1299. }
  1300. try {
  1301. if (!this.queries.isNameSegmentVocabEmpty()) return true; // raced: healed meanwhile
  1302. await this.rebuildNameSegmentVocab();
  1303. return true;
  1304. } finally {
  1305. this.fileLock.release();
  1306. }
  1307. });
  1308. }
  1309. /**
  1310. * Rebuild the segment vocabulary from the current graph, batched and
  1311. * yielding — the upgrade-heal path for indexes built before the vocab table
  1312. * existed. Runs inside the index mutex/lock (sync and
  1313. * healSegmentVocabIfEmpty hold them).
  1314. */
  1315. private async rebuildNameSegmentVocab(): Promise<void> {
  1316. const maybeYield = createYielder();
  1317. const BATCH = 2000;
  1318. for (let offset = 0; ; offset += BATCH) {
  1319. const names = this.queries.getDistinctNodeNames(BATCH, offset);
  1320. if (names.length === 0) break;
  1321. this.queries.insertNameSegmentsBatch(names);
  1322. await maybeYield();
  1323. }
  1324. }
  1325. /**
  1326. * Normalized project-name tokens (go.mod / package.json / repo dir) used to
  1327. * down-weight the non-discriminative project name in search ranking (#720).
  1328. * Exposed so explore can exclude it from the PascalCase type-disambiguation
  1329. * bias, which would otherwise pull overloaded tokens toward whichever stack
  1330. * embeds the project name.
  1331. */
  1332. getProjectNameTokens(): Set<string> {
  1333. return this.queries.getProjectNameTokens();
  1334. }
  1335. /**
  1336. * Find the project's "primary route file" — the file with the densest
  1337. * concentration of framework-emitted `route` nodes (≥3 routes, ≥30%
  1338. * of all non-test routes). Used to inline the routing config in
  1339. * `codegraph_explore` responses on small realworld template repos
  1340. * (rails-realworld, laravel-realworld, drupal-admintoolbar, …) where
  1341. * Glob+Read of `routes.rb`/`urls.py`/etc. otherwise beats codegraph.
  1342. */
  1343. getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null {
  1344. return this.queries.getTopRouteFile();
  1345. }
  1346. /**
  1347. * Build a URL → handler routing manifest from the index. Each entry
  1348. * pairs a route node (URL + method) with its handler function/method
  1349. * via the `references` edge that framework resolvers emit. Returns
  1350. * null when fewer than 3 valid (non-test) routes exist.
  1351. */
  1352. getRoutingManifest(limit?: number): {
  1353. entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
  1354. topHandlerFile: string | null;
  1355. topHandlerFileCount: number;
  1356. totalRoutes: number;
  1357. } | null {
  1358. return this.queries.getRoutingManifest(limit);
  1359. }
  1360. // ===========================================================================
  1361. // Edge Operations
  1362. // ===========================================================================
  1363. /**
  1364. * Get outgoing edges from a node
  1365. */
  1366. getOutgoingEdges(nodeId: string): Edge[] {
  1367. return this.queries.getOutgoingEdges(nodeId);
  1368. }
  1369. /**
  1370. * Get incoming edges to a node
  1371. */
  1372. getIncomingEdges(nodeId: string): Edge[] {
  1373. return this.queries.getIncomingEdges(nodeId);
  1374. }
  1375. // ===========================================================================
  1376. // File Operations
  1377. // ===========================================================================
  1378. /**
  1379. * Get a file record by path
  1380. */
  1381. getFile(filePath: string): FileRecord | null {
  1382. return this.queries.getFileByPath(filePath);
  1383. }
  1384. /**
  1385. * Get all tracked files
  1386. */
  1387. getFiles(): FileRecord[] {
  1388. return this.queries.getAllFiles();
  1389. }
  1390. // ===========================================================================
  1391. // Graph Query Methods
  1392. // ===========================================================================
  1393. /**
  1394. * Get the context for a node (ancestors, children, references)
  1395. *
  1396. * Returns comprehensive context about a node including its containment
  1397. * hierarchy, children, incoming/outgoing references, type information,
  1398. * and relevant imports.
  1399. *
  1400. * @param nodeId - ID of the focal node
  1401. * @returns Context object with all related information
  1402. */
  1403. getContext(nodeId: string): Context {
  1404. return this.graphManager.getContext(nodeId);
  1405. }
  1406. /**
  1407. * Traverse the graph from a starting node
  1408. *
  1409. * Uses breadth-first search by default. Supports filtering by edge types,
  1410. * node types, and traversal direction.
  1411. *
  1412. * @param startId - Starting node ID
  1413. * @param options - Traversal options
  1414. * @returns Subgraph containing traversed nodes and edges
  1415. */
  1416. traverse(startId: string, options?: TraversalOptions): Subgraph {
  1417. return this.traverser.traverseBFS(startId, options);
  1418. }
  1419. /**
  1420. * Get the call graph for a function
  1421. *
  1422. * Returns both callers (functions that call this function) and
  1423. * callees (functions called by this function) up to the specified depth.
  1424. *
  1425. * @param nodeId - ID of the function/method node
  1426. * @param depth - Maximum depth in each direction (default: 2)
  1427. * @returns Subgraph containing the call graph
  1428. */
  1429. getCallGraph(nodeId: string, depth: number = 2): Subgraph {
  1430. return this.traverser.getCallGraph(nodeId, depth);
  1431. }
  1432. /**
  1433. * Get the type hierarchy for a class/interface
  1434. *
  1435. * Returns both ancestors (types this extends/implements) and
  1436. * descendants (types that extend/implement this).
  1437. *
  1438. * @param nodeId - ID of the class/interface node
  1439. * @returns Subgraph containing the type hierarchy
  1440. */
  1441. getTypeHierarchy(nodeId: string): Subgraph {
  1442. return this.traverser.getTypeHierarchy(nodeId);
  1443. }
  1444. /**
  1445. * Find all usages of a symbol
  1446. *
  1447. * Returns all nodes that reference the specified symbol through
  1448. * any edge type (calls, references, type_of, etc.).
  1449. *
  1450. * @param nodeId - ID of the symbol node
  1451. * @returns Array of nodes and edges that reference this symbol
  1452. */
  1453. findUsages(nodeId: string): Array<{ node: Node; edge: Edge }> {
  1454. return this.traverser.findUsages(nodeId);
  1455. }
  1456. /**
  1457. * Get callers of a function/method
  1458. *
  1459. * @param nodeId - ID of the function/method node
  1460. * @param maxDepth - Maximum depth to traverse (default: 1)
  1461. * @returns Array of nodes that call this function
  1462. */
  1463. getCallers(nodeId: string, maxDepth: number = 1): Array<{ node: Node; edge: Edge }> {
  1464. return this.traverser.getCallers(nodeId, maxDepth);
  1465. }
  1466. /**
  1467. * Get callees of a function/method
  1468. *
  1469. * @param nodeId - ID of the function/method node
  1470. * @param maxDepth - Maximum depth to traverse (default: 1)
  1471. * @returns Array of nodes called by this function
  1472. */
  1473. getCallees(nodeId: string, maxDepth: number = 1): Array<{ node: Node; edge: Edge }> {
  1474. return this.traverser.getCallees(nodeId, maxDepth);
  1475. }
  1476. /**
  1477. * Calculate the impact radius of a node
  1478. *
  1479. * Returns all nodes that could be affected by changes to this node.
  1480. *
  1481. * @param nodeId - ID of the node
  1482. * @param maxDepth - Maximum depth to traverse (default: 3)
  1483. * @returns Subgraph containing potentially impacted nodes
  1484. */
  1485. getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph {
  1486. return this.traverser.getImpactRadius(nodeId, maxDepth);
  1487. }
  1488. /**
  1489. * Find the shortest path between two nodes
  1490. *
  1491. * @param fromId - Starting node ID
  1492. * @param toId - Target node ID
  1493. * @param edgeKinds - Edge types to consider (all if empty)
  1494. * @returns Array of nodes and edges forming the path, or null if no path exists
  1495. */
  1496. findPath(
  1497. fromId: string,
  1498. toId: string,
  1499. edgeKinds?: Edge['kind'][]
  1500. ): Array<{ node: Node; edge: Edge | null }> | null {
  1501. return this.traverser.findPath(fromId, toId, edgeKinds);
  1502. }
  1503. /**
  1504. * Get ancestors of a node in the containment hierarchy
  1505. *
  1506. * @param nodeId - ID of the node
  1507. * @returns Array of ancestor nodes from immediate parent to root
  1508. */
  1509. getAncestors(nodeId: string): Node[] {
  1510. return this.traverser.getAncestors(nodeId);
  1511. }
  1512. /**
  1513. * Get immediate children of a node
  1514. *
  1515. * @param nodeId - ID of the node
  1516. * @returns Array of child nodes
  1517. */
  1518. getChildren(nodeId: string): Node[] {
  1519. return this.traverser.getChildren(nodeId);
  1520. }
  1521. /**
  1522. * Get dependencies of a file
  1523. *
  1524. * @param filePath - Path to the file
  1525. * @returns Array of file paths this file depends on
  1526. */
  1527. getFileDependencies(filePath: string): string[] {
  1528. return this.graphManager.getFileDependencies(filePath);
  1529. }
  1530. /**
  1531. * Get dependents of a file
  1532. *
  1533. * @param filePath - Path to the file
  1534. * @returns Array of file paths that depend on this file
  1535. */
  1536. getFileDependents(filePath: string): string[] {
  1537. return this.graphManager.getFileDependents(filePath);
  1538. }
  1539. /**
  1540. * Find circular dependencies in the codebase
  1541. *
  1542. * @returns Array of cycles, each cycle is an array of file paths
  1543. */
  1544. findCircularDependencies(): string[][] {
  1545. return this.graphManager.findCircularDependencies();
  1546. }
  1547. /**
  1548. * Find dead code (unreferenced symbols)
  1549. *
  1550. * @param kinds - Node kinds to check (default: functions, methods, classes)
  1551. * @returns Array of unreferenced nodes
  1552. */
  1553. findDeadCode(kinds?: Node['kind'][]): Node[] {
  1554. return this.graphManager.findDeadCode(kinds);
  1555. }
  1556. /**
  1557. * Get complexity metrics for a node
  1558. *
  1559. * @param nodeId - ID of the node
  1560. * @returns Object containing various complexity metrics
  1561. */
  1562. getNodeMetrics(nodeId: string): {
  1563. incomingEdgeCount: number;
  1564. outgoingEdgeCount: number;
  1565. callCount: number;
  1566. callerCount: number;
  1567. childCount: number;
  1568. depth: number;
  1569. } {
  1570. return this.graphManager.getNodeMetrics(nodeId);
  1571. }
  1572. // ===========================================================================
  1573. // Context Building
  1574. // ===========================================================================
  1575. /**
  1576. * Get the source code for a node
  1577. *
  1578. * Reads the file and extracts the code between startLine and endLine.
  1579. *
  1580. * @param nodeId - ID of the node
  1581. * @returns Code string or null if not found
  1582. */
  1583. async getCode(nodeId: string): Promise<string | null> {
  1584. return this.contextBuilder.getCode(nodeId);
  1585. }
  1586. /**
  1587. * Find relevant subgraph for a query
  1588. *
  1589. * Combines semantic search with graph traversal to find the most
  1590. * relevant nodes and their relationships for a given query.
  1591. *
  1592. * @param query - Natural language query describing the task
  1593. * @param options - Search and traversal options
  1594. * @returns Subgraph of relevant nodes and edges
  1595. */
  1596. async findRelevantContext(
  1597. query: string,
  1598. options?: FindRelevantContextOptions
  1599. ): Promise<Subgraph> {
  1600. return this.contextBuilder.findRelevantContext(query, options);
  1601. }
  1602. /**
  1603. * Build context for a task
  1604. *
  1605. * Creates comprehensive context by:
  1606. * 1. Running FTS search to find entry points
  1607. * 2. Expanding the graph around entry points
  1608. * 3. Extracting code blocks for key nodes
  1609. * 4. Formatting output for Claude
  1610. *
  1611. * @param input - Task description (string or {title, description})
  1612. * @param options - Build options (maxNodes, includeCode, format, etc.)
  1613. * @returns TaskContext object or formatted string (markdown/JSON)
  1614. */
  1615. async buildContext(
  1616. input: TaskInput,
  1617. options?: BuildContextOptions
  1618. ): Promise<TaskContext | string> {
  1619. return this.contextBuilder.buildContext(input, options);
  1620. }
  1621. // ===========================================================================
  1622. // Database Management
  1623. // ===========================================================================
  1624. /**
  1625. * Optimize the database (vacuum and analyze)
  1626. */
  1627. optimize(): void {
  1628. this.db.optimize();
  1629. }
  1630. /**
  1631. * Clear all data from the graph
  1632. */
  1633. clear(): void {
  1634. this.queries.clear();
  1635. }
  1636. /**
  1637. * Alias for close() for backwards compatibility.
  1638. * @deprecated Use close() instead
  1639. */
  1640. destroy(): void {
  1641. this.close();
  1642. }
  1643. /**
  1644. * Completely remove CodeGraph from the project.
  1645. * This closes the database and deletes the .CodeGraph directory.
  1646. *
  1647. * WARNING: This permanently deletes all CodeGraph data for the project.
  1648. */
  1649. uninitialize(): void {
  1650. this.close();
  1651. removeDirectory(this.projectRoot);
  1652. }
  1653. }
  1654. // Default export
  1655. export default CodeGraph;