UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

1,973 lines • 81.2 kB
/**
 * Module Report (#245) — a structured, navigable substitute for raw full-file
 * reads. An agent calls this to understand a module's shape (outline, signatures,
 * who-uses-this) before requesting exact source, so `read` becomes the fallback
 * rather than the default.
 *
 * Language-uniform by construction: the outline is extracted for EVERY supported
 * language through the one tree-sitter symbol extractor (jsts uses the
 * `typescript` query), giving the same fields — name/kind/startLine/endLine/
 * signature/exported — across all 18 SYMBOL_QUERIES languages plus jsts. The
 * review graph supplies cross-file enrichment (who-uses-this, complexity, fanout)
 * merged onto entries by symbol name; that enrichment is additive and varies by
 * language (e.g. complexity exists for jsts), which is honest rather than faked.
 *
 * Single mode (no depth knob — #256). READ-ONLY by contract — it never builds a
 * graph and never calls an LSP server on this path (both repeatedly OOM'd pi when
 * an agent fanned out reports). Every call:
 *   1. tree-sitter extract of THE one file (always; cold-safe structure).
 *   2. language-agnostic inline executable extraction over the same tree-sitter
 *      AST (callbacks/closures/lambdas/function literals; no second parse).
 *   3. read the already-built review graph (in-memory, else the persisted disk
 *      snapshot) for who-uses-this / flags / imports — never a build. Cold cache
 *      → outline only.
 * `semantic.source` reflects who-uses-this provenance: "review-graph" when the
 * cached graph backs it, else "none" (cold). Live-LSP enrichment is re-homed to
 * #236, where LSP writes provenance-tagged edges INTO the graph (once, persisted)
 * for this path to read as "graph-lsp". That logic lives in clients/module-report-lsp.ts.
 *
 * Guard integrity: moduleReport injects NO read records — an outline is not
 * "having seen the body". readSymbol returns the actual body lines so the host
 * can record a read that legitimately satisfies the read-guard for that symbol.
 */
import * as fs from "node:fs";
import * as path from "node:path";
import { detectFileKind } from "./file-kinds.js";
import { logLatency } from "./latency-logger.js";
import { annotateMiddleMan } from "./middle-man-analysis.js";
import { normalizeMapKey } from "./path-utils.js";
import { resolveImportToFiles } from "./review-graph/import-resolvers.js";
import { buildSymbolId } from "./review-graph/symbol-id.js";
import { getSharedTreeSitterClient } from "./tree-sitter-shared.js";
import { TreeSitterSymbolExtractor, } from "./tree-sitter-symbol-extractor.js";
// kind -> tree-sitter languageId. The languageId keys BOTH the grammar map
// (tree-sitter-client) and SYMBOL_QUERIES (tree-sitter-symbol-extractor), so it
// must match a key present in both. jsts/cxx are resolved by extension below so
// the JSX-aware tsx grammar and the c-vs-cpp split are honoured. Using these
// gives the primary languages the same rich outline (classes/interfaces/types/
// signatures) as every other language, not the functions-only FunctionSummary.
const KIND_TO_TS_LANG = {
    python: "python",
    go: "go",
    rust: "rust",
    ruby: "ruby",
    java: "java",
    kotlin: "kotlin",
    dart: "dart",
    elixir: "elixir",
    csharp: "csharp",
    php: "php",
    swift: "swift",
    lua: "lua",
    ocaml: "ocaml",
    zig: "zig",
    shell: "bash",
    // cxx resolved by extension below (c vs cpp)
};
function tsLangForFile(filePath, kind) {
    const ext = path.extname(filePath).toLowerCase();
    if (kind === "cxx") {
        return ext === ".c" || ext === ".h" ? "c" : "cpp";
    }
    if (kind === "jsts") {
        // Route JSX-bearing files to the tsx grammar (downloaded), plain TS/JS to
        // the typescript grammar; both share the same SYMBOL_QUERIES.
        return ext === ".tsx" || ext === ".jsx" ? "tsx" : "typescript";
    }
    return kind ? KIND_TO_TS_LANG[kind] : undefined;
}
// Per-language extractor cache — extractors are cheap once their queries are
// compiled. The shared TreeSitterClient (which memoizes grammar init) is obtained
// per call from the process-wide singleton.
const extractorCache = new Map();
async function getExtractor(languageId) {
    let cached = extractorCache.get(languageId);
    if (!cached) {
        cached = (async () => {
            const client = getSharedTreeSitterClient();
            if (!client)
                return null;
            const extractor = new TreeSitterSymbolExtractor(languageId, client);
            const ok = await extractor.init();
            return ok ? extractor : null;
        })().catch((err) => {
            extractorCache.delete(languageId);
            throw err;
        });
        extractorCache.set(languageId, cached);
    }
    return cached;
}
function diagnosticMessage(err) {
    return err instanceof Error ? err.message : String(err);
}
async function extractFile(absPath, languageId, content) {
    try {
        const tsClient = getSharedTreeSitterClient();
        if (!tsClient) {
            return {
                symbols: [],
                imports: [],
                error: "tree-sitter runtime unavailable (wasm aborted)",
            };
        }
        const initialized = await tsClient.init();
        if (!initialized) {
            return {
                symbols: [],
                imports: [],
                error: "tree-sitter runtime failed to initialize",
            };
        }
        const tree = await tsClient.parseFile(absPath, languageId);
        if (!tree) {
            return {
                symbols: [],
                imports: [],
                error: `tree-sitter failed to parse as ${languageId}`,
            };
        }
        const extractor = await getExtractor(languageId);
        const root = tree.rootNode;
        if (!extractor) {
            return {
                symbols: [],
                imports: [],
                root,
                warnings: [`Symbol extractor not available for ${languageId}`],
            };
        }
        const result = extractor.extract(tree, absPath, content);
        return { symbols: result.symbols, imports: result.imports, root };
    }
    catch (err) {
        const message = diagnosticMessage(err);
        logLatency({
            type: "phase",
            phase: "module_report_extract_error",
            filePath: absPath,
            durationMs: 0,
            metadata: { error: message },
        });
        return { symbols: [], imports: [], error: message };
    }
}
function readArgsFor(filePath, startLine, endLine) {
    const offset = Math.max(1, startLine);
    const limit = Math.max(1, endLine - startLine + 1);
    return { path: filePath, offset, limit };
}
function resolveUsedBy(graph, symbolNodeId, cap, projectRoot) {
    const out = [];
    const seen = new Set();
    for (const edge of graph.edgesByTo.get(symbolNodeId) ?? []) {
        if (edge.kind !== "calls" && edge.kind !== "references")
            continue;
        const from = graph.nodes.get(edge.from);
        const rawFile = from?.filePath ??
            (edge.from.startsWith("file:") ? edge.from.slice("file:".length) : "");
        if (!rawFile)
            continue;
        const file = toDisplayPath(rawFile, projectRoot);
        // refs #655 phase 2: prefer the owner-qualified display name
        // (`UserService.run`) when the caller has one, so two different classes'
        // same-named methods are distinguishable in `usedBy`/`blastRadius` output
        // without cross-referencing line numbers. This exact dotted string is a
        // valid `read_symbol` qualifier (see `ReviewGraphNode.qualifiedName`'s doc
        // comment) — both derive from the same containment notion of "owner".
        const symbol = from?.qualifiedName ?? from?.symbolName ?? "";
        // Caller line: a symbol caller node carries metadata.line; a file-level
        // `references` edge carries the line on the edge metadata.
        const line = (typeof from?.metadata?.line === "number"
            ? from.metadata.line
            : undefined) ??
            (typeof edge.metadata?.line === "number"
                ? edge.metadata.line
                : 0);
        const key = `${file} ${symbol} ${edge.kind}`;
        if (seen.has(key))
            continue;
        seen.add(key);
        out.push({
            file,
            symbol,
            line,
            relation: edge.kind,
            ...(edge.resolution ? { resolution: edge.resolution } : {}),
        });
        if (out.length >= cap)
            break;
    }
    return out;
}
// Human-facing path: cwd-relative + forward-slashed when the file sits under the
// project root, else the absolute (slash-normalized) path. Machine fields (the
// `read` args) keep the absolute path so the host's Read tool resolves them
// unambiguously; only display fields (`path`, `usedBy.file`, imports) relativize.
function toDisplayPath(p, projectRoot) {
    if (!path.isAbsolute(p))
        return p.replace(/\\/g, "/");
    const rel = path.relative(projectRoot, p);
    return rel && !rel.startsWith("..")
        ? rel.replace(/\\/g, "/")
        : p.replace(/\\/g, "/");
}
function collectImports(graph, normalizedPath, projectRoot) {
    const fileNodeId = graph.fileNodes.get(normalizedPath);
    const external = new Set();
    const internal = new Set();
    if (fileNodeId) {
        for (const edge of graph.edgesByFrom.get(fileNodeId) ?? []) {
            if (edge.kind !== "imports")
                continue;
            const target = graph.nodes.get(edge.to);
            if (!target)
                continue;
            if (target.kind === "external") {
                external.add(String(target.metadata?.source ?? edge.to));
            }
            else if (target.filePath) {
                internal.add(toDisplayPath(target.filePath, projectRoot));
            }
            else {
                internal.add(String(target.metadata?.source ?? edge.to));
            }
        }
    }
    return {
        external: [...external].sort((a, b) => a.localeCompare(b)),
        internal: [...internal].sort((a, b) => a.localeCompare(b)),
    };
}
// An import source string "looks internal" when its shape names a same-project
// target the per-language resolver couldn't pin to a file (a not-yet-created
// file, or a language we don't resolve to disk). Relative paths and Rust's
// crate-relative prefixes are unambiguously in-project; everything else
// (bare specifiers, absolute package paths) is treated as external. This is the
// floor under `resolveImportToFiles` — it never fabricates a file path, only a
// best-effort internal/external bucket.
function looksInternal(source, languageId) {
    // C/C++ (#302): a #include is local-vs-system by syntax, not by a leading dot.
    // A system header keeps its angle brackets (<stdio.h>) → external; a quoted
    // local include arrives bare (foo.h, quotes already stripped) → internal, even
    // when the header file isn't on disk.
    if (languageId === "c" || languageId === "cpp") {
        return !source.startsWith("<");
    }
    // A leading "." is relative across every language we extract (JS ./ ../,
    // Python . .foo ..pkg, Ruby/Dart/bash relative paths) and never begins a bare
    // or scoped package specifier (react, @scope/pkg, java.util.List, fmt). Rust's
    // crate-relative prefixes are likewise unambiguously in-project.
    return (source.startsWith(".") ||
        source.startsWith("crate::") ||
        source.startsWith("super::") ||
        source.startsWith("self::"));
}
// Cold-cache imports (#301): the warm review graph is the source of truth for
// imports, but on a cold cache it's absent and `collectImports` returns empty
// even though the tree-sitter extractor already parsed the import sources. Rebuild
// the same {external, internal} shape language-uniformly from those sources:
// resolve each to real in-project files via the warm graph's own resolver
// (`resolveImportToFiles`) when the language supports it, else fall back to the
// shape heuristic. Internal entries are cwd-relative display paths (resolved) or
// the raw source (heuristic), mirroring `collectImports`.
function coldImports(imports, languageId, absPath, projectRoot) {
    const external = new Set();
    const internal = new Set();
    const warnings = [];
    for (const imp of imports) {
        let files = [];
        try {
            files = resolveImportToFiles(projectRoot, absPath, languageId, imp.source);
        }
        catch (err) {
            const message = diagnosticMessage(err);
            warnings.push(`Failed to resolve import "${imp.source}": ${message}`);
            logLatency({
                type: "phase",
                phase: "module_report_import_resolve_error",
                filePath: absPath,
                durationMs: 0,
                metadata: {
                    import: imp.source,
                    error: message,
                },
            });
        }
        if (files.length > 0) {
            for (const f of files)
                internal.add(toDisplayPath(f, projectRoot));
        }
        else if (looksInternal(imp.source, languageId)) {
            internal.add(imp.source);
        }
        else {
            external.add(imp.source);
        }
    }
    return {
        external: [...external].sort((a, b) => a.localeCompare(b)),
        internal: [...internal].sort((a, b) => a.localeCompare(b)),
        warnings,
    };
}
function toEntry(sym, normalizedPath, graph, maxRefs, projectRoot, fileKind) {
    const startLine = sym.line;
    const endLine = sym.endLine ?? sym.line;
    // The graph-node `kind` bucket in the ID must match whatever builder.ts
    // actually stamped on the node (refs #655 shared-ID-helper invariant), NOT
    // necessarily this file's own extractor's `sym.kind`. For jsts specifically,
    // builder.ts's addJsTsFile sources symbols from a DIFFERENT extractor
    // (dispatch/facts/function-facts.ts) that has no method/function distinction
    // — every jsts symbol (including class methods) is stamped `"function"`. This
    // extractor (tree-sitter-symbol-extractor, used here for ALL languages'
    // outlines) does distinguish `"method"` from `"function"`, so naively reusing
    // `sym.kind` for jsts would look up the wrong bucket and silently break
    // usedBy/fanout for every TS/JS method. Every other language builds its graph
    // node with THIS SAME extractor, so `sym.kind` matches there.
    const idKind = fileKind === "jsts" ? "function" : sym.kind;
    const symbolNodeId = buildSymbolId(normalizedPath, sym.name, idKind, sym.line);
    const node = graph?.nodes.get(symbolNodeId);
    const metadata = node?.metadata ?? {};
    const complexity = typeof metadata.cyclomaticComplexity === "number"
        ? metadata.cyclomaticComplexity
        : undefined;
    const fanout = graph
        ? (graph.edgesByFrom.get(symbolNodeId) ?? []).filter((edge) => edge.kind === "calls").length
        : undefined;
    // A private/protected member of an exported class is reachable but NOT part
    // of the public API, so it must not count as `exported` for the api/internal
    // split or read ranking (#258). The extractor's sym.isExported is untouched
    // (the review graph still sees the full surface); this gating is local to
    // the report's presentation.
    const nonPublic = sym.visibility === "private" || sym.visibility === "protected";
    const exported = (sym.isExported || !!node?.exported) && !nonPublic;
    // `flags` carries only non-derivable signals — "exported" is NOT pushed here
    // since it duplicates the `exported` boolean field below (#512).
    const flags = [];
    if (sym.isAsync)
        flags.push("async");
    if (fanout !== undefined && fanout >= 4)
        flags.push("high fanout");
    if (complexity !== undefined && complexity >= 8)
        flags.push("high complexity");
    if (metadata.isBoundaryWrapper)
        flags.push("boundary wrapper");
    const usedBy = graph
        ? resolveUsedBy(graph, symbolNodeId, maxRefs, projectRoot)
        : undefined;
    return {
        name: sym.name,
        kind: sym.kind,
        startLine,
        endLine,
        exported,
        ...(sym.visibility ? { visibility: sym.visibility } : {}),
        ...(sym.decorators?.length ? { decorators: sym.decorators } : {}),
        signature: sym.signature,
        doc: sym.doc,
        fanout: fanout && fanout > 0 ? fanout : undefined,
        complexity,
        // Empty flags array would waste ~3-5 tokens per entry on a 41-symbol
        // outline (~200 tok total). Omit when there's nothing to report.
        ...(flags.length > 0 ? { flags } : {}),
        usedBy: usedBy && usedBy.length > 0 ? usedBy : undefined,
    };
}
// Nest members under their container by line-range containment (#301), mirroring
// ast-grep's outline: a class/interface's methods sit in its `members[]`, not at
// the top level. Each entry attaches to its NEAREST (smallest) strictly-enclosing
// entry, so arbitrary depth (a method in an inner class in an outer class) nests
// correctly. Mutates the entries (sets `members`) and returns the TOP-LEVEL ones
// (no container) for the api/internal split. The flat list stays usable for
// ranking so hot nested methods still surface in recommendedReads.
function nestEntries(entries) {
    const span = (e) => e.endLine - e.startLine;
    const containerOf = new Map();
    for (const e of entries) {
        let best;
        for (const c of entries) {
            if (c === e)
                continue;
            // Strict containment: c wraps e AND is strictly larger, so equal-range
            // pairs (a mis-extracted class+ctor on the same lines) never mutually nest.
            const contains = c.startLine <= e.startLine &&
                c.endLine >= e.endLine &&
                span(c) > span(e);
            if (!contains)
                continue;
            if (!best || span(c) < span(best))
                best = c;
        }
        containerOf.set(e, best);
    }
    for (const e of entries) {
        const parent = containerOf.get(e);
        if (!parent)
            continue;
        if (!parent.members)
            parent.members = [];
        parent.members.push(e);
    }
    for (const e of entries) {
        if (e.members)
            e.members.sort((a, b) => a.startLine - b.startLine);
    }
    return entries.filter((e) => !containerOf.get(e));
}
function summarizeEntries(entries) {
    return entries.map((entry) => ({
        name: entry.name,
        kind: entry.kind,
        startLine: entry.startLine,
        endLine: entry.endLine,
        exported: entry.exported,
        ...(entry.visibility ? { visibility: entry.visibility } : {}),
        ...(entry.signature ? { signature: entry.signature } : {}),
        ...(entry.doc ? { doc: entry.doc } : {}),
        ...(entry.flags ? { flags: entry.flags } : {}),
        ...(entry.members
            ? {
                members: entry.members.map((member) => ({
                    name: member.name,
                    kind: member.kind,
                    startLine: member.startLine,
                    endLine: member.endLine,
                    exported: member.exported,
                    ...(member.visibility ? { visibility: member.visibility } : {}),
                    ...(member.signature ? { signature: member.signature } : {}),
                    ...(member.doc ? { doc: member.doc } : {}),
                })),
            }
            : {}),
    }));
}
function normalizeFocus(focus) {
    return (focus ?? "")
        .toLowerCase()
        .split(/[^a-z0-9_.]+/)
        .map((part) => part.trim())
        .filter((part) => part.length >= 3)
        .slice(0, 8);
}
function focusScore(text, terms) {
    if (terms.length === 0)
        return 0;
    const haystack = text.toLowerCase();
    return terms.reduce((score, term) => score + (haystack.includes(term) ? 6 : 0), 0);
}
function rankRecommendedReads(entries, callbacks = [], limit = 5, focus) {
    const focusTerms = normalizeFocus(focus);
    const scoredSymbols = entries.map((entry) => {
        const refs = entry.usedBy?.length ?? 0;
        const focus = focusScore([
            entry.name,
            entry.kind,
            entry.signature ?? "",
            entry.flags?.join(" ") ?? "",
        ].join(" "), focusTerms);
        const score = refs * 2 +
            (entry.complexity ?? 0) +
            (entry.exported ? 2 : 0) +
            (entry.flags?.includes("high complexity") ? 3 : 0) +
            focus;
        return { kind: "symbol", entry, score, refs, focus };
    });
    const scoredCallbacks = callbacks.map((callback) => {
        const flags = callback.flags ?? [];
        const focus = focusScore([
            callback.name,
            callback.kind,
            callback.signature ?? "",
            flags.join(" "),
        ].join(" "), focusTerms);
        const score = (flags.includes("captures ctx.ui") ? 8 : 0) +
            (flags.includes("captures ctx") ? 5 : 0) +
            (flags.includes("detached timer") ? 4 : 0) +
            (flags.includes("lifecycle") ? 3 : 0) +
            (callback.kind === "object_property_callback" ? 2 : 0) +
            (callback.kind === "assigned_callback" ? 1 : 0) +
            focus;
        return { kind: "callback", callback, score, focus };
    });
    const scored = [...scoredSymbols, ...scoredCallbacks].filter((item) => item.score > 0);
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, limit).map((item) => {
        if (item.kind === "symbol") {
            const { entry, refs } = item;
            const reasons = [];
            if (item.focus > 0)
                reasons.push("matches focus");
            if (entry.exported)
                reasons.push("exported");
            if (refs > 0)
                reasons.push(`used by ${refs}`);
            if (entry.complexity !== undefined && entry.complexity >= 8) {
                reasons.push(`complexity ${entry.complexity}`);
            }
            return {
                reason: reasons.join(", ") || "public surface",
                symbol: entry.name,
                startLine: entry.startLine,
                endLine: entry.endLine,
            };
        }
        const reasons = [];
        if (item.focus > 0)
            reasons.push("matches focus");
        if (item.callback.flags?.length)
            reasons.push(...item.callback.flags);
        if (item.callback.kind === "object_property_callback") {
            reasons.push("callback property");
        }
        if (item.callback.kind === "assigned_callback") {
            reasons.push("assigned callback");
        }
        return {
            reason: reasons.join(", ") || item.callback.kind,
            symbol: item.callback.name,
            startLine: item.callback.startLine,
            endLine: item.callback.endLine,
        };
    });
}
// Cap the blast-radius list so a high-fanout module doesn't blow the token
// budget; the ranking puts the closest/most-depended-on files first. The read
// limit is the dependent file's own line count when the graph knows it, else a
// modest default — these are "go verify" pointers, not full dumps.
const BLAST_RADIUS_FILE_CAP = 12;
const BLAST_RADIUS_DEFAULT_READ_LIMIT = 400;
function blastReadArgs(graph, normalizedFile) {
    const fileNodeId = graph.fileNodes.get(normalizedFile);
    const lineCount = fileNodeId
        ? graph.nodes.get(fileNodeId)?.metadata?.lineCount
        : undefined;
    const limit = typeof lineCount === "number" && lineCount > 0
        ? lineCount
        : BLAST_RADIUS_DEFAULT_READ_LIMIT;
    // Machine field keeps the absolute (slash-normalized) path so the host's Read
    // resolves it unambiguously — same convention as ModuleSymbolEntry.read.
    return { path: normalizedFile, offset: 1, limit };
}
// Cross-file blast radius (#304): the transitive dependents of this module,
// aggregated from symbol-level impact hits to ranked FILE reads — "if you change
// this module, read/verify these files". Read-only over the CACHED graph the
// caller already loaded (never builds; the caller gates on a warm graph), so it
// shares module_report's #256 no-build contract. Returns undefined when nothing
// depends on the module (no section to show).
async function computeBlastRadius(graph, normalizedPath, projectRoot, maxDepth) {
    const { computeTransitiveImpact } = await import("./review-graph/query.js");
    const result = computeTransitiveImpact(graph, normalizedPath, { maxDepth });
    const byFile = new Map();
    for (const hit of result.hits) {
        if (!hit.file)
            continue;
        const key = normalizeMapKey(hit.file);
        if (key === normalizedPath)
            continue; // never list the module itself
        const cur = byFile.get(key) ?? {
            dependents: 0,
            minDepth: Number.POSITIVE_INFINITY,
            relations: new Set(),
        };
        cur.dependents += 1;
        cur.minDepth = Math.min(cur.minDepth, hit.depth);
        cur.relations.add(hit.relation);
        byFile.set(key, cur);
    }
    if (byFile.size === 0)
        return undefined;
    const files = [...byFile.entries()]
        .map(([key, v]) => ({
        file: toDisplayPath(key, projectRoot),
        dependents: v.dependents,
        minDepth: v.minDepth,
        relations: [...v.relations].sort((a, b) => a.localeCompare(b)),
        read: blastReadArgs(graph, key),
    }))
        // Closest hop first, then most-depended-on, then stable by path.
        .sort((a, b) => a.minDepth - b.minDepth ||
        b.dependents - a.dependents ||
        a.file.localeCompare(b.file))
        .slice(0, BLAST_RADIUS_FILE_CAP);
    return {
        truncated: result.truncated,
        maxDepth: result.maxDepthReached,
        files,
    };
}
function nodeLine(node) {
    return node.startPosition.row + 1;
}
function nodeEndLine(node) {
    return node.endPosition.row + 1;
}
function firstLine(text) {
    return text.split(/\r?\n/, 1)[0]?.trim() ?? "";
}
function findNearestSymbolName(entries, startLine, endLine) {
    let best;
    for (const entry of entries) {
        if (entry.startLine > startLine || entry.endLine < endLine)
            continue;
        if (!best ||
            entry.endLine - entry.startLine < best.endLine - best.startLine) {
            best = entry;
        }
    }
    return best?.name;
}
const INLINE_EXECUTABLE_NODE_KINDS = new Set([
    // JavaScript / TypeScript
    "arrow_function",
    "function_expression",
    // Python
    "lambda",
    // Go
    "func_literal",
    // Rust
    "closure_expression",
    // Swift / Kotlin (trailing/lambda closures)
    "lambda_literal",
    // Other grammars use one of these for lambdas/anonymous functions.
    "lambda_expression",
    "anonymous_function",
]);
const ARGUMENT_CONTAINER_NODE_KINDS = new Set([
    "arguments",
    "argument_list",
    "argument_list_expression",
]);
// Call-node kinds across grammars: JS/TS/Rust use `call_expression`; Python and
// Ruby use a bare `call`. Accepting both lets the per-language callback rules see
// the enclosing call name (e.g. `loop.call_later`) regardless of grammar.
const CALL_NODE_KINDS = new Set(["call_expression", "call"]);
function callNameForCallback(node) {
    const parent = node.parent;
    const call = ARGUMENT_CONTAINER_NODE_KINDS.has(parent?.type ?? "")
        ? parent?.parent
        : CALL_NODE_KINDS.has(parent?.type ?? "")
            ? parent
            : undefined;
    if (!call || !CALL_NODE_KINDS.has(call.type))
        return undefined;
    const callee = call.children.find((child) => !ARGUMENT_CONTAINER_NODE_KINDS.has(child.type));
    return callee?.text;
}
function eventNameForCallback(node) {
    const args = node.parent;
    if (args?.type !== "arguments")
        return undefined;
    const first = args.children.find((child) => child.type === "string" && nodeLine(child) <= nodeLine(node));
    return first?.text;
}
function propertyNameForCallback(node) {
    const parent = node.parent;
    if (parent?.type !== "pair" && parent?.type !== "key_value_pair") {
        return undefined;
    }
    const key = parent.children.find((child) => child !== node);
    return key?.text;
}
function assignedNameForCallback(node) {
    const parent = node.parent;
    if (!parent)
        return undefined;
    if (parent.type === "let_declaration" ||
        parent.type === "variable_declarator") {
        return parent.children.find((child) => child.type === "identifier")?.text;
    }
    if (parent.type === "expression_list") {
        const declaration = parent.parent;
        if (declaration?.type !== "short_var_declaration")
            return undefined;
        const nameList = declaration.children.find((child) => child !== parent);
        return nameList?.children.find((child) => child.type === "identifier")
            ?.text;
    }
    if (parent.type === "assignment") {
        return parent.children.find((child) => child.type === "identifier")?.text;
    }
    return undefined;
}
/** Walk up to `maxHops` ancestors looking for a node of one of `types`. */
function ancestorOfType(node, types, maxHops = 6) {
    let current = node.parent;
    let hops = 0;
    while (current && hops < maxHops) {
        if (types.has(current.type))
            return current;
        current = current.parent;
        hops += 1;
    }
    return undefined;
}
/** Find the first descendant of `type` within `maxDepth` levels (shallow). */
function descendantOfType(node, type, maxDepth = 2) {
    if (maxDepth < 0)
        return undefined;
    for (const child of node.children ?? []) {
        if (child.type === type)
            return child;
        const found = descendantOfType(child, type, maxDepth - 1);
        if (found)
            return found;
    }
    return undefined;
}
/**
 * Generic, JS/TS-tuned classification — the historical behavior, now the
 * default rule set for any language without a tuned entry. Kept byte-for-byte
 * equivalent to the previous callbackKind/callbackFlags/shouldIncludeCallback
 * so the refactor is behavior-preserving for every currently-supported language.
 */
function classifyGenericCallback(ctx) {
    const { node, callName, propertyName, assignedName } = ctx;
    let kind;
    if (callName === "setTimeout" ||
        callName === "setInterval" ||
        callName === "setImmediate") {
        kind = "timer_callback";
    }
    else if (callName === "pi.on" || callName?.endsWith(".on")) {
        kind = "event_handler";
    }
    else if (callName?.endsWith(".then") ||
        callName?.endsWith(".catch") ||
        callName?.endsWith(".finally")) {
        kind = "promise_callback";
    }
    else if (propertyName) {
        kind = "object_property_callback";
    }
    else if (assignedName) {
        kind = "assigned_callback";
    }
    else {
        kind = "callback";
    }
    const flags = [];
    if (node.text.trimStart().startsWith("async"))
        flags.push("async");
    if (/\bctx\s*\.\s*ui\b/.test(node.text))
        flags.push("captures ctx.ui");
    else if (/\bctx\b/.test(node.text))
        flags.push("captures ctx");
    if (kind === "timer_callback")
        flags.push("detached timer");
    if (kind === "event_handler")
        flags.push("lifecycle");
    const include = kind !== "callback" ||
        !!propertyName ||
        !!assignedName ||
        flags.some((flag) => flag.startsWith("captures "));
    return { kind, ...(flags.length > 0 ? { flags } : {}), include };
}
const jstsCallbackRules = {
    classify: (ctx) => classifyGenericCallback(ctx),
};
// Go: goroutines (`go func() {…}()`) and deferred closures (`defer func() {…}()`)
// are the high-signal lifecycle constructs the generic rules DROP (they land as
// a bare "callback"). Detect them structurally via the enclosing go_statement /
// defer_statement — unambiguous node kinds, no call-name heuristics. Anything
// else (assigned closures etc.) delegates to the generic rules unchanged.
const GO_GOROUTINE_KINDS = new Set(["go_statement"]);
const GO_DEFER_KINDS = new Set(["defer_statement"]);
const goCallbackRules = {
    classify(ctx, owner) {
        if (ancestorOfType(ctx.node, GO_GOROUTINE_KINDS, 3)) {
            return {
                kind: "goroutine",
                flags: ["goroutine"],
                include: true,
                nameBase: owner ? `${owner}.goroutine` : "goroutine",
            };
        }
        if (ancestorOfType(ctx.node, GO_DEFER_KINDS, 3)) {
            return {
                kind: "deferred_callback",
                flags: ["deferred"],
                include: true,
                nameBase: owner ? `${owner}.defer` : "defer",
            };
        }
        return classifyGenericCallback(ctx);
    },
};
/** Append a flag without duplicating it; tolerates an undefined start list. */
function withFlag(flags, flag) {
    const next = flags ? [...flags] : [];
    if (!next.includes(flag))
        next.push(flag);
    return next;
}
// Python: lambdas handed to schedulers/futures are the lifecycle-sensitive
// inline executables the generic rules drop (a bare-arg lambda lands as
// "callback"). Classify by the enclosing call name — now visible via the `call`
// node kind. Python has no `async` lambdas, so no async boundary to add here.
const pythonCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const callName = ctx.callName ?? "";
        if (/(?:^|\.)(?:call_later|call_soon|call_at)$/.test(callName) ||
            /(?:^|\.)Timer$/.test(callName)) {
            return {
                kind: "timer_callback",
                flags: withFlag(base.flags, "detached timer"),
                include: true,
            };
        }
        if (/\.add_done_callback$/.test(callName)) {
            return {
                kind: "future_callback",
                flags: withFlag(base.flags, "future completion"),
                include: true,
            };
        }
        return base;
    },
};
// Rust: closures handed to thread/task spawns, and `move` closures (capture by
// value — the classic detached-state shape), are high-signal. `move` is
// structurally certain: the closure text begins with `move` / `async move`.
const rustCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const isMove = /^\s*(?:async\s+)?move\b/.test(ctx.node.text);
        const flags = isMove ? withFlag(base.flags, "move") : base.flags;
        const callName = ctx.callName ?? "";
        if (/(?:^|::|\.)spawn$/.test(callName)) {
            return {
                kind: "task",
                flags: withFlag(flags, "spawned"),
                include: true,
            };
        }
        return {
            ...base,
            ...(flags ? { flags } : {}),
            include: base.include || isMove,
        };
    },
};
// Swift: the canonical Swift lifecycle bug is a closure that captures `self`
// strongly across an async boundary (retain cycle). The capture list is fully
// structural — `capture_list → capture_list_item → ownership_modifier`
// (weak/unowned) — so weak-vs-strong self capture is detectable with zero
// guessing. A strong self capture is the high-signal one we surface.
const swiftCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const node = ctx.node;
        const captureList = descendantOfType(node, "capture_list", 2);
        let weakSelf = false;
        if (captureList) {
            for (const item of captureList.children ?? []) {
                if (item.type !== "capture_list_item")
                    continue;
                const own = item.children?.find((c) => c.type === "ownership_modifier");
                if (own && /\bself\b/.test(item.text))
                    weakSelf = true;
            }
        }
        const refsSelf = /\bself\b/.test(node.text);
        let flags = base.flags;
        let include = base.include;
        if (weakSelf) {
            flags = withFlag(flags, "weak self");
            include = true;
        }
        else if (refsSelf) {
            flags = withFlag(flags, "captures self");
            include = true;
        }
        return { ...base, ...(flags ? { flags } : {}), include };
    },
};
// C++: a lambda with a by-reference default capture (`[&]`) can dangle once the
// enclosing scope returns — the classic async/thread bug. Capture mode is
// structural (`lambda_capture_specifier → lambda_default_capture`). Also flag
// std::thread / std::async launches.
const cppCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const capture = descendantOfType(ctx.node, "lambda_capture_specifier", 2);
        let flags = base.flags;
        let byRef = false;
        if (capture) {
            const def = capture.children?.find((c) => c.type === "lambda_default_capture");
            if (def?.text.includes("&")) {
                flags = withFlag(flags, "captures by reference");
                byRef = true;
            }
        }
        const callName = ctx.callName ?? "";
        if (/(?:^|::)(?:thread|async)$/.test(callName)) {
            return {
                kind: "task",
                flags: withFlag(flags, "spawned"),
                include: true,
            };
        }
        return {
            ...base,
            ...(flags ? { flags } : {}),
            include: base.include || byRef,
        };
    },
};
/** Trailing identifier of a (possibly dotted) callee, e.g. `scope.launch` → `launch`. */
function lastCalleeSegment(text) {
    const m = String(text ?? "")
        .trim()
        .match(/([A-Za-z_$][\w$]*)\s*$/);
    return m ? m[1] : "";
}
// Kotlin: coroutine builders (`launch`/`async`/`withContext`/`runBlocking`/…)
// are the dominant lifecycle/leak source. The trailing lambda sits under
// `call_expression → call_suffix → annotated_lambda`, so resolve the builder
// name from the enclosing call's callee.
const KOTLIN_COROUTINE_BUILDERS = new Set([
    "launch",
    "async",
    "withContext",
    "runBlocking",
    "coroutineScope",
    "supervisorScope",
]);
const kotlinCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const call = ancestorOfType(ctx.node, new Set(["call_expression"]), 4);
        const callee = call?.children?.find((c) => c.type === "navigation_expression" || c.type === "simple_identifier");
        const name = lastCalleeSegment(callee?.text);
        if (KOTLIN_COROUTINE_BUILDERS.has(name)) {
            return {
                kind: "coroutine",
                flags: withFlag(base.flags, "coroutine"),
                include: true,
                nameBase: name,
            };
        }
        return base;
    },
};
// Java: lambdas handed to `new Thread(...)`, executor `submit`/`execute`/
// `schedule`, or UI/event listeners. Resolve the constructor type or the
// method name from the enclosing invocation.
const JAVA_TASK_METHODS = /^(?:submit|execute|schedule|scheduleAtFixedRate|scheduleWithFixedDelay|invokeLater|invokeAndWait)$/;
const JAVA_LISTENER_METHODS = /^(?:add\w*Listener|set\w*Listener|subscribe|addCallback|then\w*)$/;
const javaCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const obj = ancestorOfType(ctx.node, new Set(["object_creation_expression"]), 3);
        const created = obj?.children?.find((c) => c.type === "type_identifier");
        if (created && /Thread$/.test(created.text)) {
            return {
                kind: "task",
                flags: withFlag(base.flags, "thread"),
                include: true,
                nameBase: `new ${created.text}`,
            };
        }
        const inv = ancestorOfType(ctx.node, new Set(["method_invocation"]), 3);
        if (inv) {
            const named = (inv.children ?? []).filter((c) => c.isNamed);
            const argIdx = named.findIndex((c) => c.type === "argument_list");
            const nameNode = argIdx > 0 ? named[argIdx - 1] : undefined;
            const name = nameNode?.type === "identifier" ? nameNode.text : "";
            if (JAVA_TASK_METHODS.test(name)) {
                return {
                    kind: "task",
                    flags: withFlag(base.flags, "submitted"),
                    include: true,
                    nameBase: name,
                };
            }
            if (JAVA_LISTENER_METHODS.test(name)) {
                return {
                    kind: "event_handler",
                    flags: withFlag(base.flags, "listener"),
                    include: true,
                    nameBase: name,
                };
            }
        }
        return base;
    },
};
// C#: event subscriptions (`x.Click += (s,e) => …`), `Task.Run`/`StartNew`
// launches, and `async` lambdas. The event case is a lambda whose parent is a
// `+=` assignment.
const csharpCallbackRules = {
    classify(ctx) {
        const base = classifyGenericCallback(ctx);
        const parent = ctx.node.parent;
        if (parent?.type === "assignment_expression" && /\+=/.test(parent.text)) {
            return {
                kind: "event_handler",
                flags: withFlag(base.flags, "event +="),
                include: true,
            };
        }
        const inv = ancestorOfType(ctx.node, new Set(["invocation_expression"]), 4);
        if (inv) {
            const callee = inv.children?.find((c) => c.type === "member_access_expression" || c.type === "identifier");
            const name = lastCalleeSegment(callee?.text);
            if (/^(?:Run|StartNew|Start)$/.test(name)) {
                return {
                    kind: "task",
                    flags: withFlag(base.flags, "task"),
                    include: true,
                    nameBase: name,
                };
            }
        }
        if (/^\s*async\b/.test(ctx.node.text)) {
            return {
                ...base,
                flags: withFlag(base.flags, "async"),
                include: true,
            };
        }
        return base;
    },
};
const CALLBACK_RULES = {
    typescript: jstsCallbackRules,
    tsx: jstsCallbackRules,
    javascript: jstsCallbackRules,
    go: goCallbackRules,
    python: pythonCallbackRules,
    rust: rustCallbackRules,
    swift: swiftCallbackRules,
    cpp: cppCallbackRules,
    kotlin: kotlinCallbackRules,
    java: javaCallbackRules,
    csharp: csharpCallbackRules,
};
function callbackRulesFor(languageId) {
    return ((languageId ? CALLBACK_RULES[languageId] : undefined) ?? jstsCallbackRules);
}
/**
 * Whether a language has a TUNED callback rule set (explicit CALLBACK_RULES
 * entry) vs. falling back to the generic JS/TS-shaped heuristics. Drives the
 * report's `callbackSupport` honesty signal.
 */
function callbackSupportFor(languageId) {
    return languageId && CALLBACK_RULES[languageId] ? "tuned" : "generic";
}
function extractCallbacks(root, entries, languageId, warnings) {
    if (!root)
        return [];
    const rules = callbackRulesFor(languageId);
    const callbacks = [];
    const maxDepth = 1000;
    let depthTruncated = false;
    const visit = (node, depth = 0) => {
        if (depth > maxDepth) {
            depthTruncated = true;
            return;
        }
        if (INLINE_EXECUTABLE_NODE_KINDS.has(node.type)) {
            const startLine = nodeLine(node);
            const endLine = nodeEndLine(node);
            const callName = callNameForCallback(node);
            const eventName = eventNameForCallback(node);
            const propertyName = propertyNameForCallback(node);
            const assignedName = assignedNameForCallback(node);
            const owner = findNearestSymbolName(entries, startLine, endLine);
            const cls = rules.classify({ node, callName, eventName, propertyName, assignedName }, owner);
            if (cls.include) {
                const base = cls.nameBase ??
                    (propertyName
                        ? owner
                            ? `${owner}.${propertyName}`
                            : propertyName
                        : assignedName
                            ? owner
                                ? `${owner}.${assignedName}`
                                : assignedName
                            : eventName && callName
                                ? `${callName}(${eventName})`
                                : (callName ?? "callback"));
                callbacks.push({
                    name: `${base}@${startLine}`,
                    kind: cls.kind,
                    rawKind: node.type,
                    startLine,
                    endLine,
                    signature: firstLine(node.text),
                    ...(owner ? { parentChain: [owner] } : {}),
                    ...(cls.flags ? { flags: cls.flags } : {}),
                });
            }
        }
        for (const child of node.children ?? [])
            visit(child, depth + 1);
    };
    visit(root);
    if (depthTruncated) {
        warnings?.push(`Callback extraction stopped at AST depth ${maxDepth}`);
    }
    const callbackCap = 25;
    if (callbacks.length > callbackCap) {
        warnings?.push(`Callback list truncated to ${callbackCap} of ${callbacks.length} entries`);
    }
    return callbacks.slice(0, callbackCap);
}
function unavailableReport(displayPath, error) {
    return {
        available: false,
        staleness: "unavailable",
        path: displayPath,
        ...(error ? { error } : {}),
        summary: { imports: 0, exports: 0, symbols: 0 },
        imports: { external: [], internal: [] },
        api: [],
        internal: [],
        callbacks: [],
        recommendedReads: [],
        semantic: { source: "none", references: false, implementations: false },
    };
}
/**
 * Build a structured report for a single module. Read-only, single mode (#256):
 * tree-sitter extract + 3-tier-cached review graph (who-uses-this, imports,
 * complexity/fanout) + callback extraction. This path never calls LSP; LSP-derived
 * relationships must be written into the cached graph ahead of time (#236) and are
 * then read here as graph data. Each tier degrades independently, so a cold graph
 * never aborts the report — it just narrows what's populated.
 */
export async function moduleReport(file, cwd, options) {
    const startedAt = Date.now();
    const maxRefs = Math.max(1, options?.maxRefsPerSymbol ?? 10);
    const absPath = path.resolve(cwd, file);
    const normalizedPath = normalizeMapKey(absPath);
    let content;
    try {
        content = fs.readFileSync(absPath, "utf-8");
    }
    catch {
        return unavailableReport(toDisplayPath(absPath, cwd));
    }
    const kind = detectFileKind(absPath);
    const languageId = tsLangForFile(absPath, kind);
    const lineCount = content.split(/\r?\n/).length;
    const { symbols: extracted, imports: extractedImports, root, error: extractionError, warnings: extractionWarnings, } = languageId
        ? await extractFile(absPath, languageId, content)
        : {
            symbols: [],
            imports: [],
            root: undefined,
            error: undefined,
            warnings: undefined,
        };
    if (extractionError) {
        return unavailableReport(toDisplayPath(absPath, cwd), extractionError);
    }
    // READ-ONLY: consume the already-built review graph, never build one here. A
    // synchronous full build re-runs every fact provider (TS-compiler ASTs for
    // jsts) and two racing builds OOM'd pi (#256). Cold cache → outline-only.
    let graph;
    try {
        const { getCachedReviewGraph } = await import("./review-graph/builder.js");
        graph = getCachedReviewGraph(cwd);
    }
    catch {
        graph = undefined;
    }
    // Drop function-local declarations (a nested const/arrow/function) from the
    // outline — they're implementation detail of a parent symbol, not navigable
    // module structure (#259). Presentation-only: the review graph keeps them.
    const outlineSymbols = extracted.filter((sym) => !sym.local);
    // Flat entries first — ranking and cold-import resolution both read the full
    // list. `entries` is mutated by nestEntries (members attached); `topLevel` is
    // the api/internal split surface.
    const entries = outlineSymbols.map((sym) => toEntry(sym, normalizedPath, graph, maxRefs, cwd, kind));
    const topLevel = nestEntries(entries);
    // Middle-man / delegate-only class detection (#325): a whole-class judgment
    // over the now-nested members[], so it must run AFTER nestEntries. Mutates
    // `entries` in place (topLevel/api/internal hold the same object references).
    annotateMiddleMan(entries, content, languageId);
    const api = topLevel.filter((entry) => entry.exported);
    const internal = topLevel.filter((entry) => !entry.exported);
    let callbacks = [];
    const warnings = [...(extractionWarnings ?? [])];
    try {
        callbacks = extractCallbacks(root, entries, languageId, warnings);
    }
    catch (err) {
        const message = diagnosticMessage(err);
        warnings.push(`Failed to extract callbacks: ${message}`);
        logLatency({
            type: "phase",
            phase: "module_report_callback_extract_error",
            filePath: absPath,
            durationMs: 0,
            metadata: { error: message },
        });
    }
    // Imports: the warm review graph is source-of-truth; on a cold cache (or a
    // graph without this file's node) fall back to the language-uniform tree-sitter
    // resolution (#301) so a cold report no longer shows zero imports.
    const warmImports = graph
        ? collectImports(graph, normalizedPath, cwd)
        : { external: [], internal: [] };
    const coldImportResult = warmImports.external.length + warmImports.internal.length > 0 || !languageId
        ? undefined
        : coldImports(extractedImports, languageId, absPath, cwd);
    if (coldImportResult?.warnings.length)
        warnings.push(...coldImportResult.warnings);
    const imports = coldImportResult
        ? {
            external: coldImportResult.external,
            internal: coldImportResult.internal,
        }
        : warmImports;
    const hasGraphNode = graph?.fileNodes.has(normalizedPath) ?? false;
    // #511: distinguish two very different reasons `usedBy`/`semantic` degrade to
    // "none". A graph that doesn't exist at all (`!graph`) is an honest, expected
    // cold start — the edit pipeline hasn't warmed this workspace yet. But a graph
    // that DOES exist and just doesn't have a node for THIS file (e.g. the file was
    // added/renamed after the graph was last persisted) is silently
    // indistinguishable from "no who-uses-this data exists" unless we say so. Make
    // the second case actionable: a rebuild (pilens_rebuild) would populate it.
    if (graph && !hasGraphNode) {
        warnings.push("who-uses-this is unavailable for this file: the cached review graph " +
            "exists but has no node for it (likely added/changed after the graph " +
            "was last built). Run pilens_rebuild to refresh it.");
    }
    // Cross-file blast radius (#304): opt-in, read-only over the CACHED graph. Only
    // computed when requested AND the file is in a warm graph — a cold cache omits
    // the section entirely (never builds, same #256 contract as the rest of this
    // path). Aggregated to file reads; undefined when nothing depends on the module.
    const blastRadius = options?.blastRadius && graph && hasGraphNode
        ? await computeBlastRadius(graph, normalizedPath, cwd, Math.max(1, options.blastRadiusDepth ?? 3))
        : undefined;
    const view = options?.view ?? "default";
    const summaryView = view === "summary";
    // "compact" computes the same full data as "default" — it's a rendering
    // instruction for the caller (renderCompactModuleReport), not a data tier —
    // so it only needs to echo back on the report; it never gates section content
    // the way summaryView does.
    const compactView = view === "compact";
    let importsProvenance = "none";
    if (coldImportResult) {
        importsProvenance = "syntax";
    }
    else if (graph) {
        importsProvenance = "cached-review-graph";
    }
    const blastRadiusProvenance = blastRadius ? "cached-review-graph" : "none";
    const report = {
        available: entries.length > 0 || hasGraphNode,
        staleness: entries.length === 0 && !hasGraphNode ? "unavailable" : "fresh",
        path: toDisplayPath(absPath, cwd),
        language: kind ?? undefined,
        lineCount,
        summary: {
            imports: imports.external.length + imports.internal.length,
            exports: api.length,
            symbols: entries.length,
        },
        imports,
        ...(warnings.length > 0 ? { warnings } : {}),
        api: summaryView ? summarizeEntries(api) : api,
        internal: summaryView ? summarizeEntries(internal) : internal,
        callbacks: summaryView ? [] : callbacks,
        callbackSupport: callbackSupportFor(languageId),
        recommendedReads: rankRecommendedReads(entries, callbacks, 5, options?.focus),
        ...(summaryView ? { view: "summary" } : {}),
        ...(compactView ? { view: "compact" } : {}),
        ...(blastRadius && !summaryView ? { blastRadius } : {}),
        ...(graph ? { graphBuiltAt: graph.builtAt } : {}),
        provenance: {
            symbols: languageId ? "syntax" : "none",
            imports: importsProvenance,
            usedBy: hasGraphNode ? "cached-review-graph" : "none",
            callbacks: languageId && !summaryView ? "heuristic-tree-sitter" : "none",
            ...(options?.blastRadius
                ? { blastRadius: blastRadiusProvenance }
                : {}),
        },
        semantic: {
            // Provenance of who-uses-this / references. The AST review graph is the
            // only source on this read path; "graph-lsp" is reserved for #236 (LSP
            // writes provenance edges INTO the graph). Cold cache → "none".
            source: hasGraphNode ? "review-graph" : "none",
            references: hasGraphNode,
            implementations: false,
        },
    };
    // Observability (#256): record graph source (cached vs cold) so a future
    // regression is attributable per call. This path is read-only by contract —
    // "graph: cached|cold", never a build, never an LSP call.
    logLatency({
        type: "phase",
        phase: "module_report",
        filePath: absPath,
        durationMs: Date.now() - startedAt,
        metadata: {
            graph: graph ? "cached" : "cold",
            symbols: entries.length,
            exported: api.length,
        },
    });
    return report;
}
// --- Compact (line-oriented text) rendering (#512 slice 4) ------------------
//
// An opt-in `view: "compact"` alternative to the JSON report: one line per
// symbol/callback instead of a repeated-keys JSON object, at roughly a quarter
// of the token cost for the same information. Purely a rendering step over the
// already-built ModuleReport — it changes no data, only presentation, so a
// caller that wants JSON just skips this function. Default view stays JSON
// (this is opt-in for dogfooding, not a default flip).
function padRange(startLine, endLine, width) {
    return `${startLine}-${endLine}`.padEnd(width);
}
const KIND_ABBREV = {
    function: "fn",
    method: "fn",
    class: "class",
    interface: "iface",
    type: "type",
    variable: "var",
    property: "prop",
};
function compactKind(kind) {
    return KIND_ABBREV[kind] ?? kind;
}
function compactUsedBySuffix(usedBy) {
    if (!usedBy || usedBy.length === 0)
        return "";
    const counts = new Map();
    for (const u of usedBy)
        counts.set(u.file, (counts.get(u.file) ?? 0) + 1);
    const parts = [...counts.entries()].map(([file, n]) => n > 1 ? `${file}×${n}` : file);
    return `  used-by: ${parts.join(", ")}`;
}
function compactEntryLine(entry, width) {
    const range = padRange(entry.startLine, entry.endLine, width);
    const kind = compactKind(entry.kind).padEnd(6);
    const sig = entry.signature ? `${entry.name}${entry.signature}` : entry.name;
    const flagsSuffix = entry.flags && entry.flags.length > 0 ? `  [${entry.flags.join(", ")}]` : "";
    const docSuffix = entry.doc ? `  — ${entry.doc}` : "";
    const usedBySuffix = compactUsedBySuffix(entry.usedBy);
    return `  ${range}${kind}${sig}${flagsSuffix}${usedBySuffix}${docSuffix}`;
}
function compactMemberLines(entry, width, indent) {
    if (!entry.members || entry.members.length === 0)
        return [];
    return entry.members.map((m) => `${indent}${compactEntryLine(m, width).slice(2)}`);
}
function compactCallbackLine(callback, width) {
    const range = padRange(callback.startLine, callback.endLine, width);
    const kind = callback.kind.padEnd(20);
    const flagsSuffix = callback.flags && callback.flags.length > 0
        ? `  [${callback.flags.join(", ")}]`
        : "";
    const ownerSuffix = callback.parentChain?.length
        ? `  (in ${callback.parentChain.join(".")})`
        : "";
    return `  ${range}${kind}${callback.name}${flagsSuffix}${ownerSuffix}`;
}
/**
 * Render a ModuleReport as the line-oriented compact text view (#512 slice 4):
 * one line per symbol/member/callback instead of a JSON object per entry.
 * Example:
 * ```
 * clients/agent-nudge.ts jsts 266L — 8 symbols, 5 exported | imports: bus-publish, latency-logger
 * API:
 *   77-81    fn  _resetAgentNudgeForTests()  — Test-only: clear accumulator state.
 * INTERNAL:
 *   95-104   fn  isValidPayload(data: unknown)
 * CALLBACKS:
 *   164-172  event_handler  events.on@164  [lifecycle]  (in wireAgentNudgeSubscriber)
 * ```
 * Purely presentational over an already-built report — call `moduleReport`
 * first (with `view: "compact"` or any other view) and pass its result here.
 */
export function renderCompactModuleReport(report) {
    if (!report.available) {
        return `${report.path} — unavailable${report.error ? `: ${report.error}` : ""}`;
    }
    const allEntries = [...report.api, ...report.internal];
    const allRanges = allEntries.flatMap((e) => [
        e,
        ...(e.members ?? []),
    ]);
    const width = Math.max(5, ...allRanges.map((e) => `${e.startLine}-${e.endLine}`.length), ...report.callbacks.map((c) => `${c.startLine}-${c.endLine}`.length)) + 2;
    const importsList = [...report.imports.internal, ...report.imports.external];
    const importsSuffix = importsList.length > 0
        ? ` | imports: ${importsList
            .slice(0, 6)
            .map((i) => baseNameNoExt(i))
            .join(", ")}${importsList.length > 6 ? ", …" : ""}`
        : "";
    const lines = [
        `${report.path} ${report.language ?? "?"} ${report.lineCount ?? "?"}L — ` +
            `${report.summary.symbols} symbols, ${report.summary.exports} exported${importsSuffix}`,
    ];
    if (report.api.length > 0) {
        lines.push("API:");
        for (const entry of report.api) {
            lines.push(compactEntryLine(entry, width));
            lines.push(...compactMemberLines(entry, width, "    "));
        }
    }
    if (report.internal.length > 0) {
        lines.push("INTERNAL:");
        for (const entry of report.internal) {
            lines.push(compactEntryLine(entry, width));
            lines.push(...compactMemberLines(entry, width, "    "));
        }
    }
    if (report.callbacks.length > 0) {
        lines.push("CALLBACKS:");
        for (const callback of report.callbacks) {
            lines.push(compactCallbackLine(callback, width));
        }
    }
    if (report.recommendedReads.length > 0) {
        lines.push("RECOMMENDED:");
        for (const r of report.recommendedReads) {
            lines.push(`  ${padRange(r.startLine, r.endLine, width)}${r.symbol ?? ""}  — ${r.reason}`);
        }
    }
    return lines.join("\n");
}
function baseNameNoExt(p) {
    const base = p.split(/[\\/]/).pop() ?? p;
    return base.replace(/\.[^./\\]+$/, "");
}
// Duplicate-name disambiguation (#523 item 4). `candidates` is every symbol
// already known to share the requested name; when `kind` is given it narrows
// first, else the historical "first match" (source order) wins — unchanged
// behavior for the common (unambiguous) case. `ambiguous` is populated only
// when the CHOSEN pool still has more than one entry, so a kind that uniquely
// resolves the collision reports no ambiguity.
function selectMatch(candidates, kind) {
    if (candidates.length === 0)
        return {};
    const filtered = kind ? candidates.filter((c) => c.kind === kind) : candidates;
    const pool = filtered.length > 0 ? filtered : candidates;
    const match = pool[0];
    if (pool.length <= 1)
        return { match };
    return {
        match,
        ambiguous: {
            count: pool.length,
            kinds: [...new Set(pool.map((c) => c.kind))],
        },
    };
}
// `Class.method` qualification (#523 item 3). Members are located by line-range
// containment within the named parent — the same shape module-report's
// `nestEntries` uses for the outline, computed directly here over the flat
// extractor list (readSymbol never builds the nested outline). Returns
// undefined (NOT a miss) when the qualifier doesn't resolve to a known parent —
// the caller then falls through to the plain unqualified lookup using the full
// dotted string, which naturally misses and feeds the did-you-mean path below
// rather than crashing.
function resolveQualifiedMatch(symbols, qualifiedName, kind) {
    const dotIdx = qualifiedName.lastIndexOf(".");
    if (dotIdx <= 0 || dotIdx === qualifiedName.length - 1)
        return undefined;
    const parentName = qualifiedName.slice(0, dotIdx);
    const memberName = qualifiedName.slice(dotIdx + 1);
    const parent = symbols.find((candidate) => candidate.name === parentName);
    if (!parent)
        return undefined;
    const parentStart = parent.line;
    const parentEnd = parent.endLine ?? parent.line;
    const members = symbols.filter((candidate) => candidate !== parent &&
        candidate.name === memberName &&
        candidate.line >= parentStart &&
        (candidate.endLine ?? candidate.line) <= parentEnd);
    if (members.length === 0)
        return undefined;
    return selectMatch(members, kind);
}
// Did-you-mean on miss (#523 item 2). A small, focused Levenshtein distance —
// NOT a reuse of read-guard-tool-lines.ts's `findSimilarLines`/`tokenSimilarity`.
// That function does Jaccard similarity over whitespace-tokenized LINE CONTENT
// (built for relocated-block suggestions across a window of file lines); a
// single identifier is one token to it, so "isAgentNudgeEnable" vs
// "isAgentNudgeEnabled" scores 0 (disjoint token sets) despite being a
// one-character typo. Symbol-name matching needs character-level edit distance
// instead, so this is a small dedicated implementation rather than a forced
// reuse. No existing levenshtein/editDistance utility was found elsewhere in
// the codebase (checked clients/read-guard-tool-lines.ts and clients/dispatch/
// dispatcher.ts, the only other "similarity" hits).
function levenshteinDistance(a, b) {
    const al = a.length;
    const bl = b.length;
    if (al === 0)
        return bl;
    if (bl === 0)
        return al;
    let prev = new Array(bl + 1);
    let curr = new Array(bl + 1);
    for (let j = 0; j <= bl; j++)
        prev[j] = j;
    for (let i = 1; i <= al; i++) {
        curr[0] = i;
        for (let j = 1; j <= bl; j++) {
            const cost = a[i - 1] === b[j - 1] ? 0 : 1;
            curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
        }
        [prev, curr] = [curr, prev];
    }
    return prev[bl];
}
// Normalized similarity in [0, 1]; 1 = identical (case-insensitive).
function nameSimilarity(a, b) {
    const maxLen = Math.max(a.length, b.length);
    if (maxLen === 0)
        return 1;
    return 1 - levenshteinDistance(a.toLowerCase(), b.toLowerCase()) / maxLen;
}
// Threshold chosen so a plausible typo/near-miss (one-two edits on a
// medium-length identifier, e.g. "isAgentNudgeEnable" -> "isAgentNudgeEnabled",
// score ~0.95) clears it while a wildly-wrong name (near-zero character
// overlap) doesn't — avoids suggesting misleading names on a genuine miss.
const SIMILAR_NAME_MIN_SCORE = 0.45;
const SIMILAR_NAME_MAX_SUGGESTIONS = 3;
function suggestSimilarNames(candidates, target) {
    const unique = [...new Set(candidates)].filter((name) => name !== target);
    return unique
        .map((name) => ({ name, score: nameSimilarity(target, name) }))
        .filter((entry) => entry.score >= SIMILAR_NAME_MIN_SCORE)
        .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
        .slice(0, SIMILAR_NAME_MAX_SUGGESTIONS)
        .map((entry) => entry.name);
}
/**
 * Return the verbatim body of a single symbol. Unlike moduleReport (which shows
 * shape, not content), this delivers the actual source lines — so the host can
 * record it as a read that legitimately satisfies the read-guard's coverage for
 * that symbol's range.
 */
export async function readSymbol(file, symbolName, cwd, options = {}) {
    const { kind: symbolKindFilter } = options;
    const startedAt = Date.now();
    const absPath = path.resolve(cwd, file);
    // Pure tree-sitter on one file — no graph, no LSP. Log for correlation with
    // module_report frequency/timing (#256), one event per outcome.
    const log = (found) => {
        logLatency({
            type: "phase",
            phase: "read_symbol",
            filePath: absPath,
            durationMs: Date.now() - startedAt,
            metadata: { symbol: symbolName, found },
        });
    };
    let content;
    try {
        content = fs.readFileSync(absPath, "utf-8");
    }
    catch {
        log(false);
        return { found: false, path: absPath, name: symbolName };
    }
    const kind = detectFileKind(absPath);
    const languageId = tsLangForFile(absPath, kind);
    if (!languageId) {
        log(false);
        return { found: false, path: absPath, name: symbolName };
    }
    const { symbols, root, error: extractionError, warnings: extractionWarnings, } = await extractFile(absPath, languageId, content);
    if (extractionError) {
        log(false);
        return {
            found: false,
            path: absPath,
            name: symbolName,
            error: extractionError,
        };
    }
    const lines = content.split(/\r?\n/);
    // #523 item 3: a dotted name tries the qualified (Class.method) lookup
    // first; a hit there wins outright. Otherwise fall through to the plain
    // unqualified lookup using the FULL requested name (so an unresolved
    // qualifier or a non-dotted name both flow through the same miss/did-you-
    // mean path below rather than a separate branch).
    const qualified = resolveQualifiedMatch(symbols, symbolName, symbolKindFilter);
    const unqualifiedMatches = symbols.filter((candidate) => candidate.name === symbolName);
    const selection = qualified ?? selectMatch(unqualifiedMatches, symbolKindFilter);
    if (selection.match) {
        const sym = selection.match;
        // #523 item 1: extend the returned range (and thus the read-guard
        // coverage recorded for it — see tools/module-report.ts's
        // recordReadCoverage) to include an attached doc comment, when one
        // exists. `docStartLine` is the SAME position-based, blank-line-gap-aware
        // attachment computation the outline's `doc` summary already uses (#517's
        // extractDocCommentInfo) — no re-derivation here, just reusing the line
        // it already computed.
        const startLine = sym.docStartLine ?? sym.line;
        const endLine = sym.endLine ?? sym.line;
        const source = lines.slice(startLine - 1, endLine).join("\n");
        log(true);
        return {
            found: true,
            path: absPath,
            name: sym.name,
            kind: sym.kind,
            startLine,
            endLine,
            signature: sym.signature,
            source,
            ...(selection.ambiguous ? { ambiguous: selection.ambiguous } : {}),
        };
    }
    const owners = symbols
        .filter((candidate) => !candidate.local)
        .map((candidate) => ({
        name: candidate.name,
        startLine: candidate.line,
        endLine: candidate.endLine ?? candidate.line,
    }));
    let allCallbacks;
    const callbackWarnings = [...(extractionWarnings ?? [])];
    try {
        allCallbacks = extractCallbacks(root, owners, languageId, callbackWarnings);
    }
    catch (err) {
        const message = `Callback extraction failed: ${diagnosticMessage(err)}`;
        logLatency({
            type: "phase",
            phase: "read_symbol_callback_extract_error",
            filePath: absPath,
            durationMs: Date.now() - startedAt,
            metadata: { symbol: symbolName, error: message },
        });
        log(false);
        return { found: false, path: absPath, name: symbolName, error: message };
    }
    const callback = allCallbacks.find((candidate) => candidate.name === symbolName);
    if (!callback) {
        log(false);
        // #523 item 2: embed the ~3 nearest symbol/callback names directly in the
        // miss response so the caller can self-correct without a module_report
        // round-trip. Non-local symbols only — locals aren't reachable by name
        // from outside their enclosing scope, so suggesting one would send the
        // caller to a dead end.
        const corpus = [
            ...symbols.filter((candidate) => !candidate.local).map((c) => c.name),
            ...allCallbacks.map((c) => c.name),
        ];
        const suggestions = suggestSimilarNames(corpus, symbolName);
        return {
            found: false,
            path: absPath,
            name: symbolName,
            ...(callbackWarnings.length > 0 ? { warnings: callbackWarnings } : {}),
            ...(suggestions.length > 0 ? { suggestions } : {}),
        };
    }
    const source = lines
        .slice(callback.startLine - 1, callback.endLine)
        .join("\n");
    log(true);
    return {
        found: true,
        path: absPath,
        name: callback.name,
        kind: callback.kind,
        startLine: callback.startLine,
        endLine: callback.endLine,
        signature: callback.signature,
        source,
    };
}
function symbolMatchesKind(kind, filters) {
    if (filters.size === 0)
        return true;
    const normalized = kind.toLowerCase();
    if (filters.has(normalized))
        return true;
    if (filters.has("function") && normalized.includes("function"))
        return true;
    if (filters.has("method") && normalized.includes("method"))
        return true;
    if (filters.has("type") &&
        ["class", "interface", "struct", "enum", "trait", "type"].includes(normalized)) {
        return true;
    }
    return false;
}
function callbackMatchesKind(kind, filters) {
    if (filters.size === 0)
        return true;
    const normalized = kind.toLowerCase();
    return (filters.has(normalized) ||
        filters.has("callback") ||
        filters.has("closure") ||
        filters.has("lambda") ||
        (normalized.includes("callback") && filters.has("function")));
}
function symbolParentChain(symbol, symbols) {
    const chain = symbols
        .filter((candidate) => {
        if (candidate === symbol)
            return false;
        const start = candidate.line;
        const end = candidate.endLine ?? candidate.line;
        const symEnd = symbol.endLine ?? symbol.line;
        return (start <= symbol.line &&
            end >= symEnd &&
            end - start > symEnd - symbol.line);
    })
        .sort((a, b) => a.line - b.line ||
        (b.endLine ?? b.line) - b.line - ((a.endLine ?? a.line) - a.line))
        .map((candidate) => candidate.name);
    return chain.length > 0 ? chain : undefined;
}
function clampSliceRange(targetLine, startLine, endLine, limit) {
    const boundedLimit = Math.max(1, Math.min(endLine - startLine + 1, limit));
    let sliceStart = targetLine - Math.floor(boundedLimit / 2);
    sliceStart = Math.max(startLine, Math.min(sliceStart, endLine - boundedLimit + 1));
    return { startLine: sliceStart, endLine: sliceStart + boundedLimit - 1 };
}
function enclosingOutline(selected, symbols, callbacks, filters, filePath) {
    const items = [];
    for (const sym of symbols) {
        const startLine = sym.line;
        const endLine = sym.endLine ?? sym.line;
        if (startLine === selected.startLine && endLine === selected.endLine)
            continue;
        if (startLine < selected.startLine || endLine > selected.endLine)
            continue;
        if (!symbolMatchesKind(sym.kind, filters))
            continue;
        items.push({
            name: sym.name,
            kind: sym.kind,
            startLine,
            endLine,
            signature: sym.signature,
            parentChain: symbolParentChain(sym, symbols),
            read: readArgsFor(filePath, startLine, endLine),
        });
    }
    for (const callback of callbacks) {
        if (callback.startLine < selected.startLine ||
            callback.endLine > selected.endLine)
            continue;
        if (!callbackMatchesKind(callback.kind, filters))
            continue;
        items.push({
            name: callback.name,
            kind: callback.kind,
            startLine: callback.startLine,
            endLine: callback.endLine,
            signature: callback.signature,
            parentChain: callback.parentChain,
            read: readArgsFor(filePath, callback.startLine, callback.endLine),
        });
    }
    return items
        .sort((a, b) => a.startLine - b.startLine ||
        a.endLine - a.startLine - (b.endLine - b.startLine))
        .slice(0, 25);
}
/**
 * Read the smallest useful symbol/callback enclosing a source line. This is the
 * search/diagnostic → exact-body bridge: single-file tree-sitter only, no graph,
 * no LSP, and the returned range is concrete read-guard coverage.
 */
export async function readEnclosing(file, line, cwd, options) {
    const startedAt = Date.now();
    const absPath = path.resolve(cwd, file);
    const targetLine = Math.max(1, Math.floor(line));
    const log = (found) => {
        logLatency({
            type: "phase",
            phase: "read_enclosing",
            filePath: absPath,
            durationMs: Date.now() - startedAt,
            metadata: { line: targetLine, found },
        });
    };
    let content;
    try {
        content = fs.readFileSync(absPath, "utf-8");
    }
    catch {
        log(false);
        return { found: false, path: absPath, line: targetLine };
    }
    const kind = detectFileKind(absPath);
    const languageId = tsLangForFile(absPath, kind);
    if (!languageId) {
        log(false);
        return { found: false, path: absPath, line: targetLine };
    }
    const { symbols, root, error: extractionError, warnings: extractionWarnings, } = await extractFile(absPath, languageId, content);
    if (extractionError) {
        log(false);
        return {
            found: false,
            path: absPath,
            line: targetLine,
            error: extractionError,
        };
    }
    const filters = new Set((options?.kinds ?? []).map((value) => value.toLowerCase()));
    const owners = symbols
        .filter((candidate) => !candidate.local)
        .map((candidate) => ({
        name: candidate.name,
        startLine: candidate.line,
        endLine: candidate.endLine ?? candidate.line,
    }));
    const warnings = [...(extractionWarnings ?? [])];
    let callbacks = [];
    try {
        callbacks = extractCallbacks(root, owners, languageId, warnings);
    }
    catch (err) {
        const message = `Callback extraction failed: ${diagnosticMessage(err)}`;
        warnings.push(message);
        logLatency({
            type: "phase",
            phase: "read_enclosing_callback_extract_error",
            filePath: absPath,
            durationMs: Date.now() - startedAt,
            metadata: { line: targetLine, error: message },
        });
    }
    const candidates = [];
    for (const sym of symbols.filter((candidate) => !candidate.local)) {
        const startLine = sym.line;
        const endLine = sym.endLine ?? sym.line;
        if (targetLine < startLine || targetLine > endLine)
            continue;
        if (!symbolMatchesKind(sym.kind, filters))
            continue;
        candidates.push({
            name: sym.name,
            kind: sym.kind,
            startLine,
            endLine,
            signature: sym.signature,
            parentChain: symbolParentChain(sym, symbols),
            priority: 1,
        });
    }
    for (const callback of callbacks) {
        if (targetLine < callback.startLine || targetLine > callback.endLine)
            continue;
        if (!callbackMatchesKind(callback.kind, filters))
            continue;
        candidates.push({
            name: callback.name,
            kind: callback.kind,
            startLine: callback.startLine,
            endLine: callback.endLine,
            signature: callback.signature,
            parentChain: callback.parentChain,
            priority: 0,
        });
    }
    candidates.sort((a, b) => {
        const span = a.endLine - a.startLine - (b.endLine - b.startLine);
        return span || a.priority - b.priority || a.startLine - b.startLine;
    });
    const selected = candidates[0];
    if (!selected) {
        log(false);
        return {
            found: false,
            path: absPath,
            line: targetLine,
            ...(warnings.length > 0 ? { warnings } : {}),
        };
    }
    const lines = content.split(/\r?\n/);
    const limit = selected.endLine - selected.startLine + 1;
    if (options?.maxLines && limit > options.maxLines) {
        const oversize = options.onOversize ?? "error";
        const base = {
            path: absPath,
            line: targetLine,
            name: selected.name,
            kind: selected.kind,
            startLine: selected.startLine,
            endLine: selected.endLine,
            signature: selected.signature,
            parentChain: selected.parentChain,
            enclosingStartLine: selected.startLine,
            enclosingEndLine: selected.endLine,
            ...(warnings.length > 0 ? { warnings } : {}),
        };
        if (oversize === "slice") {
            const sliceLimit = Math.max(1, Math.floor(options.aroundLine ?? options.maxLines ?? 80));
            const slice = clampSliceRange(targetLine, selected.startLine, selected.endLine, sliceLimit);
            const source = lines.slice(slice.startLine - 1, slice.endLine).join("\n");
            log(true);
            return {
                ...base,
                found: true,
                startLine: slice.startLine,
                endLine: slice.endLine,
                partial: true,
                selection: {
                    strategy: "oversize-slice",
                    source: "tree-sitter",
                    confidence: "medium",
                },
                source,
            };
        }
        if (oversize === "outline") {
            log(false);
            return {
                ...base,
                found: false,
                selection: {
                    strategy: "oversize-outline",
                    source: "tree-sitter",
                    confidence: "medium",
                },
                outline: enclosingOutline(selected, symbols, callbacks, filters, absPath),
                error: `Enclosing ${selected.kind} spans ${limit} lines, above maxLines ${options.maxLines}`,
            };
        }
        log(false);
        return {
            ...base,
            found: false,
            error: `Enclosing ${selected.kind} spans ${limit} lines, above maxLines ${options.maxLines}`,
        };
    }
    const source = lines
        .slice(selected.startLine - 1, selected.endLine)
        .join("\n");
    log(true);
    return {
        found: true,
        path: absPath,
        line: targetLine,
        name: selected.name,
        kind: selected.kind,
        startLine: selected.startLine,
        endLine: selected.endLine,
        enclosingStartLine: selected.startLine,
        enclosingEndLine: selected.endLine,
        signature: selected.signature,
        parentChain: selected.parentChain,
        selection: {
            strategy: "range-containment",
            source: "tree-sitter",
            confidence: "high",
        },
        ...(warnings.length > 0 ? { warnings } : {}),
        source,
    };
}