1use 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
38const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
40const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
42const MAX_INCREMENTAL_GRAPH_BYTES: u64 = super::MAX_PUBLICATION_STAGING_BYTES;
44const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
46const STAGED_GRAPH_ROW_BYTES: u64 = 128;
48const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
50const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
52const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
54const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
56const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
58const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
60const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
62const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
64const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
66const UNKNOWN_REFERENCE: &str = "unknown-reference";
68const CARGO_PACKAGE_MANAGER: &str = "cargo";
70const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
72const NODE_SYSTEM: &str = "node";
74const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
76const CONFIGURATION_SYSTEM: &str = "configuration";
78const ENVIRONMENT_SYSTEM: &str = "environment-variable";
80const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
82
83pub(super) enum RepositoryGraphMutation {
85 Full,
87 AffectedPaths(Vec<String>),
89}
90
91pub(super) struct StagedRepositoryGraph {
93 project: ProjectInstanceId,
95 mutation: RepositoryGraphMutation,
97 entities: Vec<GraphEntity>,
99 relations: Vec<LogicalRelation>,
101 occurrences: Vec<RelationOccurrence>,
103 coverage: Vec<CoverageRecord>,
105 entity_exports: Vec<EntityResolutionKey>,
107 relation_dependencies: Vec<RelationDependencyKey>,
109 database: Option<StagedGraphDatabase>,
111 retained_bytes: u64,
113}
114
115struct StagedGraphDatabase {
117 store: Option<AtlasStore>,
119 directory: Option<TempDir>,
121 _lease: File,
123}
124
125impl StagedGraphDatabase {
126 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 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 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 pub(super) const fn retained_bytes(&self) -> u64 {
172 self.retained_bytes
173 }
174
175 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
231pub(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
293pub(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(¤t_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(¤t_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
468struct EntityProjection {
470 entity_by_digest: BTreeMap<String, GraphEntity>,
472 owners_by_graph: BTreeMap<String, GraphOwners>,
474 keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
476 entity_exports: Vec<EntityResolutionKey>,
478 retained_bytes: u64,
480}
481
482struct GraphOwners {
484 file_digest: String,
486 symbol_digests: Vec<Option<String>>,
488}
489
490struct GraphSymbolIndex<'graph> {
492 indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
494}
495
496impl<'graph> GraphSymbolIndex<'graph> {
497 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 fn get(&self, name: &str) -> &[usize] {
512 self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
513 }
514}
515
516struct DerivedRelationFact {
518 kind: ExtendedRelationKind,
520 relation: SymbolRelation,
522 target: DerivedRelationTarget,
524}
525
526enum DerivedRelationTarget {
528 Parser {
530 keys: Vec<CanonicalResolutionKey>,
532 },
533 RepositoryPath,
535 External {
537 system: &'static str,
539 },
540}
541
542struct PackageIndex {
544 packages: Vec<PackageOwner>,
546}
547
548struct PackageOwner {
550 root: String,
552 name: String,
554 manifest: String,
556}
557
558impl PackageIndex {
559 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 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
605fn 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
614fn is_cargo_manifest_path(path: &str) -> bool {
616 path == "Cargo.toml" || path.ends_with("/Cargo.toml")
617}
618
619fn 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#[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#[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
857fn 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
885pub(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
894fn 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
911fn 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#[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#[cfg(not(windows))]
957fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
958 fs::remove_file(path)
959}
960
961fn 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
1023fn 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
1140fn 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#[derive(Default)]
1223struct ProjectedGraphRows {
1224 relations: Vec<LogicalRelation>,
1226 occurrences: Vec<RelationOccurrence>,
1228 coverage: Vec<CoverageRecord>,
1230 external_entities: Vec<GraphEntity>,
1232 relation_dependencies: Vec<RelationDependencyKey>,
1234}
1235
1236impl ProjectedGraphRows {
1237 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 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 fn is_empty(&self) -> bool {
1259 self.row_count() == 0
1260 }
1261
1262 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
1272fn 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
1460fn 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#[derive(Default)]
1513struct ProjectResolutionRegistry {
1514 supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
1516 candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
1518 retained_bytes: u64,
1520}
1521
1522impl ProjectResolutionRegistry {
1523 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 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 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
1585fn 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
1599fn 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
1611fn 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
1641fn 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
1658fn 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
1679fn 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
1720fn 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
1728fn 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
1740fn 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
1816fn 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
1830fn 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
1843fn 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
1856fn 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
1927fn 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
1941fn 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
1976fn 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
2000fn 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
2019fn 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
2029fn 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
2047fn 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
2084fn 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
2109fn 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
2157fn 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
2164fn 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#[derive(Clone, Copy)]
2195struct ResolutionMatches<'a> {
2196 first: Option<&'a GraphEntity>,
2198 count: u32,
2200}
2201
2202fn 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
2276fn 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
2332fn 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
2357fn 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
2411fn 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
2424fn 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
2431fn 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
2476fn external_reference_identity(reference: &str) -> Option<String> {
2478 let reference = reference.trim();
2479 (!reference.is_empty()).then(|| reference.to_string())
2480}
2481
2482fn 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
2499fn 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
2509fn 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
2520fn 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
2526fn 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
2545fn 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
2559fn 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
2569fn 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
2585fn 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
2631fn 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
2640fn 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
2648fn 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
2679fn 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
2702fn 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
2709fn 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
2716fn 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
2738fn 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
2748fn 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
2758fn 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
2773fn 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
2793fn 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
2824fn 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
2854fn 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
2871fn 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
2893fn 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
2904fn 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 ®istry.supplemental_entities_by_digest.len(),
3162 &0,
3163 "duplicate registry-owned entity count",
3164 )?;
3165 require_eq(
3166 ®istered_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 ®istry,
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 ®istry,
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 ®istry,
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}