UNPKG

pi-lens

Version:

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

1,886 lines • 82.6 kB
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileContentProvider } from "../dispatch/facts/file-content.js";
import { featureHintMetadata } from "../feature-hints.js";
import { detectFileKind, KIND_EXTENSIONS } from "../file-kinds.js";
import { detectFileRole } from "../file-role.js";
import { getProjectDataDir } from "../file-utils.js";
import { logLatency } from "../latency-logger.js";
import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, } from "../path-utils.js";
import { collectProjectSourceFilesAsync } from "../project-scan-policy.js";
import { resolveImportToFiles } from "./import-resolvers.js";
import { RUNTIME_CONFIG } from "../runtime-config.js";
import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
import { resolveGitIdentity } from "./git-identity.js";
import { buildSymbolId } from "./symbol-id.js";
// v3 (#260): test files are no longer indexed. Bumping the version makes
// loadPersistedGraph reject any v2 snapshot (which still contains test-file
// nodes/edges) → a clean tests-free rebuild on first load after upgrade, for
// every project, without anyone deleting the cache by hand.
// v4 (#655, narrow first slice): symbol-node IDs changed shape from
// `<file>:<name>` to `<file>:<name>:<kind>:<startLine>` (see symbol-id.ts) to
// stop overloads/same-named methods/nested functions from colliding onto one
// node. A v3 snapshot's nodes/edges still use the old ID shape throughout, so
// it must be rejected rather than merged with newly-built v4 IDs — same
// safe-rebuild mechanism as the v2→v3 bump above.
const REVIEW_GRAPH_VERSION = "v4";
const MAIN_KINDS = new Set([
    "jsts",
    "python",
    "go",
    "rust",
    "ruby",
    "cxx",
    // Languages added in #152: WASMs + symbol queries now available
    "java",
    "kotlin",
    "dart",
    "elixir",
    "csharp",
    "php",
    "swift",
    "lua",
    "ocaml",
    "zig",
    "shell",
]);
// File extensions for the kinds the graph actually ingests. Scoping the source
// walk to these means the maxGraphFiles cap counts only graph-relevant files —
// so a repo heavy in JSON/YAML/Markdown doesn't trip the cap on files the graph
// would have filtered out anyway (the cap is on the walk, not on noise). #250.
const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTENSIONS[kind] ?? []);
const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
const extractorCache = new Map();
// Per-invocation Promise cache: deduplicates concurrent buildOrUpdateGraph calls
// for the same (cwd, changedFiles). Cleared at the start of each pipeline
// invocation. A separate workspace cache below preserves the expensive parsed
// graph across invocations when source file mtimes/sizes have not changed.
const _buildCache = new Map();
const _workspaceGraphCache = new Map();
// #459: process-wide monotonic source for ReviewGraph.buildGeneration stamps.
// Never reset (uniqueness is the invariant — a workspace-cache clear must not
// let a new build collide with a generation a derived-data cache recorded
// earlier in the same process).
let _graphGenerationCounter = 0;
/** Beyond this many seq-changed files, incremental re-extract nears sweep cost — just sweep (#451). */
const SEQ_FASTPATH_MAX_CHANGES = 32;
/** Force a full walk+stat re-verify at least this often in wall time (external-edit safety valve, #451). */
const SEQ_FASTPATH_REVERIFY_MS = 5 * 60_000;
/** ...and at least every Nth fast-path build per workspace. */
const SEQ_FASTPATH_REVERIFY_EVERY = 20;
function seqFastpathEnabled() {
    const raw = process.env.PI_LENS_GRAPH_SEQ_FASTPATH;
    return raw !== "0" && raw !== "false";
}
let _lastGraphBuildInfo = {
    reused: false,
    mode: "full",
    graphChanged: true,
};
export function clearGraphCache() {
    _buildCache.clear();
}
export function clearReviewGraphWorkspaceCache() {
    _buildCache.clear();
    _workspaceGraphCache.clear();
    _lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
}
// #300 Edge 2: the review-graph's cross-worktree isolation is INCIDENTAL to
// the cwd-derived data-dir slug (getProjectDataDir) — it holds only because
// every process is launched with its own worktree as cwd. If a host ever
// passes the main repo root as cwd while editing worktree files by absolute
// path, that assumption silently breaks. This doesn't hard-fail (the issue
// is explicit: log-once observability is enough) — it just makes the
// assumption visible. The Set records every cwd whose check has RUN (not just
// mismatches), so resolveGitIdentity's fs reads happen once per cwd per
// process — zero per-build cost after the first, mismatch or not.
const _cwdWorktreeCheckedCwds = new Set();
export function _resetCwdWorktreeMismatchLogForTests() {
    _cwdWorktreeCheckedCwds.clear();
}
function logCwdWorktreeMismatchOnce(cwd) {
    const key = normalizeMapKey(cwd);
    if (_cwdWorktreeCheckedCwds.has(key))
        return;
    _cwdWorktreeCheckedCwds.add(key);
    const identity = resolveGitIdentity(cwd);
    if (!identity)
        return; // not a git repo — nothing to compare against
    if (identity.worktreeRoot === normalizeFilePath(path.resolve(cwd)))
        return;
    logLatency({
        type: "phase",
        phase: "review_graph_cwd_worktree_mismatch",
        filePath: cwd,
        durationMs: 0,
        metadata: { cwd, worktreeRoot: identity.worktreeRoot },
    });
}
export function getLastGraphBuildInfo() {
    return _lastGraphBuildInfo;
}
/**
 * Read-only access to the already-built review graph for `cwd` — NEVER builds.
 * Returns a query-ready clone of the in-memory cached graph if one exists, else
 * undefined. For read-substitute callers (module_report, #256) that must not
 * trigger a synchronous full rebuild on the agent's call path: a full build
 * re-runs every fact provider (TS-compiler ASTs for jsts, tree-sitter for the
 * rest), and two of those racing OOM'd pi. Callers degrade to outline-only when
 * this returns undefined; the live edit pipeline keeps the cache warm so in pi it
 * is almost always present (possibly a few edits stale, which is fine for a
 * navigation read).
 */
// Stored snapshots are cloned with EMPTY index maps (see cloneGraph). Build them
// once, in place, so the read accessor can hand back the cached object directly
// instead of clone+reindex on every call (#260: module_report was burning
// 200-425ms each over a 13.5MB graph). The snapshot is never mutated after
// caching — a new build replaces the map entry rather than editing in place — so
// the populated indexes stay valid and the object is safe to share read-only.
function ensureIndexed(graph) {
    if (graph.edges.length > 0 && graph.edgesByFrom.size === 0) {
        rebuildIndexes(graph);
    }
}
/**
 * READ-ONLY accessor. Returns the cached graph as a SHARED, already-indexed
 * object — callers (module_report's outline + blast radius) must not mutate it. No clone,
 * no per-call reindex.
 */
export function getCachedReviewGraph(cwd) {
    const key = normalizeMapKey(cwd);
    const cached = _workspaceGraphCache.get(key);
    if (cached) {
        ensureIndexed(cached.graph);
        return cached.graph;
    }
    // Tier 3: the persisted disk snapshot. This is the cross-PROCESS path — the
    // edit pipeline (one process) persists the graph; a separate module_report
    // process reads it here instead of seeing an empty in-memory cache (the
    // "graph: cold" symptom). Possibly a few edits stale, which is fine for a
    // navigation read. Warm the in-memory cache so repeat reads in this process
    // skip the disk read. loadPersistedGraph already rebuilt the indexes.
    // #300: this read is BLIND — nothing downstream content-verifies it, so a
    // stamped snapshot from a different HEAD/worktree must be dropped here.
    const disk = loadPersistedGraph(cwd, { verifyGitStamp: true });
    if (!disk)
        return undefined;
    _workspaceGraphCache.set(key, {
        signature: disk.signature,
        fileSignatures: disk.fileSignatures,
        fileHashes: disk.fileHashes,
        graph: disk.graph,
    });
    return disk.graph;
}
function makeCtx(filePath, cwd, facts) {
    return {
        filePath,
        cwd,
        kind: detectFileKind(filePath),
        fileRole: detectFileRole(filePath),
        pi: { getFlag: () => undefined },
        autofix: false,
        deltaMode: false,
        facts,
        blockingOnly: false,
        modifiedRanges: undefined,
        hasTool: async () => false,
        log: () => { },
    };
}
function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function createEmptyGraph() {
    return {
        version: REVIEW_GRAPH_VERSION,
        builtAt: new Date().toISOString(),
        nodes: new Map(),
        edges: [],
        edgesByFrom: new Map(),
        edgesByTo: new Map(),
        fileNodes: new Map(),
        symbolNodesByFile: new Map(),
        changedSymbolsByFile: new Map(),
    };
}
function cloneGraph(graph) {
    return {
        version: graph.version,
        builtAt: graph.builtAt,
        nodes: new Map(graph.nodes),
        edges: graph.edges.map((edge) => ({ ...edge })),
        edgesByFrom: new Map(),
        edgesByTo: new Map(),
        fileNodes: new Map(),
        symbolNodesByFile: new Map(),
        changedSymbolsByFile: new Map(graph.changedSymbolsByFile),
    };
}
function sourceSignatureEntry(file) {
    try {
        const stat = fs.statSync(file);
        return `${stat.size}:${stat.mtimeMs}`;
    }
    catch {
        return "missing";
    }
}
// Chunked-yield budget for the per-edit signature/stat loops. 100 stat calls
// per chunk keeps each synchronous burst well under pi's typing window while
// adding negligible scheduling overhead. The work and its output are identical
// to a tight synchronous loop — only the loop yields the event loop between
// chunks so a large project's cascade graph rebuild can't freeze the TUI.
const STAT_YIELD_EVERY = 100;
const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
/**
 * Async, chunked-yield twin of the per-file source-signature map. Produces the
 * exact same `file -> "size:mtimeMs"` map as a synchronous loop, but yields to
 * the event loop every {@link STAT_YIELD_EVERY} stats. Used on the per-edit
 * cascade path where statting every project file synchronously would otherwise
 * block the loop for hundreds of ms on a large repo.
 */
async function sourceSignatureMapAsync(files) {
    const signatures = new Map();
    let sinceYield = 0;
    for (const file of files) {
        signatures.set(file, sourceSignatureEntry(file));
        if (++sinceYield >= STAT_YIELD_EVERY) {
            sinceYield = 0;
            await yieldToLoop();
        }
    }
    return signatures;
}
function sourceSignatureFromMap(signatures) {
    return [...signatures.entries()]
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([file, signature]) => `${file}:${signature}`)
        .join("|");
}
function contentHashEntry(file) {
    try {
        // sha256, not for security — a content fingerprint for change detection;
        // avoids SonarCloud's weak-hash (sha1/md5) flag.
        return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
    }
    catch {
        return "missing";
    }
}
/**
 * Async, chunked-yield content-hash map for a set of files. Used at full build
 * to record per-file content hashes so a later run can tell a *content* change
 * apart from pure mtime/size drift (formatter no-op, git checkout) — see
 * {@link confirmContentChanged}. Reads file bytes, so it runs only on the
 * (rare) full-build path, not the per-edit signature loop.
 */
async function sourceHashMapAsync(files) {
    const hashes = new Map();
    let sinceYield = 0;
    for (const file of files) {
        hashes.set(file, contentHashEntry(file));
        if (++sinceYield >= STAT_YIELD_EVERY) {
            sinceYield = 0;
            await yieldToLoop();
        }
    }
    return hashes;
}
/**
 * #202: confirm which mtime/size-changed candidates actually changed CONTENT. A
 * candidate whose content hash matches the prior hash is pure mtime drift —
 * reusing its already-parsed graph nodes is safe. Returns the truly
 * content-changed subset plus the merged hash map (prior hashes + freshly
 * computed candidate hashes) for persisting. When prior hashes are absent (a
 * pre-#202 cache), every candidate reports as changed, so behavior degrades
 * exactly to the old mtime-only logic — never a false reuse.
 */
async function confirmContentChanged(candidates, previousHashes) {
    const prior = previousHashes ?? new Map();
    const hashes = new Map(prior);
    const trulyChanged = [];
    let sinceYield = 0;
    for (const file of candidates) {
        const hash = contentHashEntry(file);
        hashes.set(file, hash);
        if (prior.get(file) !== hash)
            trulyChanged.push(file);
        if (++sinceYield >= STAT_YIELD_EVERY) {
            sinceYield = 0;
            await yieldToLoop();
        }
    }
    return { trulyChanged, hashes };
}
/**
 * #202: structural delta between two source-signature maps. The predecessor
 * (changedSignatureFiles) returned undefined on ANY count change, so a single
 * newly-created file forced a full whole-repo rebuild — the dominant cause of
 * the multi-second graph_build spikes during a burst of new files (pi-lens has
 * no fs-watcher, so it learns of N new sibling files all at once on the next
 * edit). Reporting added / removed / changed explicitly lets an add-only or
 * change-only delta be applied incrementally — see {@link tryIncrementalFromCache}.
 */
function diffSignatureMaps(previous, next) {
    const added = [];
    const changed = [];
    for (const [file, signature] of next) {
        const oldSignature = previous.get(file);
        if (oldSignature === undefined)
            added.push(file);
        else if (oldSignature !== signature)
            changed.push(file);
    }
    const removed = [];
    for (const file of previous.keys()) {
        if (!next.has(file))
            removed.push(file);
    }
    return { added, removed, changed };
}
function getReviewGraphMaxFiles() {
    const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILES ?? "", 10);
    return Number.isFinite(override) && override > 0
        ? override
        : RUNTIME_CONFIG.reviewGraph.maxFiles;
}
function getReviewGraphMaxFileBytes() {
    const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILE_BYTES ?? "", 10);
    return Number.isFinite(override) && override > 0
        ? override
        : RUNTIME_CONFIG.reviewGraph.maxFileBytes;
}
function isWithinReviewGraphSizeLimit(file) {
    try {
        return fs.statSync(file).size <= getReviewGraphMaxFileBytes();
    }
    catch {
        return false;
    }
}
async function getGraphSourceFiles(cwd) {
    // Async, chunked-yield walk (identical output to the sync collector) so the
    // per-edit cascade graph rebuild doesn't block the event loop on a large repo.
    //
    // Cap the walk at maxGraphFiles+1: an over-limit repo (or a root that climbed
    // to $HOME) short-circuits collection instead of enumerating the entire tree
    // and paying a statSync per file before the caller bails on count (#250). When
    // the cap is hit the caller skips the build on count alone, so the unfiltered
    // over-limit list is all it needs — see _doBuildGraph's too_many_files branch.
    const maxGraphFiles = getReviewGraphMaxFiles();
    const collected = await collectProjectSourceFilesAsync(cwd, {
        // Only walk graph-relevant extensions so the cap counts what the graph
        // keeps (post-filter), not JSON/YAML/MD noise it would discard anyway.
        extensions: MAIN_KIND_EXTENSIONS,
        maxFiles: maxGraphFiles + 1,
    });
    if (collected.length > maxGraphFiles) {
        // Contents are unused by the too_many_files branch; return the capped list
        // so the caller's `length > maxGraphFiles` check still trips.
        return collected;
    }
    const result = [];
    let sinceYield = 0;
    for (const raw of collected) {
        const file = normalizeMapKey(raw);
        const kind = detectFileKind(file);
        // isWithinReviewGraphSizeLimit does a statSync per file — yield periodically
        // so the size-limit filter (one stat each) can't hold the loop in one burst.
        // #260: test files are NOT graph-relevant (a heavily-tested repo was ~56%
        // tests, bloating the graph + every build/clone/serialize). The role check
        // is pure string work, so it also short-circuits the per-file statSync.
        if (!!kind &&
            MAIN_KINDS.has(kind) &&
            detectFileRole(file) !== "test" &&
            isWithinReviewGraphSizeLimit(file)) {
            result.push(file);
        }
        if (++sinceYield >= STAT_YIELD_EVERY) {
            sinceYield = 0;
            await yieldToLoop();
        }
    }
    return result;
}
function addNode(graph, node) {
    graph.nodes.set(node.id, node);
    if (node.kind === "file" && node.filePath) {
        graph.fileNodes.set(node.filePath, node.id);
    }
}
function addEdge(graph, edge) {
    graph.edges.push(edge);
    const from = graph.edgesByFrom.get(edge.from) ?? [];
    from.push(edge);
    graph.edgesByFrom.set(edge.from, from);
    const to = graph.edgesByTo.get(edge.to) ?? [];
    to.push(edge);
    graph.edgesByTo.set(edge.to, to);
}
function rebuildIndexes(graph) {
    graph.edgesByFrom = new Map();
    graph.edgesByTo = new Map();
    graph.fileNodes = new Map();
    graph.symbolNodesByFile = new Map();
    for (const node of graph.nodes.values()) {
        if (node.kind === "file" && node.filePath) {
            graph.fileNodes.set(node.filePath, node.id);
        }
        if (node.kind === "symbol" && node.filePath) {
            const ids = graph.symbolNodesByFile.get(node.filePath) ?? [];
            ids.push(node.id);
            graph.symbolNodesByFile.set(node.filePath, ids);
        }
    }
    for (const edge of graph.edges) {
        const from = graph.edgesByFrom.get(edge.from) ?? [];
        from.push(edge);
        graph.edgesByFrom.set(edge.from, from);
        const to = graph.edgesByTo.get(edge.to) ?? [];
        to.push(edge);
        graph.edgesByTo.set(edge.to, to);
    }
}
const GRAPH_CACHE_FILENAME = "review-graph.json";
function loadPersistedGraph(cwd, opts) {
    const cachePath = path.join(getProjectDataDir(cwd), "cache", GRAPH_CACHE_FILENAME);
    try {
        const raw = fs.readFileSync(cachePath, "utf-8");
        const data = JSON.parse(raw);
        if (data.version !== REVIEW_GRAPH_VERSION)
            return null;
        if (opts?.verifyGitStamp && data.gitStamp) {
            // #300: a stamped snapshot must match the CURRENT repo identity. This
            // closes the "worktree removed + re-added at the same path for a
            // different branch" edge — the data-dir slug is reused, but the stamp
            // mismatch forces a cold rebuild instead of serving the old branch's
            // graph. Opt-in per call site: only the BLIND read path
            // (getCachedReviewGraph) verifies — the build path's tier-2 load is
            // already content-verified downstream (signature + #202 hash confirm),
            // and dropping there on every HEAD move would nuke the cold cache
            // after each commit. Any resolution failure (non-git, unreadable HEAD)
            // yields undefined from resolveGitIdentity — treated as "can't
            // verify," not a mismatch, so it does NOT drop the snapshot.
            const current = resolveGitIdentity(cwd);
            if (current &&
                (current.headCommit !== data.gitStamp.headCommit ||
                    current.worktreeRoot !== data.gitStamp.worktreeRoot)) {
                return null;
            }
        }
        const graph = {
            version: data.version,
            builtAt: data.builtAt,
            nodes: new Map(data.nodes),
            edges: data.edges,
            edgesByFrom: new Map(),
            edgesByTo: new Map(),
            fileNodes: new Map(),
            symbolNodesByFile: new Map(),
            changedSymbolsByFile: new Map(),
        };
        rebuildIndexes(graph);
        return {
            signature: data.signature,
            fileSignatures: new Map(data.fileSignatures ?? []),
            fileHashes: new Map(data.fileHashes ?? []),
            graph,
        };
    }
    catch {
        return null;
    }
}
/**
 * The version string of the persisted graph, read cheaply from the HEAD of the
 * cache file (the `version` key is serialized first) — never parses the multi-MB
 * body. Returns null when no graph is persisted.
 */
function getPersistedReviewGraphVersion(cwd) {
    const cachePath = path.join(getProjectDataDir(cwd), "cache", GRAPH_CACHE_FILENAME);
    let fd;
    try {
        fd = fs.openSync(cachePath, "r");
        const buf = Buffer.alloc(200);
        const n = fs.readSync(fd, buf, 0, 200, 0);
        const match = buf.toString("utf-8", 0, n).match(/"version"\s*:\s*"([^"]+)"/);
        return match ? match[1] : null;
    }
    catch {
        return null;
    }
    finally {
        if (fd !== undefined) {
            try {
                fs.closeSync(fd);
            }
            catch {
                /* ignore */
            }
        }
    }
}
/**
 * True when a persisted graph exists but was written under an OLDER
 * REVIEW_GRAPH_VERSION — a schema/scope change (#260: test exclusion) means it
 * must be rebuilt. The session bootstrap consults this to proactively rebuild
 * once after an upgrade, so reads aren't stranded cold until the next edit.
 * Returns false when nothing is persisted (a normal cold start builds on demand).
 */
export function isReviewGraphMigrationNeeded(cwd) {
    const version = getPersistedReviewGraphVersion(cwd);
    return version !== null && version !== REVIEW_GRAPH_VERSION;
}
// --- Throttled, size-guarded graph persistence (circuit-breaker, #260) ---
// The whole graph is serialized as one blob. Doing that synchronously on every
// edit turn — `JSON.stringify` of a multi-MB graph plus number formatting for
// every line/complexity/fanout — spiked the host into a `Fatal ... Zone` OOM,
// especially when it overlapped the next build or the host's tsc. Two guards:
//   1. Coalesce: a burst of edits schedules ONE write after a quiet window,
//      instead of one full serialize per turn (the spike multiplier).
//   2. Ceiling: refuse to serialize a graph above an element cap (fail-safe —
//      log + skip rather than OOM the host; same fail-closed spirit as the
//      read-guard).
const GRAPH_PERSIST_DEBOUNCE_MS_DEFAULT = 1500;
const GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT = 200_000;
function graphPersistDebounceMs() {
    const raw = Number(process.env.PI_LENS_GRAPH_PERSIST_DEBOUNCE_MS);
    return Number.isFinite(raw) && raw >= 0
        ? raw
        : GRAPH_PERSIST_DEBOUNCE_MS_DEFAULT;
}
function graphPersistMaxElements() {
    const raw = Number(process.env.PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS);
    return Number.isFinite(raw) && raw > 0
        ? raw
        : GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT;
}
const _pendingPersist = new Map();
const _persistTimers = new Map();
function writePending(key) {
    const pending = _pendingPersist.get(key);
    if (!pending)
        return;
    _pendingPersist.delete(key);
    const timer = _persistTimers.get(key);
    if (timer) {
        clearTimeout(timer);
        _persistTimers.delete(key);
    }
    const startedAt = Date.now();
    let json;
    try {
        json = JSON.stringify(pending.data);
    }
    catch (err) {
        console.error("[review-graph] cache serialize failed:", err.message);
        return;
    }
    logLatency({
        type: "phase",
        phase: "review_graph_persist",
        filePath: pending.cachePath,
        durationMs: Date.now() - startedAt,
        metadata: { elements: pending.elementCount, bytes: json.length },
    });
    fs.mkdir(pending.cacheDir, { recursive: true }, (mkdirErr) => {
        if (mkdirErr) {
            console.error("[review-graph] cache dir creation failed:", mkdirErr.message);
            return;
        }
        // Write-to-temp + rename so the snapshot lands atomically: a reader
        // (another process's blind load, or the tier-2 disk load in tests) must
        // never see a created-but-partially-written file — that parses as
        // corrupt and silently forces a full rebuild. rename() replaces the
        // destination atomically on both POSIX and Windows (libuv uses
        // MOVEFILE_REPLACE_EXISTING).
        const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
        fs.writeFile(tmpPath, json, "utf-8", (writeErr) => {
            if (writeErr) {
                console.error("[review-graph] cache write failed:", writeErr.message);
                return;
            }
            fs.rename(tmpPath, pending.cachePath, (renameErr) => {
                if (renameErr) {
                    console.error("[review-graph] cache rename failed:", renameErr.message);
                    fs.rm(tmpPath, { force: true }, () => { });
                }
            });
        });
    });
}
// Flush any pending writes synchronously at process teardown so a debounced
// snapshot isn't lost. Sync writes only (no child spawn — see the teardown
// libuv hazard); best-effort.
let _persistExitHookInstalled = false;
function ensurePersistExitHook() {
    if (_persistExitHookInstalled)
        return;
    _persistExitHookInstalled = true;
    process.once("exit", () => {
        for (const [, pending] of _pendingPersist) {
            try {
                fs.mkdirSync(pending.cacheDir, { recursive: true });
                // Same atomic tmp+rename as writePending: even at teardown a crash
                // mid-write must not leave a truncated snapshot for the next start.
                const tmpPath = `${pending.cachePath}.tmp-${process.pid}`;
                fs.writeFileSync(tmpPath, JSON.stringify(pending.data), "utf-8");
                fs.renameSync(tmpPath, pending.cachePath);
            }
            catch {
                // Teardown is best-effort; a missed persist just re-confirms next start.
            }
        }
        _pendingPersist.clear();
    });
}
function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
    const elementCount = graph.nodes.size + graph.edges.length;
    const cap = graphPersistMaxElements();
    if (elementCount > cap) {
        // Fail-safe: a runaway graph would OOM the host on serialize. Skip + log.
        logLatency({
            type: "phase",
            phase: "review_graph_persist",
            filePath: cwd,
            durationMs: 0,
            metadata: { skipped: "size_cap", elements: elementCount, cap },
        });
        return;
    }
    const cacheDir = path.join(getProjectDataDir(cwd), "cache");
    const cachePath = path.join(cacheDir, GRAPH_CACHE_FILENAME);
    // #300: resolve the git stamp fresh at persist time (HEAD changes on
    // commit/checkout, so it isn't cached like the gitdir location — but these
    // are plain fs reads, cheap even called per-persist). undefined for
    // non-git cwds, which serializes as `gitStamp: undefined` → omitted key.
    const gitStamp = resolveGitIdentity(cwd);
    // Build the serializable shape now (cheap array views over the snapshot the
    // caller already cloned), but defer the expensive stringify+write to the
    // debounced flush so a burst of edits collapses to a single write.
    const data = {
        version: graph.version,
        builtAt: graph.builtAt,
        signature,
        fileSignatures: Array.from(fileSignatures.entries()),
        fileHashes: fileHashes ? Array.from(fileHashes.entries()) : undefined,
        nodes: Array.from(graph.nodes.entries()),
        edges: graph.edges,
        gitStamp,
    };
    const key = normalizeMapKey(cwd);
    _pendingPersist.set(key, { cacheDir, cachePath, data, elementCount });
    ensurePersistExitHook();
    const debounce = graphPersistDebounceMs();
    const existing = _persistTimers.get(key);
    if (existing)
        clearTimeout(existing);
    if (debounce === 0) {
        writePending(key);
        return;
    }
    const timer = setTimeout(() => writePending(key), debounce);
    // Don't keep the event loop alive solely for a cache write.
    if (typeof timer.unref === "function")
        timer.unref();
    _persistTimers.set(key, timer);
}
/** Test hook: force any pending debounced persist to write immediately. */
export function flushReviewGraphPersistsForTests() {
    for (const key of [..._pendingPersist.keys()])
        writePending(key);
}
// TS-as-ESM sources commonly write `import { x } from "./service.js"` while
// the real file on disk is `service.ts` (Node's ESM resolver requires the
// RUNTIME extension in the specifier, which is `.js` even for a `.ts` source —
// this repo's own `clients/**/*.ts` does this throughout). Stripping a known
// JS/TS extension from the specifier before re-appending candidate extensions
// below lets that universal pattern resolve to the real `.ts` file; refs #655
// phase 2, where this directly gates the new "import" resolution tier, but it
// was already a latent gap for the plain file->file `imports` edge too.
const JS_TS_EXT_RE = /\.(mjs|cjs|jsx?|tsx?)$/i;
function localImportToFile(cwd, filePath, source) {
    if (!source.startsWith("."))
        return undefined;
    const base = path.resolve(path.dirname(filePath), source);
    const strippedSource = source.replace(JS_TS_EXT_RE, "");
    const strippedBase = strippedSource === source
        ? base
        : path.resolve(path.dirname(filePath), strippedSource);
    const candidates = [
        base,
        strippedBase,
        `${strippedBase}.ts`,
        `${strippedBase}.tsx`,
        `${strippedBase}.js`,
        `${strippedBase}.jsx`,
        path.join(base, "index.ts"),
        path.join(base, "index.tsx"),
        path.join(base, "index.js"),
        path.join(base, "index.jsx"),
    ];
    for (const candidate of candidates) {
        if (candidate.startsWith(path.resolve(cwd)) && fs.existsSync(candidate)) {
            return normalizeMapKey(candidate);
        }
    }
    return undefined;
}
function upsertChangedSymbols(graph, facts, filePath) {
    // #260: tests aren't in the graph, so don't track their changed symbols.
    if (detectFileRole(filePath) === "test")
        return;
    const normalized = normalizeMapKey(filePath);
    const changed = facts.getSessionFact(`${CHANGED_SYMBOLS_PREFIX}${normalized}`);
    if (changed && changed.length > 0) {
        graph.changedSymbolsByFile.set(normalized, [...changed]);
    }
    else {
        graph.changedSymbolsByFile.delete(normalized);
    }
}
async function ensureReviewGraphFacts(filePath, cwd, facts) {
    const ctx = makeCtx(filePath, cwd, facts);
    await fileContentProvider.run(ctx, facts);
    // The import/function fact providers parse via the shared tree-sitter client
    // (#419/#402 — no `typescript` compiler). Loaded on demand + run here so
    // file.imports / file.reexports / file.functionSummaries are populated before
    // the graph reads them; if the parse stack is unavailable the graph builds
    // without structural facts rather than failing (the shared client loads
    // web-tree-sitter lazily, so that degrade otherwise lives at client.init()).
    try {
        const [{ importFactProvider }, { functionFactProvider }] = await Promise.all([
            import("../dispatch/facts/import-facts.js"),
            import("../dispatch/facts/function-facts.js"),
        ]);
        // Both providers are async (tree-sitter parse) — await so the facts are
        // populated before the graph reads them.
        await importFactProvider.run(ctx, facts);
        await functionFactProvider.run(ctx, facts);
    }
    catch (err) {
        console.error(`[pi-lens] review-graph structural facts disabled (degraded mode): ${err?.message ?? String(err)}`);
    }
}
function addJsTsFile(graph, cwd, filePath, facts) {
    const normalized = normalizeMapKey(filePath);
    const content = facts.getFileFact(normalized, "file.content") ?? "";
    const fileNodeId = `file:${normalized}`;
    addNode(graph, {
        id: fileNodeId,
        kind: "file",
        language: "jsts",
        filePath: normalized,
        metadata: {
            lineCount: content.split("\n").length,
            ...featureHintMetadata(normalized),
        },
    });
    const imports = facts.getFileFact(normalized, "file.imports") ?? [];
    const functions = facts.getFileFact(normalized, "file.functionSummaries") ?? [];
    for (const entry of imports) {
        const localFile = localImportToFile(cwd, normalized, entry.source);
        if (localFile) {
            const targetId = `file:${localFile}`;
            if (!graph.nodes.has(targetId)) {
                addNode(graph, {
                    id: targetId,
                    kind: "file",
                    language: detectFileKind(localFile) ?? "jsts",
                    filePath: localFile,
                });
            }
            addEdge(graph, { from: fileNodeId, to: targetId, kind: "imports" });
        }
        else {
            const targetId = `${entry.source.startsWith(".") ? "module" : "external"}:${entry.source}`;
            if (!graph.nodes.has(targetId)) {
                addNode(graph, {
                    id: targetId,
                    kind: entry.source.startsWith(".") ? "module" : "external",
                    language: "jsts",
                    metadata: { source: entry.source },
                });
            }
            addEdge(graph, { from: fileNodeId, to: targetId, kind: "imports" });
        }
    }
    // refs #655 phase 2 ("import" resolution tier): a bare-name callee that
    // matches a named/default import specifier hints exactly which in-project
    // FILE it should resolve against, narrowing resolveDeferredSymbolEdges'
    // candidate search below (before it falls back to the graph-wide
    // uniqueness check). Only local (in-project) import sources produce a
    // hint — third-party/stdlib imports have no graph file to narrow to.
    const importedNameToFile = new Map();
    for (const entry of imports) {
        const localFile = localImportToFile(cwd, normalized, entry.source);
        if (!localFile)
            continue;
        for (const name of entry.names)
            importedNameToFile.set(name, localFile);
        if (entry.defaultName)
            importedNameToFile.set(entry.defaultName, localFile);
    }
    // refs #655 phase 2 (qualified names + "receiver-type" resolution): build
    // the per-file `Owner.method -> symbolId[]` map alongside each node so the
    // second pass below (call-site resolution) can look up SAME-FILE receiver
    // types without a second traversal. Collecting ALL matches (not just the
    // last-written one) lets the resolver below tell "exactly one real target"
    // apart from "this owner+name pair is itself ambiguous" (duplicate/overload
    // declarations sharing one qualified name) — the latter must stay
    // "name-only", never guess one of the 2+ candidates.
    const methodsByQualifiedName = new Map();
    for (const fn of functions) {
        const symbolId = buildSymbolId(normalized, fn.name, "function", fn.line);
        const qualifiedName = buildQualifiedName(fn.owner, fn.name);
        if (qualifiedName) {
            const existing = methodsByQualifiedName.get(qualifiedName) ?? [];
            existing.push(symbolId);
            methodsByQualifiedName.set(qualifiedName, existing);
        }
        addNode(graph, {
            id: symbolId,
            kind: "symbol",
            language: "jsts",
            filePath: normalized,
            symbolName: fn.name,
            symbolKind: "function",
            ...(qualifiedName ? { qualifiedName } : {}),
            exported: new RegExp(String.raw `export\s+(?:async\s+)?(?:function|const|let|var)\s+${escapeRegExp(fn.name)}\b`).test(content),
            metadata: {
                line: fn.line,
                column: fn.column,
                cyclomaticComplexity: fn.cyclomaticComplexity,
                maxNestingDepth: fn.maxNestingDepth,
                isBoundaryWrapper: fn.isBoundaryWrapper,
                isPassThroughWrapper: fn.isPassThroughWrapper,
                ...featureHintMetadata(`${fn.name} ${normalized}`),
            },
        });
        addEdge(graph, { from: fileNodeId, to: symbolId, kind: "contains" });
        addEdge(graph, { from: fileNodeId, to: symbolId, kind: "defines" });
    }
    for (const fn of functions) {
        const symbolId = buildSymbolId(normalized, fn.name, "function", fn.line);
        // Member call sites (`obj.method()`) with a same-file, structurally
        // determinable receiver type resolve directly here — refs #655 phase 2
        // "receiver-type" tier. Skip their text form in the outgoingCalls loop
        // below (memberCallText) so the same call site doesn't double-edge.
        const memberCallTexts = new Set();
        for (const site of fn.memberCallSites ?? []) {
            const callText = `${site.receiver}.${site.method}`;
            memberCallTexts.add(callText);
            const receiverClass = fn.receiverTypes?.[site.receiver];
            const candidates = receiverClass
                ? (methodsByQualifiedName.get(`${receiverClass}.${site.method}`) ?? [])
                : [];
            if (candidates.length === 1) {
                addEdge(graph, {
                    from: symbolId,
                    to: candidates[0],
                    kind: "calls",
                    metadata: {
                        unresolvedName: callText,
                        receiver: site.receiver,
                        receiverType: receiverClass,
                    },
                    resolution: "receiver-type",
                });
                continue;
            }
            if (candidates.length > 1) {
                // The receiver's class is known, but that class has 2+ same-named
                // methods (duplicate/overload declarations) — the owner+name pair
                // itself is ambiguous. Point at a qualified-name placeholder (not the
                // bare-name one, which would incorrectly conflate this with unrelated
                // same-named methods elsewhere) and stay "name-only": never guess
                // which of the 2+ candidates this call reaches.
                const qualifiedPlaceholderId = `symbol-qualified-name:${receiverClass}.${site.method}`;
                if (!graph.nodes.has(qualifiedPlaceholderId)) {
                    addNode(graph, {
                        id: qualifiedPlaceholderId,
                        kind: "symbol",
                        language: "jsts",
                        symbolName: site.method,
                        qualifiedName: `${receiverClass}.${site.method}`,
                        metadata: { unresolvedName: callText, ambiguousCandidates: candidates.length },
                    });
                }
                addEdge(graph, {
                    from: symbolId,
                    to: qualifiedPlaceholderId,
                    kind: "calls",
                    metadata: {
                        unresolvedName: callText,
                        receiver: site.receiver,
                        receiverType: receiverClass,
                    },
                    resolution: "name-only",
                });
                continue;
            }
            // Receiver type unknown — falls back to the same "definite external"
            // placeholder the pre-#655 code used for every dotted call;
            // conservative (never claims a resolution tier it can't back up).
            const externalId = `external:${callText}`;
            if (!graph.nodes.has(externalId)) {
                addNode(graph, {
                    id: externalId,
                    kind: "external",
                    language: "jsts",
                    metadata: { unresolvedName: callText },
                });
            }
            addEdge(graph, {
                from: symbolId,
                to: externalId,
                kind: "calls",
                metadata: { unresolvedName: callText },
            });
        }
        for (const callee of fn.outgoingCalls) {
            if (memberCallTexts.has(callee))
                continue;
            const targetId = callee.includes(".")
                ? `external:${callee}`
                : `symbol-name:${callee}`;
            if (!graph.nodes.has(targetId)) {
                addNode(graph, {
                    id: targetId,
                    kind: callee.includes(".") ? "external" : "symbol",
                    language: "jsts",
                    symbolName: callee.includes(".") ? undefined : callee,
                    metadata: { unresolvedName: callee },
                });
            }
            const importHintFile = !callee.includes(".")
                ? importedNameToFile.get(callee)
                : undefined;
            addEdge(graph, {
                from: symbolId,
                to: targetId,
                kind: "calls",
                metadata: {
                    unresolvedName: callee,
                    ...(importHintFile ? { importHintFile } : {}),
                },
                // A definite external call (`callee.includes(".")`) is never
                // ambiguous — no in-project candidate to collide with, so no
                // resolution marker. An in-project bare-name callee starts
                // "name-only" and resolveDeferredSymbolEdges below may upgrade it
                // to "import" (when importHintFile narrows it) or "exact" once
                // every file has been added.
                ...(callee.includes(".") ? {} : { resolution: "name-only" }),
            });
        }
    }
}
function mapKindToTreeSitterLanguage(kind, filePath) {
    switch (kind) {
        case "python": return "python";
        case "go": return "go";
        case "rust": return "rust";
        case "ruby": return "ruby";
        case "cxx": {
            const ext = filePath ? path.extname(filePath).toLowerCase() : "";
            return ext === ".c" || ext === ".h" ? "c" : "cpp";
        }
        case "java": return "java";
        case "kotlin": return "kotlin";
        case "dart": return "dart";
        case "elixir": return "elixir";
        case "csharp": return "csharp";
        case "php": return "php";
        case "swift": return "swift";
        case "lua": return "lua";
        case "ocaml": return "ocaml";
        case "zig": return "zig";
        case "shell": return "bash";
        default: return undefined;
    }
}
async function getExtractor(languageId) {
    if (extractorCache.has(languageId))
        return extractorCache.get(languageId);
    const client = getSharedTreeSitterClient();
    if (!client)
        return null;
    const extractor = new TreeSitterSymbolExtractor(languageId, client);
    const ok = await extractor.init();
    if (!ok)
        return null;
    extractorCache.set(languageId, extractor);
    return extractor;
}
async function extractTreeSitterSymbols(filePath, languageId) {
    const empty = { symbols: [], refs: [], imports: [] };
    const treeSitterClient = getSharedTreeSitterClient();
    if (!treeSitterClient)
        return empty;
    const initialized = await treeSitterClient.init();
    if (!initialized)
        return empty;
    const tree = await treeSitterClient.parseFile(filePath, languageId);
    if (!tree)
        return empty;
    const extractor = await getExtractor(languageId);
    if (!extractor)
        return empty;
    const content = fs.readFileSync(filePath, "utf-8");
    return extractor.extract(tree, filePath, content);
}
// #655: some grammars' SYMBOL_QUERIES match the SAME declaration node under two
// patterns — e.g. python's generic `function_definition` rule also matches a
// method's `function_definition` nested inside a class body, in addition to
// the class-scoped "method" rule (tree-sitter-symbol-extractor.ts has no
// `#not-`-style scope predicate to exclude it). `extract()` then yields TWO
// Symbol records for one real declaration: identical name/line/column,
// differing only in `kind` ("function" vs "method"). The pre-#655
// `${file}:${name}` ID silently collapsed these onto one node (`Map.set`
// overwrote by name, last-extracted kind winning in whatever order
// `Query.matches` returned). The new kind-qualified ID would otherwise turn
// that pre-existing extractor quirk into two REAL, persisted duplicate nodes
// for one symbol — so dedupe by (name, line, column) here, preferring the
// more specific kind, keeping exactly one node per real declaration regardless
// of how many query patterns matched it.
const SYMBOL_KIND_SPECIFICITY = {
    method: 2,
    property: 2,
};
function dedupeSamePositionSymbols(symbols) {
    const bestByKey = new Map();
    for (const symbol of symbols) {
        const key = `${symbol.name}${symbol.line}${symbol.column}`;
        const existing = bestByKey.get(key);
        if (!existing) {
            bestByKey.set(key, symbol);
            continue;
        }
        const existingScore = SYMBOL_KIND_SPECIFICITY[existing.kind] ?? 0;
        const candidateScore = SYMBOL_KIND_SPECIFICITY[symbol.kind] ?? 0;
        if (candidateScore > existingScore)
            bestByKey.set(key, symbol);
    }
    return [...bestByKey.values()];
}
function addTreeSitterFile(graph, cwd, filePath, languageId, extracted) {
    const normalized = normalizeMapKey(filePath);
    const fileNodeId = `file:${normalized}`;
    addNode(graph, {
        id: fileNodeId,
        kind: "file",
        language: languageId,
        filePath: normalized,
        metadata: featureHintMetadata(normalized),
    });
    const dedupedSymbols = dedupeSamePositionSymbols(extracted.symbols);
    // refs #655 phase 2: qualified (owner-chain) display name, computed via the
    // SAME strict-containment/smallest-span algorithm module-report.ts's outline
    // nesting (`nestEntries`, #301) uses over its own tree-sitter-symbol-extractor
    // output — see symbol-containment.ts. Candidates are the file's OWN deduped
    // symbol list; a symbol with no strictly-containing entry (top-level) gets
    // no qualifiedName.
    const containers = dedupedSymbols.map((s) => ({
        name: s.name,
        startLine: s.line,
        endLine: s.endLine ?? s.line,
    }));
    for (const symbol of dedupedSymbols) {
        const symbolId = buildSymbolId(normalized, symbol.name, symbol.kind, symbol.line);
        const owner = findOwnerName(containers, symbol.line, symbol.endLine ?? symbol.line);
        const qualifiedName = buildQualifiedName(owner, symbol.name);
        addNode(graph, {
            id: symbolId,
            kind: "symbol",
            language: languageId,
            filePath: normalized,
            symbolName: symbol.name,
            symbolKind: symbol.kind,
            ...(qualifiedName ? { qualifiedName } : {}),
            exported: symbol.isExported,
            metadata: {
                line: symbol.line,
                column: symbol.column,
                signature: symbol.signature,
                ...featureHintMetadata(`${symbol.name} ${normalized}`),
            },
        });
        addEdge(graph, { from: fileNodeId, to: symbolId, kind: "contains" });
        addEdge(graph, { from: fileNodeId, to: symbolId, kind: "defines" });
    }
    for (const ref of extracted.refs) {
        const targetId = `symbol-name:${ref.symbolId.split(":").pop() ?? ref.symbolId}`;
        if (!graph.nodes.has(targetId)) {
            addNode(graph, {
                id: targetId,
                kind: "symbol",
                language: languageId,
                symbolName: ref.symbolId.split(":").pop() ?? ref.symbolId,
                metadata: { unresolvedName: ref.symbolId },
            });
        }
        addEdge(graph, {
            from: fileNodeId,
            to: targetId,
            kind: "references",
            metadata: { line: ref.line, column: ref.column },
            // Always starts bare-name-only (the extractor has no scope/type info);
            // resolveDeferredSymbolEdges may upgrade this to "exact" below.
            resolution: "name-only",
        });
    }
    // #249: import edges for tree-sitter languages. First try to resolve the
    // source to in-project FILE(s) (ruby/zig/bash/dart relative paths, python
    // dotted modules, go package dirs, java source-root files — see
    // import-resolvers.ts); on success emit real file→file edges like jsts/cxx.
    // An unresolvable source (stdlib, third-party, namespace-only langs) falls
    // back to an UNRESOLVED external/module node — never a fabricated file edge.
    for (const imp of extracted.imports) {
        const resolved = resolveImportToFiles(cwd, filePath, languageId, imp.source);
        if (resolved.length > 0) {
            for (const target of resolved) {
                const toNode = ensureFileNode(graph, target, mapKindToTreeSitterLanguage(detectFileKind(target), target) ??
                    languageId);
                addEdge(graph, {
                    from: fileNodeId,
                    to: toNode,
                    kind: "imports",
                    metadata: { line: imp.line, source: imp.source },
                });
            }
            continue;
        }
        const isRelative = imp.source.startsWith(".");
        const targetId = `${isRelative ? "module" : "external"}:${imp.source}`;
        if (!graph.nodes.has(targetId)) {
            addNode(graph, {
                id: targetId,
                kind: isRelative ? "module" : "external",
                language: languageId,
                metadata: { source: imp.source },
            });
        }
        addEdge(graph, {
            from: fileNodeId,
            to: targetId,
            kind: "imports",
            metadata: { line: imp.line },
        });
    }
}
function ensureFileNode(graph, filePath, languageId) {
    const normalized = normalizeMapKey(filePath);
    const existing = graph.fileNodes.get(normalized);
    if (existing)
        return existing;
    const fileNodeId = `file:${normalized}`;
    addNode(graph, {
        id: fileNodeId,
        kind: "file",
        language: languageId,
        filePath: normalized,
        metadata: featureHintMetadata(normalized),
    });
    return fileNodeId;
}
function resolveCxxInclude(cwd, filePath, source) {
    const candidates = [
        path.resolve(path.dirname(filePath), source),
        path.resolve(cwd, source),
        path.resolve(cwd, "include", source),
        path.resolve(cwd, "src", source),
    ];
    const root = path.resolve(cwd);
    for (const candidate of candidates) {
        if (!candidate.startsWith(root + path.sep) && candidate !== root)
            continue;
        if (fs.existsSync(candidate) && detectFileKind(candidate) === "cxx") {
            return normalizeMapKey(candidate);
        }
    }
    return undefined;
}
function parseLocalCxxInclude(line) {
    let i = 0;
    while (i < line.length && (line[i] === " " || line[i] === "\t"))
        i += 1;
    if (line[i] !== "#")
        return undefined;
    i += 1;
    while (i < line.length && (line[i] === " " || line[i] === "\t"))
        i += 1;
    if (!line.startsWith("include", i))
        return undefined;
    i += "include".length;
    if (i >= line.length || (line[i] !== " " && line[i] !== "\t")) {
        return undefined;
    }
    while (i < line.length && (line[i] === " " || line[i] === "\t"))
        i += 1;
    if (line[i] !== '"')
        return undefined;
    i += 1;
    const start = i;
    while (i < line.length && line[i] !== '"')
        i += 1;
    if (i >= line.length || i === start)
        return undefined;
    return line.slice(start, i);
}
function addCxxIncludeEdges(graph, cwd, filePath) {
    let content = "";
    try {
        content = fs.readFileSync(filePath, "utf-8");
    }
    catch {
        return;
    }
    const fromNode = ensureFileNode(graph, filePath, "cpp");
    for (const line of content.split(/\r?\n/)) {
        const source = parseLocalCxxInclude(line);
        if (!source)
            continue;
        const target = resolveCxxInclude(cwd, filePath, source);
        if (!target)
            continue;
        const languageId = mapKindToTreeSitterLanguage("cxx", target) ?? "cpp";
        const toNode = ensureFileNode(graph, target, languageId);
        addEdge(graph, {
            from: fromNode,
            to: toNode,
            kind: "imports",
            metadata: { source },
        });
    }
}
function removeFileOwnedGraphData(graph, filePath) {
    const normalized = normalizeMapKey(filePath);
    const fileNodeId = `file:${normalized}`;
    const removedIds = new Set();
    const removedSymbolIds = new Set();
    for (const [id, node] of graph.nodes) {
        if (node.filePath !== normalized)
            continue;
        removedIds.add(id);
        if (node.kind === "symbol")
            removedSymbolIds.add(id);
    }
    if (graph.nodes.has(fileNodeId))
        removedIds.add(fileNodeId);
    const preservedIncomingSymbolEdges = [];
    graph.edges = graph.edges.filter((edge) => {
        const fromRemoved = removedIds.has(edge.from);
        const toRemoved = removedIds.has(edge.to);
        if (fromRemoved)
            return false;
        if (removedSymbolIds.has(edge.to)) {
            preservedIncomingSymbolEdges.push({ ...edge });
            return false;
        }
        // Preserve importer edges to the stable file node id; the node is re-added below.
        if (toRemoved && edge.to === fileNodeId)
            return true;
        return !toRemoved;
    });
    for (const id of removedIds)
        graph.nodes.delete(id);
    rebuildIndexes(graph);
    return preservedIncomingSymbolEdges;
}
async function addFileToGraph(graph, cwd, file, facts) {
    const kind = detectFileKind(file);
    if (!kind || !MAIN_KINDS.has(kind))
        return;
    // #260: tests aren't graph-relevant — guard the per-file chokepoint too so
    // the incremental/cascade path (a changed *.test.ts) never adds them either.
    if (detectFileRole(file) === "test")
        return;
    if (kind === "jsts") {
        await ensureReviewGraphFacts(file, cwd, facts);
        addJsTsFile(graph, cwd, file, facts);
        return;
    }
    const languageId = mapKindToTreeSitterLanguage(kind, file);
    if (!languageId)
        return;
    const extracted = await extractTreeSitterSymbols(file, languageId);
    addTreeSitterFile(graph, cwd, file, languageId, extracted);
    if (kind === "cxx")
        addCxxIncludeEdges(graph, cwd, file);
}
function restoreValidIncomingEdges(graph, edges) {
    const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`));
    for (const edge of edges) {
        if (!graph.nodes.has(edge.from) || !graph.nodes.has(edge.to))
            continue;
        const key = `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`;
        if (existing.has(key))
            continue;
        graph.edges.push(edge);
        existing.add(key);
    }
    rebuildIndexes(graph);
}
async function updateGraphFiles(graph, cwd, files, facts) {
    const preservedIncoming = [];
    for (const file of files) {
        preservedIncoming.push(...removeFileOwnedGraphData(graph, file));
        await addFileToGraph(graph, cwd, file, facts);
    }
    restoreValidIncomingEdges(graph, preservedIncoming);
    resolveDeferredSymbolEdges(graph);
    graph.changedSymbolsByFile.clear();
    for (const file of files) {
        upsertChangedSymbols(graph, facts, file);
    }
}
function resolveDeferredSymbolEdges(graph) {
    const symbolNameToIds = new Map();
    for (const node of graph.nodes.values()) {
        if (node.kind !== "symbol" || !node.symbolName)
            continue;
        if (node.metadata?.unresolvedName)
            continue;
        const ids = symbolNameToIds.get(node.symbolName) ?? [];
        ids.push(node.id);
        symbolNameToIds.set(node.symbolName, ids);
    }
    graph.edges = graph.edges.map((edge) => {
        const targetNode = graph.nodes.get(edge.to);
        if (!targetNode?.metadata?.unresolvedName)
            return edge;
        const candidates = symbolNameToIds.get(targetNode.symbolName ?? "") ?? [];
        // refs #655 phase 2 ("import" tier): the calling file's own imports named
        // exactly which in-project file this bare callee comes from (see
        // `addJsTsFile`'s `importHintFile`). Narrow to that file BEFORE the
        // graph-wide uniqueness check — a name that's ambiguous project-wide can
        // still be unambiguous once scoped to the one file it was imported from.
        const importHintFile = edge.metadata?.importHintFile;
        if (importHintFile) {
            const scoped = candidates.filter((id) => graph.nodes.get(id)?.filePath === importHintFile);
            if (scoped.length === 1) {
                return { ...edge, to: scoped[0], resolution: "import" };
            }
        }
        if (candidates.length === 1) {
            // Exactly one same-named real symbol exists graph-wide: the bare-name
            // match is provably unambiguous (refs #655 — resolution confidence).
            return { ...edge, to: candidates[0], resolution: "exact" };
        }
        // 0 or 2+ candidates (and no import hint narrowed it): stays on the
        // unresolved placeholder, resolution stays "name-only" (set at edge
        // creation) — a consumer must not treat this edge's target as a
        // confirmed graph node.
        return edge;
    });
    rebuildIndexes(graph);
}
/**
 * #451: the freshness-provenance fields written onto a workspace cache entry by
 * any path that has just done (or reused a still-valid result of) the full
 * walk+stat sweep. Records the projectSeq CAPTURED AT BUILD START — not read at
 * stamp time — so a bump that interleaves during this build's awaits has
 * seq > stamp and is re-ingested by the next diff (a miss would be a silently
 * stale graph; a redundant re-extract is harmless). Also resets the
 * periodic-reverify clock/counter — this build IS the verify.
 */
function verifiedCacheFields(seqAtBuildStart) {
    return {
        builtAtProjectSeq: seqAtBuildStart,
        lastFullVerifyMs: Date.now(),
        fastPathSinceVerify: 0,
    };
}
/**
 * #202: satisfy a build from a cached graph entry incrementally when the source
 * file set changed only by ADDITIONS and/or CONTENT changes (no removals).
 * Returns the query-ready graph, or undefined when an incremental update doesn't
 * apply (a file was removed, the cache has no signatures to diff, or nothing
 * actually changed) and the caller must fall through.
 *
 * This is the lever that keeps a burst of newly-created files off the
 * full-rebuild path. `updateGraphFiles` re-parses each target from disk and is a
 * remove-then-add that no-ops the remove for a not-yet-present file, so adding
 * the new files (plus any hash-confirmed content changes) incrementally is
 * correct regardless of whether the file was in this edit's changed set —
 * dropping the old `.every(in changedSet)` restriction that bailed to a full
 * rebuild for a sibling that changed on disk outside the current edit.
 */
async function tryIncrementalFromCache(cached, ctx) {
    if (cached.fileSignatures.size === 0)
        return undefined;
    const { added, removed, changed } = diffSignatureMaps(cached.fileSignatures, ctx.fileSignatures);
    // A removal must prune nodes/edges and can dangle incoming edges; that's rare
    // on an edit burst — fall through to a correct full rebuild.
    if (removed.length > 0)
        return undefined;
    if (added.length === 0 && changed.length === 0)
        return undefined;
    // Confirm size/mtime-changed EXISTING files by content hash so pure drift
    // (formatter no-op, git checkout, re-save) neither reparses nor forces a full
    // build. Added files are genuinely new — no prior hash to compare.
    const { trulyChanged, hashes } = await confirmContentChanged(changed, cached.fileHashes);
    const filesToUpdate = [...added, ...trulyChanged];
    if (filesToUpdate.length === 0) {
        // Pure drift on existing files only — reuse the cached graph as-is.
        // #459: content unchanged ⇒ carry the entry's generation forward (a legacy
        // or disk-hydrated entry without one gets a fresh stamp — conservative:
        // derived caches see it as new and rebuild once).
        const generation = cached.buildGeneration ?? ++_graphGenerationCounter;
        const graph = cloneGraph(cached.graph);
        rebuildIndexes(graph);
        graph.changedSymbolsByFile.clear();
        for (const file of ctx.normalizedChanged) {
            upsertChangedSymbols(graph, ctx.facts, file);
        }
        _workspaceGraphCache.set(ctx.normalizedCwd, {
            signature: ctx.signature,
            fileSignatures: new Map(ctx.fileSignatures),
            fileHashes: hashes,
            graph: cloneGraph(cached.graph),
            buildGeneration: generation,
            ...verifiedCacheFields(ctx.seqAtBuildStart),
        });
        // #260: pure drift leaves the graph unchanged — don't rewrite the disk blob.
        _lastGraphBuildInfo = { reused: true, mode: "cached", graphChanged: false };
        graph.buildGeneration = generation;
        ctx.facts.setSessionFact("session.reviewGraph", graph);
        return graph;
    }
    // Record content hashes for the newly-added files too, so the next run can
    // tell their future drift from a real change (otherwise they would re-confirm
    // as changed on every build until the next full rebuild).
    for (const file of added) {
        hashes.set(file, contentHashEntry(file));
    }
    const graph = cloneGraph(cached.graph);
    await updateGraphFiles(graph, ctx.cwd, filesToUpdate, ctx.facts);
    // #459: real re-extract ⇒ new generation.
    const generation = ++_graphGenerationCounter;
    const graphSnapshot = cloneGraph(graph);
    _workspaceGraphCache.set(ctx.normalizedCwd, {
        signature: ctx.signature,
        fileSignatures: new Map(ctx.fileSignatures),
        fileHashes: hashes,
        graph: graphSnapshot,
        buildGeneration: generation,
        ...verifiedCacheFields(ctx.seqAtBuildStart),
    });
    persistGraph(ctx.cwd, ctx.signature, ctx.fileSignatures, hashes, graphSnapshot);
    _lastGraphBuildInfo = { reused: true, mode: "incremental", graphChanged: true };
    graph.buildGeneration = generation;
    ctx.facts.setSessionFact("session.reviewGraph", graph);
    return graph;
}
function hasGraphKindExtension(file) {
    const kind = detectFileKind(file);
    return !!kind && MAIN_KINDS.has(kind) && detectFileRole(file) !== "test";
}
/**
 * #451: satisfy a build WITHOUT the O(project) walk+stat sweep, using the
 * RuntimeCoordinator's seq state to enumerate exactly which files changed since
 * this workspace graph was last built. On success sets `_lastGraphBuildInfo` and
 * returns `{ graph }`; on any doubt returns `{ fallback }` WITHOUT touching
 * `_lastGraphBuildInfo` (the caller's full sweep sets the mode and stamps the
 * fallback reason). Correctness bar is HIGH: any doubt ⇒ fall back.
 */
async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart) {
    const cached = _workspaceGraphCache.get(normalizedCwd);
    // Condition 2: need an in-process entry that recorded a build seq.
    if (!cached || cached.builtAtProjectSeq === undefined) {
        return { fallback: "no-seq" };
    }
    // Condition 5: periodic full re-verify safety valve (external edits — IDE, git
    // checkout — never bump projectSeq). Age OR count triggers a sweep.
    const now = Date.now();
    const ageMs = cached.lastFullVerifyMs === undefined
        ? Number.POSITIVE_INFINITY
        : now - cached.lastFullVerifyMs;
    const sinceVerify = cached.fastPathSinceVerify ?? 0;
    if (ageMs > SEQ_FASTPATH_REVERIFY_MS || sinceVerify >= SEQ_FASTPATH_REVERIFY_EVERY) {
        return { fallback: "verify-due" };
    }
    // Condition 3: bounded change set. changed ∪ changedFiles(param), normalized.
    const changedSet = new Set(seqHint.getFilesChangedSince(cached.builtAtProjectSeq));
    for (const file of normalizedChanged)
        changedSet.add(file);
    const changed = [...changedSet];
    if (changed.length > SEQ_FASTPATH_MAX_CHANGES) {
        return { fallback: "too-many-changes" };
    }
    // Condition 4 + removal check. A file already known to the graph is safe. A
    // file NOT in fileSignatures must exist on disk with a graph-kind extension
    // (a genuine new file — updateGraphFiles' remove-then-add handles the add). A
    // changed file that no longer exists on disk is a DELETION: incremental has no
    // node-removal here, so fall back to the sweep (simple + correct).
    const filesToUpdate = [];
    for (const file of changed) {
        const known = cached.fileSignatures.has(file);
        let existsOnDisk = false;
        try {
            existsOnDisk = fs.statSync(file).isFile();
        }
        catch {
            existsOnDisk = false;
        }
        if (!existsOnDisk) {
            // Known-but-now-missing = deletion; unknown-and-missing = irrelevant
            // (e.g. a non-source path). Either way, be safe: known deletions need the
            // sweep; unknown missing files we can just ignore.
            if (known) {
                return { fallback: "removed-file" };
            }
            continue;
        }
        if (!known) {
            // New file: only ingest if it's a graph-relevant kind; a changed
            // non-source sibling (config, doc) is simply not graph material.
            if (!hasGraphKindExtension(file))
                continue;
        }
        filesToUpdate.push(file);
    }
    if (filesToUpdate.length === 0) {
        // Nothing graph-relevant actually changed. Reuse the cached graph as-is,
        // refresh changed-symbol annotations, bump the fast-path counter.
        const graph = cloneGraph(cached.graph);
        rebuildIndexes(graph);
        graph.changedSymbolsByFile.clear();
        for (const file of normalizedChanged) {
            upsertChangedSymbols(graph, facts, file);
        }
        // Stamp the seq captured at BUILD START — a bump that raced in during this
        // build has seq > stamp and is re-diffed next build, never missed.
        cached.builtAtProjectSeq = seqAtBuildStart;
        cached.fastPathSinceVerify = sinceVerify + 1;
        // #459: nothing graph-relevant changed — this is a genuine no-op reuse.
        const generation = (cached.buildGeneration ??= ++_graphGenerationCounter);
        _lastGraphBuildInfo = { reused: true, mode: "seq-fastpath", graphChanged: false };
        graph.buildGeneration = generation;
        facts.setSessionFact("session.reviewGraph", graph);
        return { graph };
    }
    // Incremental re-extract over exactly the changed files. Reuses the SAME
    // machinery as the signature-diff incremental path (updateGraphFiles), so
    // there's no second incremental implementation.
    const graph = cloneGraph(cached.graph);
    try {
        await updateGraphFiles(graph, cwd, filesToUpdate, facts);
    }
    catch {
        return { fallback: "stat-error" };
    }
    // Update fileSignatures/fileHashes for ONLY the touched files (stat/hash just
    // those — the whole point of the fast path). Recompute the aggregate signature
    // the same way the incremental branch does (sourceSignatureFromMap).
    const nextSignatures = new Map(cached.fileSignatures);
    const nextHashes = new Map(cached.fileHashes ?? new Map());
    for (const file of filesToUpdate) {
        nextSignatures.set(file, sourceSignatureEntry(file));
        nextHashes.set(file, contentHashEntry(file));
    }
    const nextSignature = sourceSignatureFromMap(nextSignatures);
    // #459: real re-extract ⇒ new generation.
    const generation = ++_graphGenerationCounter;
    const graphSnapshot = cloneGraph(graph);
    _workspaceGraphCache.set(normalizedCwd, {
        signature: nextSignature,
        fileSignatures: nextSignatures,
        fileHashes: nextHashes,
        graph: graphSnapshot,
        buildGeneration: generation,
        // Build-start seq, not stamp-time: see verifiedCacheFields — a bump that
        // interleaved during updateGraphFiles' awaits must be re-diffed next build.
        builtAtProjectSeq: seqAtBuildStart,
        lastFullVerifyMs: cached.lastFullVerifyMs,
        fastPathSinceVerify: sinceVerify + 1,
    });
    persistGraph(cwd, nextSignature, nextSignatures, nextHashes, graphSnapshot);
    // #459: filesToUpdate was non-empty — this fastpath re-extracted real files,
    // so (unlike the no-op branch above) the graph object did change.
    _lastGraphBuildInfo = { reused: true, mode: "seq-fastpath", graphChanged: true };
    graph.buildGeneration = generation;
    facts.setSessionFact("session.reviewGraph", graph);
    return { graph };
}
async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
    const normalizedCwd = normalizeMapKey(cwd);
    const normalizedChanged = changedFiles.map((file) => normalizeMapKey(file));
    const normalizedChangedSet = new Set(normalizedChanged);
    logCwdWorktreeMismatchOnce(cwd);
    // #622: reject a cwd that IS (or is an ancestor of) $HOME before any walk is
    // attempted. The 3 real per-edit callers (dispatch/integration.ts's
    // computeCascadeForFile, mcp/analyze.ts, tree-sitter.ts's
    // runBlastRadiusInBackground) pass their session/pipeline cwd straight
    // through on the assumption it's already a real project root — true when Pi
    // is launched inside a repo, false when Pi is launched from $HOME itself and
    // then edits an absolute-path file in some other repo. In that case
    // getGraphSourceFiles's maxGraphFiles cap (#250) only trips AFTER a full
    // unfiltered $HOME walk (206k+ files, ~500s of blocked event loop — #622),
    // because the cap counts post-filter *kept* files, not directory entries
    // visited. Bail before the walk starts instead, mirroring the same
    // isAtOrAboveHomeDir ceiling already used by startup-scan.ts,
    // dead-code-client.ts, knip-client.ts, and runtime-session.ts's
    // resolveSnapshotRoot for the identical class of escape (#253/#250). Unlike
    // those (which resolve a root by walking UP from an arbitrary start dir),
    // this checks cwd directly: buildOrUpdateGraph's contract is that cwd
    // already IS the project root, so there is no safe substitute root to fall
    // back to here — skip graph construction entirely (matching #622's own
    // stated expected behavior) rather than walking a directory the caller never
    // asked for.
    if (isAtOrAboveHomeDir(path.resolve(cwd))) {
        const graph = createEmptyGraph();
        for (const file of normalizedChanged) {
            upsertChangedSymbols(graph, facts, file);
        }
        _lastGraphBuildInfo = {
            reused: false,
            mode: "skipped",
            skipReason: "unsafe_root",
            // #459: never persisted/reused, same as the too_many_files skip below —
            // treat as changed so dependents never trust stale derived state.
            graphChanged: true,
        };
        facts.setSessionFact("session.reviewGraph", graph);
        return graph;
    }
    // #451: capture the seq BEFORE any await — every builtAtProjectSeq stamp in
    // this build uses this value, so a bump that interleaves mid-build has
    // seq > stamp and is re-diffed next build (redundant re-extract, never a miss).
    const seqAtBuildStart = seqHint?.projectSeq();
    // #451: seq fast path — skip the O(project) walk+stat sweep when the
    // RuntimeCoordinator can tell us exactly which files changed. Any doubt inside
    // falls through to the full sweep below (which refreshes the verify clock). The
    // fallback reason is stamped onto whichever build-info the sweep records, so
    // cascade.log can watch the fast-path hit/miss rate.
    let seqFastpathFallback;
    if (seqHint && seqAtBuildStart !== undefined && seqFastpathEnabled()) {
        const fast = await trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart);
        if ("graph" in fast)
            return fast.graph;
        seqFastpathFallback = fast.fallback;
    }
    const filesToBuild = await getGraphSourceFiles(cwd);
    const maxGraphFiles = getReviewGraphMaxFiles();
    if (filesToBuild.length > maxGraphFiles) {
        const graph = createEmptyGraph();
        graph.version = REVIEW_GRAPH_VERSION;
        graph.builtAt = new Date().toISOString();
        for (const file of normalizedChanged) {
            upsertChangedSymbols(graph, facts, file);
        }
        _lastGraphBuildInfo = {
            reused: false,
            mode: "skipped",
            skipReason: "too_many_files",
            sourceFileCount: filesToBuild.length,
            maxFileCount: maxGraphFiles,
            seqFastpathFallback,
            // #459: a fresh empty graph is returned every call on this path (never
            // persisted/reused) — treat it as changed so dependents never trust stale
            // derived state across skip/unskip transitions. Deliberately NOT stamped
            // with a buildGeneration: absent ⇒ derived caches rebuild every time.
            graphChanged: true,
        };
        facts.setSessionFact("session.reviewGraph", graph);
        return graph;
    }
    const fileSignatures = await sourceSignatureMapAsync(filesToBuild);
    const signature = sourceSignatureFromMap(fileSignatures);
    // Tier 1: in-memory cache (hot path — same process, already built this session)
    const memCached = _workspaceGraphCache.get(normalizedCwd);
    if (memCached?.signature === signature) {
        const graph = cloneGraph(memCached.graph);
        rebuildIndexes(graph);
        graph.changedSymbolsByFile.clear();
        for (const file of normalizedChanged) {
            upsertChangedSymbols(graph, facts, file);
        }
        // #451: a signature-matching hit means the walk+stat just confirmed nothing
        // changed — a legitimate full verify. Refresh the clock/counter and seq so a
        // later fast path diffs from here and the periodic re-verify resets.
        Object.assign(memCached, verifiedCacheFields(seqAtBuildStart));
        // #459: content unchanged ⇒ carry the entry's generation forward.
        const generation = (memCached.buildGeneration ??= ++_graphGenerationCounter);
        _lastGraphBuildInfo = {
            reused: true,
            mode: "cached",
            seqFastpathFallback,
            graphChanged: false,
        };
        graph.buildGeneration = generation;
        facts.setSessionFact("session.reviewGraph", graph);
        return graph;
    }
    if (memCached) {
        const incremental = await tryIncrementalFromCache(memCached, {
            cwd,
            normalizedCwd,
            normalizedChanged,
            fileSignatures,
            signature,
            facts,
            seqAtBuildStart,
        });
        if (incremental) {
            _lastGraphBuildInfo.seqFastpathFallback = seqFastpathFallback;
            return incremental;
        }
    }
    // Tier 2: disk cache (cold start — files unchanged since last persist).
    // #300: deliberately does NOT verify the git stamp — the signature match /
    // #202 content-hash confirm below already content-verify the load, and
    // dropping on every HEAD move would force a full whole-repo rebuild after
    // each plain `git commit` (HEAD moves, files unchanged).
    const diskCached = loadPersistedGraph(cwd);
    if (diskCached?.signature === signature) {
        const graph = cloneGraph(diskCached.graph);
        rebuildIndexes(graph);
        graph.changedSymbolsByFile.clear();
        for (const file of normalizedChanged) {
            upsertChangedSymbols(graph, facts, file);
        }
        // #459: disk-hydrated content is new to THIS process — fresh stamp (a prior
        // process's derived caches don't exist here; in-process derived caches from
        // before a workspace-cache clear must not match it).
        const generation = ++_graphGenerationCounter;
        _workspaceGraphCache.set(normalizedCwd, {
            signature,
            fileSignatures: new Map(fileSignatures),
            fileHashes: diskCached.fileHashes,
            graph: cloneGraph(diskCached.graph),
            buildGeneration: generation,
            ...verifiedCacheFields(seqAtBuildStart),
        });
        _lastGraphBuildInfo = {
            reused: true,
            mode: "cached",
            seqFastpathFallback,
            graphChanged: false,
        };
        graph.buildGeneration = generation;
        facts.setSessionFact("session.reviewGraph", graph);
        return graph;
    }
    if (diskCached) {
        // #202: same incremental path as the in-memory tier. This is where it pays
        // off most — on cold start, git/checkout mtime drift or a burst of new
        // files since the last persist would otherwise force a full whole-repo
        // rebuild; the delta + content-hash confirm reuses the persisted graph.
        const incremental = await tryIncrementalFromCache({
            signature: diskCached.signature,
            fileSignatures: diskCached.fileSignatures,
            fileHashes: diskCached.fileHashes,
            graph: diskCached.graph,
        }, {
            cwd,
            normalizedCwd,
            normalizedChanged,
            fileSignatures,
            signature,
            facts,
            seqAtBuildStart,
        });
        if (incremental) {
            _lastGraphBuildInfo.seqFastpathFallback = seqFastpathFallback;
            return incremental;
        }
    }
    // Tier 3: full build
    const graph = createEmptyGraph();
    for (const file of filesToBuild) {
        await addFileToGraph(graph, cwd, file, facts);
        if (normalizedChangedSet.has(file)) {
            upsertChangedSymbols(graph, facts, file);
        }
    }
    resolveDeferredSymbolEdges(graph);
    graph.version = REVIEW_GRAPH_VERSION;
    graph.builtAt = new Date().toISOString();
    // #202: record per-file content hashes so the next run can tell a real
    // content change apart from pure mtime/size drift. Only runs on the (rare)
    // full-build path; the OS file cache is warm from the parse above.
    const fileHashes = await sourceHashMapAsync(filesToBuild);
    // #459: full rebuild ⇒ new generation.
    const generation = ++_graphGenerationCounter;
    const graphSnapshot = cloneGraph(graph);
    _workspaceGraphCache.set(normalizedCwd, {
        signature,
        fileSignatures: new Map(fileSignatures),
        fileHashes,
        graph: graphSnapshot,
        buildGeneration: generation,
        ...verifiedCacheFields(seqAtBuildStart),
    });
    persistGraph(cwd, signature, fileSignatures, fileHashes, graphSnapshot); // fire-and-forget
    _lastGraphBuildInfo = {
        reused: false,
        mode: "full",
        seqFastpathFallback,
        graphChanged: true,
    };
    graph.buildGeneration = generation;
    facts.setSessionFact("session.reviewGraph", graph);
    return graph;
}
export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
    const cacheKey = `${cwd}|${[...changedFiles].sort((a, b) => a.localeCompare(b)).join(",")}`;
    const cached = _buildCache.get(cacheKey);
    if (cached)
        return cached;
    const promise = _doBuildGraph(cwd, changedFiles, facts, seqHint).catch((err) => {
        _buildCache.delete(cacheKey);
        throw err;
    });
    _buildCache.set(cacheKey, promise);
    return promise;
}
/**
 * Extract symbols and refs from an already-built ReviewGraph for call graph construction.
 * Reuses parsed data without re-running tree-sitter — symbols come from "symbol" nodes,
 * refs come from "references" edges. Line numbers are unavailable here (not stored in graph
 * nodes), so caller attribution falls back to file-level keys in buildCallGraph.
 */
export function extractSymbolsAndRefsFromGraph(graph) {
    const allSymbols = new Map();
    const allRefs = new Map();
    for (const node of graph.nodes.values()) {
        if (node.kind === "symbol" && node.filePath && node.symbolName) {
            const sym = {
                id: `${node.filePath}:${node.symbolName}`,
                name: node.symbolName,
                kind: "function",
                filePath: node.filePath,
                line: 1,
                column: 1,
                isExported: false,
            };
            const list = allSymbols.get(node.filePath) ?? [];
            list.push(sym);
            allSymbols.set(node.filePath, list);
        }
    }
    for (const edge of graph.edges) {
        if (edge.kind === "references" && edge.from.startsWith("file:")) {
            const callerFile = edge.from.slice("file:".length);
            const refName = edge.to.startsWith("symbol-name:")
                ? edge.to.slice("symbol-name:".length)
                : edge.to.split(":").pop() ?? edge.to;
            const ref = {
                symbolId: `${callerFile}:${refName}`,
                filePath: callerFile,
                line: edge.metadata?.line ?? 1,
                column: edge.metadata?.column ?? 1,
            };
            const list = allRefs.get(callerFile) ?? [];
            list.push(ref);
            allRefs.set(callerFile, list);
        }
    }
    return { allSymbols, allRefs };
}