* CodeGraph
*
* A local-first code intelligence system that builds a semantic
* knowledge graph from any codebase.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
Node,
NodeKind,
Edge,
FileRecord,
ExtractionResult,
Subgraph,
TraversalOptions,
SearchOptions,
SearchResult,
SegmentMatch,
Context,
GraphStats,
TaskInput,
TaskContext,
BuildContextOptions,
FindRelevantContextOptions,
UnresolvedReference,
IndexHealth,
Language,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve';
import { QueryBuilder, type DeprioritizedPathMatcher } from './db/queries';
import { importPathKeys, moduleReferenceKeys } from './db/reference-tail';
import {
isInitialized,
createDirectory,
removeDirectory,
validateDirectory,
} from './directory';
import {
ExtractionOrchestrator,
IndexProgress,
IndexResult,
SyncResult,
extractFromSource,
initGrammars,
} from './extraction';
import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars';
import {
ReferenceResolver,
changedRoutes,
createResolver,
ResolutionResult,
} from './resolution';
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
import { createImportReader, reopenNameMatchedImports, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { GraphTraverser, GraphQueryManager } from './graph';
import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
import { EXTRACTION_VERSION } from './extraction/extraction-version';
import { getCodeGraphDir } from './directory';
import { deriveProjectNameTokens } from './search/query-utils';
import ignore from 'ignore';
import { loadDeprioritizePatterns } from './project-config';
import { CodeGraphPackageVersion } from './mcp/version';
import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments';
import { createYielder } from './resolution/cooperative-yield';
import { ESM_IMPORT_LANGUAGES } from './resolution/import-resolver';
import { minRefsForPool } from './resolution/resolver-pool';
export * from './types';
export { getDatabasePath, DatabaseConnection } from './db';
export { QueryBuilder } from './db/queries';
export {
getCodeGraphDir,
isInitialized,
findNearestCodeGraphRoot,
CODEGRAPH_DIR,
} from './directory';
export { IndexProgress, IndexResult, SyncResult } from './extraction';
export { detectLanguage, isLanguageSupported, isGrammarLoaded, getSupportedLanguages, initGrammars, loadGrammarsForLanguages, loadAllGrammars } from './extraction';
export { ResolutionResult } from './resolution';
export {
CodeGraphError,
FileError,
ParseError,
DatabaseError,
SearchError,
VectorError,
ConfigError,
Logger,
setLogger,
getLogger,
silentLogger,
defaultLogger,
} from './errors';
export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils';
export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync';
export { MCPServer } from './mcp';
* Options for initializing a new CodeGraph project
*/
export interface InitOptions {
index?: boolean;
onProgress?: (progress: IndexProgress) => void;
}
* Options for opening an existing CodeGraph project
*/
export interface OpenOptions {
sync?: boolean;
readOnly?: boolean;
}
* Options for indexing
*/
export interface IndexOptions {
onProgress?: (progress: IndexProgress) => void;
signal?: AbortSignal;
verbose?: boolean;
paths?: string[];
}
* Main CodeGraph class
*
* Provides the primary interface for interacting with the code knowledge graph.
*/
const PYTHON_ROOTS_METADATA_KEY = 'python_root_fingerprint';
const WORKSPACE_METADATA_KEY = 'js_workspace_fingerprint';
const FRAMEWORKS_AT_INDEX_KEY = 'frameworks_at_index';
* Metadata key: `{ added, removed }` — the frameworks a sync detected since the
* last full index, against {@link FRAMEWORKS_AT_INDEX_KEY}; '' when none.
*/
const FRAMEWORKS_SINCE_INDEX_KEY = 'frameworks_since_index';
function parseNameList(value: string | null): string[] | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((n) => typeof n === 'string') ? parsed : null;
} catch {
return null;
}
}
export class CodeGraph {
private db: DatabaseConnection;
private queries: QueryBuilder;
private projectRoot: string;
private orchestrator!: ExtractionOrchestrator;
private resolver!: ReferenceResolver;
private graphManager!: GraphQueryManager;
private traverser!: GraphTraverser;
private contextBuilder!: ContextBuilder;
private indexMutex = new Mutex();
private fileLock: FileLock;
private watcher: FileWatcher | null = null;
private constructor(
db: DatabaseConnection,
queries: QueryBuilder,
projectRoot: string
) {
this.db = db;
this.queries = queries;
this.projectRoot = projectRoot;
this.fileLock = new FileLock(
path.join(getCodeGraphDir(projectRoot), 'codegraph.lock')
);
this.wireLayers();
}
* (Re)build the query/extraction/graph layers over the current `this.queries`
* (which wraps `this.db`). Factored out of the constructor so `reopenIfReplaced`
* can rebuild them against a fresh connection without duplicating the wiring.
* The path-based `fileLock` is independent of the DB handle, so it stays put.
*/
private wireLayers(): void {
try {
this.queries.setProjectNameTokens(deriveProjectNameTokens(this.projectRoot));
} catch {
}
let cachedPatterns: string[] | undefined;
let cachedMatcher: ReturnType<typeof ignore> | undefined;
const currentMatcher = (): ReturnType<typeof ignore> | null => {
const patterns = loadDeprioritizePatterns(this.projectRoot);
if (patterns.length === 0) return null;
if (patterns !== cachedPatterns) {
cachedPatterns = patterns;
cachedMatcher = ignore().add(patterns);
}
return cachedMatcher!;
};
const matches = (matcher: ReturnType<typeof ignore> | null, filePath: string): boolean => {
if (!matcher) return false;
const rel = path.isAbsolute(filePath)
? path.relative(this.projectRoot, filePath)
: filePath;
if (!rel || rel.startsWith('..')) return false;
return matcher.ignores(rel.split(path.sep).join('/'));
};
const deprioritized: DeprioritizedPathMatcher = (filePath: string): boolean => {
try {
return matches(currentMatcher(), filePath);
} catch {
return false;
}
};
deprioritized.snapshot = () => {
let matcher: ReturnType<typeof ignore> | null = null;
try { matcher = currentMatcher(); } catch { matcher = null; }
return (filePath: string): boolean => {
try {
return matches(matcher, filePath);
} catch {
return false;
}
};
};
this.queries.setDeprioritizedPathMatcher(deprioritized);
this.orchestrator = new ExtractionOrchestrator(this.projectRoot, this.queries);
this.resolver = createResolver(this.projectRoot, this.queries);
this.graphManager = new GraphQueryManager(this.queries);
this.traverser = new GraphTraverser(this.queries);
this.contextBuilder = createContextBuilder(
this.projectRoot,
this.queries,
this.traverser
);
}
* Set when this instance followed a database replaced on disk (#1902): the
* next sync that can run reconciles the whole tree, because whatever the old
* handle absorbed since the rebuild never reached the new file.
*/
private pendingFullReconcile = false;
private static readonly RECREATE_GRACE_MS = 120_000;
private isFreshlyRecreated(): boolean {
try {
return Date.now() - fs.statSync(getDatabasePath(this.projectRoot)).mtimeMs < CodeGraph.RECREATE_GRACE_MS;
} catch {
return false;
}
}
* Heal a stale database handle in place. If `.codegraph/` was removed and
* recreated at the SAME path while this instance held the DB open — a git
* worktree removed and re-added, or `rm -rf .codegraph` + `codegraph init` —
* our open fd points at the now-unlinked inode and can never see the new
* index, so every query returns the pre-removal snapshot until the process
* restarts (#925). When that's detected, open the live file at the same path,
* rebuild the query layers, and swap them IN PLACE, so every holder of this
* instance (the MCP daemon's default project, cached projectPath connections)
* heals without a restart. Returns true iff it reopened.
*
* POSIX-only in practice: `isReplacedOnDisk` never fires on Windows (an open
* file can't be unlinked there, and st_ino is unreliable).
*
* Refuses (returns false) while an index/sync holds the index mutex: closing
* the handle that run is writing through would break it mid-flight. `sync()`
* performs the same check itself once it holds the mutex (#1902), so the
* replaced file is still picked up — by that sync, or by the caller's retry.
*/
reopenIfReplaced(): boolean {
if (this.indexMutex.isLocked()) return false;
return this.reopenReplacedDatabase();
}
private reopenReplacedDatabase(): boolean {
if (!this.db.isReplacedOnDisk()) return false;
const dbPath = this.db.getPath();
const fresh = DatabaseConnection.open(dbPath, { readOnly: this.db.readOnly });
const stale = this.db;
this.db = fresh;
this.queries = new QueryBuilder(fresh.getDb());
this.wireLayers();
this.pendingFullReconcile = true;
try { stale.close(); } catch { }
return true;
}
* Initialize a new CodeGraph project
*
* Creates the .CodeGraph directory, database, and configuration.
*
* @param projectRoot - Path to the project root directory
* @param options - Initialization options
* @returns A new CodeGraph instance
*/
static async init(projectRoot: string, options: InitOptions = {}): Promise<CodeGraph> {
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);
if (isInitialized(resolvedRoot)) {
throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
}
createDirectory(resolvedRoot);
const dbPath = getDatabasePath(resolvedRoot);
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
const instance = new CodeGraph(db, queries, resolvedRoot);
if (options.index) {
await instance.indexAll({ onProgress: options.onProgress });
}
return instance;
}
* Initialize synchronously (without indexing)
*/
static initSync(projectRoot: string): CodeGraph {
const resolvedRoot = path.resolve(projectRoot);
if (isInitialized(resolvedRoot)) {
throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
}
createDirectory(resolvedRoot);
const dbPath = getDatabasePath(resolvedRoot);
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
return new CodeGraph(db, queries, resolvedRoot);
}
* Open an existing CodeGraph project
*
* @param projectRoot - Path to the project root directory
* @param options - Open options
* @returns A CodeGraph instance
*/
static async open(projectRoot: string, options: OpenOptions = {}): Promise<CodeGraph> {
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);
if (!isInitialized(resolvedRoot)) {
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
}
const validation = validateDirectory(resolvedRoot);
if (!validation.valid) {
throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
}
const dbPath = getDatabasePath(resolvedRoot);
const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly, deferIndexRecovery: true });
try {
await db.recoverIndexesOffThread();
} catch (err) {
db.close();
throw err;
}
const queries = new QueryBuilder(db.getDb());
const instance = new CodeGraph(db, queries, resolvedRoot);
if (options.sync && !options.readOnly) {
try {
await instance.sync();
} catch (err) {
instance.destroy();
throw err;
}
}
return instance;
}
* Rebuild the project's database from scratch and return a fresh, empty
* instance — the "same result as a fresh init" semantics that `codegraph
* index` documents.
*
* Unlike `open()` followed by `clear()`, this DISCARDS the existing
* `.codegraph/codegraph.db` (and its `-wal`/`-shm` sidecars) before
* re-initializing, instead of opening the old database and DELETE-ing every
* row. On a large or pre-fix poisoned index — e.g. an old graph that scanned
* an ignored gitlink corpus (#1065) into ~1.6M nodes with a multi-GB WAL —
* the per-row `nodes_fts` delete-trigger churn blocks the main thread long
* enough to trip the #850 liveness watchdog before indexing even starts, so a
* full re-index could never recover the bad state (#1067). Discarding the
* files is O(1) regardless of size, reclaims the disk, and sidesteps opening
* (and running migrations against) the poisoned database entirely.
*/
static async recreate(projectRoot: string): Promise<CodeGraph> {
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);
if (!isInitialized(resolvedRoot)) {
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
}
const dbPath = getDatabasePath(resolvedRoot);
try {
removeDatabaseFiles(dbPath);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not rebuild the index — the database file is in use (${reason}). ` +
`Stop any running CodeGraph MCP server/daemon for this project and retry, ` +
`or remove the ${getCodeGraphDir(resolvedRoot)} directory and run "codegraph init".`
);
}
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
return new CodeGraph(db, queries, resolvedRoot);
}
* Open synchronously (without sync).
*
* `deferIndexRecovery` leaves the secondary indexes an interrupted bulk load
* dropped for {@link recoverIndexes} to rebuild off this thread, instead of
* rebuilding them here (#1887): for a caller on a watched event loop, which
* then awaits {@link recoverIndexes} before it relies on the project.
*/
static openSync(projectRoot: string, options: Pick<OpenOptions, 'readOnly'> & { deferIndexRecovery?: boolean } = {}): CodeGraph {
const resolvedRoot = path.resolve(projectRoot);
if (!isInitialized(resolvedRoot)) {
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
}
const validation = validateDirectory(resolvedRoot);
if (!validation.valid) {
throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
}
const dbPath = getDatabasePath(resolvedRoot);
const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly, deferIndexRecovery: options.deferIndexRecovery });
const queries = new QueryBuilder(db.getDb());
return new CodeGraph(db, queries, resolvedRoot);
}
* Rebuild, on a worker thread, the secondary indexes an interrupted bulk
* load dropped and `openSync({ deferIndexRecovery: true })` left (#1887).
* Resolves at once when nothing is left, which is every open but the first
* after such an interruption; rejects when the rebuild fails, and the next
* call tries again. Reads stay correct meanwhile, only slower.
*/
recoverIndexes(): Promise<void> {
return this.db.recoverIndexesOffThread();
}
hasPendingIndexRecovery(): boolean {
return this.db.hasDeferredIndexRecovery();
}
* Check if a directory has been initialized as a CodeGraph project
*/
static isInitialized(projectRoot: string): boolean {
return isInitialized(path.resolve(projectRoot));
}
* Close the CodeGraph instance and release resources
*/
close(): void {
this.unwatch();
this.fileLock.release();
this.db.close();
}
* Get the project root directory
*/
getProjectRoot(): string {
return this.projectRoot;
}
* Index all files in the project
*
* Uses a mutex to prevent concurrent indexing operations.
*/
async indexAll(options: IndexOptions = {}): Promise<IndexResult> {
return this.indexMutex.withLock(async () => {
const startedAt = Date.now();
try {
this.fileLock.acquire();
} catch {
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 };
}
const freshDb = this.queries.getNodeAndEdgeCount().nodes === 0;
const fastInit = process.env.CODEGRAPH_NO_FAST_INIT !== '1' && freshDb;
if (fastInit) {
try {
this.db.getDb().pragma('journal_mode = MEMORY');
this.db.getDb().pragma('synchronous = OFF');
} catch { }
}
const deferWal = !fastInit && process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
let walValve: WalCheckpointValve | null = null;
let priorAutocheckpoint = 1000;
let restoreAutocheckpoint = false;
if (deferWal) {
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
walValve = new WalCheckpointValve(
this.db,
resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB, this.db.getDbFileSizeBytes()),
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
}
try {
const gitState = this.orchestrator.beginGitIndexState(true);
const before = this.queries.getNodeAndEdgeCount();
try { this.queries.setMetadata('index_state', 'indexing'); } catch { }
try { this.queries.clearNameSegmentVocab(); } catch { }
this.db.beginBulkNodeLoad();
if (freshDb) this.db.beginBulkParseLoad();
let result: IndexResult;
try {
result = await this.orchestrator.indexAll(
options.onProgress,
options.signal,
options.verbose,
walValve ? () => walValve!.backpressure() : undefined,
freshDb ? { dbPath: this.db.getPath(), fastInit } : null
);
} finally {
if (freshDb) {
const tIdx = Date.now();
await this.db.endBulkParseLoad();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] parse-index-rebuild: ${Date.now() - tIdx}ms`);
}
const tFts = Date.now();
this.db.endBulkNodeLoad();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] fts-rebuild: ${Date.now() - tFts}ms`);
}
if (walValve) await walValve.foldNow();
if (result.success && result.filesIndexed > 0) {
const tReinit = Date.now();
this.resolver.initialize();
this.resolver.runPostExtract();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolver-reinit: ${Date.now() - tReinit}ms`);
}
if (result.success && result.filesIndexed > 0) {
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
if (fastInit && unresolvedCount >= minRefsForPool()) {
try {
this.db.getDb().pragma('synchronous = NORMAL');
this.db.getDb().pragma('journal_mode = WAL');
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
restoreAutocheckpoint = true;
walValve = new WalCheckpointValve(
this.db,
undefined,
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
} catch { }
}
options.onProgress?.({
phase: 'resolving',
current: 0,
total: unresolvedCount,
});
const tResolve = Date.now();
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
},
walValve ? () => walValve!.backpressure() : undefined
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolution: ${Date.now() - tResolve}ms`);
const tChained = Date.now();
await this.resolver.resolveChainedCallsViaConformance();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] chainedConformance: ${Date.now() - tChained}ms`);
const tDeferred = Date.now();
await this.resolver.resolveDeferredThisMemberRefs();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] deferredThisMember: ${Date.now() - tDeferred}ms`);
}
if (result.success && result.filesIndexed > 0) {
const tMaint = Date.now();
if (walValve) { walValve.stop(); await walValve.drain(); }
await this.db.runMaintenance();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] maintenance: ${Date.now() - tMaint}ms`);
}
if (result.success && result.filesIndexed > 0) {
const tCount = Date.now();
const after = this.queries.getNodeAndEdgeCount();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] count-recompute: ${Date.now() - tCount}ms`);
result.nodesCreated = after.nodes - before.nodes;
result.edgesCreated = after.edges - before.edges;
}
if (result.success) this.stampPythonRootFingerprint();
if (result.success) this.stampWorkspaceFingerprint();
if (result.success) this.stampIndexedFrameworks();
if (result.success && result.filesIndexed > 0) {
try {
this.queries.setMetadata('indexed_with_version', CodeGraphPackageVersion);
this.queries.setMetadata('indexed_with_extraction_version', String(EXTRACTION_VERSION));
} catch { }
}
if (result.success && result.filesErrored === 0 &&
(result.filesDiscovered === undefined || result.filesIndexed + result.filesSkipped >= result.filesDiscovered)) {
this.orchestrator.finishGitIndexState(gitState, true);
}
try {
if (!result.success) {
this.queries.setMetadata('index_state', 'failed');
} else {
const accounted = result.filesIndexed + result.filesSkipped + result.filesErrored;
const discovered = result.filesDiscovered;
const shortfall = discovered !== undefined ? discovered - accounted : 0;
if (discovered !== undefined && shortfall > 0) {
this.queries.setMetadata('index_state', 'partial');
this.queries.setMetadata('index_files_discovered', String(discovered));
this.queries.setMetadata('index_files_accounted', String(accounted));
result.errors.push({
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\`.`,
severity: 'warning',
code: 'index_partial',
});
} else {
this.queries.setMetadata('index_state', 'complete');
if (discovered !== undefined) {
this.queries.setMetadata('index_files_discovered', String(discovered));
this.queries.setMetadata('index_files_accounted', String(accounted));
}
}
}
} catch { }
result.durationMs = Date.now() - startedAt;
return result;
} finally {
if (walValve) { walValve.stop(); await walValve.drain(); }
if (deferWal || restoreAutocheckpoint) {
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { }
}
if (fastInit) {
try {
this.db.getDb().pragma('synchronous = NORMAL');
this.db.getDb().pragma('journal_mode = WAL');
} catch { }
}
this.fileLock.release();
}
});
}
* Index specific files
*
* Uses a mutex to prevent concurrent indexing operations.
*/
async indexFiles(filePaths: string[]): Promise<IndexResult> {
return this.indexMutex.withLock(async () => {
try {
this.fileLock.acquire();
} catch {
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 };
}
try {
return await this.orchestrator.indexFiles(filePaths).finally(() => this.resolver.clearCaches());
} finally {
this.fileLock.release();
}
});
}
* Sync with current file state (incremental update).
* Throws LockUnavailableError if the cross-process write lock is unavailable.
*
* Uses a mutex to prevent concurrent indexing operations.
*/
private pythonFilePaths(): Set<string> {
return new Set(this.queries.getAllFilePaths().filter((f) => f.endsWith('.py')));
}
private stampPythonRootFingerprint(fingerprint = this.resolver.getPythonRootFingerprint()): void {
try {
this.queries.setMetadata(PYTHON_ROOTS_METADATA_KEY, fingerprint);
} catch { }
}
private stampWorkspaceFingerprint(fingerprint = this.resolver.getWorkspaceFingerprint()): void {
try {
this.queries.setMetadata(WORKSPACE_METADATA_KEY, fingerprint);
} catch { }
}
private stampIndexedFrameworks(): void {
const names = this.orchestrator.getDetectedFrameworkNames();
if (names === null) return;
try {
this.queries.setMetadata(FRAMEWORKS_AT_INDEX_KEY, JSON.stringify([...names].sort()));
this.queries.setMetadata(FRAMEWORKS_SINCE_INDEX_KEY, '');
} catch { }
}
* A sync detected frameworks again because what detection read had changed
* (#2516): a framework arrived mid-session, like Express in a new
* `package.json`, or one left. Files the sync extracted were read with the
* new set already. The resolver detects again when its set differs, so
* resolution and the passes after it use the new one too. Files indexed
* before the change were extracted without it, so the change against the
* last full index is recorded for `codegraph status`, which recommends a
* re-index: re-extracting every file a framework can touch would mean
* re-parsing most of the project inside a watcher's sync.
*/
private followDetectedFrameworks(detected: string[]): void {
const now = [...detected].sort();
const resolverNow = [...this.resolver.getDetectedFrameworks()].sort();
if (now.join('\0') !== resolverNow.join('\0')) this.resolver.initialize();
try {
const atIndex = parseNameList(this.queries.getMetadata(FRAMEWORKS_AT_INDEX_KEY));
if (!atIndex) return;
const added = now.filter((name) => !atIndex.includes(name));
const removed = atIndex.filter((name) => !now.includes(name));
const value = added.length > 0 || removed.length > 0 ? JSON.stringify({ added, removed }) : '';
if ((this.queries.getMetadata(FRAMEWORKS_SINCE_INDEX_KEY) ?? '') !== value) {
this.queries.setMetadata(FRAMEWORKS_SINCE_INDEX_KEY, value);
}
} catch { }
}
* The frameworks a sync detected since the last full index, against the ones
* that index extracted every file with (#2516): files indexed before the
* change were read without the frameworks added, and with the ones removed,
* so a full re-index brings them in line. Null when nothing changed, or for
* an index built before this was recorded.
*/
getFrameworkChangesSinceIndex(): { added: string[]; removed: string[] } | null {
try {
const value = this.queries.getMetadata(FRAMEWORKS_SINCE_INDEX_KEY);
if (!value) return null;
const parsed = JSON.parse(value) as { added?: unknown; removed?: unknown };
const names = (list: unknown): string[] => (Array.isArray(list) ? list.filter((n): n is string => typeof n === 'string') : []);
const added = names(parsed.added);
const removed = names(parsed.removed);
return added.length > 0 || removed.length > 0 ? { added, removed } : null;
} catch {
return null;
}
}
async sync(options: IndexOptions = {}): Promise<SyncResult> {
return this.indexMutex.withLock(async () => {
const startedAt = Date.now();
try {
this.fileLock.acquire();
} catch (err) {
throw new LockUnavailableError(
`Sync could not acquire the file lock; retry when the index is available. ${err instanceof Error ? err.message : String(err)}`
);
}
try {
this.reopenReplacedDatabase();
} catch (err) {
this.fileLock.release();
throw new LockUnavailableError(
`Sync could not open the rebuilt index yet; retry when the rebuild finishes. ${err instanceof Error ? err.message : String(err)}`
);
}
if (this.pendingFullReconcile) {
if (this.getIndexState() === null && this.isFreshlyRecreated()) {
this.fileLock.release();
throw new LockUnavailableError('A rebuild of this index is in progress; retry when it finishes.');
}
options = { ...options, paths: undefined };
}
const deferWal = process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
let walValve: WalCheckpointValve | null = null;
let priorAutocheckpoint = 1000;
if (deferWal) {
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
walValve = new WalCheckpointValve(
this.db,
resolveWalValveMb(process.env.CODEGRAPH_WAL_VALVE_MB, this.db.getDbFileSizeBytes()),
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
}
try {
const vocabWasEmpty = (() => {
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
})();
const backpressure = walValve ? () => walValve!.backpressure() : undefined;
const fullReconcile = !options.paths || options.paths.length === 0;
const gitState = this.orchestrator.beginGitIndexState(fullReconcile);
const pythonFilesBefore = this.pythonFilePaths();
let refreshSynthesis = this.queries.getMetadata('synthesis_pending') === '1' ||
this.queries.getUnresolvedReferencesCount() > 0;
if (refreshSynthesis) this.queries.setMetadata('synthesis_pending', '1');
const watchRoutes = this.resolver.hasNavigationRouters();
let routesBefore = null as Node[] | null;
const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure,
(filePath, content) => {
if (watchRoutes && routesBefore === null) routesBefore = this.queries.getNodesByKind('route');
if (!refreshSynthesis && (this.queries.hasSynthesizedEdgesTouchingFile(filePath) ||
this.queries.wasSynthesisInput(filePath) ||
(content !== undefined && hasSynthesisPattern(filePath, content)))) {
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
}
});
if (result.detectedFrameworks) this.followDetectedFrameworks(result.detectedFrameworks);
if (!refreshSynthesis && result.definitionDelta && result.changedFilePaths) {
const delta = new Set(result.definitionDelta);
if (this.queries.getNodesByFiles(result.changedFilePaths).some((n) => delta.has(n.name) && isJsxChildCandidate(n))) {
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
}
}
if (walValve) await walValve.foldNow();
if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) {
this.resolver.runPostExtract();
}
if (routesBefore) {
const tNav = Date.now();
const routes = changedRoutes(routesBefore, this.queries.getNodesByKind('route'));
const reopened = this.resolver.reopenNavigationsFor(routes, result.changedFilePaths ?? []);
if (routes.length > 0 && !refreshSynthesis) {
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
}
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(`[phase-timing] sync-navigation-retry: ${Date.now() - tNav}ms (${routes.length} routes changed, ${reopened} refs re-opened)`);
}
}
if (fullReconcile) {
const tWorkspace = Date.now();
const before = this.queries.getMetadata(WORKSPACE_METADATA_KEY);
const now = this.resolver.refreshWorkspacePackages();
if (before !== null && before !== now) {
let reopened = 0;
for (const language of ESM_IMPORT_LANGUAGES) reopened += this.orchestrator.resurrectResolutionEdgesForLanguage(language);
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(`[phase-timing] sync-workspace-packages: ${Date.now() - tWorkspace}ms (${reopened} refs re-opened)`);
}
}
if (before !== now) this.stampWorkspaceFingerprint(now);
}
const filesChanged = result.filesAdded > 0 || result.filesModified > 0;
let edgesBeforeRetry: number | undefined;
const edgesBeforeResolution = this.queries.getMaxEdgeId();
if (filesChanged) {
if (result.changedFilePaths) {
const tRefLoad = Date.now();
const unresolvedRefs = this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-ref-load: ${Date.now() - tRefLoad}ms (${unresolvedRefs.length} refs)`);
options.onProgress?.({
phase: 'resolving',
current: 0,
total: unresolvedRefs.length,
});
this.resolver.resolveAndPersist(unresolvedRefs, (current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
const tRetry = Date.now();
edgesBeforeRetry = this.queries.getMaxEdgeId();
const retryable = this.queries.getRetryableFailedReferences([...new Set([
...this.queries.getNodeNamesByFiles(result.changedFilePaths),
...result.changedFilePaths.flatMap(moduleReferenceKeys),
])]);
const addedFiles = new Set(result.addedFilePaths ?? []);
const resolvesToAdded = (specifier: string, fromFile: string, language: Language): boolean => {
const file = this.resolver.resolveModuleFile(specifier, fromFile, language);
return file !== null && addedFiles.has(file);
};
const resolvedModules = addedFiles.size === 0 ? [] : this.queries.getFailedScopedImports()
.filter((s) => resolvesToAdded(s.name, s.filePath, s.language))
.map((s) => s.name);
const folderImports = addedFiles.size === 0 ? [] : this.queries.getFailedFolderImports()
.filter((ref) => ref.filePath !== undefined && ref.language !== undefined &&
resolvesToAdded(ref.referenceName, ref.filePath, ref.language));
const retryRows = new Set(retryable.map((ref) => ref.rowId));
const importRetry = this.queries.getRetryableFailedImports(
(result.addedFilePaths ?? []).flatMap(importPathKeys),
this.queries.getNodeNamesByFiles(result.changedFilePaths, ['namespace', 'module']),
undefined,
resolvedModules,
undefined,
this.resolver.couldNameAnyOf([...addedFiles])
);
for (const ref of [...importRetry, ...folderImports]) {
if (!retryRows.has(ref.rowId)) {
retryable.push(ref);
retryRows.add(ref.rowId);
}
}
retryable.sort((a, b) => (a.rowId ?? 0) - (b.rowId ?? 0));
if (retryable.length > 0) {
options.onProgress?.({
phase: 'resolving',
current: 0,
total: retryable.length,
});
await this.resolver.resolveAndPersistListYielding(retryable);
options.onProgress?.({
phase: 'resolving',
current: retryable.length,
total: retryable.length,
});
}
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-failed-ref-retry: ${Date.now() - tRetry}ms (${retryable.length} refs)`);
const importReader = createImportReader(this.projectRoot);
const added = (result.addedFilePaths ?? []).flatMap((filePath) => {
const file = this.queries.getFileByPath(filePath);
return file ? [{ path: filePath, language: file.language }] : [];
});
if (added.length > 0) {
const tAdded = Date.now();
const reopened = reopenReferencesThroughAddedFiles(
this.queries, importReader, added, edgesBeforeResolution,
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language),
(file) => this.resolver.workspacePackageDirOf(file)
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`);
}
const tBarrels = Date.now();
const delta = new Set(result.definitionDelta ?? []);
const seeds = new Map<string, Language>(added.map((file) => [file.path, file.language]));
const modified = result.changedFilePaths.filter((filePath) => !seeds.has(filePath));
if (delta.size > 0 && modified.length > 0) {
for (const node of this.queries.getNodesByFiles(modified)) {
if (delta.has(node.name)) seeds.set(node.filePath, node.language);
}
}
const throughBarrels = reopenReferencesThroughBarrels(
this.queries, importReader, [...seeds].map(([path, language]) => ({ path, language })), edgesBeforeResolution
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-barrel-modules: ${Date.now() - tBarrels}ms (${throughBarrels} refs re-opened)`);
const declared = reopenReferencesThroughDeclaredPackages(
this.queries, importReader, this.projectRoot, result.changedFilePaths, edgesBeforeResolution
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS && declared > 0) console.error(`[phase-timing] sync-declared-packages: ${declared} refs re-opened`);
const tDefaults = Date.now();
const movedDefaults = this.resolver.changedDefaultExports(modified, result.changedFilePaths, edgesBeforeResolution);
const throughDefaults = movedDefaults.length === 0 ? 0
: reopenReferencesThroughChangedDefaults(this.queries, importReader, movedDefaults, edgesBeforeResolution);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-default-exports: ${Date.now() - tDefaults}ms (${movedDefaults.length} defaults moved, ${throughDefaults} refs re-opened)`);
const tNamed = Date.now();
const editedModules = modified.flatMap((filePath) => {
const file = this.queries.getFileByPath(filePath);
return file ? [{ path: filePath, language: file.language }] : [];
});
const nameMatched = reopenNameMatchedImports(
this.queries, importReader, editedModules, edgesBeforeResolution,
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language),
(ref) => this.resolver.resolveNow(ref)
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-name-matched-imports: ${Date.now() - tNamed}ms (${nameMatched} refs re-opened)`);
} else {
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
options.onProgress?.({
phase: 'resolving',
current: 0,
total: unresolvedCount,
});
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
},
backpressure,
false
);
}
}
if (result.definitionDelta && process.env.CODEGRAPH_NO_REBIND !== '1') {
const tRebind = Date.now();
const rebound = this.orchestrator.resurrectStaleResolutionEdges(
result.definitionDelta,
result.changedFilePaths ?? [],
edgesBeforeRetry
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(
`[phase-timing] sync-rebind: ${Date.now() - tRebind}ms (${result.definitionDelta.length} changed names, ${rebound} edges re-opened)`
);
}
}
if (filesChanged && result.changedFilePaths) {
const tModules = Date.now();
const reopened = this.resolver.reopenRouteModuleReaders(result.changedFilePaths);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(`[phase-timing] sync-route-modules: ${Date.now() - tModules}ms (${reopened} refs re-opened)`);
}
}
{
const before = this.queries.getMetadata(PYTHON_ROOTS_METADATA_KEY);
const now = this.resolver.getPythonRootFingerprint();
if (before !== null && before !== now) {
this.resolver.clearCaches();
this.orchestrator.resurrectResolutionEdgesForLanguage('python');
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
} else if (before !== null) {
const after = this.pythonFilePaths();
const moved = [...after].filter((f) => !pythonFilesBefore.has(f));
for (const f of pythonFilesBefore) if (!after.has(f)) moved.push(f);
const reopened = new Set<string>();
if (moved.length > 0) {
this.resolver.clearCaches();
for (const f of this.orchestrator.resurrectResolutionEdgesTouching('python', this.resolver.getPythonReopenScope(moved))) reopened.add(f);
}
const editedInits = (result.changedFilePaths ?? []).filter(
(f) => pythonFilesBefore.has(f) && after.has(f) && /(?:^|[\\/])__init__\.py$/.test(f)
);
if (editedInits.length > 0) {
this.resolver.clearCaches();
for (const f of this.orchestrator.resurrectPythonPackageImporters(this.resolver.getPythonPackageImporters(editedInits))) reopened.add(f);
}
if (reopened.size > 0) {
this.resolver.resolveAndPersist(this.queries.getUnresolvedReferencesByFiles([...reopened]));
if (!refreshSynthesis && [...reopened].some((f) =>
this.queries.hasSynthesizedEdgesTouchingFile(f) || this.queries.wasSynthesisInput(f))) {
refreshSynthesis = true;
this.queries.setMetadata('synthesis_pending', '1');
}
}
}
if (before !== now) this.stampPythonRootFingerprint(now);
}
result.pendingRefsProcessed = 0;
result.pendingRefsResolved = 0;
result.pendingRefsUnresolved = 0;
const orphanCount = this.queries.getUnresolvedReferencesCount();
if (orphanCount > 0) {
options.onProgress?.({
phase: 'resolving',
current: 0,
total: orphanCount,
});
const recovery = await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
},
backpressure,
false
);
result.pendingRefsProcessed = recovery.stats.total;
result.pendingRefsResolved = recovery.stats.resolved;
result.pendingRefsUnresolved = recovery.stats.unresolved;
}
if (process.env.CODEGRAPH_NO_REBIND !== '1') {
const touched = [...(result.changedFilePaths ?? []), ...(result.removedFilePaths ?? [])];
const touchedPy = touched.filter((p) => /\.pyi?$/.test(p));
if (touchedPy.length > 0) {
const tTypes = Date.now();
const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedPy, result.changedFilePaths ?? []);
const ceiling = Number(process.env.CODEGRAPH_PY_ATTR_RETRY_CEILING ?? '') || 2000;
const failed = this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling);
const retry = [...this.queries.getUnresolvedReferencesByFiles(reopenedFiles), ...(failed ?? [])];
if (retry.length > 0) await this.resolver.resolveAndPersistListYielding(retry);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(
`[phase-timing] sync-type-rebind: ${Date.now() - tTypes}ms (${retry.length} refs${failed ? '' : ', failed-ref fan-in over ceiling'})`
);
}
}
}
if (filesChanged || orphanCount > 0) {
await this.resolver.resolveChainedCallsViaConformance();
await this.resolver.resolveDeferredThisMemberRefs();
}
if (refreshSynthesis) {
await this.resolver.refreshSynthesis(this.db.getPath(), (done, total) => {
options.onProgress?.({ phase: 'linking', current: done, total });
}, backpressure);
this.queries.setMetadata('synthesis_pending', '0');
}
if (filesChanged || result.filesRemoved > 0 || orphanCount > 0 || refreshSynthesis) {
await this.db.runMaintenance();
}
try {
if (vocabWasEmpty && this.queries.getNodeAndEdgeCount().nodes > 0) {
await this.rebuildNameSegmentVocab();
}
} catch { }
if (fullReconcile && this.getIndexState() === 'indexing') {
try { this.queries.setMetadata('index_state', 'complete'); } catch { }
}
this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);
if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false;
result.durationMs = Date.now() - startedAt;
return result;
} finally {
if (walValve) { walValve.stop(); await walValve.drain(); }
if (deferWal) {
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { }
}
this.fileLock.release();
}
});
}
* Check if an indexing operation is currently in progress
*/
isIndexing(): boolean {
return this.indexMutex.isLocked();
}
* Whether an OS watcher event points at a file whose index metadata is still
* current. Used on Windows to discard NTFS last-access notifications, which
* libuv reports through fs.watch as ordinary change events (#1451).
*/
private isIndexedFileStateCurrent(filePath: string): boolean {
const tracked = this.queries.getFileByPath(filePath);
if (!tracked) return false;
try {
const stat = fs.statSync(path.join(this.projectRoot, filePath));
return (
stat.isFile() &&
Number.isFinite(stat.mtimeMs) &&
Number.isFinite(tracked.modifiedAt) &&
stat.size === tracked.size &&
Math.floor(stat.mtimeMs) === Math.floor(tracked.modifiedAt)
);
} catch {
return false;
}
}
* Start watching for file changes and auto-syncing.
*
* Uses native OS file events (FSEvents on macOS, inotify on Linux 19+,
* ReadDirectoryChangesW on Windows) with debouncing to avoid thrashing.
*
* @param options - Watch options (debounce delay, callbacks)
* @returns true if watching started successfully
*/
watch(options: WatchOptions = {}): boolean {
if (this.watcher?.isActive()) return true;
this.watcher = new FileWatcher(
this.projectRoot,
async (paths?: string[]) => {
const result = await this.sync({ paths });
const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved;
return { filesChanged, durationMs: result.durationMs };
},
options,
process.platform === 'win32'
? (filePath) => this.isIndexedFileStateCurrent(filePath)
: undefined
);
return this.watcher.start();
}
* Stop watching for file changes.
*/
unwatch(): void {
if (this.watcher) {
this.watcher.stop();
this.watcher = null;
}
}
* Check if the file watcher is active.
*/
isWatching(): boolean {
return this.watcher?.isActive() ?? false;
}
* True once live watching has degraded, or while a re-armed watcher has not
* completed its full catch-up. Distinct from `!isWatching()`: a stopped or
* never-started watcher is inactive but NOT degraded. MCP tools use this for
* a whole-index stale notice, since pending files are lost when watching
* stops (#876, #1959).
*/
isWatcherDegraded(): boolean {
return this.watcher?.isDegraded() ?? false;
}
getWatcherDegradedReason(): string | null {
return this.watcher?.getDegradedReason() ?? null;
}
rearmWatcherAfterLockContention(): boolean {
return this.watcher?.rearmAfterLockContention() ?? false;
}
isWatcherRecovering(): boolean {
return this.watcher?.isRecoveringFromLock() ?? false;
}
* Files seen by the file watcher since the last successful sync —
* the per-file "stale" signal MCP tools attach to responses so an agent
* can fall back to {@link Read} for just the affected file without
* waiting for a debounced sync to complete (issue #403).
*
* Returns an empty list when the watcher isn't active, or no events have
* arrived. Each entry includes `firstSeenMs` and `lastSeenMs` (wall-clock
* `Date.now()` values) so callers can render "edited Nms ago", plus an
* `indexing` flag indicating whether the in-flight sync (if any) will
* absorb that file.
*/
getPendingFiles(): PendingFile[] {
return this.watcher?.getPendingFiles() ?? [];
}
* Resolves once the file watcher has installed its watch set. Useful for
* tests that need a deterministic boundary before asserting on
* `getPendingFiles()`. Resolves immediately when no watcher is active.
*/
waitUntilWatcherReady(timeoutMs?: number): Promise<void> {
return this.watcher ? this.watcher.waitUntilReady(timeoutMs) : Promise.resolve();
}
* Get files that have changed since last index
*/
getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
return this.orchestrator.getChangedFiles();
}
* Most recent index timestamp (ms since epoch) across all tracked files, or
* null when nothing is indexed yet. Lets library consumers check index
* freshness without shelling out to `codegraph status --json`. (#329)
*/
getLastIndexedAt(): number | null {
return this.queries.getLastIndexedAt();
}
* How far the last sync got and how many files it left behind — the cheapest
* marker of "has this index moved". One query; safe to call on every
* filesystem event a live viewer sees.
*/
getIndexRevision(): { lastIndexedAt: number | null; fileCount: number } {
return this.queries.getIndexRevision();
}
* Files re-indexed strictly after `since`, newest first — what a sync just
* picked up. `total` is the real count, `paths` is capped at `limit`.
*/
getFilesIndexedSince(since: number, limit: number): { paths: string[]; total: number } {
return this.queries.getFilesIndexedSince(since, limit);
}
* Forget everything held in memory about rows another process may have
* changed.
*
* The query layer keeps an LRU of nodes by id, invalidated by writes made
* through THIS instance — which is exactly right for a process that owns the
* index, and wrong for one that is only reading a database somebody else is
* writing. A long-lived reader (the `codegraph ui` server, a daemon holding a
* graph open across an agent's edits) will otherwise answer `getNode(id)`
* with a row a sync deleted minutes ago, while every SQL-backed query beside
* it reports the truth — a disagreement that reads as a bug in whichever
* screen shows both.
*
* Cheap (clearing a bounded Map) and safe to call whenever the database file
* looks like it moved.
*/
dropReadCaches(): void {
this.queries.clearCache();
}
* Completeness of the last full index run. `'complete'` is the only good
* state. `'indexing'` after the fact means a run was killed mid-index (OOM,
* SIGKILL, liveness watchdog) and the on-disk index is truncated;
* `'partial'` means the run finished but silently dropped files
* (discovered > indexed+skipped+errored); `'failed'` means it reported
* failure. `null` = index predates this marker. Surfaced by
* `codegraph status`.
*/
getIndexState(): 'indexing' | 'complete' | 'partial' | 'failed' | null {
const raw = this.queries.getMetadata('index_state');
return raw === 'indexing' || raw === 'complete' || raw === 'partial' || raw === 'failed'
? raw
: null;
}
* Which engine built the current index: the package version + extraction
* version stamped at the last full `indexAll`. Either field is null for an
* index built before stamping existed (treated as stale). See
* `extraction-version.ts` and `isIndexStale()`.
*/
getIndexBuildInfo(): { version: string | null; extractionVersion: number | null } {
const version = this.queries.getMetadata('indexed_with_version');
const ev = this.queries.getMetadata('indexed_with_extraction_version');
const parsed = ev != null ? parseInt(ev, 10) : NaN;
return { version, extractionVersion: Number.isFinite(parsed) ? parsed : null };
}
* True when the on-disk index was built by an engine whose extraction is
* older than the one now running — i.e. a re-index would add data a migration
* can't backfill. False when there's no index yet (nothing to refresh) or the
* stamp is current. This is the signal behind `codegraph status`'s re-index
* hint and `codegraph upgrade`'s reminder.
*/
isIndexStale(): boolean {
if (this.queries.getLastIndexedAt() == null) return false;
const { extractionVersion } = this.getIndexBuildInfo();
return extractionVersion == null || extractionVersion < EXTRACTION_VERSION;
}
* Extract nodes and edges from source code (without storing)
*/
extractFromSource(filePath: string, source: string): ExtractionResult {
return extractFromSource(filePath, source, detectLanguage(filePath, source, undefined, this.projectRoot));
}
* Resolve unresolved references and create edges
*
* This method takes unresolved references from extraction and attempts
* to resolve them using multiple strategies:
* - Framework-specific patterns (React, Express, Laravel)
* - Import-based resolution
* - Name-based symbol matching
*/
resolveReferences(onProgress?: (current: number, total: number) => void): ResolutionResult {
const unresolvedRefs = this.queries.getUnresolvedReferences();
return this.resolver.resolveAndPersist(unresolvedRefs, onProgress);
}
* Resolve references in batches to keep memory bounded on large codebases.
* Processes chunks of unresolved refs, persisting results after each batch.
*/
async resolveReferencesBatched(
onProgress?: (current: number, total: number) => void,
onSynthesisProgress?: (done: number, total: number) => void,
backpressure?: () => Promise<void> | null,
synthesize: boolean = true
): Promise<ResolutionResult> {
return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
dbPath: this.db.getPath(),
bulkEdgeLoad: {
begin: () => this.db.beginBulkEdgeLoad(),
end: () => this.db.endBulkEdgeLoad({ deferSynthesisSite: true }),
deferred: () => this.db.createSynthesisSiteIndex(),
},
refIndexLoad: {
begin: () => this.db.beginBulkRefLoad(),
end: () => this.db.endBulkRefLoad(),
},
backpressure,
}, synthesize);
}
* References extracted but never attempted by a resolution pass. Zero on a
* healthy index — a completed pass consumes every pending row (resolving it
* or parking it as failed, #1240). Non-zero at rest means a pass was
* interrupted mid-run (killed indexer, crash — #1187), so some files' call
* edges are missing; the next `sync` sweeps them.
*/
getPendingReferenceCount(): number {
return this.queries.getUnresolvedReferencesCount();
}
* Get detected frameworks in the project
*/
getDetectedFrameworks(): string[] {
return this.resolver.getDetectedFrameworks();
}
* Re-initialize the resolver (useful after adding new files)
*/
reinitializeResolver(): void {
this.resolver.initialize();
}
* Get statistics about the knowledge graph
*/
getStats(): GraphStats {
const stats = this.queries.getStats();
stats.dbSizeBytes = this.db.getSize();
stats.walSizeBytes = this.db.getWalSizeBytes();
return stats;
}
* Active SQLite backend for this project's connection (`node-sqlite` — Node's
* built-in real-SQLite module). Surfaced via `codegraph status` and the
* `codegraph_status` MCP tool alongside the effective journal mode.
*/
getBackend(): import('./db').SqliteBackend {
return this.db.getBackend();
}
* The journal mode actually in effect ('wal', 'delete', …). 'wal' means
* readers never block on a concurrent writer; anything else means they can,
* which is the precondition for the "database is locked" failures in issue
* #238. Surfaced via `codegraph status` and the `codegraph_status` MCP tool.
*/
getJournalMode(): string {
return this.db.getJournalMode();
}
* Get a node by ID
*/
getNode(id: string): Node | null {
return this.queries.getNodeById(id);
}
* Get many nodes by id in ONE round-trip (LRU-cache aware).
*
* The batch form of {@link getNode}. Anything resolving a list of edges to
* their endpoints — a caller list, a callee rail, an impact set — must use
* this rather than a `getNode` per edge: a symbol with 500 callers is 500
* queries otherwise. Ids that name nothing are simply absent from the map.
*/
getNodesByIds(ids: readonly string[]): Map<string, Node> {
return this.queries.getNodesByIds(ids);
}
* Every symbol carrying an exact qualified name.
*
* The identity that survives a re-index. A node's id contains its start line,
* so any edit ABOVE a symbol gives it a different id — anything that has to
* name the same symbol across two indexes (a saved trail, a bookmark, a
* review comment) has to key on this instead, and then disambiguate the
* result by kind and file. Index-backed; unlike
* {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
* nothing.
*/
getNodesByQualifiedName(qualifiedName: string): Node[] {
return this.queries.getNodesByQualifiedNameExact(qualifiedName);
}
* Outgoing edges for many source nodes at once — the batch form of
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
*/
getOutgoingEdgesFrom(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getOutgoingEdgesFrom(nodeIds, kinds);
}
* Fan-in (incoming edge count) for many nodes at once — the "hub" signal,
* without a query per node. See {@link QueryBuilder.countIncomingEdges}.
*/
getFanIn(ids: readonly string[]): Map<string, number> {
return this.queries.countIncomingEdges(ids);
}
* Incoming edges for many target nodes at once — the mirror of
* {@link getOutgoingEdgesFrom}. See {@link QueryBuilder.getIncomingEdgesTo}.
*/
getIncomingEdgesTo(nodeIds: readonly string[], kinds?: Edge['kind'][]): Edge[] {
return this.queries.getIncomingEdgesTo(nodeIds, kinds);
}
* Fan-out (outgoing edge count) for many nodes at once — the mirror of
* {@link getFanIn}. See {@link QueryBuilder.countOutgoingEdges}.
*/
getFanOut(ids: readonly string[]): Map<string, number> {
return this.queries.countOutgoingEdges(ids);
}
* Symbols nothing in the index points at, by kind — the candidate set the
* dead code report (`src/graph/dead-code.ts`) applies its exclusions to.
*
* Every edge kind except `contains` counts as a reference, so a method is
* not "reached" by the class that holds it. An unreferenced symbol is not
* yet a dead one: see {@link buildDeadCodeReport}.
*/
getUnreferencedNodes(
kinds: readonly Node['kind'][],
limit: number
): Array<{ node: Node; generated: boolean }> {
return this.queries.getUnreferencedNodes(kinds, limit);
}
* Which of the given names are carried by more than one symbol, at least one
* of which something references — the names a "nothing reaches this" claim
* must not be made about, because the resolver may have picked the twin.
*/
getAmbiguousReferencedNames(names: Iterable<string>): Set<string> {
return this.queries.getAmbiguousReferencedNames(names);
}
* Which of the given languages this index records an export marker for. A
* language with none has no "reachable from outside" signal at all.
*/
getLanguagesWithExports(languages: Iterable<string>): Set<string> {
return this.queries.getLanguagesWithExports(languages);
}
* Which of the given names the index holds an unresolved reference to — the
* resolver saw the name and could not decide what it meant. A symbol with
* such a name can never be called unreferenced.
*/
getUnresolvedNamesAmong(names: Iterable<string>): Set<string> {
return this.queries.getUnresolvedNamesAmong(names);
}
* Which of the given symbols extend or implement a type outside the index —
* an ancestor the resolver could not follow, so it has no edge (#1973).
*/
getUnresolvedSupertypeSourcesAmong(nodeIds: Iterable<string>): Set<string> {
return this.queries.getUnresolvedSupertypeSourcesAmong(nodeIds);
}
* The names each of the given symbols refers to, by reference kind, where
* the resolver could not follow the reference — a decorator or an interface
* from outside the index, which leaves no edge. Ids with none are absent.
*/
getUnresolvedReferenceNamesFrom(nodeIds: Iterable<string>, kinds: readonly Edge['kind'][]): Map<string, string[]> {
return this.queries.getUnresolvedReferenceNamesFrom(nodeIds, kinds);
}
* Every symbol that refers to each of the given names, by reference kind,
* where the resolver could not follow it: for `implements`, every class that
* names a given interface from outside the index.
*/
getUnresolvedReferenceSourcesNamed(names: Iterable<string>, kinds: readonly Edge['kind'][]): Map<string, Set<string>> {
return this.queries.getUnresolvedReferenceSourcesNamed(names, kinds);
}
* The symbols with the most distinct dependents, most first — the index's
* hubs. Distinct dependents, not edges: a helper called forty times from one
* function has one dependent, and it is dependents a blast radius grows from.
*/
getTopDependedOn(limit: number): Array<{ nodeId: string; dependents: number }> {
return this.queries.getTopDependedOn(limit);
}
* The graph's executable roots — files that run something at module level (a
* CLI, a worker entry, a script), ranked by calls x the number of other files
* they reach. A statement at the top level of a file is recorded as an edge
* out of the *file* node, which is what makes these visible at all.
*/
getTopCallingFiles(
limit: number
): Array<{ nodeId: string; filePath: string; calls: number; reaches: number; score: number }> {
return this.queries.getTopCallingFiles(limit);
}
* How many other files depend on each of the given files, counted through
* their symbols (an `imports` edge points at the symbol, not the file).
* A zero means nothing else in the index reaches into that file.
*/
getFileDependentCounts(filePaths: string[]): Map<string, number> {
return new Map(
this.queries.getFileDependentCounts(filePaths).map((row) => [row.filePath, row.dependents])
);
}
* How far each of the given files reaches out: distinct other files their
* symbols touch, and how many references that is. The mirror of
* {@link getFileDependentCounts}; a test file's reach is what it exercises.
*/
getFileReachCounts(filePaths: string[]): Map<string, { reaches: number; refs: number }> {
return new Map(
this.queries
.getFileReachCounts(filePaths)
.map((row) => [row.filePath, { reaches: row.reaches, refs: row.refs }])
);
}
getFileNodes(filePaths: string[]): Node[] {
return this.queries.getFileNodes(filePaths);
}
* Roll the edge table up to module granularity, for a file → module
* assignment the caller decides.
*
* The architecture map's single query: cross-module edge counts by kind,
* the `declared` subset of each (see {@link QueryBuilder.aggregateModuleGraph}),
* and the busiest symbol pairs behind each link. Read-only, and bounded by
* the number of modules rather than the number of edges.
*/
getModuleAggregation(
assignments: ReadonlyArray<{ filePath: string; module: string }>,
options: {
kinds: readonly Edge['kind'][];
minConfidence: number;
topPairsPerLink: number;
pairKinds: readonly Edge['kind'][];
}
): ReturnType<QueryBuilder['aggregateModuleGraph']> {
return this.queries.aggregateModuleGraph(assignments, options);
}
* Every ordered pair of files where one reaches into the other — the edge
* list a cycle finder runs on. See {@link QueryBuilder.getCrossFileDependencyPairs}.
*/
getFileDependencyPairs(minConfidence = 0): Array<{ source: string; target: string }> {
return this.queries.getCrossFileDependencyPairs(minConfidence);
}
* References from a symbol that never resolved to an indexed node — the
* calls and type mentions that leave the index. Lets a reader account for
* the call sites that have no callee row instead of implying there are none.
*/
getUnresolvedReferencesFrom(nodeId: string): UnresolvedReference[] {
return this.queries.getUnresolvedReferencesFrom(nodeId);
}
* The same, for every symbol in a FILE at once, in line order.
*
* One indexed lookup instead of one per symbol — the whole-file reader needs
* it for every line it draws. See
* {@link QueryBuilder.getUnresolvedReferencesInFile}.
*/
getUnresolvedReferencesInFile(filePath: string, limit?: number): UnresolvedReference[] {
return this.queries.getUnresolvedReferencesInFile(filePath, limit);
}
* Get all nodes in a file
*/
getNodesInFile(filePath: string): Node[] {
return this.queries.getNodesByFile(filePath);
}
* Get all nodes in several files, in one batched query.
*/
getNodesInFiles(filePaths: readonly string[]): Node[] {
return this.queries.getNodesByFiles(filePaths);
}
* Get all nodes of a specific kind
*/
getNodesByKind(kind: Node['kind']): Node[] {
return this.queries.getNodesByKind(kind);
}
* Get ALL nodes with an exact name (direct index lookup, not FTS-ranked/capped).
* Used to enumerate every overload of a heavily-overloaded name so the specific
* definition the caller wants is never dropped below a search cut.
*/
getNodesByName(name: string): Node[] {
return this.queries.getNodesByName(name);
}
getNodesByNamePrefix(prefix: string, limit = 20): Node[] {
return this.queries.getNodesByNamePrefix(prefix, limit);
}
* Nodes whose name CONTAINS `substring` (LIKE scan, ASCII-case-insensitive,
* shortest-first). The camel-infix lookup FTS can't do — `profileInfo`
* inside `getProfileInfoV2` is one FTS token (#1196).
*/
getNodesByNameSubstring(
substring: string,
options: { kinds?: NodeKind[]; limit?: number; excludePrefix?: boolean } = {}
): Node[] {
return this.queries
.findNodesByNameSubstring(substring, options)
.map((r) => r.node);
}
* Search nodes by text
*/
searchNodes(query: string, options?: SearchOptions): SearchResult[] {
return this.queries.searchNodes(query, options);
}
getExploreMissDiagnostics(query: string) {
return this.queries.getExploreMissDiagnostics(query);
}
* Which of `names` the index has never seen, by name, path, signature or
* docstring — the names explore reports as not in the index (#2462).
*/
getUnseenNames(names: readonly string[]): string[] {
return this.queries.getUnseenNames(names);
}
* The COBOL copybooks a query names (`CVACT01Y`, `MYCOPYBOOK`): each
* member's indexed copybook file(s) and every `COPY` / `EXEC SQL INCLUDE`
* statement that includes it. Empty for a query that names none.
*/
findNamedCopybooks(query: string): NamedCopybook[] {
return findNamedCopybooks(this.queries, query);
}
* Graph-derived prompt matching for the front-load hook's MEDIUM tier:
* which indexed symbols do these prose words name? "state machine des
* commandes" → `OrderStateMachine`, in any human language whose technical
* nouns are Latin script — no keyword list involved.
*
* Precision comes from the repo's own naming statistics, not vocabulary:
* - CO-OCCURRENCE: ≥2 words that are segments of the SAME name ("state" +
* "machine" → OrderStateMachine) is strong evidence and always qualifies.
* - RARITY: a single matched word qualifies only when its segment is
* discriminative here (≤ {@link SEGMENT_RARITY_CEILING} distinct names) —
* "checkout" in a shop backend yes, "state" in a react app no.
* Every candidate is re-verified against `nodes` before being returned
* (vocab rows are proposals; deletions leave orphans by design), so a
* returned symbol is guaranteed to exist right now.
*/
getSegmentMatches(words: string[], limit: number = 6): SegmentMatch[] {
if (words.length === 0) return [];
const variantToWord = new Map<string, string>();
for (const word of words) {
for (const variant of segmentLookupVariants(word)) {
if (!variantToWord.has(variant)) variantToWord.set(variant, word);
}
}
const variants = [...variantToWord.keys()];
const variantPairs = [...variantToWord.entries()].map(([segment, word]) => ({ segment, word }));
const candidates: Array<{ name: string; matchedWords: Set<string> }> = [];
for (const hit of this.queries.getSegmentCoOccurrence(variantPairs, 2, 24)) {
const matched = this.wordsMatchingName(hit.name, variantToWord);
if (matched.size >= 2) candidates.push({ name: hit.name, matchedWords: matched });
}
if (candidates.length === 0) {
const singleWordVariants = variants.filter((v) => variantToWord.get(v)!.length >= 5);
const counts = this.queries.getSegmentNameCounts(singleWordVariants);
const rare = [...counts.entries()]
.filter(([, n]) => n >= 2 && n <= CodeGraph.SEGMENT_RARITY_CEILING)
.sort((a, b) => a[1] - b[1])
.slice(0, 2);
for (const [variant] of rare) {
const word = variantToWord.get(variant)!;
for (const name of this.queries.getNamesForSegment(variant, 12)) {
if (splitIdentifierSegments(name).length < 2) continue;
candidates.push({ name, matchedWords: new Set([word]) });
}
}
}
const out: SegmentMatch[] = [];
const seen = new Set<string>();
candidates.sort((a, b) => b.matchedWords.size - a.matchedWords.size || a.name.length - b.name.length);
for (const candidate of candidates) {
if (out.length >= limit) break;
if (seen.has(candidate.name)) continue;
seen.add(candidate.name);
const nodes = this.queries.getNodesByName(candidate.name);
if (nodes.length === 0) continue;
const rep = nodes.find((n) => n.kind !== 'file' && n.kind !== 'import');
if (!rep) continue;
out.push({
name: candidate.name,
kind: rep.kind,
filePath: rep.filePath,
startLine: rep.startLine ?? 0,
matchedWords: [...candidate.matchedWords].sort(),
});
}
return out;
}
* is noise, not signal. Ceiling for the single-word tier; co-occurrence is
* exempt because two words on one name is already discriminative. */
private static readonly SEGMENT_RARITY_CEILING = 25;
* variants). Segments are recomputed in JS — a name-keyed vocab lookup
* would scan the (segment, name) primary key. */
private wordsMatchingName(name: string, variantToWord: Map<string, string>): Set<string> {
const segments = new Set(splitIdentifierSegments(name));
const matched = new Set<string>();
for (const [variant, word] of variantToWord) {
if (segments.has(variant)) matched.add(word);
}
return matched;
}
* One-shot upgrade heal for callers that open the graph WITHOUT syncing —
* concretely the prompt hook, whose MEDIUM tier reads the segment
* vocabulary: a database migrated from before the vocab table existed
* starts with it empty, and the only other backfill lives inside `sync()`,
* which such callers never run (#1142). Returns true when the vocab is
* usable (already populated — the overwhelmingly common one-SELECT case —
* or healed here); false when it isn't (empty graph, or another process
* holds the index lock — that process's own sync heals it).
*
* A read-only instance, as the hook opens since #2913, writes through a
* connection of its own, opened only once it holds the index lock. Every
* index and sync holds that lock while it writes, so the repairs a writer's
* open runs, and the migration an index from before the table needs, cannot
* disturb a bulk load in progress.
*/
async healSegmentVocabIfEmpty(): Promise<boolean> {
const empty = (() => {
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
})();
if (!empty) return true;
if (this.queries.getNodeAndEdgeCount().nodes === 0) return false;
return this.indexMutex.withLock(async () => {
try {
this.fileLock.acquire();
} catch {
return false;
}
try {
if (!this.queries.isNameSegmentVocabEmpty()) return true;
if (!this.db.readOnly) {
await this.rebuildNameSegmentVocab();
return true;
}
const writer = DatabaseConnection.open(this.db.getPath());
try {
await this.rebuildNameSegmentVocab(new QueryBuilder(writer.getDb()));
} finally {
writer.close();
}
return true;
} finally {
this.fileLock.release();
}
});
}
* Rebuild the segment vocabulary from the current graph, batched and
* yielding — the upgrade-heal path for indexes built before the vocab table
* existed. Runs inside the index mutex/lock (sync and
* healSegmentVocabIfEmpty hold them), through `queries`.
*/
private async rebuildNameSegmentVocab(queries: QueryBuilder = this.queries): Promise<void> {
const maybeYield = createYielder();
const BATCH = 2000;
for (let offset = 0; ; offset += BATCH) {
const names = queries.getDistinctNodeNames(BATCH, offset);
if (names.length === 0) break;
queries.insertNameSegmentsBatch(names);
await maybeYield();
}
}
* Normalized project-name tokens (go.mod / package.json / repo dir) used to
* down-weight the non-discriminative project name in search ranking (#720).
* Exposed so explore can exclude it from the PascalCase type-disambiguation
* bias, which would otherwise pull overloaded tokens toward whichever stack
* embeds the project name.
*/
getProjectNameTokens(): Set<string> {
return this.queries.getProjectNameTokens();
}
* Find the project's "primary route file" — the file with the densest
* concentration of framework-emitted `route` nodes (≥3 routes, ≥30%
* of all non-test routes). Used to inline the routing config in
* `codegraph_explore` responses on small realworld template repos
* (rails-realworld, laravel-realworld, drupal-admintoolbar, …) where
* Glob+Read of `routes.rb`/`urls.py`/etc. otherwise beats codegraph.
*/
getTopRouteFile(): { filePath: string; routeCount: number; totalRoutes: number } | null {
return this.queries.getTopRouteFile();
}
* Build a URL → handler routing manifest from the index. Each entry
* pairs a route node (URL + method) with its handler function/method
* via the `references` edge that framework resolvers emit. Returns
* null when fewer than 3 valid (non-test) routes exist.
*/
getRoutingManifest(limit?: number, perRoute?: boolean): {
entries: Array<{
url: string;
handler: string;
handlerFile: string;
handlerLine: number;
handlerKind: string;
routeId: string;
routeFile: string;
routeLine: number;
}>;
topHandlerFile: string | null;
topHandlerFileCount: number;
totalRoutes: number;
} | null {
return this.queries.getRoutingManifest(limit, perRoute);
}
* Get outgoing edges from a node
*/
getOutgoingEdges(nodeId: string): Edge[] {
return this.queries.getOutgoingEdges(nodeId);
}
* Get incoming edges to a node
*/
getIncomingEdges(nodeId: string): Edge[] {
return this.queries.getIncomingEdges(nodeId);
}
* Get a file record by path
*/
getFile(filePath: string): FileRecord | null {
return this.queries.getFileByPath(filePath);
}
* Whether the index holds any file under `dir`, a project-relative POSIX
* directory (e.g. `packages/api`).
*/
hasFilesUnder(dir: string): boolean {
return this.queries.hasFilesUnder(dir);
}
* Get all tracked files
*/
getFiles(): FileRecord[] {
return this.queries.getAllFiles();
}
* A `(path) => boolean` generated-file test over a BOUNDED candidate list,
* unioning the index-time content-banner flag with the filename convention
* (#1500). One query up front, O(1) per call after — built for use inside a
* ranking comparator, where re-querying per comparison would be quadratic.
*
* Pass every path you might ask about; a path outside the list falls back to
* the filename check alone.
*/
generatedFilePredicate(filePaths: Iterable<string>): (filePath: string) => boolean {
return this.queries.generatedPredicateFor(filePaths);
}
* A `(path) => boolean` ambient-declaration test over a BOUNDED candidate
* list: true for a file that declares nothing but types, originates no call
* edge, and that nothing in the index depends on — an ambient `.d.ts` of
* global shims, vendored typings, module augmentation (CG-28). Structural
* rather than extension-based, and deliberately narrow: see
* `QueryBuilder.getAmbientDeclarationPathsAmong` for why each condition is
* there, in particular why a `types.ts` the codebase imports is NOT flagged.
*/
ambientDeclarationFilePredicate(filePaths: Iterable<string>): (filePath: string) => boolean {
return this.queries.ambientDeclarationPredicateFor(filePaths);
}
getGeneratedFileCount(): number {
return this.queries.countGeneratedFiles();
}
* Indexed files whose symbols are missing although their content is current
* — rows a content-hash comparison calls up to date (#2336). Reported by
* `status`; `sync` repairs the `needsReindex` group.
*/
getIndexHealth(): IndexHealth {
const needsReindex: string[] = [];
const parseErrors: string[] = [];
for (const file of this.queries.getFilesWithoutNodesOrWithErrors()) {
const errors = file.errors ?? [];
if (hasGrammarLoadFailure(errors)) {
needsReindex.push(file.path);
} else if (file.nodeCount === 0) {
if (isFileLevelOnlyLanguage(file.language)) continue;
if (errors.length === 0) needsReindex.push(file.path);
else if (errors.some((e) => e.severity === 'error' || e.code === 'parse_error')) parseErrors.push(file.path);
} else if (errors.some((e) => e.code === 'parse_error')) {
parseErrors.push(file.path);
}
}
return { needsReindex, parseErrors };
}
* Get the context for a node (ancestors, children, references)
*
* Returns comprehensive context about a node including its containment
* hierarchy, children, incoming/outgoing references, type information,
* and relevant imports.
*
* @param nodeId - ID of the focal node
* @returns Context object with all related information
*/
getContext(nodeId: string): Context {
return this.graphManager.getContext(nodeId);
}
* Traverse the graph from a starting node
*
* Uses breadth-first search by default. Supports filtering by edge types,
* node types, and traversal direction.
*
* @param startId - Starting node ID
* @param options - Traversal options
* @returns Subgraph containing traversed nodes and edges
*/
traverse(startId: string, options?: TraversalOptions): Subgraph {
return this.traverser.traverseBFS(startId, options);
}
* Get the call graph for a function
*
* Returns both callers (functions that call this function) and
* callees (functions called by this function) up to the specified depth.
*
* @param nodeId - ID of the function/method node
* @param depth - Maximum depth in each direction (default: 2)
* @returns Subgraph containing the call graph
*/
getCallGraph(nodeId: string, depth: number = 2): Subgraph {
return this.traverser.getCallGraph(nodeId, depth);
}
* Get the type hierarchy for a class/interface
*
* Returns both ancestors (types this extends/implements) and
* descendants (types that extend/implement this).
*
* @param nodeId - ID of the class/interface node
* @returns Subgraph containing the type hierarchy
*/
getTypeHierarchy(nodeId: string): Subgraph {
return this.traverser.getTypeHierarchy(nodeId);
}
* Find all usages of a symbol
*
* Returns all nodes that reference the specified symbol through
* any edge type (calls, references, type_of, etc.).
*
* @param nodeId - ID of the symbol node
* @returns Array of nodes and edges that reference this symbol
*/
findUsages(nodeId: string): Array<{ node: Node; edge: Edge }> {
return this.traverser.findUsages(nodeId);
}
* Get callers of a function/method
*
* @param nodeId - ID of the function/method node
* @param maxDepth - Maximum depth to traverse (default: 1)
* @returns Array of nodes that call this function
*/
getCallers(nodeId: string, maxDepth: number = 1): Array<{ node: Node; edge: Edge }> {
return this.traverser.getCallers(nodeId, maxDepth);
}
* Get callees of a function/method
*
* @param nodeId - ID of the function/method node
* @param maxDepth - Maximum depth to traverse (default: 1)
* @returns Array of nodes called by this function
*/
getCallees(nodeId: string, maxDepth: number = 1): Array<{ node: Node; edge: Edge }> {
return this.traverser.getCallees(nodeId, maxDepth);
}
* Calculate the impact radius of a node
*
* Returns all nodes that could be affected by changes to this node.
*
* @param nodeId - ID of the node
* @param maxDepth - Maximum depth to traverse (default: 3)
* @returns Subgraph containing potentially impacted nodes
*/
getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph {
return this.traverser.getImpactRadius(nodeId, maxDepth);
}
* Find the shortest path between two nodes
*
* @param fromId - Starting node ID
* @param toId - Target node ID
* @param edgeKinds - Edge types to consider (all if empty)
* @returns Array of nodes and edges forming the path, or null if no path exists
*/
findPath(
fromId: string,
toId: string,
edgeKinds?: Edge['kind'][]
): Array<{ node: Node; edge: Edge | null }> | null {
return this.traverser.findPath(fromId, toId, edgeKinds);
}
* Get ancestors of a node in the containment hierarchy
*
* @param nodeId - ID of the node
* @returns Array of ancestor nodes from immediate parent to root
*/
getAncestors(nodeId: string): Node[] {
return this.traverser.getAncestors(nodeId);
}
* Get immediate children of a node
*
* @param nodeId - ID of the node
* @returns Array of child nodes
*/
getChildren(nodeId: string): Node[] {
return this.traverser.getChildren(nodeId);
}
* Get dependencies of a file
*
* @param filePath - Path to the file
* @returns Array of file paths this file depends on
*/
getFileDependencies(filePath: string): string[] {
return this.graphManager.getFileDependencies(filePath);
}
* Get dependents of a file
*
* @param filePath - Path to the file
* @returns Array of file paths that depend on this file
*/
getFileDependents(filePath: string): string[] {
return this.graphManager.getFileDependents(filePath);
}
* Find circular dependencies in the codebase
*
* @returns Array of cycles, each cycle is an array of file paths
*/
findCircularDependencies(): string[][] {
return this.graphManager.findCircularDependencies();
}
* Find unreferenced symbols — the RAW candidate set.
*
* Non-exported symbols of the given kinds with no incoming edge but
* `contains`. That is a fact, not a claim: on this engine's own index it
* returns ~2 500 symbols, of which about 20 are actually unreachable. The
* rest are overrides, framework registrations, mis-resolved twins and
* references the extractor never recorded.
*
* For a list anybody should act on, use `buildDeadCodeReport` from
* `src/graph/dead-code.ts`, which applies the exclusions and counts every one
* of them. This method is kept as-is because it is a published API.
*
* @param kinds - Node kinds to check (default: functions, methods, classes)
* @returns Array of unreferenced nodes
*/
findDeadCode(kinds?: Node['kind'][]): Node[] {
return this.graphManager.findDeadCode(kinds);
}
* Get complexity metrics for a node
*
* @param nodeId - ID of the node
* @returns Object containing various complexity metrics
*/
getNodeMetrics(nodeId: string): {
incomingEdgeCount: number;
outgoingEdgeCount: number;
callCount: number;
callerCount: number;
childCount: number;
depth: number;
} {
return this.graphManager.getNodeMetrics(nodeId);
}
* Get the source code for a node
*
* Reads the file and extracts the code between startLine and endLine.
*
* @param nodeId - ID of the node
* @returns Code string or null if not found
*/
async getCode(nodeId: string): Promise<string | null> {
return this.contextBuilder.getCode(nodeId);
}
* Find relevant subgraph for a query
*
* Combines semantic search with graph traversal to find the most
* relevant nodes and their relationships for a given query.
*
* @param query - Natural language query describing the task
* @param options - Search and traversal options
* @returns Subgraph of relevant nodes and edges
*/
async findRelevantContext(
query: string,
options?: FindRelevantContextOptions
): Promise<Subgraph> {
let seedNames = options?.seedNames;
if (seedNames === undefined) {
try {
seedNames = this.getSegmentMatches(extractSegmentSearchWords(query), 8)
.map((m) => m.name);
} catch {
seedNames = [];
}
}
return this.contextBuilder.findRelevantContext(query, { ...options, seedNames });
}
* Build context for a task
*
* Creates comprehensive context by:
* 1. Running FTS search to find entry points
* 2. Expanding the graph around entry points
* 3. Extracting code blocks for key nodes
* 4. Formatting output for Claude
*
* @param input - Task description (string or {title, description})
* @param options - Build options (maxNodes, includeCode, format, etc.)
* @returns TaskContext object or formatted string (markdown/JSON)
*/
async buildContext(
input: TaskInput,
options?: BuildContextOptions
): Promise<TaskContext | string> {
return this.contextBuilder.buildContext(input, options);
}
* Optimize the database (vacuum and analyze)
*/
optimize(): void {
this.db.optimize();
}
* Clear all data from the graph
*/
clear(): void {
this.queries.clear();
}
* Alias for close() for backwards compatibility.
* @deprecated Use close() instead
*/
destroy(): void {
this.close();
}
* Completely remove CodeGraph from the project.
* This closes the database and deletes the .CodeGraph directory.
*
* WARNING: This permanently deletes all CodeGraph data for the project.
*/
uninitialize(): void {
this.close();
removeDirectory(this.projectRoot);
}
}
export default CodeGraph;