mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
Python imports now resolve the way the interpreter finds modules: from the package roots the project has, not by any file whose path merely ends in the module's name. A project root is the repository, each service with its own pyproject.toml, setup.cfg or setup.py, its src/, and the package directories its build config declares; a local root answers only importers within its reach, so a test fixture's copy of a package never becomes the application's. Relative imports climb from the importer's own package. Before, `import json` or `from datetime import date` could link to a project file or function of that name, and a src-layout package like flask's was found through a test fixture named flask.py. On flask, fastapi, mealie and django, relative imports now resolve (about 2,200 links added), stdlib and third-party names stop linking to project namesakes (about 1,300 wrong links removed), and imports of a module's function land on the function rather than its file. A sync re-opens what an added or removed module, an edited __init__.py or a changed package root can move. Indexing CPU time is unchanged. Merged with main: a function-local `import a.b as X` (PR 2607) now uses the root-aware lookup, the outside-module receiver check (PR 2606) passes the importer, and plain imports are read at module level only, since the mappings apply to every function of the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2694 lines
113 KiB
TypeScript
2694 lines
113 KiB
TypeScript
/**
|
|
* 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 } 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, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels } 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 { minRefsForPool } from './resolution/resolver-pool';
|
|
|
|
// 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;
|
|
/** Watcher fast path: reconcile ONLY these project-relative paths (see ExtractionOrchestrator.sync). */
|
|
paths?: string[];
|
|
}
|
|
|
|
/**
|
|
* Main CodeGraph class
|
|
*
|
|
* Provides the primary interface for interacting with the code knowledge graph.
|
|
*/
|
|
/** project_metadata key for the Python package-root digest (see sync). */
|
|
const PYTHON_ROOTS_METADATA_KEY = 'python_root_fingerprint';
|
|
|
|
export class CodeGraph {
|
|
private db: DatabaseConnection;
|
|
private queries: QueryBuilder;
|
|
private projectRoot: string;
|
|
// Assigned via wireLayers() from the constructor (and again on reopen) — the
|
|
// `!` tells TS these are definitely set even though the assignment is one
|
|
// method call away from the constructor body.
|
|
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;
|
|
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 {
|
|
// 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(this.projectRoot));
|
|
} catch {
|
|
// Best-effort: ranking still works without it.
|
|
}
|
|
// Down-weight the peripheral trees the project named in `codegraph.json`
|
|
// `deprioritize` — indexed and findable, but never outranking real code
|
|
// (#982). Ranking-only, so a bad pattern costs relevance, never recall.
|
|
//
|
|
// Read LAZILY, not once here: `wireLayers` runs from the constructor and
|
|
// from `reopenIfReplaced`, so a matcher built here would freeze at whatever
|
|
// the config said when the project opened. The MCP server caches one
|
|
// CodeGraph per root for its whole lifetime, so editing `codegraph.json`
|
|
// would appear to do nothing until the process restarted — `exclude` and
|
|
// `include` do not behave that way. `loadDeprioritizePatterns` is
|
|
// mtime-cached, so this costs one `stat`; the compiled matcher is memoized
|
|
// on the pattern array's identity, which the cache keeps stable.
|
|
let cachedPatterns: string[] | undefined;
|
|
let cachedMatcher: ReturnType<typeof ignore> | undefined;
|
|
this.queries.setDeprioritizedPathMatcher((filePath: string): boolean => {
|
|
try {
|
|
const patterns = loadDeprioritizePatterns(this.projectRoot);
|
|
if (patterns.length === 0) return false;
|
|
if (patterns !== cachedPatterns) {
|
|
cachedPatterns = patterns;
|
|
cachedMatcher = ignore().add(patterns);
|
|
}
|
|
const rel = path.isAbsolute(filePath)
|
|
? path.relative(this.projectRoot, filePath)
|
|
: filePath;
|
|
if (!rel || rel.startsWith('..')) return false;
|
|
return cachedMatcher!.ignores(rel.split(path.sep).join('/'));
|
|
} catch {
|
|
// Ranking must never take the search down with it.
|
|
return false;
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
/** How long a recreated, not-yet-indexed database is treated as a rebuild in progress. */
|
|
private static readonly RECREATE_GRACE_MS = 120_000;
|
|
|
|
/** The database file at the path was written within the recreate grace window. */
|
|
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();
|
|
}
|
|
|
|
/** The body of {@link reopenIfReplaced}, without the in-flight-sync guard. */
|
|
private reopenReplacedDatabase(): boolean {
|
|
if (!this.db.isReplacedOnDisk()) return false;
|
|
const dbPath = this.db.getPath();
|
|
// Open the live file FIRST — if that throws (e.g. mid-recreate), the old
|
|
// handle stays in place and the caller retries on the next query, rather
|
|
// than leaving this instance with no connection at all.
|
|
const fresh = DatabaseConnection.open(dbPath, { readOnly: this.db.readOnly });
|
|
const stale = this.db;
|
|
this.db = fresh;
|
|
this.queries = new QueryBuilder(fresh.getDb());
|
|
this.wireLayers();
|
|
// Whoever reopened — a sync, or a tool call's self-heal — the next sync
|
|
// must reconcile the whole tree (#1902).
|
|
this.pendingFullReconcile = true;
|
|
// Releasing the dead handle also frees the leaked db/-wal/-shm fds that were
|
|
// pinning the unlinked inode (#925).
|
|
try { stale.close(); } catch { /* the old inode is gone; closing just frees fds */ }
|
|
return true;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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, { readOnly: options.readOnly });
|
|
const queries = new QueryBuilder(db.getDb());
|
|
|
|
const instance = new CodeGraph(db, queries, resolvedRoot);
|
|
|
|
// Sync if requested
|
|
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);
|
|
|
|
// Check if initialized — recreate REBUILDS an existing project; it is not a
|
|
// first-time `init`.
|
|
if (!isInitialized(resolvedRoot)) {
|
|
throw new Error(`CodeGraph not initialized in ${resolvedRoot}. Run init() first.`);
|
|
}
|
|
|
|
const dbPath = getDatabasePath(resolvedRoot);
|
|
try {
|
|
removeDatabaseFiles(dbPath);
|
|
} catch (err) {
|
|
// POSIX unlinks an open file fine; this fires mainly on Windows when a
|
|
// live daemon/MCP server still holds the database. Turn the raw EBUSY into
|
|
// an actionable instruction instead of a generic failure.
|
|
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".`
|
|
);
|
|
}
|
|
|
|
// Re-create an empty, freshly-schema'd database at the same path.
|
|
const db = DatabaseConnection.initialize(dbPath);
|
|
const queries = new QueryBuilder(db.getDb());
|
|
|
|
return new CodeGraph(db, queries, resolvedRoot);
|
|
}
|
|
|
|
/**
|
|
* Open synchronously (without sync)
|
|
*/
|
|
static openSync(projectRoot: string, options: Pick<OpenOptions, 'readOnly'> = {}): 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, { readOnly: options.readOnly });
|
|
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 () => {
|
|
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 };
|
|
}
|
|
// Defer WAL auto-checkpointing for the whole bulk run (#1231): the
|
|
// default 1000-page interval re-writes hot pages into the main DB file
|
|
// over and over — ~95% of all disk I/O during a bulk index, and a
|
|
// 19+min → 45s difference on HDD-class storage. The valve bounds WAL
|
|
// growth by backfilling PASSIVEly on a worker thread (never blocking
|
|
// the writer or the #850 watchdog heartbeat); runMaintenance below does
|
|
// the final fold-up before the interval is restored in the finally.
|
|
// Kill switch: CODEGRAPH_NO_WAL_DEFER=1. Non-WAL journal modes (some
|
|
// network filesystems) have no WAL to defer — skip.
|
|
// Fast-init: on a COMPLETELY fresh DB, trade crash-durability for speed
|
|
// during the bulk build (journal in memory, no fsync). Safe because the
|
|
// DB is disposable until the index completes — index_state stays
|
|
// 'indexing' and a crashed init is re-run from scratch; existing DBs
|
|
// (re-index/sync) never take this path. Kill switch:
|
|
// CODEGRAPH_NO_FAST_INIT=1 (same pattern as CODEGRAPH_NO_WAL_DEFER).
|
|
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 { /* keep WAL */ }
|
|
}
|
|
const deferWal = !fastInit && process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
|
|
let walValve: WalCheckpointValve | null = null;
|
|
let priorAutocheckpoint = 1000;
|
|
// Set when the fastInit+pool path below defers autocheckpointing, so the
|
|
// finally knows to restore the interval on that path too.
|
|
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();
|
|
// Mark the index as in-flight BEFORE any writes: a run killed
|
|
// mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
|
|
// marker behind, so `codegraph status` can tell a truncated index
|
|
// from a completed one instead of silently serving partial results.
|
|
try { this.queries.setMetadata('index_state', 'indexing'); } catch { /* metadata is advisory */ }
|
|
// Segment vocabulary starts empty and is repopulated by the node write
|
|
// path as every file (re-)indexes below — so a full index is also the
|
|
// orphan-cleanup pass for names deleted since the last one.
|
|
try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ }
|
|
// Bulk FTS mode for the mass-insert phase: drop the per-row FTS sync
|
|
// triggers, rebuild nodes_fts once from the nodes table afterwards.
|
|
// Crash inside the window is healed on the next DatabaseConnection.open.
|
|
this.db.beginBulkNodeLoad();
|
|
// Fresh-init only: also drop the parse-lane secondary indexes for the
|
|
// mass insert (the store-writer's B-tree-maintenance floor, plan §4d)
|
|
// and rebuild each in one scan afterwards. Incremental runs keep them
|
|
// — they delete per-file rows mid-phase through the file_path indexes.
|
|
if (freshDb) this.db.beginBulkParseLoad();
|
|
let result: IndexResult;
|
|
try {
|
|
result = await this.orchestrator.indexAll(
|
|
options.onProgress,
|
|
options.signal,
|
|
options.verbose,
|
|
walValve ? () => walValve!.backpressure() : undefined,
|
|
// Store-writer offload is fresh-DB-only: with any pre-existing
|
|
// data the store path must read (existing-file checks, cross-file
|
|
// edge snapshots) and delete, which belongs on one thread.
|
|
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`);
|
|
}
|
|
|
|
// Fold the parse phase's WAL BEFORE the first post-parse reads
|
|
// (resolver re-init and resolution both read on the main thread):
|
|
// paging a bulk-write-sized WAL there is what blew the #850
|
|
// watchdog's 60s window in the #1231 repro. Off-thread + awaited,
|
|
// so the event loop keeps turning.
|
|
if (walValve) await walValve.foldNow();
|
|
|
|
// 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) {
|
|
const tReinit = Date.now();
|
|
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();
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolver-reinit: ${Date.now() - tReinit}ms`);
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Fast-init leaves the DB in memory-journal (rollback) mode, where
|
|
// the parallel resolver pool's read connections would contend with
|
|
// the main writer's exclusive commits. When the pool will actually
|
|
// run (enough pending refs), restore WAL BEFORE resolution so
|
|
// readers never block the writer; otherwise stay in the fast mode
|
|
// until the finally — sequential resolution has no readers.
|
|
if (fastInit && unresolvedCount >= minRefsForPool()) {
|
|
try {
|
|
this.db.getDb().pragma('synchronous = NORMAL');
|
|
this.db.getDb().pragma('journal_mode = WAL');
|
|
// Defer auto-checkpointing for the resolution phase, same
|
|
// rationale as the deferWal path above: at the default 1000-page
|
|
// interval, the persist loop's edge inserts + ref deletes make
|
|
// SQLite re-write hot B-tree pages into the main DB file inline
|
|
// on the writer over and over (#1231's pathology — measured as
|
|
// ~58% of the resolution phase on a 255k-ref repo). The valve
|
|
// bounds WAL growth off-thread; runMaintenance does the final
|
|
// fold and the finally restores the interval.
|
|
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 { /* keep current mode; resolution still works sequentially */ }
|
|
}
|
|
|
|
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`);
|
|
|
|
// 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).
|
|
const tChained = Date.now();
|
|
await this.resolver.resolveChainedCallsViaConformance();
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] chainedConformance: ${Date.now() - tChained}ms`);
|
|
// Same lifecycle for `this.<member>` callback registrations whose
|
|
// member is inherited from a supertype (#808).
|
|
const tDeferred = Date.now();
|
|
await this.resolver.resolveDeferredThisMemberRefs();
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[synth-timing] deferredThisMember: ${Date.now() - tDeferred}ms`);
|
|
}
|
|
|
|
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
|
// Off-thread (worker connection): on a multi-GB index this is minutes
|
|
// of IO, and inline it starved the #850 watchdog AFTER a fully
|
|
// successful index. Never load-bearing for correctness.
|
|
if (result.success && result.filesIndexed > 0) {
|
|
const tMaint = Date.now();
|
|
// Quiesce the valve first so its in-flight checkpoint and the
|
|
// maintenance checkpoint don't contend for the checkpointer lock
|
|
// (the loser would silently no-op and leave the WAL unfolded).
|
|
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`);
|
|
}
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
// 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) this.stampPythonRootFingerprint();
|
|
|
|
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 */ }
|
|
}
|
|
|
|
if (result.success && result.filesErrored === 0 &&
|
|
(result.filesDiscovered === undefined || result.filesIndexed + result.filesSkipped >= result.filesDiscovered)) {
|
|
this.orchestrator.finishGitIndexState(gitState, true);
|
|
}
|
|
|
|
// Reconcile the scan's ground truth against what the pipeline
|
|
// accounted for. A shortfall means files were silently dropped
|
|
// (observed in the wild: a run under heavy load came up 37 files
|
|
// short with no error) — record it and tell the user, don't let the
|
|
// index pass as complete.
|
|
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 { /* metadata is advisory — never fail an index over it */ }
|
|
|
|
// The time covers the whole run, like the totals above. The
|
|
// orchestrator's covers extraction alone, and the summary printed it
|
|
// as the run's, leaving out resolution and linking (#2334).
|
|
result.durationMs = Date.now() - startedAt;
|
|
return result;
|
|
} finally {
|
|
// Restore the auto-checkpoint interval AFTER the fold-up above so the
|
|
// next ordinary write doesn't inherit a giant inline checkpoint. On
|
|
// the error path the WAL may still be large; correctness is unchanged
|
|
// (SQLite replays the WAL on the next open) and the follow-up write
|
|
// that folds it is the known cost of a failed run.
|
|
if (walValve) { walValve.stop(); await walValve.drain(); }
|
|
if (deferWal || restoreAutocheckpoint) {
|
|
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
|
|
}
|
|
if (fastInit) {
|
|
// Back to the durable defaults; journal_mode=WAL folds the MEMORY
|
|
// journal state into a normal WAL-mode database file.
|
|
try {
|
|
this.db.getDb().pragma('synchronous = NORMAL');
|
|
this.db.getDb().pragma('journal_mode = WAL');
|
|
} catch { /* connection may be closing */ }
|
|
}
|
|
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).
|
|
* Throws LockUnavailableError if the cross-process write lock is unavailable.
|
|
*
|
|
* Uses a mutex to prevent concurrent indexing operations.
|
|
*/
|
|
/** Indexed Python file paths — the before/after sets a sync diffs. */
|
|
private pythonFilePaths(): Set<string> {
|
|
return new Set(this.queries.getAllFilePaths().filter((f) => f.endsWith('.py')));
|
|
}
|
|
|
|
/** Record the Python package-root digest a later sync compares against. */
|
|
private stampPythonRootFingerprint(fingerprint = this.resolver.getPythonRootFingerprint()): void {
|
|
try {
|
|
this.queries.setMetadata(PYTHON_ROOTS_METADATA_KEY, fingerprint);
|
|
} catch { /* metadata is advisory */ }
|
|
}
|
|
|
|
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)}`
|
|
);
|
|
}
|
|
// A full rebuild in another process (`codegraph index` → recreate)
|
|
// unlinks the database and creates a new file at the same path. A
|
|
// long-lived instance — the MCP daemon's watcher — would otherwise keep
|
|
// "syncing" into the dead inode, and nothing it wrote there is visible
|
|
// to anyone (#1902). Follow the path before writing (one stat), and
|
|
// widen a scoped sync to a full one: whatever the old handle absorbed
|
|
// since the rebuild is gone, so the new file has to be reconciled whole.
|
|
// If the reopen fails (the rebuild is mid-way), report lock contention
|
|
// so the watcher keeps its pending files and retries.
|
|
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) {
|
|
// `codegraph index` recreates the file, THEN takes the write lock in
|
|
// indexAll. A sync landing in that gap would otherwise run a full
|
|
// reconcile of the empty file and hold the lock the rebuild is about
|
|
// to ask for. A fresh file with no index_state yet is that rebuild:
|
|
// step aside (lock contention, the watcher retries) and reconcile in
|
|
// full once it is done. Bounded, so a rebuild that died before
|
|
// indexing does not park the watcher forever.
|
|
if (this.getIndexState() === null && this.isFreshlyRecreated()) {
|
|
this.fileLock.release();
|
|
throw new LockUnavailableError('A rebuild of this index is in progress; retry when it finishes.');
|
|
}
|
|
// Cleared only once this run completes (below): a sync that throws
|
|
// must leave the full catch-up for the next one.
|
|
options = { ...options, paths: undefined };
|
|
}
|
|
// Defer WAL auto-checkpointing for the whole incremental run, exactly
|
|
// as indexAll does for the bulk path (#1231): sync's store loop and its
|
|
// resolution passes churn the same FTS + secondary-index hot pages, and
|
|
// at the default 1000-page cadence the inline checkpoints re-write them
|
|
// over and over — on HDD-class storage a 7-file sync took 2 minutes at
|
|
// 0-2% CPU (#1248). The cost scales with the EXISTING database size,
|
|
// not the change size, so small syncs on big indexes hurt most. The
|
|
// valve bounds WAL growth off-thread; runMaintenance at the end does
|
|
// the final fold-up before the interval is restored in the finally.
|
|
// Same kill switch as indexAll: CODEGRAPH_NO_WAL_DEFER=1. Idle valve
|
|
// cost is one timer, so watcher-frequency syncs stay cheap.
|
|
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 {
|
|
// Captured BEFORE the sync runs: the sync's own incremental writes
|
|
// populate vocab rows for the files it touches, so an end-of-sync
|
|
// emptiness check would see "non-empty" and skip the backfill forever,
|
|
// leaving every unchanged file's names unsegmented.
|
|
const vocabWasEmpty = (() => {
|
|
try { return this.queries.isNameSegmentVocabEmpty(); } catch { return false; }
|
|
})();
|
|
|
|
// Writer-side backstop for deferred WAL checkpointing (#1539): sync
|
|
// previously armed the valve but never called backpressure(), so the
|
|
// hard/file caps were never enforced during daemon catch-up — only
|
|
// timer-driven PASSIVE checkpoints ran, and a query-pool reader could
|
|
// pin frames while the WAL grew without a bound.
|
|
const backpressure = walValve ? () => walValve!.backpressure() : undefined;
|
|
const fullReconcile = !options.paths || options.paths.length === 0;
|
|
const gitState = this.orchestrator.beginGitIndexState(fullReconcile);
|
|
|
|
const pythonFilesBefore = this.pythonFilePaths();
|
|
// An interrupted index may have absorbed its changed files before
|
|
// resolution/synthesis. Detect those orphans BEFORE this sync adds refs.
|
|
let refreshSynthesis = this.queries.getMetadata('synthesis_pending') === '1' ||
|
|
this.queries.getUnresolvedReferencesCount() > 0;
|
|
if (refreshSynthesis) this.queries.setMetadata('synthesis_pending', '1');
|
|
// The route nodes as they were before this sync replaced or removed a
|
|
// file — read at the first change, so a sync that changes nothing
|
|
// reads nothing, and only where a router binds navigation calls to
|
|
// routes. Compared with the routes after runPostExtract below.
|
|
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');
|
|
}
|
|
});
|
|
|
|
// A component a tag in an unchanged file may render: `<Team />` links
|
|
// to the `Team` this sync adds. The gate above reads each changed file
|
|
// on its own, and a component with no markup of its own (`return null`,
|
|
// a wrapper) matches none of its patterns. Removing one needs no check:
|
|
// a component a tag rendered had synthesized edges into its file.
|
|
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');
|
|
}
|
|
}
|
|
|
|
// Fold the store phase's WAL BEFORE the post-store reads below
|
|
// (resolution reads on the main thread) — same rationale as
|
|
// indexAll's fold between store and resolution.
|
|
if (walValve) await walValve.foldNow();
|
|
|
|
// 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). A removal counts too: deleting the
|
|
// file that hands a React Router table to the router leaves the
|
|
// table's routes behind unless this pass runs to take them away.
|
|
// (A pure-removal sync still resolves refs below — the deletion path
|
|
// resurrects the removed file's incoming edges as pending refs, #1240
|
|
// removal case — and runPostExtract starts by dropping the resolver's
|
|
// name caches, which a long-lived daemon warmed against the
|
|
// pre-removal graph.)
|
|
if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) {
|
|
this.resolver.runPostExtract();
|
|
}
|
|
|
|
// A route that appeared, went away or was renamed changes what a
|
|
// navigation call in an UNCHANGED file resolves to: `history.push(
|
|
// '/login')` binds again once `/login` is back. While the route was
|
|
// missing the call was parked as failed, or bound to a catch-all, and
|
|
// nothing below revisits it: the retry keys on the names this sync's
|
|
// files define, which a call named for the router's method (`push`)
|
|
// never matches. Put those calls back in the pending set for the sweep
|
|
// below, and let the synthesizers redraw the links markup makes to
|
|
// routes. The route table is final here: renames and table routes are
|
|
// runPostExtract's.
|
|
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)`);
|
|
}
|
|
}
|
|
|
|
// Resolve references if files were updated
|
|
const filesChanged = result.filesAdded > 0 || result.filesModified > 0;
|
|
// The last edge before the failed-ref retry below: the rebind's
|
|
// per-name ceiling counts only edges up to it (see
|
|
// resurrectStaleResolutionEdges).
|
|
let edgesBeforeRetry: number | undefined;
|
|
// And the last edge before this sync resolved anything: an importer's
|
|
// edge up to it was bound before a module this sync adds was there.
|
|
const edgesBeforeResolution = this.queries.getMaxEdgeId();
|
|
if (filesChanged) {
|
|
if (result.changedFilePaths) {
|
|
// Scope resolution to changed files (git fast path — bounded set)
|
|
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,
|
|
});
|
|
});
|
|
|
|
// Retry previously-failed refs the changed files may now satisfy
|
|
// (#1240). Scoped resolution above only re-resolves refs FROM the
|
|
// changed files — but when a changed file gains an export/symbol,
|
|
// refs in UNCHANGED files that failed against the old graph can
|
|
// now resolve, and nothing else ever revisits them (their rows
|
|
// were parked as status='failed' by an earlier completed pass).
|
|
// Look them up by the symbol names the changed files now carry
|
|
// and re-resolve just that set. The names include each file's
|
|
// own, which a reference written as a path
|
|
// (`snippets/price.liquid`) waits under, and the keys a route's
|
|
// lazily loaded module waits under (`module:Team` for
|
|
// `lazy-import:./pages/Team`), as does a reference through an
|
|
// import binding the module declares under another name
|
|
// (`module:tag` for `import tagsController from
|
|
// './tag/tag.controller'`) and a direct member reference through
|
|
// any binding (`module:constants` for `LIST.includes(x)` after
|
|
// `import { LIST } from './constants'`): each resolves through the
|
|
// module, so an edit can satisfy it as well as an added file. On
|
|
// a sync where no failed ref matches, this is a few indexed
|
|
// lookups.
|
|
const tRetry = Date.now();
|
|
edgesBeforeRetry = this.queries.getMaxEdgeId();
|
|
const retryable = this.queries.getRetryableFailedReferences([...new Set([
|
|
...this.queries.getNodeNamesByFiles(result.changedFilePaths),
|
|
...result.changedFilePaths.flatMap(moduleReferenceKeys),
|
|
])]);
|
|
// A failed import waits for a file, a folder or a namespace, not a
|
|
// symbol: `package:app/b.dart` for a file named `b.dart`, `./ui`
|
|
// for `ui/index.ts`, `using Foo.Bar` for that namespace's node.
|
|
// Look those up by what the ADDED files can be imported as (a
|
|
// modified file was already there for any import of it), and by
|
|
// the namespaces and modules the changed files declare.
|
|
// A scoped package that a `tsconfig` path or a workspace package maps
|
|
// to an added file (`@excalidraw/common` to `packages/common/src/
|
|
// index.ts`) waits under its last segment, which that file's keys
|
|
// never give: look its failed imports up by the specifier itself.
|
|
// An import of the importer's own folder or one above it (`.`,
|
|
// `..`, `../..`) waits under no key at all, so each is tried
|
|
// where written.
|
|
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));
|
|
// A key a popular import shares (`utils`, `react`, `assert`) is
|
|
// past the ceiling, and is still looked through for the imports
|
|
// that could name an added file.
|
|
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);
|
|
}
|
|
}
|
|
// In row order, as a full index resolves them: when two refs make
|
|
// the same edge, such as a route's lazily loaded class that its
|
|
// layout also renders, the one written first names it, as it
|
|
// does in a fresh index.
|
|
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)`);
|
|
|
|
// A module that appeared, such as a package `__init__.py` or an
|
|
// `index.ts` re-exporting what it imports: what the files importing
|
|
// it bound by name in its absence, or failed, goes back to pending
|
|
// for the sweep below, which binds it through the module as a fresh
|
|
// index does. (The removal half runs before removed files are deleted.)
|
|
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)
|
|
);
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-added-module-importers: ${Date.now() - tAdded}ms (${reopened} refs re-opened)`);
|
|
}
|
|
|
|
// A reference that reaches a module through a barrel waits for a
|
|
// file below it: React Router's `import:@/pages#HomePage`, parked
|
|
// under `module:pages`, for `pages/Home/index.tsx`, whose own keys
|
|
// name its folder and stem; `import { MatxLoading } from
|
|
// './components'` for the `MatxLoading.jsx` the barrel passes on
|
|
// as `default as MatxLoading`, a name that file never declares.
|
|
// Those that reach a file this sync adds, or one whose definitions
|
|
// it changed, go back to pending for the sweep: the failed ones,
|
|
// and the edges bound before this sync resolved anything, which a
|
|
// name match may have taken elsewhere while the file lacked the
|
|
// name. A body-only edit satisfies none.
|
|
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)`);
|
|
} 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,
|
|
});
|
|
},
|
|
(done, totalPasses) => {
|
|
options.onProgress?.({
|
|
phase: 'linking',
|
|
current: done,
|
|
total: totalPasses,
|
|
});
|
|
},
|
|
backpressure,
|
|
false
|
|
);
|
|
}
|
|
}
|
|
|
|
// Re-open resolution edges this sync may have invalidated ELSEWHERE in
|
|
// the repo (CG-33). Everything above re-resolves references in the
|
|
// changed files; this covers the opposite direction — references in
|
|
// files the sync never touched whose answer depended on a definition
|
|
// that just appeared or disappeared. Without it a synced index never
|
|
// converges to a full rebuild: measured at 4.3% of distinct edges wrong
|
|
// on codegraph's own index, in both directions, mostly `calls`. The
|
|
// resurrected refs are pending rows, so the orphan sweep immediately
|
|
// below is what resolves them — batched, yielding, multi-pass, exactly
|
|
// as a full index resolves.
|
|
//
|
|
// `definitionDelta` is empty for a body-only edit, so the overwhelmingly
|
|
// common sync pays one branch. CODEGRAPH_NO_REBIND=1 disables it.
|
|
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)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// The same for a route whose answer is read from a module: one that
|
|
// renders `const Docs = lazy(() => import('./pages/Docs'))` binds to
|
|
// the component the module exports, so it moves when the module is
|
|
// added or edited, though no name it carries changes.
|
|
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)`);
|
|
}
|
|
}
|
|
|
|
// Python module answers this sync may have moved in files it never
|
|
// touched. If the root set changed (a top-level package appeared, a
|
|
// build config was edited) any import anywhere may bind differently:
|
|
// re-open them all. Otherwise only modules or packages appeared or
|
|
// vanished below the roots, and only the edges into those module
|
|
// names, out of a changed package, and the failures naming them can
|
|
// move. The orphan sweep below re-resolves whatever is re-opened
|
|
// exactly as a full index would. An index stamped by an older engine
|
|
// has no fingerprint — record one rather than re-resolving on upgrade.
|
|
{
|
|
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');
|
|
// Any Python edge may move, so any synthesis built on one may too.
|
|
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);
|
|
}
|
|
// An edited `__init__.py` can add, drop or repoint what its
|
|
// package binds, which moves what its importers' names resolve to.
|
|
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);
|
|
}
|
|
// Resolve the narrow set now, the way the changed files' own
|
|
// references were resolved above, rather than leaving a handful of
|
|
// refs to the orphan sweep and its whole-graph synthesis pass.
|
|
if (reopened.size > 0) {
|
|
this.resolver.resolveAndPersist(this.queries.getUnresolvedReferencesByFiles([...reopened]));
|
|
// Same rule as the changed files above: a moved edge in a file
|
|
// synthesis read or wrote means synthesis must be rebuilt.
|
|
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);
|
|
}
|
|
|
|
// Orphan sweep (#1187). A resolution pass that dies mid-run — the #850
|
|
// daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves
|
|
// the refs it never reached in unresolved_refs, and the git-scoped fast
|
|
// path above never revisits them (it reads only the changed files'
|
|
// rows). Those files' call edges were then missing PERMANENTLY, with
|
|
// nothing to see except a too-small blast radius, until a full
|
|
// re-index. A completed pass takes every row it processed out of the
|
|
// PENDING set (resolved rows are deleted, unresolvable ones parked as
|
|
// status='failed' for the #1240 retry above), so any pending row now
|
|
// is such an orphan — or a row from an older engine's scoped pass.
|
|
// Grind them down with the batched resolver; this also makes a bare
|
|
// `codegraph sync` the recovery command for a wedged index. On a
|
|
// healthy index this is one COUNT query.
|
|
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 (filesChanged || orphanCount > 0) {
|
|
// 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).
|
|
await this.resolver.resolveChainedCallsViaConformance();
|
|
// Same lifecycle for `this.<member>` callback registrations whose
|
|
// member is inherited from a supertype (#808).
|
|
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');
|
|
}
|
|
|
|
// Refresh planner stats + checkpoint the WAL after bulk writes.
|
|
// Off-thread — see indexAll's call site.
|
|
if (filesChanged || result.filesRemoved > 0 || orphanCount > 0 || refreshSynthesis) {
|
|
await this.db.runMaintenance();
|
|
}
|
|
|
|
// Heal the segment vocabulary on indexes built before the table
|
|
// existed (upgrade path): incremental writes above only cover changed
|
|
// files, so a vocab that was empty when this sync STARTED means the
|
|
// bulk was never segmented — backfill it (INSERT OR IGNORE, so the
|
|
// rows the sync just wrote are fine). Batched + yielding — sync can
|
|
// run on the daemon's liveness-watchdog thread (#850/#1091).
|
|
try {
|
|
if (vocabWasEmpty && this.queries.getNodeAndEdgeCount().nodes > 0) {
|
|
await this.rebuildNameSegmentVocab();
|
|
}
|
|
} catch { /* vocab is advisory — never fail a sync over it */ }
|
|
|
|
// A killed full index leaves this marker at `indexing`. Sync repairs
|
|
// missing files, pending refs, and (on open) dropped indexes, so a
|
|
// successful recovery must also close the metadata state (#1556).
|
|
if (fullReconcile && this.getIndexState() === 'indexing') {
|
|
try { this.queries.setMetadata('index_state', 'complete'); } catch { /* advisory */ }
|
|
}
|
|
|
|
this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);
|
|
|
|
if (fullReconcile && result.filesChecked > 0) this.pendingFullReconcile = false;
|
|
// The whole sync, as for indexAll: resolution and linking included (#2334).
|
|
result.durationMs = Date.now() - startedAt;
|
|
return result;
|
|
} finally {
|
|
// Mirror indexAll's teardown: stop the valve, then restore the
|
|
// auto-checkpoint interval (runMaintenance above already folded the
|
|
// WAL on the success path; on the error path SQLite replays it on
|
|
// the next open).
|
|
if (walValve) { walValve.stop(); await walValve.drain(); }
|
|
if (deferWal) {
|
|
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
|
|
}
|
|
this.fileLock.release();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if an indexing operation is currently in progress
|
|
*/
|
|
isIndexing(): boolean {
|
|
return this.indexMutex.isLocked();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// File Watching
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 {
|
|
// Missing/inaccessible files must still reach sync so removals and
|
|
// transient filesystem failures are reconciled rather than hidden.
|
|
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;
|
|
}
|
|
|
|
/** The reason live watching degraded, or null if it is healthy (#876). */
|
|
getWatcherDegradedReason(): string | null {
|
|
return this.watcher?.getDegradedReason() ?? null;
|
|
}
|
|
|
|
/** Re-arm a lock-degraded watcher; its stale state persists until a full sync. */
|
|
rearmWatcherAfterLockContention(): boolean {
|
|
return this.watcher?.rearmAfterLockContention() ?? false;
|
|
}
|
|
|
|
/** True while a re-armed watcher owes a full reconcile of missed changes. */
|
|
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 {
|
|
// The project root is what tells a Shopify theme's JSON templates (Liquid)
|
|
// apart from other JSON.
|
|
return extractFromSource(filePath, source, detectLanguage(filePath, source, undefined, this.projectRoot));
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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,
|
|
onSynthesisProgress?: (done: number, total: number) => void,
|
|
// The WAL valve's writer-side backstop, threaded into the batch loop's
|
|
// pool-idle boundaries. Without it the valve's only lever during
|
|
// resolution is timer-driven passive checkpoints, which the pool's
|
|
// continuous reads keep perpetually partial — the WAL then accretes the
|
|
// whole phase's write volume (22GB on a 4.6GB DB at kernel scale).
|
|
backpressure?: () => Promise<void> | null,
|
|
synthesize: boolean = true
|
|
): Promise<ResolutionResult> {
|
|
return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
|
|
dbPath: this.db.getPath(),
|
|
// Bulk-edge-load hooks: on big runs the resolver drops the non-unique
|
|
// edge indexes for the batch loop and recreates them before synthesis
|
|
// (which reads kind-keyed). Concurrent readers (a daemon serving this
|
|
// project mid-index) stay CORRECT during the window — target/kind reads
|
|
// just degrade to scans until the recreate.
|
|
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();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Graph Statistics
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Node Operations
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 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 }])
|
|
);
|
|
}
|
|
|
|
/** The `file` nodes for the given paths, in one query. */
|
|
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);
|
|
}
|
|
|
|
/** Nodes whose name starts with `prefix` (index range scan, capped). */
|
|
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);
|
|
}
|
|
|
|
/** Lexical evidence for an empty explore result; does not alter retrieval. */
|
|
getExploreMissDiagnostics(query: string) {
|
|
return this.queries.getExploreMissDiagnostics(query);
|
|
}
|
|
|
|
/**
|
|
* 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 [];
|
|
// Variant → original word (plural folding), for coverage accounting.
|
|
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()];
|
|
|
|
// Tier A: co-occurrence. The SQL folds variants back to their original
|
|
// word (#1146), so minWords=2 means two distinct PROMPT WORDS — a name
|
|
// matching both `service` and `services` can't tie with (or crowd past
|
|
// the LIMIT) a genuine two-word match. The JS re-check below recomputes
|
|
// the fold from live segments as the honesty layer.
|
|
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 });
|
|
}
|
|
|
|
// Tier B: single rare word. Only when co-occurrence found nothing — a
|
|
// co-occurring name is categorically stronger evidence — and under
|
|
// stricter rules, because one word is thin: the word must be ≥5 chars
|
|
// (measured FPs: "this", "typo"); the segment must appear in AT LEAST TWO
|
|
// names (a concept the codebase is about clusters across names —
|
|
// CheckoutService/CheckoutController — while a prose coincidence is a
|
|
// singleton: measured FP "deploy to PRODUCTION" → the one name
|
|
// matchesNonProductionDir); and the candidate name must have ≥2 segments
|
|
// (a bare common verb matching a bare function name — "write" → `write` —
|
|
// is prose coincidence, not the user naming a symbol).
|
|
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]) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify against nodes (the honesty gate) and pick a representative
|
|
// definition per name. A name whose only nodes are file/import kind has
|
|
// no real definition to point at — surfacing the import statement instead
|
|
// reads as a matched symbol but isn't one (#1144) — so it's skipped, the
|
|
// same way an orphaned vocab row is. (Import names no longer enter the
|
|
// vocab at write time, but rows written before that exclusion persist
|
|
// until the next full index.)
|
|
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; // orphaned vocab row — name no longer exists
|
|
const rep = nodes.find((n) => n.kind !== 'file' && n.kind !== 'import');
|
|
if (!rep) continue; // no real definition — don't surface an import/file as one
|
|
out.push({
|
|
name: candidate.name,
|
|
kind: rep.kind,
|
|
filePath: rep.filePath,
|
|
startLine: rep.startLine ?? 0,
|
|
matchedWords: [...candidate.matchedWords].sort(),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A single word ("state") can match hundreds of names in a big repo — that
|
|
* 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;
|
|
|
|
/** Which of the prompt's original words match `name`'s segments (via
|
|
* 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).
|
|
*/
|
|
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; // an index/sync is running — it backfills the vocab itself
|
|
}
|
|
try {
|
|
if (!this.queries.isNameSegmentVocabEmpty()) return true; // raced: healed meanwhile
|
|
await this.rebuildNameSegmentVocab();
|
|
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).
|
|
*/
|
|
private async rebuildNameSegmentVocab(): Promise<void> {
|
|
const maybeYield = createYielder();
|
|
const BATCH = 2000;
|
|
for (let offset = 0; ; offset += BATCH) {
|
|
const names = this.queries.getDistinctNodeNames(BATCH, offset);
|
|
if (names.length === 0) break;
|
|
this.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): {
|
|
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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/** How many indexed files are flagged tool-generated. Reported by `status`. */
|
|
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)) {
|
|
// Stored without being parsed — its grammar could not load (#2335).
|
|
needsReindex.push(file.path);
|
|
} else if (file.nodeCount === 0) {
|
|
// Every parse stores at least the file node, so zero nodes means a
|
|
// wiped row (#1541) or a recorded failure — except file-level-only
|
|
// languages and files over the size limit, which are empty on purpose.
|
|
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')) {
|
|
// Parsed, but the tree had errors and no symbols survived.
|
|
parseErrors.push(file.path);
|
|
}
|
|
}
|
|
return { needsReindex, parseErrors };
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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 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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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> {
|
|
// Segment-vocab supplement: FTS keeps camelCase names as single tokens,
|
|
// so a word-level query ("auto-scroll to bottom") can never reach
|
|
// `pinFeedIfNearBottom` through search alone. Resolve the query's words
|
|
// against name_segment_vocab (same precision rules as the prompt hook:
|
|
// co-occurrence, else rare singles, verified against live nodes) and hand
|
|
// the names down as dampened exact-name seeds. Callers that pass their
|
|
// own seedNames keep them; failures degrade to no supplement.
|
|
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);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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;
|