projectatlas/runtime/
graph_projection.rs

1//! Normalize parser-owned symbol facts into one generation-bound repository graph.
2
3use super::{
4    CliError, INDEX_FRESHNESS_SAMPLE_LIMIT, IndexReadStatus, IndexRefreshReason,
5    IndexRefreshRequired, IndexRefreshScope, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
6    IndexWorkStage, Node, NodeKind, SymbolBuildStage, SymbolProjectionChange,
7    normalize_native_path_display,
8};
9use projectatlas_core::IndexGeneration;
10use projectatlas_core::graph::{
11    CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
12    CoverageState, EntityResolutionKey, EntitySelector, ExtendedRelationKind, ExternalSelector,
13    GraphContractError, GraphEntity, GraphIdentityText, GraphLimits, GraphRelationKind,
14    LogicalRelation, PackageSelector, ProjectInstanceId, RelationDependencyKey, RelationOccurrence,
15    RelationResolution, RepositoryFilePath, RepositoryNodePath, SourceSpan, SymbolSelector,
16};
17use projectatlas_core::language::{SemanticProviderOwner, language_capability};
18use projectatlas_core::symbols::{
19    ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolRelation,
20};
21use projectatlas_db::{
22    AtlasStore, IndexPublicationGuard, RepositoryAffectedSourceFootprint,
23    RepositoryResolutionCandidate,
24};
25use projectatlas_symbols::{
26    ConfiguredModuleResolution, MAX_RESOLUTION_KEYS_PER_FACT, ResolutionKeyProjection,
27    ResolutionProjectionContext, ResolutionProjectionError, derive_resolution_keys_with_context,
28    parse_import_references,
29};
30use std::borrow::{Borrow, Cow};
31use std::cmp::Reverse;
32use std::collections::{BTreeMap, BTreeSet, BinaryHeap, btree_map::Entry};
33use std::fs::{self, File, OpenOptions};
34use std::num::NonZeroU32;
35use std::path::{Path, PathBuf};
36use tempfile::{Builder as TempDirBuilder, TempDir};
37
38/// Maximum canonical keys or distinct source paths admitted by one incremental closure.
39const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
40/// Maximum aggregate normalized graph rows admitted by one incremental closure.
41const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
42/// Maximum conservative graph bytes admitted before requesting a complete refresh.
43const MAX_INCREMENTAL_GRAPH_BYTES: u64 = super::MAX_PUBLICATION_STAGING_BYTES;
44/// Maximum persisted key bindings retained by one complete in-memory graph projection.
45const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
46/// Conservative fixed bytes counted for each staged graph or binding row.
47const STAGED_GRAPH_ROW_BYTES: u64 = 128;
48/// Maximum graph-map rows processed between cooperative cancellation checks.
49const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
50/// Maximum simultaneous parser/entity/key bytes before rows spill to typed `SQLite` staging.
51const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
52/// Borrowed entity references inserted per disposable staging call.
53const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
54/// Maximum normalized graph rows retained between disposable staging writes.
55const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
56/// Direct child prefix owned by disposable graph staging.
57const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
58/// Stable cross-process lease protecting active staging and restart cleanup.
59const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
60/// Typed disposable database inside every owned staging directory.
61const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
62/// Internal invariant failure for a staging owner already entering teardown.
63const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
64/// Maximum persisted symbol-graph paths reconstructed between work checks.
65const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
66/// Stable fallback for a parser relation that omitted a usable display target.
67const UNKNOWN_REFERENCE: &str = "unknown-reference";
68/// Stable package manager for currently extracted Cargo manifest ownership.
69const CARGO_PACKAGE_MANAGER: &str = "cargo";
70/// Stable external namespace for the Rust standard-library distribution.
71const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
72/// Stable external namespace for explicitly qualified Node.js built-ins.
73const NODE_SYSTEM: &str = "node";
74/// Honest coverage diagnostic for fallback or structural relation extraction.
75const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
76/// External namespace for content-free configuration identities.
77const CONFIGURATION_SYSTEM: &str = "configuration";
78/// External namespace for static environment-variable identities.
79const ENVIRONMENT_SYSTEM: &str = "environment-variable";
80/// External namespace for content-free deployment platform identities.
81const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
82
83/// One graph mutation staged outside the database writer transaction.
84pub(super) enum RepositoryGraphMutation {
85    /// Replace the complete normalized graph.
86    Full,
87    /// Replace the exact admitted source closure while retaining unrelated rows.
88    AffectedPaths(Vec<String>),
89}
90
91/// Complete normalized graph and canonical-key rows waiting for publication.
92pub(super) struct StagedRepositoryGraph {
93    /// Project identity owning every staged graph fact.
94    project: ProjectInstanceId,
95    /// Full or affected-path replacement selected by the staging caller.
96    mutation: RepositoryGraphMutation,
97    /// Stable typed entities ready for normalized persistence.
98    entities: Vec<GraphEntity>,
99    /// Deduplicated logical relationships ready for persistence.
100    relations: Vec<LogicalRelation>,
101    /// Exact source occurrences retained separately from logical relations.
102    occurrences: Vec<RelationOccurrence>,
103    /// Parser and relation coverage facts for staged source paths.
104    coverage: Vec<CoverageRecord>,
105    /// Canonical resolution keys exported by staged entities.
106    entity_exports: Vec<EntityResolutionKey>,
107    /// Canonical dependency keys retained by staged relations.
108    relation_dependencies: Vec<RelationDependencyKey>,
109    /// Optional disposable database replacing the in-memory row vectors.
110    database: Option<StagedGraphDatabase>,
111    /// Conservative bytes retained until the parent publication completes.
112    retained_bytes: u64,
113}
114
115/// File-backed graph rows staged outside the main database writer transaction.
116struct StagedGraphDatabase {
117    /// Open typed store copied into the main publication.
118    store: Option<AtlasStore>,
119    /// Owning directory removed after the store closes.
120    directory: Option<TempDir>,
121    /// Cross-process lease preventing restart cleanup while the stage is active.
122    _lease: File,
123}
124
125impl StagedGraphDatabase {
126    /// Return the live typed staging store.
127    fn store(&self) -> Result<&AtlasStore, CliError> {
128        self.store
129            .as_ref()
130            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
131    }
132
133    /// Return the live typed staging store mutably during preparation.
134    fn store_mut(&mut self) -> Result<&mut AtlasStore, CliError> {
135        self.store
136            .as_mut()
137            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
138    }
139
140    /// Return the live disposable staging directory.
141    fn directory(&self) -> Result<&TempDir, CliError> {
142        self.directory
143            .as_ref()
144            .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
145    }
146}
147
148impl Drop for StagedGraphDatabase {
149    fn drop(&mut self) {
150        let prepared = self.store.take().is_some();
151        let Some(directory) = self.directory.take() else {
152            return;
153        };
154        let database_path = directory.path().join(GRAPH_STAGE_DATABASE_FILE_NAME);
155        let direct_database = fs::symlink_metadata(&database_path).is_ok_and(|metadata| {
156            metadata.file_type().is_file() && !metadata.file_type().is_symlink()
157        });
158        if prepared
159            && direct_database
160            && remove_owned_graph_stage_payload(directory.path(), &database_path, None).is_ok()
161        {
162            drop(directory);
163        } else {
164            let _retained_path: PathBuf = directory.keep();
165        }
166    }
167}
168
169impl StagedRepositoryGraph {
170    /// Return conservative retained bytes counted toward the parent staging budget.
171    pub(super) const fn retained_bytes(&self) -> u64 {
172        self.retained_bytes
173    }
174
175    /// Apply the complete staged graph through the parent publication transaction.
176    pub(super) fn apply(
177        &self,
178        publication: &mut IndexPublicationGuard<'_>,
179        control: &IndexWorkControl,
180    ) -> Result<(), CliError> {
181        control.check(IndexWorkStage::Publication)?;
182        if let Some(database) = &self.database {
183            if !database.directory()?.path().is_dir() {
184                return Err(CliError::InvalidInput(
185                    "repository graph staging directory is unavailable".to_string(),
186                ));
187            }
188            if !matches!(self.mutation, RepositoryGraphMutation::Full) {
189                return Err(CliError::InvalidInput(
190                    "database-backed graph staging only supports full replacement".to_string(),
191                ));
192            }
193            publication.replace_repository_graph_from_staging(
194                self.project,
195                database.store()?,
196                Some(control),
197            )?;
198            control.check(IndexWorkStage::Publication)?;
199            return Ok(());
200        }
201        match &self.mutation {
202            RepositoryGraphMutation::Full => {
203                publication.replace_repository_graph_with_resolution_keys(
204                    self.project,
205                    &self.entities,
206                    &self.relations,
207                    &self.occurrences,
208                    &self.coverage,
209                    &self.entity_exports,
210                    &self.relation_dependencies,
211                )?;
212            }
213            RepositoryGraphMutation::AffectedPaths(paths) => {
214                publication.replace_repository_graph_for_paths_with_resolution_keys(
215                    self.project,
216                    paths,
217                    &self.entities,
218                    &self.relations,
219                    &self.occurrences,
220                    &self.coverage,
221                    &self.entity_exports,
222                    &self.relation_dependencies,
223                )?;
224            }
225        }
226        control.check(IndexWorkStage::Publication)?;
227        Ok(())
228    }
229}
230
231/// Stage a complete repository graph from current parser output plus safe reused graphs.
232pub(super) fn stage_full_repository_graph(
233    store: &AtlasStore,
234    root: &Path,
235    base_generation: IndexGeneration,
236    nodes: &[Node],
237    symbols: &SymbolBuildStage,
238    control: &IndexWorkControl,
239) -> Result<StagedRepositoryGraph, CliError> {
240    cleanup_abandoned_repository_graph_staging(store, root, control)?;
241    let project = selected_project(store)?;
242    let generation = next_generation(base_generation)?;
243    let paths = nodes
244        .iter()
245        .filter(|node| node.kind == NodeKind::File)
246        .map(|node| node.path.clone())
247        .collect::<BTreeSet<_>>();
248    let graphs = complete_symbol_graphs(store, &paths, symbols, control)?;
249    control.check(IndexWorkStage::SymbolParsing)?;
250    let configured_modules =
251        super::module_resolution::load_configured_module_resolution(root, nodes, control)?;
252    let packages = PackageIndex::from_graphs(&graphs)?;
253    let entity_projection = build_entity_projection_with_config(
254        project,
255        generation,
256        nodes,
257        &graphs,
258        &packages,
259        &configured_modules,
260        true,
261        control,
262    )?;
263    let candidates = resolution_registry_from_exports(&entity_projection, control)?;
264    enforce_resolution_staging_budget(&entity_projection, &candidates)?;
265    let graph_work_bytes = symbols
266        .retained_bytes
267        .saturating_add(entity_projection.retained_bytes)
268        .saturating_add(candidates.retained_bytes);
269    if graph_work_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
270        finish_projection_in_database(
271            root,
272            nodes,
273            project,
274            generation,
275            &graphs,
276            entity_projection,
277            &candidates,
278            control,
279        )
280    } else {
281        finish_projection(
282            project,
283            generation,
284            RepositoryGraphMutation::Full,
285            &graphs,
286            entity_projection,
287            &candidates,
288            control,
289        )
290    }
291}
292
293/// Stage one bounded dependency-aware graph closure for an incremental publication.
294pub(super) fn stage_incremental_repository_graph(
295    store: &AtlasStore,
296    root: &Path,
297    base_generation: IndexGeneration,
298    expected_nodes: &[Node],
299    direct_paths: &[String],
300    symbols: &SymbolBuildStage,
301    control: &IndexWorkControl,
302) -> Result<StagedRepositoryGraph, CliError> {
303    let project = selected_project(store)?;
304    let generation = next_generation(base_generation)?;
305    let configured_modules =
306        super::module_resolution::load_configured_module_resolution(root, expected_nodes, control)?;
307    let direct_paths = direct_paths.iter().cloned().collect::<BTreeSet<_>>();
308    enforce_incremental_count(
309        root,
310        "direct source paths",
311        direct_paths.len(),
312        &direct_paths,
313    )?;
314    let _direct_footprint =
315        admitted_persisted_footprint(store, project, root, &direct_paths, control)?;
316
317    let current_file_paths = expected_nodes
318        .iter()
319        .filter(|node| node.kind == NodeKind::File)
320        .map(|node| node.path.clone())
321        .collect::<BTreeSet<_>>();
322    let manifest_paths = expected_nodes
323        .iter()
324        .filter(|node| node.kind == NodeKind::File && is_cargo_manifest_path(&node.path))
325        .map(|node| node.path.clone())
326        .collect::<BTreeSet<_>>();
327    let package_graph_paths = direct_paths
328        .intersection(&current_file_paths)
329        .cloned()
330        .chain(manifest_paths.iter().cloned())
331        .collect::<BTreeSet<_>>();
332    let package_graphs = complete_symbol_graphs(store, &package_graph_paths, symbols, control)?;
333    let packages = PackageIndex::from_graphs(&package_graphs)?;
334    let direct_graphs = package_graphs
335        .iter()
336        .filter(|graph| direct_paths.contains(&graph.path))
337        .cloned()
338        .collect::<Vec<_>>();
339
340    let old_exports = store.repository_export_keys_for_paths(
341        project,
342        &direct_paths.iter().cloned().collect::<Vec<_>>(),
343        MAX_INCREMENTAL_RESOLUTION_ITEMS,
344    )?;
345    if old_exports.truncated {
346        return Err(dependency_closure_limit(
347            root,
348            direct_paths.iter().cloned(),
349            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
350        ));
351    }
352    let mut changed_keys = old_exports.rows.into_iter().collect::<BTreeSet<_>>();
353    for graph in &direct_graphs {
354        control.check(IndexWorkStage::SymbolParsing)?;
355        let projection = resolution_projection_with_config(
356            project,
357            packages.package_name(&graph.path),
358            graph,
359            &configured_modules,
360        )?;
361        changed_keys.extend(projection.source_keys().iter().cloned());
362        for symbol in projection.symbol_keys() {
363            changed_keys.extend(symbol.keys().iter().cloned());
364        }
365    }
366    enforce_incremental_count(
367        root,
368        "old and new export keys",
369        changed_keys.len(),
370        &direct_paths,
371    )?;
372
373    let inbound = store.repository_affected_source_paths(
374        project,
375        &changed_keys.iter().cloned().collect::<Vec<_>>(),
376        MAX_INCREMENTAL_RESOLUTION_ITEMS,
377    )?;
378    if inbound.truncated {
379        return Err(dependency_closure_limit(
380            root,
381            inbound.rows.iter().map(|path| path.as_str().to_string()),
382            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
383        ));
384    }
385    let mut affected_paths = direct_paths;
386    affected_paths.extend(inbound.rows.into_iter().map(String::from));
387    enforce_incremental_count(
388        root,
389        "affected source paths",
390        affected_paths.len(),
391        &affected_paths,
392    )?;
393    control.check(IndexWorkStage::SymbolParsing)?;
394    let persisted_footprint =
395        admitted_persisted_footprint(store, project, root, &affected_paths, control)?;
396
397    let affected_graph_paths = affected_paths
398        .intersection(&current_file_paths)
399        .cloned()
400        .collect::<BTreeSet<_>>();
401    let affected_graphs = complete_symbol_graphs(store, &affected_graph_paths, symbols, control)?;
402    let affected_nodes = expected_nodes
403        .iter()
404        .filter(|node| affected_paths.contains(&node.path))
405        .cloned()
406        .collect::<Vec<_>>();
407    let entity_projection = build_entity_projection_with_config(
408        project,
409        generation,
410        &affected_nodes,
411        &affected_graphs,
412        &packages,
413        &configured_modules,
414        false,
415        control,
416    )?;
417
418    let dependency_keys = entity_projection
419        .keys_by_graph
420        .values()
421        .flat_map(ResolutionKeyProjection::relation_keys)
422        .flat_map(|relation| relation.keys().iter().cloned())
423        .collect::<BTreeSet<_>>();
424    enforce_incremental_count(
425        root,
426        "affected dependency keys",
427        dependency_keys.len(),
428        &affected_paths,
429    )?;
430    let persisted = store.repository_resolution_candidates_for_keys(
431        project,
432        &dependency_keys.iter().cloned().collect::<Vec<_>>(),
433        MAX_INCREMENTAL_RESOLUTION_ITEMS,
434    )?;
435    if persisted.truncated {
436        return Err(dependency_closure_limit(
437            root,
438            affected_paths.iter().cloned(),
439            usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
440        ));
441    }
442    let mut candidates = resolution_registry_from_persisted(
443        project,
444        generation,
445        persisted.rows,
446        &affected_paths,
447        control,
448    )?;
449    merge_resolution_registries(
450        &mut candidates,
451        resolution_registry_from_exports(&entity_projection, control)?,
452        control,
453    )?;
454    enforce_resolution_staging_budget(&entity_projection, &candidates)?;
455    let staged = finish_projection(
456        project,
457        generation,
458        RepositoryGraphMutation::AffectedPaths(affected_paths.iter().cloned().collect()),
459        &affected_graphs,
460        entity_projection,
461        &candidates,
462        control,
463    )?;
464    enforce_incremental_projection_limits(root, &affected_paths, persisted_footprint, &staged)?;
465    Ok(staged)
466}
467
468/// Entity/key facts prepared before relation resolution.
469struct EntityProjection {
470    /// Entities keyed by their compact digest for collision-safe lookup.
471    entity_by_digest: BTreeMap<String, GraphEntity>,
472    /// File and symbol owners keyed by parser graph path.
473    owners_by_graph: BTreeMap<String, GraphOwners>,
474    /// Canonical resolution-key projections keyed by parser graph path.
475    keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
476    /// Canonical resolution keys exported by staged entities.
477    entity_exports: Vec<EntityResolutionKey>,
478    /// Conservative bytes retained by entities and export keys.
479    retained_bytes: u64,
480}
481
482/// File and symbol entities associated with one parser graph.
483struct GraphOwners {
484    /// Digest of the file entity owning the parser graph.
485    file_digest: String,
486    /// Optional stable entity digest corresponding to each parser symbol row.
487    symbol_digests: Vec<Option<String>>,
488}
489
490/// Borrowed per-graph symbol lookup used by every relation in that file.
491struct GraphSymbolIndex<'graph> {
492    /// Parser symbol indices grouped by exact source name.
493    indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
494}
495
496impl<'graph> GraphSymbolIndex<'graph> {
497    /// Build one bounded name index instead of rescanning all symbols per relation.
498    fn new(graph: &'graph SymbolGraph, control: &IndexWorkControl) -> Result<Self, CliError> {
499        let mut indices_by_name = BTreeMap::new();
500        for (index, symbol) in graph.symbols.iter().enumerate() {
501            check_graph_work(control, index)?;
502            indices_by_name
503                .entry(symbol.name.as_str())
504                .or_insert_with(Vec::new)
505                .push(index);
506        }
507        Ok(Self { indices_by_name })
508    }
509
510    /// Return exact parser symbol rows for one source name.
511    fn get(&self, name: &str) -> &[usize] {
512        self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
513    }
514}
515
516/// One conservative additive relation derived from already bounded parser facts.
517struct DerivedRelationFact {
518    /// Additive graph relation kind.
519    kind: ExtendedRelationKind,
520    /// Parser-compatible source, target, span, context, and trust facts.
521    relation: SymbolRelation,
522    /// Resolution strategy for the derived target.
523    target: DerivedRelationTarget,
524}
525
526/// Closed target classes accepted by additive relation projection.
527enum DerivedRelationTarget {
528    /// Resolve through the same typed provider keys as one parser relation.
529    Parser {
530        /// Canonical provider-owned dependency keys.
531        keys: Vec<CanonicalResolutionKey>,
532    },
533    /// Resolve one statically visible repository-relative file path.
534    RepositoryPath,
535    /// Bind one content-free external namespace and identity.
536    External {
537        /// Closed external namespace.
538        system: &'static str,
539    },
540}
541
542/// Package names assigned by longest manifest-directory ownership.
543struct PackageIndex {
544    /// Cargo packages ordered from the most-specific repository root.
545    packages: Vec<PackageOwner>,
546}
547
548/// One Cargo package and its owning repository prefix.
549struct PackageOwner {
550    /// Repository prefix owned by this package.
551    root: String,
552    /// Canonical package name from the manifest graph.
553    name: String,
554    /// Repository-relative manifest path proving ownership.
555    manifest: String,
556}
557
558impl PackageIndex {
559    /// Build deterministic longest-prefix package ownership from manifest graphs.
560    fn from_graphs(graphs: &[impl Borrow<SymbolGraph>]) -> Result<Self, CliError> {
561        let mut packages = Vec::new();
562        for graph in graphs {
563            let graph = graph.borrow();
564            for symbol in &graph.symbols {
565                if symbol.kind != SymbolKind::Package {
566                    continue;
567                }
568                GraphIdentityText::new(symbol.name.clone()).map_err(invalid_graph_contract)?;
569                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?;
570                let root = graph
571                    .path
572                    .rsplit_once('/')
573                    .map_or(String::new(), |(parent, _manifest)| parent.to_string());
574                packages.push(PackageOwner {
575                    root,
576                    name: symbol.name.clone(),
577                    manifest: graph.path.clone(),
578                });
579            }
580        }
581        packages.sort_by(|left, right| {
582            right
583                .root
584                .len()
585                .cmp(&left.root.len())
586                .then_with(|| left.root.cmp(&right.root))
587                .then_with(|| left.name.cmp(&right.name))
588                .then_with(|| left.manifest.cmp(&right.manifest))
589        });
590        packages.dedup_by(|left, right| {
591            left.root == right.root && left.name == right.name && left.manifest == right.manifest
592        });
593        Ok(Self { packages })
594    }
595
596    /// Return the most-specific Cargo package owning one repository path.
597    fn package_name(&self, path: &str) -> Option<&str> {
598        self.packages
599            .iter()
600            .find(|package| repository_path_belongs_to(path, &package.root))
601            .map(|package| package.name.as_str())
602    }
603}
604
605/// Return whether one path belongs to a normalized repository prefix.
606fn repository_path_belongs_to(path: &str, root: &str) -> bool {
607    root.is_empty()
608        || path == root
609        || path
610            .strip_prefix(root)
611            .is_some_and(|suffix| suffix.starts_with('/'))
612}
613
614/// Return whether one normalized repository path is a Cargo package manifest.
615fn is_cargo_manifest_path(path: &str) -> bool {
616    path == "Cargo.toml" || path.ends_with("/Cargo.toml")
617}
618
619/// Qualify graph-only parent identity from the parser's existing containment rows.
620///
621/// Sorting is `O(n log n)`; each same-name candidate is pushed and popped at most once.
622fn qualified_symbol_parents(graph: &SymbolGraph) -> Vec<Option<String>> {
623    let mut order = (0..graph.symbols.len()).collect::<Vec<_>>();
624    order.sort_by_key(|&index| (graph.symbols[index].line_start, index));
625    let mut active_by_name = BTreeMap::<&str, Vec<usize>>::new();
626    let mut qualified_names = vec![None::<String>; graph.symbols.len()];
627    let mut parents = vec![None; graph.symbols.len()];
628    for index in order {
629        let symbol = &graph.symbols[index];
630        let parent = symbol.parent.as_deref().map(|parent| {
631            let Some(candidates) = active_by_name.get_mut(parent) else {
632                return parent.to_string();
633            };
634            while candidates.last().is_some_and(|&candidate_index| {
635                graph.symbols[candidate_index].line_end < symbol.line_end
636            }) {
637                candidates.pop();
638            }
639            candidates
640                .last()
641                .and_then(|&candidate_index| qualified_names[candidate_index].clone())
642                .unwrap_or_else(|| parent.to_string())
643        });
644        qualified_names[index] = Some(parent.as_ref().map_or_else(
645            || symbol.name.clone(),
646            |parent| format!("{parent}::{}", symbol.name),
647        ));
648        parents[index] = parent;
649        active_by_name
650            .entry(symbol.name.as_str())
651            .or_default()
652            .push(index);
653    }
654    parents
655}
656
657/// Project file, symbol, package, and canonical export facts from parser graphs.
658#[cfg(test)]
659fn build_entity_projection(
660    project: ProjectInstanceId,
661    generation: IndexGeneration,
662    nodes: &[Node],
663    graphs: &[impl Borrow<SymbolGraph>],
664    packages: &PackageIndex,
665    include_project: bool,
666    control: &IndexWorkControl,
667) -> Result<EntityProjection, CliError> {
668    build_entity_projection_with_config(
669        project,
670        generation,
671        nodes,
672        graphs,
673        packages,
674        &ConfiguredModuleResolution::default(),
675        include_project,
676        control,
677    )
678}
679
680/// Project entity/key facts with one shared configured-module snapshot.
681#[allow(clippy::too_many_arguments)]
682fn build_entity_projection_with_config(
683    project: ProjectInstanceId,
684    generation: IndexGeneration,
685    nodes: &[Node],
686    graphs: &[impl Borrow<SymbolGraph>],
687    packages: &PackageIndex,
688    configured_modules: &ConfiguredModuleResolution,
689    include_project: bool,
690    control: &IndexWorkControl,
691) -> Result<EntityProjection, CliError> {
692    let mut entity_by_digest = BTreeMap::new();
693    if include_project {
694        insert_entity(
695            &mut entity_by_digest,
696            GraphEntity::new(project, EntitySelector::Project, generation)
697                .map_err(invalid_graph_contract)?,
698        )?;
699    }
700    for node in nodes {
701        control.check(IndexWorkStage::SymbolParsing)?;
702        let selector = match node.kind {
703            NodeKind::Folder => EntitySelector::Folder {
704                path: RepositoryNodePath::new(Path::new(&node.path))
705                    .map_err(invalid_graph_contract)?,
706            },
707            NodeKind::File => EntitySelector::File {
708                path: RepositoryFilePath::new(Path::new(&node.path))
709                    .map_err(invalid_graph_contract)?,
710            },
711        };
712        insert_entity(
713            &mut entity_by_digest,
714            GraphEntity::new(project, selector, generation).map_err(invalid_graph_contract)?,
715        )?;
716    }
717
718    let mut owners_by_graph = BTreeMap::new();
719    let mut keys_by_graph = BTreeMap::new();
720    let mut entity_exports = Vec::new();
721    let mut retained_bytes = 0_u64;
722    for graph in graphs {
723        let graph = graph.borrow();
724        control.check(IndexWorkStage::SymbolParsing)?;
725        let file = GraphEntity::new(
726            project,
727            EntitySelector::File {
728                path: RepositoryFilePath::new(Path::new(&graph.path))
729                    .map_err(invalid_graph_contract)?,
730            },
731            generation,
732        )
733        .map_err(invalid_graph_contract)?;
734        let file_digest = file.key().digest().to_string();
735        insert_entity(&mut entity_by_digest, file)?;
736        let mut symbol_digests = Vec::with_capacity(graph.symbols.len());
737        let qualified_parents = qualified_symbol_parents(graph);
738        for (symbol, qualified_parent) in graph.symbols.iter().zip(qualified_parents) {
739            control.check(IndexWorkStage::SymbolParsing)?;
740            let entity = match symbol.kind {
741                SymbolKind::Import | SymbolKind::Dependency | SymbolKind::Workspace => None,
742                SymbolKind::Package => Some(
743                    GraphEntity::new(
744                        project,
745                        EntitySelector::Package {
746                            package: PackageSelector {
747                                manager: GraphIdentityText::new(CARGO_PACKAGE_MANAGER)
748                                    .map_err(invalid_graph_contract)?,
749                                name: GraphIdentityText::new(symbol.name.clone())
750                                    .map_err(invalid_graph_contract)?,
751                                manifest: RepositoryFilePath::new(Path::new(&graph.path))
752                                    .map_err(invalid_graph_contract)?,
753                            },
754                        },
755                        generation,
756                    )
757                    .map_err(invalid_graph_contract)?,
758                ),
759                _ => Some(
760                    GraphEntity::new(
761                        project,
762                        EntitySelector::Symbol {
763                            symbol: SymbolSelector {
764                                file: RepositoryFilePath::new(Path::new(&graph.path))
765                                    .map_err(invalid_graph_contract)?,
766                                name: GraphIdentityText::new(symbol.name.clone())
767                                    .map_err(invalid_graph_contract)?,
768                                kind: symbol.kind,
769                                parent: qualified_parent
770                                    .map(GraphIdentityText::new)
771                                    .transpose()
772                                    .map_err(invalid_graph_contract)?,
773                                signature: GraphIdentityText::new(
774                                    if symbol.signature.trim().is_empty() {
775                                        symbol.name.clone()
776                                    } else {
777                                        symbol.signature.trim().to_string()
778                                    },
779                                )
780                                .map_err(invalid_graph_contract)?,
781                            },
782                        },
783                        generation,
784                    )
785                    .map_err(invalid_graph_contract)?,
786                ),
787            };
788            let entity_digest = entity
789                .as_ref()
790                .map(|entity| entity.key().digest().to_string());
791            if let Some(entity) = entity {
792                insert_entity(&mut entity_by_digest, entity)?;
793            }
794            symbol_digests.push(entity_digest);
795        }
796        let resolution = resolution_projection_with_config(
797            project,
798            packages.package_name(&graph.path),
799            graph,
800            configured_modules,
801        )?;
802        let file = entity_by_digest
803            .get(&file_digest)
804            .ok_or_else(|| CliError::InvalidInput("graph file owner was not staged".to_string()))?;
805        for key in resolution.source_keys() {
806            entity_exports.push(
807                EntityResolutionKey::new(file.key().clone(), key.clone())
808                    .map_err(invalid_graph_contract)?,
809            );
810        }
811        for symbol_keys in resolution.symbol_keys() {
812            let Some(entity) = symbol_digests
813                .get(symbol_keys.symbol_index())
814                .and_then(Option::as_ref)
815                .and_then(|digest| entity_by_digest.get(digest))
816            else {
817                continue;
818            };
819            for key in symbol_keys.keys() {
820                entity_exports.push(
821                    EntityResolutionKey::new(entity.key().clone(), key.clone())
822                        .map_err(invalid_graph_contract)?,
823                );
824            }
825        }
826        retained_bytes = retained_bytes.saturating_add(resolution_retained_bytes(&resolution));
827        owners_by_graph.insert(
828            graph.path.clone(),
829            GraphOwners {
830                file_digest,
831                symbol_digests,
832            },
833        );
834        keys_by_graph.insert(graph.path.clone(), resolution);
835    }
836    sort_dedup_exports(&mut entity_exports);
837    enforce_key_binding_limit(entity_exports.len())?;
838    retained_bytes = entity_by_digest
839        .values()
840        .fold(retained_bytes, |bytes, entity| {
841            bytes.saturating_add(entity_retained_bytes(entity))
842        })
843        .saturating_add(
844            STAGED_GRAPH_ROW_BYTES
845                .saturating_mul(u64::try_from(entity_exports.len()).unwrap_or(u64::MAX)),
846        );
847    enforce_resolution_registry_budget(retained_bytes)?;
848    Ok(EntityProjection {
849        entity_by_digest,
850        owners_by_graph,
851        keys_by_graph,
852        entity_exports,
853        retained_bytes,
854    })
855}
856
857/// Acquire the one project-local graph-staging lease without waiting.
858fn try_graph_stage_lease(staging_parent: &Path) -> Result<Option<File>, CliError> {
859    let path = staging_parent.join(GRAPH_STAGE_LEASE_FILE_NAME);
860    if let Ok(metadata) = fs::symlink_metadata(&path)
861        && (!metadata.file_type().is_file() || metadata.file_type().is_symlink())
862    {
863        return Err(CliError::InvalidInput(format!(
864            "repository graph staging lease is not a direct file: {}",
865            normalize_native_path_display(&path)
866        )));
867    }
868    let file = OpenOptions::new()
869        .read(true)
870        .write(true)
871        .create(true)
872        .truncate(false)
873        .open(&path)
874        .map_err(|source| CliError::Io {
875            path: path.clone(),
876            source,
877        })?;
878    match file.try_lock() {
879        Ok(()) => Ok(Some(file)),
880        Err(fs::TryLockError::WouldBlock) => Ok(None),
881        Err(fs::TryLockError::Error(source)) => Err(CliError::Io { path, source }),
882    }
883}
884
885/// Remove inactive disposable graph stages owned by the selected project.
886pub(super) fn cleanup_abandoned_repository_graph_staging(
887    store: &AtlasStore,
888    root: &Path,
889    control: &IndexWorkControl,
890) -> Result<(), CliError> {
891    cleanup_abandoned_graph_staging(root, selected_project(store)?, control)
892}
893
894/// Remove only validated, inactive disposable graph stages left by an earlier process.
895fn cleanup_abandoned_graph_staging(
896    root: &Path,
897    project: ProjectInstanceId,
898    control: &IndexWorkControl,
899) -> Result<(), CliError> {
900    control.check(IndexWorkStage::Publication)?;
901    let staging_parent = root.join(".projectatlas");
902    if !staging_parent.is_dir() {
903        return Ok(());
904    }
905    let Some(_lease) = try_graph_stage_lease(&staging_parent)? else {
906        return Ok(());
907    };
908    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)
909}
910
911/// Remove stage payloads while retaining the validated ownership database as a crash marker.
912fn remove_owned_graph_stage_payload(
913    stage: &Path,
914    database_path: &Path,
915    control: Option<&IndexWorkControl>,
916) -> Result<(), CliError> {
917    let entries = fs::read_dir(stage).map_err(|source| CliError::Io {
918        path: stage.to_path_buf(),
919        source,
920    })?;
921    for entry in entries {
922        if let Some(control) = control {
923            control.check(IndexWorkStage::Publication)?;
924        }
925        let entry = entry.map_err(|source| CliError::Io {
926            path: stage.to_path_buf(),
927            source,
928        })?;
929        let path = entry.path();
930        if path == database_path {
931            continue;
932        }
933        let metadata = fs::symlink_metadata(&path).map_err(|source| CliError::Io {
934            path: path.clone(),
935            source,
936        })?;
937        let result = if metadata.file_type().is_symlink() {
938            remove_graph_stage_symlink(&path)
939        } else if metadata.file_type().is_dir() {
940            fs::remove_dir_all(&path)
941        } else {
942            fs::remove_file(&path)
943        };
944        result.map_err(|source| CliError::Io { path, source })?;
945    }
946    Ok(())
947}
948
949/// Remove only a graph-stage symlink leaf, never its target.
950#[cfg(windows)]
951fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
952    fs::remove_dir(path).or_else(|_directory_error| fs::remove_file(path))
953}
954
955/// Remove only a graph-stage symlink leaf, never its target.
956#[cfg(not(windows))]
957fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
958    fs::remove_file(path)
959}
960
961/// Remove direct child stages whose typed database binds the exact project.
962fn cleanup_abandoned_graph_staging_while_locked(
963    staging_parent: &Path,
964    root: &Path,
965    project: ProjectInstanceId,
966    control: &IndexWorkControl,
967) -> Result<(), CliError> {
968    let entries = fs::read_dir(staging_parent).map_err(|source| CliError::Io {
969        path: staging_parent.to_path_buf(),
970        source,
971    })?;
972    for entry in entries {
973        control.check(IndexWorkStage::Publication)?;
974        let entry = entry.map_err(|source| CliError::Io {
975            path: staging_parent.to_path_buf(),
976            source,
977        })?;
978        let file_name = entry.file_name();
979        let Some(file_name) = file_name.to_str() else {
980            continue;
981        };
982        if !file_name.starts_with(GRAPH_STAGE_DIRECTORY_PREFIX)
983            || file_name.len() == GRAPH_STAGE_DIRECTORY_PREFIX.len()
984        {
985            continue;
986        }
987        let path = entry.path();
988        let Ok(metadata) = fs::symlink_metadata(&path) else {
989            continue;
990        };
991        if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
992            continue;
993        }
994        let database_path = path.join(GRAPH_STAGE_DATABASE_FILE_NAME);
995        let database_metadata = match fs::symlink_metadata(&database_path) {
996            Ok(metadata) => metadata,
997            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
998                let _remove_empty_shell = fs::remove_dir(&path);
999                continue;
1000            }
1001            Err(_) => continue,
1002        };
1003        if !database_metadata.file_type().is_file() || database_metadata.file_type().is_symlink() {
1004            continue;
1005        }
1006        let owned = AtlasStore::repository_graph_staging_belongs_to(&database_path, root, project)
1007            .unwrap_or(false);
1008        if !owned {
1009            continue;
1010        }
1011        control.check(IndexWorkStage::Publication)?;
1012        remove_owned_graph_stage_payload(&path, &database_path, Some(control))?;
1013        control.check(IndexWorkStage::Publication)?;
1014        fs::remove_file(&database_path).map_err(|source| CliError::Io {
1015            path: database_path,
1016            source,
1017        })?;
1018        fs::remove_dir(&path).map_err(|source| CliError::Io { path, source })?;
1019    }
1020    Ok(())
1021}
1022
1023/// Spill a large full graph to typed disposable `SQLite` rows before main publication.
1024fn finish_projection_in_database(
1025    root: &Path,
1026    nodes: &[Node],
1027    project: ProjectInstanceId,
1028    generation: IndexGeneration,
1029    graphs: &[impl Borrow<SymbolGraph>],
1030    mut entities: EntityProjection,
1031    candidates: &ProjectResolutionRegistry,
1032    control: &IndexWorkControl,
1033) -> Result<StagedRepositoryGraph, CliError> {
1034    let staging_parent = root.join(".projectatlas");
1035    fs::create_dir_all(&staging_parent).map_err(|source| CliError::Io {
1036        path: staging_parent.clone(),
1037        source,
1038    })?;
1039    let lease = try_graph_stage_lease(&staging_parent)?.ok_or_else(|| {
1040        CliError::InvalidInput(
1041            "another repository graph staging operation is active for this project".to_string(),
1042        )
1043    })?;
1044    cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)?;
1045    let directory = TempDirBuilder::new()
1046        .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
1047        .tempdir_in(&staging_parent)
1048        .map_err(|source| CliError::Io {
1049            path: staging_parent,
1050            source,
1051        })?;
1052    let mut database = StagedGraphDatabase {
1053        store: None,
1054        directory: Some(directory),
1055        _lease: lease,
1056    };
1057    let database_path = database
1058        .directory()?
1059        .path()
1060        .join(GRAPH_STAGE_DATABASE_FILE_NAME);
1061    database.store = Some(AtlasStore::create_repository_graph_staging(
1062        &database_path,
1063        root,
1064        project,
1065    )?);
1066    database.store_mut()?.replace_scan(nodes)?;
1067    {
1068        let mut staging = database
1069            .store_mut()?
1070            .begin_repository_graph_staging(project, generation)?;
1071        let mut entity_batch = Vec::with_capacity(GRAPH_STAGE_ENTITY_BATCH_SIZE);
1072        for entity in entities.entity_by_digest.values() {
1073            entity_batch.push(entity);
1074            if entity_batch.len() == GRAPH_STAGE_ENTITY_BATCH_SIZE {
1075                control.check(IndexWorkStage::Publication)?;
1076                staging.append_entity_refs(&entity_batch)?;
1077                entity_batch.clear();
1078            }
1079        }
1080        if !entity_batch.is_empty() {
1081            staging.append_entity_refs(&entity_batch)?;
1082            entity_batch.clear();
1083        }
1084        staging.append_batch(&[], &[], &[], &[], &entities.entity_exports, &[])?;
1085        let mut staged_rows = ProjectedGraphRows::default();
1086        for graph in graphs {
1087            let graph = graph.borrow();
1088            let rows = project_graph_rows(
1089                project,
1090                generation,
1091                graph,
1092                &mut entities,
1093                candidates,
1094                control,
1095            )?;
1096            staged_rows.append(rows);
1097            if staged_rows.row_count() < GRAPH_STAGE_ROW_BATCH_SIZE {
1098                continue;
1099            }
1100            staging.append_batch(
1101                &staged_rows.external_entities,
1102                &staged_rows.relations,
1103                &staged_rows.occurrences,
1104                &staged_rows.coverage,
1105                &[],
1106                &staged_rows.relation_dependencies,
1107            )?;
1108            staged_rows.clear();
1109        }
1110        if !staged_rows.is_empty() {
1111            staging.append_batch(
1112                &staged_rows.external_entities,
1113                &staged_rows.relations,
1114                &staged_rows.occurrences,
1115                &staged_rows.coverage,
1116                &[],
1117                &staged_rows.relation_dependencies,
1118            )?;
1119        }
1120        staging.complete()?;
1121    }
1122    database.store()?.checkpoint_repository_graph_staging()?;
1123    database.store()?.begin_index_read_snapshot()?;
1124    let _staged_generation = database.store()?.repository_graph_generation()?;
1125    let retained_bytes = normalize_native_path_display(&database_path).len() as u64;
1126    Ok(StagedRepositoryGraph {
1127        project,
1128        mutation: RepositoryGraphMutation::Full,
1129        entities: Vec::new(),
1130        relations: Vec::new(),
1131        occurrences: Vec::new(),
1132        coverage: Vec::new(),
1133        entity_exports: Vec::new(),
1134        relation_dependencies: Vec::new(),
1135        database: Some(database),
1136        retained_bytes,
1137    })
1138}
1139
1140/// Resolve staged relationships and finish one complete normalized graph batch.
1141fn finish_projection(
1142    project: ProjectInstanceId,
1143    generation: IndexGeneration,
1144    mutation: RepositoryGraphMutation,
1145    graphs: &[impl Borrow<SymbolGraph>],
1146    mut entities: EntityProjection,
1147    candidates: &ProjectResolutionRegistry,
1148    control: &IndexWorkControl,
1149) -> Result<StagedRepositoryGraph, CliError> {
1150    let mut relations_by_digest = BTreeMap::new();
1151    let mut occurrences = Vec::new();
1152    let mut relation_dependencies = Vec::new();
1153    let mut coverage = Vec::new();
1154    for graph in graphs {
1155        let graph = graph.borrow();
1156        let rows = project_graph_rows(
1157            project,
1158            generation,
1159            graph,
1160            &mut entities,
1161            candidates,
1162            control,
1163        )?;
1164        for (relation_index, relation) in rows.relations.into_iter().enumerate() {
1165            insert_relation(
1166                &mut relations_by_digest,
1167                relation,
1168                &graph.path,
1169                "projected",
1170                relation_index,
1171            )?;
1172        }
1173        occurrences.extend(rows.occurrences);
1174        relation_dependencies.extend(rows.relation_dependencies);
1175        coverage.extend(rows.coverage);
1176        entities.retained_bytes = rows
1177            .external_entities
1178            .iter()
1179            .fold(entities.retained_bytes, |bytes, entity| {
1180                bytes.saturating_add(entity_retained_bytes(entity))
1181            });
1182        for entity in rows.external_entities {
1183            insert_entity(&mut entities.entity_by_digest, entity)?;
1184        }
1185    }
1186    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
1187    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
1188    occurrences.sort_by(|left, right| {
1189        left.relation()
1190            .digest()
1191            .cmp(right.relation().digest())
1192            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
1193            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
1194            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
1195    });
1196    occurrences.dedup();
1197    sort_dedup_dependencies(&mut relation_dependencies);
1198    enforce_key_binding_limit(relation_dependencies.len())?;
1199    let added_rows = relations
1200        .len()
1201        .saturating_add(occurrences.len())
1202        .saturating_add(coverage.len())
1203        .saturating_add(relation_dependencies.len());
1204    entities.retained_bytes = entities.retained_bytes.saturating_add(
1205        STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(added_rows).unwrap_or(u64::MAX)),
1206    );
1207    Ok(StagedRepositoryGraph {
1208        project,
1209        mutation,
1210        entities: entities.entity_by_digest.into_values().collect(),
1211        relations,
1212        occurrences,
1213        coverage,
1214        entity_exports: entities.entity_exports,
1215        relation_dependencies,
1216        database: None,
1217        retained_bytes: entities.retained_bytes,
1218    })
1219}
1220
1221/// One graph's normalized rows, bounded by the parser graph already in memory.
1222#[derive(Default)]
1223struct ProjectedGraphRows {
1224    /// Deduplicated logical relations owned by the graph.
1225    relations: Vec<LogicalRelation>,
1226    /// Exact supporting source occurrences.
1227    occurrences: Vec<RelationOccurrence>,
1228    /// Coverage rows for the graph.
1229    coverage: Vec<CoverageRecord>,
1230    /// Content-free external entities referenced by the graph.
1231    external_entities: Vec<GraphEntity>,
1232    /// Canonical dependency keys retained by the graph relations.
1233    relation_dependencies: Vec<RelationDependencyKey>,
1234}
1235
1236impl ProjectedGraphRows {
1237    /// Append one graph while retaining a single bounded staging batch.
1238    fn append(&mut self, rows: Self) {
1239        self.relations.extend(rows.relations);
1240        self.occurrences.extend(rows.occurrences);
1241        self.coverage.extend(rows.coverage);
1242        self.external_entities.extend(rows.external_entities);
1243        self.relation_dependencies
1244            .extend(rows.relation_dependencies);
1245    }
1246
1247    /// Return aggregate normalized rows retained by this staging batch.
1248    fn row_count(&self) -> usize {
1249        self.relations
1250            .len()
1251            .saturating_add(self.occurrences.len())
1252            .saturating_add(self.coverage.len())
1253            .saturating_add(self.external_entities.len())
1254            .saturating_add(self.relation_dependencies.len())
1255    }
1256
1257    /// Return whether the staging batch is empty.
1258    fn is_empty(&self) -> bool {
1259        self.row_count() == 0
1260    }
1261
1262    /// Release all successfully staged rows while preserving allocated capacity.
1263    fn clear(&mut self) {
1264        self.relations.clear();
1265        self.occurrences.clear();
1266        self.coverage.clear();
1267        self.external_entities.clear();
1268        self.relation_dependencies.clear();
1269    }
1270}
1271
1272/// Resolve one parser graph and release its temporary owner/key workspace.
1273fn project_graph_rows(
1274    project: ProjectInstanceId,
1275    generation: IndexGeneration,
1276    graph: &SymbolGraph,
1277    entities: &mut EntityProjection,
1278    candidates: &ProjectResolutionRegistry,
1279    control: &IndexWorkControl,
1280) -> Result<ProjectedGraphRows, CliError> {
1281    control.check(IndexWorkStage::SymbolParsing)?;
1282    let owners = entities
1283        .owners_by_graph
1284        .remove(&graph.path)
1285        .ok_or_else(|| CliError::InvalidInput("graph owners were not staged".to_string()))?;
1286    let resolution_keys = entities
1287        .keys_by_graph
1288        .remove(&graph.path)
1289        .ok_or_else(|| CliError::InvalidInput("graph keys were not staged".to_string()))?;
1290    let symbol_index = GraphSymbolIndex::new(graph, control)?;
1291    let keys_by_relation = resolution_keys
1292        .relation_keys()
1293        .iter()
1294        .map(|entry| (entry.relation_index(), entry.keys()))
1295        .collect::<BTreeMap<_, _>>();
1296    let mut relations_by_digest = BTreeMap::new();
1297    let mut occurrences = Vec::new();
1298    let mut relation_dependencies = Vec::new();
1299    let mut external_entities = BTreeMap::new();
1300    for (relation_index, source_relation) in graph.relations.iter().enumerate() {
1301        control.check(IndexWorkStage::SymbolParsing)?;
1302        let source = relation_source(
1303            &owners,
1304            &entities.entity_by_digest,
1305            &symbol_index,
1306            source_relation,
1307        )?;
1308        let dependency_keys = keys_by_relation
1309            .get(&relation_index)
1310            .copied()
1311            .unwrap_or(&[]);
1312        let resolution = relation_resolution(
1313            project,
1314            generation,
1315            source_relation,
1316            &owners,
1317            graph,
1318            &symbol_index,
1319            dependency_keys,
1320            candidates,
1321            &entities.entity_by_digest,
1322            &mut external_entities,
1323            control,
1324        )?;
1325        let relation = LogicalRelation::new(
1326            source,
1327            GraphRelationKind::from_legacy(source_relation.kind),
1328            resolution,
1329            relation_confidence(source_relation.parser),
1330            relation_completeness(source_relation.parser),
1331            generation,
1332        )
1333        .map_err(invalid_graph_contract)?;
1334        insert_relation(
1335            &mut relations_by_digest,
1336            relation.clone(),
1337            &graph.path,
1338            "logical",
1339            relation_index,
1340        )?;
1341        let line = u32::try_from(source_relation.line).map_err(|error| {
1342            CliError::InvalidInput(format!("relation source line exceeds graph range: {error}"))
1343        })?;
1344        let end_column =
1345            u32::try_from(source_relation.context.chars().count()).map_err(|error| {
1346                CliError::InvalidInput(format!(
1347                    "relation source context exceeds graph range: {error}"
1348                ))
1349            })?;
1350        occurrences.push(
1351            RelationOccurrence::new(
1352                &relation,
1353                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
1354                SourceSpan::new(line.max(1), 0, line.max(1), end_column)
1355                    .map_err(invalid_graph_contract)?,
1356                generation,
1357            )
1358            .map_err(invalid_graph_contract)?,
1359        );
1360        for key in dependency_keys {
1361            relation_dependencies.push(
1362                RelationDependencyKey::new(relation.key().clone(), key.clone())
1363                    .map_err(invalid_graph_contract)?,
1364            );
1365        }
1366    }
1367    for (fact_index, fact) in derived_relation_facts(graph, &keys_by_relation)
1368        .into_iter()
1369        .enumerate()
1370    {
1371        control.check(IndexWorkStage::SymbolParsing)?;
1372        let source = relation_source(
1373            &owners,
1374            &entities.entity_by_digest,
1375            &symbol_index,
1376            &fact.relation,
1377        )?;
1378        let resolution = derived_relation_resolution(
1379            project,
1380            generation,
1381            &fact,
1382            &owners,
1383            graph,
1384            &symbol_index,
1385            candidates,
1386            &entities.entity_by_digest,
1387            &mut external_entities,
1388            control,
1389        )?;
1390        let relation = LogicalRelation::new(
1391            source,
1392            GraphRelationKind::Extended(fact.kind),
1393            resolution,
1394            relation_confidence(fact.relation.parser),
1395            relation_completeness(fact.relation.parser),
1396            generation,
1397        )
1398        .map_err(invalid_graph_contract)?;
1399        insert_relation(
1400            &mut relations_by_digest,
1401            relation.clone(),
1402            &graph.path,
1403            "derived",
1404            fact_index,
1405        )?;
1406        let line = u32::try_from(fact.relation.line).map_err(|error| {
1407            CliError::InvalidInput(format!(
1408                "derived relation source line exceeds graph range: {error}"
1409            ))
1410        })?;
1411        let end_column = u32::try_from(fact.relation.context.chars().count()).map_err(|error| {
1412            CliError::InvalidInput(format!(
1413                "derived relation source context exceeds graph range: {error}"
1414            ))
1415        })?;
1416        occurrences.push(
1417            RelationOccurrence::new(
1418                &relation,
1419                RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
1420                SourceSpan::new(line.max(1), 0, line.max(1), end_column)
1421                    .map_err(invalid_graph_contract)?,
1422                generation,
1423            )
1424            .map_err(invalid_graph_contract)?,
1425        );
1426        if let DerivedRelationTarget::Parser { keys } = fact.target {
1427            for key in keys {
1428                relation_dependencies.push(
1429                    RelationDependencyKey::new(relation.key().clone(), key)
1430                        .map_err(invalid_graph_contract)?,
1431                );
1432            }
1433        }
1434    }
1435    let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
1436    relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
1437    occurrences.sort_by(|left, right| {
1438        left.relation()
1439            .digest()
1440            .cmp(right.relation().digest())
1441            .then_with(|| left.file().as_str().cmp(right.file().as_str()))
1442            .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
1443            .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
1444    });
1445    occurrences.dedup();
1446    sort_dedup_dependencies(&mut relation_dependencies);
1447    enforce_key_binding_limit(relation_dependencies.len())?;
1448    entities.retained_bytes = entities
1449        .retained_bytes
1450        .saturating_sub(resolution_retained_bytes(&resolution_keys));
1451    Ok(ProjectedGraphRows {
1452        relations,
1453        occurrences,
1454        coverage: vec![coverage_for_graph(graph, generation)?],
1455        external_entities: external_entities.into_values().collect(),
1456        relation_dependencies,
1457    })
1458}
1459
1460/// Deduplicate relation occurrences while conservatively retaining ambiguity.
1461fn insert_relation(
1462    relations: &mut BTreeMap<String, LogicalRelation>,
1463    relation: LogicalRelation,
1464    graph_path: &str,
1465    relation_kind: &str,
1466    relation_index: usize,
1467) -> Result<(), CliError> {
1468    let digest = relation.key().digest().to_string();
1469    match relations.entry(digest) {
1470        Entry::Vacant(entry) => {
1471            entry.insert(relation);
1472            Ok(())
1473        }
1474        Entry::Occupied(mut entry) => {
1475            let existing = entry.get();
1476            if existing == &relation {
1477                return Ok(());
1478            }
1479            let mergeable = existing.key() == relation.key()
1480                && existing.source() == relation.source()
1481                && existing.kind() == relation.kind()
1482                && existing.confidence() == relation.confidence()
1483                && existing.completeness() == relation.completeness()
1484                && existing.generation() == relation.generation();
1485            if mergeable
1486                && let (
1487                    RelationResolution::Ambiguous {
1488                        reference: existing_reference,
1489                        candidates: existing_candidates,
1490                    },
1491                    RelationResolution::Ambiguous {
1492                        reference: incoming_reference,
1493                        candidates: incoming_candidates,
1494                    },
1495                ) = (existing.resolution(), relation.resolution())
1496                && existing_reference == incoming_reference
1497            {
1498                if incoming_candidates > existing_candidates {
1499                    entry.insert(relation);
1500                }
1501                return Ok(());
1502            }
1503            Err(CliError::InvalidInput(format!(
1504                "{relation_kind} relation digest retained conflicting facts for {graph_path} \
1505                 relation {relation_index}: existing={existing:?}, incoming={relation:?}"
1506            )))
1507        }
1508    }
1509}
1510
1511/// Project-wide stable entities and their canonical resolution-key bindings.
1512#[derive(Default)]
1513struct ProjectResolutionRegistry {
1514    /// Persisted candidates not already owned by the staged entity projection.
1515    supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
1516    /// Canonical keys mapped only to sorted, deduplicated entity digests.
1517    candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
1518    /// Conservative peak bytes retained by this temporary resolution registry.
1519    retained_bytes: u64,
1520}
1521
1522impl ProjectResolutionRegistry {
1523    /// Insert one key-to-entity binding without cloning the entity per exported key.
1524    fn insert_candidate(
1525        &mut self,
1526        key: &CanonicalResolutionKey,
1527        entity: &GraphEntity,
1528    ) -> Result<(), CliError> {
1529        let digest = entity.key().digest().to_string();
1530        match self.supplemental_entities_by_digest.entry(digest.clone()) {
1531            Entry::Occupied(entry) if entry.get() != entity => {
1532                return Err(CliError::InvalidInput(
1533                    "graph entity digest retained conflicting selectors".to_string(),
1534                ));
1535            }
1536            Entry::Occupied(_entry) => {}
1537            Entry::Vacant(entry) => {
1538                self.retained_bytes = self
1539                    .retained_bytes
1540                    .saturating_add(entity_retained_bytes(entity))
1541                    .saturating_add(digest.len() as u64);
1542                entry.insert(entity.clone());
1543            }
1544        }
1545        self.insert_candidate_binding(key, digest)
1546    }
1547
1548    /// Bind an entity already owned by the staged projection without cloning it.
1549    fn insert_staged_candidate(
1550        &mut self,
1551        key: &CanonicalResolutionKey,
1552        entity: &GraphEntity,
1553    ) -> Result<(), CliError> {
1554        self.insert_candidate_binding(key, entity.key().digest().to_string())
1555    }
1556
1557    /// Insert one canonical-key binding for an entity owned by either registry.
1558    fn insert_candidate_binding(
1559        &mut self,
1560        key: &CanonicalResolutionKey,
1561        digest: String,
1562    ) -> Result<(), CliError> {
1563        if let Some(existing) = self.candidate_digests_by_key.get_mut(key) {
1564            if existing.insert(digest.clone()) {
1565                self.retained_bytes = self
1566                    .retained_bytes
1567                    .saturating_add(STAGED_GRAPH_ROW_BYTES)
1568                    .saturating_add(digest.len() as u64);
1569            }
1570        } else {
1571            self.retained_bytes = self
1572                .retained_bytes
1573                .saturating_add(STAGED_GRAPH_ROW_BYTES)
1574                .saturating_add(key.canonical_identity().len() as u64)
1575                .saturating_add(STAGED_GRAPH_ROW_BYTES)
1576                .saturating_add(digest.len() as u64);
1577            self.candidate_digests_by_key
1578                .insert(key.clone(), BTreeSet::from([digest]));
1579        }
1580        enforce_resolution_registry_budget(self.retained_bytes)?;
1581        Ok(())
1582    }
1583}
1584
1585/// Reject a temporary resolution registry before it can exceed publication memory.
1586fn enforce_resolution_registry_budget(retained_bytes: u64) -> Result<(), CliError> {
1587    if retained_bytes > super::MAX_PUBLICATION_STAGING_BYTES {
1588        return Err(IndexWorkFailure::resource_limit(
1589            IndexWorkStage::SymbolParsing,
1590            IndexWorkResource::OutputBytes,
1591            super::MAX_PUBLICATION_STAGING_BYTES,
1592            retained_bytes,
1593        )
1594        .into());
1595    }
1596    Ok(())
1597}
1598
1599/// Reject the simultaneous entity projection and lookup registry peak.
1600fn enforce_resolution_staging_budget(
1601    projection: &EntityProjection,
1602    registry: &ProjectResolutionRegistry,
1603) -> Result<(), CliError> {
1604    enforce_resolution_registry_budget(
1605        projection
1606            .retained_bytes
1607            .saturating_add(registry.retained_bytes),
1608    )
1609}
1610
1611/// Count conservative fixed and variable bytes retained by one graph entity.
1612fn entity_retained_bytes(entity: &GraphEntity) -> u64 {
1613    let selector_bytes = match entity.selector() {
1614        EntitySelector::Project => 0,
1615        EntitySelector::Folder { path } => path.as_str().len() as u64,
1616        EntitySelector::File { path } => path.as_str().len() as u64,
1617        EntitySelector::Package { package } => {
1618            (package.manager.as_str().len()
1619                + package.name.as_str().len()
1620                + package.manifest.as_str().len()) as u64
1621        }
1622        EntitySelector::Symbol { symbol } => {
1623            (symbol.file.as_str().len()
1624                + symbol.name.as_str().len()
1625                + symbol
1626                    .parent
1627                    .as_ref()
1628                    .map_or(0, |parent| parent.as_str().len())
1629                + symbol.signature.as_str().len()) as u64
1630        }
1631        EntitySelector::External { external } => {
1632            (external.system.as_str().len() + external.identity.as_str().len()) as u64
1633        }
1634    };
1635    STAGED_GRAPH_ROW_BYTES
1636        .saturating_add(entity.key().digest().len() as u64)
1637        .saturating_add(entity.key().canonical_identity().len() as u64)
1638        .saturating_add(selector_bytes)
1639}
1640
1641/// Build current resolution candidates from newly staged entity exports.
1642fn resolution_registry_from_exports(
1643    projection: &EntityProjection,
1644    control: &IndexWorkControl,
1645) -> Result<ProjectResolutionRegistry, CliError> {
1646    let mut candidates = ProjectResolutionRegistry::default();
1647    for (index, binding) in projection.entity_exports.iter().enumerate() {
1648        check_graph_work(control, index)?;
1649        let digest = binding.entity().digest().to_string();
1650        let entity = projection.entity_by_digest.get(&digest).ok_or_else(|| {
1651            CliError::InvalidInput("resolution export owner was not staged".to_string())
1652        })?;
1653        candidates.insert_staged_candidate(binding.key(), entity)?;
1654    }
1655    Ok(candidates)
1656}
1657
1658/// Rebind unaffected persisted candidates to the pending graph generation.
1659fn resolution_registry_from_persisted(
1660    project: ProjectInstanceId,
1661    generation: IndexGeneration,
1662    candidates: Vec<RepositoryResolutionCandidate>,
1663    replaced_paths: &BTreeSet<String>,
1664    control: &IndexWorkControl,
1665) -> Result<ProjectResolutionRegistry, CliError> {
1666    let mut by_key = ProjectResolutionRegistry::default();
1667    for (index, candidate) in candidates.into_iter().enumerate() {
1668        check_graph_work(control, index)?;
1669        if entity_owner_path(candidate.entity()).is_some_and(|path| replaced_paths.contains(path)) {
1670            continue;
1671        }
1672        let entity = GraphEntity::new(project, candidate.entity().selector().clone(), generation)
1673            .map_err(invalid_graph_contract)?;
1674        by_key.insert_candidate(candidate.key(), &entity)?;
1675    }
1676    Ok(by_key)
1677}
1678
1679/// Merge normalized candidate registries while retaining one entity per digest.
1680fn merge_resolution_registries(
1681    target: &mut ProjectResolutionRegistry,
1682    source: ProjectResolutionRegistry,
1683    control: &IndexWorkControl,
1684) -> Result<(), CliError> {
1685    let ProjectResolutionRegistry {
1686        supplemental_entities_by_digest,
1687        candidate_digests_by_key,
1688        retained_bytes: _retained_bytes,
1689    } = source;
1690    for entity in supplemental_entities_by_digest.into_values() {
1691        check_graph_work(control, target.supplemental_entities_by_digest.len())?;
1692        let digest = entity.key().digest().to_string();
1693        match target.supplemental_entities_by_digest.entry(digest.clone()) {
1694            Entry::Occupied(entry) if entry.get() != &entity => {
1695                return Err(CliError::InvalidInput(
1696                    "resolution candidate entity retained conflicting selectors".to_string(),
1697                ));
1698            }
1699            Entry::Occupied(_entry) => {}
1700            Entry::Vacant(entry) => {
1701                target.retained_bytes = target
1702                    .retained_bytes
1703                    .saturating_add(entity_retained_bytes(&entity))
1704                    .saturating_add(digest.len() as u64);
1705                entry.insert(entity);
1706            }
1707        }
1708    }
1709    let mut bindings = 0_usize;
1710    for (key, candidates) in candidate_digests_by_key {
1711        for digest in candidates {
1712            check_graph_work(control, bindings)?;
1713            target.insert_candidate_binding(&key, digest)?;
1714            bindings = bindings.saturating_add(1);
1715        }
1716    }
1717    Ok(())
1718}
1719
1720/// Observe cancellation and deadline state at bounded graph-map intervals.
1721fn check_graph_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
1722    if index.is_multiple_of(GRAPH_WORK_CHECK_INTERVAL) {
1723        control.check(IndexWorkStage::SymbolParsing)?;
1724    }
1725    Ok(())
1726}
1727
1728/// Return the source-owner path for entities eligible to export graph keys.
1729fn entity_owner_path(entity: &GraphEntity) -> Option<&str> {
1730    match entity.selector() {
1731        EntitySelector::File { path } => Some(path.as_str()),
1732        EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
1733        EntitySelector::Package { package } => Some(package.manifest.as_str()),
1734        EntitySelector::Project
1735        | EntitySelector::Folder { .. }
1736        | EntitySelector::External { .. } => None,
1737    }
1738}
1739
1740/// Derive additive families only from exact bounded parser facts and path policy.
1741fn derived_relation_facts(
1742    graph: &SymbolGraph,
1743    keys_by_relation: &BTreeMap<usize, &[CanonicalResolutionKey]>,
1744) -> Vec<DerivedRelationFact> {
1745    let mut facts = Vec::new();
1746    let test_path = is_test_path(&graph.path);
1747    for (index, relation) in graph.relations.iter().enumerate() {
1748        if test_path && matches!(relation.kind, RelationKind::Imports | RelationKind::Calls) {
1749            let keys = keys_by_relation
1750                .get(&index)
1751                .copied()
1752                .unwrap_or_default()
1753                .to_vec();
1754            push_derived_relation(
1755                &mut facts,
1756                ExtendedRelationKind::Tests,
1757                relation.clone(),
1758                DerivedRelationTarget::Parser { keys },
1759            );
1760        }
1761        if relation.kind == RelationKind::Calls {
1762            if let Some(handler) = static_route_handler(relation) {
1763                push_derived_relation(
1764                    &mut facts,
1765                    ExtendedRelationKind::RoutesTo,
1766                    derived_parser_relation(relation, handler),
1767                    DerivedRelationTarget::Parser { keys: Vec::new() },
1768                );
1769            }
1770            if let Some(key) = static_environment_key(relation) {
1771                push_derived_relation(
1772                    &mut facts,
1773                    ExtendedRelationKind::Configures,
1774                    derived_parser_relation(relation, key),
1775                    DerivedRelationTarget::External {
1776                        system: ENVIRONMENT_SYSTEM,
1777                    },
1778                );
1779            }
1780            if let Some(kind) = static_data_access_kind(&relation.target_name)
1781                && let Some(path) = static_string_argument(&relation.context)
1782                    .and_then(normalize_static_repository_path)
1783            {
1784                push_derived_relation(
1785                    &mut facts,
1786                    kind,
1787                    derived_parser_relation(relation, path),
1788                    DerivedRelationTarget::RepositoryPath,
1789                );
1790            }
1791        }
1792    }
1793    if let Some(identity) = configuration_file_identity(&graph.path) {
1794        push_derived_relation(
1795            &mut facts,
1796            ExtendedRelationKind::Configures,
1797            file_owned_relation(graph, identity),
1798            DerivedRelationTarget::External {
1799                system: CONFIGURATION_SYSTEM,
1800            },
1801        );
1802    }
1803    if let Some(identity) = deployment_platform_identity(&graph.path) {
1804        push_derived_relation(
1805            &mut facts,
1806            ExtendedRelationKind::Deploys,
1807            file_owned_relation(graph, identity),
1808            DerivedRelationTarget::External {
1809                system: DEPLOYMENT_SYSTEM,
1810            },
1811        );
1812    }
1813    facts
1814}
1815
1816/// Retain one additive fact from the already bounded parser graph.
1817fn push_derived_relation(
1818    facts: &mut Vec<DerivedRelationFact>,
1819    kind: ExtendedRelationKind,
1820    relation: SymbolRelation,
1821    target: DerivedRelationTarget,
1822) {
1823    facts.push(DerivedRelationFact {
1824        kind,
1825        relation,
1826        target,
1827    });
1828}
1829
1830/// Copy source context while replacing only the statically selected target.
1831fn derived_parser_relation(relation: &SymbolRelation, target_name: String) -> SymbolRelation {
1832    SymbolRelation {
1833        path: relation.path.clone(),
1834        source_name: relation.source_name.clone(),
1835        target_name,
1836        kind: RelationKind::Calls,
1837        line: relation.line,
1838        context: relation.context.clone(),
1839        parser: relation.parser,
1840    }
1841}
1842
1843/// Create one file-owned content-free configuration or deployment fact.
1844fn file_owned_relation(graph: &SymbolGraph, target_name: String) -> SymbolRelation {
1845    SymbolRelation {
1846        path: graph.path.clone(),
1847        source_name: "<module>".to_string(),
1848        target_name,
1849        kind: RelationKind::Calls,
1850        line: 1,
1851        context: graph.path.clone(),
1852        parser: graph.parser,
1853    }
1854}
1855
1856/// Resolve one additive relation through its closed target class.
1857fn derived_relation_resolution<'a>(
1858    project: ProjectInstanceId,
1859    generation: IndexGeneration,
1860    fact: &DerivedRelationFact,
1861    owners: &GraphOwners,
1862    graph: &SymbolGraph,
1863    symbol_index: &GraphSymbolIndex<'_>,
1864    candidates: &'a ProjectResolutionRegistry,
1865    entities: &'a BTreeMap<String, GraphEntity>,
1866    external_entities: &mut BTreeMap<String, GraphEntity>,
1867    control: &IndexWorkControl,
1868) -> Result<RelationResolution, CliError> {
1869    match &fact.target {
1870        DerivedRelationTarget::Parser { keys } => relation_resolution(
1871            project,
1872            generation,
1873            &fact.relation,
1874            owners,
1875            graph,
1876            symbol_index,
1877            keys,
1878            candidates,
1879            entities,
1880            external_entities,
1881            control,
1882        ),
1883        DerivedRelationTarget::RepositoryPath => {
1884            let candidate = GraphEntity::new(
1885                project,
1886                EntitySelector::File {
1887                    path: RepositoryFilePath::new(Path::new(&fact.relation.target_name))
1888                        .map_err(invalid_graph_contract)?,
1889                },
1890                generation,
1891            )
1892            .map_err(invalid_graph_contract)?;
1893            match entities.get(candidate.key().digest()) {
1894                Some(entity) if entity == &candidate => {
1895                    RelationResolution::resolved(entity).map_err(invalid_graph_contract)
1896                }
1897                Some(_conflict) => Err(CliError::InvalidInput(
1898                    "static repository-path target collided with another graph entity".to_string(),
1899                )),
1900                None => Ok(RelationResolution::Unresolved {
1901                    reference: GraphIdentityText::new(fact.relation.target_name.clone())
1902                        .map_err(invalid_graph_contract)?,
1903                }),
1904            }
1905        }
1906        DerivedRelationTarget::External { system } => {
1907            let entity = GraphEntity::new(
1908                project,
1909                EntitySelector::External {
1910                    external: ExternalSelector {
1911                        system: GraphIdentityText::new(*system).map_err(invalid_graph_contract)?,
1912                        identity: GraphIdentityText::new(fact.relation.target_name.clone())
1913                            .map_err(invalid_graph_contract)?,
1914                    },
1915                },
1916                generation,
1917            )
1918            .map_err(invalid_graph_contract)?;
1919            let resolution =
1920                RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
1921            insert_entity(external_entities, entity)?;
1922            Ok(resolution)
1923        }
1924    }
1925}
1926
1927/// Return whether a repository path is an accepted test-source location.
1928fn is_test_path(path: &str) -> bool {
1929    let normalized = path.replace('\\', "/").to_ascii_lowercase();
1930    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
1931    normalized
1932        .split('/')
1933        .any(|segment| matches!(segment, "test" | "tests" | "__tests__"))
1934        || file.contains(".test.")
1935        || file.contains(".spec.")
1936        || file
1937            .split_once('.')
1938            .is_some_and(|(stem, _extension)| stem.ends_with("_test") || stem.starts_with("test_"))
1939}
1940
1941/// Extract one exact handler identifier from a static route registration.
1942fn static_route_handler(relation: &SymbolRelation) -> Option<String> {
1943    let leaf = call_leaf(&relation.target_name);
1944    if !matches!(
1945        leaf,
1946        "route" | "add_route" | "map_get" | "map_post" | "map_put" | "map_patch" | "map_delete"
1947    ) {
1948        return None;
1949    }
1950    let route = static_string_argument(&relation.context)?;
1951    if !route.starts_with('/')
1952        || route.chars().any(char::is_control)
1953        || relation.context.matches(',').count() != 1
1954    {
1955        return None;
1956    }
1957    let handler = relation
1958        .context
1959        .rsplit_once(',')?
1960        .1
1961        .trim()
1962        .trim_end_matches([')', ';'])
1963        .trim();
1964    let valid_handler = !handler.is_empty()
1965        && handler != relation.source_name
1966        && handler
1967            .chars()
1968            .next()
1969            .is_some_and(|character| !character.is_ascii_digit())
1970        && handler.chars().all(|character| {
1971            character.is_ascii_alphanumeric() || matches!(character, '_' | ':' | '.' | '$')
1972        });
1973    valid_handler.then(|| handler.trim_matches('$').to_string())
1974}
1975
1976/// Extract one static environment key without retaining its value.
1977fn static_environment_key(relation: &SymbolRelation) -> Option<String> {
1978    let normalized = relation
1979        .target_name
1980        .trim()
1981        .trim_end_matches('!')
1982        .to_ascii_lowercase();
1983    if !(normalized.ends_with("env::var")
1984        || normalized.ends_with("env::var_os")
1985        || normalized.ends_with("os.getenv")
1986        || normalized.ends_with("getenvironmentvariable")
1987        || matches!(normalized.as_str(), "getenv" | "getenv_os"))
1988    {
1989        return None;
1990    }
1991    let key = static_string_argument(&relation.context)?;
1992    (!key.is_empty()
1993        && key.len() <= 128
1994        && key
1995            .bytes()
1996            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
1997    .then(|| key.to_string())
1998}
1999
2000/// Classify a call as one accepted bounded static read or write API.
2001fn static_data_access_kind(target: &str) -> Option<ExtendedRelationKind> {
2002    let normalized = target.trim().trim_end_matches('!').to_ascii_lowercase();
2003    let leaf = call_leaf(&normalized);
2004    if matches!(
2005        leaf,
2006        "read" | "read_to_string" | "readfile" | "readfilesync"
2007    ) || normalized.ends_with("file::open")
2008    {
2009        Some(ExtendedRelationKind::Reads)
2010    } else if matches!(leaf, "write" | "writefile" | "writefilesync" | "create")
2011        || normalized.ends_with("file::create")
2012    {
2013        Some(ExtendedRelationKind::Writes)
2014    } else {
2015        None
2016    }
2017}
2018
2019/// Return the normalized leaf of one qualified call target.
2020fn call_leaf(target: &str) -> &str {
2021    target
2022        .trim()
2023        .trim_end_matches('!')
2024        .rsplit([':', '.', '/'])
2025        .find(|part| !part.is_empty())
2026        .unwrap_or_default()
2027}
2028
2029/// Return the first argument only when it is one complete static string literal.
2030fn static_string_argument(context: &str) -> Option<&str> {
2031    let open = context.find('(')?;
2032    let argument = context[open + 1..].trim_start();
2033    let quote = argument.chars().next()?;
2034    if !matches!(quote, '\'' | '"') {
2035        return None;
2036    }
2037    let value = &argument[quote.len_utf8()..];
2038    let end = value.find(quote)?;
2039    let value = &value[..end];
2040    (!value.contains('\\')
2041        && !value.contains("${")
2042        && !value.contains("#{")
2043        && !value.chars().any(char::is_control))
2044    .then_some(value)
2045}
2046
2047/// Normalize one static relative source literal into a repository path.
2048fn normalize_static_repository_path(value: &str) -> Option<String> {
2049    let value = value.replace('\\', "/");
2050    if value.is_empty()
2051        || value.starts_with('/')
2052        || value.starts_with('~')
2053        || value.contains("://")
2054        || value.contains('$')
2055        || value.contains('{')
2056        || value.contains('}')
2057        || value
2058            .split('/')
2059            .next()
2060            .is_some_and(|part| part.contains(':'))
2061    {
2062        return None;
2063    }
2064    let mut parts = Vec::new();
2065    for part in value.split('/') {
2066        match part {
2067            "" | "." => {}
2068            ".." => {
2069                parts.pop()?;
2070            }
2071            part if part == "."
2072                || part == ".."
2073                || part.chars().any(char::is_control)
2074                || part.trim() != part =>
2075            {
2076                return None;
2077            }
2078            part => parts.push(part.to_string()),
2079        }
2080    }
2081    (!parts.is_empty()).then(|| parts.join("/"))
2082}
2083
2084/// Classify one exact repository configuration filename without reading values.
2085fn configuration_file_identity(path: &str) -> Option<String> {
2086    let normalized = path.replace('\\', "/").to_ascii_lowercase();
2087    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
2088    let identity = if file == ".env" || file.starts_with(".env.") {
2089        "dotenv"
2090    } else if matches!(
2091        file,
2092        "config.json"
2093            | "config.yaml"
2094            | "config.yml"
2095            | "config.toml"
2096            | "settings.json"
2097            | "settings.yaml"
2098            | "settings.yml"
2099            | "settings.toml"
2100            | "appsettings.json"
2101    ) {
2102        "application-config"
2103    } else {
2104        return None;
2105    };
2106    Some(identity.to_string())
2107}
2108
2109/// Classify one accepted infrastructure path into a content-free platform.
2110fn deployment_platform_identity(path: &str) -> Option<String> {
2111    let normalized = path.replace('\\', "/").to_ascii_lowercase();
2112    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
2113    let identity = if file_has_extension(file, "tf")
2114        || file
2115            .strip_suffix(".json")
2116            .is_some_and(|stem| file_has_extension(stem, "tf"))
2117    {
2118        "terraform"
2119    } else if file == "dockerfile"
2120        || file.starts_with("dockerfile.")
2121        || matches!(
2122            file,
2123            "compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
2124        )
2125    {
2126        "containers"
2127    } else if matches!(
2128        file,
2129        "chart.yaml" | "kustomization.yaml" | "kustomization.yml"
2130    ) || (normalized
2131        .split('/')
2132        .any(|segment| matches!(segment, "k8s" | "kubernetes" | "helm" | "kustomize"))
2133        && matches!(
2134            Path::new(file).extension().and_then(|value| value.to_str()),
2135            Some("yaml" | "yml" | "json")
2136        ))
2137    {
2138        "kubernetes"
2139    } else if file_has_extension(file, "bicep") {
2140        "azure-bicep"
2141    } else if file == "sam-template.yaml"
2142        || (file.contains("cloudformation")
2143            && matches!(
2144                Path::new(file).extension().and_then(|value| value.to_str()),
2145                Some("yaml" | "yml" | "json")
2146            ))
2147    {
2148        "cloudformation"
2149    } else if file == "playbook.yaml" || file == "playbook.yml" {
2150        "ansible"
2151    } else {
2152        return None;
2153    };
2154    Some(identity.to_string())
2155}
2156
2157/// Match one exact extension without platform case assumptions.
2158fn file_has_extension(file: &str, expected: &str) -> bool {
2159    Path::new(file)
2160        .extension()
2161        .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
2162}
2163
2164/// Resolve one parser relation's unique local source entity when possible.
2165fn relation_source<'a>(
2166    owners: &GraphOwners,
2167    entities: &'a BTreeMap<String, GraphEntity>,
2168    symbol_index: &GraphSymbolIndex<'_>,
2169    relation: &projectatlas_core::symbols::SymbolRelation,
2170) -> Result<&'a GraphEntity, CliError> {
2171    let file = entities.get(&owners.file_digest).ok_or_else(|| {
2172        CliError::InvalidInput("graph file owner entity was not staged".to_string())
2173    })?;
2174    let mut matched = None;
2175    for &index in symbol_index.get(&relation.source_name) {
2176        let digest = owners.symbol_digests.get(index).ok_or_else(|| {
2177            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
2178        })?;
2179        let Some(digest) = digest else {
2180            continue;
2181        };
2182        let entity = entities.get(digest).ok_or_else(|| {
2183            CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
2184        })?;
2185        if matched.is_some() {
2186            return Ok(file);
2187        }
2188        matched = Some(entity);
2189    }
2190    Ok(matched.unwrap_or(file))
2191}
2192
2193/// Resolve one relation from canonical dependency keys and bounded candidates.
2194#[derive(Clone, Copy)]
2195struct ResolutionMatches<'a> {
2196    /// First target in stable digest order when at least one target exists.
2197    first: Option<&'a GraphEntity>,
2198    /// Exact number of distinct target entities.
2199    count: u32,
2200}
2201
2202/// Resolve one relation from canonical dependency keys and bounded candidates.
2203fn relation_resolution<'a>(
2204    project: ProjectInstanceId,
2205    generation: IndexGeneration,
2206    relation: &projectatlas_core::symbols::SymbolRelation,
2207    owners: &GraphOwners,
2208    graph: &SymbolGraph,
2209    symbol_index: &GraphSymbolIndex<'_>,
2210    dependency_keys: &[CanonicalResolutionKey],
2211    candidates: &'a ProjectResolutionRegistry,
2212    staged_entities: &'a BTreeMap<String, GraphEntity>,
2213    external_entities: &mut BTreeMap<String, GraphEntity>,
2214    control: &IndexWorkControl,
2215) -> Result<RelationResolution, CliError> {
2216    let matches = match relation.kind {
2217        RelationKind::Contains => local_relation_matches(
2218            relation,
2219            owners,
2220            graph,
2221            symbol_index,
2222            staged_entities,
2223            control,
2224        )?,
2225        RelationKind::Calls => {
2226            let local = local_relation_matches(
2227                relation,
2228                owners,
2229                graph,
2230                symbol_index,
2231                staged_entities,
2232                control,
2233            )?;
2234            if local.count == 0 {
2235                registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
2236            } else {
2237                local
2238            }
2239        }
2240        RelationKind::Imports | RelationKind::DependsOn => {
2241            registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
2242        }
2243    };
2244    match matches.count {
2245        0 => {
2246            if let Some(external) = explicit_external_selector(graph, relation)? {
2247                let entity =
2248                    GraphEntity::new(project, EntitySelector::External { external }, generation)
2249                        .map_err(invalid_graph_contract)?;
2250                let resolution =
2251                    RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
2252                insert_entity(external_entities, entity)?;
2253                return Ok(resolution);
2254            }
2255            Ok(RelationResolution::Unresolved {
2256                reference: GraphIdentityText::new(relation_reference(relation))
2257                    .map_err(invalid_graph_contract)?,
2258            })
2259        }
2260        1 => RelationResolution::resolved(
2261            matches
2262                .first
2263                .ok_or_else(|| CliError::InvalidInput("resolved target disappeared".to_string()))?,
2264        )
2265        .map_err(invalid_graph_contract),
2266        count => Ok(RelationResolution::Ambiguous {
2267            reference: GraphIdentityText::new(relation_reference(relation))
2268                .map_err(invalid_graph_contract)?,
2269            candidates: NonZeroU32::new(count).ok_or_else(|| {
2270                CliError::InvalidInput("ambiguous target count was zero".to_string())
2271            })?,
2272        }),
2273    }
2274}
2275
2276/// Resolve exact declarations owned by the relation's source file.
2277fn local_relation_matches<'a>(
2278    relation: &projectatlas_core::symbols::SymbolRelation,
2279    owners: &GraphOwners,
2280    graph: &SymbolGraph,
2281    symbol_index: &GraphSymbolIndex<'_>,
2282    staged_entities: &'a BTreeMap<String, GraphEntity>,
2283    control: &IndexWorkControl,
2284) -> Result<ResolutionMatches<'a>, CliError> {
2285    let mut targets = BTreeMap::<&str, &GraphEntity>::new();
2286    let target_name = relation.target_name.trim();
2287    let source_parent = if relation.kind == RelationKind::Calls {
2288        unique_source_parent(graph, symbol_index, &relation.source_name, control)?
2289    } else {
2290        None
2291    };
2292    for (candidate_index, &row_index) in symbol_index.get(target_name).iter().enumerate() {
2293        check_graph_work(control, candidate_index)?;
2294        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
2295            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
2296        })?;
2297        let digest = owners.symbol_digests.get(row_index).ok_or_else(|| {
2298            CliError::InvalidInput("graph symbol owner index was not staged".to_string())
2299        })?;
2300        let exact_match = match relation.kind {
2301            RelationKind::Contains => {
2302                symbol.name == target_name
2303                    && symbol.parent.as_deref() == Some(relation.source_name.as_str())
2304            }
2305            RelationKind::Calls => {
2306                symbol.name == target_name
2307                    && (symbol.parent.is_none()
2308                        || symbol.parent.as_deref() == Some(relation.source_name.as_str())
2309                        || source_parent.is_some_and(|source_parent| {
2310                            symbol.parent.as_deref() == Some(source_parent)
2311                        }))
2312            }
2313            RelationKind::Imports | RelationKind::DependsOn => false,
2314        };
2315        if exact_match && let Some(digest) = digest {
2316            let entity = staged_entities.get(digest).ok_or_else(|| {
2317                CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
2318            })?;
2319            if !targets.contains_key(entity.key().digest()) {
2320                enforce_resolution_match_budget(targets.len().saturating_add(1))?;
2321            }
2322            targets.insert(entity.key().digest(), entity);
2323        }
2324    }
2325    let count = distinct_resolution_count(targets.len())?;
2326    Ok(ResolutionMatches {
2327        first: targets.into_values().next(),
2328        count,
2329    })
2330}
2331
2332/// Return one unambiguous containing scope for the parser relation source.
2333fn unique_source_parent<'a>(
2334    graph: &'a SymbolGraph,
2335    symbol_index: &GraphSymbolIndex<'_>,
2336    source_name: &str,
2337    control: &IndexWorkControl,
2338) -> Result<Option<&'a str>, CliError> {
2339    let mut parent = None;
2340    let mut source_found = false;
2341    for (candidate_index, &row_index) in symbol_index.get(source_name).iter().enumerate() {
2342        check_graph_work(control, candidate_index)?;
2343        let symbol = graph.symbols.get(row_index).ok_or_else(|| {
2344            CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
2345        })?;
2346        let candidate = symbol.parent.as_deref();
2347        if !source_found {
2348            parent = candidate;
2349            source_found = true;
2350        } else if parent != candidate {
2351            return Ok(None);
2352        }
2353    }
2354    Ok(parent)
2355}
2356
2357/// Merge sorted candidate streams without materializing their full union.
2358fn registry_resolution_matches<'a>(
2359    dependency_keys: &[CanonicalResolutionKey],
2360    candidates: &'a ProjectResolutionRegistry,
2361    staged_entities: &'a BTreeMap<String, GraphEntity>,
2362    control: &IndexWorkControl,
2363) -> Result<ResolutionMatches<'a>, CliError> {
2364    enforce_resolution_match_budget(dependency_keys.len().saturating_mul(2))?;
2365    let mut streams = dependency_keys
2366        .iter()
2367        .filter_map(|key| candidates.candidate_digests_by_key.get(key))
2368        .map(BTreeSet::iter)
2369        .collect::<Vec<_>>();
2370    let mut frontier = BinaryHeap::new();
2371    for (stream_index, stream) in streams.iter_mut().enumerate() {
2372        if let Some(digest) = stream.next() {
2373            frontier.push(Reverse((digest.as_str(), stream_index)));
2374        }
2375    }
2376
2377    let mut first = None;
2378    let mut last_digest = None;
2379    let mut count = 0_u32;
2380    let mut visited = 0_usize;
2381    while let Some(Reverse((digest, stream_index))) = frontier.pop() {
2382        check_graph_work(control, visited)?;
2383        visited = visited.saturating_add(1);
2384        if last_digest != Some(digest) {
2385            let entity = staged_entities
2386                .get(digest)
2387                .or_else(|| candidates.supplemental_entities_by_digest.get(digest))
2388                .ok_or_else(|| {
2389                    CliError::InvalidInput(
2390                        "resolution candidate entity was not registered".to_string(),
2391                    )
2392                })?;
2393            first.get_or_insert(entity);
2394            count = count.checked_add(1).ok_or_else(|| {
2395                IndexWorkFailure::resource_limit(
2396                    IndexWorkStage::SymbolParsing,
2397                    IndexWorkResource::RelationRows,
2398                    u64::from(u32::MAX),
2399                    u64::from(u32::MAX) + 1,
2400                )
2401            })?;
2402            last_digest = Some(digest);
2403        }
2404        if let Some(next) = streams[stream_index].next() {
2405            frontier.push(Reverse((next.as_str(), stream_index)));
2406        }
2407    }
2408    Ok(ResolutionMatches { first, count })
2409}
2410
2411/// Convert one exact distinct target count without truncation.
2412fn distinct_resolution_count(count: usize) -> Result<u32, CliError> {
2413    u32::try_from(count).map_err(|_conversion_error| {
2414        IndexWorkFailure::resource_limit(
2415            IndexWorkStage::SymbolParsing,
2416            IndexWorkResource::RelationRows,
2417            u64::from(u32::MAX),
2418            u64::try_from(count).unwrap_or(u64::MAX),
2419        )
2420        .into()
2421    })
2422}
2423
2424/// Bound temporary maps and heap storage used while selecting relation targets.
2425fn enforce_resolution_match_budget(rows: usize) -> Result<(), CliError> {
2426    enforce_resolution_registry_budget(
2427        STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(rows).unwrap_or(u64::MAX)),
2428    )
2429}
2430
2431/// Return one explicit external identity without guessing ordinary missing packages.
2432fn explicit_external_selector(
2433    graph: &SymbolGraph,
2434    relation: &projectatlas_core::symbols::SymbolRelation,
2435) -> Result<Option<ExternalSelector>, CliError> {
2436    let semantic_provider = graph
2437        .language
2438        .as_deref()
2439        .and_then(language_capability)
2440        .and_then(|capability| capability.effective_semantic_provider());
2441    let classified = match (semantic_provider, relation.kind) {
2442        (Some(SemanticProviderOwner::Cargo), RelationKind::DependsOn) => {
2443            external_reference_identity(&relation.target_name)
2444                .map(|identity| (CARGO_PACKAGE_MANAGER, identity))
2445        }
2446        (Some(SemanticProviderOwner::Rust), RelationKind::Imports | RelationKind::Calls) => {
2447            rust_toolchain_identity(relation).map(|identity| (RUST_TOOLCHAIN_SYSTEM, identity))
2448        }
2449        (Some(SemanticProviderOwner::EcmaScript), RelationKind::Imports) => {
2450            node_builtin_identity(relation).map(|identity| (NODE_SYSTEM, identity))
2451        }
2452        (
2453            Some(
2454                SemanticProviderOwner::Python
2455                | SemanticProviderOwner::Unavailable
2456                | SemanticProviderOwner::Cargo
2457                | SemanticProviderOwner::EcmaScript,
2458            )
2459            | None,
2460            _,
2461        )
2462        | (Some(SemanticProviderOwner::Rust), RelationKind::Contains | RelationKind::DependsOn) => {
2463            None
2464        }
2465    };
2466    classified
2467        .map(|(system, identity)| {
2468            Ok(ExternalSelector {
2469                system: GraphIdentityText::new(system).map_err(invalid_graph_contract)?,
2470                identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
2471            })
2472        })
2473        .transpose()
2474}
2475
2476/// Normalize one explicit non-empty external reference.
2477fn external_reference_identity(reference: &str) -> Option<String> {
2478    let reference = reference.trim();
2479    (!reference.is_empty()).then(|| reference.to_string())
2480}
2481
2482/// Recognize only explicitly qualified Rust toolchain roots.
2483fn rust_toolchain_identity(
2484    relation: &projectatlas_core::symbols::SymbolRelation,
2485) -> Option<String> {
2486    if relation.kind == RelationKind::Imports {
2487        let mut references = parse_import_references(&relation.target_name).into_iter();
2488        let first = rust_import_identity(&references.next()?)?;
2489        return references.try_fold(first, |common, reference| {
2490            let identity = rust_import_identity(&reference)?;
2491            common_rust_path(&common, &identity)
2492        });
2493    }
2494    let target = relation_reference(relation);
2495    rust_toolchain_root(&target)?;
2496    Some(target)
2497}
2498
2499/// Return one explicitly toolchain-owned Rust import identity.
2500fn rust_import_identity(reference: &projectatlas_symbols::ImportReference) -> Option<String> {
2501    let module = reference.module();
2502    rust_toolchain_root(module)?;
2503    Some(reference.imported().map_or_else(
2504        || module.to_string(),
2505        |imported| format!("{module}::{imported}"),
2506    ))
2507}
2508
2509/// Return the non-empty common Rust module path shared by two imports.
2510fn common_rust_path(left: &str, right: &str) -> Option<String> {
2511    let components = left
2512        .split("::")
2513        .zip(right.split("::"))
2514        .take_while(|(left, right)| left == right)
2515        .map(|(component, _right)| component)
2516        .collect::<Vec<_>>();
2517    (!components.is_empty()).then(|| components.join("::"))
2518}
2519
2520/// Return the accepted toolchain root of one Rust module path.
2521fn rust_toolchain_root(path: &str) -> Option<&str> {
2522    let root = path.trim().split("::").next()?;
2523    matches!(root, "std" | "core" | "alloc").then_some(root)
2524}
2525
2526/// Recognize only the explicit `node:` built-in module scheme.
2527fn node_builtin_identity(relation: &projectatlas_core::symbols::SymbolRelation) -> Option<String> {
2528    parse_import_references(&relation.target_name)
2529        .into_iter()
2530        .find_map(|reference| {
2531            reference
2532                .module()
2533                .strip_prefix("node:")
2534                .filter(|identity| !identity.is_empty())
2535                .map(ToString::to_string)
2536        })
2537        .or_else(|| {
2538            quoted_ecmascript_module(&relation.target_name)
2539                .and_then(|module| module.strip_prefix("node:"))
2540                .filter(|identity| !identity.is_empty())
2541                .map(ToString::to_string)
2542        })
2543}
2544
2545/// Read the explicit quoted module from one parser-validated ECMAScript import.
2546fn quoted_ecmascript_module(import: &str) -> Option<&str> {
2547    let import = import.trim();
2548    if !import.starts_with("import ") {
2549        return None;
2550    }
2551    let (quote_index, quote) = import
2552        .char_indices()
2553        .find(|(_index, character)| matches!(character, '\'' | '"'))?;
2554    let remainder = &import[quote_index + quote.len_utf8()..];
2555    let end = remainder.find(quote)?;
2556    Some(&remainder[..end])
2557}
2558
2559/// Normalize an empty parser reference into one stable diagnostic identity.
2560fn nonempty_reference(value: &str) -> String {
2561    let value = value.trim();
2562    if value.is_empty() {
2563        UNKNOWN_REFERENCE.to_string()
2564    } else {
2565        value.to_string()
2566    }
2567}
2568
2569/// Keep call arguments and literal values out of persisted relation diagnostics.
2570fn relation_reference(relation: &SymbolRelation) -> String {
2571    let mut value = if relation.kind == RelationKind::Calls {
2572        relation
2573            .target_name
2574            .split_once('(')
2575            .map_or(relation.target_name.as_str(), |(target, _arguments)| target)
2576    } else {
2577        &relation.target_name
2578    };
2579    if relation.kind == RelationKind::Calls && value.contains(['\'', '"']) {
2580        value = call_leaf(value);
2581    }
2582    nonempty_reference(value)
2583}
2584
2585/// Project parser trust into one path-scoped coverage record.
2586fn coverage_for_graph(
2587    graph: &SymbolGraph,
2588    generation: IndexGeneration,
2589) -> Result<CoverageRecord, CliError> {
2590    let scope = CoverageScope::Path {
2591        path: RepositoryNodePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
2592    };
2593    let covered = u64::try_from(graph.relations.len()).unwrap_or(u64::MAX);
2594    match graph.parser {
2595        ParserKind::TreeSitter | ParserKind::Manifest => CoverageRecord::new(
2596            scope,
2597            None,
2598            CoverageState::Complete,
2599            covered,
2600            0,
2601            generation,
2602            None,
2603            None,
2604        )
2605        .map_err(invalid_graph_contract),
2606        ParserKind::Structural | ParserKind::Fallback if covered > 0 => CoverageRecord::new(
2607            scope,
2608            None,
2609            CoverageState::Partial,
2610            covered,
2611            1,
2612            generation,
2613            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
2614            None,
2615        )
2616        .map_err(invalid_graph_contract),
2617        ParserKind::Structural | ParserKind::Fallback => CoverageRecord::new(
2618            scope,
2619            None,
2620            CoverageState::Failed,
2621            0,
2622            1,
2623            generation,
2624            Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
2625            None,
2626        )
2627        .map_err(invalid_graph_contract),
2628    }
2629}
2630
2631/// Map parser strength to relation confidence.
2632fn relation_confidence(parser: ParserKind) -> ConfidenceClass {
2633    match parser {
2634        ParserKind::TreeSitter | ParserKind::Manifest => ConfidenceClass::Exact,
2635        ParserKind::Structural => ConfidenceClass::Medium,
2636        ParserKind::Fallback => ConfidenceClass::Low,
2637    }
2638}
2639
2640/// Map parser strength to relation completeness.
2641fn relation_completeness(parser: ParserKind) -> Completeness {
2642    match parser {
2643        ParserKind::TreeSitter | ParserKind::Manifest => Completeness::Complete,
2644        ParserKind::Structural | ParserKind::Fallback => Completeness::Partial,
2645    }
2646}
2647
2648/// Overlay staged symbol changes on persisted graphs for exact selected paths.
2649fn complete_symbol_graphs<'a>(
2650    store: &AtlasStore,
2651    paths: &BTreeSet<String>,
2652    symbols: &'a SymbolBuildStage,
2653    control: &IndexWorkControl,
2654) -> Result<Vec<Cow<'a, SymbolGraph>>, CliError> {
2655    let paths = paths.iter().cloned().collect::<Vec<_>>();
2656    let mut graphs = BTreeMap::new();
2657    for chunk in paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
2658        control.check(IndexWorkStage::SymbolParsing)?;
2659        for graph in store.load_symbol_graphs_for_paths(chunk)? {
2660            graphs.insert(graph.path.clone(), Cow::Owned(graph));
2661        }
2662    }
2663    for (index, change) in symbols.changes.iter().enumerate() {
2664        check_graph_work(control, index)?;
2665        match change {
2666            SymbolProjectionChange::Parsed(parsed) if paths.binary_search(&parsed.path).is_ok() => {
2667                graphs.insert(parsed.path.clone(), Cow::Borrowed(&parsed.graph));
2668            }
2669            SymbolProjectionChange::Clear { path, .. } if paths.binary_search(path).is_ok() => {
2670                graphs.remove(path);
2671            }
2672            SymbolProjectionChange::Parsed(_) | SymbolProjectionChange::Clear { .. } => {}
2673        }
2674    }
2675    graphs.retain(|path, _graph| paths.binary_search(path).is_ok());
2676    Ok(graphs.into_values().collect())
2677}
2678
2679/// Derive canonical resolution keys with repository compiler configuration.
2680fn resolution_projection_with_config(
2681    project: ProjectInstanceId,
2682    package: Option<&str>,
2683    graph: &SymbolGraph,
2684    configured_modules: &ConfiguredModuleResolution,
2685) -> Result<ResolutionKeyProjection, CliError> {
2686    let context = ResolutionProjectionContext::with_configured_modules(configured_modules);
2687    match derive_resolution_keys_with_context(project, package, graph, context) {
2688        Ok(projection) => Ok(projection),
2689        Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
2690            Err(IndexWorkFailure::resource_limit(
2691                IndexWorkStage::SymbolParsing,
2692                IndexWorkResource::RelationRows,
2693                u64::try_from(MAX_RESOLUTION_KEYS_PER_FACT).unwrap_or(u64::MAX),
2694                u64::try_from(requested).unwrap_or(u64::MAX),
2695            )
2696            .into())
2697        }
2698        Err(ResolutionProjectionError::Contract(error)) => Err(invalid_graph_contract(error)),
2699    }
2700}
2701
2702/// Load the project identity required by normalized graph projection.
2703fn selected_project(store: &AtlasStore) -> Result<ProjectInstanceId, CliError> {
2704    store.project_instance_id()?.ok_or_else(|| {
2705        CliError::InvalidInput("repository graph requires a bound project identity".to_string())
2706    })
2707}
2708
2709/// Return the pending graph generation after one successful publication.
2710fn next_generation(base: IndexGeneration) -> Result<IndexGeneration, CliError> {
2711    base.checked_next().ok_or_else(|| {
2712        CliError::InvalidInput("repository graph generation is exhausted".to_string())
2713    })
2714}
2715
2716/// Insert one stable entity while rejecting digest collisions.
2717fn insert_entity(
2718    entities: &mut BTreeMap<String, GraphEntity>,
2719    entity: GraphEntity,
2720) -> Result<(), CliError> {
2721    let digest = entity.key().digest().to_string();
2722    if let Some(existing) = entities.get(&digest) {
2723        if !existing
2724            .key()
2725            .reconcile(entity.key())
2726            .map_err(invalid_graph_contract)?
2727        {
2728            return Err(CliError::InvalidInput(
2729                "graph entity digest retained conflicting ownership".to_string(),
2730            ));
2731        }
2732        return Ok(());
2733    }
2734    entities.insert(digest, entity);
2735    Ok(())
2736}
2737
2738/// Sort and deduplicate entity export bindings by canonical identity and owner.
2739fn sort_dedup_exports(exports: &mut Vec<EntityResolutionKey>) {
2740    exports.sort_by(|left, right| {
2741        left.key()
2742            .cmp(right.key())
2743            .then_with(|| left.entity().digest().cmp(right.entity().digest()))
2744    });
2745    exports.dedup();
2746}
2747
2748/// Sort and deduplicate relation dependency bindings by canonical key and owner.
2749fn sort_dedup_dependencies(dependencies: &mut Vec<RelationDependencyKey>) {
2750    dependencies.sort_by(|left, right| {
2751        left.key()
2752            .cmp(right.key())
2753            .then_with(|| left.relation().digest().cmp(right.relation().digest()))
2754    });
2755    dependencies.dedup();
2756}
2757
2758/// Enforce the complete-projection ceiling for canonical key bindings.
2759fn enforce_key_binding_limit(count: usize) -> Result<(), CliError> {
2760    let observed = u64::try_from(count).unwrap_or(u64::MAX);
2761    if observed > MAX_GRAPH_KEY_BINDINGS {
2762        return Err(IndexWorkFailure::resource_limit(
2763            IndexWorkStage::SymbolParsing,
2764            IndexWorkResource::RelationRows,
2765            MAX_GRAPH_KEY_BINDINGS,
2766            observed,
2767        )
2768        .into());
2769    }
2770    Ok(())
2771}
2772
2773/// Enforce one path/key count before incremental graph work expands further.
2774fn enforce_incremental_count<T>(
2775    root: &Path,
2776    _context: &'static str,
2777    count: usize,
2778    sample: &BTreeSet<T>,
2779) -> Result<(), CliError>
2780where
2781    T: ToString + Ord,
2782{
2783    if count > MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
2784        return Err(dependency_closure_limit(
2785            root,
2786            sample.iter().map(ToString::to_string),
2787            count,
2788        ));
2789    }
2790    Ok(())
2791}
2792
2793/// Load and admit the persisted rows that an incremental replacement will remove.
2794fn admitted_persisted_footprint(
2795    store: &AtlasStore,
2796    project: ProjectInstanceId,
2797    root: &Path,
2798    affected_paths: &BTreeSet<String>,
2799    control: &IndexWorkControl,
2800) -> Result<RepositoryAffectedSourceFootprint, CliError> {
2801    control.check(IndexWorkStage::SymbolParsing)?;
2802    let footprint = store.repository_affected_source_footprint(
2803        project,
2804        &affected_paths.iter().cloned().collect::<Vec<_>>(),
2805        u32::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(u32::MAX),
2806    )?;
2807    control.check(IndexWorkStage::SymbolParsing)?;
2808    if footprint.truncated {
2809        return Err(dependency_closure_limit(
2810            root,
2811            affected_paths.iter().cloned(),
2812            usize::try_from(footprint.rows).unwrap_or(usize::MAX),
2813        ));
2814    }
2815    enforce_incremental_projection_budget(
2816        root,
2817        affected_paths,
2818        footprint.rows,
2819        footprint.retained_bytes,
2820    )?;
2821    Ok(footprint)
2822}
2823
2824/// Enforce aggregate old-removal and new-insertion work for one closure.
2825fn enforce_incremental_projection_limits(
2826    root: &Path,
2827    affected_paths: &BTreeSet<String>,
2828    persisted: RepositoryAffectedSourceFootprint,
2829    staged: &StagedRepositoryGraph,
2830) -> Result<(), CliError> {
2831    let staged_rows = [
2832        affected_paths.len(),
2833        staged.entities.len(),
2834        staged.relations.len(),
2835        staged.occurrences.len(),
2836        staged.coverage.len(),
2837        staged.entity_exports.len(),
2838        staged.relation_dependencies.len(),
2839    ]
2840    .into_iter()
2841    .fold(0_u64, |total, count| {
2842        total.saturating_add(u64::try_from(count).unwrap_or(u64::MAX))
2843    });
2844    enforce_incremental_projection_budget(
2845        root,
2846        affected_paths,
2847        persisted.rows.saturating_add(staged_rows),
2848        persisted
2849            .retained_bytes
2850            .saturating_add(staged.retained_bytes),
2851    )
2852}
2853
2854/// Return typed full-refresh guidance when an incremental projection exceeds a bound.
2855fn enforce_incremental_projection_budget(
2856    root: &Path,
2857    affected_paths: &BTreeSet<String>,
2858    rows: u64,
2859    retained_bytes: u64,
2860) -> Result<(), CliError> {
2861    if rows > MAX_INCREMENTAL_GRAPH_ROWS || retained_bytes > MAX_INCREMENTAL_GRAPH_BYTES {
2862        return Err(dependency_closure_limit(
2863            root,
2864            affected_paths.iter().cloned(),
2865            affected_paths.len(),
2866        ));
2867    }
2868    Ok(())
2869}
2870
2871/// Construct deterministic full-refresh guidance for an oversized dependency closure.
2872fn dependency_closure_limit(
2873    root: &Path,
2874    sample: impl IntoIterator<Item = String>,
2875    observed: usize,
2876) -> CliError {
2877    CliError::RefreshRequired(Box::new(IndexRefreshRequired {
2878        project_root: normalize_native_path_display(root),
2879        status: IndexReadStatus::RefreshRequired,
2880        reason: IndexRefreshReason::DependencyClosureLimit,
2881        scope: IndexRefreshScope::Full,
2882        changed: observed,
2883        added: 0,
2884        removed: 0,
2885        modified: observed,
2886        sample_paths: sample
2887            .into_iter()
2888            .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
2889            .collect(),
2890    }))
2891}
2892
2893/// Translate a graph-domain contract violation for the CLI boundary.
2894fn invalid_graph_contract(error: GraphContractError) -> CliError {
2895    CliError::from(error)
2896}
2897
2898impl From<GraphContractError> for CliError {
2899    fn from(error: GraphContractError) -> Self {
2900        Self::InvalidInput(format!("repository graph projection failed: {error}"))
2901    }
2902}
2903
2904/// Count conservative retained canonical resolution-key bytes.
2905fn resolution_retained_bytes(projection: &ResolutionKeyProjection) -> u64 {
2906    projection
2907        .source_keys()
2908        .iter()
2909        .chain(
2910            projection
2911                .symbol_keys()
2912                .iter()
2913                .flat_map(projectatlas_symbols::SymbolResolutionKeys::keys),
2914        )
2915        .chain(
2916            projection
2917                .relation_keys()
2918                .iter()
2919                .flat_map(projectatlas_symbols::RelationResolutionKeys::keys),
2920        )
2921        .fold(0_u64, |bytes, key| {
2922            bytes
2923                .saturating_add(32)
2924                .saturating_add(key.canonical_identity().len() as u64)
2925        })
2926}
2927
2928#[cfg(test)]
2929mod tests {
2930    use super::{
2931        CliError, GRAPH_STAGE_DATABASE_FILE_NAME, GRAPH_STAGE_DIRECTORY_PREFIX, GraphOwners,
2932        GraphSymbolIndex, MAX_INCREMENTAL_GRAPH_BYTES, MAX_INCREMENTAL_GRAPH_ROWS, PackageIndex,
2933        ProjectResolutionRegistry, RepositoryGraphMutation, StagedRepositoryGraph,
2934        build_entity_projection, build_entity_projection_with_config,
2935        cleanup_abandoned_graph_staging, enforce_incremental_projection_budget,
2936        enforce_incremental_projection_limits, explicit_external_selector, finish_projection,
2937        finish_projection_in_database, insert_relation, is_cargo_manifest_path,
2938        registry_resolution_matches, relation_resolution, remove_owned_graph_stage_payload,
2939        repository_path_belongs_to, resolution_registry_from_exports, rust_toolchain_identity,
2940        stage_incremental_repository_graph, try_graph_stage_lease,
2941    };
2942    use crate::runtime::{
2943        IndexRefreshReason, IndexRefreshScope, SymbolBuildReport, SymbolBuildStage,
2944    };
2945    use projectatlas_core::graph::{
2946        CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageScope, CoverageState,
2947        EntityResolutionKey, EntitySelector, ExtendedRelationKind, GraphEntity, GraphIdentityText,
2948        GraphRelationKind, LogicalRelation, PackageSelector, ProjectInstanceId,
2949        RelationDependencyKey, RelationResolution, RepositoryFilePath, RepositoryNodePath,
2950        ResolutionKeyDomain, ReusableTargetSelector, SymbolSelector,
2951    };
2952    use projectatlas_core::relation_capabilities::{
2953        RELATION_FAMILY_CAPABILITIES, RelationFamilyState,
2954    };
2955    use projectatlas_core::symbols::{
2956        CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
2957        SymbolRelation,
2958    };
2959    use projectatlas_core::{
2960        IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
2961        Node, NodeKind,
2962    };
2963    use projectatlas_db::{
2964        AtlasStore, RepositoryAffectedSourceFootprint, RepositoryGraphRelationQuery,
2965    };
2966    use projectatlas_symbols::extract_symbol_graph;
2967    use projectatlas_symbols::{
2968        ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
2969        EcmaScriptPathMapping,
2970    };
2971    use std::collections::{BTreeMap, BTreeSet};
2972    use std::error::Error;
2973    use std::fmt::Debug;
2974    use std::fs;
2975    use std::io;
2976    use std::num::NonZeroU32;
2977    use std::path::Path;
2978    use std::thread;
2979    use std::time::{Duration, Instant};
2980
2981    #[cfg(unix)]
2982    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
2983        std::os::unix::fs::symlink(target, link)
2984    }
2985
2986    #[cfg(windows)]
2987    fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
2988        match std::os::windows::fs::symlink_dir(target, link) {
2989            Ok(()) => Ok(()),
2990            Err(source) if source.raw_os_error() == Some(1314) => {
2991                let status = std::process::Command::new("cmd")
2992                    .arg("/C")
2993                    .arg("mklink")
2994                    .arg("/J")
2995                    .arg(link)
2996                    .arg(target)
2997                    .status()?;
2998                if status.success() {
2999                    Ok(())
3000                } else {
3001                    Err(source)
3002                }
3003            }
3004            Err(source) => Err(source),
3005        }
3006    }
3007
3008    #[cfg(unix)]
3009    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
3010        std::os::unix::fs::symlink(target, link)
3011    }
3012
3013    #[cfg(windows)]
3014    fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
3015        std::os::windows::fs::symlink_file(target, link)
3016    }
3017
3018    #[test]
3019    fn cargo_package_ownership_uses_the_longest_repository_prefix() -> Result<(), Box<dyn Error>> {
3020        let graphs = vec![
3021            package_graph("Cargo.toml", "workspace"),
3022            package_graph("crates/member/Cargo.toml", "member"),
3023        ];
3024        let packages = PackageIndex::from_graphs(&graphs)?;
3025        require_eq(
3026            &packages.package_name("src/lib.rs"),
3027            &Some("workspace"),
3028            "root package ownership",
3029        )?;
3030        require_eq(
3031            &packages.package_name("crates/member/src/lib.rs"),
3032            &Some("member"),
3033            "nested package ownership",
3034        )?;
3035        require(
3036            repository_path_belongs_to("crates/member/src/lib.rs", "crates/member"),
3037            "member path was not owned by its package",
3038        )?;
3039        require(
3040            !repository_path_belongs_to("crates/membership/src/lib.rs", "crates/member"),
3041            "package prefix matched a partial segment",
3042        )?;
3043        require(is_cargo_manifest_path("Cargo.toml"), "root manifest")?;
3044        require(
3045            is_cargo_manifest_path("crates/member/Cargo.toml"),
3046            "nested manifest",
3047        )?;
3048        require(
3049            !is_cargo_manifest_path("docs/Cargo.toml.example"),
3050            "manifest suffix lookalike",
3051        )?;
3052        Ok(())
3053    }
3054
3055    #[test]
3056    fn incremental_projection_budget_requests_one_complete_refresh() -> Result<(), Box<dyn Error>> {
3057        let root = Path::new("repository");
3058        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
3059        for (rows, retained_bytes) in [
3060            (MAX_INCREMENTAL_GRAPH_ROWS + 1, 0),
3061            (0, MAX_INCREMENTAL_GRAPH_BYTES + 1),
3062        ] {
3063            let error =
3064                enforce_incremental_projection_budget(root, &affected_paths, rows, retained_bytes)
3065                    .err()
3066                    .ok_or_else(|| io::Error::other("oversized closure reached publication"))?;
3067            let CliError::RefreshRequired(report) = error else {
3068                return Err(io::Error::other(format!(
3069                    "expected typed full-refresh guidance, found {error:?}"
3070                ))
3071                .into());
3072            };
3073            require_eq(
3074                &report.reason,
3075                &IndexRefreshReason::DependencyClosureLimit,
3076                "incremental budget reason",
3077            )?;
3078            require_eq(
3079                &report.scope,
3080                &IndexRefreshScope::Full,
3081                "incremental budget scope",
3082            )?;
3083            require_eq(&report.changed, &1, "incremental changed paths")?;
3084            require_eq(
3085                &report.sample_paths,
3086                &vec!["src/lib.rs".to_string()],
3087                "incremental sample paths",
3088            )?;
3089        }
3090        Ok(())
3091    }
3092
3093    #[test]
3094    fn incremental_projection_budget_combines_old_and_new_work() -> Result<(), Box<dyn Error>> {
3095        let root = Path::new("repository");
3096        let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
3097        let persisted = RepositoryAffectedSourceFootprint {
3098            rows: MAX_INCREMENTAL_GRAPH_ROWS,
3099            retained_bytes: 0,
3100            truncated: false,
3101        };
3102        let staged = StagedRepositoryGraph {
3103            project: ProjectInstanceId::from_bytes([1; 16])?,
3104            mutation: RepositoryGraphMutation::AffectedPaths(vec!["src/lib.rs".to_string()]),
3105            entities: Vec::new(),
3106            relations: Vec::new(),
3107            occurrences: Vec::new(),
3108            coverage: Vec::new(),
3109            entity_exports: Vec::new(),
3110            relation_dependencies: Vec::new(),
3111            database: None,
3112            retained_bytes: 0,
3113        };
3114        let error =
3115            enforce_incremental_projection_limits(root, &affected_paths, persisted, &staged)
3116                .err()
3117                .ok_or_else(|| io::Error::other("combined old and new work was admitted"))?;
3118        let CliError::RefreshRequired(report) = error else {
3119            return Err(io::Error::other(format!(
3120                "expected typed full-refresh guidance, found {error:?}"
3121            ))
3122            .into());
3123        };
3124        require_eq(
3125            &report.reason,
3126            &IndexRefreshReason::DependencyClosureLimit,
3127            "combined budget reason",
3128        )?;
3129        require_eq(
3130            &report.scope,
3131            &IndexRefreshScope::Full,
3132            "combined budget scope",
3133        )?;
3134        Ok(())
3135    }
3136
3137    #[test]
3138    fn resolution_registry_reuses_staged_export_entities() -> Result<(), Box<dyn Error>> {
3139        let project = ProjectInstanceId::from_bytes([2; 16])?;
3140        let generation = IndexGeneration::new(1);
3141        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3142        let graphs = vec![
3143            extract_symbol_graph(
3144                "Cargo.toml",
3145                Some("cargo-manifest"),
3146                "[package]\nname = \"atlas\"\n",
3147            ),
3148            extract_symbol_graph("src/lib.rs", Some("rust"), "pub fn run() {}\n"),
3149        ];
3150        let packages = PackageIndex::from_graphs(&graphs)?;
3151        let projection =
3152            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
3153        let registry = resolution_registry_from_exports(&projection, &control)?;
3154        let registered_bindings = registry
3155            .candidate_digests_by_key
3156            .values()
3157            .map(BTreeSet::len)
3158            .sum::<usize>();
3159
3160        require_eq(
3161            &registry.supplemental_entities_by_digest.len(),
3162            &0,
3163            "duplicate registry-owned entity count",
3164        )?;
3165        require_eq(
3166            &registered_bindings,
3167            &projection.entity_exports.len(),
3168            "registry key binding count",
3169        )?;
3170        require(
3171            registry
3172                .candidate_digests_by_key
3173                .values()
3174                .flatten()
3175                .all(|digest| projection.entity_by_digest.contains_key(digest)),
3176            "resolution key referenced an unstaged entity digest",
3177        )?;
3178        let mut bindings_per_entity = BTreeMap::<&str, usize>::new();
3179        for digest in registry.candidate_digests_by_key.values().flatten() {
3180            *bindings_per_entity.entry(digest).or_default() += 1;
3181        }
3182        require(
3183            bindings_per_entity.values().any(|count| *count > 1),
3184            "fixture did not exercise one entity exported under multiple canonical keys",
3185        )?;
3186        Ok(())
3187    }
3188
3189    #[test]
3190    fn restart_cleanup_removes_only_inactive_owned_graph_stages() -> Result<(), Box<dyn Error>> {
3191        let temp = tempfile::tempdir()?;
3192        let root = temp.path().join("restart-cleanup");
3193        let atlas_dir = root.join(".projectatlas");
3194        fs::create_dir_all(&atlas_dir)?;
3195        let database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
3196        let store = AtlasStore::open_for_project(&database, &root)?;
3197        let project = store
3198            .project_instance_id()?
3199            .ok_or("bound project identity is missing")?;
3200
3201        let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}owned"));
3202        fs::create_dir(&owned)?;
3203        let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
3204        drop(AtlasStore::create_repository_graph_staging(
3205            &owned_database,
3206            &root,
3207            project,
3208        )?);
3209        let owned_payload = owned.join("payload");
3210        fs::create_dir(&owned_payload)?;
3211        fs::write(owned_payload.join("row"), "discard")?;
3212        let owned_link_target = temp.path().join("owned-link-target");
3213        fs::create_dir(&owned_link_target)?;
3214        fs::write(owned_link_target.join("sentinel"), "preserve")?;
3215        let owned_payload_link = owned.join("linked-payload");
3216        create_directory_link(&owned_link_target, &owned_payload_link)?;
3217        let interrupted_shell =
3218            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}interrupted-shell"));
3219        fs::create_dir(&interrupted_shell)?;
3220        let unvalidated_nonempty = atlas_dir.join(format!(
3221            "{GRAPH_STAGE_DIRECTORY_PREFIX}unvalidated-nonempty"
3222        ));
3223        fs::create_dir(&unvalidated_nonempty)?;
3224        fs::write(unvalidated_nonempty.join("sentinel"), "preserve")?;
3225        let lookalike = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}lookalike"));
3226        fs::create_dir(&lookalike)?;
3227        drop(AtlasStore::open_for_project(
3228            &lookalike.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3229            &root,
3230        )?);
3231        let foreign_root = temp.path().join("foreign-project");
3232        fs::create_dir(&foreign_root)?;
3233        let foreign_store =
3234            AtlasStore::open_for_project(&foreign_root.join("projectatlas.db"), &foreign_root)?;
3235        let foreign_project = foreign_store
3236            .project_instance_id()?
3237            .ok_or("foreign project identity is missing")?;
3238        let foreign_project_stage =
3239            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-project"));
3240        fs::create_dir(&foreign_project_stage)?;
3241        drop(AtlasStore::create_repository_graph_staging(
3242            &foreign_project_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3243            &root,
3244            foreign_project,
3245        )?);
3246        let foreign_root_stage =
3247            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-root"));
3248        fs::create_dir(&foreign_root_stage)?;
3249        drop(AtlasStore::create_repository_graph_staging(
3250            &foreign_root_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3251            &foreign_root,
3252            project,
3253        )?);
3254        let linked_stage_target = temp.path().join("linked-stage-target");
3255        fs::create_dir(&linked_stage_target)?;
3256        fs::write(linked_stage_target.join("sentinel"), "preserve")?;
3257        drop(AtlasStore::create_repository_graph_staging(
3258            &linked_stage_target.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3259            &root,
3260            project,
3261        )?);
3262        let linked_stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked"));
3263        create_directory_link(&linked_stage_target, &linked_stage)?;
3264
3265        let linked_database_target = temp.path().join("linked-stage-database.db");
3266        drop(AtlasStore::create_repository_graph_staging(
3267            &linked_database_target,
3268            &root,
3269            project,
3270        )?);
3271        let linked_database_stage =
3272            atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked-database"));
3273        fs::create_dir(&linked_database_stage)?;
3274        let linked_database = linked_database_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
3275        let linked_database_created =
3276            match create_file_link(&linked_database_target, &linked_database) {
3277                Ok(()) => true,
3278                #[cfg(windows)]
3279                Err(source) if source.raw_os_error() == Some(1314) => false,
3280                Err(source) => return Err(source.into()),
3281            };
3282        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3283
3284        let lease = try_graph_stage_lease(&atlas_dir)?
3285            .ok_or("test could not acquire graph staging lease")?;
3286        remove_owned_graph_stage_payload(&owned, &owned_database, Some(&control))?;
3287        require(
3288            owned_database.is_file() && !owned_payload.exists(),
3289            "payload cleanup did not retain the ownership database until last",
3290        )?;
3291        require(
3292            fs::symlink_metadata(&owned_payload_link).is_err()
3293                && owned_link_target.join("sentinel").is_file(),
3294            "payload cleanup followed or retained a linked child",
3295        )?;
3296        cleanup_abandoned_graph_staging(&root, project, &control)?;
3297        require(
3298            owned.exists(),
3299            "restart cleanup removed an actively leased stage",
3300        )?;
3301        drop(lease);
3302
3303        let canceled_control = IndexWorkControl::new(IndexCancellation::new(), None);
3304        canceled_control.cancel();
3305        let canceled = cleanup_abandoned_graph_staging(&root, project, &canceled_control)
3306            .err()
3307            .ok_or("canceled restart cleanup unexpectedly succeeded")?;
3308        require(
3309            matches!(
3310                canceled,
3311                CliError::IndexWork(IndexWorkFailure::Cancelled {
3312                    stage: IndexWorkStage::Publication
3313                })
3314            ),
3315            "restart cleanup did not preserve typed cancellation",
3316        )?;
3317        require(
3318            owned.exists(),
3319            "canceled restart cleanup removed an owned stage",
3320        )?;
3321
3322        cleanup_abandoned_graph_staging(&root, project, &control)?;
3323        require(
3324            !owned.exists(),
3325            "restart cleanup retained an inactive owned stage",
3326        )?;
3327        require(
3328            !interrupted_shell.exists(),
3329            "restart cleanup retained an empty interrupted stage shell",
3330        )?;
3331        require(
3332            unvalidated_nonempty.join("sentinel").is_file(),
3333            "restart cleanup removed a non-empty unvalidated stage",
3334        )?;
3335        require(
3336            lookalike.exists(),
3337            "restart cleanup removed an unvalidated lookalike stage",
3338        )?;
3339        require(
3340            foreign_project_stage.exists(),
3341            "restart cleanup removed a valid stage owned by another project",
3342        )?;
3343        require(
3344            foreign_root_stage.exists(),
3345            "restart cleanup removed a valid stage bound to another root",
3346        )?;
3347        require(
3348            fs::symlink_metadata(&linked_stage).is_ok()
3349                && linked_stage_target.join("sentinel").is_file(),
3350            "restart cleanup followed a linked stage directory",
3351        )?;
3352        if linked_database_created {
3353            require(
3354                fs::symlink_metadata(&linked_database).is_ok() && linked_database_target.is_file(),
3355                "restart cleanup followed a linked staging database",
3356            )?;
3357        }
3358        Ok(())
3359    }
3360
3361    #[test]
3362    fn restart_cleanup_observes_cancellation_between_owned_stages() -> Result<(), Box<dyn Error>> {
3363        const STAGE_COUNT: usize = 64;
3364        const FILES_PER_STAGE: usize = 64;
3365
3366        let temp = tempfile::tempdir()?;
3367        let root = temp.path().join("restart-cleanup-cancellation");
3368        let atlas_dir = root.join(".projectatlas");
3369        fs::create_dir_all(&atlas_dir)?;
3370        let store =
3371            AtlasStore::open_for_project(&atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME), &root)?;
3372        let project = store
3373            .project_instance_id()?
3374            .ok_or("bound project identity is missing")?;
3375        let mut stages = Vec::with_capacity(STAGE_COUNT);
3376        for stage_index in 0..STAGE_COUNT {
3377            let stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}{stage_index:03}"));
3378            fs::create_dir(&stage)?;
3379            drop(AtlasStore::create_repository_graph_staging(
3380                &stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3381                &root,
3382                project,
3383            )?);
3384            let payload = stage.join("payload");
3385            fs::create_dir(&payload)?;
3386            for file_index in 0..FILES_PER_STAGE {
3387                fs::write(payload.join(format!("{file_index:03}")), b"x")?;
3388            }
3389            stages.push(stage);
3390        }
3391
3392        let cancellation = IndexCancellation::new();
3393        let control = IndexWorkControl::new(cancellation.clone(), None);
3394        let worker_root = root;
3395        let worker =
3396            thread::spawn(move || cleanup_abandoned_graph_staging(&worker_root, project, &control));
3397        let observation_deadline = Instant::now() + Duration::from_secs(30);
3398        loop {
3399            let remaining = stages.iter().filter(|stage| stage.exists()).count();
3400            if remaining < STAGE_COUNT {
3401                cancellation.cancel();
3402                break;
3403            }
3404            if worker.is_finished() {
3405                return Err(io::Error::other(
3406                    "restart cleanup completed before in-flight cancellation was observed",
3407                )
3408                .into());
3409            }
3410            if Instant::now() >= observation_deadline {
3411                cancellation.cancel();
3412                return Err(io::Error::other(
3413                    "restart cleanup removed no stage within the test deadline",
3414                )
3415                .into());
3416            }
3417            thread::yield_now();
3418        }
3419        let result = worker
3420            .join()
3421            .map_err(|_panic| io::Error::other("restart cleanup worker panicked"))?;
3422        require(
3423            matches!(
3424                result,
3425                Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
3426                    stage: IndexWorkStage::Publication
3427                }))
3428            ),
3429            "restart cleanup did not return typed in-flight cancellation",
3430        )?;
3431        let remaining = stages.iter().filter(|stage| stage.exists()).count();
3432        require(
3433            remaining > 0 && remaining < STAGE_COUNT,
3434            "in-flight cancellation did not preserve a partial cleanup boundary",
3435        )
3436    }
3437
3438    #[test]
3439    fn staged_database_owner_retains_incomplete_creation() -> Result<(), Box<dyn Error>> {
3440        let temp = tempfile::tempdir()?;
3441        let atlas_dir = temp.path().join(".projectatlas");
3442        fs::create_dir(&atlas_dir)?;
3443        let directory = tempfile::Builder::new()
3444            .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
3445            .tempdir_in(&atlas_dir)?;
3446        let staging_path = directory.path().to_path_buf();
3447        fs::write(
3448            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME),
3449            "incomplete",
3450        )?;
3451        fs::write(staging_path.join("payload"), "preserve")?;
3452        let lease = try_graph_stage_lease(&atlas_dir)?
3453            .ok_or("test could not acquire graph staging lease")?;
3454        let owner = super::StagedGraphDatabase {
3455            store: None,
3456            directory: Some(directory),
3457            _lease: lease,
3458        };
3459
3460        drop(owner);
3461
3462        require(
3463            staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME).is_file()
3464                && staging_path.join("payload").is_file(),
3465            "incomplete staging creation was recursively deleted",
3466        )
3467    }
3468
3469    #[test]
3470    fn database_staging_publishes_and_removes_its_disposable_store() -> Result<(), Box<dyn Error>> {
3471        let temp = tempfile::tempdir()?;
3472        let root = temp.path().join("database-staging");
3473        let atlas_dir = root.join(".projectatlas");
3474        fs::create_dir_all(&atlas_dir)?;
3475        let database = atlas_dir.join("projectatlas.db");
3476        let mut store = AtlasStore::open_for_project(&database, &root)?;
3477        let project = store
3478            .project_instance_id()?
3479            .ok_or("bound project identity is missing")?;
3480        let generation = IndexGeneration::new(1);
3481        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3482        let graphs = vec![extract_symbol_graph(
3483            "src/lib.rs",
3484            Some("rust"),
3485            "pub fn caller() { helper(); }\nfn helper() {}\n",
3486        )];
3487        let nodes = vec![test_file_node("src/lib.rs", "rust")];
3488        let packages = PackageIndex::from_graphs(&graphs)?;
3489        let projection = build_entity_projection(
3490            project, generation, &nodes, &graphs, &packages, true, &control,
3491        )?;
3492        let candidates = resolution_registry_from_exports(&projection, &control)?;
3493        let staged = finish_projection_in_database(
3494            &root,
3495            &nodes,
3496            project,
3497            generation,
3498            &graphs,
3499            projection,
3500            &candidates,
3501            &control,
3502        )?;
3503        let staging_path = staged
3504            .database
3505            .as_ref()
3506            .ok_or("database staging was not selected")?
3507            .directory()?
3508            .path()
3509            .to_path_buf();
3510        let drop_payload = staging_path.join("drop-payload");
3511        fs::create_dir(&drop_payload)?;
3512        fs::write(drop_payload.join("row"), "discard")?;
3513        let drop_link_target = temp.path().join("drop-link-target");
3514        fs::create_dir(&drop_link_target)?;
3515        fs::write(drop_link_target.join("sentinel"), "preserve")?;
3516        create_directory_link(&drop_link_target, &staging_path.join("drop-linked-payload"))?;
3517        require(
3518            staging_path.exists(),
3519            "database staging directory is missing",
3520        )?;
3521        {
3522            let mut publication = store.begin_index_publication("database-staging")?;
3523            publication.begin_scan_replacement()?;
3524            publication.upsert_scan_node_batch(&nodes)?;
3525            publication.finish_scan_replacement()?;
3526            staged.apply(&mut publication, &control)?;
3527            publication.complete()?;
3528        }
3529        drop(staged);
3530        require(
3531            !staging_path.exists(),
3532            "database staging directory survived publication",
3533        )?;
3534        require(
3535            drop_link_target.join("sentinel").is_file(),
3536            "database staging drop followed a linked payload",
3537        )?;
3538        drop(store);
3539
3540        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
3541        let coverage = reader.repository_graph_coverage(
3542            project,
3543            &CoverageScope::Path {
3544                path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
3545            },
3546            8,
3547        )?;
3548        require(
3549            !coverage.truncated,
3550            "database-staged coverage was truncated",
3551        )?;
3552        require(
3553            !coverage.rows.is_empty(),
3554            "database-staged graph rows were not published",
3555        )?;
3556        Ok(())
3557    }
3558
3559    #[test]
3560    fn accepted_relation_families_publish_and_reopen() -> Result<(), Box<dyn Error>> {
3561        let temp = tempfile::tempdir()?;
3562        let root = temp.path().join("accepted-relation-families");
3563        for directory in ["src", "tests", "config", "infra", "data"] {
3564            fs::create_dir_all(root.join(directory))?;
3565        }
3566        let database = root.join("projectatlas.db");
3567        let mut store = AtlasStore::open_for_project(&database, &root)?;
3568        let project = store
3569            .project_instance_id()?
3570            .ok_or_else(|| io::Error::other("relation inventory identity is missing"))?;
3571        let generation = IndexGeneration::new(1);
3572        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3573        let graphs = vec![
3574            extract_symbol_graph(
3575                "Cargo.toml",
3576                Some("cargo-manifest"),
3577                concat!(
3578                    "[package]\nname = \"relation-fixture\"\nversion = \"0.1.0\"\n",
3579                    "\n[dependencies]\nserde = \"1\"\n",
3580                ),
3581            ),
3582            extract_symbol_graph(
3583                "src/lib.rs",
3584                Some("rust"),
3585                concat!(
3586                    "use std::fs;\n",
3587                    "pub struct Router { pub enabled: bool }\n",
3588                    "impl Router { pub fn install(&self) {} }\n",
3589                    "pub fn handler() {}\n",
3590                    "pub fn register() {\n",
3591                    "    route(\"/health\", handler);\n",
3592                    "    let _ = std::env::var(\"ATLAS_MODE\").unwrap_or_else(|_| \"super-secret\".into());\n",
3593                    "    let _ = fs::read_to_string(\"data/input.txt\");\n",
3594                    "    let _ = fs::write(\"data/output.txt\", \"ok\");\n",
3595                    "}\n",
3596                    "fn route(_path: &str, _handler: fn()) {}\n",
3597                ),
3598            ),
3599            extract_symbol_graph(
3600                "tests/feature_test.rs",
3601                Some("rust"),
3602                "fn subject() {}\nfn verifies_subject() { subject(); }\n",
3603            ),
3604            extract_symbol_graph(
3605                "config/appsettings.json",
3606                Some("json"),
3607                "{\"token\":\"super-secret\"}\n",
3608            ),
3609            extract_symbol_graph(
3610                "infra/main.tf",
3611                Some("terraform"),
3612                "resource \"null_resource\" \"fixture\" {}\n",
3613            ),
3614            extract_symbol_graph("data/input.txt", None, "input\n"),
3615            extract_symbol_graph("data/output.txt", None, ""),
3616        ];
3617        let nodes = graphs
3618            .iter()
3619            .map(|graph| {
3620                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
3621            })
3622            .collect::<Vec<_>>();
3623        let packages = PackageIndex::from_graphs(&graphs)?;
3624        let projection = build_entity_projection(
3625            project, generation, &nodes, &graphs, &packages, true, &control,
3626        )?;
3627        let candidates = resolution_registry_from_exports(&projection, &control)?;
3628        let staged = finish_projection(
3629            project,
3630            generation,
3631            RepositoryGraphMutation::Full,
3632            &graphs,
3633            projection,
3634            &candidates,
3635            &control,
3636        )?;
3637        {
3638            let mut publication = store.begin_index_publication("accepted-relation-families")?;
3639            publication.begin_scan_replacement()?;
3640            publication.upsert_scan_node_batch(&nodes)?;
3641            publication.finish_scan_replacement()?;
3642            staged.apply(&mut publication, &control)?;
3643            publication.complete()?;
3644        }
3645        drop(store);
3646
3647        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
3648        for capability in RELATION_FAMILY_CAPABILITIES
3649            .iter()
3650            .filter(|capability| capability.state == RelationFamilyState::Active)
3651        {
3652            for &family in capability.graph_relations {
3653                let page = reader.repository_graph_relations(
3654                    RepositoryGraphRelationQuery::Family { relation: family },
3655                    128,
3656                )?;
3657                require(!page.truncated, &format!("{family:?} page was truncated"))?;
3658                require(
3659                    !page.rows.is_empty(),
3660                    &format!("{family:?} had no reopened persisted relation"),
3661                )?;
3662                for relation in page.rows {
3663                    let occurrences = reader.repository_graph_occurrences(&relation, 32)?;
3664                    require(
3665                        !occurrences.rows.is_empty() && !occurrences.truncated,
3666                        &format!("{family:?} lost exact source occurrences"),
3667                    )?;
3668                    require(
3669                        !format!("{relation:?}").contains("super-secret"),
3670                        &format!("secret value escaped into persisted {family:?} relation"),
3671                    )?;
3672                }
3673            }
3674        }
3675        Ok(())
3676    }
3677
3678    #[test]
3679    fn accepted_relation_families_abstain_without_static_evidence() -> Result<(), Box<dyn Error>> {
3680        let project = ProjectInstanceId::from_bytes([13; 16])?;
3681        let generation = IndexGeneration::new(1);
3682        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3683        let graphs = vec![
3684            extract_symbol_graph(
3685                "src/dynamic.rs",
3686                Some("rust"),
3687                concat!(
3688                    "use std::fs;\n",
3689                    "struct Client;\n",
3690                    "impl Client { fn get(&self, _path: &str, _handler: fn()) {} }\n",
3691                    "fn handler() {}\n",
3692                    "fn dynamic(client: &Client, route_path: &str, key: &str, file: &str) {\n",
3693                    "    client.get(\"/health\", handler);\n",
3694                    "    route(route_path, handler);\n",
3695                    "    let _ = std::env::var(key);\n",
3696                    "    let _ = fs::read_to_string(file);\n",
3697                    "    let _ = fs::write(file, \"super-secret\");\n",
3698                    "}\n",
3699                    "fn route(_path: &str, _handler: fn()) {}\n",
3700                ),
3701            ),
3702            extract_symbol_graph(
3703                "src/escaping.rs",
3704                Some("rust"),
3705                concat!(
3706                    "use std::fs;\n",
3707                    "fn unsafe_paths() {\n",
3708                    "    let _ = fs::read_to_string(\"../secret.txt\");\n",
3709                    "    let _ = fs::write(\"C:/secret.txt\", \"super-secret\");\n",
3710                    "}\n",
3711                ),
3712            ),
3713            extract_symbol_graph(
3714                "src/dynamic.js",
3715                Some("javascript"),
3716                concat!(
3717                    "function handler() {}\n",
3718                    "function middleware() {}\n",
3719                    "function route(...args) {}\n",
3720                    "route(\"/health\", handler, middleware);\n",
3721                ),
3722            ),
3723            extract_symbol_graph("docs/main.tf.example", None, "not infrastructure\n"),
3724            extract_symbol_graph("docs/k8s/README.md", None, "not infrastructure\n"),
3725            extract_symbol_graph("docs/cloudformation-notes.md", None, "not infrastructure\n"),
3726            extract_symbol_graph("config/settings.json.bak", None, "super-secret\n"),
3727        ];
3728        let packages = PackageIndex::from_graphs(&graphs)?;
3729        let projection =
3730            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
3731        let candidates = resolution_registry_from_exports(&projection, &control)?;
3732        let staged = finish_projection(
3733            project,
3734            generation,
3735            RepositoryGraphMutation::Full,
3736            &graphs,
3737            projection,
3738            &candidates,
3739            &control,
3740        )?;
3741        for family in [
3742            ExtendedRelationKind::Tests,
3743            ExtendedRelationKind::RoutesTo,
3744            ExtendedRelationKind::Configures,
3745            ExtendedRelationKind::Deploys,
3746            ExtendedRelationKind::Reads,
3747            ExtendedRelationKind::Writes,
3748        ] {
3749            require(
3750                staged
3751                    .relations
3752                    .iter()
3753                    .all(|relation| relation.kind() != GraphRelationKind::Extended(family)),
3754                &format!("dynamic or lookalike input fabricated {family:?}"),
3755            )?;
3756        }
3757        require(
3758            staged
3759                .entities
3760                .iter()
3761                .all(|entity| !entity.key().canonical_identity().contains("super-secret")),
3762            "negative fixture leaked a secret into graph identity",
3763        )?;
3764        Ok(())
3765    }
3766
3767    #[test]
3768    fn qualified_symbol_scopes_produce_distinct_graph_entity_keys() -> Result<(), Box<dyn Error>> {
3769        let project = ProjectInstanceId::from_bytes([3; 16])?;
3770        let generation = IndexGeneration::new(1);
3771        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3772        for (path, language, source, expected_parents) in [
3773            (
3774                "src/lib.rs",
3775                "rust",
3776                concat!(
3777                    "mod first { struct Runner; impl Runner { fn run(&self) {} } }\n",
3778                    "mod second { struct Runner; impl Runner { fn run(&self) {} } }\n",
3779                ),
3780                ["first::Runner", "second::Runner"],
3781            ),
3782            (
3783                "src/Runner.java",
3784                "java",
3785                concat!(
3786                    "class First { class Runner { void run() {} } }\n",
3787                    "class Second { class Runner { void run() {} } }\n",
3788                ),
3789                ["First::Runner", "Second::Runner"],
3790            ),
3791        ] {
3792            let graph = extract_symbol_graph(path, Some(language), source);
3793            let method_indices = graph
3794                .symbols
3795                .iter()
3796                .enumerate()
3797                .filter_map(|(index, symbol)| {
3798                    (symbol.kind == SymbolKind::Method && symbol.name == "run").then_some(index)
3799                })
3800                .collect::<Vec<_>>();
3801            require_eq(&method_indices.len(), &2, "scoped method count")?;
3802            let first_symbol = &graph.symbols[method_indices[0]];
3803            let second_symbol = &graph.symbols[method_indices[1]];
3804            require_eq(
3805                &first_symbol.parent.as_deref(),
3806                &Some("Runner"),
3807                "legacy leaf parent",
3808            )?;
3809            require_eq(
3810                &first_symbol.parent,
3811                &second_symbol.parent,
3812                "same legacy leaf parent",
3813            )?;
3814            require_eq(
3815                &first_symbol.signature,
3816                &second_symbol.signature,
3817                "same declaration signature",
3818            )?;
3819
3820            let graphs = vec![graph];
3821            let packages = PackageIndex::from_graphs(&graphs)?;
3822            let projection = build_entity_projection(
3823                project,
3824                generation,
3825                &[],
3826                &graphs,
3827                &packages,
3828                true,
3829                &control,
3830            )?;
3831            let owners = projection
3832                .owners_by_graph
3833                .get(path)
3834                .ok_or("scoped graph owners are missing")?;
3835            let first = owners.symbol_digests[method_indices[0]]
3836                .as_ref()
3837                .and_then(|digest| projection.entity_by_digest.get(digest))
3838                .ok_or("first scoped method entity is missing")?;
3839            let second = owners.symbol_digests[method_indices[1]]
3840                .as_ref()
3841                .and_then(|digest| projection.entity_by_digest.get(digest))
3842                .ok_or("second scoped method entity is missing")?;
3843            let EntitySelector::Symbol {
3844                symbol: first_selector,
3845            } = first.selector()
3846            else {
3847                return Err("first scoped entity is not a symbol".into());
3848            };
3849            let EntitySelector::Symbol {
3850                symbol: second_selector,
3851            } = second.selector()
3852            else {
3853                return Err("second scoped entity is not a symbol".into());
3854            };
3855            require_eq(
3856                &first_selector
3857                    .parent
3858                    .as_ref()
3859                    .map(GraphIdentityText::as_str),
3860                &Some(expected_parents[0]),
3861                "first graph identity parent",
3862            )?;
3863            require_eq(
3864                &second_selector
3865                    .parent
3866                    .as_ref()
3867                    .map(GraphIdentityText::as_str),
3868                &Some(expected_parents[1]),
3869                "second graph identity parent",
3870            )?;
3871            require(
3872                first.key() != second.key(),
3873                "independent semantic scopes shared one graph entity key",
3874            )?;
3875        }
3876        Ok(())
3877    }
3878
3879    #[test]
3880    fn external_classification_follows_effective_semantic_provider() -> Result<(), Box<dyn Error>> {
3881        for (language, target, system, identity) in [
3882            ("html", "import fs from \"node:fs\";", "node", "fs"),
3883            ("svelte", "import url from \"node:url\";", "node", "url"),
3884            ("vue", "import path from \"node:path\";", "node", "path"),
3885        ] {
3886            let case = resolution_case(language, RelationKind::Imports, target, &[]);
3887            let external = explicit_external_selector(&case.graph, &case.relation)?
3888                .ok_or("embedded ECMAScript external classification is missing")?;
3889            require_eq(&external.system.as_str(), &system, "external system")?;
3890            require_eq(&external.identity.as_str(), &identity, "external identity")?;
3891        }
3892
3893        let lock = resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]);
3894        require(
3895            explicit_external_selector(&lock.graph, &lock.relation)?.is_none(),
3896            "Cargo.lock was misclassified as an explicit external dependency",
3897        )?;
3898        Ok(())
3899    }
3900
3901    #[test]
3902    fn grouped_rust_imports_use_only_their_common_external_module() -> Result<(), Box<dyn Error>> {
3903        let grouped = resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]);
3904        require_eq(
3905            &rust_toolchain_identity(&grouped.relation),
3906            &Some("std".to_string()),
3907            "grouped Rust external root",
3908        )?;
3909        let nested = resolution_case(
3910            "rust",
3911            RelationKind::Imports,
3912            "use std::fs::{read, write};",
3913            &[],
3914        );
3915        require_eq(
3916            &rust_toolchain_identity(&nested.relation),
3917            &Some("std::fs".to_string()),
3918            "nested grouped Rust external module",
3919        )?;
3920        let ordinary = resolution_case("rust", RelationKind::Imports, "use std::fs;", &[]);
3921        require_eq(
3922            &rust_toolchain_identity(&ordinary.relation),
3923            &Some("std::fs".to_string()),
3924            "ordinary Rust external module",
3925        )?;
3926        Ok(())
3927    }
3928
3929    #[test]
3930    fn registry_candidate_merge_deduplicates_exactly_and_observes_cancellation()
3931    -> Result<(), Box<dyn Error>> {
3932        let project = ProjectInstanceId::from_bytes([6; 16])?;
3933        let generation = IndexGeneration::new(1);
3934        let first_key = test_resolution_key(project, "first-key")?;
3935        let second_key = test_resolution_key(project, "second-key")?;
3936        let first = test_symbol_entity(project, generation, "src/first.rs", "first")?;
3937        let second = test_symbol_entity(project, generation, "src/second.rs", "second")?;
3938        let mut registry = ProjectResolutionRegistry::default();
3939        registry.insert_candidate(&first_key, &first)?;
3940        registry.insert_candidate(&second_key, &first)?;
3941        registry.insert_candidate(&second_key, &second)?;
3942        let control = IndexWorkControl::new(IndexCancellation::new(), None);
3943        let staged_entities = BTreeMap::new();
3944        let matches = registry_resolution_matches(
3945            &[first_key.clone(), second_key.clone()],
3946            &registry,
3947            &staged_entities,
3948            &control,
3949        )?;
3950        require_eq(&matches.count, &2, "distinct merged candidate count")?;
3951        let expected_first = [&first, &second]
3952            .into_iter()
3953            .min_by_key(|entity| entity.key().digest())
3954            .ok_or("candidate fixture is empty")?;
3955        require_eq(
3956            &matches.first.map(|entity| entity.key().digest()),
3957            &Some(expected_first.key().digest()),
3958            "stable first candidate",
3959        )?;
3960
3961        let cancellation = IndexCancellation::new();
3962        let canceled_control = IndexWorkControl::new(cancellation.clone(), None);
3963        cancellation.cancel();
3964        require(
3965            registry_resolution_matches(
3966                &[first_key, second_key],
3967                &registry,
3968                &staged_entities,
3969                &canceled_control,
3970            )
3971            .is_err(),
3972            "candidate merge ignored cancellation",
3973        )?;
3974        Ok(())
3975    }
3976
3977    #[test]
3978    fn duplicate_ambiguous_relations_keep_the_largest_candidate_count() -> Result<(), Box<dyn Error>>
3979    {
3980        let project = ProjectInstanceId::from_bytes([15; 16])?;
3981        let generation = IndexGeneration::new(1);
3982        let source = test_file_entity(project, generation, "src/duplicate.ts")?;
3983        let relation = |candidates| -> Result<LogicalRelation, Box<dyn Error>> {
3984            Ok(LogicalRelation::new(
3985                &source,
3986                GraphRelationKind::from_legacy(RelationKind::Contains),
3987                RelationResolution::Ambiguous {
3988                    reference: GraphIdentityText::new("declarations")?,
3989                    candidates: NonZeroU32::new(candidates).ok_or("candidate count was zero")?,
3990                },
3991                ConfidenceClass::Exact,
3992                Completeness::Complete,
3993                generation,
3994            )?)
3995        };
3996        let mut relations = BTreeMap::new();
3997        insert_relation(
3998            &mut relations,
3999            relation(2)?,
4000            "src/duplicate.ts",
4001            "logical",
4002            0,
4003        )?;
4004        insert_relation(
4005            &mut relations,
4006            relation(3)?,
4007            "src/duplicate.ts",
4008            "logical",
4009            1,
4010        )?;
4011        insert_relation(
4012            &mut relations,
4013            relation(2)?,
4014            "src/duplicate.ts",
4015            "logical",
4016            2,
4017        )?;
4018        let retained = relations
4019            .into_values()
4020            .next()
4021            .ok_or("deduplicated relation was not retained")?;
4022        require(
4023            matches!(
4024                retained.resolution(),
4025                RelationResolution::Ambiguous { candidates, .. } if candidates.get() == 3
4026            ),
4027            "deduplicated ambiguity did not retain the largest candidate count",
4028        )?;
4029
4030        let mut conflicting = BTreeMap::new();
4031        insert_relation(
4032            &mut conflicting,
4033            relation(2)?,
4034            "src/duplicate.ts",
4035            "logical",
4036            0,
4037        )?;
4038        let different_confidence = LogicalRelation::new(
4039            &source,
4040            GraphRelationKind::from_legacy(RelationKind::Contains),
4041            RelationResolution::Ambiguous {
4042                reference: GraphIdentityText::new("declarations")?,
4043                candidates: NonZeroU32::new(2).ok_or("candidate count was zero")?,
4044            },
4045            ConfidenceClass::High,
4046            Completeness::Complete,
4047            generation,
4048        )?;
4049        require(
4050            insert_relation(
4051                &mut conflicting,
4052                different_confidence,
4053                "src/duplicate.ts",
4054                "logical",
4055                1,
4056            )
4057            .is_err(),
4058            "a non-ambiguity conflict was merged",
4059        )?;
4060        Ok(())
4061    }
4062
4063    #[test]
4064    fn same_file_private_calls_resolve_and_duplicate_declarations_stay_ambiguous()
4065    -> Result<(), Box<dyn Error>> {
4066        let project = ProjectInstanceId::from_bytes([5; 16])?;
4067        let generation = IndexGeneration::new(1);
4068        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4069        let private_graph = extract_symbol_graph(
4070            "src/private.rs",
4071            Some("rust"),
4072            "pub fn caller() { helper(); }\nfn helper() {}\n",
4073        );
4074        require(
4075            private_graph
4076                .symbols
4077                .iter()
4078                .any(|symbol| symbol.name == "helper" && !symbol.exported),
4079            "fixture helper was not private",
4080        )?;
4081        let packages = PackageIndex::from_graphs(std::slice::from_ref(&private_graph))?;
4082        let projection = build_entity_projection(
4083            project,
4084            generation,
4085            &[],
4086            std::slice::from_ref(&private_graph),
4087            &packages,
4088            true,
4089            &control,
4090        )?;
4091        let candidates = resolution_registry_from_exports(&projection, &control)?;
4092        let staged = finish_projection(
4093            project,
4094            generation,
4095            RepositoryGraphMutation::Full,
4096            std::slice::from_ref(&private_graph),
4097            projection,
4098            &candidates,
4099            &control,
4100        )?;
4101        let helper_call = staged
4102            .relations
4103            .iter()
4104            .find(|relation| {
4105                matches!(
4106                    relation.resolution(),
4107                    RelationResolution::Resolved {
4108                        selector: ReusableTargetSelector::Symbol { symbol },
4109                        ..
4110                    } if symbol.name.as_str() == "helper"
4111                        && symbol.file.as_str() == "src/private.rs"
4112                )
4113            })
4114            .ok_or("private same-file helper call did not resolve")?;
4115        require_eq(
4116            &helper_call.kind(),
4117            &GraphRelationKind::from_legacy(RelationKind::Calls),
4118            "private same-file relation kind",
4119        )?;
4120
4121        let duplicate_graph = SymbolGraph {
4122            path: "src/duplicate.rs".to_string(),
4123            language: Some("rust".to_string()),
4124            parser: ParserKind::TreeSitter,
4125            symbols: vec![
4126                test_code_symbol("src/duplicate.rs", "caller", Some("Owner"), "fn caller()"),
4127                test_code_symbol(
4128                    "src/duplicate.rs",
4129                    "helper",
4130                    Some("Owner"),
4131                    "fn helper(first: u8)",
4132                ),
4133                test_code_symbol(
4134                    "src/duplicate.rs",
4135                    "helper",
4136                    Some("Owner"),
4137                    "fn helper(second: u16)",
4138                ),
4139                test_code_symbol(
4140                    "src/duplicate.rs",
4141                    "helper",
4142                    Some("Unrelated"),
4143                    "fn helper()",
4144                ),
4145            ],
4146            relations: vec![SymbolRelation {
4147                path: "src/duplicate.rs".to_string(),
4148                source_name: "caller".to_string(),
4149                target_name: "helper".to_string(),
4150                kind: RelationKind::Calls,
4151                line: 1,
4152                context: "helper()".to_string(),
4153                parser: ParserKind::TreeSitter,
4154            }],
4155        };
4156        let packages = PackageIndex::from_graphs(std::slice::from_ref(&duplicate_graph))?;
4157        let projection = build_entity_projection(
4158            project,
4159            generation,
4160            &[],
4161            std::slice::from_ref(&duplicate_graph),
4162            &packages,
4163            true,
4164            &control,
4165        )?;
4166        let candidates = resolution_registry_from_exports(&projection, &control)?;
4167        let staged = finish_projection(
4168            project,
4169            generation,
4170            RepositoryGraphMutation::Full,
4171            std::slice::from_ref(&duplicate_graph),
4172            projection,
4173            &candidates,
4174            &control,
4175        )?;
4176        require(
4177            staged.relations.iter().any(|relation| {
4178                matches!(
4179                    relation.resolution(),
4180                    RelationResolution::Ambiguous {
4181                        reference,
4182                        candidates,
4183                    } if reference.as_str() == "helper" && candidates.get() == 2
4184                )
4185            }),
4186            "duplicate same-file declarations did not remain ambiguous",
4187        )?;
4188        Ok(())
4189    }
4190
4191    #[test]
4192    fn configured_module_targets_reuse_graph_ambiguity_ownership() -> Result<(), Box<dyn Error>> {
4193        let project = ProjectInstanceId::from_bytes([19; 16])?;
4194        let generation = IndexGeneration::new(1);
4195        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4196        let graphs = vec![
4197            extract_symbol_graph(
4198                "src/first/controller.ts",
4199                Some("typescript"),
4200                "export function useController() { return 'first'; }\n",
4201            ),
4202            extract_symbol_graph(
4203                "src/second/controller.ts",
4204                Some("typescript"),
4205                "export function useController() { return 'second'; }\n",
4206            ),
4207            extract_symbol_graph(
4208                "src/page.ts",
4209                Some("typescript"),
4210                "import { useController } from '@/controller';\nexport const value = useController();\n",
4211            ),
4212        ];
4213        let packages = PackageIndex::from_graphs(&graphs)?;
4214        let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
4215            "tsconfig.json",
4216            EcmaScriptConfigKind::TypeScript,
4217            None,
4218            vec![EcmaScriptPathMapping::new(
4219                "@/*",
4220                vec!["src/first/*".to_string(), "src/second/*".to_string()],
4221            )?],
4222        )?])?;
4223        let projection = build_entity_projection_with_config(
4224            project,
4225            generation,
4226            &[],
4227            &graphs,
4228            &packages,
4229            &configured,
4230            true,
4231            &control,
4232        )?;
4233        let candidates = resolution_registry_from_exports(&projection, &control)?;
4234        let staged = finish_projection(
4235            project,
4236            generation,
4237            RepositoryGraphMutation::Full,
4238            &graphs,
4239            projection,
4240            &candidates,
4241            &control,
4242        )?;
4243        for kind in [RelationKind::Imports, RelationKind::Calls] {
4244            require(
4245                staged.relations.iter().any(|relation| {
4246                    relation.kind() == GraphRelationKind::from_legacy(kind)
4247                        && matches!(
4248                            relation.resolution(),
4249                            RelationResolution::Ambiguous { candidates, .. }
4250                                if candidates.get() == 2
4251                        )
4252                }),
4253                "configured mapping did not retain all ambiguity candidates",
4254            )?;
4255        }
4256        Ok(())
4257    }
4258
4259    #[test]
4260    fn extracted_provider_matrix_survives_sqlite_publication_and_reopen()
4261    -> Result<(), Box<dyn Error>> {
4262        let temp = tempfile::tempdir()?;
4263        let root = temp.path().join("extracted-provider-matrix");
4264        fs::create_dir_all(&root)?;
4265        let database = root.join("projectatlas.db");
4266        let mut store = AtlasStore::open_for_project(&database, &root)?;
4267        let project = store
4268            .project_instance_id()?
4269            .ok_or("bound project identity is missing")?;
4270        let generation = IndexGeneration::new(1);
4271        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4272        let graphs = vec![
4273            extract_symbol_graph(
4274                "src/lib.rs",
4275                Some("rust"),
4276                "use std::{fs, io};\npub fn caller() { private_helper(); }\nfn private_helper() {}\n",
4277            ),
4278            extract_symbol_graph(
4279                "src/config.rs",
4280                Some("rust"),
4281                "pub fn load_timeout_millis() -> u64 { 250 }\n",
4282            ),
4283            extract_symbol_graph(
4284                "src/handler.rs",
4285                Some("rust"),
4286                "use crate::config;\npub fn health_response() { config::load_timeout_millis(); }\n",
4287            ),
4288            extract_symbol_graph(
4289                "src/router.rs",
4290                Some("rust"),
4291                "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
4292            ),
4293            extract_symbol_graph(
4294                "src/app.js",
4295                Some("javascript"),
4296                "import path from \"node:path\";\nexport function run() { return path.join('a', 'b'); }\n",
4297            ),
4298            extract_symbol_graph(
4299                "src/app.py",
4300                Some("python"),
4301                "import requests\n\ndef run():\n    return requests.get('https://example.test')\n",
4302            ),
4303            extract_symbol_graph(
4304                "Cargo.toml",
4305                Some("cargo-manifest"),
4306                "[package]\nname = \"matrix-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nduplicate = \"1\"\n",
4307            ),
4308            extract_symbol_graph(
4309                "vendor/first/Cargo.toml",
4310                Some("cargo-manifest"),
4311                "[package]\nname = \"duplicate\"\nversion = \"1.0.0\"\n",
4312            ),
4313            extract_symbol_graph(
4314                "vendor/second/Cargo.toml",
4315                Some("cargo-manifest"),
4316                "[package]\nname = \"duplicate\"\nversion = \"2.0.0\"\n",
4317            ),
4318            extract_symbol_graph(
4319                "public/index.html",
4320                Some("html"),
4321                "<script type=\"module\">\nimport fs from \"node:fs\";\nexport function boot() { return fs.readFile; }\n</script>\n",
4322            ),
4323            extract_symbol_graph(
4324                "src/Page.svelte",
4325                Some("svelte"),
4326                "<script lang=\"ts\">\nimport url from \"node:url\";\nexport function page() { return url.parse('https://example.test'); }\n</script>\n",
4327            ),
4328        ];
4329        for (language, expected_relation) in [
4330            ("rust", "std"),
4331            ("javascript", "node:path"),
4332            ("python", "requests"),
4333            ("cargo-manifest", "duplicate"),
4334            ("html", "node:fs"),
4335            ("svelte", "node:url"),
4336        ] {
4337            require(
4338                graphs.iter().any(|graph| {
4339                    graph.language.as_deref() == Some(language)
4340                        && graph
4341                            .relations
4342                            .iter()
4343                            .any(|relation| relation.target_name.contains(expected_relation))
4344                }),
4345                &format!("extracted {language} provider relation is missing"),
4346            )?;
4347        }
4348        let nodes = graphs
4349            .iter()
4350            .map(|graph| {
4351                test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
4352            })
4353            .collect::<Vec<_>>();
4354        let packages = PackageIndex::from_graphs(&graphs)?;
4355        let projection = build_entity_projection(
4356            project, generation, &nodes, &graphs, &packages, true, &control,
4357        )?;
4358        let candidates = resolution_registry_from_exports(&projection, &control)?;
4359        let staged = finish_projection(
4360            project,
4361            generation,
4362            RepositoryGraphMutation::Full,
4363            &graphs,
4364            projection,
4365            &candidates,
4366            &control,
4367        )?;
4368        let source_keys = staged
4369            .entities
4370            .iter()
4371            .map(|entity| entity.key().clone())
4372            .collect::<Vec<_>>();
4373        {
4374            let mut publication = store.begin_index_publication("extracted-provider-matrix")?;
4375            publication.begin_scan_replacement()?;
4376            publication.upsert_scan_node_batch(&nodes)?;
4377            publication.finish_scan_replacement()?;
4378            staged.apply(&mut publication, &control)?;
4379            publication.complete()?;
4380        }
4381        drop(store);
4382
4383        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
4384        let mut reopened = Vec::new();
4385        for source in source_keys {
4386            let page = reader.repository_graph_relations(
4387                RepositoryGraphRelationQuery::Outbound { source },
4388                128,
4389            )?;
4390            require(!page.truncated, "extracted provider matrix was truncated")?;
4391            reopened.extend(page.rows);
4392        }
4393        let mut external = BTreeSet::new();
4394        let mut unresolved = BTreeSet::new();
4395        let mut ambiguous = BTreeMap::new();
4396        let mut resolved_symbols = BTreeSet::new();
4397        for relation in reopened {
4398            require_eq(
4399                &relation.generation(),
4400                &generation,
4401                "extracted relation generation",
4402            )?;
4403            match relation.resolution() {
4404                RelationResolution::External {
4405                    external: target, ..
4406                } => {
4407                    external.insert((
4408                        target.system.as_str().to_string(),
4409                        target.identity.as_str().to_string(),
4410                    ));
4411                }
4412                RelationResolution::Unresolved { reference } => {
4413                    unresolved.insert(reference.as_str().to_string());
4414                }
4415                RelationResolution::Ambiguous {
4416                    reference,
4417                    candidates,
4418                } => {
4419                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
4420                }
4421                RelationResolution::Resolved { selector, .. } => {
4422                    if let ReusableTargetSelector::Symbol { symbol } = selector {
4423                        resolved_symbols.insert((
4424                            symbol.file.as_str().to_string(),
4425                            symbol.name.as_str().to_string(),
4426                        ));
4427                    }
4428                }
4429            }
4430        }
4431        for expected in [
4432            ("rust-toolchain".to_string(), "std".to_string()),
4433            ("node".to_string(), "path".to_string()),
4434            ("node".to_string(), "fs".to_string()),
4435            ("node".to_string(), "url".to_string()),
4436        ] {
4437            require(
4438                external.contains(&expected),
4439                &format!("reopened external target is missing: {expected:?}"),
4440            )?;
4441        }
4442        require(
4443            unresolved
4444                .iter()
4445                .any(|reference| reference.contains("requests")),
4446            "reopened Python unresolved import is missing",
4447        )?;
4448        require_eq(
4449            &ambiguous.get("duplicate"),
4450            &Some(&2),
4451            "reopened Cargo duplicate ambiguity",
4452        )?;
4453        require(
4454            resolved_symbols.contains(&("src/lib.rs".to_string(), "private_helper".to_string())),
4455            "reopened private Rust helper resolution is missing",
4456        )?;
4457        require(
4458            resolved_symbols.contains(&(
4459                "src/config.rs".to_string(),
4460                "load_timeout_millis".to_string(),
4461            )),
4462            "reopened Rust module-qualified call resolution is missing",
4463        )?;
4464        require(
4465            resolved_symbols
4466                .contains(&("src/handler.rs".to_string(), "health_response".to_string())),
4467            "reopened Rust callback resolution is missing",
4468        )?;
4469        reader.finish_index_read_snapshot()?;
4470        Ok(())
4471    }
4472
4473    #[test]
4474    fn closed_resolution_states_survive_sqlite_publication_and_reopen() -> Result<(), Box<dyn Error>>
4475    {
4476        let temp = tempfile::tempdir()?;
4477        let root = temp.path().join("closed-resolution-states");
4478        fs::create_dir_all(&root)?;
4479        let database = root.join("projectatlas.db");
4480        let mut store = AtlasStore::open_for_project(&database, &root)?;
4481        let project = store
4482            .project_instance_id()?
4483            .ok_or("bound project identity is missing")?;
4484        let generation = IndexGeneration::new(1);
4485        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4486        let source = test_file_entity(project, generation, "src/main.rs")?;
4487        let unique_target = test_symbol_entity(project, generation, "src/unique.rs", "unique")?;
4488        let first_shared = test_symbol_entity(project, generation, "src/first.rs", "shared")?;
4489        let second_shared = test_symbol_entity(project, generation, "src/second.rs", "shared")?;
4490        let local_package = GraphEntity::new(
4491            project,
4492            EntitySelector::Package {
4493                package: PackageSelector {
4494                    manager: GraphIdentityText::new("cargo")?,
4495                    name: GraphIdentityText::new("local-package")?,
4496                    manifest: RepositoryFilePath::new(Path::new("vendor/local/Cargo.toml"))?,
4497                },
4498            },
4499            generation,
4500        )?;
4501        let unique_key = test_resolution_key(project, "unique")?;
4502        let shared_key = test_resolution_key(project, "shared")?;
4503        let local_package_key = test_resolution_key(project, "local-package")?;
4504        let mut registry = ProjectResolutionRegistry::default();
4505        registry.insert_candidate(&unique_key, &unique_target)?;
4506        registry.insert_candidate(&shared_key, &first_shared)?;
4507        registry.insert_candidate(&shared_key, &second_shared)?;
4508        registry.insert_candidate(&local_package_key, &local_package)?;
4509        let source_digest = source.key().digest().to_string();
4510        let owners = GraphOwners {
4511            file_digest: source_digest.clone(),
4512            symbol_digests: Vec::new(),
4513        };
4514        let staged_entities = BTreeMap::from([(source_digest, source.clone())]);
4515        let mut external_entities = BTreeMap::new();
4516        let cases = [
4517            resolution_case("rust", RelationKind::Calls, "unique", &[&unique_key]),
4518            resolution_case("rust", RelationKind::Calls, "shared", &[&shared_key]),
4519            resolution_case("rust", RelationKind::Calls, "missing", &[]),
4520            resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]),
4521            resolution_case("cargo-manifest", RelationKind::DependsOn, "serde", &[]),
4522            resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]),
4523            resolution_case(
4524                "javascript",
4525                RelationKind::Imports,
4526                "import path from \"node:path\";",
4527                &[],
4528            ),
4529            resolution_case(
4530                "html",
4531                RelationKind::Imports,
4532                "import fs from \"node:fs\";",
4533                &[],
4534            ),
4535            resolution_case(
4536                "svelte",
4537                RelationKind::Imports,
4538                "import url from \"node:url\";",
4539                &[],
4540            ),
4541            resolution_case(
4542                "javascript",
4543                RelationKind::Imports,
4544                "import value from \"left-pad\";",
4545                &[],
4546            ),
4547            resolution_case("python", RelationKind::Imports, "import requests", &[]),
4548            resolution_case(
4549                "cargo-manifest",
4550                RelationKind::DependsOn,
4551                "local-package",
4552                &[&local_package_key],
4553            ),
4554        ];
4555        let mut relations = Vec::new();
4556        let mut dependencies = Vec::new();
4557        for case in &cases {
4558            let symbol_index = GraphSymbolIndex::new(&case.graph, &control)?;
4559            let resolution = relation_resolution(
4560                project,
4561                generation,
4562                &case.relation,
4563                &owners,
4564                &case.graph,
4565                &symbol_index,
4566                &case.keys,
4567                &registry,
4568                &staged_entities,
4569                &mut external_entities,
4570                &control,
4571            )?;
4572            let relation = LogicalRelation::new(
4573                &source,
4574                GraphRelationKind::from_legacy(case.relation.kind),
4575                resolution,
4576                ConfidenceClass::High,
4577                Completeness::Complete,
4578                generation,
4579            )?;
4580            for key in &case.keys {
4581                dependencies.push(RelationDependencyKey::new(
4582                    relation.key().clone(),
4583                    key.clone(),
4584                )?);
4585            }
4586            relations.push(relation);
4587        }
4588        let external_keys = external_entities
4589            .values()
4590            .map(|entity| entity.key().clone())
4591            .collect::<Vec<_>>();
4592        let entities = [
4593            source.clone(),
4594            unique_target,
4595            first_shared,
4596            second_shared,
4597            local_package,
4598        ]
4599        .into_iter()
4600        .chain(external_entities.into_values())
4601        .collect::<Vec<_>>();
4602        let exports = [
4603            EntityResolutionKey::new(entities[1].key().clone(), unique_key.clone())?,
4604            EntityResolutionKey::new(entities[2].key().clone(), shared_key.clone())?,
4605            EntityResolutionKey::new(entities[3].key().clone(), shared_key.clone())?,
4606            EntityResolutionKey::new(entities[4].key().clone(), local_package_key.clone())?,
4607        ];
4608        let staged = StagedRepositoryGraph {
4609            project,
4610            mutation: RepositoryGraphMutation::Full,
4611            entities,
4612            relations,
4613            occurrences: Vec::new(),
4614            coverage: Vec::new(),
4615            entity_exports: exports.into(),
4616            relation_dependencies: dependencies,
4617            database: None,
4618            retained_bytes: 0,
4619        };
4620        {
4621            let mut publication = store.begin_index_publication("closed-resolution-states")?;
4622            publication.begin_scan_replacement()?;
4623            publication.upsert_scan_node_batch(&[
4624                test_file_node("src/main.rs", "rust"),
4625                test_file_node("src/unique.rs", "rust"),
4626                test_file_node("src/first.rs", "rust"),
4627                test_file_node("src/second.rs", "rust"),
4628                test_file_node("vendor/local/Cargo.toml", "cargo-manifest"),
4629            ])?;
4630            publication.finish_scan_replacement()?;
4631            staged.apply(&mut publication, &control)?;
4632            publication.complete()?;
4633        }
4634        drop(store);
4635
4636        let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
4637        let page = reader.repository_graph_relations(
4638            RepositoryGraphRelationQuery::Outbound {
4639                source: source.key().clone(),
4640            },
4641            32,
4642        )?;
4643        require(!page.truncated, "closed resolution proof was truncated")?;
4644        require_eq(&page.rows.len(), &cases.len(), "reopened relation count")?;
4645        let mut resolved = BTreeSet::new();
4646        let mut ambiguous = BTreeMap::new();
4647        let mut unresolved = BTreeSet::new();
4648        let mut external = BTreeSet::new();
4649        for relation in &page.rows {
4650            require_eq(
4651                &relation.generation(),
4652                &generation,
4653                "reopened relation generation",
4654            )?;
4655            match relation.resolution() {
4656                RelationResolution::Resolved {
4657                    selector,
4658                    generation: target_generation,
4659                    ..
4660                } => {
4661                    require_eq(target_generation, &generation, "resolved target generation")?;
4662                    match selector {
4663                        ReusableTargetSelector::Symbol { symbol } => {
4664                            resolved.insert(symbol.name.as_str().to_string());
4665                        }
4666                        ReusableTargetSelector::Package { package } => {
4667                            resolved.insert(package.name.as_str().to_string());
4668                        }
4669                        ReusableTargetSelector::Folder { .. }
4670                        | ReusableTargetSelector::File { .. } => {}
4671                    }
4672                }
4673                RelationResolution::Ambiguous {
4674                    reference,
4675                    candidates,
4676                } => {
4677                    ambiguous.insert(reference.as_str().to_string(), candidates.get());
4678                }
4679                RelationResolution::Unresolved { reference } => {
4680                    unresolved.insert(reference.as_str().to_string());
4681                }
4682                RelationResolution::External {
4683                    external: selector,
4684                    generation: target_generation,
4685                    ..
4686                } => {
4687                    require_eq(target_generation, &generation, "external target generation")?;
4688                    external.insert((
4689                        selector.system.as_str().to_string(),
4690                        selector.identity.as_str().to_string(),
4691                    ));
4692                }
4693            }
4694        }
4695        require_eq(
4696            &resolved,
4697            &BTreeSet::from(["local-package".to_string(), "unique".to_string()]),
4698            "resolved targets",
4699        )?;
4700        require_eq(
4701            &ambiguous,
4702            &BTreeMap::from([("shared".to_string(), 2)]),
4703            "ambiguous targets",
4704        )?;
4705        require_eq(
4706            &unresolved,
4707            &BTreeSet::from([
4708                "import requests".to_string(),
4709                "import value from \"left-pad\";".to_string(),
4710                "missing".to_string(),
4711                "serde-lock".to_string(),
4712            ]),
4713            "unresolved targets",
4714        )?;
4715        require_eq(
4716            &external,
4717            &BTreeSet::from([
4718                ("cargo".to_string(), "serde".to_string()),
4719                ("node".to_string(), "fs".to_string()),
4720                ("node".to_string(), "path".to_string()),
4721                ("node".to_string(), "url".to_string()),
4722                ("rust-toolchain".to_string(), "std".to_string()),
4723            ]),
4724            "external targets",
4725        )?;
4726        for key in &external_keys {
4727            let entity = reader
4728                .repository_graph_entity(key)?
4729                .ok_or("reopened external entity is missing")?;
4730            require(
4731                matches!(entity.selector(), EntitySelector::External { .. }),
4732                "external relation target reopened as a local entity",
4733            )?;
4734            require_eq(
4735                &entity.generation(),
4736                &generation,
4737                "external entity generation",
4738            )?;
4739        }
4740        let affected = reader.repository_affected_source_paths(
4741            project,
4742            &[unique_key, shared_key, local_package_key],
4743            32,
4744        )?;
4745        require(!affected.truncated, "dependency source proof was truncated")?;
4746        require_eq(
4747            &affected.rows,
4748            &vec![RepositoryFilePath::new(Path::new("src/main.rs"))?],
4749            "reopened dependency source paths",
4750        )?;
4751        reader.finish_index_read_snapshot()?;
4752        Ok(())
4753    }
4754
4755    struct ResolutionCase {
4756        graph: SymbolGraph,
4757        relation: SymbolRelation,
4758        keys: Vec<CanonicalResolutionKey>,
4759    }
4760
4761    fn resolution_case(
4762        language: &str,
4763        kind: RelationKind,
4764        target: &str,
4765        keys: &[&CanonicalResolutionKey],
4766    ) -> ResolutionCase {
4767        let parser = if language.starts_with("cargo-") {
4768            ParserKind::Manifest
4769        } else {
4770            ParserKind::TreeSitter
4771        };
4772        ResolutionCase {
4773            graph: SymbolGraph {
4774                path: "src/main.rs".to_string(),
4775                language: Some(language.to_string()),
4776                parser,
4777                symbols: Vec::new(),
4778                relations: Vec::new(),
4779            },
4780            relation: SymbolRelation {
4781                path: "src/main.rs".to_string(),
4782                source_name: "src/main.rs".to_string(),
4783                target_name: target.to_string(),
4784                kind,
4785                line: 1,
4786                context: target.to_string(),
4787                parser,
4788            },
4789            keys: keys.iter().map(|key| (*key).clone()).collect(),
4790        }
4791    }
4792
4793    fn test_resolution_key(
4794        project: ProjectInstanceId,
4795        identity: &str,
4796    ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
4797        let provider = GraphIdentityText::new("test-provider")?;
4798        let language = GraphIdentityText::new("test-language")?;
4799        let identity = GraphIdentityText::new(identity)?;
4800        Ok(CanonicalResolutionKey::new(
4801            project,
4802            ResolutionKeyDomain::Declaration,
4803            &provider,
4804            &language,
4805            None,
4806            None,
4807            None,
4808            &identity,
4809        ))
4810    }
4811
4812    fn test_file_entity(
4813        project: ProjectInstanceId,
4814        generation: IndexGeneration,
4815        path: &str,
4816    ) -> Result<GraphEntity, Box<dyn Error>> {
4817        Ok(GraphEntity::new(
4818            project,
4819            EntitySelector::File {
4820                path: RepositoryFilePath::new(Path::new(path))?,
4821            },
4822            generation,
4823        )?)
4824    }
4825
4826    fn test_symbol_entity(
4827        project: ProjectInstanceId,
4828        generation: IndexGeneration,
4829        path: &str,
4830        name: &str,
4831    ) -> Result<GraphEntity, Box<dyn Error>> {
4832        Ok(GraphEntity::new(
4833            project,
4834            EntitySelector::Symbol {
4835                symbol: SymbolSelector {
4836                    file: RepositoryFilePath::new(Path::new(path))?,
4837                    name: GraphIdentityText::new(name)?,
4838                    kind: SymbolKind::Function,
4839                    parent: None,
4840                    signature: GraphIdentityText::new(format!("fn {name}()"))?,
4841                },
4842            },
4843            generation,
4844        )?)
4845    }
4846
4847    fn test_code_symbol(
4848        path: &str,
4849        name: &str,
4850        parent: Option<&str>,
4851        signature: &str,
4852    ) -> CodeSymbol {
4853        CodeSymbol {
4854            path: path.to_string(),
4855            language: Some("rust".to_string()),
4856            name: name.to_string(),
4857            kind: SymbolKind::Function,
4858            signature: signature.to_string(),
4859            exported: false,
4860            documentation: None,
4861            line_start: 1,
4862            line_end: 1,
4863            parent: parent.map(ToString::to_string),
4864            parser: ParserKind::TreeSitter,
4865            detail: Some("function_item".to_string()),
4866        }
4867    }
4868
4869    fn test_file_node(path: &str, language: &str) -> Node {
4870        Node {
4871            path: path.to_string(),
4872            kind: NodeKind::File,
4873            parent_path: path
4874                .rsplit_once('/')
4875                .map(|(parent, _name)| parent.to_string()),
4876            extension: Path::new(path)
4877                .extension()
4878                .map(|extension| format!(".{}", extension.to_string_lossy())),
4879            language: Some(language.to_string()),
4880            size_bytes: Some(1),
4881            mtime_ns: Some(1),
4882            content_hash: Some(format!("hash:{path}")),
4883        }
4884    }
4885
4886    #[test]
4887    fn reopened_source_parse_success_does_not_promote_fallback_graph_facts()
4888    -> Result<(), Box<dyn Error>> {
4889        let temp = tempfile::tempdir()?;
4890        let database = temp.path().join("projectatlas.db");
4891        let mut store = AtlasStore::open(&database)?;
4892        let graph = SymbolGraph {
4893            path: "src/optional.lang".to_string(),
4894            language: Some("optional-language".to_string()),
4895            parser: ParserKind::Fallback,
4896            symbols: vec![CodeSymbol {
4897                path: "src/optional.lang".to_string(),
4898                language: Some("optional-language".to_string()),
4899                name: "entry".to_string(),
4900                kind: SymbolKind::Function,
4901                signature: "entry()".to_string(),
4902                exported: false,
4903                documentation: None,
4904                line_start: 1,
4905                line_end: 1,
4906                parent: None,
4907                parser: ParserKind::Fallback,
4908                detail: None,
4909            }],
4910            relations: vec![SymbolRelation {
4911                path: "src/optional.lang".to_string(),
4912                source_name: "entry".to_string(),
4913                target_name: "helper".to_string(),
4914                kind: RelationKind::Calls,
4915                line: 1,
4916                context: "helper()".to_string(),
4917                parser: ParserKind::Fallback,
4918            }],
4919        };
4920        store.replace_symbol_graph_with_metadata(
4921            &graph,
4922            &SourceParseMetadata {
4923                path: graph.path.clone(),
4924                language: graph.language.clone(),
4925                parser: ParserKind::TreeSitter,
4926                symbol_count: graph.symbols.len(),
4927                relation_count: graph.relations.len(),
4928            },
4929        )?;
4930        drop(store);
4931
4932        let reader = AtlasStore::open_read_only(&database)?;
4933        let graphs = reader.load_symbol_graphs_for_paths(std::slice::from_ref(&graph.path))?;
4934        require_eq(&graphs, &vec![graph], "reopened fact graph")?;
4935        let project = ProjectInstanceId::from_bytes([7; 16])?;
4936        let generation = IndexGeneration::new(1);
4937        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4938        let packages = PackageIndex::from_graphs(&graphs)?;
4939        let entities =
4940            build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
4941        let candidates = resolution_registry_from_exports(&entities, &control)?;
4942        let staged = finish_projection(
4943            project,
4944            generation,
4945            RepositoryGraphMutation::Full,
4946            &graphs,
4947            entities,
4948            &candidates,
4949            &control,
4950        )?;
4951        require_eq(&staged.relations.len(), &1, "normalized relation count")?;
4952        require_eq(
4953            &staged.relations[0].confidence(),
4954            &ConfidenceClass::Low,
4955            "fallback relation confidence after reopen",
4956        )?;
4957        require_eq(
4958            &staged.relations[0].completeness(),
4959            &Completeness::Partial,
4960            "fallback relation completeness after reopen",
4961        )?;
4962        require(
4963            staged
4964                .relations
4965                .iter()
4966                .any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)),
4967            "reopened graph lost its legacy call relation",
4968        )?;
4969        require_eq(&staged.coverage.len(), &1, "normalized coverage count")?;
4970        require_eq(
4971            &staged.coverage[0].state(),
4972            &CoverageState::Partial,
4973            "fallback coverage after reopen",
4974        )?;
4975        reader.finish_index_read_snapshot()?;
4976        Ok(())
4977    }
4978
4979    #[test]
4980    fn persisted_closure_overflow_preserves_the_complete_generation() -> Result<(), Box<dyn Error>>
4981    {
4982        let temp = tempfile::tempdir()?;
4983        let root = temp.path().join("persisted-closure-overflow");
4984        fs::create_dir_all(&root)?;
4985        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
4986        let project = store
4987            .project_instance_id()?
4988            .ok_or("bound project identity is missing")?;
4989        let control = IndexWorkControl::new(IndexCancellation::new(), None);
4990        let graphs = vec![function_graph("src/lib.rs", 1)];
4991        let packages = PackageIndex::from_graphs(&graphs)?;
4992        let entity_projection = build_entity_projection(
4993            project,
4994            IndexGeneration::new(1),
4995            &[],
4996            &graphs,
4997            &packages,
4998            true,
4999            &control,
5000        )?;
5001        let dependency_keys = entity_projection
5002            .keys_by_graph
5003            .values()
5004            .flat_map(projectatlas_symbols::ResolutionKeyProjection::relation_keys)
5005            .flat_map(|relation| relation.keys().iter().cloned())
5006            .collect::<BTreeSet<_>>()
5007            .into_iter()
5008            .collect::<Vec<_>>();
5009        let candidates = resolution_registry_from_exports(&entity_projection, &control)?;
5010        let staged = finish_projection(
5011            project,
5012            IndexGeneration::new(1),
5013            RepositoryGraphMutation::Full,
5014            &graphs,
5015            entity_projection,
5016            &candidates,
5017            &control,
5018        )?;
5019        let file_key = staged
5020            .entities
5021            .iter()
5022            .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
5023            .ok_or("staged file entity is missing")?
5024            .key()
5025            .clone();
5026        {
5027            let mut publication = store.begin_index_publication("closure-overflow")?;
5028            publication.begin_scan_replacement()?;
5029            publication.upsert_scan_node_batch(&[Node {
5030                path: "src/lib.rs".to_string(),
5031                kind: NodeKind::File,
5032                parent_path: Some("src".to_string()),
5033                extension: Some(".rs".to_string()),
5034                language: Some("rust".to_string()),
5035                size_bytes: Some(1),
5036                mtime_ns: Some(1),
5037                content_hash: Some("initial".to_string()),
5038            }])?;
5039            publication.finish_scan_replacement()?;
5040            staged.apply(&mut publication, &control)?;
5041            publication.complete()?;
5042        }
5043
5044        store.replace_symbol_graph(&function_graph(
5045            "src/lib.rs",
5046            usize::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(usize::MAX),
5047        ))?;
5048        let publication_before = store
5049            .index_publication()?
5050            .ok_or("complete publication is missing")?;
5051        let entity_before = store
5052            .repository_graph_entity(&file_key)?
5053            .ok_or("published file entity is missing")?;
5054        let exports_before =
5055            store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?;
5056        let dependencies_before =
5057            store.repository_affected_source_paths(project, &dependency_keys, 100)?;
5058        let empty_symbols = empty_symbol_build_stage();
5059
5060        let error = stage_incremental_repository_graph(
5061            &store,
5062            &root,
5063            publication_before.generation,
5064            &[],
5065            &["src/lib.rs".to_string()],
5066            &empty_symbols,
5067            &control,
5068        )
5069        .err()
5070        .ok_or_else(|| io::Error::other("oversized closure acquired the publication writer"))?;
5071        let CliError::RefreshRequired(report) = error else {
5072            return Err(io::Error::other(format!(
5073                "expected typed full-refresh guidance, found {error:?}"
5074            ))
5075            .into());
5076        };
5077        require_eq(
5078            &report.reason,
5079            &IndexRefreshReason::DependencyClosureLimit,
5080            "persisted overflow reason",
5081        )?;
5082        require_eq(
5083            &report.scope,
5084            &IndexRefreshScope::Full,
5085            "persisted overflow scope",
5086        )?;
5087        require_eq(
5088            &store.index_publication()?,
5089            &Some(publication_before),
5090            "publication after overflow",
5091        )?;
5092        require_eq(
5093            &store.repository_graph_entity(&file_key)?,
5094            &Some(entity_before),
5095            "file entity after overflow",
5096        )?;
5097        require_eq(
5098            &store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?,
5099            &exports_before,
5100            "export keys after overflow",
5101        )?;
5102        require_eq(
5103            &store.repository_affected_source_paths(project, &dependency_keys, 100)?,
5104            &dependencies_before,
5105            "dependency owners after overflow",
5106        )?;
5107        Ok(())
5108    }
5109
5110    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
5111        if condition {
5112            Ok(())
5113        } else {
5114            Err(io::Error::other(message).into())
5115        }
5116    }
5117
5118    fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
5119    where
5120        T: Debug + PartialEq,
5121    {
5122        if actual == expected {
5123            Ok(())
5124        } else {
5125            Err(io::Error::other(format!(
5126                "{label} mismatch: actual={actual:?}, expected={expected:?}"
5127            ))
5128            .into())
5129        }
5130    }
5131
5132    fn empty_symbol_build_stage() -> SymbolBuildStage {
5133        SymbolBuildStage {
5134            report: SymbolBuildReport {
5135                candidates: 0,
5136                parsed: 0,
5137                unchanged: 0,
5138                too_large: 0,
5139                binary_or_non_utf8: 0,
5140                timed_out: 0,
5141                max_workers: 1,
5142                timeout_seconds: None,
5143                symbols: 0,
5144                relations: 0,
5145                summaries: 0,
5146                purpose_suggestions: 0,
5147            },
5148            changes: Vec::new(),
5149            retained_bytes: 0,
5150        }
5151    }
5152
5153    fn function_graph(path: &str, symbol_count: usize) -> SymbolGraph {
5154        SymbolGraph {
5155            path: path.to_string(),
5156            language: Some("rust".to_string()),
5157            parser: ParserKind::TreeSitter,
5158            symbols: (0..symbol_count)
5159                .map(|index| CodeSymbol {
5160                    path: path.to_string(),
5161                    language: Some("rust".to_string()),
5162                    name: format!("symbol_{index}"),
5163                    kind: SymbolKind::Function,
5164                    signature: format!("fn symbol_{index}()"),
5165                    exported: index == 0,
5166                    documentation: None,
5167                    line_start: index + 1,
5168                    line_end: index + 1,
5169                    parent: None,
5170                    parser: ParserKind::TreeSitter,
5171                    detail: Some("function_item".to_string()),
5172                })
5173                .collect(),
5174            relations: vec![SymbolRelation {
5175                path: path.to_string(),
5176                source_name: "symbol_0".to_string(),
5177                target_name: "dependency".to_string(),
5178                kind: RelationKind::Calls,
5179                line: 1,
5180                context: "dependency()".to_string(),
5181                parser: ParserKind::TreeSitter,
5182            }],
5183        }
5184    }
5185
5186    fn package_graph(path: &str, name: &str) -> SymbolGraph {
5187        SymbolGraph {
5188            path: path.to_string(),
5189            language: Some("cargo-manifest".to_string()),
5190            parser: ParserKind::Manifest,
5191            symbols: vec![CodeSymbol {
5192                path: path.to_string(),
5193                language: Some("cargo-manifest".to_string()),
5194                name: name.to_string(),
5195                kind: SymbolKind::Package,
5196                signature: format!("name = \"{name}\""),
5197                exported: true,
5198                documentation: None,
5199                line_start: 1,
5200                line_end: 1,
5201                parent: None,
5202                parser: ParserKind::Manifest,
5203                detail: Some("cargo-package".to_string()),
5204            }],
5205            relations: Vec::new(),
5206        }
5207    }
5208}