UNPKG

socraticode

Version:

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

4,914 lines • 233 kB
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
 * Per-language symbol & call-site extraction (mirrors `graph-imports.ts`).
 *
 * Populated in Phase B with ast-grep patterns for each language.
 */
import { Lang, parse } from "@ast-grep/napi";
import { CS_GLOBAL_ANCHOR, getLanguageFromExtension } from "../constants.js";
import { analyzeElixirTemplate, isElixirTemplateExtension } from "./elixir-templates.js";
import { phpFoldCase } from "./graph-php-case.js";
import { logger } from "./logger.js";
import { gdscriptParserAvailable } from "./parser-availability.js";
/** Build a stable SymbolNode.id. */
function makeId(file, qualifiedName, line) {
    return `${file}::${qualifiedName}#${line}`;
}
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function extractBindingIdentifiers(node) {
    if (!node)
        return [];
    const kind = node.kind?.();
    if (kind === "identifier" || kind === "shorthand_property_identifier_pattern") {
        return [{ name: node.text(), node }];
    }
    if (kind === "pair_pattern" || kind === "pair") {
        const value = node.field?.("value") ?? node.children?.()[2];
        return extractBindingIdentifiers(value);
    }
    if (kind === "assignment_pattern" || kind === "object_assignment_pattern") {
        const left = node.field?.("left") ?? node.children?.()[0];
        return extractBindingIdentifiers(left);
    }
    if (kind === "required_parameter" || kind === "optional_parameter") {
        const pat = node.field?.("pattern") ?? node.children?.()[0];
        return extractBindingIdentifiers(pat);
    }
    if (kind === "rest_pattern") {
        const kids = node.children?.() ?? [];
        for (const k of kids) {
            if (k.kind?.() === "identifier") {
                return [{ name: k.text(), node: k }];
            }
        }
        return [];
    }
    if (kind === "object_pattern" || kind === "array_pattern") {
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const result = [];
        const kids = node.children?.() ?? [];
        for (const k of kids) {
            const kKind = k.kind?.();
            if (kKind !== "{" && kKind !== "}" && kKind !== "[" && kKind !== "]" && kKind !== ",") {
                result.push(...extractBindingIdentifiers(k));
            }
        }
        return result;
    }
    return [];
}
/** Convert raw call sites to unresolved SymbolEdge objects (resolution in Phase C). */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function safeFindAll(node, kind) {
    try {
        return node.findAll({ rule: { kind } });
    }
    catch {
        return [];
    }
}
/**
 * Several kinds in one traversal. `findAll` walks the whole tree per call, so
 * asking for n kinds separately reads the file n times.
 *
 * The failure mode differs from n calls to {@link safeFindAll}, deliberately:
 * ast-grep throws on a kind the grammar does not define, so one kind absent
 * from the grammar loses the whole set here, where separate calls would lose
 * only that kind. Losing the set is the louder failure, and the caller's tests
 * name every kind it asks for — a grammar that dropped one would turn a test
 * red instead of quietly returning fewer symbols.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function safeFindAllAny(node, kinds) {
    try {
        return node.findAll({ rule: { any: kinds.map((kind) => ({ kind })) } });
    }
    catch {
        return [];
    }
}
/**
 * Single-node counterpart of {@link safeFindAll}. ast-grep REJECTS a kind the
 * grammar does not define — it throws rather than returning null — so a direct
 * `node.find({rule:{kind}})` written with a `?? find(otherKind)` fallback never
 * reaches its fallback on the grammars that need it. The concrete casualty:
 * `.js`/`.jsx`/`.mjs`/`.cjs` all parse with the JavaScript grammar, which has
 * no `type_identifier`, so one `class` in a plain-JS file aborted the whole
 * extraction and the file contributed a bare module symbol and zero calls.
 * Returning null keeps every existing fallback chain meaningful.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function safeFind(node, kind) {
    if (!node)
        return null;
    try {
        return node.find({ rule: { kind } }) ?? null;
    }
    catch {
        return null;
    }
}
/**
 * Per-language dedupe set for symbol-extraction failures. Without this, a
 * missing PHP grammar would emit one warn per file (potentially hundreds).
 * We log the first failure per language at warn level (with the underlying
 * error attached) and silently skip subsequent failures.
 */
const symbolExtractionWarned = new Set();
/**
 * Warn-once flag for Dart files the bundled grammar cannot fully parse. The
 * `@ast-grep/lang-dart` grammar predates Dart 3, so files using Dart 3 class
 * modifiers (sealed/base/interface/final/mixin class) or extension types
 * produce ERROR nodes and lose symbols. We surface this once per process at
 * warn level (per-file detail goes to debug) so the failure is not silent,
 * without spamming one warn per affected file on large Flutter projects.
 */
let dartParseErrorWarned = false;
/**
 * Reset the per-language dedupe set. Intended for tests that want to assert
 * deterministically on extraction warnings.
 */
export function resetSymbolExtractionWarnings() {
    symbolExtractionWarned.clear();
    dartParseErrorWarned = false;
}
/** Find the deepest scope frame covering a line. */
function findCallerId(scopes, line, fallback) {
    let best = null;
    for (const s of scopes) {
        if (line >= s.startLine && line <= s.endLine) {
            if (!best || s.startLine >= best.startLine)
                best = s;
        }
    }
    return best ? best.symbolId : fallback;
}
/**
 * Public entry point: extract symbols and raw call sites from a source file.
 * Returns empty arrays if the language is unsupported or parsing fails.
 */
export function extractSymbolsAndCalls(source, lang, ext, relativePath) {
    const language = getLanguageFromExtension(ext);
    const langKey = String(lang);
    // Per-file synthetic "module" scope so unattributed calls have a caller.
    const moduleSymbol = {
        id: makeId(relativePath, "<module>", 1),
        name: "<module>",
        qualifiedName: "<module>",
        kind: "module",
        file: relativePath,
        line: 1,
        endLine: source.split("\n").length,
        language,
    };
    try {
        if (isElixirTemplateExtension(ext)) {
            return extractFromElixirTemplate(source, ext, relativePath, moduleSymbol);
        }
        if (langKey === Lang.JavaScript ||
            langKey === Lang.TypeScript ||
            langKey === Lang.Tsx) {
            return extractFromTsLike(source, lang, relativePath, language, moduleSymbol);
        }
        if (langKey === "python") {
            return extractFromPython(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "go") {
            return extractFromGo(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "rust") {
            return extractFromRust(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "java" || langKey === "kotlin" || langKey === "scala") {
            return extractFromJvm(source, lang, relativePath, language, moduleSymbol);
        }
        if (langKey === "csharp") {
            return extractFromCSharp(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "c" || langKey === "cpp") {
            return extractFromCFamily(source, lang, relativePath, language, moduleSymbol);
        }
        if (langKey === "ruby") {
            return extractFromRuby(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "php") {
            return extractFromPhp(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "swift") {
            return extractFromSwift(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "bash") {
            return extractFromBash(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "lua") {
            return extractFromLua(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "dart") {
            return extractFromDart(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "elixir") {
            return extractFromElixir(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "gdscript") {
            return extractFromGdscript(source, relativePath, language, moduleSymbol);
        }
        if (langKey === "godot-resource") {
            return extractFromGodotResource(source, relativePath, language, moduleSymbol);
        }
        // Svelte, Vue and others fall through to the regex fallback.
        return extractFromRegex(source, relativePath, language, moduleSymbol);
    }
    catch (err) {
        if (!symbolExtractionWarned.has(langKey)) {
            symbolExtractionWarned.add(langKey);
            logger.warn("Symbol extraction failed for language; subsequent failures will be suppressed for this language", {
                lang: langKey,
                file: relativePath,
                error: err instanceof Error ? err.message : String(err),
            });
        }
        return { symbols: [moduleSymbol], rawCalls: [] };
    }
}
// ── Elixir ───────────────────────────────────────────────────────────────
function extractFromElixir(source, file, language, moduleSym) {
    const root = parse("elixir", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const childrenOf = (node) => {
        try {
            return node.children();
        }
        catch {
            return [];
        }
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const targetOf = (node) => {
        try {
            return node.field("target") ?? null;
        }
        catch {
            return null;
        }
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const targetName = (node) => {
        const target = targetOf(node);
        return target?.kind() === "identifier" ? target.text() : null;
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const calleeName = (node) => {
        const target = targetOf(node);
        if (target?.kind() === "identifier")
            return target.text();
        if (target?.kind() !== "dot")
            return null;
        const children = childrenOf(target);
        if (children[0]?.kind() !== "alias")
            return null;
        return [...children].reverse().find((child) => child.kind() === "identifier")?.text() ?? null;
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const nodeKey = (node) => {
        const range = node.range();
        return `${range.start.line}:${range.start.column}:${range.end.line}:${range.end.column}`;
    };
    const addSymbol = (name, qualifiedName, kind, startLine, endLine) => {
        const symbol = {
            id: makeId(file, qualifiedName, startLine),
            name, qualifiedName, kind, file, line: startLine, endLine, language,
        };
        symbols.push(symbol);
        scopes.push({ name: qualifiedName, startLine, endLine, symbolId: symbol.id });
    };
    const calls = safeFindAll(root, "call");
    const modules = [];
    for (const node of calls) {
        if (targetName(node) !== "defmodule")
            continue;
        const args = childrenOf(node).find((child) => child.kind() === "arguments");
        const rawName = args ? safeFindAll(args, "alias")[0]?.text() : null;
        if (!rawName)
            continue;
        const range = node.range();
        const startLine = range.start.line + 1;
        const endLine = range.end.line + 1;
        const owner = modules
            .filter((module) => startLine >= module.startLine && endLine <= module.endLine)
            .sort((a, b) => b.startLine - a.startLine)[0];
        const name = owner && !rawName.includes(".") ? `${owner.name}.${rawName}` : rawName;
        addSymbol(name, name, "module", startLine, endLine);
        modules.push({ name, startLine, endLine });
    }
    for (const node of calls) {
        const visibility = targetName(node);
        if (visibility !== "def" && visibility !== "defp")
            continue;
        const name = node.text().match(/^(?:def|defp)\s+([a-z_]\w*[!?]?)/)?.[1];
        if (!name)
            continue;
        const range = node.range();
        const startLine = range.start.line + 1;
        const endLine = range.end.line + 1;
        const owner = modules
            .filter((module) => startLine >= module.startLine && endLine <= module.endLine)
            .sort((a, b) => b.startLine - a.startLine)[0];
        addSymbol(name, owner ? `${owner.name}.${name}` : name, "function", startLine, endLine);
    }
    const definitionMacros = new Set([
        "def", "defp", "defmodule", "defstruct", "defguard", "defguardp", "defmacro", "defmacrop",
        "defdelegate", "defprotocol", "defimpl",
    ]);
    const definitionsWithHeads = new Set([
        "def", "defp", "defguard", "defguardp", "defmacro", "defmacrop", "defdelegate",
    ]);
    const definitionHeads = new Set();
    for (const node of calls) {
        if (!definitionsWithHeads.has(targetName(node) ?? ""))
            continue;
        const args = childrenOf(node).find((child) => child.kind() === "arguments");
        const firstArgument = args ? childrenOf(args)[0] : null;
        const head = firstArgument?.kind() === "binary_operator" ? firstArgument.field("left") : firstArgument;
        if (head?.kind() === "call")
            definitionHeads.add(nodeKey(head));
    }
    const ignoredCalls = new Set([
        ...definitionMacros,
        "alias", "import", "require", "use",
        "if", "unless", "for", "with", "case", "cond", "receive", "try", "quote", "unquote",
    ]);
    const rawCalls = [];
    for (const node of calls) {
        const name = calleeName(node);
        if (!name ||
            ignoredCalls.has(name) ||
            definitionHeads.has(nodeKey(node)) ||
            node.parent()?.kind() === "unary_operator")
            continue;
        const line = node.range().start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, line, moduleSym.id),
            calleeName: name,
            kind: "call",
            callSite: { file, line },
        });
    }
    return { symbols, rawCalls };
}
function extractFromElixirTemplate(source, ext, file, moduleSym) {
    const analysis = analyzeElixirTemplate(source, ext);
    if (!analysis) {
        logger.debug("Invalid HEEx/EEx template AST; skipping symbols and calls", { file });
        return { symbols: [moduleSym], rawCalls: [] };
    }
    const rawCalls = analysis.elixirSource
        ? extractFromElixir(analysis.elixirSource, file, moduleSym.language, moduleSym).rawCalls
            .map((call) => ({ ...call, callerId: moduleSym.id }))
        : [];
    return { symbols: [moduleSym], rawCalls };
}
// ── Lua (namespace tables: function T.f(), local function f(), T.f = function()) ──
/**
 * Lua has no node-kind-specific extractor upstream and previously fell through
 * to the regex fallback, which records `Mod` for `function Mod.parse()`.
 * This walks the ast-grep Lua tree so namespace-table style (`Table.method`,
 * the common Lua module/OOP idiom) resolves to precise qualified symbols plus
 * their call sites.
 */
function extractFromLua(source, file, language, moduleSym) {
    const root = parse("lua", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    const NAME = new Set(["dot_index_expression", "method_index_expression", "identifier"]);
    const KW = new Set([
        "if", "for", "while", "return", "function", "local", "then", "do", "end",
        "and", "or", "not", "elseif", "else", "in", "repeat", "until", "nil", "true", "false",
    ]);
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const kidsOf = (n) => {
        try {
            return n.children();
        }
        catch {
            return [];
        }
    };
    const shortName = (qn) => {
        const parts = qn.split(/[.:]/);
        return parts[parts.length - 1];
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const addSym = (nameNode, rangeNode) => {
        const qn = nameNode.text().replace(/\s+/g, "");
        if (!/^[A-Za-z_][\w]*([.:][A-Za-z_][\w]*)*$/.test(qn))
            return;
        const range = rangeNode.range();
        const startLine = range.start.line + 1;
        const endLine = range.end.line + 1;
        const sym = {
            id: makeId(file, qn, startLine),
            name: shortName(qn),
            qualifiedName: qn,
            kind: /[.:]/.test(qn) ? "method" : "function",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
    };
    // `function T.f()`, `function T:m()`, `function f()`, `local function f()` —
    // the name is the DIRECT child before `parameters`, not a body expression.
    for (const fn of safeFindAll(root, "function_declaration")) {
        const kids = kidsOf(fn);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const pIdx = kids.findIndex((c) => c.kind() === "parameters");
        const limit = pIdx < 0 ? kids.length : pIdx;
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        let nameNode = null;
        for (let i = 0; i < limit; i++) {
            if (NAME.has(kids[i].kind())) {
                nameNode = kids[i];
                break;
            }
        }
        if (nameNode)
            addSym(nameNode, fn);
    }
    // `T.f = function() … end` / `local f = function() … end` — the RHS must be
    // DIRECTLY a function_definition (don't match nested anonymous functions).
    for (const assign of safeFindAll(root, "assignment_statement")) {
        const kids = kidsOf(assign);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const rhs = kids.find((c) => c.kind() === "expression_list");
        if (!rhs)
            continue;
        const rhs0 = kidsOf(rhs)[0];
        if (rhs0?.kind() !== "function_definition")
            continue;
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const vl = kids.find((c) => c.kind() === "variable_list");
        const nameNode = vl ? kidsOf(vl)[0] : null;
        if (nameNode && NAME.has(nameNode.kind()))
            addSym(nameNode, assign);
    }
    // Calls — attribute each to its enclosing function scope (or <module>).
    const rawCalls = [];
    for (const call of safeFindAll(root, "function_call")) {
        const fnExpr = kidsOf(call)[0];
        if (!fnExpr)
            continue;
        const ids = safeFindAll(fnExpr, "identifier");
        const callee = ids.length > 0
            ? ids[ids.length - 1].text()
            : fnExpr.kind() === "identifier"
                ? fnExpr.text()
                : null;
        if (!callee || KW.has(callee))
            continue;
        const line = call.range().start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, line, moduleSym.id),
            calleeName: callee,
            kind: "call",
            callSite: { file, line },
        });
    }
    return { symbols, rawCalls };
}
// ── Dart (type-first signatures, sibling signature/body pairs, selector calls) ──
/**
 * Dart previously fell through to the regex fallback, which cannot match
 * type-first signatures (`void foo()`, `Future<int> baz() async`), so
 * classes, methods, and call sites were invisible to the symbol graph.
 * This walks the ast-grep Dart tree instead. Grammar quirks handled here:
 * class/mixin/enum/extension nodes span their bodies, but a function is a
 * `function_signature` followed by a SIBLING `function_body`, so scope
 * ranges are stitched from each pair; plain constructors live inside a
 * generic `declaration` wrapper; and there is no call_expression kind, so
 * calls are recovered from `argument_part` nodes (callee = the preceding
 * identifier or selector chain, or the `cascade_selector` for `..` calls).
 */
function extractFromDart(source, file, language, moduleSym) {
    const root = parse("dart", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    // Surface (not silently swallow) files the grammar cannot fully parse.
    // ERROR nodes for Dart almost always mean Dart 3 syntax the bundled grammar
    // predates; the affected declarations lose their symbols. Per-file detail at
    // debug, a single warn per process so big Flutter repos are not spammed.
    const parseErrors = safeFindAll(root, "ERROR").length;
    if (parseErrors > 0) {
        logger.debug("Dart file has parse errors; some symbols skipped (likely Dart 3 syntax unsupported by the grammar)", {
            file,
            parseErrors,
        });
        if (!dartParseErrorWarned) {
            dartParseErrorWarned = true;
            logger.warn("Some Dart files use syntax the bundled grammar (@ast-grep/lang-dart) cannot parse — likely Dart 3 class modifiers (sealed/base/interface/final/mixin class) or extension types. Symbols in those regions are skipped until the upstream grammar is updated.");
        }
    }
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const kidsOf = (n) => {
        try {
            return n.children();
        }
        catch {
            return [];
        }
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const childOfKind = (n, kind) => 
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    kidsOf(n).find((c) => c.kind() === kind) ?? null;
    // Direct identifier children only — the name slot. Type annotations are
    // `type_identifier`/`void_type` and parameter names are nested deeper, so
    // they never appear here.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const idChildren = (n) => 
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    kidsOf(n).filter((c) => c.kind() === "identifier");
    // Operators are not named by an identifier: the name is the token after the
    // `operator` keyword (e.g. `+`, `==`, `[]`). Build "operator<tok>" so the
    // symbol is `Owner.operator+` etc. Returns null when the shape is unexpected.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const operatorName = (sig) => {
        const kids = kidsOf(sig);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const opIdx = kids.findIndex((c) => c.kind() === "operator");
        if (opIdx < 0 || opIdx + 1 >= kids.length)
            return null;
        const tok = kids[opIdx + 1].text().replace(/\s+/g, "");
        return tok ? `operator${tok}` : null;
    };
    const addSym = (name, qualifiedName, kind, startLine, endLine) => {
        const sym = {
            id: makeId(file, qualifiedName, startLine),
            name,
            qualifiedName,
            kind,
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: qualifiedName, startLine, endLine, symbolId: sym.id });
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const lineOf = (n) => n.range().start.line + 1;
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const endLineOf = (n) => n.range().end.line + 1;
    /**
     * Emit the member symbols of a class-like body. Members come in ordered
     * sibling pairs: a `method_signature` (wrapping function / getter / setter /
     * operator / factory signatures) or a `declaration` (a plain constructor, an
     * abstract bodyless member, or a field), optionally followed by its
     * `function_body`. Bodyless abstract members and operators live under
     * `declaration`; fields are skipped.
     */
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const walkMembers = (bodyNode, owner) => {
        const members = kidsOf(bodyNode);
        for (let i = 0; i < members.length; i++) {
            const member = members[i];
            const memberKind = member.kind();
            const next = members[i + 1];
            const scopeEnd = next && next.kind() === "function_body" ? endLineOf(next) : endLineOf(member);
            if (memberKind === "method_signature") {
                const inner = kidsOf(member)[0];
                if (!inner)
                    continue;
                const innerKind = inner.kind();
                if (innerKind === "factory_constructor_signature") {
                    const ids = idChildren(inner);
                    if (ids.length === 0)
                        continue;
                    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                    const qn = ids.map((c) => c.text()).join(".");
                    addSym(ids[ids.length - 1].text(), qn, "constructor", lineOf(member), scopeEnd);
                }
                else if (innerKind === "operator_signature") {
                    // `T operator +(T o) { ... }` — operators are not named by an identifier.
                    const name = operatorName(inner);
                    if (!name)
                        continue;
                    addSym(name, `${owner}.${name}`, "method", lineOf(member), scopeEnd);
                }
                else if (innerKind === "function_signature" ||
                    innerKind === "getter_signature" ||
                    innerKind === "setter_signature") {
                    const ids = idChildren(inner);
                    if (ids.length === 0)
                        continue;
                    const name = ids[ids.length - 1].text();
                    addSym(name, `${owner}.${name}`, "method", lineOf(member), scopeEnd);
                }
            }
            else if (memberKind === "declaration") {
                // A `declaration` member is one of:
                //   - a plain/named constructor:     `constructor_signature`
                //   - an abstract (bodyless) member:  `function_signature` /
                //     `getter_signature` / `setter_signature` / `operator_signature`
                //     (e.g. `void foo();`, `int get x;`, `set y(int v);`, `T operator +(T o);`)
                //   - a field (type + initializer, no signature child): skipped
                const ctor = childOfKind(member, "constructor_signature");
                if (ctor) {
                    const ids = idChildren(ctor);
                    if (ids.length === 0)
                        continue;
                    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                    const qn = ids.map((c) => c.text()).join(".");
                    addSym(ids[ids.length - 1].text(), qn, "constructor", lineOf(member), scopeEnd);
                    continue;
                }
                const sig = childOfKind(member, "function_signature") ??
                    childOfKind(member, "getter_signature") ??
                    childOfKind(member, "setter_signature") ??
                    childOfKind(member, "operator_signature");
                if (!sig)
                    continue; // field or unrecognized shape — skip, as before
                const name = sig.kind() === "operator_signature"
                    ? operatorName(sig)
                    : (idChildren(sig).at(-1)?.text() ?? null);
                if (!name)
                    continue;
                addSym(name, `${owner}.${name}`, "method", lineOf(member), scopeEnd);
            }
        }
    };
    // ── Top-level declarations (ordered walk so signature/body pairs line up) ──
    // Dart 3 class modifiers (`sealed` / `base` / `interface` / `final` /
    // `mixin class`) and `extension type` are NOT handled: the vendored grammar
    // (@ast-grep/lang-dart 0.0.7, latest published) predates them and parses
    // them to ERROR nodes (no `sealed_class_declaration` /
    // `extension_type_declaration` kinds exist). The affected declaration is
    // dropped, and depending on parser recovery it can also drop following
    // sibling classes; the rest of the file still extracts. The ERROR count is
    // surfaced via the warn above. Revisit when the upstream grammar updates.
    const topLevel = kidsOf(root);
    for (let i = 0; i < topLevel.length; i++) {
        const node = topLevel[i];
        const nodeKind = node.kind();
        if (nodeKind === "class_definition" || nodeKind === "mixin_declaration" || nodeKind === "extension_declaration") {
            const nameNode = childOfKind(node, "identifier");
            if (!nameNode)
                continue;
            const name = nameNode.text();
            const kind = nodeKind === "mixin_declaration" ? "trait" : "class";
            addSym(name, name, kind, lineOf(node), endLineOf(node));
            const body = childOfKind(node, "class_body") ?? childOfKind(node, "extension_body");
            if (body)
                walkMembers(body, name);
        }
        else if (nodeKind === "enum_declaration") {
            const nameNode = childOfKind(node, "identifier");
            if (nameNode)
                addSym(nameNode.text(), nameNode.text(), "enum", lineOf(node), endLineOf(node));
        }
        else if (nodeKind === "type_alias") {
            const nameNode = childOfKind(node, "type_identifier");
            if (nameNode)
                addSym(nameNode.text(), nameNode.text(), "interface", lineOf(node), endLineOf(node));
        }
        else if (nodeKind === "function_signature" || nodeKind === "getter_signature" || nodeKind === "setter_signature") {
            const ids = idChildren(node);
            if (ids.length === 0)
                continue;
            const name = ids[ids.length - 1].text();
            const next = topLevel[i + 1];
            const scopeEnd = next && next.kind() === "function_body" ? endLineOf(next) : endLineOf(node);
            addSym(name, name, "function", lineOf(node), scopeEnd);
        }
    }
    // ── Calls — every invocation wraps an `argument_part` node ──────────────
    const rawCalls = [];
    for (const ap of safeFindAll(root, "argument_part")) {
        const holder = ap.parent();
        if (!holder)
            continue;
        const holderKind = holder.kind();
        let callee = null;
        if (holderKind === "cascade_section") {
            // `obj..method(args)` — the callee lives in the cascade_selector.
            const cs = childOfKind(holder, "cascade_selector");
            const id = cs ? childOfKind(cs, "identifier") : null;
            callee = id ? id.text() : null;
        }
        else if (holderKind === "selector") {
            // `name(args)` / `expr.name(args)` — the callee is the previous
            // sibling: a bare identifier, or a selector whose trailing identifier
            // is the method name (`f.bar(…)`, `mat.runApp(…)`, `Foo.create(…)`).
            const parent = holder.parent();
            if (!parent)
                continue;
            const siblings = kidsOf(parent);
            const hr = holder.range();
            const idx = siblings.findIndex(
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            (c) => {
                if (c.kind() !== "selector")
                    return false;
                const r = c.range();
                return (r.start.line === hr.start.line &&
                    r.start.column === hr.start.column &&
                    r.end.line === hr.end.line &&
                    r.end.column === hr.end.column);
            });
            if (idx <= 0)
                continue;
            const prev = siblings[idx - 1];
            if (prev.kind() === "identifier") {
                callee = prev.text();
            }
            else if (prev.kind() === "selector") {
                const ids = safeFindAll(prev, "identifier");
                callee = ids.length > 0 ? ids[ids.length - 1].text() : null;
            }
        }
        if (!callee)
            continue;
        const line = ap.range().start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, line, moduleSym.id),
            calleeName: callee,
            kind: "call",
            callSite: { file, line },
        });
    }
    return { symbols, rawCalls };
}
// ── JS / TS / TSX ────────────────────────────────────────────────────────
function extractFromTsLike(source, lang, file, language, moduleSym) {
    const root = parse(lang, source).root();
    const symbols = [moduleSym];
    const scopes = [];
    const moduleScopeSymbolIds = new Set();
    const declaredBindingsCache = new Map();
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    function isModuleScopeDeclaration(node) {
        let parent = node.parent?.();
        if (parent?.kind?.() === "export_statement") {
            parent = parent.parent?.();
        }
        return parent?.kind?.() === "program";
    }
    const functionScopeBoundaries = new Set([
        "function_declaration",
        "generator_function_declaration",
        "function_expression",
        "generator_function",
        "arrow_function",
        "method_definition",
        "class_declaration",
        "class_expression",
    ]);
    // `var` is function-scoped, including through nested blocks, but a nested
    // function or class starts a different scope. A recursive findAll() crosses
    // those boundaries and can incorrectly shadow an import in the outer function.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    function collectFunctionScopedVars(functionNode, bound) {
        const body = functionNode.field?.("body");
        if (!body)
            return;
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        function visit(node, isRoot = false) {
            const nodeKind = node.kind?.();
            if (!isRoot && functionScopeBoundaries.has(nodeKind))
                return;
            if (nodeKind === "variable_declaration") {
                for (const decl of node.children?.() ?? []) {
                    if (decl.kind?.() !== "variable_declarator")
                        continue;
                    const nameNode = decl.field?.("name") ?? decl.children?.()[0];
                    if (!nameNode)
                        continue;
                    for (const binding of extractBindingIdentifiers(nameNode)) {
                        bound.add(binding.name);
                    }
                }
            }
            for (const child of node.children?.() ?? []) {
                visit(child);
            }
        }
        visit(body, true);
    }
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    function getDeclaredBindingsInNode(node) {
        if (!node)
            return new Set();
        const range = node.range?.();
        const cacheKey = range ? `${node.kind?.()}:${range.start.index}:${range.end.index}` : "";
        if (cacheKey) {
            const cached = declaredBindingsCache.get(cacheKey);
            if (cached)
                return cached;
        }
        const bound = new Set();
        const kind = node.kind?.();
        // 1. Function parameters & function hoisted vars
        if (kind === "function_declaration" ||
            kind === "function_expression" ||
            kind === "arrow_function" ||
            kind === "method_definition") {
            const params = node.field?.("parameters") ?? safeFind(node, "formal_parameters");
            if (params) {
                for (const child of params.children?.() ?? []) {
                    for (const b of extractBindingIdentifiers(child)) {
                        bound.add(b.name);
                    }
                }
            }
            collectFunctionScopedVars(node, bound);
        }
        // 2. Catch clause parameter
        if (kind === "catch_clause") {
            const param = node.field?.("parameter") ?? safeFind(node, "identifier");
            if (param) {
                for (const b of extractBindingIdentifiers(param)) {
                    bound.add(b.name);
                }
            }
        }
        // 3. For loops: for (const x of items), for (let i = 0; ...), for (const k in obj)
        if (kind === "for_statement" || kind === "for_in_statement" || kind === "for_of_statement") {
            const init = node.field?.("initializer") ?? node.field?.("left");
            if (init) {
                for (const decl of safeFindAll(init, "variable_declarator")) {
                    const nameNode = decl.field?.("name") ?? decl.children?.()[0];
                    if (nameNode) {
                        for (const b of extractBindingIdentifiers(nameNode)) {
                            bound.add(b.name);
                        }
                    }
                }
            }
        }
        // 4. Block / function body / switch case declarations
        if (kind === "statement_block" || kind === "switch_case" || kind === "switch_block") {
            for (const child of node.children?.() ?? []) {
                const cKind = child.kind?.();
                if (cKind === "lexical_declaration" || cKind === "variable_declaration") {
                    for (const decl of child.children?.() ?? []) {
                        if (decl.kind?.() === "variable_declarator") {
                            const nameNode = decl.field?.("name") ?? decl.children?.()[0];
                            if (nameNode) {
                                for (const b of extractBindingIdentifiers(nameNode)) {
                                    bound.add(b.name);
                                }
                            }
                        }
                    }
                }
                else if (cKind === "function_declaration" || cKind === "class_declaration") {
                    const nameNode = child.field?.("name") ?? safeFind(child, "identifier");
                    if (nameNode) {
                        bound.add(nameNode.text());
                    }
                }
            }
        }
        if (cacheKey) {
            declaredBindingsCache.set(cacheKey, bound);
        }
        return bound;
    }
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    function isLexicallyBound(idNode, name) {
        let curr = idNode.parent?.();
        while (curr && curr.kind?.() !== "program") {
            const bound = getDeclaredBindingsInNode(curr);
            if (bound.has(name)) {
                return true;
            }
            curr = curr.parent?.();
        }
        return false;
    }
    // Class declarations
    for (const node of safeFindAll(root, "class_declaration")) {
        const nameNode = node.field("name")
            ?? safeFind(node, "type_identifier")
            ?? safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const range = node.range();
        const startLine = range.start.line + 1;
        const endLine = range.end.line + 1;
        const parentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isDefaultExport = /^\s*export\s+default\b/.test(node.text()) || /^\s*export\s+default\b/.test(parentText);
        const isExported = isDefaultExport || /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(parentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "class",
            exportedAs: isDefaultExport ? "default" : undefined,
            isExported,
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
        const body = node.field("body");
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        let members = [];
        try {
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            members = body ? body.children().filter((c) => c.kind() === "method_definition") : [];
        }
        catch {
            members = [];
        }
        for (const m of members) {
            const mNameNode = m.field("name");
            const mName = mNameNode?.kind() === "property_identifier" ? mNameNode.text() : null;
            if (!mName)
                continue;
            const mr = m.range();
            const mStart = mr.start.line + 1;
            const mEnd = mr.end.line + 1;
            const qname = `${name}.${mName}`;
            const msym = {
                id: makeId(file, qname, mStart),
                name: mName, qualifiedName: qname,
                kind: mName === "constructor" ? "constructor" : "method",
                file, line: mStart, endLine: mEnd, language,
            };
            symbols.push(msym);
            scopes.push({ name: qname, startLine: mStart, endLine: mEnd, symbolId: msym.id });
        }
    }
    // Top-level function declarations
    for (const node of safeFindAll(root, "function_declaration")) {
        const nameNode = safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = node.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const fnParentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isDefaultExport = /^\s*export\s+default\b/.test(node.text()) || /^\s*export\s+default\b/.test(fnParentText);
        const isExported = isDefaultExport || /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(fnParentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function",
            exportedAs: isDefaultExport ? "default" : undefined,
            isExported,
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Generator function declarations
    for (const node of safeFindAll(root, "generator_function_declaration")) {
        const nameNode = safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = node.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const genParentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isDefaultExport = /^\s*export\s+default\b/.test(node.text()) || /^\s*export\s+default\b/.test(genParentText);
        const isExported = isDefaultExport || /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(genParentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function",
            exportedAs: isDefaultExport ? "default" : undefined,
            isExported,
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Interface declarations (TS / TSX)
    for (const node of safeFindAll(root, "interface_declaration")) {
        const nameNode = node.field("name")
            ?? safeFind(node, "type_identifier")
            ?? safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = node.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const ifaceParentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isExported = /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(ifaceParentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "interface", isExported, file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Type alias declarations (TS / TSX)
    for (const node of safeFindAll(root, "type_alias_declaration")) {
        const nameNode = node.field("name")
            ?? safeFind(node, "type_identifier")
            ?? safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = node.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const typeParentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isExported = /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(typeParentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "type", isExported, file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Enum declarations (TS / TSX)
    for (const node of safeFindAll(root, "enum_declaration")) {
        const nameNode = node.field("name")
            ?? safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = node.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const enumParentText = node.parent?.()?.kind?.() === "export_statement" ? node.parent().text() : "";
        const isExported = /^\s*export\b/.test(node.text()) || /^\s*export\b/.test(enumParentText);
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "enum", isExported, file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        if (isModuleScopeDeclaration(node))
            moduleScopeSymbolIds.add(sym.id);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Lexical and variable declarations: const, let, var (top-level only, direct declarators)
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const topLevelDeclNodes = [];
    try {
        for (const child of root.children()) {
            if (child.kind() === "export_statement") {
                const decl = child.field("declaration");
                if (decl) {
                    if (decl.kind() === "lexical_declaration" || decl.kind() === "variable_declaration") {
                        topLevelDeclNodes.push({ node: decl, isExported: true });
                    }
                }
                else {
                    for (const sub of child.children()) {
                        if (sub.kind() === "lexical_declaration" || sub.kind() === "variable_declaration") {
                            topLevelDeclNodes.push({ node: sub, isExported: true });
                        }
                    }
                }
            }
            else if (child.kind() === "lexical_declaration" || child.kind() === "variable_declaration") {
                topLevelDeclNodes.push({ node: child, isExported: false });
            }
        }
    }
    catch {
        // fallback if root.children() is unavailable
    }
    for (const { node, isExported } of topLevelDeclNodes) {
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const declarators = (node.children?.() ?? []).filter((c) => c.kind() === "variable_declarator");
        for (const decl of declarators) {
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            const nameNode = decl.field("name") ?? (decl.children?.().find((c) => c.kind() === "identifier" || c.kind() === "object_pattern" || c.kind() === "array_pattern"));
            if (!nameNode)
                continue;
            const bindings = extractBindingIdentifiers(nameNode);
            const value = decl.field?.("value");
            const valueKind = value?.kind?.();
            const fn = valueKind === "arrow_function" || valueKind === "function_expression"
                ? value
                : null;
            if (fn && bindings.length === 1) {
                const name = bindings[0].name;
                const r = (fn ?? decl).range();
                const startLine = r.start.line + 1;
                const endLine = r.end.line + 1;
                const sym = {
                    id: makeId(file, name, startLine),
                    name, qualifiedName: name, kind: "function", isExported, file, line: startLine, endLine, language,
                };
                symbols.push(sym);
                moduleScopeSymbolIds.add(sym.id);
                scopes.push({ name, startLine, endLine, symbolId: sym.id });
            }
            else {
                for (const b of bindings) {
                    const name = b.name;
                    const r = b.node.range();
                    const startLine = r.start.line + 1;
                    const endLine = r.end.line + 1;
                    const sym = {
                        id: makeId(file, name, startLine),
                        name, qualifiedName: name, kind: "variable", isExported, file, line: startLine, endLine, language,
                    };
                    symbols.push(sym);
                    moduleScopeSymbolIds.add(sym.id);
                }
            }
        }
    }
    // Track declaration names and lines to avoid self-referencing declaration identifiers
    const declaredNamesAndLines = new Set();
    for (const s of symbols) {
        if (s.name !== "<module>") {
            declaredNamesAndLines.add(`${s.name}#${s.line}`);
        }
    }
    const rawCalls = [];
    const localToExportedName = new Map();
    const localToImportInfo = new Map();
    const namespaceNames = new Set();
    const namespaceToModule = new Map();
    // 1. Import statements: named imports (`import { X, Y as Z }`), type imports (`import type { T }`), default imports (`import D from ...`), namespace imports (`import * as NS from ...`)
    for (const imp of safeFindAll(root, "import_statement")) {
        const r = imp.range();
        const line = r.start.line + 1;
        const sourceNode = imp.field("source") ?? safeFind(imp, "string");
        const sourceModule = sourceNode ? sourceNode.text().replace(/^['"`]|['"`]$/g, "") : undefined;
        for (const spec of safeFindAll(imp, "import_specifier")) {
            const nameNode = spec.field("name") ?? safeFind(spec, "identifier");
            if (!nameNode)
                continue;
            const originalName = nameNode.text();
            const aliasNode = spec.field("alias");
            const localName = aliasNode ? aliasNode.text() : originalName;
            localToExportedName.set(localName, originalName);
            if (sourceModule) {
                localToImportInfo.set(localName, { sourceModule, importedName: originalName });
            }
            rawCalls.push({
                callerId: moduleSym.id,
                calleeName: originalName,
                kind: "import",
                sourceModule,
                importedName: originalName,
                localAlias: localName !== originalName ? localName : undefined,
                callSite: { file, line },
            });
        }
        // Namespace import: `import * as NS from "..."`
        const nsNode = safeFind(imp, "namespace_import");
        if (nsNode) {
            const idNode = safeFind(nsNode, "identifier");
            if (idNode) {
                const nsName = idNode.text();
                namespaceNames.add(nsName);
                if (sourceModule) {
                    namespaceToModule.set(nsName, sourceModule);
                }
                rawCalls.push({
                    callerId: moduleSym.id,
                    calleeName: nsName,
                    kind: "import",
                    sourceModule,
                    importedName: "*",
                    localAlias: nsName,
                    callSite: { file, line },
                });
            }
        }
        const clause = safeFind(imp, "import_clause");
        if (clause) {
            // Direct identifier in import_clause is default import
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            const kids = clause.children?.() ?? [];
            for (const k of kids) {
                if (k.kind() === "identifier") {
                    const defaultName = k.text();
                    localToExportedName.set(defaultName, defaultName);
                    if (sourceModule) {
                        localToImportInfo.set(defaultName, { sourceModule, importedName: "default" });
                    }
                    rawCalls.push({
                        callerId: moduleSym.id,
                        calleeName: defaultName,
                        kind: "import",
                        sourceModule,
                        importedName: "default",
                        localAlias: defaultName,
                        callSite: { file, line },
                    });
                }
            }
        }
    }
    // 2. Re-export statements (`export { X, Y as Z } from '...'` and `export { X }`)
    for (const exp of safeFindAll(root, "export_statement")) {
        const r = exp.range();
        const line = r.start.line + 1;
        const sourceNode = exp.field("source") ?? safeFind(exp, "string");
        const sourceModule = sourceNode ? sourceNode.text().replace(/^['"`]|['"`]$/g, "") : undefined;
        const expText = exp.text();
        const starReexport = expText.match(/export\s+\*(?:\s+as\s+([\w$]+))?\s+from/);
        if (sourceModule && starReexport) {
            const isNamespace = Boolean(starReexport[1]);
            rawCalls.push({
                callerId: moduleSym.id,
                calleeName: starReexport[1] ?? "*",
                kind: "reexport",
                sourceModule,
                importedName: isNamespace ? undefined : "*",
                localAlias: starReexport[1],
                callSite: { file, line },
            });
        }
        if (!sourceModule) {
            const defMatch = expText.match(/^\s*export\s+default\s+([a-zA-Z_$][\w$]*)/);
            if (defMatch) {
                const defName = defMatch[1];
                for (const s of symbols) {
                    if (s.name === defName && moduleScopeSymbolIds.has(s.id)) {
                        s.isExported = true;
                        s.exportedAs = "default";
                    }
                }
            }
        }
        for (const spec of safeFindAll(exp, "export_specifier")) {
            const nameNode = spec.field("name") ?? safeFind(spec, "identifier");
            if (!nameNode)
                continue;
            const expName = nameNode.text();
            const aliasNode = spec.field("alias");
            const localName = aliasNode ? aliasNode.text() : expName;
            localToExportedName.set(localName, expName);
            if (!sourceModule) {
                for (const s of symbols) {
                    if (s.name === expName && moduleScopeSymbolIds.has(s.id)) {
                        s.isExported = true;
                        if (aliasNode) {
                            s.exportedAs = aliasNode.text();
                        }
                    }
                }
            }
            rawCalls.push({
                callerId: moduleSym.id,
                calleeName: expName,
                kind: "reexport",
                sourceModule,
                importedName: expName,
                localAlias: localName !== expName ? localName : undefined,
                callSite: { file, line },
            });
        }
    }
    // 3. Call sites and instantiations
    for (const k of ["call_expression", "new_expression"]) {
        for (const node of safeFindAll(root, k)) {
            const info = extractCalleeInfoJs(node.text());
            if (!info)
                continue;
            let calleeName = info.calleeName;
            const impInfo = info.isBare ? localToImportInfo.get(calleeName) : undefined;
            const mapped = info.isBare ? localToExportedName.get(calleeName) : undefined;
            const localAlias = mapped && mapped !== calleeName ? calleeName : undefined;
            if (mapped) {
                calleeName = mapped;
            }
            const r = node.range();
            const callLine = r.start.line + 1;
            const callerId = findCallerId(scopes, callLine, moduleSym.id);
            rawCalls.push({
                callerId,
                calleeName,
                kind: "call",
                sourceModule: impInfo?.sourceModule,
                importedName: impInfo?.importedName,
                localAlias,
                callSite: { file, line: callLine },
            });
        }
    }
    // 4. Type references (type annotations, type arguments, implements, extends)
    const TS_BUILTIN_TYPES = new Set([
        "string", "number", "boolean", "any", "unknown", "never", "void", "null", "undefined",
        "symbol", "bigint", "object", "Function", "true", "false",
        "Array", "Record", "Promise", "Partial", "Required", "Readonly", "Pick", "Omit",
        "Exclude", "Extract", "NonNullable", "ReturnType", "InstanceType", "Parameters",
        "ConstructorParameters", "Awaited", "Map", "Set", "WeakMap", "WeakSet", "Error",
        "RegExp", "Date", "Uint8Array", "Int8Array", "Uint16Array", "Int16Array",
        "Uint32Array", "Int32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array",
        "ArrayBuffer", "DataView", "Iterable", "AsyncIterable", "Iterator", "AsyncIterator",
        "Generator", "AsyncGenerator", "TemplateStringsArray", "PropertyKey",
        "T", "K", "V", "U", "R", "P",
    ]);
    for (const node of safeFindAll(root, "type_identifier")) {
        let name = node.text();
        if (TS_BUILTIN_TYPES.has(name))
            continue;
        const r = node.range();
        const line = r.start.line + 1;
        if (declaredNamesAndLines.has(`${name}#${line}`))
            continue;
        const impInfo = localToImportInfo.get(name);
        const mapped = localToExportedName.get(name);
        const localAlias = mapped && mapped !== name ? name : undefined;
        if (mapped) {
            name = mapped;
        }
        const callerId = findCallerId(scopes, line, moduleSym.id);
        rawCalls.push({
            callerId,
            calleeName: name,
            kind: "type_reference",
            sourceModule: impInfo?.sourceModule,
            importedName: impInfo?.importedName,
            localAlias,
            callSite: { file, line },
        });
    }
    // 5. Value references and namespace member accesses in expressions / statements
    if (namespaceNames.size > 0) {
        for (const node of safeFindAll(root, "member_expression")) {
            const objNode = node.field("object");
            const propNode = node.field("property");
            if (objNode && propNode && namespaceNames.has(objNode.text())) {
                const objText = objNode.text();
                const propName = propNode.text();
                const sourceModule = namespaceToModule.get(objText);
                const r = node.range();
                const line = r.start.line + 1;
                const callerId = findCallerId(scopes, line, moduleSym.id);
                rawCalls.push({
                    callerId,
                    calleeName: propName,
                    kind: "value_reference",
                    sourceModule,
                    importedName: propName,
                    callSite: { file, line },
                });
            }
        }
    }
    if (localToExportedName.size > 0) {
        for (const idNode of safeFindAll(root, "identifier")) {
            const localName = idNode.text();
            const originalName = localToExportedName.get(localName);
            if (!originalName)
                continue;
            const r = idNode.range();
            const line = r.start.line + 1;
            if (declaredNamesAndLines.has(`${localName}#${line}`))
                continue;
            const parent = idNode.parent();
            const parentKind = parent?.kind?.();
            // Skip import_specifier / import_clause / export_specifier definition sites themselves
            if (parentKind === "import_specifier" || parentKind === "import_clause" || parentKind === "export_specifier") {
                continue;
            }
            // Skip non-computed member property access (e.g. obj.foo)
            if (parentKind === "member_expression") {
                const prop = parent?.field?.("property");
                if (prop && prop.range().start.index === r.start.index) {
                    continue;
                }
            }
            // Skip object literal keys (e.g. { foo: 1 })
            if (parentKind === "pair") {
                const key = parent?.field?.("key");
                if (key && key.range().start.index === r.start.index) {
                    continue;
                }
            }
            // Skip property or method definitions
            if (parentKind === "property_signature" || parentKind === "method_definition" || parentKind === "field_definition") {
                continue;
            }
            // Skip direct invocation sites already recorded as calls
            if (parentKind === "call_expression") {
                const fn = parent?.field?.("function");
                if (fn && fn.range().start.index === r.start.index) {
                    continue;
                }
            }
            if (parentKind === "new_expression") {
                const ctor = parent?.field?.("constructor");
                if (ctor && ctor.range().start.index === r.start.index) {
                    continue;
                }
            }
            const callerId = findCallerId(scopes, line, moduleSym.id);
            // Skip if shadowed by local parameter, variable, destructuring, catch binding, or block scope
            if (isLexicallyBound(idNode, localName)) {
                continue;
            }
            const impInfo = localToImportInfo.get(localName);
            rawCalls.push({
                callerId,
                calleeName: originalName,
                kind: "value_reference",
                sourceModule: impInfo?.sourceModule,
                importedName: impInfo?.importedName,
                localAlias: localName !== originalName ? localName : undefined,
                callSite: { file, line },
            });
        }
    }
    // Deduplicate rawCalls sharing (callerId, calleeName, kind, sourceModule, importedName, localAlias) to keep graph compact
    const dedupedCalls = [];
    const seenCalls = new Set();
    for (const call of rawCalls) {
        const key = `${call.callerId}::${call.calleeName}::${call.kind}::${call.sourceModule ?? ""}::${call.importedName ?? ""}::${call.localAlias ?? ""}`;
        if (!seenCalls.has(key)) {
            seenCalls.add(key);
            dedupedCalls.push(call);
        }
    }
    return { symbols, rawCalls: dedupedCalls };
}
/** Pull the callee name and whether it was an unqualified bare identifier (not a member call). */
function extractCalleeInfoJs(text) {
    const cleaned = text.replace(/^\s*new\s+/, "");
    // `foo(...)` → "foo"  ;  `obj.foo(...)` → "foo"  ;  `obj.bar.foo(...)` → "foo"
    const m = cleaned.match(/^([\w$.]+)\s*(?:\(|$)/);
    if (!m)
        return null;
    const chain = m[1];
    const isBare = !chain.includes(".");
    const parts = chain.split(".");
    const last = parts[parts.length - 1];
    return /^[A-Za-z_$][\w$]*$/.test(last) ? { calleeName: last, isBare } : null;
}
/** Pull the callee's bare name from the start of a call/new expression's text. */
function extractCalleeNameJs(text) {
    return extractCalleeInfoJs(text)?.calleeName ?? null;
}
/** The characters PHP admits first in an identifier, as a character-class body. */
const PHP_IDENTIFIER_START = "A-Za-z_\\u0080-\\uffff";
/** The characters PHP admits after the first: the same set plus the digits. */
const PHP_IDENTIFIER_PART = `${PHP_IDENTIFIER_START}0-9`;
/**
 * PHP's identifier rule, as it reaches this code once the source is decoded.
 *
 * The language states the rule over bytes —
 * `[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*` — and every byte of a UTF-8
 * multibyte sequence is at least 0x80, so after decoding it reduces to "ASCII
 * letters, digits and `_`, plus any non-ASCII character". `Café`, `Документ`
 * and `文書` are all legal PHP names, and the grammar parses each as a `name`;
 * a character class transliterated straight from the spec's `\x80-\xff` would
 * still reject the last two. The same argument holds one level down, over
 * UTF-16: every code unit of a non-ASCII character is at least 0x80, so
 * `\u0080-\uffff`, matched code unit by code unit, admits every non-ASCII
 * character — one outside the BMP by its two surrogate halves.
 *
 * That is why these patterns carry no `u` flag, and it is load-bearing: with
 * it the range would end at U+FFFF, and `$o->𠮷Id()` would be cut down to its
 * ASCII tail `Id`. The grammar's own `name` token does stop at the BMP, and it
 * cuts there on both sides of a call — a limit of the parse, which the rule
 * here cannot see past. A type name holding such a character usually arrives
 * already cut short at it; `extractFromPhp` recognises the cut from the source
 * around the node and emits nothing, rather than an edge to the shorter name.
 * The exception is a `\`-rooted spelling, where the `qualified_name` wrapper
 * spans the fragment and the node's text is the whole written name, so
 * `extends \𠮷Doc` is read as `𠮷Doc` and simply answers nothing.
 * A declaration's name carries no such guard, so `class 𠮷Doc {}` is still
 * filed under `Doc` and `function 𠮷Id()` under `Id`. A callee name is read
 * from the call node's own text, which the cut does not reach, so above the BMP
 * the two no longer meet: `$o->𠮷Id()` is named `𠮷Id` and answers no
 * declaration. The ASCII-only pattern truncated that call to `Id` and so agreed
 * with the truncated declaration by coincidence — the same truncation that
 * answered `$o->crée()` with an unrelated `e` — so the whole name is preferred
 * to it. Inside the BMP, where every accented, Cyrillic and CJK name lives,
 * both sides read the name whole and it resolves.
 *
 * Both PHP name guards in this file are built from this one source, so they
 * cannot drift apart: {@link phpClassRef} tests a whole name against
 * {@link PHP_IDENTIFIER}, and {@link extractCalleeNamePhp} takes the name a
 * receiver ends with through {@link PHP_IDENTIFIER_TAIL}. `graph-resolution.ts`
 * states the same rule again for the file-import graph, which reads PHP
 * namespace and class declarations out of source text rather than a parse.
 */
const PHP_IDENTIFIER_SOURCE = `[${PHP_IDENTIFIER_START}][${PHP_IDENTIFIER_PART}]*`;
/** A string that is exactly one PHP identifier. */
const PHP_IDENTIFIER = new RegExp(`^${PHP_IDENTIFIER_SOURCE}$`);
/** One character PHP admits inside an identifier, surrogate halves included. */
const PHP_IDENTIFIER_CHAR = new RegExp(`^[${PHP_IDENTIFIER_PART}]$`);
/**
 * One character that carries a PHP type reference on past a node's right edge:
 * an identifier character, which would make one longer name of the two runs, or
 * the `\` that adds another segment to a qualified one.
 */
const PHP_TYPE_REF_CHAR = new RegExp(`^[${PHP_IDENTIFIER_PART}\\\\]$`);
/**
 * The PHP identifier a string ends with, as group 1.
 *
 * The lookbehind pins every attempt to the start of a run of identifier
 * characters. Without it the engine retries from each character of a run and
 * rescans to the end of it every time, which is quadratic in the run's length
 * — and once non-ASCII is admitted, a CJK paragraph in an earlier argument of a
 * fluent chain is a single run, its punctuation included. Leading digits are
 * skipped rather than rejected, as the ASCII-only pattern skipped them.
 */
const PHP_IDENTIFIER_TAIL = new RegExp(`(?<![${PHP_IDENTIFIER_PART}])[0-9]*(${PHP_IDENTIFIER_SOURCE})$`);
/**
 * Callee name for a PHP call expression.
 *
 * PHP separates a callee from its receiver with `::` (static) or `->`
 * (instance), neither of which appears in the JS chain pattern `[\w$.]+`.
 * Running PHP calls through `extractCalleeNameJs` therefore returned null for
 * every method and static call — the match stopped dead at the `:` of
 * `Cls::method(` or the `-` of `$obj->method(` — so only bare
 * `function_call_expression` nodes survived, which in practice means stdlib
 * calls. Every cross-file PHP call edge was dropped, leaving `codebase_impact`
 * and `codebase_symbol` reporting no callers for code with many.
 *
 * The callee is the identifier before *this* call's own argument list, which is
 * the last top-level parenthesis group in the node's text. Taking the first `(`
 * instead names the wrong method on a fluent chain: ast-grep reports one node
 * per link, and each node's text starts at the head of the chain, so
 * `Model::where('x')->orderBy('y')->get()` would yield `where` three times
 * rather than `where`, `orderBy`, `get`.
 *
 * Quoted sections are skipped so a parenthesis inside a string literal —
 * `where('a)b')` — cannot unbalance the scan.
 *
 * The name is read by PHP's own identifier rule, {@link PHP_IDENTIFIER_TAIL}.
 * The match is anchored at the end of the receiver, so an ASCII-only pattern
 * did worse than miss a non-ASCII name: `$o->crée()` matched the ASCII tail
 * alone and recorded a call to `e`, which then resolved like any real name.
 *
 *   foo(…)                                 → "foo"
 *   Cls::make(…)                           → "make"
 *   $this->svc->blacklist(…)               → "blacklist"
 *   Acme\Support\Cls::of(…)                → "of"
 *   Model::where(…)->orderBy(…)->get()     → "get"   (outermost node)
 *   $o->crée(…)                            → "crée"
 */
function extractCalleeNamePhp(text) {
    let depth = 0;
    let quote = null;
    let lastTopLevelOpen = -1;
    for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        if (quote !== null) {
            if (ch === "\\")
                i++; // escaped char — consume the pair
            else if (ch === quote)
                quote = null;
            continue;
        }
        if (ch === "'" || ch === '"')
            quote = ch;
        else if (ch === "(") {
            if (depth === 0)
                lastTopLevelOpen = i;
            depth++;
        }
        else if (ch === ")") {
            depth--;
        }
    }
    if (lastTopLevelOpen <= 0)
        return null;
    const receiver = text.slice(0, lastTopLevelOpen).trimEnd();
    const m = receiver.match(PHP_IDENTIFIER_TAIL);
    return m ? m[1] : null;
}
// ── Python ───────────────────────────────────────────────────────────────
function extractFromPython(source, file, language, moduleSym) {
    const root = parse("python", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    // Classes
    for (const cls of safeFindAll(root, "class_definition")) {
        const nameNode = safeFind(cls, "identifier");
        if (!nameNode)
            continue;
        const className = nameNode.text();
        const r = cls.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const csym = {
            id: makeId(file, className, startLine),
            name: className, qualifiedName: className, kind: "class", file, line: startLine, endLine, language,
        };
        symbols.push(csym);
        scopes.push({ name: className, startLine, endLine, symbolId: csym.id });
        // Methods
        for (const fn of safeFindAll(cls, "function_definition")) {
            const fnName = safeFind(fn, "identifier")?.text();
            if (!fnName)
                continue;
            const fr = fn.range();
            const fStart = fr.start.line + 1;
            const fEnd = fr.end.line + 1;
            const qname = `${className}.${fnName}`;
            const fsym = {
                id: makeId(file, qname, fStart),
                name: fnName, qualifiedName: qname,
                kind: fnName === "__init__" ? "constructor" : "method",
                file, line: fStart, endLine: fEnd, language,
            };
            symbols.push(fsym);
            scopes.push({ name: qname, startLine: fStart, endLine: fEnd, symbolId: fsym.id });
        }
    }
    // Top-level functions (those not nested inside classes)
    for (const fn of safeFindAll(root, "function_definition")) {
        const fnName = safeFind(fn, "identifier")?.text();
        if (!fnName)
            continue;
        const r = fn.range();
        const startLine = r.start.line + 1;
        // Skip if already captured as a method (start line matches an existing scope's nested method)
        if (symbols.some((s) => s.file === file && s.line === startLine && s.name === fnName))
            continue;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, fnName, startLine),
            name: fnName, qualifiedName: fnName, kind: "function", file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name: fnName, startLine, endLine, symbolId: sym.id });
    }
    // Calls
    const rawCalls = [];
    for (const node of safeFindAll(root, "call")) {
        const calleeName = extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── Go ───────────────────────────────────────────────────────────────────
function extractFromGo(source, file, language, moduleSym) {
    const root = parse("go", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    for (const fn of safeFindAll(root, "function_declaration")) {
        const nameNode = safeFind(fn, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    for (const fn of safeFindAll(root, "method_declaration")) {
        const nameNode = safeFind(fn, "field_identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "method", file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "call_expression")) {
        const calleeName = extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── Rust ─────────────────────────────────────────────────────────────────
/**
 * Every name a file's `use` declarations put in its own scope, paired with the
 * path each names. Walked over the tree rather than over the text, because the
 * alias is what is wanted and the text-level helpers in `graph-imports.ts`
 * delete it: `rustUseLeafPath` strips ` as X` before returning the path.
 *
 * A nested list carries its prefix down (`use a::{b, c as d}` binds `b` to
 * `a::b` and `d` to `a::c`), and `self` in a list binds the prefix's own last
 * segment (`use a::{self}` binds `a`). A wildcard binds no name and is skipped:
 * what it brings into scope cannot be known from this file alone, and guessing
 * would widen resolution rather than narrow it.
 */
/**
 * A path with its turbofish removed: `Vec::<Option<u8>>` → `Vec`. Scanned for
 * the matching `>` rather than matched with `::<[^>]*>`, which stops at the
 * first `>` and leaves a stray one behind on a nested generic.
 */
function stripTurbofish(path) {
    let out = "";
    for (let i = 0; i < path.length; i++) {
        if (path[i] === ":" && path.slice(i, i + 3) === "::<") {
            let depth = 0;
            let j = i + 2;
            for (; j < path.length; j++) {
                if (path[j] === "<")
                    depth++;
                else if (path[j] === ">" && --depth === 0)
                    break;
            }
            i = j;
            continue;
        }
        out += path[i];
    }
    return out;
}
/**
 * The callee of a Rust call, split into the terminal name and the path that
 * qualifies it. Read off the `function` field rather than scanned out of the
 * node's text, which is what `extractCalleeNameJs` did and why every qualified
 * call was dropped: its chain pattern `[\w$.]+` stops dead at the `:` of `::`.
 *
 * A chain needs no special case here. ast-grep reports one `call_expression`
 * per link, and each link's own `function` field names only that link, so
 * `Path::new(p).components().all(f)` yields `all`, `components` and `new`
 * rather than nothing.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function extractCalleeInfoRust(fn) {
    if (!fn)
        return null;
    switch (fn.kind()) {
        case "identifier":
            return { name: fn.text() };
        // `obj.method()` — the receiver is an expression, not a path, so there is
        // nothing to narrow with. The name alone is what this call knows.
        case "field_expression": {
            const field = fn.field("field");
            return field ? { name: field.text() } : null;
        }
        case "scoped_identifier": {
            const name = fn.field("name");
            if (!name)
                return null;
            const path = fn.field("path");
            const qualifier = path ? stripTurbofish(path.text()).trim() : "";
            return qualifier ? { name: name.text(), qualifier } : { name: name.text() };
        }
        // `foo::<T>()` — the turbofish sits on the function itself.
        case "generic_function":
            return extractCalleeInfoRust(fn.field("function"));
        default:
            return null;
    }
}
/**
 * How many inline `mod`s a node is written inside. Zero means the file's own
 * module, which is the one resolution can name.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function rustInlineModDepth(node) {
    let depth = 0;
    for (let p = node.parent(); p; p = p.parent()) {
        if (p.kind() === "mod_item")
            depth += 1;
    }
    return depth;
}
/**
 * The full path of inline `mod`s around a node, outermost first, or `null`
 * when there is none.
 *
 * The whole path matters to re-exports: `pub use imp::*;` carries direct
 * exports from `imp`, but it does not flatten a private `imp::hidden` module
 * into the file's top level.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function rustInlineModPath(node) {
    const modules = [];
    for (let p = node.parent(); p; p = p.parent()) {
        if (p.kind() !== "mod_item")
            continue;
        const nameNode = p.field("name");
        if (nameNode)
            modules.unshift(nameNode.text());
    }
    return modules.length > 0 ? modules.join("::") : null;
}
/**
 * A qualifier rewritten as the file itself would have written it.
 *
 * `super` counts modules, not files, and an inline `mod` is a module: inside
 * `#[cfg(test)] mod tests { … }` written in `b.rs`, `super::helper()` means
 * `b.rs`'s own `helper`, and `super::super::sub::f()` means the `sub` of
 * `b.rs`'s parent. Resolution knows only files, so it would read both as
 * relative to the file and answer with the parent's `helper` and the
 * grandparent's `sub` — not a wider answer, a different one.
 *
 * The hops an inline `mod` already accounts for are consumed here, where the
 * nesting is still visible. A path consumed to nothing is `self`, which is
 * what it then means. A path with segments left over is written bare, the way
 * the file itself would write it: bare is the file's own namespace, which is
 * both its child modules and the names its `use` declarations bring in, and
 * `self::` would be read as the modules alone.
 *
 * `rootedInInlineMod` says the rewrite could not be made: the path never
 * climbed out of the inline modules, and what it is rooted in has no file.
 * The qualifier then comes back as it was written, for a reader, and
 * resolution refuses it rather than answering out of a scope that is not the
 * one the path names.
 *
 * A `self::` path written inside an inline `mod` is rooted the same way and is
 * refused the same way. `self` is the module the path is written in, so inside
 * `mod tests { … }` it is `tests` — checked against rustc, where
 * `self::helper()` beside a `mod tests`-level `helper` calls that one and not
 * the file's. `tests` has no file, so this is the same refusal, not a new one.
 * Lowercase only: `Self::` is the implementing type, not a module, and
 * `Self::poll()` inside a `#[cfg(test)] mod tests` is a call this still
 * answers.
 */
function rustQualifierFromFile(
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
node, qualifier) {
    const asWritten = { qualifier, rootedInInlineMod: false };
    const segments = qualifier.split("::");
    if (segments[0] !== "super" && segments[0] !== "self")
        return asWritten;
    const inlineMods = rustInlineModDepth(node);
    if (inlineMods === 0)
        return asWritten;
    if (segments[0] === "self")
        return { qualifier, rootedInInlineMod: true };
    let consumed = 0;
    while (consumed < inlineMods && segments[consumed] === "super")
        consumed += 1;
    // Fewer `super` than inline modules: the path never climbed out of them, so
    // it is rooted in a module that lives inside this file and has no file of
    // its own to name. `mod a { mod b { super::c::f() } }` means the `c` beside
    // `b`, and writing what is left bare hands it to a `mod c;` on the file —
    // another file entirely, answered as `unique`.
    //
    // Answering with the file instead is no better: the file holds the sibling
    // inline modules too, so `super::helper()` would collect a `helper` that
    // Rust cannot see from there and hand it to whoever walks the candidates.
    // Nothing here can name that scope — a path may even be rooted at a file
    // module an inline module declares, since `mod a { pub mod c; }` in `x.rs`
    // is `x/a/c.rs`, which tokio writes 23 times — so the call goes unresolved
    // and keeps the qualifier as the source wrote it.
    if (consumed < inlineMods)
        return { qualifier, rootedInInlineMod: true };
    const rest = segments.slice(consumed);
    // What is left may still climb above the file, and then it is a
    // file-relative `super::` path, which is exactly how it now reads.
    return { qualifier: rest.length === 0 ? "self" : rest.join("::"), rootedInInlineMod: false };
}
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function collectRustUseBindings(decl, bindings) {
    const join = (prefix, rest) => (prefix ? `${prefix}::${rest}` : rest);
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const walk = (node, prefix) => {
        switch (node.kind()) {
            case "use_as_clause": {
                const pathNode = node.field("path");
                const aliasNode = node.field("alias");
                if (!pathNode || !aliasNode)
                    return;
                // `use crate::a::{self as A};` names `crate::a`, not `crate::a::self`:
                // inside a list, `self` is the module the list hangs off. Recorded
                // literally, `A::run()` matched no module and went unresolved. cargo
                // 1.98 compiles that fixture with `A::run()` returning `a`'s own.
                const target = pathNode.text();
                bindings.push({
                    local: aliasNode.text(),
                    path: target === "self" ? (prefix || "self") : join(prefix, target),
                });
                return;
            }
            case "scoped_use_list": {
                const listNode = node.field("list");
                if (!listNode)
                    return;
                const pathNode = node.field("path");
                const inner = pathNode ? join(prefix, pathNode.text()) : prefix;
                // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                for (const child of listNode.children())
                    walk(child, inner);
                return;
            }
            case "use_list": {
                // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                for (const child of node.children())
                    walk(child, prefix);
                return;
            }
            case "identifier":
            case "scoped_identifier": {
                const text = node.text();
                const local = text.split("::").pop() ?? text;
                bindings.push({ local, path: join(prefix, text) });
                return;
            }
            case "self": {
                const last = prefix.split("::").pop();
                if (last)
                    bindings.push({ local: last, path: prefix });
                return;
            }
            case "use_wildcard": {
                // `use imp::*;` binds names this cannot enumerate — the module it
                // reads from need not even be a file. It is recorded under `*` all the
                // same, because "some name arrives at this file's top level from that
                // module" is exactly what separates "the top level cannot reach that
                // symbol" from "it might, and nothing here can tell". tokio's
                // `runtime/scheduler/multi_thread/counters.rs` re-exports an inline
                // `mod` this way, and 34 calls depend on it.
                const text = node.text();
                const from = text.endsWith("::*") ? text.slice(0, -3) : "";
                bindings.push({ local: "*", path: join(prefix, from) });
                return;
            }
            default:
                // `use`, `;`, `{`, `}`, `,`, a visibility modifier.
                return;
        }
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    for (const child of decl.children())
        walk(child, "");
}
function extractFromRust(source, file, language, moduleSym) {
    const root = parse("rust", source).root();
    const scopes = [];
    // Collected with the byte offset each declaration starts at, because that is
    // the only key that orders two declarations sharing a line. See the sort at
    // the end of this function.
    const declared = [];
    const bindings = [];
    // The declarations that sit inside an inline `mod`. Collected here because
    // this is the only place the nesting is still visible: by the time
    // resolution has the symbols, an inline module has left no trace on them.
    // Ids, not nodes, because that is what a candidate list holds.
    const inlineModSymbolIds = [];
    for (const fn of safeFindAll(root, "function_item")) {
        const nameNode = safeFind(fn, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function", file, line: startLine, endLine, language,
        };
        declared.push({ sym, offset: r.start.index });
        const fnInlineMod = rustInlineModPath(fn);
        if (fnInlineMod)
            inlineModSymbolIds.push([sym.id, fnInlineMod]);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    // Everything a Rust file declares that is not a function. Only `function_item`
    // was read, so every type a crate exposes was absent from the symbol graph:
    // `codebase_symbol` could not find a struct, an enum or a trait by name, and
    // resolution — which matches a callee name against the symbols of the files
    // the file graph says are imported — had nothing to match a type against.
    //
    // The name comes from the `name` field, which the grammar gives each of
    // these: a `type_identifier` for the type-like ones, an `identifier` for the
    // value-like ones. Searching for a bare `identifier` instead would take the
    // wrong child — a struct's first `identifier` is its first field.
    //
    // `impl_item` and `use_declaration` are not in the table, but for different
    // reasons, and only one of them means "not read". `safeFindAll` walks the
    // whole tree, so what an impl *contains* is read: its methods as
    // `function_item`, and an associated `type` or `const` through this table —
    // under a bare name, without the implementing type, the way a method's name
    // is already bare. Two impls of one trait therefore yield two symbols of the
    // same associated name: reported as a limit rather than fixed, because
    // qualifying a name is a change to how every language in this file names a
    // member.
    //
    // A declaration without a body is still a declaration. `function_signature_item`
    // covers both places Rust puts one — a method declared in a trait, and a `fn`
    // inside an `extern` block — and `associated_type` is the trait's own
    // `type Item;`, which is a different node from the `type_item` an impl writes.
    // Reading only the definitions meant a reader who found a trait could not find
    // anything the trait declares.
    //
    // A `use` is genuinely not an item: what it names is declared elsewhere, and
    // the file graph already carries that edge.
    //
    // `isExported` is left unset, as it already is for `fn` above. The resolver
    // admits a symbol unless that flag is explicitly `false`, so writing `pub`
    // into it would silently drop every private item from resolution — a change
    // of behaviour rather than an extraction fix.
    //
    // The kinds are the ones this file already gives these shapes elsewhere,
    // checked by running those extractors rather than by reading them: C++ and C#
    // yield `struct` for a struct, Scala and PHP yield `trait` for a trait.
    // (`extractFromSwift` reads as a fifth precedent and is not one — its
    // `struct` branch keys on `struct_declaration`, which the packaged grammar
    // never produces, so a Swift struct comes out `class`.) A `union` has no
    // precedent here; it is a record like a struct and is filed as one.
    const RUST_ITEM_KINDS = new Map([
        ["struct_item", "struct"],
        ["union_item", "struct"],
        ["enum_item", "enum"],
        ["trait_item", "trait"],
        ["type_item", "type"],
        ["associated_type", "type"],
        ["const_item", "variable"],
        ["static_item", "variable"],
        ["function_signature_item", "function"],
    ]);
    // One pass, not one per kind: `findAll` walks the whole tree each time it is
    // called, so one call per kind reads the file once per kind. Measured on
    // ripgrep's `crates/core/flags/defs.rs`, seven separate passes cost +51% over
    // reading no items at all, where one pass costs +18%.
    // Only the `use` declarations at the top of the file, which are the ones
    // whose names are in scope for the whole file. A `use` written inside a `fn`
    // or a `mod x { }` binds a name **there**, and treating it as the file's
    // would let it point a call in a different scope at a different type — a
    // wrong answer stated as `unique`, which is worse than no answer. Reading
    // `root.children()` costs nothing: it does not descend.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    for (const child of root.children()) {
        if (child.kind() === "use_declaration")
            collectRustUseBindings(child, bindings);
    }
    for (const item of safeFindAllAny(root, [...RUST_ITEM_KINDS.keys()])) {
        const symbolKind = RUST_ITEM_KINDS.get(item.kind());
        if (!symbolKind)
            continue;
        const nameNode = item.field("name");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = item.range();
        const startLine = r.start.line + 1;
        const id = makeId(file, name, startLine);
        declared.push({
            sym: {
                id,
                name,
                qualifiedName: name,
                kind: symbolKind,
                file,
                line: startLine,
                endLine: r.end.line + 1,
                language,
            },
            offset: r.start.index,
        });
        const itemInlineMod = rustInlineModPath(item);
        if (itemInlineMod)
            inlineModSymbolIds.push([id, itemInlineMod]);
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "call_expression")) {
        const callee = extractCalleeInfoRust(node.field("function"));
        if (!callee)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        const qualifier = callee.qualifier
            ? rustQualifierFromFile(node, callee.qualifier)
            : undefined;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName: callee.name,
            kind: "call",
            calleeQualifier: qualifier?.qualifier,
            qualifierRootedInInlineMod: qualifier?.rootedInInlineMod ? true : undefined,
            callSite: { file, line: callLine },
        });
    }
    for (const node of safeFindAll(root, "macro_invocation")) {
        const nameNode = safeFind(node, "identifier");
        if (!nameNode)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName: nameNode.text(),
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    // In declaration order, because a reader is shown a prefix of this list and
    // not all of it: `listSymbols` cuts at its limit in payload order. Collected
    // in two passes — functions, then everything else — the items would all sit
    // after the functions, and on ripgrep 14.1.1's `crates/core/flags/defs.rs`
    // (982 symbols, limit 200) not one of them appeared.
    //
    // The key is the byte offset the declaration starts at, not the line and not
    // the name. Rust puts no weight on line breaks, so `const A: u8 = 1; const
    // B: u8 = 2;` is two declarations on one line; ordering those by name would
    // put a later declaration first, and at the limit boundary that is a listing
    // that drops the earlier one. An offset is unique per declaration, so the
    // order is total and is the source's own.
    //
    // `<module>` is prepended rather than sorted: it is synthetic, it has no
    // offset of its own, and it belongs at the head.
    declared.sort((a, b) => a.offset - b.offset);
    const symbols = [moduleSym, ...declared.map((d) => d.sym)];
    return { symbols, rawCalls, bindings, inlineModSymbolIds };
}
// ── JVM (Java / Kotlin / Scala) ──────────────────────────────────────────
function extractFromJvm(source, langKey, file, language, moduleSym) {
    const root = parse(langKey, source).root();
    const symbols = [moduleSym];
    const scopes = [];
    const classKinds = langKey === "scala"
        ? ["class_definition", "object_definition", "trait_definition"]
        : ["class_declaration", "interface_declaration", "enum_declaration", "object_declaration"];
    for (const k of classKinds) {
        for (const cls of safeFindAll(root, k)) {
            const name = extractJvmTypeName(cls.text(), langKey);
            if (!name)
                continue;
            const r = cls.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            const kind = k.includes("interface") ? "interface"
                : k.includes("trait") ? "trait"
                    : k.includes("enum") ? "enum" : "class";
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name, kind, file, line: startLine, endLine, language,
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    const methodKinds = langKey === "scala"
        ? ["function_definition"]
        : langKey === "kotlin"
            ? ["function_declaration"]
            : ["method_declaration", "constructor_declaration"];
    for (const k of methodKinds) {
        for (const m of safeFindAll(root, k)) {
            const name = extractJvmCallableName(m.text());
            if (!name)
                continue;
            const r = m.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name,
                kind: k.includes("constructor") ? "constructor" : "method",
                file, line: startLine, endLine, language,
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    const callKinds = langKey === "java"
        ? ["method_invocation"]
        : ["call_expression"];
    const rawCalls = [];
    for (const k of callKinds) {
        for (const node of safeFindAll(root, k)) {
            const calleeName = extractCalleeNameJs(node.text());
            if (!calleeName)
                continue;
            const r = node.range();
            const callLine = r.start.line + 1;
            rawCalls.push({
                callerId: findCallerId(scopes, callLine, moduleSym.id),
                calleeName,
                kind: "call",
                callSite: { file, line: callLine },
            });
        }
    }
    return { symbols, rawCalls };
}
function stripJvmAnnotations(text) {
    return text
        .split("\n")
        .map((line) => line.replace(/^\s*(?:@(?:[\w$]+:)?[\w$.]+(?:\([^)]*\))?\s*)+/, ""))
        .join("\n");
}
function extractJvmTypeName(text, langKey) {
    const withoutAnnotations = stripJvmAnnotations(text);
    const header = withoutAnnotations.split("{", 1)[0] ?? withoutAnnotations;
    const pattern = langKey === "scala"
        ? /\b(?:class|object|trait)\s+([A-Za-z_$][\w$]*)\b/
        : /\b(?:class|interface|enum|object)\s+([A-Za-z_$][\w$]*)\b/;
    return header.match(pattern)?.[1] ?? null;
}
function extractJvmCallableName(text) {
    const withoutAnnotations = stripJvmAnnotations(text);
    const signature = withoutAnnotations
        .split("{", 1)[0]
        .split("=", 1)[0]
        .trim();
    const scalaDefMatches = Array.from(signature.matchAll(/\bdef\s+([A-Za-z_$][\w$]*)\b/g));
    if (scalaDefMatches.length > 0) {
        return scalaDefMatches[scalaDefMatches.length - 1][1];
    }
    const matches = Array.from(signature.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g));
    return matches.length > 0 ? matches[matches.length - 1][1] : null;
}
// ── C# ──────────────────────────────────────────────────────────────────
/** The declarations that name a type, with the symbol kind each is recorded as. */
const CS_TYPE_DECLARATIONS = new Map([
    ["class_declaration", "class"],
    ["interface_declaration", "interface"],
    ["record_declaration", "class"], // a `record struct` is recorded as `struct`
    ["struct_declaration", "struct"],
    ["enum_declaration", "enum"],
    ["delegate_declaration", "type"],
]);
/** The member declarations recorded as symbols, with the kind each is recorded as. */
const CS_MEMBER_DECLARATIONS = new Map([
    ["method_declaration", "method"],
    ["constructor_declaration", "constructor"],
    ["property_declaration", "variable"],
]);
/** The `name` field of a declaration, or `null` when the grammar gives none. */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csNameField(node) {
    try {
        return node.field("name") ?? null;
    }
    catch {
        return null;
    }
}
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csRange(node) {
    const r = node.range();
    return { start: r.start.index, end: r.end.index };
}
function csWithin(inner, outer) {
    return outer.start <= inner.start && inner.end <= outer.end;
}
/** The leading path segment for a `global::` anchor, kept wherever the path is recorded. */
const CS_GLOBAL = CS_GLOBAL_ANCHOR;
/**
 * A name path as an edge records it: `Ns.Calc`, or with its anchor segment
 * (`CS_GLOBAL`, or `App.Shop::` for an alias declared in `namespace App.Shop`)
 * joined to it: `global::Ns.Calc`, `App.Shop::Ns.Calc`.
 */
function csJoin(path) {
    return path[0]?.endsWith("::") ? path[0] + path.slice(1).join(".") : path.join(".");
}
/**
 * The dotted path a name-only type or expression spells, type arguments
 * dropped: `["Ns", "Calc"]` for `Ns.Calc` and `Ns.Calc<T>`, and
 * `[CS_GLOBAL, "Ns", "Calc"]` for `global::Ns.Calc`, whether written as a
 * type or as a member-access receiver. `null` when any part is not a name —
 * `this`, `base`, a call, an element access, or an extern alias other than
 * `global`, whose target no symbol records.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csNamePath(node) {
    if (!node)
        return null;
    switch (node.kind()) {
        case "identifier":
            return [node.text()];
        case "generic_name": {
            const id = safeFind(node, "identifier");
            return id ? [id.text()] : null;
        }
        case "qualified_name":
        case "member_access_expression": {
            const head = csNamePath(node.field?.(node.kind() === "qualified_name" ? "qualifier" : "expression") ?? null);
            const last = csNamePath(csNameField(node));
            return head && last ? [...head, ...last] : null;
        }
        case "alias_qualified_name": {
            if (node.field?.("alias")?.text() !== "global")
                return null;
            const rest = csNamePath(csNameField(node));
            return rest ? [CS_GLOBAL, ...rest] : null;
        }
        default:
            return null;
    }
}
/**
 * The qualified type names of one file, read top-down: which identifiers are
 * qualifier segments (`Ns` and `Inner` in `Ns.Inner.Foo`, never `Foo`), and
 * the path written before each terminal. Segments are told apart by byte
 * range, not text, so the terminal `Order` of `Order.Order` is kept, and a
 * type argument inside a qualifier (`Item` in `Outer<Item>.Inner`) stays a
 * type of its own.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csQualifiedNames(root) {
    // Start offsets of the identifiers that are qualifier segments.
    const segments = new Set();
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const markSegments = (qualifier) => {
        if (qualifier.kind() === "identifier") {
            segments.add(csRange(qualifier).start);
            return;
        }
        const typeArgs = safeFindAll(qualifier, "type_argument_list").map(csRange);
        for (const id of safeFindAll(qualifier, "identifier")) {
            const r = csRange(id);
            if (!typeArgs.some((a) => csWithin(r, a)))
                segments.add(r.start);
        }
    };
    // Terminal identifier's start → the path written before it; `null` when it
    // is not a plain path (an extern alias), so no reference is drawn rather
    // than a bare one that would bind whatever same-named type is nearest.
    const written = new Map();
    for (const qn of safeFindAll(root, "qualified_name")) {
        const qualifier = qn.field?.("qualifier") ?? null;
        if (qualifier)
            markSegments(qualifier);
        const head = csTypeHeadIdentifier(csNameField(qn));
        if (head)
            written.set(csRange(head).start, csNamePath(qualifier));
    }
    for (const aqn of safeFindAll(root, "alias_qualified_name")) {
        const alias = aqn.field?.("alias") ?? null;
        if (alias)
            markSegments(alias);
        const head = csTypeHeadIdentifier(csNameField(aqn));
        if (head)
            written.set(csRange(head).start, alias?.text() === "global" ? [CS_GLOBAL] : null);
    }
    return {
        isSegment: (id) => segments.has(csRange(id).start),
        // Only a missing entry means "written bare": `null` must reach the caller's skip.
        writtenQualifier: (id) => {
            const path = written.get(csRange(id).start);
            return path === undefined ? [] : path;
        },
    };
}
/** A C# type expression's own identifier: `Foo` in `Foo`, `Foo<T>`, `Ns.Foo`, `Foo?`, `Foo[]`. */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csTypeHeadIdentifier(node) {
    if (!node)
        return null;
    const kind = node.kind();
    if (kind === "identifier")
        return node;
    if (kind === "generic_name")
        return safeFind(node, "identifier");
    if (kind === "qualified_name")
        return csTypeHeadIdentifier(csNameField(node));
    if (kind === "nullable_type" || kind === "array_type" || kind === "pointer_type" || kind === "ref_type") {
        return csTypeHeadIdentifier(node.field?.("type") ?? node.children?.()?.[0] ?? null);
    }
    return null;
}
/**
 * The namespaces and types of one file with their byte ranges, outermost
 * first, so a declaration's enclosing scopes are those whose range contains
 * its own. Line numbers cannot tell two classes written on one line apart;
 * ranges can.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function csContainers(root) {
    const containers = [];
    for (const k of CS_TYPE_DECLARATIONS.keys()) {
        for (const d of safeFindAll(root, k)) {
            const n = csNameField(d);
            if (n)
                containers.push({ range: csRange(d), segments: [n.text()], isType: true });
        }
    }
    for (const ns of safeFindAll(root, "namespace_declaration")) {
        containers.push({ range: csRange(ns), segments: csNamePath(csNameField(ns)) ?? [], isType: false });
    }
    return containers.sort((a, b) => a.range.start - b.range.start || b.range.end - a.range.end);
}
/**
 * C# declarations are named by their `name` field, never by the first
 * identifier under them — that is an attribute (`[Fact]`, `[HttpGet]`,
 * `[ApiController]`) or a return type (`Task<…>`, `DateTime`) whenever one
 * precedes the name. Members carry `Owner.Name` as their qualified name, and
 * every symbol its full name with namespace and enclosing types as
 * `csFullName`, so a written `Billing.Calc.Compute(...)` can be matched to
 * the method it names at resolution. The owner is the type that contains the
 * declaration in the syntax tree.
 *
 * Edges keep the path the source writes. An invocation's receiver is its
 * `calleeQualifier` when it is a PascalCase name path (a type, possibly
 * namespace-qualified — never `this`, `base` or a field). An object creation
 * is a call whose qualifier is the created type's path. `type_reference`
 * covers base lists, parameter / field / property / local / return types,
 * type arguments, `typeof`, casts, attributes and member-access receivers,
 * each with the written qualifier of a qualified type. Framework types are
 * recorded too: only resolution can tell a project's own `Task` from .NET's.
 */
function extractFromCSharp(source, file, language, moduleSym) {
    const root = parse("csharp", source).root();
    const symbols = [moduleSym];
    // Every declaration with its byte range: an edge's caller is the innermost
    // one containing it, so two declarations sharing a line are told apart.
    const declared = [];
    const callerAt = (at) => {
        let best = null;
        for (const d of declared) {
            if (d.range.start <= at && at <= d.range.end && (!best || d.range.start >= best.range.start))
                best = d;
        }
        return best ? best.sym.id : moduleSym.id;
    };
    // A file-scoped `namespace A.B;` is a sibling of the declarations it covers, not their ancestor.
    const fileScoped = safeFind(root, "file_scoped_namespace_declaration");
    const fileNamespace = csNamePath(csNameField(fileScoped)) ?? [];
    const containers = csContainers(root);
    const qualified = csQualifiedNames(root);
    // `using Store = App.Tests.ProductStore;`: an alias stands for the path it
    // names, so every path below is written out through it before it is
    // recorded. `using App.Billing;` and `using static App.Atlas;` name the
    // namespaces and types whose types a name may be imported from — a static
    // import brings in the type's nested types. A directive inside a namespace
    // block applies within that block only, so sibling blocks never share one.
    // A `global using` counts for its own file alone (see `csUsings`).
    // A namespace block with its full name: `A.B` for `namespace B` inside
    // `namespace A`. A file-scoped namespace is a block running to the end of
    // the file, so a `using` written after it is that namespace's own.
    const namespaceBlocks = containers
        .filter((c) => !c.isType)
        .map((c) => ({
        range: c.range,
        namespace: [
            ...containers.filter((o) => !o.isType && o !== c && csWithin(c.range, o.range)).flatMap((o) => o.segments),
            ...c.segments,
        ].join("."),
        usings: [],
    }));
    if (fileScoped) {
        namespaceBlocks.push({
            range: { start: csRange(fileScoped).start, end: csRange(root).end },
            namespace: fileNamespace.join("."),
            usings: [],
        });
    }
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const blockOf = (node) => {
        const r = csRange(node);
        let best = null;
        for (const blk of namespaceBlocks) {
            if (csWithin(r, blk.range) && (!best || blk.range.start >= best.range.start))
                best = blk;
        }
        return best;
    };
    const aliases = new Map();
    const usings = [];
    for (const u of safeFindAll(root, "using_directive")) {
        const alias = csNameField(u);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const children = u.children?.() ?? [];
        const named = children.filter((c) => c.isNamed());
        const target = named.length > 0 ? csNamePath(named[named.length - 1]) : null;
        if (!target)
            continue;
        const block = blockOf(u);
        if (alias && named.length >= 2) {
            // An alias's target is looked up where the alias is declared, never
            // where it is used: a file-level one from the global namespace, one in a
            // block from the block's namespace outward (`App.Shop::`), so a nearer
            // namespace at the use cannot redirect it. `global::` stays as written.
            const anchor = block ? `${block.namespace}::` : CS_GLOBAL;
            const anchored = target[0] === CS_GLOBAL ? target : [anchor, ...target];
            const list = aliases.get(alias.text()) ?? [];
            list.push({ block: block?.range ?? null, target: anchored });
            aliases.set(alias.text(), list);
        }
        else if (!alias) {
            // A block's import is resolved from the block's namespace, unless anchored;
            // a file-level one from the global namespace.
            if (block)
                block.usings.push(csJoin(target));
            else
                usings.push((target[0] === CS_GLOBAL ? target.slice(1) : target).join("."));
        }
    }
    if (usings.length > 0)
        moduleSym.csUsings = usings;
    // The blocks that import anything, each with the nearest enclosing one that
    // does; a declaration records the innermost (`csBlock`).
    const importing = namespaceBlocks.filter((blk) => blk.usings.length > 0);
    const importingBlockOf = (range, except) => {
        let best;
        importing.forEach((blk, i) => {
            if (blk !== except && csWithin(range, blk.range) && (best === undefined || blk.range.start >= importing[best].range.start))
                best = i;
        });
        return best;
    };
    if (importing.length > 0) {
        moduleSym.csBlockUsings = importing.map((blk) => {
            const outer = importingBlockOf(blk.range, blk);
            return { namespace: blk.namespace, usings: blk.usings, ...(outer !== undefined ? { outer } : {}) };
        });
    }
    // The alias visible at byte offset `at`: the innermost block declaring it, else the file's.
    const expandAt = (path, at) => {
        let best = null;
        for (const a of aliases.get(path[0]) ?? []) {
            if (a.block && !(a.block.start <= at && at <= a.block.end))
                continue;
            if (!best || (a.block && (!best.block || a.block.start >= best.block.start)))
                best = a;
        }
        return best ? [...best.target, ...path.slice(1)] : path;
    };
    // The written base types of a type declaration: the list's own entries, so
    // a primary-constructor argument (`X` in `: Base(X)`) is never one.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const baseTypes = (d) => {
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const list = d.children?.().find((c) => c.kind() === "base_list");
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        return (list?.children?.().filter((c) => c.isNamed()) ?? []).map((c) => 
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        c.kind() === "primary_constructor_base_type" ? c.children().find((x) => x.isNamed()) : c);
    };
    const declarations = [
        [CS_TYPE_DECLARATIONS, false],
        [CS_MEMBER_DECLARATIONS, true],
    ];
    // Type symbols with their ranges, so the members recorded only by name can find their owner.
    const typeSymbols = [];
    for (const [kinds, isMember] of declarations) {
        for (const [k, kind] of kinds) {
            for (const d of safeFindAll(root, k)) {
                const nameNode = csNameField(d) ?? safeFind(d, "identifier");
                if (!nameNode)
                    continue;
                const name = nameNode.text();
                const own = csRange(d);
                const enclosing = containers.filter((c) => csWithin(own, c.range) && !(c.range.start === own.start && c.range.end === own.end));
                const owner = enclosing.filter((c) => c.isType).pop()?.segments[0] ?? null;
                const path = [...fileNamespace, ...enclosing.flatMap((c) => c.segments)];
                const qualifiedName = isMember && owner ? `${owner}.${name}` : name;
                const r = d.range();
                const startLine = r.start.line + 1;
                const endLine = r.end.line + 1;
                const sym = {
                    id: makeId(file, qualifiedName, startLine),
                    name, qualifiedName,
                    // `record struct` is a value type, like `struct`; `record` and `record class` are classes.
                    kind: k === "record_declaration" && d.children?.().some((c) => c.kind() === "struct")
                        ? "struct"
                        : kind,
                    file, line: startLine, endLine, language,
                    csFullName: [...path, name].join("."),
                };
                const block = importingBlockOf(own);
                if (block !== undefined)
                    sym.csBlock = block;
                symbols.push(sym);
                declared.push({ range: own, sym });
                if (!isMember) {
                    typeSymbols.push({ range: own, sym });
                    const bases = baseTypes(d)
                        .map((b) => csNamePath(b))
                        .filter((b) => b !== null)
                        .map((b) => csJoin(expandAt(b, own.start)));
                    if (bases.length > 0)
                        sym.csBases = bases;
                }
            }
        }
    }
    // The innermost type declaration containing `range`, other than `self`.
    const ownerOf = (range, self) => {
        let owner = null;
        for (const t of typeSymbols) {
            if (t.sym !== self && csWithin(range, t.range) && (!owner || t.range.start >= owner.range.start))
                owner = t;
        }
        return owner?.sym ?? null;
    };
    // C# reads a value whose type has the value's own name either way (the
    // "Color Color" rule), so `Status Status` hides no type `Status`, but only
    // when both readings name the same type. The declared type's written path
    // is kept when its last segment is the value's name: a plain name or a
    // qualified one, `?` allowed; an array, a pointer or a generic is another type.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const typedAsItself = (type, name) => {
        const nullable = type?.kind() === "nullable_type";
        const inner = nullable ? (type.field?.("type") ?? type.children?.()?.[0] ?? null) : type;
        const kind = inner?.kind();
        if (kind !== "identifier" && kind !== "qualified_name" && kind !== "alias_qualified_name")
            return null;
        const path = csNamePath(inner);
        return path && path[path.length - 1] === name ? { path, nullable } : null;
    };
    // Each type's value members and nested types by name, read below to tell a
    // receiver that is a value from one that is a type. A member typed as itself
    // is neither: whether it hides the type is left to resolution, which can
    // compare the two readings (`csSelfTypedMembers`).
    const valueMembers = new Map();
    const selfTypedMembers = new Map();
    const nestedTypes = new Map();
    const addName = (map, sym, name) => {
        const names = map.get(sym);
        if (names)
            names.add(name);
        else
            map.set(sym, new Set([name]));
    };
    // Fields, constants, events and enum members are not symbols, but a member
    // access names them (`OrderState.Open.Id`): each type keeps their names in
    // `csMembers` so resolution can tell a static member from a namespace segment.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const addMember = (d, name, type, inCsMembers) => {
        const owner = ownerOf(csRange(d));
        if (!owner)
            return;
        if (inCsMembers) {
            owner.csMembers ??= [];
            owner.csMembers.push(name);
        }
        const self = type ? typedAsItself(type, name) : null;
        if (self) {
            owner.csSelfTypedMembers ??= [];
            owner.csSelfTypedMembers.push({
                name,
                type: csJoin(expandAt(self.path, csRange(type).start)),
                ...(self.nullable ? { nullable: true } : {}),
            });
            addName(selfTypedMembers, owner, name);
        }
        else
            addName(valueMembers, owner, name);
    };
    for (const k of ["field_declaration", "event_field_declaration"]) {
        for (const d of safeFindAll(root, k)) {
            const declaration = safeFind(d, "variable_declaration");
            const type = declaration?.field?.("type") ?? null;
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            for (const v of declaration?.children?.().filter((c) => c.kind() === "variable_declarator") ?? []) {
                const n = csNameField(v) ?? safeFind(v, "identifier");
                if (n)
                    addMember(d, n.text(), type, true);
            }
        }
    }
    for (const k of ["event_declaration", "enum_member_declaration"]) {
        for (const d of safeFindAll(root, k)) {
            const n = csNameField(d);
            if (n)
                addMember(d, n.text(), d.field?.("type") ?? null, true);
        }
    }
    // A property is a symbol of its own, and a value all the same.
    for (const d of safeFindAll(root, "property_declaration")) {
        const n = csNameField(d);
        if (n)
            addMember(d, n.text(), d.field?.("type") ?? null, false);
    }
    for (const t of typeSymbols) {
        const owner = ownerOf(t.range, t.sym);
        if (owner)
            addName(nestedTypes, owner, t.sym.name);
    }
    // Locals, parameters, pattern and range variables, each with the range it
    // is visible in: the enclosing block or construct, or the whole member for
    // a parameter. A pattern variable is given its whole block, which over-
    // rather than under-states where it is visible.
    const localScopes = new Set([
        "block", "switch_section", "switch_expression_arm", "for_statement", "foreach_statement", "using_statement", "fixed_statement",
        "catch_clause", "lambda_expression", "anonymous_method_expression", "query_expression",
    ]);
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const scopeOf = (node) => {
        let p = node.parent?.() ?? null;
        while (p && !localScopes.has(p.kind()))
            p = p.parent?.() ?? null;
        return csRange(p ?? root);
    };
    const locals = new Map();
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const bind = (id, scope, type = null) => {
        if (!id)
            return;
        if (id.kind() !== "identifier" && id.kind() !== "implicit_parameter") {
            // A designation or tuple pattern binds every identifier directly in it.
            for (const part of id.children?.() ?? [])
                if (part.kind() === "identifier")
                    bind(part, scope);
            return;
        }
        const name = id.text();
        // Only a plain `Status Status` is read in one scope both ways; a qualified
        // or nullable type may be another type than the name finds, so it stays a value.
        const self = type ? typedAsItself(type, name) : null;
        if (self && self.path.length === 1 && !self.nullable && !expandAt(self.path, csRange(type).start)[0].endsWith("::"))
            return;
        const scopes = locals.get(name);
        if (scopes)
            scopes.push(scope);
        else
            locals.set(name, [scope]);
    };
    for (const p of safeFindAll(root, "parameter")) {
        const owner = p.parent?.()?.parent?.() ?? null;
        bind(csNameField(p), csRange(owner ?? root), p.field?.("type") ?? null);
    }
    for (const p of safeFindAll(root, "implicit_parameter"))
        bind(p, csRange(p.parent?.() ?? root));
    for (const v of safeFindAll(root, "variable_declarator")) {
        const declaration = v.parent?.() ?? null;
        const statement = declaration?.parent?.() ?? null;
        const kind = statement?.kind();
        if (!statement || kind === "field_declaration" || kind === "event_field_declaration")
            continue;
        const scope = kind === "local_declaration_statement" ? scopeOf(statement) : csRange(statement);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const id = csNameField(v) ?? v.children?.().find((c) => c.kind() === "identifier" || c.kind() === "tuple_pattern");
        bind(id, scope, declaration?.field?.("type") ?? null);
    }
    for (const k of ["declaration_pattern", "recursive_pattern", "declaration_expression", "var_pattern"]) {
        for (const d of safeFindAll(root, k))
            bind(csNameField(d) ?? safeFind(d, "identifier"), scopeOf(d), d.field?.("type") ?? null);
    }
    for (const d of safeFindAll(root, "catch_declaration"))
        bind(csNameField(d), csRange(d.parent?.() ?? root), d.field?.("type"));
    for (const d of safeFindAll(root, "foreach_statement"))
        bind(d.field?.("left") ?? null, csRange(d), d.field?.("type"));
    for (const k of ["from_clause", "let_clause", "join_clause", "join_into_clause", "query_continuation"]) {
        for (const d of safeFindAll(root, k)) {
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            bind(csNameField(d) ?? d.children?.().find((c) => c.kind() === "identifier"), scopeOf(d));
        }
    }
    /**
     * Whether the simple name `name` at byte offset `at` names a value, looked
     * up as C# looks a simple name up in an expression: a local or parameter
     * first, then each enclosing type, innermost first, which declares it as a
     * nested type (a type) or as a field, property, constant or event (a value).
     * Members a type declares in another file or inherits are left to resolution.
     */
    const valueAt = (name, at) => {
        if (locals.get(name)?.some((r) => r.start <= at && at <= r.end))
            return true;
        const enclosing = typeSymbols
            .filter((t) => t.range.start <= at && at <= t.range.end)
            .sort((a, b) => b.range.start - a.range.start);
        for (const t of enclosing) {
            if (nestedTypes.get(t.sym)?.has(name) || selfTypedMembers.get(t.sym)?.has(name))
                return false;
            if (valueMembers.get(t.sym)?.has(name))
                return true;
        }
        return false;
    };
    const rawCalls = [];
    const seenRefs = new Set();
    // `at` is the reference's byte offset, which decides its caller.
    const pushTypeRef = (name, qualifier, line, at, memberReceiver = false) => {
        if (!/^[A-Z]/.test(name))
            return;
        const callerId = callerAt(at);
        const key = `${callerId}|${qualifier ?? ""}|${name}|${line}`;
        if (seenRefs.has(key))
            return;
        seenRefs.add(key);
        rawCalls.push({
            callerId,
            calleeName: name,
            kind: "type_reference",
            ...(qualifier ? { calleeQualifier: qualifier } : {}),
            ...(memberReceiver ? { csMemberReceiver: true } : {}),
            callSite: { file, line },
        });
    };
    // A path's last segment is the type; the segments before it are its written
    // qualifier. `path` is written out through the alias in scope at `at`.
    const pushPathRef = (path, line, at, memberReceiver = false) => {
        pushTypeRef(path[path.length - 1], path.length > 1 ? csJoin(path.slice(0, -1)) : undefined, line, at, memberReceiver);
    };
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const pushTypeRefAt = (id) => {
        if (qualified.isSegment(id))
            return;
        const qualifier = qualified.writtenQualifier(id);
        if (qualifier === null)
            return;
        const at = csRange(id).start;
        pushPathRef(expandAt([...qualifier, id.text()], at), id.range().start.line + 1, at);
    };
    for (const node of safeFindAll(root, "invocation_expression")) {
        const r = node.range();
        const callLine = r.start.line + 1;
        const callerId = callerAt(csRange(node).start);
        const fn = node.field?.("function") ?? null;
        let calleeName = null;
        let calleeQualifier;
        // A receiver that is no name path: `this`, `base`, or `?` for any other
        // expression (a call result, `a?.b`, an element access).
        let receiver;
        if (fn?.kind() === "member_access_expression") {
            calleeName = csTypeHeadIdentifier(csNameField(fn))?.text() ?? null;
            // Any name path is kept as written, a lowercase namespace or a variable
            // included: dropping it would leave a bare name for local-first lookup.
            const expr = fn.field?.("expression") ?? null;
            const path = csNamePath(expr);
            // A path starting at a value (`order.Save()`, `Report.Print()` on a
            // parameter `Report`) is an instance receiver, never a type.
            if (path && (path[0] === CS_GLOBAL || !valueAt(path[0], csRange(expr).start))) {
                calleeQualifier = csJoin(expandAt(path, csRange(expr).start));
            }
            else
                receiver = expr?.kind() === "this" || expr?.kind() === "base" ? expr.kind() : "?";
        }
        else if (fn?.kind() === "identifier" || fn?.kind() === "generic_name") {
            calleeName = csTypeHeadIdentifier(fn)?.text() ?? null;
        }
        else if (fn) {
            receiver = "?";
        }
        if (!calleeName)
            calleeName = extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        rawCalls.push({
            callerId,
            calleeName,
            kind: "call",
            ...(calleeQualifier ? { calleeQualifier } : {}),
            ...(receiver ? { receiver } : {}),
            callSite: { file, line: callLine },
        });
    }
    // `new Billing.Calc()` calls `Calc`'s constructor: the qualifier is the
    // type's whole path, so the repeated `Calc` tells resolution it is a
    // creation. It also references the type.
    for (const node of safeFindAll(root, "object_creation_expression")) {
        const typeNode = node.field?.("type") ?? null;
        const written = csNamePath(typeNode);
        if (!written)
            continue;
        const at = csRange(typeNode).start;
        const path = expandAt(written, at);
        const line = node.range().start.line + 1;
        rawCalls.push({
            callerId: callerAt(at),
            calleeName: path[path.length - 1],
            kind: "call",
            calleeQualifier: csJoin(path),
            callSite: { file, line },
        });
        pushPathRef(path, line, at);
    }
    // Type positions: the declaration's `type` field, the base list, type
    // arguments, `typeof` and casts. Every PascalCase identifier under them
    // names a type, except a qualifier segment of a qualified name.
    const typedKinds = [
        "parameter", "variable_declaration", "property_declaration", "method_declaration", "typeof_expression",
        "cast_expression", "event_field_declaration", "delegate_declaration", "indexer_declaration",
    ];
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const typeNodes = [];
    for (const k of typedKinds) {
        for (const d of safeFindAll(root, k)) {
            const t = d.field?.("type") ?? d.field?.("returns") ?? null;
            if (t)
                typeNodes.push(t);
        }
    }
    for (const k of CS_TYPE_DECLARATIONS.keys())
        for (const d of safeFindAll(root, k))
            typeNodes.push(...baseTypes(d));
    typeNodes.push(...safeFindAll(root, "type_argument_list"));
    for (const t of typeNodes) {
        if (t.kind() === "identifier") {
            pushTypeRefAt(t);
            continue;
        }
        // A tuple element's label (`Total` in `(int Total, Order Item)`) names no type.
        const labels = new Set(safeFindAll(t, "tuple_element")
            .map((e) => csNameField(e))
            .filter((n) => n?.kind() === "identifier")
            .map((n) => csRange(n).start));
        for (const id of safeFindAll(t, "identifier"))
            if (!labels.has(csRange(id).start))
                pushTypeRefAt(id);
    }
    // `Calc.Run()`, `Mode.A`, `Billing.Calc.Limit`: a member access whose
    // receiver is a PascalCase name path names a type — the C# counterpart of
    // the import edge a TypeScript `import { Calc }` gives the class, since
    // `using` names only a namespace. Only the outermost access of a chain is
    // read, so `Billing.Calc.Limit` references `Billing.Calc` and never a type
    // named `Billing`; which segment of the path is the type is left to
    // resolution (`csMemberReceiver`).
    // An inner access shares its start with the outer one, so the key is the whole range.
    const rangeKey = (r) => `${r.start}:${r.end}`;
    const innerAccesses = new Set();
    for (const node of safeFindAll(root, "member_access_expression")) {
        const e = node.field?.("expression") ?? null;
        if (e?.kind() === "member_access_expression")
            innerAccesses.add(rangeKey(csRange(e)));
    }
    for (const node of safeFindAll(root, "member_access_expression")) {
        if (innerAccesses.has(rangeKey(csRange(node))))
            continue;
        const expr = node.field?.("expression") ?? null;
        const receiver = csNamePath(expr);
        // A lowercase head is far more often a variable than a namespace, and an
        // unresolvable reference per field access would drown the type graph; the
        // call itself still keeps a lowercase receiver as its qualifier.
        if (!receiver || (receiver[0] !== CS_GLOBAL && !/^[A-Z]/.test(receiver[0])))
            continue;
        const at = csRange(expr).start;
        // A value's member (`Config.Load()` on a property `Config`) references no type.
        if (receiver[0] !== CS_GLOBAL && valueAt(receiver[0], at))
            continue;
        pushPathRef(expandAt(receiver, at), node.range().start.line + 1, at, true);
    }
    // `[Foo]` names the class `FooAttribute`; the compiler accepts both spellings.
    for (const a of safeFindAll(root, "attribute")) {
        // Expanded before the suffix: `[Obs]` under `using Obs = System.ObsoleteAttribute;`.
        const nameNode = csNameField(a);
        const written = csNamePath(nameNode);
        if (!written)
            continue;
        const at = csRange(nameNode).start;
        const path = expandAt(written, at);
        const last = path[path.length - 1];
        const name = last.endsWith("Attribute") ? last : `${last}Attribute`;
        pushPathRef([...path.slice(0, -1), name], a.range().start.line + 1, at);
    }
    return { symbols, rawCalls };
}
// ── C / C++ ──────────────────────────────────────────────────────────────
function extractFromCFamily(source, langKey, file, language, moduleSym) {
    const root = parse(langKey, source).root();
    const symbols = [moduleSym];
    const scopes = [];
    if (langKey === "cpp") {
        for (const k of ["class_specifier", "struct_specifier"]) {
            for (const cls of safeFindAll(root, k)) {
                const nameNode = safeFind(cls, "type_identifier");
                if (!nameNode)
                    continue;
                const name = nameNode.text();
                const r = cls.range();
                const startLine = r.start.line + 1;
                const endLine = r.end.line + 1;
                const sym = {
                    id: makeId(file, name, startLine),
                    name, qualifiedName: name,
                    kind: k.includes("struct") ? "struct" : "class",
                    file, line: startLine, endLine, language,
                };
                symbols.push(sym);
                scopes.push({ name, startLine, endLine, symbolId: sym.id });
            }
        }
    }
    for (const fn of safeFindAll(root, "function_definition")) {
        const declarator = safeFind(fn, "function_declarator");
        const nameNode = safeFind(declarator, "identifier")
            ?? safeFind(declarator, "qualified_identifier");
        if (!nameNode)
            continue;
        const fullName = nameNode.text();
        const name = fullName.split("::").pop() ?? fullName;
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, fullName, startLine),
            name, qualifiedName: fullName,
            kind: fullName.includes("::") ? "method" : "function",
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name: fullName, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "call_expression")) {
        const calleeName = extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── Ruby ────────────────────────────────────────────────────────────────
function extractFromRuby(source, file, language, moduleSym) {
    const root = parse("ruby", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    for (const k of ["class", "module"]) {
        for (const cls of safeFindAll(root, k)) {
            const nameNode = safeFind(cls, "constant")
                ?? safeFind(cls, "identifier");
            if (!nameNode)
                continue;
            const name = nameNode.text();
            const r = cls.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name,
                kind: k === "module" ? "module" : "class",
                file, line: startLine, endLine, language,
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    for (const m of safeFindAll(root, "method")) {
        const nameNode = safeFind(m, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = m.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "method",
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "call")) {
        const methodNode = node.field("method");
        const calleeName = methodNode ? methodNode.text() : extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── PHP ─────────────────────────────────────────────────────────────────
/**
 * PHP names a type position can hold that no project symbol ever answers: the
 * three pseudo-types, and the built-in scalar and compound type names.
 *
 * `int`, `void` and the rest reach us as `primitive_type` rather than
 * `named_type`, so reading only `named_type` nodes already skips them in a
 * signature. They are listed anyway because `new`, `extends`, `implements` and
 * a trait `use` read a bare `name` node, which draws no such distinction —
 * `new static()` and `new self()` both arrive as one. PHP type names are
 * case-insensitive, so membership is tested lowercased.
 *
 * `integer`, `double` and `boolean` are deliberately absent. They read like
 * type keywords and are not: PHP treats each as a CLASS name, warning
 * `"integer" will be interpreted as a class name. Did you mean "int"?` and
 * then resolving it to a class of that name. Filtering them would drop a real
 * reference from any project that declares one.
 */
const PHP_NON_SYMBOL_TYPES = new Set([
    "self", "parent", "static",
    "int", "float", "string", "bool",
    "array", "object", "callable", "iterable", "mixed", "void", "never",
    "null", "false", "true",
]);
/** Record one `use` clause's import under its local spelling, case-folded. */
function phpAliasAdd(table, local, entry) {
    const key = phpFoldCase(local);
    const entries = table.get(key);
    if (entries)
        entries.push(entry);
    else
        table.set(key, [entry]);
}
/**
 * The import in force for `local` at `offset`: the latest `use` declared
 * before it, and nothing at all when every declaration comes after.
 *
 * The scan does not assume the entries are ordered — it compares offsets — so
 * it does not depend on the traversal handing back `use` clauses in source
 * order.
 */
function phpAliasAt(table, local, offset) {
    const entries = table.get(phpFoldCase(local));
    if (!entries)
        return undefined;
    let best;
    for (const entry of entries) {
        if (entry.offset < offset && (best === undefined || entry.offset > best.offset)) {
            best = entry;
        }
    }
    return best;
}
/**
 * The class a PHP class reference names.
 *
 * PHP writes a class name four ways and resolves each differently:
 *
 *   - fully qualified, `\App\Base\Base` — exactly that, whatever the file
 *     imports;
 *   - namespace-relative, `namespace\Base` — under the current namespace;
 *   - qualified, `Base\Base` — its first segment through a `use` in force for
 *     it, and otherwise under the current namespace;
 *   - unqualified, `Base` — through a `use` in force for it, and otherwise
 *     under the current namespace. A class name never falls back to the global
 *     namespace the way an unqualified function or constant name does.
 *
 * Only the terminal segment can match a declared symbol, so `calleeName` is
 * that segment — or, for an unqualified name a `use` renamed, the name the
 * `use` imported, which is what makes
 * `use App\Base\Base as Ancestor; class X extends Ancestor` an edge to `Base`.
 *
 * A leading `\` is read before it is stripped. Under `use X\Other as Foo;`,
 * `extends Foo` resolves to `X\Other` while `extends \Foo` fatals with
 * `Class "Foo" not found` — the alias is not consulted at all — so stripping
 * the slash first would name a different class from the one written.
 *
 * @param raw Class spelling exactly as written, leading `\` included.
 * @param offset Source offset of the reference. Which namespace and which
 *               `use` statements are in force is a question about position,
 *               not just about the file.
 * @param names The file's namespaces and imports.
 * @returns The reference, or null for a spelling no project symbol can answer
 *          (a pseudo-type, a built-in, or a form {@link PHP_IDENTIFIER} does
 *          not admit as an identifier).
 */
function phpClassRef(raw, offset, names) {
    const trimmed = raw.trim();
    const fullyQualified = trimmed.startsWith("\\");
    const segments = trimmed.replace(/^\\+/, "").split("\\").map((s) => s.trim());
    const terminal = segments[segments.length - 1] ?? "";
    // PHP's own identifier rule, non-ASCII names included — see
    // {@link PHP_IDENTIFIER}. Matched whole, so a name it does not admit gets no
    // edge rather than a shortened one.
    if (!PHP_IDENTIFIER.test(terminal))
        return null;
    if (PHP_NON_SYMBOL_TYPES.has(terminal.toLowerCase()))
        return null;
    const underCurrentNamespace = (path) => {
        const ns = names.namespaceAt(offset);
        return (ns ? [ns, ...path] : path).join("\\");
    };
    if (fullyQualified)
        return { calleeName: terminal, fqcn: segments.join("\\") };
    // `namespace` is a keyword, not a segment: `namespace\Base` is the current
    // namespace's `Base`, spelled explicitly, and no `use` can rename it.
    if (segments.length > 1 && phpFoldCase(segments[0]) === "namespace") {
        return { calleeName: terminal, fqcn: underCurrentNamespace(segments.slice(1)) };
    }
    // A qualified name's first segment is what an import can answer — `use
    // App\Models as M;` makes `M\User` into `App\Models\User` — and the rest is
    // read beneath it. The short name is the terminal segment either way.
    if (segments.length > 1) {
        const head = names.importAt(segments[0], offset);
        return {
            calleeName: terminal,
            fqcn: head ? [head.path, ...segments.slice(1)].join("\\") : underCurrentNamespace(segments),
        };
    }
    const imported = names.importAt(terminal, offset);
    if (!imported)
        return { calleeName: terminal, fqcn: underCurrentNamespace(segments) };
    return imported.imported !== terminal
        ? { calleeName: imported.imported, localAlias: terminal, fqcn: imported.path }
        : { calleeName: terminal, fqcn: imported.path };
}
/**
 * Whether a `use` node carries a `function` or `const` qualifier, making it an
 * import of something that is not a type.
 *
 * Asked of two different nodes, because the grammar files the keyword in two
 * different places and neither alone covers PHP's three spellings:
 *
 *     use function A\B as C;        // keyword on the CLAUSE
 *     use function A\{B as C};      // keyword on the DECLARATION
 *     use A\{B, function c};        // keyword on the CLAUSE again, per member
 *
 * Checking only the clause let the grouped form through, and checking only the
 * declaration would let the mixed group's `function c` member through. A
 * function or constant import that reaches the alias table answers a type
 * reference with a name no class was declared under — a fabricated edge.
 *
 * @param node A `namespace_use_declaration` or `namespace_use_clause`.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function phpUseImportsNonType(node) {
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    return node.children().some((c) => c.kind() === "function" || c.kind() === "const");
}
/**
 * The (local spelling → imported short name) pair one `use` clause declares.
 *
 * Covers the plain (`use A\B\C;`), aliased (`use A\B\C as D;`) and grouped
 * (`use A\{B, C as D};`) forms — the grammar files a group's members as
 * `namespace_use_clause` nodes of their own, so one `findAll` over a file
 * reaches all three. A clause that imports a function or a constant rather
 * than a type yields null; see {@link phpUseImportsNonType} for why its
 * caller has to make the same check of the enclosing declaration.
 *
 * A grouped clause names only the tail of its path — `User` in
 * `use App\Models\{User, Post}` — and the shared `App\Models` prefix sits on
 * the enclosing declaration, so the full path is read from there.
 *
 * @param clause A `namespace_use_clause` node.
 * @returns `[local spelling, imported short name, full imported path]`, or null
 *          for a clause that imports no type.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function phpUseClauseAlias(clause) {
    if (phpUseImportsNonType(clause))
        return null;
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const kids = clause.children();
    const names = kids.filter((c) => c.kind() === "name" || c.kind() === "qualified_name");
    if (names.length === 0)
        return null;
    const clausePath = names[0].text().trim().replace(/^\\+/, "");
    const importedPath = clausePath.split("\\");
    const imported = importedPath[importedPath.length - 1]?.trim() ?? "";
    if (!imported)
        return null;
    const aliased = names.length > 1 && kids.some((c) => c.kind() === "as");
    const local = aliased ? names[names.length - 1].text().trim() : imported;
    if (!local)
        return null;
    const group = clause.parent();
    const prefix = group?.kind() === "namespace_use_group"
        ? group.parent()?.children()
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            .find((c) => c.kind() === "namespace_name")
            ?.text().trim().replace(/^\\+/, "")
        : undefined;
    return [local, imported, prefix ? `${prefix}\\${clausePath}` : clausePath];
}
/**
 * Every namespace a PHP file declares, as a source extent, in source order.
 *
 * PHP writes a namespace in two forms and scopes them differently:
 *
 *   - braced, `namespace A { … }`, whose scope is exactly its braces, and which
 *     is the only form that may appear more than once with different names in
 *     one file *and* enclose code;
 *   - unbraced, `namespace A;`, whose scope runs from the statement to the next
 *     `namespace` statement or to end of file.
 *
 * The grammar gives an unbraced `namespace_definition` a range covering only
 * `namespace A;` itself, so its extent has to be computed from its successor —
 * hence the two branches. Only the root's own children are read, so no extent
 * can enclose another: siblings do not overlap, and an unbraced extent stops
 * where the next declaration starts. At most one extent contains any offset.
 *
 * @param root Parsed root of a PHP file.
 * @returns Extents with empty alias tables, in source order; empty for a file
 *          that declares no namespace.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function buildPhpNamespaceScopes(root) {
    const endOfFile = root.range().end.index;
    // A namespace declaration is a top-level statement — PHP rejects one
    // anywhere else — so the root's own children hold every one of them. Read
    // from there rather than by searching the tree: every file that declares a
    // class now asks for its namespace, and a full traversal to find a statement
    // that can only sit at the top charged each of them for the whole file.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const defs = root.children().filter((c) => c.kind() === "namespace_definition").sort(
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    (a, b) => a.range().start.index - b.range().start.index);
    return defs.map((def, i) => {
        const range = def.range();
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const braced = def.children().some((c) => c.kind() === "compound_statement");
        return {
            start: range.start.index,
            end: braced ? range.end.index : (defs[i + 1]?.range().start.index ?? endOfFile),
            name: def.field("name")?.text().trim().replace(/^\\+/, "") ?? "",
            aliases: new Map(),
        };
    });
}
/**
 * Resolve a source offset to the `use`-alias table in force there.
 *
 * PHP scopes a `use` statement to its enclosing namespace, not to the file. One
 * table per file therefore applies an alias where nothing imports it, and that
 * FABRICATES an edge rather than omitting one:
 *
 *     namespace A { use X\Base as Al; class One extends Al {} }
 *     namespace B { class Two extends Al {} }
 *
 * `Two` extends `B\Al` — a class this file never names — yet a file-wide table
 * rewrote it to `Base` and drew an edge to `A`'s import. Every other decision in
 * this extractor prefers an honest `unresolved` over a guess, so a wrong edge is
 * the one outcome it must not produce.
 *
 * Offsets, not lines: two braced namespace blocks are legal on one line, and a
 * line number cannot tell their references apart.
 *
 * A file with no namespace, or one whose reference sits outside every declared
 * namespace, falls back to the whole file's aliases — the behaviour before this
 * became scope-aware, and the right answer for the single-namespace file that
 * nearly every PSR-4 autoloaded class is. That fallback is position-scoped too:
 * the runtime is no more forgiving at global scope, where the same file without
 * its `namespace` line fatals with `Class "Al" not found`.
 *
 * Scope selects the table; the offset then selects within it, because being in
 * the right namespace is necessary for an import to apply but not sufficient —
 * it must also have been declared above the reference. See
 * {@link PhpAliasEntry}.
 *
 * The namespace in force comes from the same scopes: a reference inside a
 * namespace's extent is in that namespace, and one outside every extent is in
 * the global namespace.
 *
 * @param root Parsed root of a PHP file.
 * @returns The imports and the namespace in force at any offset.
 */
// biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
function buildPhpNames(root) {
    const scopes = buildPhpNamespaceScopes(root);
    const scopeAt = (offset) => scopes.find((s) => offset >= s.start && offset < s.end);
    // The imports are read on the first lookup, not up front. Every file that
    // declares a class asks for its namespace, but many never resolve a class
    // name at all, and the walk below is a second pass over the whole tree.
    let fileAliases;
    const imports = () => {
        if (fileAliases)
            return fileAliases;
        const table = new Map();
        // Walked declaration-first rather than straight to the clauses, because a
        // `use function A\{B as C};` carries its qualifier on the declaration and
        // its clauses look exactly like type imports from below. The per-clause
        // check inside `phpUseClauseAlias` still has to run: a mixed group,
        // `use A\{B, function c};`, qualifies its members individually.
        for (const decl of safeFindAll(root, "namespace_use_declaration")) {
            if (phpUseImportsNonType(decl))
                continue;
            for (const clause of safeFindAll(decl, "namespace_use_clause")) {
                const alias = phpUseClauseAlias(clause);
                if (!alias)
                    continue;
                const entry = {
                    offset: clause.range().start.index,
                    imported: alias[1],
                    path: alias[2],
                };
                phpAliasAdd(table, alias[0], entry);
                const scope = scopeAt(entry.offset);
                if (scope)
                    phpAliasAdd(scope.aliases, alias[0], entry);
            }
        }
        fileAliases = table;
        return table;
    };
    return {
        importAt: (local, offset) => {
            // First, so a namespace's own table is filled before it is read.
            const file = imports();
            return phpAliasAt(scopeAt(offset)?.aliases ?? file, local, offset);
        },
        namespaceAt: (offset) => scopeAt(offset)?.name ?? "",
    };
}
/**
 * Bucket name for closure and arrow-function return types.
 *
 * Every other key in that map is a tree-sitter node kind; this one deliberately
 * is not, because the two anonymous forms are gathered in a single traversal
 * and share one bucket. Named rather than spelled inline so a reader checking
 * the emission loop against the grammar does not go looking for a node kind
 * called "anonymous".
 */
const ANON_RETURN_TYPES = "anonymous-function-return-types";
function extractFromPhp(source, file, language, moduleSym) {
    const root = parse("php", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    // Namespaces and imports, read on first use. A file that declares no class,
    // makes no static call and names no type never asks, and pays nothing.
    let phpNames;
    const names = () => {
        phpNames ??= buildPhpNames(root);
        return phpNames;
    };
    // The owner forms `SymbolNode.phpOwner` documents: a namespace as a prefix,
    // with PHP's trailing `\` (`\App\Models\`, `\` for the global namespace), and
    // a class as a name (`\App\Models\Invoice`).
    const namespacePrefixAt = (offset) => {
        const ns = names().namespaceAt(offset);
        return ns ? `\\${ns}\\` : "\\";
    };
    // The class, interface, trait or enum a method is declared in, as its fully
    // qualified name. Read from the declaration's `name` field, not its first
    // `name` descendant: an attribute list comes before the name inside the
    // declaration, and `#[Entity] class User` would otherwise own its methods as
    // `Entity`. A method of an anonymous class has no name to be reached by, so
    // it has no owner, and nothing qualified can resolve to it.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const ownerClassOf = (method) => {
        for (let node = method.parent(); node; node = node.parent()) {
            const kind = node.kind();
            if (kind === "anonymous_class")
                return undefined;
            if (kind === "class_declaration" || kind === "interface_declaration"
                || kind === "trait_declaration" || kind === "enum_declaration") {
                const name = node.field("name")?.text();
                return name ? `${namespacePrefixAt(node.range().start.index)}${name}` : undefined;
            }
        }
        return undefined;
    };
    /**
     * Whether the parse handed back part of a type reference rather than all of it.
     *
     * The grammar's `name` token stops at the BMP, so `class X extends 野𠮷`
     * arrives as the name `野` with the rest beside it as an `ERROR` fragment,
     * and `extends 𠮷Doc` as `Doc` with the fragment in front. A qualified name
     * loses more than the segment that was cut: the parse gives up at the cut and
     * hands back only what precedes it, so `extends App\野𠮷` arrives as `App` —
     * a namespace head, and one that plenty of projects also declare a class
     * under. A reference emitted from any of those parts names a class the source
     * never writes, and if a class of that name is reachable it resolves to it,
     * confidently — the truncation this file's callee scan was widened to stop,
     * arriving from the other side.
     *
     * Read from the source rather than from the tree, because the question is
     * not what the parser made of the remainder but whether there is one: a
     * character that carries the reference on, touching either end of the node,
     * means it runs past what the parse returned.
     *
     * A name the parse read whole is never touched that way, because the
     * grammar's `name` token, like PHP's own tokenizer, takes the longest run it
     * can, and a whole qualified name is one node with its separators inside it.
     * The two runs differ only where the grammar stops early: past the BMP, and
     * at the handful of characters below it that its `name` token treats as
     * whitespace although PHP admits them inside an identifier — U+00A0, U+200B
     * and U+FEFF. A name spelled with one of those is valid PHP and does lose its
     * edge here, exactly as `野𠮷` does. That is the same trade rather than a
     * false positive: the parse had already cut the name short, so the choice is
     * between no edge and an edge to whatever class the surviving part names.
     *
     * The two ends do not ask the same question. On the right, either an
     * identifier character or a `\` carries on. On the left, two identifier
     * characters meeting always do. A `\` behind an identifier run is harder:
     * `new\App\Made()` is valid PHP, since `\` ends the keyword there, and must
     * keep its edge rather than lose it to the `w` of `new`; but a fragment cut
     * from in front of a `\`-rooted node costs a leading segment, and
     * {@link phpClassRef} reads every segment into the qualifier, so
     * `new 𠮷\App\Made()` would name `\App\Made` rather than the class written.
     * Every keyword that can stand there is ASCII and every fragment the grammar
     * cuts is not, so a run holding a non-ASCII character is a fragment.
     */
    const isSplitName = (range) => {
        const start = range.start.index;
        if (PHP_TYPE_REF_CHAR.test(source[range.end.index] ?? ""))
            return true;
        if (!PHP_IDENTIFIER_CHAR.test(source[start - 1] ?? ""))
            return false;
        if (source[start] !== "\\")
            return PHP_IDENTIFIER_CHAR.test(source[start] ?? "");
        for (let i = start - 1; i >= 0 && PHP_IDENTIFIER_CHAR.test(source[i]); i--) {
            if (source.charCodeAt(i) > 0x7f)
                return true;
        }
        return false;
    };
    // The class a node spells, read at its own offset, or null for a name the
    // parse cut short ({@link isSplitName}) — qualified by the part that
    // survived, it would name a class the source never writes — or one that
    // names no class ({@link phpClassRef}).
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const classRefOf = (node) => {
        const range = node.range();
        return isSplitName(range) ? null : phpClassRef(node.text(), range.start.index, names());
    };
    // A class written as a name, as the fully qualified name it resolves to
    // under the file's namespace and imports: `Invoice`, `Models\Invoice`,
    // `\App\Models\Invoice` or `namespace\Invoice`, each in the class form
    // `SymbolNode.phpOwner` documents (`\App\Models\Invoice`). Anything else —
    // a variable, an expression, or `self`, `static` and `parent`, which are a
    // `relative_scope` rather than a name — has no such name, and neither does
    // a name {@link classRefOf} refuses.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const classNameOf = (node) => {
        const kind = node?.kind();
        if (kind !== "name" && kind !== "qualified_name" && kind !== "relative_name")
            return undefined;
        const ref = classRefOf(node);
        return ref ? `\\${ref.fqcn}` : undefined;
    };
    // A static call's class, when it is written as a name: `Invoice::capture()`
    // carries the class it names as its qualifier. Everything else is left
    // unqualified and resolves as it always has — `$class::m()`, and `self::`,
    // `static::` and `parent::`, which are answered by the class the call sits
    // in and are outside this change. So is a method written as a variable:
    // `Invoice::$m()` runs whatever method `$m` holds, and qualified, the name
    // the callee scan reads from it would resolve with confidence to a method
    // that happens to be called `m`.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const staticCallQualifier = (node) => node.field("name")?.kind() === "name" ? classNameOf(node.field("scope")) : undefined;
    // What a class or trait inherits static methods from, read from its own
    // declaration: the class a class `extends`, and the traits a class or trait
    // `use`s in its body. Resolution follows these when the class a static call
    // names does not declare the method itself, as PHP does. Only the body's
    // direct `use_declaration`s are read, so a nested anonymous class's traits
    // are not taken for its container's, and a `use T { a as b; }` alias list,
    // filed under its own `use_list`, is not read as a trait. An interface
    // declares no method a static call can run, so its `extends` is not read.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const inheritanceOf = (cls) => {
        const out = {};
        if (cls.kind() === "class_declaration") {
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            const base = cls.children().find((c) => c.kind() === "base_clause");
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            const parent = base?.children().map((c) => classNameOf(c)).find(Boolean);
            if (parent)
                out.phpExtends = parent;
        }
        const traits = [];
        for (const child of cls.field("body")?.children() ?? []) {
            if (child.kind() !== "use_declaration")
                continue;
            for (const named of child.children()) {
                const trait = classNameOf(named);
                if (trait)
                    traits.push(trait);
            }
        }
        if (traits.length > 0)
            out.phpTraits = traits;
        return out;
    };
    // The class-like's name comes from the declaration's `name` field, for the
    // reason {@link ownerClassOf} already reads it that way: an attribute list
    // comes before the name inside the declaration, so the first `name`
    // descendant is the attribute's — `#[ObservedBy(…)] class Post` was
    // registered as `ObservedBy`, and `codebase_symbol` and `codebase_impact`
    // could not find `Post` at all (issue #205).
    for (const k of ["class_declaration", "interface_declaration", "trait_declaration"]) {
        for (const cls of safeFindAll(root, k)) {
            const nameNode = cls.field("name");
            if (!nameNode)
                continue;
            const name = nameNode.text();
            const r = cls.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name,
                kind: k.includes("interface") ? "interface" : k.includes("trait") ? "trait" : "class",
                file, line: startLine, endLine, language,
                phpOwner: namespacePrefixAt(r.start.index),
                ...inheritanceOf(cls),
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    // Each declaration's `return_type`, stashed by node kind as the symbols are
    // read. These two kinds are walked here anyway, so asking `safeFindAll` for
    // them again below re-walked the whole tree twice per file for nodes already
    // in hand. Stashed rather than emitted: `pushTypeRef` attributes a reference
    // to its innermost caller and so needs `scopes` complete, and the emission
    // order — parameters and properties first, then methods, then functions —
    // decides which of two same-key edges survives the dedupe, so it is kept
    // exactly as it was. Collected above the name guard, because a declaration
    // the guard skips still contributed its return type before.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const returnTypes = new Map();
    for (const k of ["function_definition", "method_declaration"]) {
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const forKind = [];
        returnTypes.set(k, forKind);
        for (const m of safeFindAll(root, k)) {
            const returnType = m.field("return_type");
            if (returnType)
                forKind.push(returnType);
            const nameNode = safeFind(m, "name");
            if (!nameNode)
                continue;
            const name = nameNode.text();
            const r = m.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            // An `abstract` method has no body for a call to run, so it is left
            // unowned, as an anonymous class's methods are, and no qualified edge
            // can reach it. PHP answers the call with the implementation instead: a
            // trait's `abstract public static function make()` does not shadow the
            // `make()` its class inherits, and calling an abstract method by name is
            // an error. Owned, the declaration was found first and resolution
            // stopped there, with the method PHP actually runs one level further up.
            const owner = k === "method_declaration"
                // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                && !m.children().some((c) => c.kind() === "abstract_modifier")
                ? ownerClassOf(m)
                : undefined;
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name,
                kind: k === "function_definition" ? "function" : "method",
                file, line: startLine, endLine, language,
                ...(owner ? { phpOwner: owner } : {}),
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    const rawCalls = [];
    for (const k of ["function_call_expression", "member_call_expression", "scoped_call_expression"]) {
        for (const node of safeFindAll(root, k)) {
            const calleeName = extractCalleeNamePhp(node.text());
            if (!calleeName)
                continue;
            const r = node.range();
            const callLine = r.start.line + 1;
            const calleeQualifier = k === "scoped_call_expression" ? staticCallQualifier(node) : undefined;
            rawCalls.push({
                callerId: findCallerId(scopes, callLine, moduleSym.id),
                calleeName,
                kind: "call",
                ...(calleeQualifier ? { calleeQualifier } : {}),
                callSite: { file, line: callLine },
            });
        }
    }
    // ── Structural type references ──────────────────────────────────────
    // `extends`, `implements`, a trait `use`, `new`, and parameter, property and
    // return types — including a closure's and an arrow function's return type.
    // Without these a class reached only by inheritance or a type hint
    // has no symbol edge at all, so file-mode `codebase_impact` — which derives
    // its reverse index from resolved symbol edges — reports no dependents for
    // a base class every subclass names.
    //
    // Neither `sourceModule` nor `importedName` is set on these edges, and that
    // is deliberate. Resolution's module-targeted branch matches `sourceModule`
    // as a *path*; a PHP FQCN is not one, so handing it a backslashed name
    // strands the edge as `unresolved`. Each edge carries its namespace as
    // `calleeQualifier` instead, and resolution's PHP branch matches the whole
    // qualified class against the owners declared in the caller's own file, its
    // resolved file-import dependencies, and then the rest of the project. That
    // last step is what reaches an inline FQCN, or a sibling in the caller's own
    // namespace, that no `use` or require puts in the file graph; it cannot
    // guess, because it matches the qualified name rather than a short one.
    //
    // Aliases are resolved per namespace, not per file: see
    // {@link buildPhpNames} for why a file-wide table fabricates an edge in a
    // file that opens more than one namespace. The same `names()` the symbols
    // above were owned through, so a file's namespaces are read once.
    const typeRefs = [];
    // Takes the node rather than its text and line so the alias lookup can use
    // the reference's own source offset — two braced namespace blocks fit on one
    // line, a line number could not tell their references apart, and an alias
    // declared later on the same line must not reach back to it.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const pushTypeRef = (node, kind) => {
        const ref = classRefOf(node);
        if (!ref)
            return;
        const line = node.range().start.line + 1;
        // The namespace the class is declared in, as the prefix its class symbol is
        // owned under — `\App\Models\` for `App\Models\Invoice`, `\` for a class in
        // the global namespace. Resolution matches the class name inside it.
        const cut = ref.fqcn.lastIndexOf("\\");
        const calleeQualifier = cut < 0 ? "\\" : `\\${ref.fqcn.slice(0, cut)}\\`;
        typeRefs.push({
            callerId: findCallerId(scopes, line, moduleSym.id),
            calleeName: ref.calleeName,
            kind,
            localAlias: ref.localAlias,
            calleeQualifier,
            callSite: { file, line },
        });
    };
    // `class X extends Y` and `interface I extends J, K` are both `base_clause`;
    // `implements` is `class_interface_clause`, which an `enum` carries too; a
    // trait `use` inside a class body is `use_declaration` (a closure's
    // `use ($x)` is a different kind and cannot be confused with it). None of
    // the three exposes a useful named field, so the named types are read off
    // the direct children — which also keeps a `use T { a as b; }` alias list,
    // filed under a nested `use_list`, from being read as a trait name.
    for (const k of ["base_clause", "class_interface_clause", "use_declaration"]) {
        for (const clause of safeFindAll(root, k)) {
            for (const child of clause.children()) {
                const kind = child.kind();
                if (kind !== "name" && kind !== "qualified_name" && kind !== "relative_name")
                    continue;
                pushTypeRef(child, "type_reference");
            }
        }
    }
    // `new Foo()`. Recorded as a `call`, matching how the TypeScript extractor
    // treats `new_expression`. `new $cls()` names a `variable_name` and
    // `new class … {}` an `anonymous_class`, so neither yields a name here —
    // the anonymous class's own `extends` is still caught by the loop above.
    for (const node of safeFindAll(root, "object_creation_expression")) {
        for (const child of node.children()) {
            const kind = child.kind();
            if (kind !== "name" && kind !== "qualified_name" && kind !== "relative_name")
                continue;
            pushTypeRef(child, "call");
            break; // the class name is the only such child; `arguments` is its own node
        }
    }
    // Parameter, property and return types. `named_type` is the only wrapper that
    // carries a project name, and `findAll` on a node includes that node itself,
    // so one search unwraps `?Foo`, `A|B`, `A&B` and `(A&B)|null` alike and
    // yields nothing for a `primitive_type`.
    //
    // The two property kinds are the same declaration written two ways:
    // `property_promotion_parameter` is a constructor-promoted property, which is
    // a parameter in the signature, and `property_declaration` is the longhand
    // `private ?PersonRecord $rec = null;`. Reading only the promoted form made a
    // class's collaborator visible or invisible according to which spelling it
    // happened to use, which is not a distinction the graph should draw.
    //
    // A variadic parameter is its own node kind rather than a `simple_parameter`
    // with a flag, so `Handler ...$handlers` carries its type under the same
    // `type` field but is invisible to a scan that names only the plain kind.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const pushNamedTypes = (typeNode) => {
        for (const named of safeFindAll(typeNode, "named_type")) {
            pushTypeRef(named, "type_reference");
        }
    };
    for (const k of [
        "simple_parameter",
        "variadic_parameter",
        "property_promotion_parameter",
        "property_declaration",
    ]) {
        for (const node of safeFindAll(root, k)) {
            const typeNode = node.field("type");
            if (typeNode)
                pushNamedTypes(typeNode);
        }
    }
    // Closure and arrow-function return types. Their parameters were already
    // reached by the parameter scan above, so without this
    // `fn (): Gadget => …` named a collaborator the graph could not see, while
    // the same signature on a named function could.
    //
    // Collected here rather than in the declaration loop, because that loop
    // registers a symbol per node and neither form declares a name —
    // `safeFind(fn, "name")` would return the first `name` in the body and file a
    // symbol under it. Both kinds are read in one `safeFindAllAny` traversal,
    // which returns document order rather than grouping by kind; the named
    // declarations cannot use it for that reason, since the dedupe pins their
    // order, but nothing downstream tells these two apart.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const anonReturnTypes = [];
    returnTypes.set(ANON_RETURN_TYPES, anonReturnTypes);
    for (const fn of safeFindAllAny(root, ["anonymous_function", "arrow_function"])) {
        const returnType = fn.field("return_type");
        if (returnType)
            anonReturnTypes.push(returnType);
    }
    // Return types, from the nodes the loops above already held, in the order
    // the pass that used to re-walk for them emitted: methods, then functions.
    // The two anonymous forms follow, so the emission order the dedupe depends
    // on is unchanged for every file that has none.
    for (const k of ["method_declaration", "function_definition", ANON_RETURN_TYPES]) {
        for (const typeNode of returnTypes.get(k) ?? [])
            pushNamedTypes(typeNode);
    }
    // Deduplicate on (callerId, qualifier, calleeName, kind), as the TypeScript
    // extractor does on everything but the qualifier: a class named in both a
    // parameter and the return type of one method is one edge, not two. The
    // qualifier is part of the key because it is part of what the edge names —
    // `new A\Foo()` and `new B\Foo()` in one method are two classes, and a
    // `new Foo()` is not the same edge as a bare `Foo()` function call beside it.
    // The seen-set starts from the call edges already collected, so existing call
    // edges are never dropped, only matched against.
    const dedupeKey = (c) => `${c.callerId}::${c.calleeQualifier ?? ""}::${c.calleeName}::${c.kind}`;
    const seenTypeRefs = new Set(rawCalls.map(dedupeKey));
    for (const ref of typeRefs) {
        const key = dedupeKey(ref);
        if (seenTypeRefs.has(key))
            continue;
        seenTypeRefs.add(key);
        rawCalls.push(ref);
    }
    return { symbols, rawCalls };
}
// ── Swift ───────────────────────────────────────────────────────────────
function extractFromSwift(source, file, language, moduleSym) {
    const root = parse("swift", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    // The node kind does not say which form this is. The packaged Swift grammar
    // files a `class`, a `struct`, an `enum`, an `actor` and an `extension` all
    // under `class_declaration`, and defines no `struct_declaration` or
    // `enum_declaration` at all — asking for those threw, `safeFindAll` returned
    // empty, and the `struct` and `enum` branches below were unreachable, so every
    // Swift struct and enum was extracted as a `class`.
    //
    // What distinguishes them is the declaration keyword, and it is not reliably
    // the first child: `public struct P` puts a `modifiers` node first, and
    // `indirect enum E` an `indirect`. So the keyword is searched for among the
    // direct children rather than read off the head — `children()` does not
    // descend, so a nested `struct` inside a body cannot be mistaken for it.
    //
    // Only `struct` and `enum` are named. `class`, `actor` and `extension` keep
    // the `class` they already had; naming them is a separate question about what
    // those forms should be called, not this fix.
    const SWIFT_FORM_KINDS = new Map([
        ["struct", "struct"],
        ["enum", "enum"],
    ]);
    for (const k of ["class_declaration", "protocol_declaration"]) {
        for (const cls of safeFindAll(root, k)) {
            const nameNode = safeFind(cls, "type_identifier")
                ?? safeFind(cls, "identifier");
            if (!nameNode)
                continue;
            const name = nameNode.text();
            const r = cls.range();
            const startLine = r.start.line + 1;
            const endLine = r.end.line + 1;
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            const keyword = cls.children().find((c) => SWIFT_FORM_KINDS.has(c.kind()));
            const sym = {
                id: makeId(file, name, startLine),
                name, qualifiedName: name,
                kind: k === "protocol_declaration"
                    ? "interface"
                    : (keyword && SWIFT_FORM_KINDS.get(keyword.kind())) ?? "class",
                file, line: startLine, endLine, language,
            };
            symbols.push(sym);
            scopes.push({ name, startLine, endLine, symbolId: sym.id });
        }
    }
    for (const fn of safeFindAll(root, "function_declaration")) {
        const nameNode = safeFind(fn, "simple_identifier")
            ?? safeFind(fn, "identifier");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function",
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "call_expression")) {
        const calleeName = extractCalleeNameJs(node.text());
        if (!calleeName)
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── Bash ────────────────────────────────────────────────────────────────
function extractFromBash(source, file, language, moduleSym) {
    const root = parse("bash", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    for (const fn of safeFindAll(root, "function_definition")) {
        const nameNode = safeFind(fn, "word");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const r = fn.range();
        const startLine = r.start.line + 1;
        const endLine = r.end.line + 1;
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function",
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    for (const node of safeFindAll(root, "command")) {
        const nameNode = safeFind(node, "command_name");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        if (!/^[A-Za-z_][\w]*$/.test(name))
            continue;
        const r = node.range();
        const callLine = r.start.line + 1;
        rawCalls.push({
            callerId: findCallerId(scopes, callLine, moduleSym.id),
            calleeName: name,
            kind: "call",
            callSite: { file, line: callLine },
        });
    }
    return { symbols, rawCalls };
}
// ── Godot Resource (.tscn / .tres) — text-based extraction ────────────────
/**
 * Extract symbols from Godot scene (.tscn) and resource (.tres) files.
 *
 * These are text-based Godot resource formats with INI-like sections:
 *   [gd_scene load_steps=2 format=3]
 *   [ext_resource type="Script" path="res://scripts/Fighter.gd" id="1"]
 *   [node name="Fighter" type="CharacterBody2D"]
 *   script = ExtResource("1")
 *   [node name="HealthBar" type="ProgressBar" parent="."]
 *
 * We extract:
 * - Node definitions as variable symbols (name = node name, typeName = node type)
 *   so $NodePath resolution can look them up by name.
 * - Sub-resource definitions as variable symbols (typeName = resource type).
 *
 * External resource references ([ext_resource]) are handled by graph-imports.ts
 * for the file-import graph, not here.
 */
function extractFromGodotResource(source, file, language, moduleSym) {
    const symbols = [moduleSym];
    const rawCalls = [];
    const lines = source.split("\n");
    // Match section headers — attributes can appear in any order in Godot's
    // text resource format, so we match the header and extract attributes
    // generically (same approach for node, sub_resource, and ext_resource).
    const nodeHeaderRegex = /^\[node\s+/;
    const subResourceHeaderRegex = /^\[sub_resource\s+/;
    const extResourceHeaderRegex = /^\[ext_resource\s+/;
    const sectionHeaderRegex = /^\[/;
    const attrPairRegex = /(\w+)="([^"]*)"/g;
    // Match script = ExtResource("id") within node sections
    const scriptAssignRegex = /^script\s*=\s*ExtResource\(["']([^"']+)["']\)/;
    /** Extract quoted key/value attributes from one Godot resource header. */
    const headerAttributes = (line) => {
        const attrs = new Map();
        attrPairRegex.lastIndex = 0;
        for (;;) {
            const match = attrPairRegex.exec(line);
            if (!match)
                return attrs;
            attrs.set(match[1], match[2]);
        }
    };
    // Map ext_resource id → resource path (for script lookups)
    const extResourcePaths = new Map();
    // Track the current node section for script assignment parsing
    let currentNodeName = null;
    let currentNodeType = null;
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        // A script assignment belongs only to the immediately enclosing node
        // section. Any other section ends that node, including connection and
        // editable sections that do not otherwise produce symbols here.
        if (sectionHeaderRegex.test(line) && !nodeHeaderRegex.test(line)) {
            currentNodeName = null;
            currentNodeType = null;
        }
        // Ext resource declaration — extract path, type, and id from any order
        if (extResourceHeaderRegex.test(line)) {
            const attrs = headerAttributes(line);
            const resPath = attrs.get("path") ?? "";
            const resType = attrs.get("type") ?? "";
            const resId = attrs.get("id") ?? "";
            if (!resPath || !resId)
                continue;
            // Only track Script resources (the ones that matter for type resolution)
            if (resType === "Script" || resPath.endsWith(".gd")) {
                extResourcePaths.set(resId, resPath);
            }
            continue;
        }
        // Node definition — extract name and type from any attribute order
        if (nodeHeaderRegex.test(line)) {
            const attrs = headerAttributes(line);
            currentNodeName = attrs.get("name") ?? null;
            currentNodeType = attrs.get("type") ?? "Node";
            if (!currentNodeName)
                continue;
            const name = currentNodeName;
            const nodeType = currentNodeType;
            const qn = name; // Node names are top-level in the scene
            const sym = {
                id: makeId(file, qn, i + 1),
                name,
                qualifiedName: qn,
                kind: "variable",
                file,
                line: i + 1,
                endLine: i + 1,
                language,
                typeName: nodeType,
            };
            symbols.push(sym);
            continue;
        }
        // Sub-resource definition — extract type and id from any attribute order
        if (subResourceHeaderRegex.test(line)) {
            const attrs = headerAttributes(line);
            const resType = attrs.get("type") ?? "";
            const resId = attrs.get("id") ?? "";
            if (!resId)
                continue;
            const qn = `<sub_resource:${resId}>`;
            const sym = {
                id: makeId(file, qn, i + 1),
                name: resId,
                qualifiedName: qn,
                kind: "variable",
                file,
                line: i + 1,
                endLine: i + 1,
                language,
                typeName: resType,
            };
            symbols.push(sym);
            currentNodeName = null; // We're in a sub_resource section, not a node
            continue;
        }
        // Script assignment within a node section
        if (currentNodeName) {
            const scriptMatch = line.match(scriptAssignRegex);
            if (scriptMatch) {
                const extId = scriptMatch[1];
                const scriptPath = extResourcePaths.get(extId);
                if (scriptPath) {
                    // Update the last-pushed node symbol's typeName to a script marker.
                    // The marker `script:res://path` is resolved during call-site
                    // resolution to the actual class_name.
                    const nodeSym = symbols[symbols.length - 1];
                    if (nodeSym && nodeSym.kind === "variable" && nodeSym.name === currentNodeName) {
                        nodeSym.typeName = `script:${scriptPath}`;
                    }
                }
            }
        }
    }
    return { symbols, rawCalls };
}
/**
 * Extract symbols from GDScript source using the tree-sitter GDScript grammar.
 *
 * Extracts:
 * - `class_name` declarations as class symbols
 * - `function_definition` nodes as method symbols (qualified with class_name)
 * - `variable_statement` with `type` child as typed variable records (for
 *   receiver-type resolution in Phase 4)
 * - `call` nodes as bare function calls (callee name only)
 * - `attribute` → `attribute_call` as method calls with receiver + method name
 *
 * Falls back to regex extraction when the tree-sitter parser is unavailable
 * (e.g. linux-arm64 has no prebuild).
 */
function extractFromGdscript(source, file, language, moduleSym) {
    // Check if the GDScript parser is available; if not, use regex fallback.
    // The import is a live ESM binding from code-graph.ts, set during
    // ensureDynamicLanguages() at startup.
    if (!gdscriptParserAvailable) {
        return extractFromRegex(source, file, language, moduleSym);
    }
    const root = parse("gdscript", source).root();
    const symbols = [moduleSym];
    const scopes = [];
    const rawCalls = [];
    // Cache line count to avoid repeated source.split("\n") calls.
    const totalLines = source.split("\n").length;
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const kidsOf = (n) => {
        try {
            return n.children();
        }
        catch {
            return [];
        }
    };
    // Detect parse errors (ERROR/MISSING nodes from tree-sitter) and log a
    // diagnostic so users can identify malformed GDScript that produces
    // incomplete symbol extraction. We only scan the top-level children to
    // keep this cheap — deeply nested errors are still caught by the
    // extraction logic falling back to regex for unrecognized constructs.
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const topKids = kidsOf(root);
    const errorCount = topKids.filter((n) => {
        try {
            return n.kind() === "ERROR" || n.kind() === "MISSING";
        }
        catch {
            return false;
        }
    }).length;
    if (errorCount > 0) {
        logger.debug("GDScript parse errors detected — symbol extraction may be incomplete", {
            file,
            errorNodes: errorCount,
            totalLines,
        });
    }
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const lineOf = (n) => n.range().start.line + 1;
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const endLineOf = (n) => n.range().end.line + 1;
    // ── Extract class_name ──────────────────────────────────────────────
    let className = null;
    for (const node of safeFindAll(root, "class_name_statement")) {
        const nameNode = safeFind(node, "name");
        if (nameNode) {
            const cn = nameNode.text();
            className = cn;
            const startLine = lineOf(node);
            const endLine = totalLines;
            const sym = {
                id: makeId(file, cn, startLine),
                name: cn,
                qualifiedName: cn,
                kind: "class",
                file,
                line: startLine,
                endLine,
                language,
                isGdscriptClassName: true,
            };
            symbols.push(sym);
            scopes.push({ name: cn, startLine, endLine, symbolId: sym.id });
            break; // Only one class_name per file
        }
    }
    // ── Extract inner classes ───────────────────────────────────────────
    // `class Inner extends Node:` — inner classes are separate from class_name.
    // Their methods must be qualified with the inner class name, not the outer
    // class_name. We build a map of inner class range → qualified name so the
    // function extractor can check if a function is inside an inner class by
    // comparing line ranges (ast-grep node identity is not stable across
    // separate findAll calls, so we can't use a Map keyed by node reference).
    const innerClasses = [];
    for (const cls of safeFindAll(root, "class_definition")) {
        const nameNode = safeFind(cls, "name");
        if (!nameNode)
            continue;
        const innerName = nameNode.text();
        const startLine = lineOf(cls);
        const endLine = endLineOf(cls);
        // Build the full qualified name by checking if this inner class is nested
        // inside another inner class. We pick the narrowest enclosing inner class
        // (processed so far) and prepend its QN, so `class More` inside `class Inner`
        // inside `class_name Outer` gets `Outer.Inner.More`, not `Outer.More`.
        let prefix = className;
        let bestSpan = Infinity;
        for (const ic of innerClasses) {
            if (startLine >= ic.startLine && startLine <= ic.endLine) {
                const span = ic.endLine - ic.startLine;
                if (span < bestSpan) {
                    prefix = ic.qn;
                    bestSpan = span;
                }
            }
        }
        const innerQn = prefix ? `${prefix}.${innerName}` : innerName;
        const sym = {
            id: makeId(file, innerQn, startLine),
            name: innerName,
            qualifiedName: innerQn,
            kind: "class",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: innerQn, startLine, endLine, symbolId: sym.id });
        innerClasses.push({ qn: innerQn, startLine, endLine });
    }
    // Check if a line falls inside any inner class range.
    // Pick the narrowest span so nested inner classes (Outer.Inner.More)
    // are attributed correctly, not the outer container.
    const findInnerClass = (line) => {
        let best = null;
        for (const ic of innerClasses) {
            if (line >= ic.startLine && line <= ic.endLine) {
                const span = ic.endLine - ic.startLine;
                if (!best || span < best.span)
                    best = { qn: ic.qn, span };
            }
        }
        return best?.qn ?? null;
    };
    // ── Extract function definitions ────────────────────────────────────
    for (const fn of safeFindAll(root, "function_definition")) {
        const nameNode = safeFind(fn, "name");
        if (!nameNode)
            continue;
        const name = nameNode.text();
        const startLine = lineOf(fn);
        // If this function is inside an inner class, qualify with the inner class
        const innerClass = findInnerClass(startLine);
        const ownerClass = innerClass ?? className;
        const qn = ownerClass ? `${ownerClass}.${name}` : name;
        const endLine = endLineOf(fn);
        const sym = {
            id: makeId(file, qn, startLine),
            name,
            qualifiedName: qn,
            kind: ownerClass ? "method" : "function",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
    }
    // ── Extract constructor definitions (_init) ─────────────────────────
    // The GDScript grammar separates `func _init()` as `constructor_definition`
    // (no `name` field — the name is the bare token `_init` after `func`, and
    // its kind() is the name itself, not "identifier").
    for (const fn of safeFindAll(root, "constructor_definition")) {
        const kids = kidsOf(fn);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const funcKwIdx = kids.findIndex((c) => c.kind() === "func");
        const nameNode = funcKwIdx >= 0 && funcKwIdx + 1 < kids.length
            ? kids[funcKwIdx + 1]
            : null;
        if (!nameNode)
            continue;
        const name = nameNode.text(); // Always "_init"
        if (!name || name === "(")
            continue; // Safety: skip if not a name token
        const startLine = lineOf(fn);
        const innerClass = findInnerClass(startLine);
        const ownerClass = innerClass ?? className;
        const qn = ownerClass ? `${ownerClass}.${name}` : name;
        const endLine = endLineOf(fn);
        const sym = {
            id: makeId(file, qn, startLine),
            name,
            qualifiedName: qn,
            kind: "constructor",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
    }
    // ── Extract lambdas (anonymous functions) ───────────────────────────
    // `var f = func(): pass` or `call(func(): return 42)` — inline callbacks.
    // These are not named but create a scope so calls inside the lambda body
    // are attributed to the lambda instead of <module>.
    for (const lambda of safeFindAll(root, "lambda")) {
        const startLine = lineOf(lambda);
        const endLine = endLineOf(lambda);
        const qn = className
            ? `${className}.<lambda>#${startLine}`
            : `<lambda>#${startLine}`;
        const sym = {
            id: makeId(file, qn, startLine),
            name: `<lambda>#${startLine}`,
            qualifiedName: qn,
            kind: "function",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
        scopes.push({ name: qn, startLine, endLine, symbolId: sym.id });
    }
    // ── Extract typed variables (for receiver-type resolution) ──────────
    // `var opponent: Fighter` → variable symbol with typeName = "Fighter"
    // `var health: int = 100` → variable symbol with typeName = "int"
    // These are not callable symbols but are needed to resolve receiver types
    // for method calls like `opponent.attack()`.
    for (const vs of safeFindAll(root, "variable_statement")) {
        const nameNode = safeFind(vs, "name");
        const typeNode = kidsOf(vs).find((child) => child.kind() === "type");
        if (!nameNode || !typeNode)
            continue;
        const varName = nameNode.text();
        const typeName = typeNode.text();
        // Skip primitive types — they have no methods to resolve
        // Note: String, Array, Dictionary are builtin classes with methods,
        // so they are NOT skipped here — they resolve as engine API via
        // GODOT_BUILTIN_CLASSES during receiver-type resolution.
        if (["int", "float", "bool", "void", "Nil", "null"].includes(typeName))
            continue;
        const qn = className ? `${className}.${varName}` : varName;
        const startLine = lineOf(vs);
        const endLine = startLine; // Variables are single-line for scope purposes
        const sym = {
            id: makeId(file, qn, startLine),
            name: varName,
            qualifiedName: qn,
            kind: "variable",
            file,
            line: startLine,
            endLine,
            language,
            typeName,
        };
        symbols.push(sym);
        // Don't add variables as scope frames — they're not callable scopes
    }
    // ── Extract assignment-site type inferences ─────────────────────────
    // Two sources:
    // 1. `variable_statement` with initializer but no type annotation:
    //    `var x = Fighter.new()` → inferredTypes["x"] = "Fighter"
    //    `var x = self`          → inferredTypes["x"] = "<self>"
    //    `var x = opponent`      → inferredTypes["x"] = "ref:opponent"
    // 2. `assignment` nodes:
    //    `x = Fighter.new()`     → inferredTypes["x"] = "Fighter"
    //    `state.member = self`   → memberAssignments.push({receiver, member, valueType})
    //
    // These are resolved during Phase 4 (resolveCallSites) where the
    // classNameIndex and typedVars are available to interpret "<self>" and
    // "ref:varName" markers.
    const inferredTypes = new Map();
    const memberAssignments = [];
    // Helper: find the enclosing scope's line range for a given line.
    // Returns the deepest function/class scope containing the line, or
    // the module scope (entire file) if no function scope is found.
    const findScopeRange = (line) => {
        let best = null;
        let bestSpan = Infinity;
        for (const s of scopes) {
            if (line >= s.startLine && line <= s.endLine) {
                const span = s.endLine - s.startLine;
                if (span < bestSpan) {
                    bestSpan = span;
                    best = { startLine: s.startLine, endLine: s.endLine };
                }
            }
        }
        return best ?? { startLine: 1, endLine: totalLines };
    };
    // Helper: record an inferred type with scope info.
    const recordInferred = (varName, type, line) => {
        const scope = findScopeRange(line);
        let arr = inferredTypes.get(varName);
        if (!arr) {
            arr = [];
            inferredTypes.set(varName, arr);
        }
        // Replace any existing inference in the same scope (last-writer-wins
        // within a scope), or add a new one.
        const existingIdx = arr.findIndex((e) => e.startLine === scope.startLine && e.endLine === scope.endLine);
        if (existingIdx >= 0) {
            arr[existingIdx] = { type, ...scope };
        }
        else {
            arr.push({ type, ...scope });
        }
    };
    // Helper: infer type from an RHS expression node.
    // Returns a type name, "<self>", or "ref:varName".
    // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
    const inferTypeFromExpr = (exprNode) => {
        if (!exprNode)
            return null;
        // self → special marker (resolved to file's className during resolution)
        if (exprNode.kind() === "identifier" && exprNode.text() === "self")
            return "<self>";
        // Builtin type literals → infer the builtin type
        if (exprNode.kind() === "array")
            return "Array";
        if (exprNode.kind() === "dictionary")
            return "Dictionary";
        if (exprNode.kind() === "string")
            return "String";
        if (exprNode.kind() === "integer")
            return "int";
        if (exprNode.kind() === "float")
            return "float";
        // ClassName.new() → attribute with attribute_call method "new"
        if (exprNode.kind() === "attribute") {
            const attrCall = exprNode.find({ rule: { kind: "attribute_call" } });
            if (attrCall) {
                const methodId = attrCall.find({ rule: { kind: "identifier" } });
                if (methodId?.text() === "new") {
                    // The receiver is the first child of the attribute (before the dot).
                    // Only infer a type if the receiver is a plain identifier (class name).
                    // If it's a call (preload(...), load(...)) or other expression, we
                    // can't determine the type without resolving the script path.
                    const receiverNode = kidsOf(exprNode)[0];
                    if (receiverNode?.kind() === "identifier") {
                        const receiverName = receiverNode.text();
                        // Skip builtin function names used as static callers
                        if (receiverName !== "preload" && receiverName !== "load") {
                            return receiverName;
                        }
                    }
                }
            }
            return null;
        }
        // Bare identifier → reference to another variable
        if (exprNode.kind() === "identifier") {
            const name = exprNode.text();
            // Skip keywords and builtins
            if (["true", "false", "null"].includes(name))
                return null;
            return `ref:${name}`;
        }
        return null;
    };
    // 1. variable_statement with initializer
    //    Captures inferred types for both untyped vars (P1) and typed vars (P2).
    //    For typed vars, the inferred type is used for widened-annotation
    //    narrowing during resolution (prefer runtime type over declared base).
    for (const vs of safeFindAll(root, "variable_statement")) {
        const nameNode = safeFind(vs, "name");
        if (!nameNode)
            continue;
        const varName = nameNode.text();
        // Find the initializer expression (child after "=")
        const kids = kidsOf(vs);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const eqIdx = kids.findIndex((c) => c.kind() === "=");
        if (eqIdx < 0 || eqIdx + 1 >= kids.length)
            continue;
        const exprNode = kids[eqIdx + 1];
        const inferred = inferTypeFromExpr(exprNode);
        if (inferred)
            recordInferred(varName, inferred, lineOf(vs));
    }
    // 2. assignment nodes
    for (const a of safeFindAll(root, "assignment")) {
        const kids = kidsOf(a);
        // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
        const eqIdx = kids.findIndex((c) => c.kind() === "=");
        if (eqIdx < 0 || eqIdx + 1 >= kids.length)
            continue;
        const lhs = kids[0];
        const rhs = kids[eqIdx + 1];
        const inferred = inferTypeFromExpr(rhs);
        if (!inferred)
            continue;
        if (lhs.kind() === "identifier") {
            // Simple assignment: x = Expr
            recordInferred(lhs.text(), inferred, lineOf(a));
        }
        else if (lhs.kind() === "attribute") {
            // Member assignment: receiver.member = Expr
            // The attribute has identifiers: receiver and member name
            // The first identifier is the receiver, the last is the member
            const lhsKids = kidsOf(lhs);
            const allIds = lhsKids.filter(
            // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
            (c) => c.kind() === "identifier");
            if (allIds.length === 2) {
                const receiver = allIds[0].text();
                const memberName = allIds[1].text();
                memberAssignments.push({ receiver, memberName, valueType: inferred });
            }
            // Single-identifier attributes shouldn't occur for assignments — skip.
        }
    }
    // ── Extract signals ─────────────────────────────────────────────────
    // `signal hit_landed(damage: int)` → signal symbol, callable via .emit()
    // `signal died` → signal symbol
    for (const sig of safeFindAll(root, "signal_statement")) {
        const nameNode = safeFind(sig, "name");
        if (!nameNode)
            continue;
        const sigName = nameNode.text();
        const qn = className ? `${className}.${sigName}` : sigName;
        const startLine = lineOf(sig);
        const endLine = startLine;
        const sym = {
            id: makeId(file, qn, startLine),
            name: sigName,
            qualifiedName: qn,
            kind: "signal",
            file,
            line: startLine,
            endLine,
            language,
        };
        symbols.push(sym);
    }
    // ── Extract enums and their members ─────────────────────────────────
    // `enum State { IDLE, WALK, ATTACK }` → enum symbol + constant per member
    // `enum { RED, GREEN, BLUE }` → anonymous enum, constants only
    for (const en of safeFindAll(root, "enum_definition")) {
        const nameNode = safeFind(en, "name");
        const enumName = nameNode?.text() ?? null;
        const startLine = lineOf(en);
        const endLine = endLineOf(en);
        if (enumName) {
            const qn = className ? `${className}.${enumName}` : enumName;
            const sym = {
                id: makeId(file, qn, startLine),
                name: enumName,
                qualifiedName: qn,
                kind: "enum",
                file,
                line: startLine,
                endLine,
                language,
            };
            symbols.push(sym);
        }
        // Extract enum members as constants
        for (const enumerator of safeFindAll(en, "enumerator")) {
            const idNode = safeFind(enumerator, "identifier");
            if (!idNode)
                continue;
            const memberName = idNode.text();
            // Qualified as EnumName.MEMBER or just MEMBER for anonymous enums
            const qn = enumName
                ? (className ? `${className}.${enumName}.${memberName}` : `${enumName}.${memberName}`)
                : memberName;
            const memberLine = lineOf(enumerator);
            const sym = {
                id: makeId(file, qn, memberLine),
                name: memberName,
                qualifiedName: qn,
                kind: "constant",
                file,
                line: memberLine,
                endLine: memberLine,
                language,
            };
            symbols.push(sym);
        }
    }
    // ── Extract constants ───────────────────────────────────────────────
    // `const MAX_HEALTH = 100` → constant symbol
    // `const SPEED: float = 500.0` → constant symbol with typeName
    for (const cs of safeFindAll(root, "const_statement")) {
        const nameNode = safeFind(cs, "name");
        if (!nameNode)
            continue;
        const constName = nameNode.text();
        const qn = className ? `${className}.${constName}` : constName;
        const startLine = lineOf(cs);
        const endLine = startLine;
        const typeNode = kidsOf(cs).find((child) => child.kind() === "type");
        const sym = {
            id: makeId(file, qn, startLine),
            name: constName,
            qualifiedName: qn,
            kind: "constant",
            file,
            line: startLine,
            endLine,
            language,
            ...(typeNode ? { typeName: typeNode.text() } : {}),
        };
        symbols.push(sym);
    }
    // ── Extract calls ───────────────────────────────────────────────────
    // GDScript has two call forms:
    //   1. `call` node: bare function call — emit calleeName only
    //   2. `attribute` node containing `attribute_call`: method call —
    //      emit calleeName + receiver (the identifier before the dot)
    // GDScript keywords only — NOT Godot builtin functions. Builtin function
    // names (print, push_error, etc.) are filtered at resolution time after
    // local lookup, so a user-defined `func print()` can shadow the engine builtin.
    // `emit` and `connect` are handled in the attribute-call path above for
    // signal emit/connect; as bare calls they're meaningless and will be
    // filtered by GODOT_BUILTIN_FUNCTIONS at resolution time.
    const KW = new Set([
        "if", "for", "while", "return", "func", "class", "var", "const",
        "signal", "enum", "match", "break", "continue", "pass", "assert",
        "await", "yield", "and", "or", "not", "in", "as", "is", "elif", "else",
        "true", "false", "null",
    ]);
    // Method calls: one attribute can contain several direct attribute_call
    // children, as in `fighter.get_state().update()`. Process each direct child
    // exactly once so the inner and terminal methods are both represented.
    for (const attr of safeFindAll(root, "attribute")) {
        const attrChildren = kidsOf(attr);
        const attrCalls = attrChildren.filter((child) => child.kind() === "attribute_call");
        for (const attrCall of attrCalls) {
            const methodId = safeFind(attrCall, "identifier");
            if (!methodId)
                continue;
            const callee = methodId.text();
            const callIndex = attrChildren.indexOf(attrCall);
            const receiverChildren = attrChildren.slice(0, callIndex);
            const firstChild = receiverChildren[0];
            // Receiver is the expression before this particular method call.
            // Attribute-call names already traversed become receiver segments, so
            // the terminal call in `fighter.get_state().update()` has receiver
            // `fighter.get_state` while the inner call has receiver `fighter`.
            let receiver = null;
            if (firstChild?.kind() === "identifier") {
                const receiverParts = [];
                for (const child of receiverChildren) {
                    if (child.kind() === "identifier")
                        receiverParts.push(child.text());
                    else if (child.kind() === "attribute_call") {
                        const priorMethodId = safeFind(child, "identifier");
                        if (priorMethodId)
                            receiverParts.push(priorMethodId.text());
                    }
                }
                receiver = receiverParts.length > 0 ? receiverParts.join(".") : null;
            }
            else if (firstChild?.kind() === "get_node") {
                const gnText = firstChild.text();
                const nodePath = gnText.startsWith("$")
                    ? gnText.slice(1)
                    : safeFind(firstChild, "string")?.text().replace(/^['"]|['"]$/g, "") ?? null;
                if (nodePath) {
                    const receiverParts = [nodePath];
                    for (const child of receiverChildren.slice(1)) {
                        if (child.kind() === "identifier")
                            receiverParts.push(child.text());
                        else if (child.kind() === "attribute_call") {
                            const priorMethodId = safeFind(child, "identifier");
                            if (priorMethodId)
                                receiverParts.push(priorMethodId.text());
                        }
                    }
                    receiver = receiverParts.join(".");
                }
            }
            // Signal emit/connect: `signal_name.emit(args)` or
            // `signal_name.connect(handler)`. The signal is the final identifier
            // immediately before this call; a preceding method call is dynamic and
            // cannot safely be treated as a statically named signal.
            if (callee === "emit" || callee === "connect") {
                let precedingValue = null;
                for (let i = receiverChildren.length - 1; i >= 0; i--) {
                    if (receiverChildren[i].kind() !== ".") {
                        precedingValue = receiverChildren[i];
                        break;
                    }
                }
                if (precedingValue?.kind() === "identifier") {
                    const signalName = precedingValue.text();
                    if (callee === "emit") {
                        rawCalls.push({
                            calleeName: signalName,
                            kind: "call",
                            callSite: { file, line: lineOf(attr) },
                            callerId: findCallerId(scopes, lineOf(attr), moduleSym.id),
                            signalEmit: true,
                        });
                    }
                    else {
                        const args = safeFind(attrCall, "arguments");
                        if (args) {
                            for (const arg of kidsOf(args)) {
                                if (arg.kind() === "identifier") {
                                    rawCalls.push({
                                        calleeName: arg.text(),
                                        kind: "call",
                                        callSite: { file, line: lineOf(attr) },
                                        callerId: findCallerId(scopes, lineOf(attr), moduleSym.id),
                                    });
                                    break;
                                }
                                if (arg.kind() === "attribute") {
                                    const argIds = arg.findAll({ rule: { kind: "identifier" } });
                                    if (argIds.length > 0) {
                                        rawCalls.push({
                                            calleeName: argIds[argIds.length - 1].text(),
                                            kind: "call",
                                            receiver: argIds.length > 1 ? argIds[0].text() : undefined,
                                            callSite: { file, line: lineOf(attr) },
                                            callerId: findCallerId(scopes, lineOf(attr), moduleSym.id),
                                        });
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
                continue;
            }
            // Dynamic dispatch on attribute calls uses the first string argument as
            // the real method or signal name instead of exposing the helper method.
            if (callee === "call" || callee === "call_deferred" || callee === "emit_signal") {
                const args = safeFind(attrCall, "arguments");
                const strNode = args ? safeFind(args, "string") : null;
                if (strNode) {
                    const methodName = strNode.text().replace(/^['"]|['"]$/g, "");
                    if (methodName && /^[A-Za-z_][\w]*$/.test(methodName)) {
                        const line = lineOf(attr);
                        rawCalls.push({
                            callerId: findCallerId(scopes, line, moduleSym.id),
                            calleeName: methodName,
                            kind: "call",
                            callSite: { file, line },
                            ...(receiver ? { receiver } : {}),
                        });
                    }
                }
                continue;
            }
            const line = lineOf(attr);
            rawCalls.push({
                callerId: findCallerId(scopes, line, moduleSym.id),
                calleeName: callee,
                kind: "call",
                callSite: { file, line },
                ...(receiver ? { receiver } : {}),
            });
        }
    }
    // Bare function calls: call nodes (not inside an attribute)
    // Includes dynamic dispatch resolution: call("method"), call_deferred("method"),
    // emit_signal("signal") → extract the string argument as the real callee name.
    // connect("signal", target, "method") → extract the last string arg as method name.
    const DYNAMIC_DISPATCH = new Set(["call", "call_deferred", "emit_signal"]);
    for (const call of safeFindAll(root, "call")) {
        // Skip calls that are children of attribute nodes (already handled above)
        const parent = call.parent();
        if (parent && parent.kind() === "attribute_call")
            continue;
        const idNode = safeFind(call, "identifier");
        if (!idNode)
            continue;
        const callee = idNode.text();
        // connect("signal", target, "method") — special case: last string arg is method name
        // Godot 3 style signal connection, still common in Godot 4 codebases.
        // `self` is an identifier (not a string), so we collect all string args
        // and use the last one as the method name: connect("hit", self, "_on_hit")
        // has 2 strings: "hit" and "_on_hit" → method = "_on_hit".
        if (callee === "connect") {
            const args = safeFind(call, "arguments");
            if (args) {
                // biome-ignore lint/suspicious/noExplicitAny: ast-grep node type leaks through
                const strNodes = args.findAll({ rule: { kind: "string" } }).map((n) => n);
                if (strNodes.length >= 2) {
                    const methodName = strNodes[strNodes.length - 1].text().replace(/^['"]|['"]$/g, "");
                    if (methodName && /^[A-Za-z_][\w]*$/.test(methodName)) {
                        const line = lineOf(call);
                        rawCalls.push({
                            callerId: findCallerId(scopes, line, moduleSym.id),
                            calleeName: methodName,
                            kind: "call",
                            callSite: { file, line },
                        });
                        continue;
                    }
                }
            }
            // If connect doesn't match the expected pattern, skip it entirely
            // (don't emit "connect" as a callee name — it would be unresolved noise)
            continue;
        }
        // Dynamic dispatch: extract method/signal name from first string argument.
        // `call("take_damage", 10)` → calleeName = "take_damage"
        // `emit_signal("died")` → calleeName = "died"
        // If the first arg is not a string literal, fall through to normal handling.
        if (DYNAMIC_DISPATCH.has(callee)) {
            const args = safeFind(call, "arguments");
            if (args) {
                const strNode = safeFind(args, "string");
                if (strNode) {
                    // Strip quotes (single or double) from the string literal
                    const methodName = strNode.text().replace(/^['"]|['"]$/g, "");
                    if (methodName && /^[A-Za-z_][\w]*$/.test(methodName)) {
                        const line = lineOf(call);
                        rawCalls.push({
                            callerId: findCallerId(scopes, line, moduleSym.id),
                            calleeName: methodName,
                            kind: "call",
                            callSite: { file, line },
                        });
                        continue;
                    }
                }
            }
            // String arg not found — skip dynamic dispatch calls rather than
            // emitting "call"/"emit_signal" as callee names (they'd be unresolved).
            continue;
        }
        if (KW.has(callee))
            continue;
        const line = lineOf(call);
        rawCalls.push({
            callerId: findCallerId(scopes, line, moduleSym.id),
            calleeName: callee,
            kind: "call",
            callSite: { file, line },
        });
    }
    return { symbols, rawCalls, inferredTypes, memberAssignments };
}
// ── Regex fallback (Dart, Lua, Svelte/Vue, anything unsupported) ────────
function extractFromRegex(source, file, language, moduleSym) {
    const symbols = [moduleSym];
    const scopes = [];
    const lines = source.split("\n");
    // Generic `function NAME` / `def NAME` / `fn NAME` / `func NAME` patterns
    const fnRegex = /^\s*(?:export\s+|public\s+|private\s+|static\s+|async\s+)*(?:function|def|fn|func|sub|local\s+function)\s+([A-Za-z_][\w]*)/;
    for (let i = 0; i < lines.length; i++) {
        const m = lines[i].match(fnRegex);
        if (!m)
            continue;
        const name = m[1];
        const startLine = i + 1;
        // Heuristic end line: next line with same or less indentation
        const indent = lines[i].match(/^\s*/)?.[0].length ?? 0;
        let endLine = startLine;
        for (let j = i + 1; j < lines.length; j++) {
            const text = lines[j];
            if (text.trim() === "")
                continue;
            const ind = text.match(/^\s*/)?.[0].length ?? 0;
            if (ind <= indent)
                break;
            endLine = j + 1;
        }
        const sym = {
            id: makeId(file, name, startLine),
            name, qualifiedName: name, kind: "function",
            file, line: startLine, endLine, language,
        };
        symbols.push(sym);
        scopes.push({ name, startLine, endLine, symbolId: sym.id });
    }
    const rawCalls = [];
    const callRegex = /([A-Za-z_][\w]*)\s*\(/g;
    for (let i = 0; i < lines.length; i++) {
        let m = null;
        callRegex.lastIndex = 0;
        m = callRegex.exec(lines[i]);
        while (m !== null) {
            const name = m[1];
            // Skip language keywords/control flow
            if (!["if", "for", "while", "switch", "return", "function", "def", "fn", "func", "class", "new"].includes(name)) {
                const callLine = i + 1;
                rawCalls.push({
                    callerId: findCallerId(scopes, callLine, moduleSym.id),
                    calleeName: name,
                    kind: "call",
                    callSite: { file, line: callLine },
                });
            }
            m = callRegex.exec(lines[i]);
        }
    }
    return { symbols, rawCalls };
}
/** Convert raw call sites to unresolved SymbolEdge objects (resolution in Phase C). */
export function rawCallsToUnresolvedEdges(rawCalls) {
    return rawCalls.map((c) => ({
        callerId: c.callerId,
        calleeName: c.calleeName,
        calleeCandidates: [],
        confidence: "unresolved",
        kind: c.kind,
        sourceModule: c.sourceModule,
        importedName: c.importedName,
        localAlias: c.localAlias,
        calleeQualifier: c.calleeQualifier,
        ...(c.csMemberReceiver ? { csMemberReceiver: true } : {}),
        callSite: c.callSite,
        ...(c.receiver ? { receiver: c.receiver } : {}),
        ...(c.signalEmit ? { signalEmit: true } : {}),
    }));
}
//# sourceMappingURL=graph-symbols.js.map