"use strict";
/**
 * Canonical graph query module. Per CLAUDE.md Rule 12, every consumer
 * (explore CLI subcommands, dashboard viz adapter, future 2.8 context
 * CLI, future 2.8 reachability) imports from here — never reimplements
 * graph traversal. Arch-check enforces.
 *
 * Sprint 1 ships the SKELETON: type signatures + empty implementations
 * so the canonical entry points exist for the arch rule to lock onto.
 * Sprint 2 fills the bodies as the explore CLI subcommands land.
 *
 * Every query is a pure function: takes a `Graph` (and optionally
 * other args), returns a typed result. No side effects, no I/O,
 * no caching — caching belongs at the loader level, not the query
 * level.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.callersOf = callersOf;
exports.calleesOf = calleesOf;
exports.nodesInFile = nodesInFile;
exports.hotFilesQuery = hotFilesQuery;
exports.communitiesQuery = communitiesQuery;
exports.fileSummaryQuery = fileSummaryQuery;
exports.entryPointsQuery = entryPointsQuery;
exports.apiSurfaceQuery = apiSurfaceQuery;
exports.featureQuery = featureQuery;
exports.contextQuery = contextQuery;
exports.findingContextQuery = findingContextQuery;
exports.fileLineContextQuery = fileLineContextQuery;
// ─── Low-level primitives ────────────────────────────────────────────────────
/** Nodes that call into the given nodeId (predecessors via `calls` edges). */
function callersOf(graph, nodeId) {
    const incoming = graph.edgesToNode.get(nodeId) ?? [];
    const out = [];
    for (const e of incoming) {
        if (e.relation !== 'calls')
            continue;
        const n = graph.nodeById.get(e.from);
        if (n)
            out.push(n);
    }
    return out;
}
/** Nodes that the given nodeId calls into (successors via `calls` edges). */
function calleesOf(graph, nodeId) {
    const outgoing = graph.edgesFromNode.get(nodeId) ?? [];
    const out = [];
    for (const e of outgoing) {
        if (e.relation !== 'calls')
            continue;
        const n = graph.nodeById.get(e.to);
        if (n)
            out.push(n);
    }
    return out;
}
/** All nodes declared in the given source file. */
function nodesInFile(graph, sourceFile) {
    return [...(graph.nodesByFile.get(sourceFile) ?? [])];
}
/**
 * Top-N files by total in-degree (callers + importers). The
 * "centrality" proxy — files many other files depend on. Useful as
 * a "what's the foundational layer of this repo?" answer.
 *
 * Files are derived from the union of `sourceFile` across all nodes;
 * the per-file aggregation traverses each node's inbound/outbound
 * edges. Limit defaults to 20 per the Sprint 0 spec.
 */
function hotFilesQuery(graph, limit = 20) {
    const perFile = new Map();
    for (const node of graph.nodes) {
        if (!node.sourceFile)
            continue;
        let agg = perFile.get(node.sourceFile);
        if (!agg) {
            agg = { callsIn: 0, callsOut: 0, nodes: [] };
            perFile.set(node.sourceFile, agg);
        }
        agg.nodes.push(node);
        for (const e of graph.edgesToNode.get(node.id) ?? []) {
            if (e.relation === 'calls')
                agg.callsIn++;
        }
        for (const e of graph.edgesFromNode.get(node.id) ?? []) {
            if (e.relation === 'calls')
                agg.callsOut++;
        }
    }
    // Imports-in: count edges into the FILE's module node. Module
    // nodes have `kind === 'module'` and their `sourceFile` IS the
    // file path. Aggregate to the file by matching on that.
    const importsInByFile = new Map();
    for (const node of graph.nodes) {
        if (node.kind !== 'module' || !node.sourceFile)
            continue;
        let count = 0;
        for (const e of graph.edgesToNode.get(node.id) ?? []) {
            if (e.relation === 'imports_from')
                count++;
        }
        importsInByFile.set(node.sourceFile, (importsInByFile.get(node.sourceFile) ?? 0) + count);
    }
    const results = [];
    for (const [sourceFile, agg] of perFile) {
        const importsIn = importsInByFile.get(sourceFile) ?? 0;
        // Pick a community via any of the file's nodes — module node
        // first if present, else any symbol's community.
        const moduleNode = agg.nodes.find((n) => n.kind === 'module');
        const sampleNode = moduleNode ?? agg.nodes[0];
        const community = sampleNode ? graph.communityByNode.get(sampleNode.id) : undefined;
        results.push({
            sourceFile,
            callsIn: agg.callsIn,
            importsIn,
            callsOut: agg.callsOut,
            communityId: community?.id,
            communityLabel: community?.dominantSourceDir || undefined,
        });
    }
    // Rank by total in-degree (calls + imports). Ties broken by
    // alphabetical source file path for stable output.
    results.sort((a, b) => {
        const ai = a.callsIn + a.importsIn;
        const bi = b.callsIn + b.importsIn;
        if (bi !== ai)
            return bi - ai;
        return a.sourceFile.localeCompare(b.sourceFile);
    });
    return results.slice(0, limit);
}
/**
 * Top-N communities by node count, with each community's top-3 hot
 * files (by in-degree within the community). Gives a "what are the
 * natural modules in this repo?" answer that complements `hot-files`
 * (which is global).
 */
function communitiesQuery(graph, limit = 8) {
    const callsInByNode = computeCallsInByNode(graph);
    const sortedCommunities = [...graph.communities].sort((a, b) => b.nodeIds.length - a.nodeIds.length);
    return sortedCommunities.slice(0, limit).map((c) => {
        // Per-file in-degree within this community only.
        const inDegByFile = new Map();
        for (const nid of c.nodeIds) {
            const node = graph.nodeById.get(nid);
            if (!node?.sourceFile)
                continue;
            const d = callsInByNode.get(nid) ?? 0;
            inDegByFile.set(node.sourceFile, (inDegByFile.get(node.sourceFile) ?? 0) + d);
        }
        const topHotFiles = [...inDegByFile.entries()]
            .filter(([, d]) => d > 0)
            .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
            .slice(0, 3)
            .map(([f]) => f);
        return {
            id: c.id,
            nodeCount: c.nodeIds.length,
            dominantSourceDir: c.dominantSourceDir,
            dominantPack: c.dominantPack,
            cohesion: c.cohesion,
            topHotFiles,
        };
    });
}
/**
 * Internal helper: precompute per-node call in-degree. Shared between
 * `hotFilesQuery` (file-level aggregation) and `communitiesQuery`
 * (community-bounded ranking). Per CLAUDE.md Rule 2 — one source of
 * truth for the calls-in-degree counter.
 */
function computeCallsInByNode(graph) {
    const m = new Map();
    for (const node of graph.nodes) {
        let d = 0;
        for (const e of graph.edgesToNode.get(node.id) ?? []) {
            if (e.relation === 'calls')
                d++;
        }
        if (d > 0)
            m.set(node.id, d);
    }
    return m;
}
/**
 * Build the per-file summary. `found: false` when the file isn't in
 * the graph (excluded by minified detection / vendored / unsupported
 * extension); the consumer handles that case with an explanatory
 * note instead of an empty result.
 */
function fileSummaryQuery(graph, sourceFile) {
    const nodes = nodesInFile(graph, sourceFile);
    if (nodes.length === 0) {
        return {
            sourceFile,
            found: false,
            symbols: [],
            callerFiles: [],
            calleeFiles: [],
            importsIn: [],
            importsOut: [],
        };
    }
    // Per-symbol summary for non-module nodes.
    const symbols = [];
    // Caller / callee aggregation, deduped at the file level.
    const callerCounts = new Map();
    const calleeCounts = new Map();
    // Imports in/out aggregation against the file's module node.
    const importsInFiles = new Set();
    const importsOutFiles = new Set();
    for (const node of nodes) {
        if (node.kind !== 'module') {
            let inCalls = 0;
            let outCalls = 0;
            for (const e of graph.edgesToNode.get(node.id) ?? []) {
                if (e.relation === 'calls') {
                    inCalls++;
                    const src = graph.nodeById.get(e.from);
                    if (src?.sourceFile && src.sourceFile !== sourceFile) {
                        callerCounts.set(src.sourceFile, (callerCounts.get(src.sourceFile) ?? 0) + 1);
                    }
                }
            }
            for (const e of graph.edgesFromNode.get(node.id) ?? []) {
                if (e.relation === 'calls') {
                    outCalls++;
                    const dst = graph.nodeById.get(e.to);
                    if (dst?.sourceFile && dst.sourceFile !== sourceFile) {
                        calleeCounts.set(dst.sourceFile, (calleeCounts.get(dst.sourceFile) ?? 0) + 1);
                    }
                }
            }
            symbols.push({
                id: node.id,
                kind: node.kind,
                label: node.label,
                line: node.line,
                exported: node.exported,
                callsIn: inCalls,
                callsOut: outCalls,
            });
        }
        else {
            // Module node: harvest imports edges.
            for (const e of graph.edgesToNode.get(node.id) ?? []) {
                if (e.relation === 'imports_from') {
                    const src = graph.nodeById.get(e.from);
                    if (src?.sourceFile && src.sourceFile !== sourceFile) {
                        importsInFiles.add(src.sourceFile);
                    }
                }
            }
            for (const e of graph.edgesFromNode.get(node.id) ?? []) {
                if (e.relation === 'imports_from') {
                    const dst = graph.nodeById.get(e.to);
                    if (dst?.sourceFile && dst.sourceFile !== sourceFile) {
                        importsOutFiles.add(dst.sourceFile);
                    }
                }
            }
        }
    }
    // Pick a representative node for community lookup — module first,
    // else any symbol.
    const moduleNode = nodes.find((n) => n.kind === 'module');
    const sampleNode = moduleNode ?? nodes[0];
    const community = sampleNode ? graph.communityByNode.get(sampleNode.id) : undefined;
    return {
        sourceFile,
        found: true,
        symbols: symbols.sort((a, b) => b.callsIn - a.callsIn || a.label.localeCompare(b.label)),
        callerFiles: [...callerCounts.entries()]
            .map(([sourceFile, count]) => ({ sourceFile, count }))
            .sort((a, b) => b.count - a.count || a.sourceFile.localeCompare(b.sourceFile)),
        calleeFiles: [...calleeCounts.entries()]
            .map(([sourceFile, count]) => ({ sourceFile, count }))
            .sort((a, b) => b.count - a.count || a.sourceFile.localeCompare(b.sourceFile)),
        importsIn: [...importsInFiles].sort().map((sourceFile) => ({ sourceFile })),
        importsOut: [...importsOutFiles].sort().map((sourceFile) => ({ sourceFile })),
        communityId: community?.id,
        communityLabel: community?.dominantSourceDir || undefined,
        communityPack: community?.dominantPack || undefined,
    };
}
/**
 * Discover entry-point symbols by intersecting graph nodes with the
 * union of active packs' `primaryComponentPaths` + `routePaths`. The
 * rank is by call out-degree — entry points typically fan OUT (they
 * receive a request, then call many downstream functions). A high
 * out-degree node in a primary-architecture path is almost certainly
 * a real entry point.
 *
 * `flags` is the per-pack boolean map from `DetectedStack.languages`;
 * only patterns from active packs contribute. This matches the
 * existing pack-driven analyzer pattern.
 */
function entryPointsQuery(graph, primaryPaths, routePaths, limit = 10) {
    if (primaryPaths.length === 0 && routePaths.length === 0) {
        return [];
    }
    // Classify each source file by whether it matches a pattern.
    // Patterns are case-insensitive substrings of the relative POSIX
    // path (per the architecturalShape contract). routePaths overlap
    // primaryComponentPaths in many packs; tag a file by the most
    // specific match (route > primary > none).
    const classify = (sourceFile) => {
        const lower = sourceFile.toLowerCase();
        for (const p of routePaths) {
            if (lower.includes(p.toLowerCase())) {
                return { matched: true, label: patternLabel(p), isRoute: true };
            }
        }
        for (const p of primaryPaths) {
            if (lower.includes(p.toLowerCase())) {
                return { matched: true, label: patternLabel(p), isRoute: false };
            }
        }
        return { matched: false, label: '', isRoute: false };
    };
    const results = [];
    for (const node of graph.nodes) {
        if (node.kind === 'module')
            continue;
        if (!node.sourceFile)
            continue;
        const c = classify(node.sourceFile);
        if (!c.matched)
            continue;
        let callsOut = 0;
        for (const e of graph.edgesFromNode.get(node.id) ?? []) {
            if (e.relation === 'calls')
                callsOut++;
        }
        if (callsOut === 0)
            continue; // entry points fan out; zero-out-degree symbols aren't entry points
        // Pack: derive from extension via the helper below. Avoids a
        // second pack registry import — keeps queries.ts independent of
        // languages/index.ts for this lookup.
        results.push({
            sourceFile: node.sourceFile,
            line: node.line,
            symbol: node.label,
            componentType: c.label,
            callsOut,
            pack: packFromExt(node.sourceFile),
        });
    }
    // Rank by callsOut desc, ties by sourceFile asc for stability.
    results.sort((a, b) => b.callsOut - a.callsOut || a.sourceFile.localeCompare(b.sourceFile));
    return results.slice(0, limit);
}
/**
 * Extract a human-readable label from a path pattern. E.g.
 * `/controllers/` → `controllers`, `/Forms/` → `forms`.
 */
function patternLabel(pattern) {
    return pattern.replace(/[/\\]/g, '').toLowerCase();
}
// Per-extension pack id derivation. Mirrors EXT_TO_PACK in the
// Python script. Kept here as a private helper rather than imported
// from the registry to keep queries.ts importable without pulling
// the whole pack module surface.
function packFromExt(sourceFile) {
    const i = sourceFile.lastIndexOf('.');
    if (i < 0)
        return '';
    const ext = sourceFile.slice(i).toLowerCase();
    const map = {
        '.ts': 'typescript',
        '.tsx': 'typescript',
        '.js': 'typescript',
        '.jsx': 'typescript',
        '.mjs': 'typescript',
        '.cjs': 'typescript',
        '.py': 'python',
        '.go': 'go',
        '.rs': 'rust',
        '.cs': 'csharp',
        '.kt': 'kotlin',
        '.kts': 'kotlin',
        '.java': 'java',
        '.rb': 'ruby',
    };
    return map[ext] ?? '';
}
/**
 * Find exported symbols with zero internal callers. `packsExcluded`
 * lists pack ids whose `exportDetection.reliability === 'unreliable'`
 * — those packs' nodes are skipped because we can't trust their
 * `exported` flag (today: ruby). The consumer surfaces the exclusion
 * as a note in its output.
 */
function apiSurfaceQuery(graph, packsExcluded, limit = 25) {
    const excluded = new Set(packsExcluded);
    const results = [];
    for (const node of graph.nodes) {
        if (node.kind === 'module')
            continue;
        if (node.exported !== true)
            continue; // absent or false → skip
        const pack = packFromExt(node.sourceFile);
        if (excluded.has(pack))
            continue;
        // Zero internal callers — check inbound calls edges. Note: the
        // calls in-degree includes potential graphify same-name conflicts
        // (run() at one site can attract calls meant for run() at another),
        // so this is a "best effort" — but consumers know that's the limit.
        let hasCaller = false;
        for (const e of graph.edgesToNode.get(node.id) ?? []) {
            if (e.relation === 'calls') {
                hasCaller = true;
                break;
            }
        }
        if (hasCaller)
            continue;
        results.push({
            sourceFile: node.sourceFile,
            line: node.line,
            symbol: node.label,
            kind: node.kind,
            pack,
        });
    }
    // Sort by sourceFile asc (groups by file naturally) then line asc.
    results.sort((a, b) => a.sourceFile.localeCompare(b.sourceFile) || (a.line ?? 0) - (b.line ?? 0));
    return results.slice(0, limit);
}
/**
 * The marquee query — "where is feature X implemented?" Three-stage
 * resolution:
 *
 *   1. Direct symbolIndex lookup (case-insensitive, exact match on
 *      the stripped name)
 *   2. Substring expansion (opt-in via opts.substring) — scans every
 *      node's label for substring match
 *   3. Structural expansion — for each seed, gather community
 *      membership + immediate callers + callees, group by community
 *
 * On zero hits, computes edit-distance suggestions against the
 * symbolIndex keys so the caller can prompt the user with "did you
 * mean..."
 */
function featureQuery(graph, keyword, opts = {}) {
    const limit = opts.limit ?? 50;
    const kw = keyword.toLowerCase().trim();
    if (!kw) {
        return { results: [], suggestions: [] };
    }
    // Stage 1 + 2: direct symbolIndex match + optional substring expansion.
    const seedIds = findSeedIds(graph, kw, opts.substring ?? false);
    if (seedIds.size === 0) {
        return { results: [], suggestions: suggestionsFor(graph, kw) };
    }
    // Stage 3: structural expansion. For each seed, gather its
    // community + direct callers/callees. Group expanded set by
    // community id.
    const expandedByComm = new Map();
    const unclusteredExpansion = new Set();
    for (const seedId of seedIds) {
        const community = graph.communityByNode.get(seedId);
        const bucket = community
            ? (expandedByComm.get(community.id) ?? new Set())
            : unclusteredExpansion;
        bucket.add(seedId);
        // Direct callers (1 hop)
        for (const e of graph.edgesToNode.get(seedId) ?? []) {
            if (e.relation === 'calls')
                bucket.add(e.from);
        }
        // Direct callees (1 hop)
        for (const e of graph.edgesFromNode.get(seedId) ?? []) {
            if (e.relation === 'calls')
                bucket.add(e.to);
        }
        if (community)
            expandedByComm.set(community.id, bucket);
    }
    // Build cluster objects, ranked by seed count then size.
    const clusters = [];
    let clusterIdx = 0;
    const buildCluster = (nodeIds, community) => {
        const files = new Set();
        const keySymbols = new Set();
        let seedHits = 0;
        for (const nid of nodeIds) {
            const node = graph.nodeById.get(nid);
            if (!node)
                continue;
            if (node.sourceFile)
                files.add(node.sourceFile);
            if (seedIds.has(nid)) {
                seedHits++;
                // Promote seed nodes to keySymbols list.
                const stripped = stripParens(node.label);
                if (stripped)
                    keySymbols.add(stripped);
            }
        }
        // Top-8 key symbols by alpha for stable output.
        const keySymbolsList = [...keySymbols].sort().slice(0, 8);
        const filesList = [...files].sort();
        const role = roleLabel(community);
        return {
            clusterId: clusterIdx++,
            communityId: community?.id,
            role,
            dominantSourceDir: community?.dominantSourceDir ?? '',
            files: filesList,
            keySymbols: keySymbolsList,
            seedHits,
        };
    };
    for (const [commId, ids] of expandedByComm) {
        const community = graph.communityById.get(commId);
        clusters.push(buildCluster(ids, community));
    }
    if (unclusteredExpansion.size > 0) {
        clusters.push(buildCluster(unclusteredExpansion, undefined));
    }
    // Rank clusters by seedHits desc, then size desc, then community id asc.
    clusters.sort((a, b) => b.seedHits - a.seedHits ||
        b.files.length - a.files.length ||
        (a.communityId ?? 9999) - (b.communityId ?? 9999));
    const limitedClusters = clusters.slice(0, limit);
    // Central entry point: across all seed ids, the one with the
    // highest call in-degree globally.
    let centralId;
    let centralCount = 0;
    for (const seedId of seedIds) {
        let inDeg = 0;
        for (const e of graph.edgesToNode.get(seedId) ?? []) {
            if (e.relation === 'calls')
                inDeg++;
        }
        if (inDeg > centralCount) {
            centralCount = inDeg;
            centralId = seedId;
        }
    }
    let centralEntryPoint;
    if (centralId && centralCount > 0) {
        const n = graph.nodeById.get(centralId);
        if (n) {
            centralEntryPoint = {
                sourceFile: n.sourceFile,
                line: n.line,
                symbol: n.label,
                calledFrom: centralCount,
            };
        }
    }
    return { results: limitedClusters, suggestions: [], centralEntryPoint };
}
/**
 * The marquee token-reduction primitive — "give me just the relevant
 * structural slice for this query." Resolves seeds the same way
 * `featureQuery` does (shared `findSeedIds`), then expands breadth-
 * first through `calls` edges, stopping when the running token
 * estimate fills the budget (or an optional `maxDepth` ceiling is
 * reached). Adaptive depth falls out for free: a hot symbol's
 * immediate neighbors fill the budget at hop 1, while a cold symbol's
 * sparse neighborhood leaves room to reach hop 2+.
 *
 * Pure: no I/O, no formatting. Same `Graph` in → same `ContextResult`
 * out.
 */
function contextQuery(graph, keyword, opts = {}) {
    const budget = opts.budget ?? 2000;
    const tokensPerNode = opts.tokensPerNode ?? 15;
    const maxDepth = opts.maxDepth ?? Infinity;
    const kw = keyword.toLowerCase().trim();
    const empty = {
        query: keyword,
        matched: false,
        selection: [],
        byCommunity: [],
        blastRadius: { callers: 0, callerFiles: 0 },
        truncated: false,
        omittedCount: 0,
        estimatedTokens: 0,
        budget,
        suggestions: [],
    };
    if (!kw)
        return empty;
    const seedIds = findSeedIds(graph, kw, opts.substring ?? false);
    if (seedIds.size === 0) {
        return { ...empty, suggestions: suggestionsFor(graph, kw) };
    }
    // Budget-bounded BFS over `calls` edges. The queue carries (id, hop);
    // seeds enter at hop 0. We add a node to the selection only if its
    // estimated render cost still fits the budget; the first node that
    // would overflow flips `truncated` and we drain the remaining queue
    // into `omittedCount` (a lower bound — honest "+N more").
    const selection = [];
    const visited = new Set();
    const queue = [];
    for (const id of seedIds)
        queue.push({ id, hop: 0 });
    let estimatedTokens = 0;
    let truncated = false;
    let omittedCount = 0;
    while (queue.length > 0) {
        const { id, hop } = queue.shift();
        if (visited.has(id))
            continue;
        visited.add(id);
        const node = graph.nodeById.get(id);
        if (!node || node.kind === 'module')
            continue;
        if (estimatedTokens + tokensPerNode > budget) {
            // Budget exhausted — this node + everything still queued is omitted.
            truncated = true;
            omittedCount++;
            continue;
        }
        const callers = callersOf(graph, id);
        const callees = calleesOf(graph, id);
        selection.push({
            id,
            symbol: stripParens(node.label),
            sourceFile: node.sourceFile,
            line: node.line,
            kind: node.kind,
            hop,
            callsIn: callers.length,
            callsOut: callees.length,
        });
        estimatedTokens += tokensPerNode;
        if (hop < maxDepth) {
            for (const n of callers)
                if (!visited.has(n.id))
                    queue.push({ id: n.id, hop: hop + 1 });
            for (const n of callees)
                if (!visited.has(n.id))
                    queue.push({ id: n.id, hop: hop + 1 });
        }
    }
    // Anchor: highest call-in-degree seed (the "start here" symbol).
    let anchor;
    let anchorCount = -1;
    for (const seedId of seedIds) {
        const node = graph.nodeById.get(seedId);
        if (!node)
            continue;
        const inDeg = callersOf(graph, seedId).length;
        if (inDeg > anchorCount) {
            anchorCount = inDeg;
            anchor = {
                sourceFile: node.sourceFile,
                line: node.line,
                symbol: stripParens(node.label),
                calledFrom: inDeg,
            };
        }
    }
    // Blast radius: unique callers of the SEEDS + distinct caller files
    // (the surface a change to the matched symbols would touch).
    const callerIds = new Set();
    const callerFiles = new Set();
    for (const seedId of seedIds) {
        for (const caller of callersOf(graph, seedId)) {
            callerIds.add(caller.id);
            if (caller.sourceFile)
                callerFiles.add(caller.sourceFile);
        }
    }
    // Group the selection by community for orientation. Communities
    // containing a SEED (hop-0) symbol rank first — the reader cares
    // most about where the matched symbols live, not the biggest
    // incidental cluster the BFS fanned into. (graphify often lumps
    // much of a repo into one mega-community; sorting purely by size
    // would bury the seed under that grab-bag.)
    const groupsByComm = new Map();
    for (const sel of selection) {
        const community = graph.communityByNode.get(sel.id);
        const key = community?.id;
        let group = groupsByComm.get(key);
        if (!group) {
            group = {
                communityId: community?.id,
                role: roleLabel(community),
                files: [],
                symbols: [],
                hasSeed: false,
            };
            groupsByComm.set(key, group);
        }
        if (sel.sourceFile && !group.files.includes(sel.sourceFile))
            group.files.push(sel.sourceFile);
        group.symbols.push(sel.symbol);
        if (sel.hop === 0)
            group.hasSeed = true;
    }
    const byCommunity = [...groupsByComm.values()]
        .sort((a, b) => Number(b.hasSeed) - Number(a.hasSeed) ||
        b.symbols.length - a.symbols.length ||
        (a.communityId ?? 9999) - (b.communityId ?? 9999))
        .map(({ hasSeed: _hasSeed, ...group }) => group);
    return {
        query: keyword,
        matched: true,
        anchor,
        selection,
        byCommunity,
        blastRadius: { callers: callerIds.size, callerFiles: callerFiles.size },
        truncated,
        omittedCount,
        estimatedTokens,
        budget,
        suggestions: [],
    };
}
/**
 * Graph context for one finding location. Reuses `fileSummaryQuery`
 * for the file-level caller aggregation + community lookup (Rule 2 —
 * one source of truth for "who depends on this file"), then maps an
 * optional `line` to the nearest enclosing declaration.
 *
 * Pure: same `Graph` + location in → same `FindingContext` out. The
 * enrichment adapter (`src/explore/finding-context.ts`) owns the
 * graph load, the per-finding loop, and the dedupe budget.
 */
function findingContextQuery(graph, sourceFile, line, opts = {}) {
    const topN = opts.topCallerFiles ?? 5;
    const summary = fileSummaryQuery(graph, sourceFile);
    if (!summary.found) {
        return {
            found: false,
            sourceFile,
            blastRadius: { callerFiles: 0, callers: 0, topCallerFiles: [] },
        };
    }
    const callers = summary.callerFiles.reduce((acc, c) => acc + c.count, 0);
    const topCallerFiles = summary.callerFiles.slice(0, topN).map((c) => c.sourceFile);
    let enclosingSymbol;
    if (typeof line === 'number') {
        let best;
        for (const sym of summary.symbols) {
            if (typeof sym.line !== 'number')
                continue;
            if (sym.line <= line && (best?.line === undefined || sym.line > best.line)) {
                best = sym;
            }
        }
        if (best)
            enclosingSymbol = { symbol: stripParens(best.label), line: best.line };
    }
    const community = summary.communityId !== undefined
        ? {
            id: summary.communityId,
            role: summary.communityLabel ?? `community-${summary.communityId}`,
        }
        : { role: 'unclustered' };
    return {
        found: true,
        sourceFile,
        community,
        blastRadius: { callerFiles: summary.callerFiles.length, callers, topCallerFiles },
        enclosingSymbol,
    };
}
/**
 * Resolve the structural neighborhood of a `file:line` location. Reuses
 * `fileSummaryQuery` for the file-level blast radius + community (Rule 2
 * — one source of truth for "who depends on this file") and the
 * `callersOf` / `calleesOf` primitives for the enclosing symbol's direct
 * edges. Pure: same `Graph` + location in → same `FileLineContext` out.
 */
function fileLineContextQuery(graph, sourceFile, line, opts = {}) {
    const maxList = opts.maxList ?? 8;
    const summary = fileSummaryQuery(graph, sourceFile);
    if (!summary.found) {
        return {
            found: false,
            sourceFile,
            callers: [],
            callees: [],
            blastRadius: { callerFiles: 0, callers: 0 },
        };
    }
    const nodes = nodesInFile(graph, sourceFile).filter((n) => n.kind !== 'module');
    // Enclosing symbol: declaration nearest at-or-above the target line.
    let enclosing;
    for (const n of nodes) {
        if (typeof n.line !== 'number' || n.line > line)
            continue;
        if (enclosing?.line === undefined || n.line > enclosing.line)
            enclosing = n;
    }
    // Span end: the next declaration strictly below the enclosing one
    // (exclusive). Undefined ⇒ the symbol runs to EOF (CLI slices to the
    // budget). Module nodes are excluded so an import block doesn't cut
    // the body short.
    let endLineExclusive;
    if (enclosing?.line !== undefined) {
        for (const n of nodes) {
            if (typeof n.line !== 'number' || n.line <= enclosing.line)
                continue;
            if (endLineExclusive === undefined || n.line < endLineExclusive)
                endLineExclusive = n.line;
        }
    }
    const toRef = (n) => ({
        symbol: stripParens(n.label),
        sourceFile: n.sourceFile,
        line: n.line,
    });
    const callers = enclosing ? callersOf(graph, enclosing.id).slice(0, maxList).map(toRef) : [];
    const callees = enclosing ? calleesOf(graph, enclosing.id).slice(0, maxList).map(toRef) : [];
    const fileCallers = summary.callerFiles.reduce((acc, c) => acc + c.count, 0);
    const community = summary.communityId !== undefined
        ? {
            id: summary.communityId,
            role: summary.communityLabel ?? `community-${summary.communityId}`,
        }
        : { role: 'unclustered' };
    return {
        found: true,
        sourceFile,
        enclosingSymbol: enclosing
            ? {
                id: enclosing.id,
                symbol: stripParens(enclosing.label),
                line: enclosing.line,
                kind: enclosing.kind,
                callsIn: callersOf(graph, enclosing.id).length,
                callsOut: calleesOf(graph, enclosing.id).length,
            }
            : undefined,
        span: enclosing?.line !== undefined ? { startLine: enclosing.line, endLineExclusive } : undefined,
        callers,
        callees,
        community,
        blastRadius: { callerFiles: summary.callerFiles.length, callers: fileCallers },
    };
}
function roleLabel(community) {
    if (!community)
        return 'unclustered';
    if (community.dominantSourceDir)
        return community.dominantSourceDir;
    return `community-${community.id}`;
}
function stripParens(label) {
    if (!label)
        return '';
    let s = label.replace(/\(\)$/, '');
    if (s.includes('.'))
        s = s.split('.').pop() ?? s;
    return s;
}
/**
 * Iterative Levenshtein with O(min(a, b)) memory. Fast enough for
 * the suggestions scan (a few hundred symbolIndex keys × ≤20 chars).
 */
function levenshtein(a, b) {
    if (a === b)
        return 0;
    if (a.length === 0)
        return b.length;
    if (b.length === 0)
        return a.length;
    const long = a.length >= b.length ? a : b;
    const short = a.length >= b.length ? b : a;
    let prev = new Array(short.length + 1);
    let curr = new Array(short.length + 1);
    for (let i = 0; i <= short.length; i++)
        prev[i] = i;
    for (let i = 1; i <= long.length; i++) {
        curr[0] = i;
        for (let j = 1; j <= short.length; j++) {
            const cost = long[i - 1] === short[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[short.length];
}
/**
 * Resolve seed node ids for a (lowercased, trimmed) keyword. Stage 1
 * is an exact symbolIndex hit; stage 2 (opt-in) adds substring
 * matches across the index keys. Shared by `featureQuery` and
 * `contextQuery` so the two surfaces match identically — one source
 * of truth for "what does this keyword resolve to" (Rule 2).
 */
function findSeedIds(graph, kw, substring) {
    const seedIds = new Set(graph.symbolIndex[kw] ?? []);
    if (substring) {
        for (const [key, ids] of Object.entries(graph.symbolIndex)) {
            if (key.includes(kw)) {
                for (const id of ids)
                    seedIds.add(id);
            }
        }
    }
    return seedIds;
}
/**
 * "Did you mean" suggestions for a keyword that matched no symbols.
 * Two merged flavors: substring matches (symbols whose name CONTAINS
 * the keyword — the common case, since users rarely type exact long
 * symbol names) and Levenshtein ≤2 typo candidates. Substring is only
 * tried for keywords of length ≥ 3 (shorter generates too many false
 * positives). Top-5 by hit count. Shared by `featureQuery` +
 * `contextQuery`.
 */
function suggestionsFor(graph, kw) {
    const suggestions = [];
    const seen = new Set();
    if (kw.length >= 3) {
        for (const key of Object.keys(graph.symbolIndex)) {
            if (key.includes(kw)) {
                suggestions.push({ key, hits: graph.symbolIndex[key].length });
                seen.add(key);
            }
        }
    }
    for (const key of Object.keys(graph.symbolIndex)) {
        if (seen.has(key))
            continue;
        if (levenshtein(kw, key) <= 2) {
            suggestions.push({ key, hits: graph.symbolIndex[key].length });
        }
    }
    suggestions.sort((a, b) => b.hits - a.hits || a.key.localeCompare(b.key));
    return suggestions.slice(0, 5);
}
//# sourceMappingURL=queries.js.map