socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
1,481 lines • 78 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Lang, registerDynamicLanguage } from "@ast-grep/napi";
import { graphCollectionName, loadPythonRoots, projectIdFromPath } from "../config.js";
import { ELIXIR_TEMPLATE_EXTENSIONS, EXTENSION_LANGUAGE_MAP, EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES, toForwardSlash } from "../constants.js";
import { ensureElixirTemplateParsers, isElixirTemplateExtension } from "./elixir-templates.js";
import { detectExtensionFromSource, resolveExtensionlessDetection } from "./extensionless.js";
import { loadPathAliases } from "./graph-aliases.js";
import { extractImports } from "./graph-imports.js";
import { createGraphInputRecorder, decideGraphRebuild, GraphInputsMovedDuringBuild, graphCapabilitiesHash, scanDirectory, } from "./graph-inputs.js";
import { buildCsNamespaceMap, buildDartPackageMap, buildElixirModuleMap, buildGodotProjectIndexes, buildGodotUidIndexes, buildGoModuleInfo, buildJvmSuffixMap, buildPhpFqcnMap, buildPhpPsr4Map, buildPythonManifests, buildRustCrateMap, findGodotProjectRootForProject, findGodotRootForFile, parseGodotAutoloads, pythonRootsForFile, resolveImport } from "./graph-resolution.js";
import { computeUnresolvedPct, resolveCallSites, } from "./graph-symbol-resolution.js";
import { extractSymbolsAndCalls, rawCallsToUnresolvedEdges, } from "./graph-symbols.js";
import { createTypeScriptResolver } from "./graph-typescript.js";
import { createIgnoreFilter } from "./ignore.js";
import { logger } from "./logger.js";
import { gdscriptParserAvailable, setGdscriptParserAvailable } from "./parser-availability.js";
import { deleteGraphData, describeQdrantError, getGraphMetadata, loadGraphData, loadGraphInputs, saveGraphData } from "./qdrant.js";
import { assertNoReclamationBarrier, withWriterLock } from "./reclamation-barrier.js";
import { dropSymbolGraphCache, SymbolGraphCache, setSymbolGraphCache, } from "./symbol-graph-cache.js";
import { allNameShardKeys, cleanStaleGenerations, contentHashOf, coordinateProject, deleteGeneration, deleteSymbolGraphData, ensureSymbolGraphCollections, nameShardKey, registerStagingGeneration, reverseShardKeyForCallee, saveFilePayloads, saveNameShard, saveReverseShard, saveSymbolGraphMeta, unregisterStagingGeneration, } from "./symbol-graph-store.js";
// Re-export analysis functions for external consumers
export { describeGraphBuilder, describeUnresolvedSymbolEdges, findCircularDependencies, generateMermaidDiagram, getFileDependencies, getGraphStats, isGraphBuilderStale, isImportResolutionLow } from "./graph-analysis.js";
// createRequire needed to load native addon packages in ESM
const esmRequire = createRequire(import.meta.url);
/** Track which projects currently have a graph build in flight */
const graphBuildInProgress = new Map();
/** In-flight build promises — allows callers to share a single build */
const graphBuildPromises = new Map();
/** Track the last completed graph build per project */
const lastGraphBuildCompleted = new Map();
/** Check if a graph build is currently in progress for a project */
export function isGraphBuildInProgress(projectPath) {
return graphBuildInProgress.has(path.resolve(projectPath));
}
/** Get progress details for a graph build currently in progress */
export function getGraphBuildProgress(projectPath) {
return graphBuildInProgress.get(path.resolve(projectPath)) ?? null;
}
/** Get the last completed graph build for a project */
export function getLastGraphBuildCompleted(projectPath) {
return lastGraphBuildCompleted.get(path.resolve(projectPath)) ?? null;
}
/** Get all projects currently building a graph */
export function getGraphBuildInProgressProjects() {
return Array.from(graphBuildInProgress.keys());
}
// ── Graph cache (service-level, shared by tools and watcher) ─────────────
/** In-memory graph cache keyed by resolved project path */
/** Keyed by resolved path; the identity is kept from load time, since a checkout can change what it resolves to. */
const graphCache = new Map();
function cacheGraph(resolvedPath, graph, projectId = identityOfCachedPath(resolvedPath) ?? "") {
graphCache.set(resolvedPath, { graph, projectId });
}
/** Invalidate graph cache for a project (called by watcher on file changes) */
export function invalidateGraphCache(projectPath) {
graphCache.delete(path.resolve(projectPath));
}
/** A pinned identity can be cached under more than one checkout path; drop them all. */
export function invalidateGraphCacheForIdentity(projectId) {
for (const [cachedPath, entry] of Array.from(graphCache.entries())) {
if (entry.projectId === projectId || identityOfCachedPath(cachedPath) === projectId)
graphCache.delete(cachedPath);
}
}
function identityOfCachedPath(cachedPath) {
try {
return projectIdFromPath(cachedPath);
}
catch {
return null;
}
}
/** Get a cached graph, or load from Qdrant, or build one */
export async function getOrBuildGraph(projectPath, extraExtensions) {
const existing = await getExistingGraph(projectPath);
if (existing)
return existing;
const resolved = path.resolve(projectPath);
const graph = await buildCodeGraph(resolved, extraExtensions);
// Strip symbol fields when serving as a plain CodeGraph
const plain = { nodes: graph.nodes, edges: graph.edges };
cacheGraph(resolved, plain);
return plain;
}
/** Get a cached or persisted graph without creating one when it is absent. */
export async function getExistingGraph(projectPath) {
const resolved = path.resolve(projectPath);
const projectId = projectIdFromPath(resolved);
const cached = graphCache.get(resolved);
if (cached?.projectId === projectId)
return cached.graph;
if (cached)
graphCache.delete(resolved);
const graphCollName = graphCollectionName(projectId);
const persisted = await loadGraphData(graphCollName);
if (!persisted)
return null;
cacheGraph(resolved, persisted, projectId);
return persisted;
}
/**
* Whether an incremental update has to rebuild the code graph, and why.
*
* The answer comes from the record the last build left behind — the files it
* read and the settings it read them under — never from a predicate describing
* which files a graph is built from. There is no such predicate to keep in
* step, which is the whole reason the build reports its own inputs.
*
* Conservative wherever the record cannot settle the question: a project with
* no usable record rebuilds once and writes one, and so does any addition, any
* settings change, and any recorded input that moved. Only a change proven to
* touch nothing the build read is skipped.
*/
export async function shouldRebuildGraph(projectPath, change, extraExtensions, projectId) {
const resolved = path.resolve(projectPath);
try {
const graphCollName = graphCollectionName(projectId ?? projectIdFromPath(resolved));
const load = await loadGraphInputs(graphCollName);
if (load.status === "absent") {
// Nothing persisted: there is no graph here to refresh, and building one
// is not something an update that indexed nothing should decide to do.
return { rebuild: true, reason: "no code graph has been built yet", graphExists: false };
}
if (load.status === "failed") {
// A graph is presumed to be there and nothing is known about it, which
// is the most conservative of the three outcomes, not the quietest.
return {
rebuild: true,
reason: `the graph's stored inputs could not be read: ${load.error}`,
graphExists: true,
};
}
return await decideGraphRebuild(resolved, load.value, change, extraExtensions ?? EXTRA_EXTENSIONS, await currentGraphCapabilities(), loadPythonRoots(resolved));
}
catch (err) {
// Failing to answer is not an answer. Anything unexpected here leaves the
// caller doing exactly what it did before this gate existed.
logger.warn("Could not decide whether the code graph needs rebuilding — rebuilding", {
projectPath: resolved,
error: err instanceof Error ? err.message : String(err),
});
return {
rebuild: true,
reason: "the record of the last build could not be read",
graphExists: true,
};
}
}
/**
* The parsers this process actually has, as one hash.
*
* Read after {@link ensureDynamicLanguages} has run, which every path into the
* build and the decision does first, so it describes the process answering
* rather than some other one.
*/
export async function currentGraphCapabilities() {
return graphCapabilitiesHash({
loadedGrammars: getDynamicLanguageStatus().loaded,
gdscript: gdscriptParserAvailable,
// Resolved unconditionally, not only where the project has templates: the
// build and the decision have to answer this the same way or every Elixir
// project rebuilds on every update. The loader memoises its own promise,
// so this is a 10ms WASM load once per process and free thereafter — and
// it is load-bearing, since both import and symbol extraction fall back to
// a line/leaf approximation when the grammars are missing.
elixirTemplates: await ensureElixirTemplateParsers(),
});
}
/** Force-rebuild, cache, and persist a graph.
* If a build is already in progress for this project, returns the existing
* in-flight promise (deduplication — same as indexer concurrency guard).
*
* Backward-compatible: accepts either `extraExtensions` (legacy positional
* Set) or a `RebuildGraphOptions` object.
*/
export async function rebuildGraph(projectPath, optsOrExtras) {
const resolved = path.resolve(projectPath);
const opts = optsOrExtras instanceof Set ? { extraExtensions: optsOrExtras } : (optsOrExtras ?? {});
const projectId = opts.projectId ?? projectIdFromPath(resolved);
// Concurrency guard: if already building, return the existing promise
const existing = graphBuildPromises.get(resolved);
if (existing) {
if (existing.projectId !== projectId || opts.target) {
throw new Error("A graph build is already running; retry the Git refresh after it completes.");
}
logger.info("Graph build already in progress, joining existing build", { projectPath: resolved });
return existing.promise;
}
await assertNoReclamationBarrier(projectId);
// Start tracked build. A refused lock rejects here, so a caller joining the
// promise later sees the rejection and never a null.
const promise = withWriterLock(projectId, "graph", () => doRebuildGraph(resolved, { ...opts, projectId })).then((graph) => {
if (graph === null)
throw new Error(`Another process holds the graph lock for ${resolved}, or it could not be taken`);
return graph;
});
graphBuildPromises.set(resolved, { projectId, promise });
try {
return await promise;
}
finally {
graphBuildPromises.delete(resolved);
}
}
/**
* How many times a build is retried when files move underneath it.
*
* Bounded because a tree under continuous change will never produce a
* consistent snapshot, and an unbounded retry would spin rather than say so.
*/
const MAX_CONSISTENT_BUILD_ATTEMPTS = 3;
/**
* Build the graph, retrying from a fresh scan while files move underneath it.
*
* The build reads some sources twice, so a file edited between those reads
* yields a graph whose resolver half and node half describe different content.
* The recorder refuses to seal a record for that, and the answer is a fresh
* scan rather than a repaired one: nothing about the mixed attempt is worth
* keeping, and a retry is cheap relative to serving a graph nobody can date.
*
* Gives up after {@link MAX_CONSISTENT_BUILD_ATTEMPTS} by rethrowing, which
* the incremental update reports as a non-fatal graph-build failure and which
* leaves the previous graph — cached and persisted — exactly where it was.
*/
async function buildConsistentCodeGraph(resolvedPath, opts, progress) {
let moved;
for (let attempt = 1; attempt <= MAX_CONSISTENT_BUILD_ATTEMPTS; attempt++) {
// A retry starts from nothing, so the counters must too, or a watcher sees
// one build's files added to the last one's.
progress.filesTotal = 0;
progress.filesProcessed = 0;
progress.filesSkipped = undefined;
progress.phase = "scanning files";
try {
return await buildCodeGraph(resolvedPath, opts.extraExtensions, progress);
}
catch (err) {
if (!(err instanceof GraphInputsMovedDuringBuild))
throw err;
moved = err;
logger.warn("Files changed while the code graph was being built — retrying from a fresh scan", {
projectPath: resolvedPath,
attempt,
of: MAX_CONSISTENT_BUILD_ATTEMPTS,
conflicts: [...err.conflicts].map(([file, detail]) => ({ file, detail })).slice(0, 5),
});
}
}
throw moved;
}
/** Internal: performs the actual graph rebuild with progress tracking */
async function doRebuildGraph(resolvedPath, opts) {
const progress = {
startedAt: Date.now(),
filesTotal: 0,
filesProcessed: 0,
phase: "scanning files",
};
graphBuildInProgress.set(resolvedPath, progress);
try {
await opts.target?.assertCurrent();
// Nothing here replaces the cached or persisted graph until one attempt has
// come back internally consistent. The cache is deliberately *not* cleared
// first: a build that gives up must leave the previous graph serving, which
// clearing it up front would defeat.
const built = await buildConsistentCodeGraph(resolvedPath, opts, progress);
await opts.target?.assertCurrent();
const graph = { nodes: built.nodes, edges: built.edges };
const projectId = opts.projectId ?? projectIdFromPath(resolvedPath);
if (!opts.target)
cacheGraph(resolvedPath, graph, projectId);
// Persist file-import graph to Qdrant
progress.phase = "persisting";
const graphCollName = graphCollectionName(projectId);
if (!opts.target)
await saveGraphData(graphCollName, resolvedPath, graph, built.graphInputs);
// Build & persist symbol graph (resolution + sharded persistence) — unless
// the caller asked to skip it (Phase F watcher path).
let symbolGraphError;
if (!opts.skipSymbolGraph) {
try {
progress.phase = "resolving symbols";
// Use the autoload table built by buildCodeGraph (per-project, merged
// from all Godot project roots) for GDScript receiver-type resolution.
resolveCallSites(graph, built.symbolsByFile, built.outgoingCallsByFile, built.rustBindingsByFile, built.rustCrateRootByFile, built.rustInlineScopedCalls, built.rustInlineDeclaredSymbols, built.rustCrateRootsByFile, built.autoloadTable, built.inferredTypesByFile, built.memberAssignmentsByFile, built.resToRepoPathMap, built.godotContext);
progress.phase = "persisting symbols";
await persistSymbolGraph(projectId, resolvedPath, built.symbolsByFile, built.outgoingCallsByFile);
}
catch (err) {
if (opts.target)
throw new Error(`Symbol graph persistence failed: ${describeQdrantError(err)}`, { cause: err });
// Keep returning the file-import graph: it is built and saved, and the
// caller asked for it. But record WHY the symbol half is missing, with
// the server's own reason (a bare "Bad Request" names nothing), so the
// build is no longer reported as an unqualified success while
// codebase_impact quietly has no data.
symbolGraphError = describeQdrantError(err);
logger.error("Symbol graph build failed (file-import graph saved)", {
projectPath: resolvedPath,
error: symbolGraphError,
});
}
}
else {
// This build deliberately did not touch the symbol graph (the incremental
// watcher path passes skipSymbolGraph), so it has no standing to declare
// the symbol graph healthy. Carry any recorded failure forward, or a
// single edited file after a failed persist would overwrite the record
// with a clean one and hide a still-broken graph. Only the branch above,
// an actual successful persist, clears it.
symbolGraphError = lastGraphBuildCompleted.get(resolvedPath)?.symbolGraphError;
}
if (opts.target) {
await opts.target.assertCurrent();
// Do not advance the input record on a failed symbol build. A retry,
// including one after restart, must still see that reconciliation is due.
await saveGraphData(graphCollName, resolvedPath, graph, built.graphInputs);
await opts.target.assertCurrent();
cacheGraph(resolvedPath, graph, projectId);
}
lastGraphBuildCompleted.set(resolvedPath, {
completedAt: Date.now(),
durationMs: Date.now() - progress.startedAt,
filesProcessed: progress.filesProcessed,
filesSkipped: progress.filesSkipped,
nodesCreated: graph.nodes.length,
edgesCreated: graph.edges.length,
symbolGraphError,
});
return graph;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
progress.error = message;
lastGraphBuildCompleted.set(resolvedPath, {
completedAt: Date.now(),
durationMs: Date.now() - progress.startedAt,
filesProcessed: progress.filesProcessed,
filesSkipped: progress.filesSkipped,
nodesCreated: 0,
edgesCreated: 0,
error: message,
// A build that died before (or during) the symbol-graph phase did not fix
// it either, so preserve any failure the last build recorded. Reading it
// back out of the map is deliberate: this catch is outside the scope of
// the try's symbolGraphError, and the get resolves before the set.
// Without this a transient outage would wipe the record, and the next
// incremental build would carry the blank forward as "healthy".
symbolGraphError: lastGraphBuildCompleted.get(resolvedPath)?.symbolGraphError,
});
throw err;
}
finally {
graphBuildInProgress.delete(resolvedPath);
}
}
/** Persist the symbol graph: per-file payloads + sharded indices + meta. */
async function persistSymbolGraph(projectId, resolvedPath, symbolsByFile, outgoingCallsByFile) {
return coordinateProject(projectId, async () => {
await ensureSymbolGraphCollections(projectId);
// Build per-file payloads (need source bytes for contentHash).
const payloads = [];
let totalSymbols = 0;
let totalEdges = 0;
for (const [relPath, symbols] of symbolsByFile.entries()) {
const outgoingCalls = outgoingCallsByFile.get(relPath) ?? [];
let language = "plaintext";
const firstNonModule = symbols.find((s) => s.name !== "<module>");
if (firstNonModule)
language = firstNonModule.language;
else
language = symbols[0]?.language ?? language;
let contentHash = "";
try {
const src = await fs.readFile(path.join(resolvedPath, relPath), "utf-8");
contentHash = contentHashOf(src);
}
catch {
// ignore
}
payloads.push({
file: relPath, language, contentHash, symbols, outgoingCalls,
});
totalSymbols += symbols.filter((s) => s.name !== "<module>").length;
totalEdges += outgoingCalls.length;
}
// Build sharded indices
const nameShards = new Map();
for (const key of allNameShardKeys())
nameShards.set(key, {});
for (const [file, symbols] of symbolsByFile.entries()) {
for (const sym of symbols) {
if (sym.name === "<module>")
continue;
const shardKey = nameShardKey(sym.name);
const shard = nameShards.get(shardKey);
if (!shard)
continue;
const ref = { file, id: sym.id };
// Use hasOwn — `shard[sym.name]` would return Object.prototype.constructor
// (a function) for symbol names like "constructor" / "toString" / "hasOwnProperty".
const existing = Object.hasOwn(shard, sym.name) ? shard[sym.name] : undefined;
if (existing)
existing.push(ref);
else
shard[sym.name] = [ref];
}
}
const newGeneration = randomUUID();
registerStagingGeneration(projectId, newGeneration);
const reverseShardSets = new Map();
for (const edges of outgoingCallsByFile.values()) {
for (const e of edges) {
for (const calleeId of e.calleeCandidates) {
const bucket = reverseShardKeyForCallee(calleeId);
let shard = reverseShardSets.get(bucket);
if (!shard) {
shard = new Map();
reverseShardSets.set(bucket, shard);
}
let callers = shard.get(calleeId);
if (!callers) {
callers = new Set();
shard.set(calleeId, callers);
}
callers.add(e.callerId);
}
}
}
const reverseShards = new Map();
for (const [bucket, shardSets] of reverseShardSets.entries()) {
const shard = {};
for (const [calleeId, callers] of shardSets.entries()) {
shard[calleeId] = Array.from(callers);
}
reverseShards.set(bucket, shard);
}
try {
// Persist all payloads and shards into the staged generation first
await saveFilePayloads(projectId, payloads, newGeneration);
for (const [shardKey, shard] of nameShards.entries()) {
if (Object.keys(shard).length === 0)
continue;
await saveNameShard(projectId, shardKey, shard, newGeneration);
}
for (const [bucket, shard] of reverseShards.entries()) {
if (Object.keys(shard).length === 0)
continue;
await saveReverseShard(projectId, bucket, shard, newGeneration);
}
// Activate that generation with one metadata-pointer write only after every staged write succeeds
const meta = {
projectId,
symbolCount: totalSymbols,
edgeCount: totalEdges,
fileCount: symbolsByFile.size,
unresolvedEdgePct: computeUnresolvedPct(outgoingCallsByFile),
builtAt: Date.now(),
schemaVersion: 2,
generation: newGeneration,
};
await saveSymbolGraphMeta(projectId, meta);
// Replace cache entry
const cache = new SymbolGraphCache(projectId, meta);
setSymbolGraphCache(cache);
// Safely retire superseded generations and clean abandoned staged generations
await cleanStaleGenerations(projectId, newGeneration).catch((err) => {
logger.warn("Failed to clean stale symbol-graph generations", {
projectId,
error: err instanceof Error ? err.message : String(err),
});
});
logger.info("Symbol graph persisted", {
projectId,
files: meta.fileCount,
symbols: meta.symbolCount,
edges: meta.edgeCount,
unresolvedPct: meta.unresolvedEdgePct.toFixed(1),
});
}
catch (err) {
// Staging failed: clean up the incomplete staged generation immediately
await deleteGeneration(projectId, newGeneration).catch(() => { });
throw err;
}
finally {
unregisterStagingGeneration(projectId, newGeneration);
}
});
}
/**
* Wait for any in-flight graph build to finish for a project.
* Resolves immediately if no build is in progress.
* Swallows errors — the caller typically wants to proceed regardless.
*/
export async function awaitGraphBuild(projectPath) {
const resolved = path.resolve(projectPath);
const build = graphBuildPromises.get(resolved);
if (build) {
try {
await build.promise;
}
catch { /* swallow — caller proceeds regardless */ }
}
}
/** Remove a persisted code graph from Qdrant and clear cache */
export async function removeGraph(projectPath) {
const resolved = path.resolve(projectPath);
graphCache.delete(resolved);
const projectId = projectIdFromPath(resolved);
const graphCollName = graphCollectionName(projectId);
await deleteGraphData(graphCollName);
await deleteSymbolGraphData(projectId);
dropSymbolGraphCache(projectId);
logger.info("Removed code graph", { projectPath: resolved });
}
/** Check if a graph exists (in cache or persisted) */
export async function hasGraph(projectPath) {
const resolved = path.resolve(projectPath);
const projectId = projectIdFromPath(resolved);
if (graphCache.get(resolved)?.projectId === projectId)
return true;
const graphCollName = graphCollectionName(projectId);
const meta = await getGraphMetadata(graphCollName);
return meta !== null;
}
/** Get graph metadata for status display */
export async function getGraphStatus(projectPath) {
const resolved = path.resolve(projectPath);
const projectId = projectIdFromPath(resolved);
const graphCollName = graphCollectionName(projectId);
const meta = await getGraphMetadata(graphCollName);
if (!meta)
return null;
// Best-effort symbol-graph stats
let symbol;
try {
const { loadSymbolGraphMeta } = await import("./symbol-graph-store.js");
const sm = await loadSymbolGraphMeta(projectId);
if (sm) {
symbol = {
fileCount: sm.fileCount,
symbolCount: sm.symbolCount,
edgeCount: sm.edgeCount,
unresolvedEdgePct: sm.unresolvedEdgePct,
builtAt: sm.builtAt,
};
}
}
catch {
// symbol graph optional
}
return {
lastBuiltAt: meta.lastBuiltAt,
nodeCount: meta.nodeCount,
edgeCount: meta.edgeCount,
importCount: meta.importCount,
builtByVersion: meta.builtByVersion,
cached: graphCache.get(resolved)?.projectId === projectId,
symbol,
};
}
// ── Register dynamic language grammars ───────────────────────────────────
let dynamicLangsRegistered = false;
/**
* The declaration map a path written inside an inline `mod { … }` block is
* answered with: empty, because the file's own declarations are not in that
* block's scope. Shared and never written.
*/
const EMPTY_DECLARED_MODS = new Map();
const loadedDynamicLanguages = new Set();
const failedDynamicLanguages = new Map();
/** Returns which dynamic ast-grep grammars registered successfully and which failed. */
export function getDynamicLanguageStatus() {
return {
loaded: [...loadedDynamicLanguages].sort(),
failed: [...failedDynamicLanguages.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, error]) => ({ name, error })),
};
}
/**
* Whether the GDScript tree-sitter parser was successfully registered.
* tree-sitter-gdscript ships platform-specific prebuilds; on platforms
* without a compatible artifact (e.g. linux-arm64) this stays false so
* callers can skip AST processing and use syntax-aware fallback extraction.
*/
export { gdscriptParserAvailable } from "./parser-availability.js";
/**
* Preflight the tree-sitter-gdscript native addon in an isolated child
* process. This validates three things that a simple accessSync cannot:
*
* 1. The N-API addon loads (require does not throw).
* 2. It exposes a tree-sitter language object.
* 3. ast-grep can load its `tree_sitter_gdscript` symbol and parse a snippet.
*
* Running in a child process is essential because
* `registerDynamicLanguage` replaces all globally registered languages on
* each call — a failed registration in the parent would wipe out the
* other dynamic languages before we even try to batch them.
*
* The child resolves `node-gyp-build` from the tree-sitter-gdscript
* package's own context (via createRequire rooted at its package.json),
* so it does not rely on npm's hoisted node_modules layout.
*
* @param gdscriptPkgPath - Absolute path to tree-sitter-gdscript/package.json
* @returns The resolved native artifact path on success, null on failure.
*/
function preflightGdscriptAddon(gdscriptPkgPath) {
const preflightScript = path.join(path.dirname(fileURLToPath(import.meta.url)), "gdscript-preflight.cjs");
try {
const stdout = execFileSync(process.execPath, [preflightScript, gdscriptPkgPath], {
timeout: 10_000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
// On success the preflight prints: "PREFLIGHT: OK <nativePath>"
const match = stdout.match(/^PREFLIGHT: OK (.+)$/m);
if (match)
return match[1];
// If we get here, exit code was 0 but output was unexpected
logger.warn("GDScript preflight: unexpected output", { stdout });
return null;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Extract stderr from the child process error
const stderr = err?.stderr?.trim() ?? message;
failedDynamicLanguages.set("gdscript", stderr);
logger.warn("GDScript preflight failed", { error: stderr });
return null;
}
}
export function ensureDynamicLanguages() {
if (dynamicLangsRegistered)
return;
const langPackages = [
["python", "@ast-grep/lang-python"],
["go", "@ast-grep/lang-go"],
["java", "@ast-grep/lang-java"],
["rust", "@ast-grep/lang-rust"],
["c", "@ast-grep/lang-c"],
["cpp", "@ast-grep/lang-cpp"],
["csharp", "@ast-grep/lang-csharp"],
["ruby", "@ast-grep/lang-ruby"],
["kotlin", "@ast-grep/lang-kotlin"],
["swift", "@ast-grep/lang-swift"],
["scala", "@ast-grep/lang-scala"],
["bash", "@ast-grep/lang-bash"],
["php", "@ast-grep/lang-php"],
["lua", "@ast-grep/lang-lua"],
["dart", "@ast-grep/lang-dart"],
["elixir", "@ast-grep/lang-elixir"],
];
// Phase 1: Pre-validate each @ast-grep/lang-* grammar individually.
// A throwing libraryPath getter is isolated to its own grammar so the
// rest can still be registered. We do NOT add to loadedDynamicLanguages
// yet — that happens only after the batch succeeds.
const survivors = {};
const pendingLoaded = new Set();
for (const [name, pkg] of langPackages) {
try {
const mod = esmRequire(pkg);
// Pre-validate the lazy `libraryPath` getter. registerDynamicLanguage
// accesses this property for every entry, and a single throwing
// getter aborts the entire batch atomically (issue #43).
void mod.libraryPath;
survivors[name] = mod;
pendingLoaded.add(name);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
failedDynamicLanguages.set(name, message);
logger.warn("ast-grep grammar failed to load", { name, error: message });
}
}
// Phase 2: Preflight the GDScript native addon in an isolated child
// process. This validates the N-API addon loads, exports
// tree_sitter_gdscript, and ast-grep can parse with it — without
// risking the parent process's global language registry.
let gdscriptNativePath = null;
try {
const gdscriptPkgPath = esmRequire.resolve("tree-sitter-gdscript/package.json");
gdscriptNativePath = preflightGdscriptAddon(gdscriptPkgPath);
if (gdscriptNativePath) {
survivors.gdscript = {
libraryPath: gdscriptNativePath,
extensions: ["gd"],
languageSymbol: "tree_sitter_gdscript",
};
pendingLoaded.add("gdscript");
}
// If preflight returned null, the failure was already recorded in
// failedDynamicLanguages by preflightGdscriptAddon.
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
failedDynamicLanguages.set("gdscript", message);
logger.warn("ast-grep grammar failed to load", { name: "gdscript", error: message });
}
// Phase 3: Register all validated grammars in a single batch call.
// registerDynamicLanguage replaces all previously registered languages
// on each call, so every grammar must be in this one batch.
if (Object.keys(survivors).length > 0) {
try {
registerDynamicLanguage(survivors);
// Batch succeeded — mark all survivors as loaded and clear any
// stale failure entries from a previous failed attempt.
for (const name of pendingLoaded) {
loadedDynamicLanguages.add(name);
failedDynamicLanguages.delete(name);
}
if (survivors.gdscript) {
setGdscriptParserAvailable(true);
}
logger.info("Registered dynamic ast-grep languages", {
languages: [...loadedDynamicLanguages].sort(),
});
}
catch (err) {
// Batch failed — every candidate in the batch is affected.
// Record failure for all pending languages and keep retry
// state accurate by NOT setting dynamicLangsRegistered.
const message = err instanceof Error ? err.message : String(err);
for (const name of pendingLoaded) {
failedDynamicLanguages.set(name, message);
}
logger.warn("Dynamic language batch registration failed; all candidates affected", { error: message, candidates: [...pendingLoaded].sort() });
// Do NOT set dynamicLangsRegistered — allow a retry on next call.
return;
}
}
else {
logger.warn("No dynamic ast-grep grammars loaded; PHP, Python, JVM and other dynamic languages will fall through to <module>-only extraction");
}
if (failedDynamicLanguages.size > 0) {
logger.warn("Some dynamic ast-grep grammars failed to load; affected languages will produce only <module>-level symbols", { failed: [...failedDynamicLanguages.keys()].sort() });
}
dynamicLangsRegistered = true;
}
// ── Language mapping for ast-grep ────────────────────────────────────────
/** Map file extensions to ast-grep language identifiers */
const EXTENSION_TO_AST_GREP_LANG = {
// Dynamic languages (string identifiers)
".py": "python", ".pyw": "python", ".pyi": "python",
".java": "java",
".kt": "kotlin", ".kts": "kotlin",
".scala": "scala",
".c": "c", ".h": "c",
".cpp": "cpp", ".hpp": "cpp", ".cc": "cpp", ".hh": "cpp", ".cxx": "cpp",
".cs": "csharp",
".go": "go",
".rs": "rust",
".rb": "ruby",
".php": "php",
".swift": "swift",
".dart": "dart",
".ex": "elixir", ".exs": "elixir",
".lua": "lua",
".sh": "bash", ".bash": "bash", ".zsh": "bash",
".gd": "gdscript",
// Composite languages (parsed via HTML + script re-parse)
".svelte": "svelte",
".vue": "vue",
// Built-in languages (Lang enum)
".js": Lang.JavaScript, ".jsx": Lang.JavaScript, ".mjs": Lang.JavaScript, ".cjs": Lang.JavaScript,
".ts": Lang.TypeScript,
".tsx": Lang.Tsx,
".html": Lang.Html, ".htm": Lang.Html,
".css": Lang.Css, ".scss": Lang.Css, ".sass": Lang.Css, ".less": Lang.Css, ".styl": Lang.Css,
};
/**
* Map a file extension to its ast-grep grammar (or null when none). An
* EXTENSION_LANGUAGE_MAP override is resolved through the target language's
* canonical extension, so a mapped extension (e.g. `.inc` → php) gets the same
* grammar a native file of that language would, keeping symbol extraction and
* AST chunking consistent with the language label.
*/
export function getAstGrepLang(ext, override = EXTENSION_LANGUAGE_MAP) {
// Match getLanguageFromExtension: normalize casing so override lookups (keys
// are stored lowercased) and the grammar stay aligned with the label.
const normalized = ext.toLowerCase();
const target = override.get(normalized) ?? normalized;
return EXTENSION_TO_AST_GREP_LANG[target] ?? null;
}
// ── Graph building ───────────────────────────────────────────────────────
/**
* Get all source files in a project for graph analysis, with the detected
* extension of every extensionless file admitted by content detection.
*
* Includes files with known AST grammars, mixed Elixir templates handled by their
* dedicated parsers, and any extra extensions. Extensionless files are head-read
* here to decide admission, and the extension that decision
* used is returned in `detectedExts` so the build pass can reuse it instead of
* reading the head again.
*
* `files` is sorted lexicographically. Node documents no readdir ordering, and a
* depth-first walk additionally interleaves a directory's contents with the
* sibling entries that sort after it — `a/x.ts` is yielded before `a.ts`.
* Processing order determines node insertion order, and `buildJvmSuffixMap`'s
* first-match-wins tie-break for duplicate class paths reads the set in that
* order directly (`buildCsNamespaceMap` and `buildGoModuleInfo` sort their own
* filtered views instead). Normalize here rather than leaving it to the traversal.
*/
export async function getGraphableFiles(projectPath, extraExts, recorder) {
const ig = createIgnoreFilter(projectPath);
// The ignore files shaped this walk, so they are inputs to the graph as much
// as any source file is: a rule added to one can add or remove nodes without
// a single source file changing. The filter hands over the bytes it was
// built from, so this records the state the walk actually ran under rather
// than whatever a second read would find.
if (recorder) {
for (const source of ig.sources) {
recorder.read(path.join(projectPath, source.path), source.content);
}
// An environment root is excluded because of a marker inside it, and the
// walk never enters it — so nothing below would ever be listed, and the
// parent's listing does not move when the marker goes. List the root
// itself: `pyvenv.cfg` disappearing is then an entry-name change like any
// other, and the subtree it re-admits is not missed.
for (const environment of ig.environments) {
const absolute = path.join(projectPath, environment);
try {
const entries = await fs.readdir(absolute, { withFileTypes: true });
recorder.directory(absolute, scanDirectory(ig, projectPath, absolute, entries).listing);
}
catch {
// A directory, so it is re-checked with a `readdir` like any other —
// the file bucket's readability test would answer a question nobody
// asked of it.
recorder.unreadableDirectory(absolute);
}
}
}
const extras = extraExts ?? EXTRA_EXTENSIONS;
const files = [];
const detectedExts = new Map();
// Match getIndexableFiles' dotfile policy (glob dot:false by default) so the
// graph and the index admit the same extensionless *dotfiles* — otherwise a
// dot-named file like .bashrc/.profile would be graphed but never indexed
// (this walk sees dotfiles; the index's glob does not). (Files nested under a
// dot-directory are a separate, pre-existing divergence, not handled here.)
const includeDotFiles = (process.env.INCLUDE_DOT_FILES ?? "false").toLowerCase() === "true";
async function walk(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
}
catch (err) {
// Every file under an unreadable directory leaves the walk here, before it
// has a path of its own to report, so none of them reach the build loop's
// skip accounting — this log is their only trace. ENOENT stays quiet for a
// directory removed mid-walk, which has nothing left to graph, but not for
// the project root: there it means the whole project is missing, and the
// build would otherwise report a clean empty graph.
const code = err?.code;
if (code !== "ENOENT" || dir === projectPath) {
logger.debug("Could not read directory in graph discovery (subtree omitted)", {
dir: toForwardSlash(path.relative(projectPath, dir)) || ".",
error: err instanceof Error ? err.message : String(err),
});
}
// The subtree contributed nothing, and nothing else records that it was
// even reached: the parent's listing holds this directory's name either
// way, so a subtree that becomes listable would otherwise add nodes with
// no recorded input having moved.
recorder?.unreadableDirectory(dir);
return;
}
// The listing itself is an input: it is the only read that can tell the
// next update a file appeared — including the ones no index holds, like a
// nested `go.mod` or a `.uid` sidecar. The filter is applied here, once,
// by the same function the rebuild check re-reads the directory with, and
// the walk iterates what it kept rather than asking the filter again.
const { kept, listing } = scanDirectory(ig, projectPath, dir, entries);
recorder?.directory(dir, listing);
for (const entry of kept) {
const fullPath = path.join(dir, entry.name);
const relPath = toForwardSlash(path.relative(projectPath, fullPath));
if (entry.isDirectory()) {
await walk(fullPath);
}
else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
// Mixed Elixir templates use dedicated parsers, not the Elixir grammar.
// Godot resource files (.tscn/.tres) have no AST grammar but use tokenizer-based
// import extraction for [ext_resource] declarations.
const isGodotResource = ext === ".tscn" || ext === ".tres";
// .uid sidecar files contain UID strings for Godot's uid:// path
// resolution. They are not graph nodes themselves, but they must
// be in the file set so buildGodotUidIndexes can read them.
const isGodotUid = ext === ".uid";
if (getAstGrepLang(ext) !== null || extras.has(ext) || ELIXIR_TEMPLATE_EXTENSIONS.has(ext) || isGodotResource || isGodotUid) {
files.push(relPath);
}
else if (ext === "" && (includeDotFiles || !entry.name.startsWith("."))) {
// Extensionless: admit only when detection yields a grammar-bearing
// canonical extension. `.txt`-detected files stay out of the graph:
// we don't start adding grammar-less extensionless leaf nodes (only
// extra-extension files and mixed Elixir templates are grammar-less).
// Extensionless dotfiles are skipped unless INCLUDE_DOT_FILES, to stay
// consistent with the index (see includeDotFiles above).
const { extension: detected, head, unreadable } = await resolveExtensionlessDetection(fullPath);
if (detected && getAstGrepLang(detected) !== null) {
files.push(relPath);
detectedExts.set(relPath, detected);
}
else if (unreadable) {
// The head-read threw. Nothing was scored, so there is no head to
// watch — but the file becoming readable could make it a node, and
// no other bucket would move when it does.
recorder?.unreadable(fullPath);
}
else if (head !== null) {
// Read to decide, and turned away. Those bytes decided the shape of
// the file set, so they are what is watched — the head, not the
// whole file (which was never read) and not the size (which a
// same-length edit can flip the answer without moving).
recorder?.head(fullPath, head);
}
}
}
}
}
await walk(projectPath);
files.sort();
return { files, detectedExts };
}
/**
* Build a code graph for a project using ast-grep for polyglot support.
* Files with extra extensions (no AST grammar) are included as leaf nodes
* that can be targets of import edges from other files.
*
* Also extracts symbols and call sites in the same pass — returned via
* `symbolsByFile` / `outgoingCallsByFile` and persisted by `doRebuildGraph`.
*/
export async function buildCodeGraph(projectPath, extraExtensions, progress) {
ensureDynamicLanguages();
const resolvedPath = path.resolve(projectPath);
const recorder = createGraphInputRecorder(resolvedPath);
const effectiveExtras = extraExtensions ?? EXTRA_EXTENSIONS;
const configuredPythonRoots = loadPythonRoots(resolvedPath);
const aliases = await loadPathAliases(resolvedPath, recorder);
const { files, detectedExts } = await getGraphableFiles(resolvedPath, extraExtensions, recorder);
const fileSet = new Set(files);
const hasJsTs = files.some((file) => ["javascript", "typescript", "svelte", "vue"].includes(getLanguageFromExtension(path.extname(file))));
const tsContextFor = hasJsTs ? createTypeScriptResolver(resolvedPath, fileSet, recorder) : undefined;
if (files.some((file) => isElixirTemplateExtension(path.extname(file)))) {
await ensureElixirTemplateParsers();
}
// Build GDScript class_name index once for O(1) extends resolution.
// Scans all .gd files in a single pass; avoids 68k+ redundant reads for
// large Godot projects where every file has an extends statement.
// Per-Godot-project: each project.godot root gets its own class_name index
// so class names in one Godot project don't leak into another.
const hasGdscript = files.some((f) => f.endsWith(".gd"));
const hasGodotFiles = hasGdscript || files.some((f) => f.endsWith(".tscn") || f.endsWith(".tres"));
const godotRootCache = new Map();
// Per-project Godot indexes: maps each Godot project root to its scoped
// class_name index. Used for per-file res:// and extends resolution.
const godotProjectIndexes = hasGodotFiles
? buildGodotProjectIndexes(resolvedPath, fileSet, godotRootCache, recorder)
: undefined;
// Per-project UID indexes: maps each Godot project root to its scoped
// uid:// → relative path index. Used for uid:// resolution in GDScript
// and Godot resource files. Godot prefers UIDs over text paths.
const godotProjectUidIndexes = hasGodotFiles
? buildGodotUidIndexes(resolvedPath, fileSet, godotRootCache, recorder)
: undefined;
// Symbol resolution needs the same nearest-project boundary as import
// resolution. Keep each project's class names, autoloads, and res:// root
// isolated so duplicate names in sibling or nested projects cannot cross.
let godotContext;
if (hasGodotFiles) {
const projectRootByFile = new Map();
for (const relPath of files) {
if (!relPath.endsWith(".gd") && !relPath.endsWith(".tscn") && !relPath.endsWith(".tres")) {
continue;
}
const root = findGodotRootForFile(path.join(resolvedPath, relPath), godotProjectIndexes, godotRootCache);
if (root)
projectRootByFile.set(relPath, root);
}
const projectsByRoot = new Map();
for (const [godotRoot, classNameIndex] of godotProjectIndexes ?? []) {
const rootOffset = toForwardSlash(path.relative(resolvedPath, godotRoot));
const autoloadTable = new Map();
for (const [name, resourcePath] of parseGodotAutoloads(godotRoot, recorder)) {
const repoPath = rootOffset ? `${rootOffset}/${resourcePath}` : resourcePath;
autoloadTable.set(name, repoPath);
}
projectsByRoot.set(godotRoot, { rootOffset, classNameIndex, autoloadTable });
}
if (projectsByRoot.size > 0) {
godotContext = { projectRootByFile, projectsByRoot };
}
}
if (progress) {
progress.filesTotal = files.length;
progress.phase = "analyzing imports";
}
logger.info("Building code graph", { projectPath: resolvedPath, fileCount: files.length });
const nodesMap = new Map();
const edges = [];
const symbolsByFile = new Map();
const outgoingCallsByFile = new Map();
const inferredTypesByFile = new Map();
const memberAssignmentsByFile = new Map();
const rustBindingsByFile = new Map();
const rustInlineScopedCalls = new Set();
const rustInlineDeclaredSymbols = new Map();
// Per-reason counts, holding only the reasons that actually fired — the build log
// emits `skipReasons` straight from this map, so it never carries a zero.
const skipsByReason = new Map();
let filesSkipped = 0;
const recordSkip = (file, reason, detail) => {
logger.debug("Skipping file in graph build", { file, reason, ...detail });
skipsByReason.set(reason, (skipsByReason.get(reason) ?? 0) + 1);
filesSkipped++;
if (progress) {
progress.filesProcessed++;
progress.filesSkipped = filesSkipped;
}
};
// Build a suffix lookup map for JVM multi-module projects (Java/Kotlin/Scala).
// This resolves FQNs like com.example.Foo when the class lives under a nested
// src/main/java/ tree (e.g. module-a/sub/src/main/java/com/example/Foo.java).
// Cost: O(n) once here, O(1) per import lookup (negligible vs. full AST parse).
const hasJvm = files.some((f) => {
const e = path.extname(f).toLowerCase();
return e === ".java" || e === ".kt" || e === ".kts" || e === ".scala";
});
const jvmSuffixMap = hasJvm ? buildJvmSuffixMap(fileSet) : undefined;
// Build the PSR-4 prefix map for PHP projects. A namespace carries no path
// information, so without the declared mapping every `use` statement that
// does not happen to mirror the directory layout resolved to null — which is
// all of them in a Composer monorepo, where each package declares its own
// PSR-4 root under `packages/<name>/src`.
//
// The declaration-derived FQCN map backs it up. A package that registers its
// namespaces at run time (`$loader->addNamespace(...)`) and declares
// `"autoload": {}` has no map to read, so PSR-4 answers nothing for it and
// every one of its files stayed orphaned — the WordPress-plugin norm (issue
// #120). Consulted only after a PSR-4 miss, so the manifest stays the
// authority wherever one exists.
const hasPhp = files.some((f) => path.extname(f).toLowerCase() === ".php");
const phpPsr4Map = hasPhp ? buildPhpPsr4Map(resolvedPath, recorder) : undefined;
const phpFqcnMap = hasPhp ? buildPhpFqcnMap(fileSet, resolvedPath, recorder) : undefined;
// Build a namespace lookup map for C# projects. Each `namespace X.Y.Z` block
// (or file-scoped `namespace X.Y.Z;`) is recorded so `using X.Y.Z;` directives
// can be resolved to the file(s) that contribute to that namespace. Without
// this, every C# import resolved to null and the file graph was empty.
const hasCs = files.some((f) => path.extname(f).toLowerCase() === ".cs");
const csNamespaceMap = hasCs ? buildCsNamespaceMap(fileSet, resolvedPath, recorder) : undefined;
// Build Go module-resolution info from every go.mod in the tree (issue
// #45 for a root-level go.mod; issue #82 for nested modules in a
// monorepo). Without this, every Go import resolved to null and Go
// projects produced an empty file graph. buildGoModuleInfo discovers
// go.mod itself (independently of the graphable file set) and returns
// one entry per module, or an empty array when none parse; the resolver
// treats an empty/undefined result as "no Go resolution available" and
// behaves exactly as it did before this feature for those cases.
const hasGo = files.some((f) => f.endsWith(".go"));
const goModuleInfo = hasGo ? buildGoModuleInfo(fileSet, resolvedPath, recorder) : undefined;
// Map each in-repo Dart package name to its root, from every pubspec.yaml
// in the tree (discovered by walking, like go.mod — pubspec.yaml is never
// in the graphable file set). Flutter code imports intra-project files as
// `package:<name>/...` by convention, so without this map those imports
// all resolved to null and Dart projects lost nearly every file-graph edge
// (issue #106). An empty/undefined map keeps the resolver's old behavior:
// every `package:` import stays unresolved.
const hasDart = files.some((f) => path.extname(f).toLowerCase() === ".dart");
const dartPackageMap = hasDart ? buildDartPackageMap(resolvedPath, recorder) : undefined;
// Record the import roots every pyproject.toml in the tree implies, plus the
// workspace members each declares (discovered by walking, like go.mod and
// pubspec.yaml — pyproject.toml is never in the graphable file set). A
// workspace package's modules live under its own `src/`, which the
// resolver's project-root probe cannot reach, so without these roots every
// cross-package import — and every package's own absolute self-import —
// resolved to null and the file graph came out all but empty (issue #107).
// Explicit `.socraticode.json` roots precede these inferred roots in their
// declared order (issue #171). Empty lists keep the resolver's old behavior
// exactly.
const hasPython = files.some((f) => getLanguageFromExtension(path.extname(f).toLowerCase()) === "python");
const pythonManifests = hasPython ? buildPythonManifests(resolvedPath, recorder) : [];
// Which manifest roots apply, and in what order, depends on where the
// importing file sits, so the combined list is resolved per directory rather
// than once for the project — cached because a package directory typically
// holds many files.
const pythonRootsByDir = new Map();
const pythonRootsFor = (relPath) => {
if (configuredPythonRoots.length === 0 && pythonManifests.length === 0)
return undefined;
const dir = toForwardSlash(path.dirname(relPath));
let roots = pythonRootsByDir.get(dir);
if (!roots) {
roots = [
...new Set([
...configuredPythonRoots,
...pythonRootsForFile(pythonManifests, dir),
]),
];
pythonRootsByDir.set(dir, roots);
}
return roots;
};
// Elixir module names do not imply paths. Resolve directives against
// in-project `defmodule` declarations.
const hasElixir = files.some((f) => [".ex", ".exs"].includes(path.extname(f).toLowerCase()));
const elixirModuleMap = hasElixir ? buildElixirModuleMap(fileSet, resolvedPath, recorder) : undefined;
// Record every crate the tree declares, from each Cargo.toml (discovered by
// walking, like go.mod and pubspec.yaml — Cargo.toml is never in the
// graphable file set). A Rust path names a position in a module tree
// (`crate::`, `super::`) or another crate by name, neither of which the
// resolver could follow without knowing where each crate's root module sits:
// every specifier containing `::` resolved to null, so the file graph held
// only bare `mod` declarations. An empty list keeps `mod`, `super` and `self`
// resolving from the file's own position, as before.
const hasRust = files.some((f) => path.extname(f).toLowerCase() === ".rs");
const rustCrates = hasRust ? buildRustCrateMap(fileSet, resolvedPath, recorder) : undefined;
// Which crate each Rust file belongs to, as a path prefix. `crate::` is
// relative to a crate's own root module, so resolution needs the boundary —
// and the manifests are what draw it. Deriving it from the path instead
// means guessing at a layout: a marker like `src/` misses a crate that has
// no `src/` directory (ripgrep) and misreads one whose sources start at the
// project root (tokio), and the guess fails silently in both.
//
// The owning crate is the one whose directory contains the file and is
// deepest, with the workspace root at `"."` ranking as no depth at all —
// the same rule the import resolver settled on. A crate at the root confines
// nothing, which is correct: there is only one crate to be in.
const rustCrateRootByFile = new Map();
const rustCrateRootsByFile = new Map();
if (rustCrates && rustCrates.length > 0) {
const depthOf = (crate) => (crate.dir === "." ? 0 : crate.dir.length);
const ranked = [...rustCrates].sort((a, b) => depthOf(b) - depthOf(a));
for (const relPath of files) {
if (!relPath.endsWith(".rs"))
continue;
const owner = ranked.find((c) => c.dir === "." || relPath.startsWith(`${c.dir}/`));
if (owner) {
rustCrateRootByFile.set(relPath, owner.dir === "." ? "" : `${owner.dir}/`);
// Only a parsed manifest is complete enough to make absence a verdict.
// An unreadable manifest contributes convention roots for import
// recovery, but those roots must not hide a custom target it could not
// declare to us.
if (!owner.manifestUnread)
rustCrateRootsByFile.set(relPath, [...owner.roots]);
}
}
}
for (const relPath of files) {
let ext = path.extname(relPath).toLowerCase();
let lang = getAstGrepLang(ext);
const isElixirTemplate = isElixirTemplateExtension(ext);
const wasExtensionless = ext === "";
// Extensionless entries carry the extension discovery detected when it
// head-read them to decide admission; reuse it so the file clears the
// grammar-less-leaf gate below without a second head-read — without it an
// extensionless file would become a leaf node instead of being parsed or
// counted. What it is actually parsed as comes from the re-detection on the
// read bytes further down, which supersedes this one. Discovery admits an
// extensionless path only together with a grammar-bearing detection, so the
// guard below narrows types rather than handling a case that can occur.
if (!lang && wasExtensionless) {
const detected = detectedExts.get(relPath);
const detectedLang = detected ? getAstGrepLang(detected) : null;
if (!detected || !detectedLang)
continue;
ext = detected;
lang = detectedLang;
}
// .uid sidecar files are in the file set for UID index building but
// are not graph nodes — they contain no code, just a UID string.
if (ext === ".uid") {
if (progress)
progress.filesProcessed++;
continue;
}
// Extra extensions with no parser are included as leaf nodes so they can be
// targets of import edges, but we skip import extraction since we can't
// parse them. Godot resource files (.tscn/.tres) are an exception: they
// have no AST grammar but use tokenizer-based import extraction for
// [ext_resource] declarations, so they pass through to
// the extraction path below.
const isGodotResource = ext === ".tscn" || ext === ".tres";
const isGodotUid = ext === ".uid";
// Sidecars stay in fileSet so the UID index can read them, but they are
// metadata rather than graph nodes and never enter the source-read path.
if (isGodotUid) {
if (progress)
progress.filesProcessed++;
continue;
}
if (!lang && !isElixirTemplate && !isGodotResource) {
const absolutePath = path.join(resolvedPath, relPath);
// A leaf node is made from its path alone — nothing here reads it — so
// only its presence is recorded. Its content reaching the graph would
// mean it had a parser, which is the branch above.
recorder.present(absolutePath);
if (!nodesMap.has(relPath)) {
nodesMap.set(relPath, {
filePath: absolutePath,
relativePath: relPath,
imports: [],
exports: [],
dependencies: [],
dependents: [],
});
}
if (progress)
progress.filesProcessed++;
continue;
}
const absolutePath = path.join(resolvedPath, relPath);
let source;
try {
const stat = await fs.stat(absolutePath);
if (stat.size > MAX_GRAPH_FILE_BYTES) {
recordSkip(relPath, "oversized", { size: stat.size, limit: MAX_GRAPH_FILE_BYTES });
// The size is what excluded it, so the size is what is watched: a file
// that shrinks under the limit becomes a node the next build.
recorder.present(absolutePath, stat.size);
continue;
}
source = await fs.readFile(absolutePath, "utf-8");
recorder.read(absolutePath, source);
}
catch (err) {
// ENOENT means the file vanished between discovery and this read; anything
// else is a real fault worth the error text. Both drop the file, so both
// count — unlike a discovery-time detection miss, which keeps the file out
// of `files` entirely, leaving nothing here to count.
if (err?.code === "ENOENT")
recordSkip(relPath, "vanished");
else
recordSkip(relPath, "read-failed", { error: err instanceof Error ? err.message : String(err) });
// Tried and failed, which is not the same as deliberately not opened: a
// file that starts being readable starts being a node.
recorder.unreadable(absolutePath);
continue;
}
// Detection ran on a head-read during discovery, so content may have changed
// before this read. Re-detect on the bytes about to be parsed and parse under
// the grammar those bytes call for: the fresh answer describes what is in
// hand, so it supersedes discovery's rather than being compared to it. Only
// content that no longer detects as a grammar-bearing language has nothing to
// parse with, and that is the skip — labelling it from the stale detection
// would write junk imports and symbols into the graph.
if (wasExtensionless) {
const redetected = detectExtensionFromSource(source);
const redetectedLang = redetected ? getAstGrepLang(redetected) : null;
if (!redetected || !redetectedLang) {
recordSkip(relPath, "content-changed", { atDiscovery: ext, redetected });
continue;
}
ext = redetected;
lang = redetectedLang;
}
const language = getLanguageFromExtension(ext);
// get() may return a placeholder an earlier importer created, whose
// dependents are already populated — keep it rather than replacing it.
let node = nodesMap.get(relPath);
if (!node) {
node = {
filePath: absolutePath,
relativePath: relPath,
imports: [],
exports: [],
dependencies: [],
dependents: [],
};
nodesMap.set(relPath, node);
}
// Record the (post-detection) language so display/stats sites don't have to
// re-derive it from the path — which silently mislabels extensionless files
// as plaintext.
node.language = language;
const extractionLang = lang ?? (isGodotResource ? "godot-resource" : "elixir-template");
const importInfos = extractImports(source, extractionLang, ext);
// Extract symbols & raw call sites in the same pass
try {
const extracted = extractSymbolsAndCalls(source, extractionLang, ext, relPath);
symbolsByFile.set(relPath, extracted.symbols);
const edges = rawCallsToUnresolvedEdges(extracted.rawCalls);
outgoingCallsByFile.set(relPath, edges);
// One edge per raw call, in order, so the call at `i` is the edge at `i`.
// The flag stays out of the edge itself: it is resolution's business and
// the store must not grow a field for it.
for (let i = 0; i < extracted.rawCalls.length; i++) {
if (extracted.rawCalls[i].qualifierRootedInInlineMod)
rustInlineScopedCalls.add(edges[i]);
}
// Same reason, one level down: which declarations sit inside an inline
// `mod` is visible in the extractor and nowhere after it.
if (extracted.inlineModSymbolIds) {
for (const [id, owner] of extracted.inlineModSymbolIds) {
rustInlineDeclaredSymbols.set(id, owner);
}
}
if (extracted.bindings && extracted.bindings.length > 0) {
rustBindingsByFile.set(relPath, extracted.bindings);
}
if (extracted.inferredTypes && extracted.inferredTypes.size > 0) {
inferredTypesByFile.set(relPath, extracted.inferredTypes);
}
if (extracted.memberAssignments && extracted.memberAssignments.length > 0) {
memberAssignmentsByFile.set(relPath, extracted.memberAssignments);
}
}
catch (err) {
logger.debug("Symbol extraction failed (continuing)", {
file: relPath,
error: err instanceof Error ? err.message : String(err),
});
}
// Which modules this file brings into the tree by declaring them. A Rust
// path with an unanchored head can only reach a module the file declares,
// and the declaration is the only evidence of that in the source: matching
// a neighbouring file by name instead let a third-party head capture the
// import whenever a file of that name happened to exist.
//
// The name comes from the declaration itself and not from its specifier.
// Reading the specifier's last segment got both ends wrong: a
// `#[path = "custom.rs"] mod foo;` recorded `custom.rs` and lost every
// `use foo::Item;` in that file, and a `mod bar;` written inside
// `mod outer { … }` recorded `bar` as if the file declared it — which
// handed a same-named neighbouring file back the capture this gate exists
// to stop. Both checked with cargo 1.70.0 and 1.98.0: the attribute form
// compiles with `foo` in scope, and at the file's own level a name
// declared inside an inline block reaches the dependency instead, or
// fails with E0432 when there is none.
// The map carries the file with the name, because half the declarations
// move it: `#[path = "custom.rs"] mod foo;` names `foo` and files it at
// `custom.rs`, and a resolver holding only the name looks for `src/foo.rs`,
// finds nothing, and falls through to a library called `foo` if the
// workspace has one — an edge into an unrelated crate. Where the
// declaration does not move anything, the value is the name itself.
const declaredMods = new Map();
const tsContext = ["javascript", "typescript", "svelte", "vue"].includes(language)
? tsContextFor?.(absolutePath) : undefined;
for (const imp of importInfos) {
if (imp.declaredName)
declaredMods.set(imp.declaredName, imp.moduleSpecifier);
}
// Per-file Godot project root and scoped class_name index.
// Computed once per file, outside the import loop, since the root
// does not change between imports from the same file.
// Each Godot source file resolves res:// and extends relative to its
// nearest project.godot ancestor, with class_name lookups scoped to
// that same project. This supports repos with multiple Godot projects.
// When per-project indexes are available but a file has no project.godot
// ancestor, both root and index are null/undefined — class_name and
// res:// resolution are skipped for that file, matching Godot's
// project-scoped semantics.
// Non-Godot files skip this lookup entirely (the parameters are ignored
// by their resolveImport case) to avoid unnecessary filesystem walks.
let fileGodotRoot;
let fileClassNameIndex;
let fileUidIndex;
const isGodotFile = language === "gdscript" || language === "godot-resource";
if (godotProjectIndexes && isGodotFile) {
fileGodotRoot = findGodotRootForFile(absolutePath, godotProjectIndexes, godotRootCache);
if (fileGodotRoot) {
fileClassNameIndex = godotProjectIndexes.get(fileGodotRoot);
fileUidIndex = godotProjectUidIndexes?.get(fileGodotRoot);
}
// fileClassNameIndex stays undefined when no project root is found,
// preventing cross-project class_name leakage.
}
// Non-Godot files or projects without Godot indexes: fileClassNameIndex
// and fileGodotRoot stay undefined — resolveImport ignores them.
for (const imp of importInfos) {
node.imports.push(imp.moduleSpecifier);
// Try to resolve to a project file
// CSS imports from <style> blocks use CSS resolution even when the source file is Svelte/Vue
const resolutionLanguage = imp.isCssImport ? "css" : language;
// A bare path written inside an inline `mod { … }` block is answered
// without the file's declarations: they are not in that block's scope,
// and handing them over drew an edge rustc rejects with E0432. The map is
// emptied rather than omitted — omitting it turns the gate off entirely,
// which is the looser reading, not a stricter one. Edition 2015 is
// unaffected: there the path is absolute from the crate root and never
// consulted the map to begin with.
const scopedMods = imp.fromInlineBlock ? EMPTY_DECLARED_MODS : declaredMods;
const jsBareImport = tsContext && !imp.isCssImport && !imp.moduleSpecifier.startsWith(".");
const resolved = jsBareImport ? tsContext.resolve(imp)
: resolveImport(imp.moduleSpecifier, absolutePath, resolvedPath, fileSet, resolutionLanguage, tsContext?.aliases ?? aliases, jvmSuffixMap, csNamespaceMap, goModuleInfo, phpPsr4Map, dartPackageMap, pythonRootsFor(relPath), elixirModuleMap, phpFqcnMap, rustCrates, scopedMods, imp.isModuleDeclaration === true, fileClassNameIndex, fileGodotRoot, fileUidIndex, imp.fallbackSpecifier, imp.godotImportKind);
if (resolved) {
node.dependencies.push(resolved);
// Ensure target node exists
if (!nodesMap.has(resolved)) {
// A target may be skipped when its own turn comes and so never build
// its own node; carry the discovery-detected language here, since the
// path alone would report an extensionless script as plaintext.
const targetExt = detectedExts.get(resolved);
nodesMap.set(resolved, {
filePath: path.join(resolvedPath, resolved),
relativePath: resolved,
imports: [],
exports: [],
dependencies: [],
dependents: [],
language: targetExt ? getLanguageFromExtension(targetExt) : undefined,
});
}
nodesMap.get(resolved)?.dependents.push(relPath);
edges.push({
source: relPath,
target: resolved,
type: imp.isDynamic ? "dynamic-import" : "import",
});
}
}
if (progress)
progress.filesProcessed++;
}
logger.info("Code graph built", {
nodes: nodesMap.size,
edges: edges.length,
filesSkipped,
...(skipsByReason.size > 0 ? { skipReasons: Object.fromEntries(skipsByReason) } : {}),
});
// Preserve the single-project fields introduced with GDScript symbol
// resolution for direct callers. Multi-project builds deliberately leave
// them empty: no unscoped map can represent duplicate autoload names or
// duplicate res:// paths safely, and `godotContext` carries the complete
// caller-scoped representation used by the production resolver.
let autoloadTable;
const resToRepoPathMap = new Map();
if (godotContext?.projectsByRoot.size === 1) {
const [godotRoot, project] = godotContext.projectsByRoot.entries().next().value;
autoloadTable = new Map(project.autoloadTable);
const prefix = project.rootOffset ? `${project.rootOffset}/` : "";
for (const [repoPath, ownerRoot] of godotContext.projectRootByFile) {
if (ownerRoot !== godotRoot)
continue;
const resourcePath = prefix && repoPath.startsWith(prefix)
? repoPath.slice(prefix.length)
: repoPath;
resToRepoPathMap.set(resourcePath, repoPath);
}
}
else if (!godotContext) {
const godotRoot = findGodotProjectRootForProject(resolvedPath);
if (godotRoot) {
const rootOffset = toForwardSlash(path.relative(resolvedPath, godotRoot));
autoloadTable = new Map();
for (const [name, resourcePath] of parseGodotAutoloads(godotRoot, recorder)) {
const repoPath = rootOffset ? `${rootOffset}/${resourcePath}` : resourcePath;
autoloadTable.set(name, repoPath);
resToRepoPathMap.set(resourcePath, repoPath);
}
}
}
return {
nodes: Array.from(nodesMap.values()),
edges,
symbolsByFile,
outgoingCallsByFile,
autoloadTable,
resToRepoPathMap,
godotContext,
inferredTypesByFile,
memberAssignmentsByFile,
rustBindingsByFile,
rustCrateRootByFile,
rustCrateRootsByFile,
rustInlineScopedCalls,
rustInlineDeclaredSymbols,
graphInputs: recorder.finish(effectiveExtras, await currentGraphCapabilities(), configuredPythonRoots),
};
}
//# sourceMappingURL=code-graph.js.map