UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

2,464 lines • 126 kB
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
 * Cross-file call-site resolution. Given a file-import graph (from
 * `code-graph.ts`) and the per-file extracted symbols, populates each call
 * edge's `calleeCandidates` and `confidence`.
 *
 * Strategy:
 *   1. Local — callee name matches a symbol in the caller's own file
 *   2. Imported — walk caller's file `dependencies` from the file graph;
 *      any dependency exposing a same-named symbol is a candidate
 *   3. Wildcard / re-export — barrel files re-export symbols transitively;
 *      we do one extra hop through dependency files
 *   4. Resolution: 0 → "unresolved", 1 → "unique", >1 → "multiple-candidates"
 *
 * GDScript receiver-type resolution (Phase 4):
 *   For method calls with a `receiver` field, resolve the receiver to a
 *   target class file via:
 *     a. Autoload table (GameManager → scripts/core/GameManager.gd)
 *     b. class_name index (Fighter → scripts/characters/Fighter.gd)
 *     c. Typed variable in caller file (var opponent: Fighter → Fighter)
 *   Then look up the method name in the target file's symbols.
 *
 *   Godot builtins (engine classes and @GlobalScope functions) are filtered
 *   from unresolved callees — they don't count against resolution %.
 */
import { CS_BUILTIN_TYPES, CS_GLOBAL_ANCHOR, GODOT_BUILTIN_CLASSES, GODOT_BUILTIN_FUNCTIONS } from "../constants.js";
import { phpFoldCase } from "./graph-php-case.js";
/** The symbol kinds a C# type position can name. */
const CS_TYPE_KINDS = new Set(["class", "interface", "struct", "enum", "type"]);
/** The symbol kinds `new T()` can create: records are recorded as classes. */
const CS_CREATABLE_KINDS = new Set(["class", "struct"]);
/** A C# symbol's full name: `csFullName`, or `qualifiedName` on a graph persisted before it. */
function csFullNameOf(s) {
    return s.csFullName ?? s.qualifiedName;
}
/**
 * Whether a written C# path names a .NET framework type: a framework type
 * name alone (`Math`) or under `System` (`System.Math`). After a variable or
 * a project type the same name is a member (`item.Action`, `Settings.Type`),
 * no evidence of a framework type.
 */
function csFrameworkPath(segments) {
    const last = segments[segments.length - 1];
    return last !== undefined && CS_BUILTIN_TYPES.has(last) && (segments.length === 1 || segments[0] === "System");
}
/** Normalize relative path components like `foo/../bar` -> `bar` */
function normalizePath(p) {
    const parts = p.split("/").filter(Boolean);
    const stack = [];
    for (const part of parts) {
        if (part === ".")
            continue;
        if (part === "..") {
            stack.pop();
        }
        else {
            stack.push(part);
        }
    }
    return stack.join("/");
}
const KNOWN_CODE_EXT = /\.(?:[jt]sx?|m[jt]s|c[jt]s|py|rb|php|go|rs|java|kt|scala|cs|swift|dart|c|cpp|h|hpp|ex|exs|vue|svelte|lua|sh)$/i;
/**
 * The directories, relative to a crate's own, where Cargo autodiscovers
 * targets. Each target is a crate of its own, so `crate::` written inside one
 * starts at that target's root and never reaches the library.
 *
 * Two conventional shapes live in each: a `.rs` file directly inside
 * (`tests/foo.rs`) is a target, and so is `<name>/main.rs` one level down
 * (`tests/foo/main.rs`), whose neighbours are that target's modules. Nothing
 * else is: checked against `cargo metadata` on cargo 1.98, which lists
 * `tests/dirtest/main.rs` as a target and never lists `tests/common/mod.rs`
 * or `src/bin/tool/sub/main.rs`.
 */
const CARGO_TARGET_DIRS = ["src/bin", "tests", "benches", "examples"];
/**
 * The kinds a Rust path can be qualified by, as this extractor labels them: a
 * `struct` (which also covers `union`), an `enum`, a `trait`, and a `type`
 * (which also covers an associated type). Rust keeps types and values in
 * separate namespaces, and only the first can stand before a `::`.
 */
const RUST_TYPE_NAMESPACE = new Set(["struct", "enum", "trait", "type"]);
function stripKnownExt(p) {
    const lastSlash = p.lastIndexOf("/");
    const dir = lastSlash >= 0 ? p.slice(0, lastSlash + 1) : "";
    const fileName = lastSlash >= 0 ? p.slice(lastSlash + 1) : p;
    const stripped = fileName.replace(KNOWN_CODE_EXT, "");
    return dir + stripped;
}
/**
 * The dependencies whose own path ends with these module segments.
 *
 * A Rust module is a file, a directory's `mod.rs`, or a crate's `lib.rs` /
 * `main.rs`, so `a::b` can be `…/a/b.rs`, `…/a/b/mod.rs` or, when `b` is a
 * crate, `…/b/src/lib.rs`. All three are matched; anything else is not a module
 * path and the caller falls through to reading the qualifier as a type.
 *
 * Matched against the caller's resolved dependencies only. A path that names a
 * real module the caller never imports returns nothing, which is the answer
 * that keeps the qualifier narrowing.
 */
function matchModulePath(deps, segments) {
    if (segments.length === 0)
        return [];
    const wanted = segments.join("/");
    const hits = [];
    for (const dep of deps) {
        const noExt = stripKnownExt(dep);
        const tails = [noExt];
        const last = noExt.slice(noExt.lastIndexOf("/") + 1);
        if (last === "mod" || last === "lib" || last === "main") {
            tails.push(noExt.slice(0, noExt.lastIndexOf("/")));
        }
        for (const tail of tails) {
            if (tail === wanted || tail.endsWith(`/${wanted}`)) {
                hits.push(dep);
                break;
            }
        }
    }
    return hits;
}
/**
 * The directory a module's own children are filed in.
 *
 * Rust files a submodule beside its parent, not beside the parent's file:
 * `src/lib.rs` and `src/a/mod.rs` both take their children from the directory
 * they stand for (`src/` and `src/a/`), while `src/a.rs` takes its children
 * from `src/a/` — the directory named after it, which does not hold the file
 * itself.
 *
 * A crate root takes them from its own directory whatever it is called, which
 * the stem alone does not say: cargo 1.90.0 on `src/bin/x.rs` writing
 * `mod helper;` answers `file not found for module helper … create file
 * "src/bin/helper.rs"`, not `src/bin/x/helper.rs`, and a `tests/t.rs` with
 * `mod support;` compiles against `tests/support.rs`. So `isCrateRoot` is
 * asked, and not guessed from the name.
 *
 * The three stems are still read by name, and that is Rust's module rule
 * rather than a second guess at rootness: `src/a/mod.rs` is no crate root and
 * files its children in `src/a/` all the same. It is wrong in exactly one
 * shape — a `main.rs` that is an ordinary module, declared by a `mod main;`
 * somewhere, whose children Rust would file under `main/`. Not reproduced
 * against cargo, and named here rather than left implied by a comment that
 * claims the question has only one answer in this file.
 */
function childModuleDirOf(file, isCrateRoot) {
    const noExt = stripKnownExt(file);
    const lastSlash = file.lastIndexOf("/");
    // A file at the project root has no directory, and `""` is the answer:
    // `file.slice(0, -1)` is the name with its last letter cut off.
    const dir = lastSlash >= 0 ? file.slice(0, lastSlash) : "";
    const stem = noExt.slice(noExt.lastIndexOf("/") + 1);
    if (isCrateRoot || stem === "mod" || stem === "lib" || stem === "main")
        return dir;
    return dir ? `${dir}/${stem}` : stem;
}
/** Resolve an import's module specifier to a dependency file path */
function resolveDepFile(callerFile, sourceModule, deps) {
    if (!sourceModule)
        return null;
    const callerDir = callerFile.includes("/") ? callerFile.slice(0, callerFile.lastIndexOf("/")) : "";
    const rawCombined = callerDir ? `${callerDir}/${sourceModule}` : sourceModule;
    const normalized = stripKnownExt(normalizePath(rawCombined.replace(/^\.\//, "")));
    const cleanSpec = stripKnownExt(sourceModule.replace(/^[./\\]+/, ""));
    // Pass 1: exact normalized match or normalized/index
    for (const dep of deps) {
        const depWithoutExt = stripKnownExt(dep);
        if (depWithoutExt === normalized || depWithoutExt === `${normalized}/index`) {
            return dep;
        }
    }
    // Pass 2: suffix match (only if uniquely matched among dependencies)
    const suffixMatches = [];
    for (const dep of deps) {
        const depWithoutExt = stripKnownExt(dep);
        if (depWithoutExt.endsWith(`/${cleanSpec}`) ||
            depWithoutExt.endsWith(`/${cleanSpec}/index`)) {
            suffixMatches.push(dep);
        }
    }
    if (suffixMatches.length === 1) {
        return suffixMatches[0];
    }
    // Fallback: match by basename if unique among dependencies
    const baseSpec = cleanSpec.split("/").pop();
    if (baseSpec) {
        const matches = deps.filter((d) => {
            const depBase = stripKnownExt(d.split("/").pop() ?? "");
            return depBase === baseSpec || d.includes(`/${baseSpec}/index.`);
        });
        if (matches.length === 1)
            return matches[0];
    }
    return null;
}
/**
 * The key a qualified PHP edge and the symbol it names meet on.
 *
 * Built from an owner in either of the two forms {@link SymbolNode.phpOwner}
 * documents, and the name declared under it. A namespace prefix owns classes,
 * so the key is the class's whole name — `\app\models\invoice` — folded
 * throughout, since PHP matches class and namespace names
 * ASCII-case-insensitively and `new \App\models\INVOICE()` names the class
 * declared as `App\Models\Invoice`. A class owns methods, so the key is the
 * folded class name, `::`, and the method name exactly as written, which is
 * how every other call in this resolver is matched. The `::` keeps the two
 * forms apart: a namespace qualifier can never reach a method, nor a class
 * qualifier a class.
 *
 * @param owner A `phpOwner` or a PHP edge's `calleeQualifier`.
 * @param name The symbol's name, or the edge's `calleeName`.
 */
function phpOwnedKey(owner, name) {
    const folded = phpFoldCase(owner);
    return folded.endsWith("\\") ? `${folded}${phpFoldCase(name)}` : `${folded}::${name}`;
}
/**
 * The static method a call on `cls` runs, found where PHP looks for it.
 *
 * The class's own methods first. Failing those, the traits it uses — each
 * trait's own methods, then the traits that trait uses — since a trait's method
 * overrides an inherited one; then the class it extends, searched the same way.
 * Only inheritance the index can verify is followed: a class or trait's
 * ancestors are searched only when exactly one declaration of it is found
 * under its exact qualified name, nearest first. So an ancestor outside the
 * project ends the search rather than widening it; a class declared twice at
 * the same reach — the same name in two packages of one repository — is not
 * guessed between, since the two can inherit from different classes; and a
 * method no ancestor declares is not found at all. An `abstract`
 * declaration has no owner, so the search passes it by for the implementation
 * PHP runs. Two traits that both declare the method are both returned, since
 * which one PHP runs depends on an `insteadof` the index does not read.
 *
 * @param cls The class the call names, in the form `SymbolNode.phpOwner` uses for one.
 * @param method The method name, matched exactly.
 * @param found The ids declared under a {@link phpOwnedKey}, nearest first.
 * @param classLikeById The PHP classes and traits by id.
 */
function phpInheritedFrom(cls, method, found, classLikeById, seen = new Set()) {
    const folded = phpFoldCase(cls);
    if (seen.has(folded))
        return [];
    seen.add(folded);
    const own = found(phpOwnedKey(cls, method));
    if (own.length > 0)
        return [...own];
    const cut = cls.lastIndexOf("\\") + 1;
    const declarations = found(phpOwnedKey(cls.slice(0, cut), cls.slice(cut)));
    const declared = declarations.length === 1 ? classLikeById.get(declarations[0]) : undefined;
    if (!declared)
        return [];
    const viaTraits = (declared.phpTraits ?? [])
        .flatMap((t) => phpInheritedFrom(t, method, found, classLikeById, seen));
    if (viaTraits.length > 0)
        return viaTraits;
    return declared.phpExtends ? phpInheritedFrom(declared.phpExtends, method, found, classLikeById, seen) : [];
}
/**
 * Resolve all call sites for every file in `symbolsByFile`. Mutates the
 * passed-in `outgoingCallsByFile` edges in place.
 *
 */
export function resolveCallSites(fileGraph, symbolsByFile, outgoingCallsByFile, 
/**
 * Rust `use` bindings per file. Absent for every other language, and absent
 * when a caller does not have them — resolution then behaves exactly as it
 * did, which is what keeps a graph built before this readable.
 */
rustBindingsByFile, 
/**
 * Rust file → the directory prefix of the crate it belongs to, from the
 * manifests. Absent for every other language, and absent when a caller does
 * not have it — `crate::` then confines nothing, which is what it did before.
 */
rustCrateRootByFile, 
/**
 * The edges whose qualifier is rooted in an inline `mod`, by identity. That
 * scope has no file to name and no spelling this can follow, so the call is
 * left unresolved with its qualifier rather than answered out of the file:
 * the file holds the sibling inline modules too, and a `helper` declared in
 * one of those is not something Rust can reach from the other.
 */
rustInlineScopedCalls, 
/**
 * The ids of the Rust symbols declared inside an inline `mod`. A `self`
 * qualifier names the file's own module, and from there Rust reaches what
 * the file declares at its top level and nothing an inline `mod` encloses —
 * so these are dropped from a `self` path's candidates, and the call is
 * refused when dropping them leaves none.
 *
 * Absent for every other language, and absent when a caller does not have
 * it: nothing is dropped then, which is what this did before.
 */
rustInlineDeclaredSymbols, 
/**
 * Rust file → every target root declared by its parsed Cargo manifest.
 * This is in-memory build metadata only. Kept optional and last so existing
 * callers retain the pre-manifest fallback behaviour.
 */
rustCrateRootsByFile, 
/** Optional GDScript autoload map from name to project-relative path. */
autoloadTable, 
/** GDScript assignment-site type inferences, scoped by source range. */
inferredTypesByFile, 
/** GDScript cross-file member assignment type evidence. */
memberAssignmentsByFile, 
/** Godot `res://` path to repository-relative path map. */
resToRepoPathMap, 
/** Per-project GDScript resolution metadata for multi-project repositories. */
godotContext) {
    // Build a fast lookup: file → Map<symbolName, SymbolNode[]>
    const symbolIndexByFile = new Map();
    // symbol id → the file that declares it. Needed to turn "where is the type
    // `Foo` declared?" into a scope of files, without taking the file apart from
    // the id string.
    const fileOfSymbolId = new Map();
    // symbol id → what it declares. A Rust type qualifier is answered by the
    // file that declares that *type*, and a name lives in one namespace or the
    // other: `const Config` cannot qualify `Config::run()`, so the file that
    // declares it is not an answer.
    const kindOfSymbolId = new Map();
    // {@link phpOwnedKey} → the ids of the PHP symbols declared under that
    // owner and name, project-wide. Holds PHP symbols only, and only those that
    // have an owner; a qualified PHP edge is answered from here and nowhere
    // else. The key is a whole qualified name, so it nearly always has one
    // declaration, and narrowing that to the caller's file or dependencies is a
    // filter over it rather than an index of its own. The classes and traits
    // among them are kept by id too, for what they inherit from.
    const phpOwnedAnywhere = new Map();
    const phpClassLikeById = new Map();
    for (const [file, syms] of symbolsByFile.entries()) {
        const idx = new Map();
        for (const s of syms) {
            fileOfSymbolId.set(s.id, file);
            kindOfSymbolId.set(s.id, s.kind);
            if (s.phpOwner !== undefined) {
                const key = phpOwnedKey(s.phpOwner, s.name);
                const anywhere = phpOwnedAnywhere.get(key);
                if (anywhere)
                    anywhere.push(s.id);
                else
                    phpOwnedAnywhere.set(key, [s.id]);
                if (s.kind === "class" || s.kind === "trait")
                    phpClassLikeById.set(s.id, s);
            }
            if (s.name === "<module>")
                continue;
            const existing = idx.get(s.name);
            if (existing)
                existing.push(s);
            else
                idx.set(s.name, [s]);
            if (s.exportedAs && s.exportedAs !== s.name) {
                const asExisting = idx.get(s.exportedAs);
                if (asExisting)
                    asExisting.push(s);
                else
                    idx.set(s.exportedAs, [s]);
            }
        }
        symbolIndexByFile.set(file, idx);
    }
    // Build file → dependency files (1-hop from the file-import graph)
    const depsByFile = new Map();
    // Rust parent traversal needs the reverse edge as well.
    const dependentsByFile = new Map();
    const langByFile = new Map();
    for (const node of fileGraph.nodes) {
        depsByFile.set(node.relativePath, node.dependencies.slice());
        dependentsByFile.set(node.relativePath, node.dependents.slice());
        if (node.language)
            langByFile.set(node.relativePath, node.language);
    }
    // Build the legacy unscoped class_name → file index from explicit class_name
    // symbols only. Direct callers that do not supply Godot project metadata
    // retain the original single-project behaviour. Full graph builds supply
    // `godotContext` and select a project-local index for each caller below.
    // A script without class_name can contain an inner class whose qualified and
    // unqualified names match, so name shape alone cannot identify ownership.
    const classNameIndex = new Map();
    // Build res:// script path → class_name index for .tscn script resolution.
    // Maps "scripts/Fighter.gd" → "Fighter" so that .tscn nodes with
    // `script = ExtResource("res://scripts/Fighter.gd")` can resolve methods
    // on the correct project class.
    const scriptPathToClassName = new Map();
    for (const [file, syms] of symbolsByFile.entries()) {
        for (const s of syms) {
            if (s.kind === "class" && s.isGdscriptClassName === true) {
                classNameIndex.set(s.name, s.file);
                // Also map the file path (without res://) to the class name
                scriptPathToClassName.set(file, s.name);
            }
        }
    }
    const emptyClassNameIndex = new Map();
    const emptyAutoloadTable = new Map();
    const godotProjectForFile = (file) => {
        if (!godotContext)
            return undefined;
        const root = godotContext.projectRootByFile.get(file);
        return root ? godotContext.projectsByRoot.get(root) : undefined;
    };
    const classNameIndexForFile = (file) => {
        if (!godotContext)
            return classNameIndex;
        return godotProjectForFile(file)?.classNameIndex ?? emptyClassNameIndex;
    };
    const autoloadTableForFile = (file) => {
        if (!godotContext)
            return autoloadTable;
        return godotProjectForFile(file)?.autoloadTable ?? emptyAutoloadTable;
    };
    // Receiver resolution can consult scenes twice for every edge. Cache the
    // project-filtered entries once, then preserve same-directory precedence
    // with a stable partition once per caller instead of rescanning and sorting
    // the complete symbol index for every fallback.
    const sceneEntriesByProject = new Map();
    const sceneEntriesForProject = (projectRoot) => {
        const cached = sceneEntriesByProject.get(projectRoot);
        if (cached)
            return cached;
        const entries = [...symbolIndexByFile.entries()].filter(([file]) => {
            if (!file.endsWith(".tscn") && !file.endsWith(".tres"))
                return false;
            if (!godotContext)
                return true;
            return projectRoot !== undefined && godotContext.projectRootByFile.get(file) === projectRoot;
        });
        sceneEntriesByProject.set(projectRoot, entries);
        return entries;
    };
    const sceneEntriesForCaller = (callerFile, projectRoot) => {
        const callerDir = callerFile.substring(0, callerFile.lastIndexOf("/") + 1);
        const sameDirectory = [];
        const otherDirectories = [];
        for (const entry of sceneEntriesForProject(projectRoot)) {
            (entry[0].startsWith(callerDir) ? sameDirectory : otherDirectories).push(entry);
        }
        return [...sameDirectory, ...otherDirectories];
    };
    // Build file → Map<variableName, typeName> for typed variable lookup
    const typedVarsByFile = new Map();
    for (const [file, syms] of symbolsByFile.entries()) {
        const varMap = new Map();
        for (const s of syms) {
            if (s.kind === "variable" && s.typeName) {
                varMap.set(s.name, s.typeName);
            }
        }
        if (varMap.size > 0)
            typedVarsByFile.set(file, varMap);
    }
    // ── Build file → className index (for resolving "<self>" markers) ────
    const classNameByFile = new Map();
    for (const [file, syms] of symbolsByFile.entries()) {
        for (const s of syms) {
            if (s.kind === "class" && s.isGdscriptClassName === true) {
                classNameByFile.set(file, s.name);
                break;
            }
        }
    }
    // ── Resolve inferred types from assignment sites ─────────────────────
    // inferredTypesByFile contains raw markers: "<self>", "ref:varName", or
    // direct class names, each with scope info (startLine/endLine) to prevent
    // cross-function name collisions. Resolve them to concrete type names.
    // The resolved map preserves scope info so lookups can filter by call-site line.
    const resolvedInferredByFile = new Map();
    if (inferredTypesByFile) {
        for (const [file, rawInferred] of inferredTypesByFile.entries()) {
            const resolved = new Map();
            const fileClassName = classNameByFile.get(file);
            const fileTypedVars = typedVarsByFile.get(file);
            for (const [varName, entries] of rawInferred.entries()) {
                for (const entry of entries) {
                    const resolvedType = resolveTypeMarker(entry.type, fileClassName, fileTypedVars, flattenResolved(resolved, varName));
                    if (resolvedType) {
                        let arr = resolved.get(varName);
                        if (!arr) {
                            arr = [];
                            resolved.set(varName, arr);
                        }
                        // Replace existing entry in same scope, or add new
                        const idx = arr.findIndex((e) => e.startLine === entry.startLine && e.endLine === entry.endLine);
                        if (idx >= 0)
                            arr[idx] = { type: resolvedType, startLine: entry.startLine, endLine: entry.endLine };
                        else
                            arr.push({ type: resolvedType, startLine: entry.startLine, endLine: entry.endLine });
                    }
                }
            }
            // Fixed-point iteration for ref: chains (a = b, b = c, c = Fighter.new())
            // Iterate until no new resolutions or all entries are resolved.
            // Cap at a generous limit to prevent infinite loops on cyclic refs.
            const maxPasses = rawInferred.size + 1;
            for (let pass = 0; pass < maxPasses; pass++) {
                let changed = false;
                for (const [varName, entries] of rawInferred.entries()) {
                    for (const entry of entries) {
                        // Skip if this scope already has a resolution
                        const existing = resolved.get(varName);
                        if (existing?.some((e) => e.startLine === entry.startLine && e.endLine === entry.endLine))
                            continue;
                        const resolvedType = resolveTypeMarker(entry.type, fileClassName, fileTypedVars, flattenResolved(resolved, varName));
                        if (resolvedType) {
                            let arr = resolved.get(varName);
                            if (!arr) {
                                arr = [];
                                resolved.set(varName, arr);
                            }
                            arr.push({ type: resolvedType, startLine: entry.startLine, endLine: entry.endLine });
                            changed = true;
                        }
                    }
                }
                if (!changed)
                    break;
            }
            if (resolved.size > 0)
                resolvedInferredByFile.set(file, resolved);
        }
    }
    // ── Build cross-file member type table ──────────────────────────────
    // memberAssignmentsByFile records `receiver.memberName = value` patterns.
    // For each, we look up the receiver's type to know which class owns the
    // member, then record: memberTypeTable[receiverType][memberName] = valueType.
    // This lets us infer types for untyped members from cross-file assignments.
    const memberTypeTable = new Map();
    const memberTypeTablesByRoot = new Map();
    if (memberAssignmentsByFile) {
        for (const [file, assignments] of memberAssignmentsByFile.entries()) {
            const fileClassName = classNameByFile.get(file);
            const fileTypedVars = typedVarsByFile.get(file);
            const fileInferred = resolvedInferredByFile.get(file);
            const fileClassNameIndex = classNameIndexForFile(file);
            const projectRoot = godotContext?.projectRootByFile.get(file);
            let fileMemberTypeTable = memberTypeTable;
            if (godotContext) {
                if (!projectRoot)
                    continue;
                let scopedTable = memberTypeTablesByRoot.get(projectRoot);
                if (!scopedTable) {
                    scopedTable = new Map();
                    memberTypeTablesByRoot.set(projectRoot, scopedTable);
                }
                fileMemberTypeTable = scopedTable;
            }
            for (const { receiver, memberName, valueType } of assignments) {
                // Determine the receiver's type (which class owns this member)
                let receiverType = null;
                if (fileClassNameIndex.has(receiver)) {
                    receiverType = receiver;
                }
                else if (fileTypedVars?.has(receiver)) {
                    receiverType = fileTypedVars.get(receiver) ?? null;
                }
                else {
                    // Use scoped inferred lookup — for member assignments, use the
                    // first entry (member assignments are typically in _ready/_init,
                    // not in functions with name collisions)
                    const entries = fileInferred?.get(receiver);
                    receiverType = entries && entries.length > 0 ? entries[0].type : null;
                }
                if (!receiverType)
                    continue;
                // Resolve the value type — use a flattened view for ref: chain resolution
                const resolvedValueType = resolveTypeMarker(valueType, fileClassName, fileTypedVars, fileInferred ? flattenAllResolved(fileInferred) : undefined);
                if (!resolvedValueType)
                    continue;
                // Record in member type table
                let memberMap = fileMemberTypeTable.get(receiverType);
                if (!memberMap) {
                    memberMap = new Map();
                    fileMemberTypeTable.set(receiverType, memberMap);
                }
                memberMap.set(memberName, resolvedValueType);
            }
        }
    }
    /**
     * The file `super::` names from `file`, if this project has it.
     *
     * Computed from the caller's own path and then checked against the
     * dependents, in that order. Filtering the dependents by name instead — "any
     * dependent called `lib`" — accepts the crate root as the parent of every
     * file in the crate, because the crate root imports them all: `super::x()`
     * in `a/b/leaf.rs` would then reach `src/lib.rs`, which Rust does not allow
     * and the graph would state as `unique`.
     *
     * `a/b/leaf.rs` has parent `a/b/mod.rs`, or `a/b.rs` when the module is
     * written beside its directory. `a/b/mod.rs` is itself the module `a::b`, so
     * its parent is one level further up.
     */
    function parentModulesOf(file) {
        const noExt = stripKnownExt(file);
        const stem = noExt.slice(noExt.lastIndexOf("/") + 1);
        const lastSlash = file.lastIndexOf("/");
        // A file at the project root has no directory, and `""` is the answer, not
        // `file.slice(0, -1)` — which is the name with its last letter cut off and
        // matches nothing.
        let dir = lastSlash >= 0 ? file.slice(0, lastSlash) : "";
        // A module root stands for its own directory, so its parent is the
        // directory above.
        if (stem === "mod" || stem === "lib" || stem === "main") {
            if (!dir.includes("/"))
                return [];
            dir = dir.slice(0, dir.lastIndexOf("/"));
        }
        // At the root the parent can only be the crate root itself, and `.rs` is
        // not a file name.
        const candidates = dir
            ? [`${dir}/mod.rs`, `${dir}.rs`, `${dir}/lib.rs`, `${dir}/main.rs`]
            : ["lib.rs", "main.rs"];
        const dependents = new Set(dependentsByFile.get(file) ?? []);
        return candidates.filter((c) => dependents.has(c));
    }
    /**
     * Whether Cargo compiles this file as a crate of its own: a binary under
     * `src/bin/`, an integration test, an example, a benchmark, or the library
     * root itself. It decides where the file's own submodules are filed, and it
     * is the same question {@link crateRootFilesOf} answers for `crate::` — a
     * file whose `crate::` starts at itself *is* a root — so it is asked there
     * rather than restated as a second pattern that could drift from it.
     */
    function isOwnCrateRoot(file) {
        const roots = crateRootFilesOf(file);
        return roots?.length === 1 && roots[0] === file;
    }
    /**
     * The files one module declares under the name `segment` — one hop of a
     * module path.
     *
     * Two things have to hold, and each rules out a different wrong answer.
     *
     * The file has to sit where Rust files a child of *this* module:
     * `<childDir>/<segment>.rs` or `<childDir>/<segment>/mod.rs`. A file graph's
     * dependencies mix `mod x;` with `use`, so `a/mod.rs` that writes
     * `use crate::other::b;` depends on `other/b.rs` — and a hop that asked only
     * "is it a dependency called `b`?" would read `crate::a::b` as
     * `crate::other::b`.
     *
     * And the parent has to actually depend on it, which is the graph's record
     * that the declaration was written. Two files can sit at the two spellings of
     * one module, which rustc rejects (E0761) and a graph merely reports; taking
     * both is honest about it, and the caller turns two answers into
     * `multiple-candidates` rather than picking one.
     */
    function childModulesOf(parent, segment) {
        // A segment that is not a plain identifier is not a module name, and
        // pasting it into a path is how `..` would climb out of the crate.
        if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment))
            return [];
        const dir = childModuleDirOf(parent, isOwnCrateRoot(parent));
        const candidates = dir
            ? [`${dir}/${segment}.rs`, `${dir}/${segment}/mod.rs`]
            : [`${segment}.rs`, `${segment}/mod.rs`];
        const deps = new Set(depsByFile.get(parent) ?? []);
        return candidates.filter((c) => deps.has(c));
    }
    /**
     * The files a module path names, walked one segment at a time from `homes`.
     *
     * `crate::a::b` is two hops, not one suffix: the crate root declares `a` and
     * `a` declares `b`, and no single file has to depend on `a/b.rs` for the path
     * to be real. Matching the whole qualifier against the caller's own
     * dependencies instead both missed those — the graph reaches `a/mod.rs` and
     * stops — and answered too widely, because a suffix is not a path:
     * `crate::task` in tokio's `runtime/tests/task.rs` came back as
     * `runtime/task/mod.rs`, a file that path does not name, only because the
     * caller imports it and `task` is the tail of its directory.
     *
     * A hop that finds nothing ends the walk with nothing. That is the whole
     * guarantee: the answer is a module the path really reaches, or the edge
     * stays unresolved with its qualifier.
     *
     * The walk is bounded by the number of segments, not by depth: a hop out of
     * a crate root stays in the same directory (`src/lib.rs` declares
     * `src/a.rs`), so "each hop goes deeper" is not what stops it. The loop runs
     * once per segment and ends.
     */
    function walkModulePath(homes, segments) {
        if (segments.length === 0)
            return null;
        let frontier = homes;
        let entered = false;
        for (const segment of segments) {
            const next = new Set();
            for (const file of frontier) {
                for (const child of childModulesOf(file, segment))
                    next.add(child);
            }
            // Two empty answers, and they are not the same. `null` — the first hop
            // found nothing — means the module tree is invisible here, and the
            // suffix match is the only thing left to try. `[]` — a hop failed after
            // an earlier one succeeded — means the walk read a real module and the
            // name is not in it, which rustc answers with E0433. Falling back there
            // picks any reachable file whose path ends the same way: measured on a
            // fixture, `crate::a::foglia::f()` with no `foglia` under `a` came back
            // as the imported `altro/a/foglia.rs`, reported `unique`.
            if (next.size === 0)
                return entered ? [] : null;
            entered = true;
            frontier = [...next];
        }
        return frontier;
    }
    /** The root modules of one crate directory, in the order Cargo looks. */
    const rootFilesByCratePrefix = new Map();
    /** What a root module reaches, followed once per root that needs asking. */
    const reachedFromRoot = new Map();
    function reachedFrom(root) {
        const known = reachedFromRoot.get(root);
        if (known)
            return known;
        const seen = new Set([root]);
        const queue = [root];
        while (queue.length > 0) {
            const file = queue.pop();
            for (const dep of depsByFile.get(file) ?? []) {
                if (!seen.has(dep)) {
                    seen.add(dep);
                    queue.push(dep);
                }
            }
        }
        reachedFromRoot.set(root, seen);
        return seen;
    }
    /**
     * The files a `crate::` path starts from: the root of the *target* the
     * caller is compiled into.
     *
     * The manifests give the crate's directory, and the library's root module is
     * `lib.rs` or `main.rs` in it, with or without a `src/`. One directory can
     * hold two of them, and then it holds two crates: `crate::` written in
     * `main.rs` means the binary, and answering with the library is a different
     * file, not a wider one. So a root module is its own `crate::`, and where a
     * crate has more than one root the answer is the roots that actually reach
     * the caller.
     *
     * A package is more than its library, though. Cargo compiles each binary,
     * integration test, benchmark and example as a separate crate, and none of
     * them is reachable from the library — a library never imports its own
     * tests. Left to the fallback below those files got *every* root, which
     * collapses to `unique` on the library as soon as the library alone declares
     * the name, and that is the wrong file: checked with rustc, `crate::helper()`
     * in `tests/foo.rs` is the test's own `helper` and does not compile against
     * the library's. {@link CARGO_TARGET_DIRS} is where those roots are found by
     * shape.
     *
     * Three answers, and they are not the same:
     *   - `null` — nothing is known about this file's crate, so `crate::` keeps
     *     reading the caller's own scope, which is what it did before the crate
     *     map existed.
     *   - `[]` — the layout proves the caller is not part of any root that can
     *     be named here. The edge is left unresolved: a target declared only by
     *     `[[bin]] path = "…"` is invisible from here, and answering with
     *     another root names a different crate.
     *   - a non-empty list — the roots the path starts at.
     */
    function crateRootFilesOf(callerFile) {
        const prefix = rustCrateRootByFile?.get(callerFile);
        if (prefix === undefined)
            return null;
        // The manifest parser already knows every target root, including custom
        // `[lib] path`, `[[bin]] path`, tests, examples, benches and build scripts.
        // Prefer that authoritative list when it is available. A root is its own
        // crate; a module belongs to the roots that reach it. When several targets
        // exist and none reaches the file, choosing any one would manufacture a
        // cross-target edge, so the honest answer is unresolved.
        const declaredRoots = rustCrateRootsByFile?.get(callerFile);
        if (declaredRoots) {
            if (declaredRoots.includes(callerFile))
                return [callerFile];
            if (declaredRoots.length === 0)
                return [];
            const reaching = declaredRoots.filter((root) => reachedFrom(root).has(callerFile));
            if (reaching.length > 0)
                return reaching;
            return declaredRoots.length === 1 ? [...declaredRoots] : [];
        }
        for (const dir of CARGO_TARGET_DIRS) {
            const targetDir = `${prefix}${dir}/`;
            if (!callerFile.startsWith(targetDir))
                continue;
            const rest = callerFile.slice(targetDir.length);
            const slash = rest.indexOf("/");
            // `tests/foo.rs`, `src/bin/x.rs`: the file is the whole target, so it is
            // its own root.
            if (slash === -1)
                return [callerFile];
            // `tests/foo/main.rs` roots the target `foo`, and every other file in
            // that directory is one of its modules — however deep, because Cargo
            // autodiscovers the first level only.
            const mainFile = `${targetDir}${rest.slice(0, slash)}/main.rs`;
            if (rustCrateRootByFile?.has(mainFile))
                return [mainFile];
            // Without that `main.rs` the directory is no target at all —
            // `tests/common/mod.rs` is the shared-helper idiom, a module of whichever
            // tests write `mod common;` — or it is one only because a
            // `[[bin]] path = "…"` says so, which is not read here. Neither is
            // provable, and the library is a different crate.
            return [];
        }
        let roots = rootFilesByCratePrefix.get(prefix);
        if (!roots) {
            roots = ["src/lib.rs", "src/main.rs", "lib.rs", "main.rs"]
                .map((name) => `${prefix}${name}`)
                .filter((file) => symbolsByFile.has(file));
            rootFilesByCratePrefix.set(prefix, roots);
        }
        // A root module is trivially its own root.
        if (roots.includes(callerFile))
            return [callerFile];
        // No root module in sight is the same absence of information as no crate
        // map, so it keeps the same answer rather than turning into a verdict.
        if (roots.length === 0)
            return null;
        if (roots.length === 1)
            return roots;
        const reaching = roots.filter((root) => reachedFrom(root).has(callerFile));
        if (reaching.length > 0)
            return reaching;
        // Nothing reaches the caller. For an ordinary module that is honest
        // ambiguity and every root stays an answer: a `mod` declared inside a
        // macro is invisible to the file graph, yet the file really does belong to
        // one of these roots. A file named `main.rs` is not an ordinary module —
        // `mod x;` reads `x.rs` or `x/mod.rs`, never `x/main.rs` — so an unreached
        // one is a `[[bin]] path = "…"` target instead, and the library is not it.
        if (callerFile.endsWith("/main.rs"))
            return [];
        return roots;
    }
    /**
     * The scope a `super`-rooted path is read in, one hop per leading `super`:
     * the modules reached by climbing, and what they import. `null` when a hop
     * has no reachable parent, which leaves the edge unresolved rather than
     * falling back to the caller's own scope — the caller's scope is a different
     * namespace, and answering out of it is how `use super::config;` would land
     * on the caller's own `config` submodule.
     *
     * A path with no leading `super` climbs nothing and comes back as the
     * caller's own scope, so calling this on any path is safe.
     */
    function climbSuper(callerFile, path) {
        let homes = [callerFile];
        let deps = depsByFile.get(callerFile) ?? [];
        let rest = path;
        while (rest[0] === "super") {
            const parents = new Set();
            for (const home of homes) {
                for (const parent of parentModulesOf(home))
                    parents.add(parent);
            }
            if (parents.size === 0)
                return null;
            homes = [...parents];
            const reached = new Set();
            for (const home of homes) {
                for (const dep of depsByFile.get(home) ?? [])
                    reached.add(dep);
            }
            deps = [...reached];
            rest = rest.slice(1);
        }
        return { homes, deps, rest };
    }
    // Re-export traversal is on the hot path for every unresolved edge. Build
    // this once rather than rescanning every edge in a barrel on every lookup.
    // Project-wide C# index by simple name, built on first use: a static call
    // or a type position may name a symbol outside the caller's dependencies
    // (same namespace, no `using`), and scanning every file per edge is quadratic.
    let csNameIndex;
    const csByName = () => {
        if (!csNameIndex) {
            csNameIndex = new Map();
            for (const syms of symbolsByFile.values()) {
                for (const s of syms) {
                    if (s.language !== "csharp")
                        continue;
                    const list = csNameIndex.get(s.name);
                    if (list)
                        list.push(s);
                    else
                        csNameIndex.set(s.name, [s]);
                }
            }
        }
        return csNameIndex;
    };
    /**
     * The members named `member` that the C# type `t` declares, or else that its
     * nearest project base declares, followed through `csBases`. A field,
     * constant, event or enum member is no symbol of its own: with `fields`, a
     * name among a type's `csMembers` answers with that type. A base outside the
     * project (`DbContext`) has no symbols, so what it declares is never found.
     */
    const csMembersOf = (t, member, fields, seen = new Set()) => {
        const full = csFullNameOf(t);
        if (seen.has(full))
            return [];
        seen.add(full);
        const own = (csByName().get(member) ?? []).filter((m) => csFullNameOf(m) === `${full}.${member}`);
        if (own.length > 0)
            return own;
        // A partial type is one type across its files: every part's fields count.
        const parts = (csByName().get(t.name) ?? []).filter((p) => CS_TYPE_KINDS.has(p.kind) && csFullNameOf(p) === full);
        if (fields && parts.some((p) => p.csMembers?.includes(member)))
            return [t];
        for (const part of parts) {
            for (const base of part.csBases ?? []) {
                for (const b of csBaseType(part, base)) {
                    const inherited = csMembersOf(b, member, fields, seen);
                    if (inherited.length > 0)
                        return inherited;
                }
            }
        }
        return [];
    };
    // C# symbols by id, for the scope an edge's caller sits in.
    let csSymbolIndex;
    const csSymbolById = (id) => {
        if (!csSymbolIndex) {
            csSymbolIndex = new Map();
            for (const syms of symbolsByFile.values())
                for (const s of syms)
                    if (s.language === "csharp")
                        csSymbolIndex.set(s.id, s);
        }
        return csSymbolIndex.get(id);
    };
    // Every project namespace and type full name, with each of its prefixes.
    let csNamespaceIndex;
    const csIsNamespaceOrType = (full) => {
        if (!csNamespaceIndex) {
            csNamespaceIndex = new Set();
            for (const syms of symbolsByFile.values()) {
                for (const s of syms) {
                    if (s.language !== "csharp" || !CS_TYPE_KINDS.has(s.kind))
                        continue;
                    const parts = csFullNameOf(s).split(".");
                    for (let n = 1; n <= parts.length; n++)
                        csNamespaceIndex.add(parts.slice(0, n).join("."));
                }
            }
        }
        return csNamespaceIndex.has(full);
    };
    /**
     * A namespace block's `using`s as full names. Each is resolved as C#
     * resolves it, from the block's own namespace outward (`using Billing;` in
     * `namespace App` is `App.Billing` when the project declares one), and from
     * the global namespace alone when anchored.
     */
    const csBlockImportCache = new Map();
    const csBlockImports = (file, block, index) => {
        const key = `${file}\u0000${index}`;
        const cached = csBlockImportCache.get(key);
        if (cached)
            return cached;
        const resolved = block.usings.map((u) => {
            if (u.startsWith(CS_GLOBAL_ANCHOR))
                return u.slice(CS_GLOBAL_ANCHOR.length);
            const parts = block.namespace ? block.namespace.split(".") : [];
            for (let n = parts.length; n >= 1; n--) {
                const full = `${parts.slice(0, n).join(".")}.${u}`;
                if (csIsNamespaceOrType(full))
                    return full;
            }
            return u;
        });
        csBlockImportCache.set(key, resolved);
        return resolved;
    };
    const csContextCache = new Map();
    const csContextOf = (sym, file) => {
        const key = sym?.id ?? `\u0000${file}`;
        const cached = csContextCache.get(key);
        if (cached)
            return cached;
        const fileSymbols = symbolsByFile.get(file) ?? [];
        const module = fileSymbols.find((s) => s.kind === "module");
        const types = [];
        const namespaces = [];
        if (sym?.csFullName) {
            const parts = sym.csFullName.split(".");
            for (let n = CS_TYPE_KINDS.has(sym.kind) ? parts.length : parts.length - 1; n >= 1; n--) {
                const prefix = parts.slice(0, n).join(".");
                // An enclosing type is declared in the same file; any other prefix is a namespace.
                if (fileSymbols.some((s) => CS_TYPE_KINDS.has(s.kind) && s.csFullName === prefix))
                    types.push(prefix);
                else
                    namespaces.push(prefix);
            }
        }
        const blocks = module?.csBlockUsings ?? [];
        const chain = [];
        for (let b = sym?.csBlock; b !== undefined && b < blocks.length && !chain.includes(b); b = blocks[b].outer)
            chain.push(b);
        const levels = namespaces.map((namespace) => ({
            namespace,
            imports: chain.filter((b) => blocks[b].namespace === namespace).flatMap((b) => csBlockImports(file, blocks[b], b)),
        }));
        levels.push({ namespace: "", imports: module?.csUsings ?? [] });
        const context = { types, levels };
        csContextCache.set(key, context);
        return context;
    };
    /**
     * The symbols a dotted `path` names from `context`, looked up as C# looks a
     * name up rather than by name across the project, and never by file
     * proximity: an exact full name under each enclosing type, innermost first,
     * then at each namespace level its members before the imports C# consults
     * there. An import brings in types, not namespaces, so `X.Y` is taken from
     * one only when `X` is a type it imports. Two types imported under one name
     * at one level are an ambiguity C# itself rejects, so neither is answered; a
     * `global::`-anchored path is looked up in the global namespace alone. A
     * path an alias declared in a namespace block wrote out (`App.Shop::X.Y`)
     * is looked up as C# resolves the alias: from that namespace outward,
     * without the declaring block's own imports, never from where it is used.
     * Every part of a partial type is answered.
     */
    const csLookup = (context, written, accept) => {
        const cut = written.indexOf("::");
        const path = cut >= 0 ? written.slice(cut + 2) : written;
        const name = path.slice(path.lastIndexOf(".") + 1);
        const first = path.split(".")[0];
        const candidates = (csByName().get(name) ?? []).filter(accept);
        const named = (full) => candidates.filter((s) => csFullNameOf(s) === full);
        // `null` when no import at this level names `path`, `[]` when two do.
        const imported = (imports) => {
            const found = imports
                .filter((u) => first === path || csTypesNamed(`${u}.${first}`).length > 0)
                .flatMap((u) => named(`${u}.${path}`));
            const distinct = new Set(found.map(csFullNameOf)).size;
            return distinct === 0 ? null : distinct === 1 ? found : [];
        };
        if (written.startsWith(CS_GLOBAL_ANCHOR))
            return named(path);
        if (cut >= 0) {
            const parts = written.slice(0, cut).split(".");
            for (let n = parts.length; n >= 0; n--) {
                const namespace = parts.slice(0, n).join(".");
                const found = named(namespace ? `${namespace}.${path}` : path);
                if (found.length > 0)
                    return found;
                if (n === parts.length)
                    continue;
                const level = context.levels.find((l) => l.namespace === namespace);
                const fromImports = imported(level?.imports ?? []);
                if (fromImports)
                    return fromImports;
            }
            return [];
        }
        for (const scope of context.types) {
            const found = named(`${scope}.${path}`);
            if (found.length > 0)
                return found;
            // Inside `class A : B`, B's nested types are visible by simple name too.
            const inherited = csInheritedNested(scope, path, named);
            if (inherited.length > 0)
                return inherited;
        }
        for (const { namespace, imports } of context.levels) {
            const found = named(namespace ? `${namespace}.${path}` : path);
            if (found.length > 0)
                return found;
            const fromImports = imported(imports);
            if (fromImports)
                return fromImports;
        }
        return [];
    };
    // C# types by full name, the parts of a partial type together.
    let csTypeIndex;
    const csTypesNamed = (full) => {
        if (!csTypeIndex) {
            csTypeIndex = new Map();
            for (const syms of symbolsByFile.values()) {
                for (const s of syms) {
                    if (s.language !== "csharp" || !CS_TYPE_KINDS.has(s.kind))
                        continue;
                    const key = csFullNameOf(s);
                    const list = csTypeIndex.get(key);
                    if (list)
                        list.push(s);
                    else
                        csTypeIndex.set(key, [s]);
                }
            }
        }
        return csTypeIndex.get(full) ?? [];
    };
    /**
     * What `path` names among the nested types the project type `scope`
     * inherits, nearest base first. `named` is the caller's own filter, so the
     * kind it asks for still holds.
     */
    const csInheritedNested = (scope, path, named, seen = new Set()) => {
        if (seen.has(scope))
            return [];
        seen.add(scope);
        for (const part of csTypesNamed(scope)) {
            for (const base of part.csBases ?? []) {
                for (const b of csBaseType(part, base)) {
                    const full = csFullNameOf(b);
                    const found = named(`${full}.${path}`);
                    if (found.length > 0)
                        return found;
                    const further = csInheritedNested(full, path, named, seen);
                    if (further.length > 0)
                        return further;
                }
            }
        }
        return [];
    };
    // Base lookups nest: a base is looked up from its type's enclosing types,
    // whose own bases are looked up in turn. Each base a type declares is
    // looked up once, or nested types make the walk exponential in their depth;
    // a lookup still under way answers nothing, which ends a cyclic hierarchy.
    const csBaseCache = new Map();
    /** The project type a base written in `t`'s declaration names: looked up from `t`'s enclosing scopes. */
    const csBaseType = (t, path) => {
        const key = `${t.id}\u0000${path}`;
        const cached = csBaseCache.get(key);
        if (cached)
            return cached;
        csBaseCache.set(key, []);
        const self = csFullNameOf(t);
        // A base list is read outside the type's own members.
        const own = csContextOf(t, t.file);
        const context = { types: own.types.filter((s) => s !== self), levels: own.levels };
        const found = csLookup(context, path, (b) => CS_TYPE_KINDS.has(b.kind) && csFullNameOf(b) !== self);
        csBaseCache.set(key, found);
        return found;
    };
    /**
     * Whether the project type `full` declares `name` as a value — a field,
     * property, constant, event or enum member, its own or inherited from a
     * project base. A member typed as itself (`csSelfTypedMembers`) is a value
     * unless both readings name one type from `context` (`csSameTypeReading`).
     */
    const csValueMember = (full, name, context, seen = new Set()) => {
        if (seen.has(full))
            return false;
        seen.add(full);
        const parts = csTypesNamed(full);
        for (const part of parts) {
            const self = part.csSelfTypedMembers?.find((m) => m.name === name);
            if (self)
                return !csSameTypeReading(part, self, name, context);
        }
        if (parts.some((p) => p.csMembers?.includes(name)))
            return true;
        const member = `${full}.${name}`;
        if ((csByName().get(name) ?? []).some((s) => s.kind === "variable" && csFullNameOf(s) === member))
            return true;
        for (const part of parts) {
            for (const base of part.csBases ?? []) {
                for (const b of csBaseType(part, base))
                    if (csValueMember(csFullNameOf(b), name, context, seen))
                        return true;
            }
        }
        return false;
    };
    /**
     * Whether `name`, heading an expression inside `context`, names a value
     * rather than a type, as C# decides it: each enclosing type in turn,
     * innermost first, declares it as a nested type (a type) or as a value
     * member. The locals and members the extractor sees are already recorded as
     * receivers; this finds those a type declares in another file or inherits.
     */
    const csHeadIsValue = (context, name) => {
        for (const scope of context.types) {
            if (csTypesNamed(`${scope}.${name}`).length > 0 || csInheritedNested(scope, name, csTypesNamed).length > 0)
                return false;
            if (csValueMember(scope, name, context))
                return true;
        }
        return false;
    };
    /**
     * Whether a member typed as itself (`Status Status`) may be read as the type
     * from `context`, by the C# rule: its declared type, looked up from where it
     * is declared, is the one type `name` names from `context`. A nullable value
     * type (`Status?` of an enum) is `Nullable<Status>`, another type. When
     * either reading names no project type, the binding is not established and
     * the member stays a value.
     */
    const csSameTypeReading = (owner, member, name, context) => {
        const isType = (s) => CS_TYPE_KINDS.has(s.kind);
        const declared = csLookup(csContextOf(owner, owner.file), member.type, isType);
        const used = csLookup(context, name, isType);
        const one = (xs) => new Set(xs.map(csFullNameOf));
        const a = one(declared);
        const b = one(used);
        if (a.size !== 1 || b.size !== 1 || [...a][0] !== [...b][0])
            return false;
        return !member.nullable || declared.every((s) => s.kind !== "struct" && s.kind !== "enum");
    };
    const reexportsByFile = new Map();
    for (const [file, edges] of outgoingCallsByFile.entries()) {
        const reexports = edges.filter((edge) => edge.kind === "reexport");
        if (reexports.length > 0)
            reexportsByFile.set(file, reexports);
    }
    /** Recursively find symbols matching `symbolName` in `targetFile` or its re-export chains */
    function findSymbolsInTarget(targetFile, symbolName, visited = new Set()) {
        const visitKey = `${targetFile}::${symbolName}`;
        if (visited.has(visitKey))
            return [];
        visited.add(visitKey);
        const candidates = [];
        const targetIdx = symbolIndexByFile.get(targetFile);
        // 1. Direct definition in targetFile (exported bindings only)
        const directMatches = targetIdx?.get(symbolName);
        if (directMatches && directMatches.length > 0) {
            for (const s of directMatches) {
                if (s.isExported !== false) {
                    if (symbolName === "default") {
                        if (s.exportedAs === "default" || s.name === "default") {
                            candidates.push(s.id);
                        }
                    }
                    else {
                        if (s.exportedAs === undefined || s.exportedAs === symbolName || s.name === symbolName) {
                            candidates.push(s.id);
                        }
                    }
                }
            }
        }
        // If seeking default export and no exact name match, look for any symbol with exportedAs === "default"
        if (symbolName === "default" && candidates.length === 0) {
            const syms = symbolsByFile.get(targetFile) ?? [];
            for (const s of syms) {
                if (s.isExported !== false && (s.exportedAs === "default" || s.name === "default")) {
                    candidates.push(s.id);
                }
            }
        }
        // 2. Follow re-export chains in targetFile
        const targetEdges = reexportsByFile.get(targetFile) ?? [];
        const targetDeps = depsByFile.get(targetFile) ?? [];
        for (const edge of targetEdges) {
            const edgeSourceDep = edge.sourceModule
                ? resolveDepFile(targetFile, edge.sourceModule, targetDeps)
                : null;
            // Named re-export: `export { X as Y } from './mod'` or `export { X } from './mod'`
            if (edge.localAlias === symbolName || (!edge.localAlias && edge.importedName === symbolName) || (!edge.localAlias && !edge.importedName && edge.calleeName === symbolName)) {
                const nextName = edge.importedName ?? edge.calleeName;
                if (edgeSourceDep) {
                    const sub = findSymbolsInTarget(edgeSourceDep, nextName, visited);
                    candidates.push(...sub);
                }
                else {
                    // Local re-export within same file
                    const localMatch = targetIdx?.get(nextName);
                    if (localMatch)
                        for (const s of localMatch)
                            candidates.push(s.id);
                }
            }
            // Wildcard re-export: `export * from './mod'` (only when unaliased)
            if (!edge.localAlias && (edge.calleeName === "*" || edge.importedName === "*") && edgeSourceDep) {
                const sub = findSymbolsInTarget(edgeSourceDep, symbolName, visited);
                candidates.push(...sub);
            }
        }
        return candidates;
    }
    /**
     * The files a Rust qualifier names, or `null` when it names none that this
     * project can reach. `null` is not "resolve it some other way": the caller
     * leaves the edge unresolved, because widening a qualified call to a
     * repository-wide name match is how `Vec::new()` would land on all 191 `new`.
     *
     * The search stays inside one module's scope — a file, its resolved
     * dependencies, and the re-export chains those reach. That module is the
     * caller's own, except under `super::`, where it is the parent the path
     * names: a different scope, not a wider one, and the only way to answer a
     * path that points out of the caller's.
     */
    /**
     * A qualifier's scope, and how the qualifier reached it.
     *
     * `viaModulePath` is true when the files were reached by naming modules —
     * `crate::a`, `super::a`, `self`, a bare `a::b`, or the module prefix of a
     * type qualifier. That is the case where an inline `mod` inside the answer
     * is *not* reachable: `crate::a::f()` names `a`'s own module, and an `f`
     * declared inside `mod holder { }` in `a.rs` needs `crate::a::holder::f()`,
     * which is a different path. rustc says so with E0425.
     *
     * It is false when the qualifier is a bare type — `T::method()`, `Self::` —
     * because the type may be declared inside the very inline `mod` the call is
     * written in, and there Rust does reach it.
     */
    function rustQualifierScope(callerFile, qualifier, deps, bindings) {
        const asPath = (files) => ({ files, viaModulePath: true });
        const segments = qualifier.split("::").map((s) => s.trim()).filter(Boolean);
        if (segments.length === 0)
            return null;
        // `<T as Tr>::go()` and anything else the grammar hands back with syntax in
        // it: not a path this can follow.
        if (qualifier.includes("<") || qualifier.includes(">"))
            return null;
        // `self::` and `Self::` name the caller itself. `super::` names its parent,
        // which the remaining segments are then matched against.
        //
        // `homes` and `scopeDeps` are the scope the rest of the path is read in:
        // ordinarily the caller's own file and its dependencies, but under `super::`
        // the parent module's, because `super::sibling::f()` names a module the
        // caller may never import.
        let rest = segments;
        let inOwnCrate = false;
        let homes = [callerFile];
        let scopeDeps = deps;
        if (segments[0] === "self" || segments[0] === "Self") {
            // `self` is the file's own module; `Self` is the implementing type, and
            // a type can be declared inside the inline `mod` the call sits in. Only
            // the first is a module path.
            if (segments.length === 1) {
                return { files: [callerFile], viaModulePath: segments[0] === "self" };
            }
            rest = segments.slice(1);
        }
        else if (segments[0] === "super") {
            const climbed = climbSuper(callerFile, segments);
            if (!climbed)
                return null;
            homes = climbed.homes;
            scopeDeps = climbed.deps;
            rest = climbed.rest;
            if (rest.length === 0)
                return asPath(homes);
        }
        else if (segments[0] === "crate") {
            rest = segments.slice(1);
            // Confined to the caller's own crate, which is what `crate::` means.
            inOwnCrate = true;
            // And read from the crate root, which is where the path starts. A nested
            // module does not import the root — the root imports it — so
            // `crate::helper()` would otherwise miss a `helper` declared there, and
            // `crate::a::b()` would need the caller to have imported `a` itself,
            // which Rust does not ask of it.
            // The root is added to the caller's own scope, not put in its place: a
            // module path is matched one hop and by suffix, so `crate::sync::watch`
            // is found through the caller's `watch.rs` and never through the root,
            // which only declares `sync`. Both sides are inside the same crate, and
            // the confinement below still applies to both.
            const roots = crateRootFilesOf(callerFile);
            // An empty list is a verdict, not a shrug: the caller sits in a target
            // whose root cannot be named here, so the edge is left unresolved.
            // Reading `crate::a::b()` in the caller's own scope instead would answer
            // out of the caller's own module tree, which is a different namespace.
            if (roots?.length === 0)
                return null;
            if (roots) {
                homes = roots;
                scopeDeps = [
                    ...new Set([...roots, ...roots.flatMap((r) => depsByFile.get(r) ?? []), ...deps]),
                ];
            }
            // `crate::helper()` names the root module itself.
            if (rest.length === 0)
                return roots ? asPath(roots) : null;
        }
        else {
            // An imported binding rewrites the head into the path it names, so
            // `use crate::a::Type as Alias` makes `Alias::method()` reach exactly
            // what `crate::a::Type::method()` reaches and nothing else.
            const binding = bindings.find((b) => b.local === segments[0]);
            if (binding) {
                const bound = binding.path.split("::").map((s) => s.trim()).filter(Boolean);
                // The same path, so the same scope: `use crate as root;` makes
                // `root::helper()` mean `crate::helper()`, and reading it in the
                // caller's own scope would answer nothing where the plain spelling
                // answers the crate root.
                let boundRoots = null;
                if (bound[0] === "crate") {
                    inOwnCrate = true;
                    boundRoots = crateRootFilesOf(callerFile);
                    // `use crate as root;` is the same path under another name, so an
                    // unnameable root leaves it unresolved the same way.
                    if (boundRoots?.length === 0)
                        return null;
                    if (boundRoots) {
                        homes = boundRoots;
                        scopeDeps = [
                            ...new Set([
                                ...boundRoots,
                                ...boundRoots.flatMap((r) => depsByFile.get(r) ?? []),
                                ...deps,
                            ]),
                        ];
                    }
                }
                if (bound[0] === "super") {
                    // `use super::config;` binds the parent's `config`, so the bound path
                    // is read where `super::config` is read. Stripping the hop and
                    // matching `config` against the caller's own dependencies reaches the
                    // caller's own child module of that name instead — a wrong answer
                    // reported as `unique`.
                    const climbed = climbSuper(callerFile, bound);
                    if (!climbed)
                        return null;
                    homes = climbed.homes;
                    scopeDeps = climbed.deps;
                    rest = [...climbed.rest, ...segments.slice(1)];
                    // `use super as up;` binds the parent itself, so `up::f()` is a call
                    // into the parent — the same answer `super::f()` gets.
                    if (rest.length === 0)
                        return asPath(homes);
                }
                else {
                    const head = bound[0] === "crate" || bound[0] === "self" ? bound.slice(1) : bound;
                    rest = [...head, ...segments.slice(1)];
                    // `use crate as root;` binds the crate root itself; `use self as
                    // this_module;` binds the caller's own module. Neither anchor has a
                    // path segment left after the alias is expanded.
                    if (rest.length === 0) {
                        if (boundRoots)
                            return asPath(boundRoots);
                        if (bound[0] === "self")
                            return asPath(homes);
                        return null;
                    }
                }
            }
        }
        if (rest.length === 0)
            return null;
        // Same crate, by identity rather than by prefix. A prefix is a superset:
        // `""` for a package at the project root also covers a crate in `sub/`,
        // and `crates/alpha/` covers one nested at `crates/alpha/inner/beta`. The
        // map already says which crate each file belongs to, so comparing that
        // answer costs nothing and gets nesting right.
        const mine = rustCrateRootByFile?.get(callerFile);
        const reachable = inOwnCrate && mine !== undefined
            ? scopeDeps.filter((d) => rustCrateRootByFile?.get(d) === mine)
            : scopeDeps;
        const confine = (files) => inOwnCrate && mine !== undefined
            ? files.filter((f) => rustCrateRootByFile?.get(f) === mine)
            : files;
        /**
         * The files a module path names: walked from the scope the path starts in,
         * and only where the walk comes back empty, matched by suffix as before.
         *
         * The walk speaks first because it is the one that can be wrong in the
         * direction that matters. Ordering it first is what turns tokio's
         * `crate::task::yield_now()` from `runtime/task/mod.rs` — a file that path
         * does not name, reached because the caller imports it and `task` is the
         * tail of its directory — into the honest `unresolved`.
         *
         * The suffix is kept underneath rather than deleted because the walk needs
         * the module tree to be visible in the file graph, and four things hide it:
         * a `mod` written inside a macro, an inline `mod x { … }` block that has no
         * file of its own, a `use` re-binding in an ancestor module (Rust resolves
         * `super::queue` through one), and a crate root named by `[[bin]] path =`
         * rather than by its location.
         *
         * Measured over ripgrep 14.1.1, tokio 1.40.0 and Sailor: 46 edges that
         * resolve today are ones the walk alone does not reach — 28 in tokio, 18 in
         * ripgrep, none in Sailor. The fallback recovers 40 of them, and all 40
         * were read against the Rust source and were right. Refusing them buys no
         * truth: the walk has nothing to say there, so the choice is the old answer
         * or none. The 6 it does not recover are the ones where the walk does reach
         * a module and the name is simply not found in it, and 5 of those 6 are the
         * `crate::task` answer above.
         */
        const modulePathFiles = (segments) => {
            const walked = walkModulePath(homes, segments);
            // The walk never entered a module, so it has nothing to say and the
            // suffix match answers as it did.
            if (walked === null)
                return matchModulePath(reachable, segments);
            // It did enter one. Its answer stands, confined to the caller's crate,
            // and an empty one is a verdict rather than a shrug.
            return confine(walked);
        };
        // A module path, walked segment by segment from the scope it starts in.
        const byPath = modulePathFiles(rest);
        if (byPath.length > 0)
            return asPath(byPath);
        // A type qualifier: the files, within reach, that declare that name. The
        // last segment is the type — `crate::a::Type::method()` qualifies `method`
        // with `crate::a::Type`.
        //
        // Anything before it is a module path and it is not decoration: it says
        // *which* `Type`. Looked up without it, an alias for `crate::a::Type` would
        // reach `b.rs`'s `Type` as well, which is the opposite of what an alias is
        // for. When that prefix names no reachable module the answer is nothing,
        // not everything — a qualifier narrows or it fails.
        const typeName = rest[rest.length - 1];
        const modulePrefix = rest.slice(0, -1);
        let searchIn;
        if (modulePrefix.length > 0) {
            searchIn = modulePathFiles(modulePrefix);
            if (searchIn.length === 0)
                return null;
        }
        else {
            searchIn = [...homes, ...reachable];
        }
        // Only what Rust can put before a `::`. A name is in the type namespace or
        // in the value one, never both, so a `const Config` or a `fn Config` in
        // reach says nothing about `Config::run()` — counting it would put its file
        // in scope and answer with whatever `run` that file happens to declare.
        const declaring = new Set();
        for (const file of searchIn) {
            for (const id of findSymbolsInTarget(file, typeName)) {
                if (!RUST_TYPE_NAMESPACE.has(kindOfSymbolId.get(id) ?? ""))
                    continue;
                const declaredIn = fileOfSymbolId.get(id);
                if (declaredIn)
                    declaring.add(declaredIn);
            }
        }
        // A type named through a module prefix was reached by walking modules, so
        // an inline `mod` in the answer is out of reach exactly as it is for a
        // plain module path. A bare `T::method()` was not: `T` can be declared in
        // the very inline `mod` the call sits in, and there Rust reaches it.
        if (declaring.size > 0) {
            return { files: [...declaring], viaModulePath: modulePrefix.length > 0 };
        }
        return null;
    }
    /** Cached module paths used to validate crate- and super-rooted re-exports. */
    const rustModulePathsByFile = new Map();
    /** Module paths of `file`, relative to each target root that reaches it. */
    function rustModulePathsOf(file) {
        const cached = rustModulePathsByFile.get(file);
        if (cached)
            return cached;
        const paths = [];
        const roots = crateRootFilesOf(file) ?? [];
        for (const root of roots) {
            if (root === file) {
                paths.push([]);
                continue;
            }
            const base = childModuleDirOf(root, true);
            if (base && !file.startsWith(`${base}/`))
                continue;
            const relative = base ? file.slice(base.length + 1) : file;
            if (!relative.endsWith(".rs"))
                continue;
            const segments = relative.slice(0, -3).split("/").filter(Boolean);
            if (segments.at(-1) === "mod")
                segments.pop();
            if (segments.length > 0)
                paths.push(segments);
        }
        rustModulePathsByFile.set(file, paths);
        return paths;
    }
    const samePath = (left, right) => left.length === right.length && left.every((segment, index) => segment === right[index]);
    /** Whether one top-level binding reads from the inline owner of a symbol. */
    function bindingReadsInlineOwner(file, binding, owner, name) {
        if (binding.local !== "*" && binding.local !== name)
            return false;
        const target = binding.path.split("::").map((segment) => segment.trim()).filter(Boolean);
        if (binding.local !== "*") {
            // An alias of a differently named symbol does not make an unrelated
            // same-named declaration in this file the re-export's target.
            if (target.at(-1) !== name)
                return false;
            target.pop();
        }
        const ownerPath = owner.split("::").filter(Boolean);
        if (ownerPath.length === 0)
            return false;
        // Without a readable manifest, keep the established best-effort behavior:
        // explicit uses stay accepted, while a glob must at least name the full
        // inline owner rather than merely sharing its outermost segment.
        if (!rustCrateRootsByFile?.has(file)) {
            if (binding.local !== "*")
                return true;
            const unanchored = target[0] === "self" || target[0] === "crate"
                ? target.slice(1)
                : target;
            return unanchored.length >= ownerPath.length &&
                samePath(unanchored.slice(-ownerPath.length), ownerPath);
        }
        if (target[0] === "self")
            return samePath(target.slice(1), ownerPath);
        if (target[0] !== "crate" && target[0] !== "super") {
            return samePath(target, ownerPath);
        }
        const filePaths = rustModulePathsOf(file);
        const expected = filePaths.map((filePath) => [...filePath, ...ownerPath]);
        if (target[0] === "crate") {
            const absolute = target.slice(1);
            return expected.some((candidate) => samePath(absolute, candidate));
        }
        let supers = 0;
        while (target[supers] === "super")
            supers += 1;
        const rest = target.slice(supers);
        return filePaths.some((filePath, index) => {
            if (supers > filePath.length)
                return false;
            const absolute = [...filePath.slice(0, filePath.length - supers), ...rest];
            return samePath(absolute, expected[index]);
        });
    }
    /**
     * Whether the top level of the file declaring `id` brings `name` up to
     * itself with a `use`.
     *
     * From a file's own module Rust reaches what the file declares at its top
     * level and what the top level imports. `counters.rs` in tokio declares
     * `inc_num_inc_notify_local()` inside `mod imp` and then writes
     * `pub(super) use imp::*;`; `ucred.rs` does the same with explicit uses.
     *
     * A glob is recorded under `*` and carries the module it reads from, which
     * is what bounds it. `pub use imp::*;` exports what `imp` exports, not a
     * sibling module or a private `imp::hidden` nested beneath it. The complete
     * inline owner path therefore has to match the binding's source module.
     */
    function reExportedToTopLevel(id, name) {
        const file = fileOfSymbolId.get(id);
        if (!file)
            return false;
        const declared = rustBindingsByFile?.get(file);
        if (!declared)
            return false;
        const owner = rustInlineDeclaredSymbols?.get(id);
        return owner !== undefined &&
            declared.some((binding) => bindingReadsInlineOwner(file, binding, owner, name));
    }
    for (const [callerFile, edges] of outgoingCallsByFile.entries()) {
        const localIdx = symbolIndexByFile.get(callerFile);
        const deps = depsByFile.get(callerFile) ?? [];
        const localVars = typedVarsByFile.get(callerFile);
        const localInferred = resolvedInferredByFile.get(callerFile);
        const callerClassName = classNameByFile.get(callerFile);
        const bindings = rustBindingsByFile?.get(callerFile) ?? [];
        const callerGodotProject = godotProjectForFile(callerFile);
        const callerGodotRoot = godotContext?.projectRootByFile.get(callerFile);
        const callerClassNameIndex = classNameIndexForFile(callerFile);
        const callerAutoloadTable = autoloadTableForFile(callerFile);
        const callerMemberTypeTable = godotContext
            ? (callerGodotRoot ? memberTypeTablesByRoot.get(callerGodotRoot) : undefined)
            : memberTypeTable;
        const callerLang = langByFile.get(callerFile)
            ?? symbolsByFile.get(callerFile)?.find((s) => s.language)?.language;
        const isGdscript = callerLang === "gdscript";
        let cachedCallerSceneEntries;
        const getCallerSceneEntries = () => {
            cachedCallerSceneEntries ??= sceneEntriesForCaller(callerFile, callerGodotRoot);
            return cachedCallerSceneEntries;
        };
        // The ids declared under a {@link phpOwnedKey}, nearest first: those in the
        // caller's own file, else those in its dependencies, else every one in the
        // project. A key the project does not declare at all — the common case for
        // a vendor class (`DB::table()`) — is answered by the first lookup.
        let callerDeps;
        const phpFound = (key) => {
            const anywhere = phpOwnedAnywhere.get(key);
            if (!anywhere)
                return [];
            const local = anywhere.filter((id) => fileOfSymbolId.get(id) === callerFile);
            if (local.length > 0)
                return local;
            callerDeps ??= new Set(deps);
            const depFiles = callerDeps;
            const inDeps = anywhere.filter((id) => depFiles.has(fileOfSymbolId.get(id) ?? ""));
            return inDeps.length > 0 ? inDeps : anywhere;
        };
        for (const edge of edges) {
            const callLine = edge.callSite.line;
            // ── GDScript receiver-type resolution ───────────────────────────
            if (isGdscript && edge.receiver) {
                const resolved = resolveGdscriptReceiverCall(edge.receiver, edge.calleeName, callerAutoloadTable, callerClassNameIndex, localVars, symbolIndexByFile, localIdx, deps, localInferred, callerClassName, callerMemberTypeTable, scriptPathToClassName, callLine, resToRepoPathMap, callerGodotProject?.rootOffset, getCallerSceneEntries);
                if (resolved !== null) {
                    if (resolved.length > 0) {
                        edge.calleeCandidates = resolved;
                        // self.method() is a same-file call → "local", matching the
                        // generic JS/TS resolver's convention for same-file matches.
                        // All other resolved receiver calls use "unique"/"multiple-candidates".
                        edge.confidence = edge.receiver === "self"
                            ? "local"
                            : resolved.length === 1 ? "unique" : "multiple-candidates";
                        continue;
                    }
                    // resolved === [] means the receiver type is a known Godot builtin
                    // class — mark as engine API (not a project symbol, not unresolved)
                    edge.calleeCandidates = [];
                    edge.confidence = "engine";
                    continue;
                }
                // resolved === null means we couldn't determine the receiver type
                // Fall through to normal name-based resolution
            }
            // ── Signal emit resolution (GDScript only) ──────────────────────
            // `signal_name.emit()` carries an explicit marker so calleeName remains
            // the raw identifier promised by SymbolEdge. Accept and normalize the
            // pre-marker encoding so graphs produced by earlier builds still resolve.
            if (isGdscript && (edge.signalEmit || edge.calleeName.startsWith("signal:"))) {
                const signalName = edge.calleeName.startsWith("signal:")
                    ? edge.calleeName.slice(7)
                    : edge.calleeName;
                edge.calleeName = signalName;
                edge.signalEmit = true;
                const signalCandidates = [];
                // Look in local file first
                const localSignal = localIdx?.get(signalName);
                if (localSignal) {
                    for (const s of localSignal) {
                        if (s.kind === "signal")
                            signalCandidates.push(s.id);
                    }
                }
                // Look in dependencies
                if (signalCandidates.length === 0) {
                    for (const dep of deps) {
                        const depIdx = symbolIndexByFile.get(dep);
                        const matches = depIdx?.get(signalName);
                        if (matches) {
                            for (const s of matches) {
                                if (s.kind === "signal")
                                    signalCandidates.push(s.id);
                            }
                        }
                    }
                }
                if (signalCandidates.length > 0) {
                    edge.calleeCandidates = signalCandidates;
                    edge.confidence = signalCandidates.length === 1 ? "unique" : "multiple-candidates";
                }
                else {
                    edge.calleeCandidates = [];
                    edge.confidence = "unresolved";
                }
                continue;
            }
            const candidates = [];
            // A qualified call is resolved by its qualifier or not at all.
            //
            // Gated on the caller being Rust, not merely on the field being present.
            // Everything below reads `::`, `crate`, `self` and `super` as Rust means
            // them, and `rawCallsToUnresolvedEdges` carries `calleeQualifier` for
            // every language — so the day another extractor fills it, this would
            // apply Rust's semantics to it silently.
            if (edge.calleeQualifier && callerFile.endsWith(".rs")) {
                const scope = rustInlineScopedCalls?.has(edge)
                    ? null
                    : rustQualifierScope(callerFile, edge.calleeQualifier, deps, bindings);
                if (scope) {
                    for (const file of scope.files) {
                        candidates.push(...findSymbolsInTarget(file, edge.calleeName));
                    }
                }
                let uniq = Array.from(new Set(candidates));
                // A path that names modules answers with a module, and from a module
                // Rust reaches what the file declares at its top level — never what an
                // inline `mod` inside it encloses. `crate::a::f()` names `a`'s own
                // module; an `f` written in `mod holder { }` in `a.rs` is
                // `crate::a::holder::f()`, a different path, and rustc answers E0425
                // for the first. The scope this resolution has is the file, so those
                // ids come back with the rest; dropping them is what keeps `unique`
                // from naming a symbol that is not callable from where the call is
                // written, and whoever walks the candidates from following it.
                //
                // It follows the *path*, not the spelling: `self`, `super::a`,
                // `crate::a`, a bare `a::b` and the module prefix of a type qualifier
                // all reach a module the same way. A bare `T::method()` does not — `T`
                // can be declared in the very inline `mod` the call sits in, and there
                // Rust does reach it — so `viaModulePath` says which it was, rather
                // than the qualifier's first segment. Asking for the spelling `self`
                // left every other way of reaching the same module answering `unique`.
                //
                // The refusal already in place for a path rooted in an inline `mod`
                // then extends to this one: when nothing survives, the edge keeps its
                // qualifier and goes unresolved, because the module the path names
                // declares nothing of that name.
                if (scope?.viaModulePath && rustInlineDeclaredSymbols) {
                    uniq = uniq.filter((id) => !rustInlineDeclaredSymbols.has(id) || reExportedToTopLevel(id, edge.calleeName));
                }
                edge.calleeCandidates = uniq;
                if (uniq.length === 0)
                    edge.confidence = "unresolved";
                // `self::helper()` lands in the caller's own file, which is what
                // `local` has always meant here — the qualifier changes how it was
                // found, not where it was found.
                else if (uniq.every((id) => fileOfSymbolId.get(id) === callerFile)) {
                    edge.confidence = "local";
                }
                else if (uniq.length === 1)
                    edge.confidence = "unique";
                else
                    edge.confidence = "multiple-candidates";
                continue;
            }
            // A qualified PHP edge is answered by what its qualifier names, or not at
            // all — the PHP counterpart of the Rust branch above, and gated on the
            // caller's language for the same reason.
            //
            // The extractor has already resolved the qualifier under the file's
            // namespace and `use` imports: the class a static call names, or the
            // namespace a class reference names. A candidate must be declared under
            // exactly that owner (`SymbolNode.phpOwner`), which is what stops
            // `UserSchema::all()` inside `Schema::all()` from becoming a self-edge —
            // the caller declares an `all` too, but its owner is `Schema` — and stops
            // `Request::capture()` from reaching a same-named `Invoice::capture()`.
            //
            // The caller's own file is searched first, then its dependencies, as for
            // every other edge, and then the rest of the project. That last step is
            // not the name guessing this branch exists to stop: the key is the whole
            // qualified name, so it can only reach the class the source names. It is
            // what finds a sibling class in the caller's own namespace, which PHP
            // needs no `use` for and the file graph therefore draws no edge to.
            //
            // A static method the named class does not declare is looked for where
            // PHP looks for it ({@link phpInheritedFrom}). When nothing is found the
            // edge is left `unresolved` with no candidates: falling back to the
            // method name alone is exactly how the wrong-class edges were drawn.
            if (edge.calleeQualifier && callerLang === "php") {
                const ids = edge.calleeQualifier.endsWith("\\")
                    ? phpFound(phpOwnedKey(edge.calleeQualifier, edge.calleeName))
                    : phpInheritedFrom(edge.calleeQualifier, edge.calleeName, phpFound, phpClassLikeById);
                const uniq = Array.from(new Set(ids));
                edge.calleeCandidates = uniq;
                if (uniq.length === 0)
                    edge.confidence = "unresolved";
                else if (uniq.every((id) => fileOfSymbolId.get(id) === callerFile))
                    edge.confidence = "local";
                else if (uniq.length === 1)
                    edge.confidence = "unique";
                else
                    edge.confidence = "multiple-candidates";
                continue;
            }
            // 1. Local (unless edge explicitly specifies an external source module)
            //    Checked BEFORE Godot builtin filtering so a user-defined `func print()`
            //    or `func range()` resolves to the local symbol, not the engine builtin.
            if (callerLang === "csharp" && (edge.calleeQualifier || edge.kind === "type_reference" || edge.receiver)) {
                // A C# qualified call names its class (`Billing.Calc.Run()`), a
                // creation its type (`new Calc()`), a type position a type, and
                // `this.` / `base.` the caller's own type. The written path is looked
                // up from the caller's scope as C# looks it up (`csLookup`): under the
                // caller's enclosing types and namespaces, then what is imported there
                // — so `Calc` is the `Calc` the caller can see, never a same-named
                // symbol elsewhere, and a path no visible symbol matches stays
                // unresolved rather than widening to every symbol of that name. A .NET
                // framework type the caller cannot see a project type for is `engine`.
                const caller = csSymbolById(edge.callerId);
                const context = csContextOf(caller, callerFile);
                const lookup = (path, accept) => csLookup(context, path, accept);
                const qualifier = edge.calleeQualifier;
                const name = edge.calleeName;
                // `global::` or an alias's declaring namespace (`App.Shop::`), kept in front of the path.
                const cut = qualifier?.indexOf("::") ?? -1;
                const anchored = cut >= 0;
                const relative = qualifier && anchored ? qualifier.slice(cut + 2) : qualifier;
                const anchor = qualifier && anchored ? qualifier.slice(0, cut + 2) : "";
                const terminal = relative ? relative.slice(relative.lastIndexOf(".") + 1) : undefined;
                let found = [];
                let framework = false;
                if (edge.kind === "type_reference") {
                    // A member-access receiver may end in static members
                    // (`Config.Instance`), so its shorter prefixes are tried after the
                    // whole path; a type position is the whole path or nothing. A
                    // shorter prefix is accepted only for a type that declares the next
                    // segment as a member, its own or inherited from a project base
                    // (`App.Config.Instance`, or a field among `csMembers`): otherwise the leading
                    // segments may be a namespace the project does not declare, and the
                    // name alone would bind an unrelated same-named type.
                    const segments = relative ? [...relative.split("."), name] : [name];
                    const shortest = edge.csMemberReceiver ? 1 : segments.length;
                    // A member access on a value (`Config.Load()` on a property) references no type.
                    const value = edge.csMemberReceiver === true && !anchored && csHeadIsValue(context, segments[0]);
                    for (let n = segments.length; !value && n >= shortest && found.length === 0; n--) {
                        const path = anchor + segments.slice(0, n).join(".");
                        const next = n < segments.length ? segments[n] : null;
                        const declaresNext = (t) => next === null || csMembersOf(t, next, true).length > 0;
                        found = lookup(path, (s) => CS_TYPE_KINDS.has(s.kind) && declaresNext(s));
                        if (csFrameworkPath(segments.slice(0, n)))
                            framework = true;
                    }
                }
                else if (qualifier && terminal === name) {
                    // `new Calc()`: a member cannot share its class's name, so the
                    // repeated name is a creation. It calls the type's declared
                    // constructors, or the type itself when it declares none.
                    for (const type of lookup(qualifier, (s) => CS_CREATABLE_KINDS.has(s.kind))) {
                        const ctorName = `${csFullNameOf(type)}.${name}`;
                        const ctors = (csByName().get(name) ?? []).filter((s) => s.kind === "constructor" && csFullNameOf(s) === ctorName);
                        found.push(...(ctors.length > 0 ? ctors : [type]));
                    }
                    framework = csFrameworkPath(relative?.split(".") ?? []);
                }
                else if (qualifier && !anchored && csHeadIsValue(context, qualifier.split(".")[0])) {
                    // `Report.Print()` on a property `Report` declared in another file or
                    // inherited: an instance call, whose receiver's type is not inferred.
                }
                else if (qualifier) {
                    found = lookup(`${qualifier}.${name}`, (s) => !CS_TYPE_KINDS.has(s.kind));
                    if (found.length === 0) {
                        // `Derived.Create()` may call a static method `Derived` inherits.
                        const owners = lookup(qualifier, (s) => CS_TYPE_KINDS.has(s.kind));
                        for (const t of owners)
                            found.push(...csMembersOf(t, name, false));
                        // `Math.Max()` with no project `Math` in sight is a framework call;
                        // a member a visible project type lacks stays unresolved.
                        framework = owners.length === 0 && csFrameworkPath(relative?.split(".") ?? []);
                    }
                }
                else if ((edge.receiver === "this" || edge.receiver === "base") && caller?.csFullName) {
                    // `this.Run()` names the caller's own type, `base.Run()` its bases.
                    const own = CS_TYPE_KINDS.has(caller.kind)
                        ? caller.csFullName
                        : caller.csFullName.slice(0, Math.max(0, caller.csFullName.lastIndexOf(".")));
                    for (const t of csTypesNamed(own)) {
                        if (edge.receiver === "this")
                            found.push(...csMembersOf(t, name, false));
                        else
                            for (const base of t.csBases ?? [])
                                for (const b of csBaseType(t, base))
                                    found.push(...csMembersOf(b, name, false));
                    }
                }
                // Any other receiver (`?`: a call result, `a?.b`) names no target the
                // source spells out, so the edge stays unresolved.
                const uniq = Array.from(new Set(found.map((s) => s.id)));
                edge.calleeCandidates = uniq;
                if (uniq.length === 0)
                    edge.confidence = framework ? "engine" : "unresolved";
                else if (uniq.every((id) => fileOfSymbolId.get(id) === callerFile))
                    edge.confidence = "local";
                else if (uniq.length === 1)
                    edge.confidence = "unique";
                else
                    edge.confidence = "multiple-candidates";
                continue;
            }
            if (!edge.sourceModule) {
                const local = localIdx?.get(edge.calleeName);
                if (local && local.length > 0) {
                    for (const s of local)
                        candidates.push(s.id);
                    edge.calleeCandidates = candidates;
                    edge.confidence = "local";
                    continue;
                }
            }
            // ── Filter Godot builtins from bare calls (GDScript only) ───────
            // Runs after local lookup so user-defined functions shadow engine builtins.
            if (isGdscript && !edge.receiver) {
                // super() is a GDScript construct that calls the parent class _init.
                // It's not a project symbol — filter it as engine API.
                if (edge.calleeName === "super" || GODOT_BUILTIN_FUNCTIONS.has(edge.calleeName)) {
                    edge.calleeCandidates = [];
                    edge.confidence = "engine";
                    continue;
                }
            }
            // 2. Module-targeted import / reference
            if (edge.sourceModule) {
                const targetDep = resolveDepFile(callerFile, edge.sourceModule, deps);
                const searchName = edge.importedName ?? edge.calleeName;
                if (targetDep) {
                    const found = findSymbolsInTarget(targetDep, searchName);
                    candidates.push(...found);
                }
            }
            else {
                // 3. Untargeted cross-file resolution (fallback for languages without explicit module specifiers)
                const searchName = edge.importedName ?? edge.calleeName;
                for (const dep of deps) {
                    const found = findSymbolsInTarget(dep, searchName);
                    candidates.push(...found);
                }
            }
            // De-duplicate
            const uniq = Array.from(new Set(candidates));
            edge.calleeCandidates = uniq;
            if (uniq.length === 0)
                edge.confidence = "unresolved";
            else if (uniq.length === 1)
                edge.confidence = "unique";
            else
                edge.confidence = "multiple-candidates";
        }
    }
}
/**
 * Resolve a type marker from assignment-site inference.
 *
 * Markers:
 * - "<self>" → the file's class_name
 * - "ref:varName" → look up varName in typedVars or already-resolved inferred types
 * - "ClassName" → direct class name (returned as-is)
 *
 * @returns The resolved type name, or null if the marker can't be resolved yet.
 */
/** Flatten all scoped resolved inferred types into a simple name→type map.
 * Used by resolveTypeMarker for ref: chain resolution where scope doesn't matter. */
function flattenAllResolved(resolved) {
    const flat = new Map();
    for (const [name, entries] of resolved.entries()) {
        if (entries.length > 0)
            flat.set(name, entries[0].type);
    }
    return flat;
}
/** Flatten scoped resolved inferred types into a simple name→type map for a
 * specific scope. Used by resolveTypeMarker which needs a flat view. */
function flattenResolved(resolved, _varName) {
    // For ref: chain resolution, we use the first resolved entry for each var.
    // This is conservative — if a var has different types in different scopes,
    // the ref: chain picks the first one. This is acceptable because ref: chains
    // are typically within the same scope.
    const flat = new Map();
    for (const [name, entries] of resolved.entries()) {
        if (entries.length > 0)
            flat.set(name, entries[0].type);
    }
    return flat;
}
/** Look up a scoped inferred type for a variable at a specific line.
 * Returns the type from the deepest scope containing the line, or null. */
function lookupScopedInferred(scopedMap, varName, line) {
    if (!scopedMap)
        return null;
    const entries = scopedMap.get(varName);
    if (!entries || entries.length === 0)
        return null;
    // Find the entry whose scope contains the line. Prefer the deepest (narrowest) scope.
    let best = null;
    let bestSpan = Infinity;
    for (const entry of entries) {
        if (line >= entry.startLine && line <= entry.endLine) {
            const span = entry.endLine - entry.startLine;
            if (span < bestSpan) {
                bestSpan = span;
                best = entry;
            }
        }
    }
    return best?.type ?? null;
}
function resolveTypeMarker(marker, fileClassName, typedVars, resolvedInferred) {
    if (marker === "<self>") {
        return fileClassName ?? null;
    }
    if (marker.startsWith("ref:")) {
        const refName = marker.slice(4);
        // Check typed vars first
        if (typedVars?.has(refName)) {
            return typedVars.get(refName) ?? null;
        }
        // Check already-resolved inferred types (for chained references)
        if (resolvedInferred?.has(refName)) {
            return resolvedInferred.get(refName) ?? null;
        }
        return null; // Can't resolve yet (may be resolved in a later pass)
    }
    // Direct class name
    return marker;
}
/**
 * Resolve a GDScript method call with a receiver to target symbol(s).
 *
 * @returns Array of resolved SymbolNode IDs, or `[]` if the receiver type is
 *   a known Godot builtin (engine API, not a project symbol), or `null` if
 *   the receiver type cannot be determined (fall through to name-based resolution).
 */
function resolveGdscriptReceiverCall(receiver, methodName, autoloadTable, classNameIndex, localVars, symbolIndexByFile, localIdx, callerDeps, localInferred, callerClassName, memberTypeTable, scriptPathToClassName, callLine, resToRepoPathMap, godotRootOffset, getSceneEntries = () => []) {
    const repoPathForResource = (resourcePath) => {
        if (godotRootOffset !== undefined) {
            return godotRootOffset ? `${godotRootOffset}/${resourcePath}` : resourcePath;
        }
        return resToRepoPathMap?.get(resourcePath) ?? resourcePath;
    };
    // A project class_name always wins over a Godot builtin of the same name.
    // This helper returns true only for genuine engine builtins — names that
    // are NOT defined as class_name in the project.
    const isEngineBuiltin = (name) => !classNameIndex.has(name) && GODOT_BUILTIN_CLASSES.has(name);
    // ── self: resolve to caller's own class methods ────────────────────
    // `self.method()` → look up `method` in the caller's own file symbols.
    // This is the most common receiver in GDScript and was previously dropped
    // because `self` has no type in the classNameIndex/typedVars indexes.
    if (receiver === "self") {
        if (!localIdx)
            return null;
        const matches = localIdx.get(methodName);
        if (!matches || matches.length === 0)
            return null;
        const methodMatches = matches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
        if (methodMatches.length === 0)
            return null;
        return methodMatches.map((s) => s.id);
    }
    // ── super: resolve to parent class via extends dependency ──────────
    // `super.method()` → look up `method` in the caller's extends dependency.
    // The file-import graph already resolves `extends ClassName` / `extends
    // "res://path.gd"` to the parent .gd file, which is in callerDeps.
    // We prefer deps that have a top-level class_name (the extends target)
    // over preload/load targets, since the extends parent is the class that
    // `super` refers to in GDScript's inheritance chain.
    if (receiver === "super") {
        if (!callerDeps || callerDeps.length === 0)
            return null;
        // First pass: check deps that have a top-level class_name (extends target)
        for (const dep of callerDeps) {
            const depIdx = symbolIndexByFile.get(dep);
            if (!depIdx)
                continue;
            // Skip deps that don't have a class_name declaration — they're likely
            // preload/load targets, not the extends parent.
            const hasClassName = Array.from(depIdx.values()).some((syms) => syms.some((s) => s.kind === "class" && s.isGdscriptClassName === true));
            if (!hasClassName)
                continue;
            const matches = depIdx.get(methodName);
            if (!matches || matches.length === 0)
                continue;
            const methodMatches = matches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
            if (methodMatches.length > 0)
                return methodMatches.map((s) => s.id);
        }
        // Second pass: fall back to any dep (e.g. extends "res://path.gd" where
        // the target file has no class_name — Godot allows this)
        for (const dep of callerDeps) {
            const depIdx = symbolIndexByFile.get(dep);
            if (!depIdx)
                continue;
            const matches = depIdx.get(methodName);
            if (!matches || matches.length === 0)
                continue;
            const methodMatches = matches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
            if (methodMatches.length > 0)
                return methodMatches.map((s) => s.id);
        }
        return null; // Parent class exists but method not found — unresolved
    }
    // ── Multi-hop receiver resolution ──────────────────────────────────
    // `fighter.state_machine.transition_to("idle")` → receiver = "fighter.state_machine"
    // Resolve hop by hop: fighter → Fighter, state_machine on Fighter → StateMachine,
    // then look up transition_to on StateMachine.
    const receiverParts = receiver.split(".");
    if (receiverParts.length > 1) {
        // Resolve the first hop to a type name
        let currentType = null;
        // First hop: self, autoload, class_name, typed var, or inferred
        const firstPart = receiverParts[0];
        if (firstPart === "self") {
            // self.state.attack() → self resolves to the caller's class
            currentType = callerClassName ?? null;
        }
        else if (autoloadTable?.has(firstPart)) {
            currentType = firstPart;
        }
        else if (classNameIndex.has(firstPart)) {
            currentType = firstPart;
        }
        else if (localVars?.has(firstPart)) {
            currentType = localVars.get(firstPart) ?? null;
            // P2: Widened-annotation narrowing — if the declared type is a Godot
            // builtin but an assignment-site inference gives a more specific project
            // class, prefer the inferred type. This handles `var f: Node = Fighter.new()`
            // where Node is too broad to resolve Fighter-specific members.
            if (currentType) {
                const inferredType = lookupScopedInferred(localInferred, firstPart, callLine ?? 0);
                if (inferredType && inferredType !== currentType && isEngineBuiltin(currentType) && classNameIndex.has(inferredType)) {
                    currentType = inferredType;
                }
            }
        }
        else {
            const inferredType = lookupScopedInferred(localInferred, firstPart, callLine ?? 0);
            if (inferredType) {
                currentType = inferredType;
            }
            else if (callerClassName && memberTypeTable?.has(callerClassName)) {
                const memberMap = memberTypeTable.get(callerClassName);
                if (memberMap?.has(firstPart)) {
                    currentType = memberMap.get(firstPart) ?? null;
                }
            }
        }
        // .tscn fallback for first hop: if the first part is a node name in a
        // .tscn file (e.g. from $Fighter.state.attack()), resolve its type.
        // Prefer scene files in the same directory as the caller (same Godot
        // project) to avoid resolving to a node of the same name in a different scene.
        if (!currentType) {
            const nodeName = firstPart.includes("/") ? (firstPart.split("/").pop() ?? firstPart) : firstPart;
            for (const [, fileIdx] of getSceneEntries()) {
                const nodeSyms = fileIdx.get(nodeName);
                if (!nodeSyms || nodeSyms.length === 0)
                    continue;
                const nodeSym = nodeSyms[0];
                const nodeType = nodeSym.typeName;
                if (!nodeType)
                    continue;
                // Handle script: marker
                if (nodeType.startsWith("script:")) {
                    const scriptPath = nodeType.slice(7);
                    const relativePath = scriptPath.startsWith("res://") ? scriptPath.slice(6) : scriptPath;
                    // Try direct lookup first, then normalize via resToRepoPathMap
                    // for nested Godot projects (res:// path → repo-relative path)
                    const repoRel = repoPathForResource(relativePath);
                    const className = scriptPathToClassName?.get(repoRel) ?? scriptPathToClassName?.get(relativePath);
                    if (className) {
                        currentType = className;
                        break;
                    }
                }
                else if (isEngineBuiltin(nodeType)) {
                    currentType = nodeType;
                    break;
                }
                else {
                    // Unenumerated engine type — treat as engine API for the chain
                    currentType = nodeType;
                    break;
                }
            }
        }
        if (!currentType)
            return null; // Can't resolve first hop
        // Walk subsequent hops
        for (let i = 1; i < receiverParts.length; i++) {
            if (isEngineBuiltin(currentType))
                return []; // Builtin — any method is engine API
            const typeFile = classNameIndex.get(currentType) ?? autoloadTable?.get(currentType);
            if (!typeFile)
                return []; // Not a project class or autoload — treat as engine API
            // (Godot has hundreds of node types not in GODOT_BUILTIN_CLASSES; returning
            // null here would fall through to generic name-based resolution and risk
            // false positives. Returning [] safely filters as engine API.)
            const typeIdx = symbolIndexByFile.get(typeFile);
            if (!typeIdx)
                return null;
            // Look up the member in the type's file
            const memberName = receiverParts[i];
            const memberSyms = typeIdx.get(memberName);
            if (!memberSyms || memberSyms.length === 0) {
                // Check member type table for this type
                const memberMap = memberTypeTable?.get(currentType);
                if (memberMap?.has(memberName)) {
                    const nextType = memberMap.get(memberName);
                    if (!nextType)
                        return null;
                    currentType = nextType;
                    continue;
                }
                return null; // Member not found
            }
            // Find the member's type
            const memberSym = memberSyms.find((s) => s.typeName);
            if (memberSym?.typeName) {
                currentType = memberSym.typeName;
            }
            else {
                // Check member type table
                const memberMap = memberTypeTable?.get(currentType);
                if (memberMap?.has(memberName)) {
                    const nextType = memberMap.get(memberName);
                    if (!nextType)
                        return null;
                    currentType = nextType;
                }
                else {
                    return null; // Can't determine member type
                }
            }
        }
        // Now resolve the method on the final type
        if (isEngineBuiltin(currentType))
            return []; // Builtin — engine API
        if (methodName === "new")
            return [];
        const finalFile = classNameIndex.get(currentType) ?? autoloadTable?.get(currentType);
        if (!finalFile)
            return null;
        const finalIdx = symbolIndexByFile.get(finalFile);
        if (!finalIdx)
            return null;
        const matches = finalIdx.get(methodName);
        if (!matches || matches.length === 0)
            return null;
        const methodMatches = matches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
        if (methodMatches.length === 0)
            return null;
        return methodMatches.map((s) => s.id);
    }
    // Determine the receiver type name
    let typeName = null;
    // a. Autoload lookup: receiver is an autoload name (e.g. GameManager)
    if (autoloadTable?.has(receiver)) {
        typeName = receiver; // The autoload name IS the class name
    }
    // b. class_name lookup: receiver is a class name used directly (e.g. Fighter.new())
    if (!typeName && classNameIndex.has(receiver)) {
        typeName = receiver;
    }
    // c. Typed variable lookup: receiver is a variable name (e.g. opponent)
    if (!typeName && localVars?.has(receiver)) {
        typeName = localVars.get(receiver) ?? null;
    }
    // d. Inferred type lookup: receiver is an untyped variable with
    //    assignment-site inference (var x = Fighter.new() → x is Fighter)
    if (!typeName) {
        typeName = lookupScopedInferred(localInferred, receiver, callLine ?? 0);
    }
    // e. Member type table lookup: receiver is an untyped class member whose
    //    type was inferred from a cross-file assignment
    //    (state.state_machine = self in Fighter.gd → State.state_machine is Fighter)
    if (!typeName && callerClassName && memberTypeTable?.has(callerClassName)) {
        const memberMap = memberTypeTable.get(callerClassName);
        if (memberMap?.has(receiver)) {
            typeName = memberMap.get(receiver) ?? null;
        }
    }
    // P2: Widened-annotation narrowing — if the declared type is a Godot base
    // class but an assignment-site inference gives a more specific project class,
    // prefer the inferred type. This handles `var fighter: Node = Fighter.new()`
    // where Node is too broad to resolve Fighter-specific methods.
    if (typeName) {
        const inferredType = lookupScopedInferred(localInferred, receiver, callLine ?? 0);
        if (inferredType && inferredType !== typeName) {
            // Only override if declared type is a Godot builtin and inferred is a project class
            if (isEngineBuiltin(typeName) && classNameIndex.has(inferredType)) {
                typeName = inferredType;
            }
        }
    }
    if (!typeName) {
        // ── Godot builtin class as receiver (static methods) ──────────────
        // `Time.get_ticks_msec()`, `Engine.is_editor_hint()`, `Input.is_action_pressed()`
        // The receiver is a Godot builtin class name used as a static caller.
        // Any method on a known builtin class is engine API — return [].
        if (isEngineBuiltin(receiver))
            return [];
        // ── $NodePath fallback: search .tscn files for a matching node ────
        // `$HealthBar.update()` → receiver = "HealthBar"
        // `$Fighter/HealthBar.update()` → receiver = "Fighter/HealthBar"
        // If normal type resolution failed, try looking up the receiver as a
        // node name in .tscn file symbols to find its type.
        // Prefer scene files in the same directory as the caller (same Godot
        // project) to avoid resolving to a node of the same name in a different scene.
        const nodeName = receiver.includes("/") ? (receiver.split("/").pop() ?? receiver) : receiver;
        for (const [, fileIdx] of getSceneEntries()) {
            const nodeSyms = fileIdx.get(nodeName);
            if (!nodeSyms || nodeSyms.length === 0)
                continue;
            const nodeSym = nodeSyms[0];
            const nodeType = nodeSym.typeName;
            if (!nodeType)
                continue;
            // If the type is a script marker (script:res://path), resolve to the
            // script's class_name and then to the project file.
            if (nodeType.startsWith("script:")) {
                const scriptPath = nodeType.slice(7); // Strip "script:"
                const relativePath = scriptPath.startsWith("res://") ? scriptPath.slice(6) : scriptPath;
                // Normalize via resToRepoPathMap for nested Godot projects
                const repoRel = repoPathForResource(relativePath);
                const className = scriptPathToClassName?.get(repoRel) ?? scriptPathToClassName?.get(relativePath);
                if (className) {
                    const scriptFile = classNameIndex.get(className);
                    if (scriptFile) {
                        const scriptIdx = symbolIndexByFile.get(scriptFile);
                        if (scriptIdx) {
                            const methodMatches = scriptIdx.get(methodName);
                            if (methodMatches && methodMatches.length > 0) {
                                const callable = methodMatches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
                                if (callable.length > 0)
                                    return callable.map((s) => s.id);
                            }
                        }
                    }
                }
                // Script path not found in project — fall through to unresolved
                continue;
            }
            // If the type is a known Godot builtin, mark as engine API
            if (isEngineBuiltin(nodeType))
                return [];
            // If the type has a class_name in the project, resolve the method on it
            const typeFile = classNameIndex.get(nodeType);
            if (typeFile) {
                const typeIdx = symbolIndexByFile.get(typeFile);
                if (typeIdx) {
                    const typeMatches = typeIdx.get(methodName);
                    if (typeMatches && typeMatches.length > 0) {
                        const methodMatches = typeMatches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
                        if (methodMatches.length > 0)
                            return methodMatches.map((s) => s.id);
                    }
                }
            }
            else {
                // Type not in classNameIndex and not in GODOT_BUILTIN_CLASSES —
                // it's likely an unenumerated Godot builtin node type (there are
                // hundreds). Mark as engine API rather than leaving unresolved.
                return [];
            }
        }
        return null; // Can't determine receiver type
    }
    // If the type is a Godot builtin, return empty array (engine API, not unresolved)
    if (isEngineBuiltin(typeName))
        return [];
    // Find the target file for this type
    let targetFile = null;
    // Check autoload table first (autoload names map to specific files)
    if (autoloadTable?.has(typeName)) {
        targetFile = autoloadTable.get(typeName) ?? null;
    }
    // Check class_name index
    if (!targetFile && classNameIndex.has(typeName)) {
        targetFile = classNameIndex.get(typeName) ?? null;
    }
    if (!targetFile) {
        // Type is known (e.g. from a typed var `var timer: Timer`) but not in
        // classNameIndex (not a project class) and not in GODOT_BUILTIN_CLASSES
        // (not in our whitelist). It's likely an unenumerated Godot engine class
        // — there are hundreds of node types. Mark as engine API rather than
        // leaving unresolved, matching the .tscn fallback logic above.
        return [];
    }
    // `.new()` is a Godot engine builtin available on every class — it calls
    // `_init`. Don't try to resolve it to a project symbol; mark as engine API.
    if (methodName === "new")
        return [];
    // Look up the method name in the target file's symbols
    const targetIdx = symbolIndexByFile.get(targetFile);
    if (!targetIdx)
        return null;
    const matches = targetIdx.get(methodName);
    if (!matches || matches.length === 0)
        return null;
    // Filter to callable symbols only (not variables).
    const methodMatches = matches.filter((s) => s.kind === "method" || s.kind === "function" || s.kind === "constructor");
    if (methodMatches.length === 0)
        return null;
    return methodMatches.map((s) => s.id);
}
/** Compute the percentage of unresolved edges (0..100). */
export function computeUnresolvedPct(outgoingCallsByFile) {
    let total = 0;
    let unresolved = 0;
    for (const edges of outgoingCallsByFile.values()) {
        for (const e of edges) {
            // Engine-owned calls are deliberately outside the project-symbol
            // resolution metric: they have no project callee by design.
            if (e.confidence === "engine")
                continue;
            total++;
            if (e.confidence === "unresolved")
                unresolved++;
        }
    }
    return total === 0 ? 0 : (unresolved / total) * 100;
}
//# sourceMappingURL=graph-symbol-resolution.js.map