/**
 * Phase: scopeResolution
 *
 * Generic registry-primary resolution phase (RFC #909 Ring 3).
 *
 * For every language whose provider is registered in `SCOPE_RESOLVERS`:
 *   1. Filter scanned files by language extension.
 *   2. Read file contents.
 *   3. Drive the scope-based pipeline end-to-end via the generic
 *      `runScopeResolution(input, provider)` orchestrator.
 *   4. Emit IMPORTS / CALLS / ACCESSES / INHERITS / USES edges.
 *
 * This is the sole resolution path — RING4-1 (#942) deleted the legacy
 * call-resolution DAG, so there is no longer a per-language flag gating
 * registry-vs-legacy.
 *
 * Adding a language is one change: implement `ScopeResolver` in
 * `languages/<lang>/scope-resolver.ts` and register it in
 * `scope-resolution/pipeline/registry.ts`.
 *
 * @deps    parse  (needs Symbol nodes already in the graph so emit-references
 *                  can attach edges to existing Function/Method/Class nodes)
 * @reads   scannedFiles
 * @writes  graph (IMPORTS, CALLS, ACCESSES, INHERITS, USES)
 */

import type { PipelinePhase, PipelineContext, PhaseResult } from '../../pipeline-phases/types.js';
import { getPhaseOutput } from '../../pipeline-phases/types.js';
import type { StructureOutput } from '../../pipeline-phases/structure.js';
import type { ParseOutput } from '../../pipeline-phases/parse.js';
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
import { readFileContents } from '../../filesystem-walker.js';
import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
import { isLanguageAvailable } from '../../../tree-sitter/parser-loader.js';
import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { SCOPE_RESOLVERS } from './registry.js';
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
import { logHeapProbe } from '../../utils/heap-probe.js';
import {
  clearParsedFileStore,
  loadParsedFilesForPaths,
  forceGc,
} from '../../../../storage/parsedfile-store.js';
import type { ResolutionOutcome } from '../resolution-outcome.js';
import type { UndecidedSatisfaction } from '../contract/scope-resolver.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import type { CallSummary } from '../../taint/call-summary-model.js';
import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
import { buildPropertyNameIndex } from '../passes/unique-name-properties.js';
import { PdgEmitSink, type PdgEmitManifest } from '../../../lbug/pdg-emit-sink.js';
import { resolveNativeSafeStorageDir } from '../../../lbug/lbug-config.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';

import { logger } from '../../../logger.js';
export interface ScopeResolutionOutput {
  /** True when at least one language ran. */
  readonly ran: boolean;
  /** Files seen across all languages. `0` when `ran === false`. */
  readonly filesProcessed: number;
  /** IMPORTS edges emitted across all languages. */
  readonly importsEmitted: number;
  /** Reference (CALLS / ACCESSES / INHERITS / USES) edges emitted. */
  readonly referenceEdgesEmitted: number;
  /** Additive stream of resolver diagnostics; does not affect graph edges. */
  readonly resolutionOutcomes: readonly ResolutionOutcome[];
  /**
   * Interfaces whose structural-satisfaction check could not be completed
   * (#2873). Emits no edges — it is what lets a query distinguish "nothing
   * implements this" from "we could not tell what implements this".
   *
   * Absent when no language ran; `[]` when one ran and decided everything.
   */
  readonly undecidedSatisfaction?: readonly UndecidedSatisfaction[];
  /**
   * Property inference facts a CALLER needs in order to read an empty result
   * correctly (R3-1). Without these, "no ACCESSES for this field" is
   * byte-identical whether the field is unused, ambiguous, or anchored in a
   * language this pass may not infer across — three different situations with
   * three different remedies.
   */
  readonly propertyInference: {
    /** Sites declined because the name could not be narrowed to one definition. */
    readonly ambiguous: number;
    /** Those field names, capped. */
    readonly ambiguousNames: readonly string[];
    /** Sites declined because every definition of the name is another language. */
    readonly crossLanguage: number;
    /** Those field names with the languages their definitions live in, capped. */
    readonly crossLanguageNames: readonly { readonly name: string; readonly languages: string[] }[];
  };
  /** Per-language breakdown for telemetry. */
  readonly perLanguage: ReadonlyMap<
    SupportedLanguages,
    {
      readonly filesProcessed: number;
      readonly importsEmitted: number;
      readonly referenceEdgesEmitted: number;
    }
  >;
  /**
   * Per-function taint summaries harvested in the pdg window (#2084 M4 U1),
   * across all languages. Empty unless `--pdg` and a registered taint model.
   * The `taintSummaries` phase composes these over the `CALLS` graph.
   */
  readonly functionSummaries: readonly FunctionSummary[];
  /**
   * Per-function RETURN-VALUE ASCENT summaries harvested in the pdg window
   * (PDG FU-C, U-C2), across all languages. Empty unless `--pdg`. The
   * `callSummaries` phase materialises one `CALL_SUMMARY` self-loop edge per
   * entry once the resolved call graph is known.
   */
  readonly callSummaries: readonly CallSummary[];
  /**
   * Streamed PDG-emit COPY manifest (#2202). Present only when streaming was on
   * (full rebuild + `--pdg` + enabled): the BasicBlock node CSV + per-pair PDG
   * edge CSVs that were flushed to disk during the emit loop, for the persistence
   * step to COPY alongside the structural CSVs. Absent ⇒ the PDG layer (if any)
   * is in the in-memory graph and persists via the normal whole-graph emit.
   */
  readonly pdgEmitManifest?: PdgEmitManifest;
}

const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
  ran: false,
  filesProcessed: 0,
  importsEmitted: 0,
  referenceEdgesEmitted: 0,
  resolutionOutcomes: [],
  // Deliberately absent, not `[]`: nothing ran, so nothing was decided either.
  perLanguage: new Map(),
  functionSummaries: [],
  callSummaries: [],
  propertyInference: { ambiguous: 0, ambiguousNames: [], crossLanguage: 0, crossLanguageNames: [] },
});

/** Select source files that must be materialized for one resolver pass. */
export function selectScopeSourcePathsToRead(
  provider: ScopeResolver,
  primaryFilePaths: readonly string[],
  preExtractedByPath: { readonly has: (filePath: string) => boolean },
): string[] {
  const hasPostExtractHooks =
    provider.populateWorkspaceOwners !== undefined ||
    provider.populateNamespaceSiblings !== undefined ||
    provider.populateRangeBindings !== undefined ||
    provider.emitPostResolutionEdges !== undefined;
  const needsAllSourceText =
    hasPostExtractHooks && provider.postExtractSourceTextPolicy !== 'uncached-files';

  return needsAllSourceText
    ? [...primaryFilePaths]
    : primaryFilePaths.filter((filePath) => !preExtractedByPath.has(filePath));
}

export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
  name: 'scopeResolution',
  // Depends on `parse` because emit-references attaches edges to
  // already-existing Symbol nodes (Function/Method/Class) that the `parse`
  // phase creates.
  //
  // The `crossFile` dep is retained for stable ordering but is no longer
  // load-bearing: inheritance (EXTENDS/IMPLEMENTS) edges are now emitted by
  // this phase's own `preEmitInheritanceEdges` before `buildMro` runs, and
  // since RING4-1 (#942) `crossFile` only disposes the BindingAccumulator
  // (the legacy cross-file re-resolution it used to run was deleted with the
  // call-resolution DAG).
  deps: ['parse', 'crossFile', 'structure'],

  async execute(
    ctx: PipelineContext,
    deps: ReadonlyMap<string, PhaseResult<unknown>>,
  ): Promise<ScopeResolutionOutput> {
    logHeapProbe('scopeResolution-enter');
    const { scannedFiles } = getPhaseOutput<StructureOutput>(deps, 'structure');
    const parseOutput = getPhaseOutput<ParseOutput>(deps, 'parse');
    const { model, parsedFiles: workerParsedFiles } = parseOutput;
    // SemanticModel populated during `parse`: scope-resolution consumes
    // TypeRegistry / MethodRegistry / SymbolTable lookups instead of
    // rebuilding parallel indexes. See ARCHITECTURE.md § "Semantic-model
    // source of truth".

    // Build a per-file lookup of ParsedFile artifacts the workers (or
    // sequential extracts) already produced. Threading this into
    // `runScopeResolution` lets the per-language extract loop short-
    // circuit `extractParsedFile` — the dominant cost on the warm-cache
    // path, since workers can't return tree-sitter Trees across the
    // MessageChannel and scope-resolution would otherwise re-parse
    // every file from scratch on the main thread.
    const preExtractedByPath = new Map<string, import('gitnexus-shared').ParsedFile>();
    for (const pf of workerParsedFiles) {
      preExtractedByPath.set(pf.filePath, pf);
    }

    // Disk-backed ParsedFile store (#1983): on huge repos the parse phase
    // flushes worker-produced ParsedFiles to disk (instead of `workerParsedFiles`
    // heap) and we stream them back here per language. Reusing them means
    // scope-resolution does ZERO tree-sitter parsing on the main thread, which
    // is what eliminates the unbounded native re-parse leak that OOM'd large
    // analyses. `undefined` when no storage path (tests / direct calls) — those
    // runs use the in-heap `workerParsedFiles` above or fall back to a fresh
    // extract per file inside runScopeResolution.
    const parsedFileStorePath = ctx.options?.parseCache?.storagePath;

    // Standalone providers are re-extracted from source on the main thread;
    // workers intentionally do not serialize ParsedFile artifacts for them.
    // Drop any stale/cache-sourced entry defensively so provider-owned regex
    // capture logic is always the source of truth for the current content.
    for (const [path] of preExtractedByPath) {
      const lang = getLanguageFromFilename(path);
      if (lang === null) continue;
      const provider = SCOPE_RESOLVERS.get(lang);
      if (provider?.languageProvider.parseStrategy === 'standalone') {
        preExtractedByPath.delete(path);
      }
    }

    let totalFiles = 0;
    let totalImports = 0;
    let totalRefs = 0;
    let anyRan = false;
    const resolutionOutcomes: ResolutionOutcome[] = [];
    const undecidedSatisfaction: UndecidedSatisfaction[] = [];
    // M4 (#2084 U1): per-function taint summaries accumulated across every
    // language pass; the cross-function fixpoint phase reads this output.
    const functionSummaries: FunctionSummary[] = [];
    // FU-C (U-C2): per-function RETURN-VALUE ASCENT summaries accumulated across
    // every language pass; the `callSummaries` emit phase reads this output.
    const callSummaries: CallSummary[] = [];
    const perLanguage = new Map<
      SupportedLanguages,
      {
        readonly filesProcessed: number;
        readonly importsEmitted: number;
        readonly referenceEdgesEmitted: number;
      }
    >();

    // Pre-count files and languages for progress reporting. This avoids
    // a frozen progress bar during long scope-resolution runs (#1741).
    // Uses primary-language file counts only; languages that expand their
    // context via collectScopeContextPaths may process more files than shown.
    let totalScopeFiles = 0;
    let totalScopeLangs = 0;
    const allScannedPaths = new Set(scannedFiles.map((f) => f.path));
    // Partition scanned files by language ONCE (O(F)). The previous code
    // re-filtered all scannedFiles per language for the precount AND again in the
    // per-language loop below — O(languages × files), ~2.3M getLanguageFromFilename
    // calls on the Linux kernel. Bucketing once collapses that to O(F).
    // Language-agnostic: keyed by the provider-supplied SupportedLanguages value.
    const filesByLang = new Map<
      NonNullable<ReturnType<typeof getLanguageFromFilename>>,
      (typeof scannedFiles)[number][]
    >();
    for (const f of scannedFiles) {
      const fileLang = getLanguageFromFilename(f.path);
      if (fileLang === null) continue;
      // Tree-sitter providers require an available grammar. Standalone regex
      // providers deliberately have none and re-extract on the main thread.
      const resolver = SCOPE_RESOLVERS.get(fileLang);
      if (
        resolver?.languageProvider.parseStrategy !== 'standalone' &&
        !isLanguageAvailable(fileLang)
      ) {
        continue;
      }
      let bucket = filesByLang.get(fileLang);
      if (bucket === undefined) {
        bucket = [];
        filesByLang.set(fileLang, bucket);
      }
      bucket.push(f);
    }
    for (const [lang] of SCOPE_RESOLVERS) {
      const count = filesByLang.get(lang)?.length ?? 0;
      if (count > 0) {
        totalScopeLangs++;
        totalScopeFiles += count;
      }
    }
    const SCOPE_PCT_START = 90;
    const SCOPE_PCT_RANGE = 8; // 90-98 internal → 54-59% display
    let processedScopeFiles = 0;
    let currentLangIdx = 0;

    if (totalScopeFiles > 0) {
      ctx.onProgress({
        phase: 'scopeResolution',
        percent: SCOPE_PCT_START,
        message: 'Resolving types',
      });
    }

    // Build the graph-node lookup ONCE and share it across every language pass.
    // It scans the whole graph (~2 GB on the kernel) and is language-agnostic,
    // so the previous per-language rebuild burned that CPU+heap N times and, on
    // a huge repo, a tiny language's full-graph copy overlapped the next
    // language's — a real contributor to the scope-resolution memory peak.
    // Bracket the whole-graph node-lookup build with probes — scanning every
    // graph node is the silent multi-minute step before the first per-language
    // marker on huge repos, so make it observable.
    logHeapProbe(
      'scope-setup-nodeLookup-start',
      `langs=${totalScopeLangs} files=${totalScopeFiles}`,
    );
    const sharedNodeLookup = totalScopeFiles > 0 ? buildGraphNodeLookup(ctx.graph) : undefined;
    logHeapProbe('scope-setup-nodeLookup-end', `langs=${totalScopeLangs}`);
    // M4 (#2084 review P2-6): build the functionish-node index ONCE for the
    // taint summary harvest, shared across every language pass (it is a whole-
    // graph scan and language-agnostic). Only when pdg is on — off ⇒ undefined,
    // no scan, byte-identical.
    const sharedFnNodeIndex =
      ctx.options?.pdg === true && totalScopeFiles > 0
        ? buildFunctionNodeIndex(ctx.graph)
        : undefined;
    // Same treatment for the `Property`-by-name index the unique-name pass
    // consults: a whole-graph node scan, language-agnostic, and previously
    // rebuilt inside every qualifying language pass. Language filtering happens
    // at LOOKUP time against that language's own file set, so one shared index
    // serves all of them without widening what any single language can resolve
    // to.
    const sharedPropertyNameIndex =
      totalScopeFiles > 0 ? buildPropertyNameIndex(ctx.graph) : undefined;

    // Streaming/chunked PDG emit (#2202): when enabled (the caller has already
    // gated this to full-rebuild + `--pdg`), route the BasicBlock + intra-file
    // PDG-edge layer to CSV-on-disk through one sink shared across every
    // language pass, so it never accumulates in `ctx.graph` (peak RSS O(chunk)).
    // Needs the storage dir (the parse-cache store path, the same `.gitnexus`
    // dir loadGraphToLbug COPYs from); if that is somehow absent we skip
    // streaming and fall back to the in-memory whole-graph emit.
    let pdgEmitSink: PdgEmitSink | undefined;
    if (ctx.options?.streamPdgEmit === true && totalScopeFiles > 0) {
      if (parsedFileStorePath) {
        pdgEmitSink = new PdgEmitSink(
          ctx.graph,
          // Same ASCII-safe relocation the structural CSVs get (#2202 review #2):
          // on Windows non-ASCII storage paths the COPY can't open files under
          // the native path, so the dir is relocated to a hashed os.tmpdir().
          resolveNativeSafeStorageDir(parsedFileStorePath, 'pdg-csv'),
          ctx.options?.pdgEmitChunkSize,
        );
      } else {
        logger.warn(
          '[scope-resolution] streaming PDG emit requested but no storage path is ' +
            'available; falling back to in-memory whole-graph emit',
        );
      }
    }
    // Cross-pass per-file dedup set for the streaming sink (#2202): one set
    // shared across every language pass so a file emitted in two passes (e.g. a
    // `.ts` module pulled into the Vue context pass) streams its PDG layer once.
    // Only created when streaming — the in-memory-graph path dedups via its Map.
    const pdgEmittedFiles = pdgEmitSink !== undefined ? new Set<string>() : undefined;

    // Stream the PDG layer with guaranteed writer cleanup: a throw escaping the
    // per-language loop (outside run.ts's per-file try/catch — e.g. from
    // finalize/propagate/a provider hook) must still release the sink's file
    // descriptors. finalize() runs on the success path; the finally closes the
    // sink only when finalize did not (idempotent via the sink's `finalized`).
    // R3-1 accumulators — see the warning after the language loop.
    let crossLanguagePropertyReads = 0;
    const crossLanguageAnchorsByName = new Map<string, Set<string>>();
    let ambiguousPropertyReads = 0;
    const ambiguousPropertyNames = new Set<string>();
    let pdgEmitManifest: PdgEmitManifest | undefined;
    let pdgSinkSettled = false;
    try {
      for (const [lang, provider] of SCOPE_RESOLVERS) {
        const primaryLangFiles = filesByLang.get(lang) ?? [];
        if (primaryLangFiles.length === 0) continue;
        const primaryFilePaths = primaryLangFiles.map((f) => f.path);

        // Load per-language import-resolution config (tsconfig paths,
        // composer.json autoload, go.mod, ...). One I/O round trip per
        // workspace pass — cached implicitly by the result handed to
        // every `resolveImportTarget` call below.
        const resolutionConfig =
          provider.loadResolutionConfig !== undefined
            ? await provider.loadResolutionConfig(ctx.repoPath)
            : undefined;

        // Some languages (e.g. Vue) expand their file universe beyond the
        // primary-language files via the `collectScopeContextPaths` hook.
        // The hook receives raw source contents of the primary files so it
        // can trace import closures without a second tree-sitter parse.
        //
        // To avoid reading primary files twice (once for the hook, once for
        // the resolution pass), we read them upfront and merge with the
        // extra context paths the hook may add.
        // Stream this language's pre-built ParsedFiles in from the disk store
        // FIRST (huge-repo path). Doing it before reading source lets us skip
        // loading content for files the store already covers — for a provider
        // with no content-consuming hook that source is pure dead weight once
        // extraction is served from the store (~1.5 GB on the kernel's C pass).
        // Merged into `preExtractedByPath`; the per-language release block below
        // evicts these again before the next language, so only one language's
        // ParsedFiles are resident at a time.
        const loadStoreFor = async (paths: ReadonlySet<string>): Promise<void> => {
          if (!parsedFileStorePath) return;
          const fromDisk = await loadParsedFilesForPaths(parsedFileStorePath, paths);
          for (const [fp, pf] of fromDisk) preExtractedByPath.set(fp, pf);
        };

        let scopeFilePaths: Set<string>;
        let contents: Map<string, string>;
        if (provider.collectScopeContextPaths !== undefined) {
          // Context-expanding providers (e.g. Vue) need every primary file's
          // source up front for the closure hook, so load it all.
          const entryFileContents = await readFileContents(ctx.repoPath, primaryFilePaths);
          scopeFilePaths = provider.collectScopeContextPaths({
            primaryFilePaths,
            preExtractedByPath,
            entryFileContents,
            allScannedPaths,
            resolutionConfig,
          });
          // Read only the extra context files (TS/JS etc.) not already loaded.
          const extraPaths = [...scopeFilePaths].filter((p) => !entryFileContents.has(p));
          const extraContents = await readFileContents(ctx.repoPath, extraPaths);
          contents = new Map([...entryFileContents, ...extraContents]);
          await loadStoreFor(scopeFilePaths);
        } else {
          scopeFilePaths = new Set(primaryFilePaths);
          await loadStoreFor(scopeFilePaths);
          const pathsToRead = selectScopeSourcePathsToRead(
            provider,
            primaryFilePaths,
            preExtractedByPath,
          );
          contents = await readFileContents(ctx.repoPath, pathsToRead);
        }
        const filePaths = [...scopeFilePaths];
        const files: { path: string; content: string }[] = [];
        for (const fp of filePaths) {
          const content = contents.get(fp);
          if (content !== undefined) {
            files.push({ path: fp, content });
          } else if (preExtractedByPath.has(fp)) {
            // Store covers extraction for this file and we deliberately skipped
            // reading its source; extraction uses the pre-extracted ParsedFile,
            // and the provider's source-text policy guarantees its hooks can
            // tolerate empty content for cached files.
            files.push({ path: fp, content: '' });
          }
          // else: uncovered AND unreadable → skip (unchanged from prior behavior).
        }

        const langFileCount = files.length;
        logHeapProbe(
          'scope-lang-start',
          `lang=${lang} files=${langFileCount} contentsLoaded=${contents.size}`,
        );
        const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
        currentLangIdx++;
        const langTag =
          totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel;

        if (totalScopeFiles > 0) {
          const pct =
            SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE);
          ctx.onProgress({
            phase: 'scopeResolution',
            percent: pct,
            message: 'Resolving types',
            detail: `${langTag}, ${langFileCount.toLocaleString()} files`,
          });
        }

        const stats = runScopeResolution(
          {
            graph: ctx.graph,
            model,
            files,
            resolutionConfig,
            prebuiltNodeLookup: sharedNodeLookup,
            prebuiltPropertyNameIndex: sharedPropertyNameIndex,
            prebuiltFunctionNodeIndex: sharedFnNodeIndex,
            preExtractedParsedFiles: preExtractedByPath,
            scopeIndexStorePath: parsedFileStorePath,
            // CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
            pdg: ctx.options?.pdg === true,
            pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
            pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
            pdgMaxCdgEdgesPerFunction: ctx.options?.pdgMaxCdgEdgesPerFunction,
            pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
            pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
            // Streaming PDG-emit sink (#2202) — undefined ⇒ emit to the in-memory graph.
            pdgEmitSink,
            // Cross-pass per-file dedup set (#2202) — undefined when not streaming.
            pdgEmittedFiles,
            recordResolutionOutcome: (outcome) => {
              resolutionOutcomes.push(outcome);
            },
            onWarn: (msg) => {
              if (isSemanticModelValidatorEnabled()) {
                logger.warn(`[scope-resolution:${lang}] ${msg}`);
              }
            },
            onProgress:
              totalScopeFiles > 0
                ? (subPhase: ScopeResolutionSubPhase, current, total) => {
                    let langRatio: number;
                    switch (subPhase) {
                      case 'extracting':
                        langRatio = total > 0 ? (current / total) * 0.5 : 0;
                        break;
                      case 'analyzing types':
                        langRatio = 0.5;
                        break;
                      case 'resolving references':
                        langRatio = 0.7;
                        break;
                      case 'linking symbols':
                        langRatio = 0.85;
                        break;
                      default: {
                        const _exhaustive: never = subPhase;
                        langRatio = 0.85;
                      }
                    }
                    const overallRatio = Math.min(
                      1,
                      (processedScopeFiles + langRatio * langFileCount) / totalScopeFiles,
                    );
                    const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE);
                    ctx.onProgress({
                      phase: 'scopeResolution',
                      percent: pct,
                      message: 'Resolving types',
                      detail:
                        subPhase === 'extracting'
                          ? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files`
                          : `${langTag} — ${subPhase}`,
                    });
                  }
                : undefined,
          },
          provider,
        );

        // Release file contents and pre-extracted entries after each language
        // to reduce memory pressure. For large codebases (16K+ PHP files),
        // holding all source code simultaneously with scope trees causes OOM.
        // See: https://github.com/abhigyanpatwari/GitNexus/issues/1741
        //
        // Use `filePaths` (not `primaryFilePaths`) so that any context files
        // added by `collectScopeContextPaths` (e.g. TS/JS files pulled in for
        // Vue cross-file resolution) are also evicted and not held until GC.
        files.length = 0;
        contents.clear();
        for (const fp of filePaths) {
          preExtractedByPath.delete(fp);
        }
        // This language's ParsedFiles are now unreachable (runScopeResolution has
        // returned and the Map entries are deleted). Force a GC HERE so a heavy
        // language's ~17-20GB set (e.g. C/C++ on the Linux kernel) is reclaimed
        // BEFORE the next language's store-load — instead of leaving V8 to collect
        // it lazily under the next pass's allocation pressure (which, at a cap >=
        // RAM, degrades into swap-thrash). Collects only dead objects: the live
        // cross-file index of the next pass is untouched. The pre/post probe
        // confirms whether old-space fragmentation defeats the reclaim.
        logHeapProbe('lang-release-pre-gc', `lang=${lang}`);
        forceGc();
        logHeapProbe('lang-release-post-gc', `lang=${lang}`);
        logHeapProbe('scope-lang-end', `lang=${lang} filesProcessed=${stats.filesProcessed}`);

        processedScopeFiles += langFileCount;
        anyRan = true;
        functionSummaries.push(...stats.functionSummaries);
        undecidedSatisfaction.push(...stats.undecidedSatisfaction);
        callSummaries.push(...stats.callSummaries);
        totalFiles += stats.filesProcessed;
        totalImports += stats.importsEmitted;
        totalRefs += stats.referenceEdgesEmitted;
        perLanguage.set(lang, {
          filesProcessed: stats.filesProcessed,
          importsEmitted: stats.importsEmitted,
          referenceEdgesEmitted: stats.referenceEdgesEmitted,
        });

        if (isDev) {
          logger.info(
            `[scope-resolution:${lang}] ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`,
          );
        }

        // R3-1. Accumulated across languages and reported once below, NOT gated
        // on isDev: a field whose only definition lives in another language
        // answers an empty ACCESSES query that is byte-identical to "unused",
        // and that is the confident-empty failure this whole series removes.
        ambiguousPropertyReads += stats.uniqueNamePropertyAmbiguous;
        for (const n of stats.uniqueNamePropertyAmbiguousNames) ambiguousPropertyNames.add(n);
        crossLanguagePropertyReads += stats.uniqueNamePropertyCrossLanguage;
        for (const entry of stats.uniqueNamePropertyCrossLanguageNames) {
          const existing = crossLanguageAnchorsByName.get(entry.name);
          if (existing === undefined) {
            crossLanguageAnchorsByName.set(entry.name, new Set(entry.languages));
          } else {
            for (const l of entry.languages) existing.add(l);
          }
        }
      }

      if (crossLanguagePropertyReads > 0) {
        const sample = Array.from(crossLanguageAnchorsByName)
          .slice(0, 10)
          .map(([name, langs]) => `${name} (${Array.from(langs).sort().join('/')})`)
          .join(', ');
        logger.warn(
          `[scope-resolution] ${crossLanguagePropertyReads} property read/write site(s) name a field ` +
            `that IS defined in this workspace, but only in another language, so per-language inference ` +
            `declined to link them. Queries for these fields return an empty result that does NOT mean ` +
            `"unused" — it means the definition anchor is in a different language. ` +
            `Affected: ${sample}${crossLanguageAnchorsByName.size > 10 ? ', …' : ''}`,
        );
      }

      // Finalize the streaming PDG sink (#2202) once after the last language:
      // flush + close its CSV writers and capture the COPY manifest. forceGc at
      // the boundary reclaims transient write buffers (mirrors the per-language
      // release below).
      pdgEmitManifest = pdgEmitSink?.finalize();
      pdgSinkSettled = true;
      if (pdgEmitSink !== undefined) forceGc();
    } finally {
      // Release fds if a throw skipped finalize (idempotent with finalize()).
      if (pdgEmitSink !== undefined && !pdgSinkSettled) pdgEmitSink.close();
    }

    if (totalScopeFiles > 0 && anyRan) {
      ctx.onProgress({
        phase: 'scopeResolution',
        percent: SCOPE_PCT_START + SCOPE_PCT_RANGE,
        message: 'Resolving types',
        detail: 'complete',
      });
    }

    // Scope-resolution is the sole consumer of the disk-backed ParsedFile
    // store; remove its shards now (can be many GB on a huge repo) so they
    // don't linger in `.gitnexus`. Best-effort — never fail the phase on a
    // cleanup error.
    if (parsedFileStorePath) {
      try {
        await clearParsedFileStore(parsedFileStorePath);
      } catch {
        /* best-effort cleanup */
      }
    }

    // Even when no language ran, surface a finalized manifest (its CSVs are on
    // disk) so loadGraphToLbug COPYs them rather than orphaning them — empty in
    // the no-files case, harmless.
    if (!anyRan) return pdgEmitManifest ? { ...NOOP_OUTPUT, pdgEmitManifest } : NOOP_OUTPUT;

    return {
      ran: true,
      filesProcessed: totalFiles,
      importsEmitted: totalImports,
      referenceEdgesEmitted: totalRefs,
      resolutionOutcomes,
      undecidedSatisfaction,
      perLanguage,
      functionSummaries,
      callSummaries,
      pdgEmitManifest,
      propertyInference: {
        ambiguous: ambiguousPropertyReads,
        ambiguousNames: Array.from(ambiguousPropertyNames).sort().slice(0, 25),
        crossLanguage: crossLanguagePropertyReads,
        crossLanguageNames: Array.from(crossLanguageAnchorsByName)
          .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
          .slice(0, 25)
          .map(([name, languages]) => ({ name, languages: Array.from(languages).sort() })),
      },
    };
  },
};
