1mod binding_resolution;
4mod cargo_roots;
5
6use cargo_roots::CargoRootCohortIndex;
7
8#[cfg(test)]
9use projectatlas_core::graph::QUALIFIED_SYMBOL_SCOPE_PREFIX;
10
11use super::{
12 CliError, INDEX_FRESHNESS_SAMPLE_LIMIT, IndexReadStatus, IndexRefreshReason,
13 IndexRefreshRequired, IndexRefreshScope, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
14 IndexWorkStage, MAX_SYMBOL_FILE_BYTES, Node, NodeKind, SourceReadFailure, SymbolBuildStage,
15 SymbolProjectionChange, lossless_project_root_display, normalize_native_path_display,
16 read_source_bytes_controlled, source_changed_during_derivation,
17};
18use projectatlas_core::IndexGeneration;
19use projectatlas_core::graph::{
20 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
21 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
22 ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphIdentityField,
23 GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText, GraphLimitKind,
24 GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey, PackageSelector,
25 ProjectInstanceId, RelationDependencyKey, RelationOccurrence, RelationResolution,
26 RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain, SourceSpan, SymbolSelector,
27};
28use projectatlas_core::language::{SemanticProviderOwner, SymbolParserOwner, language_capability};
29use projectatlas_core::symbols::{
30 CodeSymbol, MODULE_RELATION_SOURCE, ParserKind, RelationKind, SymbolGraph, SymbolKind,
31 SymbolRelation,
32};
33use projectatlas_db::{
34 AtlasStore, IndexPublicationGuard, RepositoryAffectedSourceFootprint,
35 RepositoryResolutionCandidate, RepositoryResolutionReadPage,
36};
37use projectatlas_fs::RootScanPolicy;
38#[cfg(test)]
39use projectatlas_fs::ScanOptions;
40use projectatlas_symbols::{
41 ConfiguredModuleResolution, DOCUMENT_COVERAGE_SYMBOL, MAX_RESOLUTION_KEYS_PER_FACT,
42 MarkdownFactCompleteness, MarkdownFactLimit, MarkdownFacts, ResolutionKeyProjection,
43 ResolutionProjectionContext, ResolutionProjectionError, ResolutionProjectionFact,
44 SourceCallBinding, SourceCallFact, SourceSemanticFacts, derive_resolution_keys_with_context,
45 extract_markdown_facts_controlled, extract_symbol_graph_with_semantics_controlled,
46 parse_import_references,
47};
48use std::borrow::{Borrow, Cow};
49use std::cell::RefCell;
50use std::cmp::Reverse;
51use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, btree_map::Entry};
52use std::fs::{self, File, OpenOptions};
53use std::num::NonZeroU32;
54use std::path::{Path, PathBuf};
55use std::sync::Arc;
56use tempfile::{Builder as TempDirBuilder, TempDir};
57
58const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
60const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
62const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
64const MAX_INCREMENTAL_GRAPH_BYTES: u64 = MAX_IN_MEMORY_GRAPH_WORK_BYTES;
66const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
68const STAGED_GRAPH_ROW_BYTES: u64 = 128;
70const DOCUMENT_PROJECTION_ROW_BYTES: u64 = STAGED_GRAPH_ROW_BYTES * 8;
76const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
78const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
80const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
82const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
84const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
86const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
88const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
90const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
92const UNKNOWN_REFERENCE: &str = "unknown-reference";
94const CARGO_PACKAGE_MANAGER: &str = "cargo";
96const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
98const NODE_SYSTEM: &str = "node";
100const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
102const CONFIGURATION_SYSTEM: &str = "configuration";
104const ENVIRONMENT_SYSTEM: &str = "environment-variable";
106const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
108const DOCUMENT_PATH_PROVIDER: &str = "projectatlas-document";
110const DOCUMENT_PATH_LANGUAGE: &str = "repository-path";
112const DOCUMENT_CASEFOLD_LANGUAGE: &str = "repository-path-casefold";
114const DOCUMENT_PARTIAL_COVERAGE_REASON: &str =
116 "markdown fact extraction reached a declared limit or unsupported structure";
117const MAX_GRAPH_IDENTITY_REJECTIONS: usize = GraphLimits::MAX_ROWS as usize;
119const SYMBOL_FACT_INDEX_NAMESPACE: u64 = 1_u64 << 56;
121const RELATION_FACT_INDEX_NAMESPACE: u64 = 2_u64 << 56;
123const DERIVED_RELATION_FACT_INDEX_NAMESPACE: u64 = 3_u64 << 56;
125const MARKDOWN_FACT_INDEX_NAMESPACE: u64 = 4_u64 << 56;
127const EXPORT_FACT_INDEX_NAMESPACE: u64 = 5_u64 << 56;
129
130fn parser_fact_index(namespace: u64, index: usize) -> u64 {
132 match u64::try_from(index) {
133 Ok(index) => namespace.saturating_add(index),
134 Err(_) => u64::MAX,
135 }
136}
137
138struct PairedImportRelations {
140 by_symbol: Vec<Option<usize>>,
142 #[cfg(test)]
144 work_items: usize,
145}
146
147fn paired_import_relations(
153 graph: &SymbolGraph,
154 control: &IndexWorkControl,
155) -> Result<PairedImportRelations, CliError> {
156 let mut relations_by_line = HashMap::<usize, (Vec<usize>, usize)>::new();
157 #[cfg(test)]
158 let mut work_items = 0_usize;
159 for (relation_index, relation) in graph.relations.iter().enumerate() {
160 check_graph_work(control, relation_index)?;
161 #[cfg(test)]
162 {
163 work_items = work_items.saturating_add(1);
164 }
165 if relation.kind == RelationKind::Imports {
166 relations_by_line
167 .entry(relation.line)
168 .or_default()
169 .0
170 .push(relation_index);
171 }
172 }
173 let mut by_symbol = vec![None; graph.symbols.len()];
174 for (symbol_index, symbol) in graph.symbols.iter().enumerate() {
175 check_graph_work(control, symbol_index)?;
176 #[cfg(test)]
177 {
178 work_items = work_items.saturating_add(1);
179 }
180 if symbol.kind != SymbolKind::Import {
181 continue;
182 }
183 let Some((relation_indices, next_ordinal)) = relations_by_line.get_mut(&symbol.line_start)
184 else {
185 continue;
186 };
187 by_symbol[symbol_index] = relation_indices.get(*next_ordinal).copied();
188 *next_ordinal = (*next_ordinal).saturating_add(1);
189 }
190 Ok(PairedImportRelations {
191 by_symbol,
192 #[cfg(test)]
193 work_items,
194 })
195}
196
197fn symbol_parser_fact_index(symbol_index: usize, paired_relation_index: Option<usize>) -> u64 {
199 paired_relation_index.map_or_else(
200 || parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, symbol_index),
201 |relation_index| parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, relation_index),
202 )
203}
204
205#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
207struct IdentitySpan {
208 start_line: usize,
210 start_column: usize,
212 end_line: usize,
214 end_column: usize,
216}
217
218fn symbol_identity_span(
220 graph: &SymbolGraph,
221 symbol_index: usize,
222 paired_relation_index: Option<usize>,
223) -> IdentitySpan {
224 let Some(symbol) = graph.symbols.get(symbol_index) else {
225 return IdentitySpan {
226 start_line: 1,
227 start_column: 0,
228 end_line: 1,
229 end_column: 0,
230 };
231 };
232 let start_line = symbol.line_start.max(1);
233 let end_line = symbol.line_end.max(symbol.line_start).max(1);
234 if let Some(relation_index) = paired_relation_index
235 && let Some(relation) = graph.relations.get(relation_index)
236 {
237 let line = relation.line.max(1);
238 return IdentitySpan {
239 start_line: line,
240 start_column: 0,
241 end_line: line,
242 end_column: 0,
243 };
244 }
245 IdentitySpan {
246 start_line,
247 start_column: 0,
248 end_line,
249 end_column: 0,
250 }
251}
252
253#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
257struct IdentityFactKey {
258 span: IdentitySpan,
260 parser: u8,
262 owner: u8,
265 fact_index: u64,
267}
268
269#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
274struct IdentityRejectionKey {
275 path: RepositoryNodePath,
277 span: IdentitySpan,
279 parser: u8,
281 field: GraphIdentityField,
283 reason: GraphIdentityRejectionReason,
285 fact_index: u64,
287}
288
289impl From<&GraphIdentityRejection> for IdentityRejectionKey {
290 fn from(rejection: &GraphIdentityRejection) -> Self {
291 Self {
292 path: rejection.path.clone(),
293 span: IdentitySpan {
294 start_line: rejection.span.start_line() as usize,
295 start_column: rejection.span.start_column() as usize,
296 end_line: rejection.span.end_line() as usize,
297 end_column: rejection.span.end_column() as usize,
298 },
299 parser: parser_fact_kind(rejection.parser),
300 field: rejection.field,
301 reason: rejection.reason,
302 fact_index: rejection.fact_index,
303 }
304 }
305}
306
307fn parser_fact_kind(parser: ParserKind) -> u8 {
309 match parser {
310 ParserKind::TreeSitter => 0,
311 ParserKind::Manifest => 1,
312 ParserKind::Structural => 2,
313 ParserKind::Fallback => 3,
314 }
315}
316
317fn identity_fact_owner(fields: &[(GraphIdentityField, GraphIdentityRejectionReason)]) -> u8 {
319 u8::from(
320 fields
321 .iter()
322 .any(|(field, _reason)| *field == GraphIdentityField::ResolutionKey),
323 )
324}
325
326fn is_rederived_identity_fact(fact: &IdentityFactKey) -> bool {
328 fact.owner == 1
329 || matches!(
330 fact.fact_index & !((1_u64 << 56) - 1),
331 DERIVED_RELATION_FACT_INDEX_NAMESPACE | MARKDOWN_FACT_INDEX_NAMESPACE
332 )
333}
334
335fn identity_fact_retained_bytes(
337 path: &str,
338 new_observed_path: bool,
339 new_count_path: bool,
340) -> Result<u64, CliError> {
341 let path_bytes = u64::try_from(path.len()).map_err(|error| {
342 CliError::InvalidInput(format!(
343 "identity rejection path length overflowed: {error}"
344 ))
345 })?;
346 let path_entries = u64::from(u8::from(new_observed_path)) + u64::from(u8::from(new_count_path));
347 let path_bytes = path_bytes
348 .checked_mul(path_entries)
349 .and_then(|bytes| bytes.checked_add(STAGED_GRAPH_ROW_BYTES.checked_mul(path_entries)?))
350 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
351 path_bytes
352 .checked_add(STAGED_GRAPH_ROW_BYTES)
353 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
354}
355
356fn identity_count_path_retained_bytes(path: &str) -> Result<u64, CliError> {
358 let path_bytes = u64::try_from(path.len()).map_err(|error| {
359 CliError::InvalidInput(format!(
360 "identity rejection path length overflowed: {error}"
361 ))
362 })?;
363 path_bytes
364 .checked_add(STAGED_GRAPH_ROW_BYTES)
365 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
366}
367
368fn identity_fact_set_retained_bytes(
370 path: &str,
371 facts: &BTreeSet<IdentityFactKey>,
372) -> Result<u64, CliError> {
373 let path_bytes = u64::try_from(path.len()).map_err(|error| {
374 CliError::InvalidInput(format!(
375 "identity rejection path length overflowed: {error}"
376 ))
377 })?;
378 let fact_bytes = STAGED_GRAPH_ROW_BYTES
379 .checked_mul(u64::try_from(facts.len()).map_err(|error| {
380 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
381 })?)
382 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
383 path_bytes
384 .checked_add(STAGED_GRAPH_ROW_BYTES)
385 .and_then(|bytes| bytes.checked_add(fact_bytes))
386 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
387}
388
389fn identity_observed_path_retained_bytes(path: &str, fact_count: usize) -> Result<u64, CliError> {
391 let path_bytes = u64::try_from(path.len()).map_err(|error| {
392 CliError::InvalidInput(format!(
393 "identity rejection path length overflowed: {error}"
394 ))
395 })?;
396 let fact_bytes = STAGED_GRAPH_ROW_BYTES
397 .checked_mul(u64::try_from(fact_count).map_err(|error| {
398 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
399 })?)
400 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
401 path_bytes
402 .checked_add(STAGED_GRAPH_ROW_BYTES)
403 .and_then(|bytes| bytes.checked_add(fact_bytes))
404 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
405}
406
407fn identity_maps_retained_bytes(
409 observed_facts: &BTreeMap<String, BTreeSet<IdentityFactKey>>,
410 rejected_facts_by_path: &BTreeMap<String, u64>,
411) -> Result<u64, CliError> {
412 let mut retained = 0_u64;
413 for (path, facts) in observed_facts {
414 retained = retained
415 .checked_add(identity_observed_path_retained_bytes(path, facts.len())?)
416 .ok_or_else(|| {
417 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
418 })?;
419 }
420 for path in rejected_facts_by_path.keys() {
421 retained = retained
422 .checked_add(identity_count_path_retained_bytes(path)?)
423 .ok_or_else(|| {
424 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
425 })?;
426 }
427 Ok(retained)
428}
429
430fn identity_rejection_key_retained_bytes(path: &str) -> Result<u64, CliError> {
432 let path_bytes = u64::try_from(path.len()).map_err(|error| {
433 CliError::InvalidInput(format!(
434 "identity rejection path length overflowed: {error}"
435 ))
436 })?;
437 path_bytes
438 .checked_add(STAGED_GRAPH_ROW_BYTES)
439 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
440}
441
442fn identity_rejection_keys_retained_bytes(
444 keys: &BTreeSet<IdentityRejectionKey>,
445) -> Result<u64, CliError> {
446 keys.iter().try_fold(0_u64, |retained, key| {
447 retained
448 .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
449 .ok_or_else(|| {
450 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
451 })
452 })
453}
454
455fn identity_rejection_drop_paths_retained_bytes(paths: &BTreeSet<String>) -> Result<u64, CliError> {
457 paths.iter().try_fold(0_u64, |retained, path| {
458 retained
459 .checked_add(identity_count_path_retained_bytes(path)?)
460 .ok_or_else(|| {
461 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
462 })
463 })
464}
465
466fn identity_relation_indices_bytes(path: &str, count: usize) -> u64 {
468 STAGED_GRAPH_ROW_BYTES
469 .saturating_add(path.len() as u64)
470 .saturating_add((count as u64).saturating_mul((4 * std::mem::size_of::<usize>()) as u64))
471}
472
473fn identity_admission_retained_bytes(report: &GraphIdentityAdmission) -> u64 {
475 report.observed_fact_bytes
476}
477
478fn identity_fact_budget_failure_for_limit(limit: u64, retained_bytes: u64) -> CliError {
480 IndexWorkFailure::resource_limit(
481 IndexWorkStage::SymbolParsing,
482 IndexWorkResource::OutputBytes,
483 limit,
484 retained_bytes,
485 )
486 .into()
487}
488
489fn identity_fact_budget_failure(retained_bytes: u64) -> CliError {
491 identity_fact_budget_failure_for_limit(MAX_IN_MEMORY_GRAPH_WORK_BYTES, retained_bytes)
492}
493
494fn checked_identity_admission_budget(
496 report: &GraphIdentityAdmission,
497 control: &IndexWorkControl,
498 limit: u64,
499) -> Result<u64, CliError> {
500 control.check(IndexWorkStage::SymbolParsing)?;
501 let retained_bytes = identity_admission_retained_bytes(report);
502 if retained_bytes > limit {
503 return Err(identity_fact_budget_failure_for_limit(
504 limit,
505 retained_bytes,
506 ));
507 }
508 Ok(retained_bytes)
509}
510
511#[derive(Clone, Debug, Default)]
513pub(super) struct GraphIdentityAdmission {
514 retained_limit: Option<u64>,
516 source_admitted: bool,
518 rejections: Vec<GraphIdentityRejection>,
520 rejection_keys: BTreeSet<IdentityRejectionKey>,
522 rejected_facts_by_path: BTreeMap<String, u64>,
524 observed_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
527 observed_fact_bytes: u64,
530 reused_rejection_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
533 relation_fact_indices: BTreeMap<String, Vec<usize>>,
537 resolution_projections: BTreeMap<String, ResolutionKeyProjection>,
539 reused_rejection_counts: BTreeMap<String, u64>,
543 reused_parser_rejection_counts: BTreeMap<String, u64>,
547 reused_rejection_detail_counts: BTreeMap<String, u64>,
550 reused_rejection_details_incomplete: BTreeSet<String>,
554 rejection_details_dropped_by_path: BTreeSet<String>,
557 #[cfg(test)]
559 resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
560 #[cfg(test)]
562 paired_import_pairing_work: usize,
563}
564
565impl GraphIdentityAdmission {
566 pub(super) const fn retained_bytes(&self) -> u64 {
568 self.observed_fact_bytes
569 }
570
571 fn record(
573 &mut self,
574 path: &str,
575 span: IdentitySpan,
576 parser: ParserKind,
577 fact_index: u64,
578 fields: &[(GraphIdentityField, GraphIdentityRejectionReason)],
579 control: &IndexWorkControl,
580 ) -> Result<(), CliError> {
581 control.check(IndexWorkStage::SymbolParsing)?;
582 let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
583 let fact_span = span;
584 let span = SourceSpan::new(
585 u32::try_from(span.start_line).map_err(|error| {
586 CliError::InvalidInput(format!("identity rejection start line overflowed: {error}"))
587 })?,
588 u32::try_from(span.start_column).map_err(|error| {
589 CliError::InvalidInput(format!(
590 "identity rejection start column overflowed: {error}"
591 ))
592 })?,
593 u32::try_from(span.end_line).map_err(|error| {
594 CliError::InvalidInput(format!("identity rejection end line overflowed: {error}"))
595 })?,
596 u32::try_from(span.end_column).map_err(|error| {
597 CliError::InvalidInput(format!("identity rejection end column overflowed: {error}"))
598 })?,
599 )
600 .map_err(invalid_graph_contract)?;
601 let fact_key = IdentityFactKey {
602 span: fact_span,
603 parser: parser_fact_kind(parser),
604 owner: identity_fact_owner(fields),
605 fact_index,
606 };
607 let path_key = path.as_str().to_owned();
608 let observed_path_is_new = !self.observed_facts.contains_key(&path_key);
609 let count_path_is_new = !self.rejected_facts_by_path.contains_key(&path_key);
610 let fact_is_new = self
611 .observed_facts
612 .get(&path_key)
613 .is_none_or(|facts| !facts.contains(&fact_key));
614 let mut new_rejection_keys = Vec::new();
615 let mut dropped_rejection_detail = false;
616 for (field, reason) in fields {
617 let key = IdentityRejectionKey {
618 path: path.clone(),
619 span: fact_span,
620 parser: parser_fact_kind(parser),
621 field: *field,
622 reason: *reason,
623 fact_index,
624 };
625 if !self.rejection_keys.contains(&key)
626 && !new_rejection_keys.iter().any(|existing| existing == &key)
627 {
628 if self.rejections.len() + new_rejection_keys.len() >= MAX_GRAPH_IDENTITY_REJECTIONS
629 {
630 dropped_rejection_detail = true;
631 } else {
632 new_rejection_keys.push(key);
633 }
634 }
635 }
636 let dropped_path_is_new = dropped_rejection_detail
637 && !self
638 .rejection_details_dropped_by_path
639 .contains(path.as_str());
640 let mut additional_bytes = if fact_is_new {
641 identity_fact_retained_bytes(path.as_str(), observed_path_is_new, count_path_is_new)?
642 } else {
643 0
644 };
645 for key in &new_rejection_keys {
646 additional_bytes = additional_bytes
647 .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
648 .ok_or_else(|| {
649 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
650 })?;
651 }
652 if dropped_path_is_new {
653 additional_bytes = additional_bytes
654 .checked_add(identity_count_path_retained_bytes(path.as_str())?)
655 .ok_or_else(|| {
656 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
657 })?;
658 }
659 self.reserve_identity_bytes(additional_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
660 if dropped_rejection_detail {
661 self.rejection_details_dropped_by_path
662 .insert(path.as_str().to_owned());
663 }
664 if fact_is_new {
665 self.observed_facts
666 .entry(path_key.clone())
667 .or_default()
668 .insert(fact_key);
669 let count = self.rejected_facts_by_path.entry(path_key).or_default();
670 *count = count.checked_add(1).ok_or_else(|| {
671 CliError::InvalidInput("identity rejection count overflowed".to_string())
672 })?;
673 }
674 for key in new_rejection_keys {
675 let field = key.field;
676 let reason = key.reason;
677 self.rejection_keys.insert(key);
678 self.rejections.push(GraphIdentityRejection {
679 path: path.clone(),
680 span,
681 parser,
682 field,
683 reason,
684 fact_index,
685 });
686 }
687 Ok(())
688 }
689
690 fn reserve_identity_bytes(
692 &mut self,
693 additional_bytes: u64,
694 control: &IndexWorkControl,
695 limit: u64,
696 ) -> Result<(), CliError> {
697 control.check(IndexWorkStage::SymbolParsing)?;
698 let limit = self
699 .retained_limit
700 .map_or(limit, |remaining| limit.min(remaining));
701 let retained_bytes = self
702 .observed_fact_bytes
703 .checked_add(additional_bytes)
704 .ok_or_else(|| {
705 identity_fact_budget_failure_for_limit(limit, self.observed_fact_bytes)
706 })?;
707 if retained_bytes > limit {
708 return Err(identity_fact_budget_failure_for_limit(
709 limit,
710 retained_bytes,
711 ));
712 }
713 self.observed_fact_bytes = retained_bytes;
714 Ok(())
715 }
716
717 fn adjust_identity_bytes(&mut self, before: u64, after: u64) -> Result<(), CliError> {
719 if let Some(limit) = self.retained_limit {
720 enforce_resolution_registry_budget_with_limit(
721 self.observed_fact_bytes
722 .saturating_add(after)
723 .saturating_sub(before),
724 limit,
725 )?;
726 }
727 if after >= before {
728 self.observed_fact_bytes = self
729 .observed_fact_bytes
730 .checked_add(after - before)
731 .ok_or_else(|| {
732 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
733 })?;
734 } else {
735 self.observed_fact_bytes = self
736 .observed_fact_bytes
737 .checked_sub(before - after)
738 .ok_or_else(|| {
739 CliError::InvalidInput("identity rejection bytes underflowed".to_string())
740 })?;
741 }
742 Ok(())
743 }
744
745 fn merge_observed_identity_path(
748 &mut self,
749 path: &str,
750 incoming_facts: Option<&BTreeSet<IdentityFactKey>>,
751 incoming_total: u64,
752 control: &IndexWorkControl,
753 ) -> Result<(), CliError> {
754 control.check(IndexWorkStage::SymbolParsing)?;
755 let existing_total = self.rejected_facts_by_path.get(path).copied().unwrap_or(0);
756 let existing_observed = self.observed_facts.get(path).map_or(0, BTreeSet::len);
757 let existing_unobserved = existing_total
758 .checked_sub(u64::try_from(existing_observed).map_err(|error| {
759 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
760 })?)
761 .ok_or_else(|| {
762 CliError::InvalidInput(
763 "observed identity facts exceeded rejection count".to_string(),
764 )
765 })?;
766 let incoming_observed = incoming_facts.map_or(0, BTreeSet::len);
767 let incoming_unobserved = incoming_total
768 .checked_sub(u64::try_from(incoming_observed).map_err(|error| {
769 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
770 })?)
771 .ok_or_else(|| {
772 CliError::InvalidInput(
773 "observed identity facts exceeded rejection count".to_string(),
774 )
775 })?;
776 let new_observed = incoming_facts.map_or(0, |facts| {
777 facts
778 .iter()
779 .filter(|fact| {
780 self.observed_facts
781 .get(path)
782 .is_none_or(|existing| !existing.contains(*fact))
783 })
784 .count()
785 });
786 let observed = existing_observed
787 .checked_add(new_observed)
788 .ok_or_else(|| CliError::InvalidInput("identity fact count overflowed".to_string()))?;
789 let total = u64::try_from(observed)
790 .map_err(|error| {
791 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
792 })?
793 .checked_add(existing_unobserved)
794 .and_then(|count| count.checked_add(incoming_unobserved))
795 .ok_or_else(|| {
796 CliError::InvalidInput("identity rejection count overflowed".to_string())
797 })?;
798
799 let mut before_bytes = 0_u64;
800 if self.observed_facts.contains_key(path) {
801 before_bytes = before_bytes
802 .checked_add(identity_observed_path_retained_bytes(
803 path,
804 existing_observed,
805 )?)
806 .ok_or_else(|| {
807 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
808 })?;
809 }
810 if self.rejected_facts_by_path.contains_key(path) {
811 before_bytes = before_bytes
812 .checked_add(identity_count_path_retained_bytes(path)?)
813 .ok_or_else(|| {
814 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
815 })?;
816 }
817 let mut after_bytes = 0_u64;
818 if observed > 0 {
819 after_bytes = after_bytes
820 .checked_add(identity_observed_path_retained_bytes(path, observed)?)
821 .ok_or_else(|| {
822 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
823 })?;
824 }
825 if total > 0 {
826 after_bytes = after_bytes
827 .checked_add(identity_count_path_retained_bytes(path)?)
828 .ok_or_else(|| {
829 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
830 })?;
831 }
832 self.adjust_identity_bytes(before_bytes, after_bytes)?;
833
834 if observed == 0 {
835 self.observed_facts.remove(path);
836 } else if let Some(facts) = incoming_facts
837 && !facts.is_empty()
838 {
839 self.observed_facts
840 .entry(path.to_string())
841 .or_default()
842 .extend(facts.iter().cloned());
843 }
844 if total == 0 {
845 self.rejected_facts_by_path.remove(path);
846 self.observed_facts.remove(path);
847 } else {
848 self.rejected_facts_by_path.insert(path.to_string(), total);
849 }
850 Ok(())
851 }
852
853 fn reserve_reused_path_bytes(
855 &mut self,
856 path: &str,
857 already_retained: bool,
858 control: &IndexWorkControl,
859 limit: u64,
860 ) -> Result<(), CliError> {
861 control.check(IndexWorkStage::SymbolParsing)?;
862 if !already_retained {
863 self.reserve_identity_bytes(identity_count_path_retained_bytes(path)?, control, limit)?;
864 }
865 Ok(())
866 }
867
868 fn record_rejected_fact_count(
870 &mut self,
871 path: &str,
872 count: usize,
873 control: &IndexWorkControl,
874 ) -> Result<(), CliError> {
875 if count == 0 {
876 control.check(IndexWorkStage::SymbolParsing)?;
877 return Ok(());
878 }
879 control.check(IndexWorkStage::SymbolParsing)?;
880 let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
881 let count = u64::try_from(count).map_err(|error| {
882 CliError::InvalidInput(format!("identity rejection count overflowed: {error}"))
883 })?;
884 let path = path.as_str().to_owned();
885 if !self.rejected_facts_by_path.contains_key(&path) {
886 self.reserve_reused_path_bytes(&path, false, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
887 }
888 let entry = self.rejected_facts_by_path.entry(path).or_default();
889 *entry = entry.checked_add(count).ok_or_else(|| {
890 CliError::InvalidInput("identity rejection count overflowed".to_string())
891 })?;
892 Ok(())
893 }
894
895 fn merge(&mut self, other: Self, control: &IndexWorkControl) -> Result<(), CliError> {
897 control.check(IndexWorkStage::SymbolParsing)?;
898 let peak_retained_bytes = self
899 .observed_fact_bytes
900 .checked_add(other.observed_fact_bytes)
901 .ok_or_else(|| identity_fact_budget_failure(self.observed_fact_bytes))?;
902 if let Some(limit) = self.retained_limit {
903 enforce_resolution_registry_budget_with_limit(
907 peak_retained_bytes.saturating_add(other.observed_fact_bytes),
908 limit,
909 )?;
910 }
911 if peak_retained_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
912 return Err(identity_fact_budget_failure(peak_retained_bytes));
913 }
914 #[cfg(test)]
915 {
916 self.paired_import_pairing_work = self
917 .paired_import_pairing_work
918 .checked_add(other.paired_import_pairing_work)
919 .ok_or_else(|| {
920 CliError::InvalidInput("paired import work count overflowed".to_string())
921 })?;
922 }
923 self.source_admitted |= other.source_admitted;
924 if other
925 .resolution_projections
926 .keys()
927 .any(|path| self.resolution_projections.contains_key(path))
928 {
929 return Err(CliError::InvalidInput(
930 "duplicate resolution projection admission".to_string(),
931 ));
932 }
933 let other_observed_facts = other.observed_facts;
934 let other_rejected_facts_by_path = other.rejected_facts_by_path;
935 let other_reused_rejection_facts = other.reused_rejection_facts;
936 let other_rejection_details_dropped_by_path = other.rejection_details_dropped_by_path;
937 for (path, incoming_total) in &other_rejected_facts_by_path {
938 self.merge_observed_identity_path(
939 path,
940 other_observed_facts.get(path),
941 *incoming_total,
942 control,
943 )?;
944 }
945 for (path, facts) in &other_observed_facts {
946 if !other_rejected_facts_by_path.contains_key(path) {
947 self.merge_observed_identity_path(path, Some(facts), 0, control)?;
948 }
949 }
950 for (path, facts) in other_reused_rejection_facts {
951 control.check(IndexWorkStage::SymbolParsing)?;
952 let existing_facts = self.reused_rejection_facts.get(&path);
953 let existing_count = existing_facts.map_or(0, BTreeSet::len);
954 let new_count = facts
955 .iter()
956 .filter(|fact| existing_facts.is_none_or(|existing| !existing.contains(*fact)))
957 .count();
958 let after_count = existing_count.checked_add(new_count).ok_or_else(|| {
959 CliError::InvalidInput("identity fact count overflowed".to_string())
960 })?;
961 let before_bytes = existing_facts.map_or(Ok(0), |facts| {
962 identity_fact_set_retained_bytes(&path, facts)
963 })?;
964 let after_bytes = if after_count == 0 {
965 0
966 } else {
967 identity_observed_path_retained_bytes(&path, after_count)?
968 };
969 self.adjust_identity_bytes(before_bytes, after_bytes)?;
970 if after_count == 0 {
971 self.reused_rejection_facts.remove(&path);
972 } else {
973 self.reused_rejection_facts
974 .entry(path)
975 .or_default()
976 .extend(facts);
977 }
978 }
979 for (path, indices) in other.relation_fact_indices {
980 control.check(IndexWorkStage::SymbolParsing)?;
981 if let Some(existing) = self.relation_fact_indices.get(&path) {
982 if existing != &indices {
983 return Err(CliError::InvalidInput(
984 "conflicting relation fact admission".to_string(),
985 ));
986 }
987 } else {
988 self.reserve_identity_bytes(
989 identity_relation_indices_bytes(&path, indices.len()),
990 control,
991 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
992 )?;
993 self.relation_fact_indices.insert(path, indices);
994 }
995 }
996 control.check(IndexWorkStage::SymbolParsing)?;
997 let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths_limit(
998 &mut self.rejections,
999 &mut self.rejection_keys,
1000 other.rejections,
1001 &mut self.rejection_details_dropped_by_path,
1002 self.retained_limit
1003 .unwrap_or(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
1004 .min(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
1005 .saturating_sub(self.observed_fact_bytes),
1006 )?;
1007 self.observed_fact_bytes = self
1008 .observed_fact_bytes
1009 .checked_add(rejection_key_bytes)
1010 .ok_or_else(|| {
1011 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1012 })?;
1013 for path in other_rejection_details_dropped_by_path {
1014 control.check(IndexWorkStage::SymbolParsing)?;
1015 if self.rejection_details_dropped_by_path.insert(path.clone()) {
1016 self.reserve_identity_bytes(
1017 identity_count_path_retained_bytes(&path)?,
1018 control,
1019 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
1020 )?;
1021 }
1022 }
1023 self.resolution_projections
1024 .extend(other.resolution_projections);
1025 for (path, count) in other.reused_rejection_counts {
1026 control.check(IndexWorkStage::SymbolParsing)?;
1027 let new_path = !self.reused_rejection_counts.contains_key(&path);
1028 let path_bytes = if new_path {
1029 identity_count_path_retained_bytes(&path)?
1030 } else {
1031 0
1032 };
1033 if self
1034 .reused_rejection_counts
1035 .insert(path.clone(), count)
1036 .is_some()
1037 {
1038 return Err(CliError::InvalidInput(
1039 "duplicate reused identity rejection count".to_string(),
1040 ));
1041 }
1042 if new_path {
1043 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1044 }
1045 }
1046 for (path, count) in other.reused_parser_rejection_counts {
1047 control.check(IndexWorkStage::SymbolParsing)?;
1048 let new_path = !self.reused_parser_rejection_counts.contains_key(&path);
1049 let path_bytes = if new_path {
1050 identity_count_path_retained_bytes(&path)?
1051 } else {
1052 0
1053 };
1054 if self
1055 .reused_parser_rejection_counts
1056 .insert(path.clone(), count)
1057 .is_some()
1058 {
1059 return Err(CliError::InvalidInput(
1060 "duplicate reused parser rejection count".to_string(),
1061 ));
1062 }
1063 if new_path {
1064 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1065 }
1066 }
1067 for (path, count) in other.reused_rejection_detail_counts {
1068 control.check(IndexWorkStage::SymbolParsing)?;
1069 let new_path = !self.reused_rejection_detail_counts.contains_key(&path);
1070 let path_bytes = if new_path {
1071 identity_count_path_retained_bytes(&path)?
1072 } else {
1073 0
1074 };
1075 if self
1076 .reused_rejection_detail_counts
1077 .insert(path.clone(), count)
1078 .is_some()
1079 {
1080 return Err(CliError::InvalidInput(
1081 "duplicate reused identity rejection detail count".to_string(),
1082 ));
1083 }
1084 if new_path {
1085 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1086 }
1087 }
1088 control.check(IndexWorkStage::SymbolParsing)?;
1089 for path in other.reused_rejection_details_incomplete {
1090 let path_bytes = identity_count_path_retained_bytes(&path)?;
1091 if self.reused_rejection_details_incomplete.insert(path) {
1092 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1093 }
1094 }
1095 if self.observed_fact_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1096 return Err(identity_fact_budget_failure(self.observed_fact_bytes));
1097 }
1098 #[cfg(test)]
1099 for (key, count) in other.resolution_derivations {
1100 let entry = self.resolution_derivations.entry(key).or_default();
1101 *entry = entry.saturating_add(count);
1102 }
1103 Ok(())
1104 }
1105
1106 fn rejected_facts_for(&self, path: &str) -> u64 {
1108 self.rejected_facts_by_path.get(path).copied().unwrap_or(0)
1109 }
1110
1111 fn for_paths_with_limit(&self, paths: &BTreeSet<String>, limit: u64) -> Result<Self, CliError> {
1114 enforce_resolution_registry_budget_with_limit(self.observed_fact_bytes, limit)?;
1117 let rejected_facts_by_path = self
1118 .rejected_facts_by_path
1119 .iter()
1120 .filter(|(path, _count)| paths.contains(path.as_str()))
1121 .map(|(path, count)| (path.clone(), *count))
1122 .collect::<BTreeMap<_, _>>();
1123 let observed_facts = self
1124 .observed_facts
1125 .iter()
1126 .filter(|(path, _facts)| paths.contains(*path))
1127 .map(|(path, facts)| (path.clone(), facts.clone()))
1128 .collect::<BTreeMap<_, _>>();
1129 let rejections = self
1130 .rejections
1131 .iter()
1132 .filter(|rejection| paths.contains(rejection.path.as_str()))
1133 .cloned()
1134 .collect::<Vec<_>>();
1135 let rejection_keys = self
1136 .rejection_keys
1137 .iter()
1138 .filter(|key| paths.contains(key.path.as_str()))
1139 .cloned()
1140 .collect::<BTreeSet<_>>();
1141 let rejection_details_dropped_by_path = self
1142 .rejection_details_dropped_by_path
1143 .iter()
1144 .filter(|path| paths.contains(path.as_str()))
1145 .cloned()
1146 .collect::<BTreeSet<_>>();
1147 let marker_bytes =
1148 identity_rejection_drop_paths_retained_bytes(&rejection_details_dropped_by_path)?;
1149 let observed_fact_bytes =
1150 identity_maps_retained_bytes(&observed_facts, &rejected_facts_by_path)?
1151 .checked_add(identity_rejection_keys_retained_bytes(&rejection_keys)?)
1152 .and_then(|bytes| bytes.checked_add(marker_bytes))
1153 .and_then(|bytes| {
1154 self.relation_fact_indices
1155 .iter()
1156 .filter(|(path, _)| paths.contains(path.as_str()))
1157 .try_fold(bytes, |bytes, (path, indices)| {
1158 bytes.checked_add(identity_relation_indices_bytes(path, indices.len()))
1159 })
1160 })
1161 .ok_or_else(|| {
1162 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1163 })?;
1164 Ok(Self {
1165 retained_limit: None,
1166 rejection_keys,
1167 rejections,
1168 source_admitted: self.source_admitted,
1169 observed_fact_bytes,
1170 reused_rejection_facts: BTreeMap::new(),
1171 rejected_facts_by_path,
1172 observed_facts,
1173 relation_fact_indices: self
1174 .relation_fact_indices
1175 .iter()
1176 .filter(|(path, _indices)| paths.contains(path.as_str()))
1177 .map(|(path, indices)| (path.clone(), indices.clone()))
1178 .collect(),
1179 resolution_projections: BTreeMap::new(),
1180 reused_rejection_counts: BTreeMap::new(),
1181 reused_parser_rejection_counts: BTreeMap::new(),
1182 reused_rejection_detail_counts: BTreeMap::new(),
1183 reused_rejection_details_incomplete: BTreeSet::new(),
1184 rejection_details_dropped_by_path,
1185 #[cfg(test)]
1186 resolution_derivations: BTreeMap::new(),
1187 #[cfg(test)]
1188 paired_import_pairing_work: 0,
1189 })
1190 }
1191
1192 fn has_rejections(&self) -> bool {
1194 !self.rejected_facts_by_path.is_empty()
1195 }
1196
1197 fn incomplete_reused_rejection_paths(&self) -> &BTreeSet<String> {
1199 &self.reused_rejection_details_incomplete
1200 }
1201
1202 fn rejection_details_dropped_for(&self, path: &str) -> bool {
1205 self.rejection_details_dropped_by_path.contains(path)
1206 }
1207
1208 fn rejected_facts_for_graph(&self, path: &str, derived: &Self) -> Result<u64, CliError> {
1210 let Some(persisted) = self.reused_rejection_counts.get(path).copied() else {
1211 return self
1212 .rejected_facts_for(path)
1213 .checked_add(derived.rejected_facts_for(path))
1214 .ok_or_else(|| {
1215 CliError::InvalidInput("identity rejection count overflowed".to_string())
1216 });
1217 };
1218 let persisted_parser = self
1219 .reused_parser_rejection_counts
1220 .get(path)
1221 .copied()
1222 .unwrap_or(0);
1223 let persisted_identity = persisted.checked_sub(persisted_parser).ok_or_else(|| {
1224 CliError::InvalidInput("persisted parser rejection count exceeded total".to_string())
1225 })?;
1226 let mut current_facts = BTreeSet::<&IdentityFactKey>::new();
1227 if let Some(facts) = self.observed_facts.get(path) {
1228 current_facts.extend(facts);
1229 }
1230 if let Some(facts) = derived.observed_facts.get(path) {
1231 current_facts.extend(facts);
1232 }
1233 let current_known = u64::try_from(current_facts.len()).map_err(|error| {
1234 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1235 })?;
1236 let current_total = self
1237 .rejected_facts_for(path)
1238 .checked_add(derived.rejected_facts_for(path))
1239 .ok_or_else(|| {
1240 CliError::InvalidInput("identity rejection count overflowed".to_string())
1241 })?;
1242 let current_unknown = current_total.checked_sub(current_known).ok_or_else(|| {
1243 CliError::InvalidInput("observed identity facts exceeded rejection count".to_string())
1244 })?;
1245 let persisted_facts = self.reused_rejection_facts.get(path);
1246 let persisted_known = persisted_facts.map_or(0, BTreeSet::len);
1247 let persisted_known = u64::try_from(persisted_known).map_err(|error| {
1248 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1249 })?;
1250 let persisted_unknown =
1251 persisted_identity
1252 .checked_sub(persisted_known)
1253 .ok_or_else(|| {
1254 CliError::InvalidInput(
1255 "persisted identity facts exceeded rejection count".to_string(),
1256 )
1257 })?;
1258 let mut all_facts = current_facts;
1259 if let Some(facts) = persisted_facts {
1260 all_facts.extend(
1261 facts
1262 .iter()
1263 .filter(|fact| !is_rederived_identity_fact(fact)),
1264 );
1265 }
1266 u64::try_from(all_facts.len())
1267 .map_err(|error| {
1268 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1269 })?
1270 .checked_add(persisted_unknown)
1271 .and_then(|count| count.checked_add(current_unknown))
1272 .ok_or_else(|| {
1273 CliError::InvalidInput("identity rejection count overflowed".to_string())
1274 })
1275 }
1276
1277 fn parser_rejection_for_graph(&self, path: &str) -> u64 {
1279 self.reused_parser_rejection_counts
1280 .get(path)
1281 .copied()
1282 .unwrap_or(0)
1283 }
1284
1285 pub(super) fn source_admitted(&self) -> bool {
1287 self.source_admitted
1288 }
1289
1290 fn paths(&self) -> impl Iterator<Item = &str> {
1292 self.rejected_facts_by_path.keys().map(String::as_str)
1293 }
1294
1295 fn resolution_projection(&self, path: &str) -> Option<&ResolutionKeyProjection> {
1297 self.resolution_projections.get(path)
1298 }
1299
1300 fn relation_parser_index(&self, path: &str, admitted_index: usize) -> usize {
1302 self.relation_fact_indices
1303 .get(path)
1304 .and_then(|indices| indices.get(admitted_index))
1305 .copied()
1306 .unwrap_or(admitted_index)
1307 }
1308
1309 #[cfg(test)]
1311 fn record_resolution_derivation(&mut self, path: &str, generation: IndexGeneration) {
1312 let count = self
1313 .resolution_derivations
1314 .entry((path.to_string(), generation))
1315 .or_default();
1316 *count = count.saturating_add(1);
1317 }
1318}
1319
1320fn identity_rejection_key_set(
1322 rejections: &[GraphIdentityRejection],
1323) -> BTreeSet<IdentityRejectionKey> {
1324 rejections.iter().map(IdentityRejectionKey::from).collect()
1325}
1326
1327#[cfg(test)]
1329fn extend_bounded_identity_rejections(
1330 target: &mut Vec<GraphIdentityRejection>,
1331 target_keys: &mut BTreeSet<IdentityRejectionKey>,
1332 incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1333) -> Result<u64, CliError> {
1334 let mut dropped_paths = BTreeSet::new();
1335 extend_bounded_identity_rejections_with_drop_paths(
1336 target,
1337 target_keys,
1338 incoming,
1339 &mut dropped_paths,
1340 )
1341}
1342
1343fn extend_bounded_identity_rejections_with_drop_paths(
1345 target: &mut Vec<GraphIdentityRejection>,
1346 target_keys: &mut BTreeSet<IdentityRejectionKey>,
1347 incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1348 dropped_paths: &mut BTreeSet<String>,
1349) -> Result<u64, CliError> {
1350 extend_bounded_identity_rejections_with_drop_paths_limit(
1351 target,
1352 target_keys,
1353 incoming,
1354 dropped_paths,
1355 u64::MAX,
1356 )
1357}
1358
1359fn extend_bounded_identity_rejections_with_drop_paths_limit(
1361 target: &mut Vec<GraphIdentityRejection>,
1362 target_keys: &mut BTreeSet<IdentityRejectionKey>,
1363 incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1364 dropped_paths: &mut BTreeSet<String>,
1365 limit: u64,
1366) -> Result<u64, CliError> {
1367 let mut retained_bytes = 0_u64;
1368 for rejection in incoming {
1369 let key = IdentityRejectionKey::from(&rejection);
1370 if target_keys.contains(&key) {
1371 continue;
1372 }
1373 if target.len() >= MAX_GRAPH_IDENTITY_REJECTIONS {
1374 if !dropped_paths.contains(rejection.path.as_str()) {
1375 let additional = identity_count_path_retained_bytes(rejection.path.as_str())?;
1376 enforce_resolution_registry_budget_with_limit(
1377 retained_bytes.saturating_add(additional),
1378 limit,
1379 )?;
1380 dropped_paths.insert(rejection.path.as_str().to_owned());
1381 retained_bytes = retained_bytes
1382 .checked_add(identity_count_path_retained_bytes(rejection.path.as_str())?)
1383 .ok_or_else(|| {
1384 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1385 })?;
1386 }
1387 continue;
1388 }
1389 enforce_resolution_registry_budget_with_limit(
1390 retained_bytes.saturating_add(identity_rejection_key_retained_bytes(
1391 rejection.path.as_str(),
1392 )?),
1393 limit,
1394 )?;
1395 target_keys.insert(key);
1396 retained_bytes = retained_bytes
1397 .checked_add(identity_rejection_key_retained_bytes(
1398 rejection.path.as_str(),
1399 )?)
1400 .ok_or_else(|| {
1401 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1402 })?;
1403 target.push(rejection);
1404 }
1405 Ok(retained_bytes)
1406}
1407
1408fn mark_identity_rejection_coverage_limits(
1410 coverage: &mut [CoverageRecord],
1411 dropped_paths: &BTreeSet<String>,
1412) -> Result<(), CliError> {
1413 for row in coverage {
1414 let CoverageScope::Path { path } = row.scope() else {
1415 continue;
1416 };
1417 if row.relation().is_some()
1418 || !dropped_paths.contains(path.as_str())
1419 || row.reached_limit() == Some(GraphLimitKind::Rows)
1420 {
1421 continue;
1422 }
1423 *row = CoverageRecord::new(
1424 row.scope().clone(),
1425 row.relation(),
1426 row.state(),
1427 row.covered(),
1428 row.omitted(),
1429 row.generation(),
1430 row.reason().cloned(),
1431 Some(GraphLimitKind::Rows),
1432 )
1433 .map_err(invalid_graph_contract)?;
1434 }
1435 Ok(())
1436}
1437
1438pub(super) enum RepositoryGraphMutation {
1440 Full,
1442 AffectedPaths(Vec<String>),
1444}
1445
1446pub(super) struct StagedRepositoryGraph {
1448 source_recovery: SourceRecoveryWork,
1450 project: ProjectInstanceId,
1452 mutation: RepositoryGraphMutation,
1454 entities: Vec<GraphEntity>,
1456 relations: Vec<LogicalRelation>,
1458 occurrences: Vec<RelationOccurrence>,
1460 coverage: Vec<CoverageRecord>,
1462 entity_exports: Vec<EntityResolutionKey>,
1464 relation_dependencies: Vec<RelationDependencyKey>,
1466 document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1468 identity_rejections: Vec<GraphIdentityRejection>,
1470 #[cfg(test)]
1472 resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
1473 #[cfg(test)]
1475 peak_retained_bytes: u64,
1476 #[cfg(test)]
1478 projection_removals_before_entities: Vec<String>,
1479 scan_policy: RootScanPolicy,
1481 document_target_states: Vec<(String, DocumentTargetUnresolvedReason)>,
1483 database: Option<StagedGraphDatabase>,
1485 retained_bytes: u64,
1487}
1488
1489struct StagedGraphDatabase {
1491 store: Option<AtlasStore>,
1493 directory: Option<TempDir>,
1495 _lease: File,
1497}
1498
1499impl StagedGraphDatabase {
1500 fn store(&self) -> Result<&AtlasStore, CliError> {
1502 self.store
1503 .as_ref()
1504 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1505 }
1506
1507 fn store_mut(&mut self) -> Result<&mut AtlasStore, CliError> {
1509 self.store
1510 .as_mut()
1511 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1512 }
1513
1514 fn directory(&self) -> Result<&TempDir, CliError> {
1516 self.directory
1517 .as_ref()
1518 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1519 }
1520}
1521
1522impl Drop for StagedGraphDatabase {
1523 fn drop(&mut self) {
1524 let prepared = self.store.take().is_some();
1525 let Some(directory) = self.directory.take() else {
1526 return;
1527 };
1528 let database_path = directory.path().join(GRAPH_STAGE_DATABASE_FILE_NAME);
1529 let direct_database = fs::symlink_metadata(&database_path).is_ok_and(|metadata| {
1530 metadata.file_type().is_file() && !metadata.file_type().is_symlink()
1531 });
1532 if prepared
1533 && direct_database
1534 && remove_owned_graph_stage_payload(directory.path(), &database_path, None).is_ok()
1535 {
1536 drop(directory);
1537 } else {
1538 let _retained_path: PathBuf = directory.keep();
1539 }
1540 }
1541}
1542
1543impl StagedRepositoryGraph {
1544 pub(super) fn account_source_recovery(
1546 &self,
1547 report: &mut super::SymbolBuildReport,
1548 ) -> Result<(), CliError> {
1549 report.unchanged = report
1550 .unchanged
1551 .checked_sub(self.source_recovery.previously_unchanged)
1552 .ok_or_else(|| {
1553 CliError::InvalidInput(
1554 "source recovery exceeded the unchanged parser count".to_string(),
1555 )
1556 })?;
1557 report.parsed = report
1558 .parsed
1559 .checked_add(self.source_recovery.previously_unchanged)
1560 .and_then(|count| count.checked_add(self.source_recovery.additional_candidates))
1561 .ok_or_else(|| {
1562 CliError::InvalidInput("source recovery parser count overflowed".to_string())
1563 })?;
1564 report.candidates = report
1565 .candidates
1566 .checked_add(self.source_recovery.additional_candidates)
1567 .ok_or_else(|| {
1568 CliError::InvalidInput("source recovery candidate count overflowed".to_string())
1569 })?;
1570 if self.source_recovery.previously_unchanged > 0
1571 || self.source_recovery.additional_candidates > 0
1572 {
1573 report.max_workers = report.max_workers.max(1);
1574 }
1575 Ok(())
1576 }
1577 pub(super) const fn retained_bytes(&self) -> u64 {
1579 self.retained_bytes
1580 }
1581
1582 pub(super) fn revalidate_document_targets(&self, root: &Path) -> Result<(), CliError> {
1584 if self.document_target_states.is_empty() {
1585 return Ok(());
1586 }
1587 let current = DocumentResolutionIndex::new(root, &[], &self.scan_policy)?;
1588 for (path, expected) in &self.document_target_states {
1589 if current.absent_reason(path)? != *expected {
1590 return Err(source_changed_during_derivation(root, path));
1591 }
1592 }
1593 Ok(())
1594 }
1595
1596 pub(super) fn apply(
1598 &self,
1599 publication: &mut IndexPublicationGuard<'_>,
1600 control: &IndexWorkControl,
1601 ) -> Result<(), CliError> {
1602 control.check(IndexWorkStage::Publication)?;
1603 if let Some(database) = &self.database {
1604 if !database.directory()?.path().is_dir() {
1605 return Err(CliError::InvalidInput(
1606 "repository graph staging directory is unavailable".to_string(),
1607 ));
1608 }
1609 if !matches!(self.mutation, RepositoryGraphMutation::Full) {
1610 return Err(CliError::InvalidInput(
1611 "database-backed graph staging only supports full replacement".to_string(),
1612 ));
1613 }
1614 publication.replace_repository_graph_from_staging(
1615 self.project,
1616 database.store()?,
1617 Some(control),
1618 )?;
1619 publication
1620 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1621 if !self.document_unresolved_reasons.is_empty() {
1622 publication.set_document_unresolved_reasons_controlled(
1623 &self.document_unresolved_reasons,
1624 control,
1625 )?;
1626 }
1627 control.check(IndexWorkStage::Publication)?;
1628 return Ok(());
1629 }
1630 match &self.mutation {
1631 RepositoryGraphMutation::Full => {
1632 publication.replace_repository_graph_with_resolution_keys(
1633 self.project,
1634 &self.entities,
1635 &self.relations,
1636 &self.occurrences,
1637 &self.coverage,
1638 &self.entity_exports,
1639 &self.relation_dependencies,
1640 )?;
1641 publication
1642 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1643 }
1644 RepositoryGraphMutation::AffectedPaths(paths) => {
1645 publication.replace_repository_graph_for_paths_with_resolution_keys(
1646 self.project,
1647 paths,
1648 &self.entities,
1649 &self.relations,
1650 &self.occurrences,
1651 &self.coverage,
1652 &self.entity_exports,
1653 &self.relation_dependencies,
1654 )?;
1655 publication
1656 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1657 }
1658 }
1659 if !self.document_unresolved_reasons.is_empty() {
1660 publication.set_document_unresolved_reasons_controlled(
1661 &self.document_unresolved_reasons,
1662 control,
1663 )?;
1664 }
1665 control.check(IndexWorkStage::Publication)?;
1666 Ok(())
1667 }
1668}
1669
1670fn graph_path_inventory_bytes(nodes: &[Node], direct_paths: &[String]) -> u64 {
1676 super::staged_node_bytes(nodes)
1677 .saturating_add(super::staged_string_bytes(direct_paths))
1678 .saturating_mul(10)
1679}
1680
1681#[cfg(test)]
1683fn stage_full_repository_graph(
1684 store: &AtlasStore,
1685 root: &Path,
1686 base_generation: IndexGeneration,
1687 nodes: &[Node],
1688 scan_policy: &RootScanPolicy,
1689 symbols: &SymbolBuildStage,
1690 control: &IndexWorkControl,
1691) -> Result<StagedRepositoryGraph, CliError> {
1692 stage_full_repository_graph_with_limit(
1693 store,
1694 root,
1695 base_generation,
1696 nodes,
1697 scan_policy,
1698 symbols,
1699 control,
1700 super::MAX_PUBLICATION_STAGING_BYTES,
1701 )
1702}
1703
1704pub(super) fn stage_full_repository_graph_with_limit(
1706 store: &AtlasStore,
1707 root: &Path,
1708 base_generation: IndexGeneration,
1709 nodes: &[Node],
1710 scan_policy: &RootScanPolicy,
1711 symbols: &SymbolBuildStage,
1712 control: &IndexWorkControl,
1713 staging_limit: u64,
1714) -> Result<StagedRepositoryGraph, CliError> {
1715 let inventory_bytes = graph_path_inventory_bytes(nodes, &[]);
1716 enforce_resolution_registry_budget_with_limit(
1717 symbols.retained_bytes.saturating_add(inventory_bytes),
1718 staging_limit,
1719 )?;
1720 let staging_limit = staging_limit.saturating_sub(inventory_bytes);
1721 cleanup_abandoned_repository_graph_staging(store, root, control)?;
1722 let project = selected_project(store)?;
1723 let generation = next_generation(base_generation)?;
1724 let paths = nodes
1725 .iter()
1726 .filter(|node| node.kind == NodeKind::File)
1727 .map(|node| node.path.clone())
1728 .collect::<BTreeSet<_>>();
1729 let staging_limit = staging_limit.saturating_sub(symbols.retained_bytes);
1730 let mut sources = SourceBindingCache::new(root, nodes, None);
1731 sources.set_retained_limit(staging_limit)?;
1732 let loaded_graphs = complete_symbol_graphs(store, &paths, symbols, &mut sources, control)?;
1733 let (graphs, mut identity_admission) = admit_symbol_graphs_with_limit(
1734 loaded_graphs,
1735 control,
1736 staging_limit.saturating_sub(sources.retained_bytes),
1737 )?;
1738 let changed_paths = symbols
1739 .changes
1740 .iter()
1741 .map(|change| match change {
1742 SymbolProjectionChange::Parsed(parsed) => parsed.path.as_str(),
1743 SymbolProjectionChange::Clear { path, .. } => path.as_str(),
1744 })
1745 .collect::<BTreeSet<_>>();
1746 let reused_paths = paths
1747 .iter()
1748 .filter(|path| !changed_paths.contains(path.as_str()))
1749 .cloned()
1750 .collect::<BTreeSet<_>>();
1751 identity_admission.retained_limit = Some(staging_limit.saturating_sub(sources.retained_bytes));
1752 hydrate_reused_identity_admission(
1753 store,
1754 project,
1755 &reused_paths,
1756 &graphs,
1757 &mut identity_admission,
1758 control,
1759 )?;
1760 if !identity_admission
1761 .incomplete_reused_rejection_paths()
1762 .is_empty()
1763 {
1764 return Err(dependency_closure_limit(
1765 root,
1766 identity_admission
1767 .incomplete_reused_rejection_paths()
1768 .iter()
1769 .cloned(),
1770 identity_admission.incomplete_reused_rejection_paths().len(),
1771 ));
1772 }
1773 identity_admission.merge(
1774 symbols.identity_admission.for_paths_with_limit(
1775 &paths,
1776 staging_limit
1777 .saturating_sub(sources.retained_bytes)
1778 .saturating_sub(identity_admission.observed_fact_bytes),
1779 )?,
1780 control,
1781 )?;
1782 let mut document_facts = complete_markdown_facts(
1783 root,
1784 nodes,
1785 &graphs,
1786 symbols,
1787 control,
1788 staging_limit
1789 .saturating_sub(sources.retained_bytes)
1790 .saturating_sub(identity_admission.observed_fact_bytes),
1791 )?;
1792 identity_admission.retained_limit = Some(
1793 staging_limit
1794 .saturating_sub(sources.retained_bytes)
1795 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1796 );
1797 admit_markdown_facts(
1798 &mut document_facts,
1799 &graphs,
1800 &mut identity_admission,
1801 control,
1802 )?;
1803 control.check(IndexWorkStage::SymbolParsing)?;
1804 let configured_modules =
1805 super::module_resolution::load_configured_module_resolution(root, nodes, control)?;
1806 let staging_limit = staging_limit.saturating_sub(configured_modules.retained_bytes());
1807 sources.set_retained_limit(
1808 staging_limit
1809 .saturating_sub(identity_admission.observed_fact_bytes)
1810 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1811 )?;
1812 let packages = PackageIndex::from_sources(
1813 project,
1814 &graphs.iter().collect::<Vec<_>>(),
1815 &mut sources,
1816 control,
1817 )?;
1818 admit_resolution_key_failures_with_limit(
1819 project,
1820 generation,
1821 &graphs,
1822 &packages,
1823 &configured_modules,
1824 &mut identity_admission,
1825 control,
1826 staging_limit
1827 .saturating_sub(sources.retained_bytes)
1828 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1829 )?;
1830 ensure_admitted_resolution_projections(&graphs, &identity_admission.resolution_projections)?;
1831 let entity_limit = staging_limit
1832 .saturating_sub(sources.retained_bytes)
1833 .saturating_sub(identity_admission.observed_fact_bytes)
1834 .saturating_sub(document_fact_map_retained_bytes(&document_facts));
1835 let mut entity_projection = build_entity_projection_with_config_limit(
1836 project,
1837 generation,
1838 nodes,
1839 &graphs,
1840 &packages,
1841 &configured_modules,
1842 Some(&mut identity_admission.resolution_projections),
1843 true,
1844 control,
1845 entity_limit,
1846 )?;
1847 debug_assert!(identity_admission.resolution_projections.is_empty());
1848 let candidate_limit = staging_limit
1849 .saturating_sub(entity_projection.retained_bytes)
1850 .saturating_sub(sources.retained_bytes)
1851 .saturating_sub(identity_admission.observed_fact_bytes)
1852 .saturating_sub(document_fact_map_retained_bytes(&document_facts));
1853 let mut candidates =
1854 resolution_registry_from_exports_with_limit(&entity_projection, control, candidate_limit)?;
1855 let (bindings, binding_bytes) = binding_resolution::resolve_source_bindings(
1856 store,
1857 project,
1858 generation,
1859 &graphs.iter().collect::<Vec<_>>(),
1860 &packages,
1861 &configured_modules,
1862 &mut sources,
1863 &entity_projection,
1864 &mut candidates,
1865 None,
1866 &BTreeSet::new(),
1867 control,
1868 staging_limit
1869 .saturating_sub(identity_admission.observed_fact_bytes)
1870 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
1871 )?;
1872 entity_projection.call_bindings = bindings;
1873 entity_projection.retained_bytes = entity_projection
1874 .retained_bytes
1875 .saturating_add(binding_bytes);
1876 enforce_resolution_staging_budget(&entity_projection, &candidates)?;
1877 let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1878 let graph_work_bytes = symbols
1879 .retained_bytes
1880 .saturating_add(identity_admission.observed_fact_bytes)
1881 .saturating_add(entity_projection.retained_bytes)
1882 .saturating_add(candidates.retained_bytes)
1883 .saturating_add(document_fact_map_retained_bytes(&document_facts))
1884 .saturating_add(sources.retained_bytes)
1885 .saturating_add(document_projection_bytes);
1886 let mut staged = if graph_work_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1887 finish_projection_in_database_with_documents(
1888 root,
1889 nodes,
1890 project,
1891 generation,
1892 &graphs,
1893 &document_facts,
1894 &identity_admission,
1895 entity_projection,
1896 &candidates,
1897 scan_policy,
1898 control,
1899 )
1900 } else {
1901 finish_projection_with_documents(
1902 project,
1903 generation,
1904 RepositoryGraphMutation::Full,
1905 &graphs,
1906 root,
1907 nodes,
1908 &document_facts,
1909 &identity_admission,
1910 entity_projection,
1911 &candidates,
1912 scan_policy,
1913 control,
1914 )
1915 }?;
1916 staged.source_recovery = sources.work;
1917 Ok(staged)
1918}
1919
1920struct ResolutionKeyWorkspace<'a> {
1922 root: &'a Path,
1924 paths: &'a BTreeSet<String>,
1926 row_limit: usize,
1928 keys: BTreeSet<CanonicalResolutionKey>,
1930 retained_bytes: u64,
1932 limit: u64,
1934}
1935
1936impl<'a> ResolutionKeyWorkspace<'a> {
1937 fn new(limit: u64, root: &'a Path, paths: &'a BTreeSet<String>) -> Self {
1939 Self {
1940 root,
1941 paths,
1942 row_limit: MAX_INCREMENTAL_RESOLUTION_ITEMS as usize,
1943 keys: BTreeSet::new(),
1944 retained_bytes: 0,
1945 limit,
1946 }
1947 }
1948
1949 fn insert(&mut self, key: &CanonicalResolutionKey) -> Result<(), CliError> {
1951 if self.keys.contains(key) {
1952 return Ok(());
1953 }
1954 if self.keys.len() >= self.row_limit {
1955 return Err(dependency_closure_limit(
1956 self.root,
1957 self.paths.iter().cloned(),
1958 self.keys.len().saturating_add(1),
1959 ));
1960 }
1961 let next = self
1962 .retained_bytes
1963 .saturating_add(resolution_workspace_key_bytes(key));
1964 enforce_resolution_registry_budget_with_limit(next, self.limit)?;
1965 self.keys.insert(key.clone());
1966 self.retained_bytes = next;
1967 Ok(())
1968 }
1969
1970 fn insert_owned(&mut self, key: CanonicalResolutionKey) -> Result<(), CliError> {
1972 if self.keys.contains(&key) {
1973 return Ok(());
1974 }
1975 if self.keys.len() >= self.row_limit {
1976 return Err(dependency_closure_limit(
1977 self.root,
1978 self.paths.iter().cloned(),
1979 self.keys.len().saturating_add(1),
1980 ));
1981 }
1982 let next = self
1983 .retained_bytes
1984 .saturating_add(resolution_workspace_key_bytes(&key));
1985 enforce_resolution_registry_budget_with_limit(next, self.limit)?;
1986 self.keys.insert(key);
1987 self.retained_bytes = next;
1988 Ok(())
1989 }
1990
1991 fn snapshot(&self) -> Result<(Vec<CanonicalResolutionKey>, u64), CliError> {
1993 let bytes = self.keys.iter().fold(
1994 (self.keys.len() as u64)
1995 .saturating_mul(std::mem::size_of::<CanonicalResolutionKey>() as u64),
1996 |bytes, key| bytes.saturating_add(key.canonical_identity().len() as u64),
1997 );
1998 enforce_resolution_registry_budget_with_limit(
1999 self.retained_bytes.saturating_add(bytes),
2000 self.limit,
2001 )?;
2002 let mut snapshot = Vec::with_capacity(self.keys.len());
2003 snapshot.extend(self.keys.iter().cloned());
2004 Ok((
2005 snapshot,
2006 self.limit
2007 .saturating_sub(self.retained_bytes)
2008 .saturating_sub(bytes),
2009 ))
2010 }
2011}
2012
2013fn resolution_workspace_key_bytes(key: &CanonicalResolutionKey) -> u64 {
2015 STAGED_GRAPH_ROW_BYTES
2016 .saturating_add(std::mem::size_of::<CanonicalResolutionKey>() as u64)
2017 .saturating_add(key.retained_heap_bytes())
2018}
2019
2020#[cfg(test)]
2022fn stage_incremental_repository_graph(
2023 store: &AtlasStore,
2024 root: &Path,
2025 base_generation: IndexGeneration,
2026 expected_nodes: &[Node],
2027 direct_paths: &[String],
2028 scan_policy: &RootScanPolicy,
2029 symbols: &SymbolBuildStage,
2030 control: &IndexWorkControl,
2031) -> Result<StagedRepositoryGraph, CliError> {
2032 stage_incremental_repository_graph_with_limit(
2033 store,
2034 root,
2035 base_generation,
2036 expected_nodes,
2037 direct_paths,
2038 scan_policy,
2039 symbols,
2040 control,
2041 super::MAX_PUBLICATION_STAGING_BYTES,
2042 )
2043}
2044
2045#[cfg(test)]
2046fn stage_incremental_repository_graph_with_test_limit(
2047 store: &AtlasStore,
2048 root: &Path,
2049 base_generation: IndexGeneration,
2050 expected_nodes: &[Node],
2051 direct_paths: &[String],
2052 scan_policy: &RootScanPolicy,
2053 symbols: &SymbolBuildStage,
2054 control: &IndexWorkControl,
2055 staging_limit: u64,
2056) -> Result<StagedRepositoryGraph, CliError> {
2057 stage_incremental_repository_graph_with_limit(
2058 store,
2059 root,
2060 base_generation,
2061 expected_nodes,
2062 direct_paths,
2063 scan_policy,
2064 symbols,
2065 control,
2066 staging_limit,
2067 )
2068}
2069
2070#[allow(clippy::too_many_arguments)]
2071pub(super) fn stage_incremental_repository_graph_with_limit(
2073 store: &AtlasStore,
2074 root: &Path,
2075 base_generation: IndexGeneration,
2076 expected_nodes: &[Node],
2077 direct_paths: &[String],
2078 scan_policy: &RootScanPolicy,
2079 symbols: &SymbolBuildStage,
2080 control: &IndexWorkControl,
2081 staging_limit: u64,
2082) -> Result<StagedRepositoryGraph, CliError> {
2083 let inventory_bytes = graph_path_inventory_bytes(expected_nodes, direct_paths);
2084 enforce_resolution_registry_budget_with_limit(
2085 symbols.retained_bytes.saturating_add(inventory_bytes),
2086 staging_limit,
2087 )?;
2088 let staging_limit = staging_limit
2089 .saturating_sub(symbols.retained_bytes)
2090 .saturating_sub(inventory_bytes);
2091 let project = selected_project(store)?;
2092 let generation = next_generation(base_generation)?;
2093 let configured_modules =
2094 super::module_resolution::load_configured_module_resolution(root, expected_nodes, control)?;
2095 let staging_limit = staging_limit.saturating_sub(configured_modules.retained_bytes());
2096 let direct_paths = direct_paths.iter().cloned().collect::<BTreeSet<_>>();
2097 let mut sources = SourceBindingCache::new(root, expected_nodes, Some(&direct_paths));
2098 sources.set_retained_limit(staging_limit)?;
2099 enforce_incremental_count(
2100 root,
2101 "direct source paths",
2102 direct_paths.len(),
2103 &direct_paths,
2104 )?;
2105 let _direct_footprint =
2106 admitted_persisted_footprint(store, project, root, &direct_paths, control)?;
2107
2108 let current_file_paths = expected_nodes
2109 .iter()
2110 .filter(|node| node.kind == NodeKind::File)
2111 .map(|node| node.path.clone())
2112 .collect::<BTreeSet<_>>();
2113 let direct_graph_paths = direct_paths
2114 .intersection(¤t_file_paths)
2115 .cloned()
2116 .collect::<BTreeSet<_>>();
2117 let manifest_paths = expected_nodes
2118 .iter()
2119 .filter(|node| node.kind == NodeKind::File && is_cargo_manifest_path(&node.path))
2120 .map(|node| node.path.clone())
2121 .collect::<BTreeSet<_>>();
2122 let package_only_paths = manifest_paths
2123 .difference(&direct_graph_paths)
2124 .cloned()
2125 .collect::<BTreeSet<_>>();
2126 let loaded_direct_graphs =
2127 complete_symbol_graphs(store, &direct_graph_paths, symbols, &mut sources, control)?;
2128 let (direct_graphs, mut direct_identity_admission) = admit_symbol_graphs_with_limit(
2129 loaded_direct_graphs,
2130 control,
2131 staging_limit.saturating_sub(sources.retained_bytes),
2132 )?;
2133 direct_identity_admission.merge(
2134 symbols.identity_admission.for_paths_with_limit(
2135 &direct_graph_paths,
2136 staging_limit
2137 .saturating_sub(sources.retained_bytes)
2138 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2139 )?,
2140 control,
2141 )?;
2142 let package_graphs =
2143 complete_symbol_graphs(store, &package_only_paths, symbols, &mut sources, control)?;
2144 let (package_graphs, package_context_admission) = admit_symbol_graphs_with_limit(
2145 package_graphs,
2146 control,
2147 staging_limit
2148 .saturating_sub(sources.retained_bytes)
2149 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2150 )?;
2151 drop(package_context_admission);
2152 sources.set_retained_limit(
2153 staging_limit.saturating_sub(direct_identity_admission.observed_fact_bytes),
2154 )?;
2155 let package_index_graphs = direct_graphs
2156 .iter()
2157 .chain(package_graphs.iter())
2158 .collect::<Vec<_>>();
2159 let direct_packages =
2160 PackageIndex::from_sources(project, &package_index_graphs, &mut sources, control)?;
2161 admit_resolution_key_failures_with_limit(
2162 project,
2163 generation,
2164 &direct_graphs,
2165 &direct_packages,
2166 &configured_modules,
2167 &mut direct_identity_admission,
2168 control,
2169 staging_limit.saturating_sub(sources.retained_bytes),
2170 )?;
2171 let direct_key_bytes =
2173 resolution_projection_map_retained_bytes(&direct_identity_admission.resolution_projections);
2174 sources.set_retained_limit(
2175 staging_limit
2176 .saturating_sub(direct_key_bytes)
2177 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2178 )?;
2179
2180 let old_exports = store
2181 .repository_export_keys_for_paths_with_byte_limit(
2182 project,
2183 &direct_paths.iter().cloned().collect::<Vec<_>>(),
2184 MAX_INCREMENTAL_RESOLUTION_ITEMS,
2185 staging_limit
2186 .saturating_sub(sources.retained_bytes)
2187 .saturating_sub(direct_key_bytes)
2188 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2189 )
2190 .map_err(resolution_read_error)?;
2191 if old_exports.page.truncated {
2192 return Err(dependency_closure_limit(
2193 root,
2194 direct_paths.iter().cloned(),
2195 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2196 ));
2197 }
2198 let mut changed_keys = ResolutionKeyWorkspace::new(
2199 staging_limit
2200 .saturating_sub(sources.retained_bytes)
2201 .saturating_sub(direct_key_bytes)
2202 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2203 root,
2204 &direct_paths,
2205 );
2206 let changed_limit = changed_keys.limit;
2207 changed_keys.limit = changed_limit.saturating_sub(old_exports.retained_bytes);
2208 for key in old_exports.page.rows {
2209 changed_keys.insert_owned(key)?;
2210 }
2211 changed_keys.limit = changed_limit;
2212 for path in &direct_paths {
2213 changed_keys.insert_owned(document_file_resolution_key(project, path)?)?;
2214 changed_keys.insert_owned(document_casefold_resolution_key(project, path)?)?;
2215 }
2216 for graph in &direct_graphs {
2217 let graph = graph.graph();
2218 control.check(IndexWorkStage::SymbolParsing)?;
2219 let projection = direct_identity_admission
2220 .resolution_projection(&graph.path)
2221 .ok_or_else(|| {
2222 CliError::InvalidInput(format!(
2223 "resolution keys were not admitted for graph {}",
2224 graph.path
2225 ))
2226 })?;
2227 for key in projection.source_keys() {
2228 changed_keys.insert(key)?;
2229 }
2230 for export in projection.export_keys() {
2231 for key in export.keys() {
2232 changed_keys.insert(key)?;
2233 }
2234 }
2235 for symbol in projection.symbol_keys() {
2236 for key in symbol.keys() {
2237 changed_keys.insert(key)?;
2238 }
2239 }
2240 for symbol in &graph.symbols {
2241 if symbol.kind == SymbolKind::Heading {
2242 changed_keys.insert_owned(document_heading_resolution_key(
2243 project,
2244 &graph.path,
2245 &symbol.signature,
2246 )?)?;
2247 }
2248 }
2249 }
2250 enforce_incremental_count(
2251 root,
2252 "old and new export keys",
2253 changed_keys.keys.len(),
2254 &direct_paths,
2255 )?;
2256
2257 let inbound = {
2258 let (request, remaining) = changed_keys.snapshot()?;
2259 store
2260 .repository_affected_source_paths_with_byte_limit(
2261 project,
2262 &request,
2263 MAX_INCREMENTAL_RESOLUTION_ITEMS,
2264 remaining,
2265 )
2266 .map_err(resolution_read_error)?
2267 };
2268 drop(changed_keys);
2269 if inbound.page.truncated {
2270 return Err(dependency_closure_limit(
2271 root,
2272 inbound
2273 .page
2274 .rows
2275 .iter()
2276 .map(|path| path.as_str().to_string()),
2277 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2278 ));
2279 }
2280 let mut affected_paths = direct_paths;
2281 affected_paths.extend(inbound.page.rows.into_iter().map(String::from));
2283 affected_paths.extend(direct_identity_admission.paths().map(ToString::to_string));
2284 enforce_incremental_count(
2285 root,
2286 "affected source paths",
2287 affected_paths.len(),
2288 &affected_paths,
2289 )?;
2290 control.check(IndexWorkStage::SymbolParsing)?;
2291 let persisted_footprint =
2292 admitted_persisted_footprint(store, project, root, &affected_paths, control)?;
2293
2294 let affected_graph_paths = affected_paths
2295 .intersection(¤t_file_paths)
2296 .cloned()
2297 .collect::<BTreeSet<_>>();
2298 let newly_affected_graph_paths = affected_graph_paths
2299 .difference(&direct_graph_paths)
2300 .cloned()
2301 .collect::<BTreeSet<_>>();
2302 let loaded_affected_graphs = complete_symbol_graphs(
2303 store,
2304 &newly_affected_graph_paths,
2305 symbols,
2306 &mut sources,
2307 control,
2308 )?;
2309 let (newly_affected_graphs, mut affected_identity_admission) = admit_symbol_graphs_with_limit(
2310 loaded_affected_graphs,
2311 control,
2312 staging_limit
2313 .saturating_sub(sources.retained_bytes)
2314 .saturating_sub(direct_key_bytes)
2315 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2316 )?;
2317 hydrate_reused_identity_admission(
2318 store,
2319 project,
2320 &newly_affected_graph_paths,
2321 &newly_affected_graphs,
2322 &mut affected_identity_admission,
2323 control,
2324 )?;
2325 if !affected_identity_admission
2326 .incomplete_reused_rejection_paths()
2327 .is_empty()
2328 {
2329 return Err(dependency_closure_limit(
2330 root,
2331 affected_identity_admission
2332 .incomplete_reused_rejection_paths()
2333 .iter()
2334 .cloned(),
2335 affected_identity_admission
2336 .incomplete_reused_rejection_paths()
2337 .len(),
2338 ));
2339 }
2340 let admitted_graphs = direct_graphs
2341 .iter()
2342 .chain(newly_affected_graphs.iter())
2343 .chain(package_graphs.iter())
2344 .collect::<Vec<_>>();
2345 let packages = PackageIndex::from_sources(project, &admitted_graphs, &mut sources, control)?;
2346 admit_resolution_key_failures_with_limit(
2347 project,
2348 generation,
2349 &newly_affected_graphs,
2350 &packages,
2351 &configured_modules,
2352 &mut affected_identity_admission,
2353 control,
2354 staging_limit
2355 .saturating_sub(sources.retained_bytes)
2356 .saturating_sub(direct_key_bytes)
2357 .saturating_sub(direct_identity_admission.observed_fact_bytes),
2358 )?;
2359 direct_identity_admission.retained_limit = Some(
2360 staging_limit
2361 .saturating_sub(sources.retained_bytes)
2362 .saturating_sub(direct_key_bytes)
2363 .saturating_sub(resolution_projection_map_retained_bytes(
2364 &affected_identity_admission.resolution_projections,
2365 )),
2366 );
2367 direct_identity_admission.merge(affected_identity_admission, control)?;
2368 enforce_incremental_projection_budget(
2369 root,
2370 &affected_paths,
2371 0,
2372 direct_identity_admission.observed_fact_bytes,
2373 )?;
2374 let affected_graphs = direct_graphs
2375 .iter()
2376 .filter(|graph| affected_graph_paths.contains(&graph.graph().path))
2377 .map(SourceGraph::graph)
2378 .chain(newly_affected_graphs.iter().map(SourceGraph::graph))
2379 .collect::<Vec<_>>();
2380 ensure_admitted_resolution_projections(
2381 &affected_graphs,
2382 &direct_identity_admission.resolution_projections,
2383 )?;
2384 let mut document_facts = complete_markdown_facts(
2385 root,
2386 expected_nodes,
2387 &affected_graphs,
2388 symbols,
2389 control,
2390 staging_limit
2391 .saturating_sub(sources.retained_bytes)
2392 .saturating_sub(direct_identity_admission.observed_fact_bytes)
2393 .saturating_sub(resolution_projection_map_retained_bytes(
2394 &direct_identity_admission.resolution_projections,
2395 )),
2396 )?;
2397 direct_identity_admission.retained_limit = Some(
2398 staging_limit
2399 .saturating_sub(sources.retained_bytes)
2400 .saturating_sub(resolution_projection_map_retained_bytes(
2401 &direct_identity_admission.resolution_projections,
2402 ))
2403 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2404 );
2405 admit_markdown_facts(
2406 &mut document_facts,
2407 &affected_graphs,
2408 &mut direct_identity_admission,
2409 control,
2410 )?;
2411 let affected_nodes = expected_nodes
2412 .iter()
2413 .filter(|node| affected_paths.contains(&node.path))
2414 .cloned()
2415 .collect::<Vec<_>>();
2416 let mut entity_projection = build_entity_projection_with_config_limit(
2417 project,
2418 generation,
2419 &affected_nodes,
2420 &affected_graphs,
2421 &packages,
2422 &configured_modules,
2423 Some(&mut direct_identity_admission.resolution_projections),
2424 false,
2425 control,
2426 staging_limit
2427 .saturating_sub(sources.retained_bytes)
2428 .saturating_sub(direct_identity_admission.observed_fact_bytes)
2429 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2430 )?;
2431 debug_assert!(direct_identity_admission.resolution_projections.is_empty());
2432
2433 let dependency_limit = staging_limit
2434 .saturating_sub(entity_projection.retained_bytes)
2435 .saturating_sub(sources.retained_bytes)
2436 .saturating_sub(direct_identity_admission.observed_fact_bytes)
2437 .saturating_sub(document_fact_map_retained_bytes(&document_facts));
2438 let mut dependency_keys = ResolutionKeyWorkspace::new(dependency_limit, root, &affected_paths);
2439 for key in entity_projection
2440 .keys_by_graph
2441 .values()
2442 .flat_map(ResolutionKeyProjection::relation_keys)
2443 .flat_map(projectatlas_symbols::RelationResolutionKeys::keys)
2444 {
2445 dependency_keys.insert(key)?;
2446 }
2447 append_document_dependency_keys(project, &document_facts, &mut dependency_keys)?;
2448 enforce_incremental_count(
2449 root,
2450 "affected dependency keys",
2451 dependency_keys.keys.len(),
2452 &affected_paths,
2453 )?;
2454 let persisted = {
2455 let (request, remaining) = dependency_keys.snapshot()?;
2456 store
2457 .repository_resolution_candidates_for_keys_with_byte_limit(
2458 project,
2459 &request,
2460 MAX_INCREMENTAL_RESOLUTION_ITEMS,
2461 remaining,
2462 )
2463 .map_err(resolution_read_error)?
2464 };
2465 if persisted.page.truncated {
2466 return Err(dependency_closure_limit(
2467 root,
2468 affected_paths.iter().cloned(),
2469 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
2470 ));
2471 }
2472 let mut candidates = resolution_registry_from_persisted(
2473 project,
2474 generation,
2475 persisted,
2476 &affected_paths,
2477 control,
2478 staging_limit
2479 .saturating_sub(dependency_keys.retained_bytes)
2480 .saturating_sub(entity_projection.retained_bytes)
2481 .saturating_sub(sources.retained_bytes)
2482 .saturating_sub(direct_identity_admission.observed_fact_bytes)
2483 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2484 )?;
2485 append_resolution_exports(&mut candidates, &entity_projection, control)?;
2486 let binding_graphs = direct_graphs
2487 .iter()
2488 .filter(|graph| affected_graph_paths.contains(&graph.graph().path))
2489 .chain(newly_affected_graphs.iter())
2490 .collect::<Vec<_>>();
2491 let (bindings, binding_bytes) = binding_resolution::resolve_source_bindings(
2492 store,
2493 project,
2494 generation,
2495 &binding_graphs,
2496 &packages,
2497 &configured_modules,
2498 &mut sources,
2499 &entity_projection,
2500 &mut candidates,
2501 Some(dependency_keys.keys),
2502 &affected_paths,
2503 control,
2504 staging_limit
2505 .saturating_sub(direct_identity_admission.observed_fact_bytes)
2506 .saturating_sub(document_fact_map_retained_bytes(&document_facts)),
2507 )?;
2508 entity_projection.call_bindings = bindings;
2509 entity_projection.retained_bytes = entity_projection
2510 .retained_bytes
2511 .saturating_add(binding_bytes);
2512 let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
2513 enforce_incremental_projection_budget(
2514 root,
2515 &affected_paths,
2516 0,
2517 entity_projection
2518 .retained_bytes
2519 .saturating_add(candidates.retained_bytes)
2520 .saturating_add(document_fact_map_retained_bytes(&document_facts))
2521 .saturating_add(document_projection_bytes)
2522 .saturating_add(sources.retained_bytes)
2523 .saturating_add(direct_identity_admission.observed_fact_bytes),
2524 )?;
2525 let mut staged = finish_projection_with_documents(
2526 project,
2527 generation,
2528 RepositoryGraphMutation::AffectedPaths(affected_paths.iter().cloned().collect()),
2529 &affected_graphs,
2530 root,
2531 expected_nodes,
2532 &document_facts,
2533 &direct_identity_admission,
2534 entity_projection,
2535 &candidates,
2536 scan_policy,
2537 control,
2538 )?;
2539 enforce_incremental_projection_limits(root, &affected_paths, persisted_footprint, &staged)?;
2540 staged.source_recovery = sources.work;
2541 Ok(staged)
2542}
2543
2544struct EntityProjection {
2546 entity_by_digest: BTreeMap<String, GraphEntity>,
2548 owners_by_graph: BTreeMap<String, GraphOwners>,
2550 keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
2552 entity_exports: Vec<EntityResolutionKey>,
2554 call_bindings: binding_resolution::CallBindings,
2556 retained_bytes: u64,
2558 #[cfg(test)]
2560 peak_retained_bytes: u64,
2561 #[cfg(test)]
2563 projection_removals_before_entities: Vec<String>,
2564}
2565
2566struct GraphOwners {
2568 file_digest: String,
2570 symbol_digests: Vec<Option<String>>,
2572}
2573
2574fn php_file_include_context(context: &str) -> bool {
2576 let context = context.trim_start_matches(|character: char| character.is_ascii_whitespace());
2577 ["include", "include_once", "require", "require_once"]
2578 .iter()
2579 .any(|keyword| {
2580 context
2581 .get(..keyword.len())
2582 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
2583 && context.get(keyword.len()..).is_some_and(|suffix| {
2584 suffix.starts_with(|character: char| character.is_ascii_whitespace())
2585 || suffix.starts_with(['(', '\'', '"', '#'])
2586 || suffix.starts_with("/*")
2587 || suffix.starts_with("//")
2588 })
2589 })
2590}
2591
2592struct GraphSymbolIndex<'graph> {
2594 source_calls: Option<BTreeMap<usize, &'graph SourceCallFact>>,
2596 rust_toolchain_imports:
2598 Option<&'graph BTreeMap<usize, Option<projectatlas_symbols::ImportReference>>>,
2599 indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
2601 php_call_indices_by_name: BTreeMap<String, Vec<usize>>,
2603 php_import_positions: BTreeMap<String, BTreeMap<usize, Option<usize>>>,
2605 php_namespace_start_lines: BTreeMap<String, BTreeSet<usize>>,
2607 php_imports_have_unknown_scope: bool,
2609}
2610
2611impl<'graph> GraphSymbolIndex<'graph> {
2612 fn new(graph: &'graph SymbolGraph, control: &IndexWorkControl) -> Result<Self, CliError> {
2614 let mut indices_by_name = BTreeMap::new();
2615 let mut php_call_indices_by_name = BTreeMap::new();
2616 let mut php_import_positions = BTreeMap::<String, BTreeMap<usize, Option<usize>>>::new();
2617 let mut php_namespace_start_lines = BTreeMap::<String, BTreeSet<usize>>::new();
2618 let mut php_imports_have_unknown_scope = false;
2619 let is_php = graph
2620 .language
2621 .as_deref()
2622 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2623 let mut import_counts = HashMap::<usize, (usize, usize)>::new();
2626 let paired_imports = if is_php {
2627 for (index, symbol) in graph.symbols.iter().enumerate() {
2628 check_graph_work(control, index)?;
2629 if symbol.kind == SymbolKind::Import {
2630 import_counts.entry(symbol.line_start).or_default().0 += 1;
2631 }
2632 }
2633 for (index, relation) in graph.relations.iter().enumerate() {
2634 check_graph_work(control, index)?;
2635 if relation.kind == RelationKind::Imports {
2636 import_counts.entry(relation.line).or_default().1 += 1;
2637 }
2638 }
2639 Some(paired_import_relations(graph, control)?)
2640 } else {
2641 None
2642 };
2643 for (index, symbol) in graph.symbols.iter().enumerate() {
2644 check_graph_work(control, index)?;
2645 if is_php && symbol.kind == SymbolKind::Import {
2647 let ordinal = import_counts
2648 .get(&symbol.line_start)
2649 .filter(|(symbols, relations)| symbols == relations)
2650 .and(paired_imports.as_ref())
2651 .and_then(|paired| paired.by_symbol.get(index).copied().flatten());
2652 php_import_positions
2653 .entry(symbol.parent.as_deref().unwrap_or("").to_ascii_lowercase())
2654 .or_default()
2655 .entry(symbol.line_start)
2656 .or_insert(ordinal);
2657 }
2658 if is_php && symbol.kind == SymbolKind::Module {
2659 php_namespace_start_lines
2660 .entry(symbol.name.to_ascii_lowercase())
2661 .or_default()
2662 .insert(symbol.line_start);
2663 }
2664 indices_by_name
2665 .entry(symbol.name.as_str())
2666 .or_insert_with(Vec::new)
2667 .push(index);
2668 if is_php && matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
2669 php_call_indices_by_name
2670 .entry(symbol.name.to_ascii_lowercase())
2671 .or_insert_with(Vec::new)
2672 .push(index);
2673 }
2674 }
2675 if is_php && graph.parser != ParserKind::TreeSitter {
2677 for (index, relation) in graph.relations.iter().enumerate() {
2678 check_graph_work(control, index)?;
2679 if relation.kind == RelationKind::Imports
2680 && relation.source_name == MODULE_RELATION_SOURCE
2681 && !php_file_include_context(&relation.context)
2682 {
2683 php_imports_have_unknown_scope = true;
2684 break;
2685 }
2686 }
2687 }
2688 Ok(Self {
2689 source_calls: None,
2690 rust_toolchain_imports: None,
2691 indices_by_name,
2692 php_call_indices_by_name,
2693 php_import_positions,
2694 php_namespace_start_lines,
2695 php_imports_have_unknown_scope,
2696 })
2697 }
2698
2699 fn with_source_calls(
2701 mut self,
2702 calls: Option<&'graph [SourceCallFact]>,
2703 control: &IndexWorkControl,
2704 ) -> Result<Self, CliError> {
2705 if let Some(calls) = calls {
2706 let mut indexed = BTreeMap::new();
2707 for call in calls {
2708 control.check(IndexWorkStage::SymbolParsing)?;
2709 if indexed.insert(call.relation, call).is_some() {
2710 return Err(CliError::InvalidInput(
2711 "duplicate source call ordinal".to_string(),
2712 ));
2713 }
2714 }
2715 self.source_calls = Some(indexed);
2716 }
2717 Ok(self)
2718 }
2719
2720 fn with_rust_toolchain_imports(
2722 mut self,
2723 imports: Option<&'graph BTreeMap<usize, Option<projectatlas_symbols::ImportReference>>>,
2724 ) -> Self {
2725 self.rust_toolchain_imports = imports;
2726 self
2727 }
2728
2729 fn get(&self, name: &str) -> &[usize] {
2731 self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
2732 }
2733
2734 fn get_php_call(&self, name: &str) -> &[usize] {
2736 self.php_call_indices_by_name
2737 .get(&name.to_ascii_lowercase())
2738 .map_or(&[], Vec::as_slice)
2739 }
2740}
2741
2742struct DerivedRelationFact {
2744 kind: ExtendedRelationKind,
2746 relation: SymbolRelation,
2748 target: DerivedRelationTarget,
2750}
2751
2752enum DerivedRelationTarget {
2754 Parser {
2756 keys: Vec<CanonicalResolutionKey>,
2758 },
2759 RepositoryPath,
2761 External {
2763 system: &'static str,
2765 },
2766}
2767
2768struct PackageIndex {
2770 retained_bytes: u64,
2772 packages: Vec<PackageOwner>,
2774}
2775
2776struct PackageOwner {
2778 root: String,
2780 name: String,
2782 manifest: String,
2784 cohorts: Option<Arc<CargoRootCohortIndex>>,
2786}
2787
2788impl PackageIndex {
2789 #[cfg(test)]
2791 fn from_graphs(graphs: &[impl Borrow<SymbolGraph>]) -> Result<Self, CliError> {
2792 Self::from_graph_iter(graphs.iter().map(Borrow::borrow), &mut |_| Ok(()))
2793 }
2794
2795 fn from_graph_iter<'a>(
2797 graphs: impl IntoIterator<Item = &'a SymbolGraph>,
2798 reserve: &mut impl FnMut(u64) -> Result<(), CliError>,
2799 ) -> Result<Self, CliError> {
2800 let mut packages = Vec::new();
2801 let mut retained_bytes = 0_u64;
2802 for graph in graphs {
2803 for symbol in &graph.symbols {
2804 if symbol.kind != SymbolKind::Package {
2805 continue;
2806 }
2807 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?;
2808 let bytes = (4 * std::mem::size_of::<PackageOwner>()) as u64
2809 + (graph.path.len() as u64).saturating_mul(2)
2810 + symbol.name.len() as u64;
2811 reserve(bytes)?;
2812 retained_bytes = retained_bytes.saturating_add(bytes);
2813 let root = graph
2814 .path
2815 .rsplit_once('/')
2816 .map_or(String::new(), |(parent, _manifest)| parent.to_string());
2817 packages.push(PackageOwner {
2818 root,
2819 name: symbol.name.clone(),
2820 manifest: graph.path.clone(),
2821 cohorts: None,
2822 });
2823 }
2824 }
2825 packages.sort_by(|left, right| {
2826 right
2827 .root
2828 .len()
2829 .cmp(&left.root.len())
2830 .then_with(|| left.root.cmp(&right.root))
2831 .then_with(|| left.name.cmp(&right.name))
2832 .then_with(|| left.manifest.cmp(&right.manifest))
2833 });
2834 packages.dedup_by(|left, right| {
2835 left.root == right.root && left.name == right.name && left.manifest == right.manifest
2836 });
2837 Ok(Self {
2838 retained_bytes,
2839 packages,
2840 })
2841 }
2842
2843 fn package_name(&self, path: &str) -> Option<&str> {
2845 self.package_owner(path)
2846 .map(|package| package.name.as_str())
2847 }
2848
2849 fn package_owner(&self, path: &str) -> Option<&PackageOwner> {
2851 self.packages
2852 .iter()
2853 .find(|package| repository_path_belongs_to(path, &package.root))
2854 }
2855}
2856
2857fn repository_path_belongs_to(path: &str, root: &str) -> bool {
2859 root.is_empty()
2860 || path == root
2861 || path
2862 .strip_prefix(root)
2863 .is_some_and(|suffix| suffix.starts_with('/'))
2864}
2865
2866fn is_cargo_manifest_path(path: &str) -> bool {
2868 path == "Cargo.toml" || path.ends_with("/Cargo.toml")
2869}
2870
2871#[cfg(test)]
2873fn qualified_symbol_parents(
2874 graph: &SymbolGraph,
2875) -> Result<Vec<Option<GraphIdentityText>>, CliError> {
2876 projectatlas_symbols::declaration_identity::qualified_symbol_parents(graph)
2877 .map_err(invalid_graph_contract)
2878}
2879
2880#[cfg(test)]
2882fn source_symbol_identity(value: String) -> Result<GraphIdentityText, CliError> {
2883 projectatlas_symbols::declaration_identity::source_symbol_identity(value)
2884 .map_err(invalid_graph_contract)
2885}
2886
2887#[cfg(test)]
2889fn qualified_symbol_identity(
2890 parent: &GraphIdentityText,
2891 name: &GraphIdentityText,
2892) -> Result<GraphIdentityText, CliError> {
2893 projectatlas_symbols::declaration_identity::qualified_symbol_identity(parent, name)
2894 .map_err(invalid_graph_contract)
2895}
2896
2897#[cfg(test)]
2899fn build_entity_projection(
2900 project: ProjectInstanceId,
2901 generation: IndexGeneration,
2902 nodes: &[Node],
2903 graphs: &[impl Borrow<SymbolGraph>],
2904 packages: &PackageIndex,
2905 include_project: bool,
2906 control: &IndexWorkControl,
2907) -> Result<EntityProjection, CliError> {
2908 build_entity_projection_with_config(
2909 project,
2910 generation,
2911 nodes,
2912 graphs,
2913 packages,
2914 &ConfiguredModuleResolution::default(),
2915 None,
2916 include_project,
2917 control,
2918 )
2919}
2920
2921#[cfg(test)]
2923#[allow(clippy::too_many_arguments)]
2924fn build_entity_projection_with_config(
2925 project: ProjectInstanceId,
2926 generation: IndexGeneration,
2927 nodes: &[Node],
2928 graphs: &[impl Borrow<SymbolGraph>],
2929 packages: &PackageIndex,
2930 configured_modules: &ConfiguredModuleResolution,
2931 admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2932 include_project: bool,
2933 control: &IndexWorkControl,
2934) -> Result<EntityProjection, CliError> {
2935 build_entity_projection_with_config_limit(
2936 project,
2937 generation,
2938 nodes,
2939 graphs,
2940 packages,
2941 configured_modules,
2942 admitted_projections,
2943 include_project,
2944 control,
2945 super::MAX_PUBLICATION_STAGING_BYTES,
2946 )
2947}
2948
2949fn reserve_entity_export(
2951 owner: &projectatlas_core::graph::GraphEntityKey,
2952 key: &CanonicalResolutionKey,
2953 bytes: &mut u64,
2954 coexisting: u64,
2955 limit: u64,
2956) -> Result<(), CliError> {
2957 let next = bytes
2958 .saturating_add((4 * std::mem::size_of::<EntityResolutionKey>()) as u64)
2959 .saturating_add(owner.canonical_identity().len() as u64)
2960 .saturating_add(key.canonical_identity().len() as u64);
2961 enforce_resolution_registry_budget_with_limit(next.saturating_add(coexisting), limit)?;
2962 *bytes = next;
2963 Ok(())
2964}
2965
2966#[allow(clippy::too_many_arguments)]
2968fn build_entity_projection_with_config_limit(
2969 project: ProjectInstanceId,
2970 generation: IndexGeneration,
2971 nodes: &[Node],
2972 graphs: &[impl Borrow<SymbolGraph>],
2973 packages: &PackageIndex,
2974 configured_modules: &ConfiguredModuleResolution,
2975 mut admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2976 include_project: bool,
2977 control: &IndexWorkControl,
2978 staging_limit: u64,
2979) -> Result<EntityProjection, CliError> {
2980 let mut admitted_map_bytes = admitted_projections
2981 .as_deref()
2982 .map(resolution_projection_map_retained_bytes)
2983 .unwrap_or_default();
2984 enforce_resolution_registry_budget_with_limit(admitted_map_bytes, staging_limit)?;
2985 let mut entity_by_digest = BTreeMap::new();
2986 let mut entity_exports = Vec::new();
2987 let mut entity_bytes = 0_u64;
2988 if include_project {
2989 let entity = GraphEntity::new(project, EntitySelector::Project, generation)
2990 .map_err(invalid_graph_contract)?;
2991 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2992 enforce_resolution_registry_budget_with_limit(
2993 entity_bytes.saturating_add(admitted_map_bytes),
2994 staging_limit,
2995 )?;
2996 insert_entity(&mut entity_by_digest, entity)?;
2997 }
2998 for node in nodes {
2999 control.check(IndexWorkStage::SymbolParsing)?;
3000 let selector = match node.kind {
3001 NodeKind::Folder => EntitySelector::Folder {
3002 path: RepositoryNodePath::new(Path::new(&node.path))
3003 .map_err(invalid_graph_contract)?,
3004 },
3005 NodeKind::File => EntitySelector::File {
3006 path: RepositoryFilePath::new(Path::new(&node.path))
3007 .map_err(invalid_graph_contract)?,
3008 },
3009 };
3010 let entity =
3011 GraphEntity::new(project, selector, generation).map_err(invalid_graph_contract)?;
3012 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
3013 if node.kind == NodeKind::File {
3014 for key in [
3015 document_file_resolution_key(project, &node.path)?,
3016 document_casefold_resolution_key(project, &node.path)?,
3017 ] {
3018 reserve_entity_export(
3019 entity.key(),
3020 &key,
3021 &mut entity_bytes,
3022 admitted_map_bytes,
3023 staging_limit,
3024 )?;
3025 entity_exports.push(
3026 EntityResolutionKey::new(entity.key().clone(), key)
3027 .map_err(invalid_graph_contract)?,
3028 );
3029 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3030 }
3031 }
3032 enforce_resolution_registry_budget_with_limit(
3033 entity_bytes.saturating_add(admitted_map_bytes),
3034 staging_limit,
3035 )?;
3036 insert_entity(&mut entity_by_digest, entity)?;
3037 }
3038
3039 let mut owners_by_graph = BTreeMap::new();
3040 let mut keys_by_graph = BTreeMap::new();
3041 let mut retained_bytes = 0_u64;
3042
3043 enforce_resolution_registry_budget_with_limit(
3044 admitted_map_bytes.saturating_add(entity_bytes),
3045 staging_limit,
3046 )?;
3047 #[cfg(test)]
3048 let mut peak_retained_bytes = admitted_map_bytes.saturating_add(entity_bytes);
3049 #[cfg(test)]
3050 let mut projection_removals_before_entities = Vec::new();
3051 for graph in graphs {
3052 let graph = graph.borrow();
3053 control.check(IndexWorkStage::SymbolParsing)?;
3054 let resolution = if let Some(projections) = admitted_projections.as_deref_mut() {
3055 let resolution = projections.remove(&graph.path).ok_or_else(|| {
3056 CliError::InvalidInput(format!(
3057 "resolution keys were not admitted for graph {}",
3058 graph.path
3059 ))
3060 })?;
3061 admitted_map_bytes = admitted_map_bytes.saturating_sub(
3062 resolution_projection_map_entry_retained_bytes(&graph.path, &resolution),
3063 );
3064 #[cfg(test)]
3065 projection_removals_before_entities.push(graph.path.clone());
3066 resolution
3067 } else {
3068 resolution_projection_with_config(
3069 project,
3070 packages,
3071 graph,
3072 configured_modules,
3073 staging_limit
3074 .saturating_sub(admitted_map_bytes)
3075 .saturating_sub(entity_bytes),
3076 )?
3077 };
3078 let resolution_bytes = resolution_retained_bytes(&resolution);
3079 entity_bytes = entity_bytes.saturating_add(resolution_bytes);
3080 #[cfg(test)]
3081 {
3082 peak_retained_bytes =
3083 peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
3084 }
3085 enforce_resolution_registry_budget_with_limit(
3086 admitted_map_bytes.saturating_add(entity_bytes),
3087 staging_limit,
3088 )?;
3089 let file = GraphEntity::new(
3090 project,
3091 EntitySelector::File {
3092 path: RepositoryFilePath::new(Path::new(&graph.path))
3093 .map_err(invalid_graph_contract)?,
3094 },
3095 generation,
3096 )
3097 .map_err(invalid_graph_contract)?;
3098 let file_digest = file.key().digest().to_string();
3099 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&file));
3100 insert_entity(&mut entity_by_digest, file)?;
3101 let mut symbol_digests = Vec::with_capacity(graph.symbols.len());
3102 entity_bytes = entity_bytes.saturating_add(
3103 STAGED_GRAPH_ROW_BYTES
3104 .saturating_mul(u64::try_from(navigable_symbol_count(graph)).unwrap_or(u64::MAX)),
3105 );
3106 let qualified_parents =
3107 projectatlas_symbols::declaration_identity::qualified_symbol_parents_checked(
3108 graph,
3109 &mut |bytes| {
3110 let next = entity_bytes.saturating_add(bytes);
3111 enforce_resolution_registry_budget_with_limit(
3112 next.saturating_add(admitted_map_bytes),
3113 staging_limit,
3114 )?;
3115 entity_bytes = next;
3116 Ok::<_, CliError>(())
3117 },
3118 )?;
3119 for (symbol, qualified_parent) in graph.symbols.iter().zip(qualified_parents) {
3120 control.check(IndexWorkStage::SymbolParsing)?;
3121 if is_document_coverage_marker(graph, symbol) {
3122 symbol_digests.push(None);
3123 continue;
3124 }
3125 let entity = match symbol.kind {
3126 SymbolKind::Import | SymbolKind::Dependency | SymbolKind::Workspace => None,
3127 SymbolKind::Package => Some(
3128 GraphEntity::new(
3129 project,
3130 EntitySelector::Package {
3131 package: PackageSelector {
3132 manager: GraphIdentityText::new(CARGO_PACKAGE_MANAGER)
3133 .map_err(invalid_graph_contract)?,
3134 name: GraphIdentityText::new(symbol.name.clone())
3135 .map_err(invalid_graph_contract)?,
3136 manifest: RepositoryFilePath::new(Path::new(&graph.path))
3137 .map_err(invalid_graph_contract)?,
3138 },
3139 },
3140 generation,
3141 )
3142 .map_err(invalid_graph_contract)?,
3143 ),
3144 _ => Some(source_symbol_entity(
3145 project,
3146 generation,
3147 graph,
3148 symbol,
3149 qualified_parent,
3150 )?),
3151 };
3152 let entity_digest = entity
3153 .as_ref()
3154 .map(|entity| entity.key().digest().to_string());
3155 if let Some(entity) = entity {
3156 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
3157 if symbol.kind == SymbolKind::Heading {
3158 entity_exports.push(
3159 EntityResolutionKey::new(
3160 entity.key().clone(),
3161 document_heading_resolution_key(
3162 project,
3163 &graph.path,
3164 &symbol.signature,
3165 )?,
3166 )
3167 .map_err(invalid_graph_contract)?,
3168 );
3169 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3170 }
3171 enforce_resolution_registry_budget_with_limit(
3172 entity_bytes.saturating_add(admitted_map_bytes),
3173 staging_limit,
3174 )?;
3175 insert_entity(&mut entity_by_digest, entity)?;
3176 }
3177 symbol_digests.push(entity_digest);
3178 }
3179 let file = entity_by_digest
3180 .get(&file_digest)
3181 .ok_or_else(|| CliError::InvalidInput("graph file owner was not staged".to_string()))?;
3182 for key in resolution.source_keys() {
3183 reserve_entity_export(
3184 file.key(),
3185 key,
3186 &mut entity_bytes,
3187 admitted_map_bytes,
3188 staging_limit,
3189 )?;
3190 entity_exports.push(
3191 EntityResolutionKey::new(file.key().clone(), key.clone())
3192 .map_err(invalid_graph_contract)?,
3193 );
3194 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3195 }
3196 for export in resolution.export_keys() {
3197 for key in export.keys() {
3198 reserve_entity_export(
3199 file.key(),
3200 key,
3201 &mut entity_bytes,
3202 admitted_map_bytes,
3203 staging_limit,
3204 )?;
3205 entity_exports.push(
3206 EntityResolutionKey::new(file.key().clone(), key.clone())
3207 .map_err(invalid_graph_contract)?,
3208 );
3209 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3210 }
3211 }
3212 for symbol_keys in resolution.symbol_keys() {
3213 let Some(entity) = symbol_digests
3214 .get(symbol_keys.symbol_index())
3215 .and_then(Option::as_ref)
3216 .and_then(|digest| entity_by_digest.get(digest))
3217 else {
3218 continue;
3219 };
3220 for key in symbol_keys.keys() {
3221 reserve_entity_export(
3222 entity.key(),
3223 key,
3224 &mut entity_bytes,
3225 admitted_map_bytes,
3226 staging_limit,
3227 )?;
3228 entity_exports.push(
3229 EntityResolutionKey::new(entity.key().clone(), key.clone())
3230 .map_err(invalid_graph_contract)?,
3231 );
3232 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
3233 }
3234 }
3235 entity_bytes = entity_bytes
3236 .saturating_add(STAGED_GRAPH_ROW_BYTES)
3237 .saturating_add(graph.path.len() as u64);
3238 #[cfg(test)]
3239 {
3240 peak_retained_bytes =
3241 peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
3242 }
3243 enforce_resolution_registry_budget_with_limit(
3244 admitted_map_bytes.saturating_add(entity_bytes),
3245 staging_limit,
3246 )?;
3247 retained_bytes = retained_bytes
3248 .saturating_add(resolution_projection_map_entry_retained_bytes(
3249 &graph.path,
3250 &resolution,
3251 ))
3252 .saturating_add(STAGED_GRAPH_ROW_BYTES)
3253 .saturating_add(graph.path.len() as u64)
3254 .saturating_add(file_digest.capacity() as u64)
3255 .saturating_add(
3256 (symbol_digests.capacity() * std::mem::size_of::<Option<String>>()) as u64,
3257 )
3258 .saturating_add(
3259 symbol_digests
3260 .iter()
3261 .flatten()
3262 .map(|digest| digest.capacity() as u64)
3263 .fold(0, u64::saturating_add),
3264 );
3265 owners_by_graph.insert(
3266 graph.path.clone(),
3267 GraphOwners {
3268 file_digest,
3269 symbol_digests,
3270 },
3271 );
3272 keys_by_graph.insert(graph.path.clone(), resolution);
3273 }
3274 sort_dedup_exports(&mut entity_exports);
3275 enforce_key_binding_limit(entity_exports.len())?;
3276 retained_bytes = entity_by_digest
3277 .values()
3278 .fold(retained_bytes, |bytes, entity| {
3279 bytes.saturating_add(entity_retained_bytes(entity))
3280 })
3281 .saturating_add(
3282 entity_exports.iter().fold(
3283 (entity_exports.capacity() as u64)
3284 .saturating_mul(std::mem::size_of::<EntityResolutionKey>() as u64),
3285 |bytes, export| {
3286 bytes
3287 .saturating_add(export.key().retained_heap_bytes())
3288 .saturating_add(export.entity().canonical_identity().len() as u64)
3289 },
3290 ),
3291 );
3292 #[cfg(test)]
3293 {
3294 peak_retained_bytes = peak_retained_bytes.max(retained_bytes);
3295 }
3296 enforce_resolution_registry_budget_with_limit(retained_bytes, staging_limit)?;
3297 Ok(EntityProjection {
3298 entity_by_digest,
3299 owners_by_graph,
3300 keys_by_graph,
3301 entity_exports,
3302 call_bindings: BTreeMap::new(),
3303 retained_bytes,
3304 #[cfg(test)]
3305 peak_retained_bytes,
3306 #[cfg(test)]
3307 projection_removals_before_entities,
3308 })
3309}
3310
3311fn source_symbol_entity(
3313 project: ProjectInstanceId,
3314 generation: IndexGeneration,
3315 graph: &SymbolGraph,
3316 symbol: &CodeSymbol,
3317 parent: Option<GraphIdentityText>,
3318) -> Result<GraphEntity, CliError> {
3319 GraphEntity::new(
3320 project,
3321 EntitySelector::Symbol {
3322 symbol: SymbolSelector {
3323 file: RepositoryFilePath::new(Path::new(&graph.path))
3324 .map_err(invalid_graph_contract)?,
3325 name: GraphIdentityText::new(symbol.name.clone())
3326 .map_err(invalid_graph_contract)?,
3327 kind: symbol.kind,
3328 parent,
3329 signature: GraphIdentityText::new(if symbol.signature.trim().is_empty() {
3330 symbol.name.clone()
3331 } else {
3332 symbol.signature.trim().to_string()
3333 })
3334 .map_err(invalid_graph_contract)?,
3335 },
3336 },
3337 generation,
3338 )
3339 .map_err(invalid_graph_contract)
3340}
3341
3342fn try_graph_stage_lease(staging_parent: &Path) -> Result<Option<File>, CliError> {
3344 let path = staging_parent.join(GRAPH_STAGE_LEASE_FILE_NAME);
3345 if let Ok(metadata) = fs::symlink_metadata(&path)
3346 && (!metadata.file_type().is_file() || metadata.file_type().is_symlink())
3347 {
3348 return Err(CliError::InvalidInput(format!(
3349 "repository graph staging lease is not a direct file: {}",
3350 normalize_native_path_display(&path)
3351 )));
3352 }
3353 let file = OpenOptions::new()
3354 .read(true)
3355 .write(true)
3356 .create(true)
3357 .truncate(false)
3358 .open(&path)
3359 .map_err(|source| CliError::Io {
3360 path: path.clone(),
3361 source,
3362 })?;
3363 match file.try_lock() {
3364 Ok(()) => Ok(Some(file)),
3365 Err(fs::TryLockError::WouldBlock) => Ok(None),
3366 Err(fs::TryLockError::Error(source)) => Err(CliError::Io { path, source }),
3367 }
3368}
3369
3370pub(super) fn cleanup_abandoned_repository_graph_staging(
3372 store: &AtlasStore,
3373 root: &Path,
3374 control: &IndexWorkControl,
3375) -> Result<(), CliError> {
3376 cleanup_abandoned_graph_staging(root, selected_project(store)?, control)
3377}
3378
3379fn cleanup_abandoned_graph_staging(
3381 root: &Path,
3382 project: ProjectInstanceId,
3383 control: &IndexWorkControl,
3384) -> Result<(), CliError> {
3385 control.check(IndexWorkStage::Publication)?;
3386 let staging_parent = root.join(".projectatlas");
3387 if !staging_parent.is_dir() {
3388 return Ok(());
3389 }
3390 let Some(_lease) = try_graph_stage_lease(&staging_parent)? else {
3391 return Ok(());
3392 };
3393 cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)
3394}
3395
3396fn remove_owned_graph_stage_payload(
3398 stage: &Path,
3399 database_path: &Path,
3400 control: Option<&IndexWorkControl>,
3401) -> Result<(), CliError> {
3402 let entries = fs::read_dir(stage).map_err(|source| CliError::Io {
3403 path: stage.to_path_buf(),
3404 source,
3405 })?;
3406 for entry in entries {
3407 if let Some(control) = control {
3408 control.check(IndexWorkStage::Publication)?;
3409 }
3410 let entry = entry.map_err(|source| CliError::Io {
3411 path: stage.to_path_buf(),
3412 source,
3413 })?;
3414 let path = entry.path();
3415 if path == database_path {
3416 continue;
3417 }
3418 let metadata = fs::symlink_metadata(&path).map_err(|source| CliError::Io {
3419 path: path.clone(),
3420 source,
3421 })?;
3422 let result = if metadata.file_type().is_symlink() {
3423 remove_graph_stage_symlink(&path)
3424 } else if metadata.file_type().is_dir() {
3425 fs::remove_dir_all(&path)
3426 } else {
3427 fs::remove_file(&path)
3428 };
3429 result.map_err(|source| CliError::Io { path, source })?;
3430 }
3431 Ok(())
3432}
3433
3434#[cfg(windows)]
3436fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
3437 fs::remove_dir(path).or_else(|_directory_error| fs::remove_file(path))
3438}
3439
3440#[cfg(not(windows))]
3442fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
3443 fs::remove_file(path)
3444}
3445
3446fn cleanup_abandoned_graph_staging_while_locked(
3448 staging_parent: &Path,
3449 root: &Path,
3450 project: ProjectInstanceId,
3451 control: &IndexWorkControl,
3452) -> Result<(), CliError> {
3453 let entries = fs::read_dir(staging_parent).map_err(|source| CliError::Io {
3454 path: staging_parent.to_path_buf(),
3455 source,
3456 })?;
3457 for entry in entries {
3458 control.check(IndexWorkStage::Publication)?;
3459 let entry = entry.map_err(|source| CliError::Io {
3460 path: staging_parent.to_path_buf(),
3461 source,
3462 })?;
3463 let file_name = entry.file_name();
3464 let Some(file_name) = file_name.to_str() else {
3465 continue;
3466 };
3467 if !file_name.starts_with(GRAPH_STAGE_DIRECTORY_PREFIX)
3468 || file_name.len() == GRAPH_STAGE_DIRECTORY_PREFIX.len()
3469 {
3470 continue;
3471 }
3472 let path = entry.path();
3473 let Ok(metadata) = fs::symlink_metadata(&path) else {
3474 continue;
3475 };
3476 if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
3477 continue;
3478 }
3479 let database_path = path.join(GRAPH_STAGE_DATABASE_FILE_NAME);
3480 let database_metadata = match fs::symlink_metadata(&database_path) {
3481 Ok(metadata) => metadata,
3482 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3483 let _remove_empty_shell = fs::remove_dir(&path);
3484 continue;
3485 }
3486 Err(_) => continue,
3487 };
3488 if !database_metadata.file_type().is_file() || database_metadata.file_type().is_symlink() {
3489 continue;
3490 }
3491 let owned = AtlasStore::repository_graph_staging_belongs_to(&database_path, root, project)
3492 .unwrap_or(false);
3493 if !owned {
3494 continue;
3495 }
3496 control.check(IndexWorkStage::Publication)?;
3497 remove_owned_graph_stage_payload(&path, &database_path, Some(control))?;
3498 control.check(IndexWorkStage::Publication)?;
3499 fs::remove_file(&database_path).map_err(|source| CliError::Io {
3500 path: database_path,
3501 source,
3502 })?;
3503 fs::remove_dir(&path).map_err(|source| CliError::Io { path, source })?;
3504 }
3505 Ok(())
3506}
3507
3508fn finish_projection_in_database_with_documents(
3510 root: &Path,
3511 nodes: &[Node],
3512 project: ProjectInstanceId,
3513 generation: IndexGeneration,
3514 graphs: &[impl Borrow<SymbolGraph>],
3515 document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3516 identity_admission: &GraphIdentityAdmission,
3517 mut entities: EntityProjection,
3518 candidates: &ProjectResolutionRegistry,
3519 scan_policy: &RootScanPolicy,
3520 control: &IndexWorkControl,
3521) -> Result<StagedRepositoryGraph, CliError> {
3522 #[cfg(test)]
3523 let peak_retained_bytes = entities.peak_retained_bytes;
3524 #[cfg(test)]
3525 let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3526 let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3527 let staging_parent = root.join(".projectatlas");
3528 fs::create_dir_all(&staging_parent).map_err(|source| CliError::Io {
3529 path: staging_parent.clone(),
3530 source,
3531 })?;
3532 let lease = try_graph_stage_lease(&staging_parent)?.ok_or_else(|| {
3533 CliError::InvalidInput(
3534 "another repository graph staging operation is active for this project".to_string(),
3535 )
3536 })?;
3537 cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)?;
3538 let directory = TempDirBuilder::new()
3539 .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
3540 .tempdir_in(&staging_parent)
3541 .map_err(|source| CliError::Io {
3542 path: staging_parent,
3543 source,
3544 })?;
3545 let mut database = StagedGraphDatabase {
3546 store: None,
3547 directory: Some(directory),
3548 _lease: lease,
3549 };
3550 let database_path = database
3551 .directory()?
3552 .path()
3553 .join(GRAPH_STAGE_DATABASE_FILE_NAME);
3554 database.store = Some(AtlasStore::create_repository_graph_staging(
3555 &database_path,
3556 root,
3557 project,
3558 )?);
3559 database.store_mut()?.replace_scan(nodes)?;
3560 let mut identity_rejections = identity_admission.rejections.clone();
3561 let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3562 let mut identity_rejection_bytes =
3563 identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3564 .checked_add(identity_rejection_drop_paths_retained_bytes(
3565 &identity_admission.rejection_details_dropped_by_path,
3566 )?)
3567 .ok_or_else(|| {
3568 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3569 })?;
3570 let mut rejection_details_dropped_by_path =
3571 identity_admission.rejection_details_dropped_by_path.clone();
3572 {
3573 let mut staging = database
3574 .store_mut()?
3575 .begin_repository_graph_staging(project, generation)?;
3576 let mut entity_batch = Vec::with_capacity(GRAPH_STAGE_ENTITY_BATCH_SIZE);
3577 for entity in entities.entity_by_digest.values() {
3578 entity_batch.push(entity);
3579 if entity_batch.len() == GRAPH_STAGE_ENTITY_BATCH_SIZE {
3580 control.check(IndexWorkStage::Publication)?;
3581 staging.append_entity_refs(&entity_batch)?;
3582 entity_batch.clear();
3583 }
3584 }
3585 if !entity_batch.is_empty() {
3586 staging.append_entity_refs(&entity_batch)?;
3587 entity_batch.clear();
3588 }
3589 staging.append_batch(&[], &[], &[], &[], &entities.entity_exports, &[])?;
3590 let mut staged_rows = ProjectedGraphRows::default();
3591 for graph in graphs {
3592 let graph = graph.borrow();
3593 let rows = project_graph_rows(
3594 project,
3595 generation,
3596 graph,
3597 document_facts.get(&graph.path).map(Cow::as_ref),
3598 &document_index,
3599 &mut entities,
3600 candidates,
3601 identity_admission,
3602 control,
3603 )?;
3604 staged_rows.append(rows);
3605 identity_rejection_bytes = identity_rejection_bytes
3606 .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3607 &mut identity_rejections,
3608 &mut identity_rejection_keys,
3609 staged_rows.identity_rejections.iter().cloned(),
3610 &mut rejection_details_dropped_by_path,
3611 )?)
3612 .ok_or_else(|| {
3613 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3614 })?;
3615 mark_identity_rejection_coverage_limits(
3616 &mut staged_rows.coverage,
3617 &rejection_details_dropped_by_path,
3618 )?;
3619 staged_rows.identity_rejections.clear();
3620 if staged_rows.row_count() < GRAPH_STAGE_ROW_BATCH_SIZE {
3621 continue;
3622 }
3623 staging.append_batch(
3624 &staged_rows.external_entities,
3625 &staged_rows.relations,
3626 &staged_rows.occurrences,
3627 &staged_rows.coverage,
3628 &[],
3629 &staged_rows.relation_dependencies,
3630 )?;
3631 if !staged_rows.document_unresolved_reasons.is_empty() {
3632 staging.set_document_unresolved_reasons_controlled(
3633 &staged_rows.document_unresolved_reasons,
3634 control,
3635 )?;
3636 }
3637 staged_rows.clear();
3638 }
3639 if !staged_rows.is_empty() {
3640 staging.append_batch(
3641 &staged_rows.external_entities,
3642 &staged_rows.relations,
3643 &staged_rows.occurrences,
3644 &staged_rows.coverage,
3645 &[],
3646 &staged_rows.relation_dependencies,
3647 )?;
3648 if !staged_rows.document_unresolved_reasons.is_empty() {
3649 staging.set_document_unresolved_reasons_controlled(
3650 &staged_rows.document_unresolved_reasons,
3651 control,
3652 )?;
3653 }
3654 }
3655 staging.complete()?;
3656 }
3657 database.store()?.checkpoint_repository_graph_staging()?;
3658 database.store()?.begin_index_read_snapshot()?;
3659 let _staged_generation = database.store()?.repository_graph_generation()?;
3660 let document_target_states = document_index.observed_absent_states();
3661 let retained_bytes = database_path.as_os_str().as_encoded_bytes().len() as u64
3662 + document_target_states
3663 .iter()
3664 .map(|(path, _reason)| path.len() as u64 + STAGED_GRAPH_ROW_BYTES)
3665 .sum::<u64>()
3666 + identity_rejection_bytes;
3667 Ok(StagedRepositoryGraph {
3668 project,
3669 mutation: RepositoryGraphMutation::Full,
3670 entities: Vec::new(),
3671 relations: Vec::new(),
3672 occurrences: Vec::new(),
3673 coverage: Vec::new(),
3674 entity_exports: Vec::new(),
3675 relation_dependencies: Vec::new(),
3676 document_unresolved_reasons: Vec::new(),
3677 identity_rejections,
3678 #[cfg(test)]
3679 resolution_derivations: identity_admission.resolution_derivations.clone(),
3680 #[cfg(test)]
3681 peak_retained_bytes,
3682 #[cfg(test)]
3683 projection_removals_before_entities,
3684 scan_policy: scan_policy.clone(),
3685 document_target_states,
3686 database: Some(database),
3687 source_recovery: SourceRecoveryWork::default(),
3688 retained_bytes,
3689 })
3690}
3691
3692#[cfg(test)]
3694fn finish_projection_in_database(
3695 root: &Path,
3696 nodes: &[Node],
3697 project: ProjectInstanceId,
3698 generation: IndexGeneration,
3699 graphs: &[impl Borrow<SymbolGraph>],
3700 entities: EntityProjection,
3701 candidates: &ProjectResolutionRegistry,
3702 scan_policy: &RootScanPolicy,
3703 control: &IndexWorkControl,
3704) -> Result<StagedRepositoryGraph, CliError> {
3705 finish_projection_in_database_with_documents(
3706 root,
3707 nodes,
3708 project,
3709 generation,
3710 graphs,
3711 &BTreeMap::new(),
3712 &GraphIdentityAdmission::default(),
3713 entities,
3714 candidates,
3715 scan_policy,
3716 control,
3717 )
3718}
3719
3720fn finish_projection_with_documents(
3722 project: ProjectInstanceId,
3723 generation: IndexGeneration,
3724 mutation: RepositoryGraphMutation,
3725 graphs: &[impl Borrow<SymbolGraph>],
3726 root: &Path,
3727 nodes: &[Node],
3728 document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3729 identity_admission: &GraphIdentityAdmission,
3730 mut entities: EntityProjection,
3731 candidates: &ProjectResolutionRegistry,
3732 scan_policy: &RootScanPolicy,
3733 control: &IndexWorkControl,
3734) -> Result<StagedRepositoryGraph, CliError> {
3735 #[cfg(test)]
3736 let peak_retained_bytes = entities.peak_retained_bytes;
3737 #[cfg(test)]
3738 let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3739 let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3740 let mut relations_by_digest = BTreeMap::new();
3741 let mut occurrences = Vec::new();
3742 let mut relation_dependencies = Vec::new();
3743 let mut coverage = Vec::new();
3744 let mut document_unresolved_reasons = BTreeMap::new();
3745 let mut identity_rejections = identity_admission.rejections.clone();
3746 let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3747 let mut identity_rejection_bytes =
3748 identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3749 .checked_add(identity_rejection_drop_paths_retained_bytes(
3750 &identity_admission.rejection_details_dropped_by_path,
3751 )?)
3752 .ok_or_else(|| {
3753 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3754 })?;
3755 let mut rejection_details_dropped_by_path =
3756 identity_admission.rejection_details_dropped_by_path.clone();
3757 for graph in graphs {
3758 let graph = graph.borrow();
3759 let rows = project_graph_rows(
3760 project,
3761 generation,
3762 graph,
3763 document_facts.get(&graph.path).map(Cow::as_ref),
3764 &document_index,
3765 &mut entities,
3766 candidates,
3767 identity_admission,
3768 control,
3769 )?;
3770 for (relation_index, relation) in rows.relations.into_iter().enumerate() {
3771 insert_relation(
3772 &mut relations_by_digest,
3773 relation,
3774 &graph.path,
3775 "projected",
3776 relation_index,
3777 )?;
3778 }
3779 occurrences.extend(rows.occurrences);
3780 relation_dependencies.extend(rows.relation_dependencies);
3781 for (key, reason) in rows.document_unresolved_reasons {
3782 insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
3783 }
3784 coverage.extend(rows.coverage);
3785 identity_rejection_bytes = identity_rejection_bytes
3786 .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3787 &mut identity_rejections,
3788 &mut identity_rejection_keys,
3789 rows.identity_rejections,
3790 &mut rejection_details_dropped_by_path,
3791 )?)
3792 .ok_or_else(|| {
3793 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3794 })?;
3795 mark_identity_rejection_coverage_limits(&mut coverage, &rejection_details_dropped_by_path)?;
3796 entities.retained_bytes = rows
3797 .external_entities
3798 .iter()
3799 .fold(entities.retained_bytes, |bytes, entity| {
3800 bytes.saturating_add(entity_retained_bytes(entity))
3801 });
3802 for entity in rows.external_entities {
3803 insert_entity(&mut entities.entity_by_digest, entity)?;
3804 }
3805 }
3806 let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
3807 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
3808 occurrences.sort_by(|left, right| {
3809 left.relation()
3810 .digest()
3811 .cmp(right.relation().digest())
3812 .then_with(|| left.file().as_str().cmp(right.file().as_str()))
3813 .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
3814 .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
3815 });
3816 occurrences.dedup();
3817 sort_dedup_dependencies(&mut relation_dependencies);
3818 enforce_key_binding_limit(relation_dependencies.len())?;
3819 let added_rows = relations
3820 .len()
3821 .saturating_add(occurrences.len())
3822 .saturating_add(coverage.len())
3823 .saturating_add(relation_dependencies.len());
3824 let added_rows = added_rows.saturating_add(document_unresolved_reasons.len());
3825 let document_target_states = document_index.observed_absent_states();
3826 entities.retained_bytes = entities
3827 .retained_bytes
3828 .saturating_add(
3829 STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(added_rows).unwrap_or(u64::MAX)),
3830 )
3831 .saturating_add(
3832 document_target_states
3833 .iter()
3834 .fold(0_u64, |bytes, (path, _reason)| {
3835 bytes
3836 .saturating_add(STAGED_GRAPH_ROW_BYTES)
3837 .saturating_add(path.len() as u64)
3838 }),
3839 );
3840 Ok(StagedRepositoryGraph {
3841 project,
3842 mutation,
3843 entities: entities.entity_by_digest.into_values().collect(),
3844 relations,
3845 occurrences,
3846 coverage,
3847 entity_exports: entities.entity_exports,
3848 relation_dependencies,
3849 document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
3850 identity_rejections,
3851 #[cfg(test)]
3852 resolution_derivations: identity_admission.resolution_derivations.clone(),
3853 #[cfg(test)]
3854 peak_retained_bytes,
3855 #[cfg(test)]
3856 projection_removals_before_entities,
3857 scan_policy: scan_policy.clone(),
3858 document_target_states,
3859 database: None,
3860 source_recovery: SourceRecoveryWork::default(),
3861 retained_bytes: entities
3862 .retained_bytes
3863 .saturating_add(identity_rejection_bytes),
3864 })
3865}
3866
3867#[cfg(test)]
3869fn finish_projection(
3870 project: ProjectInstanceId,
3871 generation: IndexGeneration,
3872 mutation: RepositoryGraphMutation,
3873 graphs: &[impl Borrow<SymbolGraph>],
3874 entities: EntityProjection,
3875 candidates: &ProjectResolutionRegistry,
3876 control: &IndexWorkControl,
3877) -> Result<StagedRepositoryGraph, CliError> {
3878 let scan_policy = RootScanPolicy::discover(Path::new("."), &ScanOptions::default(), control)
3879 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
3880 finish_projection_with_documents(
3881 project,
3882 generation,
3883 mutation,
3884 graphs,
3885 Path::new("."),
3886 &[],
3887 &BTreeMap::new(),
3888 &GraphIdentityAdmission::default(),
3889 entities,
3890 candidates,
3891 &scan_policy,
3892 control,
3893 )
3894}
3895
3896#[derive(Default)]
3898struct ProjectedGraphRows {
3899 relations: Vec<LogicalRelation>,
3901 occurrences: Vec<RelationOccurrence>,
3903 coverage: Vec<CoverageRecord>,
3905 external_entities: Vec<GraphEntity>,
3907 relation_dependencies: Vec<RelationDependencyKey>,
3909 document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3911 identity_rejections: Vec<GraphIdentityRejection>,
3913}
3914
3915impl ProjectedGraphRows {
3916 fn append(&mut self, rows: Self) {
3918 self.relations.extend(rows.relations);
3919 self.occurrences.extend(rows.occurrences);
3920 self.coverage.extend(rows.coverage);
3921 self.external_entities.extend(rows.external_entities);
3922 self.relation_dependencies
3923 .extend(rows.relation_dependencies);
3924 self.document_unresolved_reasons
3925 .extend(rows.document_unresolved_reasons);
3926 self.identity_rejections.extend(rows.identity_rejections);
3927 }
3928
3929 fn row_count(&self) -> usize {
3931 self.relations
3932 .len()
3933 .saturating_add(self.occurrences.len())
3934 .saturating_add(self.coverage.len())
3935 .saturating_add(self.external_entities.len())
3936 .saturating_add(self.relation_dependencies.len())
3937 .saturating_add(self.document_unresolved_reasons.len())
3938 }
3939
3940 fn is_empty(&self) -> bool {
3942 self.row_count() == 0
3943 }
3944
3945 fn clear(&mut self) {
3947 self.relations.clear();
3948 self.occurrences.clear();
3949 self.coverage.clear();
3950 self.external_entities.clear();
3951 self.relation_dependencies.clear();
3952 self.document_unresolved_reasons.clear();
3953 self.identity_rejections.clear();
3954 }
3955}
3956
3957struct DocumentResolutionIndex<'a> {
3959 root: PathBuf,
3961 canonical_root: PathBuf,
3963 kinds_by_path: HashMap<&'a str, NodeKind>,
3965 casefold_path_counts: HashMap<String, u32>,
3967 scan_policy: &'a RootScanPolicy,
3969 absent_states: RefCell<BTreeMap<String, DocumentTargetUnresolvedReason>>,
3971}
3972
3973impl<'a> DocumentResolutionIndex<'a> {
3974 fn new(
3976 root: &Path,
3977 nodes: &'a [Node],
3978 scan_policy: &'a RootScanPolicy,
3979 ) -> Result<Self, CliError> {
3980 let canonical_root = fs::canonicalize(root).map_err(|source| CliError::Io {
3981 path: root.to_path_buf(),
3982 source,
3983 })?;
3984 let kinds_by_path = nodes
3985 .iter()
3986 .map(|node| (node.path.as_str(), node.kind))
3987 .collect::<HashMap<_, _>>();
3988 let mut casefold_path_counts = HashMap::new();
3989 for node in nodes {
3990 let count = casefold_path_counts
3991 .entry(node.path.to_lowercase())
3992 .or_insert(0_u32);
3993 *count = count.saturating_add(1);
3994 }
3995 let retained_bytes = nodes.iter().fold(0_u64, |bytes, node| {
3996 bytes
3997 .saturating_add(STAGED_GRAPH_ROW_BYTES.saturating_mul(2))
3998 .saturating_add(node.path.len() as u64)
3999 });
4000 enforce_resolution_registry_budget(retained_bytes)?;
4001 Ok(Self {
4002 root: root.to_path_buf(),
4003 canonical_root,
4004 kinds_by_path,
4005 casefold_path_counts,
4006 scan_policy,
4007 absent_states: RefCell::new(BTreeMap::new()),
4008 })
4009 }
4010
4011 fn unresolved_reason(
4013 &self,
4014 path: &str,
4015 ) -> Result<Option<DocumentTargetUnresolvedReason>, CliError> {
4016 let casefold_count = self
4017 .casefold_path_counts
4018 .get(&path.to_lowercase())
4019 .copied()
4020 .unwrap_or(0);
4021 if casefold_count > 1 {
4022 return Ok(Some(DocumentTargetUnresolvedReason::CaseConflict));
4023 }
4024 Ok(match self.kinds_by_path.get(path).copied() {
4025 Some(NodeKind::File) => None,
4026 Some(NodeKind::Folder) => Some(DocumentTargetUnresolvedReason::Unsupported),
4027 None if casefold_count == 1 => Some(DocumentTargetUnresolvedReason::CaseConflict),
4028 None => Some(self.absent_reason(path)?),
4029 })
4030 }
4031
4032 fn absent_reason(&self, path: &str) -> Result<DocumentTargetUnresolvedReason, CliError> {
4034 if let Some(reason) = self.absent_states.borrow().get(path).copied() {
4035 return Ok(reason);
4036 }
4037 let native = self.root.join(Path::new(path));
4038 let reason = if self
4039 .scan_policy
4040 .excludes_path(&native)
4041 .map_err(|source| CliError::InvalidInput(source.to_string()))?
4042 {
4043 DocumentTargetUnresolvedReason::Ignored
4044 } else {
4045 match fs::symlink_metadata(&native) {
4046 Ok(metadata) if metadata.file_type().is_dir() => {
4047 DocumentTargetUnresolvedReason::Unsupported
4048 }
4049 Ok(_metadata) => match fs::canonicalize(&native) {
4050 Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
4051 DocumentTargetUnresolvedReason::OutsideRoot
4052 }
4053 Ok(_canonical) => DocumentTargetUnresolvedReason::Unsupported,
4054 Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
4055 },
4056 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
4057 self.missing_or_escaping_ancestor(&native)
4058 }
4059 Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
4060 }
4061 };
4062 self.absent_states
4063 .borrow_mut()
4064 .insert(path.to_string(), reason);
4065 Ok(reason)
4066 }
4067
4068 fn observed_absent_states(&self) -> Vec<(String, DocumentTargetUnresolvedReason)> {
4070 self.absent_states
4071 .borrow()
4072 .iter()
4073 .map(|(path, reason)| (path.clone(), *reason))
4074 .collect()
4075 }
4076
4077 fn missing_or_escaping_ancestor(&self, native: &Path) -> DocumentTargetUnresolvedReason {
4079 let mut ancestor = native.parent();
4080 while let Some(path) = ancestor {
4081 if !path.starts_with(&self.root) {
4082 break;
4083 }
4084 match fs::canonicalize(path) {
4085 Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
4086 return DocumentTargetUnresolvedReason::OutsideRoot;
4087 }
4088 Ok(_canonical) => return DocumentTargetUnresolvedReason::Missing,
4089 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
4090 ancestor = path.parent();
4091 }
4092 Err(_source) => return DocumentTargetUnresolvedReason::Unsupported,
4093 }
4094 }
4095 DocumentTargetUnresolvedReason::Missing
4096 }
4097}
4098
4099struct DocumentResolutionOutcome {
4101 resolution: RelationResolution,
4103 unresolved_reason: Option<DocumentTargetUnresolvedReason>,
4105 dependencies: Vec<CanonicalResolutionKey>,
4107}
4108
4109#[allow(clippy::too_many_arguments)]
4111fn project_document_rows(
4112 project: ProjectInstanceId,
4113 generation: IndexGeneration,
4114 graph: &SymbolGraph,
4115 facts: &MarkdownFacts,
4116 owners: &GraphOwners,
4117 document_index: &DocumentResolutionIndex<'_>,
4118 candidates: &ProjectResolutionRegistry,
4119 staged_entities: &BTreeMap<String, GraphEntity>,
4120 control: &IndexWorkControl,
4121) -> Result<ProjectedGraphRows, CliError> {
4122 let file_source = staged_entities.get(&owners.file_digest).ok_or_else(|| {
4123 CliError::InvalidInput("document graph file owner was not staged".to_string())
4124 })?;
4125 let completeness = match facts.coverage.completeness {
4126 MarkdownFactCompleteness::Complete => Completeness::Complete,
4127 MarkdownFactCompleteness::Partial => Completeness::Partial,
4128 };
4129 let mut relations_by_digest = BTreeMap::new();
4130 let mut occurrences = Vec::new();
4131 let mut relation_dependencies = Vec::new();
4132 let mut unresolved_reasons = BTreeMap::new();
4133 for (candidate_index, candidate) in facts.link_candidates.iter().enumerate() {
4134 check_graph_work(control, candidate_index)?;
4135 if normalize_document_target(&graph.path, &candidate.selector)
4136 .is_ok_and(|target| target.path == graph.path && target.fragment.is_none())
4137 {
4138 continue;
4139 }
4140 let source = file_source;
4141 let outcome = resolve_document_candidate(
4142 project,
4143 &graph.path,
4144 &candidate.selector,
4145 document_index,
4146 candidates,
4147 staged_entities,
4148 control,
4149 )?;
4150 if matches!(
4151 &outcome.resolution,
4152 RelationResolution::Resolved { target, .. } if target == source.key()
4153 ) {
4154 continue;
4155 }
4156 let relation = LogicalRelation::new(
4157 source,
4158 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
4159 outcome.resolution,
4160 ConfidenceClass::High,
4161 completeness,
4162 generation,
4163 )
4164 .map_err(invalid_graph_contract)?;
4165 insert_relation(
4166 &mut relations_by_digest,
4167 relation.clone(),
4168 &graph.path,
4169 "document",
4170 candidate_index,
4171 )?;
4172 if let Some(reason) = outcome.unresolved_reason {
4173 insert_document_unresolved_reason(
4174 &mut unresolved_reasons,
4175 relation.key().clone(),
4176 reason,
4177 )?;
4178 }
4179 let start_line = u32::try_from(candidate.source.line_start).map_err(|error| {
4180 CliError::InvalidInput(format!("document source line exceeds graph range: {error}"))
4181 })?;
4182 let end_line = u32::try_from(candidate.source.line_end).map_err(|error| {
4183 CliError::InvalidInput(format!("document end line exceeds graph range: {error}"))
4184 })?;
4185 let start_column = u32::try_from(candidate.source.column_start).map_err(|error| {
4186 CliError::InvalidInput(format!(
4187 "document source column exceeds graph range: {error}"
4188 ))
4189 })?;
4190 let end_column = u32::try_from(candidate.source.column_end).map_err(|error| {
4191 CliError::InvalidInput(format!("document end column exceeds graph range: {error}"))
4192 })?;
4193 occurrences.push(
4194 RelationOccurrence::new(
4195 &relation,
4196 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4197 SourceSpan::new(start_line, start_column, end_line, end_column)
4198 .map_err(invalid_graph_contract)?,
4199 generation,
4200 )
4201 .map_err(invalid_graph_contract)?,
4202 );
4203 for key in outcome.dependencies {
4204 relation_dependencies.push(
4205 RelationDependencyKey::new(relation.key().clone(), key)
4206 .map_err(invalid_graph_contract)?,
4207 );
4208 }
4209 }
4210 Ok(ProjectedGraphRows {
4211 relations: relations_by_digest.into_values().collect(),
4212 occurrences,
4213 coverage: vec![document_coverage(&graph.path, facts, generation)?],
4214 external_entities: Vec::new(),
4215 relation_dependencies,
4216 document_unresolved_reasons: unresolved_reasons.into_values().collect(),
4217 identity_rejections: Vec::new(),
4218 })
4219}
4220
4221#[allow(clippy::too_many_arguments)]
4223fn resolve_document_candidate(
4224 project: ProjectInstanceId,
4225 document_path: &str,
4226 selector: &str,
4227 document_index: &DocumentResolutionIndex<'_>,
4228 candidates: &ProjectResolutionRegistry,
4229 staged_entities: &BTreeMap<String, GraphEntity>,
4230 control: &IndexWorkControl,
4231) -> Result<DocumentResolutionOutcome, CliError> {
4232 let target = match normalize_document_target(document_path, selector) {
4233 Ok(target) => target,
4234 Err(reason) => {
4235 return unresolved_document_outcome(selector, reason, Vec::new());
4236 }
4237 };
4238 let file_key = document_file_resolution_key(project, &target.path)?;
4239 let mut dependencies = vec![
4240 file_key.clone(),
4241 document_casefold_resolution_key(project, &target.path)?,
4242 ];
4243 let heading_key = target
4244 .fragment
4245 .as_deref()
4246 .map(|fragment| document_heading_resolution_key(project, &target.path, fragment))
4247 .transpose()?;
4248 if let Some(key) = heading_key.as_ref() {
4249 dependencies.push(key.clone());
4250 }
4251 if let Some(reason) = document_index.unresolved_reason(&target.path)? {
4252 return unresolved_document_outcome(selector, reason, dependencies);
4253 }
4254 if let Some(key) = heading_key.as_ref() {
4255 let matches = registry_resolution_matches(
4256 std::slice::from_ref(key),
4257 ResolutionCandidateKind::Any,
4258 candidates,
4259 staged_entities,
4260 control,
4261 )?;
4262 match matches.count {
4263 0 => {
4264 return unresolved_document_outcome(
4265 selector,
4266 DocumentTargetUnresolvedReason::Missing,
4267 dependencies,
4268 );
4269 }
4270 1 => {
4271 let target = matches.first.ok_or_else(|| {
4272 CliError::InvalidInput("resolved document heading disappeared".to_string())
4273 })?;
4274 return Ok(DocumentResolutionOutcome {
4275 resolution: RelationResolution::resolved(target)
4276 .map_err(invalid_graph_contract)?,
4277 unresolved_reason: None,
4278 dependencies,
4279 });
4280 }
4281 _ => {
4282 return unresolved_document_outcome(
4283 selector,
4284 DocumentTargetUnresolvedReason::CaseConflict,
4285 dependencies,
4286 );
4287 }
4288 }
4289 }
4290 let matches = registry_resolution_matches(
4291 std::slice::from_ref(&file_key),
4292 ResolutionCandidateKind::Any,
4293 candidates,
4294 staged_entities,
4295 control,
4296 )?;
4297 match matches.count {
4298 1 => Ok(DocumentResolutionOutcome {
4299 resolution: RelationResolution::resolved(matches.first.ok_or_else(|| {
4300 CliError::InvalidInput("resolved document file disappeared".to_string())
4301 })?)
4302 .map_err(invalid_graph_contract)?,
4303 unresolved_reason: None,
4304 dependencies,
4305 }),
4306 0 => Err(CliError::InvalidInput(format!(
4307 "admitted document target lacked its exact graph identity: {}",
4308 target.path
4309 ))),
4310 _ => unresolved_document_outcome(
4311 selector,
4312 DocumentTargetUnresolvedReason::CaseConflict,
4313 dependencies,
4314 ),
4315 }
4316}
4317
4318fn unresolved_document_outcome(
4320 selector: &str,
4321 reason: DocumentTargetUnresolvedReason,
4322 dependencies: Vec<CanonicalResolutionKey>,
4323) -> Result<DocumentResolutionOutcome, CliError> {
4324 Ok(DocumentResolutionOutcome {
4325 resolution: RelationResolution::Unresolved {
4326 reference: GraphIdentityText::new(selector).map_err(invalid_graph_contract)?,
4327 },
4328 unresolved_reason: Some(reason),
4329 dependencies,
4330 })
4331}
4332
4333fn document_coverage(
4335 path: &str,
4336 facts: &MarkdownFacts,
4337 generation: IndexGeneration,
4338) -> Result<CoverageRecord, CliError> {
4339 let scope = CoverageScope::Path {
4340 path: RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?,
4341 };
4342 let covered = u64::try_from(facts.link_candidates.len()).unwrap_or(u64::MAX);
4343 let reached_limit = facts.coverage.limits.first().map(|limit| match limit {
4344 MarkdownFactLimit::HeadingCount | MarkdownFactLimit::CandidateCount => GraphLimitKind::Rows,
4345 MarkdownFactLimit::InputBytes
4346 | MarkdownFactLimit::LabelBytes
4347 | MarkdownFactLimit::SelectorBytes
4348 | MarkdownFactLimit::EvidenceBytes => GraphLimitKind::IntermediateBytes,
4349 });
4350 let relation = Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
4351 match facts.coverage.completeness {
4352 MarkdownFactCompleteness::Complete if covered == 0 => CoverageRecord::new(
4353 scope,
4354 relation,
4355 CoverageState::NoCandidates,
4356 0,
4357 0,
4358 generation,
4359 None,
4360 None,
4361 )
4362 .map_err(invalid_graph_contract),
4363 MarkdownFactCompleteness::Complete => CoverageRecord::new(
4364 scope,
4365 relation,
4366 CoverageState::Complete,
4367 covered,
4368 0,
4369 generation,
4370 None,
4371 None,
4372 )
4373 .map_err(invalid_graph_contract),
4374 MarkdownFactCompleteness::Partial if covered > 0 => CoverageRecord::new(
4375 scope,
4376 relation,
4377 CoverageState::Partial,
4378 covered,
4379 1,
4380 generation,
4381 Some(
4382 GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
4383 .map_err(invalid_graph_contract)?,
4384 ),
4385 reached_limit,
4386 )
4387 .map_err(invalid_graph_contract),
4388 MarkdownFactCompleteness::Partial => CoverageRecord::new(
4389 scope,
4390 relation,
4391 if facts
4392 .coverage
4393 .limits
4394 .contains(&MarkdownFactLimit::InputBytes)
4395 {
4396 CoverageState::Oversized
4397 } else {
4398 CoverageState::Failed
4399 },
4400 0,
4401 1,
4402 generation,
4403 Some(
4404 GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
4405 .map_err(invalid_graph_contract)?,
4406 ),
4407 reached_limit,
4408 )
4409 .map_err(invalid_graph_contract),
4410 }
4411}
4412
4413fn insert_document_unresolved_reason(
4415 reasons: &mut BTreeMap<String, (LogicalRelationKey, DocumentTargetUnresolvedReason)>,
4416 key: LogicalRelationKey,
4417 reason: DocumentTargetUnresolvedReason,
4418) -> Result<(), CliError> {
4419 let digest = key.digest().to_string();
4420 match reasons.entry(digest) {
4421 Entry::Vacant(entry) => {
4422 entry.insert((key, reason));
4423 Ok(())
4424 }
4425 Entry::Occupied(entry) if entry.get() == &(key, reason) => Ok(()),
4426 Entry::Occupied(_entry) => Err(CliError::InvalidInput(
4427 "document relation retained conflicting unresolved reasons".to_string(),
4428 )),
4429 }
4430}
4431
4432fn project_graph_rows(
4434 project: ProjectInstanceId,
4435 generation: IndexGeneration,
4436 graph: &SymbolGraph,
4437 document_facts: Option<&MarkdownFacts>,
4438 document_index: &DocumentResolutionIndex<'_>,
4439 entities: &mut EntityProjection,
4440 candidates: &ProjectResolutionRegistry,
4441 identity_admission: &GraphIdentityAdmission,
4442 control: &IndexWorkControl,
4443) -> Result<ProjectedGraphRows, CliError> {
4444 control.check(IndexWorkStage::SymbolParsing)?;
4445 let owners = entities
4446 .owners_by_graph
4447 .remove(&graph.path)
4448 .ok_or_else(|| CliError::InvalidInput("graph owners were not staged".to_string()))?;
4449 let resolution_keys = entities
4450 .keys_by_graph
4451 .remove(&graph.path)
4452 .ok_or_else(|| CliError::InvalidInput("graph keys were not staged".to_string()))?;
4453 let call_bindings = entities
4454 .call_bindings
4455 .remove(&graph.path)
4456 .unwrap_or_default();
4457 let symbol_index = GraphSymbolIndex::new(graph, control)?
4458 .with_source_calls(resolution_keys.source_calls(), control)?
4459 .with_rust_toolchain_imports(resolution_keys.rust_toolchain_imports());
4460 let keys_by_relation = resolution_keys
4461 .relation_keys()
4462 .iter()
4463 .map(|entry| (entry.relation_index(), entry.keys()))
4464 .collect::<BTreeMap<_, _>>();
4465 let mut relations_by_digest = BTreeMap::new();
4466 let mut occurrences = Vec::new();
4467 let mut relation_dependencies = Vec::new();
4468 let mut coverage = Vec::new();
4469 let mut external_entities = BTreeMap::new();
4470 let mut document_unresolved_reasons = BTreeMap::new();
4471 for (relation_index, source_relation) in graph.relations.iter().enumerate() {
4472 control.check(IndexWorkStage::SymbolParsing)?;
4473 let source = relation_source(
4474 &owners,
4475 &entities.entity_by_digest,
4476 graph,
4477 &symbol_index,
4478 source_relation,
4479 Some(relation_index),
4480 control,
4481 )?;
4482 let dependency_keys = keys_by_relation
4483 .get(&relation_index)
4484 .copied()
4485 .unwrap_or(&[]);
4486 let binding = call_bindings.get(&relation_index);
4487 let resolution = if let Some(binding) = binding {
4488 if let Some(target) = binding.external_target() {
4489 insert_entity(&mut external_entities, target.clone())?;
4490 }
4491 binding.resolution(source_relation)?
4492 } else {
4493 relation_resolution(
4494 project,
4495 generation,
4496 source_relation,
4497 Some(relation_index),
4498 &owners,
4499 graph,
4500 &symbol_index,
4501 dependency_keys,
4502 candidates,
4503 &entities.entity_by_digest,
4504 &mut external_entities,
4505 control,
4506 )?
4507 };
4508 let relation = LogicalRelation::new(
4509 source,
4510 GraphRelationKind::from_legacy(source_relation.kind),
4511 resolution,
4512 relation_confidence(source_relation.parser),
4513 relation_completeness(source_relation.parser),
4514 generation,
4515 )
4516 .map_err(invalid_graph_contract)?;
4517 insert_relation(
4518 &mut relations_by_digest,
4519 relation.clone(),
4520 &graph.path,
4521 "logical",
4522 relation_index,
4523 )?;
4524 let occurrence_span = if let Some(span) = resolution_keys.source_span(relation_index) {
4525 span
4526 } else {
4527 let line = u32::try_from(source_relation.line).map_err(|error| {
4528 CliError::InvalidInput(format!("relation source line exceeds graph range: {error}"))
4529 })?;
4530 let end_column =
4531 u32::try_from(source_relation.context.chars().count()).map_err(|error| {
4532 CliError::InvalidInput(format!(
4533 "relation source context exceeds graph range: {error}"
4534 ))
4535 })?;
4536 SourceSpan::new(line.max(1), 0, line.max(1), end_column)
4537 .map_err(invalid_graph_contract)?
4538 };
4539 occurrences.push(
4540 RelationOccurrence::new(
4541 &relation,
4542 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4543 occurrence_span,
4544 generation,
4545 )
4546 .map_err(invalid_graph_contract)?,
4547 );
4548 for key in dependency_keys.iter().chain(
4549 binding
4550 .into_iter()
4551 .flat_map(|binding| binding.dependencies())
4552 .filter(|key| dependency_keys.binary_search(key).is_err()),
4553 ) {
4554 relation_dependencies.push(
4555 RelationDependencyKey::new(relation.key().clone(), key.clone())
4556 .map_err(invalid_graph_contract)?,
4557 );
4558 }
4559 }
4560 let mut derived_identity_admission = GraphIdentityAdmission::default();
4561 let mut admitted_derived_facts = Vec::new();
4562 for (derived_index, fact) in derived_relation_facts(graph, &keys_by_relation)
4563 .into_iter()
4564 .enumerate()
4565 {
4566 let mut failures = Vec::new();
4567 record_identity_failure(
4568 &mut failures,
4569 GraphIdentityField::RelationSource,
4570 &fact.relation.source_name,
4571 );
4572 record_identity_failure(
4573 &mut failures,
4574 GraphIdentityField::RelationTarget,
4575 &fact.relation.target_name,
4576 );
4577 if failures.is_empty() {
4578 admitted_derived_facts.push(fact);
4579 } else {
4580 derived_identity_admission.record(
4581 &graph.path,
4582 IdentitySpan {
4583 start_line: fact.relation.line.max(1),
4584 start_column: 0,
4585 end_line: fact.relation.line.max(1),
4586 end_column: 0,
4587 },
4588 fact.relation.parser,
4589 parser_fact_index(DERIVED_RELATION_FACT_INDEX_NAMESPACE, derived_index),
4590 &failures,
4591 control,
4592 )?;
4593 }
4594 }
4595 for (fact_index, fact) in admitted_derived_facts.into_iter().enumerate() {
4596 control.check(IndexWorkStage::SymbolParsing)?;
4597 let source = relation_source(
4598 &owners,
4599 &entities.entity_by_digest,
4600 graph,
4601 &symbol_index,
4602 &fact.relation,
4603 None,
4604 control,
4605 )?;
4606 let resolution = derived_relation_resolution(
4607 project,
4608 generation,
4609 &fact,
4610 &owners,
4611 graph,
4612 &symbol_index,
4613 candidates,
4614 &entities.entity_by_digest,
4615 &mut external_entities,
4616 control,
4617 )?;
4618 let relation = LogicalRelation::new(
4619 source,
4620 GraphRelationKind::Extended(fact.kind),
4621 resolution,
4622 relation_confidence(fact.relation.parser),
4623 relation_completeness(fact.relation.parser),
4624 generation,
4625 )
4626 .map_err(invalid_graph_contract)?;
4627 insert_relation(
4628 &mut relations_by_digest,
4629 relation.clone(),
4630 &graph.path,
4631 "derived",
4632 fact_index,
4633 )?;
4634 let line = u32::try_from(fact.relation.line).map_err(|error| {
4635 CliError::InvalidInput(format!(
4636 "derived relation source line exceeds graph range: {error}"
4637 ))
4638 })?;
4639 let end_column = u32::try_from(fact.relation.context.chars().count()).map_err(|error| {
4640 CliError::InvalidInput(format!(
4641 "derived relation source context exceeds graph range: {error}"
4642 ))
4643 })?;
4644 occurrences.push(
4645 RelationOccurrence::new(
4646 &relation,
4647 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
4648 SourceSpan::new(line.max(1), 0, line.max(1), end_column)
4649 .map_err(invalid_graph_contract)?,
4650 generation,
4651 )
4652 .map_err(invalid_graph_contract)?,
4653 );
4654 if let DerivedRelationTarget::Parser { keys } = fact.target {
4655 for key in keys {
4656 relation_dependencies.push(
4657 RelationDependencyKey::new(relation.key().clone(), key)
4658 .map_err(invalid_graph_contract)?,
4659 );
4660 }
4661 }
4662 }
4663 if let Some(facts) = document_facts {
4664 let rows = project_document_rows(
4665 project,
4666 generation,
4667 graph,
4668 facts,
4669 &owners,
4670 document_index,
4671 candidates,
4672 &entities.entity_by_digest,
4673 control,
4674 )?;
4675 for (relation_index, relation) in rows.relations.into_iter().enumerate() {
4676 insert_relation(
4677 &mut relations_by_digest,
4678 relation,
4679 &graph.path,
4680 "document",
4681 relation_index,
4682 )?;
4683 }
4684 occurrences.extend(rows.occurrences);
4685 coverage.extend(rows.coverage);
4686 relation_dependencies.extend(rows.relation_dependencies);
4687 for (key, reason) in rows.document_unresolved_reasons {
4688 insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
4689 }
4690 }
4691 let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
4692 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
4693 occurrences.sort_by(|left, right| {
4694 left.relation()
4695 .digest()
4696 .cmp(right.relation().digest())
4697 .then_with(|| left.file().as_str().cmp(right.file().as_str()))
4698 .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
4699 .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
4700 });
4701 occurrences.dedup();
4702 sort_dedup_dependencies(&mut relation_dependencies);
4703 enforce_key_binding_limit(relation_dependencies.len())?;
4704 entities.retained_bytes = entities
4705 .retained_bytes
4706 .saturating_sub(resolution_retained_bytes(&resolution_keys));
4707 let mut graph_coverage = vec![coverage_for_graph(
4708 graph,
4709 generation,
4710 identity_admission,
4711 &derived_identity_admission,
4712 )?];
4713 graph_coverage.extend(coverage);
4714 Ok(ProjectedGraphRows {
4715 relations,
4716 occurrences,
4717 coverage: graph_coverage,
4718 external_entities: external_entities.into_values().collect(),
4719 relation_dependencies,
4720 document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
4721 identity_rejections: derived_identity_admission.rejections,
4722 })
4723}
4724
4725fn insert_relation(
4727 relations: &mut BTreeMap<String, LogicalRelation>,
4728 relation: LogicalRelation,
4729 graph_path: &str,
4730 relation_kind: &str,
4731 relation_index: usize,
4732) -> Result<(), CliError> {
4733 let digest = relation.key().digest().to_string();
4734 match relations.entry(digest) {
4735 Entry::Vacant(entry) => {
4736 entry.insert(relation);
4737 Ok(())
4738 }
4739 Entry::Occupied(mut entry) => {
4740 let existing = entry.get();
4741 if existing == &relation {
4742 return Ok(());
4743 }
4744 let mergeable = existing.key() == relation.key()
4745 && existing.source() == relation.source()
4746 && existing.kind() == relation.kind()
4747 && existing.confidence() == relation.confidence()
4748 && existing.completeness() == relation.completeness()
4749 && existing.generation() == relation.generation();
4750 if mergeable
4751 && let (
4752 RelationResolution::Ambiguous {
4753 reference: existing_reference,
4754 candidates: existing_candidates,
4755 },
4756 RelationResolution::Ambiguous {
4757 reference: incoming_reference,
4758 candidates: incoming_candidates,
4759 },
4760 ) = (existing.resolution(), relation.resolution())
4761 && existing_reference == incoming_reference
4762 {
4763 if incoming_candidates > existing_candidates {
4764 entry.insert(relation);
4765 }
4766 return Ok(());
4767 }
4768 Err(CliError::InvalidInput(format!(
4769 "{relation_kind} relation digest retained conflicting facts for {graph_path} \
4770 relation {relation_index}: existing={existing:?}, incoming={relation:?}"
4771 )))
4772 }
4773 }
4774}
4775
4776struct ProjectResolutionRegistry {
4778 retained_limit: u64,
4780 supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
4782 candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
4784 forwarding_key_digests: BTreeSet<[u8; 32]>,
4786 retained_bytes: u64,
4788}
4789
4790impl Default for ProjectResolutionRegistry {
4791 fn default() -> Self {
4792 Self {
4793 retained_limit: super::MAX_PUBLICATION_STAGING_BYTES,
4794 supplemental_entities_by_digest: BTreeMap::new(),
4795 candidate_digests_by_key: BTreeMap::new(),
4796 forwarding_key_digests: BTreeSet::new(),
4797 retained_bytes: 0,
4798 }
4799 }
4800}
4801
4802impl ProjectResolutionRegistry {
4803 fn reserve(&mut self, bytes: u64) -> Result<(), CliError> {
4805 let next = self.retained_bytes.saturating_add(bytes);
4806 enforce_resolution_registry_budget_with_limit(next, self.retained_limit)?;
4807 self.retained_bytes = next;
4808 Ok(())
4809 }
4810
4811 fn insert_candidate(
4813 &mut self,
4814 key: &CanonicalResolutionKey,
4815 entity: &GraphEntity,
4816 ) -> Result<(), CliError> {
4817 let digest = entity.key().digest().to_string();
4818 if let Some(existing) = self.supplemental_entities_by_digest.get(&digest) {
4819 if existing != entity {
4820 return Err(CliError::InvalidInput(
4821 "graph entity digest retained conflicting selectors".to_string(),
4822 ));
4823 }
4824 } else {
4825 self.reserve(entity_retained_bytes(entity).saturating_add(digest.len() as u64))?;
4826 self.supplemental_entities_by_digest
4827 .insert(digest.clone(), entity.clone());
4828 }
4829 self.record_forwarding_key(key, entity)?;
4830 self.insert_candidate_binding(key, digest)
4831 }
4832
4833 fn insert_staged_candidate(
4835 &mut self,
4836 key: &CanonicalResolutionKey,
4837 entity: &GraphEntity,
4838 ) -> Result<(), CliError> {
4839 self.record_forwarding_key(key, entity)?;
4840 self.insert_candidate_binding(key, entity.key().digest().to_string())
4841 }
4842
4843 fn record_forwarding_key(
4845 &mut self,
4846 key: &CanonicalResolutionKey,
4847 entity: &GraphEntity,
4848 ) -> Result<(), CliError> {
4849 let digest = key.digest_bytes();
4850 if key.domain() == ResolutionKeyDomain::Declaration
4851 && matches!(entity.selector(), EntitySelector::File { .. })
4852 && !self.forwarding_key_digests.contains(&digest)
4853 {
4854 self.reserve(STAGED_GRAPH_ROW_BYTES.saturating_add(digest.len() as u64))?;
4855 self.forwarding_key_digests.insert(digest);
4856 }
4857 Ok(())
4858 }
4859
4860 fn insert_candidate_binding(
4862 &mut self,
4863 key: &CanonicalResolutionKey,
4864 digest: String,
4865 ) -> Result<(), CliError> {
4866 if self
4867 .candidate_digests_by_key
4868 .get(key)
4869 .is_some_and(|existing| existing.contains(&digest))
4870 {
4871 return Ok(());
4872 }
4873 let new_key = !self.candidate_digests_by_key.contains_key(key);
4874 let bytes = STAGED_GRAPH_ROW_BYTES
4875 .saturating_add(digest.capacity() as u64)
4876 .saturating_add(if new_key {
4877 STAGED_GRAPH_ROW_BYTES
4878 .saturating_add(std::mem::size_of::<CanonicalResolutionKey>() as u64)
4879 .saturating_add(key.canonical_identity().len() as u64)
4880 } else {
4881 0
4882 });
4883 self.reserve(bytes)?;
4884 self.candidate_digests_by_key
4885 .entry(key.clone())
4886 .or_default()
4887 .insert(digest);
4888 Ok(())
4889 }
4890}
4891
4892fn enforce_resolution_registry_budget(retained_bytes: u64) -> Result<(), CliError> {
4894 enforce_resolution_registry_budget_with_limit(
4895 retained_bytes,
4896 super::MAX_PUBLICATION_STAGING_BYTES,
4897 )
4898}
4899
4900fn enforce_resolution_registry_budget_with_limit(
4902 retained_bytes: u64,
4903 limit: u64,
4904) -> Result<(), CliError> {
4905 if retained_bytes > limit {
4906 return Err(IndexWorkFailure::resource_limit(
4907 IndexWorkStage::SymbolParsing,
4908 IndexWorkResource::OutputBytes,
4909 limit,
4910 retained_bytes,
4911 )
4912 .into());
4913 }
4914 Ok(())
4915}
4916
4917fn enforce_resolution_staging_budget(
4919 projection: &EntityProjection,
4920 registry: &ProjectResolutionRegistry,
4921) -> Result<(), CliError> {
4922 enforce_resolution_registry_budget(
4923 projection
4924 .retained_bytes
4925 .saturating_add(registry.retained_bytes),
4926 )
4927}
4928
4929fn entity_retained_bytes(entity: &GraphEntity) -> u64 {
4931 let selector_bytes = match entity.selector() {
4932 EntitySelector::Project => 0,
4933 EntitySelector::Folder { path } => path.as_str().len() as u64,
4934 EntitySelector::File { path } => path.as_str().len() as u64,
4935 EntitySelector::Package { package } => {
4936 (package.manager.as_str().len()
4937 + package.name.as_str().len()
4938 + package.manifest.as_str().len()) as u64
4939 }
4940 EntitySelector::Symbol { symbol } => {
4941 (symbol.file.as_str().len()
4942 + symbol.name.as_str().len()
4943 + symbol
4944 .parent
4945 .as_ref()
4946 .map_or(0, |parent| parent.as_str().len())
4947 + symbol.signature.as_str().len()) as u64
4948 }
4949 EntitySelector::External { external } => {
4950 (external.system.as_str().len() + external.identity.as_str().len()) as u64
4951 }
4952 };
4953 STAGED_GRAPH_ROW_BYTES
4954 .saturating_add(entity.key().digest().len() as u64)
4955 .saturating_add(entity.key().canonical_identity().len() as u64)
4956 .saturating_add(selector_bytes)
4957}
4958
4959#[cfg(test)]
4961fn resolution_registry_from_exports(
4962 projection: &EntityProjection,
4963 control: &IndexWorkControl,
4964) -> Result<ProjectResolutionRegistry, CliError> {
4965 resolution_registry_from_exports_with_limit(
4966 projection,
4967 control,
4968 super::MAX_PUBLICATION_STAGING_BYTES,
4969 )
4970}
4971
4972fn resolution_registry_from_exports_with_limit(
4974 projection: &EntityProjection,
4975 control: &IndexWorkControl,
4976 limit: u64,
4977) -> Result<ProjectResolutionRegistry, CliError> {
4978 let mut candidates = ProjectResolutionRegistry {
4979 retained_limit: limit,
4980 ..ProjectResolutionRegistry::default()
4981 };
4982 append_resolution_exports(&mut candidates, projection, control)?;
4983 Ok(candidates)
4984}
4985
4986fn append_resolution_exports(
4988 candidates: &mut ProjectResolutionRegistry,
4989 projection: &EntityProjection,
4990 control: &IndexWorkControl,
4991) -> Result<(), CliError> {
4992 for (index, binding) in projection.entity_exports.iter().enumerate() {
4993 check_graph_work(control, index)?;
4994 let digest = binding.entity().digest().to_string();
4995 let entity = projection.entity_by_digest.get(&digest).ok_or_else(|| {
4996 CliError::InvalidInput("resolution export owner was not staged".to_string())
4997 })?;
4998 candidates.insert_staged_candidate(binding.key(), entity)?;
4999 }
5000 Ok(())
5001}
5002
5003fn resolution_registry_from_persisted(
5005 project: ProjectInstanceId,
5006 generation: IndexGeneration,
5007 candidates: RepositoryResolutionReadPage<RepositoryResolutionCandidate>,
5008 replaced_paths: &BTreeSet<String>,
5009 control: &IndexWorkControl,
5010 limit: u64,
5011) -> Result<ProjectResolutionRegistry, CliError> {
5012 let mut by_key = ProjectResolutionRegistry {
5013 retained_limit: limit,
5014 ..ProjectResolutionRegistry::default()
5015 };
5016 append_persisted_resolution_candidates(
5017 &mut by_key,
5018 project,
5019 generation,
5020 candidates,
5021 replaced_paths,
5022 control,
5023 )?;
5024 Ok(by_key)
5025}
5026
5027fn append_persisted_resolution_candidates(
5029 by_key: &mut ProjectResolutionRegistry,
5030 project: ProjectInstanceId,
5031 generation: IndexGeneration,
5032 candidates: RepositoryResolutionReadPage<RepositoryResolutionCandidate>,
5033 replaced_paths: &BTreeSet<String>,
5034 control: &IndexWorkControl,
5035) -> Result<(), CliError> {
5036 let previous_limit = by_key.retained_limit;
5037 by_key.retained_limit = previous_limit.saturating_sub(candidates.retained_bytes);
5038 let result = (|| -> Result<(), CliError> {
5039 enforce_resolution_registry_budget_with_limit(
5040 by_key.retained_bytes,
5041 by_key.retained_limit,
5042 )?;
5043 for (index, candidate) in candidates.page.rows.into_iter().enumerate() {
5044 check_graph_work(control, index)?;
5045 if entity_owner_path(candidate.entity())
5046 .is_some_and(|path| replaced_paths.contains(path))
5047 {
5048 continue;
5049 }
5050 let entity =
5051 GraphEntity::new(project, candidate.entity().selector().clone(), generation)
5052 .map_err(invalid_graph_contract)?;
5053 by_key.insert_candidate(candidate.key(), &entity)?;
5054 }
5055 Ok(())
5056 })();
5057 by_key.retained_limit = previous_limit;
5058 result
5059}
5060
5061fn check_graph_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
5063 if index.is_multiple_of(GRAPH_WORK_CHECK_INTERVAL) {
5064 control.check(IndexWorkStage::SymbolParsing)?;
5065 }
5066 Ok(())
5067}
5068
5069fn entity_owner_path(entity: &GraphEntity) -> Option<&str> {
5071 match entity.selector() {
5072 EntitySelector::File { path } => Some(path.as_str()),
5073 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
5074 EntitySelector::Package { package } => Some(package.manifest.as_str()),
5075 EntitySelector::Project
5076 | EntitySelector::Folder { .. }
5077 | EntitySelector::External { .. } => None,
5078 }
5079}
5080
5081fn derived_relation_facts(
5083 graph: &SymbolGraph,
5084 keys_by_relation: &BTreeMap<usize, &[CanonicalResolutionKey]>,
5085) -> Vec<DerivedRelationFact> {
5086 let mut facts = Vec::new();
5087 let test_path = is_test_path(&graph.path);
5088 for (index, relation) in graph.relations.iter().enumerate() {
5089 if test_path && matches!(relation.kind, RelationKind::Imports | RelationKind::Calls) {
5090 let keys = keys_by_relation
5091 .get(&index)
5092 .copied()
5093 .unwrap_or_default()
5094 .to_vec();
5095 push_derived_relation(
5096 &mut facts,
5097 ExtendedRelationKind::Tests,
5098 relation.clone(),
5099 DerivedRelationTarget::Parser { keys },
5100 );
5101 }
5102 if relation.kind == RelationKind::Calls {
5103 if let Some(handler) = static_route_handler(relation) {
5104 push_derived_relation(
5105 &mut facts,
5106 ExtendedRelationKind::RoutesTo,
5107 derived_parser_relation(relation, handler),
5108 DerivedRelationTarget::Parser { keys: Vec::new() },
5109 );
5110 }
5111 if let Some(key) = static_environment_key(relation) {
5112 push_derived_relation(
5113 &mut facts,
5114 ExtendedRelationKind::Configures,
5115 derived_parser_relation(relation, key),
5116 DerivedRelationTarget::External {
5117 system: ENVIRONMENT_SYSTEM,
5118 },
5119 );
5120 }
5121 if let Some(kind) = static_data_access_kind(&relation.target_name)
5122 && let Some(path) = static_string_argument(&relation.context)
5123 .and_then(normalize_static_repository_path)
5124 {
5125 push_derived_relation(
5126 &mut facts,
5127 kind,
5128 derived_parser_relation(relation, path),
5129 DerivedRelationTarget::RepositoryPath,
5130 );
5131 }
5132 }
5133 }
5134 if let Some(identity) = configuration_file_identity(&graph.path) {
5135 push_derived_relation(
5136 &mut facts,
5137 ExtendedRelationKind::Configures,
5138 file_owned_relation(graph, identity),
5139 DerivedRelationTarget::External {
5140 system: CONFIGURATION_SYSTEM,
5141 },
5142 );
5143 }
5144 if let Some(identity) = deployment_platform_identity(&graph.path) {
5145 push_derived_relation(
5146 &mut facts,
5147 ExtendedRelationKind::Deploys,
5148 file_owned_relation(graph, identity),
5149 DerivedRelationTarget::External {
5150 system: DEPLOYMENT_SYSTEM,
5151 },
5152 );
5153 }
5154 facts
5155}
5156
5157fn push_derived_relation(
5159 facts: &mut Vec<DerivedRelationFact>,
5160 kind: ExtendedRelationKind,
5161 relation: SymbolRelation,
5162 target: DerivedRelationTarget,
5163) {
5164 facts.push(DerivedRelationFact {
5165 kind,
5166 relation,
5167 target,
5168 });
5169}
5170
5171fn derived_parser_relation(relation: &SymbolRelation, target_name: String) -> SymbolRelation {
5173 SymbolRelation {
5174 path: relation.path.clone(),
5175 source_name: relation.source_name.clone(),
5176 target_name,
5177 kind: RelationKind::Calls,
5178 line: relation.line,
5179 context: relation.context.clone(),
5180 parser: relation.parser,
5181 }
5182}
5183
5184fn file_owned_relation(graph: &SymbolGraph, target_name: String) -> SymbolRelation {
5186 SymbolRelation {
5187 path: graph.path.clone(),
5188 source_name: MODULE_RELATION_SOURCE.to_string(),
5189 target_name,
5190 kind: RelationKind::Calls,
5191 line: 1,
5192 context: graph.path.clone(),
5193 parser: graph.parser,
5194 }
5195}
5196
5197fn derived_relation_resolution<'a>(
5199 project: ProjectInstanceId,
5200 generation: IndexGeneration,
5201 fact: &DerivedRelationFact,
5202 owners: &GraphOwners,
5203 graph: &SymbolGraph,
5204 symbol_index: &GraphSymbolIndex<'_>,
5205 candidates: &'a ProjectResolutionRegistry,
5206 entities: &'a BTreeMap<String, GraphEntity>,
5207 external_entities: &mut BTreeMap<String, GraphEntity>,
5208 control: &IndexWorkControl,
5209) -> Result<RelationResolution, CliError> {
5210 match &fact.target {
5211 DerivedRelationTarget::Parser { keys } => relation_resolution(
5212 project,
5213 generation,
5214 &fact.relation,
5215 None,
5216 owners,
5217 graph,
5218 symbol_index,
5219 keys,
5220 candidates,
5221 entities,
5222 external_entities,
5223 control,
5224 ),
5225 DerivedRelationTarget::RepositoryPath => {
5226 let candidate = GraphEntity::new(
5227 project,
5228 EntitySelector::File {
5229 path: RepositoryFilePath::new(Path::new(&fact.relation.target_name))
5230 .map_err(invalid_graph_contract)?,
5231 },
5232 generation,
5233 )
5234 .map_err(invalid_graph_contract)?;
5235 match entities.get(candidate.key().digest()) {
5236 Some(entity) if entity == &candidate => {
5237 RelationResolution::resolved(entity).map_err(invalid_graph_contract)
5238 }
5239 Some(_conflict) => Err(CliError::InvalidInput(
5240 "static repository-path target collided with another graph entity".to_string(),
5241 )),
5242 None => Ok(RelationResolution::Unresolved {
5243 reference: GraphIdentityText::new(fact.relation.target_name.clone())
5244 .map_err(invalid_graph_contract)?,
5245 }),
5246 }
5247 }
5248 DerivedRelationTarget::External { system } => {
5249 let entity = GraphEntity::new(
5250 project,
5251 EntitySelector::External {
5252 external: ExternalSelector {
5253 system: GraphIdentityText::new(*system).map_err(invalid_graph_contract)?,
5254 identity: GraphIdentityText::new(fact.relation.target_name.clone())
5255 .map_err(invalid_graph_contract)?,
5256 },
5257 },
5258 generation,
5259 )
5260 .map_err(invalid_graph_contract)?;
5261 let resolution =
5262 RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
5263 insert_entity(external_entities, entity)?;
5264 Ok(resolution)
5265 }
5266 }
5267}
5268
5269fn is_test_path(path: &str) -> bool {
5271 let normalized = path.replace('\\', "/").to_ascii_lowercase();
5272 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5273 normalized
5274 .split('/')
5275 .any(|segment| matches!(segment, "test" | "tests" | "__tests__"))
5276 || file.contains(".test.")
5277 || file.contains(".spec.")
5278 || file
5279 .split_once('.')
5280 .is_some_and(|(stem, _extension)| stem.ends_with("_test") || stem.starts_with("test_"))
5281}
5282
5283fn static_route_handler(relation: &SymbolRelation) -> Option<String> {
5285 let leaf = call_leaf(&relation.target_name);
5286 if !matches!(
5287 leaf,
5288 "route" | "add_route" | "map_get" | "map_post" | "map_put" | "map_patch" | "map_delete"
5289 ) {
5290 return None;
5291 }
5292 let route = static_string_argument(&relation.context)?;
5293 if !route.starts_with('/')
5294 || route.chars().any(char::is_control)
5295 || relation.context.matches(',').count() != 1
5296 {
5297 return None;
5298 }
5299 let handler = relation
5300 .context
5301 .rsplit_once(',')?
5302 .1
5303 .trim()
5304 .trim_end_matches([')', ';'])
5305 .trim();
5306 let valid_handler = !handler.is_empty()
5307 && handler != relation.source_name
5308 && handler
5309 .chars()
5310 .next()
5311 .is_some_and(|character| !character.is_ascii_digit())
5312 && handler.chars().all(|character| {
5313 character.is_ascii_alphanumeric() || matches!(character, '_' | ':' | '.' | '$')
5314 });
5315 valid_handler.then(|| handler.trim_matches('$').to_string())
5316}
5317
5318fn static_environment_key(relation: &SymbolRelation) -> Option<String> {
5320 let normalized = relation
5321 .target_name
5322 .trim()
5323 .trim_end_matches('!')
5324 .to_ascii_lowercase();
5325 if !(normalized.ends_with("env::var")
5326 || normalized.ends_with("env::var_os")
5327 || normalized.ends_with("os.getenv")
5328 || normalized.ends_with("getenvironmentvariable")
5329 || matches!(normalized.as_str(), "getenv" | "getenv_os"))
5330 {
5331 return None;
5332 }
5333 let key = static_string_argument(&relation.context)?;
5334 (!key.is_empty()
5335 && key.len() <= 128
5336 && key
5337 .bytes()
5338 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
5339 .then(|| key.to_string())
5340}
5341
5342fn static_data_access_kind(target: &str) -> Option<ExtendedRelationKind> {
5344 let normalized = target.trim().trim_end_matches('!').to_ascii_lowercase();
5345 let leaf = call_leaf(&normalized);
5346 if matches!(
5347 leaf,
5348 "read" | "read_to_string" | "readfile" | "readfilesync"
5349 ) || normalized.ends_with("file::open")
5350 {
5351 Some(ExtendedRelationKind::Reads)
5352 } else if matches!(leaf, "write" | "writefile" | "writefilesync" | "create")
5353 || normalized.ends_with("file::create")
5354 {
5355 Some(ExtendedRelationKind::Writes)
5356 } else {
5357 None
5358 }
5359}
5360
5361fn call_leaf(target: &str) -> &str {
5363 target
5364 .trim()
5365 .trim_end_matches('!')
5366 .rsplit([':', '.', '/'])
5367 .find(|part| !part.is_empty())
5368 .unwrap_or_default()
5369}
5370
5371fn static_string_argument(context: &str) -> Option<&str> {
5373 let open = context.find('(')?;
5374 let argument = context[open + 1..].trim_start();
5375 let quote = argument.chars().next()?;
5376 if !matches!(quote, '\'' | '"') {
5377 return None;
5378 }
5379 let value = &argument[quote.len_utf8()..];
5380 let end = value.find(quote)?;
5381 let value = &value[..end];
5382 (!value.contains('\\')
5383 && !value.contains("${")
5384 && !value.contains("#{")
5385 && !value.chars().any(char::is_control))
5386 .then_some(value)
5387}
5388
5389fn normalize_static_repository_path(value: &str) -> Option<String> {
5391 let value = value.replace('\\', "/");
5392 if value.is_empty()
5393 || value.starts_with('/')
5394 || value.starts_with('~')
5395 || value.contains("://")
5396 || value.contains('$')
5397 || value.contains('{')
5398 || value.contains('}')
5399 || value
5400 .split('/')
5401 .next()
5402 .is_some_and(|part| part.contains(':'))
5403 {
5404 return None;
5405 }
5406 let mut parts = Vec::new();
5407 for part in value.split('/') {
5408 match part {
5409 "" | "." => {}
5410 ".." => {
5411 parts.pop()?;
5412 }
5413 part if part == "."
5414 || part == ".."
5415 || part.chars().any(char::is_control)
5416 || part.trim() != part =>
5417 {
5418 return None;
5419 }
5420 part => parts.push(part.to_string()),
5421 }
5422 }
5423 (!parts.is_empty()).then(|| parts.join("/"))
5424}
5425
5426fn configuration_file_identity(path: &str) -> Option<String> {
5428 let normalized = path.replace('\\', "/").to_ascii_lowercase();
5429 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5430 let identity = if file == ".env" || file.starts_with(".env.") {
5431 "dotenv"
5432 } else if matches!(
5433 file,
5434 "config.json"
5435 | "config.yaml"
5436 | "config.yml"
5437 | "config.toml"
5438 | "settings.json"
5439 | "settings.yaml"
5440 | "settings.yml"
5441 | "settings.toml"
5442 | "appsettings.json"
5443 ) {
5444 "application-config"
5445 } else {
5446 return None;
5447 };
5448 Some(identity.to_string())
5449}
5450
5451fn deployment_platform_identity(path: &str) -> Option<String> {
5453 let normalized = path.replace('\\', "/").to_ascii_lowercase();
5454 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
5455 let identity = if file_has_extension(file, "tf")
5456 || file
5457 .strip_suffix(".json")
5458 .is_some_and(|stem| file_has_extension(stem, "tf"))
5459 {
5460 "terraform"
5461 } else if file == "dockerfile"
5462 || file.starts_with("dockerfile.")
5463 || matches!(
5464 file,
5465 "compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
5466 )
5467 {
5468 "containers"
5469 } else if matches!(
5470 file,
5471 "chart.yaml" | "kustomization.yaml" | "kustomization.yml"
5472 ) || (normalized
5473 .split('/')
5474 .any(|segment| matches!(segment, "k8s" | "kubernetes" | "helm" | "kustomize"))
5475 && matches!(
5476 Path::new(file).extension().and_then(|value| value.to_str()),
5477 Some("yaml" | "yml" | "json")
5478 ))
5479 {
5480 "kubernetes"
5481 } else if file_has_extension(file, "bicep") {
5482 "azure-bicep"
5483 } else if file == "sam-template.yaml"
5484 || (file.contains("cloudformation")
5485 && matches!(
5486 Path::new(file).extension().and_then(|value| value.to_str()),
5487 Some("yaml" | "yml" | "json")
5488 ))
5489 {
5490 "cloudformation"
5491 } else if file == "playbook.yaml" || file == "playbook.yml" {
5492 "ansible"
5493 } else {
5494 return None;
5495 };
5496 Some(identity.to_string())
5497}
5498
5499fn file_has_extension(file: &str, expected: &str) -> bool {
5501 Path::new(file)
5502 .extension()
5503 .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
5504}
5505
5506fn relation_source<'a>(
5508 owners: &GraphOwners,
5509 entities: &'a BTreeMap<String, GraphEntity>,
5510 graph: &SymbolGraph,
5511 symbol_index: &GraphSymbolIndex<'_>,
5512 relation: &SymbolRelation,
5513 relation_index: Option<usize>,
5514 control: &IndexWorkControl,
5515) -> Result<&'a GraphEntity, CliError> {
5516 let file = entities.get(&owners.file_digest).ok_or_else(|| {
5517 CliError::InvalidInput("graph file owner entity was not staged".to_string())
5518 })?;
5519 let is_php_call = relation.kind == RelationKind::Calls
5520 && graph
5521 .language
5522 .as_deref()
5523 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5524 let is_php_contains = relation.kind == RelationKind::Contains
5525 && graph
5526 .language
5527 .as_deref()
5528 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5529 if relation.kind == RelationKind::Calls
5530 && !is_php_call
5531 && let Some(index) = relation_index
5532 && let Some(calls) = &symbol_index.source_calls
5533 {
5534 return Ok(calls
5535 .get(&index)
5536 .and_then(|call| call.owner)
5537 .and_then(|owner| owners.symbol_digests.get(owner))
5538 .and_then(Option::as_ref)
5539 .and_then(|digest| entities.get(digest))
5540 .unwrap_or(file));
5541 }
5542 let php_source = if is_php_call {
5543 unique_php_call_source(
5544 graph,
5545 symbol_index,
5546 &relation.source_name,
5547 relation.line,
5548 control,
5549 )?
5550 } else if is_php_contains {
5551 unique_php_containment_source(graph, symbol_index, relation, control)?
5552 } else {
5553 None
5554 };
5555 let indices = if is_php_call || is_php_contains {
5556 php_source.as_slice()
5557 } else {
5558 symbol_index.get(&relation.source_name)
5559 };
5560 let mut matched = None;
5561 for (candidate_index, &index) in indices.iter().enumerate() {
5562 check_graph_work(control, candidate_index)?;
5563 let digest = owners.symbol_digests.get(index).ok_or_else(|| {
5564 CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5565 })?;
5566 let Some(digest) = digest else {
5567 continue;
5568 };
5569 let entity = entities.get(digest).ok_or_else(|| {
5570 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5571 })?;
5572 if matched.is_some() {
5573 return Ok(file);
5574 }
5575 matched = Some(entity);
5576 }
5577 Ok(matched.unwrap_or(file))
5578}
5579
5580#[derive(Clone, Copy)]
5582struct ResolutionMatches<'a> {
5583 first: Option<&'a GraphEntity>,
5585 count: u32,
5587}
5588
5589fn relation_resolution<'a>(
5591 project: ProjectInstanceId,
5592 generation: IndexGeneration,
5593 relation: &projectatlas_core::symbols::SymbolRelation,
5594 relation_index: Option<usize>,
5595 owners: &GraphOwners,
5596 graph: &SymbolGraph,
5597 symbol_index: &GraphSymbolIndex<'_>,
5598 dependency_keys: &[CanonicalResolutionKey],
5599 candidates: &'a ProjectResolutionRegistry,
5600 staged_entities: &'a BTreeMap<String, GraphEntity>,
5601 external_entities: &mut BTreeMap<String, GraphEntity>,
5602 control: &IndexWorkControl,
5603) -> Result<RelationResolution, CliError> {
5604 let matches = match relation.kind {
5605 RelationKind::Contains => local_relation_matches(
5606 relation,
5607 relation_index,
5608 owners,
5609 graph,
5610 symbol_index,
5611 staged_entities,
5612 control,
5613 )?,
5614 RelationKind::Calls => {
5615 let source_policy = relation_index.and_then(|index| {
5616 symbol_index
5617 .source_calls
5618 .as_ref()
5619 .map(|calls| calls.get(&index).copied())
5620 });
5621 if let Some(policy) = source_policy {
5622 match policy.map(|call| &call.binding) {
5623 Some(SourceCallBinding::LocalDeclaration(symbol)) => source_local_call_matches(
5624 std::slice::from_ref(symbol),
5625 owners,
5626 staged_entities,
5627 control,
5628 )?,
5629 Some(SourceCallBinding::LocalDeclarations(symbols)) => {
5630 source_local_call_matches(symbols, owners, staged_entities, control)?
5631 }
5632 Some(
5633 SourceCallBinding::Imported { .. }
5634 | SourceCallBinding::Qualified(_)
5635 | SourceCallBinding::RustPath(_),
5636 ) => registry_resolution_matches(
5637 dependency_keys,
5638 ResolutionCandidateKind::Declaration,
5639 candidates,
5640 staged_entities,
5641 control,
5642 )?,
5643 Some(SourceCallBinding::Shadowed | SourceCallBinding::Unresolved) | None => {
5644 ResolutionMatches {
5645 first: None,
5646 count: 0,
5647 }
5648 }
5649 Some(SourceCallBinding::PhpNamespace) => {
5650 let local = local_relation_matches(
5651 relation,
5652 relation_index,
5653 owners,
5654 graph,
5655 symbol_index,
5656 staged_entities,
5657 control,
5658 )?;
5659 if local.count == 0 {
5660 registry_resolution_matches(
5661 dependency_keys,
5662 ResolutionCandidateKind::Declaration,
5663 candidates,
5664 staged_entities,
5665 control,
5666 )?
5667 } else {
5668 local
5669 }
5670 }
5671 }
5672 } else {
5673 let local = local_relation_matches(
5674 relation,
5675 relation_index,
5676 owners,
5677 graph,
5678 symbol_index,
5679 staged_entities,
5680 control,
5681 )?;
5682 if local.count == 0 {
5683 registry_resolution_matches(
5684 dependency_keys,
5685 ResolutionCandidateKind::Declaration,
5686 candidates,
5687 staged_entities,
5688 control,
5689 )?
5690 } else {
5691 local
5692 }
5693 }
5694 }
5695 RelationKind::Imports | RelationKind::DependsOn => registry_resolution_matches(
5696 dependency_keys,
5697 ResolutionCandidateKind::Any,
5698 candidates,
5699 staged_entities,
5700 control,
5701 )?,
5702 };
5703 match matches.count {
5704 0 => {
5705 if let Some(external) =
5706 source_external_selector(graph, relation, relation_index, symbol_index)?
5707 {
5708 let entity =
5709 GraphEntity::new(project, EntitySelector::External { external }, generation)
5710 .map_err(invalid_graph_contract)?;
5711 let resolution =
5712 RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
5713 insert_entity(external_entities, entity)?;
5714 return Ok(resolution);
5715 }
5716 Ok(RelationResolution::Unresolved {
5717 reference: GraphIdentityText::new(relation_reference(relation))
5718 .map_err(invalid_graph_contract)?,
5719 })
5720 }
5721 1 => RelationResolution::resolved(
5722 matches
5723 .first
5724 .ok_or_else(|| CliError::InvalidInput("resolved target disappeared".to_string()))?,
5725 )
5726 .map_err(invalid_graph_contract),
5727 count => Ok(RelationResolution::Ambiguous {
5728 reference: GraphIdentityText::new(relation_reference(relation))
5729 .map_err(invalid_graph_contract)?,
5730 candidates: NonZeroU32::new(count).ok_or_else(|| {
5731 CliError::InvalidInput("ambiguous target count was zero".to_string())
5732 })?,
5733 }),
5734 }
5735}
5736
5737fn source_local_call_matches<'a>(
5739 symbols: &[usize],
5740 owners: &GraphOwners,
5741 entities: &'a BTreeMap<String, GraphEntity>,
5742 control: &IndexWorkControl,
5743) -> Result<ResolutionMatches<'a>, CliError> {
5744 let mut targets = BTreeMap::new();
5745 for symbol in symbols {
5746 control.check(IndexWorkStage::SymbolParsing)?;
5747 let Some(target) = owners
5748 .symbol_digests
5749 .get(*symbol)
5750 .and_then(Option::as_ref)
5751 .and_then(|digest| entities.get(digest))
5752 else {
5753 return Ok(ResolutionMatches {
5754 first: None,
5755 count: 0,
5756 });
5757 };
5758 targets.insert(target.key().digest(), target);
5759 }
5760 Ok(ResolutionMatches {
5761 first: targets.first_key_value().map(|(_, entity)| *entity),
5762 count: u32::try_from(targets.len()).map_err(|error| {
5763 CliError::InvalidInput(format!("source call candidate count overflowed: {error}"))
5764 })?,
5765 })
5766}
5767
5768fn local_relation_matches<'a>(
5770 relation: &projectatlas_core::symbols::SymbolRelation,
5771 relation_index: Option<usize>,
5772 owners: &GraphOwners,
5773 graph: &SymbolGraph,
5774 symbol_index: &GraphSymbolIndex<'_>,
5775 staged_entities: &'a BTreeMap<String, GraphEntity>,
5776 control: &IndexWorkControl,
5777) -> Result<ResolutionMatches<'a>, CliError> {
5778 let mut targets = BTreeMap::<&str, &GraphEntity>::new();
5779 let mut global_fallback_targets = BTreeMap::<&str, &GraphEntity>::new();
5780 let target_name = relation.target_name.trim();
5781 let is_php_call = relation.kind == RelationKind::Calls
5782 && graph
5783 .language
5784 .as_deref()
5785 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5786 let is_php_contains = relation.kind == RelationKind::Contains
5787 && graph
5788 .language
5789 .as_deref()
5790 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
5791 let php_containment_source = if is_php_contains {
5792 unique_php_containment_source(graph, symbol_index, relation, control)?
5793 .and_then(|index| graph.symbols.get(index))
5794 } else {
5795 None
5796 };
5797 let source_parent = if relation.kind == RelationKind::Calls {
5798 unique_source_parent(
5799 graph,
5800 symbol_index,
5801 &relation.source_name,
5802 is_php_call.then_some(relation.line),
5803 control,
5804 )?
5805 } else {
5806 None
5807 };
5808 let known_source_namespace = if is_php_call {
5809 unique_php_source_namespace(graph, symbol_index, source_parent, relation, control)?
5810 } else {
5811 None
5812 };
5813 if is_php_call
5814 && target_name
5815 .split_once("::")
5816 .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5817 && let Some(parent) = source_parent
5818 {
5819 for (candidate_index, &index) in symbol_index.get(parent).iter().enumerate() {
5820 check_graph_work(control, candidate_index)?;
5821 let owner = graph.symbols.get(index).ok_or_else(|| {
5822 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5823 })?;
5824 if owner.kind == SymbolKind::Trait
5825 && owner.line_start <= relation.line
5826 && relation.line <= owner.line_end
5827 {
5828 return Ok(ResolutionMatches {
5830 first: None,
5831 count: 0,
5832 });
5833 }
5834 }
5835 }
5836 let php_imports_may_alias = symbol_index.php_imports_have_unknown_scope
5837 || known_source_namespace.map_or(
5838 !symbol_index.php_import_positions.is_empty(),
5839 |namespace| {
5840 let namespace = namespace.to_ascii_lowercase();
5841 let block_start = symbol_index
5842 .php_namespace_start_lines
5843 .get(&namespace)
5844 .and_then(|starts| starts.range(..=relation.line).next_back())
5845 .copied()
5846 .unwrap_or(0);
5847 symbol_index
5848 .php_import_positions
5849 .get(&namespace)
5850 .and_then(|positions| positions.range(block_start..=relation.line).next())
5851 .is_some_and(|(&line, &import_index)| {
5852 line < relation.line
5853 || import_index
5854 .zip(relation_index)
5855 .is_none_or(|(import, call)| import <= call)
5856 })
5857 },
5858 );
5859 let namespace_relative_target = if is_php_call
5860 && let Some((prefix, relative)) = target_name.split_once('\\')
5861 && !prefix.is_empty()
5862 && (prefix.eq_ignore_ascii_case("namespace") || !php_imports_may_alias)
5863 {
5864 let Some(namespace) = known_source_namespace else {
5865 return Ok(ResolutionMatches {
5866 first: None,
5867 count: 0,
5868 });
5869 };
5870 let relative = if prefix.eq_ignore_ascii_case("namespace") {
5871 relative
5872 } else {
5873 target_name
5874 };
5875 Some(if namespace.is_empty() {
5876 format!("\\{relative}")
5877 } else {
5878 format!("\\{namespace}\\{relative}")
5879 })
5880 } else {
5881 None
5882 };
5883 let lookup_target = namespace_relative_target.as_deref().unwrap_or(target_name);
5884 if is_php_call
5885 && php_imports_may_alias
5886 && !lookup_target.starts_with('\\')
5887 && !lookup_target
5888 .split_once("::")
5889 .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5890 {
5891 return Ok(ResolutionMatches {
5894 first: None,
5895 count: 0,
5896 });
5897 }
5898 let (lookup_name, scoped_parent, mut scoped_namespace, target_namespace) = if is_php_call {
5899 let Some(lookup) = php_call_lookup(lookup_target, source_parent) else {
5900 return Ok(ResolutionMatches {
5901 first: None,
5902 count: 0,
5903 });
5904 };
5905 lookup
5906 } else {
5907 (target_name, None, None, None)
5908 };
5909 if is_php_call && scoped_parent.is_some() && scoped_namespace.is_none() {
5910 scoped_namespace = known_source_namespace;
5911 if scoped_namespace.is_none() {
5912 return Ok(ResolutionMatches {
5913 first: None,
5914 count: 0,
5915 });
5916 }
5917 }
5918 let source_namespace = if is_php_call && scoped_parent.is_none() {
5919 if target_namespace.is_some() {
5920 target_namespace
5921 } else {
5922 known_source_namespace
5923 }
5924 } else {
5925 None
5926 };
5927 let candidate_indices = if is_php_call {
5928 symbol_index.get_php_call(lookup_name)
5929 } else {
5930 symbol_index.get(lookup_name)
5931 };
5932 for (candidate_index, &row_index) in candidate_indices.iter().enumerate() {
5933 check_graph_work(control, candidate_index)?;
5934 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5935 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5936 })?;
5937 let digest = owners.symbol_digests.get(row_index).ok_or_else(|| {
5938 CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5939 })?;
5940 let exact_match = match relation.kind {
5941 RelationKind::Contains => {
5942 symbol.name == lookup_name
5943 && symbol.parent.as_deref() == Some(relation.source_name.as_str())
5944 && if is_php_contains {
5945 if let Some(source) = php_containment_source {
5946 symbol.line_start == relation.line
5947 && symbol.detail.as_deref() == Some(relation.context.as_str())
5948 && php_entity_matches_qualified_scope(
5949 staged_entities,
5950 digest.as_ref(),
5951 &source.name,
5952 source.parent.as_deref().unwrap_or(""),
5953 )?
5954 } else {
5955 false
5956 }
5957 } else {
5958 true
5959 }
5960 }
5961 RelationKind::Calls => {
5962 let name_matches = if is_php_call {
5963 symbol.name.eq_ignore_ascii_case(lookup_name)
5964 } else {
5965 symbol.name == lookup_name
5966 };
5967 name_matches
5968 && (!is_php_call
5969 || symbol.kind
5970 == if scoped_parent.is_some() {
5971 SymbolKind::Method
5972 } else {
5973 SymbolKind::Function
5974 })
5975 && scoped_parent.is_none_or(|parent| {
5976 symbol.parent.as_deref().is_some_and(|symbol_parent| {
5977 if is_php_call {
5978 symbol_parent.eq_ignore_ascii_case(parent)
5979 } else {
5980 symbol_parent == parent
5981 }
5982 })
5983 })
5984 && if let (Some(namespace), Some(scoped_parent)) =
5985 (scoped_namespace, scoped_parent)
5986 {
5987 php_entity_matches_qualified_scope(
5988 staged_entities,
5989 digest.as_ref(),
5990 scoped_parent,
5991 namespace,
5992 )?
5993 } else {
5994 true
5995 }
5996 && source_namespace.is_none_or(|namespace| {
5997 symbol.parent.as_deref().is_some_and(|symbol_parent| {
5998 symbol_parent.eq_ignore_ascii_case(namespace)
5999 }) || (target_namespace.is_none_or(str::is_empty)
6000 && symbol.parent.is_none())
6001 })
6002 && (scoped_parent.is_some()
6003 || source_namespace.is_some()
6004 || symbol.parent.is_none()
6005 || symbol.parent.as_deref() == Some(relation.source_name.as_str())
6006 || source_parent.is_some_and(|source_parent| {
6007 symbol.parent.as_deref() == Some(source_parent)
6008 }))
6009 }
6010 RelationKind::Imports | RelationKind::DependsOn => false,
6011 };
6012 if exact_match && let Some(digest) = digest {
6013 let entity = staged_entities.get(digest).ok_or_else(|| {
6014 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
6015 })?;
6016 let is_global_fallback = is_php_call
6017 && scoped_parent.is_none()
6018 && target_namespace.is_none()
6019 && source_namespace.is_some()
6020 && symbol.kind == SymbolKind::Function
6021 && symbol.parent.is_none();
6022 let distinct_target_count = targets.len().saturating_add(global_fallback_targets.len());
6023 let destination = if is_global_fallback {
6024 &mut global_fallback_targets
6025 } else {
6026 &mut targets
6027 };
6028 if !destination.contains_key(entity.key().digest()) {
6029 enforce_resolution_match_budget(distinct_target_count.saturating_add(1))?;
6030 }
6031 destination.insert(entity.key().digest(), entity);
6032 }
6033 }
6034 let targets = if targets.is_empty() {
6035 global_fallback_targets
6036 } else {
6037 targets
6038 };
6039 let count = distinct_resolution_count(targets.len())?;
6040 Ok(ResolutionMatches {
6041 first: targets.into_values().next(),
6042 count,
6043 })
6044}
6045
6046fn php_call_lookup<'a>(
6048 target_name: &'a str,
6049 source_parent: Option<&'a str>,
6050) -> Option<(&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>)> {
6051 let Some((scope, member)) = target_name.rsplit_once("::") else {
6052 let Some(qualified_target) = target_name.strip_prefix('\\') else {
6053 return Some((target_name, None, None, None));
6054 };
6055 let Some((namespace, function)) = qualified_target.rsplit_once('\\') else {
6056 return Some((qualified_target, None, None, Some("")));
6057 };
6058 if namespace.is_empty() || function.is_empty() {
6059 return None;
6060 }
6061 return Some((function, None, None, Some(namespace)));
6062 };
6063 if scope.is_empty() || member.is_empty() {
6064 return None;
6065 }
6066 if scope.eq_ignore_ascii_case("self") {
6067 return source_parent.map(|parent| (member, Some(parent), None, None));
6068 }
6069 if scope.eq_ignore_ascii_case("static") {
6070 return None;
6071 }
6072 if scope.eq_ignore_ascii_case("parent") {
6073 return None;
6074 }
6075 if scope.contains('\\') && !scope.starts_with('\\') {
6076 return None;
6077 }
6078 let scope_without_root = scope.strip_prefix('\\').unwrap_or(scope);
6079 let (scope, namespace) = match scope_without_root.rsplit_once('\\') {
6080 Some((namespace, scope)) if !namespace.is_empty() && !scope.is_empty() => {
6081 (scope, Some(namespace))
6082 }
6083 None if scope.starts_with('\\') && !scope_without_root.is_empty() => {
6084 (scope_without_root, Some(""))
6085 }
6086 None if !scope_without_root.is_empty() => (scope_without_root, None),
6087 _ => return None,
6088 };
6089 Some((member, Some(scope), namespace, None))
6090}
6091
6092fn php_entity_matches_qualified_scope(
6094 staged_entities: &BTreeMap<String, GraphEntity>,
6095 digest: Option<&String>,
6096 scoped_parent: &str,
6097 namespace: &str,
6098) -> Result<bool, CliError> {
6099 let Some(digest) = digest else {
6100 return Ok(false);
6101 };
6102 let entity = staged_entities.get(digest).ok_or_else(|| {
6103 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
6104 })?;
6105 let EntitySelector::Symbol { symbol } = entity.selector() else {
6106 return Ok(false);
6107 };
6108 let Some(parent) = symbol.parent.as_ref() else {
6109 return Ok(false);
6110 };
6111 Ok(
6112 if let Some((owner_namespace, owner_name)) = parent.as_str().rsplit_once("::") {
6113 owner_name.eq_ignore_ascii_case(scoped_parent)
6114 && owner_namespace.eq_ignore_ascii_case(namespace)
6115 } else {
6116 namespace.is_empty() && parent.as_str().eq_ignore_ascii_case(scoped_parent)
6117 },
6118 )
6119}
6120
6121fn unique_php_source_namespace<'a>(
6123 graph: &'a SymbolGraph,
6124 symbol_index: &GraphSymbolIndex<'_>,
6125 source_parent: Option<&str>,
6126 relation: &projectatlas_core::symbols::SymbolRelation,
6127 control: &IndexWorkControl,
6128) -> Result<Option<&'a str>, CliError> {
6129 let Some(source_parent) = source_parent else {
6130 if relation.source_name == MODULE_RELATION_SOURCE {
6132 return Ok(Some(""));
6133 }
6134 let mut global_function = false;
6135 let mut namespace = None;
6136 for (candidate_index, &row_index) in
6137 symbol_index.get(&relation.source_name).iter().enumerate()
6138 {
6139 check_graph_work(control, candidate_index)?;
6140 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6141 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6142 })?;
6143 if matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method)
6144 && symbol.line_start <= relation.line
6145 && relation.line <= symbol.line_end
6146 {
6147 if symbol.kind != SymbolKind::Function || symbol.parent.is_some() {
6148 return Ok(None);
6149 }
6150 global_function = true;
6151 } else if symbol.kind == SymbolKind::Module {
6152 namespace = Some(symbol.name.as_str());
6153 }
6154 }
6155 return Ok(if global_function { Some("") } else { namespace });
6156 };
6157 let mut namespace = None;
6158 let mut module_namespace = None;
6159 let mut source_found = false;
6160 for (candidate_index, &row_index) in symbol_index.get(source_parent).iter().enumerate() {
6161 check_graph_work(control, candidate_index)?;
6162 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6163 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6164 })?;
6165 let candidate = match symbol.kind {
6166 SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
6167 if relation.line < symbol.line_start || relation.line > symbol.line_end {
6168 continue;
6169 }
6170 Some(symbol.parent.as_deref().unwrap_or(""))
6171 }
6172 SymbolKind::Module if symbol.name == source_parent => {
6173 module_namespace = Some(symbol.name.as_str());
6174 continue;
6175 }
6176 _ => continue,
6177 };
6178 if !source_found {
6179 namespace = candidate;
6180 source_found = true;
6181 } else if namespace != candidate {
6182 return Ok(None);
6183 }
6184 }
6185 Ok(namespace.or(module_namespace))
6187}
6188
6189fn unique_source_parent<'a>(
6191 graph: &'a SymbolGraph,
6192 symbol_index: &GraphSymbolIndex<'_>,
6193 source_name: &str,
6194 relation_line: Option<usize>,
6195 control: &IndexWorkControl,
6196) -> Result<Option<&'a str>, CliError> {
6197 if let Some(line) = relation_line {
6198 let Some(index) = unique_php_call_source(graph, symbol_index, source_name, line, control)?
6199 else {
6200 return Ok(None);
6201 };
6202 let symbol = graph.symbols.get(index).ok_or_else(|| {
6203 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6204 })?;
6205 return Ok(symbol.parent.as_deref());
6206 }
6207 let mut parent = None;
6208 let mut source_found = false;
6209 for (candidate_index, &row_index) in symbol_index.get(source_name).iter().enumerate() {
6210 check_graph_work(control, candidate_index)?;
6211 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
6212 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6213 })?;
6214 let candidate = symbol.parent.as_deref();
6215 if !source_found {
6216 parent = candidate;
6217 source_found = true;
6218 } else if parent != candidate {
6219 return Ok(None);
6220 }
6221 }
6222 Ok(parent)
6223}
6224
6225fn unique_php_containment_source(
6227 graph: &SymbolGraph,
6228 symbol_index: &GraphSymbolIndex<'_>,
6229 relation: &SymbolRelation,
6230 control: &IndexWorkControl,
6231) -> Result<Option<usize>, CliError> {
6232 let mut target = None;
6233 for (candidate_index, &index) in symbol_index.get(&relation.target_name).iter().enumerate() {
6234 check_graph_work(control, candidate_index)?;
6235 let symbol = graph.symbols.get(index).ok_or_else(|| {
6236 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6237 })?;
6238 if symbol.parent.as_deref() == Some(relation.source_name.as_str())
6239 && symbol.line_start == relation.line
6240 && symbol.detail.as_deref() == Some(relation.context.as_str())
6241 && target.replace(index).is_some()
6242 {
6243 return Ok(None);
6244 }
6245 }
6246 let Some(target) = target else {
6247 return Ok(None);
6248 };
6249 let Some(target_span) = graph.symbols[target].source_selector else {
6250 return Ok(None);
6251 };
6252 let mut matched = None;
6253 let mut module = None;
6254 for (candidate_index, &index) in symbol_index.get(&relation.source_name).iter().enumerate() {
6255 check_graph_work(control, candidate_index)?;
6256 let symbol = graph.symbols.get(index).ok_or_else(|| {
6257 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6258 })?;
6259 let contains = match symbol.kind {
6260 SymbolKind::Module => {
6261 module.get_or_insert(index);
6262 false
6263 }
6264 SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
6265 index != target
6266 && symbol.source_selector.is_some_and(|span| {
6267 span.byte_start <= target_span.byte_start
6268 && target_span.byte_end <= span.byte_end
6269 })
6270 }
6271 _ => false,
6272 };
6273 if contains && matched.replace(index).is_some() {
6274 return Ok(None);
6275 }
6276 }
6277 Ok(matched.or(module))
6278}
6279
6280fn unique_php_call_source(
6282 graph: &SymbolGraph,
6283 symbol_index: &GraphSymbolIndex<'_>,
6284 source_name: &str,
6285 line: usize,
6286 control: &IndexWorkControl,
6287) -> Result<Option<usize>, CliError> {
6288 let mut callable = None;
6289 let mut callable_boundary = false;
6290 let mut module = None;
6291 for (candidate_index, &index) in symbol_index.get(source_name).iter().enumerate() {
6292 check_graph_work(control, candidate_index)?;
6293 let symbol = graph.symbols.get(index).ok_or_else(|| {
6294 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
6295 })?;
6296 match symbol.kind {
6297 SymbolKind::Function | SymbolKind::Method
6298 if symbol.line_start <= line && line <= symbol.line_end =>
6299 {
6300 if callable.replace(index).is_some() {
6301 return Ok(None);
6302 }
6303 callable_boundary = line == symbol.line_start || line == symbol.line_end;
6304 }
6305 SymbolKind::Module => {
6306 module = Some(index);
6308 }
6309 _ => {}
6310 }
6311 }
6312 if callable.is_some() {
6313 Ok(if module.is_some() && callable_boundary {
6316 None
6317 } else {
6318 callable
6319 })
6320 } else {
6321 Ok(module)
6322 }
6323}
6324
6325#[derive(Clone, Copy)]
6327enum ResolutionCandidateKind {
6328 Any,
6330 Declaration,
6332}
6333
6334fn registry_resolution_matches<'a>(
6336 dependency_keys: &[CanonicalResolutionKey],
6337 kind: ResolutionCandidateKind,
6338 candidates: &'a ProjectResolutionRegistry,
6339 staged_entities: &'a BTreeMap<String, GraphEntity>,
6340 control: &IndexWorkControl,
6341) -> Result<ResolutionMatches<'a>, CliError> {
6342 enforce_resolution_match_budget(dependency_keys.len().saturating_mul(2))?;
6343 let mut streams = dependency_keys
6344 .iter()
6345 .filter_map(|key| candidates.candidate_digests_by_key.get(key))
6346 .map(BTreeSet::iter)
6347 .collect::<Vec<_>>();
6348 let mut frontier = BinaryHeap::new();
6349 for (stream_index, stream) in streams.iter_mut().enumerate() {
6350 if let Some(digest) = stream.next() {
6351 frontier.push(Reverse((digest.as_str(), stream_index)));
6352 }
6353 }
6354
6355 let mut first = None;
6356 let mut last_digest = None;
6357 let mut count = 0_u32;
6358 let mut visited = 0_usize;
6359 let mut forwarding_marker = false;
6360 while let Some(Reverse((digest, stream_index))) = frontier.pop() {
6361 check_graph_work(control, visited)?;
6362 visited = visited.saturating_add(1);
6363 if last_digest != Some(digest) {
6364 let entity = staged_entities
6365 .get(digest)
6366 .or_else(|| candidates.supplemental_entities_by_digest.get(digest))
6367 .ok_or_else(|| {
6368 CliError::InvalidInput(
6369 "resolution candidate entity was not registered".to_string(),
6370 )
6371 })?;
6372 forwarding_marker |= matches!(entity.selector(), EntitySelector::File { .. });
6373 if matches!(kind, ResolutionCandidateKind::Any)
6374 || matches!(entity.selector(), EntitySelector::Symbol { .. })
6375 {
6376 first.get_or_insert(entity);
6377 count = count.checked_add(1).ok_or_else(|| {
6378 IndexWorkFailure::resource_limit(
6379 IndexWorkStage::SymbolParsing,
6380 IndexWorkResource::RelationRows,
6381 u64::from(u32::MAX),
6382 u64::from(u32::MAX) + 1,
6383 )
6384 })?;
6385 }
6386 last_digest = Some(digest);
6387 }
6388 if let Some(next) = streams[stream_index].next() {
6389 frontier.push(Reverse((next.as_str(), stream_index)));
6390 }
6391 }
6392 if forwarding_marker && matches!(kind, ResolutionCandidateKind::Declaration) {
6393 return Ok(ResolutionMatches {
6396 first: None,
6397 count: 0,
6398 });
6399 }
6400 Ok(ResolutionMatches { first, count })
6401}
6402
6403fn distinct_resolution_count(count: usize) -> Result<u32, CliError> {
6405 u32::try_from(count).map_err(|_conversion_error| {
6406 IndexWorkFailure::resource_limit(
6407 IndexWorkStage::SymbolParsing,
6408 IndexWorkResource::RelationRows,
6409 u64::from(u32::MAX),
6410 u64::try_from(count).unwrap_or(u64::MAX),
6411 )
6412 .into()
6413 })
6414}
6415
6416fn enforce_resolution_match_budget(rows: usize) -> Result<(), CliError> {
6418 enforce_resolution_registry_budget(
6419 STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(rows).unwrap_or(u64::MAX)),
6420 )
6421}
6422
6423fn explicit_external_selector(
6425 graph: &SymbolGraph,
6426 relation: &projectatlas_core::symbols::SymbolRelation,
6427) -> Result<Option<ExternalSelector>, CliError> {
6428 let semantic_provider = graph
6429 .language
6430 .as_deref()
6431 .and_then(language_capability)
6432 .and_then(|capability| capability.effective_semantic_provider());
6433 let classified = match (semantic_provider, relation.kind) {
6434 (Some(SemanticProviderOwner::Cargo), RelationKind::DependsOn) => {
6435 external_reference_identity(&relation.target_name)
6436 .map(|identity| (CARGO_PACKAGE_MANAGER, identity))
6437 }
6438 (Some(SemanticProviderOwner::Rust), RelationKind::Imports | RelationKind::Calls) => {
6439 rust_toolchain_identity(relation).map(|identity| (RUST_TOOLCHAIN_SYSTEM, identity))
6440 }
6441 (Some(SemanticProviderOwner::EcmaScript), RelationKind::Imports) => {
6442 node_builtin_identity(relation).map(|identity| (NODE_SYSTEM, identity))
6443 }
6444 (
6445 Some(
6446 SemanticProviderOwner::Python
6447 | SemanticProviderOwner::Unavailable
6448 | SemanticProviderOwner::Cargo
6449 | SemanticProviderOwner::EcmaScript,
6450 )
6451 | None,
6452 _,
6453 )
6454 | (Some(SemanticProviderOwner::Rust), RelationKind::Contains | RelationKind::DependsOn) => {
6455 None
6456 }
6457 };
6458 classified
6459 .map(|(system, identity)| {
6460 Ok(ExternalSelector {
6461 system: GraphIdentityText::new(system).map_err(invalid_graph_contract)?,
6462 identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
6463 })
6464 })
6465 .transpose()
6466}
6467
6468fn source_external_selector(
6470 graph: &SymbolGraph,
6471 relation: &SymbolRelation,
6472 relation_index: Option<usize>,
6473 index: &GraphSymbolIndex<'_>,
6474) -> Result<Option<ExternalSelector>, CliError> {
6475 let rust = graph
6476 .language
6477 .as_deref()
6478 .and_then(language_capability)
6479 .and_then(|capability| capability.effective_semantic_provider())
6480 == Some(SemanticProviderOwner::Rust);
6481 if rust {
6482 let reference = match relation.kind {
6483 RelationKind::Calls if index.source_calls.is_some() => {
6484 match relation_index
6485 .and_then(|ordinal| index.source_calls.as_ref()?.get(&ordinal).copied())
6486 .map(|call| &call.binding)
6487 {
6488 Some(SourceCallBinding::Qualified(reference)) => Some(reference),
6489 Some(SourceCallBinding::Imported { rust_toolchain, .. }) => {
6490 rust_toolchain.as_deref()
6491 }
6492 _ => None,
6493 }
6494 }
6495 RelationKind::Imports if index.rust_toolchain_imports.is_some() => relation_index
6496 .and_then(|ordinal| index.rust_toolchain_imports?.get(&ordinal))
6497 .and_then(Option::as_ref),
6498 _ => return explicit_external_selector(graph, relation),
6499 };
6500 return reference
6501 .and_then(rust_import_identity)
6502 .map(|identity| {
6503 Ok(ExternalSelector {
6504 system: GraphIdentityText::new(RUST_TOOLCHAIN_SYSTEM)
6505 .map_err(invalid_graph_contract)?,
6506 identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
6507 })
6508 })
6509 .transpose();
6510 }
6511 explicit_external_selector(graph, relation)
6512}
6513
6514fn external_reference_identity(reference: &str) -> Option<String> {
6516 let reference = reference.trim();
6517 (!reference.is_empty()).then(|| reference.to_string())
6518}
6519
6520fn rust_toolchain_identity(
6522 relation: &projectatlas_core::symbols::SymbolRelation,
6523) -> Option<String> {
6524 if relation.kind == RelationKind::Imports {
6525 let mut references = parse_import_references(&relation.target_name).into_iter();
6526 let first = rust_import_identity(&references.next()?)?;
6527 return references.try_fold(first, |common, reference| {
6528 let identity = rust_import_identity(&reference)?;
6529 common_rust_path(&common, &identity)
6530 });
6531 }
6532 let target = relation_reference(relation);
6533 rust_toolchain_root(&target)?;
6534 Some(target)
6535}
6536
6537fn rust_import_identity(reference: &projectatlas_symbols::ImportReference) -> Option<String> {
6539 let module = reference.module();
6540 rust_toolchain_root(module)?;
6541 Some(reference.imported().map_or_else(
6542 || module.to_string(),
6543 |imported| format!("{module}::{imported}"),
6544 ))
6545}
6546
6547fn common_rust_path(left: &str, right: &str) -> Option<String> {
6549 let components = left
6550 .split("::")
6551 .zip(right.split("::"))
6552 .take_while(|(left, right)| left == right)
6553 .map(|(component, _right)| component)
6554 .collect::<Vec<_>>();
6555 (!components.is_empty()).then(|| components.join("::"))
6556}
6557
6558fn rust_toolchain_root(path: &str) -> Option<&str> {
6560 let root = path.trim().split("::").next()?;
6561 matches!(root, "std" | "core" | "alloc").then_some(root)
6562}
6563
6564fn node_builtin_identity(relation: &projectatlas_core::symbols::SymbolRelation) -> Option<String> {
6566 parse_import_references(&relation.target_name)
6567 .into_iter()
6568 .find_map(|reference| {
6569 reference
6570 .module()
6571 .strip_prefix("node:")
6572 .filter(|identity| !identity.is_empty())
6573 .map(ToString::to_string)
6574 })
6575 .or_else(|| {
6576 quoted_ecmascript_module(&relation.target_name)
6577 .and_then(|module| module.strip_prefix("node:"))
6578 .filter(|identity| !identity.is_empty())
6579 .map(ToString::to_string)
6580 })
6581}
6582
6583fn quoted_ecmascript_module(import: &str) -> Option<&str> {
6585 let import = import.trim();
6586 if !import.starts_with("import ") {
6587 return None;
6588 }
6589 let (quote_index, quote) = import
6590 .char_indices()
6591 .find(|(_index, character)| matches!(character, '\'' | '"'))?;
6592 let remainder = &import[quote_index + quote.len_utf8()..];
6593 let end = remainder.find(quote)?;
6594 Some(&remainder[..end])
6595}
6596
6597fn nonempty_reference(value: &str) -> String {
6599 let value = value.trim();
6600 if value.is_empty() {
6601 UNKNOWN_REFERENCE.to_string()
6602 } else {
6603 value.to_string()
6604 }
6605}
6606
6607fn relation_reference(relation: &SymbolRelation) -> String {
6609 let mut value = if relation.kind == RelationKind::Calls {
6610 relation
6611 .target_name
6612 .split_once('(')
6613 .map_or(relation.target_name.as_str(), |(target, _arguments)| target)
6614 } else {
6615 &relation.target_name
6616 };
6617 if relation.kind == RelationKind::Calls && value.contains(['\'', '"']) {
6618 value = call_leaf(value);
6619 }
6620 nonempty_reference(value)
6621}
6622
6623pub(super) fn is_document_coverage_marker(graph: &SymbolGraph, symbol: &CodeSymbol) -> bool {
6625 matches!(graph.language.as_deref(), Some("docx" | "pdf"))
6626 && symbol.name == DOCUMENT_COVERAGE_SYMBOL
6627 && symbol.kind == SymbolKind::Unknown
6628}
6629
6630pub(super) fn navigable_symbol_count(graph: &SymbolGraph) -> usize {
6632 graph
6633 .symbols
6634 .iter()
6635 .filter(|symbol| !is_document_coverage_marker(graph, symbol))
6636 .count()
6637}
6638
6639fn coverage_for_graph(
6641 graph: &SymbolGraph,
6642 generation: IndexGeneration,
6643 identity_admission: &GraphIdentityAdmission,
6644 derived_identity_admission: &GraphIdentityAdmission,
6645) -> Result<CoverageRecord, CliError> {
6646 let scope = CoverageScope::Path {
6647 path: RepositoryNodePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
6648 };
6649 let identity_omitted =
6650 identity_admission.rejected_facts_for_graph(&graph.path, derived_identity_admission)?;
6651 let parser_omitted = identity_admission.parser_rejection_for_graph(&graph.path);
6652 let document_gaps = graph
6653 .symbols
6654 .iter()
6655 .find(|symbol| is_document_coverage_marker(graph, symbol))
6656 .map(|symbol| symbol.signature.as_str());
6657 let omitted = identity_omitted
6658 .checked_add(parser_omitted)
6659 .and_then(|omitted| omitted.checked_add(u64::from(document_gaps.is_some())))
6660 .ok_or_else(|| CliError::InvalidInput("identity rejection count overflowed".to_string()))?;
6661 let identity_details_dropped = identity_admission.rejection_details_dropped_for(&graph.path)
6662 || derived_identity_admission.rejection_details_dropped_for(&graph.path);
6663 let reached_limit = if identity_details_dropped {
6668 Some(GraphLimitKind::Rows)
6669 } else if document_gaps.is_some_and(|gaps| gaps.contains("resource_limit:output_bytes")) {
6670 Some(GraphLimitKind::OutputBytes)
6671 } else if document_gaps.is_some_and(|gaps| gaps.contains("resource_limit:fact_count")) {
6672 Some(GraphLimitKind::Rows)
6673 } else if document_gaps.is_some_and(|gaps| {
6674 gaps.contains("resource_limit:memory_bytes")
6675 || gaps.contains("resource_limit:parser_work_bytes")
6676 || gaps.contains("resource_limit:expanded_bytes")
6677 }) {
6678 Some(GraphLimitKind::IntermediateBytes)
6679 } else {
6680 None
6681 };
6682 let covered = if identity_omitted > 0 || document_gaps.is_some() {
6683 u64::try_from(
6684 graph
6685 .symbols
6686 .len()
6687 .saturating_sub(usize::from(document_gaps.is_some()))
6688 .saturating_add(graph.relations.len()),
6689 )
6690 .unwrap_or(u64::MAX)
6691 } else {
6692 u64::try_from(graph.relations.len()).unwrap_or(u64::MAX)
6693 };
6694 if omitted > 0 {
6695 let state = if covered > 0 {
6696 CoverageState::Partial
6697 } else {
6698 CoverageState::Failed
6699 };
6700 let reason = document_gaps.map_or_else(
6701 || PARTIAL_COVERAGE_REASON.to_owned(),
6702 |gaps| format!("document_text_incomplete:{gaps}"),
6703 );
6704 return CoverageRecord::new(
6705 scope,
6706 None,
6707 state,
6708 covered,
6709 omitted,
6710 generation,
6711 Some(GraphIdentityText::new(reason).map_err(invalid_graph_contract)?),
6712 reached_limit,
6713 )
6714 .map_err(invalid_graph_contract);
6715 }
6716 match graph.parser {
6717 ParserKind::TreeSitter | ParserKind::Manifest => CoverageRecord::new(
6718 scope,
6719 None,
6720 CoverageState::Complete,
6721 covered,
6722 0,
6723 generation,
6724 None,
6725 None,
6726 )
6727 .map_err(invalid_graph_contract),
6728 ParserKind::Structural | ParserKind::Fallback if covered > 0 => CoverageRecord::new(
6729 scope,
6730 None,
6731 CoverageState::Partial,
6732 covered,
6733 1,
6734 generation,
6735 Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
6736 None,
6737 )
6738 .map_err(invalid_graph_contract),
6739 ParserKind::Structural | ParserKind::Fallback => CoverageRecord::new(
6740 scope,
6741 None,
6742 CoverageState::Failed,
6743 0,
6744 1,
6745 generation,
6746 Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
6747 None,
6748 )
6749 .map_err(invalid_graph_contract),
6750 }
6751}
6752
6753fn relation_confidence(parser: ParserKind) -> ConfidenceClass {
6755 match parser {
6756 ParserKind::TreeSitter | ParserKind::Manifest => ConfidenceClass::Exact,
6757 ParserKind::Structural => ConfidenceClass::Medium,
6758 ParserKind::Fallback => ConfidenceClass::Low,
6759 }
6760}
6761
6762fn relation_completeness(parser: ParserKind) -> Completeness {
6764 match parser {
6765 ParserKind::TreeSitter | ParserKind::Manifest => Completeness::Complete,
6766 ParserKind::Structural | ParserKind::Fallback => Completeness::Partial,
6767 }
6768}
6769
6770fn reserve_identity_hydration_scratch(
6773 report: &mut GraphIdentityAdmission,
6774 scratch: &mut u64,
6775 limit: u64,
6776 additional: u64,
6777) -> Result<(), CliError> {
6778 let next = scratch.saturating_add(additional);
6779 enforce_resolution_registry_budget_with_limit(
6780 next.saturating_add(report.observed_fact_bytes),
6781 limit,
6782 )?;
6783 *scratch = next;
6784 report.retained_limit = Some(limit.saturating_sub(next));
6785 Ok(())
6786}
6787
6788fn hydrate_reused_identity_admission(
6796 store: &AtlasStore,
6797 project: ProjectInstanceId,
6798 reused_paths: &BTreeSet<String>,
6799 graphs: &[impl Borrow<SymbolGraph>],
6800 report: &mut GraphIdentityAdmission,
6801 control: &IndexWorkControl,
6802) -> Result<(), CliError> {
6803 let previous_limit = report.retained_limit;
6804 let result = (|| -> Result<(), CliError> {
6805 if reused_paths.is_empty() {
6806 return Ok(());
6807 }
6808 let phase_limit = report
6809 .retained_limit
6810 .unwrap_or(super::MAX_PUBLICATION_STAGING_BYTES);
6811 let mut scratch_bytes = 0_u64;
6812 reserve_identity_hydration_scratch(
6813 report,
6814 &mut scratch_bytes,
6815 phase_limit,
6816 reused_paths.iter().fold(0_u64, |bytes, path| {
6817 bytes.saturating_add(128).saturating_add(path.len() as u64)
6818 }),
6819 )?;
6820 let reused_node_paths = reused_paths
6821 .iter()
6822 .map(|path| RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract))
6823 .collect::<Result<Vec<_>, _>>()?;
6824 let mut persisted_rejections = Vec::new();
6825 let mut persisted_coverage = BTreeMap::new();
6826 let mut persisted_rejection_details_dropped_by_path = BTreeSet::new();
6827 for paths in reused_node_paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
6828 control.check(IndexWorkStage::SymbolParsing)?;
6829 let rejection_rows = match store.repository_graph_identity_rejections(
6830 project,
6831 paths,
6832 GraphLimits::MAX_ROWS,
6833 Some(control),
6834 ) {
6835 Ok(rows) => rows,
6836 Err(projectatlas_db::DbError::GraphRowShape { table, reason })
6837 if table == "project_identity"
6838 && reason
6839 == "typed graph generation does not match complete publication" =>
6840 {
6841 return Ok(());
6844 }
6845 Err(error) => return Err(error.into()),
6846 };
6847 let row_bytes = rejection_rows.iter().fold(
6848 (rejection_rows.capacity() as u64)
6849 .saturating_mul((2 * std::mem::size_of::<GraphIdentityRejection>()) as u64),
6850 |bytes, row| bytes.saturating_add(row.path.as_str().len() as u64),
6851 );
6852 reserve_identity_hydration_scratch(report, &mut scratch_bytes, phase_limit, row_bytes)?;
6853 persisted_rejections.extend(rejection_rows);
6854 let coverage = match store.repository_graph_path_coverage(project, paths, Some(control))
6855 {
6856 Ok(coverage) => coverage,
6857 Err(projectatlas_db::DbError::GraphRowShape { table, reason })
6858 if table == "project_identity"
6859 && reason
6860 == "typed graph generation does not match complete publication" =>
6861 {
6862 return Ok(());
6863 }
6864 Err(error) => return Err(error.into()),
6865 };
6866 if coverage.truncated {
6867 return Err(CliError::InvalidInput(
6868 "reused graph coverage exceeded the publication row ceiling".to_string(),
6869 ));
6870 }
6871 for row in coverage.rows {
6872 let CoverageScope::Path { path } = row.scope() else {
6873 continue;
6874 };
6875 if row.relation().is_none() && row.omitted() > 0 {
6876 control.check(IndexWorkStage::SymbolParsing)?;
6877 reserve_identity_hydration_scratch(
6878 report,
6879 &mut scratch_bytes,
6880 phase_limit,
6881 STAGED_GRAPH_ROW_BYTES.saturating_add(path.as_str().len() as u64),
6882 )?;
6883 persisted_coverage.insert(path.as_str().to_owned(), row.omitted());
6884 }
6885 if row.relation().is_none() && row.reached_limit() == Some(GraphLimitKind::Rows) {
6886 control.check(IndexWorkStage::SymbolParsing)?;
6887 reserve_identity_hydration_scratch(
6888 report,
6889 &mut scratch_bytes,
6890 phase_limit,
6891 STAGED_GRAPH_ROW_BYTES.saturating_add(path.as_str().len() as u64),
6892 )?;
6893 persisted_rejection_details_dropped_by_path.insert(path.as_str().to_owned());
6894 }
6895 }
6896 }
6897 let mut persisted_fact_counts = BTreeMap::<String, BTreeSet<IdentityFactKey>>::new();
6898 for path in persisted_rejection_details_dropped_by_path {
6899 control.check(IndexWorkStage::SymbolParsing)?;
6900 if report
6901 .rejection_details_dropped_by_path
6902 .insert(path.clone())
6903 {
6904 report.reserve_reused_path_bytes(
6905 &path,
6906 false,
6907 control,
6908 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6909 )?;
6910 }
6911 }
6912 for rejection in persisted_rejections {
6913 control.check(IndexWorkStage::SymbolParsing)?;
6914 let namespace = rejection.fact_index & !((1_u64 << 56) - 1);
6915 let path = rejection.path.as_str().to_owned();
6916 reserve_identity_hydration_scratch(
6917 report,
6918 &mut scratch_bytes,
6919 phase_limit,
6920 STAGED_GRAPH_ROW_BYTES
6921 .saturating_mul(2)
6922 .saturating_add(path.len() as u64),
6923 )?;
6924 persisted_fact_counts
6925 .entry(path.clone())
6926 .or_default()
6927 .insert(IdentityFactKey {
6928 span: IdentitySpan {
6929 start_line: rejection.span.start_line() as usize,
6930 start_column: rejection.span.start_column() as usize,
6931 end_line: rejection.span.end_line() as usize,
6932 end_column: rejection.span.end_column() as usize,
6933 },
6934 parser: parser_fact_kind(rejection.parser),
6935 owner: u8::from(rejection.field == GraphIdentityField::ResolutionKey),
6936 fact_index: rejection.fact_index,
6937 });
6938 if namespace == DERIVED_RELATION_FACT_INDEX_NAMESPACE {
6941 continue;
6942 }
6943 control.check(IndexWorkStage::SymbolParsing)?;
6944 let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths_limit(
6945 &mut report.rejections,
6946 &mut report.rejection_keys,
6947 std::iter::once(rejection),
6948 &mut report.rejection_details_dropped_by_path,
6949 report
6950 .retained_limit
6951 .unwrap_or(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
6952 .min(MAX_IN_MEMORY_GRAPH_WORK_BYTES)
6953 .saturating_sub(report.observed_fact_bytes),
6954 )?;
6955 report.reserve_identity_bytes(
6956 rejection_key_bytes,
6957 control,
6958 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6959 )?;
6960 }
6961 for (path, facts) in persisted_fact_counts {
6962 control.check(IndexWorkStage::SymbolParsing)?;
6963 if !report.reused_rejection_facts.contains_key(&path) {
6964 report.reserve_identity_bytes(
6965 identity_fact_set_retained_bytes(&path, &facts)?,
6966 control,
6967 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6968 )?;
6969 report.reused_rejection_facts.insert(path.clone(), facts);
6970 }
6971 let detail_count = u64::try_from(
6972 report
6973 .reused_rejection_facts
6974 .get(&path)
6975 .map_or(0, BTreeSet::len),
6976 )
6977 .map_err(|error| {
6978 CliError::InvalidInput(format!(
6979 "identity rejection detail count overflowed: {error}"
6980 ))
6981 })?;
6982 if !report.reused_rejection_detail_counts.contains_key(&path) {
6983 report.reserve_reused_path_bytes(
6984 &path,
6985 false,
6986 control,
6987 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6988 )?;
6989 report
6990 .reused_rejection_detail_counts
6991 .insert(path, detail_count);
6992 }
6993 }
6994 for (path, omitted) in persisted_coverage {
6995 control.check(IndexWorkStage::SymbolParsing)?;
6999 if !report.reused_rejection_counts.contains_key(&path) {
7000 report.reserve_reused_path_bytes(
7001 &path,
7002 false,
7003 control,
7004 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7005 )?;
7006 report.reused_rejection_counts.insert(path, omitted);
7007 }
7008 }
7009 reserve_identity_hydration_scratch(
7010 report,
7011 &mut scratch_bytes,
7012 phase_limit,
7013 (graphs.len() as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES),
7014 )?;
7015 let graph_parsers = graphs
7016 .iter()
7017 .map(|graph| {
7018 let graph = Borrow::<SymbolGraph>::borrow(graph);
7019 (graph.path.as_str(), graph.parser)
7020 })
7021 .collect::<BTreeMap<_, _>>();
7022 for path in reused_paths {
7023 let Some(_omitted) = report.reused_rejection_counts.get(path).copied() else {
7024 continue;
7025 };
7026 let details = report
7027 .reused_rejection_detail_counts
7028 .get(path)
7029 .copied()
7030 .unwrap_or(0);
7031 let parser = graph_parsers.get(path.as_str()).copied();
7032 let baseline = parser.map_or(0, |parser| {
7033 u64::from(matches!(
7034 parser,
7035 ParserKind::Structural | ParserKind::Fallback
7036 ))
7037 });
7038 if details == 0 && baseline > 0 {
7039 if !report.reused_parser_rejection_counts.contains_key(path) {
7044 report.reserve_reused_path_bytes(
7045 path,
7046 false,
7047 control,
7048 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7049 )?;
7050 report
7051 .reused_parser_rejection_counts
7052 .insert(path.clone(), baseline);
7053 }
7054 }
7055 if parser != Some(ParserKind::Fallback)
7060 && report.rejection_details_dropped_for(path)
7061 && !report.reused_rejection_details_incomplete.contains(path)
7062 {
7063 report.reserve_reused_path_bytes(
7064 path,
7065 false,
7066 control,
7067 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7068 )?;
7069 report
7070 .reused_rejection_details_incomplete
7071 .insert(path.clone());
7072 }
7073 }
7074 let retained_bytes =
7075 checked_identity_admission_budget(report, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
7076 report.observed_fact_bytes = retained_bytes;
7077 report.retained_limit = Some(phase_limit);
7078 Ok(())
7079 })();
7080 report.retained_limit = previous_limit;
7081 result
7082}
7083
7084struct CapturedSourceGraph {
7086 graph: SymbolGraph,
7088 semantics: SourceSemanticFacts,
7090 admission: GraphIdentityAdmission,
7092}
7093
7094enum SourceGraph<'a> {
7096 Staged {
7098 graph: &'a SymbolGraph,
7100 semantics: Option<&'a SourceSemanticFacts>,
7102 },
7103 Captured(Arc<CapturedSourceGraph>),
7105 Persisted(SymbolGraph),
7107}
7108
7109impl SourceGraph<'_> {
7110 fn graph(&self) -> &SymbolGraph {
7112 match self {
7113 Self::Staged { graph, .. } => graph,
7114 Self::Captured(source) => &source.graph,
7115 Self::Persisted(graph) => graph,
7116 }
7117 }
7118
7119 fn semantics(&self) -> Option<&SourceSemanticFacts> {
7121 match self {
7122 Self::Staged { semantics, .. } => *semantics,
7123 Self::Captured(source) => Some(&source.semantics),
7124 Self::Persisted(_) => None,
7125 }
7126 }
7127}
7128
7129impl Borrow<SymbolGraph> for SourceGraph<'_> {
7130 fn borrow(&self) -> &SymbolGraph {
7131 self.graph()
7132 }
7133}
7134
7135impl AsRef<SymbolGraph> for SourceGraph<'_> {
7136 fn as_ref(&self) -> &SymbolGraph {
7137 self.graph()
7138 }
7139}
7140
7141impl<'a> From<Cow<'a, SymbolGraph>> for SourceGraph<'a> {
7142 fn from(graph: Cow<'a, SymbolGraph>) -> Self {
7143 match graph {
7144 Cow::Borrowed(graph) => Self::Staged {
7145 graph,
7146 semantics: None,
7147 },
7148 Cow::Owned(graph) => Self::Persisted(graph),
7149 }
7150 }
7151}
7152
7153#[derive(Clone, Copy, Debug, Default)]
7155struct SourceRecoveryWork {
7156 previously_unchanged: usize,
7158 additional_candidates: usize,
7160}
7161
7162struct SourceBindingCache<'a> {
7164 retained_limit: u64,
7166 root: &'a Path,
7168 nodes: Arc<BTreeMap<&'a str, &'a Node>>,
7170 direct_paths: Option<BTreeSet<&'a str>>,
7172 captured: BTreeMap<String, Arc<CapturedSourceGraph>>,
7174 cargo_cohorts: BTreeMap<String, Arc<CargoRootCohortIndex>>,
7176 work: SourceRecoveryWork,
7178 retained_bytes: u64,
7180}
7181
7182impl<'a> SourceBindingCache<'a> {
7183 fn new(root: &'a Path, nodes: &'a [Node], direct_paths: Option<&BTreeSet<String>>) -> Self {
7185 Self {
7186 root,
7187 retained_limit: super::MAX_PUBLICATION_STAGING_BYTES,
7188 nodes: Arc::new(
7189 nodes
7190 .iter()
7191 .filter(|node| node.kind == NodeKind::File)
7192 .map(|node| (node.path.as_str(), node))
7193 .collect(),
7194 ),
7195 direct_paths: direct_paths.map(|direct| {
7196 nodes
7197 .iter()
7198 .filter(|node| direct.contains(&node.path))
7199 .map(|node| node.path.as_str())
7200 .collect()
7201 }),
7202 captured: BTreeMap::new(),
7203 cargo_cohorts: BTreeMap::new(),
7204 work: SourceRecoveryWork::default(),
7205 retained_bytes: (nodes.len() as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES),
7206 }
7207 }
7208
7209 fn set_retained_limit(&mut self, limit: u64) -> Result<(), CliError> {
7211 enforce_resolution_registry_budget_with_limit(self.retained_bytes, limit)?;
7212 self.retained_limit = limit;
7213 Ok(())
7214 }
7215
7216 fn capture(
7218 &mut self,
7219 path: &str,
7220 control: &IndexWorkControl,
7221 ) -> Result<Arc<CapturedSourceGraph>, CliError> {
7222 if let Some(source) = self.captured.get(path) {
7223 return Ok(Arc::clone(source));
7224 }
7225 control.check(IndexWorkStage::SymbolParsing)?;
7226 let node = self
7227 .nodes
7228 .get(path)
7229 .ok_or_else(|| source_changed_during_derivation(self.root, path))?;
7230 let native_path = self.root.join(Path::new(path));
7231 let bytes = match read_source_bytes_controlled(
7232 &native_path,
7233 MAX_SYMBOL_FILE_BYTES,
7234 IndexWorkStage::SymbolParsing,
7235 control,
7236 ) {
7237 Ok(bytes) => bytes,
7238 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
7239 Err(SourceReadFailure::LimitExceeded { .. }) => {
7240 return Err(source_changed_during_derivation(self.root, path));
7241 }
7242 Err(SourceReadFailure::Io(source)) => {
7243 return Err(CliError::Io {
7244 path: native_path,
7245 source,
7246 });
7247 }
7248 };
7249 if node
7250 .content_hash
7251 .as_deref()
7252 .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
7253 {
7254 return Err(source_changed_during_derivation(self.root, path));
7255 }
7256 let content = String::from_utf8(bytes)
7257 .map_err(|_source| source_changed_during_derivation(self.root, path))?;
7258 let (_, graph, mut semantics) = extract_symbol_graph_with_semantics_controlled(
7259 path,
7260 node.language.as_deref(),
7261 &content,
7262 control,
7263 )?
7264 .into_parts();
7265 let identity_limit = self
7266 .retained_limit
7267 .saturating_sub(self.retained_bytes)
7268 .saturating_sub(source_graph_retained_bytes(&graph))
7269 .saturating_sub(semantics.retained_bytes());
7270 let (graph, admission) = admit_symbol_graph_with_semantics_limit(
7271 Cow::Owned(graph),
7272 Some(&mut semantics),
7273 control,
7274 identity_limit,
7275 )?;
7276 let graph = graph.into_owned();
7277 let bytes = source_graph_retained_bytes(&graph)
7278 .saturating_add(semantics.retained_bytes())
7279 .saturating_add(admission.observed_fact_bytes)
7280 .saturating_add(STAGED_GRAPH_ROW_BYTES)
7281 .saturating_add(path.len() as u64);
7282 enforce_resolution_registry_budget_with_limit(
7283 self.retained_bytes.saturating_add(bytes),
7284 self.retained_limit,
7285 )?;
7286 let source = Arc::new(CapturedSourceGraph {
7287 graph,
7288 semantics,
7289 admission,
7290 });
7291 self.retained_bytes = self.retained_bytes.saturating_add(bytes);
7292 if self
7293 .direct_paths
7294 .as_ref()
7295 .is_none_or(|direct| direct.contains(path))
7296 {
7297 self.work.previously_unchanged += 1;
7298 } else {
7299 self.work.additional_candidates += 1;
7300 }
7301 self.captured.insert(path.to_string(), Arc::clone(&source));
7302 control.check(IndexWorkStage::SymbolParsing)?;
7303 Ok(source)
7304 }
7305}
7306
7307fn source_graph_retained_bytes(graph: &SymbolGraph) -> u64 {
7309 let mut bytes = (std::mem::size_of::<SymbolGraph>() as u64)
7310 .saturating_add(graph.path.capacity() as u64)
7311 .saturating_add(graph.language.as_ref().map_or(0, String::capacity) as u64)
7312 .saturating_add(
7313 (graph.symbols.capacity() as u64)
7314 .saturating_mul(std::mem::size_of::<CodeSymbol>() as u64),
7315 )
7316 .saturating_add(
7317 (graph.relations.capacity() as u64)
7318 .saturating_mul(std::mem::size_of::<SymbolRelation>() as u64),
7319 );
7320 for symbol in &graph.symbols {
7321 for text in [&symbol.path, &symbol.name, &symbol.signature] {
7322 bytes = bytes.saturating_add(text.capacity() as u64);
7323 }
7324 for text in [
7325 &symbol.language,
7326 &symbol.documentation,
7327 &symbol.parent,
7328 &symbol.detail,
7329 ]
7330 .into_iter()
7331 .flatten()
7332 {
7333 bytes = bytes.saturating_add(text.capacity() as u64);
7334 }
7335 }
7336 for relation in &graph.relations {
7337 for text in [
7338 &relation.path,
7339 &relation.source_name,
7340 &relation.target_name,
7341 &relation.context,
7342 ] {
7343 bytes = bytes.saturating_add(text.capacity() as u64);
7344 }
7345 }
7346 bytes
7347}
7348
7349fn graph_requires_source_bindings(graph: &SymbolGraph) -> bool {
7351 if graph
7352 .relations
7353 .iter()
7354 .any(|relation| relation.kind == RelationKind::Calls)
7355 {
7356 return true;
7357 }
7358 let provider = graph
7359 .language
7360 .as_deref()
7361 .and_then(language_capability)
7362 .and_then(|capability| capability.effective_semantic_provider());
7363 let embedded = graph
7364 .language
7365 .as_deref()
7366 .and_then(language_capability)
7367 .is_some_and(|capability| capability.embedded_language.is_some());
7368 match provider {
7369 Some(SemanticProviderOwner::Rust) => graph
7370 .relations
7371 .iter()
7372 .any(|relation| relation.kind == RelationKind::Imports),
7373 Some(SemanticProviderOwner::Python) => {
7374 graph
7375 .relations
7376 .iter()
7377 .any(|relation| relation.kind == RelationKind::Imports)
7378 || graph.symbols.iter().any(|symbol| {
7379 symbol.parent.is_none()
7380 && !symbol.name.starts_with('_')
7381 && !matches!(
7382 symbol.kind,
7383 SymbolKind::Import
7384 | SymbolKind::Dependency
7385 | SymbolKind::Package
7386 | SymbolKind::Workspace
7387 | SymbolKind::Heading
7388 )
7389 })
7390 }
7391 Some(SemanticProviderOwner::EcmaScript) => {
7392 graph
7393 .relations
7394 .iter()
7395 .any(|relation| relation.kind == RelationKind::Imports)
7396 || graph.symbols.iter().any(|symbol| {
7397 symbol.parent.is_none()
7398 && (symbol.exported || embedded)
7399 && !matches!(
7400 symbol.kind,
7401 SymbolKind::Import
7402 | SymbolKind::Dependency
7403 | SymbolKind::Package
7404 | SymbolKind::Workspace
7405 | SymbolKind::Heading
7406 )
7407 })
7408 }
7409 Some(SemanticProviderOwner::Cargo | SemanticProviderOwner::Unavailable) | None => false,
7410 }
7411}
7412
7413fn complete_symbol_graphs<'a>(
7415 store: &AtlasStore,
7416 paths: &BTreeSet<String>,
7417 symbols: &'a SymbolBuildStage,
7418 sources: &mut SourceBindingCache<'_>,
7419 control: &IndexWorkControl,
7420) -> Result<Vec<SourceGraph<'a>>, CliError> {
7421 let paths = paths.iter().cloned().collect::<Vec<_>>();
7422 let mut graphs = BTreeMap::new();
7423 for chunk in paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
7424 control.check(IndexWorkStage::SymbolParsing)?;
7425 for graph in store.load_symbol_graphs_for_paths(chunk)? {
7426 graphs.insert(graph.path.clone(), SourceGraph::Persisted(graph));
7427 }
7428 }
7429 for (index, change) in symbols.changes.iter().enumerate() {
7430 check_graph_work(control, index)?;
7431 match change {
7432 SymbolProjectionChange::Parsed(parsed) if paths.binary_search(&parsed.path).is_ok() => {
7433 graphs.insert(
7434 parsed.path.clone(),
7435 SourceGraph::Staged {
7436 graph: &parsed.graph,
7437 semantics: parsed.source_semantics.as_deref(),
7438 },
7439 );
7440 }
7441 SymbolProjectionChange::Clear { path, .. } if paths.binary_search(path).is_ok() => {
7442 graphs.remove(path);
7443 }
7444 SymbolProjectionChange::Parsed(_) | SymbolProjectionChange::Clear { .. } => {}
7445 }
7446 }
7447 graphs.retain(|path, _graph| paths.binary_search(path).is_ok());
7448 for (path, graph) in &mut graphs {
7449 control.check(IndexWorkStage::SymbolParsing)?;
7450 if matches!(graph, SourceGraph::Persisted(_))
7451 && graph_requires_source_bindings(graph.graph())
7452 {
7453 *graph = SourceGraph::Captured(sources.capture(path, control)?);
7454 }
7455 }
7456 Ok(graphs.into_values().collect())
7457}
7458
7459#[cfg(test)]
7461fn admit_symbol_graphs<'a>(
7462 graphs: Vec<impl Into<SourceGraph<'a>>>,
7463 control: &IndexWorkControl,
7464) -> Result<(Vec<SourceGraph<'a>>, GraphIdentityAdmission), CliError> {
7465 admit_symbol_graphs_with_limit(graphs, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)
7466}
7467
7468fn admit_symbol_graphs_with_limit<'a>(
7470 graphs: Vec<impl Into<SourceGraph<'a>>>,
7471 control: &IndexWorkControl,
7472 limit: u64,
7473) -> Result<(Vec<SourceGraph<'a>>, GraphIdentityAdmission), CliError> {
7474 let mut admitted = Vec::with_capacity(graphs.len());
7475 let mut report = GraphIdentityAdmission {
7476 retained_limit: Some(limit),
7477 ..GraphIdentityAdmission::default()
7478 };
7479 for (index, graph) in graphs.into_iter().enumerate() {
7480 check_graph_work(control, index)?;
7481 let (graph, graph_report) = match graph.into() {
7482 SourceGraph::Captured(source) => {
7483 enforce_resolution_registry_budget_with_limit(
7484 report
7485 .observed_fact_bytes
7486 .saturating_add(source.admission.observed_fact_bytes),
7487 limit,
7488 )?;
7489 let report = source.admission.clone();
7490 (SourceGraph::Captured(source), report)
7491 }
7492 SourceGraph::Staged {
7493 graph,
7494 semantics: Some(semantics),
7495 } => {
7496 if !semantics.matches_graph(graph) {
7497 return Err(CliError::InvalidInput(
7498 "fresh source binding evidence does not match its graph".to_string(),
7499 ));
7500 }
7501 (
7502 SourceGraph::Staged {
7503 graph,
7504 semantics: Some(semantics),
7505 },
7506 GraphIdentityAdmission::default(),
7507 )
7508 }
7509 SourceGraph::Staged {
7510 graph,
7511 semantics: None,
7512 } => {
7513 let (graph, report) = admit_symbol_graph_with_semantics_limit(
7514 Cow::Borrowed(graph),
7515 None,
7516 control,
7517 limit.saturating_sub(report.observed_fact_bytes),
7518 )?;
7519 (graph.into(), report)
7520 }
7521 SourceGraph::Persisted(graph) => {
7522 let (graph, report) = admit_symbol_graph_with_semantics_limit(
7523 Cow::Owned(graph),
7524 None,
7525 control,
7526 limit.saturating_sub(report.observed_fact_bytes),
7527 )?;
7528 (graph.into(), report)
7529 }
7530 };
7531 report.merge(graph_report, control)?;
7532 admitted.push(graph);
7533 }
7534 Ok((admitted, report))
7535}
7536
7537#[cfg(test)]
7539fn admit_symbol_graph<'a>(
7540 graph: Cow<'a, SymbolGraph>,
7541 control: &IndexWorkControl,
7542) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7543 admit_symbol_graph_with_semantics(graph, None, control)
7544}
7545
7546#[cfg(test)]
7548fn admit_symbol_graph_with_semantics<'a>(
7549 graph: Cow<'a, SymbolGraph>,
7550 semantics: Option<&mut SourceSemanticFacts>,
7551 control: &IndexWorkControl,
7552) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7553 admit_symbol_graph_with_semantics_limit(
7554 graph,
7555 semantics,
7556 control,
7557 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7558 )
7559}
7560
7561fn admit_symbol_graph_with_semantics_limit<'a>(
7563 graph: Cow<'a, SymbolGraph>,
7564 semantics: Option<&mut projectatlas_symbols::SourceSemanticFacts>,
7565 control: &IndexWorkControl,
7566 limit: u64,
7567) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
7568 if semantics
7569 .as_ref()
7570 .is_some_and(|facts| !facts.matches_graph(&graph))
7571 {
7572 return Err(CliError::InvalidInput(
7573 "source binding evidence does not match the admitted graph".to_string(),
7574 ));
7575 }
7576 let mut report = GraphIdentityAdmission {
7577 retained_limit: Some(limit),
7578 ..GraphIdentityAdmission::default()
7579 };
7580 let paired_import_relations = paired_import_relations(&graph, control)?;
7581 #[cfg(test)]
7582 {
7583 report.paired_import_pairing_work = paired_import_relations.work_items;
7584 }
7585 let mut rejected_symbols = vec![false; graph.symbols.len()];
7586 for (index, symbol) in graph.symbols.iter().enumerate() {
7587 check_graph_work(control, index)?;
7588 let paired_relation_index = paired_import_relations.by_symbol[index];
7589 let span = symbol_identity_span(&graph, index, paired_relation_index);
7590 let mut failures = Vec::new();
7591 let name_field = if symbol.kind == SymbolKind::Package {
7592 GraphIdentityField::Package
7593 } else {
7594 GraphIdentityField::Symbol
7595 };
7596 record_identity_failure(&mut failures, name_field, &symbol.name);
7597 if let Some(parent) = symbol.parent.as_deref() {
7598 record_identity_failure(&mut failures, GraphIdentityField::Parent, parent);
7599 }
7600 let signature = if symbol.signature.is_empty() {
7601 &symbol.name
7602 } else {
7603 &symbol.signature
7604 };
7605 record_identity_failure(&mut failures, GraphIdentityField::Signature, signature);
7606 if !failures.is_empty() {
7607 rejected_symbols[index] = true;
7608 report.record(
7609 &graph.path,
7610 span,
7611 symbol.parser,
7612 symbol_parser_fact_index(index, paired_relation_index),
7613 &failures,
7614 control,
7615 )?;
7616 }
7617 }
7618 let mut rejected_relations = vec![false; graph.relations.len()];
7619 for (index, relation) in graph.relations.iter().enumerate() {
7620 check_graph_work(control, index)?;
7621 let span = IdentitySpan {
7622 start_line: relation.line.max(1),
7623 start_column: 0,
7624 end_line: relation.line.max(1),
7625 end_column: 0,
7626 };
7627 let mut failures = Vec::new();
7628 record_identity_failure(
7629 &mut failures,
7630 GraphIdentityField::RelationSource,
7631 &relation.source_name,
7632 );
7633 record_identity_failure(
7634 &mut failures,
7635 GraphIdentityField::RelationTarget,
7636 &relation.target_name,
7637 );
7638 if !failures.is_empty() {
7639 rejected_relations[index] = true;
7640 report.record(
7641 &graph.path,
7642 span,
7643 relation.parser,
7644 parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, index),
7645 &failures,
7646 control,
7647 )?;
7648 }
7649 }
7650 if report.rejected_facts_by_path.is_empty() {
7651 return Ok((graph, report));
7652 }
7653 let mut graph = graph.into_owned();
7654 let relation_fact_indices = if rejected_relations.iter().any(|rejected| *rejected) {
7655 let count = rejected_relations
7656 .iter()
7657 .filter(|rejected| !**rejected)
7658 .count();
7659 if count > 0 {
7660 report.reserve_identity_bytes(
7661 identity_relation_indices_bytes(&graph.path, count),
7662 control,
7663 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7664 )?;
7665 }
7666 rejected_relations
7667 .iter()
7668 .enumerate()
7669 .filter_map(|(index, rejected)| (!rejected).then_some(index))
7670 .collect::<Vec<_>>()
7671 } else {
7672 Vec::new()
7673 };
7674 if let Some(semantics) = semantics {
7675 semantics
7676 .retain_graph_facts(
7677 &mut graph,
7678 &rejected_symbols
7679 .iter()
7680 .map(|rejected| !rejected)
7681 .collect::<Vec<_>>(),
7682 &rejected_relations
7683 .iter()
7684 .map(|rejected| !rejected)
7685 .collect::<Vec<_>>(),
7686 )
7687 .map_err(|error| {
7688 CliError::InvalidInput(format!("source binding admission failed: {error}"))
7689 })?;
7690 } else {
7691 graph.symbols = graph
7692 .symbols
7693 .into_iter()
7694 .enumerate()
7695 .filter_map(|(index, symbol)| (!rejected_symbols[index]).then_some(symbol))
7696 .collect();
7697 graph.relations = graph
7698 .relations
7699 .into_iter()
7700 .enumerate()
7701 .filter_map(|(index, relation)| (!rejected_relations[index]).then_some(relation))
7702 .collect();
7703 }
7704 if rejected_relations.iter().any(|rejected| *rejected) && !relation_fact_indices.is_empty() {
7705 report
7706 .relation_fact_indices
7707 .insert(graph.path.clone(), relation_fact_indices);
7708 }
7709 Ok((Cow::Owned(graph), report))
7710}
7711
7712#[cfg(test)]
7714pub(super) fn admit_symbol_build_stage(
7715 staged: &mut SymbolBuildStage,
7716 control: &IndexWorkControl,
7717) -> Result<GraphIdentityAdmission, CliError> {
7718 admit_symbol_build_stage_with_limit(staged, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)
7719}
7720
7721pub(super) fn admit_symbol_build_stage_with_limit(
7723 staged: &mut SymbolBuildStage,
7724 control: &IndexWorkControl,
7725 limit: u64,
7726) -> Result<GraphIdentityAdmission, CliError> {
7727 let mut report = GraphIdentityAdmission {
7728 retained_limit: Some(limit),
7729 ..GraphIdentityAdmission::default()
7730 };
7731 for (index, change) in staged.changes.iter_mut().enumerate() {
7732 check_graph_work(control, index)?;
7733 let SymbolProjectionChange::Parsed(parsed) = change else {
7734 continue;
7735 };
7736 let placeholder = SymbolGraph {
7737 path: parsed.path.clone(),
7738 language: None,
7739 parser: parsed.source_parser,
7740 symbols: Vec::new(),
7741 relations: Vec::new(),
7742 };
7743 let graph = std::mem::replace(&mut parsed.graph, placeholder);
7744 let (admitted, graph_report) = admit_symbol_graph_with_semantics_limit(
7745 Cow::Owned(graph),
7746 parsed.source_semantics.as_deref_mut(),
7747 control,
7748 limit.saturating_sub(report.observed_fact_bytes),
7749 )?;
7750 parsed.graph = admitted.into_owned();
7751 if let Some(markdown) = parsed.markdown_facts.as_deref_mut() {
7752 admit_markdown_fact_batch(
7753 &parsed.path,
7754 parsed.source_parser,
7755 markdown,
7756 &mut report,
7757 control,
7758 )?;
7759 }
7760 if graph_report.has_rejections() {
7761 parsed.summary = super::summarize_symbol_graph(&parsed.graph, None);
7764 parsed.summary_is_structural = false;
7765 if parsed.purpose_suggestion.is_some() {
7766 parsed.purpose_suggestion =
7767 Some(super::suggest_file_purpose(&parsed.path, &parsed.summary));
7768 }
7769 }
7770 report.merge(graph_report, control)?;
7771 }
7772 let mut symbols = 0_usize;
7773 let mut relations = 0_usize;
7774 for change in &staged.changes {
7775 let SymbolProjectionChange::Parsed(parsed) = change else {
7776 continue;
7777 };
7778 symbols = symbols
7779 .checked_add(navigable_symbol_count(&parsed.graph))
7780 .ok_or_else(|| {
7781 CliError::InvalidInput("admitted symbol report count overflowed".to_string())
7782 })?;
7783 relations = relations
7784 .checked_add(parsed.graph.relations.len())
7785 .ok_or_else(|| {
7786 CliError::InvalidInput("admitted relation report count overflowed".to_string())
7787 })?;
7788 }
7789 staged.report.symbols = symbols;
7790 staged.report.relations = relations;
7791 report.source_admitted = true;
7792 Ok(report)
7793}
7794
7795fn record_identity_failure(
7797 failures: &mut Vec<(GraphIdentityField, GraphIdentityRejectionReason)>,
7798 field: GraphIdentityField,
7799 value: &str,
7800) {
7801 if let Err(error) = source_symbol_identity_error(value) {
7802 failures.push((field, GraphIdentityRejectionReason::from_error(&error)));
7803 }
7804}
7805
7806fn source_symbol_identity_error(value: &str) -> Result<(), GraphContractError> {
7808 projectatlas_symbols::declaration_identity::validate_source_symbol_identity(value)
7809}
7810
7811#[allow(clippy::too_many_arguments)]
7813fn admit_resolution_key_failures_with_limit(
7814 project: ProjectInstanceId,
7815 generation: IndexGeneration,
7816 graphs: &[SourceGraph<'_>],
7817 packages: &PackageIndex,
7818 configured_modules: &ConfiguredModuleResolution,
7819 report: &mut GraphIdentityAdmission,
7820 control: &IndexWorkControl,
7821 limit: u64,
7822) -> Result<(), CliError> {
7823 let mut retained = resolution_projection_map_retained_bytes(&report.resolution_projections);
7824 for (index, graph) in graphs.iter().enumerate() {
7825 let semantics = graph.semantics();
7826 let graph = graph.graph();
7827 check_graph_work(control, index)?;
7828 #[cfg(not(test))]
7829 let _ = generation;
7830 if report.resolution_projections.contains_key(&graph.path) {
7831 return Err(CliError::InvalidInput(
7832 "duplicate resolution projection admission".to_string(),
7833 ));
7834 }
7835 #[cfg(test)]
7836 report.record_resolution_derivation(&graph.path, generation);
7837 let entry_bytes = STAGED_GRAPH_ROW_BYTES.saturating_add(graph.path.len() as u64);
7838 let before = retained
7839 .saturating_add(report.observed_fact_bytes)
7840 .saturating_add(entry_bytes);
7841 enforce_resolution_registry_budget_with_limit(before, limit)?;
7842 let mut context = resolution_projection_context(&graph.path, packages, configured_modules)
7843 .with_retained_bytes_limit(limit.saturating_sub(before));
7844 if let Some(semantics) = semantics {
7845 context = context.with_source_semantics(semantics);
7846 }
7847 match derive_resolution_keys_with_context(
7848 project,
7849 packages.package_name(&graph.path),
7850 graph,
7851 context,
7852 ) {
7853 Err(ResolutionProjectionError::SourceEvidenceMismatch) => {
7854 return Err(CliError::InvalidInput(
7855 "source semantic evidence changed before key projection".to_string(),
7856 ));
7857 }
7858 Ok(projection) => {
7859 retained = retained.saturating_add(resolution_projection_map_entry_retained_bytes(
7860 &graph.path,
7861 &projection,
7862 ));
7863 enforce_resolution_registry_budget_with_limit(
7864 retained.saturating_add(report.observed_fact_bytes),
7865 limit,
7866 )?;
7867 report
7868 .resolution_projections
7869 .insert(graph.path.clone(), projection);
7870 }
7871 Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
7872 return Err(resolution_key_limit_failure(requested));
7873 }
7874 Err(ResolutionProjectionError::MemoryLimit { requested, .. }) => {
7875 return Err(resolution_projection_memory_failure(
7876 limit,
7877 before.saturating_add(requested),
7878 ));
7879 }
7880 Err(ResolutionProjectionError::Contract(failure)) => {
7881 let (failures, rejected_count, projection) = (*failure).into_parts();
7882 retained = retained.saturating_add(resolution_projection_map_entry_retained_bytes(
7883 &graph.path,
7884 &projection,
7885 ));
7886 let failure_scratch = (failures.capacity() as u64)
7887 .saturating_mul(std::mem::size_of::<
7888 projectatlas_symbols::ResolutionProjectionFactFailure,
7889 >() as u64);
7890 enforce_resolution_registry_budget_with_limit(
7891 retained
7892 .saturating_add(failure_scratch)
7893 .saturating_add(report.observed_fact_bytes),
7894 limit,
7895 )?;
7896 let previous_limit = report.retained_limit;
7897 report.retained_limit = Some(
7898 limit
7899 .saturating_sub(retained)
7900 .saturating_sub(failure_scratch),
7901 );
7902 for failure in &failures {
7903 report.record(
7904 &graph.path,
7905 resolution_projection_span(graph, semantics, failure.fact()),
7906 graph.parser,
7907 resolution_projection_fact_index(report, &graph.path, failure.fact()),
7908 &[(
7909 GraphIdentityField::ResolutionKey,
7910 GraphIdentityRejectionReason::from_error(failure.error()),
7911 )],
7912 control,
7913 )?;
7914 }
7915 report.record_rejected_fact_count(
7916 &graph.path,
7917 rejected_count.saturating_sub(failures.len()),
7918 control,
7919 )?;
7920 report.retained_limit = previous_limit;
7921 report
7922 .resolution_projections
7923 .insert(graph.path.clone(), projection);
7924 }
7925 }
7926 }
7927 Ok(())
7928}
7929
7930fn ensure_admitted_resolution_projections(
7932 graphs: &[impl Borrow<SymbolGraph>],
7933 projections: &BTreeMap<String, ResolutionKeyProjection>,
7934) -> Result<(), CliError> {
7935 if projections.len() != graphs.len()
7936 || graphs
7937 .iter()
7938 .any(|graph| !projections.contains_key(&graph.borrow().path))
7939 {
7940 return Err(CliError::InvalidInput(
7941 "resolution projection admission did not match staged graphs".to_string(),
7942 ));
7943 }
7944 Ok(())
7945}
7946
7947fn resolution_projection_fact_index(
7949 report: &GraphIdentityAdmission,
7950 path: &str,
7951 fact: ResolutionProjectionFact,
7952) -> u64 {
7953 match fact {
7954 ResolutionProjectionFact::Source => 0,
7955 ResolutionProjectionFact::Export(index) => {
7956 parser_fact_index(EXPORT_FACT_INDEX_NAMESPACE, index)
7957 }
7958 ResolutionProjectionFact::Symbol(index) => {
7959 parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, index)
7960 }
7961 ResolutionProjectionFact::Relation(index) => parser_fact_index(
7962 RELATION_FACT_INDEX_NAMESPACE,
7963 report.relation_parser_index(path, index),
7964 ),
7965 }
7966}
7967
7968fn resolution_projection_span(
7970 graph: &SymbolGraph,
7971 semantics: Option<&SourceSemanticFacts>,
7972 fact: ResolutionProjectionFact,
7973) -> IdentitySpan {
7974 match fact {
7975 ResolutionProjectionFact::Source => graph_identity_span(graph),
7976 ResolutionProjectionFact::Export(index) => semantics
7977 .and_then(|facts| facts.exports().get(index))
7978 .map_or_else(
7979 || graph_identity_span(graph),
7980 |fact| IdentitySpan {
7981 start_line: fact.line.max(1),
7982 start_column: 0,
7983 end_line: fact.line.max(1),
7984 end_column: 0,
7985 },
7986 ),
7987 ResolutionProjectionFact::Symbol(index) => graph.symbols.get(index).map_or_else(
7988 || graph_identity_span(graph),
7989 |symbol| IdentitySpan {
7990 start_line: symbol.line_start.max(1),
7991 start_column: 0,
7992 end_line: symbol.line_end.max(symbol.line_start).max(1),
7993 end_column: 0,
7994 },
7995 ),
7996 ResolutionProjectionFact::Relation(index) => graph.relations.get(index).map_or_else(
7997 || graph_identity_span(graph),
7998 |relation| IdentitySpan {
7999 start_line: relation.line.max(1),
8000 start_column: 0,
8001 end_line: relation.line.max(1),
8002 end_column: 0,
8003 },
8004 ),
8005 }
8006}
8007
8008fn graph_identity_span(graph: &SymbolGraph) -> IdentitySpan {
8010 let mut start_line = usize::MAX;
8011 let mut end_line = 1;
8012 for symbol in &graph.symbols {
8013 start_line = start_line.min(symbol.line_start.max(1));
8014 end_line = end_line.max(symbol.line_end.max(symbol.line_start).max(1));
8015 }
8016 for relation in &graph.relations {
8017 start_line = start_line.min(relation.line.max(1));
8018 end_line = end_line.max(relation.line.max(1));
8019 }
8020 IdentitySpan {
8021 start_line: if start_line == usize::MAX {
8022 1
8023 } else {
8024 start_line
8025 },
8026 start_column: 0,
8027 end_line,
8028 end_column: 0,
8029 }
8030}
8031
8032fn admit_markdown_facts(
8034 facts: &mut BTreeMap<String, Cow<'_, MarkdownFacts>>,
8035 graphs: &[impl Borrow<SymbolGraph>],
8036 report: &mut GraphIdentityAdmission,
8037 control: &IndexWorkControl,
8038) -> Result<(), CliError> {
8039 for (graph_index, graph) in graphs.iter().enumerate() {
8040 check_graph_work(control, graph_index)?;
8041 let graph = graph.borrow();
8042 let Some(markdown) = facts.get_mut(&graph.path) else {
8043 continue;
8044 };
8045 if !markdown
8046 .link_candidates
8047 .iter()
8048 .any(|candidate| GraphIdentityText::validate(&candidate.selector).is_err())
8049 {
8050 continue;
8051 }
8052 admit_markdown_fact_batch(
8053 &graph.path,
8054 graph.parser,
8055 markdown.to_mut(),
8056 report,
8057 control,
8058 )?;
8059 }
8060 Ok(())
8061}
8062
8063fn admit_markdown_fact_batch(
8065 path: &str,
8066 parser: ParserKind,
8067 markdown: &mut MarkdownFacts,
8068 report: &mut GraphIdentityAdmission,
8069 control: &IndexWorkControl,
8070) -> Result<(), CliError> {
8071 let mut rejected = Vec::new();
8072 for (candidate_index, candidate) in markdown.link_candidates.iter().enumerate() {
8073 check_graph_work(control, candidate_index)?;
8074 if let Err(error) = GraphIdentityText::validate(&candidate.selector) {
8075 rejected.push((
8076 candidate_index,
8077 candidate.source.line_start,
8078 candidate.source.column_start,
8079 candidate.source.line_end,
8080 candidate.source.column_end,
8081 GraphIdentityRejectionReason::from_error(&error),
8082 ));
8083 }
8084 }
8085 for (candidate_index, start_line, start_column, end_line, end_column, reason) in rejected {
8086 report.record(
8087 path,
8088 IdentitySpan {
8089 start_line,
8090 start_column,
8091 end_line,
8092 end_column,
8093 },
8094 parser,
8095 parser_fact_index(MARKDOWN_FACT_INDEX_NAMESPACE, candidate_index),
8096 &[(GraphIdentityField::RelationTarget, reason)],
8097 control,
8098 )?;
8099 }
8100 markdown
8101 .link_candidates
8102 .retain(|candidate| GraphIdentityText::validate(&candidate.selector).is_ok());
8103 Ok(())
8104}
8105
8106fn complete_markdown_facts<'a>(
8108 root: &Path,
8109 nodes: &[Node],
8110 graphs: &[impl Borrow<SymbolGraph>],
8111 symbols: &'a SymbolBuildStage,
8112 control: &IndexWorkControl,
8113 limit: u64,
8114) -> Result<BTreeMap<String, Cow<'a, MarkdownFacts>>, CliError> {
8115 let graph_paths = graphs
8116 .iter()
8117 .map(|graph| graph.borrow().path.as_str())
8118 .collect::<BTreeSet<_>>();
8119 let nodes_by_path = nodes
8120 .iter()
8121 .filter(|node| node.kind == NodeKind::File)
8122 .map(|node| (node.path.as_str(), node))
8123 .collect::<BTreeMap<_, _>>();
8124 let mut facts = BTreeMap::new();
8125 let mut retained =
8126 (nodes.len().saturating_add(graphs.len()) as u64).saturating_mul(STAGED_GRAPH_ROW_BYTES);
8127 enforce_resolution_registry_budget_with_limit(retained, limit)?;
8128 for change in &symbols.changes {
8129 let SymbolProjectionChange::Parsed(parsed) = change else {
8130 continue;
8131 };
8132 if graph_paths.contains(parsed.path.as_str())
8133 && let Some(markdown) = parsed.markdown_facts.as_ref()
8134 {
8135 retained = retained
8136 .saturating_add(STAGED_GRAPH_ROW_BYTES)
8137 .saturating_add(parsed.path.len() as u64);
8138 enforce_resolution_registry_budget_with_limit(retained, limit)?;
8139 facts.insert(parsed.path.clone(), Cow::Borrowed(markdown.as_ref()));
8140 }
8141 }
8142 for graph in graphs {
8143 let graph = graph.borrow();
8144 control.check(IndexWorkStage::SymbolParsing)?;
8145 if facts.contains_key(&graph.path)
8146 || !graph
8147 .language
8148 .as_deref()
8149 .and_then(language_capability)
8150 .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
8151 {
8152 continue;
8153 }
8154 let node = nodes_by_path.get(graph.path.as_str()).ok_or_else(|| {
8155 CliError::InvalidInput(format!(
8156 "Markdown graph path was absent from the staged file inventory: {}",
8157 graph.path
8158 ))
8159 })?;
8160 let native_path = root.join(Path::new(&graph.path));
8161 let bytes = match read_source_bytes_controlled(
8162 &native_path,
8163 MAX_SYMBOL_FILE_BYTES,
8164 IndexWorkStage::SymbolParsing,
8165 control,
8166 ) {
8167 Ok(bytes) => bytes,
8168 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
8169 Err(SourceReadFailure::LimitExceeded { .. }) => {
8170 return Err(source_changed_during_derivation(root, &graph.path));
8171 }
8172 Err(SourceReadFailure::Io(source)) => {
8173 return Err(CliError::Io {
8174 path: native_path,
8175 source,
8176 });
8177 }
8178 };
8179 if node
8180 .content_hash
8181 .as_deref()
8182 .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
8183 {
8184 return Err(source_changed_during_derivation(root, &graph.path));
8185 }
8186 let content = String::from_utf8(bytes)
8187 .map_err(|_source| source_changed_during_derivation(root, &graph.path))?;
8188 let extracted = extract_markdown_facts_controlled(&content, control)?;
8189 retained = retained.saturating_add(document_fact_entry_retained_bytes(
8190 &graph.path,
8191 &extracted,
8192 true,
8193 ));
8194 enforce_resolution_registry_budget_with_limit(retained, limit)?;
8195 facts.insert(graph.path.clone(), Cow::Owned(extracted));
8196 }
8197 enforce_resolution_registry_budget(document_fact_map_retained_bytes(&facts))?;
8198 Ok(facts)
8199}
8200
8201fn document_fact_map_retained_bytes(facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>) -> u64 {
8203 facts.iter().fold(0_u64, |bytes, (path, facts)| {
8204 bytes.saturating_add(document_fact_entry_retained_bytes(
8205 path,
8206 facts,
8207 matches!(facts, Cow::Owned(_)),
8208 ))
8209 })
8210}
8211
8212fn document_fact_entry_retained_bytes(path: &str, facts: &MarkdownFacts, owned: bool) -> u64 {
8214 let bytes = STAGED_GRAPH_ROW_BYTES.saturating_add(path.len() as u64);
8215 if !owned {
8216 return bytes;
8217 }
8218 let heading_bytes = facts.headings.iter().fold(0_u64, |bytes, heading| {
8219 bytes
8220 .saturating_add(STAGED_GRAPH_ROW_BYTES)
8221 .saturating_add(heading.text.len() as u64)
8222 .saturating_add(heading.slug.len() as u64)
8223 });
8224 facts
8225 .link_candidates
8226 .iter()
8227 .fold(bytes.saturating_add(heading_bytes), |bytes, candidate| {
8228 bytes
8229 .saturating_add(STAGED_GRAPH_ROW_BYTES)
8230 .saturating_add(candidate.selector.len() as u64)
8231 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
8232 .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
8233 })
8234}
8235
8236fn document_projection_retained_bytes(
8243 facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
8244 control: &IndexWorkControl,
8245) -> Result<u64, CliError> {
8246 let mut retained_bytes = 0_u64;
8247 let mut candidate_index = 0_usize;
8248 for (document_path, facts) in facts {
8249 for candidate in &facts.link_candidates {
8250 check_graph_work(control, candidate_index)?;
8251 candidate_index = candidate_index.saturating_add(1);
8252 if normalize_document_target(document_path, &candidate.selector).is_ok_and(|target| {
8253 target.path == document_path.as_str() && target.fragment.is_none()
8254 }) {
8255 continue;
8256 }
8257 retained_bytes = retained_bytes
8258 .saturating_add(DOCUMENT_PROJECTION_ROW_BYTES)
8259 .saturating_add(candidate.selector.len() as u64)
8260 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
8261 .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
8262 }
8263 }
8264 Ok(retained_bytes)
8265}
8266
8267#[derive(Clone, Debug, Eq, PartialEq)]
8269struct DocumentTargetIdentity {
8270 path: String,
8272 fragment: Option<String>,
8274}
8275
8276fn normalize_document_target(
8278 document_path: &str,
8279 selector: &str,
8280) -> Result<DocumentTargetIdentity, DocumentTargetUnresolvedReason> {
8281 let (path_and_query, fragment) = selector
8282 .split_once('#')
8283 .map_or((selector, None), |(path, fragment)| (path, Some(fragment)));
8284 let path = path_and_query
8285 .split_once('?')
8286 .map_or(path_and_query, |(path, _query)| path);
8287 let path = strip_document_line_selector(path);
8288 if path.is_empty() {
8289 return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
8290 }
8291 let mut components = document_path
8292 .rsplit_once('/')
8293 .map_or_else(Vec::new, |(parent, _file)| {
8294 parent.split('/').map(str::to_owned).collect::<Vec<_>>()
8295 });
8296 for component in path.split('/') {
8297 match component {
8298 "" => return Err(DocumentTargetUnresolvedReason::Unsupported),
8299 "." => {}
8300 ".." => {
8301 if components.pop().is_none() {
8302 return Err(DocumentTargetUnresolvedReason::OutsideRoot);
8303 }
8304 }
8305 value if value.contains(':') => {
8306 return Err(DocumentTargetUnresolvedReason::Unsupported);
8307 }
8308 value => components.push(value.to_owned()),
8309 }
8310 }
8311 if components.is_empty() {
8312 return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
8313 }
8314 let fragment = match fragment.map(str::trim) {
8315 None => None,
8316 Some(value)
8317 if !value.is_empty()
8318 && !value.contains(['/', '\\', '?', '#', '{', '}', '<', '>', '|', '*', '$'])
8319 && !value.chars().any(char::is_whitespace) =>
8320 {
8321 Some(value.to_lowercase())
8322 }
8323 Some(_value) => return Err(DocumentTargetUnresolvedReason::NoStaticTarget),
8324 };
8325 Ok(DocumentTargetIdentity {
8326 path: components.join("/"),
8327 fragment,
8328 })
8329}
8330
8331fn strip_document_line_selector(path: &str) -> &str {
8333 let Some((identity, selector)) = path.rsplit_once(':') else {
8334 return path;
8335 };
8336 let selector = selector.strip_prefix('L').unwrap_or(selector);
8337 let valid = selector.split_once('-').map_or_else(
8338 || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
8339 |(start, end)| {
8340 !start.is_empty()
8341 && !end.is_empty()
8342 && start.chars().all(|character| character.is_ascii_digit())
8343 && end
8344 .strip_prefix('L')
8345 .unwrap_or(end)
8346 .chars()
8347 .all(|character| character.is_ascii_digit())
8348 },
8349 );
8350 if valid { identity } else { path }
8351}
8352
8353fn document_file_resolution_key(
8355 project: ProjectInstanceId,
8356 path: &str,
8357) -> Result<CanonicalResolutionKey, CliError> {
8358 document_resolution_key(project, ResolutionKeyDomain::Module, path)
8359}
8360
8361fn document_casefold_resolution_key(
8363 project: ProjectInstanceId,
8364 path: &str,
8365) -> Result<CanonicalResolutionKey, CliError> {
8366 document_resolution_key_with_language(
8367 project,
8368 ResolutionKeyDomain::Module,
8369 DOCUMENT_CASEFOLD_LANGUAGE,
8370 &path.to_lowercase(),
8371 )
8372}
8373
8374fn document_heading_resolution_key(
8376 project: ProjectInstanceId,
8377 path: &str,
8378 fragment: &str,
8379) -> Result<CanonicalResolutionKey, CliError> {
8380 document_resolution_key(
8381 project,
8382 ResolutionKeyDomain::Declaration,
8383 &format!("{path}#{fragment}"),
8384 )
8385}
8386
8387fn document_resolution_key(
8389 project: ProjectInstanceId,
8390 domain: ResolutionKeyDomain,
8391 identity: &str,
8392) -> Result<CanonicalResolutionKey, CliError> {
8393 document_resolution_key_with_language(project, domain, DOCUMENT_PATH_LANGUAGE, identity)
8394}
8395
8396fn document_resolution_key_with_language(
8398 project: ProjectInstanceId,
8399 domain: ResolutionKeyDomain,
8400 resolver_language: &str,
8401 identity: &str,
8402) -> Result<CanonicalResolutionKey, CliError> {
8403 let provider =
8404 GraphIdentityText::new(DOCUMENT_PATH_PROVIDER).map_err(invalid_graph_contract)?;
8405 let language = GraphIdentityText::new(resolver_language).map_err(invalid_graph_contract)?;
8406 let identity = GraphIdentityText::new(identity).map_err(invalid_graph_contract)?;
8407 Ok(CanonicalResolutionKey::new(
8408 project,
8409 domain,
8410 &provider,
8411 &language,
8412 None,
8413 None,
8414 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
8415 &identity,
8416 ))
8417}
8418
8419fn append_document_dependency_keys(
8421 project: ProjectInstanceId,
8422 facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
8423 keys: &mut ResolutionKeyWorkspace<'_>,
8424) -> Result<(), CliError> {
8425 for (document_path, facts) in facts {
8426 for candidate in &facts.link_candidates {
8427 let Ok(target) = normalize_document_target(document_path, &candidate.selector) else {
8428 continue;
8429 };
8430 keys.insert_owned(document_file_resolution_key(project, &target.path)?)?;
8431 keys.insert_owned(document_casefold_resolution_key(project, &target.path)?)?;
8432 if let Some(fragment) = target.fragment {
8433 keys.insert_owned(document_heading_resolution_key(
8434 project,
8435 &target.path,
8436 &fragment,
8437 )?)?;
8438 }
8439 }
8440 }
8441 Ok(())
8442}
8443
8444fn resolution_projection_with_config(
8446 project: ProjectInstanceId,
8447 packages: &PackageIndex,
8448 graph: &SymbolGraph,
8449 configured_modules: &ConfiguredModuleResolution,
8450 limit: u64,
8451) -> Result<ResolutionKeyProjection, CliError> {
8452 let context = resolution_projection_context(&graph.path, packages, configured_modules)
8453 .with_retained_bytes_limit(limit);
8454 match derive_resolution_keys_with_context(
8455 project,
8456 packages.package_name(&graph.path),
8457 graph,
8458 context,
8459 ) {
8460 Err(ResolutionProjectionError::SourceEvidenceMismatch) => Err(CliError::InvalidInput(
8461 "source semantic evidence does not match the graph".to_string(),
8462 )),
8463 Ok(projection) => Ok(projection),
8464 Err(ResolutionProjectionError::MemoryLimit { limit, requested }) => {
8465 Err(resolution_projection_memory_failure(limit, requested))
8466 }
8467 Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
8468 Err(resolution_key_limit_failure(requested))
8469 }
8470 Err(ResolutionProjectionError::Contract(failure)) => {
8471 let (mut failures, _rejected_count, _projection) = (*failure).into_parts();
8472 let Some(failure) = failures.pop() else {
8473 return Err(CliError::InvalidInput(
8474 "resolution projection reported an empty contract failure".to_string(),
8475 ));
8476 };
8477 let (_fact, error) = failure.into_parts();
8478 Err(invalid_graph_contract(error))
8479 }
8480 }
8481}
8482
8483fn resolution_projection_context<'a>(
8485 path: &str,
8486 packages: &'a PackageIndex,
8487 configured: &'a ConfiguredModuleResolution,
8488) -> ResolutionProjectionContext<'a> {
8489 let mut context = ResolutionProjectionContext::with_configured_modules(configured);
8490 if let Some(package) = packages.package_owner(path) {
8491 context = context.with_rust_package(&package.manifest, &package.root);
8492 if let Some(directory) = packages.root_cohort_directory(path) {
8493 context = context.with_rust_root_cohort(directory);
8494 }
8495 }
8496 context
8497}
8498
8499fn resolution_key_limit_failure(requested: usize) -> CliError {
8501 IndexWorkFailure::resource_limit(
8502 IndexWorkStage::SymbolParsing,
8503 IndexWorkResource::RelationRows,
8504 u64::try_from(MAX_RESOLUTION_KEYS_PER_FACT).unwrap_or(u64::MAX),
8505 u64::try_from(requested).unwrap_or(u64::MAX),
8506 )
8507 .into()
8508}
8509
8510fn resolution_read_error(error: projectatlas_db::DbError) -> CliError {
8512 match error {
8513 projectatlas_db::DbError::IndexWork(failure) => failure.into(),
8514 other => other.into(),
8515 }
8516}
8517
8518fn resolution_projection_memory_failure(limit: u64, requested: u64) -> CliError {
8520 IndexWorkFailure::resource_limit(
8521 IndexWorkStage::SymbolParsing,
8522 IndexWorkResource::OutputBytes,
8523 limit,
8524 requested,
8525 )
8526 .into()
8527}
8528
8529fn selected_project(store: &AtlasStore) -> Result<ProjectInstanceId, CliError> {
8531 store.project_instance_id()?.ok_or_else(|| {
8532 CliError::InvalidInput("repository graph requires a bound project identity".to_string())
8533 })
8534}
8535
8536fn next_generation(base: IndexGeneration) -> Result<IndexGeneration, CliError> {
8538 base.checked_next().ok_or_else(|| {
8539 CliError::InvalidInput("repository graph generation is exhausted".to_string())
8540 })
8541}
8542
8543fn insert_entity(
8545 entities: &mut BTreeMap<String, GraphEntity>,
8546 entity: GraphEntity,
8547) -> Result<(), CliError> {
8548 let digest = entity.key().digest().to_string();
8549 if let Some(existing) = entities.get(&digest) {
8550 if !existing
8551 .key()
8552 .reconcile(entity.key())
8553 .map_err(invalid_graph_contract)?
8554 {
8555 return Err(CliError::InvalidInput(
8556 "graph entity digest retained conflicting ownership".to_string(),
8557 ));
8558 }
8559 return Ok(());
8560 }
8561 entities.insert(digest, entity);
8562 Ok(())
8563}
8564
8565fn sort_dedup_exports(exports: &mut Vec<EntityResolutionKey>) {
8567 exports.sort_by(|left, right| {
8568 left.key()
8569 .cmp(right.key())
8570 .then_with(|| left.entity().digest().cmp(right.entity().digest()))
8571 });
8572 exports.dedup();
8573}
8574
8575fn sort_dedup_dependencies(dependencies: &mut Vec<RelationDependencyKey>) {
8577 dependencies.sort_by(|left, right| {
8578 left.key()
8579 .cmp(right.key())
8580 .then_with(|| left.relation().digest().cmp(right.relation().digest()))
8581 });
8582 dependencies.dedup();
8583}
8584
8585fn enforce_key_binding_limit(count: usize) -> Result<(), CliError> {
8587 let observed = u64::try_from(count).unwrap_or(u64::MAX);
8588 if observed > MAX_GRAPH_KEY_BINDINGS {
8589 return Err(IndexWorkFailure::resource_limit(
8590 IndexWorkStage::SymbolParsing,
8591 IndexWorkResource::RelationRows,
8592 MAX_GRAPH_KEY_BINDINGS,
8593 observed,
8594 )
8595 .into());
8596 }
8597 Ok(())
8598}
8599
8600fn enforce_incremental_count<T>(
8602 root: &Path,
8603 _context: &'static str,
8604 count: usize,
8605 sample: &BTreeSet<T>,
8606) -> Result<(), CliError>
8607where
8608 T: ToString + Ord,
8609{
8610 if count > MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
8611 return Err(dependency_closure_limit(
8612 root,
8613 sample.iter().map(ToString::to_string),
8614 count,
8615 ));
8616 }
8617 Ok(())
8618}
8619
8620fn admitted_persisted_footprint(
8622 store: &AtlasStore,
8623 project: ProjectInstanceId,
8624 root: &Path,
8625 affected_paths: &BTreeSet<String>,
8626 control: &IndexWorkControl,
8627) -> Result<RepositoryAffectedSourceFootprint, CliError> {
8628 control.check(IndexWorkStage::SymbolParsing)?;
8629 let footprint = store.repository_affected_source_footprint(
8630 project,
8631 &affected_paths.iter().cloned().collect::<Vec<_>>(),
8632 u32::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(u32::MAX),
8633 )?;
8634 control.check(IndexWorkStage::SymbolParsing)?;
8635 if footprint.truncated {
8636 return Err(dependency_closure_limit(
8637 root,
8638 affected_paths.iter().cloned(),
8639 usize::try_from(footprint.rows).unwrap_or(usize::MAX),
8640 ));
8641 }
8642 enforce_incremental_projection_budget(
8643 root,
8644 affected_paths,
8645 footprint.rows,
8646 footprint.retained_bytes,
8647 )?;
8648 Ok(footprint)
8649}
8650
8651fn enforce_incremental_projection_limits(
8653 root: &Path,
8654 affected_paths: &BTreeSet<String>,
8655 persisted: RepositoryAffectedSourceFootprint,
8656 staged: &StagedRepositoryGraph,
8657) -> Result<(), CliError> {
8658 let staged_rows = [
8659 affected_paths.len(),
8660 staged.entities.len(),
8661 staged.relations.len(),
8662 staged.occurrences.len(),
8663 staged.coverage.len(),
8664 staged.entity_exports.len(),
8665 staged.relation_dependencies.len(),
8666 staged.document_unresolved_reasons.len(),
8667 ]
8668 .into_iter()
8669 .fold(0_u64, |total, count| {
8670 total.saturating_add(u64::try_from(count).unwrap_or(u64::MAX))
8671 });
8672 enforce_incremental_projection_budget(
8673 root,
8674 affected_paths,
8675 persisted.rows.saturating_add(staged_rows),
8676 persisted
8677 .retained_bytes
8678 .saturating_add(staged.retained_bytes),
8679 )
8680}
8681
8682fn enforce_incremental_projection_budget(
8684 root: &Path,
8685 affected_paths: &BTreeSet<String>,
8686 rows: u64,
8687 retained_bytes: u64,
8688) -> Result<(), CliError> {
8689 if rows > MAX_INCREMENTAL_GRAPH_ROWS || retained_bytes > MAX_INCREMENTAL_GRAPH_BYTES {
8690 return Err(dependency_closure_limit(
8691 root,
8692 affected_paths.iter().cloned(),
8693 affected_paths.len(),
8694 ));
8695 }
8696 Ok(())
8697}
8698
8699fn dependency_closure_limit(
8701 root: &Path,
8702 sample: impl IntoIterator<Item = String>,
8703 observed: usize,
8704) -> CliError {
8705 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
8706 project_root: lossless_project_root_display(root),
8707 worktree: None,
8708 status: IndexReadStatus::RefreshRequired,
8709 reason: IndexRefreshReason::DependencyClosureLimit,
8710 scope: IndexRefreshScope::Full,
8711 changed: observed,
8712 added: 0,
8713 removed: 0,
8714 modified: observed,
8715 sample_paths: sample
8716 .into_iter()
8717 .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
8718 .collect(),
8719 }))
8720}
8721
8722fn invalid_graph_contract(error: GraphContractError) -> CliError {
8724 CliError::from(error)
8725}
8726
8727impl From<GraphContractError> for CliError {
8728 fn from(error: GraphContractError) -> Self {
8729 Self::InvalidInput(format!("repository graph projection failed: {error}"))
8730 }
8731}
8732
8733fn resolution_retained_bytes(projection: &ResolutionKeyProjection) -> u64 {
8735 projection.retained_bytes()
8736}
8737
8738fn resolution_projection_map_entry_retained_bytes(
8740 path: &str,
8741 projection: &ResolutionKeyProjection,
8742) -> u64 {
8743 STAGED_GRAPH_ROW_BYTES
8744 .saturating_add(std::mem::size_of::<ResolutionKeyProjection>() as u64)
8745 .saturating_add(path.len() as u64)
8746 .saturating_add(resolution_retained_bytes(projection))
8747}
8748
8749fn resolution_projection_map_retained_bytes(
8751 projections: &BTreeMap<String, ResolutionKeyProjection>,
8752) -> u64 {
8753 projections.iter().fold(0_u64, |bytes, (path, projection)| {
8754 bytes.saturating_add(resolution_projection_map_entry_retained_bytes(
8755 path, projection,
8756 ))
8757 })
8758}
8759
8760#[cfg(test)]
8761mod tests {
8762 use super::{
8763 CliError, DOCUMENT_PROJECTION_ROW_BYTES, DocumentResolutionIndex, DocumentTargetIdentity,
8764 GRAPH_STAGE_DATABASE_FILE_NAME, GRAPH_STAGE_DIRECTORY_PREFIX, GraphIdentityAdmission,
8765 GraphOwners, GraphSymbolIndex, MAX_IN_MEMORY_GRAPH_WORK_BYTES, MAX_INCREMENTAL_GRAPH_BYTES,
8766 MAX_INCREMENTAL_GRAPH_ROWS, PARTIAL_COVERAGE_REASON, PackageIndex,
8767 ProjectResolutionRegistry, QUALIFIED_SYMBOL_SCOPE_PREFIX, RepositoryGraphMutation,
8768 SourceGraph, SourceRecoveryWork, StagedRepositoryGraph, build_entity_projection,
8769 build_entity_projection_with_config, build_entity_projection_with_config_limit,
8770 cleanup_abandoned_graph_staging, coverage_for_graph, document_casefold_resolution_key,
8771 document_coverage, document_fact_map_retained_bytes, document_projection_retained_bytes,
8772 enforce_incremental_projection_budget, enforce_incremental_projection_limits,
8773 enforce_resolution_staging_budget, explicit_external_selector, finish_projection,
8774 finish_projection_in_database, finish_projection_in_database_with_documents,
8775 finish_projection_with_documents, identity_rejection_keys_retained_bytes, insert_relation,
8776 is_cargo_manifest_path, normalize_document_target, project_document_rows,
8777 qualified_symbol_identity, qualified_symbol_parents, registry_resolution_matches,
8778 relation_resolution, remove_owned_graph_stage_payload, repository_path_belongs_to,
8779 resolution_projection_map_retained_bytes, resolution_registry_from_exports,
8780 rust_toolchain_identity, source_symbol_identity, stage_full_repository_graph,
8781 stage_incremental_repository_graph, stage_incremental_repository_graph_with_test_limit,
8782 try_graph_stage_lease,
8783 };
8784 use crate::runtime::{
8785 IndexRefreshReason, IndexRefreshScope, SymbolBuildReport, SymbolBuildStage,
8786 SymbolParseSuccess, SymbolProjectionChange,
8787 };
8788 use projectatlas_core::graph::{
8789 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
8790 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
8791 ExtendedRelationKind, GraphEntity, GraphIdentityField, GraphIdentityRejectionReason,
8792 GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
8793 MAX_GRAPH_IDENTITY_BYTES, PackageSelector, ProjectInstanceId, RelationDependencyKey,
8794 RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain,
8795 ReusableTargetSelector, SymbolSelector,
8796 };
8797 use projectatlas_core::relation_capabilities::{
8798 RELATION_FAMILY_CAPABILITIES, RelationFamilyState,
8799 };
8800 use projectatlas_core::symbols::{
8801 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
8802 SymbolRelation,
8803 };
8804 use projectatlas_core::{
8805 IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
8806 IndexWorkStage, Node, NodeKind,
8807 };
8808 use projectatlas_db::{
8809 AtlasStore, RepositoryAffectedSourceFootprint, RepositoryGraphRelationQuery,
8810 };
8811 use projectatlas_fs::{RootScanPolicy, ScanOptions};
8812 use projectatlas_symbols::extract_symbol_graph;
8813 use projectatlas_symbols::{
8814 ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
8815 EcmaScriptPathMapping, MAX_DOCUMENT_LINK_CANDIDATES, MAX_DOCUMENT_SELECTOR_BYTES,
8816 MAX_MARKDOWN_EVIDENCE_BYTES, MAX_MARKDOWN_LABEL_BYTES, MarkdownFactLimit,
8817 };
8818 use rusqlite::Connection;
8819 use std::borrow::Cow;
8820 use std::collections::{BTreeMap, BTreeSet};
8821 use std::error::Error;
8822 use std::fmt::Debug;
8823 use std::fs;
8824 use std::io;
8825 use std::num::NonZeroU32;
8826 use std::path::Path;
8827 use std::thread;
8828 use std::time::{Duration, Instant};
8829
8830 #[test]
8831 fn paired_multiline_import_rejection_is_one_parser_fact() -> Result<(), Box<dyn Error>> {
8832 let graph = extract_symbol_graph(
8833 "src/page.ts",
8834 Some("typescript"),
8835 "import {\n helper\n} from './LeakedIdentity\u{0}module';\n",
8836 );
8837 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8838 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
8839 require_eq(
8840 &report.rejected_facts_for("src/page.ts"),
8841 &1,
8842 "paired multiline import omission count",
8843 )?;
8844 require_eq(
8845 &report.rejections.len(),
8846 &3,
8847 "paired multiline import typed detail count",
8848 )?;
8849 require(
8850 report.rejections.iter().all(|rejection| {
8851 rejection.fact_index == report.rejections[0].fact_index
8852 && rejection.span.start_line() == 1
8853 && rejection.span.end_line() == 1
8854 }),
8855 "paired multiline import details did not share one parser span",
8856 )?;
8857 for field in [
8858 GraphIdentityField::RelationTarget,
8859 GraphIdentityField::Signature,
8860 GraphIdentityField::Symbol,
8861 ] {
8862 require(
8863 report
8864 .rejections
8865 .iter()
8866 .any(|rejection| rejection.field == field),
8867 "paired multiline import lost a typed invalid field",
8868 )?;
8869 }
8870 require(
8871 admitted.symbols.is_empty() && admitted.relations.is_empty(),
8872 "paired multiline import retained an invalid parser fact",
8873 )?;
8874 Ok(())
8875 }
8876
8877 #[test]
8878 fn paired_import_admission_scans_scale_bound_once_and_deduplicates_replay()
8879 -> Result<(), Box<dyn Error>> {
8880 const IMPORT_SYMBOLS: usize = 4_000;
8881 const IMPORT_RELATIONS: usize = 8_000;
8882 const PAIRING_WORK_ITEMS: usize = IMPORT_SYMBOLS + IMPORT_RELATIONS;
8883 let graph = |path: &str, invalid: bool| SymbolGraph {
8884 path: path.to_string(),
8885 language: Some("typescript".to_string()),
8886 parser: ParserKind::TreeSitter,
8887 symbols: (0..IMPORT_SYMBOLS)
8888 .map(|index| {
8889 let name = if invalid {
8890 format!("invalid\0import-{index}")
8891 } else {
8892 format!("import-{index}")
8893 };
8894 CodeSymbol {
8895 path: path.to_string(),
8896 language: Some("typescript".to_string()),
8897 name: name.clone(),
8898 kind: SymbolKind::Import,
8899 signature: name,
8900 exported: false,
8901 documentation: None,
8902 line_start: 1,
8903 line_end: 2,
8904 source_selector: None,
8905 parent: None,
8906 parser: ParserKind::TreeSitter,
8907 detail: Some("import_statement".to_string()),
8908 }
8909 })
8910 .collect(),
8911 relations: (0..IMPORT_RELATIONS)
8912 .map(|index| SymbolRelation {
8913 path: path.to_string(),
8914 source_name: path.to_string(),
8915 target_name: if invalid {
8916 format!("invalid\0module-{index}")
8917 } else {
8918 format!("module-{index}")
8919 },
8920 kind: RelationKind::Imports,
8921 line: 1,
8922 context: "import".to_string(),
8923 parser: ParserKind::TreeSitter,
8924 })
8925 .collect(),
8926 };
8927 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8928
8929 let (valid, valid_report) = super::admit_symbol_graph(
8930 Cow::Owned(graph("src/import-scale-valid.ts", false)),
8931 &control,
8932 )?;
8933 require_eq(
8934 &valid_report.paired_import_pairing_work,
8935 &PAIRING_WORK_ITEMS,
8936 "valid import pairing work",
8937 )?;
8938 require_eq(
8939 &valid.symbols.len(),
8940 &IMPORT_SYMBOLS,
8941 "valid import symbol count",
8942 )?;
8943 require_eq(
8944 &valid.relations.len(),
8945 &IMPORT_RELATIONS,
8946 "valid import relation count",
8947 )?;
8948 require_eq(
8949 &valid_report.rejected_facts_for("src/import-scale-valid.ts"),
8950 &0,
8951 "valid import rejection count",
8952 )?;
8953
8954 let invalid_graph = graph("src/import-scale-invalid.ts", true);
8955 let (invalid, mut invalid_report) =
8956 super::admit_symbol_graph(Cow::Owned(invalid_graph.clone()), &control)?;
8957 require_eq(
8958 &invalid_report.paired_import_pairing_work,
8959 &PAIRING_WORK_ITEMS,
8960 "invalid import pairing work",
8961 )?;
8962 require(
8963 invalid.symbols.is_empty() && invalid.relations.is_empty(),
8964 "invalid import facts survived admission",
8965 )?;
8966 require_eq(
8967 &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
8968 &u64::try_from(IMPORT_RELATIONS)?,
8969 "invalid import rejection count",
8970 )?;
8971 require_eq(
8972 &invalid_report.rejections.len(),
8973 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
8974 "invalid import detail ceiling",
8975 )?;
8976
8977 let (replayed, replay_report) =
8978 super::admit_symbol_graph(Cow::Owned(invalid_graph), &control)?;
8979 require(
8980 replayed.symbols.is_empty() && replayed.relations.is_empty(),
8981 "replayed invalid import facts survived admission",
8982 )?;
8983 require_eq(
8984 &replay_report.paired_import_pairing_work,
8985 &PAIRING_WORK_ITEMS,
8986 "replayed import pairing work",
8987 )?;
8988 invalid_report.merge(replay_report, &control)?;
8989 require_eq(
8990 &invalid_report.paired_import_pairing_work,
8991 &(PAIRING_WORK_ITEMS * 2),
8992 "merged replay import pairing work",
8993 )?;
8994 require_eq(
8995 &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
8996 &u64::try_from(IMPORT_RELATIONS)?,
8997 "replayed import rejection count",
8998 )?;
8999 require_eq(
9000 &invalid_report.rejections.len(),
9001 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9002 "replayed import detail ceiling",
9003 )?;
9004 Ok(())
9005 }
9006
9007 #[test]
9008 fn admission_counts_distinct_same_span_facts_but_deduplicates_replays()
9009 -> Result<(), Box<dyn Error>> {
9010 let mut admission = GraphIdentityAdmission::default();
9011 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9012 let span = super::IdentitySpan {
9013 start_line: 7,
9014 start_column: 0,
9015 end_line: 7,
9016 end_column: 0,
9017 };
9018 let failure = [(
9019 GraphIdentityField::RelationTarget,
9020 GraphIdentityRejectionReason::Empty,
9021 )];
9022 admission.record(
9023 "src/facts.ts",
9024 span,
9025 ParserKind::TreeSitter,
9026 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9027 &failure,
9028 &control,
9029 )?;
9030 admission.record(
9031 "src/facts.ts",
9032 span,
9033 ParserKind::TreeSitter,
9034 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
9035 &failure,
9036 &control,
9037 )?;
9038 admission.record(
9039 "src/facts.ts",
9040 span,
9041 ParserKind::TreeSitter,
9042 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9043 &failure,
9044 &control,
9045 )?;
9046 require_eq(
9047 &admission.rejected_facts_for("src/facts.ts"),
9048 &2,
9049 "same-span distinct rejection count",
9050 )?;
9051 require_eq(
9052 &admission.rejections.len(),
9053 &2,
9054 "same-span distinct typed detail count",
9055 )?;
9056 require(
9057 admission.rejections[0].fact_index != admission.rejections[1].fact_index,
9058 "same-span distinct facts shared an internal identity",
9059 )?;
9060 Ok(())
9061 }
9062
9063 #[test]
9064 fn rejection_membership_preserves_distinct_fields_across_replay_and_merge()
9065 -> Result<(), Box<dyn Error>> {
9066 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9067 let span = super::IdentitySpan {
9068 start_line: 7,
9069 start_column: 0,
9070 end_line: 7,
9071 end_column: 0,
9072 };
9073 let failures = [
9074 (
9075 GraphIdentityField::RelationSource,
9076 GraphIdentityRejectionReason::ControlCharacters,
9077 ),
9078 (
9079 GraphIdentityField::RelationTarget,
9080 GraphIdentityRejectionReason::ControlCharacters,
9081 ),
9082 (
9083 GraphIdentityField::Parent,
9084 GraphIdentityRejectionReason::ControlCharacters,
9085 ),
9086 (
9087 GraphIdentityField::Signature,
9088 GraphIdentityRejectionReason::ControlCharacters,
9089 ),
9090 ];
9091 let mut admission = GraphIdentityAdmission::default();
9092 admission.record(
9093 "src/multi-field.ts",
9094 span,
9095 ParserKind::TreeSitter,
9096 17,
9097 &failures,
9098 &control,
9099 )?;
9100 admission.record(
9101 "src/multi-field.ts",
9102 span,
9103 ParserKind::TreeSitter,
9104 17,
9105 &failures,
9106 &control,
9107 )?;
9108 require_eq(
9109 &admission.rejections.len(),
9110 &failures.len(),
9111 "same-fact replay collapsed distinct rejection fields",
9112 )?;
9113 for &(field, reason) in &failures {
9114 require(
9115 admission
9116 .rejections
9117 .iter()
9118 .any(|rejection| rejection.field == field && rejection.reason == reason),
9119 "same-fact rejection field was lost",
9120 )?;
9121 }
9122
9123 let mut aggregated = Vec::new();
9124 let mut aggregated_keys = BTreeSet::new();
9125 super::extend_bounded_identity_rejections(
9126 &mut aggregated,
9127 &mut aggregated_keys,
9128 admission.rejections.iter().cloned(),
9129 )?;
9130 super::extend_bounded_identity_rejections(
9131 &mut aggregated,
9132 &mut aggregated_keys,
9133 admission.rejections,
9134 )?;
9135 require_eq(
9136 &aggregated.len(),
9137 &failures.len(),
9138 "bounded aggregation collapsed distinct rejection fields",
9139 )?;
9140 require_eq(
9141 &aggregated_keys.len(),
9142 &failures.len(),
9143 "bounded aggregation membership did not deduplicate replay",
9144 )?;
9145 Ok(())
9146 }
9147
9148 #[test]
9149 fn admission_merge_deduplicates_replayed_observed_facts() -> Result<(), Box<dyn Error>> {
9150 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9151 let span = super::IdentitySpan {
9152 start_line: 7,
9153 start_column: 0,
9154 end_line: 7,
9155 end_column: 0,
9156 };
9157 let failure = [(
9158 GraphIdentityField::RelationTarget,
9159 GraphIdentityRejectionReason::Empty,
9160 )];
9161 let mut first = GraphIdentityAdmission::default();
9162 for fact_index in 1..=2 {
9163 first.record(
9164 "src/merged.ts",
9165 span,
9166 ParserKind::TreeSitter,
9167 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9168 &failure,
9169 &control,
9170 )?;
9171 }
9172 let mut replay = GraphIdentityAdmission::default();
9173 for fact_index in 1..=3 {
9174 replay.record(
9175 "src/merged.ts",
9176 span,
9177 ParserKind::TreeSitter,
9178 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9179 &failure,
9180 &control,
9181 )?;
9182 }
9183 first.merge(replay, &control)?;
9184 require_eq(
9185 &first.rejected_facts_for("src/merged.ts"),
9186 &3,
9187 "merged replayed identity count",
9188 )?;
9189 require_eq(
9190 &first.rejections.len(),
9191 &3,
9192 "merged replayed typed detail count",
9193 )?;
9194 Ok(())
9195 }
9196
9197 #[test]
9198 fn admission_deduplicates_replayed_facts_after_detail_ceiling() -> Result<(), Box<dyn Error>> {
9199 let mut admission = GraphIdentityAdmission::default();
9200 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9201 let span = super::IdentitySpan {
9202 start_line: 7,
9203 start_column: 0,
9204 end_line: 7,
9205 end_column: 0,
9206 };
9207 let failure = [(
9208 GraphIdentityField::RelationTarget,
9209 GraphIdentityRejectionReason::Empty,
9210 )];
9211 for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
9212 admission.record(
9213 "src/capped.ts",
9214 span,
9215 ParserKind::TreeSitter,
9216 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9217 &failure,
9218 &control,
9219 )?;
9220 }
9221 require_eq(
9222 &admission.rejected_facts_for("src/capped.ts"),
9223 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
9224 "capped distinct rejection count",
9225 )?;
9226 require_eq(
9227 &admission.rejections.len(),
9228 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9229 "capped typed detail count",
9230 )?;
9231 admission.record(
9232 "src/capped.ts",
9233 span,
9234 ParserKind::TreeSitter,
9235 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
9236 &failure,
9237 &control,
9238 )?;
9239 require_eq(
9240 &admission.rejected_facts_for("src/capped.ts"),
9241 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
9242 "replayed capped rejection count",
9243 )?;
9244 require_eq(
9245 &admission.rejections.len(),
9246 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9247 "replayed capped typed detail count",
9248 )?;
9249 admission.record(
9250 "src/capped.ts",
9251 span,
9252 ParserKind::TreeSitter,
9253 super::parser_fact_index(
9254 super::RELATION_FACT_INDEX_NAMESPACE,
9255 super::MAX_GRAPH_IDENTITY_REJECTIONS,
9256 ),
9257 &failure,
9258 &control,
9259 )?;
9260 require_eq(
9261 &admission.rejected_facts_for("src/capped.ts"),
9262 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS + 1)?,
9263 "new capped rejection count",
9264 )?;
9265 require_eq(
9266 &admission.rejections.len(),
9267 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9268 "new capped typed detail count",
9269 )?;
9270 let mut aggregated = Vec::new();
9271 let mut aggregated_keys = BTreeSet::new();
9272 super::extend_bounded_identity_rejections(
9273 &mut aggregated,
9274 &mut aggregated_keys,
9275 admission.rejections.iter().cloned(),
9276 )?;
9277 super::extend_bounded_identity_rejections(
9278 &mut aggregated,
9279 &mut aggregated_keys,
9280 admission.rejections,
9281 )?;
9282 require_eq(
9283 &aggregated.len(),
9284 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9285 "bounded aggregation changed the distinct detail cardinality",
9286 )?;
9287 require_eq(
9288 &aggregated_keys.len(),
9289 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9290 "bounded aggregation membership lost a distinct detail",
9291 )?;
9292 Ok(())
9293 }
9294
9295 #[test]
9296 fn identity_rejection_limit_marker_is_causal_and_path_scoped() -> Result<(), Box<dyn Error>> {
9297 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9298 let failure = [(
9299 GraphIdentityField::Symbol,
9300 GraphIdentityRejectionReason::Empty,
9301 )];
9302 let span = |line| super::IdentitySpan {
9303 start_line: line,
9304 start_column: 0,
9305 end_line: line,
9306 end_column: 0,
9307 };
9308 let mut admission = GraphIdentityAdmission::default();
9309 for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
9310 admission.record(
9311 "src/exact-cap.ts",
9312 span(fact_index + 1),
9313 ParserKind::TreeSitter,
9314 super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, fact_index),
9315 &failure,
9316 &control,
9317 )?;
9318 }
9319 require(
9320 admission.rejection_details_dropped_by_path.is_empty(),
9321 "an exactly full retained detail set claimed an eviction",
9322 )?;
9323
9324 let graph = |path: &str, parser: ParserKind| SymbolGraph {
9325 path: path.to_string(),
9326 language: Some("test".to_string()),
9327 parser,
9328 symbols: Vec::new(),
9329 relations: Vec::new(),
9330 };
9331 let exact_cap = super::coverage_for_graph(
9332 &graph("src/exact-cap.ts", ParserKind::TreeSitter),
9333 IndexGeneration::new(1),
9334 &admission,
9335 &GraphIdentityAdmission::default(),
9336 )?;
9337 require_eq(
9338 &exact_cap.reached_limit(),
9339 &None,
9340 "exactly full retained details reported an eviction",
9341 )?;
9342
9343 admission.record(
9344 "src/exact-parser.ts",
9345 span(1),
9346 ParserKind::TreeSitter,
9347 super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, 10_000),
9348 &failure,
9349 &control,
9350 )?;
9351 admission.record(
9352 "src/markdown-structural.md",
9353 span(1),
9354 ParserKind::Structural,
9355 super::parser_fact_index(super::MARKDOWN_FACT_INDEX_NAMESPACE, 0),
9356 &failure,
9357 &control,
9358 )?;
9359 require(
9360 admission
9361 .rejection_details_dropped_by_path
9362 .contains("src/exact-parser.ts")
9363 && admission
9364 .rejection_details_dropped_by_path
9365 .contains("src/markdown-structural.md"),
9366 "distinct exact and structural evictions were not marked by path",
9367 )?;
9368 for (path, parser) in [
9369 ("src/exact-parser.ts", ParserKind::TreeSitter),
9370 ("src/markdown-structural.md", ParserKind::Structural),
9371 ] {
9372 let coverage = super::coverage_for_graph(
9373 &graph(path, parser),
9374 IndexGeneration::new(1),
9375 &admission,
9376 &GraphIdentityAdmission::default(),
9377 )?;
9378 require_eq(
9379 &coverage.reached_limit(),
9380 &Some(GraphLimitKind::Rows),
9381 "causal identity eviction did not reach path coverage",
9382 )?;
9383 }
9384
9385 for parser in [ParserKind::Structural, ParserKind::Fallback] {
9386 let coverage = super::coverage_for_graph(
9387 &graph("src/baseline.rs", parser),
9388 IndexGeneration::new(1),
9389 &GraphIdentityAdmission::default(),
9390 &GraphIdentityAdmission::default(),
9391 )?;
9392 require_eq(
9393 &coverage.reached_limit(),
9394 &None,
9395 "parser baseline omission claimed identity-detail eviction",
9396 )?;
9397 }
9398 let unrelated = super::coverage_for_graph(
9399 &graph("src/unrelated.rs", ParserKind::TreeSitter),
9400 IndexGeneration::new(1),
9401 &admission,
9402 &GraphIdentityAdmission::default(),
9403 )?;
9404 require_eq(
9405 &unrelated.reached_limit(),
9406 &None,
9407 "an unrelated path inherited another path's identity eviction",
9408 )?;
9409 Ok(())
9410 }
9411
9412 #[test]
9413 fn document_text_gap_survives_graph_coverage_projection() -> Result<(), Box<dyn Error>> {
9414 for (limit, reached, reason) in [
9415 (
9416 projectatlas_symbols::DocumentLimit::OutputBytes,
9417 GraphLimitKind::OutputBytes,
9418 "resource_limit:output_bytes",
9419 ),
9420 (
9421 projectatlas_symbols::DocumentLimit::ParserWorkBytes,
9422 GraphLimitKind::IntermediateBytes,
9423 "resource_limit:parser_work_bytes",
9424 ),
9425 ] {
9426 let facts = projectatlas_symbols::DocumentFacts {
9427 format: projectatlas_symbols::DocumentFormat::Docx,
9428 text: String::new(),
9429 facts: Vec::new(),
9430 symbols: Vec::new(),
9431 completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9432 gaps: vec![projectatlas_symbols::DocumentCoverageGap::ResourceLimit(
9433 limit,
9434 )],
9435 },
9436 provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
9437 };
9438 for language in [None, Some("DOCX")] {
9439 let graph = facts.symbol_graph("docs/limited.docx", language);
9440 require_eq(
9441 &graph.language.as_deref(),
9442 &Some("docx"),
9443 "canonical language",
9444 )?;
9445 require_eq(
9446 &super::navigable_symbol_count(&graph),
9447 &0,
9448 "coverage marker was counted",
9449 )?;
9450 let coverage = super::coverage_for_graph(
9451 &graph,
9452 IndexGeneration::new(1),
9453 &GraphIdentityAdmission::default(),
9454 &GraphIdentityAdmission::default(),
9455 )?;
9456 require_eq(
9457 &coverage.state(),
9458 &CoverageState::Failed,
9459 "empty document gap was lost",
9460 )?;
9461 require_eq(
9462 &coverage.reached_limit(),
9463 &Some(reached),
9464 "document ceiling was lost",
9465 )?;
9466 require(
9467 coverage
9468 .reason()
9469 .is_some_and(|value| value.as_str().contains(reason)),
9470 "document-local reason was lost",
9471 )?;
9472 }
9473 }
9474 for (limit, reached_limit, reason) in [
9475 (
9476 projectatlas_symbols::DocumentLimit::ExecutionFuel,
9477 None,
9478 "resource_limit:execution_fuel",
9479 ),
9480 (
9481 projectatlas_symbols::DocumentLimit::ExpandedBytes,
9482 Some(GraphLimitKind::IntermediateBytes),
9483 "resource_limit:expanded_bytes",
9484 ),
9485 ] {
9486 let facts = projectatlas_symbols::DocumentFacts {
9487 format: projectatlas_symbols::DocumentFormat::Pdf,
9488 text: String::new(),
9489 facts: Vec::new(),
9490 symbols: Vec::new(),
9491 completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9492 gaps: vec![projectatlas_symbols::DocumentCoverageGap::ResourceLimit(
9493 limit,
9494 )],
9495 },
9496 provenance: projectatlas_symbols::DocumentParserProvenance::PdfExtract,
9497 };
9498 let graph = facts.symbol_graph("docs/limited.pdf", Some("PDF"));
9499 let coverage = super::coverage_for_graph(
9500 &graph,
9501 IndexGeneration::new(1),
9502 &GraphIdentityAdmission::default(),
9503 &GraphIdentityAdmission::default(),
9504 )?;
9505 require_eq(&graph.language.as_deref(), &Some("pdf"), "PDF language")?;
9506 require_eq(
9507 &super::navigable_symbol_count(&graph),
9508 &0,
9509 "PDF coverage marker",
9510 )?;
9511 require_eq(
9512 &coverage.state(),
9513 &CoverageState::Failed,
9514 "PDF incomplete state",
9515 )?;
9516 require_eq(
9517 &coverage.reached_limit(),
9518 &reached_limit,
9519 "PDF resource ceiling",
9520 )?;
9521 require(
9522 coverage
9523 .reason()
9524 .is_some_and(|value| value.as_str().contains(reason)),
9525 "PDF resource reason was lost",
9526 )?;
9527 }
9528 Ok(())
9529 }
9530
9531 #[test]
9532 fn document_coverage_marker_preserves_entities_and_accounts_ordinal_metadata()
9533 -> Result<(), Box<dyn Error>> {
9534 let facts = projectatlas_symbols::DocumentFacts {
9535 format: projectatlas_symbols::DocumentFormat::Docx,
9536 text: "Hello".to_owned(),
9537 facts: vec![projectatlas_symbols::DocumentFact {
9538 text: "Hello".to_owned(),
9539 locator: projectatlas_symbols::DocumentLocator::Docx {
9540 part: "word/document.xml".to_owned(),
9541 paragraph: 1,
9542 run: 1,
9543 text_start: 0,
9544 text_end: 5,
9545 },
9546 line_start: 1,
9547 line_end: 1,
9548 }],
9549 symbols: Vec::new(),
9550 completeness: projectatlas_symbols::DocumentCompleteness::Partial {
9551 gaps: vec![projectatlas_symbols::DocumentCoverageGap::UnexaminedStory],
9552 },
9553 provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
9554 };
9555 let graph = facts.symbol_graph("guide.docx", None);
9556 let mut without_marker = graph.clone();
9557 without_marker.symbols.pop();
9558 let project = ProjectInstanceId::from_bytes([33; 16])?;
9559 let generation = IndexGeneration::new(1);
9560 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9561 let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
9562 let baseline = build_entity_projection_with_config_limit(
9563 project,
9564 generation,
9565 &[],
9566 std::slice::from_ref(&without_marker),
9567 &packages,
9568 &ConfiguredModuleResolution::default(),
9569 None,
9570 false,
9571 &control,
9572 super::super::MAX_PUBLICATION_STAGING_BYTES,
9573 )?;
9574 let with_marker = build_entity_projection_with_config_limit(
9575 project,
9576 generation,
9577 &[],
9578 std::slice::from_ref(&graph),
9579 &packages,
9580 &ConfiguredModuleResolution::default(),
9581 None,
9582 false,
9583 &control,
9584 super::super::MAX_PUBLICATION_STAGING_BYTES,
9585 )?;
9586 require_eq(
9587 &with_marker.entity_by_digest,
9588 &baseline.entity_by_digest,
9589 "coverage marker created an entity",
9590 )?;
9591 require_eq(
9592 &with_marker.entity_exports,
9593 &baseline.entity_exports,
9594 "coverage marker created exported keys",
9595 )?;
9596 require(
9597 with_marker.retained_bytes > baseline.retained_bytes,
9598 "coverage marker's retained ordinal metadata was omitted",
9599 )?;
9600 build_entity_projection_with_config_limit(
9601 project,
9602 generation,
9603 &[],
9604 std::slice::from_ref(&graph),
9605 &packages,
9606 &ConfiguredModuleResolution::default(),
9607 None,
9608 false,
9609 &control,
9610 with_marker.peak_retained_bytes,
9611 )?;
9612 let mut stage = symbol_build_stage_for_graphs(vec![graph.clone()]);
9613 super::admit_symbol_build_stage(&mut stage, &control)?;
9614 require_eq(&stage.report.symbols, &1, "coverage marker inflated report")?;
9615 let coverage = coverage_for_graph(
9616 &graph,
9617 generation,
9618 &GraphIdentityAdmission::default(),
9619 &GraphIdentityAdmission::default(),
9620 )?;
9621 require_eq(
9622 &coverage.state(),
9623 &CoverageState::Partial,
9624 "coverage was lost",
9625 )?;
9626 Ok(())
9627 }
9628
9629 #[test]
9630 fn admission_merges_many_graphs_with_incremental_retained_bytes() -> Result<(), Box<dyn Error>>
9631 {
9632 const GRAPH_COUNT: usize = 1_000;
9633 const FACTS_PER_GRAPH: usize = 10;
9634 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9635 let span = super::IdentitySpan {
9636 start_line: 7,
9637 start_column: 0,
9638 end_line: 7,
9639 end_column: 0,
9640 };
9641 let failure = [(
9642 GraphIdentityField::RelationTarget,
9643 GraphIdentityRejectionReason::Empty,
9644 )];
9645 let mut admission = GraphIdentityAdmission::default();
9646 let mut expected_bytes = 0_u64;
9647 for graph_index in 0..GRAPH_COUNT {
9648 let path = format!("src/many-graphs-{graph_index}.ts");
9649 let mut graph = GraphIdentityAdmission::default();
9650 for fact_index in 0..FACTS_PER_GRAPH {
9651 graph.record(
9652 &path,
9653 span,
9654 ParserKind::TreeSitter,
9655 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9656 &failure,
9657 &control,
9658 )?;
9659 }
9660 admission.merge(graph, &control)?;
9661 let key_bytes = super::identity_rejection_key_retained_bytes(&path)?
9662 .checked_mul(u64::try_from(FACTS_PER_GRAPH)?)
9663 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9664 let graph_bytes = super::identity_observed_path_retained_bytes(&path, FACTS_PER_GRAPH)?
9665 .checked_add(super::identity_count_path_retained_bytes(&path)?)
9666 .and_then(|bytes| bytes.checked_add(key_bytes))
9667 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9668 expected_bytes = expected_bytes
9669 .checked_add(graph_bytes)
9670 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
9671 require_eq(
9672 &admission.observed_fact_bytes,
9673 &expected_bytes,
9674 "incremental identity byte cache",
9675 )?;
9676 }
9677 require_eq(
9678 &admission.rejections.len(),
9679 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9680 "many-graph retained detail ceiling",
9681 )?;
9682 require_eq(
9683 &admission.rejection_keys.len(),
9684 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
9685 "many-graph keyed detail ceiling",
9686 )?;
9687 require_eq(
9688 &super::identity_admission_retained_bytes(&admission),
9689 &expected_bytes,
9690 "incremental identity byte cache remained authoritative",
9691 )?;
9692
9693 let replay_path = "src/many-graphs-999.ts";
9694 let mut replay = GraphIdentityAdmission::default();
9695 for fact_index in 0..FACTS_PER_GRAPH {
9696 replay.record(
9697 replay_path,
9698 span,
9699 ParserKind::TreeSitter,
9700 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
9701 &failure,
9702 &control,
9703 )?;
9704 }
9705 admission.merge(replay, &control)?;
9706 require_eq(
9707 &admission.observed_fact_bytes,
9708 &expected_bytes,
9709 "replayed graph did not inflate identity byte cache",
9710 )?;
9711 Ok(())
9712 }
9713
9714 #[test]
9715 fn observed_identity_facts_charge_budget_at_minus_equal_plus_boundaries()
9716 -> Result<(), Box<dyn Error>> {
9717 let path = "src/budget.ts";
9718 let span = super::IdentitySpan {
9719 start_line: 7,
9720 start_column: 0,
9721 end_line: 7,
9722 end_column: 0,
9723 };
9724 let failure = [(
9725 GraphIdentityField::RelationTarget,
9726 GraphIdentityRejectionReason::Empty,
9727 )];
9728 let additional = super::identity_fact_retained_bytes(path, true, true)?
9729 .checked_add(super::identity_rejection_key_retained_bytes(path)?)
9730 .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
9731 let next_fact_additional = super::STAGED_GRAPH_ROW_BYTES
9732 .checked_add(super::identity_rejection_key_retained_bytes(path)?)
9733 .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
9734
9735 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9736 let mut below = GraphIdentityAdmission {
9737 observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional + 1,
9738 ..GraphIdentityAdmission::default()
9739 };
9740 let error = below
9741 .record(
9742 path,
9743 span,
9744 ParserKind::TreeSitter,
9745 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9746 &failure,
9747 &control,
9748 )
9749 .err()
9750 .ok_or_else(|| io::Error::other("one byte over the identity budget was retained"))?;
9751 require(
9752 matches!(
9753 error,
9754 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9755 stage: IndexWorkStage::SymbolParsing,
9756 resource: IndexWorkResource::OutputBytes,
9757 limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9758 observed,
9759 }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + 1
9760 ),
9761 "identity budget overflow did not return its typed refusal",
9762 )?;
9763 require(
9764 below.observed_facts.is_empty() && below.rejected_facts_by_path.is_empty(),
9765 "identity budget refusal retained partial state",
9766 )?;
9767
9768 let mut equal = GraphIdentityAdmission {
9769 observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional,
9770 ..GraphIdentityAdmission::default()
9771 };
9772 equal.record(
9773 path,
9774 span,
9775 ParserKind::TreeSitter,
9776 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
9777 &failure,
9778 &control,
9779 )?;
9780 require_eq(
9781 &equal.observed_fact_bytes,
9782 &MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9783 "identity budget exact boundary",
9784 )?;
9785 require_eq(
9786 &equal.rejected_facts_for(path),
9787 &1,
9788 "identity budget exact boundary count",
9789 )?;
9790
9791 let error = equal
9792 .record(
9793 path,
9794 span,
9795 ParserKind::TreeSitter,
9796 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
9797 &failure,
9798 &control,
9799 )
9800 .err()
9801 .ok_or_else(|| io::Error::other("one additional identity fact exceeded the budget"))?;
9802 require(
9803 matches!(
9804 error,
9805 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9806 stage: IndexWorkStage::SymbolParsing,
9807 resource: IndexWorkResource::OutputBytes,
9808 limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9809 observed,
9810 }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + next_fact_additional
9811 ),
9812 "identity budget plus boundary did not return its typed refusal",
9813 )?;
9814 require_eq(
9815 &equal.rejected_facts_for(path),
9816 &1,
9817 "identity plus boundary changed retained count",
9818 )?;
9819 Ok(())
9820 }
9821
9822 #[test]
9823 fn observed_identity_facts_honor_cancellation_before_retention() -> Result<(), Box<dyn Error>> {
9824 let cancellation = IndexCancellation::new();
9825 let control = IndexWorkControl::new(cancellation.clone(), None);
9826 cancellation.cancel();
9827 let mut admission = GraphIdentityAdmission::default();
9828 let error = admission
9829 .record(
9830 "src/canceled.ts",
9831 super::IdentitySpan {
9832 start_line: 1,
9833 start_column: 0,
9834 end_line: 1,
9835 end_column: 0,
9836 },
9837 ParserKind::TreeSitter,
9838 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
9839 &[(
9840 GraphIdentityField::RelationTarget,
9841 GraphIdentityRejectionReason::Empty,
9842 )],
9843 &control,
9844 )
9845 .err()
9846 .ok_or_else(|| io::Error::other("canceled identity admission retained a fact"))?;
9847 require(
9848 matches!(
9849 error,
9850 CliError::IndexWork(IndexWorkFailure::Cancelled {
9851 stage: IndexWorkStage::SymbolParsing
9852 })
9853 ),
9854 "identity admission cancellation was not typed",
9855 )?;
9856 require(
9857 admission.observed_facts.is_empty()
9858 && admission.rejected_facts_by_path.is_empty()
9859 && admission.observed_fact_bytes == 0,
9860 "canceled identity admission retained partial state",
9861 )?;
9862 Ok(())
9863 }
9864
9865 #[test]
9866 fn reused_reconciliation_paths_charge_all_maps_at_injected_boundaries()
9867 -> Result<(), Box<dyn Error>> {
9868 let mut report = GraphIdentityAdmission::default();
9869 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9870 for index in 0..32 {
9871 let path = format!("src/reused-{index}.ts");
9872 for _ in 0..4 {
9873 report.reserve_reused_path_bytes(
9874 &path,
9875 false,
9876 &control,
9877 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9878 )?;
9879 }
9880 report.reused_rejection_counts.insert(path.clone(), 2);
9881 report
9882 .reused_parser_rejection_counts
9883 .insert(path.clone(), 1);
9884 report
9885 .reused_rejection_detail_counts
9886 .insert(path.clone(), 0);
9887 report.reused_rejection_details_incomplete.insert(path);
9888 }
9889 let retained = super::identity_admission_retained_bytes(&report);
9890 require(
9891 retained > 0,
9892 "reused reconciliation bytes were not retained",
9893 )?;
9894
9895 let below = retained - 1;
9896 let error = super::checked_identity_admission_budget(&report, &control, below)
9897 .err()
9898 .ok_or_else(|| io::Error::other("below-limit reused paths were accepted"))?;
9899 require(
9900 matches!(
9901 error,
9902 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9903 stage: IndexWorkStage::SymbolParsing,
9904 resource: IndexWorkResource::OutputBytes,
9905 limit,
9906 observed,
9907 }) if limit == below && observed == retained
9908 ),
9909 "below-limit reused paths did not return their typed refusal",
9910 )?;
9911 require_eq(
9912 &super::checked_identity_admission_budget(&report, &control, retained)?,
9913 &retained,
9914 "equal-limit reused paths",
9915 )?;
9916 require_eq(
9917 &super::checked_identity_admission_budget(&report, &control, retained + 1)?,
9918 &retained,
9919 "above-limit reused paths",
9920 )?;
9921 Ok(())
9922 }
9923
9924 #[test]
9925 fn reused_reconciliation_retention_honors_cancellation_before_insertion()
9926 -> Result<(), Box<dyn Error>> {
9927 let cancellation = IndexCancellation::new();
9928 let control = IndexWorkControl::new(cancellation.clone(), None);
9929 cancellation.cancel();
9930 let mut report = GraphIdentityAdmission::default();
9931 let error = report
9932 .reserve_reused_path_bytes(
9933 "src/canceled-reuse.ts",
9934 false,
9935 &control,
9936 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9937 )
9938 .err()
9939 .ok_or_else(|| io::Error::other("canceled reused path was retained"))?;
9940 require(
9941 matches!(
9942 error,
9943 CliError::IndexWork(IndexWorkFailure::Cancelled {
9944 stage: IndexWorkStage::SymbolParsing
9945 })
9946 ),
9947 "reused path cancellation was not typed",
9948 )?;
9949 require(
9950 report.reused_rejection_counts.is_empty()
9951 && report.reused_parser_rejection_counts.is_empty()
9952 && report.reused_rejection_detail_counts.is_empty()
9953 && report.reused_rejection_details_incomplete.is_empty()
9954 && report.observed_fact_bytes == 0,
9955 "canceled reused path retained reconciliation state",
9956 )?;
9957
9958 let mut incoming = GraphIdentityAdmission::default();
9959 incoming
9960 .reused_rejection_counts
9961 .insert("src/incoming.ts".to_string(), 1);
9962 let error = report
9963 .merge(incoming, &control)
9964 .err()
9965 .ok_or_else(|| io::Error::other("canceled merge retained reconciliation state"))?;
9966 require(
9967 matches!(
9968 error,
9969 CliError::IndexWork(IndexWorkFailure::Cancelled {
9970 stage: IndexWorkStage::SymbolParsing
9971 })
9972 ),
9973 "merge cancellation was not typed",
9974 )?;
9975 require(
9976 report.reused_rejection_counts.is_empty() && report.observed_fact_bytes == 0,
9977 "canceled merge retained reconciliation state",
9978 )?;
9979 Ok(())
9980 }
9981
9982 #[test]
9983 fn reused_identity_counts_preserve_new_and_removed_derived_outcomes()
9984 -> Result<(), Box<dyn Error>> {
9985 let path = "src/reused.ts";
9986 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9987 let failure = [(
9988 GraphIdentityField::ResolutionKey,
9989 GraphIdentityRejectionReason::Oversized,
9990 )];
9991 let mut admission = GraphIdentityAdmission::default();
9992 for _ in 0..2 {
9993 admission.reserve_reused_path_bytes(
9994 path,
9995 false,
9996 &control,
9997 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9998 )?;
9999 }
10000 admission
10001 .reused_rejection_counts
10002 .insert(path.to_string(), 3);
10003 admission
10004 .reused_rejection_detail_counts
10005 .insert(path.to_string(), 2);
10006 for fact_index in 0..2 {
10007 admission.record(
10008 path,
10009 super::IdentitySpan {
10010 start_line: fact_index + 1,
10011 start_column: 0,
10012 end_line: fact_index + 1,
10013 end_column: 0,
10014 },
10015 ParserKind::TreeSitter,
10016 fact_index as u64,
10017 &failure,
10018 &control,
10019 )?;
10020 }
10021 let persisted_facts = admission
10022 .observed_facts
10023 .get(path)
10024 .cloned()
10025 .ok_or("persisted identity fact keys are missing")?;
10026 admission.reserve_identity_bytes(
10027 super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
10028 &control,
10029 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10030 )?;
10031 admission
10032 .reused_rejection_facts
10033 .insert(path.to_string(), persisted_facts);
10034 require_eq(
10035 &admission.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
10036 &3,
10037 "reused unchanged rejection count",
10038 )?;
10039
10040 let mut derived = GraphIdentityAdmission::default();
10041 derived.record(
10042 path,
10043 super::IdentitySpan {
10044 start_line: 3,
10045 start_column: 0,
10046 end_line: 3,
10047 end_column: 0,
10048 },
10049 ParserKind::TreeSitter,
10050 2,
10051 &failure,
10052 &control,
10053 )?;
10054 require_eq(
10055 &admission.rejected_facts_for_graph(path, &derived)?,
10056 &4,
10057 "reused genuinely new rejection count",
10058 )?;
10059
10060 let mut removed = GraphIdentityAdmission::default();
10061 for _ in 0..2 {
10062 removed.reserve_reused_path_bytes(
10063 path,
10064 false,
10065 &control,
10066 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10067 )?;
10068 }
10069 removed.reused_rejection_counts.insert(path.to_string(), 2);
10070 removed
10071 .reused_rejection_detail_counts
10072 .insert(path.to_string(), 2);
10073 removed.reused_rejection_facts.insert(
10074 path.to_string(),
10075 admission
10076 .reused_rejection_facts
10077 .get(path)
10078 .cloned()
10079 .ok_or("persisted identity fact keys are missing")?,
10080 );
10081 let persisted_facts = removed
10082 .reused_rejection_facts
10083 .get(path)
10084 .cloned()
10085 .ok_or("persisted identity fact keys are missing")?;
10086 removed.reserve_identity_bytes(
10087 super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
10088 &control,
10089 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
10090 )?;
10091 removed.record(
10092 path,
10093 super::IdentitySpan {
10094 start_line: 1,
10095 start_column: 0,
10096 end_line: 1,
10097 end_column: 0,
10098 },
10099 ParserKind::TreeSitter,
10100 0,
10101 &failure,
10102 &control,
10103 )?;
10104 require_eq(
10105 &removed.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
10106 &1,
10107 "reused removed rejection count",
10108 )?;
10109 require_eq(
10110 &removed.rejected_facts_for_graph(path, &derived)?,
10111 &2,
10112 "reused removed plus new rejection count",
10113 )?;
10114 Ok(())
10115 }
10116
10117 #[test]
10118 fn relation_rejections_keep_original_parser_ordinals_through_reopen_and_retry()
10119 -> Result<(), Box<dyn Error>> {
10120 let temp = tempfile::tempdir()?;
10121 let root = fs::canonicalize(temp.path())?;
10122 fs::create_dir_all(root.join(".projectatlas"))?;
10123 let database = root.join(".projectatlas/projectatlas.db");
10124 let components = (0..20)
10125 .map(|_| "d".repeat(200))
10126 .collect::<Vec<_>>()
10127 .join("/");
10128 let path = format!("src/{components}/page.ts");
10129 let invalid_target = "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1);
10130 let oversized_resolved_module = "m".repeat(100);
10131 let graph = SymbolGraph {
10132 path: path.clone(),
10133 language: Some("typescript".to_string()),
10134 parser: ParserKind::TreeSitter,
10135 symbols: vec![CodeSymbol {
10136 path: path.clone(),
10137 language: Some("typescript".to_string()),
10138 name: "caller".to_string(),
10139 kind: SymbolKind::Function,
10140 signature: "function caller()".to_string(),
10141 exported: true,
10142 documentation: None,
10143 line_start: 1,
10144 line_end: 1,
10145 source_selector: None,
10146 parent: None,
10147 parser: ParserKind::TreeSitter,
10148 detail: None,
10149 }],
10150 relations: vec![
10151 SymbolRelation {
10152 path: path.clone(),
10153 source_name: "caller".to_string(),
10154 target_name: invalid_target,
10155 kind: RelationKind::Calls,
10156 line: 1,
10157 context: "caller()".to_string(),
10158 parser: ParserKind::TreeSitter,
10159 },
10160 SymbolRelation {
10161 path: path.clone(),
10162 source_name: "<module>".to_string(),
10163 target_name: format!("import {{ run }} from './{oversized_resolved_module}';"),
10164 kind: RelationKind::Imports,
10165 line: 1,
10166 context: "relation ordinal fixture".to_string(),
10167 parser: ParserKind::TreeSitter,
10168 },
10169 ],
10170 };
10171 let nodes = vec![test_file_node(&path, "typescript")];
10172 let mut store = AtlasStore::open_for_project(&database, &root)?;
10173 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10174 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10175 let symbols = symbol_build_stage_for_graphs(vec![graph.clone()]);
10176 let staged = stage_full_repository_graph(
10177 &store,
10178 &root,
10179 IndexGeneration::ZERO,
10180 &nodes,
10181 &scan_policy,
10182 &symbols,
10183 &control,
10184 )?;
10185 let relation_rejections = staged
10186 .identity_rejections
10187 .iter()
10188 .filter(|row| {
10189 matches!(
10190 row.field,
10191 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10192 )
10193 })
10194 .collect::<Vec<_>>();
10195 require_eq(
10196 &relation_rejections.len(),
10197 &2,
10198 "same-line relation rejection detail count",
10199 )?;
10200 require(
10201 relation_rejections.iter().all(|row| {
10202 row.path.as_str() == path
10203 && row.span.start_line() == 1
10204 && row.span.end_line() == 1
10205 && row.parser == ParserKind::TreeSitter
10206 && row.reason == GraphIdentityRejectionReason::Oversized
10207 }),
10208 "same-line relation rejection provenance was not exact",
10209 )?;
10210 let mut fact_indices = relation_rejections
10211 .iter()
10212 .map(|row| row.fact_index)
10213 .collect::<Vec<_>>();
10214 fact_indices.sort_unstable();
10215 require_eq(
10216 &fact_indices,
10217 &vec![
10218 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
10219 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
10220 ],
10221 "same-line relation parser ordinals",
10222 )?;
10223 require_eq(
10224 &staged.relations.len(),
10225 &1,
10226 "valid relation retained after source admission",
10227 )?;
10228 publish_full_staged_graph(
10229 &mut store,
10230 &nodes,
10231 &staged,
10232 &control,
10233 "relation-ordinal-full",
10234 )?;
10235 let first_generation = store
10236 .index_publication()?
10237 .ok_or("relation ordinal full publication is missing")?
10238 .generation;
10239 let path_key = RepositoryNodePath::new(Path::new(&path))?;
10240 let persisted = store.repository_graph_identity_rejections(
10241 store
10242 .project_instance_id()?
10243 .ok_or("relation ordinal project identity is missing")?,
10244 std::slice::from_ref(&path_key),
10245 16,
10246 None,
10247 )?;
10248 let persisted_fact_indices = persisted
10249 .iter()
10250 .filter(|row| {
10251 matches!(
10252 row.field,
10253 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10254 )
10255 })
10256 .map(|row| row.fact_index)
10257 .collect::<Vec<_>>();
10258 require_eq(
10259 &persisted_fact_indices,
10260 &fact_indices,
10261 "persisted same-line relation parser ordinals",
10262 )?;
10263 let persisted_wire = serde_json::to_string(&persisted)?;
10264 require(
10265 !persisted_wire.contains(&"x".repeat(32)),
10266 "persisted relation rejection retained invalid identity text",
10267 )?;
10268 drop(store);
10269
10270 let mut store = AtlasStore::open_for_project(&database, &root)?;
10271 let reopened = store.repository_graph_identity_rejections(
10272 store
10273 .project_instance_id()?
10274 .ok_or("reopened relation ordinal project identity is missing")?,
10275 std::slice::from_ref(&path_key),
10276 16,
10277 None,
10278 )?;
10279 require_eq(
10280 &reopened
10281 .iter()
10282 .filter(|row| {
10283 matches!(
10284 row.field,
10285 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10286 )
10287 })
10288 .map(|row| row.fact_index)
10289 .collect::<Vec<_>>(),
10290 &fact_indices,
10291 "reopened same-line relation parser ordinals",
10292 )?;
10293
10294 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
10295 let incremental_policy =
10296 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
10297 let fault_graph = graph.clone();
10298 let incremental_symbols = symbol_build_stage_for_graphs(vec![graph]);
10299 let incremental = stage_incremental_repository_graph(
10300 &store,
10301 &root,
10302 first_generation,
10303 &nodes,
10304 std::slice::from_ref(&path),
10305 &incremental_policy,
10306 &incremental_symbols,
10307 &incremental_control,
10308 )?;
10309 let incremental_rejections = incremental
10310 .identity_rejections
10311 .iter()
10312 .filter(|row| {
10313 matches!(
10314 row.field,
10315 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10316 )
10317 })
10318 .map(|row| row.fact_index)
10319 .collect::<Vec<_>>();
10320 require_eq(
10321 &incremental_rejections,
10322 &fact_indices,
10323 "incremental same-line relation parser ordinals",
10324 )?;
10325 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
10326 canceled.cancel();
10327 {
10328 let mut publication = store.begin_index_publication("relation-ordinal-cancel")?;
10329 require(
10330 incremental.apply(&mut publication, &canceled).is_err(),
10331 "relation ordinal cancellation did not fail publication",
10332 )?;
10333 }
10334 require_eq(
10335 &store
10336 .index_publication()?
10337 .map(|publication| publication.generation),
10338 &Some(first_generation),
10339 "generation after canceled relation ordinal publication",
10340 )?;
10341 {
10342 let mut publication = store.begin_index_publication("relation-ordinal-incremental")?;
10343 incremental.apply(&mut publication, &incremental_control)?;
10344 publication.complete()?;
10345 }
10346 let second_generation = store
10347 .index_publication()?
10348 .ok_or("relation ordinal incremental publication is missing")?
10349 .generation;
10350 drop(store);
10351 let mut store = AtlasStore::open_for_project(&database, &root)?;
10352 let reopened_incremental = store.repository_graph_identity_rejections(
10353 store
10354 .project_instance_id()?
10355 .ok_or("reopened incremental project identity is missing")?,
10356 std::slice::from_ref(&path_key),
10357 16,
10358 None,
10359 )?;
10360 require(
10361 reopened_incremental.iter().any(|row| {
10362 matches!(
10363 row.field,
10364 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
10365 )
10366 }),
10367 "reopened incremental relation rejection is missing",
10368 )?;
10369 let mut fault = stage_incremental_repository_graph(
10370 &store,
10371 &root,
10372 second_generation,
10373 &nodes,
10374 std::slice::from_ref(&path),
10375 &incremental_policy,
10376 &symbol_build_stage_for_graphs(vec![fault_graph]),
10377 &incremental_control,
10378 )?;
10379 fault.identity_rejections.resize(
10380 usize::try_from(GraphLimits::MAX_ROWS)
10381 .unwrap_or(usize::MAX)
10382 .saturating_add(1),
10383 fault.identity_rejections[0].clone(),
10384 );
10385 {
10386 let mut publication = store.begin_index_publication("relation-ordinal-fault")?;
10387 require(
10388 fault.apply(&mut publication, &incremental_control).is_err(),
10389 "relation ordinal late fault did not fail publication",
10390 )?;
10391 }
10392 require_eq(
10393 &store
10394 .index_publication()?
10395 .map(|publication| publication.generation),
10396 &Some(second_generation),
10397 "generation after relation ordinal late fault",
10398 )?;
10399 Ok(())
10400 }
10401
10402 #[test]
10403 fn sqlite_recovered_invalid_manifest_context_uses_shared_admission()
10404 -> Result<(), Box<dyn Error>> {
10405 let temp = tempfile::tempdir()?;
10406 let root = fs::canonicalize(temp.path())?;
10407 let database = root.join(".projectatlas/projectatlas.db");
10408 fs::create_dir_all(root.join(".projectatlas"))?;
10409 let invalid_manifest = package_graph("Cargo.toml", "bad\0package");
10410 let direct_graph = function_graph("src/lib.rs", 1);
10411 let mut store = AtlasStore::open_for_project(&database, &root)?;
10412 store.replace_symbol_graph(&invalid_manifest)?;
10413 store.replace_symbol_graph(&direct_graph)?;
10414 drop(store);
10415
10416 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
10417 let recovered = reopened
10418 .load_symbol_graphs_for_paths(&["Cargo.toml".to_string(), "src/lib.rs".to_string()])?;
10419 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10420 let (admitted, report) =
10421 super::admit_symbol_graphs(recovered.into_iter().map(Cow::Owned).collect(), &control)?;
10422 require_eq(
10423 &report.rejected_facts_for("Cargo.toml"),
10424 &1,
10425 "recovered invalid manifest rejection count",
10426 )?;
10427 let manifest = admitted
10428 .iter()
10429 .find(|graph| graph.as_ref().path == "Cargo.toml")
10430 .ok_or("recovered manifest graph is missing")?;
10431 require(
10432 manifest.as_ref().symbols.is_empty(),
10433 "invalid recovered package identity reached package context",
10434 )?;
10435 let admitted_graphs = admitted.iter().map(SourceGraph::graph).collect::<Vec<_>>();
10436 let packages = PackageIndex::from_graphs(&admitted_graphs)?;
10437 require_eq(
10438 &packages.package_name("src/lib.rs"),
10439 &None,
10440 "invalid recovered manifest silently supplied package ownership",
10441 )?;
10442 Ok(())
10443 }
10444
10445 fn drop_native_worktree_identity_schema(connection: &Connection) -> rusqlite::Result<()> {
10447 connection.execute_batch(
10448 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10449 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10450 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10451 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10452 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10453 )
10454 }
10455 #[cfg(unix)]
10456 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
10457 std::os::unix::fs::symlink(target, link)
10458 }
10459
10460 #[cfg(windows)]
10461 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
10462 match std::os::windows::fs::symlink_dir(target, link) {
10463 Ok(()) => Ok(()),
10464 Err(source) if source.raw_os_error() == Some(1314) => {
10465 let status = std::process::Command::new("cmd")
10466 .arg("/C")
10467 .arg("mklink")
10468 .arg("/J")
10469 .arg(link)
10470 .arg(target)
10471 .status()?;
10472 if status.success() {
10473 Ok(())
10474 } else {
10475 Err(source)
10476 }
10477 }
10478 Err(source) => Err(source),
10479 }
10480 }
10481
10482 #[cfg(unix)]
10483 fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
10484 std::os::unix::fs::symlink(target, link)
10485 }
10486
10487 #[cfg(windows)]
10488 fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
10489 std::os::windows::fs::symlink_file(target, link)
10490 }
10491
10492 #[test]
10493 fn cargo_lock_dependencies_do_not_own_repository_sources() -> Result<(), Box<dyn Error>> {
10494 let manifest = extract_symbol_graph(
10495 "Cargo.toml",
10496 Some("cargo-manifest"),
10497 "[package]\nname='root-package'\nversion='0.1.0'\nedition='2024'\n",
10498 );
10499 let lock = extract_symbol_graph(
10500 "Cargo.lock",
10501 Some("cargo-lock"),
10502 "version=4\n[[package]]\nname='aaa-dependency'\nversion='1.0.0'\n[[package]]\nname='root-package'\nversion='0.1.0'\n",
10503 );
10504 let packages = PackageIndex::from_graphs(&[manifest, lock.clone()])?;
10505 for path in ["src/lib.rs", "src/controller.ts", "src/module.py"] {
10506 require_eq(
10507 &packages.package_name(path),
10508 &Some("root-package"),
10509 "manifest-only package authority",
10510 )?;
10511 }
10512 let lock_only = PackageIndex::from_graphs(&[lock])?;
10513 require_eq(
10514 &lock_only.package_name("src/lib.rs"),
10515 &None,
10516 "lockfile-only ownership abstention",
10517 )?;
10518 Ok(())
10519 }
10520
10521 #[test]
10522 fn cargo_package_ownership_uses_the_longest_repository_prefix() -> Result<(), Box<dyn Error>> {
10523 let graphs = vec![
10524 package_graph("Cargo.toml", "workspace"),
10525 package_graph("crates/member/Cargo.toml", "member"),
10526 ];
10527 let packages = PackageIndex::from_graphs(&graphs)?;
10528 require_eq(
10529 &packages.package_name("src/lib.rs"),
10530 &Some("workspace"),
10531 "root package ownership",
10532 )?;
10533 require_eq(
10534 &packages.package_name("crates/member/src/lib.rs"),
10535 &Some("member"),
10536 "nested package ownership",
10537 )?;
10538 require(
10539 repository_path_belongs_to("crates/member/src/lib.rs", "crates/member"),
10540 "member path was not owned by its package",
10541 )?;
10542 require(
10543 !repository_path_belongs_to("crates/membership/src/lib.rs", "crates/member"),
10544 "package prefix matched a partial segment",
10545 )?;
10546 require(is_cargo_manifest_path("Cargo.toml"), "root manifest")?;
10547 require(
10548 is_cargo_manifest_path("crates/member/Cargo.toml"),
10549 "nested manifest",
10550 )?;
10551 require(
10552 !is_cargo_manifest_path("docs/Cargo.toml.example"),
10553 "manifest suffix lookalike",
10554 )?;
10555 Ok(())
10556 }
10557
10558 #[test]
10559 fn incremental_projection_budget_requests_one_complete_refresh() -> Result<(), Box<dyn Error>> {
10560 let root = Path::new("repository");
10561 let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
10562 for (rows, retained_bytes) in [
10563 (MAX_INCREMENTAL_GRAPH_ROWS + 1, 0),
10564 (0, MAX_INCREMENTAL_GRAPH_BYTES + 1),
10565 ] {
10566 let error =
10567 enforce_incremental_projection_budget(root, &affected_paths, rows, retained_bytes)
10568 .err()
10569 .ok_or_else(|| io::Error::other("oversized closure reached publication"))?;
10570 let CliError::RefreshRequired(report) = error else {
10571 return Err(io::Error::other(format!(
10572 "expected typed full-refresh guidance, found {error:?}"
10573 ))
10574 .into());
10575 };
10576 require_eq(
10577 &report.reason,
10578 &IndexRefreshReason::DependencyClosureLimit,
10579 "incremental budget reason",
10580 )?;
10581 require_eq(
10582 &report.scope,
10583 &IndexRefreshScope::Full,
10584 "incremental budget scope",
10585 )?;
10586 require_eq(&report.changed, &1, "incremental changed paths")?;
10587 require_eq(
10588 &report.sample_paths,
10589 &vec!["src/lib.rs".to_string()],
10590 "incremental sample paths",
10591 )?;
10592 }
10593 Ok(())
10594 }
10595
10596 #[test]
10597 fn admitted_resolution_keys_move_once_and_count_at_budget_boundary()
10598 -> Result<(), Box<dyn Error>> {
10599 let graph = function_graph("src/lib.rs", 1);
10600 let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
10601 let project = ProjectInstanceId::from_bytes([31; 16])?;
10602 let admitted_projection = projectatlas_symbols::derive_resolution_keys(
10603 project,
10604 packages.package_name(&graph.path),
10605 &graph,
10606 )?;
10607 let admitted_key_bytes = super::resolution_retained_bytes(&admitted_projection);
10608 let mut admitted = BTreeMap::from([(graph.path.clone(), admitted_projection)]);
10609 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10610 let entities = build_entity_projection_with_config(
10611 project,
10612 IndexGeneration::new(1),
10613 &[],
10614 std::slice::from_ref(&graph),
10615 &packages,
10616 &ConfiguredModuleResolution::default(),
10617 Some(&mut admitted),
10618 true,
10619 &control,
10620 )?;
10621 require(
10622 admitted.is_empty(),
10623 "admitted resolution map retained moved keys",
10624 )?;
10625 require(
10626 entities.retained_bytes >= admitted_key_bytes,
10627 "entity projection budget omitted live resolution-key bytes",
10628 )?;
10629
10630 let mut registry = ProjectResolutionRegistry {
10631 retained_bytes: super::super::MAX_PUBLICATION_STAGING_BYTES
10632 .saturating_sub(entities.retained_bytes),
10633 ..ProjectResolutionRegistry::default()
10634 };
10635 require(
10636 enforce_resolution_staging_budget(&entities, ®istry).is_ok(),
10637 "exact staging budget boundary was rejected",
10638 )?;
10639 registry.retained_bytes = registry.retained_bytes.saturating_add(1);
10640 require(
10641 enforce_resolution_staging_budget(&entities, ®istry).is_err(),
10642 "staging budget ignored live resolution-key bytes at the boundary",
10643 )?;
10644 Ok(())
10645 }
10646
10647 #[test]
10648 fn multi_graph_resolution_peak_moves_each_entry_before_entity_allocation()
10649 -> Result<(), Box<dyn Error>> {
10650 let project = ProjectInstanceId::from_bytes([32; 16])?;
10651 let generation = IndexGeneration::new(1);
10652 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10653 let graphs = vec![
10654 package_graph("Cargo.toml", "peak-workspace"),
10655 function_graph("src/target.rs", 64),
10656 function_graph("src/caller.rs", 64),
10657 ];
10658 let packages = PackageIndex::from_graphs(&graphs)?;
10659 let fresh_admitted = || {
10660 graphs
10661 .iter()
10662 .map(|graph| {
10663 Ok::<_, Box<dyn Error>>((
10664 graph.path.clone(),
10665 projectatlas_symbols::derive_resolution_keys(
10666 project,
10667 packages.package_name(&graph.path),
10668 graph,
10669 )?,
10670 ))
10671 })
10672 .collect::<Result<BTreeMap<_, _>, _>>()
10673 };
10674 let mut admitted = fresh_admitted()?;
10675 require_eq(
10676 &admitted.len(),
10677 &graphs.len(),
10678 "multi-graph admitted resolution projection count",
10679 )?;
10680 let map_bytes = resolution_projection_map_retained_bytes(&admitted);
10681 require(
10682 map_bytes > 0,
10683 "multi-graph projection map had no retained bytes",
10684 )?;
10685 let first = build_entity_projection_with_config_limit(
10686 project,
10687 generation,
10688 &[],
10689 &graphs,
10690 &packages,
10691 &ConfiguredModuleResolution::default(),
10692 Some(&mut admitted),
10693 true,
10694 &control,
10695 super::super::MAX_PUBLICATION_STAGING_BYTES,
10696 )?;
10697 require(
10698 admitted.is_empty(),
10699 "full multi-graph projection retained admitted map entries",
10700 )?;
10701 require_eq(
10702 &first.projection_removals_before_entities,
10703 &graphs
10704 .iter()
10705 .map(|graph| graph.path.clone())
10706 .collect::<Vec<_>>(),
10707 "full projection removal order",
10708 )?;
10709 require(
10710 first.peak_retained_bytes >= map_bytes,
10711 "full peak accounting omitted the admitted projection map",
10712 )?;
10713 let peak = first.peak_retained_bytes;
10714 let mut at_peak_admitted = fresh_admitted()?;
10715 let at_peak = build_entity_projection_with_config_limit(
10716 project,
10717 generation.checked_next().ok_or("generation overflow")?,
10718 &[],
10719 &graphs,
10720 &packages,
10721 &ConfiguredModuleResolution::default(),
10722 Some(&mut at_peak_admitted),
10723 true,
10724 &control,
10725 peak,
10726 )?;
10727 require(
10728 at_peak_admitted.is_empty(),
10729 "exact-peak multi-graph projection retained admitted map entries",
10730 )?;
10731 require_eq(
10732 &at_peak.projection_removals_before_entities,
10733 &graphs
10734 .iter()
10735 .map(|graph| graph.path.clone())
10736 .collect::<Vec<_>>(),
10737 "exact-peak projection removal order",
10738 )?;
10739 require(
10740 at_peak.peak_retained_bytes <= peak,
10741 "exact-peak projection exceeded the measured full peak unexpectedly",
10742 )?;
10743 let below_peak = peak.checked_sub(1).ok_or("multi-graph peak was zero")?;
10744 require_eq(
10745 &peak.saturating_sub(below_peak),
10746 &1,
10747 "multi-graph below-peak budget was not exactly one byte lower",
10748 )?;
10749 let mut below_peak_admitted = fresh_admitted()?;
10750 let below_peak_result = build_entity_projection_with_config_limit(
10751 project,
10752 generation.checked_next().ok_or("generation overflow")?,
10753 &[],
10754 &graphs,
10755 &packages,
10756 &ConfiguredModuleResolution::default(),
10757 Some(&mut below_peak_admitted),
10758 true,
10759 &control,
10760 below_peak,
10761 );
10762 require(
10763 matches!(
10764 &below_peak_result,
10765 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10766 resource: IndexWorkResource::OutputBytes,
10767 limit,
10768 observed,
10769 ..
10770 })) if *limit == below_peak && *observed == peak
10771 ),
10772 "one byte below the measured full peak did not fail at the measured peak",
10773 )?;
10774 Ok(())
10775 }
10776
10777 #[test]
10778 fn incremental_direct_and_inbound_keys_are_counted_once_at_budget_boundary()
10779 -> Result<(), Box<dyn Error>> {
10780 let temp = tempfile::tempdir()?;
10781 let root = temp.path().join("incremental-resolution-budget");
10782 fs::create_dir_all(root.join(".projectatlas"))?;
10783 let database = root.join(".projectatlas/projectatlas.db");
10784 let mut store = AtlasStore::open_for_project(&database, &root)?;
10785 let manifest_path = "Cargo.toml";
10786 let root_module_path = "src/lib.rs";
10787 let direct_path = "src/target.rs";
10788 let inbound_path = "src/caller.rs";
10789 let manifest_source =
10790 "[package]\nname = \"dependency-refresh\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
10791 let root_module_source = "mod caller;\nmod target;\n";
10792 let direct_source = "pub fn target() {}\n";
10793 let inbound_source = "pub fn caller() { target(); }\n";
10794 fs::create_dir_all(root.join("src"))?;
10795 fs::write(root.join(manifest_path), manifest_source)?;
10796 fs::write(root.join(root_module_path), root_module_source)?;
10797 fs::write(root.join(direct_path), direct_source)?;
10798 fs::write(root.join(inbound_path), inbound_source)?;
10799 let mut nodes = vec![
10800 test_file_node(manifest_path, "cargo-manifest"),
10801 test_file_node(root_module_path, "rust"),
10802 test_file_node(direct_path, "rust"),
10803 test_file_node(inbound_path, "rust"),
10804 ];
10805 capture_test_file_hashes(&root, &mut nodes)?;
10806 let graphs = vec![
10807 extract_symbol_graph(manifest_path, Some("cargo-manifest"), manifest_source),
10808 extract_symbol_graph(root_module_path, Some("rust"), root_module_source),
10809 extract_symbol_graph(direct_path, Some("rust"), direct_source),
10810 extract_symbol_graph(inbound_path, Some("rust"), inbound_source),
10811 ];
10812 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10813 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10814 let full = stage_full_repository_graph(
10815 &store,
10816 &root,
10817 IndexGeneration::ZERO,
10818 &nodes,
10819 &scan_policy,
10820 &symbol_build_stage_for_graphs(graphs.clone()),
10821 &control,
10822 )?;
10823 publish_full_staged_graph(
10824 &mut store,
10825 &nodes,
10826 &full,
10827 &control,
10828 "incremental-budget-full",
10829 )?;
10830 for graph in &graphs {
10831 store.replace_symbol_graph(graph)?;
10832 }
10833 drop(store);
10834 let store = AtlasStore::open_for_project(&database, &root)?;
10835 let base_generation = store
10836 .index_publication()?
10837 .ok_or("incremental budget full publication is missing")?
10838 .generation;
10839 let incremental = stage_incremental_repository_graph(
10840 &store,
10841 &root,
10842 base_generation,
10843 &nodes,
10844 &[direct_path.to_string()],
10845 &scan_policy,
10846 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10847 &control,
10848 )?;
10849 let affected_paths = BTreeSet::from([direct_path.to_string(), inbound_path.to_string()]);
10850 require(
10851 matches!(
10852 &incremental.mutation,
10853 RepositoryGraphMutation::AffectedPaths(paths)
10854 if paths.iter().cloned().collect::<BTreeSet<_>>() == affected_paths
10855 ),
10856 "incremental budget fixture did not include its inbound graph",
10857 )?;
10858 let expected_generation = base_generation
10859 .checked_next()
10860 .ok_or("incremental budget generation overflowed")?;
10861 for path in [&direct_path, &inbound_path] {
10862 let derivations = incremental
10863 .resolution_derivations
10864 .get(&(path.to_string(), expected_generation))
10865 .copied();
10866 require_eq(
10867 &derivations,
10868 &Some(1),
10869 "incremental direct/inbound resolution derivation count",
10870 )?;
10871 }
10872 let entity_peak = incremental.peak_retained_bytes;
10873 require(
10874 entity_peak > 0,
10875 "incremental projection measured no retained peak",
10876 )?;
10877 let expected_removals = vec![direct_path.to_string(), inbound_path.to_string()];
10878 require_eq(
10879 &incremental.projection_removals_before_entities,
10880 &expected_removals,
10881 "incremental projection removal order",
10882 )?;
10883 let unrelated_manifest_derivations = incremental
10884 .resolution_derivations
10885 .get(&(manifest_path.to_string(), expected_generation))
10886 .copied();
10887 require_eq(
10888 &unrelated_manifest_derivations,
10889 &None,
10890 "incremental unrelated manifest resolution derivation count",
10891 )?;
10892 let unrelated_root_derivations = incremental
10893 .resolution_derivations
10894 .get(&(root_module_path.to_string(), expected_generation))
10895 .copied();
10896 require_eq(
10897 &unrelated_root_derivations,
10898 &None,
10899 "incremental unrelated root-module resolution derivation count",
10900 )?;
10901 let rows = u64::try_from(incremental.entities.len())?;
10902 let retained_bytes = incremental.retained_bytes;
10903 drop(incremental);
10904 let mut refused = 0_u64;
10908 let mut peak = super::super::MAX_PUBLICATION_STAGING_BYTES;
10909 while peak - refused > 1 {
10910 let middle = refused + (peak - refused) / 2;
10911 match stage_incremental_repository_graph_with_test_limit(
10912 &store,
10913 &root,
10914 base_generation,
10915 &nodes,
10916 &[direct_path.to_string()],
10917 &scan_policy,
10918 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10919 &control,
10920 middle,
10921 ) {
10922 Ok(_) => peak = middle,
10923 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10924 resource: IndexWorkResource::OutputBytes,
10925 ..
10926 })) => refused = middle,
10927 Err(error) => return Err(error.into()),
10928 }
10929 }
10930 require(
10931 peak >= entity_peak,
10932 "operation budget omitted its entity owner",
10933 )?;
10934 let at_peak = stage_incremental_repository_graph_with_test_limit(
10935 &store,
10936 &root,
10937 base_generation,
10938 &nodes,
10939 &[direct_path.to_string()],
10940 &scan_policy,
10941 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10942 &control,
10943 peak,
10944 )?;
10945 require_eq(
10946 &at_peak.projection_removals_before_entities,
10947 &expected_removals,
10948 "exact-peak incremental projection removal order",
10949 )?;
10950 require(
10951 at_peak.peak_retained_bytes <= peak,
10952 "exact-peak incremental projection exceeded its measured peak",
10953 )?;
10954 for path in [&direct_path, &inbound_path] {
10955 require_eq(
10956 &at_peak
10957 .resolution_derivations
10958 .get(&(path.to_string(), expected_generation))
10959 .copied(),
10960 &Some(1),
10961 "exact-peak incremental derivation count",
10962 )?;
10963 }
10964 require_eq(
10965 &at_peak
10966 .resolution_derivations
10967 .get(&(manifest_path.to_string(), expected_generation))
10968 .copied(),
10969 &None,
10970 "exact-peak unrelated manifest derivation count",
10971 )?;
10972 require_eq(
10973 &at_peak
10974 .resolution_derivations
10975 .get(&(root_module_path.to_string(), expected_generation))
10976 .copied(),
10977 &None,
10978 "exact-peak unrelated root-module derivation count",
10979 )?;
10980 drop(at_peak);
10981 let below_peak = peak.checked_sub(1).ok_or("incremental peak was zero")?;
10982 require_eq(
10983 &peak.saturating_sub(below_peak),
10984 &1,
10985 "incremental below-peak budget was not exactly one byte lower",
10986 )?;
10987 let below_peak_result = stage_incremental_repository_graph_with_test_limit(
10988 &store,
10989 &root,
10990 base_generation,
10991 &nodes,
10992 &[direct_path.to_string()],
10993 &scan_policy,
10994 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
10995 &control,
10996 below_peak,
10997 );
10998 require(
10999 matches!(
11000 &below_peak_result,
11001 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11002 resource: IndexWorkResource::OutputBytes,
11003 limit,
11004 observed,
11005 ..
11006 })) if *observed > *limit
11007 ),
11008 "one byte below the operation boundary did not return a typed byte refusal",
11009 )?;
11010 require(
11011 retained_bytes > 0,
11012 "incremental budget fixture did not retain projected key bytes",
11013 )?;
11014 require(
11015 enforce_incremental_projection_budget(&root, &affected_paths, rows, retained_bytes)
11016 .is_ok(),
11017 "incremental budget rejected actual direct and inbound projection",
11018 )?;
11019 let available = MAX_INCREMENTAL_GRAPH_BYTES.saturating_sub(retained_bytes);
11020 require(
11021 enforce_incremental_projection_budget(
11022 &root,
11023 &affected_paths,
11024 rows,
11025 retained_bytes.saturating_add(available),
11026 )
11027 .is_ok(),
11028 "incremental exact byte boundary was rejected",
11029 )?;
11030 require(
11031 enforce_incremental_projection_budget(
11032 &root,
11033 &affected_paths,
11034 rows,
11035 retained_bytes.saturating_add(available).saturating_add(1),
11036 )
11037 .is_err(),
11038 "incremental byte boundary ignored one additional byte",
11039 )?;
11040 Ok(())
11041 }
11042
11043 #[test]
11044 fn incremental_projection_budget_combines_old_and_new_work() -> Result<(), Box<dyn Error>> {
11045 let temp = tempfile::tempdir()?;
11046 let root = temp.path();
11047 let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
11048 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11049 let persisted = RepositoryAffectedSourceFootprint {
11050 rows: MAX_INCREMENTAL_GRAPH_ROWS,
11051 retained_bytes: 0,
11052 truncated: false,
11053 };
11054 let staged = StagedRepositoryGraph {
11055 project: ProjectInstanceId::from_bytes([1; 16])?,
11056 mutation: RepositoryGraphMutation::AffectedPaths(vec!["src/lib.rs".to_string()]),
11057 entities: Vec::new(),
11058 relations: Vec::new(),
11059 occurrences: Vec::new(),
11060 coverage: Vec::new(),
11061 entity_exports: Vec::new(),
11062 relation_dependencies: Vec::new(),
11063 document_unresolved_reasons: Vec::new(),
11064 identity_rejections: Vec::new(),
11065 resolution_derivations: BTreeMap::new(),
11066 peak_retained_bytes: 0,
11067 projection_removals_before_entities: Vec::new(),
11068 scan_policy: RootScanPolicy::discover(root, &ScanOptions::default(), &control)?,
11069 document_target_states: Vec::new(),
11070 database: None,
11071 source_recovery: SourceRecoveryWork::default(),
11072 retained_bytes: 0,
11073 };
11074 let error =
11075 enforce_incremental_projection_limits(root, &affected_paths, persisted, &staged)
11076 .err()
11077 .ok_or_else(|| io::Error::other("combined old and new work was admitted"))?;
11078 let CliError::RefreshRequired(report) = error else {
11079 return Err(io::Error::other(format!(
11080 "expected typed full-refresh guidance, found {error:?}"
11081 ))
11082 .into());
11083 };
11084 require_eq(
11085 &report.reason,
11086 &IndexRefreshReason::DependencyClosureLimit,
11087 "combined budget reason",
11088 )?;
11089 require_eq(
11090 &report.scope,
11091 &IndexRefreshScope::Full,
11092 "combined budget scope",
11093 )?;
11094 Ok(())
11095 }
11096
11097 #[test]
11098 fn document_projection_memory_budget_includes_reason_validation_state()
11099 -> Result<(), Box<dyn Error>> {
11100 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11101 let facts: BTreeMap<String, Cow<'static, projectatlas_symbols::MarkdownFacts>> = (0..12)
11102 .map(|document| {
11103 let source = (0..1_024)
11104 .map(|link| format!("[missing]({document}-{link}.md)"))
11105 .collect::<Vec<_>>()
11106 .join("\n");
11107 (
11108 format!("docs/source-{document}.md"),
11109 Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
11110 )
11111 })
11112 .collect::<BTreeMap<_, _>>();
11113 let candidate_count = facts
11114 .values()
11115 .map(|document| document.link_candidates.len())
11116 .sum::<usize>();
11117 require_eq(&candidate_count, &12_288, "document candidate fixture size")?;
11118 let fact_bytes = document_fact_map_retained_bytes(&facts);
11119 let projection_bytes = document_projection_retained_bytes(&facts, &control)?;
11120 require(
11121 projection_bytes >= u64::try_from(candidate_count)? * DOCUMENT_PROJECTION_ROW_BYTES,
11122 "generated document projection state was under-accounted",
11123 )?;
11124 require(
11125 fact_bytes.saturating_add(projection_bytes) < 512 * 1_024 * 1_024,
11126 "bounded document projection fixture exceeded the in-memory envelope",
11127 )?;
11128 Ok(())
11129 }
11130
11131 #[test]
11132 fn document_projection_estimator_deadline_preserves_generation_and_allows_retry()
11133 -> Result<(), Box<dyn Error>> {
11134 const DOCUMENT_COUNT: usize = 513;
11135 const CANDIDATES_PER_DOCUMENT: usize = 1_024;
11136 let temp = tempfile::tempdir()?;
11137 let root = fs::canonicalize(temp.path())?;
11138 let database = root.join("projectatlas.db");
11139 let mut store = AtlasStore::open_for_project(&database, &root)?;
11140 let project = store
11141 .project_instance_id()?
11142 .ok_or("estimator cancellation project identity is missing")?;
11143 let publication_control = IndexWorkControl::new(IndexCancellation::new(), None);
11144 let baseline_graph = function_graph("src/lib.rs", 1);
11145 let baseline_packages = PackageIndex::from_graphs(std::slice::from_ref(&baseline_graph))?;
11146 let baseline_entities = build_entity_projection(
11147 project,
11148 IndexGeneration::new(1),
11149 &[],
11150 std::slice::from_ref(&baseline_graph),
11151 &baseline_packages,
11152 true,
11153 &publication_control,
11154 )?;
11155 let baseline_candidates =
11156 resolution_registry_from_exports(&baseline_entities, &publication_control)?;
11157 let baseline_staged = finish_projection(
11158 project,
11159 IndexGeneration::new(1),
11160 RepositoryGraphMutation::Full,
11161 std::slice::from_ref(&baseline_graph),
11162 baseline_entities,
11163 &baseline_candidates,
11164 &publication_control,
11165 )?;
11166 let baseline_node = test_file_node("src/lib.rs", "rust");
11167 {
11168 let mut publication = store.begin_index_publication("estimator-cancellation")?;
11169 publication.begin_scan_replacement()?;
11170 publication.upsert_scan_node_batch(std::slice::from_ref(&baseline_node))?;
11171 publication.finish_scan_replacement()?;
11172 baseline_staged.apply(&mut publication, &publication_control)?;
11173 publication.complete()?;
11174 }
11175 let publication_before = store
11176 .index_publication()?
11177 .ok_or("estimator cancellation baseline publication is missing")?;
11178
11179 let document_facts: BTreeMap<String, Cow<'_, projectatlas_symbols::MarkdownFacts>> = (0
11180 ..DOCUMENT_COUNT)
11181 .map(|document| {
11182 let path = format!("content/source-{document:03}.md");
11183 let file_name = path
11184 .rsplit_once('/')
11185 .map_or(path.as_str(), |(_parent, file_name)| file_name);
11186 let source = (0..CANDIDATES_PER_DOCUMENT)
11187 .map(|index| format!("[self-{index:04}]({file_name})"))
11188 .collect::<Vec<_>>()
11189 .join("\n");
11190 (
11191 path,
11192 Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
11193 )
11194 })
11195 .collect::<BTreeMap<_, _>>();
11196 let candidate_count = document_facts
11197 .values()
11198 .map(|facts| facts.link_candidates.len())
11199 .sum::<usize>();
11200 require_eq(
11201 &candidate_count,
11202 &(DOCUMENT_COUNT * CANDIDATES_PER_DOCUMENT),
11203 "estimator cancellation candidate count",
11204 )?;
11205
11206 let expired_control =
11207 IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
11208 let error = document_projection_retained_bytes(&document_facts, &expired_control)
11209 .err()
11210 .ok_or("expired estimator unexpectedly traversed all candidates")?;
11211 require(
11212 matches!(
11213 error,
11214 CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
11215 stage: IndexWorkStage::SymbolParsing,
11216 })
11217 ),
11218 "estimator deadline did not return its typed graph-work failure",
11219 )?;
11220 require_eq(
11221 &store.index_publication()?,
11222 &Some(publication_before.clone()),
11223 "estimator deadline changed the current generation",
11224 )?;
11225
11226 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
11227 let retained_bytes = document_projection_retained_bytes(&document_facts, &retry_control)?;
11228 require_eq(
11229 &retained_bytes,
11230 &0,
11231 "same-file candidates entered the retained projection estimate",
11232 )?;
11233 let retry_graphs = document_facts
11234 .iter()
11235 .map(|(path, facts)| facts.symbol_graph(path, Some("markdown")))
11236 .collect::<Vec<_>>();
11237 let retry_nodes = document_facts
11238 .keys()
11239 .map(|path| test_file_node(path, "markdown"))
11240 .collect::<Vec<_>>();
11241 let retry_scan_policy =
11242 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
11243 let retry_packages = PackageIndex::from_graphs(&retry_graphs)?;
11244 let retry_entities = build_entity_projection(
11245 project,
11246 IndexGeneration::new(2),
11247 &retry_nodes,
11248 &retry_graphs,
11249 &retry_packages,
11250 false,
11251 &retry_control,
11252 )?;
11253 let retry_candidates = resolution_registry_from_exports(&retry_entities, &retry_control)?;
11254 let retried_projection = finish_projection_with_documents(
11255 project,
11256 IndexGeneration::new(2),
11257 RepositoryGraphMutation::Full,
11258 &retry_graphs,
11259 &root,
11260 &retry_nodes,
11261 &document_facts,
11262 &GraphIdentityAdmission::default(),
11263 retry_entities,
11264 &retry_candidates,
11265 &retry_scan_policy,
11266 &retry_control,
11267 )?;
11268 require(
11269 retried_projection.relations.is_empty()
11270 && retried_projection.document_unresolved_reasons.is_empty(),
11271 "same-file retry emitted document projection rows",
11272 )?;
11273 require_eq(
11274 &store.index_publication()?,
11275 &Some(publication_before),
11276 "estimator retry changed the current generation without publication",
11277 )?;
11278 Ok(())
11279 }
11280
11281 #[test]
11282 fn incremental_document_projection_overflow_requests_full_refresh_staging()
11283 -> Result<(), Box<dyn Error>> {
11284 const DOCUMENT_COUNT: usize = 513;
11285 let temp = tempfile::tempdir()?;
11286 let root = fs::canonicalize(temp.path())?;
11287 let database = root.join("projectatlas.db");
11288 let mut store = AtlasStore::open_for_project(&database, &root)?;
11289 let project = store
11290 .project_instance_id()?
11291 .ok_or("incremental document budget project identity is missing")?;
11292 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11293 let template = projectatlas_symbols::extract_markdown_facts("[missing](shared.md)");
11294 let candidate = template
11295 .link_candidates
11296 .first()
11297 .cloned()
11298 .ok_or("document budget candidate fixture is missing")?;
11299 let mut template = template;
11300 while template.link_candidates.len() < MAX_DOCUMENT_LINK_CANDIDATES {
11301 template.link_candidates.push(candidate.clone());
11302 }
11303
11304 let mut nodes = Vec::with_capacity(DOCUMENT_COUNT);
11305 let mut graphs = Vec::with_capacity(DOCUMENT_COUNT);
11306 let mut changes = Vec::with_capacity(DOCUMENT_COUNT);
11307 let mut paths = Vec::with_capacity(DOCUMENT_COUNT);
11308 for document in 0..DOCUMENT_COUNT {
11309 let path = format!("content/source-{document:03}.md");
11310 let graph = template.symbol_graph(&path, Some("markdown"));
11311 nodes.push(test_file_node(&path, "markdown"));
11312 paths.push(path.clone());
11313 graphs.push(graph.clone());
11314 changes.push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
11315 path,
11316 graph,
11317 markdown_facts: Some(Box::new(template.clone())),
11318 source_semantics: None,
11319 source_parser: ParserKind::Structural,
11320 summary: String::new(),
11321 summary_is_structural: true,
11322 purpose_suggestion: None,
11323 }));
11324 }
11325 store.replace_scan(&nodes)?;
11326 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11327 let packages = PackageIndex::from_graphs(&graphs)?;
11328 let entities = build_entity_projection(
11329 project,
11330 IndexGeneration::new(1),
11331 &nodes,
11332 &graphs,
11333 &packages,
11334 false,
11335 &control,
11336 )?;
11337 let candidates = resolution_registry_from_exports(&entities, &control)?;
11338 let projected_facts = paths
11339 .iter()
11340 .map(|path| (path.clone(), Cow::Borrowed(&template)))
11341 .collect::<BTreeMap<_, _>>();
11342 let pre_document_bytes = entities
11343 .retained_bytes
11344 .saturating_add(candidates.retained_bytes)
11345 .saturating_add(document_fact_map_retained_bytes(&projected_facts));
11346 let document_projection_bytes =
11347 document_projection_retained_bytes(&projected_facts, &control)?;
11348 require(
11349 pre_document_bytes < MAX_IN_MEMORY_GRAPH_WORK_BYTES,
11350 "pre-document incremental state already exceeded the staging budget",
11351 )?;
11352 require(
11353 pre_document_bytes.saturating_add(document_projection_bytes)
11354 > MAX_IN_MEMORY_GRAPH_WORK_BYTES,
11355 "document projection did not cross the aggregate staging budget",
11356 )?;
11357
11358 let mut symbols = empty_symbol_build_stage();
11359 symbols.changes = changes;
11360 let direct_paths = paths.clone();
11361 let error = stage_incremental_repository_graph(
11362 &store,
11363 &root,
11364 IndexGeneration::new(0),
11365 &nodes,
11366 &direct_paths,
11367 &scan_policy,
11368 &symbols,
11369 &control,
11370 )
11371 .err()
11372 .ok_or("document projection overflow was admitted incrementally")?;
11373 let CliError::RefreshRequired(report) = error else {
11374 return Err(io::Error::other(format!(
11375 "expected typed full-refresh guidance, found {error:?}"
11376 ))
11377 .into());
11378 };
11379 require_eq(
11380 &report.reason,
11381 &IndexRefreshReason::DependencyClosureLimit,
11382 "document projection overflow reason",
11383 )?;
11384 require_eq(
11385 &report.scope,
11386 &IndexRefreshScope::Full,
11387 "document projection overflow scope",
11388 )?;
11389
11390 let staged = stage_full_repository_graph(
11391 &store,
11392 &root,
11393 IndexGeneration::new(0),
11394 &nodes,
11395 &scan_policy,
11396 &symbols,
11397 &control,
11398 )?;
11399 require(
11400 staged.database.is_some(),
11401 "full-refresh guidance did not select disposable SQLite staging",
11402 )?;
11403 Ok(())
11404 }
11405
11406 #[test]
11407 fn resolution_registry_reuses_staged_export_entities() -> Result<(), Box<dyn Error>> {
11408 let project = ProjectInstanceId::from_bytes([2; 16])?;
11409 let generation = IndexGeneration::new(1);
11410 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11411 let graphs = vec![
11412 extract_symbol_graph(
11413 "Cargo.toml",
11414 Some("cargo-manifest"),
11415 "[package]\nname = \"atlas\"\n",
11416 ),
11417 extract_symbol_graph("src/lib.rs", Some("rust"), "pub fn run() {}\n"),
11418 ];
11419 let packages = PackageIndex::from_graphs(&graphs)?;
11420 let projection =
11421 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
11422 let registry = resolution_registry_from_exports(&projection, &control)?;
11423 let registered_bindings = registry
11424 .candidate_digests_by_key
11425 .values()
11426 .map(BTreeSet::len)
11427 .sum::<usize>();
11428
11429 require_eq(
11430 ®istry.supplemental_entities_by_digest.len(),
11431 &0,
11432 "duplicate registry-owned entity count",
11433 )?;
11434 require_eq(
11435 ®istered_bindings,
11436 &projection.entity_exports.len(),
11437 "registry key binding count",
11438 )?;
11439 require(
11440 registry
11441 .candidate_digests_by_key
11442 .values()
11443 .flatten()
11444 .all(|digest| projection.entity_by_digest.contains_key(digest)),
11445 "resolution key referenced an unstaged entity digest",
11446 )?;
11447 let mut bindings_per_entity = BTreeMap::<&str, usize>::new();
11448 for digest in registry.candidate_digests_by_key.values().flatten() {
11449 *bindings_per_entity.entry(digest).or_default() += 1;
11450 }
11451 require(
11452 bindings_per_entity.values().any(|count| *count > 1),
11453 "fixture did not exercise one entity exported under multiple canonical keys",
11454 )?;
11455 Ok(())
11456 }
11457
11458 #[test]
11459 fn restart_cleanup_removes_only_inactive_owned_graph_stages() -> Result<(), Box<dyn Error>> {
11460 let temp = tempfile::tempdir()?;
11461 let root = temp.path().join("restart-cleanup");
11462 let atlas_dir = root.join(".projectatlas");
11463 fs::create_dir_all(&atlas_dir)?;
11464 let database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11465 let store = AtlasStore::open_for_project(&database, &root)?;
11466 let project = store
11467 .project_instance_id()?
11468 .ok_or("bound project identity is missing")?;
11469
11470 let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}owned"));
11471 fs::create_dir(&owned)?;
11472 let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11473 drop(AtlasStore::create_repository_graph_staging(
11474 &owned_database,
11475 &root,
11476 project,
11477 )?);
11478 let owned_payload = owned.join("payload");
11479 fs::create_dir(&owned_payload)?;
11480 fs::write(owned_payload.join("row"), "discard")?;
11481 let owned_link_target = temp.path().join("owned-link-target");
11482 fs::create_dir(&owned_link_target)?;
11483 fs::write(owned_link_target.join("sentinel"), "preserve")?;
11484 let owned_payload_link = owned.join("linked-payload");
11485 create_directory_link(&owned_link_target, &owned_payload_link)?;
11486 let interrupted_shell =
11487 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}interrupted-shell"));
11488 fs::create_dir(&interrupted_shell)?;
11489 let unvalidated_nonempty = atlas_dir.join(format!(
11490 "{GRAPH_STAGE_DIRECTORY_PREFIX}unvalidated-nonempty"
11491 ));
11492 fs::create_dir(&unvalidated_nonempty)?;
11493 fs::write(unvalidated_nonempty.join("sentinel"), "preserve")?;
11494 let lookalike = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}lookalike"));
11495 fs::create_dir(&lookalike)?;
11496 drop(AtlasStore::open_for_project(
11497 &lookalike.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11498 &root,
11499 )?);
11500 let foreign_root = temp.path().join("foreign-project");
11501 fs::create_dir(&foreign_root)?;
11502 let foreign_store =
11503 AtlasStore::open_for_project(&foreign_root.join("projectatlas.db"), &foreign_root)?;
11504 let foreign_project = foreign_store
11505 .project_instance_id()?
11506 .ok_or("foreign project identity is missing")?;
11507 let foreign_project_stage =
11508 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-project"));
11509 fs::create_dir(&foreign_project_stage)?;
11510 drop(AtlasStore::create_repository_graph_staging(
11511 &foreign_project_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11512 &root,
11513 foreign_project,
11514 )?);
11515 let foreign_root_stage =
11516 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-root"));
11517 fs::create_dir(&foreign_root_stage)?;
11518 drop(AtlasStore::create_repository_graph_staging(
11519 &foreign_root_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11520 &foreign_root,
11521 project,
11522 )?);
11523 let linked_stage_target = temp.path().join("linked-stage-target");
11524 fs::create_dir(&linked_stage_target)?;
11525 fs::write(linked_stage_target.join("sentinel"), "preserve")?;
11526 drop(AtlasStore::create_repository_graph_staging(
11527 &linked_stage_target.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11528 &root,
11529 project,
11530 )?);
11531 let linked_stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked"));
11532 create_directory_link(&linked_stage_target, &linked_stage)?;
11533
11534 let linked_database_target = temp.path().join("linked-stage-database.db");
11535 drop(AtlasStore::create_repository_graph_staging(
11536 &linked_database_target,
11537 &root,
11538 project,
11539 )?);
11540 let linked_database_stage =
11541 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked-database"));
11542 fs::create_dir(&linked_database_stage)?;
11543 let linked_database = linked_database_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11544 let linked_database_created =
11545 match create_file_link(&linked_database_target, &linked_database) {
11546 Ok(()) => true,
11547 #[cfg(windows)]
11548 Err(source) if source.raw_os_error() == Some(1314) => false,
11549 Err(source) => return Err(source.into()),
11550 };
11551 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11552
11553 let lease = try_graph_stage_lease(&atlas_dir)?
11554 .ok_or("test could not acquire graph staging lease")?;
11555 remove_owned_graph_stage_payload(&owned, &owned_database, Some(&control))?;
11556 require(
11557 owned_database.is_file() && !owned_payload.exists(),
11558 "payload cleanup did not retain the ownership database until last",
11559 )?;
11560 require(
11561 fs::symlink_metadata(&owned_payload_link).is_err()
11562 && owned_link_target.join("sentinel").is_file(),
11563 "payload cleanup followed or retained a linked child",
11564 )?;
11565 cleanup_abandoned_graph_staging(&root, project, &control)?;
11566 require(
11567 owned.exists(),
11568 "restart cleanup removed an actively leased stage",
11569 )?;
11570 drop(lease);
11571
11572 let canceled_control = IndexWorkControl::new(IndexCancellation::new(), None);
11573 canceled_control.cancel();
11574 let canceled = cleanup_abandoned_graph_staging(&root, project, &canceled_control)
11575 .err()
11576 .ok_or("canceled restart cleanup unexpectedly succeeded")?;
11577 require(
11578 matches!(
11579 canceled,
11580 CliError::IndexWork(IndexWorkFailure::Cancelled {
11581 stage: IndexWorkStage::Publication
11582 })
11583 ),
11584 "restart cleanup did not preserve typed cancellation",
11585 )?;
11586 require(
11587 owned.exists(),
11588 "canceled restart cleanup removed an owned stage",
11589 )?;
11590
11591 cleanup_abandoned_graph_staging(&root, project, &control)?;
11592 require(
11593 !owned.exists(),
11594 "restart cleanup retained an inactive owned stage",
11595 )?;
11596 require(
11597 !interrupted_shell.exists(),
11598 "restart cleanup retained an empty interrupted stage shell",
11599 )?;
11600 require(
11601 unvalidated_nonempty.join("sentinel").is_file(),
11602 "restart cleanup removed a non-empty unvalidated stage",
11603 )?;
11604 require(
11605 lookalike.exists(),
11606 "restart cleanup removed an unvalidated lookalike stage",
11607 )?;
11608 require(
11609 foreign_project_stage.exists(),
11610 "restart cleanup removed a valid stage owned by another project",
11611 )?;
11612 require(
11613 foreign_root_stage.exists(),
11614 "restart cleanup removed a valid stage bound to another root",
11615 )?;
11616 require(
11617 fs::symlink_metadata(&linked_stage).is_ok()
11618 && linked_stage_target.join("sentinel").is_file(),
11619 "restart cleanup followed a linked stage directory",
11620 )?;
11621 if linked_database_created {
11622 require(
11623 fs::symlink_metadata(&linked_database).is_ok() && linked_database_target.is_file(),
11624 "restart cleanup followed a linked staging database",
11625 )?;
11626 }
11627 Ok(())
11628 }
11629
11630 #[test]
11631 fn restart_cleanup_reclaims_schema_nineteen_owned_graph_stage() -> Result<(), Box<dyn Error>> {
11632 let temp = tempfile::tempdir()?;
11633 let root = temp.path().join("schema-19-restart-cleanup");
11634 let atlas_dir = root.join(".projectatlas");
11635 fs::create_dir_all(&atlas_dir)?;
11636 let main_database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11637 let store = AtlasStore::open_for_project(&main_database, &root)?;
11638 let project = store
11639 .project_instance_id()?
11640 .ok_or("bound project identity is missing")?;
11641 let prepare_schema_nineteen =
11642 |stage: &Path, stage_root: &Path, stage_project: ProjectInstanceId| {
11643 fs::create_dir(stage)?;
11644 let database = stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11645 drop(AtlasStore::create_repository_graph_staging(
11646 &database,
11647 stage_root,
11648 stage_project,
11649 )?);
11650 let connection = Connection::open(database)?;
11651 drop_native_worktree_identity_schema(&connection)?;
11652 connection.execute_batch(
11653 "DROP TABLE project_root_identity;
11654 DROP TABLE IF EXISTS graph_identity_rejections;
11655 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
11656 )?;
11657 Ok::<(), Box<dyn Error>>(())
11658 };
11659
11660 let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-owned"));
11661 prepare_schema_nineteen(&owned, &root, project)?;
11662 let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11663 let owned_matches =
11664 AtlasStore::repository_graph_staging_belongs_to(&owned_database, &root, project)?;
11665 #[cfg(windows)]
11666 require(
11667 owned_matches,
11668 "schema-19 owned staging database was not admitted",
11669 )?;
11670 #[cfg(unix)]
11671 require(
11672 owned_matches,
11673 "schema-19 staging database was not admitted by its durable staging ownership",
11674 )?;
11675 fs::write(owned.join("large-graph-payload"), b"stale graph payload")?;
11676
11677 let foreign_root = temp.path().join("schema-19-foreign-root");
11678 fs::create_dir(&foreign_root)?;
11679 let foreign = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-foreign"));
11680 let foreign_root_project = ProjectInstanceId::from_bytes([9; 16])?;
11681 prepare_schema_nineteen(&foreign, &foreign_root, foreign_root_project)?;
11682 let foreign_database = foreign.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11683 require(
11684 !AtlasStore::repository_graph_staging_belongs_to(&foreign_database, &root, project)?,
11685 "schema-19 unrelated-root staging database was admitted",
11686 )?;
11687
11688 let schema_eighteen = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema18"));
11689 prepare_schema_nineteen(&schema_eighteen, &root, project)?;
11690 let schema_eighteen_database = schema_eighteen.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11691 let connection = Connection::open(&schema_eighteen_database)?;
11692 connection.execute(
11693 "UPDATE metadata SET value = '18' WHERE key = 'schema_version'",
11694 [],
11695 )?;
11696 require(
11697 !AtlasStore::repository_graph_staging_belongs_to(
11698 &schema_eighteen_database,
11699 &root,
11700 project,
11701 )?,
11702 "schema-18 staging database was admitted as a schema-19 predecessor",
11703 )?;
11704
11705 let incomplete_current =
11706 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}current-incomplete"));
11707 fs::create_dir(&incomplete_current)?;
11708 let incomplete_current_database = incomplete_current.join(GRAPH_STAGE_DATABASE_FILE_NAME);
11709 drop(AtlasStore::create_repository_graph_staging(
11710 &incomplete_current_database,
11711 &root,
11712 project,
11713 )?);
11714 let connection = Connection::open(&incomplete_current_database)?;
11715 connection.execute_batch(
11716 "DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections;",
11717 )?;
11718 require(
11719 !AtlasStore::repository_graph_staging_belongs_to(
11720 &incomplete_current_database,
11721 &root,
11722 project,
11723 )
11724 .unwrap_or(false),
11725 "current staging database without native identity was admitted",
11726 )?;
11727
11728 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11729 cleanup_abandoned_graph_staging(&root, project, &control)?;
11730 #[cfg(windows)]
11731 require(
11732 !owned.exists(),
11733 "restart cleanup retained an owned schema-19 staging database",
11734 )?;
11735 #[cfg(unix)]
11736 require(
11737 !owned.exists(),
11738 "restart cleanup retained an owned schema-19 staging database",
11739 )?;
11740 require(
11741 foreign.exists(),
11742 "restart cleanup removed an unrelated schema-19 staging database",
11743 )?;
11744 require(
11745 schema_eighteen.exists(),
11746 "restart cleanup removed a non-predecessor schema-18 staging database",
11747 )?;
11748 require(
11749 incomplete_current.exists(),
11750 "restart cleanup removed a current staging database without native identity",
11751 )?;
11752 Ok(())
11753 }
11754
11755 #[test]
11756 fn restart_cleanup_observes_cancellation_between_owned_stages() -> Result<(), Box<dyn Error>> {
11757 const STAGE_COUNT: usize = 64;
11758 const FILES_PER_STAGE: usize = 64;
11759
11760 let temp = tempfile::tempdir()?;
11761 let root = temp.path().join("restart-cleanup-cancellation");
11762 let atlas_dir = root.join(".projectatlas");
11763 fs::create_dir_all(&atlas_dir)?;
11764 let store =
11765 AtlasStore::open_for_project(&atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME), &root)?;
11766 let project = store
11767 .project_instance_id()?
11768 .ok_or("bound project identity is missing")?;
11769 let mut stages = Vec::with_capacity(STAGE_COUNT);
11770 for stage_index in 0..STAGE_COUNT {
11771 let stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}{stage_index:03}"));
11772 fs::create_dir(&stage)?;
11773 drop(AtlasStore::create_repository_graph_staging(
11774 &stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11775 &root,
11776 project,
11777 )?);
11778 let payload = stage.join("payload");
11779 fs::create_dir(&payload)?;
11780 for file_index in 0..FILES_PER_STAGE {
11781 fs::write(payload.join(format!("{file_index:03}")), b"x")?;
11782 }
11783 stages.push(stage);
11784 }
11785
11786 let cancellation = IndexCancellation::new();
11787 let control = IndexWorkControl::new(cancellation.clone(), None);
11788 let worker_root = root;
11789 let worker =
11790 thread::spawn(move || cleanup_abandoned_graph_staging(&worker_root, project, &control));
11791 let observation_deadline = Instant::now() + Duration::from_secs(30);
11792 loop {
11793 let remaining = stages.iter().filter(|stage| stage.exists()).count();
11794 if remaining < STAGE_COUNT {
11795 cancellation.cancel();
11796 break;
11797 }
11798 if worker.is_finished() {
11799 return Err(io::Error::other(
11800 "restart cleanup completed before in-flight cancellation was observed",
11801 )
11802 .into());
11803 }
11804 if Instant::now() >= observation_deadline {
11805 cancellation.cancel();
11806 return Err(io::Error::other(
11807 "restart cleanup removed no stage within the test deadline",
11808 )
11809 .into());
11810 }
11811 thread::yield_now();
11812 }
11813 let result = worker
11814 .join()
11815 .map_err(|_panic| io::Error::other("restart cleanup worker panicked"))?;
11816 require(
11817 matches!(
11818 result,
11819 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11820 stage: IndexWorkStage::Publication
11821 }))
11822 ),
11823 "restart cleanup did not return typed in-flight cancellation",
11824 )?;
11825 let remaining = stages.iter().filter(|stage| stage.exists()).count();
11826 require(
11827 remaining > 0 && remaining < STAGE_COUNT,
11828 "in-flight cancellation did not preserve a partial cleanup boundary",
11829 )
11830 }
11831
11832 #[test]
11833 fn staged_database_owner_retains_incomplete_creation() -> Result<(), Box<dyn Error>> {
11834 let temp = tempfile::tempdir()?;
11835 let atlas_dir = temp.path().join(".projectatlas");
11836 fs::create_dir(&atlas_dir)?;
11837 let directory = tempfile::Builder::new()
11838 .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
11839 .tempdir_in(&atlas_dir)?;
11840 let staging_path = directory.path().to_path_buf();
11841 fs::write(
11842 staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME),
11843 "incomplete",
11844 )?;
11845 fs::write(staging_path.join("payload"), "preserve")?;
11846 let lease = try_graph_stage_lease(&atlas_dir)?
11847 .ok_or("test could not acquire graph staging lease")?;
11848 let owner = super::StagedGraphDatabase {
11849 store: None,
11850 directory: Some(directory),
11851 _lease: lease,
11852 };
11853
11854 drop(owner);
11855
11856 require(
11857 staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME).is_file()
11858 && staging_path.join("payload").is_file(),
11859 "incomplete staging creation was recursively deleted",
11860 )
11861 }
11862
11863 #[test]
11864 fn database_staging_publishes_and_removes_its_disposable_store() -> Result<(), Box<dyn Error>> {
11865 let temp = tempfile::tempdir()?;
11866 let root = temp.path().join("database-staging");
11867 let atlas_dir = root.join(".projectatlas");
11868 fs::create_dir_all(&atlas_dir)?;
11869 let database = atlas_dir.join("projectatlas.db");
11870 let mut store = AtlasStore::open_for_project(&database, &root)?;
11871 let project = store
11872 .project_instance_id()?
11873 .ok_or("bound project identity is missing")?;
11874 let generation = IndexGeneration::new(1);
11875 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11876 let graphs = vec![extract_symbol_graph(
11877 "src/lib.rs",
11878 Some("rust"),
11879 "pub fn caller() { helper(); }\nfn helper() {}\n",
11880 )];
11881 let nodes = vec![test_file_node("src/lib.rs", "rust")];
11882 let packages = PackageIndex::from_graphs(&graphs)?;
11883 let projection = build_entity_projection(
11884 project, generation, &nodes, &graphs, &packages, true, &control,
11885 )?;
11886 let candidates = resolution_registry_from_exports(&projection, &control)?;
11887 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11888 let staged = finish_projection_in_database(
11889 &root,
11890 &nodes,
11891 project,
11892 generation,
11893 &graphs,
11894 projection,
11895 &candidates,
11896 &scan_policy,
11897 &control,
11898 )?;
11899 let staging_path = staged
11900 .database
11901 .as_ref()
11902 .ok_or("database staging was not selected")?
11903 .directory()?
11904 .path()
11905 .to_path_buf();
11906 let drop_payload = staging_path.join("drop-payload");
11907 fs::create_dir(&drop_payload)?;
11908 fs::write(drop_payload.join("row"), "discard")?;
11909 let drop_link_target = temp.path().join("drop-link-target");
11910 fs::create_dir(&drop_link_target)?;
11911 fs::write(drop_link_target.join("sentinel"), "preserve")?;
11912 create_directory_link(&drop_link_target, &staging_path.join("drop-linked-payload"))?;
11913 require(
11914 staging_path.exists(),
11915 "database staging directory is missing",
11916 )?;
11917 {
11918 let mut publication = store.begin_index_publication("database-staging")?;
11919 publication.begin_scan_replacement()?;
11920 publication.upsert_scan_node_batch(&nodes)?;
11921 publication.finish_scan_replacement()?;
11922 staged.apply(&mut publication, &control)?;
11923 publication.complete()?;
11924 }
11925 drop(staged);
11926 require(
11927 !staging_path.exists(),
11928 "database staging directory survived publication",
11929 )?;
11930 require(
11931 drop_link_target.join("sentinel").is_file(),
11932 "database staging drop followed a linked payload",
11933 )?;
11934 drop(store);
11935
11936 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
11937 let coverage = reader.repository_graph_coverage(
11938 project,
11939 &CoverageScope::Path {
11940 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
11941 },
11942 8,
11943 )?;
11944 require(
11945 !coverage.truncated,
11946 "database-staged coverage was truncated",
11947 )?;
11948 require(
11949 !coverage.rows.is_empty(),
11950 "database-staged graph rows were not published",
11951 )?;
11952 Ok(())
11953 }
11954
11955 #[test]
11956 fn staged_identity_rejections_count_toward_normal_and_database_bytes()
11957 -> Result<(), Box<dyn Error>> {
11958 let temp = tempfile::tempdir()?;
11959 let root = temp.path().join("identity-rejection-bytes");
11960 fs::create_dir_all(root.join("src"))?;
11961 fs::write(root.join("src/lib.rs"), "pub fn indexed() {}\n")?;
11962 let database = root.join("projectatlas.db");
11963 let store = AtlasStore::open_for_project(&database, &root)?;
11964 let project = store
11965 .project_instance_id()?
11966 .ok_or("identity rejection fixture project identity is missing")?;
11967 let generation = IndexGeneration::new(1);
11968 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11969 let graphs = vec![extract_symbol_graph(
11970 "src/lib.rs",
11971 Some("rust"),
11972 "pub fn indexed() {}\n",
11973 )];
11974 let nodes = vec![test_file_node("src/lib.rs", "rust")];
11975 let packages = PackageIndex::from_graphs(&graphs)?;
11976 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
11977 let mut admission = GraphIdentityAdmission::default();
11978 let rejection_path = format!("src/{}.rs", "long-rejection-path".repeat(64));
11979 admission.record(
11980 &rejection_path,
11981 super::IdentitySpan {
11982 start_line: 4,
11983 start_column: 0,
11984 end_line: 4,
11985 end_column: 12,
11986 },
11987 ParserKind::TreeSitter,
11988 7,
11989 &[(
11990 GraphIdentityField::Symbol,
11991 GraphIdentityRejectionReason::Oversized,
11992 )],
11993 &control,
11994 )?;
11995 let rejection_bytes = identity_rejection_keys_retained_bytes(&admission.rejection_keys)?;
11996 require(
11997 rejection_bytes > 1,
11998 "identity rejection fixture did not retain a bounded detail payload",
11999 )?;
12000 let build_projection = || {
12001 let projection = build_entity_projection(
12002 project, generation, &nodes, &graphs, &packages, true, &control,
12003 )?;
12004 let candidates = resolution_registry_from_exports(&projection, &control)?;
12005 Result::<_, Box<dyn Error>>::Ok((projection, candidates))
12006 };
12007
12008 let (projection, candidates) = build_projection()?;
12009 let baseline = finish_projection_with_documents(
12010 project,
12011 generation,
12012 RepositoryGraphMutation::Full,
12013 &graphs,
12014 &root,
12015 &nodes,
12016 &BTreeMap::new(),
12017 &GraphIdentityAdmission::default(),
12018 projection,
12019 &candidates,
12020 &scan_policy,
12021 &control,
12022 )?;
12023 let (projection, candidates) = build_projection()?;
12024 let in_memory = finish_projection_with_documents(
12025 project,
12026 generation,
12027 RepositoryGraphMutation::Full,
12028 &graphs,
12029 &root,
12030 &nodes,
12031 &BTreeMap::new(),
12032 &admission,
12033 projection,
12034 &candidates,
12035 &scan_policy,
12036 &control,
12037 )?;
12038 require_eq(
12039 &in_memory.retained_bytes(),
12040 &baseline.retained_bytes().saturating_add(rejection_bytes),
12041 "in-memory identity rejection retained bytes",
12042 )?;
12043 require_eq(
12044 &in_memory.identity_rejections.len(),
12045 &1,
12046 "in-memory identity rejection detail count",
12047 )?;
12048 let parent_prefix = super::super::MAX_PUBLICATION_STAGING_BYTES
12049 .checked_sub(in_memory.retained_bytes())
12050 .ok_or("identity rejection fixture exceeded the publication budget")?;
12051 require(
12052 super::super::enforce_publication_staging_budget(
12053 parent_prefix.saturating_add(in_memory.retained_bytes()),
12054 )
12055 .is_ok(),
12056 "parent publication budget rejected exact staged identity bytes",
12057 )?;
12058 require(
12059 super::super::enforce_publication_staging_budget(
12060 parent_prefix
12061 .saturating_add(in_memory.retained_bytes())
12062 .saturating_add(1),
12063 )
12064 .is_err(),
12065 "parent publication budget accepted one byte over staged identity bytes",
12066 )?;
12067 drop(in_memory);
12068 drop(baseline);
12069
12070 let (projection, candidates) = build_projection()?;
12071 let baseline_database = finish_projection_in_database_with_documents(
12072 &root,
12073 &nodes,
12074 project,
12075 generation,
12076 &graphs,
12077 &BTreeMap::new(),
12078 &GraphIdentityAdmission::default(),
12079 projection,
12080 &candidates,
12081 &scan_policy,
12082 &control,
12083 )?;
12084 let baseline_database_path_bytes = baseline_database
12085 .database
12086 .as_ref()
12087 .ok_or("database staging baseline was not selected")?
12088 .directory()?
12089 .path()
12090 .join(GRAPH_STAGE_DATABASE_FILE_NAME)
12091 .as_os_str()
12092 .as_encoded_bytes()
12093 .len() as u64;
12094 require_eq(
12095 &baseline_database.retained_bytes(),
12096 &baseline_database_path_bytes,
12097 "database staging baseline retained bytes",
12098 )?;
12099 drop(baseline_database);
12100
12101 let (projection, candidates) = build_projection()?;
12102 let database_staged = finish_projection_in_database_with_documents(
12103 &root,
12104 &nodes,
12105 project,
12106 generation,
12107 &graphs,
12108 &BTreeMap::new(),
12109 &admission,
12110 projection,
12111 &candidates,
12112 &scan_policy,
12113 &control,
12114 )?;
12115 let database_path_bytes = database_staged
12116 .database
12117 .as_ref()
12118 .ok_or("database staging was not selected")?
12119 .directory()?
12120 .path()
12121 .join(GRAPH_STAGE_DATABASE_FILE_NAME)
12122 .as_os_str()
12123 .as_encoded_bytes()
12124 .len() as u64;
12125 require_eq(
12126 &database_staged.retained_bytes(),
12127 &database_path_bytes.saturating_add(rejection_bytes),
12128 "database staging identity rejection retained bytes",
12129 )?;
12130 Ok(())
12131 }
12132
12133 #[test]
12134 fn accepted_relation_families_publish_and_reopen() -> Result<(), Box<dyn Error>> {
12135 let temp = tempfile::tempdir()?;
12136 let root = temp.path().join("accepted-relation-families");
12137 for directory in ["src", "tests", "config", "infra", "data"] {
12138 fs::create_dir_all(root.join(directory))?;
12139 }
12140 let database = root.join("projectatlas.db");
12141 let mut store = AtlasStore::open_for_project(&database, &root)?;
12142 let project = store
12143 .project_instance_id()?
12144 .ok_or_else(|| io::Error::other("relation inventory identity is missing"))?;
12145 let generation = IndexGeneration::new(1);
12146 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12147 let graphs = vec![
12148 extract_symbol_graph(
12149 "Cargo.toml",
12150 Some("cargo-manifest"),
12151 concat!(
12152 "[package]\nname = \"relation-fixture\"\nversion = \"0.1.0\"\n",
12153 "\n[dependencies]\nserde = \"1\"\n",
12154 ),
12155 ),
12156 extract_symbol_graph(
12157 "src/lib.rs",
12158 Some("rust"),
12159 concat!(
12160 "use std::fs;\n",
12161 "pub struct Router { pub enabled: bool }\n",
12162 "impl Router { pub fn install(&self) {} }\n",
12163 "pub fn handler() {}\n",
12164 "pub fn register() {\n",
12165 " route(\"/health\", handler);\n",
12166 " let _ = std::env::var(\"ATLAS_MODE\").unwrap_or_else(|_| \"super-secret\".into());\n",
12167 " let _ = fs::read_to_string(\"data/input.txt\");\n",
12168 " let _ = fs::write(\"data/output.txt\", \"ok\");\n",
12169 "}\n",
12170 "fn route(_path: &str, _handler: fn()) {}\n",
12171 ),
12172 ),
12173 extract_symbol_graph(
12174 "tests/feature_test.rs",
12175 Some("rust"),
12176 "fn subject() {}\nfn verifies_subject() { subject(); }\n",
12177 ),
12178 extract_symbol_graph(
12179 "config/appsettings.json",
12180 Some("json"),
12181 "{\"token\":\"super-secret\"}\n",
12182 ),
12183 extract_symbol_graph(
12184 "infra/main.tf",
12185 Some("terraform"),
12186 "resource \"null_resource\" \"fixture\" {}\n",
12187 ),
12188 extract_symbol_graph("data/input.txt", None, "input\n"),
12189 extract_symbol_graph("data/output.txt", None, ""),
12190 ];
12191 let nodes = graphs
12192 .iter()
12193 .map(|graph| {
12194 test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
12195 })
12196 .collect::<Vec<_>>();
12197 let packages = PackageIndex::from_graphs(&graphs)?;
12198 let projection = build_entity_projection(
12199 project, generation, &nodes, &graphs, &packages, true, &control,
12200 )?;
12201 let candidates = resolution_registry_from_exports(&projection, &control)?;
12202 let staged = finish_projection(
12203 project,
12204 generation,
12205 RepositoryGraphMutation::Full,
12206 &graphs,
12207 projection,
12208 &candidates,
12209 &control,
12210 )?;
12211 {
12212 let mut publication = store.begin_index_publication("accepted-relation-families")?;
12213 publication.begin_scan_replacement()?;
12214 publication.upsert_scan_node_batch(&nodes)?;
12215 publication.finish_scan_replacement()?;
12216 staged.apply(&mut publication, &control)?;
12217 publication.complete()?;
12218 }
12219 drop(store);
12220
12221 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12222 for capability in RELATION_FAMILY_CAPABILITIES
12223 .iter()
12224 .filter(|capability| capability.state == RelationFamilyState::Active)
12225 {
12226 for &family in capability.graph_relations {
12227 let page = reader.repository_graph_relations(
12228 RepositoryGraphRelationQuery::Family { relation: family },
12229 128,
12230 )?;
12231 require(!page.truncated, &format!("{family:?} page was truncated"))?;
12232 require(
12233 !page.rows.is_empty(),
12234 &format!("{family:?} had no reopened persisted relation"),
12235 )?;
12236 for relation in page.rows {
12237 let occurrences = reader.repository_graph_occurrences(&relation, 32)?;
12238 require(
12239 !occurrences.rows.is_empty() && !occurrences.truncated,
12240 &format!("{family:?} lost exact source occurrences"),
12241 )?;
12242 require(
12243 !format!("{relation:?}").contains("super-secret"),
12244 &format!("secret value escaped into persisted {family:?} relation"),
12245 )?;
12246 }
12247 }
12248 }
12249 Ok(())
12250 }
12251
12252 #[test]
12253 fn accepted_relation_families_abstain_without_static_evidence() -> Result<(), Box<dyn Error>> {
12254 let project = ProjectInstanceId::from_bytes([13; 16])?;
12255 let generation = IndexGeneration::new(1);
12256 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12257 let graphs = vec![
12258 extract_symbol_graph(
12259 "src/dynamic.rs",
12260 Some("rust"),
12261 concat!(
12262 "use std::fs;\n",
12263 "struct Client;\n",
12264 "impl Client { fn get(&self, _path: &str, _handler: fn()) {} }\n",
12265 "fn handler() {}\n",
12266 "fn dynamic(client: &Client, route_path: &str, key: &str, file: &str) {\n",
12267 " client.get(\"/health\", handler);\n",
12268 " route(route_path, handler);\n",
12269 " let _ = std::env::var(key);\n",
12270 " let _ = fs::read_to_string(file);\n",
12271 " let _ = fs::write(file, \"super-secret\");\n",
12272 "}\n",
12273 "fn route(_path: &str, _handler: fn()) {}\n",
12274 ),
12275 ),
12276 extract_symbol_graph(
12277 "src/escaping.rs",
12278 Some("rust"),
12279 concat!(
12280 "use std::fs;\n",
12281 "fn unsafe_paths() {\n",
12282 " let _ = fs::read_to_string(\"../secret.txt\");\n",
12283 " let _ = fs::write(\"C:/secret.txt\", \"super-secret\");\n",
12284 "}\n",
12285 ),
12286 ),
12287 extract_symbol_graph(
12288 "src/dynamic.js",
12289 Some("javascript"),
12290 concat!(
12291 "function handler() {}\n",
12292 "function middleware() {}\n",
12293 "function route(...args) {}\n",
12294 "route(\"/health\", handler, middleware);\n",
12295 ),
12296 ),
12297 extract_symbol_graph("docs/main.tf.example", None, "not infrastructure\n"),
12298 extract_symbol_graph("docs/k8s/README.md", None, "not infrastructure\n"),
12299 extract_symbol_graph("docs/cloudformation-notes.md", None, "not infrastructure\n"),
12300 extract_symbol_graph("config/settings.json.bak", None, "super-secret\n"),
12301 ];
12302 let packages = PackageIndex::from_graphs(&graphs)?;
12303 let projection =
12304 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
12305 let candidates = resolution_registry_from_exports(&projection, &control)?;
12306 let staged = finish_projection(
12307 project,
12308 generation,
12309 RepositoryGraphMutation::Full,
12310 &graphs,
12311 projection,
12312 &candidates,
12313 &control,
12314 )?;
12315 for family in [
12316 ExtendedRelationKind::Tests,
12317 ExtendedRelationKind::RoutesTo,
12318 ExtendedRelationKind::Configures,
12319 ExtendedRelationKind::Deploys,
12320 ExtendedRelationKind::Reads,
12321 ExtendedRelationKind::Writes,
12322 ] {
12323 require(
12324 staged
12325 .relations
12326 .iter()
12327 .all(|relation| relation.kind() != GraphRelationKind::Extended(family)),
12328 &format!("dynamic or lookalike input fabricated {family:?}"),
12329 )?;
12330 }
12331 require(
12332 staged
12333 .entities
12334 .iter()
12335 .all(|entity| !entity.key().canonical_identity().contains("super-secret")),
12336 "negative fixture leaked a secret into graph identity",
12337 )?;
12338 Ok(())
12339 }
12340
12341 #[test]
12342 fn qualified_symbol_identity_preserves_boundaries_and_compacts_stably()
12343 -> Result<(), Box<dyn Error>> {
12344 let name = GraphIdentityText::new("leaf")?;
12345 require_eq(
12346 &qualified_symbol_identity(&GraphIdentityText::new("outer")?, &name)?,
12347 &GraphIdentityText::new("outer::leaf")?,
12348 "shallow qualified identity",
12349 )?;
12350
12351 let exact_parent = GraphIdentityText::new(
12352 "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len()),
12353 )?;
12354 let exact = qualified_symbol_identity(&exact_parent, &name)?;
12355 require_eq(
12356 &exact.as_str().len(),
12357 &MAX_GRAPH_IDENTITY_BYTES,
12358 "exact-boundary qualified identity bytes",
12359 )?;
12360 require(
12361 exact.as_str().ends_with("::leaf"),
12362 "exact-boundary qualified identity changed its readable suffix",
12363 )?;
12364
12365 let first_overbound_parent = GraphIdentityText::new(
12366 "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len() + 1),
12367 )?;
12368 let compact = qualified_symbol_identity(&first_overbound_parent, &name)?;
12369 require(
12370 compact.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES,
12371 "first overbound qualified identity remained oversized",
12372 )?;
12373 require(
12374 compact.as_str().ends_with("::leaf"),
12375 "compacted qualified identity lost its nearest symbol name",
12376 )?;
12377 require_eq(
12378 &qualified_symbol_identity(&first_overbound_parent, &name)?,
12379 &compact,
12380 "repeated compact qualified identity",
12381 )?;
12382
12383 let multibyte_parent = GraphIdentityText::new(format!(
12384 "{}x",
12385 "é".repeat((MAX_GRAPH_IDENTITY_BYTES - "::".len() - "界".len() - 1) / "é".len())
12386 ))?;
12387 let multibyte =
12388 qualified_symbol_identity(&multibyte_parent, &GraphIdentityText::new("界")?)?;
12389 require_eq(
12390 &multibyte.as_str().len(),
12391 &MAX_GRAPH_IDENTITY_BYTES,
12392 "multibyte exact-boundary qualified identity bytes",
12393 )?;
12394 require(
12395 multibyte.as_str().ends_with("::界"),
12396 "multibyte qualified identity changed its readable suffix",
12397 )?;
12398
12399 let parent_a = GraphIdentityText::new("a".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
12400 let parent_b = GraphIdentityText::new("b".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
12401 let scoped_a = qualified_symbol_identity(&parent_a, &name)?;
12402 let scoped_b = qualified_symbol_identity(&parent_b, &name)?;
12403 require(
12404 scoped_a != scoped_b,
12405 "distinct deep ancestors with an equal suffix shared one identity",
12406 )?;
12407 require(
12408 scoped_a != qualified_symbol_identity(&parent_a, &GraphIdentityText::new("other")?)?,
12409 "distinct overbound candidates shared one compact identity",
12410 )?;
12411 let (literal_parent, _) = scoped_a
12412 .as_str()
12413 .rsplit_once("::")
12414 .ok_or_else(|| io::Error::other("compact scope omitted its readable suffix"))?;
12415 require(
12416 source_symbol_identity(literal_parent.to_string()).is_err(),
12417 "compact scope namespace remained admissible as an exact source parent",
12418 )?;
12419 Ok(())
12420 }
12421
12422 #[test]
12423 fn qualified_symbol_parents_reject_invalid_raw_components() -> Result<(), Box<dyn Error>> {
12424 for (name, parent) in [
12425 ("bad\nname".to_string(), None),
12426 ("valid".to_string(), Some("bad\nparent".to_string())),
12427 (
12428 format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal"),
12429 Some("parent".to_string()),
12430 ),
12431 (
12432 "valid".to_string(),
12433 Some(format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal")),
12434 ),
12435 (
12436 "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
12437 Some("parent".to_string()),
12438 ),
12439 ] {
12440 let graph = SymbolGraph {
12441 path: "src/invalid.rs".to_string(),
12442 language: Some("rust".to_string()),
12443 parser: ParserKind::TreeSitter,
12444 symbols: vec![CodeSymbol {
12445 path: "src/invalid.rs".to_string(),
12446 language: Some("rust".to_string()),
12447 name,
12448 kind: SymbolKind::Function,
12449 signature: "fn valid()".to_string(),
12450 exported: false,
12451 documentation: None,
12452 line_start: 1,
12453 line_end: 1,
12454 source_selector: None,
12455 parent,
12456 parser: ParserKind::TreeSitter,
12457 detail: Some("function_item".to_string()),
12458 }],
12459 relations: Vec::new(),
12460 };
12461 require(
12462 qualified_symbol_parents(&graph).is_err(),
12463 "invalid raw symbol identity reached qualified derivation",
12464 )?;
12465 }
12466 Ok(())
12467 }
12468
12469 #[test]
12470 fn qualified_symbol_parents_bound_four_thousand_deep_scopes() -> Result<(), Box<dyn Error>> {
12471 const DEPTH: usize = 4_000;
12472 let names = (0..DEPTH)
12473 .map(|index| format!("scope_{index:04}_{}", "x".repeat(229)))
12474 .collect::<Vec<_>>();
12475 let graph = SymbolGraph {
12476 path: "src/deep.rs".to_string(),
12477 language: Some("rust".to_string()),
12478 parser: ParserKind::TreeSitter,
12479 symbols: names
12480 .iter()
12481 .enumerate()
12482 .map(|(index, name)| CodeSymbol {
12483 path: "src/deep.rs".to_string(),
12484 language: Some("rust".to_string()),
12485 name: name.clone(),
12486 kind: SymbolKind::Module,
12487 signature: name.clone(),
12488 exported: false,
12489 documentation: None,
12490 line_start: index + 1,
12491 line_end: DEPTH * 2 - index,
12492 source_selector: None,
12493 parent: index.checked_sub(1).map(|parent| names[parent].clone()),
12494 parser: ParserKind::TreeSitter,
12495 detail: Some("mod_item".to_string()),
12496 })
12497 .collect(),
12498 relations: Vec::new(),
12499 };
12500 let first = qualified_symbol_parents(&graph)?;
12501 require_eq(&first.len(), &DEPTH, "deep qualified parent count")?;
12502 require(
12503 first.first().is_some_and(Option::is_none),
12504 "deep root unexpectedly gained a parent",
12505 )?;
12506 require(
12507 first
12508 .iter()
12509 .flatten()
12510 .all(|parent| parent.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES),
12511 "deep qualification retained an oversized parent",
12512 )?;
12513 require(
12514 first
12515 .iter()
12516 .flatten()
12517 .any(|parent| parent.as_str().starts_with("@projectatlas.scope.v1:")),
12518 "deep qualification never exercised compact scope identity",
12519 )?;
12520 require_eq(
12521 &qualified_symbol_parents(&graph)?,
12522 &first,
12523 "repeated deep qualified parents",
12524 )?;
12525 Ok(())
12526 }
12527
12528 #[test]
12529 fn qualified_symbol_scopes_produce_distinct_graph_entity_keys() -> Result<(), Box<dyn Error>> {
12530 let project = ProjectInstanceId::from_bytes([3; 16])?;
12531 let generation = IndexGeneration::new(1);
12532 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12533 for (path, language, source, expected_parents) in [
12534 (
12535 "src/lib.rs",
12536 "rust",
12537 concat!(
12538 "mod first { struct Runner; impl Runner { fn run(&self) {} } }\n",
12539 "mod second { struct Runner; impl Runner { fn run(&self) {} } }\n",
12540 ),
12541 ["first::Runner", "second::Runner"],
12542 ),
12543 (
12544 "src/Runner.java",
12545 "java",
12546 concat!(
12547 "class First { class Runner { void run() {} } }\n",
12548 "class Second { class Runner { void run() {} } }\n",
12549 ),
12550 ["First::Runner", "Second::Runner"],
12551 ),
12552 ] {
12553 let graph = extract_symbol_graph(path, Some(language), source);
12554 let method_indices = graph
12555 .symbols
12556 .iter()
12557 .enumerate()
12558 .filter_map(|(index, symbol)| {
12559 (symbol.kind == SymbolKind::Method && symbol.name == "run").then_some(index)
12560 })
12561 .collect::<Vec<_>>();
12562 require_eq(&method_indices.len(), &2, "scoped method count")?;
12563 let first_symbol = &graph.symbols[method_indices[0]];
12564 let second_symbol = &graph.symbols[method_indices[1]];
12565 require_eq(
12566 &first_symbol.parent.as_deref(),
12567 &Some("Runner"),
12568 "legacy leaf parent",
12569 )?;
12570 require_eq(
12571 &first_symbol.parent,
12572 &second_symbol.parent,
12573 "same legacy leaf parent",
12574 )?;
12575 require_eq(
12576 &first_symbol.signature,
12577 &second_symbol.signature,
12578 "same declaration signature",
12579 )?;
12580
12581 let graphs = vec![graph];
12582 let packages = PackageIndex::from_graphs(&graphs)?;
12583 let projection = build_entity_projection(
12584 project,
12585 generation,
12586 &[],
12587 &graphs,
12588 &packages,
12589 true,
12590 &control,
12591 )?;
12592 let owners = projection
12593 .owners_by_graph
12594 .get(path)
12595 .ok_or("scoped graph owners are missing")?;
12596 let first = owners.symbol_digests[method_indices[0]]
12597 .as_ref()
12598 .and_then(|digest| projection.entity_by_digest.get(digest))
12599 .ok_or("first scoped method entity is missing")?;
12600 let second = owners.symbol_digests[method_indices[1]]
12601 .as_ref()
12602 .and_then(|digest| projection.entity_by_digest.get(digest))
12603 .ok_or("second scoped method entity is missing")?;
12604 let EntitySelector::Symbol {
12605 symbol: first_selector,
12606 } = first.selector()
12607 else {
12608 return Err("first scoped entity is not a symbol".into());
12609 };
12610 let EntitySelector::Symbol {
12611 symbol: second_selector,
12612 } = second.selector()
12613 else {
12614 return Err("second scoped entity is not a symbol".into());
12615 };
12616 require_eq(
12617 &first_selector
12618 .parent
12619 .as_ref()
12620 .map(GraphIdentityText::as_str),
12621 &Some(expected_parents[0]),
12622 "first graph identity parent",
12623 )?;
12624 require_eq(
12625 &second_selector
12626 .parent
12627 .as_ref()
12628 .map(GraphIdentityText::as_str),
12629 &Some(expected_parents[1]),
12630 "second graph identity parent",
12631 )?;
12632 require(
12633 first.key() != second.key(),
12634 "independent semantic scopes shared one graph entity key",
12635 )?;
12636 }
12637 Ok(())
12638 }
12639
12640 #[test]
12641 fn external_classification_follows_effective_semantic_provider() -> Result<(), Box<dyn Error>> {
12642 for (language, target, system, identity) in [
12643 ("html", "import fs from \"node:fs\";", "node", "fs"),
12644 ("svelte", "import url from \"node:url\";", "node", "url"),
12645 ("vue", "import path from \"node:path\";", "node", "path"),
12646 ] {
12647 let case = resolution_case(language, RelationKind::Imports, target, &[]);
12648 let external = explicit_external_selector(&case.graph, &case.relation)?
12649 .ok_or("embedded ECMAScript external classification is missing")?;
12650 require_eq(&external.system.as_str(), &system, "external system")?;
12651 require_eq(&external.identity.as_str(), &identity, "external identity")?;
12652 }
12653
12654 let lock = resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]);
12655 require(
12656 explicit_external_selector(&lock.graph, &lock.relation)?.is_none(),
12657 "Cargo.lock was misclassified as an explicit external dependency",
12658 )?;
12659 Ok(())
12660 }
12661
12662 #[test]
12663 fn native_rust_external_identity_follows_the_binding_instead_of_its_spelling()
12664 -> Result<(), Box<dyn Error>> {
12665 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12666 let project = ProjectInstanceId::from_bytes([40; 16])?;
12667 for (source, expected) in [
12668 ("mod std {} fn caller(){std::fs::read(\"x\");}", None),
12669 (
12670 "mod fake {} use crate::fake as std; fn caller(){std::fs::read(\"x\");}",
12671 None,
12672 ),
12673 (
12674 "use std; fn caller(){std::fs::read(\"x\");}",
12675 Some("std::fs::read"),
12676 ),
12677 (
12678 "use std::mem::drop as dispose; fn caller(){dispose(1);}",
12679 Some("std::mem::drop"),
12680 ),
12681 (
12682 "use core::mem as std; fn caller(){std::drop(1);}",
12683 Some("core::mem::drop"),
12684 ),
12685 (
12686 "use std::mem::drop as dispose; fn caller(dispose:fn(u8)){dispose(1);}",
12687 None,
12688 ),
12689 ] {
12690 let extracted = projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12691 "src/lib.rs",
12692 Some("rust"),
12693 source,
12694 &control,
12695 )?;
12696 let graph = extracted.graph();
12697 let facts = extracted.semantics();
12698 let projection = projectatlas_symbols::derive_resolution_keys_with_context(
12699 project,
12700 None,
12701 graph,
12702 projectatlas_symbols::ResolutionProjectionContext::default()
12703 .with_source_semantics(facts),
12704 )?;
12705 let index = super::GraphSymbolIndex::new(graph, &control)?
12706 .with_source_calls(projection.source_calls(), &control)?
12707 .with_rust_toolchain_imports(projection.rust_toolchain_imports());
12708 let call = facts.calls().last().ok_or("fixture call missing")?;
12709 let external = super::source_external_selector(
12710 graph,
12711 &graph.relations[call.relation],
12712 Some(call.relation),
12713 &index,
12714 )?;
12715 require_eq(
12716 &external.as_ref().map(|external| external.identity.as_str()),
12717 &expected,
12718 source,
12719 )?;
12720 }
12721 Ok(())
12722 }
12723
12724 #[test]
12725 fn grouped_rust_imports_use_only_their_common_external_module() -> Result<(), Box<dyn Error>> {
12726 let grouped = resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]);
12727 require_eq(
12728 &rust_toolchain_identity(&grouped.relation),
12729 &Some("std".to_string()),
12730 "grouped Rust external root",
12731 )?;
12732 let nested = resolution_case(
12733 "rust",
12734 RelationKind::Imports,
12735 "use std::fs::{read, write};",
12736 &[],
12737 );
12738 require_eq(
12739 &rust_toolchain_identity(&nested.relation),
12740 &Some("std::fs".to_string()),
12741 "nested grouped Rust external module",
12742 )?;
12743 let ordinary = resolution_case("rust", RelationKind::Imports, "use std::fs;", &[]);
12744 require_eq(
12745 &rust_toolchain_identity(&ordinary.relation),
12746 &Some("std::fs".to_string()),
12747 "ordinary Rust external module",
12748 )?;
12749 Ok(())
12750 }
12751
12752 #[test]
12753 fn explicit_export_forwarding_tracks_actual_targets_and_intermediate_changes()
12754 -> Result<(), Box<dyn Error>> {
12755 let temp = tempfile::tempdir()?;
12756 let root = temp.path();
12757 let sources = [
12758 ("worker.js", "export function work() {}"),
12759 ("other.js", "export function other() {}"),
12760 ("inner.js", "export {work as launch} from './worker.js';"),
12761 (
12762 "barrel.js",
12763 "export {launch as entry} from './inner.js'; export * as tools from './worker.js'; export {work as collision} from './worker.js'; export {other as collision} from './other.js';",
12764 ),
12765 ("loopA.js", "export {cycle} from './loopB.js';"),
12766 ("loopB.js", "export {cycle} from './loopC.js';"),
12767 ("loopC.js", "export {cycle} from './loopA.js';"),
12768 ("route1.js", "export {cycle} from './route2.js';"),
12769 ("route2.js", "export {cycle} from './loopC.js';"),
12770 (
12771 "second.js",
12772 "import {cycle} from './route1.js'; function second() {cycle();}",
12773 ),
12774 ("wild.js", "export * from './worker.js';"),
12775 (
12776 "caller.js",
12777 "import {entry, tools, collision} from './barrel.js'; import {cycle} from './loopA.js'; import {work} from './wild.js'; function clean() {entry(); tools.work();} function blocked() {collision(); cycle(); work();} function shadow(entry) {entry();}",
12778 ),
12779 ];
12780 let mut nodes = Vec::new();
12781 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12782 let make_parsed =
12783 |path: &str, content: &str| -> Result<SymbolParseSuccess, Box<dyn Error>> {
12784 let (_, graph, semantics) =
12785 projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12786 path,
12787 Some("javascript"),
12788 content,
12789 &control,
12790 )?
12791 .into_parts();
12792 Ok(SymbolParseSuccess {
12793 path: path.to_string(),
12794 source_parser: graph.parser,
12795 graph,
12796 source_semantics: Some(Box::new(semantics)),
12797 markdown_facts: None,
12798 summary: "static export source".to_string(),
12799 summary_is_structural: false,
12800 purpose_suggestion: None,
12801 })
12802 };
12803 let mut symbols = empty_symbol_build_stage();
12804 for (path, content) in sources {
12805 fs::write(root.join(path), content)?;
12806 nodes.push(test_file_node(path, "javascript"));
12807 symbols
12808 .changes
12809 .push(SymbolProjectionChange::Parsed(make_parsed(path, content)?));
12810 }
12811 capture_test_file_hashes(root, &mut nodes)?;
12812 symbols.report.candidates = nodes.len();
12813 symbols.report.parsed = nodes.len();
12814 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
12815 fs::create_dir_all(root.join(".projectatlas"))?;
12816 let mut store =
12817 AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
12818 let project = store.project_instance_id()?.ok_or("missing project")?;
12819 let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
12820 let full = stage_full_repository_graph(
12821 &store,
12822 root,
12823 IndexGeneration::ZERO,
12824 &nodes,
12825 &policy,
12826 &symbols,
12827 &control,
12828 )?;
12829 let resolved_targets = |stage: &StagedRepositoryGraph| {
12830 stage
12831 .relations
12832 .iter()
12833 .filter_map(|relation| {
12834 if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
12835 return None;
12836 }
12837 match relation.resolution() {
12838 RelationResolution::Resolved {
12839 selector: ReusableTargetSelector::Symbol { symbol },
12840 ..
12841 } => Some((
12842 symbol.file.as_str().to_string(),
12843 symbol.name.as_str().to_string(),
12844 )),
12845 _ => None,
12846 }
12847 })
12848 .collect::<BTreeSet<_>>()
12849 };
12850 require_eq(
12851 &resolved_targets(&full),
12852 &BTreeSet::from([("worker.js".to_string(), "work".to_string())]),
12853 "forwarded source target",
12854 )?;
12855 let unresolved_calls = |stage: &StagedRepositoryGraph| {
12856 stage
12857 .relations
12858 .iter()
12859 .filter(|relation| {
12860 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12861 && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12862 })
12863 .count()
12864 };
12865 require_eq(
12866 &unresolved_calls(&full),
12867 &5,
12868 "duplicate, cycle, wildcard and shadow calls must abstain",
12869 )?;
12870 require_eq(
12871 &full.source_recovery.previously_unchanged,
12872 &0,
12873 "fresh source was reparsed during forwarding",
12874 )?;
12875 let inner_cause = super::document_file_resolution_key(project, "inner.js")?;
12876 require(
12877 full.relation_dependencies
12878 .iter()
12879 .any(|binding| binding.key() == &inner_cause),
12880 "call lost intermediate file invalidation cause",
12881 )?;
12882 publish_full_staged_graph(&mut store, &nodes, &full, &control, "forwarding initial")?;
12883 for change in &symbols.changes {
12884 if let SymbolProjectionChange::Parsed(parsed) = change {
12885 store.replace_symbol_graph(&parsed.graph)?;
12886 }
12887 }
12888 let base = store
12889 .repository_graph_generation()?
12890 .ok_or("missing graph generation")?;
12891 let content = "export {other as launch} from './other.js';";
12892 fs::write(root.join("inner.js"), content)?;
12893 capture_test_file_hashes(root, &mut nodes)?;
12894 let mut changed = empty_symbol_build_stage();
12895 changed
12896 .changes
12897 .push(SymbolProjectionChange::Parsed(make_parsed(
12898 "inner.js", content,
12899 )?));
12900 changed.identity_admission = super::admit_symbol_build_stage(&mut changed, &control)?;
12901 let incremental = stage_incremental_repository_graph(
12902 &store,
12903 root,
12904 base,
12905 &nodes,
12906 &["inner.js".to_string()],
12907 &policy,
12908 &changed,
12909 &control,
12910 )?;
12911 require_eq(
12912 &resolved_targets(&incremental),
12913 &BTreeSet::from([
12914 ("worker.js".to_string(), "work".to_string()),
12915 ("other.js".to_string(), "other".to_string()),
12916 ]),
12917 "unchanged caller after forwarding source change",
12918 )?;
12919 require(
12920 incremental.source_recovery.additional_candidates > 0,
12921 "extra unchanged context source parsing was not accounted",
12922 )?;
12923 publish_full_staged_graph(
12924 &mut store,
12925 &nodes,
12926 &incremental,
12927 &control,
12928 "forwarding changed intermediate",
12929 )?;
12930 store.replace_symbol_graph(&make_parsed("inner.js", content)?.graph)?;
12931 let base = store
12932 .repository_graph_generation()?
12933 .ok_or("missing changed generation")?;
12934 let content = "export {work as cycle} from './worker.js';";
12935 fs::write(root.join("loopB.js"), content)?;
12936 capture_test_file_hashes(root, &mut nodes)?;
12937 let mut changed = empty_symbol_build_stage();
12938 changed
12939 .changes
12940 .push(SymbolProjectionChange::Parsed(make_parsed(
12941 "loopB.js", content,
12942 )?));
12943 changed.identity_admission = super::admit_symbol_build_stage(&mut changed, &control)?;
12944 let repaired = stage_incremental_repository_graph(
12945 &store,
12946 root,
12947 base,
12948 &nodes,
12949 &["loopB.js".to_string()],
12950 &policy,
12951 &changed,
12952 &control,
12953 )?;
12954 require(
12955 matches!(&repaired.mutation, RepositoryGraphMutation::AffectedPaths(paths) if paths.iter().any(|path| path == "second.js")),
12956 "cached cycle suffix lost the file whose edit breaks the cycle",
12957 )?;
12958 require_eq(
12959 &unresolved_calls(&repaired),
12960 &3,
12961 "broken cycle did not repair both unchanged consumers",
12962 )?;
12963 Ok(())
12964 }
12965
12966 #[test]
12967 fn forwarding_query_cache_preserves_the_remaining_depth_budget() -> Result<(), Box<dyn Error>> {
12968 let temp = tempfile::tempdir()?;
12969 let root = temp.path();
12970 let mut files = vec![(
12971 "worker.js".to_string(),
12972 "export function work() {}".to_string(),
12973 )];
12974 for index in 0..70 {
12975 files.push((
12976 format!("long{index:02}.js"),
12977 if index == 69 {
12978 "export {work as entry} from './worker.js';".to_string()
12979 } else {
12980 format!("export {{entry}} from './long{:02}.js';", index + 1)
12981 },
12982 ));
12983 }
12984 files.push(("caller.js".to_string(), "import {entry as short} from './long10.js'; import {entry as long} from './long00.js'; function short_call() {short();} function long_call() {long();}".to_string()));
12985 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12986 let mut nodes = Vec::new();
12987 let mut symbols = empty_symbol_build_stage();
12988 for (path, content) in files {
12989 fs::write(root.join(&path), &content)?;
12990 nodes.push(test_file_node(&path, "javascript"));
12991 let (_, graph, semantics) =
12992 projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
12993 &path,
12994 Some("javascript"),
12995 &content,
12996 &control,
12997 )?
12998 .into_parts();
12999 symbols
13000 .changes
13001 .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
13002 path,
13003 source_parser: graph.parser,
13004 graph,
13005 source_semantics: Some(Box::new(semantics)),
13006 markdown_facts: None,
13007 summary: "bounded forwarding source".to_string(),
13008 summary_is_structural: false,
13009 purpose_suggestion: None,
13010 }));
13011 }
13012 capture_test_file_hashes(root, &mut nodes)?;
13013 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
13014 fs::create_dir_all(root.join(".projectatlas"))?;
13015 let store =
13016 AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
13017 let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
13018 let full = stage_full_repository_graph(
13019 &store,
13020 root,
13021 IndexGeneration::ZERO,
13022 &nodes,
13023 &policy,
13024 &symbols,
13025 &control,
13026 )?;
13027 let owners = full
13028 .entities
13029 .iter()
13030 .map(|entity| (entity.key().digest(), entity))
13031 .collect::<BTreeMap<_, _>>();
13032 let mut outcomes = BTreeMap::new();
13033 for relation in &full.relations {
13034 if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
13035 continue;
13036 }
13037 let owner = owners
13038 .get(relation.source().digest())
13039 .ok_or("missing depth fixture owner")?;
13040 let EntitySelector::Symbol { symbol } = owner.selector() else {
13041 return Err("depth call lost symbol owner".into());
13042 };
13043 outcomes.insert(symbol.name.as_str(), relation.resolution());
13044 }
13045 require(
13046 matches!(
13047 outcomes.get("short_call"),
13048 Some(RelationResolution::Resolved { .. })
13049 ),
13050 "supported shorter chain did not resolve",
13051 )?;
13052 require(
13053 matches!(
13054 outcomes.get("long_call"),
13055 Some(RelationResolution::Unresolved { .. })
13056 ),
13057 "cached target bypassed the longer query's depth bound",
13058 )?;
13059 require_eq(
13060 &full.source_recovery.previously_unchanged,
13061 &0,
13062 "forwarding parsed fresh targets again",
13063 )?;
13064 Ok(())
13065 }
13066
13067 #[test]
13068 fn no_forwarding_preflight_preserves_fast_return_and_observes_cancellation()
13069 -> Result<(), Box<dyn Error>> {
13070 let temp = tempfile::tempdir()?;
13071 let root = temp.path();
13072 let content = "function local() {} function caller() {local();}";
13073 fs::write(root.join("caller.js"), content)?;
13074 let mut nodes = vec![test_file_node("caller.js", "javascript")];
13075 capture_test_file_hashes(root, &mut nodes)?;
13076 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13077 let (_, graph, facts) =
13078 projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
13079 "caller.js",
13080 Some("javascript"),
13081 content,
13082 &control,
13083 )?
13084 .into_parts();
13085 let source = super::SourceGraph::Staged {
13086 graph: &graph,
13087 semantics: Some(&facts),
13088 };
13089 fs::create_dir_all(root.join(".projectatlas"))?;
13090 let store =
13091 AtlasStore::open_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
13092 let project = store
13093 .project_instance_id()?
13094 .ok_or("missing preflight project")?;
13095 let packages = PackageIndex::from_graphs(&[&graph])?;
13096 let configured = projectatlas_symbols::ConfiguredModuleResolution::default();
13097 let entities = super::build_entity_projection_with_config(
13098 project,
13099 IndexGeneration::new(1),
13100 &nodes,
13101 &[&graph],
13102 &packages,
13103 &configured,
13104 None,
13105 true,
13106 &control,
13107 )?;
13108 let mut candidates = super::resolution_registry_from_exports(&entities, &control)?;
13109 let mut cache = super::SourceBindingCache::new(root, &nodes, None);
13110 let (bindings, bytes) = super::binding_resolution::resolve_source_bindings(
13111 &store,
13112 project,
13113 IndexGeneration::new(1),
13114 &[&source],
13115 &packages,
13116 &configured,
13117 &mut cache,
13118 &entities,
13119 &mut candidates,
13120 None,
13121 &BTreeSet::new(),
13122 &control,
13123 super::super::MAX_PUBLICATION_STAGING_BYTES,
13124 )?;
13125 require(
13126 bindings.is_empty() && bytes == 0,
13127 "ordinary calls allocated forwarding state",
13128 )?;
13129 let cancellation = IndexCancellation::new();
13130 cancellation.cancel();
13131 let canceled = IndexWorkControl::new(cancellation, None);
13132 let result = super::binding_resolution::resolve_source_bindings(
13133 &store,
13134 project,
13135 IndexGeneration::new(1),
13136 &[&source],
13137 &packages,
13138 &configured,
13139 &mut cache,
13140 &entities,
13141 &mut candidates,
13142 None,
13143 &BTreeSet::new(),
13144 &canceled,
13145 super::super::MAX_PUBLICATION_STAGING_BYTES,
13146 );
13147 require(
13148 matches!(
13149 result,
13150 Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
13151 ),
13152 "no-forwarding preflight ignored cancellation",
13153 )?;
13154 Ok(())
13155 }
13156
13157 #[test]
13158 fn forwarding_file_markers_never_become_or_narrow_callable_targets()
13159 -> Result<(), Box<dyn Error>> {
13160 let project = ProjectInstanceId::from_bytes([46; 16])?;
13161 let generation = IndexGeneration::new(1);
13162 let key = test_resolution_key(project, "forwarding-slot")?;
13163 let file = GraphEntity::new(
13164 project,
13165 EntitySelector::File {
13166 path: RepositoryFilePath::new(Path::new("src/barrel.ts"))?,
13167 },
13168 generation,
13169 )?;
13170 let declaration = test_symbol_entity(project, generation, "src/direct.ts", "work")?;
13171 let mut registry = ProjectResolutionRegistry::default();
13172 registry.insert_candidate(&key, &file)?;
13173 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13174 let staged = BTreeMap::new();
13175 let calls = registry_resolution_matches(
13176 std::slice::from_ref(&key),
13177 super::ResolutionCandidateKind::Declaration,
13178 ®istry,
13179 &staged,
13180 &control,
13181 )?;
13182 require(
13183 calls.count == 0 && calls.first.is_none(),
13184 "file marker became a callable target",
13185 )?;
13186 registry.insert_candidate(&key, &declaration)?;
13187 let calls = registry_resolution_matches(
13188 std::slice::from_ref(&key),
13189 super::ResolutionCandidateKind::Declaration,
13190 ®istry,
13191 &staged,
13192 &control,
13193 )?;
13194 require(
13195 calls.count == 0 && calls.first.is_none(),
13196 "dropping a competing forwarding marker narrowed an unrelated declaration",
13197 )?;
13198 let ordinary = registry_resolution_matches(
13199 std::slice::from_ref(&key),
13200 super::ResolutionCandidateKind::Any,
13201 ®istry,
13202 &staged,
13203 &control,
13204 )?;
13205 require_eq(&ordinary.count, &2, "non-call lookup lost file evidence")?;
13206 Ok(())
13207 }
13208
13209 #[test]
13210 fn registry_candidate_merge_deduplicates_exactly_and_observes_cancellation()
13211 -> Result<(), Box<dyn Error>> {
13212 let project = ProjectInstanceId::from_bytes([6; 16])?;
13213 let generation = IndexGeneration::new(1);
13214 let first_key = test_resolution_key(project, "first-key")?;
13215 let second_key = test_resolution_key(project, "second-key")?;
13216 let first = test_symbol_entity(project, generation, "src/first.rs", "first")?;
13217 let second = test_symbol_entity(project, generation, "src/second.rs", "second")?;
13218 let mut registry = ProjectResolutionRegistry::default();
13219 registry.insert_candidate(&first_key, &first)?;
13220 registry.insert_candidate(&second_key, &first)?;
13221 registry.insert_candidate(&second_key, &second)?;
13222 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13223 let staged_entities = BTreeMap::new();
13224 let matches = registry_resolution_matches(
13225 &[first_key.clone(), second_key.clone()],
13226 super::ResolutionCandidateKind::Any,
13227 ®istry,
13228 &staged_entities,
13229 &control,
13230 )?;
13231 require_eq(&matches.count, &2, "distinct merged candidate count")?;
13232 let expected_first = [&first, &second]
13233 .into_iter()
13234 .min_by_key(|entity| entity.key().digest())
13235 .ok_or("candidate fixture is empty")?;
13236 require_eq(
13237 &matches.first.map(|entity| entity.key().digest()),
13238 &Some(expected_first.key().digest()),
13239 "stable first candidate",
13240 )?;
13241
13242 let cancellation = IndexCancellation::new();
13243 let canceled_control = IndexWorkControl::new(cancellation.clone(), None);
13244 cancellation.cancel();
13245 require(
13246 registry_resolution_matches(
13247 &[first_key, second_key],
13248 super::ResolutionCandidateKind::Any,
13249 ®istry,
13250 &staged_entities,
13251 &canceled_control,
13252 )
13253 .is_err(),
13254 "candidate merge ignored cancellation",
13255 )?;
13256 Ok(())
13257 }
13258
13259 #[test]
13260 fn duplicate_ambiguous_relations_keep_the_largest_candidate_count() -> Result<(), Box<dyn Error>>
13261 {
13262 let project = ProjectInstanceId::from_bytes([15; 16])?;
13263 let generation = IndexGeneration::new(1);
13264 let source = test_file_entity(project, generation, "src/duplicate.ts")?;
13265 let relation = |candidates| -> Result<LogicalRelation, Box<dyn Error>> {
13266 Ok(LogicalRelation::new(
13267 &source,
13268 GraphRelationKind::from_legacy(RelationKind::Contains),
13269 RelationResolution::Ambiguous {
13270 reference: GraphIdentityText::new("declarations")?,
13271 candidates: NonZeroU32::new(candidates).ok_or("candidate count was zero")?,
13272 },
13273 ConfidenceClass::Exact,
13274 Completeness::Complete,
13275 generation,
13276 )?)
13277 };
13278 let mut relations = BTreeMap::new();
13279 insert_relation(
13280 &mut relations,
13281 relation(2)?,
13282 "src/duplicate.ts",
13283 "logical",
13284 0,
13285 )?;
13286 insert_relation(
13287 &mut relations,
13288 relation(3)?,
13289 "src/duplicate.ts",
13290 "logical",
13291 1,
13292 )?;
13293 insert_relation(
13294 &mut relations,
13295 relation(2)?,
13296 "src/duplicate.ts",
13297 "logical",
13298 2,
13299 )?;
13300 let retained = relations
13301 .into_values()
13302 .next()
13303 .ok_or("deduplicated relation was not retained")?;
13304 require(
13305 matches!(
13306 retained.resolution(),
13307 RelationResolution::Ambiguous { candidates, .. } if candidates.get() == 3
13308 ),
13309 "deduplicated ambiguity did not retain the largest candidate count",
13310 )?;
13311
13312 let mut conflicting = BTreeMap::new();
13313 insert_relation(
13314 &mut conflicting,
13315 relation(2)?,
13316 "src/duplicate.ts",
13317 "logical",
13318 0,
13319 )?;
13320 let different_confidence = LogicalRelation::new(
13321 &source,
13322 GraphRelationKind::from_legacy(RelationKind::Contains),
13323 RelationResolution::Ambiguous {
13324 reference: GraphIdentityText::new("declarations")?,
13325 candidates: NonZeroU32::new(2).ok_or("candidate count was zero")?,
13326 },
13327 ConfidenceClass::High,
13328 Completeness::Complete,
13329 generation,
13330 )?;
13331 require(
13332 insert_relation(
13333 &mut conflicting,
13334 different_confidence,
13335 "src/duplicate.ts",
13336 "logical",
13337 1,
13338 )
13339 .is_err(),
13340 "a non-ambiguity conflict was merged",
13341 )?;
13342 Ok(())
13343 }
13344
13345 #[test]
13346 fn same_file_private_calls_resolve_and_duplicate_declarations_stay_ambiguous()
13347 -> Result<(), Box<dyn Error>> {
13348 let project = ProjectInstanceId::from_bytes([5; 16])?;
13349 let generation = IndexGeneration::new(1);
13350 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13351 let private_graph = extract_symbol_graph(
13352 "src/private.rs",
13353 Some("rust"),
13354 "pub fn caller() { helper(); }\nfn helper() {}\n",
13355 );
13356 require(
13357 private_graph
13358 .symbols
13359 .iter()
13360 .any(|symbol| symbol.name == "helper" && !symbol.exported),
13361 "fixture helper was not private",
13362 )?;
13363 let packages = PackageIndex::from_graphs(std::slice::from_ref(&private_graph))?;
13364 let projection = build_entity_projection(
13365 project,
13366 generation,
13367 &[],
13368 std::slice::from_ref(&private_graph),
13369 &packages,
13370 true,
13371 &control,
13372 )?;
13373 let candidates = resolution_registry_from_exports(&projection, &control)?;
13374 let staged = finish_projection(
13375 project,
13376 generation,
13377 RepositoryGraphMutation::Full,
13378 std::slice::from_ref(&private_graph),
13379 projection,
13380 &candidates,
13381 &control,
13382 )?;
13383 let helper_call = staged
13384 .relations
13385 .iter()
13386 .find(|relation| {
13387 matches!(
13388 relation.resolution(),
13389 RelationResolution::Resolved {
13390 selector: ReusableTargetSelector::Symbol { symbol },
13391 ..
13392 } if symbol.name.as_str() == "helper"
13393 && symbol.file.as_str() == "src/private.rs"
13394 )
13395 })
13396 .ok_or("private same-file helper call did not resolve")?;
13397 require_eq(
13398 &helper_call.kind(),
13399 &GraphRelationKind::from_legacy(RelationKind::Calls),
13400 "private same-file relation kind",
13401 )?;
13402
13403 let duplicate_graph = SymbolGraph {
13404 path: "src/duplicate.rs".to_string(),
13405 language: Some("rust".to_string()),
13406 parser: ParserKind::TreeSitter,
13407 symbols: vec![
13408 test_code_symbol("src/duplicate.rs", "caller", Some("Owner"), "fn caller()"),
13409 test_code_symbol(
13410 "src/duplicate.rs",
13411 "helper",
13412 Some("Owner"),
13413 "fn helper(first: u8)",
13414 ),
13415 test_code_symbol(
13416 "src/duplicate.rs",
13417 "helper",
13418 Some("Owner"),
13419 "fn helper(second: u16)",
13420 ),
13421 test_code_symbol(
13422 "src/duplicate.rs",
13423 "helper",
13424 Some("Unrelated"),
13425 "fn helper()",
13426 ),
13427 ],
13428 relations: vec![SymbolRelation {
13429 path: "src/duplicate.rs".to_string(),
13430 source_name: "caller".to_string(),
13431 target_name: "helper".to_string(),
13432 kind: RelationKind::Calls,
13433 line: 1,
13434 context: "helper()".to_string(),
13435 parser: ParserKind::TreeSitter,
13436 }],
13437 };
13438 let packages = PackageIndex::from_graphs(std::slice::from_ref(&duplicate_graph))?;
13439 let projection = build_entity_projection(
13440 project,
13441 generation,
13442 &[],
13443 std::slice::from_ref(&duplicate_graph),
13444 &packages,
13445 true,
13446 &control,
13447 )?;
13448 let candidates = resolution_registry_from_exports(&projection, &control)?;
13449 let staged = finish_projection(
13450 project,
13451 generation,
13452 RepositoryGraphMutation::Full,
13453 std::slice::from_ref(&duplicate_graph),
13454 projection,
13455 &candidates,
13456 &control,
13457 )?;
13458 require(
13459 staged.relations.iter().any(|relation| {
13460 matches!(
13461 relation.resolution(),
13462 RelationResolution::Ambiguous {
13463 reference,
13464 candidates,
13465 } if reference.as_str() == "helper" && candidates.get() == 2
13466 )
13467 }),
13468 "duplicate same-file declarations did not remain ambiguous",
13469 )?;
13470 Ok(())
13471 }
13472
13473 #[test]
13474 fn php_scoped_and_case_insensitive_calls_resolve_without_member_edges()
13475 -> Result<(), Box<dyn Error>> {
13476 let project = ProjectInstanceId::from_bytes([31; 16])?;
13477 let generation = IndexGeneration::new(1);
13478 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13479 let php_graph = extract_symbol_graph(
13480 "src/service.php",
13481 Some("php"),
13482 r"<?php
13483class Service {
13484 public string $boot;
13485 public static function prepare(): void {}
13486 public static function finish(): void {}
13487 public static function boot(): void {}
13488 public function run(): void {
13489 self::prepare();
13490 static::finish();
13491 Service::boot();
13492 SERVICE::BOOT();
13493 parent::inherited();
13494 $this->boot();
13495 }
13496}
13497function helper(): void {}
13498function caller(): void { HELPER(); }
13499namespace Atlas\Domain;
13500class QualifiedService {
13501 public static function boot(): void {}
13502 public static function qualified_run(): void {
13503 \Atlas\Domain\QualifiedService::boot();
13504 \Missing\Domain\QualifiedService::boot();
13505 QualifiedService::boot();
13506 }
13507}
13508namespace Other\Domain;
13509class QualifiedService {
13510 public static function boot(): void {}
13511}
13512function relative_run(): void {
13513 Atlas\Domain\QualifiedService::boot();
13514 Alias\QualifiedService::boot();
13515 namespace\QualifiedService::boot();
13516}
13517namespace TopLevel;
13518function top_level_helper(): void {}
13519top_level_helper();
13520namespace Foo;
13521function helper(): void {}
13522function qualified_helper_run(): void {
13523 \Foo\helper();
13524}
13525function rooted_global_helper_run(): void {
13526 \helper();
13527}
13528function missing_helper_run(): void {
13529 \Missing\helper();
13530}
13531class NamespacedService {
13532 public function namespaced_run(): void {
13533 helper();
13534 }
13535}
13536class DuplicateA {
13537 public static function prepare(): void {}
13538 public static function collision_run(): void {
13539 self::prepare();
13540 }
13541}
13542class DuplicateB {
13543 public static function prepare(): void {}
13544 public static function collision_run(): void {
13545 self::prepare();
13546 }
13547}
13548",
13549 );
13550 require(
13551 php_graph
13552 .relations
13553 .iter()
13554 .filter(|relation| relation.kind == RelationKind::Calls)
13555 .all(|relation| relation.target_name != "boot"),
13556 "dynamic member calls must not become unscoped PHP calls",
13557 )?;
13558 let finish_graph = |graph: &SymbolGraph| {
13559 let packages = PackageIndex::from_graphs(std::slice::from_ref(graph))?;
13560 let projection = build_entity_projection(
13561 project,
13562 generation,
13563 &[],
13564 std::slice::from_ref(graph),
13565 &packages,
13566 true,
13567 &control,
13568 )?;
13569 let candidates = resolution_registry_from_exports(&projection, &control)?;
13570 finish_projection(
13571 project,
13572 generation,
13573 RepositoryGraphMutation::Full,
13574 std::slice::from_ref(graph),
13575 projection,
13576 &candidates,
13577 &control,
13578 )
13579 };
13580 let staged = finish_graph(&php_graph)?;
13581 for declaration in [
13582 "class Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
13583 "interface Service {\nconst FLAG = 1;\nfunction run();\n}",
13584 "trait Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
13585 "enum Service {\ncase Ready;\nconst FLAG = 1;\nfunction run() {}\n}",
13586 ] {
13587 for (source, namespaces) in [
13588 (
13589 format!(
13590 "<?php namespace A {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
13591 ),
13592 ["A", "B"],
13593 ),
13594 (
13595 format!("<?php namespace A;\n{declaration}\nnamespace B;\n{declaration}"),
13596 ["A", "B"],
13597 ),
13598 (
13599 format!(
13600 "<?php namespace Service {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
13601 ),
13602 ["Service", "B"],
13603 ),
13604 (
13605 format!("<?php namespace Service;\n{declaration}\nnamespace B;\n{declaration}"),
13606 ["Service", "B"],
13607 ),
13608 ] {
13609 let graph = extract_symbol_graph("src/containment.php", Some("php"), &source);
13610 require_eq(
13611 &graph
13612 .symbols
13613 .iter()
13614 .filter(|symbol| {
13615 symbol.name == "run" && symbol.parent.as_deref() == Some("Service")
13616 })
13617 .count(),
13618 &2,
13619 "PHP parser retains unqualified member parents",
13620 )?;
13621 let contained = finish_graph(&graph)?;
13622 for namespace in namespaces {
13623 let parent = format!("{namespace}::Service");
13624 for name in ["run", "FLAG"] {
13625 require(
13626 contained.relations.iter().any(|relation| {
13627 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13628 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
13629 if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent.as_str()))
13630 && contained.entities.iter().any(|entity| entity.key() == relation.source()
13631 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
13632 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace)))
13633 }),
13634 &format!("PHP containment must keep its exact namespace owner: {parent}::{name}"),
13635 )?;
13636 }
13637 require(
13638 contained.relations.iter().any(|relation| {
13639 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13640 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
13641 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace))
13642 && contained.entities.iter().any(|entity| entity.key() == relation.source()
13643 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == namespace))
13644 }),
13645 "PHP namespace containment retains its declared type",
13646 )?;
13647 }
13648 }
13649 }
13650 for declaration in [
13651 "class N { function caller() { helper(); } }",
13652 "trait N { function caller() { helper(); } }",
13653 "enum N { case Ready; function caller() { helper(); } }",
13654 ] {
13655 for source in [
13656 format!(
13657 "<?php namespace N {{ function helper() {{}} }}\nnamespace M {{ function helper() {{}} {declaration} }}"
13658 ),
13659 format!(
13660 "<?php namespace M {{ function helper() {{}} {declaration} }}\nnamespace N {{ function helper() {{}} }}"
13661 ),
13662 format!(
13663 "<?php namespace N; function helper() {{}}\nnamespace M; function helper() {{}} {declaration}"
13664 ),
13665 format!(
13666 "<?php namespace M; function helper() {{}} {declaration}\nnamespace N; function helper() {{}}"
13667 ),
13668 ] {
13669 let graph = extract_symbol_graph("src/type-namespace.php", Some("php"), &source);
13670 let projected = finish_graph(&graph)?;
13671 let calls = projected
13672 .relations
13673 .iter()
13674 .filter(|relation| {
13675 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
13676 })
13677 .collect::<Vec<_>>();
13678 require_eq(
13679 &calls.len(),
13680 &1,
13681 "PHP type/namespace collision retains its call",
13682 )?;
13683 require(
13684 matches!(calls[0].resolution(), RelationResolution::Resolved {
13685 selector: ReusableTargetSelector::Symbol { symbol }, ..
13686 } if symbol.name.as_str() == "helper"
13687 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("M")),
13688 "PHP containing type must retain its namespace despite a same-named module",
13689 )?;
13690 }
13691 }
13692 let overlapping = extract_symbol_graph(
13693 "src/containment.php",
13694 Some("php"),
13695 "<?php namespace A { class Service { function run() {} } } namespace B { class Service { function run() {} } }",
13696 );
13697 let contained = finish_graph(&overlapping)?;
13698 require(
13699 contained.relations.iter().any(|relation| {
13700 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13701 && !matches!(relation.resolution(), RelationResolution::Resolved { .. })
13702 }),
13703 "PHP overlapping line-only owners remain unresolved",
13704 )?;
13705 require(
13706 !contained.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
13707 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "run")),
13708 "PHP overlapping line-only owners must not invent member containment",
13709 )?;
13710 for source in [
13711 "<?php namespace Service { function boot() {} } namespace { class Service {} Service::boot(); }",
13712 "<?php namespace Service {} namespace { class Service { static function boot() {} } \\Service\\boot(); }",
13713 ] {
13714 let graph = extract_symbol_graph("src/call-kind.php", Some("php"), source);
13715 let staged = finish_graph(&graph)?;
13716 let calls: Vec<_> = staged
13717 .relations
13718 .iter()
13719 .filter(|relation| {
13720 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
13721 })
13722 .collect();
13723 require_eq(&calls.len(), &1, "PHP callable-kind collision fixture")?;
13724 require(
13725 matches!(calls[0].resolution(), RelationResolution::Unresolved { .. }),
13726 "PHP calls must not resolve across function and method kinds",
13727 )?;
13728 }
13729 for (source, parent) in [
13730 (
13731 "<?php namespace N; function outer() { function inner() {} } function caller() { inner(); }",
13732 Some("N"),
13733 ),
13734 (
13735 "<?php namespace N { class OuterType { function outer() { function inner() {} } } function caller() { inner(); } }",
13736 Some("N"),
13737 ),
13738 (
13739 "<?php function outer() { function inner() {} } function caller() { inner(); }",
13740 None,
13741 ),
13742 ] {
13743 let graph = extract_symbol_graph("src/nested-call.php", Some("php"), source);
13744 let staged = finish_graph(&graph)?;
13745 require(staged.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls) && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "inner" && symbol.kind == SymbolKind::Function && symbol.parent.as_ref().map(GraphIdentityText::as_str) == parent)), &format!("nested PHP function identity must resolve in its namespace: {source}"))?;
13746 }
13747 let calls = staged
13748 .relations
13749 .iter()
13750 .filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
13751 .collect::<Vec<_>>();
13752 let staged_call = |target: &str, source: &str| {
13753 let parser_relation = php_graph.relations.iter().find(|relation| {
13754 relation.kind == RelationKind::Calls
13755 && relation.target_name == target
13756 && relation.source_name == source
13757 })?;
13758 let line = u32::try_from(parser_relation.line).ok()?;
13759 staged
13760 .occurrences
13761 .iter()
13762 .filter(|occurrence| occurrence.span().start_line() == line)
13763 .filter_map(|occurrence| {
13764 staged
13765 .relations
13766 .iter()
13767 .find(|relation| relation.key() == occurrence.relation())
13768 })
13769 .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
13770 };
13771 require(
13772 matches!(
13773 staged_call("\\Atlas\\Domain\\QualifiedService::boot", "qualified_run")
13774 .map(projectatlas_core::graph::LogicalRelation::resolution),
13775 Some(RelationResolution::Resolved {
13776 selector: ReusableTargetSelector::Symbol { symbol },
13777 ..
13778 }) if symbol.name.as_str() == "boot"
13779 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13780 == Some("Atlas\\Domain::QualifiedService")
13781 ),
13782 "fully qualified PHP call did not resolve to the local class method",
13783 )?;
13784 require(
13785 matches!(
13786 staged_call("\\Missing\\Domain\\QualifiedService::boot", "qualified_run")
13787 .map(projectatlas_core::graph::LogicalRelation::resolution),
13788 Some(RelationResolution::Unresolved { reference })
13789 if reference.as_str() == "\\Missing\\Domain\\QualifiedService::boot"
13790 ),
13791 "qualified PHP call with an invalid namespace must remain unresolved",
13792 )?;
13793 require(
13794 matches!(
13795 staged_call("QualifiedService::boot", "qualified_run")
13796 .map(projectatlas_core::graph::LogicalRelation::resolution),
13797 Some(RelationResolution::Resolved {
13798 selector: ReusableTargetSelector::Symbol { symbol },
13799 ..
13800 }) if symbol.name.as_str() == "boot"
13801 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13802 == Some("Atlas\\Domain::QualifiedService")
13803 ),
13804 "unqualified PHP class scope must select its caller namespace",
13805 )?;
13806 for target in [
13807 "Atlas\\Domain\\QualifiedService::boot",
13808 "Alias\\QualifiedService::boot",
13809 ] {
13810 require(
13811 matches!(
13812 staged_call(target, "relative_run")
13813 .map(projectatlas_core::graph::LogicalRelation::resolution),
13814 Some(RelationResolution::Unresolved { reference })
13815 if reference.as_str() == target
13816 ),
13817 &format!("non-leading qualified PHP scope must remain unresolved: {target}"),
13818 )?;
13819 }
13820 require(
13821 matches!(
13822 staged_call("namespace\\QualifiedService::boot", "relative_run")
13823 .map(projectatlas_core::graph::LogicalRelation::resolution),
13824 Some(RelationResolution::Resolved {
13825 selector: ReusableTargetSelector::Symbol { symbol },
13826 ..
13827 }) if symbol.parent.as_ref().map(GraphIdentityText::as_str)
13828 == Some("Other\\Domain::QualifiedService")
13829 ),
13830 "explicit namespace-relative PHP static scope must resolve in its caller namespace",
13831 )?;
13832 require(
13833 matches!(
13834 staged_call("top_level_helper", "TopLevel")
13835 .map(projectatlas_core::graph::LogicalRelation::resolution),
13836 Some(RelationResolution::Resolved {
13837 selector: ReusableTargetSelector::Symbol { symbol },
13838 ..
13839 }) if symbol.name.as_str() == "top_level_helper"
13840 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
13841 == Some("TopLevel")
13842 ),
13843 "semicolon namespace top-level call did not resolve locally",
13844 )?;
13845 require(
13846 matches!(
13847 staged_call("helper", "namespaced_run")
13848 .map(projectatlas_core::graph::LogicalRelation::resolution),
13849 Some(RelationResolution::Resolved {
13850 selector: ReusableTargetSelector::Symbol { symbol },
13851 ..
13852 }) if symbol.name.as_str() == "helper"
13853 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
13854 ),
13855 "namespaced PHP method call did not prefer its namespace helper",
13856 )?;
13857 require(
13858 matches!(
13859 staged_call("\\Foo\\helper", "qualified_helper_run")
13860 .map(projectatlas_core::graph::LogicalRelation::resolution),
13861 Some(RelationResolution::Resolved {
13862 selector: ReusableTargetSelector::Symbol { symbol },
13863 ..
13864 }) if symbol.name.as_str() == "helper"
13865 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
13866 ),
13867 "fully qualified PHP function call did not resolve to its namespace function",
13868 )?;
13869 require(
13870 matches!(
13871 staged_call("\\helper", "rooted_global_helper_run")
13872 .map(projectatlas_core::graph::LogicalRelation::resolution),
13873 Some(RelationResolution::Resolved {
13874 selector: ReusableTargetSelector::Symbol { symbol },
13875 ..
13876 }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
13877 ),
13878 "rooted PHP global function call did not resolve explicitly to the global helper",
13879 )?;
13880 require(
13881 matches!(
13882 staged_call("\\Missing\\helper", "missing_helper_run")
13883 .map(projectatlas_core::graph::LogicalRelation::resolution),
13884 Some(RelationResolution::Unresolved { reference })
13885 if reference.as_str() == "\\Missing\\helper"
13886 ),
13887 "fully qualified PHP function call must not fall back to a global helper",
13888 )?;
13889 let duplicate_method_calls = php_graph
13890 .relations
13891 .iter()
13892 .filter(|relation| {
13893 relation.kind == RelationKind::Calls
13894 && relation.target_name == "self::prepare"
13895 && relation.source_name == "collision_run"
13896 })
13897 .count();
13898 require_eq(
13899 &duplicate_method_calls,
13900 &2,
13901 "duplicate PHP method fixture call count",
13902 )?;
13903 let duplicate_method_parents = calls
13904 .iter()
13905 .filter_map(|relation| match relation.resolution() {
13906 RelationResolution::Resolved {
13907 selector: ReusableTargetSelector::Symbol { symbol },
13908 ..
13909 } if symbol.name.as_str() == "prepare" => {
13910 symbol.parent.as_ref().map(GraphIdentityText::as_str)
13911 }
13912 _ => None,
13913 })
13914 .collect::<BTreeSet<_>>();
13915 require_eq(
13916 &duplicate_method_parents,
13917 &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB", "Service"]),
13918 "PHP method callers retain their distinct source owners",
13919 )?;
13920 let duplicate_sources = calls
13921 .iter()
13922 .filter_map(|relation| {
13923 let entity = staged
13924 .entities
13925 .iter()
13926 .find(|entity| entity.key() == relation.source())?;
13927 match entity.selector() {
13928 EntitySelector::Symbol { symbol }
13929 if symbol.name.as_str() == "collision_run" =>
13930 {
13931 symbol.parent.as_ref().map(GraphIdentityText::as_str)
13932 }
13933 _ => None,
13934 }
13935 })
13936 .collect::<BTreeSet<_>>();
13937 require_eq(
13938 &duplicate_sources,
13939 &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB"]),
13940 "duplicate PHP calls must originate from their actual method entities",
13941 )?;
13942 let namespace_call = staged_call("top_level_helper", "TopLevel");
13943 require(namespace_call.is_some_and(|relation| staged.entities.iter().any(|entity| entity.key() == relation.source() && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "TopLevel"))), "semicolon PHP namespace retains top-level call ownership")?;
13944 for (source, name, parent) in [
13945 (
13946 "<?php class caller { function caller() { helper(); } } function helper() {}",
13947 "caller",
13948 "caller",
13949 ),
13950 (
13951 "<?php\nnamespace Foo;\nfunction Foo() {\nhelper();\n}\nfunction helper() {}",
13952 "Foo",
13953 "Foo",
13954 ),
13955 ] {
13956 let graph = extract_symbol_graph("src/source-collision.php", Some("php"), source);
13957 let staged = finish_graph(&graph)?;
13958 require(staged.relations.iter().filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)).any(|relation| staged.entities.iter().any(|entity| entity.key() == relation.source() && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent)))), "PHP callable source must not be hidden by a same-name class or module")?;
13959 }
13960 let trait_graph = extract_symbol_graph(
13961 "src/trait.php",
13962 Some("php"),
13963 "<?php trait T { function run() { self::target(); } function target() {} } class C { use T; function target() {} }",
13964 );
13965 let trait_staged = finish_graph(&trait_graph)?;
13966 require(trait_staged.relations.iter().any(|relation| matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "self::target")), "PHP trait self calls depend on the consuming class")?;
13967 let enum_graph = extract_symbol_graph(
13968 "src/enum.php",
13969 Some("php"),
13970 r"<?php
13971namespace {
13972function helper(): void {}
13973}
13974namespace Foo {
13975function helper(): void {}
13976enum NamespacedState {
13977 case Ready;
13978 public function enum_run(): void {
13979 helper();
13980 }
13981}
13982}
13983namespace Bar {
13984function fallback_run(): void {
13985 helper();
13986}
13987}
13988",
13989 );
13990 let enum_staged = finish_graph(&enum_graph)?;
13991 let enum_call = |target: &str, source: &str| {
13992 let parser_relation = enum_graph.relations.iter().find(|relation| {
13993 relation.target_name == target && relation.source_name == source
13994 })?;
13995 let line = u32::try_from(parser_relation.line).ok()?;
13996 let occurrence = enum_staged
13997 .occurrences
13998 .iter()
13999 .find(|occurrence| occurrence.span().start_line() == line)?;
14000 enum_staged
14001 .relations
14002 .iter()
14003 .find(|relation| relation.key() == occurrence.relation())
14004 };
14005 require(
14006 matches!(
14007 enum_call("helper", "enum_run")
14008 .map(projectatlas_core::graph::LogicalRelation::resolution),
14009 Some(RelationResolution::Resolved {
14010 selector: ReusableTargetSelector::Symbol { symbol },
14011 ..
14012 }) if symbol.name.as_str() == "helper"
14013 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
14014 ),
14015 "namespaced PHP enum method call did not prefer its namespace helper",
14016 )?;
14017 require(
14018 matches!(
14019 enum_call("helper", "fallback_run")
14020 .map(projectatlas_core::graph::LogicalRelation::resolution),
14021 Some(RelationResolution::Resolved {
14022 selector: ReusableTargetSelector::Symbol { symbol },
14023 ..
14024 }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
14025 ),
14026 "namespaced PHP function call did not fall back to the global helper",
14027 )?;
14028 let mixed_call_line = u32::try_from(
14029 php_graph
14030 .relations
14031 .iter()
14032 .find(|relation| relation.target_name == "SERVICE::BOOT")
14033 .ok_or("mixed-case PHP call fixture was missing")?
14034 .line,
14035 )?;
14036 let mixed_call_occurrence = staged
14037 .occurrences
14038 .iter()
14039 .find(|occurrence| occurrence.span().start_line() == mixed_call_line)
14040 .ok_or("mixed-case PHP call occurrence was missing")?;
14041 let mixed_call_relation = staged
14042 .relations
14043 .iter()
14044 .find(|relation| relation.key() == mixed_call_occurrence.relation())
14045 .ok_or("mixed-case PHP logical call was missing")?;
14046 require(
14047 matches!(
14048 mixed_call_relation.resolution(),
14049 RelationResolution::Resolved {
14050 selector: ReusableTargetSelector::Symbol { symbol },
14051 ..
14052 } if symbol.name.as_str() == "boot"
14053 && symbol.kind == SymbolKind::Method
14054 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Service")
14055 ),
14056 "mixed-case scoped PHP call did not resolve to Service::boot method",
14057 )?;
14058 for (name, parent) in [
14059 ("prepare", "Service"),
14060 ("boot", "Service"),
14061 ("helper", "<module>"),
14062 ] {
14063 require(
14064 calls.iter().any(|relation| {
14065 matches!(
14066 relation.resolution(),
14067 RelationResolution::Resolved {
14068 selector: ReusableTargetSelector::Symbol { symbol },
14069 ..
14070 } if symbol.name.as_str() == name
14071 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
14072 == (parent != "<module>").then_some(parent)
14073 )
14074 }),
14075 &format!("PHP call did not resolve to {parent}::{name}"),
14076 )?;
14077 }
14078 require(
14079 matches!(
14080 staged_call("static::finish", "run")
14081 .map(projectatlas_core::graph::LogicalRelation::resolution),
14082 Some(RelationResolution::Unresolved { reference })
14083 if reference.as_str() == "static::finish"
14084 ),
14085 "late-static PHP calls must remain unresolved without override proof",
14086 )?;
14087 require(
14088 calls.iter().any(|relation| {
14089 matches!(
14090 relation.resolution(),
14091 RelationResolution::Unresolved { reference } if reference.as_str() == "parent::inherited"
14092 )
14093 }),
14094 "parent-scoped PHP calls must remain unresolved without a proven base type",
14095 )?;
14096 require(
14097 calls.iter().all(|relation| {
14098 !matches!(
14099 relation.resolution(),
14100 RelationResolution::Resolved {
14101 selector: ReusableTargetSelector::Symbol { symbol },
14102 ..
14103 } if symbol.name.as_str() == "boot"
14104 && symbol.parent.is_none()
14105 )
14106 }),
14107 "PHP scoped calls must not resolve through an unrelated global member",
14108 )?;
14109
14110 let mut ambiguous_graph = php_graph;
14111 let mut duplicate = ambiguous_graph
14112 .symbols
14113 .iter()
14114 .find(|symbol| symbol.name == "helper")
14115 .cloned()
14116 .ok_or("PHP ambiguity fixture helper was missing")?;
14117 duplicate.name = "HELPER".to_string();
14118 ambiguous_graph.symbols.push(duplicate);
14119 let ambiguous = finish_graph(&ambiguous_graph)?;
14120 require(
14121 ambiguous.relations.iter().any(|relation| {
14122 matches!(
14123 relation.resolution(),
14124 RelationResolution::Ambiguous {
14125 reference,
14126 candidates,
14127 } if reference.as_str() == "HELPER" && candidates.get() == 2
14128 )
14129 }),
14130 "case-insensitive PHP duplicate declarations must remain ambiguous",
14131 )?;
14132
14133 let mut scope_failures = Vec::new();
14134 for (source, target, expected_parent) in [
14135 (
14136 "<?php\nclass Service { public static function boot() {} }\nService::boot();\n$callable();",
14137 "partial global static call",
14138 Some("Service"),
14139 ),
14140 (
14141 "<?php\nfunction helper() {}\nhelper();\n$callable();",
14142 "partial global function call",
14143 None,
14144 ),
14145 (
14146 "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); }",
14147 "require and local helper",
14148 None,
14149 ),
14150 (
14151 "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); }",
14152 "other include forms and local helper",
14153 None,
14154 ),
14155 (
14156 "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14157 "partial require and local helper",
14158 None,
14159 ),
14160 (
14161 "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14162 "partial include forms and local helper",
14163 None,
14164 ),
14165 (
14166 "<?php\nrequire/* comment */('bootstrap'); include_once(\"config\"); REQUIRE_ONCE # comment\n'library';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
14167 "partial commented and parenthesized includes",
14168 None,
14169 ),
14170 (
14171 "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); $callable(); }",
14172 "partial trait import and local static call",
14173 Some("Service"),
14174 ),
14175 (
14176 "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); }",
14177 "trait import and local static call",
14178 Some("Service"),
14179 ),
14180 (
14181 "<?php\nnamespace Foo;\nfunction run() { Sub\\helper(); }\nnamespace Foo\\Sub;\nfunction helper() {}",
14182 "relative qualified helper",
14183 Some("Foo\\Sub"),
14184 ),
14185 (
14186 "<?php\nnamespace { function run() { Sub\\helper(); } }\nnamespace Sub { function helper() {} }",
14187 "global qualified helper",
14188 Some("Sub"),
14189 ),
14190 (
14191 "<?php\nnamespace Foo;\nfunction run() { Sub\\Service::boot(); }\nnamespace Foo\\Sub;\nclass Service { public static function boot() {} }",
14192 "relative qualified static method",
14193 Some("Foo\\Sub::Service"),
14194 ),
14195 (
14196 "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
14197 "Service::boot",
14198 Some("Service"),
14199 ),
14200 (
14201 "<?php\nclass Service { public static function boot() {} }\nService::boot();",
14202 "Service::boot",
14203 Some("Service"),
14204 ),
14205 (
14206 "<?php\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
14207 "namespace\\helper",
14208 None,
14209 ),
14210 (
14211 "<?php\nnamespace Foo;\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
14212 "namespace\\helper",
14213 Some("Foo"),
14214 ),
14215 (
14216 "<?php\nnamespace Foo { use function Other\\helper; function run() { namespace\\helper(); } }\nnamespace Foo { function helper() {} }",
14217 "namespace\\helper",
14218 Some("Foo"),
14219 ),
14220 (
14221 "<?php\nnamespace Foo;\nfunction helper() {}\nNaMeSpAcE\\helper();",
14222 "NaMeSpAcE\\helper",
14223 Some("Foo"),
14224 ),
14225 (
14226 "<?php\nnamespace Foo;\nfunction run() { namespace\\Child\\helper(); }\nnamespace Foo\\Child;\nfunction helper() {}",
14227 "namespace\\Child\\helper",
14228 Some("Foo\\Child"),
14229 ),
14230 ] {
14231 let graph = extract_symbol_graph("src/explicit-scope.php", Some("php"), source);
14232 let staged = finish_graph(&graph)?;
14233 if !staged.relations.iter().any(|relation| {
14234 matches!(
14235 relation.resolution(),
14236 RelationResolution::Resolved {
14237 selector: ReusableTargetSelector::Symbol { symbol },
14238 ..
14239 } if symbol.parent.as_ref().map(GraphIdentityText::as_str) == expected_parent
14240 ) && relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14241 }) {
14242 scope_failures.push(target);
14243 }
14244 }
14245 require(
14246 scope_failures.is_empty(),
14247 &format!("proven PHP call scopes did not resolve: {scope_failures:?}"),
14248 )?;
14249
14250 let mut unknown_caller = extract_symbol_graph(
14251 "src/unknown-scope.php",
14252 Some("php"),
14253 "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
14254 );
14255 for relation in &mut unknown_caller.relations {
14256 if relation.kind == RelationKind::Calls {
14257 relation.source_name = "unknown_caller".to_string();
14258 }
14259 }
14260 let ambiguous_caller = extract_symbol_graph(
14261 "src/ambiguous-scope.php",
14262 Some("php"),
14263 "<?php namespace { class Service { public static function boot() {} } function run() { Service::boot(); } } namespace Foo { function run() {} }",
14264 );
14265 for graph in [&unknown_caller, &ambiguous_caller] {
14266 let staged = finish_graph(graph)?;
14267 require(
14268 staged
14269 .relations
14270 .iter()
14271 .filter(|relation| {
14272 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14273 })
14274 .all(|relation| {
14275 staged.entities.iter().any(|entity| {
14276 entity.key() == relation.source()
14277 && matches!(entity.selector(), EntitySelector::File { .. })
14278 })
14279 }),
14280 "unknown and same-line ambiguous PHP callers must retain file ownership",
14281 )?;
14282 require(
14283 staged.relations.iter().any(|relation| {
14284 matches!(
14285 relation.resolution(),
14286 RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
14287 )
14288 }),
14289 "unknown or ambiguous PHP callers must not be treated as proven global functions",
14290 )?;
14291 }
14292 for source in [
14293 "<?php namespace Foo; function Foo() {} Foo();",
14294 "<?php namespace Foo; function Foo() { helper(); } function helper() {}",
14295 ] {
14296 let graph = extract_symbol_graph("src/namespace-boundary.php", Some("php"), source);
14297 let staged = finish_graph(&graph)?;
14298 require(
14299 staged
14300 .relations
14301 .iter()
14302 .filter(|relation| {
14303 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14304 })
14305 .all(|relation| {
14306 staged.entities.iter().any(|entity| {
14307 entity.key() == relation.source()
14308 && matches!(entity.selector(), EntitySelector::File { .. })
14309 })
14310 }),
14311 "PHP namespace/callable boundary-line collisions must remain file-owned",
14312 )?;
14313 }
14314
14315 for source in [
14316 r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\{helper, other}; function after() { helper(); } }",
14317 r"<?php namespace A; function helper() {} function before() { helper(); } use function Vendor\helper, Vendor\other; function after() { helper(); }",
14318 r"<?php namespace A { use function Vendor\{helper, other}; } namespace B { function helper() {} function before() { helper(); } use function Remote\helper; function after() { helper(); } }",
14319 ] {
14320 let graph = extract_symbol_graph("src/import-groups.php", Some("php"), source);
14321 let grouped = finish_graph(&graph)?;
14322 for caller in ["before", "after"] {
14323 require(
14324 grouped.relations.iter().any(|relation| {
14325 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14326 && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
14327 && grouped.entities.iter().any(|entity| entity.key() == relation.source()
14328 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14329 }),
14330 "PHP grouped imports with unmatched source occurrences must stay conservative",
14331 )?;
14332 }
14333 }
14334
14335 let collision = extract_symbol_graph(
14336 "src/containment-collision.php",
14337 Some("php"),
14338 "<?php namespace Service { class Service { function run() {} } function outside() {} }",
14339 );
14340 let contained = finish_graph(&collision)?;
14341 for (name, parent) in [
14342 ("Service", "Service"),
14343 ("run", "Service::Service"),
14344 ("outside", "Service"),
14345 ] {
14346 require(
14347 contained.relations.iter().any(|relation| {
14348 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
14349 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14350 if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent))
14351 && contained.entities.iter().any(|entity| entity.key() == relation.source()
14352 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
14353 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == if name == "run" { Some("Service") } else { None }))
14354 }),
14355 &format!("PHP same-line namespace and type collisions must use exact containment spans: {name} {parent}"),
14356 )?;
14357 }
14358
14359 for source in [
14360 "<?php class A { public $run; function run() {} const run = 1; }",
14361 "<?php trait A { public $run; function run() {} const run = 1; }",
14362 ] {
14363 let graph = extract_symbol_graph("src/member-kinds.php", Some("php"), source);
14364 let members = finish_graph(&graph)?;
14365 require_eq(
14366 &graph
14367 .symbols
14368 .iter()
14369 .filter(|symbol| symbol.name == "run")
14370 .count(),
14371 &3,
14372 "PHP same-name fixture must retain property, method, and constant",
14373 )?;
14374 for expected in graph.symbols.iter().filter(|symbol| symbol.name == "run") {
14375 require(
14376 members.relations.iter().any(|relation| {
14377 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
14378 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14379 if symbol.name.as_str() == "run" && symbol.kind == expected.kind && symbol.signature.as_str() == expected.signature)
14380 && members.entities.iter().any(|entity| entity.key() == relation.source()
14381 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "A"))
14382 }),
14383 &format!("same-line PHP {} must retain its distinct containment edge", expected.signature),
14384 )?;
14385 }
14386 }
14387
14388 let imported_graph = extract_symbol_graph(
14389 "src/imports.php",
14390 Some("php"),
14391 r"<?php
14392namespace Local {
14393 use Remote\Service;
14394 use Remote\NamespaceAlias as Sub;
14395 use function Remote\helper;
14396 function run() { Service::boot(); helper(); Sub\helper(); }
14397}
14398namespace Local {
14399 class Service { public static function boot() {} }
14400 function helper() {}
14401}
14402namespace Local\Sub { function helper() {} }
14403",
14404 );
14405 let staged = finish_graph(&imported_graph)?;
14406 for target in ["Service::boot", "helper", "Sub\\helper"] {
14407 require(
14408 staged.relations.iter().any(|relation| {
14409 matches!(
14410 relation.resolution(),
14411 RelationResolution::Unresolved { reference } if reference.as_str() == target
14412 )
14413 }),
14414 "PHP imports must not be bypassed by same-file unqualified call matching",
14415 )?;
14416 }
14417
14418 for source in [
14419 r"<?php
14420namespace A { use function Vendor\helper; function run_a() { helper(); } }
14421namespace B { function helper() {} function run_b() { helper(); } }
14422",
14423 r"<?php
14424namespace A;
14425use function Vendor\helper;
14426function run_a() { helper(); }
14427namespace B;
14428function helper() {}
14429function run_b() { helper(); }
14430",
14431 ] {
14432 let graph = extract_symbol_graph("src/import-scopes.php", Some("php"), source);
14433 let scoped = finish_graph(&graph)?;
14434 require(
14435 scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14436 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
14437 if symbol.name.as_str() == "helper" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("B"))),
14438 "PHP import in namespace A must not suppress a proven namespace B call",
14439 )?;
14440 require(
14441 scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14442 && matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "helper")),
14443 "PHP imported alias remains unresolved in its owning namespace",
14444 )?;
14445 }
14446
14447 for source in [
14448 r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; function after() { helper(); } }",
14449 r"<?php namespace A; function before() { Sub\helper(); } use Vendor as Sub; function after() { Sub\helper(); } namespace A\Sub; function helper() {}",
14450 r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; use Vendor\Other; function after() { helper(); } } namespace B { use function Vendor\helper; function run_b() { helper(); } }",
14451 r"<?php
14452namespace { function helper() {} }
14453namespace A {
14454function before() { helper(); }
14455use function Vendor\helper;
14456function after() { helper(); }
14457}
14458",
14459 r"<?php
14460namespace A;
14461function before() { Sub\helper(); }
14462use Vendor as Sub;
14463function after() { Sub\helper(); }
14464namespace A\Sub;
14465function helper() {}
14466",
14467 ] {
14468 let graph = extract_symbol_graph("src/import-order.php", Some("php"), source);
14469 let staged = finish_graph(&graph)?;
14470 for (caller, resolved) in [("before", true), ("after", false)] {
14471 require(
14472 staged.relations.iter().any(|relation| {
14473 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14474 && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
14475 && staged.entities.iter().any(|entity| entity.key() == relation.source()
14476 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14477 }),
14478 &format!("PHP {caller} call must apply only imports already declared"),
14479 )?;
14480 }
14481 }
14482
14483 for source in [
14484 r"<?php
14485namespace A {
14486use function Vendor\helper;
14487function imported() { helper(); }
14488}
14489namespace A {
14490function helper() {}
14491function local() { helper(); }
14492}
14493",
14494 r"<?php
14495namespace A;
14496use Vendor as Sub;
14497function imported() { Sub\helper(); }
14498namespace A;
14499function local() { Sub\helper(); }
14500namespace A\Sub;
14501function helper() {}
14502",
14503 ] {
14504 let graph = extract_symbol_graph("src/reopened-namespace.php", Some("php"), source);
14505 let staged = finish_graph(&graph)?;
14506 for (caller, resolved) in [("imported", false), ("local", true)] {
14507 require(
14508 staged.relations.iter().any(|relation| {
14509 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14510 && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
14511 && staged.entities.iter().any(|entity| entity.key() == relation.source()
14512 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
14513 }),
14514 &format!("PHP {caller} call must use only its namespace declaration block"),
14515 )?;
14516 }
14517 }
14518
14519 for source in [
14520 "<?php namespace Foo { function helper() {} } namespace Foo { helper(); }",
14521 "<?php\nnamespace Foo;\nfunction helper() {}\nnamespace Foo;\nhelper();",
14522 ] {
14523 let graph = extract_symbol_graph("src/reopened-top-level.php", Some("php"), source);
14524 let staged = finish_graph(&graph)?;
14525 require(
14526 staged.relations.iter().any(|relation| {
14527 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14528 && staged.entities.iter().any(|entity| {
14529 entity.key() == relation.source()
14530 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
14531 if symbol.name.as_str() == "Foo" && symbol.kind == SymbolKind::Module)
14532 })
14533 }),
14534 "Reopened PHP namespace must retain its top-level call source",
14535 )?;
14536 }
14537
14538 for (source, remove_namespaces) in [
14539 (
14540 "<?php namespace A { use function Vendor\\helper; } namespace A { function helper() {} function local() { helper(); } }",
14541 false,
14542 ),
14543 (
14544 "<?php\nnamespace A {\nuse function Vendor\\helper;\n}\nnamespace A {\nfunction helper() {}\nfunction local() { helper(); }\n}",
14545 true,
14546 ),
14547 ] {
14548 let mut graph =
14549 extract_symbol_graph("src/uncertain-namespace.php", Some("php"), source);
14550 if remove_namespaces {
14551 graph
14552 .symbols
14553 .retain(|symbol| symbol.kind != SymbolKind::Module);
14554 }
14555 let staged = finish_graph(&graph)?;
14556 require(
14557 staged.relations.iter().any(|relation| {
14558 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14559 && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
14560 }),
14561 "PHP imports with tied or missing namespace boundaries remain unresolved",
14562 )?;
14563 require(
14564 !staged.relations.iter().any(|relation| {
14565 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14566 && matches!(relation.resolution(), RelationResolution::Resolved { .. })
14567 }),
14568 "PHP uncertain namespace boundaries must not invent call resolution",
14569 )?;
14570 }
14571
14572 let oversized_prefix = "N".repeat(241);
14573 for import in [
14574 format!("use Vendor\\{oversized_prefix} as Sub;"),
14575 format!("use Vendor\\{oversized_prefix}\\Sub;"),
14576 format!("use {oversized_prefix}\\{{Sub}};"),
14577 format!("use {}\\{{Sub}};", "N".repeat(238)),
14578 format!("use Vendor\\Kept, Vendor\\{oversized_prefix} as Sub;"),
14579 ] {
14580 let source = format!(
14581 "<?php namespace Foo {{ {import} function run() {{ Sub\\helper(); namespace\\Sub\\helper(); }} function absolute() {{ \\Foo\\Sub\\helper(); }} }} namespace Foo\\Sub {{ function helper() {{}} }}"
14582 );
14583 let graph = extract_symbol_graph("src/oversized-imports.php", Some("php"), &source);
14584 let staged = finish_graph(&graph)?;
14585 require(
14586 staged.relations.iter().any(|relation| {
14587 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14588 if reference.as_str() == "Sub\\helper")
14589 }),
14590 &format!("omitted PHP import must retain alias uncertainty: {import}"),
14591 )?;
14592 require_eq(
14593 &staged
14594 .relations
14595 .iter()
14596 .filter(|relation| {
14597 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
14598 && matches!(relation.resolution(), RelationResolution::Resolved { .. })
14599 })
14600 .count(),
14601 &2,
14602 "rooted and namespace-relative PHP calls must remain independently resolvable",
14603 )?;
14604 }
14605
14606 let mut partial_imports = imported_graph;
14607 partial_imports.parser = ParserKind::Fallback;
14608 partial_imports
14609 .symbols
14610 .retain(|symbol| symbol.kind != SymbolKind::Import);
14611 let partial_staged = finish_graph(&partial_imports)?;
14612 require(
14613 partial_staged.relations.iter().any(|relation| {
14614 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14615 if reference.as_str() == "Sub\\helper")
14616 }),
14617 "partial PHP import evidence must not prove absence of namespace aliases",
14618 )?;
14619 for context in [
14620 "require\\Sub",
14621 "include_once\\Sub",
14622 "requireé",
14623 "require\u{a0}Alias",
14624 "",
14625 "use function Vendor\\helper;",
14626 ] {
14627 for relation in &mut partial_imports.relations {
14628 if relation.kind == RelationKind::Imports {
14629 relation.context = context.to_string();
14630 }
14631 }
14632 let staged = finish_graph(&partial_imports)?;
14633 require(
14634 staged.relations.iter().any(|relation| {
14635 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14636 if reference.as_str() == "Sub\\helper")
14637 }),
14638 "unknown, namespace-prefix, and fallback use contexts must retain alias uncertainty",
14639 )?;
14640 }
14641 let missing_relative = extract_symbol_graph(
14642 "src/missing-relative.php",
14643 Some("php"),
14644 "<?php\nnamespace { function helper() {} }\nnamespace Foo { function run() { Missing\\helper(); } }",
14645 );
14646 let staged = finish_graph(&missing_relative)?;
14647 require(
14648 staged.relations.iter().any(|relation| {
14649 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
14650 if reference.as_str() == "Missing\\helper")
14651 }),
14652 "qualified PHP functions must not fall back to the global function",
14653 )?;
14654
14655 let wrong_namespace_graph = extract_symbol_graph(
14656 "src/scoped.php",
14657 Some("php"),
14658 "<?php\nnamespace Caller { function run(): void { Service::boot(); } }\nnamespace Other { class Service { public static function boot(): void {} } }\n",
14659 );
14660 let staged = finish_graph(&wrong_namespace_graph)?;
14661 require(
14662 staged.relations.iter().any(|relation| {
14663 matches!(
14664 relation.resolution(),
14665 RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
14666 )
14667 }),
14668 "unqualified PHP static calls must not resolve to a class in another namespace",
14669 )?;
14670
14671 let case_sensitive_graph = extract_symbol_graph(
14672 "src/case-sensitive.js",
14673 Some("javascript"),
14674 "function helper() {}\nfunction caller() { HELPER(); }\n",
14675 );
14676 let staged = finish_graph(&case_sensitive_graph)?;
14677 require(
14678 staged.relations.iter().any(|relation| {
14679 matches!(
14680 relation.resolution(),
14681 RelationResolution::Unresolved { reference } if reference.as_str() == "HELPER"
14682 )
14683 }),
14684 "non-PHP call matching must remain case-sensitive",
14685 )?;
14686 Ok(())
14687 }
14688
14689 #[test]
14690 fn persisted_candidate_pages_share_the_registry_transfer_allowance()
14691 -> Result<(), Box<dyn Error>> {
14692 let temp = tempfile::tempdir()?;
14693 let root = temp.path();
14694 let db = root.join("atlas.db");
14695 let mut store = AtlasStore::open_for_project(&db, root)?;
14696 let project = store
14697 .project_instance_id()?
14698 .ok_or("missing transfer fixture identity")?;
14699 let graph = function_graph("src/lib.rs", 1);
14700 let nodes = vec![test_file_node(&graph.path, "rust")];
14701 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14702 let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
14703 let staged = stage_full_repository_graph(
14704 &store,
14705 root,
14706 IndexGeneration::ZERO,
14707 &nodes,
14708 &policy,
14709 &symbol_build_stage_for_graphs(vec![graph.clone()]),
14710 &control,
14711 )?;
14712 publish_full_staged_graph(&mut store, &nodes, &staged, &control, "candidate-transfer")?;
14713 drop(store);
14714 let reader = AtlasStore::open_read_only_for_project(&db, root)?;
14715 let before = reader.index_publication()?;
14716 let keys = reader
14717 .repository_export_keys_for_paths(project, &[graph.path], 100)?
14718 .rows;
14719 let read = || {
14720 reader.repository_resolution_candidates_for_keys_with_byte_limit(
14721 project,
14722 &keys,
14723 100,
14724 1_024 * 1_024,
14725 )
14726 };
14727 let first = read()?;
14728 let page_bytes = first.retained_bytes;
14729 require(
14730 page_bytes > 0 && !first.page.rows.is_empty(),
14731 "read omitted its returned owner",
14732 )?;
14733 let replaced = BTreeSet::new();
14734 let generation = IndexGeneration::new(2);
14735 let registry = super::resolution_registry_from_persisted(
14736 project,
14737 generation,
14738 first,
14739 &replaced,
14740 &control,
14741 u64::MAX,
14742 )?;
14743 let registry_bytes = registry.retained_bytes;
14744 require(registry_bytes > 0, "transfer retained no lookup witnesses")?;
14745 drop(registry);
14746 let combined = page_bytes.saturating_add(registry_bytes);
14747 let refused = super::resolution_registry_from_persisted(
14748 project,
14749 generation,
14750 read()?,
14751 &replaced,
14752 &control,
14753 combined - 1,
14754 );
14755 require(
14756 matches!(
14757 refused,
14758 Err(CliError::IndexWork(
14759 IndexWorkFailure::ResourceLimitExceeded {
14760 resource: IndexWorkResource::OutputBytes,
14761 ..
14762 }
14763 ))
14764 ),
14765 "registry ignored the simultaneously live returned page",
14766 )?;
14767 let mut exact = super::resolution_registry_from_persisted(
14768 project,
14769 generation,
14770 read()?,
14771 &replaced,
14772 &control,
14773 combined,
14774 )?;
14775 require_eq(
14776 &exact.retained_bytes,
14777 ®istry_bytes,
14778 "exact transfer boundary changed lookup ownership",
14779 )?;
14780 super::append_persisted_resolution_candidates(
14781 &mut exact,
14782 project,
14783 generation,
14784 read()?,
14785 &replaced,
14786 &control,
14787 )?;
14788 require_eq(
14789 &exact.retained_bytes,
14790 ®istry_bytes,
14791 "duplicate page charged another registry copy",
14792 )?;
14793 exact.retained_limit = combined - 1;
14794 let failed_append = super::append_persisted_resolution_candidates(
14795 &mut exact,
14796 project,
14797 generation,
14798 read()?,
14799 &replaced,
14800 &control,
14801 );
14802 require(
14803 failed_append.is_err()
14804 && exact.retained_limit == combined - 1
14805 && exact.retained_bytes == registry_bytes,
14806 "failed read transfer erased completed cache or leaked its scoped allowance",
14807 )?;
14808 require_eq(
14809 &reader.index_publication()?,
14810 &before,
14811 "read transfer changed the published generation",
14812 )?;
14813 Ok(())
14814 }
14815
14816 #[test]
14817 fn incremental_key_workspaces_admit_clones_and_query_copies_before_growth()
14818 -> Result<(), Box<dyn Error>> {
14819 let project = ProjectInstanceId::from_bytes([22; 16])?;
14820 let key = test_resolution_key(project, &"x".repeat(1_000))?;
14821 let other = test_resolution_key(project, &"y".repeat(1_000))?;
14822 let one_owner = super::resolution_workspace_key_bytes(&key);
14823 let paths = BTreeSet::new();
14824 let root = Path::new(".");
14825 let mut exact = super::ResolutionKeyWorkspace::new(one_owner, root, &paths);
14826 exact.insert(&key)?;
14827 exact.insert(&key)?;
14828 require(
14829 exact.keys.len() == 1 && exact.retained_bytes == one_owner,
14830 "duplicate witness retained another canonical string",
14831 )?;
14832 require(
14833 matches!(
14834 exact.insert(&other),
14835 Err(CliError::IndexWork(
14836 IndexWorkFailure::ResourceLimitExceeded {
14837 resource: IndexWorkResource::OutputBytes,
14838 ..
14839 }
14840 ))
14841 ) && exact.keys.len() == 1
14842 && exact.retained_bytes == one_owner,
14843 "refused witness mutated the admitted prefix",
14844 )?;
14845 require(
14846 matches!(
14847 exact.snapshot(),
14848 Err(CliError::IndexWork(
14849 IndexWorkFailure::ResourceLimitExceeded {
14850 resource: IndexWorkResource::OutputBytes,
14851 ..
14852 }
14853 ))
14854 ),
14855 "query Vec copy ignored its coexisting set",
14856 )?;
14857 let mut available =
14858 super::ResolutionKeyWorkspace::new(one_owner.saturating_mul(4), root, &paths);
14859 available.insert(&key)?;
14860 available.insert_owned(other.clone())?;
14861 let mut one_row = super::ResolutionKeyWorkspace::new(u64::MAX, root, &paths);
14862 one_row.row_limit = 1;
14863 one_row.insert(&key)?;
14864 one_row.insert_owned(key.clone())?;
14865 require(
14866 matches!(one_row.insert(&other), Err(CliError::RefreshRequired(_)))
14867 && matches!(
14868 one_row.insert_owned(other.clone()),
14869 Err(CliError::RefreshRequired(_))
14870 )
14871 && one_row.keys.len() == 1
14872 && one_row.retained_bytes == one_owner,
14873 "distinct-key row refusal retained another owner or charged a duplicate",
14874 )?;
14875 let mut expected = vec![key, other];
14876 expected.sort();
14877 require_eq(
14878 &available.snapshot()?.0,
14879 &expected,
14880 "admitted query copy changed exact witnesses",
14881 )?;
14882 Ok(())
14883 }
14884
14885 #[test]
14886 fn configured_projection_admission_refuses_before_aggregate_retention()
14887 -> Result<(), Box<dyn Error>> {
14888 let project = ProjectInstanceId::from_bytes([21; 16])?;
14889 let generation = IndexGeneration::new(1);
14890 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14891 let text = "import 'pkg';\n".repeat(16);
14892 let mut extracted = Vec::new();
14893 for index in 0..5 {
14894 let (_, graph, facts) =
14895 projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
14896 &format!("src/source{index}.ts"),
14897 Some("typescript"),
14898 &text,
14899 &control,
14900 )?
14901 .into_parts();
14902 extracted.push((graph, facts));
14903 }
14904 let raw = extracted.iter().map(|(graph, _)| graph).collect::<Vec<_>>();
14905 let packages = PackageIndex::from_graphs(&raw)?;
14906 let sources = extracted
14907 .iter()
14908 .map(|(graph, facts)| super::SourceGraph::Staged {
14909 graph,
14910 semantics: Some(facts),
14911 })
14912 .collect::<Vec<_>>();
14913 let directory = format!("{}/", "n".repeat(220)).repeat(4);
14914 let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
14915 "tsconfig.json",
14916 EcmaScriptConfigKind::TypeScript,
14917 None,
14918 vec![EcmaScriptPathMapping::new(
14919 "pkg",
14920 (0..64)
14921 .map(|index| format!("src/target{index}/{directory}target.ts"))
14922 .collect(),
14923 )?],
14924 )?])?;
14925 let mut report = super::GraphIdentityAdmission::default();
14926 let limit = 2 * 1_024 * 1_024;
14927 let result = super::admit_resolution_key_failures_with_limit(
14928 project,
14929 generation,
14930 &sources,
14931 &packages,
14932 &configured,
14933 &mut report,
14934 &control,
14935 limit,
14936 );
14937 let retained =
14938 super::resolution_projection_map_retained_bytes(&report.resolution_projections);
14939 require(
14940 matches!(
14941 result,
14942 Err(super::CliError::IndexWork(
14943 IndexWorkFailure::ResourceLimitExceeded {
14944 resource: projectatlas_core::IndexWorkResource::OutputBytes,
14945 ..
14946 }
14947 ))
14948 ),
14949 &format!(
14950 "aggregate admission returned success with {} maps and {retained} bytes under {limit}-byte limit",
14951 report.resolution_projections.len()
14952 ),
14953 )?;
14954 require(
14955 retained <= limit
14956 && !report.resolution_projections.is_empty()
14957 && report.resolution_projections.len() < sources.len(),
14958 "admission must retain only the prefix admitted before aggregate exhaustion",
14959 )?;
14960 let (_, partial_graph, partial_facts) =
14961 projectatlas_symbols::extract_symbol_graph_with_semantics_controlled(
14962 "src/partial.js",
14963 Some("javascript"),
14964 &format!("export {{ work as '' }} from 'pkg';\n{text}"),
14965 &control,
14966 )?
14967 .into_parts();
14968 let partial = [super::SourceGraph::Staged {
14969 graph: &partial_graph,
14970 semantics: Some(&partial_facts),
14971 }];
14972 let mut rejected_neighbor = GraphIdentityAdmission::default();
14973 super::admit_resolution_key_failures_with_limit(
14974 project,
14975 generation,
14976 &partial,
14977 &packages,
14978 &configured,
14979 &mut rejected_neighbor,
14980 &control,
14981 limit,
14982 )?;
14983 require(
14984 !rejected_neighbor.rejections.is_empty()
14985 && rejected_neighbor.resolution_projections.len() == 1,
14986 "invalid neighbor did not retain a valid partial projection and rejection facts",
14987 )?;
14988 let partial_bytes =
14989 resolution_projection_map_retained_bytes(&rejected_neighbor.resolution_projections);
14990 let partial_continuation = super::admit_resolution_key_failures_with_limit(
14991 project,
14992 generation,
14993 &sources,
14994 &packages,
14995 &configured,
14996 &mut rejected_neighbor,
14997 &control,
14998 limit,
14999 );
15000 require(
15001 matches!(
15002 partial_continuation,
15003 Err(CliError::IndexWork(
15004 IndexWorkFailure::ResourceLimitExceeded {
15005 resource: projectatlas_core::IndexWorkResource::OutputBytes,
15006 ..
15007 }
15008 ))
15009 ) && resolution_projection_map_retained_bytes(
15010 &rejected_neighbor.resolution_projections,
15011 ) == partial_bytes,
15012 "partial Contract projection escaped the aggregate allowance",
15013 )?;
15014 let mut split = GraphIdentityAdmission::default();
15015 super::admit_resolution_key_failures_with_limit(
15016 project,
15017 generation,
15018 &sources[..1],
15019 &packages,
15020 &configured,
15021 &mut split,
15022 &control,
15023 limit,
15024 )?;
15025 let first_bytes = resolution_projection_map_retained_bytes(&split.resolution_projections);
15026 require(
15027 first_bytes > 0,
15028 "positive 64-target projection was not admitted",
15029 )?;
15030 let continuation = super::admit_resolution_key_failures_with_limit(
15031 project,
15032 generation,
15033 &sources[1..],
15034 &packages,
15035 &configured,
15036 &mut split,
15037 &control,
15038 limit,
15039 );
15040 require(
15041 matches!(
15042 continuation,
15043 Err(CliError::IndexWork(
15044 IndexWorkFailure::ResourceLimitExceeded {
15045 resource: projectatlas_core::IndexWorkResource::OutputBytes,
15046 ..
15047 }
15048 ))
15049 ),
15050 "a second admission minted a fresh allowance",
15051 )?;
15052 let mut nonzero_base = GraphIdentityAdmission {
15053 observed_fact_bytes: limit - first_bytes / 2,
15054 ..GraphIdentityAdmission::default()
15055 };
15056 let base_result = super::admit_resolution_key_failures_with_limit(
15057 project,
15058 generation,
15059 &sources[..1],
15060 &packages,
15061 &configured,
15062 &mut nonzero_base,
15063 &control,
15064 limit,
15065 );
15066 require(
15067 matches!(
15068 base_result,
15069 Err(CliError::IndexWork(
15070 IndexWorkFailure::ResourceLimitExceeded {
15071 resource: projectatlas_core::IndexWorkResource::OutputBytes,
15072 ..
15073 }
15074 ))
15075 ) && nonzero_base.resolution_projections.is_empty(),
15076 "already-live identity state was omitted from producer admission",
15077 )?;
15078 let cancellation = IndexCancellation::new();
15079 cancellation.cancel();
15080 let canceled = IndexWorkControl::new(cancellation, None);
15081 let before = resolution_projection_map_retained_bytes(&split.resolution_projections);
15082 let canceled_result = super::admit_resolution_key_failures_with_limit(
15083 project,
15084 generation,
15085 &sources[1..],
15086 &packages,
15087 &configured,
15088 &mut split,
15089 &canceled,
15090 limit,
15091 );
15092 require(
15093 matches!(
15094 canceled_result,
15095 Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
15096 ) && resolution_projection_map_retained_bytes(&split.resolution_projections) == before,
15097 "cancellation changed the admitted prefix",
15098 )?;
15099 Ok(())
15100 }
15101
15102 #[test]
15103 fn configured_module_targets_reuse_graph_ambiguity_ownership() -> Result<(), Box<dyn Error>> {
15104 let project = ProjectInstanceId::from_bytes([19; 16])?;
15105 let generation = IndexGeneration::new(1);
15106 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15107 let graphs = vec![
15108 extract_symbol_graph(
15109 "src/first/controller.ts",
15110 Some("typescript"),
15111 "export function useController() { return 'first'; }\n",
15112 ),
15113 extract_symbol_graph(
15114 "src/second/controller.ts",
15115 Some("typescript"),
15116 "export function useController() { return 'second'; }\n",
15117 ),
15118 extract_symbol_graph(
15119 "src/page.ts",
15120 Some("typescript"),
15121 "import { useController } from '@/controller';\nexport const value = useController();\n",
15122 ),
15123 ];
15124 let packages = PackageIndex::from_graphs(&graphs)?;
15125 let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
15126 "tsconfig.json",
15127 EcmaScriptConfigKind::TypeScript,
15128 None,
15129 vec![EcmaScriptPathMapping::new(
15130 "@/*",
15131 vec!["src/first/*".to_string(), "src/second/*".to_string()],
15132 )?],
15133 )?])?;
15134 let projection = build_entity_projection_with_config(
15135 project,
15136 generation,
15137 &[],
15138 &graphs,
15139 &packages,
15140 &configured,
15141 None,
15142 true,
15143 &control,
15144 )?;
15145 let candidates = resolution_registry_from_exports(&projection, &control)?;
15146 let staged = finish_projection(
15147 project,
15148 generation,
15149 RepositoryGraphMutation::Full,
15150 &graphs,
15151 projection,
15152 &candidates,
15153 &control,
15154 )?;
15155 for kind in [RelationKind::Imports, RelationKind::Calls] {
15156 require(
15157 staged.relations.iter().any(|relation| {
15158 relation.kind() == GraphRelationKind::from_legacy(kind)
15159 && matches!(
15160 relation.resolution(),
15161 RelationResolution::Ambiguous { candidates, .. }
15162 if candidates.get() == 2
15163 )
15164 }),
15165 "configured mapping did not retain all ambiguity candidates",
15166 )?;
15167 }
15168 Ok(())
15169 }
15170
15171 #[test]
15172 fn extracted_provider_matrix_survives_sqlite_publication_and_reopen()
15173 -> Result<(), Box<dyn Error>> {
15174 let temp = tempfile::tempdir()?;
15175 let root = temp.path().join("extracted-provider-matrix");
15176 fs::create_dir_all(&root)?;
15177 let database = root.join("projectatlas.db");
15178 let mut store = AtlasStore::open_for_project(&database, &root)?;
15179 let project = store
15180 .project_instance_id()?
15181 .ok_or("bound project identity is missing")?;
15182 let generation = IndexGeneration::new(1);
15183 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15184 let graphs = vec![
15185 extract_symbol_graph(
15186 "src/lib.rs",
15187 Some("rust"),
15188 "use std::{fs, io};\npub fn caller() { private_helper(); }\nfn private_helper() {}\n",
15189 ),
15190 extract_symbol_graph(
15191 "src/config.rs",
15192 Some("rust"),
15193 "pub fn load_timeout_millis() -> u64 { 250 }\n",
15194 ),
15195 extract_symbol_graph(
15196 "src/handler.rs",
15197 Some("rust"),
15198 "use crate::config;\npub fn health_response() { config::load_timeout_millis(); }\n",
15199 ),
15200 extract_symbol_graph(
15201 "src/router.rs",
15202 Some("rust"),
15203 "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
15204 ),
15205 extract_symbol_graph(
15206 "src/app.js",
15207 Some("javascript"),
15208 "import path from \"node:path\";\nexport function run() { return path.join('a', 'b'); }\n",
15209 ),
15210 extract_symbol_graph(
15211 "src/app.py",
15212 Some("python"),
15213 "import requests\n\ndef run():\n return requests.get('https://example.test')\n",
15214 ),
15215 extract_symbol_graph(
15216 "Cargo.toml",
15217 Some("cargo-manifest"),
15218 "[package]\nname = \"matrix-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nduplicate = \"1\"\n",
15219 ),
15220 extract_symbol_graph(
15221 "vendor/first/Cargo.toml",
15222 Some("cargo-manifest"),
15223 "[package]\nname = \"duplicate\"\nversion = \"1.0.0\"\n",
15224 ),
15225 extract_symbol_graph(
15226 "vendor/second/Cargo.toml",
15227 Some("cargo-manifest"),
15228 "[package]\nname = \"duplicate\"\nversion = \"2.0.0\"\n",
15229 ),
15230 extract_symbol_graph(
15231 "public/index.html",
15232 Some("html"),
15233 "<script type=\"module\">\nimport fs from \"node:fs\";\nexport function boot() { return fs.readFile; }\n</script>\n",
15234 ),
15235 extract_symbol_graph(
15236 "src/Page.svelte",
15237 Some("svelte"),
15238 "<script lang=\"ts\">\nimport url from \"node:url\";\nexport function page() { return url.parse('https://example.test'); }\n</script>\n",
15239 ),
15240 ];
15241 for (language, expected_relation) in [
15242 ("rust", "std"),
15243 ("javascript", "node:path"),
15244 ("python", "requests"),
15245 ("cargo-manifest", "duplicate"),
15246 ("html", "node:fs"),
15247 ("svelte", "node:url"),
15248 ] {
15249 require(
15250 graphs.iter().any(|graph| {
15251 graph.language.as_deref() == Some(language)
15252 && graph
15253 .relations
15254 .iter()
15255 .any(|relation| relation.target_name.contains(expected_relation))
15256 }),
15257 &format!("extracted {language} provider relation is missing"),
15258 )?;
15259 }
15260 let nodes = graphs
15261 .iter()
15262 .map(|graph| {
15263 test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
15264 })
15265 .collect::<Vec<_>>();
15266 let packages = PackageIndex::from_graphs(&graphs)?;
15267 let projection = build_entity_projection(
15268 project, generation, &nodes, &graphs, &packages, true, &control,
15269 )?;
15270 let candidates = resolution_registry_from_exports(&projection, &control)?;
15271 let staged = finish_projection(
15272 project,
15273 generation,
15274 RepositoryGraphMutation::Full,
15275 &graphs,
15276 projection,
15277 &candidates,
15278 &control,
15279 )?;
15280 let source_keys = staged
15281 .entities
15282 .iter()
15283 .map(|entity| entity.key().clone())
15284 .collect::<Vec<_>>();
15285 {
15286 let mut publication = store.begin_index_publication("extracted-provider-matrix")?;
15287 publication.begin_scan_replacement()?;
15288 publication.upsert_scan_node_batch(&nodes)?;
15289 publication.finish_scan_replacement()?;
15290 staged.apply(&mut publication, &control)?;
15291 publication.complete()?;
15292 }
15293 drop(store);
15294
15295 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
15296 let mut reopened = Vec::new();
15297 for source in source_keys {
15298 let page = reader.repository_graph_relations(
15299 RepositoryGraphRelationQuery::Outbound { source },
15300 128,
15301 )?;
15302 require(!page.truncated, "extracted provider matrix was truncated")?;
15303 reopened.extend(page.rows);
15304 }
15305 let mut external = BTreeSet::new();
15306 let mut unresolved = BTreeSet::new();
15307 let mut ambiguous = BTreeMap::new();
15308 let mut resolved_symbols = BTreeSet::new();
15309 for relation in reopened {
15310 require_eq(
15311 &relation.generation(),
15312 &generation,
15313 "extracted relation generation",
15314 )?;
15315 match relation.resolution() {
15316 RelationResolution::External {
15317 external: target, ..
15318 } => {
15319 external.insert((
15320 target.system.as_str().to_string(),
15321 target.identity.as_str().to_string(),
15322 ));
15323 }
15324 RelationResolution::Unresolved { reference } => {
15325 unresolved.insert(reference.as_str().to_string());
15326 }
15327 RelationResolution::Ambiguous {
15328 reference,
15329 candidates,
15330 } => {
15331 ambiguous.insert(reference.as_str().to_string(), candidates.get());
15332 }
15333 RelationResolution::Resolved { selector, .. } => {
15334 if let ReusableTargetSelector::Symbol { symbol } = selector {
15335 resolved_symbols.insert((
15336 symbol.file.as_str().to_string(),
15337 symbol.name.as_str().to_string(),
15338 ));
15339 }
15340 }
15341 }
15342 }
15343 for expected in [
15344 ("rust-toolchain".to_string(), "std::fs".to_string()),
15345 ("rust-toolchain".to_string(), "std::io".to_string()),
15346 ("node".to_string(), "path".to_string()),
15347 ("node".to_string(), "fs".to_string()),
15348 ("node".to_string(), "url".to_string()),
15349 ] {
15350 require(
15351 external.contains(&expected),
15352 &format!("reopened external target is missing: {expected:?}"),
15353 )?;
15354 }
15355 require(
15356 unresolved
15357 .iter()
15358 .any(|reference| reference.contains("requests")),
15359 "reopened Python unresolved import is missing",
15360 )?;
15361 require_eq(
15362 &ambiguous.get("duplicate"),
15363 &Some(&2),
15364 "reopened Cargo duplicate ambiguity",
15365 )?;
15366 require(
15367 resolved_symbols.contains(&("src/lib.rs".to_string(), "private_helper".to_string())),
15368 "reopened private Rust helper resolution is missing",
15369 )?;
15370 require(
15371 resolved_symbols.contains(&(
15372 "src/config.rs".to_string(),
15373 "load_timeout_millis".to_string(),
15374 )),
15375 "reopened Rust module-qualified call resolution is missing",
15376 )?;
15377 require(
15378 resolved_symbols
15379 .contains(&("src/handler.rs".to_string(), "health_response".to_string())),
15380 "reopened Rust callback resolution is missing",
15381 )?;
15382 reader.finish_index_read_snapshot()?;
15383 Ok(())
15384 }
15385
15386 #[test]
15387 fn closed_resolution_states_survive_sqlite_publication_and_reopen() -> Result<(), Box<dyn Error>>
15388 {
15389 let temp = tempfile::tempdir()?;
15390 let root = temp.path().join("closed-resolution-states");
15391 fs::create_dir_all(&root)?;
15392 let database = root.join("projectatlas.db");
15393 let mut store = AtlasStore::open_for_project(&database, &root)?;
15394 let project = store
15395 .project_instance_id()?
15396 .ok_or("bound project identity is missing")?;
15397 let generation = IndexGeneration::new(1);
15398 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15399 let source = test_file_entity(project, generation, "src/main.rs")?;
15400 let unique_target = test_symbol_entity(project, generation, "src/unique.rs", "unique")?;
15401 let first_shared = test_symbol_entity(project, generation, "src/first.rs", "shared")?;
15402 let second_shared = test_symbol_entity(project, generation, "src/second.rs", "shared")?;
15403 let local_package = GraphEntity::new(
15404 project,
15405 EntitySelector::Package {
15406 package: PackageSelector {
15407 manager: GraphIdentityText::new("cargo")?,
15408 name: GraphIdentityText::new("local-package")?,
15409 manifest: RepositoryFilePath::new(Path::new("vendor/local/Cargo.toml"))?,
15410 },
15411 },
15412 generation,
15413 )?;
15414 let unique_key = test_resolution_key(project, "unique")?;
15415 let shared_key = test_resolution_key(project, "shared")?;
15416 let local_package_key = test_resolution_key(project, "local-package")?;
15417 let mut registry = ProjectResolutionRegistry::default();
15418 registry.insert_candidate(&unique_key, &unique_target)?;
15419 registry.insert_candidate(&shared_key, &first_shared)?;
15420 registry.insert_candidate(&shared_key, &second_shared)?;
15421 registry.insert_candidate(&local_package_key, &local_package)?;
15422 let source_digest = source.key().digest().to_string();
15423 let owners = GraphOwners {
15424 file_digest: source_digest.clone(),
15425 symbol_digests: Vec::new(),
15426 };
15427 let staged_entities = BTreeMap::from([(source_digest, source.clone())]);
15428 let mut external_entities = BTreeMap::new();
15429 let cases = [
15430 resolution_case("rust", RelationKind::Calls, "unique", &[&unique_key]),
15431 resolution_case("rust", RelationKind::Calls, "shared", &[&shared_key]),
15432 resolution_case("rust", RelationKind::Calls, "missing", &[]),
15433 resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]),
15434 resolution_case("cargo-manifest", RelationKind::DependsOn, "serde", &[]),
15435 resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]),
15436 resolution_case(
15437 "javascript",
15438 RelationKind::Imports,
15439 "import path from \"node:path\";",
15440 &[],
15441 ),
15442 resolution_case(
15443 "html",
15444 RelationKind::Imports,
15445 "import fs from \"node:fs\";",
15446 &[],
15447 ),
15448 resolution_case(
15449 "svelte",
15450 RelationKind::Imports,
15451 "import url from \"node:url\";",
15452 &[],
15453 ),
15454 resolution_case(
15455 "javascript",
15456 RelationKind::Imports,
15457 "import value from \"left-pad\";",
15458 &[],
15459 ),
15460 resolution_case("python", RelationKind::Imports, "import requests", &[]),
15461 resolution_case(
15462 "cargo-manifest",
15463 RelationKind::DependsOn,
15464 "local-package",
15465 &[&local_package_key],
15466 ),
15467 ];
15468 let mut relations = Vec::new();
15469 let mut dependencies = Vec::new();
15470 for case in &cases {
15471 let symbol_index = GraphSymbolIndex::new(&case.graph, &control)?;
15472 let resolution = relation_resolution(
15473 project,
15474 generation,
15475 &case.relation,
15476 None,
15477 &owners,
15478 &case.graph,
15479 &symbol_index,
15480 &case.keys,
15481 ®istry,
15482 &staged_entities,
15483 &mut external_entities,
15484 &control,
15485 )?;
15486 let relation = LogicalRelation::new(
15487 &source,
15488 GraphRelationKind::from_legacy(case.relation.kind),
15489 resolution,
15490 ConfidenceClass::High,
15491 Completeness::Complete,
15492 generation,
15493 )?;
15494 for key in &case.keys {
15495 dependencies.push(RelationDependencyKey::new(
15496 relation.key().clone(),
15497 key.clone(),
15498 )?);
15499 }
15500 relations.push(relation);
15501 }
15502 let external_keys = external_entities
15503 .values()
15504 .map(|entity| entity.key().clone())
15505 .collect::<Vec<_>>();
15506 let entities = [
15507 source.clone(),
15508 unique_target,
15509 first_shared,
15510 second_shared,
15511 local_package,
15512 ]
15513 .into_iter()
15514 .chain(external_entities.into_values())
15515 .collect::<Vec<_>>();
15516 let exports = [
15517 EntityResolutionKey::new(entities[1].key().clone(), unique_key.clone())?,
15518 EntityResolutionKey::new(entities[2].key().clone(), shared_key.clone())?,
15519 EntityResolutionKey::new(entities[3].key().clone(), shared_key.clone())?,
15520 EntityResolutionKey::new(entities[4].key().clone(), local_package_key.clone())?,
15521 ];
15522 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15523 let staged = StagedRepositoryGraph {
15524 project,
15525 mutation: RepositoryGraphMutation::Full,
15526 entities,
15527 relations,
15528 occurrences: Vec::new(),
15529 coverage: Vec::new(),
15530 entity_exports: exports.into(),
15531 relation_dependencies: dependencies,
15532 document_unresolved_reasons: Vec::new(),
15533 identity_rejections: Vec::new(),
15534 resolution_derivations: BTreeMap::new(),
15535 peak_retained_bytes: 0,
15536 projection_removals_before_entities: Vec::new(),
15537 scan_policy,
15538 document_target_states: Vec::new(),
15539 database: None,
15540 source_recovery: SourceRecoveryWork::default(),
15541 retained_bytes: 0,
15542 };
15543 {
15544 let mut publication = store.begin_index_publication("closed-resolution-states")?;
15545 publication.begin_scan_replacement()?;
15546 publication.upsert_scan_node_batch(&[
15547 test_file_node("src/main.rs", "rust"),
15548 test_file_node("src/unique.rs", "rust"),
15549 test_file_node("src/first.rs", "rust"),
15550 test_file_node("src/second.rs", "rust"),
15551 test_file_node("vendor/local/Cargo.toml", "cargo-manifest"),
15552 ])?;
15553 publication.finish_scan_replacement()?;
15554 staged.apply(&mut publication, &control)?;
15555 publication.complete()?;
15556 }
15557 drop(store);
15558
15559 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
15560 let page = reader.repository_graph_relations(
15561 RepositoryGraphRelationQuery::Outbound {
15562 source: source.key().clone(),
15563 },
15564 32,
15565 )?;
15566 require(!page.truncated, "closed resolution proof was truncated")?;
15567 require_eq(&page.rows.len(), &cases.len(), "reopened relation count")?;
15568 let mut resolved = BTreeSet::new();
15569 let mut ambiguous = BTreeMap::new();
15570 let mut unresolved = BTreeSet::new();
15571 let mut external = BTreeSet::new();
15572 for relation in &page.rows {
15573 require_eq(
15574 &relation.generation(),
15575 &generation,
15576 "reopened relation generation",
15577 )?;
15578 match relation.resolution() {
15579 RelationResolution::Resolved {
15580 selector,
15581 generation: target_generation,
15582 ..
15583 } => {
15584 require_eq(target_generation, &generation, "resolved target generation")?;
15585 match selector {
15586 ReusableTargetSelector::Symbol { symbol } => {
15587 resolved.insert(symbol.name.as_str().to_string());
15588 }
15589 ReusableTargetSelector::Package { package } => {
15590 resolved.insert(package.name.as_str().to_string());
15591 }
15592 ReusableTargetSelector::Folder { .. }
15593 | ReusableTargetSelector::File { .. } => {}
15594 }
15595 }
15596 RelationResolution::Ambiguous {
15597 reference,
15598 candidates,
15599 } => {
15600 ambiguous.insert(reference.as_str().to_string(), candidates.get());
15601 }
15602 RelationResolution::Unresolved { reference } => {
15603 unresolved.insert(reference.as_str().to_string());
15604 }
15605 RelationResolution::External {
15606 external: selector,
15607 generation: target_generation,
15608 ..
15609 } => {
15610 require_eq(target_generation, &generation, "external target generation")?;
15611 external.insert((
15612 selector.system.as_str().to_string(),
15613 selector.identity.as_str().to_string(),
15614 ));
15615 }
15616 }
15617 }
15618 require_eq(
15619 &resolved,
15620 &BTreeSet::from(["local-package".to_string(), "unique".to_string()]),
15621 "resolved targets",
15622 )?;
15623 require_eq(
15624 &ambiguous,
15625 &BTreeMap::from([("shared".to_string(), 2)]),
15626 "ambiguous targets",
15627 )?;
15628 require_eq(
15629 &unresolved,
15630 &BTreeSet::from([
15631 "import requests".to_string(),
15632 "import value from \"left-pad\";".to_string(),
15633 "missing".to_string(),
15634 "serde-lock".to_string(),
15635 ]),
15636 "unresolved targets",
15637 )?;
15638 require_eq(
15639 &external,
15640 &BTreeSet::from([
15641 ("cargo".to_string(), "serde".to_string()),
15642 ("node".to_string(), "fs".to_string()),
15643 ("node".to_string(), "path".to_string()),
15644 ("node".to_string(), "url".to_string()),
15645 ("rust-toolchain".to_string(), "std".to_string()),
15646 ]),
15647 "external targets",
15648 )?;
15649 for key in &external_keys {
15650 let entity = reader
15651 .repository_graph_entity(key)?
15652 .ok_or("reopened external entity is missing")?;
15653 require(
15654 matches!(entity.selector(), EntitySelector::External { .. }),
15655 "external relation target reopened as a local entity",
15656 )?;
15657 require_eq(
15658 &entity.generation(),
15659 &generation,
15660 "external entity generation",
15661 )?;
15662 }
15663 let affected = reader.repository_affected_source_paths(
15664 project,
15665 &[unique_key, shared_key, local_package_key],
15666 32,
15667 )?;
15668 require(!affected.truncated, "dependency source proof was truncated")?;
15669 require_eq(
15670 &affected.rows,
15671 &vec![RepositoryFilePath::new(Path::new("src/main.rs"))?],
15672 "reopened dependency source paths",
15673 )?;
15674 reader.finish_index_read_snapshot()?;
15675 Ok(())
15676 }
15677
15678 struct ResolutionCase {
15679 graph: SymbolGraph,
15680 relation: SymbolRelation,
15681 keys: Vec<CanonicalResolutionKey>,
15682 }
15683
15684 fn resolution_case(
15685 language: &str,
15686 kind: RelationKind,
15687 target: &str,
15688 keys: &[&CanonicalResolutionKey],
15689 ) -> ResolutionCase {
15690 let parser = if language.starts_with("cargo-") {
15691 ParserKind::Manifest
15692 } else {
15693 ParserKind::TreeSitter
15694 };
15695 ResolutionCase {
15696 graph: SymbolGraph {
15697 path: "src/main.rs".to_string(),
15698 language: Some(language.to_string()),
15699 parser,
15700 symbols: Vec::new(),
15701 relations: Vec::new(),
15702 },
15703 relation: SymbolRelation {
15704 path: "src/main.rs".to_string(),
15705 source_name: "src/main.rs".to_string(),
15706 target_name: target.to_string(),
15707 kind,
15708 line: 1,
15709 context: target.to_string(),
15710 parser,
15711 },
15712 keys: keys.iter().map(|key| (*key).clone()).collect(),
15713 }
15714 }
15715
15716 fn test_resolution_key(
15717 project: ProjectInstanceId,
15718 identity: &str,
15719 ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
15720 let provider = GraphIdentityText::new("test-provider")?;
15721 let language = GraphIdentityText::new("test-language")?;
15722 let identity = GraphIdentityText::new(identity)?;
15723 Ok(CanonicalResolutionKey::new(
15724 project,
15725 ResolutionKeyDomain::Declaration,
15726 &provider,
15727 &language,
15728 None,
15729 None,
15730 None,
15731 &identity,
15732 ))
15733 }
15734
15735 fn test_file_entity(
15736 project: ProjectInstanceId,
15737 generation: IndexGeneration,
15738 path: &str,
15739 ) -> Result<GraphEntity, Box<dyn Error>> {
15740 Ok(GraphEntity::new(
15741 project,
15742 EntitySelector::File {
15743 path: RepositoryFilePath::new(Path::new(path))?,
15744 },
15745 generation,
15746 )?)
15747 }
15748
15749 fn test_symbol_entity(
15750 project: ProjectInstanceId,
15751 generation: IndexGeneration,
15752 path: &str,
15753 name: &str,
15754 ) -> Result<GraphEntity, Box<dyn Error>> {
15755 Ok(GraphEntity::new(
15756 project,
15757 EntitySelector::Symbol {
15758 symbol: SymbolSelector {
15759 file: RepositoryFilePath::new(Path::new(path))?,
15760 name: GraphIdentityText::new(name)?,
15761 kind: SymbolKind::Function,
15762 parent: None,
15763 signature: GraphIdentityText::new(format!("fn {name}()"))?,
15764 },
15765 },
15766 generation,
15767 )?)
15768 }
15769
15770 fn test_code_symbol(
15771 path: &str,
15772 name: &str,
15773 parent: Option<&str>,
15774 signature: &str,
15775 ) -> CodeSymbol {
15776 CodeSymbol {
15777 path: path.to_string(),
15778 language: Some("rust".to_string()),
15779 name: name.to_string(),
15780 kind: SymbolKind::Function,
15781 signature: signature.to_string(),
15782 exported: false,
15783 documentation: None,
15784 line_start: 1,
15785 line_end: 1,
15786 source_selector: None,
15787 parent: parent.map(ToString::to_string),
15788 parser: ParserKind::TreeSitter,
15789 detail: Some("function_item".to_string()),
15790 }
15791 }
15792
15793 #[test]
15794 fn source_graph_admission_keeps_valid_rows_and_typed_rejection_coverage()
15795 -> Result<(), Box<dyn Error>> {
15796 let valid = test_code_symbol("src/lib.rs", "valid", None, "fn valid()");
15797 let invalid_name = test_code_symbol("src/lib.rs", "bad\u{0}name", None, "fn bad()");
15798 let invalid_signature = test_code_symbol("src/lib.rs", "padded", None, " fn padded() ");
15799 let invalid_parent = test_code_symbol("src/lib.rs", "child", Some(" parent "), "child()");
15800 let invalid_reserved = test_code_symbol(
15801 "src/lib.rs",
15802 &format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}derived"),
15803 None,
15804 "fn derived()",
15805 );
15806 let invalid_oversized = test_code_symbol(
15807 "src/lib.rs",
15808 &"x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
15809 None,
15810 "fn oversized()",
15811 );
15812 let graph = SymbolGraph {
15813 path: "src/lib.rs".to_string(),
15814 language: Some("rust".to_string()),
15815 parser: ParserKind::TreeSitter,
15816 symbols: vec![
15817 valid,
15818 invalid_name,
15819 invalid_signature,
15820 invalid_parent,
15821 invalid_reserved,
15822 invalid_oversized,
15823 ],
15824 relations: vec![
15825 SymbolRelation {
15826 path: "src/lib.rs".to_string(),
15827 source_name: "valid".to_string(),
15828 target_name: "helper".to_string(),
15829 kind: RelationKind::Calls,
15830 line: 1,
15831 context: "helper()".to_string(),
15832 parser: ParserKind::TreeSitter,
15833 },
15834 SymbolRelation {
15835 path: "src/lib.rs".to_string(),
15836 source_name: "valid".to_string(),
15837 target_name: "bad\u{0}target".to_string(),
15838 kind: RelationKind::Calls,
15839 line: 2,
15840 context: "bad()".to_string(),
15841 parser: ParserKind::TreeSitter,
15842 },
15843 SymbolRelation {
15844 path: "src/lib.rs".to_string(),
15845 source_name: " valid ".to_string(),
15846 target_name: "helper".to_string(),
15847 kind: RelationKind::Calls,
15848 line: 3,
15849 context: "helper()".to_string(),
15850 parser: ParserKind::TreeSitter,
15851 },
15852 ],
15853 };
15854 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15855 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
15856 require_eq(&admitted.symbols.len(), &1, "valid symbol admission")?;
15857 require_eq(&admitted.relations.len(), &1, "valid relation admission")?;
15858 require_eq(
15859 &report.rejected_facts_for("src/lib.rs"),
15860 &7,
15861 "rejected fact count",
15862 )?;
15863 require_eq(
15864 &admitted.symbols[0].signature,
15865 &"fn valid()".to_string(),
15866 "valid signature preservation",
15867 )?;
15868
15869 let coverage = super::coverage_for_graph(
15870 &admitted,
15871 IndexGeneration::new(1),
15872 &report,
15873 &super::GraphIdentityAdmission::default(),
15874 )?;
15875 require_eq(
15876 &coverage.state(),
15877 &CoverageState::Partial,
15878 "admission coverage state",
15879 )?;
15880 require_eq(&coverage.covered(), &2, "admission covered count")?;
15881 require_eq(&coverage.omitted(), &7, "admission omitted count")?;
15882 let reason = coverage
15883 .reason()
15884 .ok_or_else(|| io::Error::other("admission coverage omitted its reason"))?
15885 .as_str();
15886 require_eq(
15887 &reason,
15888 &PARTIAL_COVERAGE_REASON,
15889 "admission coverage keeps the stable coarse reason",
15890 )?;
15891 require_eq(&report.rejections.len(), &7, "typed rejection detail count")?;
15892 require(
15893 report.rejections.iter().all(|rejection| {
15894 rejection.path.as_str() == "src/lib.rs"
15895 && rejection.parser == ParserKind::TreeSitter
15896 && rejection.span.start_line() >= 1
15897 && rejection.span.end_line() >= rejection.span.start_line()
15898 }),
15899 "typed rejection details lost path/parser/span ownership",
15900 )?;
15901 require(
15902 report
15903 .rejections
15904 .iter()
15905 .any(|rejection| rejection.field == GraphIdentityField::RelationTarget),
15906 "typed rejection details lost relation-target ownership",
15907 )?;
15908
15909 let temp = tempfile::tempdir()?;
15910 fs::create_dir_all(temp.path().join("src"))?;
15911 let nodes = vec![test_file_node("src/lib.rs", "rust")];
15912 let project = ProjectInstanceId::from_bytes([71; 16])?;
15913 let generation = IndexGeneration::new(1);
15914 let packages = PackageIndex::from_graphs(std::slice::from_ref(&admitted))?;
15915 let projection = build_entity_projection(
15916 project,
15917 generation,
15918 &nodes,
15919 std::slice::from_ref(&admitted),
15920 &packages,
15921 true,
15922 &control,
15923 )?;
15924 let candidates = resolution_registry_from_exports(&projection, &control)?;
15925 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
15926 let staged = super::finish_projection_with_documents(
15927 project,
15928 generation,
15929 RepositoryGraphMutation::Full,
15930 std::slice::from_ref(&admitted),
15931 temp.path(),
15932 &nodes,
15933 &BTreeMap::new(),
15934 &report,
15935 projection,
15936 &candidates,
15937 &scan_policy,
15938 &control,
15939 )?;
15940 require_eq(&staged.relations.len(), &1, "valid relation publication")?;
15941 require(
15942 staged.entities.iter().any(|entity| {
15943 matches!(
15944 entity.selector(),
15945 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "valid"
15946 )
15947 }),
15948 "valid symbol publication was lost with invalid siblings",
15949 )?;
15950 require_eq(
15951 &staged.identity_rejections.len(),
15952 &7,
15953 "typed rejection coverage was not staged with valid rows",
15954 )?;
15955 Ok(())
15956 }
15957
15958 #[test]
15959 fn invalid_sibling_does_not_fail_symbol_only_coverage() -> Result<(), Box<dyn Error>> {
15960 let graph = SymbolGraph {
15961 path: "src/symbol-only.rs".to_string(),
15962 language: Some("rust".to_string()),
15963 parser: ParserKind::TreeSitter,
15964 symbols: vec![
15965 test_code_symbol("src/symbol-only.rs", "valid", None, "fn valid()"),
15966 test_code_symbol("src/symbol-only.rs", "bad\u{0}name", None, "fn bad()"),
15967 ],
15968 relations: Vec::new(),
15969 };
15970 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15971 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
15972 let coverage = super::coverage_for_graph(
15973 &admitted,
15974 IndexGeneration::new(1),
15975 &report,
15976 &super::GraphIdentityAdmission::default(),
15977 )?;
15978 require_eq(
15979 &coverage.state(),
15980 &CoverageState::Partial,
15981 "valid symbols keep mixed identity coverage partial",
15982 )?;
15983 require_eq(
15984 &coverage.covered(),
15985 &1,
15986 "valid symbol is counted as covered",
15987 )?;
15988 require_eq(
15989 &coverage.omitted(),
15990 &1,
15991 "invalid sibling is counted as omitted",
15992 )?;
15993 require_eq(
15994 &coverage.reason().map(GraphIdentityText::as_str),
15995 &Some(PARTIAL_COVERAGE_REASON),
15996 "symbol-only coverage keeps the coarse reason",
15997 )?;
15998 Ok(())
15999 }
16000
16001 #[test]
16002 fn document_target_normalization_is_relative_bounded_and_platform_neutral()
16003 -> Result<(), Box<dyn Error>> {
16004 require_eq(
16005 &normalize_document_target("docs/guide.md", "../src/lib.rs:L12-L20?view=raw#entry")
16006 .map_err(|reason| io::Error::other(reason.to_string()))?,
16007 &DocumentTargetIdentity {
16008 path: "src/lib.rs".to_string(),
16009 fragment: Some("entry".to_string()),
16010 },
16011 "relative document target",
16012 )?;
16013 require_eq(
16014 &normalize_document_target("guide.md", "README")
16015 .map_err(|reason| io::Error::other(reason.to_string()))?,
16016 &DocumentTargetIdentity {
16017 path: "README".to_string(),
16018 fragment: None,
16019 },
16020 "root extensionless target",
16021 )?;
16022 require_eq(
16023 &normalize_document_target("docs/guide.md", "../../../private.txt"),
16024 &Err(DocumentTargetUnresolvedReason::OutsideRoot),
16025 "outside-root selector",
16026 )?;
16027 require_eq(
16028 &normalize_document_target("docs/guide.md", "target.md#runtime value"),
16029 &Err(DocumentTargetUnresolvedReason::NoStaticTarget),
16030 "non-static fragment refusal",
16031 )?;
16032 Ok(())
16033 }
16034
16035 #[test]
16036 fn document_rows_resolve_exact_files_and_headings_with_typed_absence()
16037 -> Result<(), Box<dyn Error>> {
16038 let temp = tempfile::tempdir()?;
16039 let root = temp.path();
16040 fs::create_dir_all(root.join("docs"))?;
16041 fs::write(root.join(".gitignore"), "docs/ignored.md\n")?;
16042 fs::write(root.join("docs/ignored.md"), "ignored")?;
16043 let source = "[lib](../src/lib.rs)\n[heading](target.md#api)\n[missing heading](target.md#absent)\n[missing](missing.md)\n[ignored](ignored.md)\n[case](../SRC/lib.rs)\n[folder](../src)\n[outside](../../../private.txt)\n[self](guide.md)\n[self heading](guide.md#api)\n";
16044 let source_facts = projectatlas_symbols::extract_markdown_facts(source);
16045 let target_facts = projectatlas_symbols::extract_markdown_facts("# API\n");
16046 let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
16047 let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
16048 let source_code_graph = SymbolGraph {
16049 path: "src/lib.rs".to_string(),
16050 language: Some("rust".to_string()),
16051 parser: ParserKind::TreeSitter,
16052 symbols: Vec::new(),
16053 relations: Vec::new(),
16054 };
16055 let graphs = vec![source_graph, target_graph, source_code_graph];
16056 let mut nodes = vec![
16057 test_file_node("docs/guide.md", "markdown"),
16058 test_file_node("docs/target.md", "markdown"),
16059 test_file_node("src/lib.rs", "rust"),
16060 ];
16061 let mut folder = test_file_node("src", "unknown");
16062 folder.kind = NodeKind::Folder;
16063 folder.language = None;
16064 folder.extension = None;
16065 nodes.push(folder);
16066 let project = ProjectInstanceId::from_bytes([31; 16])?;
16067 let generation = IndexGeneration::new(4);
16068 let packages = PackageIndex::from_graphs(&graphs)?;
16069 let control = super::super::standalone_index_work_control();
16070 let mut projection = build_entity_projection(
16071 project, generation, &nodes, &graphs, &packages, true, &control,
16072 )?;
16073 let candidates = resolution_registry_from_exports(&projection, &control)?;
16074 let owners = projection
16075 .owners_by_graph
16076 .remove("docs/guide.md")
16077 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16078 let scan_policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
16079 let index = DocumentResolutionIndex::new(root, &nodes, &scan_policy)?;
16080 let rows = project_document_rows(
16081 project,
16082 generation,
16083 &graphs[0],
16084 &source_facts,
16085 &owners,
16086 &index,
16087 &candidates,
16088 &projection.entity_by_digest,
16089 &control,
16090 )?;
16091 require_eq(&rows.relations.len(), &9, "document relation count")?;
16092 require_eq(&rows.occurrences.len(), &9, "document occurrence count")?;
16093 require_eq(
16094 &rows
16095 .relations
16096 .iter()
16097 .filter(|relation| {
16098 matches!(relation.resolution(), RelationResolution::Resolved { .. })
16099 })
16100 .count(),
16101 &2,
16102 "resolved document targets",
16103 )?;
16104 let reasons = rows
16105 .document_unresolved_reasons
16106 .iter()
16107 .map(|(_key, reason)| *reason)
16108 .collect::<BTreeSet<_>>();
16109 require_eq(
16110 &reasons,
16111 &BTreeSet::from([
16112 DocumentTargetUnresolvedReason::Missing,
16113 DocumentTargetUnresolvedReason::Ignored,
16114 DocumentTargetUnresolvedReason::OutsideRoot,
16115 DocumentTargetUnresolvedReason::CaseConflict,
16116 DocumentTargetUnresolvedReason::Unsupported,
16117 ]),
16118 "typed unresolved reasons",
16119 )?;
16120 require(
16121 rows.relations.iter().any(|relation| {
16122 matches!(
16123 relation.resolution(),
16124 RelationResolution::Resolved {
16125 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16126 symbol
16127 },
16128 ..
16129 } if symbol.kind == SymbolKind::Heading && symbol.signature.as_str() == "api"
16130 )
16131 }),
16132 "heading fragment did not resolve to its heading entity",
16133 )?;
16134 Ok(())
16135 }
16136
16137 #[test]
16138 fn markdown_identity_admission_keeps_valid_document_siblings() -> Result<(), Box<dyn Error>> {
16139 let temp = tempfile::tempdir()?;
16140 let root = fs::canonicalize(temp.path())?;
16141 fs::create_dir_all(root.join("docs"))?;
16142 let guide_source = "# Guide\n\n[invalid](<target.md\u{1}>)\n[valid](target.md#target)\n";
16143 fs::write(root.join("docs/guide.md"), guide_source)?;
16144 fs::write(root.join("docs/target.md"), "# Target\n")?;
16145 let source_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
16146 require_eq(
16147 &source_facts.link_candidates.len(),
16148 &2,
16149 "parser Markdown sibling candidate count",
16150 )?;
16151 require_eq(
16152 &source_facts.link_candidates[0].selector,
16153 &"target.md\u{1}".to_string(),
16154 "parser Markdown control selector",
16155 )?;
16156 let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
16157 let target_facts = projectatlas_symbols::extract_markdown_facts("# Target\n");
16158 let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
16159 let nodes = vec![
16160 test_file_node("docs/guide.md", "markdown"),
16161 test_file_node("docs/target.md", "markdown"),
16162 ];
16163 let control = super::super::standalone_index_work_control();
16164 let mut symbols = empty_symbol_build_stage();
16165 symbols.report.candidates = 2;
16166 symbols.report.parsed = 2;
16167 symbols.report.summaries = 2;
16168 symbols.changes = vec![
16169 SymbolProjectionChange::Parsed(SymbolParseSuccess {
16170 path: "docs/guide.md".to_string(),
16171 graph: source_graph.clone(),
16172 markdown_facts: Some(Box::new(source_facts)),
16173 source_semantics: None,
16174 source_parser: ParserKind::Structural,
16175 summary: "Guide".to_string(),
16176 summary_is_structural: true,
16177 purpose_suggestion: None,
16178 }),
16179 SymbolProjectionChange::Parsed(SymbolParseSuccess {
16180 path: "docs/target.md".to_string(),
16181 graph: target_graph.clone(),
16182 markdown_facts: Some(Box::new(target_facts)),
16183 source_semantics: None,
16184 source_parser: ParserKind::Structural,
16185 summary: "Target".to_string(),
16186 summary_is_structural: true,
16187 purpose_suggestion: None,
16188 }),
16189 ];
16190 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
16191 let admitted_counts = symbols
16192 .changes
16193 .iter()
16194 .filter_map(|change| match change {
16195 SymbolProjectionChange::Parsed(parsed) => {
16196 Some((parsed.graph.symbols.len(), parsed.graph.relations.len()))
16197 }
16198 SymbolProjectionChange::Clear { .. } => None,
16199 })
16200 .fold(
16201 (0, 0),
16202 |(symbols, relations), (next_symbols, next_relations)| {
16203 (symbols + next_symbols, relations + next_relations)
16204 },
16205 );
16206 require_eq(
16207 &(symbols.report.symbols, symbols.report.relations),
16208 &admitted_counts,
16209 "post-admission symbol report counts",
16210 )?;
16211 require_eq(
16212 &symbols.identity_admission.rejections.len(),
16213 &1,
16214 "Markdown rejection detail count",
16215 )?;
16216 let rejection = symbols
16217 .identity_admission
16218 .rejections
16219 .first()
16220 .ok_or_else(|| io::Error::other("Markdown rejection detail is missing"))?;
16221 require_eq(
16222 &rejection.path.as_str(),
16223 &"docs/guide.md",
16224 "Markdown rejection path",
16225 )?;
16226 require_eq(
16227 &rejection.parser,
16228 &ParserKind::Structural,
16229 "Markdown rejection parser",
16230 )?;
16231 require_eq(
16232 &rejection.field,
16233 &GraphIdentityField::RelationTarget,
16234 "Markdown rejection field",
16235 )?;
16236 require_eq(
16237 &rejection.reason,
16238 &GraphIdentityRejectionReason::ControlCharacters,
16239 "Markdown rejection reason",
16240 )?;
16241 require_eq(
16242 &rejection.span.start_line(),
16243 &3,
16244 "Markdown rejection start line",
16245 )?;
16246 require_eq(
16247 &rejection.span.start_column(),
16248 &0,
16249 "Markdown rejection start column",
16250 )?;
16251 require_eq(
16252 &rejection.span.end_line(),
16253 &3,
16254 "Markdown rejection end line",
16255 )?;
16256 require_eq(
16257 &rejection.span.end_column(),
16258 &23,
16259 "Markdown rejection end column",
16260 )?;
16261 let database = root.join("projectatlas.db");
16262 let mut store = AtlasStore::open_for_project(&database, &root)?;
16263 store.replace_scan(&nodes)?;
16264 store.replace_symbol_graph(&source_graph)?;
16265 store.replace_symbol_graph(&target_graph)?;
16266 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
16267 let staged = stage_full_repository_graph(
16268 &store,
16269 &root,
16270 IndexGeneration::ZERO,
16271 &nodes,
16272 &scan_policy,
16273 &symbols,
16274 &control,
16275 )?;
16276 require_eq(
16277 &staged.identity_rejections.len(),
16278 &1,
16279 "staged Markdown rejection detail count",
16280 )?;
16281 require_eq(
16282 &staged.relations.len(),
16283 &1,
16284 "staged valid Markdown relation count",
16285 )?;
16286 require(
16287 staged.coverage.iter().any(|coverage| {
16288 matches!(
16289 coverage.scope(),
16290 CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
16291 ) && coverage.state() == CoverageState::Complete
16292 && coverage.covered() == 1
16293 && coverage.omitted() == 0
16294 }),
16295 "invalid Markdown selector changed valid-sibling coverage semantics",
16296 )?;
16297 require(
16298 staged.relations.iter().any(|relation| {
16299 matches!(
16300 relation.resolution(),
16301 RelationResolution::Resolved {
16302 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16303 symbol
16304 },
16305 ..
16306 } if symbol.file.as_str() == "docs/target.md"
16307 && symbol.signature.as_str() == "target"
16308 )
16309 }),
16310 "staged valid Markdown sibling was not resolved",
16311 )?;
16312 publish_full_staged_graph(&mut store, &nodes, &staged, &control, "markdown-admission")?;
16313 drop(store);
16314 let mut store = AtlasStore::open_for_project(&database, &root)?;
16315 let project = store
16316 .project_instance_id()?
16317 .ok_or("Markdown admission project identity is missing")?;
16318 let paths = nodes
16319 .iter()
16320 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
16321 .collect::<Result<Vec<_>, _>>()?;
16322 let persisted_rejections =
16323 store.repository_graph_identity_rejections(project, &paths, 16, None)?;
16324 require_eq(
16325 &persisted_rejections.len(),
16326 &1,
16327 "persisted Markdown rejection detail count",
16328 )?;
16329 let persisted_rejection = persisted_rejections
16330 .first()
16331 .ok_or("persisted Markdown rejection detail is missing")?;
16332 require_eq(
16333 &persisted_rejection.span.start_line(),
16334 &3,
16335 "persisted Markdown rejection start line",
16336 )?;
16337 require_eq(
16338 &persisted_rejection.span.start_column(),
16339 &0,
16340 "persisted Markdown rejection start column",
16341 )?;
16342 require_eq(
16343 &persisted_rejection.span.end_line(),
16344 &3,
16345 "persisted Markdown rejection end line",
16346 )?;
16347 require_eq(
16348 &persisted_rejection.span.end_column(),
16349 &23,
16350 "persisted Markdown rejection end column",
16351 )?;
16352 let persisted_wire = serde_json::to_string(&persisted_rejections)?;
16353 require(
16354 !persisted_wire.contains("\\u0001") && !persisted_wire.contains("target.md"),
16355 "persisted Markdown rejection retained the invalid selector",
16356 )?;
16357 let persisted_relations = store.repository_graph_relation_rows(
16358 RepositoryGraphRelationQuery::Family {
16359 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
16360 },
16361 GraphLimits::MAX_ROWS,
16362 None,
16363 )?;
16364 require_eq(
16365 &persisted_relations.rows.len(),
16366 &1,
16367 "reopened valid Markdown relation count",
16368 )?;
16369 require(
16370 persisted_relations.rows.iter().any(|relation| {
16371 matches!(
16372 relation.relation.resolution(),
16373 RelationResolution::Resolved {
16374 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
16375 symbol
16376 },
16377 ..
16378 } if symbol.file.as_str() == "docs/target.md"
16379 && symbol.signature.as_str() == "target"
16380 )
16381 }),
16382 "reopened valid Markdown sibling was not resolved",
16383 )?;
16384
16385 let base_generation = store
16386 .index_publication()?
16387 .ok_or("Markdown admission publication is missing")?
16388 .generation;
16389 let mut incremental_symbols = symbol_build_stage_for_markdown(
16390 source_graph.clone(),
16391 projectatlas_symbols::extract_markdown_facts(guide_source),
16392 );
16393 incremental_symbols.identity_admission =
16394 super::admit_symbol_build_stage(&mut incremental_symbols, &control)?;
16395 let incremental_stage = stage_incremental_repository_graph(
16396 &store,
16397 &root,
16398 base_generation,
16399 &nodes,
16400 &["docs/guide.md".to_string()],
16401 &scan_policy,
16402 &incremental_symbols,
16403 &control,
16404 )?;
16405 require_eq(
16406 &incremental_stage.identity_rejections.len(),
16407 &1,
16408 "incremental Markdown rejection detail count",
16409 )?;
16410 require_eq(
16411 &incremental_stage.relations.len(),
16412 &1,
16413 "incremental valid Markdown relation count",
16414 )?;
16415 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
16416 canceled.cancel();
16417 {
16418 let mut publication = store.begin_index_publication("markdown-admission-cancel")?;
16419 let error = incremental_stage.apply(&mut publication, &canceled).err();
16420 require(
16421 matches!(
16422 error,
16423 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
16424 stage: IndexWorkStage::Publication
16425 }))
16426 ),
16427 "incremental Markdown cancellation was not observed",
16428 )?;
16429 }
16430 require_eq(
16431 &store
16432 .index_publication()?
16433 .map(|publication| publication.generation),
16434 &Some(base_generation),
16435 "publication after canceled Markdown refresh",
16436 )?;
16437 {
16438 let mut publication =
16439 store.begin_index_publication("markdown-admission-incremental")?;
16440 incremental_stage.apply(&mut publication, &control)?;
16441 publication.complete()?;
16442 }
16443 let incremental_publication = store
16444 .index_publication()?
16445 .ok_or("incremental Markdown publication is missing")?;
16446 require_eq(
16447 &incremental_publication.generation,
16448 &base_generation
16449 .checked_next()
16450 .ok_or("Markdown generation overflowed")?,
16451 "incremental Markdown generation",
16452 )?;
16453 drop(store);
16454 let mut store = AtlasStore::open_for_project(&database, &root)?;
16455 let reopened_incremental =
16456 store.repository_graph_identity_rejections(project, &paths, 16, None)?;
16457 require_eq(
16458 &reopened_incremental,
16459 &persisted_rejections,
16460 "reopened incremental Markdown rejection details",
16461 )?;
16462
16463 let fault_generation = store
16464 .index_publication()?
16465 .ok_or("incremental Markdown publication disappeared")?
16466 .generation;
16467 let mut fault_symbols = symbol_build_stage_for_markdown(
16468 source_graph.clone(),
16469 projectatlas_symbols::extract_markdown_facts(guide_source),
16470 );
16471 fault_symbols.identity_admission =
16472 super::admit_symbol_build_stage(&mut fault_symbols, &control)?;
16473 let mut fault_stage = stage_incremental_repository_graph(
16474 &store,
16475 &root,
16476 fault_generation,
16477 &nodes,
16478 &["docs/guide.md".to_string()],
16479 &scan_policy,
16480 &fault_symbols,
16481 &control,
16482 )?;
16483 fault_stage.identity_rejections.resize(
16484 usize::try_from(GraphLimits::MAX_ROWS)
16485 .unwrap_or(usize::MAX)
16486 .saturating_add(1),
16487 fault_stage.identity_rejections[0].clone(),
16488 );
16489 {
16490 let mut publication = store.begin_index_publication("markdown-admission-fault")?;
16491 require(
16492 fault_stage.apply(&mut publication, &control).is_err(),
16493 "late Markdown rejection-detail fault did not fail",
16494 )?;
16495 }
16496 require_eq(
16497 &store
16498 .index_publication()?
16499 .map(|publication| publication.generation),
16500 &Some(fault_generation),
16501 "generation after late Markdown rejection-detail fault",
16502 )?;
16503 require_eq(
16504 &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
16505 &reopened_incremental,
16506 "previous Markdown generation after late fault",
16507 )?;
16508 let retry_symbols = symbol_build_stage_for_markdown(
16509 source_graph,
16510 projectatlas_symbols::extract_markdown_facts(guide_source),
16511 );
16512 let retry_stage = stage_incremental_repository_graph(
16513 &store,
16514 &root,
16515 fault_generation,
16516 &nodes,
16517 &["docs/guide.md".to_string()],
16518 &scan_policy,
16519 &retry_symbols,
16520 &control,
16521 )?;
16522 {
16523 let mut publication = store.begin_index_publication("markdown-admission-retry")?;
16524 retry_stage.apply(&mut publication, &control)?;
16525 publication.complete()?;
16526 }
16527 require_eq(
16528 &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
16529 &reopened_incremental,
16530 "deterministic Markdown retry",
16531 )?;
16532 Ok(())
16533 }
16534
16535 #[test]
16536 fn document_rows_use_file_identity_and_deduplicate_across_headings()
16537 -> Result<(), Box<dyn Error>> {
16538 let temp = tempfile::tempdir()?;
16539 fs::create_dir_all(temp.path().join("src"))?;
16540 fs::write(temp.path().join("src/lib.rs"), "pub fn entry() {}\n")?;
16541 let source = "# First\n[target](../src/lib.rs)\n[second](guide.md#second)\n[self](guide.md#first)\n\n# Second\n[target](../src/lib.rs)\n[target](../src/lib.rs)\n[first](guide.md#first)\n[self](guide.md#second)\n";
16542 let facts = projectatlas_symbols::extract_markdown_facts(source);
16543 let enclosing_heading_bytes = facts
16544 .link_candidates
16545 .iter()
16546 .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
16547 .sum::<u64>();
16548 let mut facts_without_heading_owners = facts.clone();
16549 for candidate in &mut facts_without_heading_owners.link_candidates {
16550 candidate.enclosing_heading = None;
16551 }
16552 let retained_with_heading_owners = document_fact_map_retained_bytes(&BTreeMap::from([(
16553 "docs/guide.md".to_string(),
16554 Cow::Owned(facts.clone()),
16555 )]));
16556 let retained_without_heading_owners =
16557 document_fact_map_retained_bytes(&BTreeMap::from([(
16558 "docs/guide.md".to_string(),
16559 Cow::Owned(facts_without_heading_owners),
16560 )]));
16561 require_eq(
16562 &retained_with_heading_owners.saturating_sub(retained_without_heading_owners),
16563 &enclosing_heading_bytes,
16564 "document registry enclosing-heading bytes",
16565 )?;
16566 let source_graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
16567 let target_graph = SymbolGraph {
16568 path: "src/lib.rs".to_string(),
16569 language: Some("rust".to_string()),
16570 parser: ParserKind::TreeSitter,
16571 symbols: Vec::new(),
16572 relations: Vec::new(),
16573 };
16574 let graphs = vec![source_graph, target_graph];
16575 let nodes = vec![
16576 test_file_node("docs/guide.md", "markdown"),
16577 test_file_node("src/lib.rs", "rust"),
16578 ];
16579 let project = ProjectInstanceId::from_bytes([33; 16])?;
16580 let generation = IndexGeneration::new(5);
16581 let packages = PackageIndex::from_graphs(&graphs)?;
16582 let control = super::super::standalone_index_work_control();
16583 let mut projection = build_entity_projection(
16584 project, generation, &nodes, &graphs, &packages, true, &control,
16585 )?;
16586 let candidates = resolution_registry_from_exports(&projection, &control)?;
16587 let owners = projection
16588 .owners_by_graph
16589 .remove("docs/guide.md")
16590 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16591 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16592 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16593 let rows = project_document_rows(
16594 project,
16595 generation,
16596 &graphs[0],
16597 &facts,
16598 &owners,
16599 &index,
16600 &candidates,
16601 &projection.entity_by_digest,
16602 &control,
16603 )?;
16604 require_eq(&rows.relations.len(), &3, "file-owned relation count")?;
16605 require_eq(&rows.occurrences.len(), &7, "distinct link occurrences")?;
16606 require(
16607 rows.relations.iter().all(|relation| {
16608 projection
16609 .entity_by_digest
16610 .get(relation.source().digest())
16611 .is_some_and(|entity| {
16612 matches!(
16613 entity.selector(),
16614 EntitySelector::File { path } if path.as_str() == "docs/guide.md"
16615 )
16616 })
16617 }),
16618 "document relations were not owned by the document file",
16619 )?;
16620 let heading_targets = rows
16621 .relations
16622 .iter()
16623 .filter_map(|relation| {
16624 let RelationResolution::Resolved {
16625 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol { symbol },
16626 ..
16627 } = relation.resolution()
16628 else {
16629 return None;
16630 };
16631 (symbol.kind == SymbolKind::Heading).then(|| symbol.signature.as_str().to_string())
16632 })
16633 .collect::<BTreeSet<_>>();
16634 require_eq(
16635 &heading_targets,
16636 &BTreeSet::from(["first".to_string(), "second".to_string()]),
16637 "file-owned heading targets",
16638 )?;
16639 let source_target_occurrences = rows
16640 .occurrences
16641 .iter()
16642 .filter(|occurrence| occurrence.file().as_str() == "docs/guide.md")
16643 .count();
16644 require_eq(
16645 &source_target_occurrences,
16646 &7,
16647 "file-owned relation occurrences",
16648 )?;
16649 Ok(())
16650 }
16651
16652 #[test]
16653 fn complete_document_without_static_candidates_reports_no_candidates()
16654 -> Result<(), Box<dyn Error>> {
16655 let facts = projectatlas_symbols::extract_markdown_facts(
16656 "# Overview\n\nLong prose without a repository reference.\n",
16657 );
16658 let coverage = document_coverage("docs/overview.md", &facts, IndexGeneration::new(6))?;
16659 require_eq(
16660 &coverage.state(),
16661 &CoverageState::NoCandidates,
16662 "empty document relation coverage",
16663 )?;
16664 require_eq(&coverage.total(), &0, "empty document relation total")?;
16665 Ok(())
16666 }
16667
16668 #[test]
16669 fn partial_markdown_evidence_limit_maps_to_intermediate_bytes_coverage()
16670 -> Result<(), Box<dyn Error>> {
16671 let label = "l".repeat(MAX_MARKDOWN_LABEL_BYTES);
16672 let selector = format!(
16673 "src/{}.rs",
16674 "s".repeat(MAX_DOCUMENT_SELECTOR_BYTES - "src/".len() - ".rs".len())
16675 );
16676 let evidence_bytes = label.len() + selector.len();
16677 let source = format!("[{label}]({selector})\n")
16678 .repeat(MAX_MARKDOWN_EVIDENCE_BYTES / evidence_bytes + 1);
16679 let facts = projectatlas_symbols::extract_markdown_facts(&source);
16680 require(
16681 facts
16682 .coverage
16683 .limits
16684 .contains(&MarkdownFactLimit::EvidenceBytes),
16685 "real Markdown extraction did not reach its evidence-byte limit",
16686 )?;
16687 require(
16688 !facts.link_candidates.is_empty(),
16689 "evidence-limited Markdown extraction lost every valid candidate",
16690 )?;
16691
16692 let coverage = document_coverage("docs/limited.md", &facts, IndexGeneration::new(7))?;
16693 require_eq(
16694 &coverage.state(),
16695 &CoverageState::Partial,
16696 "evidence-limited document coverage state",
16697 )?;
16698 require_eq(
16699 &coverage.reached_limit(),
16700 &Some(GraphLimitKind::IntermediateBytes),
16701 "evidence-limited document graph limit",
16702 )?;
16703 Ok(())
16704 }
16705
16706 #[test]
16707 fn document_cycles_emit_only_canonical_bounded_edges() -> Result<(), Box<dyn Error>> {
16708 let temp = tempfile::tempdir()?;
16709 fs::create_dir_all(temp.path().join("docs"))?;
16710 fs::write(temp.path().join("docs/a.md"), "# A\n\n[b](b.md#b)\n")?;
16711 fs::write(temp.path().join("docs/b.md"), "# B\n\n[a](a.md#a)\n")?;
16712 let facts = [
16713 projectatlas_symbols::extract_markdown_facts("# A\n\n[b](b.md#b)\n"),
16714 projectatlas_symbols::extract_markdown_facts("# B\n\n[a](a.md#a)\n"),
16715 ];
16716 let graphs = vec![
16717 facts[0].symbol_graph("docs/a.md", Some("markdown")),
16718 facts[1].symbol_graph("docs/b.md", Some("markdown")),
16719 ];
16720 let nodes = vec![
16721 test_file_node("docs/a.md", "markdown"),
16722 test_file_node("docs/b.md", "markdown"),
16723 ];
16724 let project = ProjectInstanceId::from_bytes([35; 16])?;
16725 let generation = IndexGeneration::new(1);
16726 let packages = PackageIndex::from_graphs(&graphs)?;
16727 let control = super::super::standalone_index_work_control();
16728 let mut projection = build_entity_projection(
16729 project, generation, &nodes, &graphs, &packages, true, &control,
16730 )?;
16731 let candidates = resolution_registry_from_exports(&projection, &control)?;
16732 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16733 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16734 let mut relations = Vec::new();
16735 for (graph, facts) in graphs.iter().zip(&facts) {
16736 let owners = projection
16737 .owners_by_graph
16738 .remove(&graph.path)
16739 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
16740 relations.extend(
16741 project_document_rows(
16742 project,
16743 generation,
16744 graph,
16745 facts,
16746 &owners,
16747 &index,
16748 &candidates,
16749 &projection.entity_by_digest,
16750 &control,
16751 )?
16752 .relations,
16753 );
16754 }
16755 require_eq(&relations.len(), &2, "document cycle relation count")?;
16756 require(
16757 relations.iter().all(|relation| {
16758 relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
16759 && matches!(
16760 relation.resolution(),
16761 RelationResolution::Resolved {
16762 selector: ReusableTargetSelector::Symbol { symbol },
16763 ..
16764 } if symbol.kind == SymbolKind::Heading
16765 )
16766 }),
16767 "document cycle emitted a non-canonical or unresolved edge",
16768 )?;
16769 Ok(())
16770 }
16771
16772 #[test]
16773 fn document_casefold_collisions_refuse_exact_winners_and_share_invalidation_keys()
16774 -> Result<(), Box<dyn Error>> {
16775 let temp = tempfile::tempdir()?;
16776 let nodes = vec![
16777 test_file_node("src/lib.rs", "rust"),
16778 test_file_node("SRC/lib.rs", "rust"),
16779 ];
16780 let control = super::super::standalone_index_work_control();
16781 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16782 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
16783 require_eq(
16784 &index.unresolved_reason("src/lib.rs")?,
16785 &Some(DocumentTargetUnresolvedReason::CaseConflict),
16786 "exact case-collision target",
16787 )?;
16788 let project = ProjectInstanceId::from_bytes([32; 16])?;
16789 require_eq(
16790 &document_casefold_resolution_key(project, "src/lib.rs")?,
16791 &document_casefold_resolution_key(project, "SRC/lib.rs")?,
16792 "casefold invalidation key",
16793 )?;
16794 Ok(())
16795 }
16796
16797 #[test]
16798 fn document_target_state_change_refuses_stale_publication() -> Result<(), Box<dyn Error>> {
16799 let temp = tempfile::tempdir()?;
16800 let control = super::super::standalone_index_work_control();
16801 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
16802 let index = DocumentResolutionIndex::new(temp.path(), &[], &scan_policy)?;
16803 let actual = index
16804 .unresolved_reason("docs/target.md")?
16805 .ok_or_else(|| std::io::Error::other("absent target unexpectedly resolved"))?;
16806 let stale = if actual == DocumentTargetUnresolvedReason::Missing {
16807 DocumentTargetUnresolvedReason::Ignored
16808 } else {
16809 DocumentTargetUnresolvedReason::Missing
16810 };
16811 let document_target_states = vec![("docs/target.md".to_string(), stale)];
16812 drop(index);
16813 let staged = StagedRepositoryGraph {
16814 project: ProjectInstanceId::from_bytes([34; 16])?,
16815 mutation: RepositoryGraphMutation::Full,
16816 entities: Vec::new(),
16817 relations: Vec::new(),
16818 occurrences: Vec::new(),
16819 coverage: Vec::new(),
16820 entity_exports: Vec::new(),
16821 relation_dependencies: Vec::new(),
16822 document_unresolved_reasons: Vec::new(),
16823 identity_rejections: Vec::new(),
16824 resolution_derivations: BTreeMap::new(),
16825 peak_retained_bytes: 0,
16826 projection_removals_before_entities: Vec::new(),
16827 scan_policy,
16828 document_target_states,
16829 database: None,
16830 source_recovery: SourceRecoveryWork::default(),
16831 retained_bytes: 0,
16832 };
16833 require(
16834 matches!(
16835 staged.revalidate_document_targets(temp.path()),
16836 Err(CliError::RefreshRequired(_))
16837 ),
16838 "changed non-indexed target state reached publication",
16839 )?;
16840 Ok(())
16841 }
16842
16843 fn capture_test_file_hashes(root: &Path, nodes: &mut [Node]) -> Result<(), Box<dyn Error>> {
16845 for node in nodes.iter_mut().filter(|node| node.kind == NodeKind::File) {
16846 let bytes = fs::read(root.join(&node.path))?;
16847 node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
16848 node.size_bytes = Some(u64::try_from(bytes.len())?);
16849 }
16850 Ok(())
16851 }
16852
16853 fn test_file_node(path: &str, language: &str) -> Node {
16854 Node {
16855 path: path.to_string(),
16856 kind: NodeKind::File,
16857 parent_path: path
16858 .rsplit_once('/')
16859 .map(|(parent, _name)| parent.to_string()),
16860 extension: Path::new(path)
16861 .extension()
16862 .map(|extension| format!(".{}", extension.to_string_lossy())),
16863 language: Some(language.to_string()),
16864 size_bytes: Some(1),
16865 mtime_ns: Some(1),
16866 content_hash: Some(format!("hash:{path}")),
16867 }
16868 }
16869
16870 #[test]
16871 fn large_document_projection_publishes_in_memory_staging_and_incremental()
16872 -> Result<(), Box<dyn Error>> {
16873 const DOCUMENT_COUNT: usize = 10;
16874 const LINKS_PER_DOCUMENT: usize = 1_024;
16875 const INCREMENTAL_LINKS: usize = 1_024;
16876 let temp = tempfile::tempdir()?;
16877 let root = fs::canonicalize(temp.path())?;
16878 fs::create_dir_all(root.join("content/real-target"))?;
16879 fs::write(root.join("content/resolved.md"), "# Resolved\n")?;
16880 let symlink_available = match create_directory_link(
16881 &root.join("content/real-target"),
16882 &root.join("content/alias"),
16883 ) {
16884 Ok(()) => true,
16885 Err(source) if cfg!(windows) && source.raw_os_error() == Some(1314) => {
16886 fs::create_dir(root.join("content/alias"))?;
16887 false
16888 }
16889 Err(source) => return Err(source.into()),
16890 };
16891 let database = root.join("projectatlas.db");
16892 let mut store = AtlasStore::open_for_project(&database, &root)?;
16893 let project = store
16894 .project_instance_id()?
16895 .ok_or("large document fixture project identity is missing")?;
16896 let control = IndexWorkControl::new(IndexCancellation::new(), None);
16897 let mut nodes = Vec::with_capacity(DOCUMENT_COUNT + 2);
16898 let mut docs_node = test_file_node("content", "unknown");
16899 docs_node.kind = NodeKind::Folder;
16900 docs_node.language = None;
16901 docs_node.extension = None;
16902 nodes.push(docs_node);
16903 let mut graphs = Vec::with_capacity(DOCUMENT_COUNT + 1);
16904 let mut document_facts: BTreeMap<
16905 String,
16906 Cow<'static, projectatlas_symbols::MarkdownFacts>,
16907 > = BTreeMap::new();
16908 for document in 0..DOCUMENT_COUNT {
16909 let path = format!("content/source-{document:02}.md");
16910 let mut links = Vec::with_capacity(LINKS_PER_DOCUMENT);
16911 if document == 0 {
16912 links.push("[resolved](resolved.md)".to_string());
16913 links.push("[symlink](alias)".to_string());
16914 }
16915 while links.len() < LINKS_PER_DOCUMENT {
16916 let link = links.len();
16917 links.push(format!(
16918 "[missing-{document:02}-{link:04}](missing-{document:02}-{link:04}.md)"
16919 ));
16920 }
16921 let facts = projectatlas_symbols::extract_markdown_facts(&links.join("\n"));
16922 require_eq(
16923 &facts.link_candidates.len(),
16924 &LINKS_PER_DOCUMENT,
16925 "large document candidate count",
16926 )?;
16927 graphs.push(facts.symbol_graph(&path, Some("markdown")));
16928 document_facts.insert(path.clone(), Cow::Owned(facts));
16929 nodes.push(test_file_node(&path, "markdown"));
16930 }
16931 let resolved_facts = projectatlas_symbols::extract_markdown_facts("# Resolved\n");
16932 graphs.push(resolved_facts.symbol_graph("content/resolved.md", Some("markdown")));
16933 nodes.push(test_file_node("content/resolved.md", "markdown"));
16934 let candidate_count = document_facts
16935 .values()
16936 .map(|facts| facts.link_candidates.len())
16937 .sum::<usize>();
16938 require_eq(
16939 &candidate_count,
16940 &(DOCUMENT_COUNT * LINKS_PER_DOCUMENT),
16941 "large document projection candidate total",
16942 )?;
16943 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
16944 let packages = PackageIndex::from_graphs(&graphs)?;
16945
16946 let generation = IndexGeneration::new(1);
16947 let entities = build_entity_projection(
16948 project, generation, &nodes, &graphs, &packages, true, &control,
16949 )?;
16950 let candidates = resolution_registry_from_exports(&entities, &control)?;
16951 let in_memory = finish_projection_with_documents(
16952 project,
16953 generation,
16954 RepositoryGraphMutation::Full,
16955 &graphs,
16956 &root,
16957 &nodes,
16958 &document_facts,
16959 &GraphIdentityAdmission::default(),
16960 entities,
16961 &candidates,
16962 &scan_policy,
16963 &control,
16964 )?;
16965 require(
16966 in_memory.database.is_none(),
16967 "normal projection unexpectedly selected disposable staging",
16968 )?;
16969 require(
16970 in_memory.document_unresolved_reasons.len() > GraphLimits::MAX_ROWS as usize,
16971 "normal projection did not retain a multi-ceiling reason vector",
16972 )?;
16973 require_eq(
16974 &in_memory.relations.len(),
16975 &candidate_count,
16976 "normal projection relation count",
16977 )?;
16978 require(
16979 in_memory.relations.iter().any(|relation| {
16980 matches!(relation.resolution(), RelationResolution::Resolved { .. })
16981 }),
16982 "normal projection lost the resolved document result",
16983 )?;
16984 let symlink_relation = in_memory
16985 .relations
16986 .iter()
16987 .find(|relation| {
16988 matches!(
16989 relation.resolution(),
16990 RelationResolution::Unresolved { reference }
16991 if reference.as_str() == "alias"
16992 )
16993 })
16994 .ok_or("normal projection lost the symlink document result")?;
16995 let symlink_reason = in_memory
16996 .document_unresolved_reasons
16997 .iter()
16998 .find(|(key, _reason)| key == symlink_relation.key())
16999 .map(|(_key, reason)| *reason);
17000 require_eq(
17001 &symlink_reason,
17002 &Some(DocumentTargetUnresolvedReason::Unsupported),
17003 "symlink document result did not retain its closed reason",
17004 )?;
17005 require(
17006 !symlink_available || root.join("content/alias").exists(),
17007 "symlink document fixture disappeared before publication",
17008 )?;
17009 {
17010 let mut publication = store.begin_index_publication("large-document-projection")?;
17011 publication.upsert_scan_node_batch(&nodes)?;
17012 in_memory.apply(&mut publication, &control)?;
17013 publication.complete()?;
17014 }
17015 let published_page = store.repository_graph_relation_rows(
17016 RepositoryGraphRelationQuery::Family {
17017 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
17018 },
17019 GraphLimits::MAX_ROWS,
17020 None,
17021 )?;
17022 require(
17023 published_page.truncated && published_page.rows.len() == GraphLimits::MAX_ROWS as usize,
17024 "normal projection publication did not expose a bounded multi-page result",
17025 )?;
17026
17027 let incremental_path = "content/source-00.md".to_string();
17028 let incremental_source = (0..INCREMENTAL_LINKS)
17029 .map(|link| format!("[incremental-{link:04}](incremental-{link:04}.md)"))
17030 .collect::<Vec<_>>()
17031 .join("\n");
17032 let incremental_facts = projectatlas_symbols::extract_markdown_facts(&incremental_source);
17033 let incremental_graph = incremental_facts.symbol_graph(&incremental_path, Some("markdown"));
17034 let incremental_nodes = vec![test_file_node(&incremental_path, "markdown")];
17035 let incremental_packages =
17036 PackageIndex::from_graphs(std::slice::from_ref(&incremental_graph))?;
17037 let incremental_entities = build_entity_projection(
17038 project,
17039 IndexGeneration::new(2),
17040 &incremental_nodes,
17041 std::slice::from_ref(&incremental_graph),
17042 &incremental_packages,
17043 false,
17044 &control,
17045 )?;
17046 let incremental_candidates =
17047 resolution_registry_from_exports(&incremental_entities, &control)?;
17048 let incremental_staged = finish_projection_with_documents(
17049 project,
17050 IndexGeneration::new(2),
17051 RepositoryGraphMutation::AffectedPaths(vec![incremental_path.clone()]),
17052 std::slice::from_ref(&incremental_graph),
17053 &root,
17054 &nodes,
17055 &BTreeMap::from([(incremental_path.clone(), Cow::Owned(incremental_facts))]),
17056 &GraphIdentityAdmission::default(),
17057 incremental_entities,
17058 &incremental_candidates,
17059 &scan_policy,
17060 &control,
17061 )?;
17062 require(
17063 incremental_staged.document_unresolved_reasons.len() <= GraphLimits::MAX_ROWS as usize,
17064 "incremental projection exceeded its aggregate row budget",
17065 )?;
17066 enforce_incremental_projection_limits(
17067 &root,
17068 &BTreeSet::from([incremental_path]),
17069 RepositoryAffectedSourceFootprint {
17070 rows: 0,
17071 retained_bytes: 0,
17072 truncated: false,
17073 },
17074 &incremental_staged,
17075 )?;
17076 {
17077 let mut publication =
17078 store.begin_index_projection_refresh("large-document-projection")?;
17079 incremental_staged.apply(&mut publication, &control)?;
17080 publication.complete()?;
17081 }
17082 require_eq(
17083 &store.repository_graph_generation()?,
17084 &Some(IndexGeneration::new(2)),
17085 "incremental publication generation",
17086 )?;
17087
17088 let staging_generation = IndexGeneration::new(3);
17089 let staging_entities = build_entity_projection(
17090 project,
17091 staging_generation,
17092 &nodes,
17093 &graphs,
17094 &packages,
17095 true,
17096 &control,
17097 )?;
17098 let staging_candidates = resolution_registry_from_exports(&staging_entities, &control)?;
17099 let staged = finish_projection_in_database_with_documents(
17100 &root,
17101 &nodes,
17102 project,
17103 staging_generation,
17104 &graphs,
17105 &document_facts,
17106 &GraphIdentityAdmission::default(),
17107 staging_entities,
17108 &staging_candidates,
17109 &scan_policy,
17110 &control,
17111 )?;
17112 let database_stage = staged
17113 .database
17114 .as_ref()
17115 .ok_or("large projection did not select disposable staging")?;
17116 let staged_database_bytes = fs::metadata(
17117 database_stage
17118 .directory()?
17119 .path()
17120 .join(GRAPH_STAGE_DATABASE_FILE_NAME),
17121 )?
17122 .len();
17123 require(
17124 staged_database_bytes > 0,
17125 "disposable staging database retained no durable bytes",
17126 )?;
17127 {
17128 let mut publication =
17129 store.begin_index_projection_refresh("large-document-projection")?;
17130 publication.upsert_scan_node_batch(&nodes)?;
17131 staged.apply(&mut publication, &control)?;
17132 publication.complete()?;
17133 }
17134 let staged_page = store.repository_graph_relation_rows(
17135 RepositoryGraphRelationQuery::Family {
17136 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
17137 },
17138 GraphLimits::MAX_ROWS,
17139 None,
17140 )?;
17141 require(
17142 staged_page.truncated && staged_page.rows.len() == GraphLimits::MAX_ROWS as usize,
17143 "disposable staging publication did not expose a bounded multi-page result",
17144 )?;
17145 Ok(())
17146 }
17147
17148 #[test]
17149 fn incremental_document_admission_uses_emitted_rows() -> Result<(), Box<dyn Error>> {
17150 const DISCARDED_DOCUMENT_COUNT: usize = 513;
17151 const EMITTED_DOCUMENT_COUNT: usize = 10;
17152 const CANDIDATES_PER_DOCUMENT: usize = 1_024;
17153 let temp = tempfile::tempdir()?;
17154 let root = fs::canonicalize(temp.path())?;
17155 fs::create_dir_all(root.join("content"))?;
17156 let database = root.join("projectatlas.db");
17157 let mut store = AtlasStore::open_for_project(&database, &root)?;
17158 let paths = (0..DISCARDED_DOCUMENT_COUNT)
17159 .map(|index| format!("content/links-{index:03}.md"))
17160 .collect::<Vec<_>>();
17161 let mut nodes = paths
17162 .iter()
17163 .map(|path| test_file_node(path, "markdown"))
17164 .collect::<Vec<_>>();
17165 store.replace_scan(&nodes)?;
17166 let control = IndexWorkControl::new(IndexCancellation::new(), None);
17167 let symbols = empty_symbol_build_stage();
17168
17169 for path in &paths {
17170 let file_name = path
17171 .rsplit_once('/')
17172 .map(|(_parent, file_name)| file_name)
17173 .ok_or("same-file document fixture path has no parent")?;
17174 let self_source = (0..CANDIDATES_PER_DOCUMENT)
17175 .map(|index| format!("[self-{index:05}]({file_name})"))
17176 .collect::<Vec<_>>()
17177 .join("\n");
17178 fs::write(root.join(path), &self_source)?;
17179 let self_facts = projectatlas_symbols::extract_markdown_facts(&self_source);
17180 require_eq(
17181 &self_facts.link_candidates.len(),
17182 &CANDIDATES_PER_DOCUMENT,
17183 "same-file raw candidates per document",
17184 )?;
17185 store.replace_symbol_graph(&self_facts.symbol_graph(path, Some("markdown")))?;
17186 }
17187 for (node, path) in nodes.iter_mut().zip(&paths) {
17188 let bytes = fs::read(root.join(path))?;
17189 node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
17190 }
17191 store.replace_scan(&nodes)?;
17192 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
17193 let incremental = stage_incremental_repository_graph(
17194 &store,
17195 &root,
17196 IndexGeneration::new(0),
17197 &nodes,
17198 &paths,
17199 &scan_policy,
17200 &symbols,
17201 &control,
17202 )?;
17203 require(
17204 matches!(
17205 &incremental.mutation,
17206 RepositoryGraphMutation::AffectedPaths(affected) if affected == &paths
17207 ),
17208 "same-file candidates were not retained as an incremental projection",
17209 )?;
17210 require(
17211 incremental.relations.is_empty() && incremental.document_unresolved_reasons.is_empty(),
17212 "same-file candidates emitted document rows despite the no-fragment filter",
17213 )?;
17214 require(
17215 store.repository_graph_generation()?.is_none(),
17216 "incremental admission test unexpectedly published a generation",
17217 )?;
17218
17219 let emitted_paths = paths
17220 .iter()
17221 .take(EMITTED_DOCUMENT_COUNT)
17222 .cloned()
17223 .collect::<Vec<_>>();
17224 for path in &emitted_paths {
17225 let unsupported_source = (0..CANDIDATES_PER_DOCUMENT)
17226 .map(|index| format!("[external-{index:05}](target-{index:05}.md#invalid?)"))
17227 .collect::<Vec<_>>()
17228 .join("\n");
17229 fs::write(root.join(path), &unsupported_source)?;
17230 let unsupported_facts =
17231 projectatlas_symbols::extract_markdown_facts(&unsupported_source);
17232 require_eq(
17233 &unsupported_facts.link_candidates.len(),
17234 &CANDIDATES_PER_DOCUMENT,
17235 "unsupported raw candidates per document",
17236 )?;
17237 store.replace_symbol_graph(&unsupported_facts.symbol_graph(path, Some("markdown")))?;
17238 }
17239 for (node, path) in nodes.iter_mut().zip(&paths) {
17240 let bytes = fs::read(root.join(path))?;
17241 node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
17242 }
17243 store.replace_scan(&nodes)?;
17244 let error = stage_incremental_repository_graph(
17245 &store,
17246 &root,
17247 IndexGeneration::new(0),
17248 &nodes,
17249 &emitted_paths,
17250 &scan_policy,
17251 &symbols,
17252 &control,
17253 )
17254 .err()
17255 .ok_or("emitted document rows over the ceiling were admitted incrementally")?;
17256 let CliError::RefreshRequired(report) = error else {
17257 return Err(io::Error::other(format!(
17258 "expected typed full-refresh guidance, found {error:?}"
17259 ))
17260 .into());
17261 };
17262 require_eq(
17263 &report.reason,
17264 &IndexRefreshReason::DependencyClosureLimit,
17265 "emitted-row overflow reason",
17266 )?;
17267 require_eq(
17268 &report.scope,
17269 &IndexRefreshScope::Full,
17270 "emitted-row overflow scope",
17271 )?;
17272 require(
17273 store.repository_graph_generation()?.is_none(),
17274 "emitted-row overflow changed the current generation",
17275 )?;
17276 Ok(())
17277 }
17278
17279 #[test]
17280 fn reopened_source_parse_success_does_not_promote_fallback_graph_facts()
17281 -> Result<(), Box<dyn Error>> {
17282 let temp = tempfile::tempdir()?;
17283 let database = temp.path().join("projectatlas.db");
17284 let mut store = AtlasStore::open(&database)?;
17285 let graph = SymbolGraph {
17286 path: "src/optional.lang".to_string(),
17287 language: Some("optional-language".to_string()),
17288 parser: ParserKind::Fallback,
17289 symbols: vec![CodeSymbol {
17290 path: "src/optional.lang".to_string(),
17291 language: Some("optional-language".to_string()),
17292 name: "entry".to_string(),
17293 kind: SymbolKind::Function,
17294 signature: "entry()".to_string(),
17295 exported: false,
17296 documentation: None,
17297 line_start: 1,
17298 line_end: 1,
17299 source_selector: None,
17300 parent: None,
17301 parser: ParserKind::Fallback,
17302 detail: None,
17303 }],
17304 relations: vec![SymbolRelation {
17305 path: "src/optional.lang".to_string(),
17306 source_name: "entry".to_string(),
17307 target_name: "helper".to_string(),
17308 kind: RelationKind::Calls,
17309 line: 1,
17310 context: "helper()".to_string(),
17311 parser: ParserKind::Fallback,
17312 }],
17313 };
17314 store.replace_symbol_graph_with_metadata(
17315 &graph,
17316 &SourceParseMetadata {
17317 path: graph.path.clone(),
17318 language: graph.language.clone(),
17319 parser: ParserKind::TreeSitter,
17320 symbol_count: graph.symbols.len(),
17321 relation_count: graph.relations.len(),
17322 },
17323 )?;
17324 drop(store);
17325
17326 let reader = AtlasStore::open_read_only(&database)?;
17327 let graphs = reader.load_symbol_graphs_for_paths(std::slice::from_ref(&graph.path))?;
17328 require_eq(&graphs, &vec![graph], "reopened fact graph")?;
17329 let project = ProjectInstanceId::from_bytes([7; 16])?;
17330 let generation = IndexGeneration::new(1);
17331 let control = IndexWorkControl::new(IndexCancellation::new(), None);
17332 let packages = PackageIndex::from_graphs(&graphs)?;
17333 let entities =
17334 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
17335 let candidates = resolution_registry_from_exports(&entities, &control)?;
17336 let staged = finish_projection(
17337 project,
17338 generation,
17339 RepositoryGraphMutation::Full,
17340 &graphs,
17341 entities,
17342 &candidates,
17343 &control,
17344 )?;
17345 require_eq(&staged.relations.len(), &1, "normalized relation count")?;
17346 require_eq(
17347 &staged.relations[0].confidence(),
17348 &ConfidenceClass::Low,
17349 "fallback relation confidence after reopen",
17350 )?;
17351 require_eq(
17352 &staged.relations[0].completeness(),
17353 &Completeness::Partial,
17354 "fallback relation completeness after reopen",
17355 )?;
17356 require(
17357 staged
17358 .relations
17359 .iter()
17360 .any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)),
17361 "reopened graph lost its legacy call relation",
17362 )?;
17363 require_eq(&staged.coverage.len(), &1, "normalized coverage count")?;
17364 require_eq(
17365 &staged.coverage[0].state(),
17366 &CoverageState::Partial,
17367 "fallback coverage after reopen",
17368 )?;
17369 reader.finish_index_read_snapshot()?;
17370 Ok(())
17371 }
17372
17373 #[test]
17374 fn partial_php_graph_publishes_existing_partial_coverage() -> Result<(), Box<dyn Error>> {
17375 let graph = extract_symbol_graph(
17376 "src/dynamic.php",
17377 Some("php"),
17378 "<?php function run(): void { $callable(); helper(); }",
17379 );
17380 require_eq(
17381 &graph.parser,
17382 &ParserKind::Fallback,
17383 "partial PHP fact parser",
17384 )?;
17385 let coverage = coverage_for_graph(
17386 &graph,
17387 IndexGeneration::new(1),
17388 &GraphIdentityAdmission::default(),
17389 &GraphIdentityAdmission::default(),
17390 )?;
17391 require_eq(
17392 &coverage.state(),
17393 &CoverageState::Partial,
17394 "partial PHP coverage state",
17395 )?;
17396 require_eq(&coverage.covered(), &1, "partial PHP covered relations")?;
17397 require_eq(&coverage.omitted(), &1, "partial PHP omitted relations")?;
17398 require(
17399 coverage.reason().is_some(),
17400 "partial PHP coverage must disclose its reason",
17401 )?;
17402 Ok(())
17403 }
17404
17405 #[test]
17406 fn mixed_php_graph_publishes_complete_coverage() -> Result<(), Box<dyn Error>> {
17407 for (path, source, symbol_name) in [
17408 (
17409 "src/inline-output.php",
17410 "//x<?php function marker(): void { helper(); }",
17411 "marker",
17412 ),
17413 (
17414 "src/inline-echo.php",
17415 "#output<?= $value ?><?php function after_echo(): void { helper(); }",
17416 "after_echo",
17417 ),
17418 ] {
17419 let graph = extract_symbol_graph(path, Some("php"), source);
17420 require_eq(
17421 &graph.parser,
17422 &ParserKind::TreeSitter,
17423 "mixed PHP fact parser",
17424 )?;
17425 require(
17426 graph
17427 .symbols
17428 .iter()
17429 .any(|symbol| symbol.name == symbol_name),
17430 "mixed PHP declaration is missing before coverage projection",
17431 )?;
17432 let coverage = coverage_for_graph(
17433 &graph,
17434 IndexGeneration::new(1),
17435 &GraphIdentityAdmission::default(),
17436 &GraphIdentityAdmission::default(),
17437 )?;
17438 require_eq(
17439 &coverage.state(),
17440 &CoverageState::Complete,
17441 "mixed PHP coverage state",
17442 )?;
17443 require(
17444 coverage.covered() > 0,
17445 "mixed PHP complete coverage must retain static relations",
17446 )?;
17447 require_eq(&coverage.omitted(), &0, "mixed PHP omitted relations")?;
17448 require(
17449 coverage.reason().is_none(),
17450 "complete mixed PHP coverage must not disclose a partial reason",
17451 )?;
17452 }
17453 Ok(())
17454 }
17455
17456 #[test]
17457 fn persisted_closure_overflow_preserves_the_complete_generation() -> Result<(), Box<dyn Error>>
17458 {
17459 let temp = tempfile::tempdir()?;
17460 let root = temp.path().join("persisted-closure-overflow");
17461 fs::create_dir_all(&root)?;
17462 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
17463 let project = store
17464 .project_instance_id()?
17465 .ok_or("bound project identity is missing")?;
17466 let control = IndexWorkControl::new(IndexCancellation::new(), None);
17467 let graphs = vec![function_graph("src/lib.rs", 1)];
17468 let packages = PackageIndex::from_graphs(&graphs)?;
17469 let entity_projection = build_entity_projection(
17470 project,
17471 IndexGeneration::new(1),
17472 &[],
17473 &graphs,
17474 &packages,
17475 true,
17476 &control,
17477 )?;
17478 let dependency_keys = entity_projection
17479 .keys_by_graph
17480 .values()
17481 .flat_map(projectatlas_symbols::ResolutionKeyProjection::relation_keys)
17482 .flat_map(|relation| relation.keys().iter().cloned())
17483 .collect::<BTreeSet<_>>()
17484 .into_iter()
17485 .collect::<Vec<_>>();
17486 let candidates = resolution_registry_from_exports(&entity_projection, &control)?;
17487 let staged = finish_projection(
17488 project,
17489 IndexGeneration::new(1),
17490 RepositoryGraphMutation::Full,
17491 &graphs,
17492 entity_projection,
17493 &candidates,
17494 &control,
17495 )?;
17496 let file_key = staged
17497 .entities
17498 .iter()
17499 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
17500 .ok_or("staged file entity is missing")?
17501 .key()
17502 .clone();
17503 {
17504 let mut publication = store.begin_index_publication("closure-overflow")?;
17505 publication.begin_scan_replacement()?;
17506 publication.upsert_scan_node_batch(&[Node {
17507 path: "src/lib.rs".to_string(),
17508 kind: NodeKind::File,
17509 parent_path: Some("src".to_string()),
17510 extension: Some(".rs".to_string()),
17511 language: Some("rust".to_string()),
17512 size_bytes: Some(1),
17513 mtime_ns: Some(1),
17514 content_hash: Some("initial".to_string()),
17515 }])?;
17516 publication.finish_scan_replacement()?;
17517 staged.apply(&mut publication, &control)?;
17518 publication.complete()?;
17519 }
17520
17521 store.replace_symbol_graph(&function_graph(
17522 "src/lib.rs",
17523 usize::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(usize::MAX),
17524 ))?;
17525 let publication_before = store
17526 .index_publication()?
17527 .ok_or("complete publication is missing")?;
17528 let entity_before = store
17529 .repository_graph_entity(&file_key)?
17530 .ok_or("published file entity is missing")?;
17531 let exports_before =
17532 store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?;
17533 let dependencies_before =
17534 store.repository_affected_source_paths(project, &dependency_keys, 100)?;
17535 let empty_symbols = empty_symbol_build_stage();
17536 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
17537
17538 let error = stage_incremental_repository_graph(
17539 &store,
17540 &root,
17541 publication_before.generation,
17542 &[],
17543 &["src/lib.rs".to_string()],
17544 &scan_policy,
17545 &empty_symbols,
17546 &control,
17547 )
17548 .err()
17549 .ok_or_else(|| io::Error::other("oversized closure acquired the publication writer"))?;
17550 let CliError::RefreshRequired(report) = error else {
17551 return Err(io::Error::other(format!(
17552 "expected typed full-refresh guidance, found {error:?}"
17553 ))
17554 .into());
17555 };
17556 require_eq(
17557 &report.reason,
17558 &IndexRefreshReason::DependencyClosureLimit,
17559 "persisted overflow reason",
17560 )?;
17561 require_eq(
17562 &report.scope,
17563 &IndexRefreshScope::Full,
17564 "persisted overflow scope",
17565 )?;
17566 require_eq(
17567 &store.index_publication()?,
17568 &Some(publication_before),
17569 "publication after overflow",
17570 )?;
17571 require_eq(
17572 &store.repository_graph_entity(&file_key)?,
17573 &Some(entity_before),
17574 "file entity after overflow",
17575 )?;
17576 require_eq(
17577 &store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?,
17578 &exports_before,
17579 "export keys after overflow",
17580 )?;
17581 require_eq(
17582 &store.repository_affected_source_paths(project, &dependency_keys, 100)?,
17583 &dependencies_before,
17584 "dependency owners after overflow",
17585 )?;
17586 Ok(())
17587 }
17588
17589 #[test]
17590 fn semantic_provider_key_rejection_keeps_valid_exports_and_relations()
17591 -> Result<(), Box<dyn Error>> {
17592 let temp = tempfile::tempdir()?;
17593 let root = temp.path().join("semantic-resolution-key-admission");
17594 fs::create_dir_all(&root)?;
17595 let long_path = |folder: &str, component: char, file: &str| {
17596 let components = (0..20)
17597 .map(|_| std::iter::repeat_n(component, 200).collect::<String>())
17598 .collect::<Vec<_>>()
17599 .join("/");
17600 format!("src/{folder}/{components}/{file}")
17601 };
17602 let path = long_path("first", 'd', "page.rs");
17603 let sibling_path = long_path("second", 'e', "sibling.rs");
17604 let nodes = vec![
17605 test_file_node(&path, "rust"),
17606 test_file_node(&sibling_path, "rust"),
17607 ];
17608 let project_database = root.join(".projectatlas/projectatlas.db");
17609 fs::create_dir_all(root.join(".projectatlas"))?;
17610 let mut store = AtlasStore::open_for_project(&project_database, &root)?;
17611 let project = store
17612 .project_instance_id()?
17613 .ok_or("semantic resolution project identity is missing")?;
17614 let initial_graphs = vec![
17615 semantic_resolution_key_graph(&path, true),
17616 semantic_resolution_key_graph(&sibling_path, false),
17617 ];
17618 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
17619 let scan_policy =
17620 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
17621 let initial_stage = stage_full_repository_graph(
17622 &store,
17623 &root,
17624 IndexGeneration::ZERO,
17625 &nodes,
17626 &scan_policy,
17627 &symbol_build_stage_for_graphs(initial_graphs.clone()),
17628 &initial_control,
17629 )?;
17630 require(
17631 initial_stage
17632 .identity_rejections
17633 .iter()
17634 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17635 .count()
17636 >= 2,
17637 "semantic provider full-stage did not retain every invalid resolution-key fact",
17638 )?;
17639 let initial_coverage = initial_stage
17640 .coverage
17641 .iter()
17642 .find(|coverage| {
17643 matches!(
17644 coverage.scope(),
17645 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
17646 )
17647 })
17648 .ok_or("semantic provider coverage row is missing")?;
17649 require_eq(
17650 &initial_coverage.state(),
17651 &CoverageState::Partial,
17652 "semantic provider valid-symbol coverage state",
17653 )?;
17654 require(
17655 initial_coverage.covered() > 0,
17656 "semantic provider valid symbols were not counted as covered",
17657 )?;
17658 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
17659 canceled.cancel();
17660 {
17661 let mut publication = store.begin_index_publication("semantic-resolution-cancel")?;
17662 publication.begin_scan_replacement()?;
17663 publication.upsert_scan_node_batch(&nodes)?;
17664 publication.finish_scan_replacement()?;
17665 let error = initial_stage.apply(&mut publication, &canceled).err();
17666 require(
17667 matches!(
17668 error,
17669 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
17670 stage: IndexWorkStage::Publication
17671 }))
17672 ),
17673 "semantic provider cancellation was not observed after admission",
17674 )?;
17675 }
17676 require_eq(
17677 &store.index_publication()?,
17678 &None,
17679 "publication after semantic provider cancellation",
17680 )?;
17681 publish_full_staged_graph(
17682 &mut store,
17683 &nodes,
17684 &initial_stage,
17685 &initial_control,
17686 "semantic-resolution-initial",
17687 )?;
17688 let base_generation = store
17689 .index_publication()?
17690 .ok_or("semantic resolution initial publication is missing")?
17691 .generation;
17692 let rejection_paths = nodes
17693 .iter()
17694 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
17695 .collect::<Result<Vec<_>, _>>()?;
17696 let rejections =
17697 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17698 let rejections = rejections
17699 .into_iter()
17700 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17701 .collect::<Vec<_>>();
17702 require(
17703 rejections.len() >= 2,
17704 "semantic provider full-stage rejection detail count",
17705 )?;
17706 require(
17707 rejections.iter().all(|row| {
17708 row.path.as_str() == path
17709 && row.reason == GraphIdentityRejectionReason::Oversized
17710 && row.parser == ParserKind::TreeSitter
17711 }),
17712 "semantic provider rejection provenance was not exact",
17713 )?;
17714 require(
17715 rejections.iter().any(|row| row.span.start_line() == 4)
17716 && rejections.iter().any(|row| row.span.start_line() == 5),
17717 "semantic provider did not retain distinct invalid symbol spans",
17718 )?;
17719 let rejection_wire = serde_json::to_string(&rejections)?;
17720 require(
17721 !rejection_wire.contains("LeakedIdentity"),
17722 "semantic provider rejection retained raw identity text",
17723 )?;
17724
17725 let file_entities = store.repository_graph_entities_by_path(
17726 project,
17727 &RepositoryNodePath::new(Path::new(&path))?,
17728 64,
17729 )?;
17730 require(
17731 file_entities.rows.iter().any(|entity| {
17732 matches!(
17733 entity.selector(),
17734 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "page_helper"
17735 )
17736 }),
17737 "valid semantic sibling export was dropped",
17738 )?;
17739 let sibling_entities = store.repository_graph_entities_by_path(
17740 project,
17741 &RepositoryNodePath::new(Path::new(&sibling_path))?,
17742 64,
17743 )?;
17744 require(
17745 sibling_entities.rows.iter().any(|entity| {
17746 matches!(
17747 entity.selector(),
17748 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "sibling_helper"
17749 )
17750 }),
17751 "valid semantic sibling-folder export was dropped",
17752 )?;
17753 let calls = store.repository_graph_relations(
17754 RepositoryGraphRelationQuery::Family {
17755 relation: GraphRelationKind::Legacy(RelationKind::Calls),
17756 },
17757 32,
17758 )?;
17759 require(
17760 calls
17761 .rows
17762 .iter()
17763 .any(|relation| relation.resolution().resolved_target().is_some()),
17764 "valid semantic sibling relation was not resolved",
17765 )?;
17766 let imports = store.repository_graph_relations(
17767 RepositoryGraphRelationQuery::Family {
17768 relation: GraphRelationKind::Legacy(RelationKind::Imports),
17769 },
17770 32,
17771 )?;
17772 require(
17773 !imports.rows.is_empty(),
17774 "valid semantic import relation was dropped beside invalid imports",
17775 )?;
17776 let exports =
17777 store.repository_export_keys_for_paths(project, std::slice::from_ref(&path), 128)?;
17778 require(
17779 exports.rows.len() > 2,
17780 "valid semantic provider resolution exports were dropped",
17781 )?;
17782 let sibling_exports = store.repository_export_keys_for_paths(
17783 project,
17784 std::slice::from_ref(&sibling_path),
17785 128,
17786 )?;
17787 require(
17788 sibling_exports.rows.len() > 2,
17789 "valid semantic sibling-folder resolution exports were dropped",
17790 )?;
17791 let sibling_exports_before_incremental = sibling_exports.rows;
17792
17793 drop(store);
17794 let mut store = AtlasStore::open_for_project(&project_database, &root)?;
17795 require_eq(
17796 &store.project_instance_id()?,
17797 &Some(project),
17798 "semantic provider project identity after SQLite reopen",
17799 )?;
17800 let reopened_sibling_exports = store.repository_export_keys_for_paths(
17801 project,
17802 std::slice::from_ref(&sibling_path),
17803 128,
17804 )?;
17805 require_eq(
17806 &reopened_sibling_exports.rows,
17807 &sibling_exports_before_incremental,
17808 "semantic provider valid exports after SQLite reopen",
17809 )?;
17810
17811 let invalid_sibling_control = IndexWorkControl::new(IndexCancellation::new(), None);
17812 let invalid_sibling_policy =
17813 RootScanPolicy::discover(&root, &ScanOptions::default(), &invalid_sibling_control)?;
17814 let invalid_sibling_stage = stage_incremental_repository_graph(
17815 &store,
17816 &root,
17817 base_generation,
17818 &nodes,
17819 std::slice::from_ref(&sibling_path),
17820 &invalid_sibling_policy,
17821 &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
17822 &sibling_path,
17823 true,
17824 )]),
17825 &invalid_sibling_control,
17826 )?;
17827 let incremental_generation = base_generation
17828 .checked_next()
17829 .ok_or("semantic resolution incremental generation overflowed")?;
17830 let direct_derivations = invalid_sibling_stage
17831 .resolution_derivations
17832 .get(&(sibling_path.clone(), incremental_generation))
17833 .copied();
17834 require_eq(
17835 &direct_derivations,
17836 &Some(1),
17837 "semantic provider direct resolution derivation count",
17838 )?;
17839 require(
17840 invalid_sibling_stage
17841 .identity_rejections
17842 .iter()
17843 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
17844 .count()
17845 >= 2,
17846 "semantic provider incremental resolution-key rejection",
17847 )?;
17848 {
17849 let mut publication =
17850 store.begin_index_publication("semantic-resolution-incremental-invalid")?;
17851 invalid_sibling_stage.apply(&mut publication, &invalid_sibling_control)?;
17852 publication.complete()?;
17853 }
17854 let incremented_generation = store
17855 .index_publication()?
17856 .ok_or("semantic resolution incremental publication is missing")?
17857 .generation;
17858 let with_two_rejections =
17859 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17860 require(
17861 with_two_rejections.len() >= 4,
17862 "semantic provider incremental rejection detail count",
17863 )?;
17864 require(
17865 with_two_rejections
17866 .iter()
17867 .any(|row| row.path.as_str() == sibling_path),
17868 "semantic provider incremental rejection was not added for the changed path",
17869 )?;
17870 let invalid_sibling_exports = store.repository_export_keys_for_paths(
17871 project,
17872 std::slice::from_ref(&sibling_path),
17873 128,
17874 )?;
17875 require(
17876 sibling_exports_before_incremental
17877 .iter()
17878 .all(|key| invalid_sibling_exports.rows.contains(key)),
17879 "semantic provider valid exports were lost beside incremental rejection",
17880 )?;
17881 let invalid_sibling_calls = store.repository_graph_relations(
17882 RepositoryGraphRelationQuery::Family {
17883 relation: GraphRelationKind::Legacy(RelationKind::Calls),
17884 },
17885 32,
17886 )?;
17887 require(
17888 invalid_sibling_calls
17889 .rows
17890 .iter()
17891 .any(|relation| relation.resolution().resolved_target().is_some()),
17892 "semantic provider valid resolved relation was lost beside incremental rejection",
17893 )?;
17894
17895 let repaired_control = IndexWorkControl::new(IndexCancellation::new(), None);
17896 let repaired_policy =
17897 RootScanPolicy::discover(&root, &ScanOptions::default(), &repaired_control)?;
17898 let repaired_stage = stage_incremental_repository_graph(
17899 &store,
17900 &root,
17901 incremented_generation,
17902 &nodes,
17903 std::slice::from_ref(&sibling_path),
17904 &repaired_policy,
17905 &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
17906 &sibling_path,
17907 false,
17908 )]),
17909 &repaired_control,
17910 )?;
17911 {
17912 let mut publication =
17913 store.begin_index_publication("semantic-resolution-incremental-repair")?;
17914 repaired_stage.apply(&mut publication, &repaired_control)?;
17915 publication.complete()?;
17916 }
17917 let repaired_generation = store
17918 .index_publication()?
17919 .ok_or("semantic resolution repair publication is missing")?
17920 .generation;
17921 let repaired_rejections =
17922 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
17923 require(
17924 repaired_rejections.len() >= 2,
17925 "semantic provider repaired rejection detail count",
17926 )?;
17927 require(
17928 repaired_rejections
17929 .iter()
17930 .all(|row| row.path.as_str() == path),
17931 "semantic provider repair removed or replaced an unrelated path detail",
17932 )?;
17933 let repaired_sibling_exports = store.repository_export_keys_for_paths(
17934 project,
17935 std::slice::from_ref(&sibling_path),
17936 128,
17937 )?;
17938 require_eq(
17939 &repaired_sibling_exports.rows,
17940 &sibling_exports_before_incremental,
17941 "semantic provider repair did not restore valid exports",
17942 )?;
17943
17944 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
17945 let fault_policy =
17946 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
17947 let mut fault_stage = stage_full_repository_graph(
17948 &store,
17949 &root,
17950 repaired_generation,
17951 &nodes,
17952 &fault_policy,
17953 &symbol_build_stage_for_graphs(initial_graphs.clone()),
17954 &fault_control,
17955 )?;
17956 fault_stage.identity_rejections.resize(
17957 usize::try_from(GraphLimits::MAX_ROWS)
17958 .unwrap_or(usize::MAX)
17959 .saturating_add(1),
17960 fault_stage.identity_rejections[0].clone(),
17961 );
17962 {
17963 let mut publication = store.begin_index_publication("semantic-resolution-fault")?;
17964 publication.begin_scan_replacement()?;
17965 publication.upsert_scan_node_batch(&nodes)?;
17966 publication.finish_scan_replacement()?;
17967 require(
17968 fault_stage.apply(&mut publication, &fault_control).is_err(),
17969 "semantic provider late rejection-detail fault did not fail",
17970 )?;
17971 }
17972 require_eq(
17973 &store
17974 .index_publication()?
17975 .map(|publication| publication.generation),
17976 &Some(repaired_generation),
17977 "semantic provider generation after late rejection-detail fault",
17978 )?;
17979 require_eq(
17980 &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
17981 &repaired_rejections,
17982 "semantic provider prior generation after fault",
17983 )?;
17984
17985 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
17986 let retry_policy =
17987 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
17988 let retry_stage = stage_full_repository_graph(
17989 &store,
17990 &root,
17991 repaired_generation,
17992 &nodes,
17993 &retry_policy,
17994 &symbol_build_stage_for_graphs(initial_graphs),
17995 &retry_control,
17996 )?;
17997 publish_full_staged_graph(
17998 &mut store,
17999 &nodes,
18000 &retry_stage,
18001 &retry_control,
18002 "semantic-resolution-retry",
18003 )?;
18004 require_eq(
18005 &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
18006 &rejections,
18007 "semantic provider deterministic retry",
18008 )?;
18009 Ok(())
18010 }
18011
18012 #[test]
18013 fn full_stage_admits_siblings_and_reopens_typed_rejections_with_cancel_retry()
18014 -> Result<(), Box<dyn Error>> {
18015 let temp = tempfile::tempdir()?;
18016 let root = temp.path().join("full-identity-admission");
18017 fs::create_dir_all(root.join("src"))?;
18018 fs::create_dir_all(root.join("tests"))?;
18019 fs::write(
18020 root.join("src/one.rs"),
18021 "pub fn caller() { helper(); }\nfn helper() {}\n",
18022 )?;
18023 fs::write(
18024 root.join("tests/two.rs"),
18025 "pub fn caller() { helper(); }\nfn helper() {}\n",
18026 )?;
18027 let database = root.join(".projectatlas/projectatlas.db");
18028 fs::create_dir_all(root.join(".projectatlas"))?;
18029 let mut store = AtlasStore::open_for_project(&database, &root)?;
18030 let project = store
18031 .project_instance_id()?
18032 .ok_or("full admission project identity is missing")?;
18033 let nodes = vec![
18034 test_file_node("src/one.rs", "rust"),
18035 test_file_node("tests/two.rs", "rust"),
18036 ];
18037 let mut first_graph =
18038 identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget);
18039 first_graph.relations.push(SymbolRelation {
18040 path: "src/one.rs".to_string(),
18041 source_name: "bad\u{0}source".to_string(),
18042 target_name: "bad\u{0}target".to_string(),
18043 kind: RelationKind::Calls,
18044 line: 4,
18045 context: "identity admission dual-field fixture".to_string(),
18046 parser: ParserKind::TreeSitter,
18047 });
18048 let graphs = vec![
18049 first_graph,
18050 identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationSource),
18051 ];
18052 let symbols = symbol_build_stage_for_graphs(graphs);
18053 let control = IndexWorkControl::new(IndexCancellation::new(), None);
18054 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
18055 let staged = stage_full_repository_graph(
18056 &store,
18057 &root,
18058 IndexGeneration::ZERO,
18059 &nodes,
18060 &scan_policy,
18061 &symbols,
18062 &control,
18063 )?;
18064 require_eq(
18065 &staged.identity_rejections.len(),
18066 &4,
18067 "full-stage typed rejection count",
18068 )?;
18069 let staged_dual_rejections = staged
18070 .identity_rejections
18071 .iter()
18072 .filter(|row| {
18073 row.path.as_str() == "src/one.rs"
18074 && row.span.start_line() == 4
18075 && row.reason == GraphIdentityRejectionReason::ControlCharacters
18076 })
18077 .collect::<Vec<_>>();
18078 require_eq(
18079 &staged_dual_rejections.len(),
18080 &2,
18081 "full-stage dual-field rejection details",
18082 )?;
18083 require(
18084 staged_dual_rejections
18085 .iter()
18086 .any(|row| row.field == GraphIdentityField::RelationSource)
18087 && staged_dual_rejections
18088 .iter()
18089 .any(|row| row.field == GraphIdentityField::RelationTarget),
18090 "full-stage dual-field rejection lost source or target provenance",
18091 )?;
18092 require(
18093 staged_dual_rejections
18094 .iter()
18095 .map(|row| row.fact_index)
18096 .all(|fact_index| fact_index == staged_dual_rejections[0].fact_index),
18097 "full-stage dual-field rejection lost parser fact identity",
18098 )?;
18099 let staged_first_coverage = staged
18100 .coverage
18101 .iter()
18102 .find(|coverage| {
18103 matches!(
18104 coverage.scope(),
18105 CoverageScope::Path { path } if path.as_str() == "src/one.rs"
18106 )
18107 })
18108 .ok_or("full-stage first coverage row is missing")?;
18109 require_eq(
18110 &staged_first_coverage.omitted(),
18111 &2,
18112 "full-stage dual-field rejection counted once per parser fact",
18113 )?;
18114 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
18115 canceled.cancel();
18116 {
18117 let mut publication = store.begin_index_publication("full-identity-cancel")?;
18118 publication.begin_scan_replacement()?;
18119 publication.upsert_scan_node_batch(&nodes)?;
18120 publication.finish_scan_replacement()?;
18121 let error = staged.apply(&mut publication, &canceled).err();
18122 require(
18123 matches!(
18124 error,
18125 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
18126 stage: IndexWorkStage::Publication
18127 }))
18128 ),
18129 "full-stage cancellation was not observed after admission",
18130 )?;
18131 }
18132 require_eq(
18133 &store.index_publication()?,
18134 &None,
18135 "publication after canceled full-stage admission",
18136 )?;
18137
18138 publish_full_staged_graph(&mut store, &nodes, &staged, &control, "full-identity")?;
18139 let first_publication = store
18140 .index_publication()?
18141 .ok_or("full identity publication is missing")?;
18142 require_eq(
18143 &first_publication.generation,
18144 &IndexGeneration::new(1),
18145 "first full identity generation",
18146 )?;
18147 drop(store);
18148
18149 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
18150 let rejection_paths = nodes
18151 .iter()
18152 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
18153 .collect::<Result<Vec<_>, _>>()?;
18154 let reopened_rejections =
18155 reopened.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18156 require_eq(
18157 &reopened_rejections.len(),
18158 &4,
18159 "reopened full-stage typed rejection count",
18160 )?;
18161 let reopened_dual_rejections = reopened_rejections
18162 .iter()
18163 .filter(|row| {
18164 row.path.as_str() == "src/one.rs"
18165 && row.span.start_line() == 4
18166 && row.reason == GraphIdentityRejectionReason::ControlCharacters
18167 })
18168 .collect::<Vec<_>>();
18169 require_eq(
18170 &reopened_dual_rejections.len(),
18171 &2,
18172 "reopened dual-field rejection details",
18173 )?;
18174 require(
18175 reopened_dual_rejections
18176 .iter()
18177 .any(|row| row.field == GraphIdentityField::RelationSource)
18178 && reopened_dual_rejections
18179 .iter()
18180 .any(|row| row.field == GraphIdentityField::RelationTarget),
18181 "reopened dual-field rejection lost source or target provenance",
18182 )?;
18183 require(
18184 reopened_dual_rejections
18185 .iter()
18186 .map(|row| row.fact_index)
18187 .all(|fact_index| fact_index == reopened_dual_rejections[0].fact_index),
18188 "reopened dual-field rejection lost parser fact identity",
18189 )?;
18190 let target_rejection = reopened_rejections
18191 .iter()
18192 .find(|row| {
18193 row.field == GraphIdentityField::RelationTarget
18194 && row.path.as_str() == "src/one.rs"
18195 && row.span.start_line() == 3
18196 })
18197 .ok_or("relation-target rejection is missing")?;
18198 require_eq(
18199 &target_rejection.path.as_str(),
18200 &"src/one.rs",
18201 "relation-target rejection path",
18202 )?;
18203 require_eq(
18204 &target_rejection.parser,
18205 &ParserKind::TreeSitter,
18206 "relation-target rejection parser",
18207 )?;
18208 require_eq(
18209 &target_rejection.reason,
18210 &GraphIdentityRejectionReason::ControlCharacters,
18211 "relation-target rejection reason",
18212 )?;
18213 require_eq(
18214 &target_rejection.span.start_line(),
18215 &3,
18216 "relation-target rejection start line",
18217 )?;
18218 require_eq(
18219 &target_rejection.span.end_line(),
18220 &3,
18221 "relation-target rejection end line",
18222 )?;
18223 let source_rejection = reopened_rejections
18224 .iter()
18225 .find(|row| {
18226 row.field == GraphIdentityField::RelationSource
18227 && row.path.as_str() == "tests/two.rs"
18228 })
18229 .ok_or("relation-source rejection is missing")?;
18230 require_eq(
18231 &source_rejection.path.as_str(),
18232 &"tests/two.rs",
18233 "relation-source rejection path",
18234 )?;
18235 require_eq(
18236 &source_rejection.parser,
18237 &ParserKind::TreeSitter,
18238 "relation-source rejection parser",
18239 )?;
18240 require_eq(
18241 &source_rejection.reason,
18242 &GraphIdentityRejectionReason::ControlCharacters,
18243 "relation-source rejection reason",
18244 )?;
18245 require_eq(
18246 &source_rejection.span.start_line(),
18247 &3,
18248 "relation-source rejection start line",
18249 )?;
18250 require_eq(
18251 &source_rejection.span.end_line(),
18252 &3,
18253 "relation-source rejection end line",
18254 )?;
18255 let wire = serde_json::to_string(&reopened_rejections)?;
18256 require(
18257 !wire.contains("bad") && !wire.contains("target\u{0}"),
18258 "full-stage typed rejection retained raw invalid identity material",
18259 )?;
18260 for path in &rejection_paths {
18261 let entities = reopened.repository_graph_entities_by_path(project, path, 64)?;
18262 require(
18263 entities.rows.iter().any(|entity| {
18264 matches!(
18265 entity.selector(),
18266 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "caller"
18267 )
18268 }),
18269 "valid sibling symbol was not navigable after SQLite reopen",
18270 )?;
18271 }
18272 let calls = reopened.repository_graph_relations(
18273 RepositoryGraphRelationQuery::Family {
18274 relation: GraphRelationKind::Legacy(RelationKind::Calls),
18275 },
18276 16,
18277 )?;
18278 require_eq(
18279 &calls.rows.len(),
18280 &2,
18281 "valid sibling call relations after SQLite reopen",
18282 )?;
18283 reopened.finish_index_read_snapshot()?;
18284
18285 let mut writer = AtlasStore::open_for_project(&database, &root)?;
18286 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
18287 let fault_policy =
18288 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
18289 let mut fault_stage = stage_full_repository_graph(
18290 &writer,
18291 &root,
18292 first_publication.generation,
18293 &nodes,
18294 &fault_policy,
18295 &symbols,
18296 &fault_control,
18297 )?;
18298 fault_stage.identity_rejections.resize(
18299 usize::try_from(GraphLimits::MAX_ROWS)
18300 .unwrap_or(usize::MAX)
18301 .saturating_add(1),
18302 fault_stage.identity_rejections[0].clone(),
18303 );
18304 {
18305 let mut publication = writer.begin_index_publication("full-identity-fault")?;
18306 publication.begin_scan_replacement()?;
18307 publication.upsert_scan_node_batch(&nodes)?;
18308 publication.finish_scan_replacement()?;
18309 require(
18310 fault_stage.apply(&mut publication, &fault_control).is_err(),
18311 "oversized rejection detail did not fault after graph replacement",
18312 )?;
18313 }
18314 require_eq(
18315 &writer
18316 .index_publication()?
18317 .map(|publication| publication.generation),
18318 &Some(first_publication.generation),
18319 "generation after late rejection-detail fault",
18320 )?;
18321 let retained =
18322 writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18323 require_eq(
18324 &retained,
18325 &reopened_rejections,
18326 "prior complete typed rejection generation after fault",
18327 )?;
18328 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
18329 let retry_policy =
18330 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
18331 let retry_stage = stage_full_repository_graph(
18332 &writer,
18333 &root,
18334 first_publication.generation,
18335 &nodes,
18336 &retry_policy,
18337 &symbols,
18338 &retry_control,
18339 )?;
18340 publish_full_staged_graph(
18341 &mut writer,
18342 &nodes,
18343 &retry_stage,
18344 &retry_control,
18345 "full-identity-retry",
18346 )?;
18347 let retried =
18348 writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
18349 require_eq(
18350 &retried,
18351 &reopened_rejections,
18352 "deterministic full-stage retry",
18353 )?;
18354 Ok(())
18355 }
18356
18357 #[test]
18358 fn full_stage_reuse_carries_persisted_rejection_coverage_and_replaces_it()
18359 -> Result<(), Box<dyn Error>> {
18360 let temp = tempfile::tempdir()?;
18361 let root = temp.path().join("full-reuse-identity-admission");
18362 fs::create_dir_all(root.join("src"))?;
18363 fs::write(
18364 root.join("src/reused.rs"),
18365 "pub fn caller() { helper(); }\nfn helper() {}\n",
18366 )?;
18367 let database = root.join(".projectatlas/projectatlas.db");
18368 fs::create_dir_all(root.join(".projectatlas"))?;
18369 let mut store = AtlasStore::open_for_project(&database, &root)?;
18370 let project = store
18371 .project_instance_id()?
18372 .ok_or("full reuse project identity is missing")?;
18373 let path = "src/reused.rs";
18374 let mut nodes = vec![test_file_node(path, "rust")];
18375 capture_test_file_hashes(&root, &mut nodes)?;
18376 let graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
18377 let first_control = IndexWorkControl::new(IndexCancellation::new(), None);
18378 let first_policy =
18379 RootScanPolicy::discover(&root, &ScanOptions::default(), &first_control)?;
18380 let mut first_symbols = symbol_build_stage_for_graphs(vec![graph]);
18381 first_symbols.identity_admission =
18382 super::admit_symbol_build_stage(&mut first_symbols, &first_control)?;
18383 let first_graph = first_symbols
18384 .changes
18385 .iter()
18386 .find_map(|change| match change {
18387 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18388 SymbolProjectionChange::Clear { .. } => None,
18389 })
18390 .ok_or("full reuse parsed graph is missing")?;
18391 let first_stage = stage_full_repository_graph(
18392 &store,
18393 &root,
18394 IndexGeneration::ZERO,
18395 &nodes,
18396 &first_policy,
18397 &first_symbols,
18398 &first_control,
18399 )?;
18400 require_eq(
18401 &first_stage.identity_rejections.len(),
18402 &1,
18403 "full reuse initial rejection detail count",
18404 )?;
18405 publish_full_staged_graph(
18406 &mut store,
18407 &nodes,
18408 &first_stage,
18409 &first_control,
18410 "full-reuse-initial",
18411 )?;
18412 store.replace_symbol_graph(&first_graph)?;
18415 let first_generation = store
18416 .index_publication()?
18417 .ok_or("full reuse initial publication is missing")?
18418 .generation;
18419 drop(store);
18420
18421 let mut store = AtlasStore::open_for_project(&database, &root)?;
18422 let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
18423 let reuse_policy =
18424 RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
18425 let reused_stage = stage_full_repository_graph(
18426 &store,
18427 &root,
18428 first_generation,
18429 &nodes,
18430 &reuse_policy,
18431 &empty_symbol_build_stage(),
18432 &reuse_control,
18433 )?;
18434 require_eq(
18435 &reused_stage.identity_rejections,
18436 &first_stage.identity_rejections,
18437 "full reuse retained exact persisted rejection details",
18438 )?;
18439 let reused_coverage = reused_stage
18440 .coverage
18441 .iter()
18442 .find(|coverage| {
18443 matches!(
18444 coverage.scope(),
18445 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18446 ) && coverage.relation().is_none()
18447 })
18448 .ok_or("full reuse coverage row is missing")?;
18449 require_eq(
18450 &reused_coverage.state(),
18451 &CoverageState::Partial,
18452 "full reuse retained partial coverage",
18453 )?;
18454 require_eq(
18455 &reused_coverage.omitted(),
18456 &1,
18457 "full reuse retained rejection omission count",
18458 )?;
18459 require(
18460 reused_stage
18461 .relations
18462 .iter()
18463 .any(|relation| relation.resolution().resolved_target().is_some()),
18464 "full reuse dropped the valid sibling relation",
18465 )?;
18466 publish_full_staged_graph(
18467 &mut store,
18468 &nodes,
18469 &reused_stage,
18470 &reuse_control,
18471 "full-reuse-republish",
18472 )?;
18473 let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
18474 let persisted =
18475 store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?;
18476 require_eq(
18477 &persisted,
18478 &first_stage.identity_rejections,
18479 "full reuse persisted exact rejection details",
18480 )?;
18481
18482 let cancel_control = IndexWorkControl::new(IndexCancellation::new(), None);
18483 let canceled_stage = stage_full_repository_graph(
18484 &store,
18485 &root,
18486 first_generation
18487 .checked_next()
18488 .ok_or("full reuse generation overflow")?,
18489 &nodes,
18490 &reuse_policy,
18491 &empty_symbol_build_stage(),
18492 &cancel_control,
18493 )?;
18494 cancel_control.cancel();
18495 {
18496 let mut publication = store.begin_index_publication("full-reuse-cancel")?;
18497 publication.begin_scan_replacement()?;
18498 publication.upsert_scan_node_batch(&nodes)?;
18499 publication.finish_scan_replacement()?;
18500 require(
18501 canceled_stage
18502 .apply(&mut publication, &cancel_control)
18503 .is_err(),
18504 "full reuse cancellation was ignored",
18505 )?;
18506 }
18507 let retained_generation = store
18508 .index_publication()?
18509 .ok_or("full reuse republished generation is missing")?
18510 .generation;
18511 require_eq(
18512 &retained_generation,
18513 &first_generation
18514 .checked_next()
18515 .ok_or("full reuse generation overflow")?,
18516 "full reuse cancellation changed the current generation",
18517 )?;
18518 require_eq(
18519 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18520 &persisted,
18521 "full reuse cancellation changed rejection details",
18522 )?;
18523
18524 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
18525 let fault_policy =
18526 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
18527 let mut fault_stage = stage_full_repository_graph(
18528 &store,
18529 &root,
18530 retained_generation,
18531 &nodes,
18532 &fault_policy,
18533 &empty_symbol_build_stage(),
18534 &fault_control,
18535 )?;
18536 fault_stage.identity_rejections.resize(
18537 usize::try_from(GraphLimits::MAX_ROWS)
18538 .unwrap_or(usize::MAX)
18539 .saturating_add(1),
18540 fault_stage.identity_rejections[0].clone(),
18541 );
18542 {
18543 let mut publication = store.begin_index_publication("full-reuse-fault")?;
18544 publication.begin_scan_replacement()?;
18545 publication.upsert_scan_node_batch(&nodes)?;
18546 publication.finish_scan_replacement()?;
18547 require(
18548 fault_stage.apply(&mut publication, &fault_control).is_err(),
18549 "full reuse late rejection-detail fault was ignored",
18550 )?;
18551 }
18552 require_eq(
18553 &store
18554 .index_publication()?
18555 .ok_or("full reuse fault lost publication")?
18556 .generation,
18557 &retained_generation,
18558 "full reuse late fault changed current generation",
18559 )?;
18560 require_eq(
18561 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18562 &persisted,
18563 "full reuse late fault changed rejection details",
18564 )?;
18565 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
18566 let retry_policy =
18567 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
18568 let retry_stage = stage_full_repository_graph(
18569 &store,
18570 &root,
18571 retained_generation,
18572 &nodes,
18573 &retry_policy,
18574 &empty_symbol_build_stage(),
18575 &retry_control,
18576 )?;
18577 publish_full_staged_graph(
18578 &mut store,
18579 &nodes,
18580 &retry_stage,
18581 &retry_control,
18582 "full-reuse-retry",
18583 )?;
18584 require_eq(
18585 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
18586 &persisted,
18587 "full reuse retry changed rejection details",
18588 )?;
18589 let retained_generation = store
18590 .index_publication()?
18591 .ok_or("full reuse retry publication is missing")?
18592 .generation;
18593
18594 let valid_graph = extract_symbol_graph(
18595 path,
18596 Some("rust"),
18597 "pub fn caller() { helper(); }\nfn helper() {}\n",
18598 );
18599 let valid_control = IndexWorkControl::new(IndexCancellation::new(), None);
18600 let valid_policy =
18601 RootScanPolicy::discover(&root, &ScanOptions::default(), &valid_control)?;
18602 let valid_stage = stage_full_repository_graph(
18603 &store,
18604 &root,
18605 retained_generation,
18606 &nodes,
18607 &valid_policy,
18608 &symbol_build_stage_for_graphs(vec![valid_graph]),
18609 &valid_control,
18610 )?;
18611 require(
18612 valid_stage.identity_rejections.is_empty(),
18613 "full reuse changed graph retained stale rejection detail",
18614 )?;
18615 publish_full_staged_graph(
18616 &mut store,
18617 &nodes,
18618 &valid_stage,
18619 &valid_control,
18620 "full-reuse-repair",
18621 )?;
18622 require(
18623 store
18624 .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
18625 .is_empty(),
18626 "full reuse repaired graph retained stale rejection detail",
18627 )?;
18628 Ok(())
18629 }
18630
18631 #[test]
18632 fn full_stage_reuse_preserves_capped_fallback_omission_count_without_details()
18633 -> Result<(), Box<dyn Error>> {
18634 let temp = tempfile::tempdir()?;
18635 let root = temp.path().join("full-reuse-fallback-identity-admission");
18636 fs::create_dir_all(root.join("src"))?;
18637 fs::write(
18638 root.join("src/reused.rs"),
18639 "pub fn caller() { helper(); }\nfn helper() {}\n",
18640 )?;
18641 let database = root.join(".projectatlas/projectatlas.db");
18642 fs::create_dir_all(root.join(".projectatlas"))?;
18643 let mut store = AtlasStore::open_for_project(&database, &root)?;
18644 let project = store
18645 .project_instance_id()?
18646 .ok_or("fallback reuse project identity is missing")?;
18647 let path = "src/reused.rs";
18648 let mut nodes = vec![test_file_node(path, "rust")];
18649 capture_test_file_hashes(&root, &mut nodes)?;
18650 let mut graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
18651 graph.parser = ParserKind::Fallback;
18652 for symbol in &mut graph.symbols {
18653 symbol.parser = ParserKind::Fallback;
18654 }
18655 for relation in &mut graph.relations {
18656 relation.parser = ParserKind::Fallback;
18657 }
18658 let control = IndexWorkControl::new(IndexCancellation::new(), None);
18659 let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
18660 let mut symbols = symbol_build_stage_for_graphs(vec![graph]);
18661 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
18662 let persisted_graph = symbols
18663 .changes
18664 .iter()
18665 .find_map(|change| match change {
18666 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18667 SymbolProjectionChange::Clear { .. } => None,
18668 })
18669 .ok_or("fallback sanitized graph is missing")?;
18670 let mut stage = stage_full_repository_graph(
18671 &store,
18672 &root,
18673 IndexGeneration::ZERO,
18674 &nodes,
18675 &policy,
18676 &symbols,
18677 &control,
18678 )?;
18679 require_eq(
18680 &stage.identity_rejections.len(),
18681 &1,
18682 "fallback initial typed rejection detail count",
18683 )?;
18684 let coverage = stage
18685 .coverage
18686 .iter_mut()
18687 .find(|coverage| {
18688 matches!(
18689 coverage.scope(),
18690 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18691 ) && coverage.relation().is_none()
18692 })
18693 .ok_or("fallback initial coverage row is missing")?;
18694 let scope = coverage.scope().clone();
18695 let state = coverage.state();
18696 let covered = coverage.covered();
18697 let generation = coverage.generation();
18698 let reason = coverage.reason().cloned();
18699 let reached_limit = coverage.reached_limit();
18700 *coverage = CoverageRecord::new(
18701 scope,
18702 None,
18703 state,
18704 covered,
18705 2,
18706 generation,
18707 reason,
18708 reached_limit,
18709 )?;
18710 stage.identity_rejections.clear();
18713 publish_full_staged_graph(
18714 &mut store,
18715 &nodes,
18716 &stage,
18717 &control,
18718 "fallback-reuse-initial",
18719 )?;
18720 store.replace_symbol_graph(&persisted_graph)?;
18721 let base_generation = store
18722 .index_publication()?
18723 .ok_or("fallback initial publication is missing")?
18724 .generation;
18725 let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
18726 require(
18727 store
18728 .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
18729 .is_empty(),
18730 "fallback capped publication retained an unexpected detail row",
18731 )?;
18732
18733 let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
18734 let reuse_policy =
18735 RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
18736 let reused = stage_full_repository_graph(
18737 &store,
18738 &root,
18739 base_generation,
18740 &nodes,
18741 &reuse_policy,
18742 &empty_symbol_build_stage(),
18743 &reuse_control,
18744 )?;
18745 require(
18746 reused.identity_rejections.is_empty(),
18747 "fallback reuse fabricated a detail row after cap",
18748 )?;
18749 let reused_coverage = reused
18750 .coverage
18751 .iter()
18752 .find(|coverage| {
18753 matches!(
18754 coverage.scope(),
18755 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
18756 ) && coverage.relation().is_none()
18757 })
18758 .ok_or("fallback reused coverage row is missing")?;
18759 require_eq(
18760 &reused_coverage.omitted(),
18761 &2,
18762 "fallback reused persisted omission count",
18763 )?;
18764 require_eq(
18765 &reused_coverage.state(),
18766 &CoverageState::Partial,
18767 "fallback reused coverage state",
18768 )?;
18769 Ok(())
18770 }
18771
18772 #[test]
18773 fn incremental_reuse_hydrates_unchanged_affected_dependent_rejections()
18774 -> Result<(), Box<dyn Error>> {
18775 let temp = tempfile::tempdir()?;
18776 let root = temp
18777 .path()
18778 .join("incremental-reuse-dependent-identity-admission");
18779 fs::create_dir_all(root.join("src"))?;
18780 fs::write(root.join("src/provider.rs"), "pub fn changed() {}\n")?;
18781 fs::write(
18782 root.join("src/consumer.rs"),
18783 "pub fn caller() { changed(); }\n",
18784 )?;
18785 let database = root.join(".projectatlas/projectatlas.db");
18786 fs::create_dir_all(root.join(".projectatlas"))?;
18787 let mut store = AtlasStore::open_for_project(&database, &root)?;
18788 let project = store
18789 .project_instance_id()?
18790 .ok_or("incremental dependent project identity is missing")?;
18791 let provider_path = "src/provider.rs";
18792 let consumer_path = "src/consumer.rs";
18793 let mut nodes = vec![
18794 test_file_node(provider_path, "rust"),
18795 test_file_node(consumer_path, "rust"),
18796 ];
18797 capture_test_file_hashes(&root, &mut nodes)?;
18798 let provider = extract_symbol_graph(provider_path, Some("rust"), "pub fn changed() {}\n");
18799 let mut consumer = extract_symbol_graph(
18800 consumer_path,
18801 Some("rust"),
18802 "pub fn caller() { changed(); }\n",
18803 );
18804 consumer.relations.push(SymbolRelation {
18805 path: consumer_path.to_string(),
18806 source_name: "caller".to_string(),
18807 target_name: "bad\0target".to_string(),
18808 kind: RelationKind::Calls,
18809 line: 3,
18810 context: "incremental unchanged dependent fixture".to_string(),
18811 parser: ParserKind::TreeSitter,
18812 });
18813 let mut initial_symbols = symbol_build_stage_for_graphs(vec![provider, consumer]);
18814 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
18815 initial_symbols.identity_admission =
18816 super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
18817 let persisted_graphs = initial_symbols
18818 .changes
18819 .iter()
18820 .filter_map(|change| match change {
18821 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
18822 SymbolProjectionChange::Clear { .. } => None,
18823 })
18824 .collect::<Vec<_>>();
18825 let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
18826 let initial_stage = stage_full_repository_graph(
18827 &store,
18828 &root,
18829 IndexGeneration::ZERO,
18830 &nodes,
18831 &policy,
18832 &initial_symbols,
18833 &initial_control,
18834 )?;
18835 require(
18836 initial_stage
18837 .identity_rejections
18838 .iter()
18839 .any(|rejection| rejection.path.as_str() == consumer_path),
18840 "initial dependent rejection was not admitted",
18841 )?;
18842 publish_full_staged_graph(
18843 &mut store,
18844 &nodes,
18845 &initial_stage,
18846 &initial_control,
18847 "incremental-dependent-initial",
18848 )?;
18849 for graph in &persisted_graphs {
18850 store.replace_symbol_graph(graph)?;
18851 }
18852 let base_generation = store
18853 .index_publication()?
18854 .ok_or("incremental dependent initial publication is missing")?
18855 .generation;
18856 let consumer_key = RepositoryNodePath::new(Path::new(consumer_path))?;
18857 let initial_rows = store.repository_graph_identity_rejections(
18858 project,
18859 std::slice::from_ref(&consumer_key),
18860 16,
18861 None,
18862 )?;
18863 require_eq(
18864 &initial_rows.len(),
18865 &1,
18866 "initial unchanged dependent rejection rows",
18867 )?;
18868
18869 let replacement =
18870 extract_symbol_graph(provider_path, Some("rust"), "pub fn replacement() {}\n");
18871 let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
18872 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
18873 let incremental_policy =
18874 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
18875 let incremental_stage = stage_incremental_repository_graph(
18876 &store,
18877 &root,
18878 base_generation,
18879 &nodes,
18880 std::slice::from_ref(&provider_path.to_string()),
18881 &incremental_policy,
18882 &replacement_symbols,
18883 &incremental_control,
18884 )?;
18885 require(
18886 incremental_stage
18887 .identity_rejections
18888 .iter()
18889 .any(|rejection| rejection.path.as_str() == consumer_path),
18890 "incremental affected dependent did not hydrate rejection details",
18891 )?;
18892 let staged_coverage = incremental_stage
18893 .coverage
18894 .iter()
18895 .find(|coverage| {
18896 matches!(
18897 coverage.scope(),
18898 CoverageScope::Path { path } if path.as_str() == consumer_path
18899 ) && coverage.relation().is_none()
18900 })
18901 .ok_or("incremental affected dependent coverage is missing")?;
18902 require_eq(
18903 &staged_coverage.omitted(),
18904 &1,
18905 "incremental affected dependent omission count",
18906 )?;
18907 {
18908 let mut publication = store.begin_index_publication("incremental-dependent-reuse")?;
18909 incremental_stage.apply(&mut publication, &incremental_control)?;
18910 publication.complete()?;
18911 }
18912 require_eq(
18913 &store.repository_graph_identity_rejections(
18914 project,
18915 std::slice::from_ref(&consumer_key),
18916 16,
18917 None,
18918 )?,
18919 &initial_rows,
18920 "incremental affected dependent persisted rejection rows",
18921 )?;
18922 Ok(())
18923 }
18924
18925 #[test]
18926 fn incremental_reuse_reconciles_markdown_and_semantic_rejections_once()
18927 -> Result<(), Box<dyn Error>> {
18928 let temp = tempfile::tempdir()?;
18929 let root = fs::canonicalize(temp.path())?;
18930 let semantic_components = (0..20)
18931 .map(|_| "s".repeat(200))
18932 .collect::<Vec<_>>()
18933 .join("/");
18934 let semantic_path = format!("src/semantic/{semantic_components}/graph.rs");
18935 fs::create_dir_all(root.join("src"))?;
18936 fs::create_dir_all(root.join("docs"))?;
18937 fs::create_dir_all(
18938 root.join(&semantic_path)
18939 .parent()
18940 .ok_or("semantic fixture parent is missing")?,
18941 )?;
18942 let guide_source = "[invalid](<../src/worker.rs#bad\u{1}>)\n[worker](../src/worker.rs)\n";
18943 fs::write(root.join("src/worker.rs"), "pub fn worker() {}\n")?;
18944 fs::write(
18945 root.join(&semantic_path),
18946 semantic_resolution_key_source(&semantic_path, true),
18947 )?;
18948 fs::write(root.join("docs/guide.md"), guide_source)?;
18949 let database = root.join(".projectatlas/projectatlas.db");
18950 fs::create_dir_all(root.join(".projectatlas"))?;
18951 let mut store = AtlasStore::open_for_project(&database, &root)?;
18952 let project = store
18953 .project_instance_id()?
18954 .ok_or("combined reuse project identity is missing")?;
18955 let mut nodes = vec![
18956 test_file_node("src/worker.rs", "rust"),
18957 test_file_node(&semantic_path, "rust"),
18958 test_file_node("docs/guide.md", "markdown"),
18959 ];
18960 capture_test_file_hashes(&root, &mut nodes)?;
18961 let worker_graph =
18962 extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn worker() {}\n");
18963 let semantic_graph = semantic_resolution_key_graph(&semantic_path, true);
18964 let guide_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
18965 let guide_graph = guide_facts.symbol_graph("docs/guide.md", Some("markdown"));
18966 let guide_bytes = guide_source.as_bytes();
18967 nodes[2].size_bytes = Some(u64::try_from(guide_bytes.len())?);
18968 nodes[2].content_hash = Some(blake3::hash(guide_bytes).to_hex().to_string());
18969 let mut initial_symbols = symbol_build_stage_for_graphs(vec![worker_graph, semantic_graph]);
18970 initial_symbols.report.candidates = initial_symbols.report.candidates.saturating_add(1);
18971 initial_symbols.report.parsed = initial_symbols.report.parsed.saturating_add(1);
18972 initial_symbols.report.summaries = initial_symbols.report.summaries.saturating_add(1);
18973 initial_symbols
18974 .changes
18975 .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
18976 path: "docs/guide.md".to_string(),
18977 graph: guide_graph,
18978 markdown_facts: Some(Box::new(guide_facts)),
18979 source_semantics: None,
18980 source_parser: ParserKind::Structural,
18981 summary: "combined reuse Markdown fixture".to_string(),
18982 summary_is_structural: true,
18983 purpose_suggestion: None,
18984 }));
18985 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
18986 initial_symbols.identity_admission =
18987 super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
18988 let scan_policy =
18989 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
18990 let initial_stage = stage_full_repository_graph(
18991 &store,
18992 &root,
18993 IndexGeneration::ZERO,
18994 &nodes,
18995 &scan_policy,
18996 &initial_symbols,
18997 &initial_control,
18998 )?;
18999 require(
19000 initial_stage
19001 .identity_rejections
19002 .iter()
19003 .any(|row| row.path.as_str() == "docs/guide.md"),
19004 "combined reuse fixture did not retain Markdown rejection",
19005 )?;
19006 require(
19007 initial_stage.identity_rejections.iter().any(|row| {
19008 row.path.as_str() == semantic_path && row.field == GraphIdentityField::ResolutionKey
19009 }),
19010 "combined reuse fixture did not retain semantic rejection",
19011 )?;
19012 publish_full_staged_graph(
19013 &mut store,
19014 &nodes,
19015 &initial_stage,
19016 &initial_control,
19017 "combined-reuse-initial",
19018 )?;
19019 for change in &initial_symbols.changes {
19020 if let SymbolProjectionChange::Parsed(parsed) = change {
19021 store.replace_symbol_graph(&parsed.graph)?;
19022 }
19023 }
19024 let base_generation = store
19025 .index_publication()?
19026 .ok_or("combined reuse initial publication is missing")?
19027 .generation;
19028 let paths = nodes
19029 .iter()
19030 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
19031 .collect::<Result<Vec<_>, _>>()?;
19032 let initial_rows = store.repository_graph_identity_rejections(
19033 project,
19034 &paths,
19035 GraphLimits::MAX_ROWS,
19036 None,
19037 )?;
19038 let replacement =
19039 extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn replacement() {}\n");
19040 let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
19041 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
19042 let incremental_stage = stage_incremental_repository_graph(
19043 &store,
19044 &root,
19045 base_generation,
19046 &nodes,
19047 &["src/worker.rs".to_string()],
19048 &scan_policy,
19049 &replacement_symbols,
19050 &incremental_control,
19051 )?;
19052 let guide_coverage = incremental_stage
19053 .coverage
19054 .iter()
19055 .find(|coverage| {
19056 matches!(
19057 coverage.scope(),
19058 CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
19059 ) && coverage.relation().is_none()
19060 })
19061 .ok_or("combined reuse Markdown coverage is missing")?;
19062 let semantic_coverage = incremental_stage
19063 .coverage
19064 .iter()
19065 .find(|coverage| {
19066 matches!(
19067 coverage.scope(),
19068 CoverageScope::Path { path } if path.as_str() == semantic_path
19069 ) && coverage.relation().is_none()
19070 })
19071 .ok_or("combined reuse semantic coverage is missing")?;
19072 let initial_guide_omitted = initial_rows
19073 .iter()
19074 .filter(|row| row.path.as_str() == "docs/guide.md")
19075 .count();
19076 let initial_semantic_omitted = initial_rows
19077 .iter()
19078 .filter(|row| row.path.as_str() == semantic_path)
19079 .count();
19080 require_eq(
19081 &guide_coverage.omitted(),
19082 &u64::try_from(initial_guide_omitted)?,
19083 "reused Markdown omission count",
19084 )?;
19085 require_eq(
19086 &semantic_coverage.omitted(),
19087 &u64::try_from(initial_semantic_omitted)?,
19088 "reused semantic omission count",
19089 )?;
19090 require_eq(
19091 &incremental_stage
19092 .identity_rejections
19093 .iter()
19094 .filter(|row| row.path.as_str() == "docs/guide.md")
19095 .count(),
19096 &initial_guide_omitted,
19097 "reused Markdown detail count",
19098 )?;
19099 require_eq(
19100 &incremental_stage
19101 .identity_rejections
19102 .iter()
19103 .filter(|row| row.path.as_str() == semantic_path)
19104 .count(),
19105 &initial_semantic_omitted,
19106 "reused semantic detail count",
19107 )?;
19108 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
19109 canceled.cancel();
19110 {
19111 let mut publication = store.begin_index_publication("combined-reuse-cancel")?;
19112 require(
19113 incremental_stage
19114 .apply(&mut publication, &canceled)
19115 .is_err(),
19116 "combined reuse cancellation reached publication",
19117 )?;
19118 }
19119 require_eq(
19120 &store
19121 .index_publication()?
19122 .map(|publication| publication.generation),
19123 &Some(base_generation),
19124 "combined reuse cancellation changed generation",
19125 )?;
19126 {
19127 let mut publication = store.begin_index_publication("combined-reuse-incremental")?;
19128 incremental_stage.apply(&mut publication, &incremental_control)?;
19129 publication.complete()?;
19130 }
19131 let persisted_rows = store.repository_graph_identity_rejections(
19132 project,
19133 &paths,
19134 GraphLimits::MAX_ROWS,
19135 None,
19136 )?;
19137 require_eq(
19138 &persisted_rows,
19139 &initial_rows,
19140 "reused Markdown and semantic persisted rows",
19141 )?;
19142 let retry_generation = store
19143 .index_publication()?
19144 .ok_or("combined reuse incremental publication is missing")?
19145 .generation;
19146 let retry_stage = stage_incremental_repository_graph(
19147 &store,
19148 &root,
19149 retry_generation,
19150 &nodes,
19151 &["src/worker.rs".to_string()],
19152 &scan_policy,
19153 &replacement_symbols,
19154 &incremental_control,
19155 )?;
19156 require_eq(
19157 &retry_stage
19158 .identity_rejections
19159 .iter()
19160 .filter(|row| row.path.as_str() == "docs/guide.md")
19161 .count(),
19162 &initial_guide_omitted,
19163 "reused Markdown deterministic retry detail count",
19164 )?;
19165 require_eq(
19166 &retry_stage
19167 .identity_rejections
19168 .iter()
19169 .filter(|row| row.path.as_str() == semantic_path)
19170 .count(),
19171 &initial_semantic_omitted,
19172 "reused semantic deterministic retry detail count",
19173 )?;
19174 Ok(())
19175 }
19176
19177 #[test]
19178 fn incremental_stage_repairs_and_removes_only_affected_rejection_paths()
19179 -> Result<(), Box<dyn Error>> {
19180 let temp = tempfile::tempdir()?;
19181 let root = temp.path().join("incremental-identity-admission");
19182 for directory in ["src", "tests", "docs"] {
19183 fs::create_dir_all(root.join(directory))?;
19184 }
19185 for path in ["src/one.rs", "tests/two.rs", "docs/three.rs"] {
19186 fs::write(
19187 root.join(path),
19188 "pub fn caller() { helper(); }\nfn helper() {}\n",
19189 )?;
19190 }
19191 let database = root.join(".projectatlas/projectatlas.db");
19192 fs::create_dir_all(root.join(".projectatlas"))?;
19193 let mut store = AtlasStore::open_for_project(&database, &root)?;
19194 let project = store
19195 .project_instance_id()?
19196 .ok_or("incremental admission project identity is missing")?;
19197 let initial_nodes = [
19198 test_file_node("src/one.rs", "rust"),
19199 test_file_node("tests/two.rs", "rust"),
19200 test_file_node("docs/three.rs", "rust"),
19201 ];
19202 let initial_graphs = vec![
19203 identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget),
19204 identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationTarget),
19205 identity_sibling_graph("docs/three.rs", GraphIdentityField::RelationTarget),
19206 ];
19207 let initial_symbols = symbol_build_stage_for_graphs(initial_graphs);
19208 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
19209 let initial_policy =
19210 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
19211 let initial_stage = stage_full_repository_graph(
19212 &store,
19213 &root,
19214 IndexGeneration::ZERO,
19215 &initial_nodes,
19216 &initial_policy,
19217 &initial_symbols,
19218 &initial_control,
19219 )?;
19220 publish_full_staged_graph(
19221 &mut store,
19222 &initial_nodes,
19223 &initial_stage,
19224 &initial_control,
19225 "incremental-identity-initial",
19226 )?;
19227 let base_generation = store
19228 .index_publication()?
19229 .ok_or("incremental initial publication is missing")?
19230 .generation;
19231 let src_path = RepositoryNodePath::new(Path::new("src/one.rs"))?;
19232 let removed_path = RepositoryNodePath::new(Path::new("tests/two.rs"))?;
19233 let retained_path = RepositoryNodePath::new(Path::new("docs/three.rs"))?;
19234 let initial_rows = store.repository_graph_identity_rejections(
19235 project,
19236 &[
19237 src_path.clone(),
19238 removed_path.clone(),
19239 retained_path.clone(),
19240 ],
19241 16,
19242 None,
19243 )?;
19244 require_eq(
19245 &initial_rows.len(),
19246 &3,
19247 "initial incremental rejection rows",
19248 )?;
19249
19250 let repaired_graph = extract_symbol_graph(
19251 "src/one.rs",
19252 Some("rust"),
19253 "pub fn caller() { helper(); }\nfn helper() {}\n",
19254 );
19255 let repaired_symbols = symbol_build_stage_for_graphs(vec![repaired_graph]);
19256 let expected_nodes = vec![initial_nodes[0].clone(), initial_nodes[2].clone()];
19257 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
19258 let incremental_policy =
19259 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
19260 let incremental_stage = stage_incremental_repository_graph(
19261 &store,
19262 &root,
19263 base_generation,
19264 &expected_nodes,
19265 &["src/one.rs".to_string(), "tests/two.rs".to_string()],
19266 &incremental_policy,
19267 &repaired_symbols,
19268 &incremental_control,
19269 )?;
19270 {
19271 let mut publication = store.begin_index_publication("incremental-identity-repair")?;
19272 incremental_stage.apply(&mut publication, &incremental_control)?;
19273 publication.complete()?;
19274 }
19275 let repaired_rows = store.repository_graph_identity_rejections(
19276 project,
19277 &[
19278 src_path.clone(),
19279 removed_path.clone(),
19280 retained_path.clone(),
19281 ],
19282 16,
19283 None,
19284 )?;
19285 require(
19286 repaired_rows
19287 .iter()
19288 .all(|row| row.path != src_path && row.path != removed_path),
19289 "incremental repair/removal retained affected rejection detail",
19290 )?;
19291 require(
19292 repaired_rows.iter().any(|row| row.path == retained_path),
19293 "incremental repair removed unrelated rejection detail",
19294 )?;
19295 require_eq(
19296 &repaired_rows.len(),
19297 &1,
19298 "incremental unaffected rejection rows",
19299 )?;
19300 Ok(())
19301 }
19302
19303 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
19304 if condition {
19305 Ok(())
19306 } else {
19307 Err(io::Error::other(message).into())
19308 }
19309 }
19310
19311 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
19312 where
19313 T: Debug + PartialEq,
19314 {
19315 if actual == expected {
19316 Ok(())
19317 } else {
19318 Err(io::Error::other(format!(
19319 "{label} mismatch: actual={actual:?}, expected={expected:?}"
19320 ))
19321 .into())
19322 }
19323 }
19324
19325 fn publish_full_staged_graph(
19326 store: &mut AtlasStore,
19327 nodes: &[Node],
19328 staged: &StagedRepositoryGraph,
19329 control: &IndexWorkControl,
19330 label: &str,
19331 ) -> Result<(), Box<dyn Error>> {
19332 let mut publication = store.begin_index_publication(label)?;
19333 publication.begin_scan_replacement()?;
19334 publication.upsert_scan_node_batch(nodes)?;
19335 publication.finish_scan_replacement()?;
19336 staged.apply(&mut publication, control)?;
19337 publication.complete()?;
19338 Ok(())
19339 }
19340
19341 fn symbol_build_stage_for_markdown(
19342 graph: SymbolGraph,
19343 markdown_facts: projectatlas_symbols::MarkdownFacts,
19344 ) -> SymbolBuildStage {
19345 let mut stage = empty_symbol_build_stage();
19346 stage.report.candidates = 1;
19347 stage.report.parsed = 1;
19348 stage.report.summaries = 1;
19349 stage.changes = vec![SymbolProjectionChange::Parsed(SymbolParseSuccess {
19350 path: graph.path.clone(),
19351 source_semantics: None,
19352 source_parser: ParserKind::Structural,
19353 graph,
19354 markdown_facts: Some(Box::new(markdown_facts)),
19355 summary: "markdown identity admission fixture".to_string(),
19356 summary_is_structural: true,
19357 purpose_suggestion: None,
19358 })];
19359 stage
19360 }
19361
19362 fn symbol_build_stage_for_graphs(graphs: Vec<SymbolGraph>) -> SymbolBuildStage {
19363 let mut stage = empty_symbol_build_stage();
19364 stage.report.candidates = graphs.len();
19365 stage.report.parsed = graphs.len();
19366 stage.report.symbols = graphs.iter().map(|graph| graph.symbols.len()).sum();
19367 stage.report.relations = graphs.iter().map(|graph| graph.relations.len()).sum();
19368 stage.changes = graphs
19369 .into_iter()
19370 .map(|graph| {
19371 let path = graph.path.clone();
19372 let source_parser = graph.parser;
19373 SymbolProjectionChange::Parsed(SymbolParseSuccess {
19374 path,
19375 graph,
19376 markdown_facts: None,
19377 source_semantics: None,
19378 source_parser,
19379 summary: "identity admission fixture".to_string(),
19380 summary_is_structural: false,
19381 purpose_suggestion: None,
19382 })
19383 })
19384 .collect();
19385 stage
19386 }
19387
19388 fn identity_sibling_graph(path: &str, invalid_field: GraphIdentityField) -> SymbolGraph {
19389 let mut graph = extract_symbol_graph(
19390 path,
19391 Some("rust"),
19392 "pub fn caller() { helper(); }\nfn helper() {}\n",
19393 );
19394 graph.relations.push(SymbolRelation {
19395 path: path.to_string(),
19396 source_name: if invalid_field == GraphIdentityField::RelationSource {
19397 "bad\0source".to_string()
19398 } else {
19399 "caller".to_string()
19400 },
19401 target_name: if invalid_field == GraphIdentityField::RelationTarget {
19402 "bad\0target".to_string()
19403 } else {
19404 "helper".to_string()
19405 },
19406 kind: RelationKind::Calls,
19407 line: 3,
19408 context: "identity admission fixture".to_string(),
19409 parser: ParserKind::TreeSitter,
19410 });
19411 graph
19412 }
19413
19414 fn semantic_resolution_key_graph(path: &str, include_invalid: bool) -> SymbolGraph {
19415 extract_symbol_graph(
19416 path,
19417 Some("rust"),
19418 &semantic_resolution_key_source(path, include_invalid),
19419 )
19420 }
19421
19422 fn semantic_resolution_key_source(path: &str, include_invalid: bool) -> String {
19424 let helper = if path.contains("sibling") {
19425 "sibling_helper"
19426 } else {
19427 "page_helper"
19428 };
19429 let parent = "LeakedIdentity".repeat(16);
19430 let invalid_methods = if include_invalid {
19431 format!(
19432 "pub struct {parent};\nimpl {parent} {{\n pub fn first(&self) {{}}\n pub fn second(&self) {{}}\n}}\n"
19433 )
19434 } else {
19435 String::new()
19436 };
19437 format!(
19438 "use crate::worker;\n{invalid_methods}pub fn caller() {{ {helper}(); worker(); }}\npub fn {helper}() {{}}\n"
19439 )
19440 }
19441
19442 #[test]
19443 fn source_recovery_preserves_parser_ordinals_counts_and_hash_guards()
19444 -> Result<(), Box<dyn Error>> {
19445 let temp = tempfile::tempdir()?;
19446 let root = temp.path();
19447 fs::create_dir_all(root.join("src"))?;
19448 let path = "src/main.rs";
19449 let content = "use crate::worker as handler; fn zeta() {} fn alpha() {} fn caller(zeta: fn(), std: Value) { zeta(); std::fs::read(\"x\"); } fn clean() { alpha(); zeta(); std::fs::read(\"x\"); true.then(handler::health_response); }";
19450 fs::write(root.join(path), content)?;
19451 let mut store = AtlasStore::open_for_project(&root.join("atlas.db"), root)?;
19452 let worker = "src/worker.rs";
19453 let worker_content = "pub fn health_response() {}";
19454 fs::write(root.join(worker), worker_content)?;
19455 let mut nodes = vec![test_file_node(path, "rust"), test_file_node(worker, "rust")];
19456 capture_test_file_hashes(root, &mut nodes)?;
19457 store.replace_scan(&nodes)?;
19458 let original = extract_symbol_graph(path, Some("rust"), content);
19459 store.replace_symbol_graph(&original)?;
19460 store.replace_symbol_graph(&extract_symbol_graph(worker, Some("rust"), worker_content))?;
19461 let loaded = store.load_symbol_graphs_for_paths(&[path.to_string()])?;
19462 require(
19463 original.symbols != loaded[0].symbols,
19464 "fixture did not reorder SQLite symbols",
19465 )?;
19466 let control = IndexWorkControl::new(IndexCancellation::new(), None);
19467 let policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
19468 let mut reused = empty_symbol_build_stage();
19469 reused.report.candidates = 2;
19470 reused.report.unchanged = 2;
19471 let staged = stage_full_repository_graph(
19472 &store,
19473 root,
19474 IndexGeneration::ZERO,
19475 &nodes,
19476 &policy,
19477 &reused,
19478 &control,
19479 )?;
19480 require_eq(
19481 &staged.source_recovery.previously_unchanged,
19482 &1,
19483 "full recovered source count",
19484 )?;
19485 let verify = |stage: &StagedRepositoryGraph| -> Result<(), Box<dyn Error>> {
19486 let entities = stage
19487 .entities
19488 .iter()
19489 .map(|entity| (entity.key().digest(), entity))
19490 .collect::<BTreeMap<_, _>>();
19491 let mut resolved = Vec::new();
19492 let mut shadowed = 0;
19493 let mut external = 0;
19494 for relation in &stage.relations {
19495 if relation.kind() != GraphRelationKind::Legacy(RelationKind::Calls) {
19496 continue;
19497 }
19498 let source = entities
19499 .get(relation.source().digest())
19500 .ok_or("missing source")?;
19501 let EntitySelector::Symbol { symbol } = source.selector() else {
19502 return Err("call lost exact source owner".into());
19503 };
19504 match relation.resolution() {
19505 RelationResolution::Resolved { selector, .. } => {
19506 let ReusableTargetSelector::Symbol { symbol: target } = selector else {
19507 return Err("wrong target kind".into());
19508 };
19509 resolved.push((
19510 symbol.name.as_str().to_string(),
19511 target.name.as_str().to_string(),
19512 ));
19513 }
19514 RelationResolution::Unresolved { .. } if symbol.name.as_str() == "caller" => {
19515 shadowed += 1;
19516 }
19517 RelationResolution::Unresolved { reference }
19518 if symbol.name.as_str() == "clean" && reference.as_str() == "true.then" => {
19519 }
19520 RelationResolution::External { .. }
19521 if matches!(symbol.name.as_str(), "clean" | "caller") =>
19522 {
19523 external += 1;
19524 }
19525 other => {
19526 return Err(
19527 format!("unexpected source-owned call: {symbol:?} {other:?}").into(),
19528 );
19529 }
19530 }
19531 }
19532 resolved.sort();
19533 require_eq(
19534 &resolved,
19535 &vec![
19536 ("clean".to_string(), "alpha".to_string()),
19537 ("clean".to_string(), "health_response".to_string()),
19538 ("clean".to_string(), "zeta".to_string()),
19539 ],
19540 "source-owned resolved targets",
19541 )?;
19542 require_eq(&shadowed, &1, "shadowed value call")?;
19543 require_eq(
19544 &external,
19545 &2,
19546 "toolchain namespace calls ignore value parameters",
19547 )?;
19548 Ok(())
19549 };
19550 verify(&staged)?;
19551 let mut report = empty_symbol_build_stage().report;
19552 report.candidates = 2;
19553 report.unchanged = 2;
19554 staged.account_source_recovery(&mut report)?;
19555 require_eq(
19556 &(report.candidates, report.parsed, report.unchanged),
19557 &(2, 1, 1),
19558 "actual recovery accounting",
19559 )?;
19560 publish_full_staged_graph(
19561 &mut store,
19562 &nodes,
19563 &staged,
19564 &control,
19565 "source recovery full",
19566 )?;
19567 let base = store
19568 .repository_graph_generation()?
19569 .ok_or("no full generation")?;
19570 let incremental = stage_incremental_repository_graph(
19571 &store,
19572 root,
19573 base,
19574 &nodes,
19575 &[path.to_string()],
19576 &policy,
19577 &reused,
19578 &control,
19579 )?;
19580 verify(&incremental)?;
19581 require_eq(
19582 &incremental.source_recovery.previously_unchanged,
19583 &1,
19584 "incremental one parse",
19585 )?;
19586 require_eq(
19587 &incremental.source_recovery.additional_candidates,
19588 &0,
19589 "direct source not double counted",
19590 )?;
19591 let updated_worker = format!("{worker_content} // changed");
19592 fs::write(root.join(worker), &updated_worker)?;
19593 let mut updated_nodes = nodes.clone();
19594 capture_test_file_hashes(root, &mut updated_nodes)?;
19595 let changed = symbol_build_stage_for_graphs(vec![extract_symbol_graph(
19596 worker,
19597 Some("rust"),
19598 &updated_worker,
19599 )]);
19600 let inbound = stage_incremental_repository_graph(
19601 &store,
19602 root,
19603 base,
19604 &updated_nodes,
19605 &[worker.to_string()],
19606 &policy,
19607 &changed,
19608 &control,
19609 )?;
19610 verify(&inbound)?;
19611 require_eq(
19612 &inbound.source_recovery.previously_unchanged,
19613 &0,
19614 "fresh direct output was not reparsed",
19615 )?;
19616 require_eq(
19617 &inbound.source_recovery.additional_candidates,
19618 &1,
19619 "unchanged inbound source recovered once",
19620 )?;
19621 let mut report = empty_symbol_build_stage().report;
19622 report.candidates = 1;
19623 report.parsed = 1;
19624 inbound.account_source_recovery(&mut report)?;
19625 require_eq(
19626 &(report.candidates, report.parsed, report.unchanged),
19627 &(2, 2, 0),
19628 "actual inbound recovery accounting",
19629 )?;
19630 let mut cache = super::SourceBindingCache::new(root, &nodes, None);
19631 let first = cache.capture(path, &control)?;
19632 let second = cache.capture(path, &control)?;
19633 require(
19634 std::sync::Arc::ptr_eq(&first, &second),
19635 "capture reparsed a repeated path",
19636 )?;
19637 require_eq(
19638 &cache.work.previously_unchanged,
19639 &1,
19640 "cache actual parse count",
19641 )?;
19642 fs::write(root.join(path), format!("{content} // changed"))?;
19643 let mut cache = super::SourceBindingCache::new(root, &nodes, None);
19644 require(
19645 cache.capture(path, &control).is_err(),
19646 "changed captured hash admitted",
19647 )?;
19648 require_eq(
19649 &store.repository_graph_generation()?,
19650 &Some(base),
19651 "failed capture preserved publication",
19652 )?;
19653 Ok(())
19654 }
19655
19656 #[test]
19657 fn source_recovery_follows_the_consumed_import_and_export_contract() {
19658 for language in ["rust", "python", "javascript", "typescript", "tsx"] {
19659 let mut graph = function_graph("declarations", 1);
19660 graph.language = Some(language.to_string());
19661 graph.relations.clear();
19662 assert_eq!(
19663 super::graph_requires_source_bindings(&graph),
19664 matches!(language, "python" | "javascript" | "typescript" | "tsx"),
19665 "{language}: module export authority needs source binding roles"
19666 );
19667 graph.symbols[0].exported = false;
19668 assert!(
19669 super::graph_requires_source_bindings(&graph) == (language == "python"),
19670 "{language}: Python module exports do not depend on the display exported flag"
19671 );
19672 graph.relations.push(SymbolRelation {
19673 path: graph.path.clone(),
19674 source_name: "<module>".to_string(),
19675 target_name: "module".to_string(),
19676 kind: RelationKind::Imports,
19677 line: 1,
19678 context: String::new(),
19679 parser: ParserKind::TreeSitter,
19680 });
19681 assert!(super::graph_requires_source_bindings(&graph), "{language}");
19682 graph.relations[0].kind = RelationKind::Calls;
19683 assert!(super::graph_requires_source_bindings(&graph), "{language}");
19684 graph.relations.clear();
19685 graph.symbols.clear();
19686 assert!(
19687 !super::graph_requires_source_bindings(&graph),
19688 "{language}: empty graph needs no export proof"
19689 );
19690 }
19691 }
19692
19693 fn empty_symbol_build_stage() -> SymbolBuildStage {
19694 SymbolBuildStage {
19695 report: SymbolBuildReport {
19696 candidates: 0,
19697 parsed: 0,
19698 unchanged: 0,
19699 too_large: 0,
19700 binary_or_non_utf8: 0,
19701 timed_out: 0,
19702 max_workers: 1,
19703 timeout_seconds: None,
19704 symbols: 0,
19705 relations: 0,
19706 summaries: 0,
19707 purpose_suggestions: 0,
19708 },
19709 changes: Vec::new(),
19710 retained_bytes: 0,
19711 identity_admission: GraphIdentityAdmission::default(),
19712 }
19713 }
19714
19715 fn function_graph(path: &str, symbol_count: usize) -> SymbolGraph {
19716 SymbolGraph {
19717 path: path.to_string(),
19718 language: Some("rust".to_string()),
19719 parser: ParserKind::TreeSitter,
19720 symbols: (0..symbol_count)
19721 .map(|index| CodeSymbol {
19722 path: path.to_string(),
19723 language: Some("rust".to_string()),
19724 name: format!("symbol_{index}"),
19725 kind: SymbolKind::Function,
19726 signature: format!("fn symbol_{index}()"),
19727 exported: index == 0,
19728 documentation: None,
19729 line_start: index + 1,
19730 line_end: index + 1,
19731 source_selector: None,
19732 parent: None,
19733 parser: ParserKind::TreeSitter,
19734 detail: Some("function_item".to_string()),
19735 })
19736 .collect(),
19737 relations: vec![SymbolRelation {
19738 path: path.to_string(),
19739 source_name: "symbol_0".to_string(),
19740 target_name: "dependency".to_string(),
19741 kind: RelationKind::Calls,
19742 line: 1,
19743 context: "dependency()".to_string(),
19744 parser: ParserKind::TreeSitter,
19745 }],
19746 }
19747 }
19748
19749 fn package_graph(path: &str, name: &str) -> SymbolGraph {
19750 SymbolGraph {
19751 path: path.to_string(),
19752 language: Some("cargo-manifest".to_string()),
19753 parser: ParserKind::Manifest,
19754 symbols: vec![CodeSymbol {
19755 path: path.to_string(),
19756 language: Some("cargo-manifest".to_string()),
19757 name: name.to_string(),
19758 kind: SymbolKind::Package,
19759 signature: format!("name = \"{name}\""),
19760 exported: true,
19761 documentation: None,
19762 line_start: 1,
19763 line_end: 1,
19764 source_selector: None,
19765 parent: None,
19766 parser: ParserKind::Manifest,
19767 detail: Some("cargo-package".to_string()),
19768 }],
19769 relations: Vec::new(),
19770 }
19771 }
19772}