socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
3,288 lines • 169 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import { toForwardSlash } from "../constants.js";
import { extractClassNameFromGdscript } from "./gdscript-syntax.js";
import { extractSymbolsAndCalls } from "./graph-symbols.js";
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
// ── Module resolution ────────────────────────────────────────────────────
/**
* Whether `seg` names a JVM source-set directory in a `src/<sourceSet>/<lang>`
* layout. `main` covers the classic Maven/Gradle convention; the `*Main` forms
* cover Kotlin Multiplatform and Gradle source sets (`commonMain`, `androidMain`,
* `iosMain`, `jvmMain`, `nativeMain`, `desktopMain`, `appleMain`, `iosArm64Main`, ...).
*/
export function isJvmSourceSetDir(seg) {
if (seg === "main")
return true;
return /^(common|android|ios|jvm|native|desktop|macos|linux|mingw|watchos|tvos|wasm|js|uikit|apple)[A-Za-z0-9]*Main$/.test(seg);
}
// Preserve the original src/main boundary even beneath a KMP-shaped directory.
function findJvmSourceSetBoundary(parts) {
const jvmLangs = new Set(["java", "kotlin", "scala"]);
let kmpIndex = -1;
for (let i = 0; i < parts.length - 2; i++) {
if (parts[i] !== "src" || !jvmLangs.has(parts[i + 2]))
continue;
if (parts[i + 1] === "main")
return i;
if (kmpIndex === -1 && isJvmSourceSetDir(parts[i + 1]))
kmpIndex = i;
}
return kmpIndex;
}
// Keep the existing Map<string, string> view while retaining KMP alternatives.
class JvmSuffixMap extends Map {
sourceSets = new Map();
}
/**
* Build a suffix lookup map for JVM (Java/Kotlin/Scala) files in multi-module projects.
*
* For a Maven/Gradle multi-module layout such as:
* module-a/sub-module/src/main/java/com/example/Foo.java
* the map entry is:
* key: "com/example/Foo.java" (forward-slash-normalized)
* value: "module-a/sub-module/src/main/java/com/example/Foo.java"
*
* This enables O(1) resolution of fully-qualified class names that cannot be
* found via the standard prefix-based scan (e.g. src/main/java/…).
*
* Call this once per graph build and pass the result to resolveImport.
*
* Classic src/main sources take precedence over KMP source sets. The map view
* retains the first file within each group; resolveImport can also select a
* retained KMP candidate matching the importing source set. Pass a
* lexicographically ordered set (as buildCodeGraph does) for stable picks.
* Keys are normalized, but values retain the input fileSet paths.
*/
export function buildJvmSuffixMap(fileSet) {
const map = new JvmSuffixMap();
const jvmExts = new Set([".java", ".kt", ".kts", ".scala"]);
const classicClassPaths = new Set();
for (const f of fileSet) {
if (!jvmExts.has(path.extname(f)))
continue;
// Split on either separator so the logic works on Windows and POSIX.
const parts = f.split(/[\\/]/);
const idx = findJvmSourceSetBoundary(parts);
if (idx !== -1) {
// classPath = everything after src/<sourceSet>/<lang>, e.g. com/example/Foo.java
const classPath = parts.slice(idx + 3).join("/");
const classic = parts[idx + 1] === "main";
if (!classic) {
const sourceSet = parts[idx + 1];
let candidates = map.sourceSets.get(sourceSet);
if (!candidates) {
candidates = new Map();
map.sourceSets.set(sourceSet, candidates);
}
if (!candidates.has(classPath))
candidates.set(classPath, f);
}
// A KMP file cannot replace an existing classic winner.
if (!map.has(classPath) || (classic && !classicClassPaths.has(classPath))) {
map.set(classPath, f);
}
if (classic)
classicClassPaths.add(classPath);
}
}
return map;
}
/**
* Build a namespace lookup map for C# files.
*
* Scans every `.cs` file in the project for `namespace X.Y.Z` declarations
* (both block-scoped `namespace X { ... }` and file-scoped `namespace X;`
* introduced in C# 10) and builds:
*
* key: "App.Services"
* value: ["src/Services/OrderService.cs", "src/Services/UserService.cs"]
*
* Used to resolve `using App.Services;` to the candidate files that
* contribute to that namespace. Without this, every C# `using` resolved
* to `null` and C# projects produced an empty file-import graph.
*
* Files are processed in lexicographic order so the resulting candidate
* lists are deterministic across machines and runs. This matters because
* multi-file namespaces resolve to `candidates[0]` in `resolveImport`,
* and a stable "first" file is required for reproducible graphs.
*
* Cost: O(n) reads at graph-build time (negligible vs. AST parsing). Files
* with no `namespace` declaration are silently skipped. Read failures are
* swallowed since this is best-effort.
*/
export function buildCsNamespaceMap(fileSet, projectPath, recorder) {
const map = new Map();
// Match both `namespace Foo.Bar { ... }` and the file-scoped C# 10+
// syntax `namespace Foo.Bar;`. The `^\s*` lets us catch nested
// declarations (`namespace Outer { namespace Inner { ... } }`) which
// are indented inside the outer block. The dotted-identifier capture
// requires each segment to start with a letter or underscore (matching
// C# identifier rules) so junk like `namespace 1Foo` is rejected. The
// `(?=[;{])` lookahead ensures we only match real declarations and
// not stray occurrences of the word `namespace`.
const namespaceRegex = /^\s*namespace\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*(?=[;{])/gm;
// Sort .cs paths lexically so `candidates[0]` is deterministic without relying
// on how the caller ordered `fileSet` — this map owns its own ordering.
const csFiles = [...fileSet]
.filter((f) => path.extname(f).toLowerCase() === ".cs")
.sort();
for (const f of csFiles) {
let source;
const absPath = path.join(projectPath, f);
try {
source = readFileSync(absPath, "utf-8");
}
catch {
continue;
}
recorder?.read(absPath, source);
for (const match of source.matchAll(namespaceRegex)) {
const ns = match[1];
const existing = map.get(ns);
if (existing) {
if (!existing.includes(f))
existing.push(f);
}
else {
map.set(ns, [f]);
}
}
}
return map;
}
/**
* Discover every `composer.json` under `projectPath`, project-relative and
* forward-slash-normalized.
*
* `composer.json` is not a graphable file, so it is never in the set returned
* by `getGraphableFiles` — this walk is independent of that set, exactly like
* {@link findGoModFiles}. The same ignore filter (`createIgnoreFilter` /
* `shouldIgnore`) `getGraphableFiles` uses is applied, with the same
* trailing-slash convention for directories, so a manifest under
* `node_modules/`, `.git/` or any gitignored or `.socraticodeignore`d path is
* skipped — matching what the graphable walk would do.
*
* `vendor/` is additionally skipped unconditionally. DEFAULT_IGNORE_PATTERNS
* already lists it, but a `.socraticodeignore` negation (`!vendor/`) can
* re-include it, and a Composer path repository symlinks `vendor/<pkg>` back
* to the in-repo source, so a manifest read through it would register a
* second directory for a prefix the in-repo manifest already declared —
* pointing at a path whose files were indexed under their real location.
*/
function findComposerManifests(projectPath) {
const ig = createIgnoreFilter(projectPath);
const results = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
if (entry.isDirectory()) {
if (entry.name === "vendor")
continue;
walk(path.join(dir, entry.name));
}
else if (entry.name === "composer.json") {
// Mirrors findGoModFiles: readdirSync Dirents do not follow symlinks,
// so a symlinked manifest reports isFile()===false and would be missed.
let isFile = entry.isFile();
if (!isFile && entry.isSymbolicLink()) {
try {
isFile = statSync(path.join(dir, entry.name)).isFile();
}
catch {
continue;
}
}
if (isFile)
results.push(relPath);
}
}
};
walk(projectPath);
// Sorted so the directory list for a prefix declared by several manifests is
// deterministic across machines — resolveImport tries them in order.
return results.sort();
}
/**
* Build a PSR-4 prefix map for PHP projects from every in-repo `composer.json`.
*
* PHP namespaces carry no path information — `App\Models\User` only maps to
* `app/Models/User.php` because `composer.json` says so. The convention-based
* fallback in `resolveImport` guesses that mapping from the directory layout,
* which works for a single-package project whose namespace root happens to
* match a real directory name, and silently drops every import that does not:
*
* - `Database\Seeders\Foo` → the directory is `database/seeders`, lowercase,
* so the case-sensitive guess misses it
* - `Acme\Auth\Models\Role` → lives in `packages/auth/src/Models/Role.php`,
* a path no namespace-shaped guess can reach
*
* The second case is the norm in Composer monorepos (path repositories), where
* every domain package declares its own PSR-4 root. Those imports resolved to
* nothing, so package-to-package edges were absent from the graph entirely and
* impact analysis reported "no callers" for code with many callers.
*
* Reads the root manifest plus each nested one (`autoload` and `autoload-dev`),
* mapping every prefix to directories relative to the manifest that declared
* it.
*
* Call this once per graph build and pass the result to resolveImport.
*/
export function buildPhpPsr4Map(projectPath, recorder) {
const map = new Map();
const root = path.resolve(projectPath);
for (const relManifest of findComposerManifests(root)) {
const manifest = path.join(root, relManifest);
let parsed;
try {
const source = readFileSync(manifest, "utf8");
recorder?.read(manifest, source);
parsed = JSON.parse(source);
}
catch {
// A manifest the walk found and this could not read is still an input:
// it is not a graph node, so nothing else would notice it recovering.
// `read` above wins where it ran, so this only lands on a failed read.
recorder?.unreadable(manifest);
continue; // malformed manifest — the other manifests still count
}
if (typeof parsed !== "object" || parsed === null)
continue;
const manifestDir = path.dirname(manifest);
const doc = parsed;
const blocks = [doc.autoload, doc["autoload-dev"]];
for (const block of blocks) {
if (typeof block !== "object" || block === null)
continue;
const psr4 = block["psr-4"];
if (typeof psr4 !== "object" || psr4 === null)
continue;
for (const [prefix, target] of Object.entries(psr4)) {
// PSR-4 allows a string or an array of directories per prefix.
const targets = Array.isArray(target) ? target : [target];
for (const rawTarget of targets) {
if (typeof rawTarget !== "string")
continue;
const abs = path.resolve(manifestDir, rawTarget);
const rel = toForwardSlash(path.relative(root, abs)).replace(/\/+$/, "");
// A manifest outside the indexed tree (path.relative escapes upward)
// cannot contribute resolvable files.
if (rel.startsWith(".."))
continue;
const list = map.get(prefix) ?? [];
if (!list.includes(rel))
list.push(rel);
map.set(prefix, list);
}
}
}
}
return map;
}
/**
* The shape of a PHP namespace path: identifier segments joined by
* backslashes, and nothing else.
*
* PHP `use` statements and `require`/`include` paths arrive in one specifier
* space, and only their shape tells them apart. A namespace path cannot hold a
* `/`, a `.` or a separator of any other kind, so anything that does is a file
* path. Segments admit the high-byte range because PHP identifiers do.
*/
const PHP_NAMESPACE_SHAPE = /^[A-Za-z_\x80-\uFFFF][\w\x80-\uFFFF]*(?:\\[A-Za-z_\x80-\uFFFF][\w\x80-\uFFFF]*)*$/;
/**
* Build a fully-qualified-class-name lookup map for PHP files, derived from
* the declarations themselves rather than from any manifest.
*
* PSR-4 stays the authority wherever it exists, and this map is consulted only
* after it misses. It exists for the code PSR-4 cannot describe: a package that
* ships no autoload map at all and registers its namespaces at run time —
*
* $loader->addNamespace('Acme', PLUGIN_DIR . '/src/acme');
*
* — which is how WordPress plugins overwhelmingly do it, and which a
* composer.json reader cannot see. Such a package declares `"autoload": {}` or
* no manifest at all, so every `use` it makes and every `use` of it resolved to
* nothing, leaving whole trees orphaned in the graph while their symbols
* extracted perfectly (issue #120). Interpreting `spl_autoload_register` is not
* needed to fix that: the file that declares `Acme\Schema\Role` is a fact the
* source states outright.
*
* key: "Acme\\Schema\\Role"
* value: ["src/acme/schema/RoleSchema.php"]
*
* Scans every PHP file unconditionally rather than only those under roots no
* PSR-4 prefix covers, following `buildCsNamespaceMap`. One map means one
* resolution regime everywhere, and it keeps working where a composer.json
* exists but is incomplete — a partial map is the common case, not an edge one.
*
* The key is the exact FQCN, declared namespace and declared name together,
* with no tail matching, so a collision means two files literally declaring the
* same class — in practice a vendored duplicate. Files are read in
* lexicographic order and `resolveImport` takes `candidates[0]`, so the pick is
* deterministic across machines; an edge to either copy is a true "depends on
* this FQCN" edge, which is why this guesses rather than refusing, matching the
* C# resolver.
*
* Reads `fileSet`, which is already ignore-filtered, so a `vendor/` tree the
* project excluded contributes nothing and one it deliberately re-included is a
* legitimate target — no unconditional skip is needed here, unlike the
* manifest walk in `findComposerManifests`.
*
* Declarations are matched by regex at line start, as in `buildCsNamespaceMap`,
* which costs one read per file instead of a parse. Positional association
* carries the braced multi-namespace form (`namespace A { … } namespace B { … }`)
* as well as the file-scoped one. A `class` line inside a heredoc would be
* matched as a declaration; a false FQCN nobody imports resolves nothing.
*/
export function buildPhpFqcnMap(fileSet, projectPath, recorder) {
const map = new Map();
// `namespace Foo\Bar;` (file-scoped) and `namespace Foo\Bar {` (braced).
const namespaceRegex = /^[ \t]*namespace\s+([A-Za-z_\x80-\uFFFF][\w\x80-\uFFFF]*(?:\\[A-Za-z_\x80-\uFFFF][\w\x80-\uFFFF]*)*)\s*[;{]/gm;
// Enums are autoloadable and `use`-imported exactly as classes are, so they
// belong in a map whose job is answering "which file declares this name".
//
// A declaration starts its line, or follows an opening brace on one — the
// braced namespace form puts the two together as `namespace A { class B`.
// Requiring one or the other is what rejects the near-misses: ` * class Foo`
// in a doc block, `// class Foo`, `"class Foo"` in a string, and the
// anonymous `new class {}`, none of which declare a name to import.
const declarationRegex = /(?:^|\{)[ \t]*(?:(?:final|abstract|readonly)\s+)*(?:class|interface|trait|enum)\s+([A-Za-z_\x80-\uFFFF][\w\x80-\uFFFF]*)/gm;
const phpFiles = [...fileSet]
.filter((f) => path.extname(f).toLowerCase() === ".php")
.sort();
for (const file of phpFiles) {
let source;
const absPath = path.join(projectPath, file);
try {
source = readFileSync(absPath, "utf8");
}
catch {
continue;
}
recorder?.read(absPath, source);
// Namespaces in declaration order, so each type can be attributed to the
// one in effect where it appears.
const namespaces = [];
for (const match of source.matchAll(namespaceRegex)) {
namespaces.push({ index: match.index ?? 0, name: match[1] });
}
for (const match of source.matchAll(declarationRegex)) {
const at = match.index ?? 0;
let namespace = "";
for (const ns of namespaces) {
if (ns.index < at)
namespace = ns.name;
else
break;
}
const fqcn = namespace ? `${namespace}\\${match[1]}` : match[1];
const files = map.get(fqcn);
if (files) {
if (!files.includes(file))
files.push(file);
}
else {
map.set(fqcn, [file]);
}
}
}
return map;
}
/** Map each in-project Elixir `defmodule` name to its files, deterministically. */
export function buildElixirModuleMap(fileSet, projectPath, recorder) {
const map = new Map();
for (const file of [...fileSet].filter((f) => [".ex", ".exs"].includes(path.extname(f).toLowerCase())).sort()) {
let source;
const absPath = path.join(projectPath, file);
try {
source = readFileSync(absPath, "utf8");
}
catch {
continue;
}
recorder?.read(absPath, source);
const symbols = extractSymbolsAndCalls(source, "elixir", path.extname(file), file).symbols;
for (const symbol of symbols) {
if (symbol.kind !== "module" || symbol.name === "<module>")
continue;
const files = map.get(symbol.qualifiedName);
if (files) {
if (!files.includes(file))
files.push(file);
}
else {
map.set(symbol.qualifiedName, [file]);
}
}
}
return map;
}
/**
* Build Go module-resolution info for a project, one entry per `go.mod`.
*
* Discovers EVERY `go.mod` under the project root (so a monorepo with
* nested modules is supported, not just a single root-level `go.mod`),
* parses each module path with a regex, and constructs a per-module
* directory-to-representative-file map across the `.go` files owned by
* that module. `_test.go` files are excluded — Go does not allow them to
* be imported from non-test code in other packages. Files are sorted
* lexicographically before each map is built so the representative
* chosen for a multi-file package is deterministic across machines/runs.
*
* `go.mod` is discovered by walking the tree independently of `fileSet`:
* `go.mod` has no AST grammar and is not an extra extension, so it is
* NEVER admitted by `getGraphableFiles` and therefore never present in
* `fileSet`. An earlier attempt scanned `fileSet` for `go.mod` entries,
* which matched nothing in a real build and silently broke Go resolution
* for every project (issue #82, including the root-level #45 case). The
* walk reuses the same ignore filter `getGraphableFiles` uses, so a
* `go.mod` inside `node_modules/`, `.git/`, or a gitignored path is
* skipped.
*
* Each `.go` file is attributed to its DEEPEST owning module (the module
* whose `moduleDir` is the longest directory prefix of the file). The
* tie-break is directory DEPTH, not string length: the root module
* (`"."`, depth 0) must never win over a single-segment nested module
* whose directory name happens to be short (e.g. `z`, which is string
* length 1 just like `"."`).
*
* `packageMap` keys are MODULE-relative (the package directory with the
* module directory stripped), because a Go import strips the module path
* down to a module-relative directory. The map VALUES stay
* project-relative (they are the `fileSet` entries), so resolution needs
* no further translation even for a nested module.
*
* Cost: one tree walk (ignore-filtered) + one `readFileSync` per module
* plus an O(n) walk over `.go` files at graph-build time. Lookups during
* resolution are O(1).
*
* Limitations (deferred to follow-up issues if reported):
* - Parenthesized `module ( ... )` form (rare; not used by any
* mainstream Go project).
* - `vendor/` directory shadowing of external imports.
* - `replace` directives in `go.mod`.
* - `go.work` multi-module workspaces.
*/
export function buildGoModuleInfo(fileSet, projectPath, recorder) {
const goModPaths = findGoModFiles(projectPath);
if (goModPaths.length === 0)
return [];
const rawModules = [];
for (const goModRel of goModPaths) {
let goModSource;
const goModAbs = path.join(projectPath, goModRel);
try {
goModSource = readFileSync(goModAbs, "utf-8");
}
catch {
recorder?.unreadable(goModAbs);
continue;
}
recorder?.read(goModAbs, goModSource);
// Match `module <path>` at the start of a line, allowing leading
// horizontal whitespace and capturing the path token greedily up to
// the next whitespace. Module paths are non-whitespace tokens (e.g.
// `github.com/user/repo`, `go.uber.org/zap`).
const match = goModSource.match(/^[ \t]*module[ \t]+(\S+)/m);
if (!match)
continue;
const moduleDir = path.dirname(goModRel).replace(/\\/g, "/"); // "." for a root-level go.mod
const depth = moduleDir === "." ? 0 : moduleDir.split("/").length;
rawModules.push({ moduleDir, modulePath: match[1], depth });
}
if (rawModules.length === 0)
return [];
// Precompute each .go file's owning module once. A file is owned by the
// DEEPEST module whose directory is a prefix of the file's directory
// (depth, not string length — see the function docstring).
const goFiles = [...fileSet]
.filter((f) => f.endsWith(".go") && !f.endsWith("_test.go"))
.sort();
const ownerByFile = new Map();
for (const f of goFiles) {
const fileDir = path.dirname(f).replace(/\\/g, "/");
let best = null;
for (const mod of rawModules) {
// The root module (".") is a prefix of every path; a nested module
// owns a file only when the file's directory is itself or below it.
const owned = mod.moduleDir === "." ||
fileDir === mod.moduleDir ||
fileDir.startsWith(`${mod.moduleDir}/`);
if (!owned)
continue;
if (best === null || mod.depth > best.depth)
best = mod;
}
ownerByFile.set(f, best);
}
const modules = [];
for (const mod of rawModules) {
const packageMap = new Map();
for (const f of goFiles) {
if (ownerByFile.get(f) !== mod)
continue;
const absDir = path.dirname(f).replace(/\\/g, "/");
// Strip the module directory to get the MODULE-relative package
// directory (the form a Go import resolves to). Go import paths
// always use forward slashes and fileSet entries are forward-slash-
// normalized (see toForwardSlash in constants.ts).
const dir = mod.moduleDir === "."
? absDir
: absDir === mod.moduleDir
? "."
: absDir.slice(mod.moduleDir.length + 1); // absDir starts with `${mod.moduleDir}/`
if (!packageMap.has(dir)) {
packageMap.set(dir, f); // value stays project-relative (a fileSet entry)
}
}
modules.push({ modulePath: mod.modulePath, moduleDir: mod.moduleDir, packageMap });
}
return modules;
}
/**
* Discover every `go.mod` under `projectPath`, project-relative and
* forward-slash-normalized.
*
* `go.mod` is not a graphable file (no AST grammar, not an extra
* extension), so it is never in the set returned by `getGraphableFiles`.
* This walk is therefore independent of that set and is how
* {@link buildGoModuleInfo} finds modules without relying on `go.mod`
* being graphable (issue #82). The same ignore filter
* (`createIgnoreFilter` / `shouldIgnore`) `getGraphableFiles` uses is
* applied, with the same trailing-slash convention for directories, so a
* `go.mod` under `node_modules/`, `.git/`, or a gitignored path is
* skipped — exactly matching what the graphable walk would do.
*/
function findGoModFiles(projectPath) {
const ig = createIgnoreFilter(projectPath);
const results = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
if (entry.isDirectory()) {
walk(path.join(dir, entry.name));
}
else if (entry.name === "go.mod") {
// readdirSync Dirents do not follow symlinks: a symlinked go.mod
// reports isFile()===false, so without this it would be neither
// recorded nor followed and a root-level symlinked go.mod would
// regress (the old single readFileSync followed the link). statSync
// resolves the link so a symlinked go.mod is discovered like a real
// one; broken links and non-file targets are skipped.
let isFile = entry.isFile();
if (!isFile && entry.isSymbolicLink()) {
try {
isFile = statSync(path.join(dir, entry.name)).isFile();
}
catch {
continue;
}
}
if (isFile)
results.push(relPath);
}
}
};
walk(projectPath);
return results.sort();
}
/**
* Discover every `pubspec.yaml` under `projectPath`, project-relative and
* forward-slash-normalized.
*
* `pubspec.yaml` is not a graphable file (no AST grammar, not an extra
* extension), so it is never in the set returned by `getGraphableFiles` —
* this walk is independent of that set, exactly like {@link findGoModFiles}
* (the fileSet-scan trap from issue #82 applies here identically). The same
* ignore filter (`createIgnoreFilter` / `shouldIgnore`) `getGraphableFiles`
* uses is applied, with the same trailing-slash convention for directories,
* so a manifest under `node_modules/`, `.git/`, or a gitignored path is
* skipped.
*
* `.dart_tool/` is additionally skipped unconditionally. DEFAULT_IGNORE_PATTERNS
* already lists it, but a `.socraticodeignore` negation (`!.dart_tool/`) can
* re-include it, and Flutter code generation writes a `flutter_gen/pubspec.yaml`
* inside it whose `name:` would register a package root pointing at generated
* files.
*/
function findPubspecFiles(projectPath) {
const ig = createIgnoreFilter(projectPath);
const results = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
if (entry.isDirectory()) {
if (entry.name === ".dart_tool")
continue;
walk(path.join(dir, entry.name));
}
else if (entry.name === "pubspec.yaml") {
// Mirrors findGoModFiles: readdirSync Dirents do not follow symlinks,
// so a symlinked manifest reports isFile()===false and would be missed.
let isFile = entry.isFile();
if (!isFile && entry.isSymbolicLink()) {
try {
isFile = statSync(path.join(dir, entry.name)).isFile();
}
catch {
continue;
}
}
if (isFile)
results.push(relPath);
}
}
};
walk(projectPath);
// Sorted so duplicate package names across manifests resolve to the same
// root on every machine — buildDartPackageMap is first-wins in this order.
return results.sort();
}
/**
* Map every in-repo Dart package name to its package root directory
* (project-relative, forward-slash; `"."` for a root-level `pubspec.yaml`).
*
* Dart/Flutter code imports intra-project files by package URI almost
* exclusively — `import 'package:my_app/src/service.dart';` is the layout
* convention pub itself generates — and a package URI carries no path
* information: `package:<name>/<rest>` maps to `<package_root>/lib/<rest>`
* only because `<name>`'s pubspec lives at `<package_root>`. Without this
* map every such import resolved to null (issue #106), so Flutter projects
* lost nearly all file-graph edges and impact analysis reported "no callers"
* for files with many consumers. Nested manifests are read too, which is what
* resolves cross-package `package:<sibling>/...` imports in pub-workspace and
* melos monorepos.
*
* The `name:` field is matched only at column 0: pubspec is YAML, so a
* nested `name:` key legitimately appears indented inside dependency blocks
* (`dependencies: { foo: { hosted: { name: ... } } }`), and an unanchored
* match could map a dependency's name to the wrong root. Pub package names
* are lowercase identifiers (`[a-z0-9_]`); the optional quote accepts the
* YAML-quoted spelling of the same name. A manifest without a matching
* `name:` contributes nothing; the first manifest (in sorted path order) to
* declare a name wins, mirroring `buildJvmSuffixMap`'s first-wins
* determinism.
*
* Call this once per graph build and pass the result to resolveImport.
*/
export function buildDartPackageMap(projectPath, recorder) {
const map = new Map();
const root = path.resolve(projectPath);
for (const relManifest of findPubspecFiles(root)) {
let source;
const manifestAbs = path.join(root, relManifest);
try {
source = readFileSync(manifestAbs, "utf8");
}
catch {
recorder?.unreadable(manifestAbs);
continue; // unreadable manifest — the other manifests still count
}
recorder?.read(manifestAbs, source);
// A UTF-8 BOM sits before the first line's `name:` and would defeat the
// column-0 anchor below — `dart pub get` accepts a BOM'd manifest, so
// without this the package silently loses every package: edge again.
if (source.charCodeAt(0) === 0xfeff)
source = source.slice(1);
const match = source.match(/^name:[ \t]*['"]?([a-z0-9_]+)/m);
if (!match)
continue;
const packageDir = toForwardSlash(path.dirname(relManifest)); // "." at the root
if (!map.has(match[1]))
map.set(match[1], packageDir);
}
return map;
}
/**
* Discover every `pyproject.toml` under `projectPath`, project-relative and
* forward-slash-normalized.
*
* `pyproject.toml` is not a graphable file (no AST grammar, not an extra
* extension), so it is never in the set returned by `getGraphableFiles` —
* this walk is independent of that set, exactly like {@link findGoModFiles}
* (the fileSet-scan trap from issue #82 applies here identically). The same
* ignore filter (`createIgnoreFilter` / `shouldIgnore`) `getGraphableFiles`
* uses is applied, with the same trailing-slash convention for directories,
* so a manifest under `node_modules/`, `.git/`, or a gitignored path is
* skipped.
*
* `site-packages/` and `dist-packages/` are additionally skipped
* unconditionally. Every installed third-party distribution ships its own
* `pyproject.toml` in one of them, and each would register an import root
* over vendored code that shadows the project's own modules.
* DEFAULT_IGNORE_PATTERNS covers the common virtualenv directory names
* (`.venv`, `venv`, `env`), but an environment named anything else
* (`.venv312`, `myenv`) or a `.socraticodeignore` negation re-including one
* lands the walk straight in `lib/pythonX.Y/site-packages`. `dist-packages`
* is the same directory under Debian and Ubuntu's system Python.
*/
function findPyProjectManifests(projectPath) {
const ig = createIgnoreFilter(projectPath);
const results = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
if (entry.isDirectory()) {
if (entry.name === "site-packages" || entry.name === "dist-packages")
continue;
walk(path.join(dir, entry.name));
}
else if (entry.name === "pyproject.toml") {
// Mirrors findGoModFiles: readdirSync Dirents do not follow symlinks,
// so a symlinked manifest reports isFile()===false and would be missed.
// uv workspaces symlink member manifests in some layouts.
let isFile = entry.isFile();
if (!isFile && entry.isSymbolicLink()) {
try {
isFile = statSync(path.join(dir, entry.name)).isFile();
}
catch {
continue;
}
}
if (isFile)
results.push(relPath);
}
}
};
walk(projectPath);
return results.sort();
}
/**
* Discover every `pyproject.toml` in the tree and record, for each, the import
* roots it implies and the workspace members it declares.
*
* A packaged Python project puts its importable modules under the manifest's
* directory in one of two layouts: `src/` (what `uv init --lib`, hatchling
* and setuptools all generate) or flat, directly beside the manifest. Neither
* is derivable from the import itself. `from usa_wa_adapter_sos.house import
* build` names no directory that appears in
* `packages/usa-wa-adapter-sos/src/usa_wa_adapter_sos/house/build.py`: the
* distribution directory is dashed, the module is underscored, and `src/`
* sits between them. The resolver's existing project-root `src/`+`lib/` probe
* only reaches a single-package repo, so in a workspace every cross-package
* import — and every package's own absolute self-import — resolved to null
* (issue #107). A 362-file uv workspace built 3 edges.
*
* Both roots are recorded per manifest without probing the filesystem for
* which layout is in use: a root that does not exist holds no files, so it
* matches nothing, and the check would cost a `stat` per manifest to remove
* lookups that already miss.
*
* Roots are recorded rather than module names enumerated under them. Names
* would have to come from the directories actually present — a distribution
* name and its import name are not reliably related (Pillow imports as `PIL`),
* so `[project] name` cannot supply them — and enumerating directories alone
* would miss single-module distributions, where `src/mymodule.py` is the whole
* importable surface and no directory bears the module's name. Trying each
* root in turn covers both, and covers PEP 420 namespace packages (no
* `__init__.py`) for free, since it never asks what a directory contains.
*
* The only part of a manifest that is read is its `[tool.uv.workspace]`
* member list, which {@link pythonRootsForFile} needs to tell a sibling
* package apart from an unrelated project that merely carries a manifest.
*
* Sorted by directory so that when two manifests contribute a root holding the
* same top-level module name, the same one wins on every machine.
*
* Call this once per graph build and pass each file's scoped roots (see
* {@link pythonRootsForFile}) to resolveImport.
*/
export function buildPythonManifests(projectPath, recorder) {
const root = path.resolve(projectPath);
const relManifests = findPyProjectManifests(root);
const dirs = relManifests.map((m) => toForwardSlash(path.dirname(m))); // "." at the root
return relManifests.map((relManifest, i) => {
const dir = dirs[i];
let source = "";
const manifestAbs = path.join(root, relManifest);
try {
source = readFileSync(manifestAbs, "utf8");
recorder?.read(manifestAbs, source);
}
catch {
recorder?.unreadable(manifestAbs);
// Unreadable manifest still contributes its roots; it just declares no
// members, so it scopes to its own subtree.
}
return {
dir,
roots: [dir, dir === "." ? "src" : `${dir}/src`],
members: declaredWorkspaceMembers(source, dir, dirs),
};
});
}
/**
* The `[tool.uv.workspace]` table of one manifest, or null when the document
* declares none — including when it cannot be parsed at all.
*
* Reading this by pattern-matching was a losing position. The cases that kept
* surfacing were not edges but the lexer: a `# """` comment masking a valid
* table, and `members = ["a" "b"]` inventing a member out of a document uv
* rejects outright. Tracking comment and string state is the first thing a TOML
* parser does and the last thing a scanner can bolt on, and under the
* narrow-never-widen invariant every remaining gap had to be paid for by
* voiding — real edges lost in manifests that were perfectly valid.
*
* Parsing settles the lexing, and the walk below covers every legal spelling of
* the same declaration for free: `[ tool.uv.workspace ]`, a `[tool.uv]` table
* carrying `workspace = { members = [...] }`, and a top-level
* `tool.uv.workspace.members` dotted key all arrive as the same nested tables.
* Each is ordinary TOML that a user can write today, that uv reads as a
* workspace, and that the previous reader silently found no members in — the
* same failure shape as issue #107 itself.
*
* A malformed manifest declares nothing rather than failing the build: one
* unparseable `pyproject.toml` anywhere in the tree must not cost the whole
* project its Python edges, which is why an unreadable file is skipped in
* {@link buildPythonManifests} too.
*
* A leading byte-order mark is stripped first. Both this parser and `tomllib`
* reject one, since the TOML grammar has no place for it, but uv's parser skips
* it and locks the workspace normally — so without stripping, a manifest saved
* with a BOM would lose every member it declares. uv's behaviour is the one
* being modelled.
*/
function workspaceTable(source) {
let cursor;
try {
cursor = parseToml(source.replace(/^\uFEFF/, ""));
}
catch {
return null;
}
// A missing key yields undefined, which the next step rejects, so one check
// per step covers both a key that is absent and a value that cannot hold one.
//
// These lookups read through the prototype chain, since the parser returns
// plain objects rather than null-prototype ones. None of the five names this
// reader asks for — tool, uv, workspace, members, exclude — exists on
// Object.prototype, so no manifest can reach an inherited value, and an
// own-key check ahead of each one could never change an outcome.
for (const key of ["tool", "uv", "workspace"]) {
if (!isTable(cursor))
return null;
const table = cursor;
cursor = table[key];
}
return isTable(cursor) ? cursor : null;
}
/**
* Whether a parsed value can be looked up by key. A TOML date parses to an
* object and passes this too, but no key can be found under one, so it reaches
* the same void as any other undeclared workspace.
*/
function isTable(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* One `members`/`exclude` value as patterns this reader can apply, or null when
* it cannot apply them faithfully — the caller's signal to void.
*
* Null covers three shapes that mean the same thing here: the key is absent, it
* holds something other than an array of strings, or an entry uses glob syntax
* beyond the `*` and `**` translated below. The caller separates out an absent
* `exclude` before asking, since that is an ordinary manifest excluding
* nothing rather than an unreadable one.
*/
function patternList(value) {
// A type predicate rather than a cast, so the narrowing is proven by the same
// check that guards it — the invariant rests on this one holding.
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
return null;
}
return value.some(usesUnsupportedGlob) ? null : value;
}
/**
* Whether a member pattern uses glob syntax beyond the `*` and `**` this
* reader translates. uv matches with a full globset, so a character class,
* a `?`, a brace alternation or an escape selects a different set than a
* literal reading of the same text would.
*
* The pattern examined here is the parsed value, so a TOML escape sequence has
* already been resolved: `"packages/a\\b"` arrives carrying one backslash,
* which globset reads as an escape and this check rejects.
*/
function usesUnsupportedGlob(pattern) {
return /[?[\]{}\\]/.test(pattern);
}
/**
* Project-relative directories of the `[tool.uv.workspace]` members a manifest
* declares, selected from `allManifestDirs` — a member glob only matters here
* when a real manifest sits at the path it names.
*
* uv member entries are globs relative to the declaring manifest
* (`members = ["packages/*"]`), with an optional `exclude` list of the same
* shape. Only `*` and `**` are translated, and a lone `*` does not mean the
* same thing in the two lists — see the note beside the translation below.
*
* The document is parsed rather than scanned (see {@link workspaceTable}), so
* a `members` key belonging to some other tool is a different key rather than
* nearby text, and only glob translation is left to approximate.
*
* **The invariant is that this reader may narrow what a manifest declares,
* never widen it**, and the whole section is voided the moment it meets
* anything it cannot represent exactly: an unparseable document, a `members`
* or `exclude` value that is not an array of strings, or glob syntax beyond
* `*` and `**`. Voiding falls back to ancestor-path scoping, which resolves
* strictly fewer imports.
*
* The invariant has to hold for `exclude` as well as `members`, and that is
* why approximating is not enough. Dropping a member costs an edge that
* should have resolved. Dropping an *exclusion* admits a package the manifest
* explicitly excludes, and draws a cross-package edge uv would not — the
* reader inventing a declaration rather than missing one. An earlier revision
* stripped comments before reading the array, which truncated
* `exclude = ["packages/#legacy", "packages/legacy"]` at the `#` and lost the
* real exclusion behind it.
*
* A `#` inside a string is not itself a problem and is read as the literal
* path character it is: `exclude = ["packages/#legacy"]` names a directory
* that does not exist, matches nothing, and leaves `legacy` a member, which
* is what uv does with the same bytes.
*/
function declaredWorkspaceMembers(source, manifestDir, allManifestDirs) {
const workspace = workspaceTable(source);
if (workspace === null)
return [];
const memberPatterns = patternList(workspace.members);
if (memberPatterns === null)
return [];
// An absent `exclude` is an ordinary manifest excluding nothing. One that is
// present and unreadable voids alongside `members`, since ignoring it would
// admit exactly the package the manifest set out to keep out. TOML has no
// null, so an undefined value here can only mean the key is absent.
const excludePatterns = workspace.exclude === undefined ? [] : patternList(workspace.exclude);
if (excludePatterns === null)
return [];
// Split on the wildcards first and quote only the literal spans between
// them, so every other regex metacharacter matches itself. `**` always
// spans separators; what a lone `*` spans is `singleStar`, and the two
const prefix = manifestDir === "." ? "" : `${manifestDir}/`;
const relativeToManifest = (dir) => {
if (!prefix)
return dir;
return dir.startsWith(prefix) ? dir.slice(prefix.length) : null;
};
// uv expands `members` by globbing the filesystem, where a lone `*` selects
// one path segment, and matches `exclude` as a pattern against the member's
// whole path, where it does not stop at a separator. Two code paths, two
// meanings for the same character: `members = ["packages/*"]` leaves
// `packages/alpha/inner` out, while `exclude = ["*legacy"]` takes
// `packages/legacy` and `exclude = ["*"]` empties the workspace.
//
// Checked on uv 0.10.0 and 0.11.8, which agree on every one of these, so
// this is uv's model rather than one release's behaviour.
//
// The asymmetry has to be honoured because the invariant is not symmetric.
// A narrow `*` on the include side registers fewer roots than uv, which
// costs at most an edge. A narrow `*` on the exclude side fails to exclude,
// which admits a package the manifest named and draws an edge uv would not.
const included = memberPatterns.map((p) => globToRegex(p.replace(/\/+$/, ""), "[^/]*"));
if (included.length === 0)
return [];
const excluded = excludePatterns.map((p) => globToRegex(p.replace(/\/+$/, ""), ".*"));
return allManifestDirs.filter((dir) => {
if (dir === manifestDir)
return false;
const rel = relativeToManifest(dir);
if (rel === null)
return false;
return included.some((re) => re.test(rel)) && !excluded.some((re) => re.test(rel));
});
}
/**
* Compile a glob pattern into a regular expression.
* `**` spans separators; `singleStar` controls what a lone `*` spans.
*/
function globToRegex(pattern, singleStar) {
const quote = (literal) => literal.replace(/[.+^${}()|[\]\\?*]/g, "\\$&");
const body = pattern
.split("**")
.map((span) => span.split("*").map(quote).join(singleStar))
.join(".*");
return new RegExp(`^${body}$`);
}
/**
* The import roots that apply to one source file, nearest first.
*
* Two rules, each fixing a way a flat list of every root in the tree resolves
* an import to the wrong file:
*
* **Scope.** A manifest applies to a file only when it sits on the file's
* ancestor path, or when an ancestor manifest declares it as a workspace
* member. A repo's `examples/demo/pyproject.toml`, a checked-in `third_party/`
* sdist and an editable checkout inside an environment directory all carry
* manifests while sitting on no `sys.path` the file could import through;
* without this rule each one registers roots globally and turns an import that
* correctly resolved to nothing into a fabricated edge. Workspace members are
* the exception because that is exactly what a member declaration states: the
* sibling package IS importable from here, and resolving cross-package imports
* is what issue #107 is about.
*
* **Order.** Roots containing the file come first, deepest first, so a package
* resolves its own modules before a sibling's. Without this, a per-service
* monorepo of flat `uv init --app` services — each with its own `config.py` —
* resolved `import config` to whichever service sorted first alphabetically,
* drawing a confident edge into another service's file. Remaining in-scope
* roots follow lexicographically: they are the cross-package candidates, where
* nothing about the import says which package was meant, so the tie is broken
* the same way on every machine rather than left to walk order.
*
* `relSourceDir` is the file's directory, project-relative and
* forward-slashed, `"."` for a file at the root.
*/
export function pythonRootsForFile(manifests, relSourceDir) {
const isAncestorOf = (dir, target) => dir === "." || dir === target || target.startsWith(`${dir}/`);
const ancestors = manifests.filter((m) => isAncestorOf(m.dir, relSourceDir));
const inScope = new Set(ancestors.map((m) => m.dir));
for (const m of ancestors) {
for (const member of m.members)
inScope.add(member);
}
const roots = [];
for (const m of manifests) {
if (inScope.has(m.dir))
roots.push(...m.roots);
}
const contains = (root) => root === "." || relSourceDir === root || relSourceDir.startsWith(`${root}/`);
return [...new Set(roots)].sort((a, b) => {
const aContains = contains(a);
const bContains = contains(b);
if (aContains !== bContains)
return aContains ? -1 : 1;
// Deepest containing root first; "." is the shallowest and sorts last
// among them, so a package's own root outranks the project root.
if (aContains)
return b.length - a.length || a.localeCompare(b);
return a.localeCompare(b);
});
}
/**
* Discover every `Cargo.toml` under `projectPath`, project-relative and
* forward-slash-normalized.
*
* `Cargo.toml` is not a graphable file (no AST grammar, not an extra
* extension), so it is never in the set returned by `getGraphableFiles` — this
* walk is independent of that set, exactly like {@link findGoModFiles} (the
* fileSet-scan trap from issue #82 applies here identically). The same ignore
* filter `getGraphableFiles` uses is applied, with the same trailing-slash
* convention for directories.
*
* `target/` is skipped unconditionally by directory name. Additionally, any
* directory carrying `CACHEDIR.TAG` (created by Cargo per the Cache Directory
* Tagging Standard) or `.cargo-ok` is skipped unconditionally: this defends
* against custom `CARGO_TARGET_DIR` directory names where Cargo unpacks
* dependencies and build artifacts during compilation.
*/
function findCargoManifests(projectPath) {
const ig = createIgnoreFilter(projectPath);
const results = [];
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
// A Cargo target / build directory contains CACHEDIR.TAG at its root,
// created by Cargo during build (per the Cache Directory Tagging Standard).
// Skipping any directory carrying this tag or .cargo-ok prevents unpacked
// dependencies and vendored build artifacts under non-default CARGO_TARGET_DIR
// names from registering as project crates.
if (dir !== projectPath &&
entries.some((e) => e.name === "CACHEDIR.TAG" || e.name === ".cargo-ok")) {
return;
}
for (const entry of entries) {
const relPath = toForwardSlash(path.relative(projectPath, path.join(dir, entry.name)));
if (shouldIgnore(ig, entry.isDirectory() ? `${relPath}/` : relPath))
continue;
if (entry.isDirectory()) {
if (entry.name === "target")
continue;
walk(path.join(dir, entry.name));
}
else if (entry.name === "Cargo.toml") {
// Mirrors findGoModFiles: readdirSync Dirents do not follow symlinks,
// so a symlinked manifest reports isFile()===false and would be missed.
let isFile = entry.isFile();
if (!isFile && entry.isSymbolicLink()) {
try {
isFile = statSync(path.join(dir, entry.name)).isFile();
}
catch {
continue;
}
}
if (isFile)
results.push(relPath);
}
}
};
walk(projectPath);
// Sorted so a name declared by two manifests resolves to the same crate on
// every machine.
return results.sort();
}
function asTable(value) {
// isTable admits a TOML date, which carries none of the keys read below and
// so reaches the same "declares nothing" path as any other non-table.
return isTable(value) ? value : null;
}
/**
* A declared target's `path`, when the entry carries one, in the spelling the
* file set uses.
*
* Cargo accepts `path = "./src/api.rs"` and `path = "src/./api.rs"`; the file
* set holds neither, so the raw string never matched and the declaration was
* silently dropped. What followed was worse than losing the target: a `[lib]`
* declared that way erased the crate's name and roots entirely, so every
* sibling depending on it lost its edges, and a `[[bin]]` fell through to
* convention and rooted the file one directory too deep — `mod part;` in
* `src/tools/tool.rs` reached `src/tools/tool/part.rs` instead of
* `src/tools/part.rs`. Verified with cargo 1.98.0: `cargo metadata` reports
* the target at the normalized path and `cargo build` compiles against the
* sibling, with a `compile_error!` in the deeper file proving it is never read.
*
* Backslashes are folded first: a manifest written on Windows may spell the
* separator that way, and the file set is forward-slashed throughout.
*/
function declaredTargetPath(entry) {
const table = asTable(entry);
const declared = table?.path;
if (typeof declared !== "string")
return null;
const normalized = toForwardSlash(path.posix.normalize(declared.replace(/\\/g, "/")));
// `normalize` turns "" into "." and leaves a trailing slash on a directory;
// neither names a file, and passing them on would match nothing anyway.
//
// Returning null hands the target to convention rather than dropping it, and
// that is deliberate. Cargo 1.98.0 refuses such a manifest outright — "path
// `…/` for lib `x` is a directory, but a source file was expected" — so no
// build exists to mirror and the oracle names no right answer. Dropping the
// target is the more expensive of the two wrongs: it takes the crate's name
// and roots with it, and every edge its dependents draw.
return normalized === "." || normalized === "" ? null : normalized.replace(/\/+$/, "");
}
/**
* The **package names** a manifest sends to a local directory through
* `[patch.crates-io]` or the deprecated `[replace]`.
*
* Such a name reads as a registry dependency where it is declared, and cargo
* builds it from the path anyway. `[patch]` is keyed by source, `[replace]` by
* package spec (`"log:0.4.0"`), and only an entry carrying a `path` moves the
* crate inside the project.
*
* Package names, not the names the importing crate writes: `alias = { package =
* "itoa" }` is patched under `itoa` and imported as `alias`, so the caller has
* to translate before matching. And only the `crates-io` source: a
* `[patch.crates-io]` does not touch a dependency taken from a git remote,
* which cargo keeps fetching — verified on 1.98.0, where the dep-info names the
* checkout under `CARGO_HOME/git` and never the local directory.
*
* **The version is not compared**, and that is a known cost: cargo applies a
* patch only when the local crate's version satisfies the requirement, so
* `itoa = "1.0.18"` patched by a local `9.9.9` builds against the registry
* while this reads the patch as active. Matching it takes a semver
* implementation this module does not have, and the reading errs toward the
* project's own crate — the same direction the resolver took before any of this
* existed.
*/
function collectPatchedPaths(manifest) {
const patched = new Set();
if (!manifest)
return patched;
const readEntries = (tableValue) => {
const table = asTable(tableValue);
if (!table)
return;
for (const [key, value] of Object.entries(table)) {
if (!isTable(value) || typeof value.path !== "string")
continue;
// A `[replace]` key carries the version after a colon; a `[patch]` one
// does not, and splitting is harmless there.
patched.add(key.split(":")[0].replace(/-/g, "_"));
}
};
const patchSection = asTable(manifest.patch);
if (patchSection) {
for (const [source, entries] of Object.entries(patchSection)) {
// `[patch."https://github.com/…"]` patches that remote, not the registry,
// and a dependency taken from anywhere else is untouched by it.
if (source !== "crates-io")
continue;
readEntries(entries);
}
}
readEntries(manifest.replace);
return patched;
}
/**
* Extract dependency aliases declared in a `Cargo.toml` table (e.g. `[dependencies]`,
* `[dev-dependencies]`, `[build-dependencies]`, `[target.*.dependencies]`).
*
* Normalizes dashes to underscores for both the local alias name and the target
* crate package name. If `dep.workspace = true`, resolves against workspace
* dependencies declared in the enclosing workspace manifest.
*/
function extractCargoAliases(manifest, wsDeps, wsLocalDeps, externalDeps, gitDeps) {
if (!manifest)
return {};
const aliases = {};
const processDepTable = (tableValue) => {
const table = asTable(tableValue);
if (!table)
return;
for (const [depKey, depVal] of Object.entries(table)) {
const alias = depKey.replace(/-/g, "_");
if (isTable(depVal)) {
// A `path` is what makes a dependency the project's own; anything else
// — a version, a git remote — is fetched from outside, whatever the
// project happens to hold under the same name. A `workspace = true`
// entry inherits the answer from where the workspace declares it.
const local = typeof depVal.path === "string" ||
(depVal.workspace === true && (wsLocalDeps?.has(alias) ?? false));
if (!local)
externalDeps?.add(alias);
// A git dependency is fetched from its remote whatever
// `[patch.crates-io]` says, so it must not be handed back to a local
// crate by one.
if (typeof depVal.git === "string")
gitDeps?.add(alias);
if (typeof depVal.package === "string") {
aliases[alias] = depVal.package.replace(/-/g, "_");
}
else if (depVal.workspace === true) {
const wsTarget = wsDeps.get(alias) ?? wsDeps.get(depKey.replace(/_/g, "-"));
aliases[alias] = wsTarget ?? alias;
}
else {
aliases[alias] = alias;
}
}
else if (typeof depVal === "string") {
// `dep = "1.0"` is a version and nothing else: always from a registry.
externalDeps?.add(alias);
aliases[alias] = alias;
}
}
};
processDepTable(manifest.dependencies);
processDepTable(manifest["dev-dependencies"]);
processDepTable(manifest["build-dependencies"]);
const targetSection = asTable(manifest.target);
if (targetSection) {
for (const targetVal of Object.values(targetSection)) {
const targetTable = asTable(targetVal);
if (!targetTable)
continue;
processDepTable(targetTable.dependencies);
processDepTable(targetTable["dev-dependencies"]);
processDepTable(targetTable["build-dependencies"]);
}
}
return aliases;
}
/**
* Build the crate map for a Rust project, one entry per `Cargo.toml`.
*
* Rust names its own code three ways, and only one of them says anything about
* the filesystem. `mod foo;` names a sibling file, which the resolver already
* followed. `crate::`, `super::` and `self::` name a position in the module
* tree, whose root is a target's root module — `src/lib.rs`, `src/main.rs`, or
* whatever `[[bin]] path` declares. `some_crate::` names a whole other crate,
* which is only in this project if a manifest here declares it, under a name
* whose dashes Cargo turns into underscores (`sailor-core` is imported as
* `sailor_core`). None of the three resolved before this map existed: every
* specifier containing `::` returned null, so a Rust project's file graph came
* out as its bare `mod` declarations and nothing else.
*
* Targets are read from the manifest where declared and taken by convention
* otherwise, matching Cargo's own autodiscovery rules per edition:
* - `autobins`, `autotests`, `autoexamples`, `autobenches`, `autolib` flags
* turn off autodiscovery for their respective directories when set to `false`.
* - In edition 2015, autodiscovery is turned off by default for a target type
* when at least one target of that type is manually declared. In edition
* 2018+, autodiscovery remains enabled unless explicitly set to `false`.
* - `[package] build = false` disables the build script target (`build.rs`).
* - `[workspace] exclude` patterns exclude matching directories from being
* registered as importable workspace members (`name = null`).
*
* The manifest is parsed with the same TOML reader the Python side uses rather
* than scanned, so a `path` key belonging to a dependency is a different key
* rather than nearby text. A manifest that cannot be parsed contributes its
* convention targets and no name: one unreadable `Cargo.toml` must not cost the
* whole project its Rust edges.
*
* Call this once per graph build and pass the result to resolveImport.
*/
export function buildRustCrateMap(fileSet, projectPath, recorder) {
const root = path.resolve(projectPath);
const relManifests = findCargoManifests(root);
if (relManifests.length === 0)
return [];
const rustFiles = [...fileSet].filter((f) => f.endsWith(".rs")).sort();
const parsedManifests = [];
for (const relManifest of relManifests) {
const dir = toForwardSlash(path.dirname(relManifest)); // "." at the root
let manifest = null;
const manifestAbs = path.join(root, relManifest);
try {
// Recorded before the BOM is stripped: the record describes the bytes on
// disk, which is what the next update re-hashes.
const source = readFileSync(manifestAbs, "utf8");
recorder?.read(manifestAbs, source);
manifest = asTable(parseToml(source.replace(/^\uFEFF/, "")));
}
catch {
recorder?.unreadable(manifestAbs);
// Unreadable or malformed manifest: its convention targets still count.
}
parsedManifests.push({ relManifest, dir, manifest });
}
// Collect all workspace tables to resolve `[workspace] exclude` and `[workspace.dependencies]`.
//
// The whole manifest travels beside the `[workspace]` table: `[patch]` is a
// top-level section of the workspace root, not part of that table, and the
// members inherit it.
const workspaceManifests = parsedManifests
.filter((m) => m.manifest?.workspace !== undefined && isTable(m.manifest.workspace))
.map((m) => ({
dir: m.dir,
table: m.manifest?.workspace,
root: m.manifest,
}));
const findEnclosingWorkspace = (dir) => {
let best = null;
for (const ws of workspaceManifests) {
const isAncestor = ws.dir === "." || dir === ws.dir || dir.startsWith(`${ws.dir}/`);
if (isAncestor && (best === null || ws.dir.length > best.dir.length)) {
best = ws;
}
}
return best;
};
const crates = [];
for (const { dir, manifest } of parsedManifests) {
// Normalized after joining, not before: a declared `path = "../bar/src/lib.rs"`
// is legal — cargo builds it — and joining it raw produces
// `crates/foo/../bar/src/lib.rs`, which the file set never holds, so the
// declaration was dropped and with it the crate's name and roots. The
// convention paths passed through here are already clean, so this only ever
// changes a declared one.
// An absolute path is legal too, and cargo builds it: there is nothing to
// join, and joining it would produce `crates/foo//Users/…`. Where it points
// inside the project it becomes the project-relative spelling the file set
// holds; where it points outside, nothing in the tree can match it and it
// falls through to null, which is the right answer for a file that is not
// part of the project.
const under = (relative) => {
if (path.posix.isAbsolute(relative) || path.isAbsolute(relative)) {
const rel = path.relative(projectPath, relative);
return rel && !rel.startsWith("..") ? toForwardSlash(rel) : toForwardSlash(relative);
}
return toForwardSlash(path.posix.normalize(dir === "." ? relative : `${dir}/${relative}`));
};
const enclosingWs = findEnclosingWorkspace(dir);
const wsDeps = new Map();
// Which of the workspace's own dependency entries carry a `path`, which is
// what a member's `dep = { workspace = true }` inherits along with the name.
const wsLocalDeps = new Set();
if (enclosingWs) {
const wsDepsTable = asTable(enclosingWs.table.dependencies);
if (wsDepsTable) {
for (const [depKey, depVal] of Object.entries(wsDepsTable)) {
const normKey = depKey.replace(/-/g, "_");
if (isTable(depVal) && typeof depVal.path === "string")
wsLocalDeps.add(normKey);
if (isTable(depVal) && typeof depVal.package === "string") {
wsDeps.set(normKey, depVal.package.replace(/-/g, "_"));
}
else {
wsDeps.set(normKey, normKey);
}
}
}
}
// `[patch]` sends a name that reads as a registry dependency to a directory
// instead, and it is how a workspace makes its members depend on each other
// by version. tokio does exactly this for all five of its crates: read as
// registry names, 473 real edges went with them.
//
// Only the workspace root's, or the manifest's own when it belongs to no
// workspace. Cargo says so out loud where a member writes one — "patch for
// the non root package will be ignored, specify patch at the workspace
// root" — and honouring it there drew an edge into a crate the build never
// touches.
const patchedLocally = collectPatchedPaths(enclosingWs ? enclosingWs.root : manifest);
const pkg = asTable(manifest?.package);
const lib = asTable(manifest?.lib);
// A manifest with no `edition` key is a 2015 manifest — Cargo warns about
// it and carries on. Reading only the explicit spelling left every such
// package on 2018 rules, and a `[[bin]]` declared beside an undeclared
// file in `src/bin/` then handed the file a crate root Cargo never gives
// it. Old manifests are exactly the ones that omit the key.
//
// `edition.workspace = true` is not a missing key: the member inherits
// `[workspace.package] edition`, and `pkg.edition` is the table
// `{ workspace: true }` rather than a string. Read as a plain key only,
// every member of every workspace that centralises its edition — the
// recommended layout — fell back to 2015, which turns off autodiscovery
// next to a declared `[[bin]]` and reads unanchored `use` paths from the
// crate root instead of the module directory. Verified with cargo 1.70,
// 1.85 and 1.98: the member compiles `async fn`, and `cargo metadata`
// reports edition 2021 for it; with the key omitted entirely, the same
// member reports 2015 and rejects `async fn` — inheritance happens only
// when the member asks for it.
const wsPackage = enclosingWs ? asTable(enclosingWs.table.package) : null;
const inheritsEdition = isTable(pkg?.edition) && pkg.edition.workspace === true;
const inheritedEdition = inheritsEdition && typeof wsPackage?.edition === "string" ? wsPackage.edition : null;
const edition = typeof pkg?.edition === "string" ? pkg.edition : (inheritedEdition ?? "2015");
const is2015 = edition === "2015";
const declaredLib = declaredTargetPath(manifest?.lib);
const autolib = typeof pkg?.autolib === "boolean" ? pkg.autolib : true;
const conventionLib = under("src/lib.rs");
const libRoot = declaredLib && fileSet.has(under(declaredLib))
? under(declaredLib)
: (manifest?.lib !== undefined || autolib) && fileSet.has(conventionLib)
? conventionLib
: null;
// `[lib] name` overrides the package name for the importable target; both
// spellings reach the same file, so both are recorded by the caller's map.
//
// `[workspace] exclude` does NOT take a package out of the importable set,
// however much it looks like it should: it only keeps the member out of
// the default set of workspace-wide commands. A member of the workspace
// can still depend on an excluded package by path, and does — checked by
// building exactly that and watching `cargo check` accept it. Reading
// `exclude` as "not importable" lost a real edge.
const declaredName = typeof lib?.name === "string" ? lib.name : null;
const packageName = typeof pkg?.name === "string" ? pkg.name : null;
const name = libRoot ? ((declaredName ?? packageName)?.replace(/-/g, "_") ?? null) : null;
const roots = new Set();
if (libRoot)
roots.add(libRoot);
// Explicitly declared targets. An array-of-tables key holds one table per
// target; a manifest that spells it as a single table is malformed for
// Cargo, and declaredTargetPath reads it as one entry rather than failing.
for (const key of ["bin", "test", "example", "bench"]) {
const declared = manifest?.[key];
for (const entry of Array.isArray(declared) ? declared : [declared]) {
const targetPath = declaredTargetPath(entry);
const targetEntryTable = asTable(entry);
if (targetPath && fileSet.has(under(targetPath))) {
roots.add(under(targetPath));
}
else if (!targetPath && typeof targetEntryTable?.name === "string") {
const targetName = targetEntryTable.name;
if (key === "bin") {
const candidates = [`src/bin/${targetName}.rs`, `src/bin/${targetName}/main.rs`];
if (targetName === (packageName ?? name))
candidates.push("src/main.rs");
for (const cand of candidates) {
if (fileSet.has(under(cand)))
roots.add(under(cand));
}
}
else if (key === "test") {
for (const cand of [`tests/${targetName}.rs`, `tests/${targetName}/main.rs`]) {
if (fileSet.has(under(cand)))
roots.add(under(cand));
}
}
else if (key === "example") {
for (const cand of [`examples/${targetName}.rs`, `examples/${targetName}/main.rs`]) {
if (fileSet.has(under(cand)))
roots.add(under(cand));
}
}
else if (key === "bench") {
for (const cand of [`benches/${targetName}.rs`, `benches/${targetName}/main.rs`]) {
if (fileSet.has(under(cand)))
roots.add(under(cand));
}
}
}
}
}
// Build script target: `build = false` in [package] explicitly disables it.
if (pkg?.build !== false) {
const buildScript = typeof pkg?.build === "string" ? pkg.build : "build.rs";
if (fileSet.has(under(buildScript)))
roots.add(under(buildScript));
}
// Convention targets, as Cargo autodiscovers them according to edition and flags.
const autoBins = typeof pkg?.autobins === "boolean"
? pkg.autobins
: !(is2015 && manifest?.bin !== undefined);
if (autoBins) {
if (fileSet.has(under("src/main.rs")))
roots.add(under("src/main.rs"));
const prefix = `${under("src/bin")}/`;
for (const file of rustFiles) {
if (!file.startsWith(prefix))
continue;
const rest = file.slice(prefix.length);
// `<dir>/name.rs` is a target; `<dir>/name/main.rs` is a target whose
// own modules live beside it. Anything deeper is a module of one of
// those, not a target of its own.
if (!rest.includes("/"))
roots.add(file);
else if (rest.split("/").length === 2 && rest.endsWith("/main.rs"))
roots.add(file);
}
}
const autoTests = typeof pkg?.autotests === "boolean"
? pkg.autotests
: !(is2015 && manifest?.test !== undefined);
if (autoTests) {
const prefix = `${under("tests")}/`;
for (const file of rustFiles) {
if (!file.startsWith(prefix))
continue;
const rest = file.slice(prefix.length);
if (!rest.includes("/"))
roots.add(file);
else if (rest.split("/").length === 2 && rest.endsWith("/main.rs"))
roots.add(file);
}
}
const autoExamples = typeof pkg?.autoexamples === "boolean"
? pkg.autoexamples
: !(is2015 && manifest?.example !== undefined);
if (autoExamples) {
const prefix = `${under("examples")}/`;
for (const file of rustFiles) {
if (!file.startsWith(prefix))
continue;
const rest = file.slice(prefix.length);
if (!rest.includes("/"))
roots.add(file);
else if (rest.split("/").length === 2 && rest.endsWith("/main.rs"))
roots.add(file);
}
}
const autoBenches = typeof pkg?.autobenches === "boolean"
? pkg.autobenches
: !(is2015 && manifest?.bench !== undefined);
if (autoBenches) {
const prefix = `${under("benches")}/`;
for (const file of rustFiles) {
if (!file.startsWith(prefix))
continue;
const rest = file.slice(prefix.length);
if (!rest.includes("/"))
roots.add(file);
else if (rest.split("/").length === 2 && rest.endsWith("/main.rs"))
roots.add(file);
}
}
const externalDeps = new Set();
const gitDeps = new Set();
const aliases = extractCargoAliases(manifest, wsDeps, wsLocalDeps, externalDeps, gitDeps);
// The patch table names packages, the dependency table names whatever this
// crate imports them as: `alias = { package = "itoa" }` is patched under
// `itoa` and written as `alias`, so the translation has to happen here.
for (const alias of [...externalDeps]) {
if (gitDeps.has(alias))
continue;
if (patchedLocally.has(aliases[alias] ?? alias))
externalDeps.delete(alias);
}
const crateEntry = {
dir,
name,
packageName: packageName?.replace(/-/g, "_") ?? null,
libRoot,
roots: [...roots].sort(),
edition,
};
if (manifest === null)
crateEntry.manifestUnread = true;
if (Object.keys(aliases).length > 0) {
crateEntry.aliases = aliases;
}
if (externalDeps.size > 0) {
crateEntry.externalDeps = [...externalDeps].sort();
}
crates.push(crateEntry);
}
return crates;
}
/**
* The directory a Rust file's submodules live in.
*
* A crate root (`src/lib.rs`, `src/main.rs`, `src/bin/tool.rs`) and a `mod.rs`
* both own the directory they sit in — `mod foo;` in either names `foo.rs`
* beside them. Every other file owns the directory named after it: `mod bar;`
* inside `src/foo.rs` names `src/foo/bar.rs`, never `src/bar.rs`.
*
* `lib.rs` and `main.rs` own their directory by convention as well as by being
* roots, so a tree with no `Cargo.toml` — where no root is known — still reads
* them the way Cargo would.
*/
function rustModuleDir(relFile, isCrateRoot) {
const dir = toForwardSlash(path.dirname(relFile));
if (isCrateRoot || ["mod.rs", "lib.rs", "main.rs"].includes(path.basename(relFile)))
return dir;
const stem = path.basename(relFile, ".rs");
return dir === "." ? stem : `${dir}/${stem}`;
}
/** Join a module directory and a path below it, with `"."` meaning the root. */
function underModuleDir(moduleDir, relative) {
return moduleDir === "." ? relative : `${moduleDir}/${relative}`;
}
/**
* The crate root module governing one file, and the directory that root's
* module tree starts from.
*
* A file can sit under several roots — `src/bin/tool.rs` is a root of its own
* while also sitting under `src/lib.rs`'s directory — so the deepest module
* directory wins, and a file that IS a root is its own. Null when no manifest
* covers the file, which is what keeps `crate::` unresolved rather than
* guessed in a tree with no `Cargo.toml`.
*/
function rustRootForFile(crates, relFile) {
// The answer depends only on the file and the crate map, and every import in
// a file asks it again — around twenty times per file, once per `use`. On a
// workspace of a hundred crates that repetition measured two seconds of the
// build. The cache is keyed on the crate map itself, so it lives exactly as
// long as the build that produced it and never outlives a stale map.
let perFile = rustRootCache.get(crates);
if (!perFile) {
perFile = new Map();
rustRootCache.set(crates, perFile);
}
const cached = perFile.get(relFile);
if (cached !== undefined)
return cached;
let best = null;
for (const crate of crates) {
for (const root of crate.roots) {
const moduleDir = rustModuleDir(root, true);
if (root === relFile) {
best = { root, moduleDir };
break;
}
const covers = moduleDir === "." || relFile.startsWith(`${moduleDir}/`);
if (covers && (best === null || moduleDir.length > best.moduleDir.length)) {
best = { root, moduleDir };
}
}
if (best?.root === relFile)
break;
}
perFile.set(relFile, best);
return best;
}
/** Per-build memo for {@link rustRootForFile}; see the note in that function. */
const rustRootCache = new WeakMap();
/**
* The file holding the module that owns `moduleDir`, in either of the two
* layouts Rust allows for a module with children: `foo.rs` beside `foo/`, or
* `foo/mod.rs` inside it.
*/
function rustModuleFile(moduleDir, fileSet) {
if (moduleDir === ".")
return null;
for (const candidate of [`${moduleDir}.rs`, `${moduleDir}/mod.rs`]) {
if (fileSet.has(candidate))
return candidate;
}
return null;
}
/**
* Walk a module path down from a module directory to the file that holds it.
*
* The trailing segments of a Rust path name items, not modules — `crate::db::
* Connection` ends at a type — and nothing in the path says where the modules
* stop. So the longest prefix that names a file wins, and a path that names
* only items in the root resolves to the root module itself.
*/
function resolveRustModulePath(moduleDir, segments, rootFile, fileSet) {
for (let k = segments.length; k >= 1; k--) {
const base = underModuleDir(moduleDir, segments.slice(0, k).join("/"));
if (fileSet.has(`${base}.rs`))
return `${base}.rs`;
if (fileSet.has(`${base}/mod.rs`))
return `${base}/mod.rs`;
}
return rootFile && fileSet.has(rootFile) ? rootFile : null;
}
/**
* The crates rustc provides without any manifest declaring them: `std` and
* its two pieces, the proc-macro bridge, and the test harness. A path that
* names one reaches no file of this project, in any edition.
*/
const RUST_BUILTIN_CRATES = new Set(["std", "core", "alloc", "proc_macro", "test"]);
/**
* Resolve one Rust module path to the project file it names.
*
* `relSourceFile` is the importing file, project-relative and forward-slashed.
* `crates` comes from {@link buildRustCrateMap}; an empty list leaves
* `crate::` and cross-crate paths unresolved, which is what a tree carrying no
* `Cargo.toml` should produce.
*
* `super::` and `self::` are answered from the file's own position rather than
* from its crate root, so they resolve in a manifest-less tree too. Climbing
* stops at the crate root's directory when a root is known: a path cannot leave
* its crate through `super`, and without the guard `super::super::x` at the top
* of a workspace member would reach into a sibling's files.
*/
export function resolveRustImport(specifier, relSourceFile, fileSet, crates, declaredMods, isDeclaration) {
const own = rustRootForFile(crates, relSourceFile);
const isRoot = own?.root === relSourceFile;
const ownModuleDir = rustModuleDir(relSourceFile, isRoot);
// A `#[path = "…"]` attribute arrives as the path it declares, extension and
// all, which no module path ever carries.
//
// On a declared module it is relative to the directory the declaring file
// sits in — never to the directory that file's submodules live in, so
// `src/a/b.rs` and `src/a/mod.rs` both reach `src/a/moved.rs`. Written
// inside an inline `mod`, it counts from the file's own module directory
// instead, one directory deeper per inline level; extractImports marks that
// form with a `self/` head, since nothing in the path itself tells them
// apart.
if (specifier.endsWith(".rs")) {
const fromInline = specifier.startsWith("self/");
const base = fromInline ? ownModuleDir : path.dirname(relSourceFile);
const declared = toForwardSlash(path.normalize(path.join(base, fromInline ? specifier.slice("self/".length) : specifier)));
return fileSet.has(declared) ? declared : null;
}
// `use ::config::Item;` says the head names a crate and not a module in
// scope — it is how a file that also has a local `config` reaches the other
// one. Since a local module otherwise wins, the marker has to survive.
//
// What it means, though, is an edition rule: `::` is the extern prelude from
// 2018 on, and the crate root in 2015, where it says nothing a bare head does
// not already say. Checked on 1.98.0 with one crate carrying `pub mod log;`
// and `use ::log::LocalMarker;`: it compiles in 2015 and is E0432 in 2018.
const globalMarker = specifier.startsWith("::");
const segments = specifier
.split("::")
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length === 0)
return null;
// The package whose manifest governs this file, which is what says under
// which names its dependencies are imported: the same crate answers to
// different names in two members of one workspace. The innermost manifest
// containing the file wins, so depth is what ranks them — and the root's
// `"."` names no directory at all, it is the empty prefix. Measured by its
// length it is one character, which ties with every member directory of one
// character and, the sort being stable and manifests walked shallowest
// first, wins the tie: every file of `a/` was then governed by the root's
// manifest. A `[workspace]`-only root declares no dependency and no edition,
// so `a/`'s crates went unreachable and its 2018-or-later files were read as
// 2015 (review finding).
const depthOf = (crate) => (crate.dir === "." ? 0 : crate.dir.length);
const importingCrate = crates
.filter((crate) => crate.dir === "." || relSourceFile.startsWith(`${crate.dir}/`))
.sort((a, b) => depthOf(b) - depthOf(a))[0];
// A crate this project declares, preferring one whose directory contains the
// importing file — two manifests can carry the same package name, and a
// workspace member's own name must not resolve into an unrelated checkout.
const crateNamed = (name) => {
// A name the manifest fetches from a registry or a git remote is that
// crate, not a project crate of the same name: cargo compiles against what
// it fetched, and the local one is never in scope. `log = "0.4"` beside a
// workspace member also called `log` drew an edge into the member —
// verified on cargo 1.98.0, where a `compile_error!` in it does not stop
// the build because rustc never reads the file.
if (importingCrate?.externalDeps?.includes(name))
return null;
// Only what the importing package declares is in its extern prelude. A
// sibling of the workspace it does not depend on is not reachable, and
// rustc refuses the path with E0432 — yet the raw name used to be searched
// across every crate of the project, so `use helper::Thing` drew an edge
// into a sibling `helper` the manifest never named (review finding). The
// one name a package reaches without declaring it is its own library,
// which its binaries, tests and examples import by that name.
//
// A dependency is declared under its package name (or an alias of it), but
// the code writes its *library* name, and the two differ wherever the
// dependency sets `[lib] name` — `rust-crypto` is imported as `crypto`. So
// the fence admits a name in two ways: it is a key of `aliases`, which
// holds every declared dependency under the name the code writes for it;
// or it is the library name of a project crate declared under its own
// package name — not renamed, since `tools = { package = "helper" }` puts
// the crate in scope as `tools` and nothing else. A manifest that could not be read
// declares nothing that can be known, and is not fenced at all — the
// alternative costs that package every cross-crate edge. A file outside
// every manifest has no package to ask, and is left as it was.
if (importingCrate && !importingCrate.manifestUnread && name !== importingCrate.name) {
const declared = Object.entries(importingCrate.aliases ?? {});
// Renamed, the alias is what cargo hands rustc as the crate's name.
const renamed = declared.some(([alias, target]) => alias !== target && alias === name);
// Declared under its own package name, the crate's name to rustc is its
// library name — cargo passes `--extern otherlib=…` for a dependency
// written `other = { path = "../other" }` whose `[lib] name` is
// `otherlib`, and `use other::Thing` is E0432 there. Where the project
// holds no crate of that package name, the dependency comes from outside
// and the package name is all that is known of it.
const plain = new Set(declared.filter(([alias, target]) => alias === target).map(([alias]) => alias));
const libraryOfPlain = crates.some((crate) => crate.name === name && crate.packageName !== null && plain.has(crate.packageName));
const plainFromOutside = plain.has(name) && !crates.some((crate) => crate.packageName === name && crate.libRoot);
if (!renamed && !libraryOfPlain && !plainFromOutside)
return null;
}
// `dep = { package = "real-name" }` renames a dependency for the crate
// that declares it, and the code then writes the alias. The alias appears
// in no `[package] name`, so without this the path resolved to nothing —
// or worse, to a local module that happened to carry the alias.
const declared = importingCrate?.aliases?.[name] ?? name;
// What the alias resolves to is a package name, and what the code wrote
// may be a library name; a crate answers to either. `dep = { path = "…",
// package = "other" }` where `other` sets `[lib] name = "otherlib"` reached
// nothing when only the library name was compared.
const candidates = crates.filter((crate) => (crate.name === declared || crate.packageName === declared) && crate.libRoot);
if (candidates.length === 0)
return null;
return (candidates.sort((a, b) => {
const aCovers = a.dir === "." || relSourceFile.startsWith(`${a.dir}/`);
const bCovers = b.dir === "." || relSourceFile.startsWith(`${b.dir}/`);
if (aCovers !== bCovers)
return aCovers ? -1 : 1;
return b.dir.length - a.dir.length || a.dir.localeCompare(b.dir);
})[0] ?? null);
};
const head = segments[0];
// Whether the head names a module this very file declares. It gates both
// shapes an unanchored path takes — a head on its own and a head with
// segments below it — because scope is what decides them, not length.
// Left undefined by a caller, it keeps the older, looser reading, so every
// existing caller behaves as before.
const declaredHere = declaredMods === undefined || declaredMods.has(head);
// Where the declaration put that module's file, when it moved it. A
// `#[path = "custom.rs"] mod foo;` names `foo` and files it under
// `custom.rs`, and the name alone reaches neither: `src/foo.rs` does not
// exist, so the path fell through to the crate of that name and drew an edge
// into an unrelated library. What rustc does with the children is the other
// half — they sit BESIDE the file the attribute names, `src/inner.rs` and
// not `src/custom/inner.rs`, which E0583 states outright on 1.70.0 and
// 1.98.0.
const declaredFile = (() => {
const spec = declaredMods?.get(head);
if (!spec?.endsWith(".rs"))
return null;
const fromInline = spec.startsWith("self/");
const base = fromInline ? ownModuleDir : path.dirname(relSourceFile);
const declared = toForwardSlash(path.normalize(path.join(base, fromInline ? spec.slice("self/".length) : spec)));
return fileSet.has(declared) ? declared : null;
})();
// In edition 2015 an unanchored path is absolute from the crate root rather
// than relative to this file's scope.
//
// **That reading is deliberately not acted on for resolution.** Acting on it
// means answering a path with no declaration behind it, and every attempt to
// bound that produced a fresh way to draw an edge rustc rejects: a bare
// `use b;` between two integration-test crates, an undeclared sibling file, a
// `mod` declared in a nested block, a virtual workspace's root crate claiming
// its members' declarations. Each was found by a reviewer running cargo,
// after the previous one was closed.
//
// So the gate stays on for every edition: resolve what a declaration proves,
// and leave the rest unresolved. The cost is one real edge — `use foo::AtRoot;`
// in `src/deep/mod.rs` with `mod foo;` in `lib.rs` compiles and draws nothing
// — which `main` never drew either, so nothing published regresses. Fewer
// edges, none of them wrong, and the remainder is documentation rather than
// another round.
const edition2015 = importingCrate?.edition === "2015" && own !== null;
// The edition still decides the leading `::`, which is a separate question:
// the marker names an external crate only where the extern prelude exists. In
// 2015 it is the same crate root an unanchored path counts from, and letting
// it through sent the path looking for a workspace crate of that name — or,
// finding none, to nothing at all. That case is answered first below, from
// the root and nowhere else.
const global = globalMarker && !edition2015;
const target = (() => {
// In edition 2015 the leading `::` is the crate root, and the crate root
// only: the same one `crate::` names, never this file's scope. Read
// through the file's own declarations, `use ::foo::Item` in a child that
// carried `#[path = "local.rs"] mod foo;` was answered with that local
// file, while rustc reaches the root's `foo` — a review finding left open
// from an earlier round. So the file's declaration map is not consulted
// at all here.
//
// Three things a name at the root can be, tried in this order. A module
// of the root, found by walking from the root's directory — with no file
// to fall back on, since a walk that lands on the root whenever nothing
// matches would answer every other case with the root too (found by
// review: it made the two branches below unreachable). A crate the package
// declares, which `extern crate foo;` at the root puts there — a root
// declaring both a module and a crate of one name is E0260, so only one
// exists; a registry crate is such a crate and resolves to nothing. And
// otherwise an item the root defines or re-exports — `use ::BrotliResult;`
// — which is the root file itself, unless the name is one of the crates
// rustc provides without any manifest: those are not items of this crate.
//
// A 2015 file outside every target has no root to count from, and is left
// unresolved rather than guessed.
if (globalMarker && importingCrate?.edition === "2015") {
if (!own)
return null;
const atRoot = resolveRustModulePath(own.moduleDir, segments, null, fileSet);
if (atRoot)
return atRoot;
const crate = crateNamed(head);
if (crate?.libRoot) {
return segments.length === 1
? crate.libRoot
: resolveRustModulePath(rustModuleDir(crate.libRoot, true), segments.slice(1), crate.libRoot, fileSet);
}
const declaresIt = importingCrate.externalDeps?.includes(head) ||
Object.hasOwn(importingCrate.aliases ?? {}, head) ||
RUST_BUILTIN_CRATES.has(head);
return declaresIt ? null : own.root;
}
// A bare specifier is a `mod foo;` declaration (see extractImports), which
// names a file in the declaring file's own module directory. `use foo;` —
// a whole crate, no path — and `extern crate foo;` arrive in the same
// shape, so the local module is tried first and the crate name second:
// only one of the two exists in any tree that compiles.
//
// Which of the two it is, the declaration decides: `mod foo;` puts `foo`
// among the names this file declares, `use foo;` does not. Without that
// gate an orphan `src/serde.rs` — a file no `mod` names, left by a
// refactor — captured `use serde;` and drew an edge rustc does not: on
// cargo 1.70.0 and 1.98.0 that line reaches the dependency with the file
// sitting right there.
if (segments.length === 1 && !["crate", "self", "super"].includes(head)) {
if (global)
return crateNamed(head)?.libRoot ?? null;
// A declaration is answered by the declaration: from the declaring
// file's own module directory, or from wherever `#[path]` filed it. It
// is never a crate, so it never falls through to one.
if (isDeclaration) {
return declaredFile ?? resolveRustModulePath(ownModuleDir, [head], null, fileSet);
}
// A `use foo;` in edition 2015 counts from the crate root even when this
// very file declares `foo` — checked on 1.70.0 and 1.98.0, where
// `use foo::Nested;` beside `mod foo;` is E0432 while `use foo::AtRoot;`
// reaches the root's module. Telling the two apart takes knowing which
// of them wrote the specifier, which is why the caller now says so — and
// a caller that says nothing keeps the older reading, where a bare head
// is tried from the file's own directory whatever the edition.
const local = declaredHere
? (declaredFile ?? resolveRustModulePath(ownModuleDir, [head], null, fileSet))
: null;
if (local)
return local;
// A name this file declares as a module is not a crate, whatever the
// manifest carries: rustc reads the module. Falling through drew an edge
// into a same-named library whenever the module's own file was somewhere
// the name alone could not reach — which is every `#[path]`.
if (declaredMods?.has(head))
return null;
return crateNamed(head)?.libRoot ?? null;
}
if (head === "crate") {
if (!own)
return null;
return resolveRustModulePath(own.moduleDir, segments.slice(1), own.root, fileSet);
}
if (head === "self" || head === "super") {
let moduleDir = ownModuleDir;
let rest = segments;
while (rest.length > 0 && (rest[0] === "self" || rest[0] === "super")) {
if (rest[0] === "super") {
// The crate root's own directory is the top: `super` from a module
// directly under it would leave the crate.
if (own && moduleDir === own.moduleDir)
return null;
moduleDir = toForwardSlash(path.dirname(moduleDir));
}
rest = rest.slice(1);
}
// What the climb landed on is a module too, and `super::Item` names an
// item declared right there — in `foo.rs` beside `foo/`, or in
// `foo/mod.rs`. Without this the whole `foo.rs`-plus-`foo/` layout, which
// is the one rustc recommends, lost every `super::` edge out of a child.
//
// The crate root is that module for the top of the tree, and it is named
// by neither convention: `src` holds no `src.rs` and no `src/mod.rs`, so
// `super::Item` written in `src/foo.rs` — the commonest `super::` there
// is — resolved to nothing until the root was checked by name.
const parent = own && moduleDir === own.moduleDir
? own.root
: rustModuleFile(moduleDir, fileSet);
return resolveRustModulePath(moduleDir, rest, parent === relSourceFile ? null : parent, fileSet);
}
// A uniform path: since Rust 2018 a `use` may start at a module in scope
// without saying `self::`, and `pub use inner::Thing;` beside `mod inner;`
// is how a crate republishes its own modules.
//
// This is tried before the crate names because that is the order rustc
// resolves in: a module in scope wins, and the extern prelude is consulted
// last. Reading the crate names first drew an edge into an unrelated
// sibling crate whenever a local module happened to carry its name —
// `config`, `log` and `utils` are both common module names and published
// crate names — and did so without the importing package declaring any
// dependency on it.
//
// In edition 2015 an unanchored path is absolute from the crate root
// instead: `use registry::write;` in `src/client.rs` names
// `src/registry.rs`, and rustc rejects that same line from 2018 on. A
// crate with no manifest keeps the 2018 reading, which is what a bare
// tree of `.rs` files most likely is.
//
// The head must name a module the file actually declares. Matching a
// sibling `.rs` file by name alone is a filesystem answer to a question
// about scope: it let a third-party head capture the import whenever a
// file of that name happened to exist, and in `tests/` — where each file
// is its own integration-test crate and none can import another — it drew
// an edge Rust cannot express at all. rustc agrees the declaration is the
// gate: with `mod log;` present, `use log::item;` reaches the module and
// not the dependency. `declaredMods` left undefined keeps the older,
// looser reading, so every existing caller behaves as before.
//
// The gate is on in every edition. An earlier round wrote here that it was
// a 2018 rule only, on the ground that `use registry::write;` in
// `src/client.rs` compiles with `mod registry;` written in `lib.rs` — true
// of rustc, and the edge is indeed one this resolver does not draw. What
// the decision recorded above `edition2015` says is that acting on it costs
// more than it buys: see that comment for the four ways it drew edges rustc
// rejects. This paragraph is what that decision replaced (review finding:
// it contradicted the code and the other comment both).
// A head the declaration moved is answered from where it moved it, and its
// own children from the directory that file sits in — `src/inner.rs` for a
// module filed at `src/custom.rs`, which is what E0583 asks for.
if (!global && declaredFile) {
const below = resolveRustModulePath(toForwardSlash(path.dirname(declaredFile)), segments.slice(1), declaredFile, fileSet);
if (below)
return below;
}
const local = global || !declaredHere
? null
: resolveRustModulePath(ownModuleDir, segments, null, fileSet);
if (local)
return local;
// A name this file declares as a module is not a crate; see the same guard
// on the single-segment branch.
if (!global && declaredMods?.has(head))
return null;
const crate = crateNamed(head);
if (crate?.libRoot) {
return resolveRustModulePath(rustModuleDir(crate.libRoot, true), segments.slice(1), crate.libRoot, fileSet);
}
return null;
})();
// A file never imports itself. `use crate::db::Connection;` written inside
// `#[cfg(test)] mod tests` in `db.rs` names that very file, and so does a
// `super::` path that climbs back to its own module: recorded as an edge,
// each becomes a node depending on itself.
return target === relSourceFile ? null : target;
}
/**
* Scan all .gd files in a fileSet for `class_name X` declarations and build
* a global Map<className, relativePath>.
*
* @deprecated Legacy helper retained for compatibility. New graph-building
* code should use buildGodotProjectIndexes() so class names remain scoped to
* their nearest Godot project.
*/
export function buildClassNameIndex(projectPath, fileSet) {
const index = new Map();
for (const relPath of fileSet) {
if (!relPath.endsWith(".gd"))
continue;
const absPath = path.join(projectPath, relPath);
try {
const content = readFileSync(absPath, "utf-8");
const className = extractClassNameFromGdscript(content);
if (className) {
index.set(className, relPath);
}
}
catch {
// Skip unreadable files
}
}
return index;
}
/**
* Build per-Godot-project UID indexes from `.uid` sidecar files and
* `.tscn`/`.tres` file headers.
*
* For each `.uid` sidecar file (e.g. `Player.gd.uid`), reads the UID
* string and maps it to the corresponding resource file. For `.tscn`
* and `.tres` files, parses the `uid="uid://..."` attribute from the
* file header.
*
* @param projectPath - Absolute path to the SocratiCode indexing root
* @param fileSet - Set of relative file paths
* @returns Map of Godot project root (absolute) → UID index (uid:// → relative path)
*/
export function buildGodotUidIndexes(projectPath, fileSet, rootCache = new Map(), recorder) {
const indexes = new Map();
for (const relPath of fileSet) {
// .uid sidecar files: <file>.uid contains a single line "uid://..."
if (relPath.endsWith(".uid")) {
const absPath = path.join(projectPath, relPath);
const godotRoot = walkUpForGodotProject(path.dirname(absPath), rootCache);
if (!godotRoot)
continue;
let index = indexes.get(godotRoot);
if (!index) {
index = new Map();
indexes.set(godotRoot, index);
}
try {
// A `.uid` sidecar is never a graph node, so this read is the only
// place it is seen. Recorded whole, before the trim, so the hash
// describes the file rather than what was parsed out of it.
const raw = readFileSync(absPath, "utf-8");
recorder?.read(absPath, raw);
const content = raw.trim();
if (content.startsWith("uid://")) {
// The resource file is the .uid path without the .uid suffix
const resourcePath = relPath.slice(0, -4); // strip ".uid"
// A stale sidecar must not create a graph node for a missing file.
if (fileSet.has(resourcePath))
index.set(content, resourcePath);
}
}
catch {
// A sidecar is never a graph node, so the main read loop skips it and
// this is the only place it is seen — including when it fails.
recorder?.unreadable(absPath);
}
continue;
}
// .tscn/.tres files: parse uid="uid://..." from the file header
if (relPath.endsWith(".tscn") || relPath.endsWith(".tres")) {
const absPath = path.join(projectPath, relPath);
const godotRoot = walkUpForGodotProject(path.dirname(absPath), rootCache);
if (!godotRoot)
continue;
let index = indexes.get(godotRoot);
if (!index) {
index = new Map();
indexes.set(godotRoot, index);
}
try {
const content = readFileSync(absPath, "utf-8");
recorder?.read(absPath, content);
// The uid attribute appears in the first section header:
// [gd_scene ... uid="uid://..."] or [gd_resource ... uid="uid://..."]
const match = content.match(/^\[gd_(?:scene|resource)\b[^\]]*\buid="(uid:\/\/[^"]+)"/m);
if (match) {
index.set(match[1], relPath);
}
}
catch {
// Skip unreadable files
}
}
}
return indexes;
}
/**
* Build per-Godot-project class_name indexes for a set of files.
*
* For each .gd file, finds the nearest project.godot ancestor and adds
* the file's class_name to that project's index. Files not under any
* project.godot are skipped (they have no Godot project context).
*
* @param projectPath - Absolute path to the SocratiCode indexing root
* @param fileSet - Set of relative file paths
* @returns Map of Godot project root (absolute) → ClassNameIndex (relative paths)
*/
export function buildGodotProjectIndexes(projectPath, fileSet, rootCache = new Map(), recorder) {
const indexes = new Map();
// Scan .gd, .tscn, and .tres files. .gd files contribute class_name
// entries to their project's index. .tscn/.tres files are scanned only
// to discover resource-only nested projects (a project with no .gd files
// but with .tscn/.tres files still needs its root in the map so that
// res:// paths in those resource files resolve correctly).
for (const relPath of fileSet) {
const isGd = relPath.endsWith(".gd");
const isResource = relPath.endsWith(".tscn") || relPath.endsWith(".tres");
if (!isGd && !isResource)
continue;
const absPath = path.join(projectPath, relPath);
const godotRoot = walkUpForGodotProject(path.dirname(absPath), rootCache);
if (!godotRoot)
continue;
// Ensure this project root is in the map (even if the file has no
// class_name — resource-only projects need an entry).
let index = indexes.get(godotRoot);
if (!index) {
index = new Map();
indexes.set(godotRoot, index);
}
// Only .gd files can contribute class_name entries
if (!isGd)
continue;
try {
const content = readFileSync(absPath, "utf-8");
recorder?.read(absPath, content);
const className = extractClassNameFromGdscript(content);
if (className) {
index.set(className, relPath);
}
}
catch {
// Skip unreadable files
}
}
return indexes;
}
/**
* Find the nearest Godot project root for a given source file.
*
* Walks up from the file's directory looking for project.godot.
* Returns the absolute path to the directory containing it, or null
* if no project.godot is found. This is per-file (not per-repo) to
* support repositories with multiple Godot projects.
*
* @param sourceFile - Absolute path to the source file
* @param godotProjectIndexes - Retained for compatibility with existing callers.
* @param rootCache - Optional cache scoped to one graph build.
*/
export function findGodotRootForFile(sourceFile, _godotProjectIndexes, rootCache = new Map()) {
// Always walk the filesystem from the file's directory to find the
// NEAREST project.godot. This is critical for nested projects:
//
// repo/project.godot
// repo/outer.gd
// repo/game/project.godot
// repo/game/scene.tscn
//
// scene.tscn must resolve to repo/game (the nearest root), not repo
// (an outer root that happens to be in the indexes map). The indexes
// map is a cache of known roots, but it does not prove that a known
// root is the *nearest* one — a nearer project.godot may exist on
// disk that was not in the fileSet when the indexes were built.
//
// The filesystem walk is cheap (stat calls walking up from the file's
// directory, typically 1-3 hops) and guarantees correctness.
return walkUpForGodotProject(path.dirname(sourceFile), rootCache);
}
/**
* Resolve a module specifier to a relative file path within the project.
* Returns null if the module is external (e.g., npm package, stdlib).
*
* `language` is a display label as produced by `getLanguageFromExtension`
* (e.g. "shell", "typescript") — that is what `buildCodeGraph` passes. "bash"
* has its own case below as a synonym for "shell". The capitalised `Lang`
* grammar names ("JavaScript", "TypeScript", "Tsx", "Html", "Css") match no
* case. Not every display label has one either, so a switch miss is always
* possible, and it returns the same null an external module does.
*
* @param aliases - Optional path aliases (tsconfig/jsconfig paths) for JS/TS resolution
* @param classNameIndex - Pre-built GDScript class_name → file path index for O(1) extends resolution
* @param godotProjectRoot - Pre-resolved Godot project root (directory containing project.godot).
* When provided, res:// paths resolve relative to this instead of walking the filesystem per call.
* @param godotUidIndex - Pre-built UID → relative path index for uid:// resolution.
* When provided, uid:// paths are resolved via this index. When absent,
* uid:// paths cannot be resolved and return null.
* @param fallbackSpecifier - Optional path fallback for a primary uid:// specifier.
* @param godotImportKind - GDScript construct that supplied the path. Runtime
* load paths use the Godot project root; extends and preload use the source directory.
*/
export function resolveImport(moduleSpecifier, sourceFile, projectPath, fileSet, language, aliases, jvmSuffixMap, csNamespaceMap, goModuleInfo, phpPsr4Map, dartPackageMap, pythonImportRoots, elixirModuleMap, phpFqcnMap, rustCrates, rustDeclaredMods, rustIsDeclaration, classNameIndex, godotProjectRoot, godotUidIndex, fallbackSpecifier, godotImportKind) {
// Skip obvious external/stdlib modules. Go is excluded from this
// pre-check because its external classifier in `isExternalModule`
// treats any `golang.org/...` import as external, which would block
// valid local imports for projects whose own module path starts with
// `golang.org/` (e.g. someone working on `golang.org/x/sync` itself).
// The Go case below performs its own module-path-aware classification
// and returns null for everything outside the local module.
if (language !== "go" && isExternalModule(moduleSpecifier, language))
return null;
const sourceDir = path.dirname(sourceFile);
switch (language) {
case "javascript":
case "typescript":
case "svelte":
case "vue": {
const jsExtensions = [".svelte", ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
// Relative imports: ./foo, ../bar
if (moduleSpecifier.startsWith(".")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, jsExtensions);
}
// Try path alias resolution
return resolveAliasPath(moduleSpecifier, projectPath, fileSet, jsExtensions, aliases);
}
case "css":
case "scss":
case "sass":
case "less":
case "stylus": {
const cssExtensions = [".css", ".scss", ".sass", ".less", ".styl"];
// CSS @import: ./variables.css, ../mixins.scss
if (moduleSpecifier.startsWith(".")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, cssExtensions);
}
// Try path alias resolution (e.g., $lib/styles/vars.css)
return resolveAliasPath(moduleSpecifier, projectPath, fileSet, cssExtensions, aliases);
}
case "python": {
// No import edge from a file to itself (#157). A module that names
// itself is resolvable in CPython — `import mymodule` inside a
// top-level `mymodule.py` really does import a second copy — but as a
// graph edge it is noise, and every case seen in the wild is a
// first-party module named after the third-party package it wraps
// (`whisperx.py` doing `import whisperx`), where the true target is the
// installed distribution and no first-party edge exists at all.
//
// Every probe is guarded, but the disposition differs. The sibling probe
// is a guess at a directory Python may not have on `sys.path` at all, so
// a self match there falls through to the probes behind it, which are
// the manifest-declared roots — the only ones that run after it:
// `packages/pkg-a/src/ns/requests.py` importing `requests` must still
// find the legitimate `packages/pkg-a/src/requests.py` that pkg-a's
// declared root supplies. Every other absolute probe stands for a real
// path entry, so a self match there ends resolution — see the comment
// above `direct`. The relative branch has nothing to fall through to:
// it reaches a self match for
// `from . import x` written in a package's own `__init__.py`, which
// resolves the bare `.` to that same `__init__.py` — the commonest
// self-edge in any Python tree.
const relSourceFile = toForwardSlash(path.relative(projectPath, sourceFile));
const notSelf = (candidate) => candidate === null || candidate === relSourceFile ? null : candidate;
// Relative: .foo, ..bar
if (moduleSpecifier.startsWith(".")) {
const dots = moduleSpecifier.match(/^\.+/)?.[0].length ?? 0;
let baseDir = sourceDir;
for (let i = 1; i < dots; i++) {
baseDir = path.dirname(baseDir);
}
const rest = moduleSpecifier.slice(dots).replace(/\./g, "/");
return notSelf(resolveRelativePath(rest || ".", baseDir, projectPath, fileSet, [".py"]));
}
// Absolute: foo.bar.baz → foo/bar/baz.py or foo/bar/baz/__init__.py
const modulePath = moduleSpecifier.replace(/\./g, "/");
// A self match at a probe that stands for a real `sys.path` entry ENDS
// resolution rather than falling through. Whichever directory a probe
// stands for — the project root, `src/`, `lib/`, a manifest-declared
// root — a match under it that IS the source file means that directory
// is the source file's own path entry, and a path entry that already
// supplies the module is not searched past. Root `mod.py` doing
// `import mod` beside a `src/mod.py` imports itself; answering with
// `src/mod.py` trades the self-edge for an edge to an unrelated module,
// which is worse. The same holds one probe down (`src/mod.py` must not
// answer with `lib/mod.py`) and at the manifest roots, where the
// fall-through reached a *sibling workspace package*:
// `packages/pkg-a/src/requests.py` importing `requests` answered with
// `packages/pkg-b/src/requests.py`, a fabricated cross-package edge.
//
// Only the sibling probe still falls through, because it alone is a
// guess rather than a path entry: `packages/pkg-a/src/ns/requests.py`
// has no `sys.path` entry at `.../src/ns`, so the discarded sibling
// match must yield to the declared root that really supplies the module.
const direct = resolveRelativePath(modulePath, projectPath, projectPath, fileSet, [".py"]);
if (direct === relSourceFile)
return null;
if (direct)
return direct;
// Try common Python source directories (src layout)
const pySrcDirs = ["src", "lib"];
for (const dir of pySrcDirs) {
const inSrc = resolveRelativePath(path.join(dir, modulePath), projectPath, projectPath, fileSet, [".py"]);
if (inSrc === relSourceFile)
return null;
if (inSrc)
return inSrc;
}
// Sibling-flat fallback (issue #46). Common in service-style monorepos
// where each top-level directory is a runnable Python application root
// and `import config` from `service-a/main.py` means
// `service-a/config.py` because the file is run via `python main.py`
// from inside its own directory. resolveRelativePath also handles the
// `<sourceDir>/<module>/__init__.py` package case via its built-in
// Python init fallback.
//
// Ahead of the manifest-declared roots below, which is what CPython
// does: sys.path[0] is the script's own directory, ahead of every
// installed-distribution entry, so where a sibling file and a package
// root both offer the module, the sibling is what actually gets
// imported.
//
// A NON-SELF collision here is left alone, deliberately (#157). Where a
// first-party module shares a name with a third-party distribution —
// `src/pkg/requests.py` beside `src/pkg/client.py` doing `import
// requests` — which one CPython loads depends on how the file is run,
// and the tree does not say. Imported as `pkg.client`, `sys.path` finds
// the installed distribution; executed as `python src/pkg/client.py`,
// `sys.path[0]` is `src/pkg` and the sibling wins. An `__init__.py` does
// not settle it: `sys.path[0]` is the SCRIPT's directory whether or not
// the directory is also a package, so gating on one would drop the
// legitimate #46 edge from a directly executed service directory that
// happens to contain `__init__.py`. The same ambiguity covers PEP 420
// namespace packages, which have no `__init__.py` to test at all.
//
// So the sibling stands in every non-self case, and only the
// unambiguous self-resolution above is removed. Narrowing this needs
// evidence of execution mode that the resolver does not currently have —
// not an inference from layout.
const sibling = notSelf(resolveRelativePath(modulePath, sourceDir, projectPath, fileSet, [".py"]));
if (sibling)
return sibling;
// Additional import roots: explicit `.socraticode.json` roots first in
// declared order (issue #171), then manifest-declared roots nearest first
// (issue #107). The probes above reach `src/` and `lib/` at the project
// root and the importing file's own directory; neither reaches `dags/`
// or `<package>/src/`, so convention-root and cross-package imports — and
// a package's own absolute self-imports — resolved to nothing.
//
// The manifest portion is already scoped to this file and ordered by
// proximity by pythonRootsForFile — a root that is not on the file's
// ancestor path and not a declared workspace member never appears here,
// and a package's own root is tried before a sibling package's.
//
// A self match here ends resolution, like the root and `src/` probes
// above. Configured order explicitly models sys.path order; manifest
// roots are ordered containing-first, so the root supplying the source
// file is the nearest inferred path entry and roots behind it belong to
// sibling packages.
for (const importRoot of pythonImportRoots ?? []) {
const inRoot = resolveRelativePath(path.posix.join(importRoot, modulePath), projectPath, projectPath, fileSet, [".py"]);
if (inRoot === relSourceFile)
return null;
if (inRoot)
return inRoot;
}
return null;
}
case "go": {
// Local Go imports are rooted at the module path declared in each
// go.mod (built by buildGoModuleInfo at graph-build time). A project
// may contain several modules (a monorepo with nested go.mod files),
// so pick the module whose declared path is the longest STRUCTURAL
// prefix of the import, then strip that prefix to get the package's
// directory relative to the module and look up its representative
// file. Anything else (stdlib like "fmt", third-party packages like
// "github.com/x/y", or a path that only shares a textual prefix)
// resolves to null.
const modules = goModuleInfo ?? [];
if (modules.length === 0)
return null;
let chosen = null;
for (const mod of modules) {
// Structural prefix only: the import equals the module path (its
// root package) or is a direct subpackage (module path + "/").
// A bare textual prefix is NOT a match — e.g. with modules
// github.com/x and github.com/x/y, the import github.com/x/yother
// must resolve via github.com/x, not be misrouted to github.com/x/y
// and then rejected as a missing package.
const isMatch = moduleSpecifier === mod.modulePath ||
moduleSpecifier.startsWith(`${mod.modulePath}/`);
if (!isMatch)
continue;
if (chosen === null || mod.modulePath.length > chosen.modulePath.length) {
chosen = mod;
}
}
if (!chosen)
return null;
const rest = moduleSpecifier.slice(chosen.modulePath.length);
// rest === "" → the module's root package (the dir containing go.mod).
// rest starts with "/" → a subpackage; strip the leading slash.
// Anything else (an import that shares the prefix but isn't a real
// subpackage, e.g. `github.com/user/repo-other`) is external.
let moduleRelDir;
if (rest === "") {
moduleRelDir = ".";
}
else if (rest.startsWith("/")) {
moduleRelDir = rest.slice(1);
}
else {
return null;
}
// packageMap values are already project-relative fileSet entries,
// so no further translation is needed — even for a nested module.
return chosen.packageMap.get(moduleRelDir) ?? null;
}
case "java":
case "kotlin":
case "scala": {
// com.example.Foo → com/example/Foo.java (or .kt, .scala)
const filePath = moduleSpecifier.replace(/\./g, "/");
const exts = language === "java" ? [".java"] : language === "kotlin" ? [".kt", ".kts"] : [".scala"];
// 1. Try direct resolution from project root (single-module layout).
const direct = resolveRelativePath(filePath, projectPath, projectPath, fileSet, exts);
if (direct)
return direct;
// 2. Try common source directories (Maven/Gradle single-module convention).
const jvmSrcDirs = [
`src/main/${language}`, // src/main/java, src/main/kotlin, src/main/scala
"src/main",
"src",
];
for (const dir of jvmSrcDirs) {
const inSrc = resolveRelativePath(path.join(dir, filePath), projectPath, projectPath, fileSet, exts);
if (inSrc)
return inSrc;
}
// 3. Preserve classic multi-module resolution before adding KMP targets.
let kmpSuffix = null;
if (jvmSuffixMap) {
for (const ext of exts) {
const found = jvmSuffixMap.get(filePath + ext);
if (!found)
continue;
const parts = found.split(/[\\/]/);
const idx = findJvmSourceSetBoundary(parts);
if (idx === -1 || parts[idx + 1] === "main")
return found;
if (kmpSuffix === null)
kmpSuffix = found;
}
}
// 4. Prefer the importer's KMP source set, without changing classic picks.
const sourceParts = path.relative(projectPath, sourceFile).split(/[\\/]/);
const sourceIdx = findJvmSourceSetBoundary(sourceParts);
if (sourceIdx !== -1 && sourceParts[sourceIdx + 1] !== "main") {
const sourceRoot = sourceParts.slice(0, sourceIdx + 3).join("/");
const inSourceSet = resolveRelativePath(path.join(sourceRoot, filePath), projectPath, projectPath, fileSet, exts);
if (inSourceSet)
return inSourceSet;
if (jvmSuffixMap instanceof JvmSuffixMap) {
const candidates = jvmSuffixMap.sourceSets.get(sourceParts[sourceIdx + 1]);
for (const ext of exts) {
const found = candidates?.get(filePath + ext);
if (found)
return found;
}
}
}
// 5. Keep existing cross-source-set probes when no matching target exists.
const kmpSrcDirs = [
`src/commonMain/${language}`,
`src/androidMain/${language}`,
`src/iosMain/${language}`,
`src/jvmMain/${language}`,
`src/nativeMain/${language}`,
`src/desktopMain/${language}`,
`src/appleMain/${language}`,
];
for (const dir of kmpSrcDirs) {
const inSrc = resolveRelativePath(path.join(dir, filePath), projectPath, projectPath, fileSet, exts);
if (inSrc)
return inSrc;
}
return kmpSuffix;
}
case "c":
case "cpp": {
// #include "relative/path.h"
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, []);
}
case "ruby": {
if (moduleSpecifier.startsWith("./") || moduleSpecifier.startsWith("../")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [".rb"]);
}
return resolveRelativePath(moduleSpecifier, projectPath, projectPath, fileSet, [".rb"]);
}
case "php": {
// A `use` names a namespace, a require/include names a file, and both
// arrive here as one specifier. Shape decides which: a namespace path is
// identifier segments joined by backslashes and can hold nothing else.
//
// The leading `\` of a fully-qualified name (`use \App\Models\User;`) is
// stripped once, for every branch below rather than only inside the
// PSR-4 lookup where it used to be handled. Left on, it made the
// heuristics build `/App/Models/User` — an absolute path resolving
// outside the project — so a project with no composer.json missed every
// fully-qualified import it made.
const namespaced = moduleSpecifier.replace(/^\\+/, "");
if (PHP_NAMESPACE_SHAPE.test(namespaced)) {
// Declared PSR-4 first — composer.json is the authority on where a
// namespace lives, and everything below it can only infer. Longest
// matching prefix wins so `Acme\Auth\Database\Seeders\` beats the
// shorter `Acme\Auth\` that also prefixes it.
if (phpPsr4Map && phpPsr4Map.size > 0) {
let bestPrefix = "";
for (const prefix of phpPsr4Map.keys()) {
if (namespaced.startsWith(prefix) && prefix.length > bestPrefix.length) {
bestPrefix = prefix;
}
}
// An exact prefix match leaves nothing to look up: the specifier
// names the prefix's own base directory, not a file in it. Composer
// rejects a PSR-4 prefix that does not end in a separator, so this
// needs a hand-edited manifest — but left unguarded, `use Foo;`
// against a `"Foo": "src/"` entry probes the bare directory, and
// resolveRelativePath's extension and index fallbacks land it on
// `src.php` or `src/index.php`: a wrong edge rather than a missing
// one.
const relative = bestPrefix
? namespaced.slice(bestPrefix.length).replace(/\\/g, "/")
: "";
if (bestPrefix && relative) {
for (const dir of phpPsr4Map.get(bestPrefix) ?? []) {
const candidate = dir ? `${dir}/${relative}` : relative;
const hit = resolveRelativePath(candidate, projectPath, projectPath, fileSet, [".php"]);
if (hit)
return hit;
}
}
}
// Then what the declarations themselves say. This is the only branch
// that can reach a package whose namespaces are registered at run time
// rather than declared in a manifest, and it is exact where the
// heuristics below are a guess, so it outranks them.
const declared = phpFqcnMap?.get(namespaced);
if (declared && declared.length > 0)
return declared[0];
// Layout heuristics, for a namespace no manifest declares and no
// in-project file claims. Single-segment names are excluded, as they
// were before: `use Foo;` names the global namespace, and probing it
// as a path would attach any same-named file in the tree to it.
if (!namespaced.includes("\\"))
return null;
const filePath = namespaced.replace(/\\/g, "/");
// Try exact case first
const exact = resolveRelativePath(filePath, projectPath, projectPath, fileSet, [".php"]);
if (exact)
return exact;
// PSR-4 convention: lowercase first segment (App → app)
const segments = filePath.split("/");
if (segments.length > 1) {
segments[0] = segments[0].toLowerCase();
const lowered = segments.join("/");
const loweredResult = resolveRelativePath(lowered, projectPath, projectPath, fileSet, [".php"]);
if (loweredResult)
return loweredResult;
}
// Try common Composer src directories (namespace root → src/ or lib/)
const srcDirs = ["src", "lib"];
for (const dir of srcDirs) {
// Skip first segment (namespace root) and look under src/
const withoutRoot = segments.slice(1).join("/");
if (withoutRoot) {
const inSrc = resolveRelativePath(path.join(dir, withoutRoot), projectPath, projectPath, fileSet, [".php"]);
if (inSrc)
return inSrc;
}
}
return null;
}
// require/include. An explicit `./` or `../` is source-relative, which
// is also the form `__DIR__ . '<literal>'` is emitted as.
if (moduleSpecifier.startsWith("./") || moduleSpecifier.startsWith("../")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [".php"]);
}
// A bare `require 'inc/util.php'` is resolved against the include_path
// at run time, which always starts with the including file's own
// directory and typically also carries the project root. Try both, in
// that order — the ruby resolver's shape, for the same reason. Before
// this, only `./`-prefixed paths resolved and every bare require was
// dropped, which in an include-driven tree is most of them.
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [".php"])
?? resolveRelativePath(moduleSpecifier, projectPath, projectPath, fileSet, [".php"]);
}
case "rust": {
// `mod foo;`, `crate::`, `super::`, `self::` and cross-crate paths all
// resolve against the crate map. Before it, everything carrying `::`
// returned null and a Rust graph held only bare `mod` declarations. An
// empty map still resolves `mod`, `super` and `self` from the file's own
// position.
return resolveRustImport(moduleSpecifier, toForwardSlash(path.relative(projectPath, sourceFile)), fileSet, rustCrates ?? [], rustDeclaredMods, rustIsDeclaration);
}
case "csharp": {
// C# `using X.Y.Z;` resolves via a namespace lookup map built once
// at graph-build time. Project-internal namespaces map to one or
// more files (multi-file namespaces are common in real .NET
// projects). External namespaces (`System.*`, `Microsoft.*`, etc.)
// are filtered earlier by `isExternalModule`.
//
// When a namespace spans multiple files we return the first
// candidate as the resolved dependency. This produces meaningful
// edges instead of the previous always-null behaviour, which left
// C# file graphs empty and silently degraded the symbol-level
// tools' cross-file resolution. A multi-file fan-out improvement
// is tracked as a follow-up.
if (csNamespaceMap) {
const candidates = csNamespaceMap.get(moduleSpecifier);
if (candidates && candidates.length > 0) {
return candidates[0];
}
}
return null;
}
case "swift": {
if (moduleSpecifier.startsWith("./") || moduleSpecifier.startsWith("../")) {
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [".swift"]);
}
return null;
}
case "shell":
case "bash": {
// `source ./script.sh` / `. ./script.sh` (see extractImports). Shell
// resolves the argument against the run-time cwd, so nothing here is
// exact; an explicit ./ or ../ is assumed script-relative by convention,
// which is the only form worth guessing. Anything else stays unresolved:
// a bare `source lib.sh` searches PATH and then the run-time cwd when bash
// is not in POSIX mode, and `source lib/x.sh` is cwd-relative too but
// carries no ./ to invoke that convention.
if (!moduleSpecifier.startsWith("./") && !moduleSpecifier.startsWith("../"))
return null;
if (!hasLiteralShellPathShape(moduleSpecifier))
return null;
// No candidate extensions — shell loads the literal path, with no
// extension search.
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, []);
}
case "dart": {
// `dart:` never reaches here: isExternalModule classifies it external
// and the pre-check above already returned null.
if (moduleSpecifier.startsWith("package:")) {
// `package:<name>/<rest>` → `<package_root>/lib/<rest>`. The `lib/`
// segment is pub's universal mapping (a package URI's root IS the
// package's lib/ directory), so resolving <rest> against the package
// root alone would match nothing. A name absent from the map is an
// external package (package:flutter, pub.dev deps) and stays null —
// as does everything when no map was built (no pubspec.yaml found,
// or a pre-#106 caller that does not pass one).
const rest = moduleSpecifier.slice("package:".length);
const slash = rest.indexOf("/");
if (slash <= 0)
return null; // `package:name` alone names no file
const packageDir = dartPackageMap?.get(rest.slice(0, slash));
if (packageDir === undefined)
return null;
const packagePath = rest.slice(slash + 1);
// `package:<name>/` names no file, and the extension fallbacks in
// resolveRelativePath would resolve the bare lib target onto a decoy
// `lib.dart` or `lib/index.dart` — a wrong edge, not a missing one.
if (packagePath === "")
return null;
// No valid package URI carries dot segments or backslashes;
// path.posix.join would normalize dot segments (and win32
// path.resolve treats a backslash as a separator), either of which
// could escape lib/ onto an unrelated in-project file, drawing an
// edge the code never expresses.
if (packagePath.includes("\\"))
return null;
if (packagePath.split("/").some((segment) => segment === "." || segment === "..")) {
return null;
}
const libPath = path.posix.join(packageDir, "lib", packagePath);
return resolveRelativePath(libPath, projectPath, projectPath, fileSet, [".dart"]);
}
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, [".dart"]);
}
case "elixir": {
return elixirModuleMap?.get(moduleSpecifier)?.[0] ?? null;
}
case "lua": {
// require("foo.bar") → foo/bar.lua
const luaPath = moduleSpecifier.replace(/\./g, "/");
return resolveRelativePath(luaPath, projectPath, projectPath, fileSet, [".lua"]);
}
case "gdscript": {
// uid:// paths — resolve via the UID index (Godot prefers UIDs
// over text paths for resource loading)
if (moduleSpecifier.startsWith("uid://")) {
if (godotUidIndex) {
const resolved = godotUidIndex.get(moduleSpecifier);
if (resolved)
return resolved;
}
// Try fallback specifier (path from ext_resource) if UID resolution missed
if (fallbackSpecifier) {
return resolveImport(fallbackSpecifier, sourceFile, projectPath, fileSet, language, aliases, jvmSuffixMap, csNamespaceMap, goModuleInfo, phpPsr4Map, dartPackageMap, pythonImportRoots, elixirModuleMap, phpFqcnMap, rustCrates, rustDeclaredMods, rustIsDeclaration, classNameIndex, godotProjectRoot, godotUidIndex, undefined, godotImportKind);
}
// Without a UID index or fallback, uid:// paths cannot be resolved
return null;
}
// preload/load: res://path/to/file.gd → resolve relative to Godot project root
if (moduleSpecifier.startsWith("res://")) {
const resPath = moduleSpecifier.slice("res://".length);
if (resPath.endsWith(".uid"))
return null;
// Use pre-resolved root if available; fall back to filesystem walk for ad-hoc calls
const godotRoot = godotProjectRoot !== undefined ? godotProjectRoot : findGodotProjectRoot(sourceFile);
// Do NOT fall back to projectPath when no Godot root is found.
// res:// is Godot-specific and must resolve relative to a project.godot
// directory. Falling back to the arbitrary SocratiCode indexing root
// would produce false edges in repos without a Godot project.
if (!godotRoot)
return null;
// Direct file-set matching only — no extension fallback. res:// paths
// in GDScript are explicit (res://scripts/Player.gd), so the specifier
// already includes the correct extension. Adding fallback extensions
// would falsely resolve res://assets/player.png to assets/player.tscn
// when the .png is not in the project file set.
return resolveRelativePath(resPath, godotRoot, projectPath, fileSet, []);
}
// Relative extends/preload paths use the script directory. Runtime
// load() paths are localized by ResourceLoader against the res:// root.
// Callers predating godotImportKind retain the previous source-relative
// behavior, preserving the public resolver helper's compatibility.
// Exclude class: prefixed specifiers and scheme:// paths.
if (!path.isAbsolute(moduleSpecifier) && !moduleSpecifier.includes("://") && !moduleSpecifier.startsWith("class:")) {
if (moduleSpecifier.endsWith(".uid"))
return null;
if (godotImportKind === "load") {
const godotRoot = godotProjectRoot !== undefined ? godotProjectRoot : findGodotProjectRoot(sourceFile);
if (!godotRoot)
return null;
return resolveRelativePath(moduleSpecifier, godotRoot, projectPath, fileSet, []);
}
return resolveRelativePath(moduleSpecifier, path.dirname(sourceFile), projectPath, fileSet, []);
}
// extends ClassName → resolve via class_name convention
// The import extractor prefixes class-name references with "class:"
if (moduleSpecifier.startsWith("class:")) {
// class_name resolution is project-scoped: without a Godot project
// root (explicit null), do not attempt resolution. An undefined
// godotProjectRoot means the caller didn't pre-resolve it (ad-hoc
// calls, tests) — fall back to filesystem walk in resolveByClassName.
if (godotProjectRoot === null)
return null;
const className = moduleSpecifier.slice("class:".length);
return resolveByClassName(className, sourceFile, projectPath, fileSet, classNameIndex);
}
return null;
}
case "godot-resource": {
// uid:// paths — resolve via the UID index (Godot prefers UIDs
// over text paths for resource loading)
if (moduleSpecifier.startsWith("uid://")) {
if (godotUidIndex) {
const resolved = godotUidIndex.get(moduleSpecifier);
if (resolved)
return resolved;
}
// Try fallback specifier (path from ext_resource) if UID resolution missed
if (fallbackSpecifier) {
return resolveImport(fallbackSpecifier, sourceFile, projectPath, fileSet, language, aliases, jvmSuffixMap, csNamespaceMap, goModuleInfo, phpPsr4Map, dartPackageMap, pythonImportRoots, elixirModuleMap, phpFqcnMap, rustCrates, rustDeclaredMods, rustIsDeclaration, classNameIndex, godotProjectRoot, godotUidIndex);
}
// Without a UID index or fallback, uid:// paths cannot be resolved
return null;
}
// .tscn/.tres files reference other resources via [ext_resource]
// declarations with res:// paths or relative paths.
if (moduleSpecifier.startsWith("res://")) {
const resPath = moduleSpecifier.slice("res://".length);
if (resPath.endsWith(".uid"))
return null;
const godotRoot = godotProjectRoot !== undefined ? godotProjectRoot : findGodotProjectRoot(sourceFile);
// Do NOT fall back to projectPath — res:// is Godot-specific.
if (!godotRoot)
return null;
return resolveRelativePath(resPath, godotRoot, projectPath, fileSet, []);
}
// Relative path (e.g. "material.tres") — resolve relative to the
// directory containing the .tscn/.tres file, per Godot TSCN docs.
if (!path.isAbsolute(moduleSpecifier)) {
if (moduleSpecifier.endsWith(".uid"))
return null;
const sourceDir = path.dirname(sourceFile);
return resolveRelativePath(moduleSpecifier, sourceDir, projectPath, fileSet, []);
}
return null;
}
default:
return null;
}
}
/** Check if a module specifier refers to an external/stdlib module */
function isExternalModule(spec, language) {
switch (language) {
case "python":
// Common stdlib modules
return ["os", "sys", "re", "json", "math", "datetime", "collections",
"typing", "pathlib", "io", "functools", "itertools", "abc",
"asyncio", "unittest", "logging", "argparse", "subprocess",
"socket", "http", "urllib", "hashlib", "copy", "enum",
"dataclasses", "contextlib", "textwrap", "string", "struct",
"time", "threading", "multiprocessing", "xml", "csv",
"sqlite3", "pickle", "shelve", "tempfile", "shutil", "glob",
].includes(spec.split(".")[0]);
case "go":
return !spec.includes("/") || spec.startsWith("golang.org/") || !spec.includes(".");
case "java":
case "kotlin":
case "scala":
return spec.startsWith("java.") || spec.startsWith("javax.") ||
spec.startsWith("kotlin.") || spec.startsWith("kotlinx.") ||
spec.startsWith("scala.") || spec.startsWith("android.");
case "csharp":
return spec.startsWith("System.") || spec === "System" ||
spec.startsWith("Microsoft.");
case "rust":
return spec.startsWith("std::") || spec.startsWith("core::") || spec.startsWith("alloc::");
case "swift":
return ["Foundation", "UIKit", "SwiftUI", "Combine", "CoreData",
"CoreGraphics", "CoreLocation", "MapKit", "XCTest"].includes(spec);
case "php":
return false; // PHP doesn't have stdlib imports in the same way
case "ruby":
return !spec.startsWith("./") && !spec.startsWith("../") && !spec.includes("/");
case "dart":
// Only the SDK scheme is unconditionally external. `package:` URIs are
// NOT: the project's own code is imported that way by convention
// (issue #106), so they classify as resolvable and the dart case in
// resolveImport decides via the pubspec-derived package map — in-repo
// names resolve, unknown names (real external packages) return null.
return spec.startsWith("dart:");
case "lua":
// Common Lua stdlib/C modules
return ["string", "table", "math", "io", "os", "coroutine",
"debug", "package", "utf8", "bit32"].includes(spec.split(".")[0]);
case "gdscript":
// res://, uid://, and class: prefixes are always project-internal.
// Relative paths (not starting with / or a scheme) are also internal.
// Everything else is treated as external (Godot built-in types like Node2D).
if (spec.startsWith("res://") || spec.startsWith("class:") || spec.startsWith("uid://"))
return false;
// Relative paths: contains a path separator or has a file extension
// like .gd, .tscn, .tres — these are file references, not class names
if (!path.isAbsolute(spec) && (spec.includes("/") || spec.endsWith(".gd") || spec.endsWith(".tscn") || spec.endsWith(".tres")))
return false;
return true;
case "godot-resource":
// .tscn/.tres files produce res:// paths and relative paths (relative
// to the .tscn/.tres file's directory, per Godot TSCN docs).
// Both forms are project-internal; there are no external imports.
return false;
default:
return false;
}
}
/** Try resolving a module specifier via path aliases (tsconfig/jsconfig paths) */
function resolveAliasPath(moduleSpecifier, projectPath, fileSet, extensions, aliases) {
if (!aliases?.entries)
return null;
for (const [prefix, targets] of aliases.entries) {
// Wildcard aliases end with "/" (from "$lib/*") — match as prefix
// Exact aliases (no trailing "/") — match only the exact specifier
const isWildcard = prefix.endsWith("/");
const matches = isWildcard
? moduleSpecifier.startsWith(prefix)
: moduleSpecifier === prefix;
if (matches) {
const rest = moduleSpecifier.slice(prefix.length);
for (const target of targets) {
const resolved = resolveRelativePath(path.join(target, rest), projectPath, projectPath, fileSet, extensions);
if (resolved)
return resolved;
}
}
}
return null;
}
/**
* Whether a shell `source` specifier has the shape of a literal path. This reads
* the text only — a well-shaped specifier naming a file that does not exist is
* still true here, and fails later at the file-set lookup.
*
* None of the shapes below can be told apart from a literal path here, and each
* has to be screened before resolution rather than after, because normalising
* them lands on a file the shell would not open — and a match there is the
* failure being prevented rather than a salvage:
*
* - extractImports captures the whole argument list, so any whitespace-class
* character means the text cannot be told apart from a word list or a path
* still carrying its quotes. Bash splits on fewer of them than `\s` matches —
* its default IFS is space, tab and newline — but skipping all of them errs
* toward dropping an edge rather than inventing one.
* - A backslash is a shell escape and never a separator, so the unescaped path
* is unknowable here; `path.resolve` treats it as a separator on win32, which
* would cancel `./x\..\lib.sh` down to a file the script never names.
* - A trailing `/` or `/.` names a directory, which cannot be sourced, and
* normalisation would drop that trailing segment, landing on the same-named
* file.
* - A `..` following a named segment cancels it lexically during normalisation,
* so `./x/../lib.sh` lands on lib.sh beside the script. The shell walks
* components instead and loads nothing when `x` is absent or is not a
* directory. Only a leading run of `.`/`..` anchors, and an empty segment from
* `//` does not end that run.
*
* These screen the raw captured text, so a change that honours quoting and
* strips arguments has to unquote and split upstream of here.
*/
export function hasLiteralShellPathShape(specifier) {
if (/\s/.test(specifier) || specifier.includes("\\"))
return false;
if (specifier.endsWith("/") || specifier.endsWith("/."))
return false;
const segments = specifier.split("/");
const firstNamedIndex = segments.findIndex((s) => s !== "" && s !== "." && s !== "..");
// The index check is not redundant: a negative `fromIndex` counts back from
// the end, so dropping it would search only the last segment.
return firstNamedIndex === -1 || !segments.includes("..", firstNamedIndex);
}
/** Resolve a potentially extensionless path to an actual file */
function resolveRelativePath(modulePath, baseDir, projectPath, fileSet, extensions) {
const fullPath = path.resolve(baseDir, modulePath);
const relPath = toForwardSlash(path.relative(projectPath, fullPath));
// Direct match
if (fileSet.has(relPath))
return relPath;
// Try with extensions appended (for extensionless imports)
for (const ext of extensions) {
const withExt = relPath + ext;
if (fileSet.has(withExt))
return withExt;
}
// Handle TypeScript .js→.ts extension mapping:
// When a TS file imports "./foo.js", the actual file is "./foo.ts"
const existingExt = path.extname(relPath);
if (existingExt && extensions.length > 0) {
const baseName = relPath.slice(0, -existingExt.length);
for (const ext of extensions) {
if (ext !== existingExt) {
const swapped = baseName + ext;
if (fileSet.has(swapped))
return swapped;
}
}
}
// Try as directory with index file
for (const ext of extensions) {
const indexFile = toForwardSlash(path.join(relPath, `index${ext}`));
if (fileSet.has(indexFile))
return indexFile;
}
// SCSS/Sass partial: @import "variables" → _variables.scss
if (extensions.some((e) => [".scss", ".sass", ".less", ".styl"].includes(e))) {
const dir = path.dirname(relPath);
const base = path.basename(relPath);
if (!base.startsWith("_")) {
// Try _name (direct)
const partial = toForwardSlash(path.join(dir, `_${base}`));
if (fileSet.has(partial))
return partial;
// Try _name with extensions
for (const ext of extensions) {
const partialExt = toForwardSlash(path.join(dir, `_${base}${ext}`));
if (fileSet.has(partialExt))
return partialExt;
}
}
}
// Python: try __init__.py
if (extensions.includes(".py")) {
const initFile = toForwardSlash(path.join(relPath, "__init__.py"));
if (fileSet.has(initFile))
return initFile;
}
return null;
}
/**
* Walk up from a starting directory looking for project.godot.
* Returns the absolute path to the directory containing it, or null if not
* found within 50 levels or at the filesystem root. Shared by both
* {@link findGodotProjectRootForProject} and {@link findGodotProjectRoot}.
*/
function walkUpForGodotProject(startDir, rootCache) {
let dir = path.resolve(startDir);
const visited = [];
for (let i = 0; i < 50; i++) {
if (rootCache?.has(dir)) {
const cached = rootCache.get(dir) ?? null;
for (const visitedDir of visited)
rootCache.set(visitedDir, cached);
return cached;
}
visited.push(dir);
const godotProject = path.join(dir, "project.godot");
try {
if (existsSync(godotProject)) {
for (const visitedDir of visited)
rootCache?.set(visitedDir, dir);
return dir;
}
}
catch {
// Ignore stat errors
}
const parent = path.dirname(dir);
if (parent === dir)
break; // Reached filesystem root
dir = parent;
}
for (const visitedDir of visited)
rootCache?.set(visitedDir, null);
return null;
}
/**
* Find the Godot project root for a set of files by checking if project.godot
* exists at the project path or any parent. Called once per graph build so
* that res:// resolution doesn't walk the filesystem for every import.
* Returns the absolute path to the directory containing project.godot,
* or null if not found.
*/
export function findGodotProjectRootForProject(projectPath) {
return walkUpForGodotProject(path.resolve(projectPath));
}
/**
* Find the Godot project root by walking up from a source file looking for
* project.godot. Returns the absolute path to the directory containing it,
* or null if not found. Used as a fallback when no pre-resolved root is
* passed to resolveImport (tests, ad-hoc calls).
*/
function findGodotProjectRoot(sourceFile) {
return walkUpForGodotProject(path.dirname(sourceFile));
}
/**
* Normalize a `res://`-relative path by collapsing `./` and `../` segments.
* E.g. `scripts/../core/GameManager.gd` → `core/GameManager.gd`.
* Unlike path.normalize, this preserves forward slashes and does not
* resolve to an absolute path.
*/
function normalizeResPath(resPath) {
const parts = resPath.split("/");
const result = [];
for (const part of parts) {
if (part === "." || part === "")
continue;
if (part === "..") {
result.pop();
continue;
}
result.push(part);
}
return result.join("/");
}
/**
* Parse the `[autoload]` section from a Godot `project.godot` file and build
* a map of autoload name → relative script path.
*
* The `[autoload]` section has the form:
* [autoload]
* GameManager="*res://scripts/core/GameManager.gd"
* InputManager="res://scripts/core/InputManager.gd"
*
* The leading `*` means the autoload is enabled (singleton). Both `*` and
* non-`*` entries are included — the autoload name is still a global type
* regardless of singleton status.
*
* @param godotProjectRoot - Absolute path to the directory containing project.godot
* @returns Map of autoload name → relative path (forward-slash, relative to project root)
*/
export function parseGodotAutoloads(godotProjectRoot, recorder) {
const autoloads = new Map();
const projectFile = path.join(godotProjectRoot, "project.godot");
let content;
try {
content = readFileSync(projectFile, "utf-8");
}
catch {
recorder?.unreadable(projectFile);
return autoloads;
}
recorder?.read(projectFile, content);
// Find the [autoload] section and parse key="value" lines until the next
// section header or end of file.
const lines = content.split("\n");
let inAutoloadSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("[")) {
inAutoloadSection = trimmed === "[autoload]";
continue;
}
if (!inAutoloadSection)
continue;
// Match: Name="*res://path/to/file.gd" or Name="res://path/to/file.gd"
const match = trimmed.match(/^([A-Za-z_][\w]*)\s*=\s*"?\*?res:\/\/([^"]+)"?$/);
if (!match)
continue;
const [, name, resPath] = match;
// Normalize: collapse ./ and ../ segments so paths like
// `res://scripts/../core/GameManager.gd` resolve correctly.
const normalized = normalizeResPath(resPath);
autoloads.set(name, toForwardSlash(normalized));
}
return autoloads;
}
/**
* Resolve a GDScript class_name reference to a .gd file.
*
* Godot class identity comes from the `class_name` declaration, not the
* filename. A file named `Foo.gd` that does not declare `class_name Foo`
* must never resolve a bare `extends Foo` — Godot itself resolves bare class
* references through the globally registered `class_name` table, and an
* unnamed script is referenced by path (`preload`/`load`/`res://`), not by
* name. So this function consults ONLY `class_name` declarations:
*
* - When a classNameIndex is available (normal graph-build path), a single
* O(1) Map lookup with no file reads.
* - Otherwise (tests, ad-hoc calls), a disk scan of `class_name`
* declarations in `.gd` files.
*
* Filename-based resolution is intentionally NOT performed.
*/
function resolveByClassName(className, sourceFile, projectPath, fileSet, classNameIndex) {
const selfRelPath = toForwardSlash(path.relative(projectPath, sourceFile));
// When an index is available, class_name is the authoritative identity.
if (classNameIndex) {
const resolved = classNameIndex.get(className);
if (resolved && resolved !== selfRelPath) {
return resolved;
}
return null;
}
// Without an index (tests, ad-hoc calls): scan .gd files on disk for
// class_name declarations. Filename matching is intentionally skipped —
// a file named <ClassName>.gd without a class_name declaration does not
// define that class in Godot.
for (const relPath of fileSet) {
if (!relPath.endsWith(".gd"))
continue;
if (relPath === selfRelPath)
continue;
const absPath = path.join(projectPath, relPath);
try {
const content = readFileSync(absPath, "utf-8");
const declaredName = extractClassNameFromGdscript(content);
if (declaredName === className) {
return relPath;
}
}
catch {
// Skip unreadable files
}
}
return null;
}
//# sourceMappingURL=graph-resolution.js.map