* feat(resolution): conformance-aware chained-method resolution (#750) A chained static-factory/fluent call whose method lives on a SUPERTYPE the receiver conforms to — a protocol-extension method (Swift), an interface default method, or an inherited superclass method — now resolves. resolveMethodOnType falls back to walking the return type's implements/extends edges (via the new context.getSupertypes) when the method isn't a direct member. Because those edges don't exist during the single-pass resolution, a second pass (resolveChainedCallsViaConformance) re-resolves the deferred chained refs after edges are built. Still validated, so a wrong inference yields no edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changelog): conformance-aware chained-method resolution (#750) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1135 lines
36 KiB
TypeScript
1135 lines
36 KiB
TypeScript
/**
|
|
* CodeGraph
|
|
*
|
|
* A local-first code intelligence system that builds a semantic
|
|
* knowledge graph from any codebase.
|
|
*/
|
|
|
|
import * as path from 'path';
|
|
import {
|
|
Node,
|
|
Edge,
|
|
FileRecord,
|
|
ExtractionResult,
|
|
Subgraph,
|
|
TraversalOptions,
|
|
SearchOptions,
|
|
SearchResult,
|
|
Context,
|
|
GraphStats,
|
|
TaskInput,
|
|
TaskContext,
|
|
BuildContextOptions,
|
|
FindRelevantContextOptions,
|
|
} from './types';
|
|
import { DatabaseConnection, getDatabasePath } from './db';
|
|
import { QueryBuilder } from './db/queries';
|
|
import {
|
|
isInitialized,
|
|
createDirectory,
|
|
removeDirectory,
|
|
validateDirectory,
|
|
} from './directory';
|
|
import {
|
|
ExtractionOrchestrator,
|
|
IndexProgress,
|
|
IndexResult,
|
|
SyncResult,
|
|
extractFromSource,
|
|
initGrammars,
|
|
} from './extraction';
|
|
import {
|
|
ReferenceResolver,
|
|
createResolver,
|
|
ResolutionResult,
|
|
} from './resolution';
|
|
import { GraphTraverser, GraphQueryManager } from './graph';
|
|
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 { CodeGraphPackageVersion } from './mcp/version';
|
|
|
|
// Re-export types for consumers
|
|
export * from './types';
|
|
// Storage building blocks for embedded/SDK consumers that drive the graph
|
|
// directly (open a DB, run prepared queries) rather than through the CodeGraph
|
|
// facade. Exposed from the package entry so they no longer require deep imports
|
|
// into dist/ (issue #354).
|
|
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 {
|
|
/** Whether to run initial indexing after init */
|
|
index?: boolean;
|
|
|
|
/** Progress callback for indexing */
|
|
onProgress?: (progress: IndexProgress) => void;
|
|
}
|
|
|
|
/**
|
|
* Options for opening an existing CodeGraph project
|
|
*/
|
|
export interface OpenOptions {
|
|
/** Whether to run sync if files have changed */
|
|
sync?: boolean;
|
|
|
|
/** Whether to run in read-only mode */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Options for indexing
|
|
*/
|
|
export interface IndexOptions {
|
|
/** Progress callback */
|
|
onProgress?: (progress: IndexProgress) => void;
|
|
|
|
/** Abort signal for cancellation */
|
|
signal?: AbortSignal;
|
|
|
|
/** Enable verbose logging (worker lifecycle, memory, timeouts) */
|
|
verbose?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Main CodeGraph class
|
|
*
|
|
* Provides the primary interface for interacting with the code knowledge graph.
|
|
*/
|
|
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;
|
|
|
|
// Mutex for preventing concurrent indexing operations (in-process)
|
|
private indexMutex = new Mutex();
|
|
|
|
// File lock for preventing concurrent writes across processes (CLI, MCP, git hooks)
|
|
private fileLock: FileLock;
|
|
|
|
// File watcher for auto-sync on file changes
|
|
private watcher: FileWatcher | null = null;
|
|
|
|
private constructor(
|
|
db: DatabaseConnection,
|
|
queries: QueryBuilder,
|
|
projectRoot: string
|
|
) {
|
|
this.db = db;
|
|
this.queries = queries;
|
|
this.projectRoot = projectRoot;
|
|
// Down-weight the project name as a query term in search ranking — it names
|
|
// the whole repo, not a symbol, so it has no discriminative value (#720).
|
|
try {
|
|
this.queries.setProjectNameTokens(deriveProjectNameTokens(projectRoot));
|
|
} catch {
|
|
// Best-effort: ranking still works without it.
|
|
}
|
|
this.fileLock = new FileLock(
|
|
path.join(getCodeGraphDir(projectRoot), 'codegraph.lock')
|
|
);
|
|
this.orchestrator = new ExtractionOrchestrator(projectRoot, queries);
|
|
this.resolver = createResolver(projectRoot, queries);
|
|
this.graphManager = new GraphQueryManager(queries);
|
|
this.traverser = new GraphTraverser(queries);
|
|
this.contextBuilder = createContextBuilder(
|
|
projectRoot,
|
|
queries,
|
|
this.traverser
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Lifecycle Methods
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Check if already initialized
|
|
if (isInitialized(resolvedRoot)) {
|
|
throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
|
|
}
|
|
|
|
// Create directory structure
|
|
createDirectory(resolvedRoot);
|
|
|
|
// Initialize database
|
|
const dbPath = getDatabasePath(resolvedRoot);
|
|
const db = DatabaseConnection.initialize(dbPath);
|
|
const queries = new QueryBuilder(db.getDb());
|
|
|
|
const instance = new CodeGraph(db, queries, resolvedRoot);
|
|
|
|
// Run initial indexing if requested
|
|
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);
|
|
|
|
// Check if already initialized
|
|
if (isInitialized(resolvedRoot)) {
|
|
throw new Error(`CodeGraph already initialized in ${resolvedRoot}`);
|
|
}
|
|
|
|
// Create directory structure
|
|
createDirectory(resolvedRoot);
|
|
|
|
// Initialize database
|
|
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);
|
|
|
|
// Check if initialized
|
|
if (!isInitialized(resolvedRoot)) {
|
|
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
|
|
}
|
|
|
|
// Validate directory structure
|
|
const validation = validateDirectory(resolvedRoot);
|
|
if (!validation.valid) {
|
|
throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
// Open database
|
|
const dbPath = getDatabasePath(resolvedRoot);
|
|
const db = DatabaseConnection.open(dbPath);
|
|
const queries = new QueryBuilder(db.getDb());
|
|
|
|
const instance = new CodeGraph(db, queries, resolvedRoot);
|
|
|
|
// Sync if requested
|
|
if (options.sync) {
|
|
await instance.sync();
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
/**
|
|
* Open synchronously (without sync)
|
|
*/
|
|
static openSync(projectRoot: string): CodeGraph {
|
|
const resolvedRoot = path.resolve(projectRoot);
|
|
|
|
// Check if initialized
|
|
if (!isInitialized(resolvedRoot)) {
|
|
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
|
|
}
|
|
|
|
// Validate directory structure
|
|
const validation = validateDirectory(resolvedRoot);
|
|
if (!validation.valid) {
|
|
throw new Error(`Invalid CodeGraph directory: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
// Open database
|
|
const dbPath = getDatabasePath(resolvedRoot);
|
|
const db = DatabaseConnection.open(dbPath);
|
|
const queries = new QueryBuilder(db.getDb());
|
|
|
|
return new CodeGraph(db, queries, resolvedRoot);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
// Release file lock if held
|
|
this.fileLock.release();
|
|
this.db.close();
|
|
}
|
|
|
|
/**
|
|
* Get the project root directory
|
|
*/
|
|
getProjectRoot(): string {
|
|
return this.projectRoot;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Indexing
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 () => {
|
|
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 {
|
|
const before = this.queries.getNodeAndEdgeCount();
|
|
const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose);
|
|
|
|
// Re-detect frameworks now that the index is populated. The resolver
|
|
// is constructed with createResolver() before any files exist, so
|
|
// framework resolvers whose detect() consults the indexed file list
|
|
// (e.g. UIKit/SwiftUI scanning for imports, swift-objc-bridge looking
|
|
// for both Swift and ObjC files) all return false on that initial pass
|
|
// and silently drop themselves. Re-initializing here gives them a
|
|
// chance to see the actual project before resolution runs.
|
|
if (result.success && result.filesIndexed > 0) {
|
|
this.resolver.initialize();
|
|
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
|
|
// before resolution so updated names show up in subsequent reads.
|
|
this.resolver.runPostExtract();
|
|
}
|
|
|
|
// Resolve references to create call/import/extends edges
|
|
if (result.success && result.filesIndexed > 0) {
|
|
// Get count without loading all refs into memory
|
|
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
|
|
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current: 0,
|
|
total: unresolvedCount,
|
|
});
|
|
|
|
await this.resolveReferencesBatched((current, total) => {
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current,
|
|
total,
|
|
});
|
|
});
|
|
|
|
// Second pass: chained calls whose method lives on a supertype the
|
|
// receiver conforms to (protocol-extension / inherited / default-
|
|
// interface). Needs the implements/extends edges the main pass just
|
|
// built, so it runs after resolution (#750).
|
|
this.resolver.resolveChainedCallsViaConformance();
|
|
}
|
|
|
|
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
|
// Cheap and non-blocking; never load-bearing for correctness.
|
|
if (result.success && result.filesIndexed > 0) {
|
|
this.db.runMaintenance();
|
|
}
|
|
|
|
// The orchestrator only sees extraction-phase counts; resolution and
|
|
// synthesizer edges (often >50% of the graph on JVM repos) come later.
|
|
// Recompute against the DB so the CLI summary reports the true totals.
|
|
if (result.success && result.filesIndexed > 0) {
|
|
const after = this.queries.getNodeAndEdgeCount();
|
|
result.nodesCreated = after.nodes - before.nodes;
|
|
result.edgesCreated = after.edges - before.edges;
|
|
}
|
|
|
|
// Stamp the index with the engine that built it, so `codegraph status`
|
|
// and `codegraph upgrade` can recommend a re-index when the running
|
|
// engine produces richer extraction than the one on disk. Only on a
|
|
// real full index — a sync touches a subset, so it must NOT advance the
|
|
// extraction stamp (the bulk would still be stale). See extraction-version.ts.
|
|
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 { /* metadata is advisory — never fail an index over it */ }
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
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 this.orchestrator.indexFiles(filePaths);
|
|
} finally {
|
|
this.fileLock.release();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync with current file state (incremental update)
|
|
*
|
|
* Uses a mutex to prevent concurrent indexing operations.
|
|
*/
|
|
async sync(options: IndexOptions = {}): Promise<SyncResult> {
|
|
return this.indexMutex.withLock(async () => {
|
|
try {
|
|
this.fileLock.acquire();
|
|
} catch {
|
|
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
|
|
}
|
|
try {
|
|
const result = await this.orchestrator.sync(options.onProgress);
|
|
|
|
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on
|
|
// every sync that touched files so edits to `app.module.ts` propagate
|
|
// to controllers in unchanged files. The pass is idempotent and cheap
|
|
// (regex over *.module.ts only).
|
|
if (result.filesAdded > 0 || result.filesModified > 0) {
|
|
this.resolver.runPostExtract();
|
|
}
|
|
|
|
// Resolve references if files were updated
|
|
if (result.filesAdded > 0 || result.filesModified > 0) {
|
|
if (result.changedFilePaths) {
|
|
// Scope resolution to changed files (git fast path — bounded set)
|
|
const unresolvedRefs = this.queries.getUnresolvedReferencesByFiles(result.changedFilePaths);
|
|
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current: 0,
|
|
total: unresolvedRefs.length,
|
|
});
|
|
|
|
this.resolver.resolveAndPersist(unresolvedRefs, (current, total) => {
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current,
|
|
total,
|
|
});
|
|
});
|
|
} else {
|
|
// No git info — use batched resolution to avoid OOM
|
|
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
|
|
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current: 0,
|
|
total: unresolvedCount,
|
|
});
|
|
|
|
await this.resolveReferencesBatched((current, total) => {
|
|
options.onProgress?.({
|
|
phase: 'resolving',
|
|
current,
|
|
total,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Second pass: chained calls whose method lives on a supertype the
|
|
// receiver conforms to (protocol-extension / inherited). Needs the
|
|
// implements/extends edges built above (#750).
|
|
this.resolver.resolveChainedCallsViaConformance();
|
|
}
|
|
|
|
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
|
if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) {
|
|
this.db.runMaintenance();
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
this.fileLock.release();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if an indexing operation is currently in progress
|
|
*/
|
|
isIndexing(): boolean {
|
|
return this.indexMutex.isLocked();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// File Watching
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 () => {
|
|
const result = await this.sync();
|
|
// sync() returns this exact zero-shape iff it failed to acquire the
|
|
// file lock (a real empty sync always has filesChecked > 0 because
|
|
// scanDirectory ran). Surface that to the watcher as a typed error
|
|
// so it keeps pendingFiles + reschedules instead of clearing them
|
|
// (#449).
|
|
if (result.filesChecked === 0 && result.durationMs === 0) {
|
|
throw new LockUnavailableError();
|
|
}
|
|
const filesChanged = result.filesAdded + result.filesModified + result.filesRemoved;
|
|
return { filesChanged, durationMs: result.durationMs };
|
|
},
|
|
options
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Reference Resolution
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 {
|
|
// Get all unresolved references from the database
|
|
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): Promise<ResolutionResult> {
|
|
return this.resolver.resolveAndPersistBatched(onProgress);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Graph Statistics
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get statistics about the knowledge graph
|
|
*/
|
|
getStats(): GraphStats {
|
|
const stats = this.queries.getStats();
|
|
stats.dbSizeBytes = this.db.getSize();
|
|
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();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Node Operations
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get a node by ID
|
|
*/
|
|
getNode(id: string): Node | null {
|
|
return this.queries.getNodeById(id);
|
|
}
|
|
|
|
/**
|
|
* Get all nodes in a file
|
|
*/
|
|
getNodesInFile(filePath: string): Node[] {
|
|
return this.queries.getNodesByFile(filePath);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Search nodes by text
|
|
*/
|
|
searchNodes(query: string, options?: SearchOptions): SearchResult[] {
|
|
return this.queries.searchNodes(query, options);
|
|
}
|
|
|
|
/**
|
|
* 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): {
|
|
entries: Array<{ url: string; handler: string; handlerFile: string; handlerLine: number; handlerKind: string }>;
|
|
topHandlerFile: string | null;
|
|
topHandlerFileCount: number;
|
|
totalRoutes: number;
|
|
} | null {
|
|
return this.queries.getRoutingManifest(limit);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Edge Operations
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// File Operations
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Get a file record by path
|
|
*/
|
|
getFile(filePath: string): FileRecord | null {
|
|
return this.queries.getFileByPath(filePath);
|
|
}
|
|
|
|
/**
|
|
* Get all tracked files
|
|
*/
|
|
getFiles(): FileRecord[] {
|
|
return this.queries.getAllFiles();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Graph Query Methods
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 dead code (unreferenced symbols)
|
|
*
|
|
* @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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Context Building
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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> {
|
|
return this.contextBuilder.findRelevantContext(query, options);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Database Management
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
// Default export
|
|
export default CodeGraph;
|