Skip to main content

sqry_core/graph/unified/build/
entrypoint.rs

1//! Build entrypoint for unified graph.
2//!
3//! This module provides the top-level API for building a unified graph from source files.
4//! It orchestrates file discovery and delegates to the 5-pass build pipeline.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11use ignore::WalkBuilder;
12use rayon::prelude::*;
13
14use crate::graph::GraphBuilderError;
15use crate::graph::error::GraphResult;
16use crate::graph::unified::analysis::LabelBudgetConfig;
17use crate::graph::unified::analysis::ReachabilityStrategy;
18use crate::graph::unified::build::StagingGraph;
19use crate::graph::unified::build::cancellation::CancellationToken;
20use crate::graph::unified::build::parallel_commit::{
21    GlobalOffsets, PhaseCIndirectDrain, phase2_assign_ranges, phase3_parallel_commit,
22    phase4_apply_global_remap, phase4c_prime_unify_cross_file_nodes, phase4d_bulk_insert_edges,
23};
24use crate::graph::unified::build::pass3_intra::PendingEdge;
25use crate::graph::unified::build::progress::GraphBuildProgressTracker;
26use crate::graph::unified::concurrent::CodeGraph;
27use crate::graph::unified::node::{NodeId, NodeKind};
28use crate::graph::unified::storage::c_indirect::{
29    BindingEntry, CIndirectSideTables, IndirectCallsite,
30};
31use crate::graph::unified::string::StringId;
32use crate::io::FileReader;
33use crate::plugin::PluginManager;
34use crate::plugin::error::ParseError;
35use crate::plugin::{SafeParser, SafeParserConfig};
36use crate::progress::{SharedReporter, no_op_reporter};
37use crate::project::path_utils::normalize_path_components;
38
39/// Result of a successful build-and-persist operation.
40///
41/// Contains all metadata about the completed graph build, including
42/// canonical (deduplicated) edge counts, file counts by language, and
43/// provenance information.
44#[derive(Debug, Clone)]
45pub struct BuildResult {
46    /// Number of nodes in the graph.
47    pub node_count: usize,
48    /// Number of deduplicated edges (from analysis CSR, after merge/compaction).
49    /// This is the canonical edge count.
50    pub edge_count: usize,
51    /// Number of raw edges in the graph (CSR + delta buffer, before dedup).
52    /// Available for diagnostics; NOT the canonical count.
53    pub raw_edge_count: usize,
54    /// Number of indexed files, by language (e.g., `{"rust": 150, "python": 30}`).
55    ///
56    /// Counts files that entered the graph indexing pipeline and were
57    /// successfully parsed by a plugin. Not the same as "scanned files"
58    /// (all files walked by the directory scanner).
59    pub file_count: std::collections::HashMap<String, usize>,
60    /// Total number of indexed files.
61    pub total_files: usize,
62    /// ISO 8601 timestamp when the build completed.
63    pub built_at: String,
64    /// Root path that was indexed.
65    pub root_path: String,
66    /// Number of threads used for parallel file processing.
67    ///
68    /// Reflects the effective thread count from the rayon pool, not the
69    /// CLI-requested value. Useful for build diagnostics.
70    pub thread_count: usize,
71
72    /// Deterministic ordered built-in plugin ids active during the build.
73    pub active_plugin_ids: Vec<String>,
74
75    /// Reachability strategy used by each persisted analysis kind.
76    pub analysis_strategies: Vec<AnalysisStrategySummary>,
77}
78
79/// Persisted analysis strategy summary for one edge kind.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct AnalysisStrategySummary {
82    /// Stable edge-kind label (`calls`, `imports`, `references`, `inherits`).
83    pub edge_kind: &'static str,
84    /// Reachability strategy persisted for the edge kind.
85    pub strategy: ReachabilityStrategy,
86}
87
88/// Default staging memory limit per batch: 512 MB.
89///
90/// When the accumulated `StagingGraph` memory exceeds this threshold, the
91/// current batch is committed before parsing the next chunk. Override via
92/// `SQRY_STAGING_MEMORY_LIMIT_MB` or [`BuildConfig::staging_memory_limit`].
93const DEFAULT_STAGING_MEMORY_LIMIT: usize = 512 * 1024 * 1024;
94
95const BYTES_PER_MIB: usize = 1024 * 1024;
96const INDEX_WORKER_STACK_SIZE_ENV: &str = "SQRY_INDEX_WORKER_STACK_MB";
97const DEFAULT_INDEX_WORKER_STACK_SIZE_MB: usize = 32;
98const MIN_INDEX_WORKER_STACK_SIZE_MB: usize = 8;
99const MAX_INDEX_WORKER_STACK_SIZE_MB: usize = 256;
100
101/// Directory names skipped by default when discovering first-party source files.
102///
103/// These are dependency, build output, editor cache, or CI runner cache roots
104/// that routinely contain generated code or vendored third-party dependencies.
105/// The indexer still honors `.gitignore` and related ignore files; this list
106/// protects editor-triggered indexing when those files are absent or incomplete.
107/// Set `SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS=1` to disable these built-in
108/// excludes for repositories that intentionally keep first-party code in one
109/// of these directories.
110const DEFAULT_EXCLUDED_SOURCE_DIRS: &[&str] = &[
111    ".git",
112    ".hg",
113    ".svn",
114    ".cache",
115    ".next",
116    ".nuxt",
117    ".sqry",
118    ".turbo",
119    ".venv",
120    "__pycache__",
121    "_actions",
122    "_update",
123    "_work",
124    "build",
125    "dist",
126    "node_modules",
127    "target",
128    "vendor",
129    "venv",
130];
131
132const DEFAULT_EXCLUDED_SOURCE_DIR_PREFIXES: &[&str] = &["externals."];
133
134/// Configuration for building the unified graph.
135#[derive(Debug, Clone)]
136pub struct BuildConfig {
137    /// Maximum directory depth to traverse (None = unlimited).
138    pub max_depth: Option<usize>,
139
140    /// Follow symbolic links.
141    pub follow_links: bool,
142
143    /// Include hidden files and directories.
144    pub include_hidden: bool,
145
146    /// Number of threads for parallel building (None = use default based on CPU count).
147    pub num_threads: Option<usize>,
148
149    /// Maximum staging memory (bytes) to accumulate before committing a batch.
150    ///
151    /// Controls the parse-commit chunking watermark. When the sum of all
152    /// in-flight `StagingGraph` buffers exceeds this limit, the batch is
153    /// committed to the graph before the next chunk of files is parsed.
154    ///
155    /// Defaults to 512 MB. Override via
156    /// `SQRY_STAGING_MEMORY_LIMIT_MB` environment variable.
157    pub staging_memory_limit: usize,
158
159    /// Configuration for the 2-hop label budget used during analysis.
160    ///
161    /// Controls the maximum number of intervals per edge kind and what
162    /// to do when the budget is exceeded (fail or degrade to BFS).
163    pub label_budget: LabelBudgetConfig,
164}
165
166impl Default for BuildConfig {
167    fn default() -> Self {
168        let limit = std::env::var("SQRY_STAGING_MEMORY_LIMIT_MB")
169            .ok()
170            .and_then(|v| v.parse::<usize>().ok())
171            .map_or(DEFAULT_STAGING_MEMORY_LIMIT, |mb| mb * 1024 * 1024);
172
173        let label_budget = LabelBudgetConfig {
174            budget_per_kind: 15_000_000,
175            on_exceeded: crate::graph::unified::analysis::BudgetExceededPolicy::Degrade,
176            density_gate_threshold: 64,
177            skip_labels: false,
178        };
179
180        Self {
181            max_depth: None,
182            follow_links: false,
183            include_hidden: false,
184            num_threads: None,
185            staging_memory_limit: limit,
186            label_budget,
187        }
188    }
189}
190
191/// Create a rayon thread pool sized by `BuildConfig::num_threads`.
192fn create_thread_pool(config: &BuildConfig) -> Result<rayon::ThreadPool> {
193    let mut builder = rayon::ThreadPoolBuilder::new();
194    if let Some(n) = config.num_threads {
195        builder = builder.num_threads(n);
196    }
197    builder
198        .stack_size(index_worker_stack_size_bytes())
199        .build()
200        .context("Failed to create rayon thread pool for graph indexing")
201}
202
203#[must_use]
204fn index_worker_stack_size_bytes() -> usize {
205    let env_value = std::env::var(INDEX_WORKER_STACK_SIZE_ENV).ok();
206    index_worker_stack_size_bytes_from_value(env_value.as_deref())
207}
208
209#[must_use]
210fn index_worker_stack_size_bytes_from_value(value: Option<&str>) -> usize {
211    let size_mb = value
212        .and_then(|raw| raw.trim().parse::<usize>().ok())
213        .unwrap_or(DEFAULT_INDEX_WORKER_STACK_SIZE_MB)
214        .clamp(
215            MIN_INDEX_WORKER_STACK_SIZE_MB,
216            MAX_INDEX_WORKER_STACK_SIZE_MB,
217        );
218    size_mb * BYTES_PER_MIB
219}
220
221/// Compute chunk boundaries for memory-bounded parallel parse batches.
222///
223/// Splits `files` into non-overlapping ranges where each chunk's estimated
224/// staging memory stays within `memory_limit`. Uses source file size as a
225/// proxy for staging buffer size (multiplied by an expansion factor to
226/// account for AST node/edge/string overhead).
227///
228/// Returns at least one chunk even if the first file alone exceeds the limit.
229fn compute_parse_chunks(
230    files: &[PathBuf],
231    _pool: &rayon::ThreadPool,
232    _plugins: &PluginManager,
233    memory_limit: usize,
234) -> Vec<std::ops::Range<usize>> {
235    // Expansion factor: staging buffers are typically 2-8x the source file
236    // size due to AST nodes, edges, and interned strings. Use 4x as a
237    // conservative middle ground.
238    const EXPANSION_FACTOR: usize = 4;
239
240    let mut chunks = Vec::new();
241    let mut chunk_start = 0;
242    let mut chunk_estimate = 0usize;
243
244    for (i, path) in files.iter().enumerate() {
245        #[allow(clippy::cast_possible_truncation)] // File sizes always fit usize on 32/64-bit.
246        let file_size = std::fs::metadata(path)
247            .map(|m| m.len() as usize)
248            .unwrap_or(0);
249        let estimated_staging = file_size * EXPANSION_FACTOR;
250
251        // If adding this file would exceed the limit and we already have
252        // files in the chunk, finalize the current chunk first.
253        if chunk_estimate + estimated_staging > memory_limit && i > chunk_start {
254            chunks.push(chunk_start..i);
255            chunk_start = i;
256            chunk_estimate = 0;
257        }
258        chunk_estimate += estimated_staging;
259    }
260
261    // Final chunk (always push — handles single-chunk and trailing files)
262    if chunk_start < files.len() {
263        chunks.push(chunk_start..files.len());
264    }
265
266    if chunks.len() > 1 {
267        log::info!(
268            "Memory-bounded chunking: {} batches for {} files (limit: {} MB)",
269            chunks.len(),
270            files.len(),
271            memory_limit / (1024 * 1024),
272        );
273    }
274
275    chunks
276}
277
278/// Phase name for file processing during graph build.
279pub const GRAPH_FILE_PROCESSING_PHASE: &str = "File processing";
280
281/// Build a unified graph from source files.
282///
283/// This function:
284/// 1. Walks the file tree starting at `root`
285/// 2. For each file, extracts symbols using the appropriate language plugin
286/// 3. Runs the 5-pass build pipeline to populate the graph
287/// 4. Returns the completed `CodeGraph`
288///
289/// # Arguments
290///
291/// * `root` - Root directory to scan for source files
292/// * `plugins` - Plugin manager for language-specific extraction
293/// * `config` - Build configuration
294///
295/// # Returns
296///
297/// A `CodeGraph` containing the populated graph.
298///
299/// # Errors
300///
301/// Returns an error if:
302/// - The root path does not exist
303/// - No graph builders are registered
304/// - All eligible files fail to build (per-file failures are logged and skipped)
305///
306/// # Example
307///
308/// ```ignore
309/// use sqry_core::graph::unified::build::{build_unified_graph, BuildConfig};
310/// use sqry_core::plugin::PluginManager;
311/// use std::path::Path;
312///
313/// let plugins = sqry_plugin_registry::create_plugin_manager();
314/// let config = BuildConfig::default();
315/// let graph = build_unified_graph(Path::new("src"), &plugins, &config)?;
316/// println!("Created graph with {} nodes", graph.node_count());
317/// ```
318pub fn build_unified_graph(
319    root: &Path,
320    plugins: &PluginManager,
321    config: &BuildConfig,
322) -> Result<CodeGraph> {
323    build_unified_graph_cancellable(root, plugins, config, &CancellationToken::default())
324        .map_err(anyhow::Error::from)
325}
326
327/// Build a unified graph from source files with progress reporting.
328///
329/// This is the same as [`build_unified_graph`] but accepts a progress reporter
330/// for tracking build progress.
331///
332/// # Arguments
333///
334/// * `root` - Root directory to scan for source files
335/// * `plugins` - Plugin manager for language-specific extraction
336/// * `config` - Build configuration
337/// * `progress` - Progress reporter for build status updates
338///
339/// # Returns
340///
341/// A `CodeGraph` containing the populated graph.
342///
343/// # Errors
344///
345/// Returns an error if the path is missing, no graph builders are registered,
346/// or all eligible files fail to build.
347pub fn build_unified_graph_with_progress(
348    root: &Path,
349    plugins: &PluginManager,
350    config: &BuildConfig,
351    progress: SharedReporter,
352) -> Result<(CodeGraph, usize)> {
353    build_unified_graph_with_progress_cancellable(
354        root,
355        plugins,
356        config,
357        progress,
358        &CancellationToken::default(),
359    )
360    .map_err(anyhow::Error::from)
361}
362
363/// Build a unified graph with cooperative cancellation.
364///
365/// Behaves identically to [`build_unified_graph`] except that the
366/// `cancellation` token is polled at every pass boundary. A cancelled
367/// token causes the pipeline to return [`GraphBuilderError::Cancelled`]
368/// at the next boundary.
369///
370/// Used by the sqryd daemon's rebuild dispatcher to abort in-flight
371/// full rebuilds when a workspace is evicted mid-build.
372///
373/// # Errors
374///
375/// Returns [`GraphBuilderError::Cancelled`] if the token is cancelled
376/// at any pass boundary; otherwise the same error modes as
377/// [`build_unified_graph`] (lifted from `anyhow::Error` into
378/// [`GraphBuilderError::Internal`]).
379pub fn build_unified_graph_cancellable(
380    root: &Path,
381    plugins: &PluginManager,
382    config: &BuildConfig,
383    cancellation: &CancellationToken,
384) -> GraphResult<CodeGraph> {
385    let (graph, _effective_threads) =
386        build_unified_graph_inner(root, plugins, config, no_op_reporter(), cancellation)?;
387    Ok(graph)
388}
389
390/// Build a unified graph with cooperative cancellation AND a progress
391/// reporter.
392///
393/// Combines [`build_unified_graph_cancellable`] + the progress
394/// reporter variant.
395///
396/// # Errors
397///
398/// Same as [`build_unified_graph_cancellable`].
399pub fn build_unified_graph_with_progress_cancellable(
400    root: &Path,
401    plugins: &PluginManager,
402    config: &BuildConfig,
403    progress: SharedReporter,
404    cancellation: &CancellationToken,
405) -> GraphResult<(CodeGraph, usize)> {
406    build_unified_graph_inner(root, plugins, config, progress, cancellation)
407}
408
409/// Internal implementation that returns the effective thread count alongside the graph.
410///
411/// Used by [`build_and_persist_graph_with_progress`] to propagate the thread count
412/// into `BuildResult` without exposing it in the public API.
413///
414/// Accepts a [`CancellationToken`] which is polled at every pass
415/// boundary. Callers that do not need cancellation pass
416/// `&CancellationToken::default()` (via the `build_unified_graph` +
417/// `build_unified_graph_with_progress` wrappers).
418#[allow(clippy::too_many_lines)] // Complex 5-pass build pipeline requires sequential flow
419fn build_unified_graph_inner(
420    root: &Path,
421    plugins: &PluginManager,
422    config: &BuildConfig,
423    progress: SharedReporter,
424    cancellation: &CancellationToken,
425) -> GraphResult<(CodeGraph, usize)> {
426    if !root.exists() {
427        return Err(GraphBuilderError::Internal {
428            reason: format!("Path {} does not exist", root.display()),
429        });
430    }
431
432    log::info!(
433        "Building unified graph from source files in {}",
434        root.display()
435    );
436
437    // 7c cancellation boundary 1: pre-build, after arg validation.
438    cancellation.check()?;
439
440    let has_graph_builders = plugins
441        .plugins()
442        .iter()
443        .any(|plugin| plugin.graph_builder().is_some());
444    if !has_graph_builders {
445        return Err(GraphBuilderError::Internal {
446            reason: "No graph builders registered – cannot build code graph".to_string(),
447        });
448    }
449
450    // Create progress tracker for this build
451    let tracker = GraphBuildProgressTracker::new(progress);
452
453    // 1. Find source files
454    let mut files = find_source_files(root, config);
455    sort_files_for_build(root, &mut files);
456
457    // 7c cancellation boundary 2: after file discovery, before thread
458    // pool creation + graph allocation.
459    cancellation.check()?;
460
461    // 2. Create the unified graph
462    let mut graph = CodeGraph::new();
463
464    // 3. Create scoped thread pool for parallel parse
465    let pool = create_thread_pool(config).map_err(|e| GraphBuilderError::Internal {
466        reason: format!("thread pool: {e}"),
467    })?;
468    let effective_threads = pool.current_num_threads();
469    log::info!("Parallel indexing: using {effective_threads} threads");
470
471    // Chunked parallel-parse / parallel-commit pipeline.
472    //
473    // Files are processed in memory-bounded batches (chunks). Each chunk:
474    //   Phase 1: Parse files in parallel (rayon thread pool)
475    //   Phase 2: Count + prefix-sum range assignment
476    //   Phase 3: Parallel commit into disjoint pre-allocated arena/interner ranges
477    //   Phase 4: After ALL chunks — string dedup, global remap, index build, edge bulk insert
478    //
479    // The batch boundary is determined by `staging_memory_limit`: once the
480    // accumulated staging buffer size exceeds the watermark, the current
481    // batch is committed before more files are parsed. This prevents OOM
482    // on large repositories where holding all StagingGraphs simultaneously
483    // would exhaust available RAM.
484    let total_files = files.len();
485    tracker.start_phase(
486        1,
487        "Chunked structural indexing (parse -> range-plan -> semantic commit)",
488        total_files,
489    );
490
491    let (mut succeeded, mut parse_errors, mut skipped, mut timed_out) =
492        (0usize, 0usize, 0usize, 0usize);
493    let mut total_staging_bytes = 0usize;
494    let mut peak_chunk_staging_bytes = 0usize;
495    let mut max_file_staging_bytes = 0usize;
496
497    // Global offsets track running positions across chunks.
498    // For a fresh graph: node arena starts at 0 slots, string interner at 1 (sentinel).
499    let initial_string_offset = graph.strings_mut().alloc_range(0).unwrap_or(1);
500    let mut offsets = GlobalOffsets {
501        node_offset: u32::try_from(graph.nodes().slot_count()).unwrap_or(0),
502        string_offset: initial_string_offset,
503    };
504    // Collect all edges across chunks for Phase 4 bulk insert.
505    let mut all_edges: Vec<Vec<PendingEdge>> = Vec::new();
506    // T3 Cluster B (02_DESIGN §4.3.e Change 1): retain per-file staging
507    // `NodeMetadataStore` across chunks so Phase 4d-prime can merge them
508    // into `CodeGraph::macro_metadata` after Phase 4d. Empty stores are
509    // filtered out at take-time to keep this vector proportional to
510    // actually-stamped files.
511    let mut all_staged_metadata: Vec<(
512        crate::graph::unified::file::id::FileId,
513        crate::graph::unified::storage::metadata::NodeMetadataStore,
514    )> = Vec::new();
515    // Accumulate per-chunk C indirect-call drains (DESIGN §8.2 / U11).
516    // Stays at `default()` (empty) for non-C workspaces — only C plugin
517    // Phase 1 walkers (U10) push into the per-file
518    // `CIndirectStagingPayload`. Applied AFTER Phase 4c-prime cross-file
519    // unification so the post-unification `by_qualified_name` index is in
520    // its final canonical-winners-only state.
521    let mut c_indirect_pending: PhaseCIndirectDrain = PhaseCIndirectDrain::default();
522
523    let chunks = compute_parse_chunks(&files, &pool, plugins, config.staging_memory_limit);
524    for chunk_range in chunks {
525        // 7c cancellation boundary 3: top of each chunk iteration.
526        cancellation.check()?;
527
528        let chunk_files = &files[chunk_range];
529
530        // 7c test hook: observation point fired at the top of each
531        // chunk. Tests that need to flip the cancellation token
532        // between chunks register a callback here. Production builds
533        // compile this call out entirely.
534        #[cfg(any(test, feature = "rebuild-internals"))]
535        testing::fire_after_chunk_hook(cancellation);
536
537        // Phase 1: Parallel parse this chunk
538        let staged_results: Vec<(PathBuf, Result<ParsedFileOutcome>)> = pool.install(|| {
539            chunk_files
540                .par_iter()
541                .map(|path| {
542                    let result = parse_file(path.as_path(), plugins);
543                    tracker.increment_progress();
544                    (path.clone(), result)
545                })
546                .collect()
547        });
548
549        // Separate successful parses from errors/skips
550        let mut chunk_parsed: Vec<(PathBuf, ParsedFile)> = Vec::new();
551        let mut chunk_staging_bytes = 0usize;
552        for (path, result) in staged_results {
553            match result {
554                Ok(ParsedFileOutcome::Parsed(parsed)) => {
555                    let file_bytes = parsed.staging.estimated_byte_size();
556                    total_staging_bytes += file_bytes;
557                    chunk_staging_bytes += file_bytes;
558                    if file_bytes > max_file_staging_bytes {
559                        max_file_staging_bytes = file_bytes;
560                    }
561                    chunk_parsed.push((path, parsed));
562                }
563                Ok(ParsedFileOutcome::Skipped) => skipped += 1,
564                Ok(ParsedFileOutcome::TimedOut {
565                    file,
566                    phase,
567                    timeout_ms,
568                }) => {
569                    timed_out += 1;
570                    log::warn!(
571                        "Timed out building graph for {} during {} after {} ms",
572                        file.display(),
573                        phase,
574                        timeout_ms,
575                    );
576                }
577                Err(e) => {
578                    parse_errors += 1;
579                    log::warn!("Failed to parse {}: {e}", path.display());
580                }
581            }
582        }
583        if chunk_staging_bytes > peak_chunk_staging_bytes {
584            peak_chunk_staging_bytes = chunk_staging_bytes;
585        }
586
587        if chunk_parsed.is_empty() {
588            continue;
589        }
590
591        // Register files in batch
592        let file_info: Vec<_> = chunk_parsed
593            .iter()
594            .map(|(path, parsed)| (path.clone(), Some(parsed.language)))
595            .collect();
596        let file_ids = graph.files_mut().register_batch(&file_info).map_err(|e| {
597            GraphBuilderError::Internal {
598                reason: format!("Failed to register files: {e}"),
599            }
600        })?;
601
602        // Phase 2: Count + range assignment (fast, no progress needed)
603        let staging_refs: Vec<_> = chunk_parsed.iter().map(|(_, p)| &p.staging).collect();
604        let plan = phase2_assign_ranges(&staging_refs, &file_ids, &offsets);
605
606        // Pre-allocate arena and interner ranges for Phase 3.
607        let placeholder = crate::graph::unified::storage::NodeEntry::new(
608            crate::graph::unified::node::NodeKind::Other,
609            crate::graph::unified::string::StringId::new(0),
610            crate::graph::unified::file::FileId::new(0),
611        );
612        graph
613            .nodes_mut()
614            .alloc_range(plan.total_nodes, &placeholder)
615            .map_err(|e| GraphBuilderError::Internal {
616                reason: format!("Failed to alloc node range: {e:?}"),
617            })?;
618        graph
619            .strings_mut()
620            .alloc_range(plan.total_strings)
621            .map_err(|e| GraphBuilderError::Internal {
622                reason: format!("Failed to alloc string range: {e}"),
623            })?;
624
625        // Phase 3: Parallel commit into disjoint pre-allocated ranges.
626        // Use pool.install to respect BuildConfig::num_threads for rayon par_iter.
627        //
628        // `phase3_parallel_commit` is generic over
629        // `G: GraphMutationTarget` as of Task 4 Step 4 Phase 1; here
630        // the inferred `G` is `CodeGraph`, and the helper reaches the
631        // arena + interner via `graph.nodes_and_strings_mut()`
632        // internally.
633        let phase3 = pool.install(|| phase3_parallel_commit(&plan, &staging_refs, &mut graph));
634
635        // Validate written counts match plan. A mismatch indicates a bug in
636        // StagingGraph counting — abort the build to prevent phantom entries
637        // and inconsistent file registry state.
638        let expected_nodes = plan.total_nodes as usize;
639        let expected_strings = plan.total_strings as usize;
640        let expected_edges = usize::try_from(plan.total_edges)
641            .unwrap_or_else(|_| unreachable!("edge count does not fit usize"));
642        if phase3.total_nodes_written != expected_nodes
643            || phase3.total_strings_written != expected_strings
644            || phase3.total_edges_collected != expected_edges
645        {
646            return Err(GraphBuilderError::Internal {
647                reason: format!(
648                    "Phase 3 count mismatch: nodes {}/{expected_nodes}, strings {}/{expected_strings}, edges {}/{expected_edges}. This indicates a bug in StagingGraph counting.",
649                    phase3.total_nodes_written,
650                    phase3.total_strings_written,
651                    phase3.total_edges_collected,
652                ),
653            });
654        }
655
656        // Populate FileSegmentTable from the chunk's file plans.
657        for fp in &plan.file_plans {
658            let start = fp.node_range.start;
659            let count = fp.node_range.end.saturating_sub(start);
660            graph
661                .file_segments_mut()
662                .record_range(fp.file_id, start, count);
663        }
664
665        // Populate FileRegistry::per_file_nodes from Phase 3's
666        // committed-NodeId vectors. This is the Gate 0c iter-2 B2 fix
667        // (pulled base-plan Step 1 forward): each NodeId committed by
668        // parallel-parse is bucketed by its owning FileId so the
669        // bucket-bijection debug invariant at publish time can verify
670        // arena ↔ bucket consistency against real data instead of a
671        // vacuously-empty map.
672        //
673        // Iteration order matches `plan.file_plans`, which is
674        // deterministic across runs. `per_file_node_ids[i]` is the
675        // set of NodeIds committed for `plan.file_plans[i]`; the
676        // registry's `record_node` is O(1) amortised per call.
677        debug_assert_eq!(
678            phase3.per_file_node_ids.len(),
679            plan.file_plans.len(),
680            "phase3 per-file node ID vector length must match plan length"
681        );
682        for (fp, node_ids) in plan.file_plans.iter().zip(phase3.per_file_node_ids.iter()) {
683            for nid in node_ids {
684                graph.files_mut().record_node(fp.file_id, *nid);
685            }
686        }
687
688        succeeded += chunk_parsed.len();
689
690        // Merge confidence metadata from parsed files
691        for (_path, parsed) in &mut chunk_parsed {
692            if let Some(confidence) = parsed.staging.take_confidence() {
693                let language_name = parsed.language.to_string();
694                graph.merge_confidence(&language_name, confidence);
695            }
696        }
697
698        // T3 Cluster B (02_DESIGN §4.3.e Change 1): take each file's
699        // staging `NodeMetadataStore` paired with the `FileId` Phase 3
700        // assigned, and rekey it from staging-local NodeIds to canonical
701        // arena NodeIds before adding to the chunked accumulator.
702        // `chunk_parsed.iter_mut()` and `phase3.per_file_node_ids` both
703        // align with `plan.file_plans` (deterministic file order),
704        // matching the FileSegmentTable + per_file_nodes precedent above.
705        // The rekey step is required because `StagingGraph::add_node`
706        // returns sequential staging-local IDs (`staging.rs:355`) but
707        // `CodeGraph::macro_metadata` is keyed under arena IDs — see
708        // `rekey_staging_metadata_to_arena` for the contract.
709        debug_assert_eq!(
710            plan.file_plans.len(),
711            chunk_parsed.len(),
712            "per-chunk file plan / parsed-file vectors must align so Phase 4d-prime sees \
713             the FileId Phase 3 assigned"
714        );
715        debug_assert_eq!(
716            phase3.per_file_node_ids.len(),
717            plan.file_plans.len(),
718            "Phase 3 per-file node ID vector length must match plan length for metadata rekey"
719        );
720        for ((fp, (_path, parsed)), arena_ids) in plan
721            .file_plans
722            .iter()
723            .zip(chunk_parsed.iter_mut())
724            .zip(phase3.per_file_node_ids.iter())
725        {
726            let metadata = parsed.staging.take_macro_metadata();
727            if metadata.is_empty() {
728                continue;
729            }
730            let rekeyed =
731                super::parallel_commit::rekey_staging_metadata_to_arena(&metadata, arena_ids);
732            if !rekeyed.is_empty() {
733                all_staged_metadata.push((fp.file_id, rekeyed));
734            }
735        }
736
737        // Update global offsets for next chunk
738        offsets.node_offset += plan.total_nodes;
739        offsets.string_offset += plan.total_strings;
740
741        // 7c cancellation boundary 4: after chunk commit, before
742        // accumulating edges for Phase 4.
743        cancellation.check()?;
744
745        // Accumulate edges for Phase 4
746        all_edges.extend(phase3.per_file_edges);
747
748        // Accumulate C indirect-call drain into the workspace-global
749        // pending buffer (DESIGN §8.2 / U11). `None` for chunks with no C
750        // files. Merge is a `Vec::append` for each inner vec — O(n).
751        if let Some(drain) = phase3.c_indirect_drain {
752            c_indirect_pending.merge(drain);
753        }
754    }
755    tracker.complete_phase();
756
757    // 7c test hook: observation point fired after the chunk loop exits
758    // and before Phase 4 finalization. Tests that need to flip the
759    // cancellation token at this boundary register a callback here.
760    #[cfg(any(test, feature = "rebuild-internals"))]
761    testing::fire_before_phase4_hook(cancellation);
762
763    // Phase 4: Post-chunk finalization
764    tracker.start_phase(4, "Finalizing graph", 5);
765
766    // 7c cancellation boundary 5: pre-Phase-4a.
767    cancellation.check()?;
768
769    // Phase 4a: Global string dedup
770    let string_remap = graph.strings_mut().build_dedup_table();
771    if !string_remap.is_empty() {
772        log::debug!(
773            "Phase 4a: dedup removed {} duplicate string(s)",
774            string_remap.len()
775        );
776
777        // Phase 4b: Apply dedup remap to all nodes and pending edges
778        phase4_apply_global_remap(graph.nodes_mut(), &mut all_edges, &string_remap);
779    }
780    tracker.increment_progress(); // 4a+4b done
781
782    // 7c cancellation boundary 6: pre-Phase-4c (rebuild_indices).
783    cancellation.check()?;
784
785    // Phase 4c: Build indices from finalized arena.
786    // Uses build_from_arena() which is O(n log n) — no per-element duplicate check.
787    graph.rebuild_indices();
788    tracker.increment_progress(); // 4c done
789
790    // 7c cancellation boundary 7: pre-Phase-4c-prime
791    // (phase4c_prime_unify_cross_file_nodes).
792    cancellation.check()?;
793
794    // Phase 4c-prime: Cross-file node unification.
795    // Walk the arena for nodes sharing a qualified name and a call-compatible kind,
796    // merge duplicates into a single canonical node, and rewrite PendingEdge targets.
797    // Must run AFTER rebuild_indices (uses by_qualified_name) and BEFORE Phase 4d
798    // (operates on PendingEdge, not committed DeltaEdge).
799    let (unification_stats, unification_remap) =
800        phase4c_prime_unify_cross_file_nodes(&mut graph, &mut all_edges);
801    if unification_stats.nodes_merged > 0 {
802        log::info!(
803            "Phase 4c-prime: unified {} duplicate nodes ({} candidate groups examined, \
804             {} edges rewritten, {} ms)",
805            unification_stats.nodes_merged,
806            unification_stats.candidate_pairs_examined,
807            unification_stats.edges_rewritten,
808            unification_stats.elapsed_ms,
809        );
810        // 7c cancellation boundary 7b: post-4c-prime, before the
811        // optional second rebuild_indices. Codex iter-0 MAJOR: without
812        // this check, a cancellation observed after the unification
813        // walk still pays another O(n log n) index rebuild.
814        cancellation.check()?;
815        // Rebuild indices after tombstoning loser nodes
816        graph.rebuild_indices();
817    }
818    tracker.increment_progress(); // 4c-prime done
819
820    // Phase 4c-prime-post (U11 / DESIGN §8.3): apply deferred C
821    // indirect-call side-tables now that the qualified-name index reflects
822    // post-unification canonical winners.
823    //
824    // Runs AFTER Phase 4c-prime (the indices are coherent — losers are
825    // tombstoned, name/qualified_name index buckets contain only canonical
826    // winners; verified at `concurrent/graph.rs:1948-1950, 2076`) and
827    // BEFORE Phase 4d (bulk edge insert) — Phase 4d does not touch the
828    // `c_indirect_tables` slot or the metadata store, so this ordering
829    // keeps both Phase 4c-prime invariants (indices coherent) and Phase 4d
830    // invariants (edge store untouched until bulk insert) intact.
831    //
832    // Per the U11 plan iter-2 correction (TRACEABILITY:IMP:c-icall-precision-011),
833    // resolution goes through `graph.indices().by_qualified_name(str_id)`
834    // (with a `by_name` fallback for languages whose canonical qualified
835    // name equals the semantic name and therefore leaves
836    // `NodeEntry::qualified_name` unset — e.g. C) — NOT through Phase
837    // 4c-prime's internal `NodeRemapTable`, which is `pub(crate)` and
838    // consumed inside `phase4c_prime_unify_cross_file_nodes` (see
839    // `parallel_commit.rs:884-885`).
840    if !c_indirect_pending.is_empty() {
841        let stats = apply_deferred_address_taken_marks(&mut graph, c_indirect_pending);
842        log::info!(
843            target: "sqry_core::build",
844            "Phase 4c-prime-post: applied C indirect side-tables — {} address-taken marks, \
845             {} struct field signatures, {} bindings (resolved {} entries), \
846             {} pending callsites, {} local-scope indices, {} unresolved names",
847            stats.address_taken_marks_applied,
848            stats.struct_field_signatures_inserted,
849            stats.binding_entries_inserted,
850            stats.bindings_resolved,
851            stats.indirect_callsites_inserted,
852            stats.local_scope_indices_inserted,
853            stats.unresolved_names,
854        );
855    }
856
857    // 7c cancellation boundary 8: pre-Phase-4d (bulk edge insert).
858    cancellation.check()?;
859
860    // Phase 4d: Bulk insert edges via deterministic DeltaEdge conversion.
861    // Wraps the pure pending_edges_to_delta + add_edges_bulk_ordered pair
862    // behind phase4d_bulk_insert_edges so the incremental rebuild path
863    // (Task 4 Step 4 Phase 3) can reuse the same helper against a
864    // RebuildGraph. The helper carries forward the edge store's current
865    // seq counter so non-empty graphs advance deterministically.
866    let _final_edge_seq = phase4d_bulk_insert_edges(&mut graph, &all_edges);
867    tracker.increment_progress(); // 4d done
868
869    // Phase 4d-prime (T3 Cluster B, 02_DESIGN §4.3.e Change 4): propagate
870    // per-file staging `NodeMetadataStore` into `CodeGraph::macro_metadata`
871    // using the Phase 4c-prime `NodeRemapTable` to drop loser-keyed
872    // entries first. Runs after Phase 4d (remap is final) and before
873    // Phase 4e (binding-plane synthesis observes the merged metadata).
874    // The return bool is informational on the full-build plane — no
875    // production caller consumes it.
876    let _staged_metadata_merged = super::parallel_commit::phase4d_prime_propagate_staging_metadata(
877        &mut graph,
878        std::mem::take(&mut all_staged_metadata),
879        &unification_remap,
880    );
881    tracker.increment_progress(); // 4d-prime done
882    tracker.complete_phase();
883
884    log::info!(
885        "Parallel indexing complete: {succeeded} committed, {skipped} skipped, \
886         {timed_out} timed out, {parse_errors} parse errors, \
887         ~{} MB total staged, ~{} MB peak chunk (max single file: ~{} KB)",
888        total_staging_bytes / (1024 * 1024),
889        peak_chunk_staging_bytes / (1024 * 1024),
890        max_file_staging_bytes / 1024,
891    );
892
893    let attempted = succeeded + parse_errors + timed_out;
894
895    if attempted == 0 {
896        log::warn!(
897            "No eligible source files found for graph build in {}",
898            root.display()
899        );
900    }
901
902    if attempted > 0 && succeeded == 0 {
903        return Err(GraphBuilderError::Internal {
904            reason: "All graph builds failed".to_string(),
905        });
906    }
907
908    // 7c cancellation boundary 9: pre-Phase-4e (binding plane).
909    cancellation.check()?;
910
911    // ------------------------------------------------------------------
912    // Phase 4e — Binding plane derivation.
913    //
914    // Runs between Phase 4d (bulk edge insert) and Pass 5 (cross-language
915    // linking). Consumes only the language-local edge kinds Contains,
916    // Defines, Imports, Exports. Populates CodeGraph::scope_arena (P2U03),
917    // CodeGraph::alias_table (P2U04), CodeGraph::shadow_table (P2U05), and
918    // CodeGraph::scope_provenance_store (P2U11) in one pass.
919    // ------------------------------------------------------------------
920    tracker.start_phase(5, "Binding plane derivation", 1);
921    let binding_stats = super::phase4e_binding::derive_binding_plane(&mut graph);
922    log::info!(
923        target: "sqry_core::build",
924        "Phase 4e: {} scopes, {} aliases, {} shadows derived",
925        binding_stats.scopes,
926        binding_stats.aliases,
927        binding_stats.shadows,
928    );
929    tracker.increment_progress();
930    tracker.complete_phase();
931
932    // ------------------------------------------------------------------
933    // Pass 5b — C indirect-call resolution (Phase A, U12).
934    //
935    // Runs AFTER Phase 4e (binding-plane derivation) and BEFORE Pass 5
936    // (cross-language linking). It also runs before the Go T1 method-set
937    // satisfaction pass so Go sees the same pre-Pass-5 graph plus any C
938    // indirect-call precision edges. Consumes CodeGraph::c_indirect_tables
939    // populated by U10/U11 and rewrites synthetic indirect-call Calls edges
940    // into precise binding-plane / type-match candidates.
941    //
942    // Deliberately placed BEFORE `fire_before_pass5_hook` so existing
943    // test hooks observe a graph that already has resolved indirect
944    // calls and Go method-set edges — preserving the hook's "before
945    // cross-language linking" semantic. See IMPL_PLAN §"U12 —
946    // pass5b_c_indirect_resolve".
947    // ------------------------------------------------------------------
948    cancellation.check()?;
949    tracker.start_phase(6, "C indirect-call resolution", 1);
950    let c_indirect_stats = super::pass5b_c_indirect::resolve_c_indirect_calls(&mut graph);
951    log::info!(
952        target: "sqry_core::build",
953        "Pass 5b: binding={}, typematch={}, cap_exceeded={}, fallback={}",
954        c_indirect_stats.binding_resolved,
955        c_indirect_stats.typematch_resolved,
956        c_indirect_stats.cap_exceeded,
957        c_indirect_stats.stub_fallback,
958    );
959    tracker.increment_progress();
960    tracker.complete_phase();
961
962    // Go T1 method-set satisfaction pass (Cluster E1 wiring).
963    //
964    // Runs between Phase A pass 5b (C indirect-call resolution) and Pass
965    // 5 (cross-language linking). Full-build plane: `changed_files =
966    // None` because no prior pass-owned state exists; the pass walks the
967    // entire graph and emits T1.2 promoted methods, T1.1 implicit
968    // `Implements`, and T1.3 function-signature `Implements`. The
969    // tombstone-before-emit step (02_DESIGN §3.6) is skipped on this plane.
970    //
971    // No-op on workspaces with zero Go nodes — the pass body
972    // short-circuits via the `embeddings.is_empty()` check before
973    // touching the graph (see `pass_go_method_set.rs` step 1).
974    // ------------------------------------------------------------------
975    cancellation.check()?;
976    tracker.start_phase(7, "Go method-set satisfaction", 1);
977    let go_method_set_stats =
978        super::pass_go_method_set::run_go_method_set_satisfaction(&mut graph, None);
979    log::info!(
980        target: "sqry_core::build",
981        "Go method-set: {} value-form Implements, {} pointer-form Implements, \
982         {} signature Implements, {} promoted methods, {} shadow Calls/References, \
983         elapsed_ms={}",
984        go_method_set_stats.implements_edges_value,
985        go_method_set_stats.implements_edges_pointer,
986        go_method_set_stats.signature_implements_edges,
987        go_method_set_stats.promoted_method_nodes,
988        go_method_set_stats.promoted_back_reference_edges,
989        go_method_set_stats.elapsed_ms,
990    );
991    tracker.increment_progress();
992    tracker.complete_phase();
993
994    // 7c test hook: observation point fired before Pass 5. Tests that
995    // need to flip the cancellation token at this boundary register a
996    // callback here (fires BEFORE the check below so a hook that flips
997    // the token is observed by the subsequent check).
998    #[cfg(any(test, feature = "rebuild-internals"))]
999    testing::fire_before_pass5_hook(cancellation);
1000
1001    // 7c cancellation boundary 10: pre-Pass-5 (cross-language linking).
1002    cancellation.check()?;
1003
1004    // Pass 5: Cross-language linking (FFI declarations → C/C++ functions, HTTP requests → endpoints)
1005    tracker.start_phase(8, "Cross-language linking", 1);
1006    let cross_language_stats = super::pass5_cross_language::link_cross_language_edges(&mut graph);
1007    if cross_language_stats.total_edges_created > 0 {
1008        log::info!(
1009            "Pass 5: {} cross-language edges created ({} FFI, {} HTTP)",
1010            cross_language_stats.total_edges_created,
1011            cross_language_stats.ffi_edges_created,
1012            cross_language_stats.http_endpoints_matched,
1013        );
1014    }
1015    tracker.increment_progress(); // pass 5 done
1016    tracker.complete_phase();
1017
1018    log::info!("Built unified graph with {} nodes", graph.node_count());
1019
1020    // Publish-boundary invariants (A2 §F / Task 4 Gate 0d).
1021    //
1022    // This is the canonical "full rebuild end" call site named in plan
1023    // §F.3. Full rebuilds have no tombstoned NodeIds to carry forward,
1024    // so the §F.2 residue check does not run here — per plan §H step
1025    // 14, the residue check has EXACTLY ONE call site
1026    // (`RebuildGraph::finalize` step 14) against the drained tombstone
1027    // set. Full rebuilds run the §F.1 bucket bijection only, via
1028    // [`crate::graph::unified::publish::assert_publish_bijection`]:
1029    // every parallel-commit chunk populates per-file buckets via
1030    // `FileRegistry::record_node`, and the bijection proves no file
1031    // ended up with a dead / duplicate / misfiled / missing node.
1032    //
1033    // In release builds the helper is a no-op; see `publish.rs`.
1034    super::super::publish::assert_publish_bijection(&graph);
1035
1036    Ok((graph, effective_threads))
1037}
1038
1039/// Statistics returned by [`apply_deferred_address_taken_marks`].
1040///
1041/// Each counter is incremented exactly once per drained payload entry,
1042/// matching the per-vec input cardinalities in
1043/// [`PhaseCIndirectDrain`]. `unresolved_names` counts every staging name
1044/// for which neither `by_qualified_name` nor `by_name` produced a
1045/// CALL_COMPATIBLE_KINDS-filtered hit — typically address-taken names
1046/// referring to functions defined in files the plugin couldn't reach (a
1047/// forward declaration `void foo(void);` with no matching definition
1048/// anywhere in the workspace) or to the `cb_alpha` pattern when the
1049/// matching definition lives outside the indexed source tree.
1050#[derive(Debug, Default, Clone)]
1051pub(crate) struct DeferredCIndirectStats {
1052    /// Number of `mark_address_taken` calls successfully applied. Counts
1053    /// EACH match — a name resolving to N nodes contributes N to this
1054    /// counter, per SPEC §3.1.2 (mark every match on ambiguity).
1055    pub address_taken_marks_applied: usize,
1056    /// Number of `(struct_tag, field_name) → signature` entries inserted
1057    /// into `CIndirectSideTables::struct_field_fnptr`.
1058    pub struct_field_signatures_inserted: usize,
1059    /// Number of `BindingEntry` values inserted into
1060    /// `CIndirectSideTables::bindings_by_field` across all keys.
1061    pub binding_entries_inserted: usize,
1062    /// Number of bindings whose `instance_name` AND `target_fn_name` both
1063    /// resolved to a canonical `NodeId`. Bindings that fail to resolve
1064    /// either name are dropped (not inserted) because both `NodeIds` are
1065    /// required to construct a valid `BindingEntry`.
1066    pub bindings_resolved: usize,
1067    /// Number of `IndirectCallsite` entries pushed onto
1068    /// `CIndirectSideTables::pending_callsites`. A callsite whose
1069    /// `caller_qualified_name` fails to resolve is dropped — U12's
1070    /// resolver requires a real caller `NodeId` to rewrite the synthetic
1071    /// `Calls` edge.
1072    pub indirect_callsites_inserted: usize,
1073    /// Number of `(file_id, LocalScopeIndex)` pairs inserted. Last write
1074    /// wins on duplicate keys (the C plugin guarantees per-file
1075    /// uniqueness, but defensive duplicate handling lives at the `HashMap`
1076    /// layer).
1077    pub local_scope_indices_inserted: usize,
1078    /// Names that resolved to zero `CALL_COMPATIBLE_KINDS` nodes — see the
1079    /// struct-level doc for typical causes.
1080    pub unresolved_names: usize,
1081}
1082
1083type DeferredCNodeCache = std::collections::HashMap<String, Vec<NodeId>>;
1084type StructFieldSignature = ((StringId, StringId), StringId);
1085type BindingSideTableEntry = ((StringId, StringId), BindingEntry);
1086
1087fn apply_local_scope_indices(
1088    graph: &mut CodeGraph,
1089    local_scope_indices: Vec<(
1090        crate::graph::unified::file::FileId,
1091        crate::graph::unified::storage::c_indirect::LocalScopeIndex,
1092    )>,
1093    stats: &mut DeferredCIndirectStats,
1094) {
1095    if local_scope_indices.is_empty() {
1096        return;
1097    }
1098
1099    let tables = graph
1100        .c_indirect_tables_mut()
1101        .get_or_insert_with(CIndirectSideTables::new);
1102    for (file_id, scope_index) in local_scope_indices {
1103        // Defensive: in-spec, the C plugin pushes exactly one scope index per
1104        // file. Last-write-wins on duplicate keys preserves the most recently
1105        // staged index, matching the U09 wire-shape contract.
1106        tables.local_scope_indices.insert(file_id, scope_index);
1107        stats.local_scope_indices_inserted += 1;
1108    }
1109}
1110
1111fn resolve_c_callable_names<'a, I>(
1112    graph: &mut CodeGraph,
1113    names: I,
1114    stats: &mut DeferredCIndirectStats,
1115) -> DeferredCNodeCache
1116where
1117    I: Iterator<Item = &'a str>,
1118{
1119    resolve_c_names(graph, names, stats, |kind| {
1120        super::helper::CALL_COMPATIBLE_KINDS.contains(kind)
1121    })
1122}
1123
1124fn resolve_c_variable_names<'a, I>(
1125    graph: &mut CodeGraph,
1126    names: I,
1127    stats: &mut DeferredCIndirectStats,
1128) -> DeferredCNodeCache
1129where
1130    I: Iterator<Item = &'a str>,
1131{
1132    resolve_c_names(graph, names, stats, |kind| {
1133        matches!(kind, NodeKind::Variable)
1134    })
1135}
1136
1137fn resolve_c_names<'a, I, F>(
1138    graph: &mut CodeGraph,
1139    names: I,
1140    stats: &mut DeferredCIndirectStats,
1141    is_eligible_kind: F,
1142) -> DeferredCNodeCache
1143where
1144    I: Iterator<Item = &'a str>,
1145    F: Fn(&NodeKind) -> bool,
1146{
1147    let mut resolved = DeferredCNodeCache::new();
1148    for name in names {
1149        if resolved.contains_key(name) {
1150            continue;
1151        }
1152        let str_id = match graph.strings_mut().intern(name) {
1153            Ok(id) => id,
1154            Err(err) => {
1155                log::warn!(
1156                    "Phase 4c-prime-post: failed to intern C indirect name {name:?}: {err:?} - \
1157                     skipping every entry referencing this name"
1158                );
1159                resolved.insert(name.to_owned(), Vec::new());
1160                continue;
1161            }
1162        };
1163
1164        let matches = collect_c_nodes_by_string_id(graph, str_id, &is_eligible_kind);
1165        if matches.is_empty() {
1166            stats.unresolved_names += 1;
1167        }
1168        resolved.insert(name.to_owned(), matches);
1169    }
1170    resolved
1171}
1172
1173fn collect_c_nodes_by_string_id<F>(
1174    graph: &CodeGraph,
1175    str_id: StringId,
1176    is_eligible_kind: &F,
1177) -> Vec<NodeId>
1178where
1179    F: Fn(&NodeKind) -> bool,
1180{
1181    let by_qn = graph.indices().by_qualified_name(str_id).to_vec();
1182    let by_nm = graph.indices().by_name(str_id).to_vec();
1183    let mut seen: std::collections::HashSet<(u32, u64)> = std::collections::HashSet::new();
1184    let mut matches = Vec::new();
1185    let arena = graph.nodes();
1186    let files = graph.files();
1187    for nid in by_qn.into_iter().chain(by_nm) {
1188        if !seen.insert((nid.index(), nid.generation())) {
1189            continue;
1190        }
1191        let Some(entry) = arena.get(nid) else {
1192            continue;
1193        };
1194        if !is_eligible_kind(&entry.kind) {
1195            continue;
1196        }
1197        if files.language_for_file(entry.file) != Some(crate::graph::Language::C) {
1198            continue;
1199        }
1200        matches.push(nid);
1201    }
1202    matches
1203}
1204
1205fn apply_address_taken_marks(
1206    graph: &mut CodeGraph,
1207    entries: &[crate::graph::unified::build::parallel_commit::DeferredAddressTakenEntry],
1208    callable_nodes: &DeferredCNodeCache,
1209    stats: &mut DeferredCIndirectStats,
1210) {
1211    for entry in entries {
1212        if let Some(node_ids) = callable_nodes.get(&entry.function_qualified_name) {
1213            for &nid in node_ids {
1214                graph.macro_metadata_mut().mark_address_taken(nid);
1215                stats.address_taken_marks_applied += 1;
1216            }
1217        }
1218    }
1219}
1220
1221fn insert_struct_field_signatures(
1222    graph: &mut CodeGraph,
1223    signatures: &[(String, String, String)],
1224    stats: &mut DeferredCIndirectStats,
1225) {
1226    if signatures.is_empty() {
1227        return;
1228    }
1229
1230    let mut interned: Vec<StructFieldSignature> = Vec::with_capacity(signatures.len());
1231    let mut intern_failures = 0usize;
1232    for (struct_tag, field_name, signature) in signatures {
1233        let strings = graph.strings_mut();
1234        let st = strings.intern(struct_tag);
1235        let fn_ = strings.intern(field_name);
1236        let sig = strings.intern(signature);
1237        match (st, fn_, sig) {
1238            (Ok(st), Ok(fn_), Ok(sig)) => interned.push(((st, fn_), sig)),
1239            _ => intern_failures += 1,
1240        }
1241    }
1242    if intern_failures > 0 {
1243        log::warn!(
1244            "Phase 4c-prime-post: {intern_failures} struct-field-signature triple(s) \
1245             failed to intern (capacity exhaustion?) - dropped"
1246        );
1247    }
1248    let tables = graph
1249        .c_indirect_tables_mut()
1250        .get_or_insert_with(CIndirectSideTables::new);
1251    for (key, value) in interned {
1252        tables.struct_field_fnptr.insert(key, value);
1253        stats.struct_field_signatures_inserted += 1;
1254    }
1255}
1256
1257fn insert_c_indirect_bindings(
1258    graph: &mut CodeGraph,
1259    bindings: &[(
1260        crate::graph::unified::file::FileId,
1261        super::staging::PendingBinding,
1262    )],
1263    instance_nodes: &DeferredCNodeCache,
1264    callable_nodes: &DeferredCNodeCache,
1265    stats: &mut DeferredCIndirectStats,
1266) {
1267    if bindings.is_empty() {
1268        return;
1269    }
1270
1271    let mut interned_bindings: Vec<BindingSideTableEntry> = Vec::with_capacity(bindings.len());
1272    let mut intern_failures = 0usize;
1273    for (_file_id, binding) in bindings {
1274        let strings = graph.strings_mut();
1275        let Ok(st_id) = strings.intern(&binding.struct_tag) else {
1276            intern_failures += 1;
1277            continue;
1278        };
1279        let Ok(fn_id) = strings.intern(&binding.field_name) else {
1280            intern_failures += 1;
1281            continue;
1282        };
1283
1284        let instances = instance_nodes
1285            .get(&binding.instance_name)
1286            .map_or(&[] as &[NodeId], Vec::as_slice);
1287        let targets = callable_nodes
1288            .get(&binding.target_fn_name)
1289            .map_or(&[] as &[NodeId], Vec::as_slice);
1290        if instances.is_empty() || targets.is_empty() {
1291            continue;
1292        }
1293        stats.bindings_resolved += 1;
1294
1295        for &instance_node in instances {
1296            for &target_fn in targets {
1297                interned_bindings.push((
1298                    (st_id, fn_id),
1299                    BindingEntry {
1300                        instance_node,
1301                        target_fn,
1302                        site_kind: binding.site_kind,
1303                    },
1304                ));
1305            }
1306        }
1307    }
1308    if intern_failures > 0 {
1309        log::warn!(
1310            "Phase 4c-prime-post: {intern_failures} binding key(s) failed to intern \
1311             (capacity exhaustion?) - dropped"
1312        );
1313    }
1314    let tables = graph
1315        .c_indirect_tables_mut()
1316        .get_or_insert_with(CIndirectSideTables::new);
1317    for (key, entry) in interned_bindings {
1318        tables.bindings_by_field.entry(key).or_default().push(entry);
1319        stats.binding_entries_inserted += 1;
1320    }
1321}
1322
1323fn insert_indirect_callsites(
1324    graph: &mut CodeGraph,
1325    callsites: &[(
1326        crate::graph::unified::file::FileId,
1327        super::staging::PendingIndirectCallsite,
1328    )],
1329    callable_nodes: &DeferredCNodeCache,
1330    stats: &mut DeferredCIndirectStats,
1331) {
1332    if callsites.is_empty() {
1333        return;
1334    }
1335
1336    let mut callsites_to_push = Vec::with_capacity(callsites.len());
1337    for (file_id, callsite) in callsites {
1338        let Some(callers) = callable_nodes.get(&callsite.caller_qualified_name) else {
1339            continue;
1340        };
1341        for &caller in callers {
1342            callsites_to_push.push(IndirectCallsite {
1343                caller,
1344                file_id: *file_id,
1345                use_span: callsite.use_span,
1346                shape: callsite.shape.clone(),
1347                argument_count: callsite.argument_count,
1348                is_async: callsite.is_async,
1349            });
1350        }
1351    }
1352    if callsites_to_push.is_empty() {
1353        return;
1354    }
1355
1356    let tables = graph
1357        .c_indirect_tables_mut()
1358        .get_or_insert_with(CIndirectSideTables::new);
1359    for callsite in callsites_to_push {
1360        tables.pending_callsites.push(callsite);
1361        stats.indirect_callsites_inserted += 1;
1362    }
1363}
1364
1365/// Apply deferred C indirect-call side-tables to the post-unification graph.
1366///
1367/// Runs once, sequentially, immediately after `phase4c_prime_unify_cross_file_nodes`
1368/// returns (and after the optional second `rebuild_indices` call that
1369/// keeps the by-name / by-qualified-name buckets coherent post-merge).
1370/// Drives U11 from the U10 staging payload through to the final
1371/// persisted [`CIndirectSideTables`] + [`NodeFlags::ADDRESS_TAKEN`]
1372/// state.
1373///
1374/// # Resolution algorithm (per DESIGN §8.2 / §8.3, iter-2-corrected)
1375///
1376/// For each entry that names a function by qualified name (`address_taken_names`,
1377/// `bindings.instance_name`, `bindings.target_fn_name`,
1378/// `indirect_callsites[].caller_qualified_name`):
1379///
1380/// 1. Intern the name through `graph.strings_mut().intern(...)` — this
1381///    interns into the **post-Phase-4a-dedup, post-Phase-4c** canonical
1382///    interner, so the resulting `StringId` is the canonical id every
1383///    `AuxiliaryIndices` bucket is keyed on.
1384/// 2. Look up matches via `graph.indices().by_qualified_name(str_id)`
1385///    first (handles languages whose canonical qualified name differs
1386///    from the semantic name — e.g. Rust `foo::bar::baz`), and union with
1387///    `by_name(str_id)` (handles languages whose canonical qualified
1388///    name equals the semantic name and therefore left
1389///    `NodeEntry::qualified_name = None` — the entire C plugin route,
1390///    where `cb_alpha` is its own qualified name; see
1391///    `sqry-lang-c/src/relations/graph_builder.rs:230`).
1392/// 3. Filter the union to `CALL_COMPATIBLE_KINDS` (`Function`, `Method`,
1393///    `Macro`, `Constant`, `LambdaTarget`) — the same kind-set Phase
1394///    4c-prime uses for unification. Deduplicate (a node may appear in
1395///    both buckets when `qualified_name == name`). Additionally
1396///    constrain the candidate set to nodes whose owning file's
1397///    language is `Language::C` (via
1398///    `graph.files().language_for_file(entry.file)`). This is required
1399///    by SPEC §3.1.2 line 163 ("Every C `NodeKind::Function`...") and
1400///    DESIGN §8.2 lines 1239-1241 — the `by_name` fallback is
1401///    workspace-global, so without this filter a Rust `fn cb_alpha`,
1402///    Python `def cb_alpha`, etc. sharing a bare name with a C symbol
1403///    would be erroneously marked address-taken (or inserted into
1404///    `bindings_by_field` / `pending_callsites`) by C-only semantics.
1405/// 4. For `address_taken_names`: call `mark_address_taken` on every
1406///    matched `NodeId` (SPEC §3.1.2 — "is this function ever
1407///    address-taken?" — every plausible target gets flagged).
1408///
1409/// Tombstoned losers are absent from both `by_name` and `by_qualified_name`
1410/// post-Phase-4c-prime — `merge_node_into` clears their `name/qualified_name`
1411/// fields to `StringId::INVALID` / `None`, and `build_from_arena`
1412/// (re-run from `rebuild_indices`) skips them. The marks therefore land
1413/// only on canonical winners.
1414///
1415/// Tier-3 metadata revision counter bumps automatically via the
1416/// `mark_address_taken` mutation (see DESIGN §9.2 / CLAUDE.md "Derived
1417/// Analysis DB").
1418pub(crate) fn apply_deferred_address_taken_marks(
1419    graph: &mut CodeGraph,
1420    pending: PhaseCIndirectDrain,
1421) -> DeferredCIndirectStats {
1422    let mut stats = DeferredCIndirectStats::default();
1423    let PhaseCIndirectDrain {
1424        address_taken_names,
1425        struct_field_signatures,
1426        bindings,
1427        indirect_callsites,
1428        local_scope_indices,
1429    } = pending;
1430
1431    // Local-scope indices need no name resolution. Drain them first so the
1432    // c_indirect_tables slot is materialised before string interning begins.
1433    apply_local_scope_indices(graph, local_scope_indices, &mut stats);
1434
1435    // Resolve every distinct name to its canonical NodeId set ONCE,
1436    // cached in a HashMap. The drain may contain duplicates (the same
1437    // address-taken name across multiple sites/files), and bindings'
1438    // target_fn_name + indirect_callsites' caller_qualified_name share
1439    // the same callable-only lookup surface as address_taken_names.
1440    // Caching avoids re-walking the indices for every duplicate.
1441    //
1442    // Resolution = intern name → by_qualified_name ∪ by_name → filter →
1443    // dedupe. Holds the resolved NodeId vector (possibly empty for
1444    // unresolved names).
1445    //
1446    // Two caches with DIFFERENT kind filters:
1447    //
1448    // * `name_to_node_ids`: filtered to `CALL_COMPATIBLE_KINDS`
1449    //   (Function/Method/Macro/Constant/LambdaTarget). Used for any
1450    //   name that must resolve to a callable target —
1451    //   `address_taken_names`, `bindings.target_fn_name`,
1452    //   `indirect_callsites.caller_qualified_name`.
1453    //
1454    // * `instance_to_node_ids`: filtered to `Variable` only. Used for
1455    //   `bindings.instance_name` — the ops-table variable that holds
1456    //   the binding. Without this second filter the instance lookup
1457    //   would always fail (Variable ∉ CALL_COMPATIBLE_KINDS), dropping
1458    //   every legal binding emitted by the U10 designated-initializer
1459    //   path (graph_builder.rs:971-982). The Variable filter is the
1460    //   correct kind constraint per DESIGN §7.1 ("the ops-table
1461    //   variable that holds this binding"); accepting other kinds
1462    //   (e.g. a Type sharing the name) would conflate them with the
1463    //   storage location.
1464    //
1465    // Both caches ALSO filter every candidate to nodes whose owning
1466    // file's language is `Language::C`. This enforces the C-scoped
1467    // contract from SPEC §3.1.2 line 163 ("Every C
1468    // `NodeKind::Function`...") and the DESIGN §8.2 mandate that the
1469    // deferred payload carry `(function_qualified_name, file_id)`. The
1470    // `by_name` workspace-global index (a single `BTreeMap<StringId,
1471    // Vec<NodeId>>` populated by every live node — see
1472    // `sqry-core/src/graph/unified/storage/indices.rs:70-75`) would
1473    // otherwise let a same-named Rust / Python / Go function
1474    // (`fn cb_alpha`, `def cb_alpha`) be marked address-taken or be
1475    // inserted into `bindings_by_field` / `pending_callsites` under
1476    // C-only semantics. The candidate-language filter (not the drain
1477    // entry's origin `file_id`) is the right constraint because
1478    // cross-TU address-takes are legal: `cb_alpha` may be defined in
1479    // `a.c` and have its address taken in `b.c`. Both candidate
1480    // definitions must be eligible so long as they originate from C
1481    // source.
1482    let callable_names_iter = address_taken_names
1483        .iter()
1484        .map(|e| e.function_qualified_name.as_str())
1485        .chain(bindings.iter().map(|(_, b)| b.target_fn_name.as_str()))
1486        .chain(
1487            indirect_callsites
1488                .iter()
1489                .map(|(_, cs)| cs.caller_qualified_name.as_str()),
1490        );
1491    let name_to_node_ids = resolve_c_callable_names(graph, callable_names_iter, &mut stats);
1492
1493    // Second cache: bindings' `instance_name` resolves to a Variable
1494    // NodeId. Same intern → by_qualified_name ∪ by_name → dedupe
1495    // pipeline, different kind filter (Variable only). Built lazily —
1496    // skipped entirely when `pending.bindings` is empty (the common
1497    // non-C / non-vtable case).
1498    let instance_to_node_ids = resolve_c_variable_names(
1499        graph,
1500        bindings
1501            .iter()
1502            .map(|(_, binding)| binding.instance_name.as_str()),
1503        &mut stats,
1504    );
1505
1506    // Apply address-taken marks (SPEC §3.1.2: mark every match).
1507    //
1508    // The cache `name_to_node_ids` already enforces the C-language
1509    // scope at lookup time — entries here are guaranteed to be C-only
1510    // canonical NodeIds. The drain entry's origin `file_id` is not
1511    // consulted directly during mark application because cross-TU
1512    // address-takes are legal (a `cb_alpha` defined in `a.c` may have
1513    // its address taken in `b.c`); the candidate's own owning-file
1514    // language is the correct filter.
1515    apply_address_taken_marks(graph, &address_taken_names, &name_to_node_ids, &mut stats);
1516
1517    // Intern struct-field-signature triples and populate
1518    // `struct_field_fnptr`. Each leg interns through the same canonical
1519    // graph interner so downstream consumers can compare via single
1520    // `StringId::eq` (DESIGN §3.1 rationale: "amortises across functions
1521    // sharing signatures").
1522    insert_struct_field_signatures(graph, &struct_field_signatures, &mut stats);
1523
1524    // Resolve bindings: both legs (`instance_name`, `target_fn_name`) must
1525    // resolve to a canonical NodeId. On either-side miss, the binding is
1526    // dropped — without both NodeIds the entry would be uninterpretable
1527    // by U12's resolver.
1528    //
1529    // For ambiguity on either leg (multiple canonical winners with the
1530    // same QN), per DESIGN §7.1 we emit ONE BindingEntry per
1531    // (instance_node, target_fn) pair — the Cartesian product. This
1532    // preserves SPEC §3.1.2 semantics ("is this function ever
1533    // address-taken?" — every plausible binding contributes).
1534    insert_c_indirect_bindings(
1535        graph,
1536        &bindings,
1537        &instance_to_node_ids,
1538        &name_to_node_ids,
1539        &mut stats,
1540    );
1541
1542    // Resolve indirect callsites. Caller name MUST resolve — without a
1543    // caller NodeId, U12's resolver cannot retarget the synthetic Calls
1544    // edge. Drop callsites whose caller name doesn't resolve.
1545    //
1546    // Ambiguity: if the caller name resolves to multiple canonical
1547    // winners (rare; would imply the C plugin staged a callsite from a
1548    // function whose qualified name shadowed another), emit one
1549    // `IndirectCallsite` per matched NodeId so every plausible caller
1550    // gets its synthetic edge retargeted.
1551    insert_indirect_callsites(graph, &indirect_callsites, &name_to_node_ids, &mut stats);
1552
1553    stats
1554}
1555
1556/// Build unified graph, persist snapshot + manifest, and run analysis pipeline.
1557///
1558/// Convenience wrapper that uses a no-op progress reporter.
1559/// See [`build_and_persist_graph_with_progress`] for full documentation.
1560///
1561/// # Errors
1562///
1563/// Returns an error if graph building, persistence, or analysis fails.
1564pub fn build_and_persist_graph(
1565    root: &Path,
1566    plugins: &PluginManager,
1567    config: &BuildConfig,
1568    build_command: &str,
1569) -> Result<(CodeGraph, BuildResult)> {
1570    build_and_persist_graph_with_progress(
1571        root,
1572        plugins,
1573        config,
1574        build_command,
1575        inferred_plugin_selection_manifest(plugins),
1576        no_op_reporter(),
1577    )
1578}
1579
1580/// Infer a persisted plugin-selection manifest from the active plugin manager.
1581///
1582/// This is used by durable graph persistence callers that do not have CLI
1583/// plugin-selection arguments available but still need the manifest to record
1584/// which built-in plugins participated in the build.
1585#[must_use]
1586pub fn inferred_plugin_selection_manifest(
1587    plugins: &PluginManager,
1588) -> Option<crate::graph::unified::persistence::PluginSelectionManifest> {
1589    let active_plugin_ids = plugins
1590        .plugins()
1591        .iter()
1592        .map(|plugin| plugin.metadata().id.to_string())
1593        .collect::<Vec<_>>();
1594    if active_plugin_ids.is_empty() {
1595        return None;
1596    }
1597
1598    Some(
1599        crate::graph::unified::persistence::PluginSelectionManifest {
1600            active_plugin_ids,
1601            high_cost_mode: None,
1602        },
1603    )
1604}
1605
1606/// Inputs for the canonical durable graph persistence transaction.
1607pub struct DurableGraphPersistenceRequest<'a> {
1608    /// Root directory whose `.sqry/` artifacts are being committed.
1609    pub root: &'a Path,
1610    /// Plugin manager used for language/file-count metadata.
1611    pub plugins: &'a PluginManager,
1612    /// Build configuration used for analysis budgets and thread-pool sizing.
1613    pub config: &'a BuildConfig,
1614    /// Provenance string written into the manifest.
1615    pub build_command: &'a str,
1616    /// Optional plugin-selection metadata written into the manifest.
1617    pub plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1618    /// Progress reporter used for compaction, snapshot, and analysis stages.
1619    pub progress: SharedReporter,
1620    /// Effective worker thread count from the build phase.
1621    pub effective_threads: usize,
1622}
1623
1624/// Persist a pre-built graph and run the analysis pipeline.
1625///
1626/// This is the persist+analysis portion of
1627/// [`build_and_persist_graph_with_progress`], extracted so callers can enrich
1628/// the graph between build and persist.
1629///
1630/// # Errors
1631///
1632/// Returns an error if persistence or analysis fails.
1633#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1634pub fn persist_and_analyze_graph(
1635    graph: CodeGraph,
1636    root: &Path,
1637    plugins: &PluginManager,
1638    config: &BuildConfig,
1639    build_command: &str,
1640    plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1641    progress: SharedReporter,
1642    effective_threads: usize,
1643) -> Result<(CodeGraph, BuildResult)> {
1644    persist_durable_graph_transaction(
1645        graph,
1646        DurableGraphPersistenceRequest {
1647            root,
1648            plugins,
1649            config,
1650            build_command,
1651            plugin_selection,
1652            progress,
1653            effective_threads,
1654        },
1655    )
1656}
1657
1658/// Run the canonical manifest-as-commit-point graph persistence transaction.
1659///
1660/// Ordering is part of the durability contract:
1661///
1662/// 1. Remove any stale manifest first.
1663/// 2. Compact edges and write the canonical snapshot.
1664/// 3. Persist graph analyses for the new identity.
1665/// 4. Write `manifest.json` last as the commit point.
1666///
1667/// # Errors
1668///
1669/// Returns an error if any persistence, analysis, or manifest write step fails.
1670#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1671pub fn persist_durable_graph_transaction(
1672    graph: CodeGraph,
1673    request: DurableGraphPersistenceRequest<'_>,
1674) -> Result<(CodeGraph, BuildResult)> {
1675    use crate::graph::unified::analysis::csr::CsrAdjacency;
1676    use crate::graph::unified::analysis::{AnalysisIdentity, GraphAnalyses, compute_node_id_hash};
1677    use crate::graph::unified::compaction::{Direction, build_compacted_csr, snapshot_edges};
1678    use crate::graph::unified::persistence::manifest::write_manifest_bytes_atomic;
1679    use crate::graph::unified::persistence::{
1680        BuildProvenance, GraphStorage, MANIFEST_SCHEMA_VERSION, Manifest, SNAPSHOT_FORMAT_VERSION,
1681        save_to_path,
1682    };
1683    use crate::progress::IndexProgress;
1684    use chrono::Utc;
1685    use sha2::{Digest, Sha256};
1686
1687    let DurableGraphPersistenceRequest {
1688        root,
1689        plugins,
1690        config,
1691        build_command,
1692        plugin_selection,
1693        progress,
1694        effective_threads,
1695    } = request;
1696
1697    // Step 1: Ensure storage directories exist and remove old manifest
1698    // Removing the manifest BEFORE writing the new snapshot ensures that
1699    // readers see `storage.exists() == false` during the rebuild window.
1700    // Without this, an interrupted rebuild (crash after snapshot write but
1701    // before manifest write) would leave the old manifest paired with a
1702    // new, potentially incompatible snapshot — violating the commit-point
1703    // contract.
1704    let storage = GraphStorage::new(root);
1705    fs::create_dir_all(storage.graph_dir())
1706        .with_context(|| format!("Failed to create {}", storage.graph_dir().display()))?;
1707
1708    if storage.exists() {
1709        // Remove old manifest so readers don't see stale readiness.
1710        // This MUST succeed before we overwrite the snapshot — otherwise a
1711        // crash between snapshot write and manifest write leaves stale
1712        // readiness (old manifest + new snapshot).  NotFound is harmless
1713        // (race or already cleaned up); any other error is fatal.
1714        match fs::remove_file(storage.manifest_path()) {
1715            Ok(()) => {}
1716            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1717            Err(e) => {
1718                return Err(e).with_context(|| {
1719                    format!(
1720                        "Failed to remove old manifest at {} — rebuild cannot proceed safely",
1721                        storage.manifest_path().display()
1722                    )
1723                });
1724            }
1725        }
1726    }
1727
1728    // Step 2: Capture raw edge count before compaction changes it
1729    let raw_edge_count = graph.edge_count();
1730    let node_count = graph.node_count();
1731
1732    // Step 3: Compact edge stores into CSR before persistence
1733    //
1734    // The build pipeline inserts all edges into the DeltaBuffer (write-optimized).
1735    // Without compaction, the persisted snapshot stores edges in delta, causing
1736    // O(N) scans for every edges_from()/edges_to() call on load. Compacting to
1737    // CSR gives O(degree) lookups — critical for kernel-scale graphs (22M edges).
1738    progress.report(IndexProgress::StageStarted {
1739        stage_name: "Compacting edge stores for persistence",
1740    });
1741    let compaction_start = std::time::Instant::now();
1742
1743    // Snapshot both edge stores (sequential — holds read locks briefly)
1744    let forward_compaction_snapshot = {
1745        let forward_store = graph.edges().forward();
1746        snapshot_edges(&forward_store, node_count)
1747    };
1748    let reverse_compaction_snapshot = {
1749        let reverse_store = graph.edges().reverse();
1750        snapshot_edges(&reverse_store, node_count)
1751    };
1752
1753    // Build both CSRs in parallel (CPU-intensive, no locks held)
1754    let (forward_result, reverse_result) = rayon::join(
1755        || build_compacted_csr(&forward_compaction_snapshot, Direction::Forward),
1756        || build_compacted_csr(&reverse_compaction_snapshot, Direction::Reverse),
1757    );
1758
1759    let (forward_csr, _forward_build_stats) =
1760        forward_result.context("Failed to build forward CSR for persistence compaction")?;
1761    let (reverse_csr, _reverse_build_stats) =
1762        reverse_result.context("Failed to build reverse CSR for persistence compaction")?;
1763
1764    // Drop snapshots — no longer needed
1765    drop(forward_compaction_snapshot);
1766    drop(reverse_compaction_snapshot);
1767
1768    // Build analysis adjacency from forward CSR before it's consumed by swap.
1769    // This replaces the expensive build_from_snapshot merge+sort (~11s on kernel).
1770    let adjacency = CsrAdjacency::from_csr_graph(&forward_csr);
1771
1772    // Atomic mutation phase: swap both CSRs and clear both deltas
1773    graph
1774        .edges()
1775        .swap_csrs_and_clear_deltas(forward_csr, reverse_csr);
1776
1777    progress.report(IndexProgress::StageCompleted {
1778        stage_name: "Compacting edge stores for persistence",
1779        stage_duration: compaction_start.elapsed(),
1780    });
1781
1782    // Step 4: Save CSR-backed binary snapshot
1783    progress.report(IndexProgress::SavingStarted {
1784        component_name: "unified graph",
1785    });
1786    let save_start = std::time::Instant::now();
1787
1788    save_to_path(&graph, storage.snapshot_path()).with_context(|| {
1789        format!(
1790            "Failed to save snapshot to {}",
1791            storage.snapshot_path().display()
1792        )
1793    })?;
1794
1795    progress.report(IndexProgress::SavingCompleted {
1796        component_name: "unified graph",
1797        save_duration: save_start.elapsed(),
1798    });
1799
1800    // Step 5: Compute snapshot checksum
1801    let snapshot_content =
1802        fs::read(storage.snapshot_path()).context("Failed to read snapshot for checksum")?;
1803    let snapshot_sha256 = hex::encode(Sha256::digest(&snapshot_content));
1804
1805    // Step 6: Build full analyses from the prebuilt adjacency.
1806    // CsrAdjacency was already derived from the forward CsrGraph in Step 4,
1807    // eliminating the expensive re-merge from CompactionSnapshot.
1808    progress.report(IndexProgress::StageStarted {
1809        stage_name: "Computing graph analyses",
1810    });
1811    let analysis_start = std::time::Instant::now();
1812
1813    let analysis_pool = create_thread_pool(config)
1814        .context("Failed to create rayon thread pool for graph analysis")?;
1815    let analyses = analysis_pool
1816        .install(|| {
1817            GraphAnalyses::build_all_from_adjacency_with_budget(adjacency, &config.label_budget)
1818        })
1819        .context("Failed to build graph analyses")?;
1820
1821    progress.report(IndexProgress::StageCompleted {
1822        stage_name: "Computing graph analyses",
1823        stage_duration: analysis_start.elapsed(),
1824    });
1825
1826    let dedup_edge_count = analyses.adjacency.edge_count as usize;
1827
1828    let analysis_strategies = vec![
1829        AnalysisStrategySummary {
1830            edge_kind: "calls",
1831            strategy: analyses.cond_calls.strategy,
1832        },
1833        AnalysisStrategySummary {
1834            edge_kind: "imports",
1835            strategy: analyses.cond_imports.strategy,
1836        },
1837        AnalysisStrategySummary {
1838            edge_kind: "references",
1839            strategy: analyses.cond_references.strategy,
1840        },
1841        AnalysisStrategySummary {
1842            edge_kind: "inherits",
1843            strategy: analyses.cond_inherits.strategy,
1844        },
1845    ];
1846
1847    // Step 7: Count workspace files by language using plugin detection
1848    let mut file_counts: std::collections::HashMap<String, usize> =
1849        std::collections::HashMap::new();
1850    for (file_id, file_path) in graph.indexed_files() {
1851        if graph.files().is_external(file_id) {
1852            continue;
1853        }
1854        let language = plugins
1855            .plugin_for_path(file_path)
1856            .map_or_else(|| "unknown".to_string(), |p| p.metadata().id.to_string());
1857        *file_counts.entry(language).or_insert(0) += 1;
1858    }
1859    let total_files: usize = file_counts.values().sum();
1860
1861    // Step 8: Construct Manifest in memory (with dedup edge count from analysis)
1862    let built_at = Utc::now().to_rfc3339();
1863
1864    let manifest = Manifest {
1865        schema_version: MANIFEST_SCHEMA_VERSION,
1866        snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
1867        built_at: built_at.clone(),
1868        root_path: root.to_string_lossy().to_string(),
1869        node_count,
1870        edge_count: dedup_edge_count,
1871        raw_edge_count: Some(raw_edge_count),
1872        snapshot_sha256,
1873        build_provenance: BuildProvenance {
1874            sqry_version: env!("CARGO_PKG_VERSION").to_string(),
1875            build_timestamp: built_at.clone(),
1876            build_command: build_command.to_string(),
1877            plugin_hashes: std::collections::HashMap::default(),
1878        },
1879        file_count: file_counts.clone(),
1880        languages: Vec::default(),
1881        config: std::collections::HashMap::default(),
1882        confidence: graph.confidence().clone(),
1883        last_indexed_commit: get_git_head_commit(root),
1884        plugin_selection: plugin_selection.clone(),
1885    };
1886
1887    // Step 9: Serialize manifest to bytes and compute hash
1888    let manifest_bytes =
1889        serde_json::to_vec_pretty(&manifest).context("Failed to serialize manifest")?;
1890
1891    let manifest_hash = {
1892        let mut hasher = Sha256::new();
1893        hasher.update(&manifest_bytes);
1894        hex::encode(hasher.finalize())
1895    };
1896
1897    // Step 10: Construct AnalysisIdentity and persist all analyses
1898    let snapshot = graph.snapshot();
1899    let node_id_hash = compute_node_id_hash(&snapshot);
1900    let identity = AnalysisIdentity::new(manifest_hash, node_id_hash);
1901
1902    fs::create_dir_all(storage.analysis_dir()).with_context(|| {
1903        format!(
1904            "Failed to create analysis directory at {}",
1905            storage.analysis_dir().display()
1906        )
1907    })?;
1908
1909    progress.report(IndexProgress::SavingStarted {
1910        component_name: "graph analyses",
1911    });
1912
1913    analyses
1914        .persist_all(&storage, &identity)
1915        .context("Failed to persist graph analyses")?;
1916
1917    log::info!(
1918        "Graph analyses persisted to {}",
1919        storage.analysis_dir().display()
1920    );
1921
1922    progress.report(IndexProgress::SavingCompleted {
1923        component_name: "graph analyses",
1924        save_duration: analysis_start.elapsed(),
1925    });
1926
1927    // Step 11: Write manifest bytes to disk LAST (commit point)
1928    write_manifest_bytes_atomic(storage.manifest_path(), &manifest_bytes).with_context(|| {
1929        format!(
1930            "Failed to save manifest to {}",
1931            storage.manifest_path().display()
1932        )
1933    })?;
1934
1935    log::info!(
1936        "Manifest saved to {} (dedup edges: {}, raw edges: {})",
1937        storage.manifest_path().display(),
1938        dedup_edge_count,
1939        raw_edge_count
1940    );
1941
1942    let build_result = BuildResult {
1943        node_count,
1944        edge_count: dedup_edge_count,
1945        raw_edge_count,
1946        file_count: file_counts,
1947        total_files,
1948        built_at,
1949        root_path: root.to_string_lossy().to_string(),
1950        thread_count: effective_threads,
1951        active_plugin_ids: plugin_selection
1952            .map_or_else(Vec::new, |selection| selection.active_plugin_ids),
1953        analysis_strategies,
1954    };
1955
1956    Ok((graph, build_result))
1957}
1958
1959/// Build unified graph with progress, persist snapshot + manifest, and run analysis.
1960///
1961/// This is the single entry point for building a complete graph index. It combines:
1962/// 1. Graph building from source files (with progress reporting)
1963/// 2. Snapshot persistence (binary format)
1964/// 3. Analysis pipeline (CSR + SCC + Condensation DAG + labels/fallback) — strict, fails on error
1965/// 4. Manifest creation with deduplicated edge count (JSON metadata, written LAST)
1966///
1967/// The manifest is the "commit point" — written last, only after all other artifacts
1968/// succeed. Consumers check `storage.exists()` (manifest-based) for index readiness.
1969///
1970/// # Arguments
1971///
1972/// * `root` - Root directory to scan for source files
1973/// * `plugins` - Plugin manager for language-specific extraction
1974/// * `config` - Build configuration
1975/// * `build_command` - Provenance string (e.g., `"cli:index"`, `"mcp:rebuild_index"`)
1976/// * `progress` - Progress reporter for build status updates
1977///
1978/// # Errors
1979///
1980/// Returns an error if graph building, persistence, or analysis fails.
1981/// Analysis failure is strict — no fallback to raw edge counts.
1982#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1983pub fn build_and_persist_graph_with_progress(
1984    root: &Path,
1985    plugins: &PluginManager,
1986    config: &BuildConfig,
1987    build_command: &str,
1988    plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1989    progress: SharedReporter,
1990) -> Result<(CodeGraph, BuildResult)> {
1991    let (graph, effective_threads) = build_unified_graph_inner(
1992        root,
1993        plugins,
1994        config,
1995        progress.clone(),
1996        &CancellationToken::default(),
1997    )
1998    .map_err(anyhow::Error::from)?;
1999    persist_and_analyze_graph(
2000        graph,
2001        root,
2002        plugins,
2003        config,
2004        build_command,
2005        plugin_selection,
2006        progress,
2007        effective_threads,
2008    )
2009}
2010
2011/// Get the current HEAD commit SHA from a git repository.
2012#[must_use]
2013pub fn get_git_head_commit(path: &Path) -> Option<String> {
2014    let output = std::process::Command::new("git")
2015        .arg("-C")
2016        .arg(path)
2017        .args(["rev-parse", "HEAD"])
2018        .output()
2019        .ok()?;
2020
2021    if output.status.success() {
2022        let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
2023        if sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
2024            return Some(sha);
2025        }
2026    }
2027    None
2028}
2029
2030/// Find source files in the given directory.
2031///
2032/// Uses the `ignore` crate to respect `.gitignore` files and standard ignore patterns.
2033fn find_source_files(root: &Path, config: &BuildConfig) -> Vec<std::path::PathBuf> {
2034    let mut builder = WalkBuilder::new(root);
2035
2036    builder
2037        .follow_links(config.follow_links)
2038        .hidden(!config.include_hidden)
2039        .git_ignore(true)
2040        .git_global(true)
2041        .git_exclude(true);
2042
2043    if let Some(depth) = config.max_depth {
2044        builder.max_depth(Some(depth));
2045    }
2046
2047    if let Some(threads) = config.num_threads {
2048        builder.threads(threads);
2049    }
2050
2051    let root_for_filter = root.to_path_buf();
2052    builder.filter_entry(move |entry| {
2053        entry
2054            .file_type()
2055            .is_none_or(|file_type| !file_type.is_dir())
2056            || should_visit_source_dir(&root_for_filter, entry.path())
2057    });
2058
2059    let mut files = Vec::new();
2060
2061    for entry in builder.build() {
2062        let entry = match entry {
2063            Ok(entry) => entry,
2064            Err(err) => {
2065                log::warn!("Failed to read directory entry: {err}");
2066                continue;
2067            }
2068        };
2069
2070        if entry.file_type().is_some_and(|ft| ft.is_file()) {
2071            files.push(entry.into_path());
2072        }
2073    }
2074
2075    files
2076}
2077
2078fn should_visit_source_dir(root: &Path, path: &Path) -> bool {
2079    if path == root {
2080        return true;
2081    }
2082
2083    let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
2084        return true;
2085    };
2086
2087    !is_default_excluded_source_dir(name)
2088}
2089
2090fn is_default_excluded_source_dir(name: &str) -> bool {
2091    if std::env::var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS")
2092        .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
2093    {
2094        return false;
2095    }
2096
2097    DEFAULT_EXCLUDED_SOURCE_DIRS.contains(&name)
2098        || DEFAULT_EXCLUDED_SOURCE_DIR_PREFIXES
2099            .iter()
2100            .any(|prefix| name.starts_with(prefix))
2101}
2102
2103fn sort_files_for_build(root: &Path, files: &mut [PathBuf]) {
2104    let normalized_root = normalize_path_components(root);
2105    files.sort_by(|left, right| {
2106        let left_key = file_sort_key(&normalized_root, left);
2107        let right_key = file_sort_key(&normalized_root, right);
2108        left_key.cmp(&right_key).then_with(|| left.cmp(right))
2109    });
2110}
2111
2112fn file_sort_key(root: &Path, path: &Path) -> String {
2113    let normalized_path = normalize_path_components(path);
2114    let relative = normalized_path
2115        .strip_prefix(root)
2116        .unwrap_or(normalized_path.as_path());
2117    let mut key = relative.to_string_lossy().replace('\\', "/");
2118    if cfg!(windows) {
2119        key = key.to_ascii_lowercase();
2120    }
2121    key
2122}
2123
2124/// Result of successfully parsing a single file (parallel-safe, no shared state).
2125///
2126/// `pub(super)` so sibling modules in `crate::graph::unified::build`
2127/// (specifically [`super::incremental`] from Task 4 Step 4 Phase 3c onward)
2128/// can construct and consume `ParsedFile` values when driving the
2129/// parse → commit pipeline against a `RebuildGraph`. The type stays
2130/// crate-private: external callers still route through the higher-level
2131/// `build_unified_graph` / `incremental_rebuild` entrypoints.
2132#[derive(Debug)]
2133pub(super) struct ParsedFile {
2134    /// Language identifier for file counting and confidence merging.
2135    pub(super) language: crate::graph::Language,
2136    /// Staged graph operations ready for serial commit.
2137    pub(super) staging: StagingGraph,
2138}
2139
2140/// Outcome of [`parse_file`]. `pub(super)` for the same reason as
2141/// [`ParsedFile`] — shared with [`super::incremental`]'s re-parse closure
2142/// driver in Phase 3c+. Still crate-private.
2143///
2144/// `ParsedFile` is the dominant variant by both size and frequency on
2145/// every real build — `Skipped` / `TimedOut` are tail paths. Boxing
2146/// `Parsed` would add an allocation per parsed file (the parse loop is
2147/// the hottest part of Phase 1), trading the dominant case's perf for
2148/// uniform variant size. We accept the size difference on the enum
2149/// rather than pay that cost; the lint is suppressed at the
2150/// declaration with this rationale.
2151#[allow(clippy::large_enum_variant)]
2152#[derive(Debug)]
2153pub(super) enum ParsedFileOutcome {
2154    Parsed(ParsedFile),
2155    Skipped,
2156    TimedOut {
2157        file: PathBuf,
2158        phase: &'static str,
2159        timeout_ms: u64,
2160    },
2161}
2162
2163/// Parse a single file into a `StagingGraph` without touching the shared graph.
2164///
2165/// This function is safe to call from multiple threads — it creates its own
2166/// parser, reads the file, and builds a self-contained staging graph.
2167///
2168/// Returns [`ParsedFileOutcome::Skipped`] if the file has no matching plugin or graph builder.
2169///
2170/// `pub(super)` as of Task 4 Step 4 Phase 3c so the sibling
2171/// [`super::incremental`] module can re-parse closure files against the
2172/// rebuild-local `GraphMutationTarget` plane during `incremental_rebuild`.
2173pub(super) fn parse_file(path: &Path, plugins: &PluginManager) -> Result<ParsedFileOutcome> {
2174    let plugin = plugins.plugin_for_path(path);
2175    let Some(plugin) = plugin else {
2176        return Ok(ParsedFileOutcome::Skipped);
2177    };
2178
2179    let Some(builder) = plugin.graph_builder() else {
2180        return Ok(ParsedFileOutcome::Skipped);
2181    };
2182
2183    let reader =
2184        FileReader::open(path).with_context(|| format!("failed to read {}", path.display()))?;
2185    let raw_content = reader.as_slice();
2186
2187    let safe_parser = SafeParser::new(SafeParserConfig::new().with_max_input_size(
2188        usize::try_from(crate::config::buffers::max_source_file_size()).unwrap_or(usize::MAX),
2189    ));
2190    let prepared_content = plugin.preprocess(raw_content);
2191    let parse_content = prepared_content.as_ref();
2192    let parse_start = Instant::now();
2193    let tree = safe_parser
2194        .parse_file(&plugin.language(), parse_content, path)
2195        .map_err(|err| map_parse_error(path, err))?;
2196    let parse_duration = parse_start.elapsed();
2197    if parse_duration >= Duration::from_secs(2) {
2198        log::warn!("Slow parse ({parse_duration:.2?}): {}", path.display());
2199    }
2200
2201    let mut staging = StagingGraph::new();
2202    let build_start = Instant::now();
2203    match builder.build_graph(&tree, parse_content, path, &mut staging) {
2204        Ok(()) => {}
2205        Err(GraphBuilderError::BuildTimedOut {
2206            phase, timeout_ms, ..
2207        }) => {
2208            return Ok(ParsedFileOutcome::TimedOut {
2209                file: path.to_path_buf(),
2210                phase,
2211                timeout_ms,
2212            });
2213        }
2214        Err(err) => return Err(map_builder_error(path, &err)),
2215    }
2216    let build_duration = build_start.elapsed();
2217    if build_duration >= Duration::from_secs(2) {
2218        log::warn!(
2219            "Slow graph build ({build_duration:.2?}): {}",
2220            path.display()
2221        );
2222    }
2223
2224    // Resolve the per-language shape mapping from the plugin's builder and, when
2225    // present, hand the shape walker the parsed tree + the parse-aligned source so
2226    // each Function/Method body is fingerprinted alongside its body hash. Body
2227    // hashes use `raw_content` (the index's coordinate system); the shape walker
2228    // navigates the tree, whose positions live in `parse_content`.
2229    let shape_ctx = builder
2230        .shape_mapping()
2231        .map(|mapping| super::staging::ShapeAttachCtx::new(&tree, parse_content, mapping));
2232    staging.attach_body_hashes(raw_content, shape_ctx);
2233
2234    Ok(ParsedFileOutcome::Parsed(ParsedFile {
2235        language: builder.language(),
2236        staging,
2237    }))
2238}
2239
2240fn map_parse_error(path: &Path, err: ParseError) -> anyhow::Error {
2241    match err {
2242        ParseError::TreeSitterFailed => {
2243            anyhow::anyhow!("tree-sitter failed to parse {}", path.display())
2244        }
2245        ParseError::LanguageSetFailed(reason) => anyhow::anyhow!(
2246            "failed to configure tree-sitter for {}: {}",
2247            path.display(),
2248            reason
2249        ),
2250        ParseError::InputTooLarge { size, max, .. } => anyhow::anyhow!(
2251            "input too large for {}: {} bytes exceeds {} byte parser limit",
2252            path.display(),
2253            size,
2254            max
2255        ),
2256        ParseError::ParseTimedOut { timeout_micros, .. } => anyhow::anyhow!(
2257            "parse timed out for {} after {} ms",
2258            path.display(),
2259            timeout_micros / 1000
2260        ),
2261        ParseError::ParseCancelled { reason, .. } => {
2262            anyhow::anyhow!("parse cancelled for {}: {}", path.display(), reason)
2263        }
2264        _ => anyhow::anyhow!("parse error in {}: {:?}", path.display(), err),
2265    }
2266}
2267
2268fn map_builder_error(path: &Path, err: &GraphBuilderError) -> anyhow::Error {
2269    anyhow::anyhow!("graph builder error in {}: {}", path.display(), err)
2270}
2271
2272// ---------------------------------------------------------------------------
2273// Test-only hooks (Task 7 Phase 7c)
2274// ---------------------------------------------------------------------------
2275//
2276// Thread-local callbacks fired at pass boundaries inside
2277// `build_unified_graph_inner`. Tests that need to flip the
2278// `CancellationToken` between chunks / before Phase 4 / before Pass 5
2279// install a hook, trigger a rebuild, and observe the pipeline
2280// short-circuit.
2281//
2282// Follows the same pattern as [`incremental::testing`] (see
2283// `incremental.rs:1605`): the module is gated on
2284// `any(test, feature = "rebuild-internals")` and production builds
2285// compile every call site into `let _ = ...;` no-ops.
2286/// Test-only hooks exposed so `sqry-daemon` integration tests can
2287/// drive cancellation-boundary scenarios in `build_unified_graph_inner`
2288/// without reaching into private module state.
2289///
2290/// Gated on `any(test, feature = "rebuild-internals")`; production
2291/// builds compile the module out.
2292#[cfg(any(test, feature = "rebuild-internals"))]
2293pub mod testing {
2294    use super::CancellationToken;
2295    use std::cell::RefCell;
2296
2297    /// Callback invoked at the top of each chunk iteration in
2298    /// `build_unified_graph_inner`, receiving the current cancellation
2299    /// token. Tests typically call `token.cancel()` after N chunks to
2300    /// assert the pipeline short-circuits at the next boundary.
2301    pub type AfterChunkHook = Box<dyn FnMut(&CancellationToken)>;
2302    /// Callback invoked once after the chunk loop exits and before
2303    /// Phase 4 finalization.
2304    pub type BeforePhase4Hook = Box<dyn FnMut(&CancellationToken)>;
2305    /// Callback invoked once before Pass 5 cross-language linking.
2306    pub type BeforePass5Hook = Box<dyn FnMut(&CancellationToken)>;
2307
2308    thread_local! {
2309        static AFTER_CHUNK_HOOK: RefCell<Option<AfterChunkHook>> = const { RefCell::new(None) };
2310        static BEFORE_PHASE4_HOOK: RefCell<Option<BeforePhase4Hook>> = const { RefCell::new(None) };
2311        static BEFORE_PASS5_HOOK: RefCell<Option<BeforePass5Hook>> = const { RefCell::new(None) };
2312    }
2313
2314    /// Install a callback that runs at the top of each chunk iteration.
2315    /// Replaces any previously-installed hook on the current thread.
2316    pub fn set_after_chunk_hook<F>(hook: F) -> Option<AfterChunkHook>
2317    where
2318        F: FnMut(&CancellationToken) + 'static,
2319    {
2320        AFTER_CHUNK_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2321    }
2322
2323    /// Remove the currently-installed after-chunk hook. Idempotent.
2324    pub fn clear_after_chunk_hook() {
2325        AFTER_CHUNK_HOOK.with(|cell| {
2326            let _ = cell.replace(None);
2327        });
2328    }
2329
2330    /// Install a callback that runs after the chunk loop exits, before
2331    /// Phase 4 finalization. Replaces any previously-installed hook.
2332    pub fn set_before_phase4_hook<F>(hook: F) -> Option<BeforePhase4Hook>
2333    where
2334        F: FnMut(&CancellationToken) + 'static,
2335    {
2336        BEFORE_PHASE4_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2337    }
2338
2339    /// Remove the currently-installed before-Phase-4 hook. Idempotent.
2340    pub fn clear_before_phase4_hook() {
2341        BEFORE_PHASE4_HOOK.with(|cell| {
2342            let _ = cell.replace(None);
2343        });
2344    }
2345
2346    /// Install a callback that runs before Pass 5 cross-language linking.
2347    /// Replaces any previously-installed hook.
2348    pub fn set_before_pass5_hook<F>(hook: F) -> Option<BeforePass5Hook>
2349    where
2350        F: FnMut(&CancellationToken) + 'static,
2351    {
2352        BEFORE_PASS5_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2353    }
2354
2355    /// Remove the currently-installed before-Pass-5 hook. Idempotent.
2356    pub fn clear_before_pass5_hook() {
2357        BEFORE_PASS5_HOOK.with(|cell| {
2358            let _ = cell.replace(None);
2359        });
2360    }
2361
2362    /// Fire the installed after-chunk hook (if any). Called from
2363    /// `build_unified_graph_inner` at the top of every chunk iteration.
2364    pub(super) fn fire_after_chunk_hook(cancellation: &CancellationToken) {
2365        AFTER_CHUNK_HOOK.with(|cell| {
2366            if let Some(hook) = cell.borrow_mut().as_mut() {
2367                hook(cancellation);
2368            }
2369        });
2370    }
2371
2372    /// Fire the installed before-Phase-4 hook (if any).
2373    pub(super) fn fire_before_phase4_hook(cancellation: &CancellationToken) {
2374        BEFORE_PHASE4_HOOK.with(|cell| {
2375            if let Some(hook) = cell.borrow_mut().as_mut() {
2376                hook(cancellation);
2377            }
2378        });
2379    }
2380
2381    /// Fire the installed before-Pass-5 hook (if any).
2382    pub(super) fn fire_before_pass5_hook(cancellation: &CancellationToken) {
2383        BEFORE_PASS5_HOOK.with(|cell| {
2384            if let Some(hook) = cell.borrow_mut().as_mut() {
2385                hook(cancellation);
2386            }
2387        });
2388    }
2389
2390    /// RAII guard that installs an after-chunk hook on construction
2391    /// and clears it on drop. Prevents a panic mid-test from leaking
2392    /// a hook into a sibling test on the same thread.
2393    pub struct AfterChunkHookGuard {
2394        _sealed: (),
2395    }
2396
2397    impl AfterChunkHookGuard {
2398        /// Install `hook` as the thread-local after-chunk callback.
2399        pub fn install<F>(hook: F) -> Self
2400        where
2401            F: FnMut(&CancellationToken) + 'static,
2402        {
2403            let _previous = set_after_chunk_hook(hook);
2404            Self { _sealed: () }
2405        }
2406    }
2407
2408    impl Drop for AfterChunkHookGuard {
2409        fn drop(&mut self) {
2410            clear_after_chunk_hook();
2411        }
2412    }
2413
2414    /// RAII guard that installs a before-Phase-4 hook on construction
2415    /// and clears it on drop.
2416    pub struct BeforePhase4HookGuard {
2417        _sealed: (),
2418    }
2419
2420    impl BeforePhase4HookGuard {
2421        /// Install `hook` as the thread-local before-Phase-4 callback.
2422        pub fn install<F>(hook: F) -> Self
2423        where
2424            F: FnMut(&CancellationToken) + 'static,
2425        {
2426            let _previous = set_before_phase4_hook(hook);
2427            Self { _sealed: () }
2428        }
2429    }
2430
2431    impl Drop for BeforePhase4HookGuard {
2432        fn drop(&mut self) {
2433            clear_before_phase4_hook();
2434        }
2435    }
2436
2437    /// RAII guard that installs a before-Pass-5 hook on construction
2438    /// and clears it on drop.
2439    pub struct BeforePass5HookGuard {
2440        _sealed: (),
2441    }
2442
2443    impl BeforePass5HookGuard {
2444        /// Install `hook` as the thread-local before-Pass-5 callback.
2445        pub fn install<F>(hook: F) -> Self
2446        where
2447            F: FnMut(&CancellationToken) + 'static,
2448        {
2449            let _previous = set_before_pass5_hook(hook);
2450            Self { _sealed: () }
2451        }
2452    }
2453
2454    impl Drop for BeforePass5HookGuard {
2455        fn drop(&mut self) {
2456            clear_before_pass5_hook();
2457        }
2458    }
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463    use super::*;
2464    use crate::ast::Scope;
2465    use crate::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language};
2466    use crate::plugin::error::{ParseError, ScopeError};
2467    use crate::plugin::{LanguageMetadata, LanguagePlugin};
2468    use serial_test::serial;
2469    use std::fs;
2470    use std::path::{Path, PathBuf};
2471    use tempfile::TempDir;
2472    use tree_sitter::{Parser, Tree};
2473
2474    const RUST_TEST_EXTENSIONS: &[&str] = &["rs"];
2475    const FILENAME_MATCH_EXTENSIONS: &[&str] = &["rmd", "bash_profile"];
2476
2477    #[test]
2478    fn index_worker_stack_size_uses_default_without_override() {
2479        assert_eq!(
2480            index_worker_stack_size_bytes_from_value(None),
2481            DEFAULT_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2482        );
2483    }
2484
2485    #[test]
2486    fn index_worker_stack_size_honors_valid_override() {
2487        assert_eq!(
2488            index_worker_stack_size_bytes_from_value(Some("64")),
2489            64 * BYTES_PER_MIB
2490        );
2491    }
2492
2493    #[test]
2494    fn index_worker_stack_size_clamps_override_bounds() {
2495        assert_eq!(
2496            index_worker_stack_size_bytes_from_value(Some("1")),
2497            MIN_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2498        );
2499        assert_eq!(
2500            index_worker_stack_size_bytes_from_value(Some("9999")),
2501            MAX_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2502        );
2503    }
2504
2505    #[test]
2506    fn index_worker_stack_size_ignores_invalid_override() {
2507        assert_eq!(
2508            index_worker_stack_size_bytes_from_value(Some("not-a-number")),
2509            DEFAULT_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2510        );
2511    }
2512
2513    /// Test helper: commit a single parsed file to a graph using the serial path.
2514    ///
2515    /// This is only used in tests to verify parse-and-commit without running the
2516    /// full parallel pipeline. It replicates the old `commit_staged_file` logic.
2517    fn commit_parsed_file_for_test(path: &Path, mut parsed: ParsedFile, graph: &mut CodeGraph) {
2518        let file_id = graph
2519            .files_mut()
2520            .register_with_language(path, Some(parsed.language))
2521            .expect("register file");
2522        parsed.staging.apply_file_id(file_id);
2523        let string_remap = parsed
2524            .staging
2525            .commit_strings(graph.strings_mut())
2526            .expect("commit strings");
2527        parsed
2528            .staging
2529            .apply_string_remap(&string_remap)
2530            .expect("apply string remap");
2531        let node_id_mapping = parsed
2532            .staging
2533            .commit_nodes(graph.nodes_mut())
2534            .expect("commit nodes");
2535        let edges = parsed.staging.get_remapped_edges(&node_id_mapping);
2536        for edge in edges {
2537            graph.edges_mut().add_edge_with_spans(
2538                edge.source,
2539                edge.target,
2540                edge.kind.clone(),
2541                file_id,
2542                edge.spans.clone(),
2543            );
2544        }
2545    }
2546
2547    fn expect_parsed_file(outcome: ParsedFileOutcome) -> ParsedFile {
2548        match outcome {
2549            ParsedFileOutcome::Parsed(parsed) => parsed,
2550            ParsedFileOutcome::Skipped => panic!("expected parsed file, got skipped outcome"),
2551            ParsedFileOutcome::TimedOut { file, phase, .. } => {
2552                panic!(
2553                    "expected parsed file, got timeout outcome for {} during {}",
2554                    file.display(),
2555                    phase,
2556                )
2557            }
2558        }
2559    }
2560
2561    fn parse_rust_ast(content: &[u8]) -> Result<Tree, ParseError> {
2562        let mut parser = Parser::new();
2563        let language = tree_sitter_rust::LANGUAGE.into();
2564        parser
2565            .set_language(&language)
2566            .map_err(|err| ParseError::LanguageSetFailed(err.to_string()))?;
2567        parser
2568            .parse(content, None)
2569            .ok_or(ParseError::TreeSitterFailed)
2570    }
2571
2572    struct TestPlugin {
2573        metadata: LanguageMetadata,
2574        extensions: &'static [&'static str],
2575        builder: Option<Box<dyn GraphBuilder>>,
2576    }
2577
2578    impl TestPlugin {
2579        fn new(
2580            id: &'static str,
2581            extensions: &'static [&'static str],
2582            builder: Option<Box<dyn GraphBuilder>>,
2583        ) -> Self {
2584            Self {
2585                metadata: LanguageMetadata {
2586                    id,
2587                    name: "Rust",
2588                    version: "test",
2589                    author: "sqry-core tests",
2590                    description: "Test-only Rust plugin for unified graph entrypoint tests",
2591                    tree_sitter_version: "0.25",
2592                },
2593                extensions,
2594                builder,
2595            }
2596        }
2597    }
2598
2599    impl LanguagePlugin for TestPlugin {
2600        fn metadata(&self) -> LanguageMetadata {
2601            self.metadata.clone()
2602        }
2603
2604        fn extensions(&self) -> &'static [&'static str] {
2605            self.extensions
2606        }
2607
2608        fn language(&self) -> tree_sitter::Language {
2609            tree_sitter_rust::LANGUAGE.into()
2610        }
2611
2612        fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
2613            parse_rust_ast(content)
2614        }
2615
2616        fn extract_scopes(
2617            &self,
2618            _tree: &Tree,
2619            _content: &[u8],
2620            _file_path: &Path,
2621        ) -> Result<Vec<Scope>, ScopeError> {
2622            Ok(Vec::new())
2623        }
2624
2625        fn graph_builder(&self) -> Option<&dyn crate::graph::GraphBuilder> {
2626            self.builder.as_deref()
2627        }
2628    }
2629
2630    struct FailingGraphBuilder;
2631
2632    impl GraphBuilder for FailingGraphBuilder {
2633        fn build_graph(
2634            &self,
2635            _tree: &Tree,
2636            _content: &[u8],
2637            _file: &Path,
2638            _staging: &mut StagingGraph,
2639        ) -> GraphResult<()> {
2640            Err(GraphBuilderError::CrossLanguageError {
2641                reason: "forced failure".to_string(),
2642            })
2643        }
2644
2645        fn language(&self) -> Language {
2646            Language::Rust
2647        }
2648    }
2649
2650    struct NoopGraphBuilder;
2651
2652    impl GraphBuilder for NoopGraphBuilder {
2653        fn build_graph(
2654            &self,
2655            _tree: &Tree,
2656            _content: &[u8],
2657            _file: &Path,
2658            _staging: &mut StagingGraph,
2659        ) -> GraphResult<()> {
2660            Ok(())
2661        }
2662
2663        fn language(&self) -> Language {
2664            Language::Rust
2665        }
2666    }
2667
2668    struct TimeoutGraphBuilder;
2669
2670    impl GraphBuilder for TimeoutGraphBuilder {
2671        fn build_graph(
2672            &self,
2673            _tree: &Tree,
2674            _content: &[u8],
2675            file: &Path,
2676            _staging: &mut StagingGraph,
2677        ) -> GraphResult<()> {
2678            Err(GraphBuilderError::BuildTimedOut {
2679                file: file.to_path_buf(),
2680                phase: "test-timeout",
2681                timeout_ms: 42,
2682            })
2683        }
2684
2685        fn language(&self) -> Language {
2686            Language::Rust
2687        }
2688    }
2689
2690    struct SelectiveTimeoutGraphBuilder;
2691
2692    impl GraphBuilder for SelectiveTimeoutGraphBuilder {
2693        fn build_graph(
2694            &self,
2695            _tree: &Tree,
2696            _content: &[u8],
2697            file: &Path,
2698            staging: &mut StagingGraph,
2699        ) -> GraphResult<()> {
2700            use crate::graph::unified::build::helper::GraphBuildHelper;
2701
2702            let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
2703            let file_name = file
2704                .file_name()
2705                .and_then(|value| value.to_str())
2706                .unwrap_or_default();
2707
2708            if file_name == "timeout.rs" {
2709                helper.add_function("timeout_partial", None, false, false);
2710                return Err(GraphBuilderError::BuildTimedOut {
2711                    file: file.to_path_buf(),
2712                    phase: "test-timeout",
2713                    timeout_ms: 42,
2714                });
2715            }
2716
2717            helper.add_function("survivor_fn", None, false, false);
2718            Ok(())
2719        }
2720
2721        fn language(&self) -> Language {
2722            Language::Rust
2723        }
2724    }
2725
2726    #[test]
2727    fn test_build_config_default() {
2728        let config = BuildConfig::default();
2729        assert_eq!(config.max_depth, None);
2730        assert!(!config.follow_links);
2731        assert!(!config.include_hidden);
2732        assert_eq!(config.num_threads, None);
2733    }
2734
2735    #[test]
2736    #[serial]
2737    fn test_find_source_files_excludes_generated_dependency_roots() {
2738        let temp_dir = TempDir::new().expect("temp dir");
2739        let root = temp_dir.path();
2740
2741        fs::write(root.join("src.rs"), "fn src() {}").expect("write source file");
2742        for dir in [
2743            "_work",
2744            "_actions",
2745            "_update",
2746            "externals.2.334.0",
2747            "node_modules",
2748            "target",
2749            "vendor",
2750        ] {
2751            let nested = root.join(dir).join("nested");
2752            fs::create_dir_all(&nested).expect("create excluded dir");
2753            fs::write(nested.join("ignored.rs"), "fn ignored() {}")
2754                .expect("write ignored source file");
2755        }
2756        for dir in ["external_tools", "vendorized"] {
2757            let nested = root.join(dir).join("nested");
2758            fs::create_dir_all(&nested).expect("create included sibling dir");
2759            fs::write(nested.join("included.rs"), "fn included() {}")
2760                .expect("write included source file");
2761        }
2762
2763        let config = BuildConfig::default();
2764        let mut relative_files: Vec<_> = find_source_files(root, &config)
2765            .iter()
2766            .map(|path| path.strip_prefix(root).expect("strip root").to_path_buf())
2767            .collect();
2768        relative_files.sort();
2769
2770        assert_eq!(
2771            relative_files,
2772            vec![
2773                PathBuf::from("external_tools/nested/included.rs"),
2774                PathBuf::from("src.rs"),
2775                PathBuf::from("vendorized/nested/included.rs"),
2776            ]
2777        );
2778    }
2779
2780    #[test]
2781    #[serial]
2782    fn test_find_source_files_can_include_default_excluded_roots() {
2783        let temp_dir = TempDir::new().expect("temp dir");
2784        let root = temp_dir.path();
2785        let nested = root.join("vendor").join("first_party");
2786        fs::create_dir_all(&nested).expect("create vendor dir");
2787        fs::write(nested.join("included.rs"), "fn included() {}").expect("write included source");
2788
2789        unsafe {
2790            std::env::set_var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS", "1");
2791        }
2792        let config = BuildConfig::default();
2793        let files = find_source_files(root, &config);
2794        unsafe {
2795            std::env::remove_var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS");
2796        }
2797
2798        let relative_files: Vec<_> = files
2799            .iter()
2800            .map(|path| path.strip_prefix(root).expect("strip root").to_path_buf())
2801            .collect();
2802
2803        assert_eq!(
2804            relative_files,
2805            vec![PathBuf::from("vendor/first_party/included.rs")]
2806        );
2807    }
2808
2809    #[test]
2810    fn test_build_unified_graph_empty_registry_error() {
2811        let plugins = PluginManager::new();
2812        let config = BuildConfig::default();
2813        let root = std::path::Path::new(".");
2814
2815        let result = build_unified_graph(root, &plugins, &config);
2816        let err = result.expect_err("empty registry must error");
2817        // Task 7 Phase 7c: the internal pipeline now returns
2818        // `GraphBuilderError::Internal { reason }` instead of a bare
2819        // `anyhow::bail!`. The legacy `build_unified_graph` wrapper
2820        // lifts through `anyhow::Error::from`, which prefixes the
2821        // reason with the `GraphBuilderError::Internal` `Display`
2822        // string (`Internal graph builder error: ...`).
2823        assert_eq!(
2824            err.to_string(),
2825            "Internal graph builder error: No graph builders registered – cannot build code graph"
2826        );
2827    }
2828
2829    #[test]
2830    fn test_build_unified_graph_no_graph_builders_error() {
2831        let mut plugins = PluginManager::new();
2832        plugins.register_builtin(Box::new(TestPlugin::new(
2833            "rust-no-graph-builder",
2834            RUST_TEST_EXTENSIONS,
2835            None,
2836        )));
2837        let config = BuildConfig::default();
2838        let root = std::path::Path::new(".");
2839
2840        let result = build_unified_graph(root, &plugins, &config);
2841        let err = result.expect_err("no graph builders must error");
2842        assert_eq!(
2843            err.to_string(),
2844            "Internal graph builder error: No graph builders registered – cannot build code graph"
2845        );
2846    }
2847
2848    #[test]
2849    fn test_build_unified_graph_all_failures_error() {
2850        let temp_dir = TempDir::new().expect("temp dir");
2851        let file_path = temp_dir.path().join("fail.rs");
2852        fs::write(&file_path, "fn main() {}").expect("write test file");
2853
2854        let mut plugins = PluginManager::new();
2855        plugins.register_builtin(Box::new(TestPlugin::new(
2856            "rust-failing-graph-builder",
2857            RUST_TEST_EXTENSIONS,
2858            Some(Box::new(FailingGraphBuilder)),
2859        )));
2860        let config = BuildConfig::default();
2861
2862        let result = build_unified_graph(temp_dir.path(), &plugins, &config);
2863        let err = result.expect_err("all-failures must error");
2864        assert_eq!(
2865            err.to_string(),
2866            "Internal graph builder error: All graph builds failed"
2867        );
2868    }
2869
2870    #[test]
2871    fn test_parse_file_matches_uppercase_extension() {
2872        let temp_dir = TempDir::new().expect("temp dir");
2873        let file_path = temp_dir.path().join("report.Rmd");
2874        fs::write(&file_path, "fn main() {}").expect("write test file");
2875
2876        let mut plugins = PluginManager::new();
2877        plugins.register_builtin(Box::new(TestPlugin::new(
2878            "rust-filename-match",
2879            FILENAME_MATCH_EXTENSIONS,
2880            Some(Box::new(NoopGraphBuilder)),
2881        )));
2882        let mut graph = CodeGraph::new();
2883
2884        let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2885        commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2886    }
2887
2888    #[test]
2889    fn test_parse_file_matches_dotless_filename() {
2890        let temp_dir = TempDir::new().expect("temp dir");
2891        let file_path = temp_dir.path().join("bash_profile");
2892        fs::write(&file_path, "fn main() {}").expect("write test file");
2893
2894        let mut plugins = PluginManager::new();
2895        plugins.register_builtin(Box::new(TestPlugin::new(
2896            "rust-filename-match",
2897            FILENAME_MATCH_EXTENSIONS,
2898            Some(Box::new(NoopGraphBuilder)),
2899        )));
2900        let mut graph = CodeGraph::new();
2901
2902        let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2903        commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2904    }
2905
2906    #[test]
2907    fn test_parse_file_matches_pulumi_stack_filename() {
2908        let temp_dir = TempDir::new().expect("temp dir");
2909        let file_path = temp_dir.path().join("Pulumi.dev.yaml");
2910        fs::write(&file_path, "fn main() {}").expect("write test file");
2911
2912        let mut plugins = PluginManager::new();
2913        plugins.register_builtin(Box::new(TestPlugin::new(
2914            "pulumi",
2915            &["pulumi.yaml"],
2916            Some(Box::new(NoopGraphBuilder)),
2917        )));
2918        let mut graph = CodeGraph::new();
2919
2920        let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2921        commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2922    }
2923
2924    #[test]
2925    fn test_parse_file_returns_timed_out_outcome() {
2926        let temp_dir = TempDir::new().expect("temp dir");
2927        let file_path = temp_dir.path().join("timeout.rs");
2928        fs::write(&file_path, "fn main() {}").expect("write test file");
2929
2930        let mut plugins = PluginManager::new();
2931        plugins.register_builtin(Box::new(TestPlugin::new(
2932            "rust-timeout",
2933            RUST_TEST_EXTENSIONS,
2934            Some(Box::new(TimeoutGraphBuilder)),
2935        )));
2936
2937        let outcome = parse_file(&file_path, &plugins).expect("parse file");
2938        match outcome {
2939            ParsedFileOutcome::TimedOut {
2940                file,
2941                phase,
2942                timeout_ms,
2943            } => {
2944                assert_eq!(file, file_path);
2945                assert_eq!(phase, "test-timeout");
2946                assert_eq!(timeout_ms, 42);
2947            }
2948            other => panic!("expected timed out outcome, got {other:?}"),
2949        }
2950    }
2951
2952    #[test]
2953    #[serial]
2954    fn test_parse_file_rejects_oversized_input() {
2955        let temp_dir = TempDir::new().expect("temp dir");
2956        let file_path = temp_dir.path().join("oversized.rs");
2957        fs::write(&file_path, vec![b'a'; 1_048_577]).expect("write oversized file");
2958
2959        let mut plugins = PluginManager::new();
2960        plugins.register_builtin(Box::new(TestPlugin::new(
2961            "rust-oversized",
2962            RUST_TEST_EXTENSIONS,
2963            Some(Box::new(NoopGraphBuilder)),
2964        )));
2965
2966        unsafe {
2967            std::env::set_var("SQRY_MAX_SOURCE_FILE_SIZE", "1048576");
2968        }
2969        let err = parse_file(&file_path, &plugins).expect_err("oversized file should fail");
2970        unsafe {
2971            std::env::remove_var("SQRY_MAX_SOURCE_FILE_SIZE");
2972        }
2973
2974        let err_text = err.to_string();
2975        assert!(err_text.contains("oversized.rs"));
2976    }
2977
2978    #[test]
2979    fn test_build_unified_graph_skips_timed_out_file_without_partial_commit() {
2980        let temp_dir = TempDir::new().expect("temp dir");
2981        let ok_path = temp_dir.path().join("ok.rs");
2982        let timeout_path = temp_dir.path().join("timeout.rs");
2983        fs::write(&ok_path, "fn ok() {}").expect("write ok file");
2984        fs::write(&timeout_path, "fn timeout() {}").expect("write timeout file");
2985
2986        let mut plugins = PluginManager::new();
2987        plugins.register_builtin(Box::new(TestPlugin::new(
2988            "rust-selective-timeout",
2989            RUST_TEST_EXTENSIONS,
2990            Some(Box::new(SelectiveTimeoutGraphBuilder)),
2991        )));
2992        let config = BuildConfig::default();
2993
2994        let graph = build_unified_graph(temp_dir.path(), &plugins, &config)
2995            .expect("graph build should succeed with surviving files");
2996        let snapshot = graph.snapshot();
2997
2998        assert_eq!(snapshot.find_by_pattern("survivor_fn").len(), 1);
2999        assert!(
3000            snapshot.find_by_pattern("timeout_partial").is_empty(),
3001            "timed out file staging must not be committed"
3002        );
3003    }
3004
3005    // ========================================================================
3006    // Build pipeline consolidation regression tests
3007    // ========================================================================
3008
3009    /// A graph builder that creates a few nodes and edges for testing.
3010    struct SimpleGraphBuilder;
3011
3012    impl GraphBuilder for SimpleGraphBuilder {
3013        fn build_graph(
3014            &self,
3015            _tree: &Tree,
3016            _content: &[u8],
3017            file: &Path,
3018            staging: &mut StagingGraph,
3019        ) -> GraphResult<()> {
3020            use crate::graph::unified::build::helper::GraphBuildHelper;
3021
3022            let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
3023
3024            // Create two function nodes
3025            let fn1 = helper.add_function("main", None, false, false);
3026            let fn2 = helper.add_function("helper", None, false, false);
3027
3028            // Add a Calls edge from main -> helper
3029            helper.add_call_edge(fn1, fn2);
3030
3031            Ok(())
3032        }
3033
3034        fn language(&self) -> Language {
3035            Language::Rust
3036        }
3037    }
3038
3039    /// `build_and_persist_graph` returns a populated `BuildResult`.
3040    #[test]
3041    fn test_build_and_persist_graph_returns_build_result() {
3042        let temp_dir = TempDir::new().expect("temp dir");
3043        let file_path = temp_dir.path().join("test.rs");
3044        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3045
3046        let mut plugins = PluginManager::new();
3047        plugins.register_builtin(Box::new(TestPlugin::new(
3048            "rust-simple",
3049            RUST_TEST_EXTENSIONS,
3050            Some(Box::new(SimpleGraphBuilder)),
3051        )));
3052        let config = BuildConfig::default();
3053
3054        let result =
3055            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:build_result");
3056        assert!(result.is_ok(), "build_and_persist_graph should succeed");
3057
3058        let (_graph, build_result) = result.unwrap();
3059        assert!(build_result.node_count > 0, "Should have nodes");
3060        assert!(build_result.total_files > 0, "Should have indexed files");
3061        assert!(!build_result.built_at.is_empty(), "Should have timestamp");
3062        assert!(!build_result.root_path.is_empty(), "Should have root path");
3063    }
3064
3065    /// Deduplicated `edge_count` is always <= `raw_edge_count`.
3066    #[test]
3067    fn test_build_result_edge_count_le_raw() {
3068        let temp_dir = TempDir::new().expect("temp dir");
3069        let file_path = temp_dir.path().join("test.rs");
3070        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3071
3072        let mut plugins = PluginManager::new();
3073        plugins.register_builtin(Box::new(TestPlugin::new(
3074            "rust-simple",
3075            RUST_TEST_EXTENSIONS,
3076            Some(Box::new(SimpleGraphBuilder)),
3077        )));
3078        let config = BuildConfig::default();
3079
3080        let (_graph, build_result) =
3081            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:edge_count").unwrap();
3082
3083        assert!(
3084            build_result.edge_count <= build_result.raw_edge_count,
3085            "Deduplicated edge count ({}) should be <= raw edge count ({})",
3086            build_result.edge_count,
3087            build_result.raw_edge_count
3088        );
3089    }
3090
3091    /// File counts use plugin detection (keyed by plugin ID).
3092    #[test]
3093    fn test_build_and_persist_graph_file_counts_use_plugins() {
3094        let temp_dir = TempDir::new().expect("temp dir");
3095        let file_path = temp_dir.path().join("test.rs");
3096        fs::write(&file_path, "fn main() {}").expect("write test file");
3097
3098        let mut plugins = PluginManager::new();
3099        plugins.register_builtin(Box::new(TestPlugin::new(
3100            "rust-simple",
3101            RUST_TEST_EXTENSIONS,
3102            Some(Box::new(SimpleGraphBuilder)),
3103        )));
3104        let config = BuildConfig::default();
3105
3106        let (_graph, build_result) =
3107            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:file_counts")
3108                .unwrap();
3109
3110        // File counts should include the plugin's ID as the language key
3111        assert!(
3112            !build_result.file_count.is_empty(),
3113            "File counts should not be empty"
3114        );
3115        assert!(
3116            build_result.file_count.contains_key("rust-simple"),
3117            "File counts should use plugin ID. Got: {:?}",
3118            build_result.file_count
3119        );
3120    }
3121
3122    /// Manifest `edge_count` matches `BuildResult` (deduplicated).
3123    #[test]
3124    fn test_manifest_edge_count_is_deduplicated() {
3125        use crate::graph::unified::persistence::GraphStorage;
3126
3127        let temp_dir = TempDir::new().expect("temp dir");
3128        let file_path = temp_dir.path().join("test.rs");
3129        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3130
3131        let mut plugins = PluginManager::new();
3132        plugins.register_builtin(Box::new(TestPlugin::new(
3133            "rust-simple",
3134            RUST_TEST_EXTENSIONS,
3135            Some(Box::new(SimpleGraphBuilder)),
3136        )));
3137        let config = BuildConfig::default();
3138
3139        let (_graph, build_result) =
3140            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:manifest_dedup")
3141                .unwrap();
3142
3143        // Load manifest and verify edge counts match BuildResult
3144        let storage = GraphStorage::new(temp_dir.path());
3145        assert!(storage.exists(), "Manifest should exist after build");
3146
3147        let manifest = storage.load_manifest().unwrap();
3148        assert_eq!(
3149            manifest.edge_count, build_result.edge_count,
3150            "Manifest edge_count should match BuildResult (deduplicated)"
3151        );
3152        assert_eq!(
3153            manifest.raw_edge_count,
3154            Some(build_result.raw_edge_count),
3155            "Manifest raw_edge_count should match BuildResult"
3156        );
3157    }
3158
3159    /// Build command provenance is recorded in the manifest.
3160    #[test]
3161    fn test_build_command_provenance() {
3162        use crate::graph::unified::persistence::GraphStorage;
3163
3164        let temp_dir = TempDir::new().expect("temp dir");
3165        let file_path = temp_dir.path().join("test.rs");
3166        fs::write(&file_path, "fn main() {}").expect("write test file");
3167
3168        let mut plugins = PluginManager::new();
3169        plugins.register_builtin(Box::new(TestPlugin::new(
3170            "rust-simple",
3171            RUST_TEST_EXTENSIONS,
3172            Some(Box::new(SimpleGraphBuilder)),
3173        )));
3174        let config = BuildConfig::default();
3175
3176        build_and_persist_graph(temp_dir.path(), &plugins, &config, "cli:index").unwrap();
3177
3178        let storage = GraphStorage::new(temp_dir.path());
3179        let manifest = storage.load_manifest().unwrap();
3180        assert_eq!(
3181            manifest.build_provenance.build_command, "cli:index",
3182            "Build command provenance should match"
3183        );
3184    }
3185
3186    /// Wrapper-based builds infer plugin-selection provenance from the active
3187    /// plugin manager so non-CLI callers do not silently persist legacy-looking
3188    /// manifests.
3189    #[test]
3190    fn test_wrapper_infers_plugin_selection_from_manager() {
3191        use crate::graph::unified::persistence::GraphStorage;
3192
3193        let temp_dir = TempDir::new().expect("temp dir");
3194        let file_path = temp_dir.path().join("test.rs");
3195        fs::write(&file_path, "fn main() {}").expect("write test file");
3196
3197        let mut plugins = PluginManager::new();
3198        plugins.register_builtin(Box::new(TestPlugin::new(
3199            "rust-simple",
3200            RUST_TEST_EXTENSIONS,
3201            Some(Box::new(SimpleGraphBuilder)),
3202        )));
3203        let config = BuildConfig::default();
3204
3205        let (_graph, build_result) =
3206            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:wrapper_plugins")
3207                .expect("wrapper build should succeed");
3208
3209        assert_eq!(
3210            build_result.active_plugin_ids,
3211            vec!["rust-simple".to_string()],
3212            "build result should expose the inferred active plugin ids"
3213        );
3214
3215        let storage = GraphStorage::new(temp_dir.path());
3216        let manifest = storage.load_manifest().expect("manifest should load");
3217        let plugin_selection = manifest
3218            .plugin_selection
3219            .expect("wrapper should persist plugin selection metadata");
3220        assert_eq!(
3221            plugin_selection.active_plugin_ids,
3222            vec!["rust-simple".to_string()],
3223            "wrapper should persist the manager-derived plugin ids"
3224        );
3225        assert_eq!(
3226            plugin_selection.high_cost_mode, None,
3227            "wrapper-inferred plugin selection should keep high_cost_mode diagnostic-only"
3228        );
3229    }
3230
3231    /// Analysis identity hash matches the on-disk manifest bytes hash.
3232    #[test]
3233    fn test_analysis_identity_matches_manifest_hash() {
3234        use crate::graph::unified::analysis::persistence::load_csr;
3235        use crate::graph::unified::persistence::GraphStorage;
3236        use sha2::{Digest, Sha256};
3237
3238        let temp_dir = TempDir::new().expect("temp dir");
3239        let file_path = temp_dir.path().join("test.rs");
3240        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3241
3242        let mut plugins = PluginManager::new();
3243        plugins.register_builtin(Box::new(TestPlugin::new(
3244            "rust-simple",
3245            RUST_TEST_EXTENSIONS,
3246            Some(Box::new(SimpleGraphBuilder)),
3247        )));
3248        let config = BuildConfig::default();
3249
3250        build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:identity").unwrap();
3251
3252        let storage = GraphStorage::new(temp_dir.path());
3253
3254        // Compute manifest hash from on-disk manifest bytes
3255        let manifest_bytes = std::fs::read(storage.manifest_path()).unwrap();
3256        let expected_hash = hex::encode(Sha256::digest(&manifest_bytes));
3257
3258        // Load analysis identity from the CSR file (identity is embedded in each analysis file)
3259        let (_csr, identity) = load_csr(&storage.analysis_csr_path()).unwrap();
3260
3261        assert_eq!(
3262            identity.manifest_hash, expected_hash,
3263            "On-disk manifest hash should equal analysis identity hash"
3264        );
3265    }
3266
3267    /// Regression test: old manifest is removed at start of rebuild.
3268    ///
3269    /// Verifies that `build_and_persist_graph_with_progress()` removes any
3270    /// existing manifest before writing the new snapshot. This prevents the
3271    /// inconsistent state where an old manifest pairs with a new snapshot
3272    /// after an interrupted rebuild.
3273    #[test]
3274    fn test_old_manifest_removed_during_rebuild() {
3275        use crate::graph::unified::persistence::GraphStorage;
3276
3277        let temp_dir = tempfile::TempDir::new().unwrap();
3278        let src = temp_dir.path().join("lib.rs");
3279        std::fs::write(&src, "fn main() {}").unwrap();
3280
3281        // Build an initial index
3282        let mut plugins = PluginManager::new();
3283        plugins.register_builtin(Box::new(TestPlugin::new(
3284            "rust-simple",
3285            RUST_TEST_EXTENSIONS,
3286            Some(Box::new(SimpleGraphBuilder)),
3287        )));
3288        let config = BuildConfig::default();
3289        build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:initial").unwrap();
3290
3291        let storage = GraphStorage::new(temp_dir.path());
3292        assert!(
3293            storage.exists(),
3294            "Manifest should exist after initial build"
3295        );
3296
3297        // Record the original manifest's built_at timestamp
3298        let original_manifest = storage.load_manifest().unwrap();
3299        let original_built_at = original_manifest.built_at.clone();
3300
3301        // Rebuild — during the build, the old manifest should be removed first
3302        build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:rebuild").unwrap();
3303
3304        // Verify the manifest was replaced (different built_at timestamp)
3305        let new_manifest = storage.load_manifest().unwrap();
3306        assert_ne!(
3307            original_built_at, new_manifest.built_at,
3308            "Manifest should have been replaced with new timestamp"
3309        );
3310        assert_eq!(
3311            new_manifest.build_provenance.build_command, "test:rebuild",
3312            "Manifest should reflect the rebuild provenance"
3313        );
3314    }
3315
3316    /// Regression test: failed rebuild leaves index in non-ready state.
3317    ///
3318    /// Exercises the real pipeline by making the analysis directory
3319    /// non-writable after an initial build, then attempting a rebuild.
3320    /// The pipeline should:
3321    ///   1. Remove the old manifest (Step 2) — making `exists()` false.
3322    ///   2. Write the new snapshot (Step 3).
3323    ///   3. Fail at analysis persistence (Step 9) because the directory
3324    ///      is not writable.
3325    ///   4. Return an error — manifest is NEVER written.
3326    ///
3327    /// After the failed rebuild, `storage.exists()` must be false (old
3328    /// manifest removed), even though the snapshot file was updated.
3329    #[test]
3330    fn test_failed_rebuild_leaves_index_not_ready() {
3331        use crate::graph::unified::persistence::GraphStorage;
3332
3333        let temp_dir = tempfile::TempDir::new().unwrap();
3334        let src = temp_dir.path().join("lib.rs");
3335        std::fs::write(&src, "fn main() {}").unwrap();
3336
3337        // Build an initial index (success)
3338        let mut plugins = PluginManager::new();
3339        plugins.register_builtin(Box::new(TestPlugin::new(
3340            "rust-simple",
3341            RUST_TEST_EXTENSIONS,
3342            Some(Box::new(SimpleGraphBuilder)),
3343        )));
3344        let config = BuildConfig::default();
3345        build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:initial").unwrap();
3346
3347        let storage = GraphStorage::new(temp_dir.path());
3348        assert!(
3349            storage.exists(),
3350            "Manifest should exist after initial build"
3351        );
3352
3353        // Replace the analysis directory with a regular file to force a
3354        // failure at Step 9 (analysis persistence). `create_dir_all` will
3355        // fail because a regular file exists where a directory is expected.
3356        // This simulates the real failure window between snapshot write
3357        // (Step 3) and manifest write (Step 10).
3358        let analysis_dir = storage.analysis_dir().to_path_buf();
3359        std::fs::remove_dir_all(&analysis_dir).unwrap();
3360        std::fs::write(&analysis_dir, b"blocker").unwrap();
3361
3362        // Attempt rebuild — should fail at analysis persistence
3363        let result =
3364            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:failed_rebuild");
3365
3366        // Restore analysis dir so TempDir cleanup succeeds
3367        std::fs::remove_file(&analysis_dir).unwrap();
3368        std::fs::create_dir_all(&analysis_dir).unwrap();
3369
3370        // The build should have failed
3371        assert!(
3372            result.is_err(),
3373            "Rebuild should fail when analysis dir is read-only"
3374        );
3375
3376        // The old manifest should have been removed (Step 2 ran before failure)
3377        assert!(
3378            !storage.exists(),
3379            "After failed rebuild, manifest should have been removed — index is NOT ready"
3380        );
3381
3382        // The snapshot was updated (Step 3 succeeded before failure)
3383        assert!(
3384            storage.snapshot_exists(),
3385            "Snapshot should still exist on disk (written before failure)"
3386        );
3387    }
3388
3389    // ===== CSR Compaction Persistence Regression Tests =====
3390
3391    /// Graph builder that creates duplicate edges to exercise `raw_edge_count` > `edge_count`.
3392    struct DuplicateCallsGraphBuilder;
3393
3394    impl GraphBuilder for DuplicateCallsGraphBuilder {
3395        fn build_graph(
3396            &self,
3397            _tree: &Tree,
3398            _content: &[u8],
3399            file: &Path,
3400            staging: &mut StagingGraph,
3401        ) -> GraphResult<()> {
3402            use crate::graph::unified::build::helper::GraphBuildHelper;
3403
3404            let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
3405            let fn1 = helper.add_function("main", None, false, false);
3406            let fn2 = helper.add_function("helper", None, false, false);
3407
3408            // Add the same Calls edge twice to create a duplicate
3409            helper.add_call_edge(fn1, fn2);
3410            helper.add_call_edge(fn1, fn2);
3411
3412            Ok(())
3413        }
3414
3415        fn language(&self) -> Language {
3416            Language::Rust
3417        }
3418    }
3419
3420    /// Persisted snapshot has CSR on both stores and empty deltas.
3421    #[test]
3422    fn test_persisted_snapshot_compacts_both_edge_stores_before_save() {
3423        use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3424
3425        let temp_dir = TempDir::new().expect("temp dir");
3426        let file_path = temp_dir.path().join("test.rs");
3427        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3428
3429        let mut plugins = PluginManager::new();
3430        plugins.register_builtin(Box::new(TestPlugin::new(
3431            "rust-simple",
3432            RUST_TEST_EXTENSIONS,
3433            Some(Box::new(SimpleGraphBuilder)),
3434        )));
3435        let config = BuildConfig::default();
3436
3437        let _result =
3438            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:csr_compact")
3439                .expect("build should succeed");
3440
3441        // Load the persisted snapshot and verify CSR state
3442        let storage = GraphStorage::new(temp_dir.path());
3443        let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3444
3445        assert!(
3446            loaded.edges().forward().csr().is_some(),
3447            "Forward store must have CSR after persistence"
3448        );
3449        assert!(
3450            loaded.edges().reverse().csr().is_some(),
3451            "Reverse store must have CSR after persistence"
3452        );
3453
3454        let stats = loaded.edges().stats();
3455        assert_eq!(
3456            stats.forward.delta_edge_count, 0,
3457            "Forward delta must be empty after persistence"
3458        );
3459        assert_eq!(
3460            stats.reverse.delta_edge_count, 0,
3461            "Reverse delta must be empty after persistence"
3462        );
3463    }
3464
3465    /// Loaded snapshot supports reverse traversal (direct-callers / `edges_to`).
3466    #[test]
3467    fn test_loaded_snapshot_edges_to_works_after_round_trip() {
3468        use crate::graph::unified::edge::EdgeKind;
3469        use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3470        use crate::graph::unified::{
3471            FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery,
3472        };
3473
3474        let temp_dir = TempDir::new().expect("temp dir");
3475        let file_path = temp_dir.path().join("test.rs");
3476        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3477
3478        let mut plugins = PluginManager::new();
3479        plugins.register_builtin(Box::new(TestPlugin::new(
3480            "rust-simple",
3481            RUST_TEST_EXTENSIONS,
3482            Some(Box::new(SimpleGraphBuilder)),
3483        )));
3484        let config = BuildConfig::default();
3485
3486        build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:round_trip")
3487            .expect("build should succeed");
3488
3489        let storage = GraphStorage::new(temp_dir.path());
3490        let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3491
3492        // Find main and helper node IDs through symbol resolution
3493        let snapshot = loaded.snapshot();
3494
3495        let main_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3496            symbol: "main",
3497            file_scope: FileScope::Any,
3498            mode: ResolutionMode::AllowSuffixCandidates,
3499        }) {
3500            SymbolCandidateOutcome::Candidates(ids) => ids[0],
3501            _ => panic!("main node must exist"),
3502        };
3503
3504        let helper_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3505            symbol: "helper",
3506            file_scope: FileScope::Any,
3507            mode: ResolutionMode::AllowSuffixCandidates,
3508        }) {
3509            SymbolCandidateOutcome::Candidates(ids) => ids[0],
3510            _ => panic!("helper node must exist"),
3511        };
3512
3513        // Forward: main -> helper
3514        let forward_edges = loaded.edges().edges_from(main_id);
3515        let has_call = forward_edges
3516            .iter()
3517            .any(|e| e.target == helper_id && matches!(e.kind, EdgeKind::Calls { .. }));
3518        assert!(has_call, "Forward traversal: main should call helper");
3519
3520        // Reverse: helper <- main (the critical regression check)
3521        let reverse_edges = loaded.edges().edges_to(helper_id);
3522        let has_caller = reverse_edges
3523            .iter()
3524            .any(|e| e.source == main_id && matches!(e.kind, EdgeKind::Calls { .. }));
3525        assert!(
3526            has_caller,
3527            "Reverse traversal: helper should have main as caller"
3528        );
3529    }
3530
3531    /// `raw_edge_count` >= `edge_count` still holds after pre-save compaction.
3532    #[test]
3533    fn test_raw_edge_count_preserved_across_pre_save_compaction() {
3534        use crate::graph::unified::persistence::GraphStorage;
3535
3536        let temp_dir = TempDir::new().expect("temp dir");
3537        let file_path = temp_dir.path().join("test.rs");
3538        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3539
3540        let mut plugins = PluginManager::new();
3541        plugins.register_builtin(Box::new(TestPlugin::new(
3542            "rust-dup",
3543            RUST_TEST_EXTENSIONS,
3544            Some(Box::new(DuplicateCallsGraphBuilder)),
3545        )));
3546        let config = BuildConfig::default();
3547
3548        let (_graph, build_result) =
3549            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:raw_edge_count")
3550                .expect("build should succeed");
3551
3552        assert!(
3553            build_result.raw_edge_count > build_result.edge_count,
3554            "raw_edge_count ({}) must be > edge_count ({}) for duplicate builder",
3555            build_result.raw_edge_count,
3556            build_result.edge_count
3557        );
3558
3559        // Verify manifest matches
3560        let storage = GraphStorage::new(temp_dir.path());
3561        let manifest = storage.load_manifest().expect("manifest should load");
3562
3563        assert_eq!(
3564            manifest.raw_edge_count,
3565            Some(build_result.raw_edge_count),
3566            "Manifest raw_edge_count must match build result"
3567        );
3568        assert_eq!(
3569            manifest.edge_count, build_result.edge_count,
3570            "Manifest edge_count must match build result"
3571        );
3572    }
3573
3574    /// Full round-trip: build -> save -> load -> query produces correct results.
3575    #[test]
3576    fn test_build_save_load_query_round_trip_preserves_edge_queries() {
3577        use crate::graph::unified::edge::EdgeKind;
3578        use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3579        use crate::graph::unified::{
3580            FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery,
3581        };
3582
3583        let temp_dir = TempDir::new().expect("temp dir");
3584        let file_path = temp_dir.path().join("test.rs");
3585        fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3586
3587        let mut plugins = PluginManager::new();
3588        plugins.register_builtin(Box::new(TestPlugin::new(
3589            "rust-simple",
3590            RUST_TEST_EXTENSIONS,
3591            Some(Box::new(SimpleGraphBuilder)),
3592        )));
3593        let config = BuildConfig::default();
3594
3595        let (_original_graph, build_result) =
3596            build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:full_round_trip")
3597                .expect("build should succeed");
3598
3599        // Load from disk
3600        let storage = GraphStorage::new(temp_dir.path());
3601        let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3602
3603        // Edge count on loaded graph should match dedup count
3604        assert_eq!(
3605            loaded.edge_count(),
3606            build_result.edge_count,
3607            "Loaded graph edge count must match build result dedup count"
3608        );
3609
3610        // Node count should match
3611        assert_eq!(
3612            loaded.node_count(),
3613            build_result.node_count,
3614            "Loaded graph node count must match build result"
3615        );
3616
3617        // Verify edge queries work on loaded graph
3618        let snapshot = loaded.snapshot();
3619
3620        let main_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3621            symbol: "main",
3622            file_scope: FileScope::Any,
3623            mode: ResolutionMode::AllowSuffixCandidates,
3624        }) {
3625            SymbolCandidateOutcome::Candidates(ids) => {
3626                assert!(!ids.is_empty(), "main must exist");
3627                ids[0]
3628            }
3629            _ => panic!("main node must exist"),
3630        };
3631
3632        let helper_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3633            symbol: "helper",
3634            file_scope: FileScope::Any,
3635            mode: ResolutionMode::AllowSuffixCandidates,
3636        }) {
3637            SymbolCandidateOutcome::Candidates(ids) => {
3638                assert!(!ids.is_empty(), "helper must exist");
3639                ids[0]
3640            }
3641            _ => panic!("helper node must exist"),
3642        };
3643
3644        // Forward query: main calls helper
3645        let fwd = loaded.edges().edges_from(main_id);
3646        let has_fwd_call = fwd
3647            .iter()
3648            .any(|e| e.target == helper_id && matches!(e.kind, EdgeKind::Calls { .. }));
3649        assert!(has_fwd_call, "edges_from(main) must include call to helper");
3650
3651        // Reverse query: helper called by main
3652        let rev = loaded.edges().edges_to(helper_id);
3653        let has_rev_call = rev
3654            .iter()
3655            .any(|e| e.source == main_id && matches!(e.kind, EdgeKind::Calls { .. }));
3656        assert!(has_rev_call, "edges_to(helper) must include caller main");
3657    }
3658
3659    // -----------------------------------------------------------------
3660    // Phase 7c cancellation wire-through tests (task 7 phase 7c)
3661    // -----------------------------------------------------------------
3662    //
3663    // The four cancellation-boundary tests below exercise the pipeline
3664    // at distinct points in `build_unified_graph_inner`:
3665    //
3666    //   1. preflight — token cancelled before the first boundary; no
3667    //      FS walk, no parse, no Phase 4 work.
3668    //   2. mid-chunk — token flipped after the first chunk commits via
3669    //      the AfterChunkHookGuard; second chunk never parses.
3670    //   3. pre-Phase-4 — token flipped after the chunk loop exits via
3671    //      the BeforePhase4HookGuard; Phase 4a+ never runs.
3672    //   4. pre-Pass-5 — token flipped before cross-language linking
3673    //      via the BeforePass5HookGuard; Pass 5 never runs.
3674    //
3675    // A fifth test confirms the backwards-compatible default path
3676    // (no cancellation arg) still returns a fully-built graph.
3677
3678    fn build_rust_test_fixture(dir: &Path, file_count: usize) {
3679        for i in 0..file_count {
3680            let path = dir.join(format!("fixture_{i}.rs"));
3681            fs::write(&path, format!("pub fn fn_{i}() {{ let _ = {i}; }}")).expect("write fixture");
3682        }
3683    }
3684
3685    fn make_rust_test_plugins() -> PluginManager {
3686        let mut plugins = PluginManager::new();
3687        plugins.register_builtin(Box::new(TestPlugin::new(
3688            "rust-noop-for-cancellation-tests",
3689            RUST_TEST_EXTENSIONS,
3690            Some(Box::new(NoopGraphBuilder)),
3691        )));
3692        plugins
3693    }
3694
3695    #[test]
3696    fn build_unified_graph_cancellable_preflight_cancellation_returns_cancelled() {
3697        let tmp = TempDir::new().expect("tmp");
3698        build_rust_test_fixture(tmp.path(), 4);
3699        let plugins = make_rust_test_plugins();
3700        let config = BuildConfig::default();
3701
3702        let cancel = CancellationToken::new();
3703        cancel.cancel();
3704
3705        let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3706        let err = result.expect_err("pre-cancelled token must short-circuit");
3707        assert!(
3708            matches!(err, GraphBuilderError::Cancelled),
3709            "expected Cancelled, got: {err:?}"
3710        );
3711    }
3712
3713    #[test]
3714    fn build_unified_graph_cancellable_mid_chunk_cancellation_returns_cancelled() {
3715        let tmp = TempDir::new().expect("tmp");
3716        // Force multiple chunks by setting a tiny staging_memory_limit.
3717        build_rust_test_fixture(tmp.path(), 8);
3718        let plugins = make_rust_test_plugins();
3719        // A very small memory limit forces ~1 file per chunk.
3720        let config = BuildConfig {
3721            staging_memory_limit: 1,
3722            ..BuildConfig::default()
3723        };
3724
3725        let cancel = CancellationToken::new();
3726
3727        // Install a hook that cancels after the FIRST chunk. The hook
3728        // fires at the TOP of every chunk iteration (including chunk 0
3729        // before cancelling). We cancel on the first call; the next
3730        // iteration's top-of-loop `cancellation.check()` short-circuits.
3731        let cancel_for_hook = cancel.clone();
3732        let mut call_count = 0u32;
3733        let _guard = testing::AfterChunkHookGuard::install(move |tok| {
3734            call_count += 1;
3735            if call_count >= 2 {
3736                cancel_for_hook.cancel();
3737                // `tok` is the same shared Arc under the hood.
3738                assert!(tok.is_cancelled());
3739            }
3740        });
3741
3742        let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3743        let err = result.expect_err("mid-chunk cancellation must short-circuit");
3744        assert!(
3745            matches!(err, GraphBuilderError::Cancelled),
3746            "expected Cancelled, got: {err:?}"
3747        );
3748    }
3749
3750    #[test]
3751    fn build_unified_graph_cancellable_pre_phase4_cancellation_short_circuits() {
3752        let tmp = TempDir::new().expect("tmp");
3753        build_rust_test_fixture(tmp.path(), 4);
3754        let plugins = make_rust_test_plugins();
3755        let config = BuildConfig::default();
3756
3757        let cancel = CancellationToken::new();
3758        let cancel_for_hook = cancel.clone();
3759        let _guard = testing::BeforePhase4HookGuard::install(move |_tok| {
3760            cancel_for_hook.cancel();
3761        });
3762
3763        let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3764        let err = result.expect_err("pre-Phase-4 cancellation must short-circuit");
3765        assert!(
3766            matches!(err, GraphBuilderError::Cancelled),
3767            "expected Cancelled, got: {err:?}"
3768        );
3769    }
3770
3771    #[test]
3772    fn build_unified_graph_cancellable_pre_pass5_cancellation_short_circuits() {
3773        let tmp = TempDir::new().expect("tmp");
3774        build_rust_test_fixture(tmp.path(), 4);
3775        let plugins = make_rust_test_plugins();
3776        let config = BuildConfig::default();
3777
3778        let cancel = CancellationToken::new();
3779        let cancel_for_hook = cancel.clone();
3780        let _guard = testing::BeforePass5HookGuard::install(move |_tok| {
3781            cancel_for_hook.cancel();
3782        });
3783
3784        let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3785        let err = result.expect_err("pre-Pass-5 cancellation must short-circuit");
3786        assert!(
3787            matches!(err, GraphBuilderError::Cancelled),
3788            "expected Cancelled, got: {err:?}"
3789        );
3790    }
3791
3792    #[test]
3793    fn build_unified_graph_default_path_is_backwards_compatible() {
3794        let tmp = TempDir::new().expect("tmp");
3795        build_rust_test_fixture(tmp.path(), 3);
3796        let plugins = make_rust_test_plugins();
3797        let config = BuildConfig::default();
3798
3799        // Legacy API: no cancellation parameter. Must return a
3800        // built graph without triggering cancellation short-circuits.
3801        // (The test plugin is a NoopGraphBuilder that produces zero
3802        // nodes; we only assert the success path returns Ok.)
3803        let _graph = build_unified_graph(tmp.path(), &plugins, &config)
3804            .expect("legacy path must still build successfully");
3805    }
3806}