index.ts 56 KB

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