1use super::{
4 CliError, INDEX_FRESHNESS_SAMPLE_LIMIT, IndexReadStatus, IndexRefreshReason,
5 IndexRefreshRequired, IndexRefreshScope, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
6 IndexWorkStage, MAX_SYMBOL_FILE_BYTES, Node, NodeKind, SourceReadFailure, SymbolBuildStage,
7 SymbolProjectionChange, lossless_project_root_display, normalize_native_path_display,
8 read_source_bytes_controlled, source_changed_during_derivation,
9};
10use projectatlas_core::IndexGeneration;
11use projectatlas_core::graph::{
12 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
13 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
14 ExtendedRelationKind, ExternalSelector, GraphContractError, GraphEntity, GraphIdentityField,
15 GraphIdentityRejection, GraphIdentityRejectionReason, GraphIdentityText, GraphLimitKind,
16 GraphLimits, GraphRelationKind, LogicalRelation, LogicalRelationKey, MAX_GRAPH_IDENTITY_BYTES,
17 PackageSelector, ProjectInstanceId, QUALIFIED_SYMBOL_SCOPE_PREFIX, RelationDependencyKey,
18 RelationOccurrence, RelationResolution, RepositoryFilePath, RepositoryNodePath,
19 ResolutionKeyDomain, SourceSpan, SymbolSelector,
20};
21use projectatlas_core::language::{SemanticProviderOwner, SymbolParserOwner, language_capability};
22use projectatlas_core::symbols::{
23 MODULE_RELATION_SOURCE, ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolRelation,
24};
25use projectatlas_db::{
26 AtlasStore, IndexPublicationGuard, RepositoryAffectedSourceFootprint,
27 RepositoryResolutionCandidate,
28};
29use projectatlas_fs::RootScanPolicy;
30#[cfg(test)]
31use projectatlas_fs::ScanOptions;
32use projectatlas_symbols::{
33 ConfiguredModuleResolution, MAX_RESOLUTION_KEYS_PER_FACT, MarkdownFactCompleteness,
34 MarkdownFactLimit, MarkdownFacts, ResolutionKeyProjection, ResolutionProjectionContext,
35 ResolutionProjectionError, ResolutionProjectionFact, derive_resolution_keys_with_context,
36 extract_markdown_facts_controlled, parse_import_references,
37};
38use std::borrow::{Borrow, Cow};
39use std::cell::RefCell;
40use std::cmp::Reverse;
41use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, btree_map::Entry};
42use std::fs::{self, File, OpenOptions};
43use std::num::NonZeroU32;
44use std::path::{Path, PathBuf};
45use tempfile::{Builder as TempDirBuilder, TempDir};
46
47const MAX_INCREMENTAL_RESOLUTION_ITEMS: u32 = GraphLimits::MAX_ROWS;
49const MAX_IN_MEMORY_GRAPH_WORK_BYTES: u64 = 512 * 1_024 * 1_024;
51const MAX_INCREMENTAL_GRAPH_ROWS: u64 = GraphLimits::MAX_ROWS as u64;
53const MAX_INCREMENTAL_GRAPH_BYTES: u64 = MAX_IN_MEMORY_GRAPH_WORK_BYTES;
55const MAX_GRAPH_KEY_BINDINGS: u64 = 8_000_000;
57const STAGED_GRAPH_ROW_BYTES: u64 = 128;
59const DOCUMENT_PROJECTION_ROW_BYTES: u64 = STAGED_GRAPH_ROW_BYTES * 8;
65const GRAPH_WORK_CHECK_INTERVAL: usize = 256;
67const GRAPH_STAGE_ENTITY_BATCH_SIZE: usize = 1_024;
69const GRAPH_STAGE_ROW_BATCH_SIZE: usize = 8_192;
71const GRAPH_STAGE_DIRECTORY_PREFIX: &str = "graph-stage-";
73const GRAPH_STAGE_LEASE_FILE_NAME: &str = "repository-graph-stage.lock";
75const GRAPH_STAGE_DATABASE_FILE_NAME: &str = "projectatlas.db";
77const GRAPH_STAGE_OWNER_UNAVAILABLE: &str = "repository graph staging owner is unavailable";
79const PERSISTED_GRAPH_PATHS_PER_CHUNK: usize = 256;
81const UNKNOWN_REFERENCE: &str = "unknown-reference";
83const CARGO_PACKAGE_MANAGER: &str = "cargo";
85const RUST_TOOLCHAIN_SYSTEM: &str = "rust-toolchain";
87const NODE_SYSTEM: &str = "node";
89const PARTIAL_COVERAGE_REASON: &str = "parser does not prove complete relationship coverage";
91const CONFIGURATION_SYSTEM: &str = "configuration";
93const ENVIRONMENT_SYSTEM: &str = "environment-variable";
95const DEPLOYMENT_SYSTEM: &str = "deployment-platform";
97const DOCUMENT_PATH_PROVIDER: &str = "projectatlas-document";
99const DOCUMENT_PATH_LANGUAGE: &str = "repository-path";
101const DOCUMENT_CASEFOLD_LANGUAGE: &str = "repository-path-casefold";
103const DOCUMENT_PARTIAL_COVERAGE_REASON: &str =
105 "markdown fact extraction reached a declared limit or unsupported structure";
106const MAX_GRAPH_IDENTITY_REJECTIONS: usize = GraphLimits::MAX_ROWS as usize;
108const SYMBOL_FACT_INDEX_NAMESPACE: u64 = 1_u64 << 56;
110const RELATION_FACT_INDEX_NAMESPACE: u64 = 2_u64 << 56;
112const DERIVED_RELATION_FACT_INDEX_NAMESPACE: u64 = 3_u64 << 56;
114const MARKDOWN_FACT_INDEX_NAMESPACE: u64 = 4_u64 << 56;
116
117fn parser_fact_index(namespace: u64, index: usize) -> u64 {
119 match u64::try_from(index) {
120 Ok(index) => namespace.saturating_add(index),
121 Err(_) => u64::MAX,
122 }
123}
124
125struct PairedImportRelations {
127 by_symbol: Vec<Option<usize>>,
129 #[cfg(test)]
131 work_items: usize,
132}
133
134fn paired_import_relations(
140 graph: &SymbolGraph,
141 control: &IndexWorkControl,
142) -> Result<PairedImportRelations, CliError> {
143 let mut relations_by_line = HashMap::<usize, (Vec<usize>, usize)>::new();
144 #[cfg(test)]
145 let mut work_items = 0_usize;
146 for (relation_index, relation) in graph.relations.iter().enumerate() {
147 check_graph_work(control, relation_index)?;
148 #[cfg(test)]
149 {
150 work_items = work_items.saturating_add(1);
151 }
152 if relation.kind == RelationKind::Imports {
153 relations_by_line
154 .entry(relation.line)
155 .or_default()
156 .0
157 .push(relation_index);
158 }
159 }
160 let mut by_symbol = vec![None; graph.symbols.len()];
161 for (symbol_index, symbol) in graph.symbols.iter().enumerate() {
162 check_graph_work(control, symbol_index)?;
163 #[cfg(test)]
164 {
165 work_items = work_items.saturating_add(1);
166 }
167 if symbol.kind != SymbolKind::Import {
168 continue;
169 }
170 let Some((relation_indices, next_ordinal)) = relations_by_line.get_mut(&symbol.line_start)
171 else {
172 continue;
173 };
174 by_symbol[symbol_index] = relation_indices.get(*next_ordinal).copied();
175 *next_ordinal = (*next_ordinal).saturating_add(1);
176 }
177 Ok(PairedImportRelations {
178 by_symbol,
179 #[cfg(test)]
180 work_items,
181 })
182}
183
184fn symbol_parser_fact_index(symbol_index: usize, paired_relation_index: Option<usize>) -> u64 {
186 paired_relation_index.map_or_else(
187 || parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, symbol_index),
188 |relation_index| parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, relation_index),
189 )
190}
191
192#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
194struct IdentitySpan {
195 start_line: usize,
197 start_column: usize,
199 end_line: usize,
201 end_column: usize,
203}
204
205fn symbol_identity_span(
207 graph: &SymbolGraph,
208 symbol_index: usize,
209 paired_relation_index: Option<usize>,
210) -> IdentitySpan {
211 let Some(symbol) = graph.symbols.get(symbol_index) else {
212 return IdentitySpan {
213 start_line: 1,
214 start_column: 0,
215 end_line: 1,
216 end_column: 0,
217 };
218 };
219 let start_line = symbol.line_start.max(1);
220 let end_line = symbol.line_end.max(symbol.line_start).max(1);
221 if let Some(relation_index) = paired_relation_index
222 && let Some(relation) = graph.relations.get(relation_index)
223 {
224 let line = relation.line.max(1);
225 return IdentitySpan {
226 start_line: line,
227 start_column: 0,
228 end_line: line,
229 end_column: 0,
230 };
231 }
232 IdentitySpan {
233 start_line,
234 start_column: 0,
235 end_line,
236 end_column: 0,
237 }
238}
239
240#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
244struct IdentityFactKey {
245 span: IdentitySpan,
247 parser: u8,
249 owner: u8,
252 fact_index: u64,
254}
255
256#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
261struct IdentityRejectionKey {
262 path: RepositoryNodePath,
264 span: IdentitySpan,
266 parser: u8,
268 field: GraphIdentityField,
270 reason: GraphIdentityRejectionReason,
272 fact_index: u64,
274}
275
276impl From<&GraphIdentityRejection> for IdentityRejectionKey {
277 fn from(rejection: &GraphIdentityRejection) -> Self {
278 Self {
279 path: rejection.path.clone(),
280 span: IdentitySpan {
281 start_line: rejection.span.start_line() as usize,
282 start_column: rejection.span.start_column() as usize,
283 end_line: rejection.span.end_line() as usize,
284 end_column: rejection.span.end_column() as usize,
285 },
286 parser: parser_fact_kind(rejection.parser),
287 field: rejection.field,
288 reason: rejection.reason,
289 fact_index: rejection.fact_index,
290 }
291 }
292}
293
294fn parser_fact_kind(parser: ParserKind) -> u8 {
296 match parser {
297 ParserKind::TreeSitter => 0,
298 ParserKind::Manifest => 1,
299 ParserKind::Structural => 2,
300 ParserKind::Fallback => 3,
301 }
302}
303
304fn identity_fact_owner(fields: &[(GraphIdentityField, GraphIdentityRejectionReason)]) -> u8 {
306 u8::from(
307 fields
308 .iter()
309 .any(|(field, _reason)| *field == GraphIdentityField::ResolutionKey),
310 )
311}
312
313fn is_rederived_identity_fact(fact: &IdentityFactKey) -> bool {
315 fact.owner == 1
316 || matches!(
317 fact.fact_index & !((1_u64 << 56) - 1),
318 DERIVED_RELATION_FACT_INDEX_NAMESPACE | MARKDOWN_FACT_INDEX_NAMESPACE
319 )
320}
321
322fn identity_fact_retained_bytes(
324 path: &str,
325 new_observed_path: bool,
326 new_count_path: bool,
327) -> Result<u64, CliError> {
328 let path_bytes = u64::try_from(path.len()).map_err(|error| {
329 CliError::InvalidInput(format!(
330 "identity rejection path length overflowed: {error}"
331 ))
332 })?;
333 let path_entries = u64::from(u8::from(new_observed_path)) + u64::from(u8::from(new_count_path));
334 let path_bytes = path_bytes
335 .checked_mul(path_entries)
336 .and_then(|bytes| bytes.checked_add(STAGED_GRAPH_ROW_BYTES.checked_mul(path_entries)?))
337 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
338 path_bytes
339 .checked_add(STAGED_GRAPH_ROW_BYTES)
340 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
341}
342
343fn identity_count_path_retained_bytes(path: &str) -> Result<u64, CliError> {
345 let path_bytes = u64::try_from(path.len()).map_err(|error| {
346 CliError::InvalidInput(format!(
347 "identity rejection path length overflowed: {error}"
348 ))
349 })?;
350 path_bytes
351 .checked_add(STAGED_GRAPH_ROW_BYTES)
352 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
353}
354
355fn identity_fact_set_retained_bytes(
357 path: &str,
358 facts: &BTreeSet<IdentityFactKey>,
359) -> Result<u64, CliError> {
360 let path_bytes = u64::try_from(path.len()).map_err(|error| {
361 CliError::InvalidInput(format!(
362 "identity rejection path length overflowed: {error}"
363 ))
364 })?;
365 let fact_bytes = STAGED_GRAPH_ROW_BYTES
366 .checked_mul(u64::try_from(facts.len()).map_err(|error| {
367 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
368 })?)
369 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
370 path_bytes
371 .checked_add(STAGED_GRAPH_ROW_BYTES)
372 .and_then(|bytes| bytes.checked_add(fact_bytes))
373 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
374}
375
376fn identity_observed_path_retained_bytes(path: &str, fact_count: usize) -> Result<u64, CliError> {
378 let path_bytes = u64::try_from(path.len()).map_err(|error| {
379 CliError::InvalidInput(format!(
380 "identity rejection path length overflowed: {error}"
381 ))
382 })?;
383 let fact_bytes = STAGED_GRAPH_ROW_BYTES
384 .checked_mul(u64::try_from(fact_count).map_err(|error| {
385 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
386 })?)
387 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))?;
388 path_bytes
389 .checked_add(STAGED_GRAPH_ROW_BYTES)
390 .and_then(|bytes| bytes.checked_add(fact_bytes))
391 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
392}
393
394fn identity_maps_retained_bytes(
396 observed_facts: &BTreeMap<String, BTreeSet<IdentityFactKey>>,
397 rejected_facts_by_path: &BTreeMap<String, u64>,
398) -> Result<u64, CliError> {
399 let mut retained = 0_u64;
400 for (path, facts) in observed_facts {
401 retained = retained
402 .checked_add(identity_observed_path_retained_bytes(path, facts.len())?)
403 .ok_or_else(|| {
404 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
405 })?;
406 }
407 for path in rejected_facts_by_path.keys() {
408 retained = retained
409 .checked_add(identity_count_path_retained_bytes(path)?)
410 .ok_or_else(|| {
411 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
412 })?;
413 }
414 Ok(retained)
415}
416
417fn identity_rejection_key_retained_bytes(path: &str) -> Result<u64, CliError> {
419 let path_bytes = u64::try_from(path.len()).map_err(|error| {
420 CliError::InvalidInput(format!(
421 "identity rejection path length overflowed: {error}"
422 ))
423 })?;
424 path_bytes
425 .checked_add(STAGED_GRAPH_ROW_BYTES)
426 .ok_or_else(|| CliError::InvalidInput("identity rejection bytes overflowed".to_string()))
427}
428
429fn identity_rejection_keys_retained_bytes(
431 keys: &BTreeSet<IdentityRejectionKey>,
432) -> Result<u64, CliError> {
433 keys.iter().try_fold(0_u64, |retained, key| {
434 retained
435 .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
436 .ok_or_else(|| {
437 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
438 })
439 })
440}
441
442fn identity_rejection_drop_paths_retained_bytes(paths: &BTreeSet<String>) -> Result<u64, CliError> {
444 paths.iter().try_fold(0_u64, |retained, path| {
445 retained
446 .checked_add(identity_count_path_retained_bytes(path)?)
447 .ok_or_else(|| {
448 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
449 })
450 })
451}
452
453fn identity_admission_retained_bytes(report: &GraphIdentityAdmission) -> u64 {
455 report.observed_fact_bytes
456}
457
458fn identity_fact_budget_failure_for_limit(limit: u64, retained_bytes: u64) -> CliError {
460 IndexWorkFailure::resource_limit(
461 IndexWorkStage::SymbolParsing,
462 IndexWorkResource::OutputBytes,
463 limit,
464 retained_bytes,
465 )
466 .into()
467}
468
469fn identity_fact_budget_failure(retained_bytes: u64) -> CliError {
471 identity_fact_budget_failure_for_limit(MAX_IN_MEMORY_GRAPH_WORK_BYTES, retained_bytes)
472}
473
474fn checked_identity_admission_budget(
476 report: &GraphIdentityAdmission,
477 control: &IndexWorkControl,
478 limit: u64,
479) -> Result<u64, CliError> {
480 control.check(IndexWorkStage::SymbolParsing)?;
481 let retained_bytes = identity_admission_retained_bytes(report);
482 if retained_bytes > limit {
483 return Err(identity_fact_budget_failure_for_limit(
484 limit,
485 retained_bytes,
486 ));
487 }
488 Ok(retained_bytes)
489}
490
491#[derive(Clone, Debug, Default)]
493pub(super) struct GraphIdentityAdmission {
494 source_admitted: bool,
496 rejections: Vec<GraphIdentityRejection>,
498 rejection_keys: BTreeSet<IdentityRejectionKey>,
500 rejected_facts_by_path: BTreeMap<String, u64>,
502 observed_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
505 observed_fact_bytes: u64,
508 reused_rejection_facts: BTreeMap<String, BTreeSet<IdentityFactKey>>,
511 relation_fact_indices: BTreeMap<String, Vec<usize>>,
515 resolution_projections: BTreeMap<String, ResolutionKeyProjection>,
517 reused_rejection_counts: BTreeMap<String, u64>,
521 reused_parser_rejection_counts: BTreeMap<String, u64>,
525 reused_rejection_detail_counts: BTreeMap<String, u64>,
528 reused_rejection_details_incomplete: BTreeSet<String>,
532 rejection_details_dropped_by_path: BTreeSet<String>,
535 #[cfg(test)]
537 resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
538 #[cfg(test)]
540 paired_import_pairing_work: usize,
541}
542
543impl GraphIdentityAdmission {
544 fn record(
546 &mut self,
547 path: &str,
548 span: IdentitySpan,
549 parser: ParserKind,
550 fact_index: u64,
551 fields: &[(GraphIdentityField, GraphIdentityRejectionReason)],
552 control: &IndexWorkControl,
553 ) -> Result<(), CliError> {
554 control.check(IndexWorkStage::SymbolParsing)?;
555 let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
556 let fact_span = span;
557 let span = SourceSpan::new(
558 u32::try_from(span.start_line).map_err(|error| {
559 CliError::InvalidInput(format!("identity rejection start line overflowed: {error}"))
560 })?,
561 u32::try_from(span.start_column).map_err(|error| {
562 CliError::InvalidInput(format!(
563 "identity rejection start column overflowed: {error}"
564 ))
565 })?,
566 u32::try_from(span.end_line).map_err(|error| {
567 CliError::InvalidInput(format!("identity rejection end line overflowed: {error}"))
568 })?,
569 u32::try_from(span.end_column).map_err(|error| {
570 CliError::InvalidInput(format!("identity rejection end column overflowed: {error}"))
571 })?,
572 )
573 .map_err(invalid_graph_contract)?;
574 let fact_key = IdentityFactKey {
575 span: fact_span,
576 parser: parser_fact_kind(parser),
577 owner: identity_fact_owner(fields),
578 fact_index,
579 };
580 let path_key = path.as_str().to_owned();
581 let observed_path_is_new = !self.observed_facts.contains_key(&path_key);
582 let count_path_is_new = !self.rejected_facts_by_path.contains_key(&path_key);
583 let fact_is_new = self
584 .observed_facts
585 .get(&path_key)
586 .is_none_or(|facts| !facts.contains(&fact_key));
587 let mut new_rejection_keys = Vec::new();
588 let mut dropped_rejection_detail = false;
589 for (field, reason) in fields {
590 let key = IdentityRejectionKey {
591 path: path.clone(),
592 span: fact_span,
593 parser: parser_fact_kind(parser),
594 field: *field,
595 reason: *reason,
596 fact_index,
597 };
598 if !self.rejection_keys.contains(&key)
599 && !new_rejection_keys.iter().any(|existing| existing == &key)
600 {
601 if self.rejections.len() + new_rejection_keys.len() >= MAX_GRAPH_IDENTITY_REJECTIONS
602 {
603 dropped_rejection_detail = true;
604 } else {
605 new_rejection_keys.push(key);
606 }
607 }
608 }
609 let dropped_path_is_new = dropped_rejection_detail
610 && !self
611 .rejection_details_dropped_by_path
612 .contains(path.as_str());
613 let mut additional_bytes = if fact_is_new {
614 identity_fact_retained_bytes(path.as_str(), observed_path_is_new, count_path_is_new)?
615 } else {
616 0
617 };
618 for key in &new_rejection_keys {
619 additional_bytes = additional_bytes
620 .checked_add(identity_rejection_key_retained_bytes(key.path.as_str())?)
621 .ok_or_else(|| {
622 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
623 })?;
624 }
625 if dropped_path_is_new {
626 additional_bytes = additional_bytes
627 .checked_add(identity_count_path_retained_bytes(path.as_str())?)
628 .ok_or_else(|| {
629 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
630 })?;
631 }
632 self.reserve_identity_bytes(additional_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
633 if dropped_rejection_detail {
634 self.rejection_details_dropped_by_path
635 .insert(path.as_str().to_owned());
636 }
637 if fact_is_new {
638 self.observed_facts
639 .entry(path_key.clone())
640 .or_default()
641 .insert(fact_key);
642 let count = self.rejected_facts_by_path.entry(path_key).or_default();
643 *count = count.checked_add(1).ok_or_else(|| {
644 CliError::InvalidInput("identity rejection count overflowed".to_string())
645 })?;
646 }
647 for key in new_rejection_keys {
648 let field = key.field;
649 let reason = key.reason;
650 self.rejection_keys.insert(key);
651 self.rejections.push(GraphIdentityRejection {
652 path: path.clone(),
653 span,
654 parser,
655 field,
656 reason,
657 fact_index,
658 });
659 }
660 Ok(())
661 }
662
663 fn reserve_identity_bytes(
665 &mut self,
666 additional_bytes: u64,
667 control: &IndexWorkControl,
668 limit: u64,
669 ) -> Result<(), CliError> {
670 control.check(IndexWorkStage::SymbolParsing)?;
671 let retained_bytes = self
672 .observed_fact_bytes
673 .checked_add(additional_bytes)
674 .ok_or_else(|| {
675 identity_fact_budget_failure_for_limit(limit, self.observed_fact_bytes)
676 })?;
677 if retained_bytes > limit {
678 return Err(identity_fact_budget_failure_for_limit(
679 limit,
680 retained_bytes,
681 ));
682 }
683 self.observed_fact_bytes = retained_bytes;
684 Ok(())
685 }
686
687 fn adjust_identity_bytes(&mut self, before: u64, after: u64) -> Result<(), CliError> {
689 if after >= before {
690 self.observed_fact_bytes = self
691 .observed_fact_bytes
692 .checked_add(after - before)
693 .ok_or_else(|| {
694 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
695 })?;
696 } else {
697 self.observed_fact_bytes = self
698 .observed_fact_bytes
699 .checked_sub(before - after)
700 .ok_or_else(|| {
701 CliError::InvalidInput("identity rejection bytes underflowed".to_string())
702 })?;
703 }
704 Ok(())
705 }
706
707 fn merge_observed_identity_path(
710 &mut self,
711 path: &str,
712 incoming_facts: Option<&BTreeSet<IdentityFactKey>>,
713 incoming_total: u64,
714 control: &IndexWorkControl,
715 ) -> Result<(), CliError> {
716 control.check(IndexWorkStage::SymbolParsing)?;
717 let existing_total = self.rejected_facts_by_path.get(path).copied().unwrap_or(0);
718 let existing_observed = self.observed_facts.get(path).map_or(0, BTreeSet::len);
719 let existing_unobserved = existing_total
720 .checked_sub(u64::try_from(existing_observed).map_err(|error| {
721 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
722 })?)
723 .ok_or_else(|| {
724 CliError::InvalidInput(
725 "observed identity facts exceeded rejection count".to_string(),
726 )
727 })?;
728 let incoming_observed = incoming_facts.map_or(0, BTreeSet::len);
729 let incoming_unobserved = incoming_total
730 .checked_sub(u64::try_from(incoming_observed).map_err(|error| {
731 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
732 })?)
733 .ok_or_else(|| {
734 CliError::InvalidInput(
735 "observed identity facts exceeded rejection count".to_string(),
736 )
737 })?;
738 let new_observed = incoming_facts.map_or(0, |facts| {
739 facts
740 .iter()
741 .filter(|fact| {
742 self.observed_facts
743 .get(path)
744 .is_none_or(|existing| !existing.contains(*fact))
745 })
746 .count()
747 });
748 let observed = existing_observed
749 .checked_add(new_observed)
750 .ok_or_else(|| CliError::InvalidInput("identity fact count overflowed".to_string()))?;
751 let total = u64::try_from(observed)
752 .map_err(|error| {
753 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
754 })?
755 .checked_add(existing_unobserved)
756 .and_then(|count| count.checked_add(incoming_unobserved))
757 .ok_or_else(|| {
758 CliError::InvalidInput("identity rejection count overflowed".to_string())
759 })?;
760
761 let mut before_bytes = 0_u64;
762 if self.observed_facts.contains_key(path) {
763 before_bytes = before_bytes
764 .checked_add(identity_observed_path_retained_bytes(
765 path,
766 existing_observed,
767 )?)
768 .ok_or_else(|| {
769 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
770 })?;
771 }
772 if self.rejected_facts_by_path.contains_key(path) {
773 before_bytes = before_bytes
774 .checked_add(identity_count_path_retained_bytes(path)?)
775 .ok_or_else(|| {
776 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
777 })?;
778 }
779 let mut after_bytes = 0_u64;
780 if observed > 0 {
781 after_bytes = after_bytes
782 .checked_add(identity_observed_path_retained_bytes(path, observed)?)
783 .ok_or_else(|| {
784 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
785 })?;
786 }
787 if total > 0 {
788 after_bytes = after_bytes
789 .checked_add(identity_count_path_retained_bytes(path)?)
790 .ok_or_else(|| {
791 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
792 })?;
793 }
794 self.adjust_identity_bytes(before_bytes, after_bytes)?;
795
796 if observed == 0 {
797 self.observed_facts.remove(path);
798 } else if let Some(facts) = incoming_facts
799 && !facts.is_empty()
800 {
801 self.observed_facts
802 .entry(path.to_string())
803 .or_default()
804 .extend(facts.iter().cloned());
805 }
806 if total == 0 {
807 self.rejected_facts_by_path.remove(path);
808 self.observed_facts.remove(path);
809 } else {
810 self.rejected_facts_by_path.insert(path.to_string(), total);
811 }
812 Ok(())
813 }
814
815 fn reserve_reused_path_bytes(
817 &mut self,
818 path: &str,
819 already_retained: bool,
820 control: &IndexWorkControl,
821 limit: u64,
822 ) -> Result<(), CliError> {
823 control.check(IndexWorkStage::SymbolParsing)?;
824 if !already_retained {
825 self.reserve_identity_bytes(identity_count_path_retained_bytes(path)?, control, limit)?;
826 }
827 Ok(())
828 }
829
830 fn record_rejected_fact_count(
832 &mut self,
833 path: &str,
834 count: usize,
835 control: &IndexWorkControl,
836 ) -> Result<(), CliError> {
837 if count == 0 {
838 control.check(IndexWorkStage::SymbolParsing)?;
839 return Ok(());
840 }
841 control.check(IndexWorkStage::SymbolParsing)?;
842 let path = RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?;
843 let count = u64::try_from(count).map_err(|error| {
844 CliError::InvalidInput(format!("identity rejection count overflowed: {error}"))
845 })?;
846 let path = path.as_str().to_owned();
847 if !self.rejected_facts_by_path.contains_key(&path) {
848 self.reserve_reused_path_bytes(&path, false, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
849 }
850 let entry = self.rejected_facts_by_path.entry(path).or_default();
851 *entry = entry.checked_add(count).ok_or_else(|| {
852 CliError::InvalidInput("identity rejection count overflowed".to_string())
853 })?;
854 Ok(())
855 }
856
857 fn merge(&mut self, other: Self, control: &IndexWorkControl) -> Result<(), CliError> {
859 control.check(IndexWorkStage::SymbolParsing)?;
860 let peak_retained_bytes = self
861 .observed_fact_bytes
862 .checked_add(other.observed_fact_bytes)
863 .ok_or_else(|| identity_fact_budget_failure(self.observed_fact_bytes))?;
864 if peak_retained_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
865 return Err(identity_fact_budget_failure(peak_retained_bytes));
866 }
867 #[cfg(test)]
868 {
869 self.paired_import_pairing_work = self
870 .paired_import_pairing_work
871 .checked_add(other.paired_import_pairing_work)
872 .ok_or_else(|| {
873 CliError::InvalidInput("paired import work count overflowed".to_string())
874 })?;
875 }
876 self.source_admitted |= other.source_admitted;
877 if other
878 .resolution_projections
879 .keys()
880 .any(|path| self.resolution_projections.contains_key(path))
881 {
882 return Err(CliError::InvalidInput(
883 "duplicate resolution projection admission".to_string(),
884 ));
885 }
886 let other_observed_facts = other.observed_facts;
887 let other_rejected_facts_by_path = other.rejected_facts_by_path;
888 let other_reused_rejection_facts = other.reused_rejection_facts;
889 let other_rejection_details_dropped_by_path = other.rejection_details_dropped_by_path;
890 for (path, incoming_total) in &other_rejected_facts_by_path {
891 self.merge_observed_identity_path(
892 path,
893 other_observed_facts.get(path),
894 *incoming_total,
895 control,
896 )?;
897 }
898 for (path, facts) in &other_observed_facts {
899 if !other_rejected_facts_by_path.contains_key(path) {
900 self.merge_observed_identity_path(path, Some(facts), 0, control)?;
901 }
902 }
903 for (path, facts) in other_reused_rejection_facts {
904 control.check(IndexWorkStage::SymbolParsing)?;
905 let existing_facts = self.reused_rejection_facts.get(&path);
906 let existing_count = existing_facts.map_or(0, BTreeSet::len);
907 let new_count = facts
908 .iter()
909 .filter(|fact| existing_facts.is_none_or(|existing| !existing.contains(*fact)))
910 .count();
911 let after_count = existing_count.checked_add(new_count).ok_or_else(|| {
912 CliError::InvalidInput("identity fact count overflowed".to_string())
913 })?;
914 let before_bytes = existing_facts.map_or(Ok(0), |facts| {
915 identity_fact_set_retained_bytes(&path, facts)
916 })?;
917 let after_bytes = if after_count == 0 {
918 0
919 } else {
920 identity_observed_path_retained_bytes(&path, after_count)?
921 };
922 self.adjust_identity_bytes(before_bytes, after_bytes)?;
923 if after_count == 0 {
924 self.reused_rejection_facts.remove(&path);
925 } else {
926 self.reused_rejection_facts
927 .entry(path)
928 .or_default()
929 .extend(facts);
930 }
931 }
932 for (path, indices) in other.relation_fact_indices {
933 control.check(IndexWorkStage::SymbolParsing)?;
934 if let Some(existing) = self.relation_fact_indices.get(&path) {
935 if existing != &indices {
936 return Err(CliError::InvalidInput(
937 "conflicting relation fact admission".to_string(),
938 ));
939 }
940 } else {
941 self.relation_fact_indices.insert(path, indices);
942 }
943 }
944 control.check(IndexWorkStage::SymbolParsing)?;
945 let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths(
946 &mut self.rejections,
947 &mut self.rejection_keys,
948 other.rejections,
949 &mut self.rejection_details_dropped_by_path,
950 )?;
951 self.observed_fact_bytes = self
952 .observed_fact_bytes
953 .checked_add(rejection_key_bytes)
954 .ok_or_else(|| {
955 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
956 })?;
957 for path in other_rejection_details_dropped_by_path {
958 control.check(IndexWorkStage::SymbolParsing)?;
959 if self.rejection_details_dropped_by_path.insert(path.clone()) {
960 self.reserve_identity_bytes(
961 identity_count_path_retained_bytes(&path)?,
962 control,
963 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
964 )?;
965 }
966 }
967 self.resolution_projections
968 .extend(other.resolution_projections);
969 for (path, count) in other.reused_rejection_counts {
970 control.check(IndexWorkStage::SymbolParsing)?;
971 let new_path = !self.reused_rejection_counts.contains_key(&path);
972 let path_bytes = if new_path {
973 identity_count_path_retained_bytes(&path)?
974 } else {
975 0
976 };
977 if self
978 .reused_rejection_counts
979 .insert(path.clone(), count)
980 .is_some()
981 {
982 return Err(CliError::InvalidInput(
983 "duplicate reused identity rejection count".to_string(),
984 ));
985 }
986 if new_path {
987 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
988 }
989 }
990 for (path, count) in other.reused_parser_rejection_counts {
991 control.check(IndexWorkStage::SymbolParsing)?;
992 let new_path = !self.reused_parser_rejection_counts.contains_key(&path);
993 let path_bytes = if new_path {
994 identity_count_path_retained_bytes(&path)?
995 } else {
996 0
997 };
998 if self
999 .reused_parser_rejection_counts
1000 .insert(path.clone(), count)
1001 .is_some()
1002 {
1003 return Err(CliError::InvalidInput(
1004 "duplicate reused parser rejection count".to_string(),
1005 ));
1006 }
1007 if new_path {
1008 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1009 }
1010 }
1011 for (path, count) in other.reused_rejection_detail_counts {
1012 control.check(IndexWorkStage::SymbolParsing)?;
1013 let new_path = !self.reused_rejection_detail_counts.contains_key(&path);
1014 let path_bytes = if new_path {
1015 identity_count_path_retained_bytes(&path)?
1016 } else {
1017 0
1018 };
1019 if self
1020 .reused_rejection_detail_counts
1021 .insert(path.clone(), count)
1022 .is_some()
1023 {
1024 return Err(CliError::InvalidInput(
1025 "duplicate reused identity rejection detail count".to_string(),
1026 ));
1027 }
1028 if new_path {
1029 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1030 }
1031 }
1032 control.check(IndexWorkStage::SymbolParsing)?;
1033 for path in other.reused_rejection_details_incomplete {
1034 let path_bytes = identity_count_path_retained_bytes(&path)?;
1035 if self.reused_rejection_details_incomplete.insert(path) {
1036 self.reserve_identity_bytes(path_bytes, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
1037 }
1038 }
1039 if self.observed_fact_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1040 return Err(identity_fact_budget_failure(self.observed_fact_bytes));
1041 }
1042 #[cfg(test)]
1043 for (key, count) in other.resolution_derivations {
1044 let entry = self.resolution_derivations.entry(key).or_default();
1045 *entry = entry.saturating_add(count);
1046 }
1047 Ok(())
1048 }
1049
1050 fn rejected_facts_for(&self, path: &str) -> u64 {
1052 self.rejected_facts_by_path.get(path).copied().unwrap_or(0)
1053 }
1054
1055 fn for_paths(&self, paths: &BTreeSet<String>) -> Result<Self, CliError> {
1057 let rejected_facts_by_path = self
1058 .rejected_facts_by_path
1059 .iter()
1060 .filter(|(path, _count)| paths.contains(path.as_str()))
1061 .map(|(path, count)| (path.clone(), *count))
1062 .collect::<BTreeMap<_, _>>();
1063 let observed_facts = self
1064 .observed_facts
1065 .iter()
1066 .filter(|(path, _facts)| paths.contains(*path))
1067 .map(|(path, facts)| (path.clone(), facts.clone()))
1068 .collect::<BTreeMap<_, _>>();
1069 let rejections = self
1070 .rejections
1071 .iter()
1072 .filter(|rejection| paths.contains(rejection.path.as_str()))
1073 .cloned()
1074 .collect::<Vec<_>>();
1075 let rejection_keys = self
1076 .rejection_keys
1077 .iter()
1078 .filter(|key| paths.contains(key.path.as_str()))
1079 .cloned()
1080 .collect::<BTreeSet<_>>();
1081 let rejection_details_dropped_by_path = self
1082 .rejection_details_dropped_by_path
1083 .iter()
1084 .filter(|path| paths.contains(path.as_str()))
1085 .cloned()
1086 .collect::<BTreeSet<_>>();
1087 let marker_bytes =
1088 identity_rejection_drop_paths_retained_bytes(&rejection_details_dropped_by_path)?;
1089 let observed_fact_bytes =
1090 identity_maps_retained_bytes(&observed_facts, &rejected_facts_by_path)?
1091 .checked_add(identity_rejection_keys_retained_bytes(&rejection_keys)?)
1092 .and_then(|bytes| bytes.checked_add(marker_bytes))
1093 .ok_or_else(|| {
1094 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1095 })?;
1096 Ok(Self {
1097 rejection_keys,
1098 rejections,
1099 source_admitted: self.source_admitted,
1100 observed_fact_bytes,
1101 reused_rejection_facts: BTreeMap::new(),
1102 rejected_facts_by_path,
1103 observed_facts,
1104 relation_fact_indices: self
1105 .relation_fact_indices
1106 .iter()
1107 .filter(|(path, _indices)| paths.contains(path.as_str()))
1108 .map(|(path, indices)| (path.clone(), indices.clone()))
1109 .collect(),
1110 resolution_projections: BTreeMap::new(),
1111 reused_rejection_counts: BTreeMap::new(),
1112 reused_parser_rejection_counts: BTreeMap::new(),
1113 reused_rejection_detail_counts: BTreeMap::new(),
1114 reused_rejection_details_incomplete: BTreeSet::new(),
1115 rejection_details_dropped_by_path,
1116 #[cfg(test)]
1117 resolution_derivations: BTreeMap::new(),
1118 #[cfg(test)]
1119 paired_import_pairing_work: 0,
1120 })
1121 }
1122
1123 fn has_rejections(&self) -> bool {
1125 !self.rejected_facts_by_path.is_empty()
1126 }
1127
1128 fn incomplete_reused_rejection_paths(&self) -> &BTreeSet<String> {
1130 &self.reused_rejection_details_incomplete
1131 }
1132
1133 fn rejection_details_dropped_for(&self, path: &str) -> bool {
1136 self.rejection_details_dropped_by_path.contains(path)
1137 }
1138
1139 fn rejected_facts_for_graph(&self, path: &str, derived: &Self) -> Result<u64, CliError> {
1141 let Some(persisted) = self.reused_rejection_counts.get(path).copied() else {
1142 return self
1143 .rejected_facts_for(path)
1144 .checked_add(derived.rejected_facts_for(path))
1145 .ok_or_else(|| {
1146 CliError::InvalidInput("identity rejection count overflowed".to_string())
1147 });
1148 };
1149 let persisted_parser = self
1150 .reused_parser_rejection_counts
1151 .get(path)
1152 .copied()
1153 .unwrap_or(0);
1154 let persisted_identity = persisted.checked_sub(persisted_parser).ok_or_else(|| {
1155 CliError::InvalidInput("persisted parser rejection count exceeded total".to_string())
1156 })?;
1157 let mut current_facts = BTreeSet::<&IdentityFactKey>::new();
1158 if let Some(facts) = self.observed_facts.get(path) {
1159 current_facts.extend(facts);
1160 }
1161 if let Some(facts) = derived.observed_facts.get(path) {
1162 current_facts.extend(facts);
1163 }
1164 let current_known = u64::try_from(current_facts.len()).map_err(|error| {
1165 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1166 })?;
1167 let current_total = self
1168 .rejected_facts_for(path)
1169 .checked_add(derived.rejected_facts_for(path))
1170 .ok_or_else(|| {
1171 CliError::InvalidInput("identity rejection count overflowed".to_string())
1172 })?;
1173 let current_unknown = current_total.checked_sub(current_known).ok_or_else(|| {
1174 CliError::InvalidInput("observed identity facts exceeded rejection count".to_string())
1175 })?;
1176 let persisted_facts = self.reused_rejection_facts.get(path);
1177 let persisted_known = persisted_facts.map_or(0, BTreeSet::len);
1178 let persisted_known = u64::try_from(persisted_known).map_err(|error| {
1179 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1180 })?;
1181 let persisted_unknown =
1182 persisted_identity
1183 .checked_sub(persisted_known)
1184 .ok_or_else(|| {
1185 CliError::InvalidInput(
1186 "persisted identity facts exceeded rejection count".to_string(),
1187 )
1188 })?;
1189 let mut all_facts = current_facts;
1190 if let Some(facts) = persisted_facts {
1191 all_facts.extend(
1192 facts
1193 .iter()
1194 .filter(|fact| !is_rederived_identity_fact(fact)),
1195 );
1196 }
1197 u64::try_from(all_facts.len())
1198 .map_err(|error| {
1199 CliError::InvalidInput(format!("identity fact count overflowed: {error}"))
1200 })?
1201 .checked_add(persisted_unknown)
1202 .and_then(|count| count.checked_add(current_unknown))
1203 .ok_or_else(|| {
1204 CliError::InvalidInput("identity rejection count overflowed".to_string())
1205 })
1206 }
1207
1208 fn parser_rejection_for_graph(&self, path: &str) -> u64 {
1210 self.reused_parser_rejection_counts
1211 .get(path)
1212 .copied()
1213 .unwrap_or(0)
1214 }
1215
1216 pub(super) fn source_admitted(&self) -> bool {
1218 self.source_admitted
1219 }
1220
1221 fn paths(&self) -> impl Iterator<Item = &str> {
1223 self.rejected_facts_by_path.keys().map(String::as_str)
1224 }
1225
1226 fn resolution_projection(&self, path: &str) -> Option<&ResolutionKeyProjection> {
1228 self.resolution_projections.get(path)
1229 }
1230
1231 fn relation_parser_index(&self, path: &str, admitted_index: usize) -> usize {
1233 self.relation_fact_indices
1234 .get(path)
1235 .and_then(|indices| indices.get(admitted_index))
1236 .copied()
1237 .unwrap_or(admitted_index)
1238 }
1239
1240 #[cfg(test)]
1242 fn record_resolution_derivation(&mut self, path: &str, generation: IndexGeneration) {
1243 let count = self
1244 .resolution_derivations
1245 .entry((path.to_string(), generation))
1246 .or_default();
1247 *count = count.saturating_add(1);
1248 }
1249}
1250
1251fn identity_rejection_key_set(
1253 rejections: &[GraphIdentityRejection],
1254) -> BTreeSet<IdentityRejectionKey> {
1255 rejections.iter().map(IdentityRejectionKey::from).collect()
1256}
1257
1258#[cfg(test)]
1260fn extend_bounded_identity_rejections(
1261 target: &mut Vec<GraphIdentityRejection>,
1262 target_keys: &mut BTreeSet<IdentityRejectionKey>,
1263 incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1264) -> Result<u64, CliError> {
1265 let mut dropped_paths = BTreeSet::new();
1266 extend_bounded_identity_rejections_with_drop_paths(
1267 target,
1268 target_keys,
1269 incoming,
1270 &mut dropped_paths,
1271 )
1272}
1273
1274fn extend_bounded_identity_rejections_with_drop_paths(
1276 target: &mut Vec<GraphIdentityRejection>,
1277 target_keys: &mut BTreeSet<IdentityRejectionKey>,
1278 incoming: impl IntoIterator<Item = GraphIdentityRejection>,
1279 dropped_paths: &mut BTreeSet<String>,
1280) -> Result<u64, CliError> {
1281 let mut retained_bytes = 0_u64;
1282 for rejection in incoming {
1283 let key = IdentityRejectionKey::from(&rejection);
1284 if target_keys.contains(&key) {
1285 continue;
1286 }
1287 if target.len() >= MAX_GRAPH_IDENTITY_REJECTIONS {
1288 if dropped_paths.insert(rejection.path.as_str().to_owned()) {
1289 retained_bytes = retained_bytes
1290 .checked_add(identity_count_path_retained_bytes(rejection.path.as_str())?)
1291 .ok_or_else(|| {
1292 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1293 })?;
1294 }
1295 continue;
1296 }
1297 target_keys.insert(key);
1298 retained_bytes = retained_bytes
1299 .checked_add(identity_rejection_key_retained_bytes(
1300 rejection.path.as_str(),
1301 )?)
1302 .ok_or_else(|| {
1303 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
1304 })?;
1305 target.push(rejection);
1306 }
1307 Ok(retained_bytes)
1308}
1309
1310fn mark_identity_rejection_coverage_limits(
1312 coverage: &mut [CoverageRecord],
1313 dropped_paths: &BTreeSet<String>,
1314) -> Result<(), CliError> {
1315 for row in coverage {
1316 let CoverageScope::Path { path } = row.scope() else {
1317 continue;
1318 };
1319 if row.relation().is_some()
1320 || !dropped_paths.contains(path.as_str())
1321 || row.reached_limit() == Some(GraphLimitKind::Rows)
1322 {
1323 continue;
1324 }
1325 *row = CoverageRecord::new(
1326 row.scope().clone(),
1327 row.relation(),
1328 row.state(),
1329 row.covered(),
1330 row.omitted(),
1331 row.generation(),
1332 row.reason().cloned(),
1333 Some(GraphLimitKind::Rows),
1334 )
1335 .map_err(invalid_graph_contract)?;
1336 }
1337 Ok(())
1338}
1339
1340pub(super) enum RepositoryGraphMutation {
1342 Full,
1344 AffectedPaths(Vec<String>),
1346}
1347
1348pub(super) struct StagedRepositoryGraph {
1350 project: ProjectInstanceId,
1352 mutation: RepositoryGraphMutation,
1354 entities: Vec<GraphEntity>,
1356 relations: Vec<LogicalRelation>,
1358 occurrences: Vec<RelationOccurrence>,
1360 coverage: Vec<CoverageRecord>,
1362 entity_exports: Vec<EntityResolutionKey>,
1364 relation_dependencies: Vec<RelationDependencyKey>,
1366 document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
1368 identity_rejections: Vec<GraphIdentityRejection>,
1370 #[cfg(test)]
1372 resolution_derivations: BTreeMap<(String, IndexGeneration), usize>,
1373 #[cfg(test)]
1375 peak_retained_bytes: u64,
1376 #[cfg(test)]
1378 projection_removals_before_entities: Vec<String>,
1379 scan_policy: RootScanPolicy,
1381 document_target_states: Vec<(String, DocumentTargetUnresolvedReason)>,
1383 database: Option<StagedGraphDatabase>,
1385 retained_bytes: u64,
1387}
1388
1389struct StagedGraphDatabase {
1391 store: Option<AtlasStore>,
1393 directory: Option<TempDir>,
1395 _lease: File,
1397}
1398
1399impl StagedGraphDatabase {
1400 fn store(&self) -> Result<&AtlasStore, CliError> {
1402 self.store
1403 .as_ref()
1404 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1405 }
1406
1407 fn store_mut(&mut self) -> Result<&mut AtlasStore, CliError> {
1409 self.store
1410 .as_mut()
1411 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1412 }
1413
1414 fn directory(&self) -> Result<&TempDir, CliError> {
1416 self.directory
1417 .as_ref()
1418 .ok_or_else(|| CliError::InvalidInput(GRAPH_STAGE_OWNER_UNAVAILABLE.to_string()))
1419 }
1420}
1421
1422impl Drop for StagedGraphDatabase {
1423 fn drop(&mut self) {
1424 let prepared = self.store.take().is_some();
1425 let Some(directory) = self.directory.take() else {
1426 return;
1427 };
1428 let database_path = directory.path().join(GRAPH_STAGE_DATABASE_FILE_NAME);
1429 let direct_database = fs::symlink_metadata(&database_path).is_ok_and(|metadata| {
1430 metadata.file_type().is_file() && !metadata.file_type().is_symlink()
1431 });
1432 if prepared
1433 && direct_database
1434 && remove_owned_graph_stage_payload(directory.path(), &database_path, None).is_ok()
1435 {
1436 drop(directory);
1437 } else {
1438 let _retained_path: PathBuf = directory.keep();
1439 }
1440 }
1441}
1442
1443impl StagedRepositoryGraph {
1444 pub(super) const fn retained_bytes(&self) -> u64 {
1446 self.retained_bytes
1447 }
1448
1449 pub(super) fn revalidate_document_targets(&self, root: &Path) -> Result<(), CliError> {
1451 if self.document_target_states.is_empty() {
1452 return Ok(());
1453 }
1454 let current = DocumentResolutionIndex::new(root, &[], &self.scan_policy)?;
1455 for (path, expected) in &self.document_target_states {
1456 if current.absent_reason(path)? != *expected {
1457 return Err(source_changed_during_derivation(root, path));
1458 }
1459 }
1460 Ok(())
1461 }
1462
1463 pub(super) fn apply(
1465 &self,
1466 publication: &mut IndexPublicationGuard<'_>,
1467 control: &IndexWorkControl,
1468 ) -> Result<(), CliError> {
1469 control.check(IndexWorkStage::Publication)?;
1470 if let Some(database) = &self.database {
1471 if !database.directory()?.path().is_dir() {
1472 return Err(CliError::InvalidInput(
1473 "repository graph staging directory is unavailable".to_string(),
1474 ));
1475 }
1476 if !matches!(self.mutation, RepositoryGraphMutation::Full) {
1477 return Err(CliError::InvalidInput(
1478 "database-backed graph staging only supports full replacement".to_string(),
1479 ));
1480 }
1481 publication.replace_repository_graph_from_staging(
1482 self.project,
1483 database.store()?,
1484 Some(control),
1485 )?;
1486 publication
1487 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1488 if !self.document_unresolved_reasons.is_empty() {
1489 publication.set_document_unresolved_reasons_controlled(
1490 &self.document_unresolved_reasons,
1491 control,
1492 )?;
1493 }
1494 control.check(IndexWorkStage::Publication)?;
1495 return Ok(());
1496 }
1497 match &self.mutation {
1498 RepositoryGraphMutation::Full => {
1499 publication.replace_repository_graph_with_resolution_keys(
1500 self.project,
1501 &self.entities,
1502 &self.relations,
1503 &self.occurrences,
1504 &self.coverage,
1505 &self.entity_exports,
1506 &self.relation_dependencies,
1507 )?;
1508 publication
1509 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1510 }
1511 RepositoryGraphMutation::AffectedPaths(paths) => {
1512 publication.replace_repository_graph_for_paths_with_resolution_keys(
1513 self.project,
1514 paths,
1515 &self.entities,
1516 &self.relations,
1517 &self.occurrences,
1518 &self.coverage,
1519 &self.entity_exports,
1520 &self.relation_dependencies,
1521 )?;
1522 publication
1523 .replace_graph_identity_rejections(self.project, &self.identity_rejections)?;
1524 }
1525 }
1526 if !self.document_unresolved_reasons.is_empty() {
1527 publication.set_document_unresolved_reasons_controlled(
1528 &self.document_unresolved_reasons,
1529 control,
1530 )?;
1531 }
1532 control.check(IndexWorkStage::Publication)?;
1533 Ok(())
1534 }
1535}
1536
1537pub(super) fn stage_full_repository_graph(
1539 store: &AtlasStore,
1540 root: &Path,
1541 base_generation: IndexGeneration,
1542 nodes: &[Node],
1543 scan_policy: &RootScanPolicy,
1544 symbols: &SymbolBuildStage,
1545 control: &IndexWorkControl,
1546) -> Result<StagedRepositoryGraph, CliError> {
1547 cleanup_abandoned_repository_graph_staging(store, root, control)?;
1548 let project = selected_project(store)?;
1549 let generation = next_generation(base_generation)?;
1550 let paths = nodes
1551 .iter()
1552 .filter(|node| node.kind == NodeKind::File)
1553 .map(|node| node.path.clone())
1554 .collect::<BTreeSet<_>>();
1555 let loaded_graphs = complete_symbol_graphs(store, &paths, symbols, control)?;
1556 let (graphs, mut identity_admission) = admit_symbol_graphs(loaded_graphs, control)?;
1557 let changed_paths = symbols
1558 .changes
1559 .iter()
1560 .map(|change| match change {
1561 SymbolProjectionChange::Parsed(parsed) => parsed.path.as_str(),
1562 SymbolProjectionChange::Clear { path, .. } => path.as_str(),
1563 })
1564 .collect::<BTreeSet<_>>();
1565 let reused_paths = paths
1566 .iter()
1567 .filter(|path| !changed_paths.contains(path.as_str()))
1568 .cloned()
1569 .collect::<BTreeSet<_>>();
1570 hydrate_reused_identity_admission(
1571 store,
1572 project,
1573 &reused_paths,
1574 &graphs,
1575 &mut identity_admission,
1576 control,
1577 )?;
1578 if !identity_admission
1579 .incomplete_reused_rejection_paths()
1580 .is_empty()
1581 {
1582 return Err(dependency_closure_limit(
1583 root,
1584 identity_admission
1585 .incomplete_reused_rejection_paths()
1586 .iter()
1587 .cloned(),
1588 identity_admission.incomplete_reused_rejection_paths().len(),
1589 ));
1590 }
1591 identity_admission.merge(symbols.identity_admission.for_paths(&paths)?, control)?;
1592 let mut document_facts = complete_markdown_facts(root, nodes, &graphs, symbols, control)?;
1593 admit_markdown_facts(
1594 &mut document_facts,
1595 &graphs,
1596 &mut identity_admission,
1597 control,
1598 )?;
1599 control.check(IndexWorkStage::SymbolParsing)?;
1600 let configured_modules =
1601 super::module_resolution::load_configured_module_resolution(root, nodes, control)?;
1602 let packages = PackageIndex::from_graphs(&graphs)?;
1603 admit_resolution_key_failures(
1604 project,
1605 generation,
1606 &graphs,
1607 &packages,
1608 &configured_modules,
1609 &mut identity_admission,
1610 control,
1611 )?;
1612 ensure_admitted_resolution_projections(&graphs, &identity_admission.resolution_projections)?;
1613 let entity_projection = build_entity_projection_with_config(
1614 project,
1615 generation,
1616 nodes,
1617 &graphs,
1618 &packages,
1619 &configured_modules,
1620 Some(&mut identity_admission.resolution_projections),
1621 true,
1622 control,
1623 )?;
1624 debug_assert!(identity_admission.resolution_projections.is_empty());
1625 let candidates = resolution_registry_from_exports(&entity_projection, control)?;
1626 enforce_resolution_staging_budget(&entity_projection, &candidates)?;
1627 let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1628 let graph_work_bytes = symbols
1629 .retained_bytes
1630 .saturating_add(identity_admission.observed_fact_bytes)
1631 .saturating_add(entity_projection.retained_bytes)
1632 .saturating_add(candidates.retained_bytes)
1633 .saturating_add(document_fact_map_retained_bytes(&document_facts))
1634 .saturating_add(document_projection_bytes);
1635 if graph_work_bytes > MAX_IN_MEMORY_GRAPH_WORK_BYTES {
1636 finish_projection_in_database_with_documents(
1637 root,
1638 nodes,
1639 project,
1640 generation,
1641 &graphs,
1642 &document_facts,
1643 &identity_admission,
1644 entity_projection,
1645 &candidates,
1646 scan_policy,
1647 control,
1648 )
1649 } else {
1650 finish_projection_with_documents(
1651 project,
1652 generation,
1653 RepositoryGraphMutation::Full,
1654 &graphs,
1655 root,
1656 nodes,
1657 &document_facts,
1658 &identity_admission,
1659 entity_projection,
1660 &candidates,
1661 scan_policy,
1662 control,
1663 )
1664 }
1665}
1666
1667pub(super) fn stage_incremental_repository_graph(
1669 store: &AtlasStore,
1670 root: &Path,
1671 base_generation: IndexGeneration,
1672 expected_nodes: &[Node],
1673 direct_paths: &[String],
1674 scan_policy: &RootScanPolicy,
1675 symbols: &SymbolBuildStage,
1676 control: &IndexWorkControl,
1677) -> Result<StagedRepositoryGraph, CliError> {
1678 stage_incremental_repository_graph_with_limit(
1679 store,
1680 root,
1681 base_generation,
1682 expected_nodes,
1683 direct_paths,
1684 scan_policy,
1685 symbols,
1686 control,
1687 super::MAX_PUBLICATION_STAGING_BYTES,
1688 )
1689}
1690
1691#[cfg(test)]
1692fn stage_incremental_repository_graph_with_test_limit(
1693 store: &AtlasStore,
1694 root: &Path,
1695 base_generation: IndexGeneration,
1696 expected_nodes: &[Node],
1697 direct_paths: &[String],
1698 scan_policy: &RootScanPolicy,
1699 symbols: &SymbolBuildStage,
1700 control: &IndexWorkControl,
1701 staging_limit: u64,
1702) -> Result<StagedRepositoryGraph, CliError> {
1703 stage_incremental_repository_graph_with_limit(
1704 store,
1705 root,
1706 base_generation,
1707 expected_nodes,
1708 direct_paths,
1709 scan_policy,
1710 symbols,
1711 control,
1712 staging_limit,
1713 )
1714}
1715
1716#[allow(clippy::too_many_arguments)]
1717fn stage_incremental_repository_graph_with_limit(
1719 store: &AtlasStore,
1720 root: &Path,
1721 base_generation: IndexGeneration,
1722 expected_nodes: &[Node],
1723 direct_paths: &[String],
1724 scan_policy: &RootScanPolicy,
1725 symbols: &SymbolBuildStage,
1726 control: &IndexWorkControl,
1727 staging_limit: u64,
1728) -> Result<StagedRepositoryGraph, CliError> {
1729 let project = selected_project(store)?;
1730 let generation = next_generation(base_generation)?;
1731 let configured_modules =
1732 super::module_resolution::load_configured_module_resolution(root, expected_nodes, control)?;
1733 let direct_paths = direct_paths.iter().cloned().collect::<BTreeSet<_>>();
1734 enforce_incremental_count(
1735 root,
1736 "direct source paths",
1737 direct_paths.len(),
1738 &direct_paths,
1739 )?;
1740 let _direct_footprint =
1741 admitted_persisted_footprint(store, project, root, &direct_paths, control)?;
1742
1743 let current_file_paths = expected_nodes
1744 .iter()
1745 .filter(|node| node.kind == NodeKind::File)
1746 .map(|node| node.path.clone())
1747 .collect::<BTreeSet<_>>();
1748 let direct_graph_paths = direct_paths
1749 .intersection(¤t_file_paths)
1750 .cloned()
1751 .collect::<BTreeSet<_>>();
1752 let manifest_paths = expected_nodes
1753 .iter()
1754 .filter(|node| node.kind == NodeKind::File && is_cargo_manifest_path(&node.path))
1755 .map(|node| node.path.clone())
1756 .collect::<BTreeSet<_>>();
1757 let package_only_paths = manifest_paths
1758 .difference(&direct_graph_paths)
1759 .cloned()
1760 .collect::<BTreeSet<_>>();
1761 let loaded_direct_graphs =
1762 complete_symbol_graphs(store, &direct_graph_paths, symbols, control)?;
1763 let (direct_graphs, mut direct_identity_admission) =
1764 admit_symbol_graphs(loaded_direct_graphs, control)?;
1765 direct_identity_admission.merge(
1766 symbols.identity_admission.for_paths(&direct_graph_paths)?,
1767 control,
1768 )?;
1769 let package_graphs = complete_symbol_graphs(store, &package_only_paths, symbols, control)?;
1770 let (package_graphs, _package_context_admission) =
1771 admit_symbol_graphs(package_graphs, control)?;
1772 let package_index_graphs = direct_graphs
1773 .iter()
1774 .map(Cow::as_ref)
1775 .chain(package_graphs.iter().map(Cow::as_ref))
1776 .collect::<Vec<_>>();
1777 let direct_packages = PackageIndex::from_graphs(&package_index_graphs)?;
1778 admit_resolution_key_failures(
1779 project,
1780 generation,
1781 &direct_graphs,
1782 &direct_packages,
1783 &configured_modules,
1784 &mut direct_identity_admission,
1785 control,
1786 )?;
1787
1788 let old_exports = store.repository_export_keys_for_paths(
1789 project,
1790 &direct_paths.iter().cloned().collect::<Vec<_>>(),
1791 MAX_INCREMENTAL_RESOLUTION_ITEMS,
1792 )?;
1793 if old_exports.truncated {
1794 return Err(dependency_closure_limit(
1795 root,
1796 direct_paths.iter().cloned(),
1797 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1798 ));
1799 }
1800 let mut changed_keys = old_exports.rows.into_iter().collect::<BTreeSet<_>>();
1801 for path in &direct_paths {
1802 changed_keys.insert(document_file_resolution_key(project, path)?);
1803 changed_keys.insert(document_casefold_resolution_key(project, path)?);
1804 }
1805 for graph in &direct_graphs {
1806 control.check(IndexWorkStage::SymbolParsing)?;
1807 let projection = direct_identity_admission
1808 .resolution_projection(&graph.path)
1809 .ok_or_else(|| {
1810 CliError::InvalidInput(format!(
1811 "resolution keys were not admitted for graph {}",
1812 graph.path
1813 ))
1814 })?;
1815 changed_keys.extend(projection.source_keys().iter().cloned());
1816 for symbol in projection.symbol_keys() {
1817 changed_keys.extend(symbol.keys().iter().cloned());
1818 }
1819 for symbol in &graph.symbols {
1820 if symbol.kind == SymbolKind::Heading {
1821 changed_keys.insert(document_heading_resolution_key(
1822 project,
1823 &graph.path,
1824 &symbol.signature,
1825 )?);
1826 }
1827 }
1828 }
1829 enforce_incremental_count(
1830 root,
1831 "old and new export keys",
1832 changed_keys.len(),
1833 &direct_paths,
1834 )?;
1835
1836 let inbound = store.repository_affected_source_paths(
1837 project,
1838 &changed_keys.iter().cloned().collect::<Vec<_>>(),
1839 MAX_INCREMENTAL_RESOLUTION_ITEMS,
1840 )?;
1841 if inbound.truncated {
1842 return Err(dependency_closure_limit(
1843 root,
1844 inbound.rows.iter().map(|path| path.as_str().to_string()),
1845 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1846 ));
1847 }
1848 let mut affected_paths = direct_paths;
1849 affected_paths.extend(inbound.rows.into_iter().map(String::from));
1850 affected_paths.extend(direct_identity_admission.paths().map(ToString::to_string));
1851 enforce_incremental_count(
1852 root,
1853 "affected source paths",
1854 affected_paths.len(),
1855 &affected_paths,
1856 )?;
1857 control.check(IndexWorkStage::SymbolParsing)?;
1858 let persisted_footprint =
1859 admitted_persisted_footprint(store, project, root, &affected_paths, control)?;
1860
1861 let affected_graph_paths = affected_paths
1862 .intersection(¤t_file_paths)
1863 .cloned()
1864 .collect::<BTreeSet<_>>();
1865 let newly_affected_graph_paths = affected_graph_paths
1866 .difference(&direct_graph_paths)
1867 .cloned()
1868 .collect::<BTreeSet<_>>();
1869 let loaded_affected_graphs =
1870 complete_symbol_graphs(store, &newly_affected_graph_paths, symbols, control)?;
1871 let (newly_affected_graphs, mut affected_identity_admission) =
1872 admit_symbol_graphs(loaded_affected_graphs, control)?;
1873 hydrate_reused_identity_admission(
1874 store,
1875 project,
1876 &newly_affected_graph_paths,
1877 &newly_affected_graphs,
1878 &mut affected_identity_admission,
1879 control,
1880 )?;
1881 if !affected_identity_admission
1882 .incomplete_reused_rejection_paths()
1883 .is_empty()
1884 {
1885 return Err(dependency_closure_limit(
1886 root,
1887 affected_identity_admission
1888 .incomplete_reused_rejection_paths()
1889 .iter()
1890 .cloned(),
1891 affected_identity_admission
1892 .incomplete_reused_rejection_paths()
1893 .len(),
1894 ));
1895 }
1896 let admitted_graphs = direct_graphs
1897 .iter()
1898 .map(Cow::as_ref)
1899 .chain(newly_affected_graphs.iter().map(Cow::as_ref))
1900 .chain(package_graphs.iter().map(Cow::as_ref))
1901 .collect::<Vec<_>>();
1902 let packages = PackageIndex::from_graphs(&admitted_graphs)?;
1903 admit_resolution_key_failures(
1904 project,
1905 generation,
1906 &newly_affected_graphs,
1907 &packages,
1908 &configured_modules,
1909 &mut affected_identity_admission,
1910 control,
1911 )?;
1912 direct_identity_admission.merge(affected_identity_admission, control)?;
1913 enforce_incremental_projection_budget(
1914 root,
1915 &affected_paths,
1916 0,
1917 direct_identity_admission.observed_fact_bytes,
1918 )?;
1919 let affected_graphs = direct_graphs
1920 .iter()
1921 .filter(|graph| affected_graph_paths.contains(&graph.path))
1922 .map(Cow::as_ref)
1923 .chain(newly_affected_graphs.iter().map(Cow::as_ref))
1924 .collect::<Vec<_>>();
1925 ensure_admitted_resolution_projections(
1926 &affected_graphs,
1927 &direct_identity_admission.resolution_projections,
1928 )?;
1929 let mut document_facts =
1930 complete_markdown_facts(root, expected_nodes, &affected_graphs, symbols, control)?;
1931 admit_markdown_facts(
1932 &mut document_facts,
1933 &affected_graphs,
1934 &mut direct_identity_admission,
1935 control,
1936 )?;
1937 let affected_nodes = expected_nodes
1938 .iter()
1939 .filter(|node| affected_paths.contains(&node.path))
1940 .cloned()
1941 .collect::<Vec<_>>();
1942 let entity_projection = build_entity_projection_with_config_limit(
1943 project,
1944 generation,
1945 &affected_nodes,
1946 &affected_graphs,
1947 &packages,
1948 &configured_modules,
1949 Some(&mut direct_identity_admission.resolution_projections),
1950 false,
1951 control,
1952 staging_limit,
1953 )?;
1954 debug_assert!(direct_identity_admission.resolution_projections.is_empty());
1955
1956 let mut dependency_keys = entity_projection
1957 .keys_by_graph
1958 .values()
1959 .flat_map(ResolutionKeyProjection::relation_keys)
1960 .flat_map(|relation| relation.keys().iter().cloned())
1961 .collect::<BTreeSet<_>>();
1962 dependency_keys.extend(document_dependency_keys(project, &document_facts)?);
1963 enforce_incremental_count(
1964 root,
1965 "affected dependency keys",
1966 dependency_keys.len(),
1967 &affected_paths,
1968 )?;
1969 let persisted = store.repository_resolution_candidates_for_keys(
1970 project,
1971 &dependency_keys.iter().cloned().collect::<Vec<_>>(),
1972 MAX_INCREMENTAL_RESOLUTION_ITEMS,
1973 )?;
1974 if persisted.truncated {
1975 return Err(dependency_closure_limit(
1976 root,
1977 affected_paths.iter().cloned(),
1978 usize::try_from(MAX_INCREMENTAL_RESOLUTION_ITEMS).unwrap_or(usize::MAX) + 1,
1979 ));
1980 }
1981 let mut candidates = resolution_registry_from_persisted(
1982 project,
1983 generation,
1984 persisted.rows,
1985 &affected_paths,
1986 control,
1987 )?;
1988 merge_resolution_registries(
1989 &mut candidates,
1990 resolution_registry_from_exports(&entity_projection, control)?,
1991 control,
1992 )?;
1993 let document_projection_bytes = document_projection_retained_bytes(&document_facts, control)?;
1994 enforce_incremental_projection_budget(
1995 root,
1996 &affected_paths,
1997 0,
1998 entity_projection
1999 .retained_bytes
2000 .saturating_add(candidates.retained_bytes)
2001 .saturating_add(document_fact_map_retained_bytes(&document_facts))
2002 .saturating_add(document_projection_bytes)
2003 .saturating_add(direct_identity_admission.observed_fact_bytes),
2004 )?;
2005 let staged = finish_projection_with_documents(
2006 project,
2007 generation,
2008 RepositoryGraphMutation::AffectedPaths(affected_paths.iter().cloned().collect()),
2009 &affected_graphs,
2010 root,
2011 expected_nodes,
2012 &document_facts,
2013 &direct_identity_admission,
2014 entity_projection,
2015 &candidates,
2016 scan_policy,
2017 control,
2018 )?;
2019 enforce_incremental_projection_limits(root, &affected_paths, persisted_footprint, &staged)?;
2020 Ok(staged)
2021}
2022
2023struct EntityProjection {
2025 entity_by_digest: BTreeMap<String, GraphEntity>,
2027 owners_by_graph: BTreeMap<String, GraphOwners>,
2029 keys_by_graph: BTreeMap<String, ResolutionKeyProjection>,
2031 entity_exports: Vec<EntityResolutionKey>,
2033 retained_bytes: u64,
2035 #[cfg(test)]
2037 peak_retained_bytes: u64,
2038 #[cfg(test)]
2040 projection_removals_before_entities: Vec<String>,
2041}
2042
2043struct GraphOwners {
2045 file_digest: String,
2047 symbol_digests: Vec<Option<String>>,
2049}
2050
2051fn php_file_include_context(context: &str) -> bool {
2053 let context = context.trim_start_matches(|character: char| character.is_ascii_whitespace());
2054 ["include", "include_once", "require", "require_once"]
2055 .iter()
2056 .any(|keyword| {
2057 context
2058 .get(..keyword.len())
2059 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
2060 && context.get(keyword.len()..).is_some_and(|suffix| {
2061 suffix.starts_with(|character: char| character.is_ascii_whitespace())
2062 || suffix.starts_with(['(', '\'', '"', '#'])
2063 || suffix.starts_with("/*")
2064 || suffix.starts_with("//")
2065 })
2066 })
2067}
2068
2069struct GraphSymbolIndex<'graph> {
2071 indices_by_name: BTreeMap<&'graph str, Vec<usize>>,
2073 php_call_indices_by_name: BTreeMap<String, Vec<usize>>,
2075 php_import_positions: BTreeMap<String, BTreeMap<usize, Option<usize>>>,
2077 php_namespace_start_lines: BTreeMap<String, BTreeSet<usize>>,
2079 php_imports_have_unknown_scope: bool,
2081}
2082
2083impl<'graph> GraphSymbolIndex<'graph> {
2084 fn new(graph: &'graph SymbolGraph, control: &IndexWorkControl) -> Result<Self, CliError> {
2086 let mut indices_by_name = BTreeMap::new();
2087 let mut php_call_indices_by_name = BTreeMap::new();
2088 let mut php_import_positions = BTreeMap::<String, BTreeMap<usize, Option<usize>>>::new();
2089 let mut php_namespace_start_lines = BTreeMap::<String, BTreeSet<usize>>::new();
2090 let mut php_imports_have_unknown_scope = false;
2091 let is_php = graph
2092 .language
2093 .as_deref()
2094 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2095 let mut import_counts = HashMap::<usize, (usize, usize)>::new();
2098 let paired_imports = if is_php {
2099 for (index, symbol) in graph.symbols.iter().enumerate() {
2100 check_graph_work(control, index)?;
2101 if symbol.kind == SymbolKind::Import {
2102 import_counts.entry(symbol.line_start).or_default().0 += 1;
2103 }
2104 }
2105 for (index, relation) in graph.relations.iter().enumerate() {
2106 check_graph_work(control, index)?;
2107 if relation.kind == RelationKind::Imports {
2108 import_counts.entry(relation.line).or_default().1 += 1;
2109 }
2110 }
2111 Some(paired_import_relations(graph, control)?)
2112 } else {
2113 None
2114 };
2115 for (index, symbol) in graph.symbols.iter().enumerate() {
2116 check_graph_work(control, index)?;
2117 if is_php && symbol.kind == SymbolKind::Import {
2119 let ordinal = import_counts
2120 .get(&symbol.line_start)
2121 .filter(|(symbols, relations)| symbols == relations)
2122 .and(paired_imports.as_ref())
2123 .and_then(|paired| paired.by_symbol.get(index).copied().flatten());
2124 php_import_positions
2125 .entry(symbol.parent.as_deref().unwrap_or("").to_ascii_lowercase())
2126 .or_default()
2127 .entry(symbol.line_start)
2128 .or_insert(ordinal);
2129 }
2130 if is_php && symbol.kind == SymbolKind::Module {
2131 php_namespace_start_lines
2132 .entry(symbol.name.to_ascii_lowercase())
2133 .or_default()
2134 .insert(symbol.line_start);
2135 }
2136 indices_by_name
2137 .entry(symbol.name.as_str())
2138 .or_insert_with(Vec::new)
2139 .push(index);
2140 if is_php && matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
2141 php_call_indices_by_name
2142 .entry(symbol.name.to_ascii_lowercase())
2143 .or_insert_with(Vec::new)
2144 .push(index);
2145 }
2146 }
2147 if is_php && graph.parser != ParserKind::TreeSitter {
2149 for (index, relation) in graph.relations.iter().enumerate() {
2150 check_graph_work(control, index)?;
2151 if relation.kind == RelationKind::Imports
2152 && relation.source_name == MODULE_RELATION_SOURCE
2153 && !php_file_include_context(&relation.context)
2154 {
2155 php_imports_have_unknown_scope = true;
2156 break;
2157 }
2158 }
2159 }
2160 Ok(Self {
2161 indices_by_name,
2162 php_call_indices_by_name,
2163 php_import_positions,
2164 php_namespace_start_lines,
2165 php_imports_have_unknown_scope,
2166 })
2167 }
2168
2169 fn get(&self, name: &str) -> &[usize] {
2171 self.indices_by_name.get(name).map_or(&[], Vec::as_slice)
2172 }
2173
2174 fn get_php_call(&self, name: &str) -> &[usize] {
2176 self.php_call_indices_by_name
2177 .get(&name.to_ascii_lowercase())
2178 .map_or(&[], Vec::as_slice)
2179 }
2180}
2181
2182struct DerivedRelationFact {
2184 kind: ExtendedRelationKind,
2186 relation: SymbolRelation,
2188 target: DerivedRelationTarget,
2190}
2191
2192enum DerivedRelationTarget {
2194 Parser {
2196 keys: Vec<CanonicalResolutionKey>,
2198 },
2199 RepositoryPath,
2201 External {
2203 system: &'static str,
2205 },
2206}
2207
2208struct PackageIndex {
2210 packages: Vec<PackageOwner>,
2212}
2213
2214struct PackageOwner {
2216 root: String,
2218 name: String,
2220 manifest: String,
2222}
2223
2224impl PackageIndex {
2225 fn from_graphs(graphs: &[impl Borrow<SymbolGraph>]) -> Result<Self, CliError> {
2227 let mut packages = Vec::new();
2228 for graph in graphs {
2229 let graph = graph.borrow();
2230 for symbol in &graph.symbols {
2231 if symbol.kind != SymbolKind::Package {
2232 continue;
2233 }
2234 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?;
2235 let root = graph
2236 .path
2237 .rsplit_once('/')
2238 .map_or(String::new(), |(parent, _manifest)| parent.to_string());
2239 packages.push(PackageOwner {
2240 root,
2241 name: symbol.name.clone(),
2242 manifest: graph.path.clone(),
2243 });
2244 }
2245 }
2246 packages.sort_by(|left, right| {
2247 right
2248 .root
2249 .len()
2250 .cmp(&left.root.len())
2251 .then_with(|| left.root.cmp(&right.root))
2252 .then_with(|| left.name.cmp(&right.name))
2253 .then_with(|| left.manifest.cmp(&right.manifest))
2254 });
2255 packages.dedup_by(|left, right| {
2256 left.root == right.root && left.name == right.name && left.manifest == right.manifest
2257 });
2258 Ok(Self { packages })
2259 }
2260
2261 fn package_name(&self, path: &str) -> Option<&str> {
2263 self.packages
2264 .iter()
2265 .find(|package| repository_path_belongs_to(path, &package.root))
2266 .map(|package| package.name.as_str())
2267 }
2268}
2269
2270fn repository_path_belongs_to(path: &str, root: &str) -> bool {
2272 root.is_empty()
2273 || path == root
2274 || path
2275 .strip_prefix(root)
2276 .is_some_and(|suffix| suffix.starts_with('/'))
2277}
2278
2279fn is_cargo_manifest_path(path: &str) -> bool {
2281 path == "Cargo.toml" || path.ends_with("/Cargo.toml")
2282}
2283
2284fn qualified_symbol_parents(
2291 graph: &SymbolGraph,
2292) -> Result<Vec<Option<GraphIdentityText>>, CliError> {
2293 let is_php = graph
2294 .language
2295 .as_deref()
2296 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
2297 let mut order = (0..graph.symbols.len()).collect::<Vec<_>>();
2298 order.sort_by_key(|&index| (graph.symbols[index].line_start, index));
2299 let mut active_by_name = BTreeMap::<&str, Vec<usize>>::new();
2300 let mut qualified_names = vec![None::<GraphIdentityText>; graph.symbols.len()];
2301 let mut parents = vec![None; graph.symbols.len()];
2302 for index in order {
2303 let symbol = &graph.symbols[index];
2304 let name = source_symbol_identity(symbol.name.clone())?;
2305 let mut parent = symbol
2306 .parent
2307 .clone()
2308 .map(source_symbol_identity)
2309 .transpose()?;
2310 if let Some(immediate_parent) = parent.as_ref()
2311 && let Some(candidates) = active_by_name.get_mut(immediate_parent.as_str())
2312 {
2313 while candidates.last().is_some_and(|&candidate_index| {
2314 let candidate = &graph.symbols[candidate_index];
2315 if is_php
2318 && candidate.kind != SymbolKind::Module
2319 && let Some((outer, inner)) =
2320 candidate.source_selector.zip(symbol.source_selector)
2321 {
2322 outer.byte_end < inner.byte_end
2323 } else {
2324 candidate.line_end < symbol.line_end
2325 }
2326 }) {
2327 candidates.pop();
2328 }
2329 if let Some(qualified_parent) = candidates
2330 .last()
2331 .and_then(|&candidate_index| qualified_names[candidate_index].clone())
2332 {
2333 parent = Some(qualified_parent);
2334 }
2335 }
2336 qualified_names[index] = Some(match parent.as_ref() {
2337 Some(parent) => qualified_symbol_identity(parent, &name)?,
2338 None => name,
2339 });
2340 parents[index] = parent;
2341 active_by_name
2342 .entry(symbol.name.as_str())
2343 .or_default()
2344 .push(index);
2345 }
2346 Ok(parents)
2347}
2348
2349fn source_symbol_identity(value: String) -> Result<GraphIdentityText, CliError> {
2351 let identity = GraphIdentityText::new(value).map_err(invalid_graph_contract)?;
2352 if identity.as_str().starts_with(QUALIFIED_SYMBOL_SCOPE_PREFIX) {
2353 return Err(invalid_graph_contract(
2354 GraphContractError::InvalidIdentityText {
2355 reason: "source symbol identity uses the reserved derived-scope namespace",
2356 },
2357 ));
2358 }
2359 Ok(identity)
2360}
2361
2362fn qualified_symbol_identity(
2364 parent: &GraphIdentityText,
2365 name: &GraphIdentityText,
2366) -> Result<GraphIdentityText, CliError> {
2367 const SEPARATOR: &str = "::";
2368 const DIGEST_DOMAIN: &str = "projectatlas.graph.qualified-symbol-scope.v1";
2369 let qualified_len = parent.as_str().len() + SEPARATOR.len() + name.as_str().len();
2370 if qualified_len <= MAX_GRAPH_IDENTITY_BYTES {
2371 let mut qualified = String::with_capacity(qualified_len);
2372 qualified.push_str(parent.as_str());
2373 qualified.push_str(SEPARATOR);
2374 qualified.push_str(name.as_str());
2375 return GraphIdentityText::new(qualified).map_err(invalid_graph_contract);
2376 }
2377
2378 let compact_len =
2379 QUALIFIED_SYMBOL_SCOPE_PREFIX.len() + 64 + SEPARATOR.len() + name.as_str().len();
2380 if compact_len > MAX_GRAPH_IDENTITY_BYTES {
2381 return Err(invalid_graph_contract(
2382 GraphContractError::InvalidIdentityText {
2383 reason: "derived scope cannot retain its nearest admitted symbol name",
2384 },
2385 ));
2386 }
2387 let mut hasher = blake3::Hasher::new_derive_key(DIGEST_DOMAIN);
2388 hasher.update(parent.as_str().as_bytes());
2389 hasher.update(SEPARATOR.as_bytes());
2390 hasher.update(name.as_str().as_bytes());
2391 let digest = hasher.finalize().to_hex();
2392 let mut compact = String::with_capacity(compact_len);
2393 compact.push_str(QUALIFIED_SYMBOL_SCOPE_PREFIX);
2394 compact.push_str(digest.as_str());
2395 compact.push_str(SEPARATOR);
2396 compact.push_str(name.as_str());
2397 GraphIdentityText::new(compact).map_err(invalid_graph_contract)
2398}
2399
2400#[cfg(test)]
2402fn build_entity_projection(
2403 project: ProjectInstanceId,
2404 generation: IndexGeneration,
2405 nodes: &[Node],
2406 graphs: &[impl Borrow<SymbolGraph>],
2407 packages: &PackageIndex,
2408 include_project: bool,
2409 control: &IndexWorkControl,
2410) -> Result<EntityProjection, CliError> {
2411 build_entity_projection_with_config(
2412 project,
2413 generation,
2414 nodes,
2415 graphs,
2416 packages,
2417 &ConfiguredModuleResolution::default(),
2418 None,
2419 include_project,
2420 control,
2421 )
2422}
2423
2424#[allow(clippy::too_many_arguments)]
2426fn build_entity_projection_with_config(
2427 project: ProjectInstanceId,
2428 generation: IndexGeneration,
2429 nodes: &[Node],
2430 graphs: &[impl Borrow<SymbolGraph>],
2431 packages: &PackageIndex,
2432 configured_modules: &ConfiguredModuleResolution,
2433 admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2434 include_project: bool,
2435 control: &IndexWorkControl,
2436) -> Result<EntityProjection, CliError> {
2437 build_entity_projection_with_config_limit(
2438 project,
2439 generation,
2440 nodes,
2441 graphs,
2442 packages,
2443 configured_modules,
2444 admitted_projections,
2445 include_project,
2446 control,
2447 super::MAX_PUBLICATION_STAGING_BYTES,
2448 )
2449}
2450
2451#[allow(clippy::too_many_arguments)]
2453fn build_entity_projection_with_config_limit(
2454 project: ProjectInstanceId,
2455 generation: IndexGeneration,
2456 nodes: &[Node],
2457 graphs: &[impl Borrow<SymbolGraph>],
2458 packages: &PackageIndex,
2459 configured_modules: &ConfiguredModuleResolution,
2460 mut admitted_projections: Option<&mut BTreeMap<String, ResolutionKeyProjection>>,
2461 include_project: bool,
2462 control: &IndexWorkControl,
2463 staging_limit: u64,
2464) -> Result<EntityProjection, CliError> {
2465 let mut entity_by_digest = BTreeMap::new();
2466 let mut entity_exports = Vec::new();
2467 let mut entity_bytes = 0_u64;
2468 if include_project {
2469 let entity = GraphEntity::new(project, EntitySelector::Project, generation)
2470 .map_err(invalid_graph_contract)?;
2471 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2472 insert_entity(&mut entity_by_digest, entity)?;
2473 }
2474 for node in nodes {
2475 control.check(IndexWorkStage::SymbolParsing)?;
2476 let selector = match node.kind {
2477 NodeKind::Folder => EntitySelector::Folder {
2478 path: RepositoryNodePath::new(Path::new(&node.path))
2479 .map_err(invalid_graph_contract)?,
2480 },
2481 NodeKind::File => EntitySelector::File {
2482 path: RepositoryFilePath::new(Path::new(&node.path))
2483 .map_err(invalid_graph_contract)?,
2484 },
2485 };
2486 let entity =
2487 GraphEntity::new(project, selector, generation).map_err(invalid_graph_contract)?;
2488 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2489 if node.kind == NodeKind::File {
2490 for key in [
2491 document_file_resolution_key(project, &node.path)?,
2492 document_casefold_resolution_key(project, &node.path)?,
2493 ] {
2494 entity_exports.push(
2495 EntityResolutionKey::new(entity.key().clone(), key)
2496 .map_err(invalid_graph_contract)?,
2497 );
2498 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2499 }
2500 }
2501 insert_entity(&mut entity_by_digest, entity)?;
2502 }
2503
2504 let mut owners_by_graph = BTreeMap::new();
2505 let mut keys_by_graph = BTreeMap::new();
2506 let mut retained_bytes = 0_u64;
2507 let mut admitted_map_bytes = admitted_projections
2508 .as_deref()
2509 .map(resolution_projection_map_retained_bytes)
2510 .unwrap_or_default();
2511 enforce_resolution_registry_budget_with_limit(
2512 admitted_map_bytes.saturating_add(entity_bytes),
2513 staging_limit,
2514 )?;
2515 #[cfg(test)]
2516 let mut peak_retained_bytes = admitted_map_bytes.saturating_add(entity_bytes);
2517 #[cfg(test)]
2518 let mut projection_removals_before_entities = Vec::new();
2519 for graph in graphs {
2520 let graph = graph.borrow();
2521 control.check(IndexWorkStage::SymbolParsing)?;
2522 let resolution = if let Some(projections) = admitted_projections.as_deref_mut() {
2523 let resolution = projections.remove(&graph.path).ok_or_else(|| {
2524 CliError::InvalidInput(format!(
2525 "resolution keys were not admitted for graph {}",
2526 graph.path
2527 ))
2528 })?;
2529 admitted_map_bytes = admitted_map_bytes.saturating_sub(
2530 resolution_projection_map_entry_retained_bytes(&graph.path, &resolution),
2531 );
2532 #[cfg(test)]
2533 projection_removals_before_entities.push(graph.path.clone());
2534 resolution
2535 } else {
2536 resolution_projection_with_config(
2537 project,
2538 packages.package_name(&graph.path),
2539 graph,
2540 configured_modules,
2541 )?
2542 };
2543 let resolution_bytes = resolution_retained_bytes(&resolution);
2544 entity_bytes = entity_bytes.saturating_add(resolution_bytes);
2545 #[cfg(test)]
2546 {
2547 peak_retained_bytes =
2548 peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
2549 }
2550 enforce_resolution_registry_budget_with_limit(
2551 admitted_map_bytes.saturating_add(entity_bytes),
2552 staging_limit,
2553 )?;
2554 let file = GraphEntity::new(
2555 project,
2556 EntitySelector::File {
2557 path: RepositoryFilePath::new(Path::new(&graph.path))
2558 .map_err(invalid_graph_contract)?,
2559 },
2560 generation,
2561 )
2562 .map_err(invalid_graph_contract)?;
2563 let file_digest = file.key().digest().to_string();
2564 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&file));
2565 insert_entity(&mut entity_by_digest, file)?;
2566 let mut symbol_digests = Vec::with_capacity(graph.symbols.len());
2567 entity_bytes = entity_bytes.saturating_add(
2568 STAGED_GRAPH_ROW_BYTES
2569 .saturating_mul(u64::try_from(graph.symbols.len()).unwrap_or(u64::MAX)),
2570 );
2571 let qualified_parents = qualified_symbol_parents(graph)?;
2572 for (symbol, qualified_parent) in graph.symbols.iter().zip(qualified_parents) {
2573 control.check(IndexWorkStage::SymbolParsing)?;
2574 let entity = match symbol.kind {
2575 SymbolKind::Import | SymbolKind::Dependency | SymbolKind::Workspace => None,
2576 SymbolKind::Package => Some(
2577 GraphEntity::new(
2578 project,
2579 EntitySelector::Package {
2580 package: PackageSelector {
2581 manager: GraphIdentityText::new(CARGO_PACKAGE_MANAGER)
2582 .map_err(invalid_graph_contract)?,
2583 name: GraphIdentityText::new(symbol.name.clone())
2584 .map_err(invalid_graph_contract)?,
2585 manifest: RepositoryFilePath::new(Path::new(&graph.path))
2586 .map_err(invalid_graph_contract)?,
2587 },
2588 },
2589 generation,
2590 )
2591 .map_err(invalid_graph_contract)?,
2592 ),
2593 _ => Some(
2594 GraphEntity::new(
2595 project,
2596 EntitySelector::Symbol {
2597 symbol: SymbolSelector {
2598 file: RepositoryFilePath::new(Path::new(&graph.path))
2599 .map_err(invalid_graph_contract)?,
2600 name: GraphIdentityText::new(symbol.name.clone())
2601 .map_err(invalid_graph_contract)?,
2602 kind: symbol.kind,
2603 parent: qualified_parent,
2604 signature: GraphIdentityText::new(
2605 if symbol.signature.trim().is_empty() {
2606 symbol.name.clone()
2607 } else {
2608 symbol.signature.trim().to_string()
2609 },
2610 )
2611 .map_err(invalid_graph_contract)?,
2612 },
2613 },
2614 generation,
2615 )
2616 .map_err(invalid_graph_contract)?,
2617 ),
2618 };
2619 let entity_digest = entity
2620 .as_ref()
2621 .map(|entity| entity.key().digest().to_string());
2622 if let Some(entity) = entity {
2623 entity_bytes = entity_bytes.saturating_add(entity_retained_bytes(&entity));
2624 if symbol.kind == SymbolKind::Heading {
2625 entity_exports.push(
2626 EntityResolutionKey::new(
2627 entity.key().clone(),
2628 document_heading_resolution_key(
2629 project,
2630 &graph.path,
2631 &symbol.signature,
2632 )?,
2633 )
2634 .map_err(invalid_graph_contract)?,
2635 );
2636 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2637 }
2638 insert_entity(&mut entity_by_digest, entity)?;
2639 }
2640 symbol_digests.push(entity_digest);
2641 }
2642 let file = entity_by_digest
2643 .get(&file_digest)
2644 .ok_or_else(|| CliError::InvalidInput("graph file owner was not staged".to_string()))?;
2645 for key in resolution.source_keys() {
2646 entity_exports.push(
2647 EntityResolutionKey::new(file.key().clone(), key.clone())
2648 .map_err(invalid_graph_contract)?,
2649 );
2650 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2651 }
2652 for symbol_keys in resolution.symbol_keys() {
2653 let Some(entity) = symbol_digests
2654 .get(symbol_keys.symbol_index())
2655 .and_then(Option::as_ref)
2656 .and_then(|digest| entity_by_digest.get(digest))
2657 else {
2658 continue;
2659 };
2660 for key in symbol_keys.keys() {
2661 entity_exports.push(
2662 EntityResolutionKey::new(entity.key().clone(), key.clone())
2663 .map_err(invalid_graph_contract)?,
2664 );
2665 entity_bytes = entity_bytes.saturating_add(STAGED_GRAPH_ROW_BYTES);
2666 }
2667 }
2668 entity_bytes = entity_bytes
2669 .saturating_add(STAGED_GRAPH_ROW_BYTES)
2670 .saturating_add(graph.path.len() as u64);
2671 #[cfg(test)]
2672 {
2673 peak_retained_bytes =
2674 peak_retained_bytes.max(admitted_map_bytes.saturating_add(entity_bytes));
2675 }
2676 enforce_resolution_registry_budget_with_limit(
2677 admitted_map_bytes.saturating_add(entity_bytes),
2678 staging_limit,
2679 )?;
2680 retained_bytes = retained_bytes.saturating_add(resolution_retained_bytes(&resolution));
2681 owners_by_graph.insert(
2682 graph.path.clone(),
2683 GraphOwners {
2684 file_digest,
2685 symbol_digests,
2686 },
2687 );
2688 keys_by_graph.insert(graph.path.clone(), resolution);
2689 }
2690 sort_dedup_exports(&mut entity_exports);
2691 enforce_key_binding_limit(entity_exports.len())?;
2692 retained_bytes = entity_by_digest
2693 .values()
2694 .fold(retained_bytes, |bytes, entity| {
2695 bytes.saturating_add(entity_retained_bytes(entity))
2696 })
2697 .saturating_add(
2698 STAGED_GRAPH_ROW_BYTES
2699 .saturating_mul(u64::try_from(entity_exports.len()).unwrap_or(u64::MAX)),
2700 );
2701 #[cfg(test)]
2702 {
2703 peak_retained_bytes = peak_retained_bytes.max(retained_bytes);
2704 }
2705 enforce_resolution_registry_budget_with_limit(retained_bytes, staging_limit)?;
2706 Ok(EntityProjection {
2707 entity_by_digest,
2708 owners_by_graph,
2709 keys_by_graph,
2710 entity_exports,
2711 retained_bytes,
2712 #[cfg(test)]
2713 peak_retained_bytes,
2714 #[cfg(test)]
2715 projection_removals_before_entities,
2716 })
2717}
2718
2719fn try_graph_stage_lease(staging_parent: &Path) -> Result<Option<File>, CliError> {
2721 let path = staging_parent.join(GRAPH_STAGE_LEASE_FILE_NAME);
2722 if let Ok(metadata) = fs::symlink_metadata(&path)
2723 && (!metadata.file_type().is_file() || metadata.file_type().is_symlink())
2724 {
2725 return Err(CliError::InvalidInput(format!(
2726 "repository graph staging lease is not a direct file: {}",
2727 normalize_native_path_display(&path)
2728 )));
2729 }
2730 let file = OpenOptions::new()
2731 .read(true)
2732 .write(true)
2733 .create(true)
2734 .truncate(false)
2735 .open(&path)
2736 .map_err(|source| CliError::Io {
2737 path: path.clone(),
2738 source,
2739 })?;
2740 match file.try_lock() {
2741 Ok(()) => Ok(Some(file)),
2742 Err(fs::TryLockError::WouldBlock) => Ok(None),
2743 Err(fs::TryLockError::Error(source)) => Err(CliError::Io { path, source }),
2744 }
2745}
2746
2747pub(super) fn cleanup_abandoned_repository_graph_staging(
2749 store: &AtlasStore,
2750 root: &Path,
2751 control: &IndexWorkControl,
2752) -> Result<(), CliError> {
2753 cleanup_abandoned_graph_staging(root, selected_project(store)?, control)
2754}
2755
2756fn cleanup_abandoned_graph_staging(
2758 root: &Path,
2759 project: ProjectInstanceId,
2760 control: &IndexWorkControl,
2761) -> Result<(), CliError> {
2762 control.check(IndexWorkStage::Publication)?;
2763 let staging_parent = root.join(".projectatlas");
2764 if !staging_parent.is_dir() {
2765 return Ok(());
2766 }
2767 let Some(_lease) = try_graph_stage_lease(&staging_parent)? else {
2768 return Ok(());
2769 };
2770 cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)
2771}
2772
2773fn remove_owned_graph_stage_payload(
2775 stage: &Path,
2776 database_path: &Path,
2777 control: Option<&IndexWorkControl>,
2778) -> Result<(), CliError> {
2779 let entries = fs::read_dir(stage).map_err(|source| CliError::Io {
2780 path: stage.to_path_buf(),
2781 source,
2782 })?;
2783 for entry in entries {
2784 if let Some(control) = control {
2785 control.check(IndexWorkStage::Publication)?;
2786 }
2787 let entry = entry.map_err(|source| CliError::Io {
2788 path: stage.to_path_buf(),
2789 source,
2790 })?;
2791 let path = entry.path();
2792 if path == database_path {
2793 continue;
2794 }
2795 let metadata = fs::symlink_metadata(&path).map_err(|source| CliError::Io {
2796 path: path.clone(),
2797 source,
2798 })?;
2799 let result = if metadata.file_type().is_symlink() {
2800 remove_graph_stage_symlink(&path)
2801 } else if metadata.file_type().is_dir() {
2802 fs::remove_dir_all(&path)
2803 } else {
2804 fs::remove_file(&path)
2805 };
2806 result.map_err(|source| CliError::Io { path, source })?;
2807 }
2808 Ok(())
2809}
2810
2811#[cfg(windows)]
2813fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
2814 fs::remove_dir(path).or_else(|_directory_error| fs::remove_file(path))
2815}
2816
2817#[cfg(not(windows))]
2819fn remove_graph_stage_symlink(path: &Path) -> std::io::Result<()> {
2820 fs::remove_file(path)
2821}
2822
2823fn cleanup_abandoned_graph_staging_while_locked(
2825 staging_parent: &Path,
2826 root: &Path,
2827 project: ProjectInstanceId,
2828 control: &IndexWorkControl,
2829) -> Result<(), CliError> {
2830 let entries = fs::read_dir(staging_parent).map_err(|source| CliError::Io {
2831 path: staging_parent.to_path_buf(),
2832 source,
2833 })?;
2834 for entry in entries {
2835 control.check(IndexWorkStage::Publication)?;
2836 let entry = entry.map_err(|source| CliError::Io {
2837 path: staging_parent.to_path_buf(),
2838 source,
2839 })?;
2840 let file_name = entry.file_name();
2841 let Some(file_name) = file_name.to_str() else {
2842 continue;
2843 };
2844 if !file_name.starts_with(GRAPH_STAGE_DIRECTORY_PREFIX)
2845 || file_name.len() == GRAPH_STAGE_DIRECTORY_PREFIX.len()
2846 {
2847 continue;
2848 }
2849 let path = entry.path();
2850 let Ok(metadata) = fs::symlink_metadata(&path) else {
2851 continue;
2852 };
2853 if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
2854 continue;
2855 }
2856 let database_path = path.join(GRAPH_STAGE_DATABASE_FILE_NAME);
2857 let database_metadata = match fs::symlink_metadata(&database_path) {
2858 Ok(metadata) => metadata,
2859 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
2860 let _remove_empty_shell = fs::remove_dir(&path);
2861 continue;
2862 }
2863 Err(_) => continue,
2864 };
2865 if !database_metadata.file_type().is_file() || database_metadata.file_type().is_symlink() {
2866 continue;
2867 }
2868 let owned = AtlasStore::repository_graph_staging_belongs_to(&database_path, root, project)
2869 .unwrap_or(false);
2870 if !owned {
2871 continue;
2872 }
2873 control.check(IndexWorkStage::Publication)?;
2874 remove_owned_graph_stage_payload(&path, &database_path, Some(control))?;
2875 control.check(IndexWorkStage::Publication)?;
2876 fs::remove_file(&database_path).map_err(|source| CliError::Io {
2877 path: database_path,
2878 source,
2879 })?;
2880 fs::remove_dir(&path).map_err(|source| CliError::Io { path, source })?;
2881 }
2882 Ok(())
2883}
2884
2885fn finish_projection_in_database_with_documents(
2887 root: &Path,
2888 nodes: &[Node],
2889 project: ProjectInstanceId,
2890 generation: IndexGeneration,
2891 graphs: &[impl Borrow<SymbolGraph>],
2892 document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
2893 identity_admission: &GraphIdentityAdmission,
2894 mut entities: EntityProjection,
2895 candidates: &ProjectResolutionRegistry,
2896 scan_policy: &RootScanPolicy,
2897 control: &IndexWorkControl,
2898) -> Result<StagedRepositoryGraph, CliError> {
2899 #[cfg(test)]
2900 let peak_retained_bytes = entities.peak_retained_bytes;
2901 #[cfg(test)]
2902 let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
2903 let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
2904 let staging_parent = root.join(".projectatlas");
2905 fs::create_dir_all(&staging_parent).map_err(|source| CliError::Io {
2906 path: staging_parent.clone(),
2907 source,
2908 })?;
2909 let lease = try_graph_stage_lease(&staging_parent)?.ok_or_else(|| {
2910 CliError::InvalidInput(
2911 "another repository graph staging operation is active for this project".to_string(),
2912 )
2913 })?;
2914 cleanup_abandoned_graph_staging_while_locked(&staging_parent, root, project, control)?;
2915 let directory = TempDirBuilder::new()
2916 .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
2917 .tempdir_in(&staging_parent)
2918 .map_err(|source| CliError::Io {
2919 path: staging_parent,
2920 source,
2921 })?;
2922 let mut database = StagedGraphDatabase {
2923 store: None,
2924 directory: Some(directory),
2925 _lease: lease,
2926 };
2927 let database_path = database
2928 .directory()?
2929 .path()
2930 .join(GRAPH_STAGE_DATABASE_FILE_NAME);
2931 database.store = Some(AtlasStore::create_repository_graph_staging(
2932 &database_path,
2933 root,
2934 project,
2935 )?);
2936 database.store_mut()?.replace_scan(nodes)?;
2937 let mut identity_rejections = identity_admission.rejections.clone();
2938 let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
2939 let mut identity_rejection_bytes =
2940 identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
2941 .checked_add(identity_rejection_drop_paths_retained_bytes(
2942 &identity_admission.rejection_details_dropped_by_path,
2943 )?)
2944 .ok_or_else(|| {
2945 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
2946 })?;
2947 let mut rejection_details_dropped_by_path =
2948 identity_admission.rejection_details_dropped_by_path.clone();
2949 {
2950 let mut staging = database
2951 .store_mut()?
2952 .begin_repository_graph_staging(project, generation)?;
2953 let mut entity_batch = Vec::with_capacity(GRAPH_STAGE_ENTITY_BATCH_SIZE);
2954 for entity in entities.entity_by_digest.values() {
2955 entity_batch.push(entity);
2956 if entity_batch.len() == GRAPH_STAGE_ENTITY_BATCH_SIZE {
2957 control.check(IndexWorkStage::Publication)?;
2958 staging.append_entity_refs(&entity_batch)?;
2959 entity_batch.clear();
2960 }
2961 }
2962 if !entity_batch.is_empty() {
2963 staging.append_entity_refs(&entity_batch)?;
2964 entity_batch.clear();
2965 }
2966 staging.append_batch(&[], &[], &[], &[], &entities.entity_exports, &[])?;
2967 let mut staged_rows = ProjectedGraphRows::default();
2968 for graph in graphs {
2969 let graph = graph.borrow();
2970 let rows = project_graph_rows(
2971 project,
2972 generation,
2973 graph,
2974 document_facts.get(&graph.path).map(Cow::as_ref),
2975 &document_index,
2976 &mut entities,
2977 candidates,
2978 identity_admission,
2979 control,
2980 )?;
2981 staged_rows.append(rows);
2982 identity_rejection_bytes = identity_rejection_bytes
2983 .checked_add(extend_bounded_identity_rejections_with_drop_paths(
2984 &mut identity_rejections,
2985 &mut identity_rejection_keys,
2986 staged_rows.identity_rejections.iter().cloned(),
2987 &mut rejection_details_dropped_by_path,
2988 )?)
2989 .ok_or_else(|| {
2990 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
2991 })?;
2992 mark_identity_rejection_coverage_limits(
2993 &mut staged_rows.coverage,
2994 &rejection_details_dropped_by_path,
2995 )?;
2996 staged_rows.identity_rejections.clear();
2997 if staged_rows.row_count() < GRAPH_STAGE_ROW_BATCH_SIZE {
2998 continue;
2999 }
3000 staging.append_batch(
3001 &staged_rows.external_entities,
3002 &staged_rows.relations,
3003 &staged_rows.occurrences,
3004 &staged_rows.coverage,
3005 &[],
3006 &staged_rows.relation_dependencies,
3007 )?;
3008 if !staged_rows.document_unresolved_reasons.is_empty() {
3009 staging.set_document_unresolved_reasons_controlled(
3010 &staged_rows.document_unresolved_reasons,
3011 control,
3012 )?;
3013 }
3014 staged_rows.clear();
3015 }
3016 if !staged_rows.is_empty() {
3017 staging.append_batch(
3018 &staged_rows.external_entities,
3019 &staged_rows.relations,
3020 &staged_rows.occurrences,
3021 &staged_rows.coverage,
3022 &[],
3023 &staged_rows.relation_dependencies,
3024 )?;
3025 if !staged_rows.document_unresolved_reasons.is_empty() {
3026 staging.set_document_unresolved_reasons_controlled(
3027 &staged_rows.document_unresolved_reasons,
3028 control,
3029 )?;
3030 }
3031 }
3032 staging.complete()?;
3033 }
3034 database.store()?.checkpoint_repository_graph_staging()?;
3035 database.store()?.begin_index_read_snapshot()?;
3036 let _staged_generation = database.store()?.repository_graph_generation()?;
3037 let document_target_states = document_index.observed_absent_states();
3038 let retained_bytes = database_path.as_os_str().as_encoded_bytes().len() as u64
3039 + document_target_states
3040 .iter()
3041 .map(|(path, _reason)| path.len() as u64 + STAGED_GRAPH_ROW_BYTES)
3042 .sum::<u64>()
3043 + identity_rejection_bytes;
3044 Ok(StagedRepositoryGraph {
3045 project,
3046 mutation: RepositoryGraphMutation::Full,
3047 entities: Vec::new(),
3048 relations: Vec::new(),
3049 occurrences: Vec::new(),
3050 coverage: Vec::new(),
3051 entity_exports: Vec::new(),
3052 relation_dependencies: Vec::new(),
3053 document_unresolved_reasons: Vec::new(),
3054 identity_rejections,
3055 #[cfg(test)]
3056 resolution_derivations: identity_admission.resolution_derivations.clone(),
3057 #[cfg(test)]
3058 peak_retained_bytes,
3059 #[cfg(test)]
3060 projection_removals_before_entities,
3061 scan_policy: scan_policy.clone(),
3062 document_target_states,
3063 database: Some(database),
3064 retained_bytes,
3065 })
3066}
3067
3068#[cfg(test)]
3070fn finish_projection_in_database(
3071 root: &Path,
3072 nodes: &[Node],
3073 project: ProjectInstanceId,
3074 generation: IndexGeneration,
3075 graphs: &[impl Borrow<SymbolGraph>],
3076 entities: EntityProjection,
3077 candidates: &ProjectResolutionRegistry,
3078 scan_policy: &RootScanPolicy,
3079 control: &IndexWorkControl,
3080) -> Result<StagedRepositoryGraph, CliError> {
3081 finish_projection_in_database_with_documents(
3082 root,
3083 nodes,
3084 project,
3085 generation,
3086 graphs,
3087 &BTreeMap::new(),
3088 &GraphIdentityAdmission::default(),
3089 entities,
3090 candidates,
3091 scan_policy,
3092 control,
3093 )
3094}
3095
3096fn finish_projection_with_documents(
3098 project: ProjectInstanceId,
3099 generation: IndexGeneration,
3100 mutation: RepositoryGraphMutation,
3101 graphs: &[impl Borrow<SymbolGraph>],
3102 root: &Path,
3103 nodes: &[Node],
3104 document_facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
3105 identity_admission: &GraphIdentityAdmission,
3106 mut entities: EntityProjection,
3107 candidates: &ProjectResolutionRegistry,
3108 scan_policy: &RootScanPolicy,
3109 control: &IndexWorkControl,
3110) -> Result<StagedRepositoryGraph, CliError> {
3111 #[cfg(test)]
3112 let peak_retained_bytes = entities.peak_retained_bytes;
3113 #[cfg(test)]
3114 let projection_removals_before_entities = entities.projection_removals_before_entities.clone();
3115 let document_index = DocumentResolutionIndex::new(root, nodes, scan_policy)?;
3116 let mut relations_by_digest = BTreeMap::new();
3117 let mut occurrences = Vec::new();
3118 let mut relation_dependencies = Vec::new();
3119 let mut coverage = Vec::new();
3120 let mut document_unresolved_reasons = BTreeMap::new();
3121 let mut identity_rejections = identity_admission.rejections.clone();
3122 let mut identity_rejection_keys = identity_rejection_key_set(&identity_rejections);
3123 let mut identity_rejection_bytes =
3124 identity_rejection_keys_retained_bytes(&identity_rejection_keys)?
3125 .checked_add(identity_rejection_drop_paths_retained_bytes(
3126 &identity_admission.rejection_details_dropped_by_path,
3127 )?)
3128 .ok_or_else(|| {
3129 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3130 })?;
3131 let mut rejection_details_dropped_by_path =
3132 identity_admission.rejection_details_dropped_by_path.clone();
3133 for graph in graphs {
3134 let graph = graph.borrow();
3135 let rows = project_graph_rows(
3136 project,
3137 generation,
3138 graph,
3139 document_facts.get(&graph.path).map(Cow::as_ref),
3140 &document_index,
3141 &mut entities,
3142 candidates,
3143 identity_admission,
3144 control,
3145 )?;
3146 for (relation_index, relation) in rows.relations.into_iter().enumerate() {
3147 insert_relation(
3148 &mut relations_by_digest,
3149 relation,
3150 &graph.path,
3151 "projected",
3152 relation_index,
3153 )?;
3154 }
3155 occurrences.extend(rows.occurrences);
3156 relation_dependencies.extend(rows.relation_dependencies);
3157 for (key, reason) in rows.document_unresolved_reasons {
3158 insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
3159 }
3160 coverage.extend(rows.coverage);
3161 identity_rejection_bytes = identity_rejection_bytes
3162 .checked_add(extend_bounded_identity_rejections_with_drop_paths(
3163 &mut identity_rejections,
3164 &mut identity_rejection_keys,
3165 rows.identity_rejections,
3166 &mut rejection_details_dropped_by_path,
3167 )?)
3168 .ok_or_else(|| {
3169 CliError::InvalidInput("identity rejection bytes overflowed".to_string())
3170 })?;
3171 mark_identity_rejection_coverage_limits(&mut coverage, &rejection_details_dropped_by_path)?;
3172 entities.retained_bytes = rows
3173 .external_entities
3174 .iter()
3175 .fold(entities.retained_bytes, |bytes, entity| {
3176 bytes.saturating_add(entity_retained_bytes(entity))
3177 });
3178 for entity in rows.external_entities {
3179 insert_entity(&mut entities.entity_by_digest, entity)?;
3180 }
3181 }
3182 let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
3183 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
3184 occurrences.sort_by(|left, right| {
3185 left.relation()
3186 .digest()
3187 .cmp(right.relation().digest())
3188 .then_with(|| left.file().as_str().cmp(right.file().as_str()))
3189 .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
3190 .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
3191 });
3192 occurrences.dedup();
3193 sort_dedup_dependencies(&mut relation_dependencies);
3194 enforce_key_binding_limit(relation_dependencies.len())?;
3195 let added_rows = relations
3196 .len()
3197 .saturating_add(occurrences.len())
3198 .saturating_add(coverage.len())
3199 .saturating_add(relation_dependencies.len());
3200 let added_rows = added_rows.saturating_add(document_unresolved_reasons.len());
3201 let document_target_states = document_index.observed_absent_states();
3202 entities.retained_bytes = entities
3203 .retained_bytes
3204 .saturating_add(
3205 STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(added_rows).unwrap_or(u64::MAX)),
3206 )
3207 .saturating_add(
3208 document_target_states
3209 .iter()
3210 .fold(0_u64, |bytes, (path, _reason)| {
3211 bytes
3212 .saturating_add(STAGED_GRAPH_ROW_BYTES)
3213 .saturating_add(path.len() as u64)
3214 }),
3215 );
3216 Ok(StagedRepositoryGraph {
3217 project,
3218 mutation,
3219 entities: entities.entity_by_digest.into_values().collect(),
3220 relations,
3221 occurrences,
3222 coverage,
3223 entity_exports: entities.entity_exports,
3224 relation_dependencies,
3225 document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
3226 identity_rejections,
3227 #[cfg(test)]
3228 resolution_derivations: identity_admission.resolution_derivations.clone(),
3229 #[cfg(test)]
3230 peak_retained_bytes,
3231 #[cfg(test)]
3232 projection_removals_before_entities,
3233 scan_policy: scan_policy.clone(),
3234 document_target_states,
3235 database: None,
3236 retained_bytes: entities
3237 .retained_bytes
3238 .saturating_add(identity_rejection_bytes),
3239 })
3240}
3241
3242#[cfg(test)]
3244fn finish_projection(
3245 project: ProjectInstanceId,
3246 generation: IndexGeneration,
3247 mutation: RepositoryGraphMutation,
3248 graphs: &[impl Borrow<SymbolGraph>],
3249 entities: EntityProjection,
3250 candidates: &ProjectResolutionRegistry,
3251 control: &IndexWorkControl,
3252) -> Result<StagedRepositoryGraph, CliError> {
3253 let scan_policy = RootScanPolicy::discover(Path::new("."), &ScanOptions::default(), control)
3254 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
3255 finish_projection_with_documents(
3256 project,
3257 generation,
3258 mutation,
3259 graphs,
3260 Path::new("."),
3261 &[],
3262 &BTreeMap::new(),
3263 &GraphIdentityAdmission::default(),
3264 entities,
3265 candidates,
3266 &scan_policy,
3267 control,
3268 )
3269}
3270
3271#[derive(Default)]
3273struct ProjectedGraphRows {
3274 relations: Vec<LogicalRelation>,
3276 occurrences: Vec<RelationOccurrence>,
3278 coverage: Vec<CoverageRecord>,
3280 external_entities: Vec<GraphEntity>,
3282 relation_dependencies: Vec<RelationDependencyKey>,
3284 document_unresolved_reasons: Vec<(LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3286 identity_rejections: Vec<GraphIdentityRejection>,
3288}
3289
3290impl ProjectedGraphRows {
3291 fn append(&mut self, rows: Self) {
3293 self.relations.extend(rows.relations);
3294 self.occurrences.extend(rows.occurrences);
3295 self.coverage.extend(rows.coverage);
3296 self.external_entities.extend(rows.external_entities);
3297 self.relation_dependencies
3298 .extend(rows.relation_dependencies);
3299 self.document_unresolved_reasons
3300 .extend(rows.document_unresolved_reasons);
3301 self.identity_rejections.extend(rows.identity_rejections);
3302 }
3303
3304 fn row_count(&self) -> usize {
3306 self.relations
3307 .len()
3308 .saturating_add(self.occurrences.len())
3309 .saturating_add(self.coverage.len())
3310 .saturating_add(self.external_entities.len())
3311 .saturating_add(self.relation_dependencies.len())
3312 .saturating_add(self.document_unresolved_reasons.len())
3313 }
3314
3315 fn is_empty(&self) -> bool {
3317 self.row_count() == 0
3318 }
3319
3320 fn clear(&mut self) {
3322 self.relations.clear();
3323 self.occurrences.clear();
3324 self.coverage.clear();
3325 self.external_entities.clear();
3326 self.relation_dependencies.clear();
3327 self.document_unresolved_reasons.clear();
3328 self.identity_rejections.clear();
3329 }
3330}
3331
3332struct DocumentResolutionIndex<'a> {
3334 root: PathBuf,
3336 canonical_root: PathBuf,
3338 kinds_by_path: HashMap<&'a str, NodeKind>,
3340 casefold_path_counts: HashMap<String, u32>,
3342 scan_policy: &'a RootScanPolicy,
3344 absent_states: RefCell<BTreeMap<String, DocumentTargetUnresolvedReason>>,
3346}
3347
3348impl<'a> DocumentResolutionIndex<'a> {
3349 fn new(
3351 root: &Path,
3352 nodes: &'a [Node],
3353 scan_policy: &'a RootScanPolicy,
3354 ) -> Result<Self, CliError> {
3355 let canonical_root = fs::canonicalize(root).map_err(|source| CliError::Io {
3356 path: root.to_path_buf(),
3357 source,
3358 })?;
3359 let kinds_by_path = nodes
3360 .iter()
3361 .map(|node| (node.path.as_str(), node.kind))
3362 .collect::<HashMap<_, _>>();
3363 let mut casefold_path_counts = HashMap::new();
3364 for node in nodes {
3365 let count = casefold_path_counts
3366 .entry(node.path.to_lowercase())
3367 .or_insert(0_u32);
3368 *count = count.saturating_add(1);
3369 }
3370 let retained_bytes = nodes.iter().fold(0_u64, |bytes, node| {
3371 bytes
3372 .saturating_add(STAGED_GRAPH_ROW_BYTES.saturating_mul(2))
3373 .saturating_add(node.path.len() as u64)
3374 });
3375 enforce_resolution_registry_budget(retained_bytes)?;
3376 Ok(Self {
3377 root: root.to_path_buf(),
3378 canonical_root,
3379 kinds_by_path,
3380 casefold_path_counts,
3381 scan_policy,
3382 absent_states: RefCell::new(BTreeMap::new()),
3383 })
3384 }
3385
3386 fn unresolved_reason(
3388 &self,
3389 path: &str,
3390 ) -> Result<Option<DocumentTargetUnresolvedReason>, CliError> {
3391 let casefold_count = self
3392 .casefold_path_counts
3393 .get(&path.to_lowercase())
3394 .copied()
3395 .unwrap_or(0);
3396 if casefold_count > 1 {
3397 return Ok(Some(DocumentTargetUnresolvedReason::CaseConflict));
3398 }
3399 Ok(match self.kinds_by_path.get(path).copied() {
3400 Some(NodeKind::File) => None,
3401 Some(NodeKind::Folder) => Some(DocumentTargetUnresolvedReason::Unsupported),
3402 None if casefold_count == 1 => Some(DocumentTargetUnresolvedReason::CaseConflict),
3403 None => Some(self.absent_reason(path)?),
3404 })
3405 }
3406
3407 fn absent_reason(&self, path: &str) -> Result<DocumentTargetUnresolvedReason, CliError> {
3409 if let Some(reason) = self.absent_states.borrow().get(path).copied() {
3410 return Ok(reason);
3411 }
3412 let native = self.root.join(Path::new(path));
3413 let reason = if self
3414 .scan_policy
3415 .excludes_path(&native)
3416 .map_err(|source| CliError::InvalidInput(source.to_string()))?
3417 {
3418 DocumentTargetUnresolvedReason::Ignored
3419 } else {
3420 match fs::symlink_metadata(&native) {
3421 Ok(metadata) if metadata.file_type().is_dir() => {
3422 DocumentTargetUnresolvedReason::Unsupported
3423 }
3424 Ok(_metadata) => match fs::canonicalize(&native) {
3425 Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
3426 DocumentTargetUnresolvedReason::OutsideRoot
3427 }
3428 Ok(_canonical) => DocumentTargetUnresolvedReason::Unsupported,
3429 Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
3430 },
3431 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3432 self.missing_or_escaping_ancestor(&native)
3433 }
3434 Err(_source) => DocumentTargetUnresolvedReason::Unsupported,
3435 }
3436 };
3437 self.absent_states
3438 .borrow_mut()
3439 .insert(path.to_string(), reason);
3440 Ok(reason)
3441 }
3442
3443 fn observed_absent_states(&self) -> Vec<(String, DocumentTargetUnresolvedReason)> {
3445 self.absent_states
3446 .borrow()
3447 .iter()
3448 .map(|(path, reason)| (path.clone(), *reason))
3449 .collect()
3450 }
3451
3452 fn missing_or_escaping_ancestor(&self, native: &Path) -> DocumentTargetUnresolvedReason {
3454 let mut ancestor = native.parent();
3455 while let Some(path) = ancestor {
3456 if !path.starts_with(&self.root) {
3457 break;
3458 }
3459 match fs::canonicalize(path) {
3460 Ok(canonical) if !canonical.starts_with(&self.canonical_root) => {
3461 return DocumentTargetUnresolvedReason::OutsideRoot;
3462 }
3463 Ok(_canonical) => return DocumentTargetUnresolvedReason::Missing,
3464 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
3465 ancestor = path.parent();
3466 }
3467 Err(_source) => return DocumentTargetUnresolvedReason::Unsupported,
3468 }
3469 }
3470 DocumentTargetUnresolvedReason::Missing
3471 }
3472}
3473
3474struct DocumentResolutionOutcome {
3476 resolution: RelationResolution,
3478 unresolved_reason: Option<DocumentTargetUnresolvedReason>,
3480 dependencies: Vec<CanonicalResolutionKey>,
3482}
3483
3484#[allow(clippy::too_many_arguments)]
3486fn project_document_rows(
3487 project: ProjectInstanceId,
3488 generation: IndexGeneration,
3489 graph: &SymbolGraph,
3490 facts: &MarkdownFacts,
3491 owners: &GraphOwners,
3492 document_index: &DocumentResolutionIndex<'_>,
3493 candidates: &ProjectResolutionRegistry,
3494 staged_entities: &BTreeMap<String, GraphEntity>,
3495 control: &IndexWorkControl,
3496) -> Result<ProjectedGraphRows, CliError> {
3497 let file_source = staged_entities.get(&owners.file_digest).ok_or_else(|| {
3498 CliError::InvalidInput("document graph file owner was not staged".to_string())
3499 })?;
3500 let completeness = match facts.coverage.completeness {
3501 MarkdownFactCompleteness::Complete => Completeness::Complete,
3502 MarkdownFactCompleteness::Partial => Completeness::Partial,
3503 };
3504 let mut relations_by_digest = BTreeMap::new();
3505 let mut occurrences = Vec::new();
3506 let mut relation_dependencies = Vec::new();
3507 let mut unresolved_reasons = BTreeMap::new();
3508 for (candidate_index, candidate) in facts.link_candidates.iter().enumerate() {
3509 check_graph_work(control, candidate_index)?;
3510 if normalize_document_target(&graph.path, &candidate.selector)
3511 .is_ok_and(|target| target.path == graph.path && target.fragment.is_none())
3512 {
3513 continue;
3514 }
3515 let source = file_source;
3516 let outcome = resolve_document_candidate(
3517 project,
3518 &graph.path,
3519 &candidate.selector,
3520 document_index,
3521 candidates,
3522 staged_entities,
3523 control,
3524 )?;
3525 if matches!(
3526 &outcome.resolution,
3527 RelationResolution::Resolved { target, .. } if target == source.key()
3528 ) {
3529 continue;
3530 }
3531 let relation = LogicalRelation::new(
3532 source,
3533 GraphRelationKind::Extended(ExtendedRelationKind::Documents),
3534 outcome.resolution,
3535 ConfidenceClass::High,
3536 completeness,
3537 generation,
3538 )
3539 .map_err(invalid_graph_contract)?;
3540 insert_relation(
3541 &mut relations_by_digest,
3542 relation.clone(),
3543 &graph.path,
3544 "document",
3545 candidate_index,
3546 )?;
3547 if let Some(reason) = outcome.unresolved_reason {
3548 insert_document_unresolved_reason(
3549 &mut unresolved_reasons,
3550 relation.key().clone(),
3551 reason,
3552 )?;
3553 }
3554 let start_line = u32::try_from(candidate.source.line_start).map_err(|error| {
3555 CliError::InvalidInput(format!("document source line exceeds graph range: {error}"))
3556 })?;
3557 let end_line = u32::try_from(candidate.source.line_end).map_err(|error| {
3558 CliError::InvalidInput(format!("document end line exceeds graph range: {error}"))
3559 })?;
3560 let start_column = u32::try_from(candidate.source.column_start).map_err(|error| {
3561 CliError::InvalidInput(format!(
3562 "document source column exceeds graph range: {error}"
3563 ))
3564 })?;
3565 let end_column = u32::try_from(candidate.source.column_end).map_err(|error| {
3566 CliError::InvalidInput(format!("document end column exceeds graph range: {error}"))
3567 })?;
3568 occurrences.push(
3569 RelationOccurrence::new(
3570 &relation,
3571 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3572 SourceSpan::new(start_line, start_column, end_line, end_column)
3573 .map_err(invalid_graph_contract)?,
3574 generation,
3575 )
3576 .map_err(invalid_graph_contract)?,
3577 );
3578 for key in outcome.dependencies {
3579 relation_dependencies.push(
3580 RelationDependencyKey::new(relation.key().clone(), key)
3581 .map_err(invalid_graph_contract)?,
3582 );
3583 }
3584 }
3585 Ok(ProjectedGraphRows {
3586 relations: relations_by_digest.into_values().collect(),
3587 occurrences,
3588 coverage: vec![document_coverage(&graph.path, facts, generation)?],
3589 external_entities: Vec::new(),
3590 relation_dependencies,
3591 document_unresolved_reasons: unresolved_reasons.into_values().collect(),
3592 identity_rejections: Vec::new(),
3593 })
3594}
3595
3596#[allow(clippy::too_many_arguments)]
3598fn resolve_document_candidate(
3599 project: ProjectInstanceId,
3600 document_path: &str,
3601 selector: &str,
3602 document_index: &DocumentResolutionIndex<'_>,
3603 candidates: &ProjectResolutionRegistry,
3604 staged_entities: &BTreeMap<String, GraphEntity>,
3605 control: &IndexWorkControl,
3606) -> Result<DocumentResolutionOutcome, CliError> {
3607 let target = match normalize_document_target(document_path, selector) {
3608 Ok(target) => target,
3609 Err(reason) => {
3610 return unresolved_document_outcome(selector, reason, Vec::new());
3611 }
3612 };
3613 let file_key = document_file_resolution_key(project, &target.path)?;
3614 let mut dependencies = vec![
3615 file_key.clone(),
3616 document_casefold_resolution_key(project, &target.path)?,
3617 ];
3618 let heading_key = target
3619 .fragment
3620 .as_deref()
3621 .map(|fragment| document_heading_resolution_key(project, &target.path, fragment))
3622 .transpose()?;
3623 if let Some(key) = heading_key.as_ref() {
3624 dependencies.push(key.clone());
3625 }
3626 if let Some(reason) = document_index.unresolved_reason(&target.path)? {
3627 return unresolved_document_outcome(selector, reason, dependencies);
3628 }
3629 if let Some(key) = heading_key.as_ref() {
3630 let matches = registry_resolution_matches(
3631 std::slice::from_ref(key),
3632 candidates,
3633 staged_entities,
3634 control,
3635 )?;
3636 match matches.count {
3637 0 => {
3638 return unresolved_document_outcome(
3639 selector,
3640 DocumentTargetUnresolvedReason::Missing,
3641 dependencies,
3642 );
3643 }
3644 1 => {
3645 let target = matches.first.ok_or_else(|| {
3646 CliError::InvalidInput("resolved document heading disappeared".to_string())
3647 })?;
3648 return Ok(DocumentResolutionOutcome {
3649 resolution: RelationResolution::resolved(target)
3650 .map_err(invalid_graph_contract)?,
3651 unresolved_reason: None,
3652 dependencies,
3653 });
3654 }
3655 _ => {
3656 return unresolved_document_outcome(
3657 selector,
3658 DocumentTargetUnresolvedReason::CaseConflict,
3659 dependencies,
3660 );
3661 }
3662 }
3663 }
3664 let matches = registry_resolution_matches(
3665 std::slice::from_ref(&file_key),
3666 candidates,
3667 staged_entities,
3668 control,
3669 )?;
3670 match matches.count {
3671 1 => Ok(DocumentResolutionOutcome {
3672 resolution: RelationResolution::resolved(matches.first.ok_or_else(|| {
3673 CliError::InvalidInput("resolved document file disappeared".to_string())
3674 })?)
3675 .map_err(invalid_graph_contract)?,
3676 unresolved_reason: None,
3677 dependencies,
3678 }),
3679 0 => Err(CliError::InvalidInput(format!(
3680 "admitted document target lacked its exact graph identity: {}",
3681 target.path
3682 ))),
3683 _ => unresolved_document_outcome(
3684 selector,
3685 DocumentTargetUnresolvedReason::CaseConflict,
3686 dependencies,
3687 ),
3688 }
3689}
3690
3691fn unresolved_document_outcome(
3693 selector: &str,
3694 reason: DocumentTargetUnresolvedReason,
3695 dependencies: Vec<CanonicalResolutionKey>,
3696) -> Result<DocumentResolutionOutcome, CliError> {
3697 Ok(DocumentResolutionOutcome {
3698 resolution: RelationResolution::Unresolved {
3699 reference: GraphIdentityText::new(selector).map_err(invalid_graph_contract)?,
3700 },
3701 unresolved_reason: Some(reason),
3702 dependencies,
3703 })
3704}
3705
3706fn document_coverage(
3708 path: &str,
3709 facts: &MarkdownFacts,
3710 generation: IndexGeneration,
3711) -> Result<CoverageRecord, CliError> {
3712 let scope = CoverageScope::Path {
3713 path: RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract)?,
3714 };
3715 let covered = u64::try_from(facts.link_candidates.len()).unwrap_or(u64::MAX);
3716 let reached_limit = facts.coverage.limits.first().map(|limit| match limit {
3717 MarkdownFactLimit::HeadingCount | MarkdownFactLimit::CandidateCount => GraphLimitKind::Rows,
3718 MarkdownFactLimit::InputBytes
3719 | MarkdownFactLimit::LabelBytes
3720 | MarkdownFactLimit::SelectorBytes
3721 | MarkdownFactLimit::EvidenceBytes => GraphLimitKind::IntermediateBytes,
3722 });
3723 let relation = Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents));
3724 match facts.coverage.completeness {
3725 MarkdownFactCompleteness::Complete if covered == 0 => CoverageRecord::new(
3726 scope,
3727 relation,
3728 CoverageState::NoCandidates,
3729 0,
3730 0,
3731 generation,
3732 None,
3733 None,
3734 )
3735 .map_err(invalid_graph_contract),
3736 MarkdownFactCompleteness::Complete => CoverageRecord::new(
3737 scope,
3738 relation,
3739 CoverageState::Complete,
3740 covered,
3741 0,
3742 generation,
3743 None,
3744 None,
3745 )
3746 .map_err(invalid_graph_contract),
3747 MarkdownFactCompleteness::Partial if covered > 0 => CoverageRecord::new(
3748 scope,
3749 relation,
3750 CoverageState::Partial,
3751 covered,
3752 1,
3753 generation,
3754 Some(
3755 GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
3756 .map_err(invalid_graph_contract)?,
3757 ),
3758 reached_limit,
3759 )
3760 .map_err(invalid_graph_contract),
3761 MarkdownFactCompleteness::Partial => CoverageRecord::new(
3762 scope,
3763 relation,
3764 if facts
3765 .coverage
3766 .limits
3767 .contains(&MarkdownFactLimit::InputBytes)
3768 {
3769 CoverageState::Oversized
3770 } else {
3771 CoverageState::Failed
3772 },
3773 0,
3774 1,
3775 generation,
3776 Some(
3777 GraphIdentityText::new(DOCUMENT_PARTIAL_COVERAGE_REASON)
3778 .map_err(invalid_graph_contract)?,
3779 ),
3780 reached_limit,
3781 )
3782 .map_err(invalid_graph_contract),
3783 }
3784}
3785
3786fn insert_document_unresolved_reason(
3788 reasons: &mut BTreeMap<String, (LogicalRelationKey, DocumentTargetUnresolvedReason)>,
3789 key: LogicalRelationKey,
3790 reason: DocumentTargetUnresolvedReason,
3791) -> Result<(), CliError> {
3792 let digest = key.digest().to_string();
3793 match reasons.entry(digest) {
3794 Entry::Vacant(entry) => {
3795 entry.insert((key, reason));
3796 Ok(())
3797 }
3798 Entry::Occupied(entry) if entry.get() == &(key, reason) => Ok(()),
3799 Entry::Occupied(_entry) => Err(CliError::InvalidInput(
3800 "document relation retained conflicting unresolved reasons".to_string(),
3801 )),
3802 }
3803}
3804
3805fn project_graph_rows(
3807 project: ProjectInstanceId,
3808 generation: IndexGeneration,
3809 graph: &SymbolGraph,
3810 document_facts: Option<&MarkdownFacts>,
3811 document_index: &DocumentResolutionIndex<'_>,
3812 entities: &mut EntityProjection,
3813 candidates: &ProjectResolutionRegistry,
3814 identity_admission: &GraphIdentityAdmission,
3815 control: &IndexWorkControl,
3816) -> Result<ProjectedGraphRows, CliError> {
3817 control.check(IndexWorkStage::SymbolParsing)?;
3818 let owners = entities
3819 .owners_by_graph
3820 .remove(&graph.path)
3821 .ok_or_else(|| CliError::InvalidInput("graph owners were not staged".to_string()))?;
3822 let resolution_keys = entities
3823 .keys_by_graph
3824 .remove(&graph.path)
3825 .ok_or_else(|| CliError::InvalidInput("graph keys were not staged".to_string()))?;
3826 let symbol_index = GraphSymbolIndex::new(graph, control)?;
3827 let keys_by_relation = resolution_keys
3828 .relation_keys()
3829 .iter()
3830 .map(|entry| (entry.relation_index(), entry.keys()))
3831 .collect::<BTreeMap<_, _>>();
3832 let mut relations_by_digest = BTreeMap::new();
3833 let mut occurrences = Vec::new();
3834 let mut relation_dependencies = Vec::new();
3835 let mut coverage = Vec::new();
3836 let mut external_entities = BTreeMap::new();
3837 let mut document_unresolved_reasons = BTreeMap::new();
3838 for (relation_index, source_relation) in graph.relations.iter().enumerate() {
3839 control.check(IndexWorkStage::SymbolParsing)?;
3840 let source = relation_source(
3841 &owners,
3842 &entities.entity_by_digest,
3843 graph,
3844 &symbol_index,
3845 source_relation,
3846 control,
3847 )?;
3848 let dependency_keys = keys_by_relation
3849 .get(&relation_index)
3850 .copied()
3851 .unwrap_or(&[]);
3852 let resolution = relation_resolution(
3853 project,
3854 generation,
3855 source_relation,
3856 Some(relation_index),
3857 &owners,
3858 graph,
3859 &symbol_index,
3860 dependency_keys,
3861 candidates,
3862 &entities.entity_by_digest,
3863 &mut external_entities,
3864 control,
3865 )?;
3866 let relation = LogicalRelation::new(
3867 source,
3868 GraphRelationKind::from_legacy(source_relation.kind),
3869 resolution,
3870 relation_confidence(source_relation.parser),
3871 relation_completeness(source_relation.parser),
3872 generation,
3873 )
3874 .map_err(invalid_graph_contract)?;
3875 insert_relation(
3876 &mut relations_by_digest,
3877 relation.clone(),
3878 &graph.path,
3879 "logical",
3880 relation_index,
3881 )?;
3882 let line = u32::try_from(source_relation.line).map_err(|error| {
3883 CliError::InvalidInput(format!("relation source line exceeds graph range: {error}"))
3884 })?;
3885 let end_column =
3886 u32::try_from(source_relation.context.chars().count()).map_err(|error| {
3887 CliError::InvalidInput(format!(
3888 "relation source context exceeds graph range: {error}"
3889 ))
3890 })?;
3891 occurrences.push(
3892 RelationOccurrence::new(
3893 &relation,
3894 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3895 SourceSpan::new(line.max(1), 0, line.max(1), end_column)
3896 .map_err(invalid_graph_contract)?,
3897 generation,
3898 )
3899 .map_err(invalid_graph_contract)?,
3900 );
3901 for key in dependency_keys {
3902 relation_dependencies.push(
3903 RelationDependencyKey::new(relation.key().clone(), key.clone())
3904 .map_err(invalid_graph_contract)?,
3905 );
3906 }
3907 }
3908 let mut derived_identity_admission = GraphIdentityAdmission::default();
3909 let mut admitted_derived_facts = Vec::new();
3910 for (derived_index, fact) in derived_relation_facts(graph, &keys_by_relation)
3911 .into_iter()
3912 .enumerate()
3913 {
3914 let mut failures = Vec::new();
3915 record_identity_failure(
3916 &mut failures,
3917 GraphIdentityField::RelationSource,
3918 &fact.relation.source_name,
3919 );
3920 record_identity_failure(
3921 &mut failures,
3922 GraphIdentityField::RelationTarget,
3923 &fact.relation.target_name,
3924 );
3925 if failures.is_empty() {
3926 admitted_derived_facts.push(fact);
3927 } else {
3928 derived_identity_admission.record(
3929 &graph.path,
3930 IdentitySpan {
3931 start_line: fact.relation.line.max(1),
3932 start_column: 0,
3933 end_line: fact.relation.line.max(1),
3934 end_column: 0,
3935 },
3936 fact.relation.parser,
3937 parser_fact_index(DERIVED_RELATION_FACT_INDEX_NAMESPACE, derived_index),
3938 &failures,
3939 control,
3940 )?;
3941 }
3942 }
3943 for (fact_index, fact) in admitted_derived_facts.into_iter().enumerate() {
3944 control.check(IndexWorkStage::SymbolParsing)?;
3945 let source = relation_source(
3946 &owners,
3947 &entities.entity_by_digest,
3948 graph,
3949 &symbol_index,
3950 &fact.relation,
3951 control,
3952 )?;
3953 let resolution = derived_relation_resolution(
3954 project,
3955 generation,
3956 &fact,
3957 &owners,
3958 graph,
3959 &symbol_index,
3960 candidates,
3961 &entities.entity_by_digest,
3962 &mut external_entities,
3963 control,
3964 )?;
3965 let relation = LogicalRelation::new(
3966 source,
3967 GraphRelationKind::Extended(fact.kind),
3968 resolution,
3969 relation_confidence(fact.relation.parser),
3970 relation_completeness(fact.relation.parser),
3971 generation,
3972 )
3973 .map_err(invalid_graph_contract)?;
3974 insert_relation(
3975 &mut relations_by_digest,
3976 relation.clone(),
3977 &graph.path,
3978 "derived",
3979 fact_index,
3980 )?;
3981 let line = u32::try_from(fact.relation.line).map_err(|error| {
3982 CliError::InvalidInput(format!(
3983 "derived relation source line exceeds graph range: {error}"
3984 ))
3985 })?;
3986 let end_column = u32::try_from(fact.relation.context.chars().count()).map_err(|error| {
3987 CliError::InvalidInput(format!(
3988 "derived relation source context exceeds graph range: {error}"
3989 ))
3990 })?;
3991 occurrences.push(
3992 RelationOccurrence::new(
3993 &relation,
3994 RepositoryFilePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
3995 SourceSpan::new(line.max(1), 0, line.max(1), end_column)
3996 .map_err(invalid_graph_contract)?,
3997 generation,
3998 )
3999 .map_err(invalid_graph_contract)?,
4000 );
4001 if let DerivedRelationTarget::Parser { keys } = fact.target {
4002 for key in keys {
4003 relation_dependencies.push(
4004 RelationDependencyKey::new(relation.key().clone(), key)
4005 .map_err(invalid_graph_contract)?,
4006 );
4007 }
4008 }
4009 }
4010 if let Some(facts) = document_facts {
4011 let rows = project_document_rows(
4012 project,
4013 generation,
4014 graph,
4015 facts,
4016 &owners,
4017 document_index,
4018 candidates,
4019 &entities.entity_by_digest,
4020 control,
4021 )?;
4022 for (relation_index, relation) in rows.relations.into_iter().enumerate() {
4023 insert_relation(
4024 &mut relations_by_digest,
4025 relation,
4026 &graph.path,
4027 "document",
4028 relation_index,
4029 )?;
4030 }
4031 occurrences.extend(rows.occurrences);
4032 coverage.extend(rows.coverage);
4033 relation_dependencies.extend(rows.relation_dependencies);
4034 for (key, reason) in rows.document_unresolved_reasons {
4035 insert_document_unresolved_reason(&mut document_unresolved_reasons, key, reason)?;
4036 }
4037 }
4038 let mut relations = relations_by_digest.into_values().collect::<Vec<_>>();
4039 relations.sort_by(|left, right| left.key().digest().cmp(right.key().digest()));
4040 occurrences.sort_by(|left, right| {
4041 left.relation()
4042 .digest()
4043 .cmp(right.relation().digest())
4044 .then_with(|| left.file().as_str().cmp(right.file().as_str()))
4045 .then_with(|| left.span().start_line().cmp(&right.span().start_line()))
4046 .then_with(|| left.span().start_column().cmp(&right.span().start_column()))
4047 });
4048 occurrences.dedup();
4049 sort_dedup_dependencies(&mut relation_dependencies);
4050 enforce_key_binding_limit(relation_dependencies.len())?;
4051 entities.retained_bytes = entities
4052 .retained_bytes
4053 .saturating_sub(resolution_retained_bytes(&resolution_keys));
4054 let mut graph_coverage = vec![coverage_for_graph(
4055 graph,
4056 generation,
4057 identity_admission,
4058 &derived_identity_admission,
4059 )?];
4060 graph_coverage.extend(coverage);
4061 Ok(ProjectedGraphRows {
4062 relations,
4063 occurrences,
4064 coverage: graph_coverage,
4065 external_entities: external_entities.into_values().collect(),
4066 relation_dependencies,
4067 document_unresolved_reasons: document_unresolved_reasons.into_values().collect(),
4068 identity_rejections: derived_identity_admission.rejections,
4069 })
4070}
4071
4072fn insert_relation(
4074 relations: &mut BTreeMap<String, LogicalRelation>,
4075 relation: LogicalRelation,
4076 graph_path: &str,
4077 relation_kind: &str,
4078 relation_index: usize,
4079) -> Result<(), CliError> {
4080 let digest = relation.key().digest().to_string();
4081 match relations.entry(digest) {
4082 Entry::Vacant(entry) => {
4083 entry.insert(relation);
4084 Ok(())
4085 }
4086 Entry::Occupied(mut entry) => {
4087 let existing = entry.get();
4088 if existing == &relation {
4089 return Ok(());
4090 }
4091 let mergeable = existing.key() == relation.key()
4092 && existing.source() == relation.source()
4093 && existing.kind() == relation.kind()
4094 && existing.confidence() == relation.confidence()
4095 && existing.completeness() == relation.completeness()
4096 && existing.generation() == relation.generation();
4097 if mergeable
4098 && let (
4099 RelationResolution::Ambiguous {
4100 reference: existing_reference,
4101 candidates: existing_candidates,
4102 },
4103 RelationResolution::Ambiguous {
4104 reference: incoming_reference,
4105 candidates: incoming_candidates,
4106 },
4107 ) = (existing.resolution(), relation.resolution())
4108 && existing_reference == incoming_reference
4109 {
4110 if incoming_candidates > existing_candidates {
4111 entry.insert(relation);
4112 }
4113 return Ok(());
4114 }
4115 Err(CliError::InvalidInput(format!(
4116 "{relation_kind} relation digest retained conflicting facts for {graph_path} \
4117 relation {relation_index}: existing={existing:?}, incoming={relation:?}"
4118 )))
4119 }
4120 }
4121}
4122
4123#[derive(Default)]
4125struct ProjectResolutionRegistry {
4126 supplemental_entities_by_digest: BTreeMap<String, GraphEntity>,
4128 candidate_digests_by_key: BTreeMap<CanonicalResolutionKey, BTreeSet<String>>,
4130 retained_bytes: u64,
4132}
4133
4134impl ProjectResolutionRegistry {
4135 fn insert_candidate(
4137 &mut self,
4138 key: &CanonicalResolutionKey,
4139 entity: &GraphEntity,
4140 ) -> Result<(), CliError> {
4141 let digest = entity.key().digest().to_string();
4142 match self.supplemental_entities_by_digest.entry(digest.clone()) {
4143 Entry::Occupied(entry) if entry.get() != entity => {
4144 return Err(CliError::InvalidInput(
4145 "graph entity digest retained conflicting selectors".to_string(),
4146 ));
4147 }
4148 Entry::Occupied(_entry) => {}
4149 Entry::Vacant(entry) => {
4150 self.retained_bytes = self
4151 .retained_bytes
4152 .saturating_add(entity_retained_bytes(entity))
4153 .saturating_add(digest.len() as u64);
4154 entry.insert(entity.clone());
4155 }
4156 }
4157 self.insert_candidate_binding(key, digest)
4158 }
4159
4160 fn insert_staged_candidate(
4162 &mut self,
4163 key: &CanonicalResolutionKey,
4164 entity: &GraphEntity,
4165 ) -> Result<(), CliError> {
4166 self.insert_candidate_binding(key, entity.key().digest().to_string())
4167 }
4168
4169 fn insert_candidate_binding(
4171 &mut self,
4172 key: &CanonicalResolutionKey,
4173 digest: String,
4174 ) -> Result<(), CliError> {
4175 if let Some(existing) = self.candidate_digests_by_key.get_mut(key) {
4176 if existing.insert(digest.clone()) {
4177 self.retained_bytes = self
4178 .retained_bytes
4179 .saturating_add(STAGED_GRAPH_ROW_BYTES)
4180 .saturating_add(digest.len() as u64);
4181 }
4182 } else {
4183 self.retained_bytes = self
4184 .retained_bytes
4185 .saturating_add(STAGED_GRAPH_ROW_BYTES)
4186 .saturating_add(key.canonical_identity().len() as u64)
4187 .saturating_add(STAGED_GRAPH_ROW_BYTES)
4188 .saturating_add(digest.len() as u64);
4189 self.candidate_digests_by_key
4190 .insert(key.clone(), BTreeSet::from([digest]));
4191 }
4192 enforce_resolution_registry_budget(self.retained_bytes)?;
4193 Ok(())
4194 }
4195}
4196
4197fn enforce_resolution_registry_budget(retained_bytes: u64) -> Result<(), CliError> {
4199 enforce_resolution_registry_budget_with_limit(
4200 retained_bytes,
4201 super::MAX_PUBLICATION_STAGING_BYTES,
4202 )
4203}
4204
4205fn enforce_resolution_registry_budget_with_limit(
4207 retained_bytes: u64,
4208 limit: u64,
4209) -> Result<(), CliError> {
4210 if retained_bytes > limit {
4211 return Err(IndexWorkFailure::resource_limit(
4212 IndexWorkStage::SymbolParsing,
4213 IndexWorkResource::OutputBytes,
4214 limit,
4215 retained_bytes,
4216 )
4217 .into());
4218 }
4219 Ok(())
4220}
4221
4222fn enforce_resolution_staging_budget(
4224 projection: &EntityProjection,
4225 registry: &ProjectResolutionRegistry,
4226) -> Result<(), CliError> {
4227 enforce_resolution_registry_budget(
4228 projection
4229 .retained_bytes
4230 .saturating_add(registry.retained_bytes),
4231 )
4232}
4233
4234fn entity_retained_bytes(entity: &GraphEntity) -> u64 {
4236 let selector_bytes = match entity.selector() {
4237 EntitySelector::Project => 0,
4238 EntitySelector::Folder { path } => path.as_str().len() as u64,
4239 EntitySelector::File { path } => path.as_str().len() as u64,
4240 EntitySelector::Package { package } => {
4241 (package.manager.as_str().len()
4242 + package.name.as_str().len()
4243 + package.manifest.as_str().len()) as u64
4244 }
4245 EntitySelector::Symbol { symbol } => {
4246 (symbol.file.as_str().len()
4247 + symbol.name.as_str().len()
4248 + symbol
4249 .parent
4250 .as_ref()
4251 .map_or(0, |parent| parent.as_str().len())
4252 + symbol.signature.as_str().len()) as u64
4253 }
4254 EntitySelector::External { external } => {
4255 (external.system.as_str().len() + external.identity.as_str().len()) as u64
4256 }
4257 };
4258 STAGED_GRAPH_ROW_BYTES
4259 .saturating_add(entity.key().digest().len() as u64)
4260 .saturating_add(entity.key().canonical_identity().len() as u64)
4261 .saturating_add(selector_bytes)
4262}
4263
4264fn resolution_registry_from_exports(
4266 projection: &EntityProjection,
4267 control: &IndexWorkControl,
4268) -> Result<ProjectResolutionRegistry, CliError> {
4269 let mut candidates = ProjectResolutionRegistry::default();
4270 for (index, binding) in projection.entity_exports.iter().enumerate() {
4271 check_graph_work(control, index)?;
4272 let digest = binding.entity().digest().to_string();
4273 let entity = projection.entity_by_digest.get(&digest).ok_or_else(|| {
4274 CliError::InvalidInput("resolution export owner was not staged".to_string())
4275 })?;
4276 candidates.insert_staged_candidate(binding.key(), entity)?;
4277 }
4278 Ok(candidates)
4279}
4280
4281fn resolution_registry_from_persisted(
4283 project: ProjectInstanceId,
4284 generation: IndexGeneration,
4285 candidates: Vec<RepositoryResolutionCandidate>,
4286 replaced_paths: &BTreeSet<String>,
4287 control: &IndexWorkControl,
4288) -> Result<ProjectResolutionRegistry, CliError> {
4289 let mut by_key = ProjectResolutionRegistry::default();
4290 for (index, candidate) in candidates.into_iter().enumerate() {
4291 check_graph_work(control, index)?;
4292 if entity_owner_path(candidate.entity()).is_some_and(|path| replaced_paths.contains(path)) {
4293 continue;
4294 }
4295 let entity = GraphEntity::new(project, candidate.entity().selector().clone(), generation)
4296 .map_err(invalid_graph_contract)?;
4297 by_key.insert_candidate(candidate.key(), &entity)?;
4298 }
4299 Ok(by_key)
4300}
4301
4302fn merge_resolution_registries(
4304 target: &mut ProjectResolutionRegistry,
4305 source: ProjectResolutionRegistry,
4306 control: &IndexWorkControl,
4307) -> Result<(), CliError> {
4308 let ProjectResolutionRegistry {
4309 supplemental_entities_by_digest,
4310 candidate_digests_by_key,
4311 retained_bytes: _retained_bytes,
4312 } = source;
4313 for entity in supplemental_entities_by_digest.into_values() {
4314 check_graph_work(control, target.supplemental_entities_by_digest.len())?;
4315 let digest = entity.key().digest().to_string();
4316 match target.supplemental_entities_by_digest.entry(digest.clone()) {
4317 Entry::Occupied(entry) if entry.get() != &entity => {
4318 return Err(CliError::InvalidInput(
4319 "resolution candidate entity retained conflicting selectors".to_string(),
4320 ));
4321 }
4322 Entry::Occupied(_entry) => {}
4323 Entry::Vacant(entry) => {
4324 target.retained_bytes = target
4325 .retained_bytes
4326 .saturating_add(entity_retained_bytes(&entity))
4327 .saturating_add(digest.len() as u64);
4328 entry.insert(entity);
4329 }
4330 }
4331 }
4332 let mut bindings = 0_usize;
4333 for (key, candidates) in candidate_digests_by_key {
4334 for digest in candidates {
4335 check_graph_work(control, bindings)?;
4336 target.insert_candidate_binding(&key, digest)?;
4337 bindings = bindings.saturating_add(1);
4338 }
4339 }
4340 Ok(())
4341}
4342
4343fn check_graph_work(control: &IndexWorkControl, index: usize) -> Result<(), CliError> {
4345 if index.is_multiple_of(GRAPH_WORK_CHECK_INTERVAL) {
4346 control.check(IndexWorkStage::SymbolParsing)?;
4347 }
4348 Ok(())
4349}
4350
4351fn entity_owner_path(entity: &GraphEntity) -> Option<&str> {
4353 match entity.selector() {
4354 EntitySelector::File { path } => Some(path.as_str()),
4355 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
4356 EntitySelector::Package { package } => Some(package.manifest.as_str()),
4357 EntitySelector::Project
4358 | EntitySelector::Folder { .. }
4359 | EntitySelector::External { .. } => None,
4360 }
4361}
4362
4363fn derived_relation_facts(
4365 graph: &SymbolGraph,
4366 keys_by_relation: &BTreeMap<usize, &[CanonicalResolutionKey]>,
4367) -> Vec<DerivedRelationFact> {
4368 let mut facts = Vec::new();
4369 let test_path = is_test_path(&graph.path);
4370 for (index, relation) in graph.relations.iter().enumerate() {
4371 if test_path && matches!(relation.kind, RelationKind::Imports | RelationKind::Calls) {
4372 let keys = keys_by_relation
4373 .get(&index)
4374 .copied()
4375 .unwrap_or_default()
4376 .to_vec();
4377 push_derived_relation(
4378 &mut facts,
4379 ExtendedRelationKind::Tests,
4380 relation.clone(),
4381 DerivedRelationTarget::Parser { keys },
4382 );
4383 }
4384 if relation.kind == RelationKind::Calls {
4385 if let Some(handler) = static_route_handler(relation) {
4386 push_derived_relation(
4387 &mut facts,
4388 ExtendedRelationKind::RoutesTo,
4389 derived_parser_relation(relation, handler),
4390 DerivedRelationTarget::Parser { keys: Vec::new() },
4391 );
4392 }
4393 if let Some(key) = static_environment_key(relation) {
4394 push_derived_relation(
4395 &mut facts,
4396 ExtendedRelationKind::Configures,
4397 derived_parser_relation(relation, key),
4398 DerivedRelationTarget::External {
4399 system: ENVIRONMENT_SYSTEM,
4400 },
4401 );
4402 }
4403 if let Some(kind) = static_data_access_kind(&relation.target_name)
4404 && let Some(path) = static_string_argument(&relation.context)
4405 .and_then(normalize_static_repository_path)
4406 {
4407 push_derived_relation(
4408 &mut facts,
4409 kind,
4410 derived_parser_relation(relation, path),
4411 DerivedRelationTarget::RepositoryPath,
4412 );
4413 }
4414 }
4415 }
4416 if let Some(identity) = configuration_file_identity(&graph.path) {
4417 push_derived_relation(
4418 &mut facts,
4419 ExtendedRelationKind::Configures,
4420 file_owned_relation(graph, identity),
4421 DerivedRelationTarget::External {
4422 system: CONFIGURATION_SYSTEM,
4423 },
4424 );
4425 }
4426 if let Some(identity) = deployment_platform_identity(&graph.path) {
4427 push_derived_relation(
4428 &mut facts,
4429 ExtendedRelationKind::Deploys,
4430 file_owned_relation(graph, identity),
4431 DerivedRelationTarget::External {
4432 system: DEPLOYMENT_SYSTEM,
4433 },
4434 );
4435 }
4436 facts
4437}
4438
4439fn push_derived_relation(
4441 facts: &mut Vec<DerivedRelationFact>,
4442 kind: ExtendedRelationKind,
4443 relation: SymbolRelation,
4444 target: DerivedRelationTarget,
4445) {
4446 facts.push(DerivedRelationFact {
4447 kind,
4448 relation,
4449 target,
4450 });
4451}
4452
4453fn derived_parser_relation(relation: &SymbolRelation, target_name: String) -> SymbolRelation {
4455 SymbolRelation {
4456 path: relation.path.clone(),
4457 source_name: relation.source_name.clone(),
4458 target_name,
4459 kind: RelationKind::Calls,
4460 line: relation.line,
4461 context: relation.context.clone(),
4462 parser: relation.parser,
4463 }
4464}
4465
4466fn file_owned_relation(graph: &SymbolGraph, target_name: String) -> SymbolRelation {
4468 SymbolRelation {
4469 path: graph.path.clone(),
4470 source_name: MODULE_RELATION_SOURCE.to_string(),
4471 target_name,
4472 kind: RelationKind::Calls,
4473 line: 1,
4474 context: graph.path.clone(),
4475 parser: graph.parser,
4476 }
4477}
4478
4479fn derived_relation_resolution<'a>(
4481 project: ProjectInstanceId,
4482 generation: IndexGeneration,
4483 fact: &DerivedRelationFact,
4484 owners: &GraphOwners,
4485 graph: &SymbolGraph,
4486 symbol_index: &GraphSymbolIndex<'_>,
4487 candidates: &'a ProjectResolutionRegistry,
4488 entities: &'a BTreeMap<String, GraphEntity>,
4489 external_entities: &mut BTreeMap<String, GraphEntity>,
4490 control: &IndexWorkControl,
4491) -> Result<RelationResolution, CliError> {
4492 match &fact.target {
4493 DerivedRelationTarget::Parser { keys } => relation_resolution(
4494 project,
4495 generation,
4496 &fact.relation,
4497 None,
4498 owners,
4499 graph,
4500 symbol_index,
4501 keys,
4502 candidates,
4503 entities,
4504 external_entities,
4505 control,
4506 ),
4507 DerivedRelationTarget::RepositoryPath => {
4508 let candidate = GraphEntity::new(
4509 project,
4510 EntitySelector::File {
4511 path: RepositoryFilePath::new(Path::new(&fact.relation.target_name))
4512 .map_err(invalid_graph_contract)?,
4513 },
4514 generation,
4515 )
4516 .map_err(invalid_graph_contract)?;
4517 match entities.get(candidate.key().digest()) {
4518 Some(entity) if entity == &candidate => {
4519 RelationResolution::resolved(entity).map_err(invalid_graph_contract)
4520 }
4521 Some(_conflict) => Err(CliError::InvalidInput(
4522 "static repository-path target collided with another graph entity".to_string(),
4523 )),
4524 None => Ok(RelationResolution::Unresolved {
4525 reference: GraphIdentityText::new(fact.relation.target_name.clone())
4526 .map_err(invalid_graph_contract)?,
4527 }),
4528 }
4529 }
4530 DerivedRelationTarget::External { system } => {
4531 let entity = GraphEntity::new(
4532 project,
4533 EntitySelector::External {
4534 external: ExternalSelector {
4535 system: GraphIdentityText::new(*system).map_err(invalid_graph_contract)?,
4536 identity: GraphIdentityText::new(fact.relation.target_name.clone())
4537 .map_err(invalid_graph_contract)?,
4538 },
4539 },
4540 generation,
4541 )
4542 .map_err(invalid_graph_contract)?;
4543 let resolution =
4544 RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
4545 insert_entity(external_entities, entity)?;
4546 Ok(resolution)
4547 }
4548 }
4549}
4550
4551fn is_test_path(path: &str) -> bool {
4553 let normalized = path.replace('\\', "/").to_ascii_lowercase();
4554 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4555 normalized
4556 .split('/')
4557 .any(|segment| matches!(segment, "test" | "tests" | "__tests__"))
4558 || file.contains(".test.")
4559 || file.contains(".spec.")
4560 || file
4561 .split_once('.')
4562 .is_some_and(|(stem, _extension)| stem.ends_with("_test") || stem.starts_with("test_"))
4563}
4564
4565fn static_route_handler(relation: &SymbolRelation) -> Option<String> {
4567 let leaf = call_leaf(&relation.target_name);
4568 if !matches!(
4569 leaf,
4570 "route" | "add_route" | "map_get" | "map_post" | "map_put" | "map_patch" | "map_delete"
4571 ) {
4572 return None;
4573 }
4574 let route = static_string_argument(&relation.context)?;
4575 if !route.starts_with('/')
4576 || route.chars().any(char::is_control)
4577 || relation.context.matches(',').count() != 1
4578 {
4579 return None;
4580 }
4581 let handler = relation
4582 .context
4583 .rsplit_once(',')?
4584 .1
4585 .trim()
4586 .trim_end_matches([')', ';'])
4587 .trim();
4588 let valid_handler = !handler.is_empty()
4589 && handler != relation.source_name
4590 && handler
4591 .chars()
4592 .next()
4593 .is_some_and(|character| !character.is_ascii_digit())
4594 && handler.chars().all(|character| {
4595 character.is_ascii_alphanumeric() || matches!(character, '_' | ':' | '.' | '$')
4596 });
4597 valid_handler.then(|| handler.trim_matches('$').to_string())
4598}
4599
4600fn static_environment_key(relation: &SymbolRelation) -> Option<String> {
4602 let normalized = relation
4603 .target_name
4604 .trim()
4605 .trim_end_matches('!')
4606 .to_ascii_lowercase();
4607 if !(normalized.ends_with("env::var")
4608 || normalized.ends_with("env::var_os")
4609 || normalized.ends_with("os.getenv")
4610 || normalized.ends_with("getenvironmentvariable")
4611 || matches!(normalized.as_str(), "getenv" | "getenv_os"))
4612 {
4613 return None;
4614 }
4615 let key = static_string_argument(&relation.context)?;
4616 (!key.is_empty()
4617 && key.len() <= 128
4618 && key
4619 .bytes()
4620 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
4621 .then(|| key.to_string())
4622}
4623
4624fn static_data_access_kind(target: &str) -> Option<ExtendedRelationKind> {
4626 let normalized = target.trim().trim_end_matches('!').to_ascii_lowercase();
4627 let leaf = call_leaf(&normalized);
4628 if matches!(
4629 leaf,
4630 "read" | "read_to_string" | "readfile" | "readfilesync"
4631 ) || normalized.ends_with("file::open")
4632 {
4633 Some(ExtendedRelationKind::Reads)
4634 } else if matches!(leaf, "write" | "writefile" | "writefilesync" | "create")
4635 || normalized.ends_with("file::create")
4636 {
4637 Some(ExtendedRelationKind::Writes)
4638 } else {
4639 None
4640 }
4641}
4642
4643fn call_leaf(target: &str) -> &str {
4645 target
4646 .trim()
4647 .trim_end_matches('!')
4648 .rsplit([':', '.', '/'])
4649 .find(|part| !part.is_empty())
4650 .unwrap_or_default()
4651}
4652
4653fn static_string_argument(context: &str) -> Option<&str> {
4655 let open = context.find('(')?;
4656 let argument = context[open + 1..].trim_start();
4657 let quote = argument.chars().next()?;
4658 if !matches!(quote, '\'' | '"') {
4659 return None;
4660 }
4661 let value = &argument[quote.len_utf8()..];
4662 let end = value.find(quote)?;
4663 let value = &value[..end];
4664 (!value.contains('\\')
4665 && !value.contains("${")
4666 && !value.contains("#{")
4667 && !value.chars().any(char::is_control))
4668 .then_some(value)
4669}
4670
4671fn normalize_static_repository_path(value: &str) -> Option<String> {
4673 let value = value.replace('\\', "/");
4674 if value.is_empty()
4675 || value.starts_with('/')
4676 || value.starts_with('~')
4677 || value.contains("://")
4678 || value.contains('$')
4679 || value.contains('{')
4680 || value.contains('}')
4681 || value
4682 .split('/')
4683 .next()
4684 .is_some_and(|part| part.contains(':'))
4685 {
4686 return None;
4687 }
4688 let mut parts = Vec::new();
4689 for part in value.split('/') {
4690 match part {
4691 "" | "." => {}
4692 ".." => {
4693 parts.pop()?;
4694 }
4695 part if part == "."
4696 || part == ".."
4697 || part.chars().any(char::is_control)
4698 || part.trim() != part =>
4699 {
4700 return None;
4701 }
4702 part => parts.push(part.to_string()),
4703 }
4704 }
4705 (!parts.is_empty()).then(|| parts.join("/"))
4706}
4707
4708fn configuration_file_identity(path: &str) -> Option<String> {
4710 let normalized = path.replace('\\', "/").to_ascii_lowercase();
4711 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4712 let identity = if file == ".env" || file.starts_with(".env.") {
4713 "dotenv"
4714 } else if matches!(
4715 file,
4716 "config.json"
4717 | "config.yaml"
4718 | "config.yml"
4719 | "config.toml"
4720 | "settings.json"
4721 | "settings.yaml"
4722 | "settings.yml"
4723 | "settings.toml"
4724 | "appsettings.json"
4725 ) {
4726 "application-config"
4727 } else {
4728 return None;
4729 };
4730 Some(identity.to_string())
4731}
4732
4733fn deployment_platform_identity(path: &str) -> Option<String> {
4735 let normalized = path.replace('\\', "/").to_ascii_lowercase();
4736 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
4737 let identity = if file_has_extension(file, "tf")
4738 || file
4739 .strip_suffix(".json")
4740 .is_some_and(|stem| file_has_extension(stem, "tf"))
4741 {
4742 "terraform"
4743 } else if file == "dockerfile"
4744 || file.starts_with("dockerfile.")
4745 || matches!(
4746 file,
4747 "compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
4748 )
4749 {
4750 "containers"
4751 } else if matches!(
4752 file,
4753 "chart.yaml" | "kustomization.yaml" | "kustomization.yml"
4754 ) || (normalized
4755 .split('/')
4756 .any(|segment| matches!(segment, "k8s" | "kubernetes" | "helm" | "kustomize"))
4757 && matches!(
4758 Path::new(file).extension().and_then(|value| value.to_str()),
4759 Some("yaml" | "yml" | "json")
4760 ))
4761 {
4762 "kubernetes"
4763 } else if file_has_extension(file, "bicep") {
4764 "azure-bicep"
4765 } else if file == "sam-template.yaml"
4766 || (file.contains("cloudformation")
4767 && matches!(
4768 Path::new(file).extension().and_then(|value| value.to_str()),
4769 Some("yaml" | "yml" | "json")
4770 ))
4771 {
4772 "cloudformation"
4773 } else if file == "playbook.yaml" || file == "playbook.yml" {
4774 "ansible"
4775 } else {
4776 return None;
4777 };
4778 Some(identity.to_string())
4779}
4780
4781fn file_has_extension(file: &str, expected: &str) -> bool {
4783 Path::new(file)
4784 .extension()
4785 .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
4786}
4787
4788fn relation_source<'a>(
4790 owners: &GraphOwners,
4791 entities: &'a BTreeMap<String, GraphEntity>,
4792 graph: &SymbolGraph,
4793 symbol_index: &GraphSymbolIndex<'_>,
4794 relation: &SymbolRelation,
4795 control: &IndexWorkControl,
4796) -> Result<&'a GraphEntity, CliError> {
4797 let file = entities.get(&owners.file_digest).ok_or_else(|| {
4798 CliError::InvalidInput("graph file owner entity was not staged".to_string())
4799 })?;
4800 let is_php_call = relation.kind == RelationKind::Calls
4801 && graph
4802 .language
4803 .as_deref()
4804 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4805 let is_php_contains = relation.kind == RelationKind::Contains
4806 && graph
4807 .language
4808 .as_deref()
4809 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4810 let php_source = if is_php_call {
4811 unique_php_call_source(
4812 graph,
4813 symbol_index,
4814 &relation.source_name,
4815 relation.line,
4816 control,
4817 )?
4818 } else if is_php_contains {
4819 unique_php_containment_source(graph, symbol_index, relation, control)?
4820 } else {
4821 None
4822 };
4823 let indices = if is_php_call || is_php_contains {
4824 php_source.as_slice()
4825 } else {
4826 symbol_index.get(&relation.source_name)
4827 };
4828 let mut matched = None;
4829 for (candidate_index, &index) in indices.iter().enumerate() {
4830 check_graph_work(control, candidate_index)?;
4831 let digest = owners.symbol_digests.get(index).ok_or_else(|| {
4832 CliError::InvalidInput("graph symbol owner index was not staged".to_string())
4833 })?;
4834 let Some(digest) = digest else {
4835 continue;
4836 };
4837 let entity = entities.get(digest).ok_or_else(|| {
4838 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
4839 })?;
4840 if matched.is_some() {
4841 return Ok(file);
4842 }
4843 matched = Some(entity);
4844 }
4845 Ok(matched.unwrap_or(file))
4846}
4847
4848#[derive(Clone, Copy)]
4850struct ResolutionMatches<'a> {
4851 first: Option<&'a GraphEntity>,
4853 count: u32,
4855}
4856
4857fn relation_resolution<'a>(
4859 project: ProjectInstanceId,
4860 generation: IndexGeneration,
4861 relation: &projectatlas_core::symbols::SymbolRelation,
4862 relation_index: Option<usize>,
4863 owners: &GraphOwners,
4864 graph: &SymbolGraph,
4865 symbol_index: &GraphSymbolIndex<'_>,
4866 dependency_keys: &[CanonicalResolutionKey],
4867 candidates: &'a ProjectResolutionRegistry,
4868 staged_entities: &'a BTreeMap<String, GraphEntity>,
4869 external_entities: &mut BTreeMap<String, GraphEntity>,
4870 control: &IndexWorkControl,
4871) -> Result<RelationResolution, CliError> {
4872 let matches = match relation.kind {
4873 RelationKind::Contains => local_relation_matches(
4874 relation,
4875 relation_index,
4876 owners,
4877 graph,
4878 symbol_index,
4879 staged_entities,
4880 control,
4881 )?,
4882 RelationKind::Calls => {
4883 let local = local_relation_matches(
4884 relation,
4885 relation_index,
4886 owners,
4887 graph,
4888 symbol_index,
4889 staged_entities,
4890 control,
4891 )?;
4892 if local.count == 0 {
4893 registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
4894 } else {
4895 local
4896 }
4897 }
4898 RelationKind::Imports | RelationKind::DependsOn => {
4899 registry_resolution_matches(dependency_keys, candidates, staged_entities, control)?
4900 }
4901 };
4902 match matches.count {
4903 0 => {
4904 if let Some(external) = explicit_external_selector(graph, relation)? {
4905 let entity =
4906 GraphEntity::new(project, EntitySelector::External { external }, generation)
4907 .map_err(invalid_graph_contract)?;
4908 let resolution =
4909 RelationResolution::external(&entity).map_err(invalid_graph_contract)?;
4910 insert_entity(external_entities, entity)?;
4911 return Ok(resolution);
4912 }
4913 Ok(RelationResolution::Unresolved {
4914 reference: GraphIdentityText::new(relation_reference(relation))
4915 .map_err(invalid_graph_contract)?,
4916 })
4917 }
4918 1 => RelationResolution::resolved(
4919 matches
4920 .first
4921 .ok_or_else(|| CliError::InvalidInput("resolved target disappeared".to_string()))?,
4922 )
4923 .map_err(invalid_graph_contract),
4924 count => Ok(RelationResolution::Ambiguous {
4925 reference: GraphIdentityText::new(relation_reference(relation))
4926 .map_err(invalid_graph_contract)?,
4927 candidates: NonZeroU32::new(count).ok_or_else(|| {
4928 CliError::InvalidInput("ambiguous target count was zero".to_string())
4929 })?,
4930 }),
4931 }
4932}
4933
4934fn local_relation_matches<'a>(
4936 relation: &projectatlas_core::symbols::SymbolRelation,
4937 relation_index: Option<usize>,
4938 owners: &GraphOwners,
4939 graph: &SymbolGraph,
4940 symbol_index: &GraphSymbolIndex<'_>,
4941 staged_entities: &'a BTreeMap<String, GraphEntity>,
4942 control: &IndexWorkControl,
4943) -> Result<ResolutionMatches<'a>, CliError> {
4944 let mut targets = BTreeMap::<&str, &GraphEntity>::new();
4945 let mut global_fallback_targets = BTreeMap::<&str, &GraphEntity>::new();
4946 let target_name = relation.target_name.trim();
4947 let is_php_call = relation.kind == RelationKind::Calls
4948 && graph
4949 .language
4950 .as_deref()
4951 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4952 let is_php_contains = relation.kind == RelationKind::Contains
4953 && graph
4954 .language
4955 .as_deref()
4956 .is_some_and(|language| language.eq_ignore_ascii_case("php"));
4957 let php_containment_source = if is_php_contains {
4958 unique_php_containment_source(graph, symbol_index, relation, control)?
4959 .and_then(|index| graph.symbols.get(index))
4960 } else {
4961 None
4962 };
4963 let source_parent = if relation.kind == RelationKind::Calls {
4964 unique_source_parent(
4965 graph,
4966 symbol_index,
4967 &relation.source_name,
4968 is_php_call.then_some(relation.line),
4969 control,
4970 )?
4971 } else {
4972 None
4973 };
4974 let known_source_namespace = if is_php_call {
4975 unique_php_source_namespace(graph, symbol_index, source_parent, relation, control)?
4976 } else {
4977 None
4978 };
4979 if is_php_call
4980 && target_name
4981 .split_once("::")
4982 .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
4983 && let Some(parent) = source_parent
4984 {
4985 for (candidate_index, &index) in symbol_index.get(parent).iter().enumerate() {
4986 check_graph_work(control, candidate_index)?;
4987 let owner = graph.symbols.get(index).ok_or_else(|| {
4988 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
4989 })?;
4990 if owner.kind == SymbolKind::Trait
4991 && owner.line_start <= relation.line
4992 && relation.line <= owner.line_end
4993 {
4994 return Ok(ResolutionMatches {
4996 first: None,
4997 count: 0,
4998 });
4999 }
5000 }
5001 }
5002 let php_imports_may_alias = symbol_index.php_imports_have_unknown_scope
5003 || known_source_namespace.map_or(
5004 !symbol_index.php_import_positions.is_empty(),
5005 |namespace| {
5006 let namespace = namespace.to_ascii_lowercase();
5007 let block_start = symbol_index
5008 .php_namespace_start_lines
5009 .get(&namespace)
5010 .and_then(|starts| starts.range(..=relation.line).next_back())
5011 .copied()
5012 .unwrap_or(0);
5013 symbol_index
5014 .php_import_positions
5015 .get(&namespace)
5016 .and_then(|positions| positions.range(block_start..=relation.line).next())
5017 .is_some_and(|(&line, &import_index)| {
5018 line < relation.line
5019 || import_index
5020 .zip(relation_index)
5021 .is_none_or(|(import, call)| import <= call)
5022 })
5023 },
5024 );
5025 let namespace_relative_target = if is_php_call
5026 && let Some((prefix, relative)) = target_name.split_once('\\')
5027 && !prefix.is_empty()
5028 && (prefix.eq_ignore_ascii_case("namespace") || !php_imports_may_alias)
5029 {
5030 let Some(namespace) = known_source_namespace else {
5031 return Ok(ResolutionMatches {
5032 first: None,
5033 count: 0,
5034 });
5035 };
5036 let relative = if prefix.eq_ignore_ascii_case("namespace") {
5037 relative
5038 } else {
5039 target_name
5040 };
5041 Some(if namespace.is_empty() {
5042 format!("\\{relative}")
5043 } else {
5044 format!("\\{namespace}\\{relative}")
5045 })
5046 } else {
5047 None
5048 };
5049 let lookup_target = namespace_relative_target.as_deref().unwrap_or(target_name);
5050 if is_php_call
5051 && php_imports_may_alias
5052 && !lookup_target.starts_with('\\')
5053 && !lookup_target
5054 .split_once("::")
5055 .is_some_and(|(scope, _)| scope.eq_ignore_ascii_case("self"))
5056 {
5057 return Ok(ResolutionMatches {
5060 first: None,
5061 count: 0,
5062 });
5063 }
5064 let (lookup_name, scoped_parent, mut scoped_namespace, target_namespace) = if is_php_call {
5065 let Some(lookup) = php_call_lookup(lookup_target, source_parent) else {
5066 return Ok(ResolutionMatches {
5067 first: None,
5068 count: 0,
5069 });
5070 };
5071 lookup
5072 } else {
5073 (target_name, None, None, None)
5074 };
5075 if is_php_call && scoped_parent.is_some() && scoped_namespace.is_none() {
5076 scoped_namespace = known_source_namespace;
5077 if scoped_namespace.is_none() {
5078 return Ok(ResolutionMatches {
5079 first: None,
5080 count: 0,
5081 });
5082 }
5083 }
5084 let source_namespace = if is_php_call && scoped_parent.is_none() {
5085 if target_namespace.is_some() {
5086 target_namespace
5087 } else {
5088 known_source_namespace
5089 }
5090 } else {
5091 None
5092 };
5093 let candidate_indices = if is_php_call {
5094 symbol_index.get_php_call(lookup_name)
5095 } else {
5096 symbol_index.get(lookup_name)
5097 };
5098 for (candidate_index, &row_index) in candidate_indices.iter().enumerate() {
5099 check_graph_work(control, candidate_index)?;
5100 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5101 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5102 })?;
5103 let digest = owners.symbol_digests.get(row_index).ok_or_else(|| {
5104 CliError::InvalidInput("graph symbol owner index was not staged".to_string())
5105 })?;
5106 let exact_match = match relation.kind {
5107 RelationKind::Contains => {
5108 symbol.name == lookup_name
5109 && symbol.parent.as_deref() == Some(relation.source_name.as_str())
5110 && if is_php_contains {
5111 if let Some(source) = php_containment_source {
5112 symbol.line_start == relation.line
5113 && symbol.detail.as_deref() == Some(relation.context.as_str())
5114 && php_entity_matches_qualified_scope(
5115 staged_entities,
5116 digest.as_ref(),
5117 &source.name,
5118 source.parent.as_deref().unwrap_or(""),
5119 )?
5120 } else {
5121 false
5122 }
5123 } else {
5124 true
5125 }
5126 }
5127 RelationKind::Calls => {
5128 let name_matches = if is_php_call {
5129 symbol.name.eq_ignore_ascii_case(lookup_name)
5130 } else {
5131 symbol.name == lookup_name
5132 };
5133 name_matches
5134 && (!is_php_call
5135 || symbol.kind
5136 == if scoped_parent.is_some() {
5137 SymbolKind::Method
5138 } else {
5139 SymbolKind::Function
5140 })
5141 && scoped_parent.is_none_or(|parent| {
5142 symbol.parent.as_deref().is_some_and(|symbol_parent| {
5143 if is_php_call {
5144 symbol_parent.eq_ignore_ascii_case(parent)
5145 } else {
5146 symbol_parent == parent
5147 }
5148 })
5149 })
5150 && if let (Some(namespace), Some(scoped_parent)) =
5151 (scoped_namespace, scoped_parent)
5152 {
5153 php_entity_matches_qualified_scope(
5154 staged_entities,
5155 digest.as_ref(),
5156 scoped_parent,
5157 namespace,
5158 )?
5159 } else {
5160 true
5161 }
5162 && source_namespace.is_none_or(|namespace| {
5163 symbol.parent.as_deref().is_some_and(|symbol_parent| {
5164 symbol_parent.eq_ignore_ascii_case(namespace)
5165 }) || (target_namespace.is_none_or(str::is_empty)
5166 && symbol.parent.is_none())
5167 })
5168 && (scoped_parent.is_some()
5169 || source_namespace.is_some()
5170 || symbol.parent.is_none()
5171 || symbol.parent.as_deref() == Some(relation.source_name.as_str())
5172 || source_parent.is_some_and(|source_parent| {
5173 symbol.parent.as_deref() == Some(source_parent)
5174 }))
5175 }
5176 RelationKind::Imports | RelationKind::DependsOn => false,
5177 };
5178 if exact_match && let Some(digest) = digest {
5179 let entity = staged_entities.get(digest).ok_or_else(|| {
5180 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5181 })?;
5182 let is_global_fallback = is_php_call
5183 && scoped_parent.is_none()
5184 && target_namespace.is_none()
5185 && source_namespace.is_some()
5186 && symbol.kind == SymbolKind::Function
5187 && symbol.parent.is_none();
5188 let distinct_target_count = targets.len().saturating_add(global_fallback_targets.len());
5189 let destination = if is_global_fallback {
5190 &mut global_fallback_targets
5191 } else {
5192 &mut targets
5193 };
5194 if !destination.contains_key(entity.key().digest()) {
5195 enforce_resolution_match_budget(distinct_target_count.saturating_add(1))?;
5196 }
5197 destination.insert(entity.key().digest(), entity);
5198 }
5199 }
5200 let targets = if targets.is_empty() {
5201 global_fallback_targets
5202 } else {
5203 targets
5204 };
5205 let count = distinct_resolution_count(targets.len())?;
5206 Ok(ResolutionMatches {
5207 first: targets.into_values().next(),
5208 count,
5209 })
5210}
5211
5212fn php_call_lookup<'a>(
5214 target_name: &'a str,
5215 source_parent: Option<&'a str>,
5216) -> Option<(&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>)> {
5217 let Some((scope, member)) = target_name.rsplit_once("::") else {
5218 let Some(qualified_target) = target_name.strip_prefix('\\') else {
5219 return Some((target_name, None, None, None));
5220 };
5221 let Some((namespace, function)) = qualified_target.rsplit_once('\\') else {
5222 return Some((qualified_target, None, None, Some("")));
5223 };
5224 if namespace.is_empty() || function.is_empty() {
5225 return None;
5226 }
5227 return Some((function, None, None, Some(namespace)));
5228 };
5229 if scope.is_empty() || member.is_empty() {
5230 return None;
5231 }
5232 if scope.eq_ignore_ascii_case("self") {
5233 return source_parent.map(|parent| (member, Some(parent), None, None));
5234 }
5235 if scope.eq_ignore_ascii_case("static") {
5236 return None;
5237 }
5238 if scope.eq_ignore_ascii_case("parent") {
5239 return None;
5240 }
5241 if scope.contains('\\') && !scope.starts_with('\\') {
5242 return None;
5243 }
5244 let scope_without_root = scope.strip_prefix('\\').unwrap_or(scope);
5245 let (scope, namespace) = match scope_without_root.rsplit_once('\\') {
5246 Some((namespace, scope)) if !namespace.is_empty() && !scope.is_empty() => {
5247 (scope, Some(namespace))
5248 }
5249 None if scope.starts_with('\\') && !scope_without_root.is_empty() => {
5250 (scope_without_root, Some(""))
5251 }
5252 None if !scope_without_root.is_empty() => (scope_without_root, None),
5253 _ => return None,
5254 };
5255 Some((member, Some(scope), namespace, None))
5256}
5257
5258fn php_entity_matches_qualified_scope(
5260 staged_entities: &BTreeMap<String, GraphEntity>,
5261 digest: Option<&String>,
5262 scoped_parent: &str,
5263 namespace: &str,
5264) -> Result<bool, CliError> {
5265 let Some(digest) = digest else {
5266 return Ok(false);
5267 };
5268 let entity = staged_entities.get(digest).ok_or_else(|| {
5269 CliError::InvalidInput("graph symbol owner entity was not staged".to_string())
5270 })?;
5271 let EntitySelector::Symbol { symbol } = entity.selector() else {
5272 return Ok(false);
5273 };
5274 let Some(parent) = symbol.parent.as_ref() else {
5275 return Ok(false);
5276 };
5277 Ok(
5278 if let Some((owner_namespace, owner_name)) = parent.as_str().rsplit_once("::") {
5279 owner_name.eq_ignore_ascii_case(scoped_parent)
5280 && owner_namespace.eq_ignore_ascii_case(namespace)
5281 } else {
5282 namespace.is_empty() && parent.as_str().eq_ignore_ascii_case(scoped_parent)
5283 },
5284 )
5285}
5286
5287fn unique_php_source_namespace<'a>(
5289 graph: &'a SymbolGraph,
5290 symbol_index: &GraphSymbolIndex<'_>,
5291 source_parent: Option<&str>,
5292 relation: &projectatlas_core::symbols::SymbolRelation,
5293 control: &IndexWorkControl,
5294) -> Result<Option<&'a str>, CliError> {
5295 let Some(source_parent) = source_parent else {
5296 if relation.source_name == MODULE_RELATION_SOURCE {
5298 return Ok(Some(""));
5299 }
5300 let mut global_function = false;
5301 let mut namespace = None;
5302 for (candidate_index, &row_index) in
5303 symbol_index.get(&relation.source_name).iter().enumerate()
5304 {
5305 check_graph_work(control, candidate_index)?;
5306 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5307 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5308 })?;
5309 if matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method)
5310 && symbol.line_start <= relation.line
5311 && relation.line <= symbol.line_end
5312 {
5313 if symbol.kind != SymbolKind::Function || symbol.parent.is_some() {
5314 return Ok(None);
5315 }
5316 global_function = true;
5317 } else if symbol.kind == SymbolKind::Module {
5318 namespace = Some(symbol.name.as_str());
5319 }
5320 }
5321 return Ok(if global_function { Some("") } else { namespace });
5322 };
5323 let mut namespace = None;
5324 let mut module_namespace = None;
5325 let mut source_found = false;
5326 for (candidate_index, &row_index) in symbol_index.get(source_parent).iter().enumerate() {
5327 check_graph_work(control, candidate_index)?;
5328 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5329 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5330 })?;
5331 let candidate = match symbol.kind {
5332 SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
5333 if relation.line < symbol.line_start || relation.line > symbol.line_end {
5334 continue;
5335 }
5336 Some(symbol.parent.as_deref().unwrap_or(""))
5337 }
5338 SymbolKind::Module if symbol.name == source_parent => {
5339 module_namespace = Some(symbol.name.as_str());
5340 continue;
5341 }
5342 _ => continue,
5343 };
5344 if !source_found {
5345 namespace = candidate;
5346 source_found = true;
5347 } else if namespace != candidate {
5348 return Ok(None);
5349 }
5350 }
5351 Ok(namespace.or(module_namespace))
5353}
5354
5355fn unique_source_parent<'a>(
5357 graph: &'a SymbolGraph,
5358 symbol_index: &GraphSymbolIndex<'_>,
5359 source_name: &str,
5360 relation_line: Option<usize>,
5361 control: &IndexWorkControl,
5362) -> Result<Option<&'a str>, CliError> {
5363 if let Some(line) = relation_line {
5364 let Some(index) = unique_php_call_source(graph, symbol_index, source_name, line, control)?
5365 else {
5366 return Ok(None);
5367 };
5368 let symbol = graph.symbols.get(index).ok_or_else(|| {
5369 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5370 })?;
5371 return Ok(symbol.parent.as_deref());
5372 }
5373 let mut parent = None;
5374 let mut source_found = false;
5375 for (candidate_index, &row_index) in symbol_index.get(source_name).iter().enumerate() {
5376 check_graph_work(control, candidate_index)?;
5377 let symbol = graph.symbols.get(row_index).ok_or_else(|| {
5378 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5379 })?;
5380 let candidate = symbol.parent.as_deref();
5381 if !source_found {
5382 parent = candidate;
5383 source_found = true;
5384 } else if parent != candidate {
5385 return Ok(None);
5386 }
5387 }
5388 Ok(parent)
5389}
5390
5391fn unique_php_containment_source(
5393 graph: &SymbolGraph,
5394 symbol_index: &GraphSymbolIndex<'_>,
5395 relation: &SymbolRelation,
5396 control: &IndexWorkControl,
5397) -> Result<Option<usize>, CliError> {
5398 let mut target = None;
5399 for (candidate_index, &index) in symbol_index.get(&relation.target_name).iter().enumerate() {
5400 check_graph_work(control, candidate_index)?;
5401 let symbol = graph.symbols.get(index).ok_or_else(|| {
5402 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5403 })?;
5404 if symbol.parent.as_deref() == Some(relation.source_name.as_str())
5405 && symbol.line_start == relation.line
5406 && symbol.detail.as_deref() == Some(relation.context.as_str())
5407 && target.replace(index).is_some()
5408 {
5409 return Ok(None);
5410 }
5411 }
5412 let Some(target) = target else {
5413 return Ok(None);
5414 };
5415 let Some(target_span) = graph.symbols[target].source_selector else {
5416 return Ok(None);
5417 };
5418 let mut matched = None;
5419 let mut module = None;
5420 for (candidate_index, &index) in symbol_index.get(&relation.source_name).iter().enumerate() {
5421 check_graph_work(control, candidate_index)?;
5422 let symbol = graph.symbols.get(index).ok_or_else(|| {
5423 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5424 })?;
5425 let contains = match symbol.kind {
5426 SymbolKind::Module => {
5427 module.get_or_insert(index);
5428 false
5429 }
5430 SymbolKind::Class | SymbolKind::Interface | SymbolKind::Trait | SymbolKind::Enum => {
5431 index != target
5432 && symbol.source_selector.is_some_and(|span| {
5433 span.byte_start <= target_span.byte_start
5434 && target_span.byte_end <= span.byte_end
5435 })
5436 }
5437 _ => false,
5438 };
5439 if contains && matched.replace(index).is_some() {
5440 return Ok(None);
5441 }
5442 }
5443 Ok(matched.or(module))
5444}
5445
5446fn unique_php_call_source(
5448 graph: &SymbolGraph,
5449 symbol_index: &GraphSymbolIndex<'_>,
5450 source_name: &str,
5451 line: usize,
5452 control: &IndexWorkControl,
5453) -> Result<Option<usize>, CliError> {
5454 let mut callable = None;
5455 let mut callable_boundary = false;
5456 let mut module = None;
5457 for (candidate_index, &index) in symbol_index.get(source_name).iter().enumerate() {
5458 check_graph_work(control, candidate_index)?;
5459 let symbol = graph.symbols.get(index).ok_or_else(|| {
5460 CliError::InvalidInput("graph symbol lookup index was invalid".to_string())
5461 })?;
5462 match symbol.kind {
5463 SymbolKind::Function | SymbolKind::Method
5464 if symbol.line_start <= line && line <= symbol.line_end =>
5465 {
5466 if callable.replace(index).is_some() {
5467 return Ok(None);
5468 }
5469 callable_boundary = line == symbol.line_start || line == symbol.line_end;
5470 }
5471 SymbolKind::Module => {
5472 module = Some(index);
5474 }
5475 _ => {}
5476 }
5477 }
5478 if callable.is_some() {
5479 Ok(if module.is_some() && callable_boundary {
5482 None
5483 } else {
5484 callable
5485 })
5486 } else {
5487 Ok(module)
5488 }
5489}
5490
5491fn registry_resolution_matches<'a>(
5493 dependency_keys: &[CanonicalResolutionKey],
5494 candidates: &'a ProjectResolutionRegistry,
5495 staged_entities: &'a BTreeMap<String, GraphEntity>,
5496 control: &IndexWorkControl,
5497) -> Result<ResolutionMatches<'a>, CliError> {
5498 enforce_resolution_match_budget(dependency_keys.len().saturating_mul(2))?;
5499 let mut streams = dependency_keys
5500 .iter()
5501 .filter_map(|key| candidates.candidate_digests_by_key.get(key))
5502 .map(BTreeSet::iter)
5503 .collect::<Vec<_>>();
5504 let mut frontier = BinaryHeap::new();
5505 for (stream_index, stream) in streams.iter_mut().enumerate() {
5506 if let Some(digest) = stream.next() {
5507 frontier.push(Reverse((digest.as_str(), stream_index)));
5508 }
5509 }
5510
5511 let mut first = None;
5512 let mut last_digest = None;
5513 let mut count = 0_u32;
5514 let mut visited = 0_usize;
5515 while let Some(Reverse((digest, stream_index))) = frontier.pop() {
5516 check_graph_work(control, visited)?;
5517 visited = visited.saturating_add(1);
5518 if last_digest != Some(digest) {
5519 let entity = staged_entities
5520 .get(digest)
5521 .or_else(|| candidates.supplemental_entities_by_digest.get(digest))
5522 .ok_or_else(|| {
5523 CliError::InvalidInput(
5524 "resolution candidate entity was not registered".to_string(),
5525 )
5526 })?;
5527 first.get_or_insert(entity);
5528 count = count.checked_add(1).ok_or_else(|| {
5529 IndexWorkFailure::resource_limit(
5530 IndexWorkStage::SymbolParsing,
5531 IndexWorkResource::RelationRows,
5532 u64::from(u32::MAX),
5533 u64::from(u32::MAX) + 1,
5534 )
5535 })?;
5536 last_digest = Some(digest);
5537 }
5538 if let Some(next) = streams[stream_index].next() {
5539 frontier.push(Reverse((next.as_str(), stream_index)));
5540 }
5541 }
5542 Ok(ResolutionMatches { first, count })
5543}
5544
5545fn distinct_resolution_count(count: usize) -> Result<u32, CliError> {
5547 u32::try_from(count).map_err(|_conversion_error| {
5548 IndexWorkFailure::resource_limit(
5549 IndexWorkStage::SymbolParsing,
5550 IndexWorkResource::RelationRows,
5551 u64::from(u32::MAX),
5552 u64::try_from(count).unwrap_or(u64::MAX),
5553 )
5554 .into()
5555 })
5556}
5557
5558fn enforce_resolution_match_budget(rows: usize) -> Result<(), CliError> {
5560 enforce_resolution_registry_budget(
5561 STAGED_GRAPH_ROW_BYTES.saturating_mul(u64::try_from(rows).unwrap_or(u64::MAX)),
5562 )
5563}
5564
5565fn explicit_external_selector(
5567 graph: &SymbolGraph,
5568 relation: &projectatlas_core::symbols::SymbolRelation,
5569) -> Result<Option<ExternalSelector>, CliError> {
5570 let semantic_provider = graph
5571 .language
5572 .as_deref()
5573 .and_then(language_capability)
5574 .and_then(|capability| capability.effective_semantic_provider());
5575 let classified = match (semantic_provider, relation.kind) {
5576 (Some(SemanticProviderOwner::Cargo), RelationKind::DependsOn) => {
5577 external_reference_identity(&relation.target_name)
5578 .map(|identity| (CARGO_PACKAGE_MANAGER, identity))
5579 }
5580 (Some(SemanticProviderOwner::Rust), RelationKind::Imports | RelationKind::Calls) => {
5581 rust_toolchain_identity(relation).map(|identity| (RUST_TOOLCHAIN_SYSTEM, identity))
5582 }
5583 (Some(SemanticProviderOwner::EcmaScript), RelationKind::Imports) => {
5584 node_builtin_identity(relation).map(|identity| (NODE_SYSTEM, identity))
5585 }
5586 (
5587 Some(
5588 SemanticProviderOwner::Python
5589 | SemanticProviderOwner::Unavailable
5590 | SemanticProviderOwner::Cargo
5591 | SemanticProviderOwner::EcmaScript,
5592 )
5593 | None,
5594 _,
5595 )
5596 | (Some(SemanticProviderOwner::Rust), RelationKind::Contains | RelationKind::DependsOn) => {
5597 None
5598 }
5599 };
5600 classified
5601 .map(|(system, identity)| {
5602 Ok(ExternalSelector {
5603 system: GraphIdentityText::new(system).map_err(invalid_graph_contract)?,
5604 identity: GraphIdentityText::new(identity).map_err(invalid_graph_contract)?,
5605 })
5606 })
5607 .transpose()
5608}
5609
5610fn external_reference_identity(reference: &str) -> Option<String> {
5612 let reference = reference.trim();
5613 (!reference.is_empty()).then(|| reference.to_string())
5614}
5615
5616fn rust_toolchain_identity(
5618 relation: &projectatlas_core::symbols::SymbolRelation,
5619) -> Option<String> {
5620 if relation.kind == RelationKind::Imports {
5621 let mut references = parse_import_references(&relation.target_name).into_iter();
5622 let first = rust_import_identity(&references.next()?)?;
5623 return references.try_fold(first, |common, reference| {
5624 let identity = rust_import_identity(&reference)?;
5625 common_rust_path(&common, &identity)
5626 });
5627 }
5628 let target = relation_reference(relation);
5629 rust_toolchain_root(&target)?;
5630 Some(target)
5631}
5632
5633fn rust_import_identity(reference: &projectatlas_symbols::ImportReference) -> Option<String> {
5635 let module = reference.module();
5636 rust_toolchain_root(module)?;
5637 Some(reference.imported().map_or_else(
5638 || module.to_string(),
5639 |imported| format!("{module}::{imported}"),
5640 ))
5641}
5642
5643fn common_rust_path(left: &str, right: &str) -> Option<String> {
5645 let components = left
5646 .split("::")
5647 .zip(right.split("::"))
5648 .take_while(|(left, right)| left == right)
5649 .map(|(component, _right)| component)
5650 .collect::<Vec<_>>();
5651 (!components.is_empty()).then(|| components.join("::"))
5652}
5653
5654fn rust_toolchain_root(path: &str) -> Option<&str> {
5656 let root = path.trim().split("::").next()?;
5657 matches!(root, "std" | "core" | "alloc").then_some(root)
5658}
5659
5660fn node_builtin_identity(relation: &projectatlas_core::symbols::SymbolRelation) -> Option<String> {
5662 parse_import_references(&relation.target_name)
5663 .into_iter()
5664 .find_map(|reference| {
5665 reference
5666 .module()
5667 .strip_prefix("node:")
5668 .filter(|identity| !identity.is_empty())
5669 .map(ToString::to_string)
5670 })
5671 .or_else(|| {
5672 quoted_ecmascript_module(&relation.target_name)
5673 .and_then(|module| module.strip_prefix("node:"))
5674 .filter(|identity| !identity.is_empty())
5675 .map(ToString::to_string)
5676 })
5677}
5678
5679fn quoted_ecmascript_module(import: &str) -> Option<&str> {
5681 let import = import.trim();
5682 if !import.starts_with("import ") {
5683 return None;
5684 }
5685 let (quote_index, quote) = import
5686 .char_indices()
5687 .find(|(_index, character)| matches!(character, '\'' | '"'))?;
5688 let remainder = &import[quote_index + quote.len_utf8()..];
5689 let end = remainder.find(quote)?;
5690 Some(&remainder[..end])
5691}
5692
5693fn nonempty_reference(value: &str) -> String {
5695 let value = value.trim();
5696 if value.is_empty() {
5697 UNKNOWN_REFERENCE.to_string()
5698 } else {
5699 value.to_string()
5700 }
5701}
5702
5703fn relation_reference(relation: &SymbolRelation) -> String {
5705 let mut value = if relation.kind == RelationKind::Calls {
5706 relation
5707 .target_name
5708 .split_once('(')
5709 .map_or(relation.target_name.as_str(), |(target, _arguments)| target)
5710 } else {
5711 &relation.target_name
5712 };
5713 if relation.kind == RelationKind::Calls && value.contains(['\'', '"']) {
5714 value = call_leaf(value);
5715 }
5716 nonempty_reference(value)
5717}
5718
5719fn coverage_for_graph(
5721 graph: &SymbolGraph,
5722 generation: IndexGeneration,
5723 identity_admission: &GraphIdentityAdmission,
5724 derived_identity_admission: &GraphIdentityAdmission,
5725) -> Result<CoverageRecord, CliError> {
5726 let scope = CoverageScope::Path {
5727 path: RepositoryNodePath::new(Path::new(&graph.path)).map_err(invalid_graph_contract)?,
5728 };
5729 let identity_omitted =
5730 identity_admission.rejected_facts_for_graph(&graph.path, derived_identity_admission)?;
5731 let parser_omitted = identity_admission.parser_rejection_for_graph(&graph.path);
5732 let omitted = identity_omitted
5733 .checked_add(parser_omitted)
5734 .ok_or_else(|| CliError::InvalidInput("identity rejection count overflowed".to_string()))?;
5735 let identity_details_dropped = identity_admission.rejection_details_dropped_for(&graph.path)
5736 || derived_identity_admission.rejection_details_dropped_for(&graph.path);
5737 let reached_limit = (omitted > 0 && identity_details_dropped).then_some(GraphLimitKind::Rows);
5741 let covered = if identity_omitted > 0 {
5742 u64::try_from(graph.symbols.len().saturating_add(graph.relations.len())).unwrap_or(u64::MAX)
5743 } else {
5744 u64::try_from(graph.relations.len()).unwrap_or(u64::MAX)
5745 };
5746 if omitted > 0 {
5747 let state = if covered > 0 {
5748 CoverageState::Partial
5749 } else {
5750 CoverageState::Failed
5751 };
5752 return CoverageRecord::new(
5753 scope,
5754 None,
5755 state,
5756 covered,
5757 omitted,
5758 generation,
5759 Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5760 reached_limit,
5761 )
5762 .map_err(invalid_graph_contract);
5763 }
5764 match graph.parser {
5765 ParserKind::TreeSitter | ParserKind::Manifest => CoverageRecord::new(
5766 scope,
5767 None,
5768 CoverageState::Complete,
5769 covered,
5770 0,
5771 generation,
5772 None,
5773 None,
5774 )
5775 .map_err(invalid_graph_contract),
5776 ParserKind::Structural | ParserKind::Fallback if covered > 0 => CoverageRecord::new(
5777 scope,
5778 None,
5779 CoverageState::Partial,
5780 covered,
5781 1,
5782 generation,
5783 Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5784 None,
5785 )
5786 .map_err(invalid_graph_contract),
5787 ParserKind::Structural | ParserKind::Fallback => CoverageRecord::new(
5788 scope,
5789 None,
5790 CoverageState::Failed,
5791 0,
5792 1,
5793 generation,
5794 Some(GraphIdentityText::new(PARTIAL_COVERAGE_REASON).map_err(invalid_graph_contract)?),
5795 None,
5796 )
5797 .map_err(invalid_graph_contract),
5798 }
5799}
5800
5801fn relation_confidence(parser: ParserKind) -> ConfidenceClass {
5803 match parser {
5804 ParserKind::TreeSitter | ParserKind::Manifest => ConfidenceClass::Exact,
5805 ParserKind::Structural => ConfidenceClass::Medium,
5806 ParserKind::Fallback => ConfidenceClass::Low,
5807 }
5808}
5809
5810fn relation_completeness(parser: ParserKind) -> Completeness {
5812 match parser {
5813 ParserKind::TreeSitter | ParserKind::Manifest => Completeness::Complete,
5814 ParserKind::Structural | ParserKind::Fallback => Completeness::Partial,
5815 }
5816}
5817
5818fn hydrate_reused_identity_admission(
5826 store: &AtlasStore,
5827 project: ProjectInstanceId,
5828 reused_paths: &BTreeSet<String>,
5829 graphs: &[impl Borrow<SymbolGraph>],
5830 report: &mut GraphIdentityAdmission,
5831 control: &IndexWorkControl,
5832) -> Result<(), CliError> {
5833 if reused_paths.is_empty() {
5834 return Ok(());
5835 }
5836 let reused_node_paths = reused_paths
5837 .iter()
5838 .map(|path| RepositoryNodePath::new(Path::new(path)).map_err(invalid_graph_contract))
5839 .collect::<Result<Vec<_>, _>>()?;
5840 let mut persisted_rejections = Vec::new();
5841 let mut persisted_coverage = BTreeMap::new();
5842 let mut persisted_rejection_details_dropped_by_path = BTreeSet::new();
5843 for paths in reused_node_paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
5844 control.check(IndexWorkStage::SymbolParsing)?;
5845 let rejection_rows = match store.repository_graph_identity_rejections(
5846 project,
5847 paths,
5848 GraphLimits::MAX_ROWS,
5849 Some(control),
5850 ) {
5851 Ok(rows) => rows,
5852 Err(projectatlas_db::DbError::GraphRowShape { table, reason })
5853 if table == "project_identity"
5854 && reason == "typed graph generation does not match complete publication" =>
5855 {
5856 return Ok(());
5859 }
5860 Err(error) => return Err(error.into()),
5861 };
5862 persisted_rejections.extend(rejection_rows);
5863 let coverage = match store.repository_graph_path_coverage(project, paths, Some(control)) {
5864 Ok(coverage) => coverage,
5865 Err(projectatlas_db::DbError::GraphRowShape { table, reason })
5866 if table == "project_identity"
5867 && reason == "typed graph generation does not match complete publication" =>
5868 {
5869 return Ok(());
5870 }
5871 Err(error) => return Err(error.into()),
5872 };
5873 if coverage.truncated {
5874 return Err(CliError::InvalidInput(
5875 "reused graph coverage exceeded the publication row ceiling".to_string(),
5876 ));
5877 }
5878 for row in coverage.rows {
5879 let CoverageScope::Path { path } = row.scope() else {
5880 continue;
5881 };
5882 if row.relation().is_none() && row.omitted() > 0 {
5883 control.check(IndexWorkStage::SymbolParsing)?;
5884 persisted_coverage.insert(path.as_str().to_owned(), row.omitted());
5885 }
5886 if row.relation().is_none() && row.reached_limit() == Some(GraphLimitKind::Rows) {
5887 control.check(IndexWorkStage::SymbolParsing)?;
5888 persisted_rejection_details_dropped_by_path.insert(path.as_str().to_owned());
5889 }
5890 }
5891 }
5892 let mut persisted_fact_counts = BTreeMap::<String, BTreeSet<IdentityFactKey>>::new();
5893 for path in persisted_rejection_details_dropped_by_path {
5894 control.check(IndexWorkStage::SymbolParsing)?;
5895 if report
5896 .rejection_details_dropped_by_path
5897 .insert(path.clone())
5898 {
5899 report.reserve_reused_path_bytes(
5900 &path,
5901 false,
5902 control,
5903 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5904 )?;
5905 }
5906 }
5907 for rejection in persisted_rejections {
5908 control.check(IndexWorkStage::SymbolParsing)?;
5909 let namespace = rejection.fact_index & !((1_u64 << 56) - 1);
5910 let path = rejection.path.as_str().to_owned();
5911 persisted_fact_counts
5912 .entry(path.clone())
5913 .or_default()
5914 .insert(IdentityFactKey {
5915 span: IdentitySpan {
5916 start_line: rejection.span.start_line() as usize,
5917 start_column: rejection.span.start_column() as usize,
5918 end_line: rejection.span.end_line() as usize,
5919 end_column: rejection.span.end_column() as usize,
5920 },
5921 parser: parser_fact_kind(rejection.parser),
5922 owner: u8::from(rejection.field == GraphIdentityField::ResolutionKey),
5923 fact_index: rejection.fact_index,
5924 });
5925 if namespace == DERIVED_RELATION_FACT_INDEX_NAMESPACE {
5928 continue;
5929 }
5930 control.check(IndexWorkStage::SymbolParsing)?;
5931 let rejection_key_bytes = extend_bounded_identity_rejections_with_drop_paths(
5932 &mut report.rejections,
5933 &mut report.rejection_keys,
5934 std::iter::once(rejection),
5935 &mut report.rejection_details_dropped_by_path,
5936 )?;
5937 report.reserve_identity_bytes(
5938 rejection_key_bytes,
5939 control,
5940 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5941 )?;
5942 }
5943 for (path, facts) in persisted_fact_counts {
5944 control.check(IndexWorkStage::SymbolParsing)?;
5945 if !report.reused_rejection_facts.contains_key(&path) {
5946 report.reserve_identity_bytes(
5947 identity_fact_set_retained_bytes(&path, &facts)?,
5948 control,
5949 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5950 )?;
5951 report.reused_rejection_facts.insert(path.clone(), facts);
5952 }
5953 let detail_count = u64::try_from(
5954 report
5955 .reused_rejection_facts
5956 .get(&path)
5957 .map_or(0, BTreeSet::len),
5958 )
5959 .map_err(|error| {
5960 CliError::InvalidInput(format!(
5961 "identity rejection detail count overflowed: {error}"
5962 ))
5963 })?;
5964 if !report.reused_rejection_detail_counts.contains_key(&path) {
5965 report.reserve_reused_path_bytes(
5966 &path,
5967 false,
5968 control,
5969 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5970 )?;
5971 report
5972 .reused_rejection_detail_counts
5973 .insert(path, detail_count);
5974 }
5975 }
5976 for (path, omitted) in persisted_coverage {
5977 control.check(IndexWorkStage::SymbolParsing)?;
5981 if !report.reused_rejection_counts.contains_key(&path) {
5982 report.reserve_reused_path_bytes(
5983 &path,
5984 false,
5985 control,
5986 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
5987 )?;
5988 report.reused_rejection_counts.insert(path, omitted);
5989 }
5990 }
5991 let graph_parsers = graphs
5992 .iter()
5993 .map(|graph| {
5994 let graph = Borrow::<SymbolGraph>::borrow(graph);
5995 (graph.path.as_str(), graph.parser)
5996 })
5997 .collect::<BTreeMap<_, _>>();
5998 for path in reused_paths {
5999 let Some(_omitted) = report.reused_rejection_counts.get(path).copied() else {
6000 continue;
6001 };
6002 let details = report
6003 .reused_rejection_detail_counts
6004 .get(path)
6005 .copied()
6006 .unwrap_or(0);
6007 let parser = graph_parsers.get(path.as_str()).copied();
6008 let baseline = parser.map_or(0, |parser| {
6009 u64::from(matches!(
6010 parser,
6011 ParserKind::Structural | ParserKind::Fallback
6012 ))
6013 });
6014 if details == 0 && baseline > 0 {
6015 if !report.reused_parser_rejection_counts.contains_key(path) {
6020 report.reserve_reused_path_bytes(
6021 path,
6022 false,
6023 control,
6024 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6025 )?;
6026 report
6027 .reused_parser_rejection_counts
6028 .insert(path.clone(), baseline);
6029 }
6030 }
6031 if parser != Some(ParserKind::Fallback)
6036 && report.rejection_details_dropped_for(path)
6037 && !report.reused_rejection_details_incomplete.contains(path)
6038 {
6039 report.reserve_reused_path_bytes(
6040 path,
6041 false,
6042 control,
6043 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
6044 )?;
6045 report
6046 .reused_rejection_details_incomplete
6047 .insert(path.clone());
6048 }
6049 }
6050 let retained_bytes =
6051 checked_identity_admission_budget(report, control, MAX_IN_MEMORY_GRAPH_WORK_BYTES)?;
6052 report.observed_fact_bytes = retained_bytes;
6053 Ok(())
6054}
6055
6056fn complete_symbol_graphs<'a>(
6058 store: &AtlasStore,
6059 paths: &BTreeSet<String>,
6060 symbols: &'a SymbolBuildStage,
6061 control: &IndexWorkControl,
6062) -> Result<Vec<Cow<'a, SymbolGraph>>, CliError> {
6063 let paths = paths.iter().cloned().collect::<Vec<_>>();
6064 let mut graphs = BTreeMap::new();
6065 for chunk in paths.chunks(PERSISTED_GRAPH_PATHS_PER_CHUNK) {
6066 control.check(IndexWorkStage::SymbolParsing)?;
6067 for graph in store.load_symbol_graphs_for_paths(chunk)? {
6068 graphs.insert(graph.path.clone(), Cow::Owned(graph));
6069 }
6070 }
6071 for (index, change) in symbols.changes.iter().enumerate() {
6072 check_graph_work(control, index)?;
6073 match change {
6074 SymbolProjectionChange::Parsed(parsed) if paths.binary_search(&parsed.path).is_ok() => {
6075 graphs.insert(parsed.path.clone(), Cow::Borrowed(&parsed.graph));
6076 }
6077 SymbolProjectionChange::Clear { path, .. } if paths.binary_search(path).is_ok() => {
6078 graphs.remove(path);
6079 }
6080 SymbolProjectionChange::Parsed(_) | SymbolProjectionChange::Clear { .. } => {}
6081 }
6082 }
6083 graphs.retain(|path, _graph| paths.binary_search(path).is_ok());
6084 Ok(graphs.into_values().collect())
6085}
6086
6087fn admit_symbol_graphs<'a>(
6089 graphs: Vec<Cow<'a, SymbolGraph>>,
6090 control: &IndexWorkControl,
6091) -> Result<(Vec<Cow<'a, SymbolGraph>>, GraphIdentityAdmission), CliError> {
6092 let mut admitted = Vec::with_capacity(graphs.len());
6093 let mut report = GraphIdentityAdmission::default();
6094 for (index, graph) in graphs.into_iter().enumerate() {
6095 check_graph_work(control, index)?;
6096 let (graph, graph_report) = admit_symbol_graph(graph, control)?;
6097 report.merge(graph_report, control)?;
6098 admitted.push(graph);
6099 }
6100 Ok((admitted, report))
6101}
6102
6103fn admit_symbol_graph<'a>(
6105 graph: Cow<'a, SymbolGraph>,
6106 control: &IndexWorkControl,
6107) -> Result<(Cow<'a, SymbolGraph>, GraphIdentityAdmission), CliError> {
6108 let mut report = GraphIdentityAdmission::default();
6109 let paired_import_relations = paired_import_relations(&graph, control)?;
6110 #[cfg(test)]
6111 {
6112 report.paired_import_pairing_work = paired_import_relations.work_items;
6113 }
6114 let mut rejected_symbols = vec![false; graph.symbols.len()];
6115 for (index, symbol) in graph.symbols.iter().enumerate() {
6116 check_graph_work(control, index)?;
6117 let paired_relation_index = paired_import_relations.by_symbol[index];
6118 let span = symbol_identity_span(&graph, index, paired_relation_index);
6119 let mut failures = Vec::new();
6120 let name_field = if symbol.kind == SymbolKind::Package {
6121 GraphIdentityField::Package
6122 } else {
6123 GraphIdentityField::Symbol
6124 };
6125 record_identity_failure(&mut failures, name_field, &symbol.name);
6126 if let Some(parent) = symbol.parent.as_deref() {
6127 record_identity_failure(&mut failures, GraphIdentityField::Parent, parent);
6128 }
6129 let signature = if symbol.signature.is_empty() {
6130 &symbol.name
6131 } else {
6132 &symbol.signature
6133 };
6134 record_identity_failure(&mut failures, GraphIdentityField::Signature, signature);
6135 if !failures.is_empty() {
6136 rejected_symbols[index] = true;
6137 report.record(
6138 &graph.path,
6139 span,
6140 symbol.parser,
6141 symbol_parser_fact_index(index, paired_relation_index),
6142 &failures,
6143 control,
6144 )?;
6145 }
6146 }
6147 let mut rejected_relations = vec![false; graph.relations.len()];
6148 for (index, relation) in graph.relations.iter().enumerate() {
6149 check_graph_work(control, index)?;
6150 let span = IdentitySpan {
6151 start_line: relation.line.max(1),
6152 start_column: 0,
6153 end_line: relation.line.max(1),
6154 end_column: 0,
6155 };
6156 let mut failures = Vec::new();
6157 record_identity_failure(
6158 &mut failures,
6159 GraphIdentityField::RelationSource,
6160 &relation.source_name,
6161 );
6162 record_identity_failure(
6163 &mut failures,
6164 GraphIdentityField::RelationTarget,
6165 &relation.target_name,
6166 );
6167 if !failures.is_empty() {
6168 rejected_relations[index] = true;
6169 report.record(
6170 &graph.path,
6171 span,
6172 relation.parser,
6173 parser_fact_index(RELATION_FACT_INDEX_NAMESPACE, index),
6174 &failures,
6175 control,
6176 )?;
6177 }
6178 }
6179 if report.rejected_facts_by_path.is_empty() {
6180 return Ok((graph, report));
6181 }
6182 let mut graph = graph.into_owned();
6183 graph.symbols = graph
6184 .symbols
6185 .into_iter()
6186 .enumerate()
6187 .filter_map(|(index, symbol)| (!rejected_symbols[index]).then_some(symbol))
6188 .collect();
6189 let mut relation_fact_indices = Vec::with_capacity(graph.relations.len());
6190 graph.relations = graph
6191 .relations
6192 .into_iter()
6193 .enumerate()
6194 .filter_map(|(index, relation)| {
6195 (!rejected_relations[index]).then(|| {
6196 relation_fact_indices.push(index);
6197 relation
6198 })
6199 })
6200 .collect();
6201 if rejected_relations.iter().any(|rejected| *rejected) && !relation_fact_indices.is_empty() {
6202 report
6203 .relation_fact_indices
6204 .insert(graph.path.clone(), relation_fact_indices);
6205 }
6206 Ok((Cow::Owned(graph), report))
6207}
6208
6209pub(super) fn admit_symbol_build_stage(
6211 staged: &mut SymbolBuildStage,
6212 control: &IndexWorkControl,
6213) -> Result<GraphIdentityAdmission, CliError> {
6214 let mut report = GraphIdentityAdmission::default();
6215 for (index, change) in staged.changes.iter_mut().enumerate() {
6216 check_graph_work(control, index)?;
6217 let SymbolProjectionChange::Parsed(parsed) = change else {
6218 continue;
6219 };
6220 let placeholder = SymbolGraph {
6221 path: parsed.path.clone(),
6222 language: None,
6223 parser: parsed.source_parser,
6224 symbols: Vec::new(),
6225 relations: Vec::new(),
6226 };
6227 let graph = std::mem::replace(&mut parsed.graph, placeholder);
6228 let (admitted, graph_report) = admit_symbol_graph(Cow::Owned(graph), control)?;
6229 parsed.graph = admitted.into_owned();
6230 if let Some(markdown) = parsed.markdown_facts.as_deref_mut() {
6231 admit_markdown_fact_batch(
6232 &parsed.path,
6233 parsed.source_parser,
6234 markdown,
6235 &mut report,
6236 control,
6237 )?;
6238 }
6239 if graph_report.has_rejections() {
6240 parsed.summary = super::summarize_symbol_graph(&parsed.graph, None);
6243 parsed.summary_is_structural = false;
6244 if parsed.purpose_suggestion.is_some() {
6245 parsed.purpose_suggestion =
6246 Some(super::suggest_file_purpose(&parsed.path, &parsed.summary));
6247 }
6248 }
6249 report.merge(graph_report, control)?;
6250 }
6251 let mut symbols = 0_usize;
6252 let mut relations = 0_usize;
6253 for change in &staged.changes {
6254 let SymbolProjectionChange::Parsed(parsed) = change else {
6255 continue;
6256 };
6257 symbols = symbols
6258 .checked_add(parsed.graph.symbols.len())
6259 .ok_or_else(|| {
6260 CliError::InvalidInput("admitted symbol report count overflowed".to_string())
6261 })?;
6262 relations = relations
6263 .checked_add(parsed.graph.relations.len())
6264 .ok_or_else(|| {
6265 CliError::InvalidInput("admitted relation report count overflowed".to_string())
6266 })?;
6267 }
6268 staged.report.symbols = symbols;
6269 staged.report.relations = relations;
6270 report.source_admitted = true;
6271 Ok(report)
6272}
6273
6274fn record_identity_failure(
6276 failures: &mut Vec<(GraphIdentityField, GraphIdentityRejectionReason)>,
6277 field: GraphIdentityField,
6278 value: &str,
6279) {
6280 if let Err(error) = source_symbol_identity_error(value) {
6281 failures.push((field, GraphIdentityRejectionReason::from_error(&error)));
6282 }
6283}
6284
6285fn source_symbol_identity_error(value: &str) -> Result<(), GraphContractError> {
6287 GraphIdentityText::validate(value)?;
6288 if value.starts_with(QUALIFIED_SYMBOL_SCOPE_PREFIX) {
6289 return Err(GraphContractError::InvalidIdentityText {
6290 reason: "source symbol identity uses the reserved derived-scope namespace",
6291 });
6292 }
6293 Ok(())
6294}
6295
6296fn admit_resolution_key_failures(
6298 project: ProjectInstanceId,
6299 generation: IndexGeneration,
6300 graphs: &[impl Borrow<SymbolGraph>],
6301 packages: &PackageIndex,
6302 configured_modules: &ConfiguredModuleResolution,
6303 report: &mut GraphIdentityAdmission,
6304 control: &IndexWorkControl,
6305) -> Result<(), CliError> {
6306 for (index, graph) in graphs.iter().enumerate() {
6307 let graph = graph.borrow();
6308 check_graph_work(control, index)?;
6309 #[cfg(not(test))]
6310 let _ = generation;
6311 if report.resolution_projections.contains_key(&graph.path) {
6312 return Err(CliError::InvalidInput(
6313 "duplicate resolution projection admission".to_string(),
6314 ));
6315 }
6316 #[cfg(test)]
6317 report.record_resolution_derivation(&graph.path, generation);
6318 let context = ResolutionProjectionContext::with_configured_modules(configured_modules);
6319 match derive_resolution_keys_with_context(
6320 project,
6321 packages.package_name(&graph.path),
6322 graph,
6323 context,
6324 ) {
6325 Ok(projection) => {
6326 report
6327 .resolution_projections
6328 .insert(graph.path.clone(), projection);
6329 }
6330 Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
6331 return Err(resolution_key_limit_failure(requested));
6332 }
6333 Err(ResolutionProjectionError::Contract(failure)) => {
6334 let (failures, rejected_count, projection) = (*failure).into_parts();
6335 for failure in &failures {
6336 report.record(
6337 &graph.path,
6338 resolution_projection_span(graph, failure.fact()),
6339 graph.parser,
6340 resolution_projection_fact_index(report, &graph.path, failure.fact()),
6341 &[(
6342 GraphIdentityField::ResolutionKey,
6343 GraphIdentityRejectionReason::from_error(failure.error()),
6344 )],
6345 control,
6346 )?;
6347 }
6348 report.record_rejected_fact_count(
6349 &graph.path,
6350 rejected_count.saturating_sub(failures.len()),
6351 control,
6352 )?;
6353 report
6354 .resolution_projections
6355 .insert(graph.path.clone(), projection);
6356 }
6357 }
6358 }
6359 Ok(())
6360}
6361
6362fn ensure_admitted_resolution_projections(
6364 graphs: &[impl Borrow<SymbolGraph>],
6365 projections: &BTreeMap<String, ResolutionKeyProjection>,
6366) -> Result<(), CliError> {
6367 if projections.len() != graphs.len()
6368 || graphs
6369 .iter()
6370 .any(|graph| !projections.contains_key(&graph.borrow().path))
6371 {
6372 return Err(CliError::InvalidInput(
6373 "resolution projection admission did not match staged graphs".to_string(),
6374 ));
6375 }
6376 Ok(())
6377}
6378
6379fn resolution_projection_fact_index(
6381 report: &GraphIdentityAdmission,
6382 path: &str,
6383 fact: ResolutionProjectionFact,
6384) -> u64 {
6385 match fact {
6386 ResolutionProjectionFact::Source => 0,
6387 ResolutionProjectionFact::Symbol(index) => {
6388 parser_fact_index(SYMBOL_FACT_INDEX_NAMESPACE, index)
6389 }
6390 ResolutionProjectionFact::Relation(index) => parser_fact_index(
6391 RELATION_FACT_INDEX_NAMESPACE,
6392 report.relation_parser_index(path, index),
6393 ),
6394 }
6395}
6396
6397fn resolution_projection_span(graph: &SymbolGraph, fact: ResolutionProjectionFact) -> IdentitySpan {
6399 match fact {
6400 ResolutionProjectionFact::Source => graph_identity_span(graph),
6401 ResolutionProjectionFact::Symbol(index) => graph.symbols.get(index).map_or_else(
6402 || graph_identity_span(graph),
6403 |symbol| IdentitySpan {
6404 start_line: symbol.line_start.max(1),
6405 start_column: 0,
6406 end_line: symbol.line_end.max(symbol.line_start).max(1),
6407 end_column: 0,
6408 },
6409 ),
6410 ResolutionProjectionFact::Relation(index) => graph.relations.get(index).map_or_else(
6411 || graph_identity_span(graph),
6412 |relation| IdentitySpan {
6413 start_line: relation.line.max(1),
6414 start_column: 0,
6415 end_line: relation.line.max(1),
6416 end_column: 0,
6417 },
6418 ),
6419 }
6420}
6421
6422fn graph_identity_span(graph: &SymbolGraph) -> IdentitySpan {
6424 let mut start_line = usize::MAX;
6425 let mut end_line = 1;
6426 for symbol in &graph.symbols {
6427 start_line = start_line.min(symbol.line_start.max(1));
6428 end_line = end_line.max(symbol.line_end.max(symbol.line_start).max(1));
6429 }
6430 for relation in &graph.relations {
6431 start_line = start_line.min(relation.line.max(1));
6432 end_line = end_line.max(relation.line.max(1));
6433 }
6434 IdentitySpan {
6435 start_line: if start_line == usize::MAX {
6436 1
6437 } else {
6438 start_line
6439 },
6440 start_column: 0,
6441 end_line,
6442 end_column: 0,
6443 }
6444}
6445
6446fn admit_markdown_facts(
6448 facts: &mut BTreeMap<String, Cow<'_, MarkdownFacts>>,
6449 graphs: &[impl Borrow<SymbolGraph>],
6450 report: &mut GraphIdentityAdmission,
6451 control: &IndexWorkControl,
6452) -> Result<(), CliError> {
6453 for (graph_index, graph) in graphs.iter().enumerate() {
6454 check_graph_work(control, graph_index)?;
6455 let graph = graph.borrow();
6456 let Some(markdown) = facts.get_mut(&graph.path) else {
6457 continue;
6458 };
6459 if !markdown
6460 .link_candidates
6461 .iter()
6462 .any(|candidate| GraphIdentityText::validate(&candidate.selector).is_err())
6463 {
6464 continue;
6465 }
6466 admit_markdown_fact_batch(
6467 &graph.path,
6468 graph.parser,
6469 markdown.to_mut(),
6470 report,
6471 control,
6472 )?;
6473 }
6474 Ok(())
6475}
6476
6477fn admit_markdown_fact_batch(
6479 path: &str,
6480 parser: ParserKind,
6481 markdown: &mut MarkdownFacts,
6482 report: &mut GraphIdentityAdmission,
6483 control: &IndexWorkControl,
6484) -> Result<(), CliError> {
6485 let mut rejected = Vec::new();
6486 for (candidate_index, candidate) in markdown.link_candidates.iter().enumerate() {
6487 check_graph_work(control, candidate_index)?;
6488 if let Err(error) = GraphIdentityText::validate(&candidate.selector) {
6489 rejected.push((
6490 candidate_index,
6491 candidate.source.line_start,
6492 candidate.source.column_start,
6493 candidate.source.line_end,
6494 candidate.source.column_end,
6495 GraphIdentityRejectionReason::from_error(&error),
6496 ));
6497 }
6498 }
6499 for (candidate_index, start_line, start_column, end_line, end_column, reason) in rejected {
6500 report.record(
6501 path,
6502 IdentitySpan {
6503 start_line,
6504 start_column,
6505 end_line,
6506 end_column,
6507 },
6508 parser,
6509 parser_fact_index(MARKDOWN_FACT_INDEX_NAMESPACE, candidate_index),
6510 &[(GraphIdentityField::RelationTarget, reason)],
6511 control,
6512 )?;
6513 }
6514 markdown
6515 .link_candidates
6516 .retain(|candidate| GraphIdentityText::validate(&candidate.selector).is_ok());
6517 Ok(())
6518}
6519
6520fn complete_markdown_facts<'a>(
6522 root: &Path,
6523 nodes: &[Node],
6524 graphs: &[impl Borrow<SymbolGraph>],
6525 symbols: &'a SymbolBuildStage,
6526 control: &IndexWorkControl,
6527) -> Result<BTreeMap<String, Cow<'a, MarkdownFacts>>, CliError> {
6528 let graph_paths = graphs
6529 .iter()
6530 .map(|graph| graph.borrow().path.as_str())
6531 .collect::<BTreeSet<_>>();
6532 let nodes_by_path = nodes
6533 .iter()
6534 .filter(|node| node.kind == NodeKind::File)
6535 .map(|node| (node.path.as_str(), node))
6536 .collect::<BTreeMap<_, _>>();
6537 let mut facts = BTreeMap::new();
6538 for change in &symbols.changes {
6539 let SymbolProjectionChange::Parsed(parsed) = change else {
6540 continue;
6541 };
6542 if graph_paths.contains(parsed.path.as_str())
6543 && let Some(markdown) = parsed.markdown_facts.as_ref()
6544 {
6545 facts.insert(parsed.path.clone(), Cow::Borrowed(markdown.as_ref()));
6546 }
6547 }
6548 for graph in graphs {
6549 let graph = graph.borrow();
6550 control.check(IndexWorkStage::SymbolParsing)?;
6551 if facts.contains_key(&graph.path)
6552 || !graph
6553 .language
6554 .as_deref()
6555 .and_then(language_capability)
6556 .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
6557 {
6558 continue;
6559 }
6560 let node = nodes_by_path.get(graph.path.as_str()).ok_or_else(|| {
6561 CliError::InvalidInput(format!(
6562 "Markdown graph path was absent from the staged file inventory: {}",
6563 graph.path
6564 ))
6565 })?;
6566 let native_path = root.join(Path::new(&graph.path));
6567 let bytes = match read_source_bytes_controlled(
6568 &native_path,
6569 MAX_SYMBOL_FILE_BYTES,
6570 IndexWorkStage::SymbolParsing,
6571 control,
6572 ) {
6573 Ok(bytes) => bytes,
6574 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
6575 Err(SourceReadFailure::LimitExceeded { .. }) => {
6576 return Err(source_changed_during_derivation(root, &graph.path));
6577 }
6578 Err(SourceReadFailure::Io(source)) => {
6579 return Err(CliError::Io {
6580 path: native_path,
6581 source,
6582 });
6583 }
6584 };
6585 if node
6586 .content_hash
6587 .as_deref()
6588 .is_none_or(|expected| blake3::hash(&bytes).to_hex().as_str() != expected)
6589 {
6590 return Err(source_changed_during_derivation(root, &graph.path));
6591 }
6592 let content = String::from_utf8(bytes)
6593 .map_err(|_source| source_changed_during_derivation(root, &graph.path))?;
6594 facts.insert(
6595 graph.path.clone(),
6596 Cow::Owned(extract_markdown_facts_controlled(&content, control)?),
6597 );
6598 }
6599 enforce_resolution_registry_budget(document_fact_map_retained_bytes(&facts))?;
6600 Ok(facts)
6601}
6602
6603fn document_fact_map_retained_bytes(facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>) -> u64 {
6605 facts.iter().fold(0_u64, |bytes, (path, facts)| {
6606 let bytes = bytes
6607 .saturating_add(STAGED_GRAPH_ROW_BYTES)
6608 .saturating_add(path.len() as u64);
6609 if !matches!(facts, Cow::Owned(_)) {
6610 return bytes;
6611 }
6612 let heading_bytes = facts.headings.iter().fold(0_u64, |bytes, heading| {
6613 bytes
6614 .saturating_add(STAGED_GRAPH_ROW_BYTES)
6615 .saturating_add(heading.text.len() as u64)
6616 .saturating_add(heading.slug.len() as u64)
6617 });
6618 facts.link_candidates.iter().fold(
6619 bytes.saturating_add(heading_bytes),
6620 |bytes, candidate| {
6621 bytes
6622 .saturating_add(STAGED_GRAPH_ROW_BYTES)
6623 .saturating_add(candidate.selector.len() as u64)
6624 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6625 .saturating_add(
6626 candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64
6627 )
6628 },
6629 )
6630 })
6631}
6632
6633fn document_projection_retained_bytes(
6640 facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
6641 control: &IndexWorkControl,
6642) -> Result<u64, CliError> {
6643 let mut retained_bytes = 0_u64;
6644 let mut candidate_index = 0_usize;
6645 for (document_path, facts) in facts {
6646 for candidate in &facts.link_candidates {
6647 check_graph_work(control, candidate_index)?;
6648 candidate_index = candidate_index.saturating_add(1);
6649 if normalize_document_target(document_path, &candidate.selector).is_ok_and(|target| {
6650 target.path == document_path.as_str() && target.fragment.is_none()
6651 }) {
6652 continue;
6653 }
6654 retained_bytes = retained_bytes
6655 .saturating_add(DOCUMENT_PROJECTION_ROW_BYTES)
6656 .saturating_add(candidate.selector.len() as u64)
6657 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6658 .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
6659 }
6660 }
6661 Ok(retained_bytes)
6662}
6663
6664#[derive(Clone, Debug, Eq, PartialEq)]
6666struct DocumentTargetIdentity {
6667 path: String,
6669 fragment: Option<String>,
6671}
6672
6673fn normalize_document_target(
6675 document_path: &str,
6676 selector: &str,
6677) -> Result<DocumentTargetIdentity, DocumentTargetUnresolvedReason> {
6678 let (path_and_query, fragment) = selector
6679 .split_once('#')
6680 .map_or((selector, None), |(path, fragment)| (path, Some(fragment)));
6681 let path = path_and_query
6682 .split_once('?')
6683 .map_or(path_and_query, |(path, _query)| path);
6684 let path = strip_document_line_selector(path);
6685 if path.is_empty() {
6686 return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
6687 }
6688 let mut components = document_path
6689 .rsplit_once('/')
6690 .map_or_else(Vec::new, |(parent, _file)| {
6691 parent.split('/').map(str::to_owned).collect::<Vec<_>>()
6692 });
6693 for component in path.split('/') {
6694 match component {
6695 "" => return Err(DocumentTargetUnresolvedReason::Unsupported),
6696 "." => {}
6697 ".." => {
6698 if components.pop().is_none() {
6699 return Err(DocumentTargetUnresolvedReason::OutsideRoot);
6700 }
6701 }
6702 value if value.contains(':') => {
6703 return Err(DocumentTargetUnresolvedReason::Unsupported);
6704 }
6705 value => components.push(value.to_owned()),
6706 }
6707 }
6708 if components.is_empty() {
6709 return Err(DocumentTargetUnresolvedReason::NoStaticTarget);
6710 }
6711 let fragment = match fragment.map(str::trim) {
6712 None => None,
6713 Some(value)
6714 if !value.is_empty()
6715 && !value.contains(['/', '\\', '?', '#', '{', '}', '<', '>', '|', '*', '$'])
6716 && !value.chars().any(char::is_whitespace) =>
6717 {
6718 Some(value.to_lowercase())
6719 }
6720 Some(_value) => return Err(DocumentTargetUnresolvedReason::NoStaticTarget),
6721 };
6722 Ok(DocumentTargetIdentity {
6723 path: components.join("/"),
6724 fragment,
6725 })
6726}
6727
6728fn strip_document_line_selector(path: &str) -> &str {
6730 let Some((identity, selector)) = path.rsplit_once(':') else {
6731 return path;
6732 };
6733 let selector = selector.strip_prefix('L').unwrap_or(selector);
6734 let valid = selector.split_once('-').map_or_else(
6735 || !selector.is_empty() && selector.chars().all(|character| character.is_ascii_digit()),
6736 |(start, end)| {
6737 !start.is_empty()
6738 && !end.is_empty()
6739 && start.chars().all(|character| character.is_ascii_digit())
6740 && end
6741 .strip_prefix('L')
6742 .unwrap_or(end)
6743 .chars()
6744 .all(|character| character.is_ascii_digit())
6745 },
6746 );
6747 if valid { identity } else { path }
6748}
6749
6750fn document_file_resolution_key(
6752 project: ProjectInstanceId,
6753 path: &str,
6754) -> Result<CanonicalResolutionKey, CliError> {
6755 document_resolution_key(project, ResolutionKeyDomain::Module, path)
6756}
6757
6758fn document_casefold_resolution_key(
6760 project: ProjectInstanceId,
6761 path: &str,
6762) -> Result<CanonicalResolutionKey, CliError> {
6763 document_resolution_key_with_language(
6764 project,
6765 ResolutionKeyDomain::Module,
6766 DOCUMENT_CASEFOLD_LANGUAGE,
6767 &path.to_lowercase(),
6768 )
6769}
6770
6771fn document_heading_resolution_key(
6773 project: ProjectInstanceId,
6774 path: &str,
6775 fragment: &str,
6776) -> Result<CanonicalResolutionKey, CliError> {
6777 document_resolution_key(
6778 project,
6779 ResolutionKeyDomain::Declaration,
6780 &format!("{path}#{fragment}"),
6781 )
6782}
6783
6784fn document_resolution_key(
6786 project: ProjectInstanceId,
6787 domain: ResolutionKeyDomain,
6788 identity: &str,
6789) -> Result<CanonicalResolutionKey, CliError> {
6790 document_resolution_key_with_language(project, domain, DOCUMENT_PATH_LANGUAGE, identity)
6791}
6792
6793fn document_resolution_key_with_language(
6795 project: ProjectInstanceId,
6796 domain: ResolutionKeyDomain,
6797 resolver_language: &str,
6798 identity: &str,
6799) -> Result<CanonicalResolutionKey, CliError> {
6800 let provider =
6801 GraphIdentityText::new(DOCUMENT_PATH_PROVIDER).map_err(invalid_graph_contract)?;
6802 let language = GraphIdentityText::new(resolver_language).map_err(invalid_graph_contract)?;
6803 let identity = GraphIdentityText::new(identity).map_err(invalid_graph_contract)?;
6804 Ok(CanonicalResolutionKey::new(
6805 project,
6806 domain,
6807 &provider,
6808 &language,
6809 None,
6810 None,
6811 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
6812 &identity,
6813 ))
6814}
6815
6816fn document_dependency_keys(
6818 project: ProjectInstanceId,
6819 facts: &BTreeMap<String, Cow<'_, MarkdownFacts>>,
6820) -> Result<BTreeSet<CanonicalResolutionKey>, CliError> {
6821 let mut keys = BTreeSet::new();
6822 for (document_path, facts) in facts {
6823 for candidate in &facts.link_candidates {
6824 let Ok(target) = normalize_document_target(document_path, &candidate.selector) else {
6825 continue;
6826 };
6827 keys.insert(document_file_resolution_key(project, &target.path)?);
6828 keys.insert(document_casefold_resolution_key(project, &target.path)?);
6829 if let Some(fragment) = target.fragment {
6830 keys.insert(document_heading_resolution_key(
6831 project,
6832 &target.path,
6833 &fragment,
6834 )?);
6835 }
6836 }
6837 }
6838 Ok(keys)
6839}
6840
6841fn resolution_projection_with_config(
6843 project: ProjectInstanceId,
6844 package: Option<&str>,
6845 graph: &SymbolGraph,
6846 configured_modules: &ConfiguredModuleResolution,
6847) -> Result<ResolutionKeyProjection, CliError> {
6848 let context = ResolutionProjectionContext::with_configured_modules(configured_modules);
6849 match derive_resolution_keys_with_context(project, package, graph, context) {
6850 Ok(projection) => Ok(projection),
6851 Err(ResolutionProjectionError::KeyLimit { requested, .. }) => {
6852 Err(resolution_key_limit_failure(requested))
6853 }
6854 Err(ResolutionProjectionError::Contract(failure)) => {
6855 let (mut failures, _rejected_count, _projection) = (*failure).into_parts();
6856 let Some(failure) = failures.pop() else {
6857 return Err(CliError::InvalidInput(
6858 "resolution projection reported an empty contract failure".to_string(),
6859 ));
6860 };
6861 let (_fact, error) = failure.into_parts();
6862 Err(invalid_graph_contract(error))
6863 }
6864 }
6865}
6866
6867fn resolution_key_limit_failure(requested: usize) -> CliError {
6869 IndexWorkFailure::resource_limit(
6870 IndexWorkStage::SymbolParsing,
6871 IndexWorkResource::RelationRows,
6872 u64::try_from(MAX_RESOLUTION_KEYS_PER_FACT).unwrap_or(u64::MAX),
6873 u64::try_from(requested).unwrap_or(u64::MAX),
6874 )
6875 .into()
6876}
6877
6878fn selected_project(store: &AtlasStore) -> Result<ProjectInstanceId, CliError> {
6880 store.project_instance_id()?.ok_or_else(|| {
6881 CliError::InvalidInput("repository graph requires a bound project identity".to_string())
6882 })
6883}
6884
6885fn next_generation(base: IndexGeneration) -> Result<IndexGeneration, CliError> {
6887 base.checked_next().ok_or_else(|| {
6888 CliError::InvalidInput("repository graph generation is exhausted".to_string())
6889 })
6890}
6891
6892fn insert_entity(
6894 entities: &mut BTreeMap<String, GraphEntity>,
6895 entity: GraphEntity,
6896) -> Result<(), CliError> {
6897 let digest = entity.key().digest().to_string();
6898 if let Some(existing) = entities.get(&digest) {
6899 if !existing
6900 .key()
6901 .reconcile(entity.key())
6902 .map_err(invalid_graph_contract)?
6903 {
6904 return Err(CliError::InvalidInput(
6905 "graph entity digest retained conflicting ownership".to_string(),
6906 ));
6907 }
6908 return Ok(());
6909 }
6910 entities.insert(digest, entity);
6911 Ok(())
6912}
6913
6914fn sort_dedup_exports(exports: &mut Vec<EntityResolutionKey>) {
6916 exports.sort_by(|left, right| {
6917 left.key()
6918 .cmp(right.key())
6919 .then_with(|| left.entity().digest().cmp(right.entity().digest()))
6920 });
6921 exports.dedup();
6922}
6923
6924fn sort_dedup_dependencies(dependencies: &mut Vec<RelationDependencyKey>) {
6926 dependencies.sort_by(|left, right| {
6927 left.key()
6928 .cmp(right.key())
6929 .then_with(|| left.relation().digest().cmp(right.relation().digest()))
6930 });
6931 dependencies.dedup();
6932}
6933
6934fn enforce_key_binding_limit(count: usize) -> Result<(), CliError> {
6936 let observed = u64::try_from(count).unwrap_or(u64::MAX);
6937 if observed > MAX_GRAPH_KEY_BINDINGS {
6938 return Err(IndexWorkFailure::resource_limit(
6939 IndexWorkStage::SymbolParsing,
6940 IndexWorkResource::RelationRows,
6941 MAX_GRAPH_KEY_BINDINGS,
6942 observed,
6943 )
6944 .into());
6945 }
6946 Ok(())
6947}
6948
6949fn enforce_incremental_count<T>(
6951 root: &Path,
6952 _context: &'static str,
6953 count: usize,
6954 sample: &BTreeSet<T>,
6955) -> Result<(), CliError>
6956where
6957 T: ToString + Ord,
6958{
6959 if count > MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
6960 return Err(dependency_closure_limit(
6961 root,
6962 sample.iter().map(ToString::to_string),
6963 count,
6964 ));
6965 }
6966 Ok(())
6967}
6968
6969fn admitted_persisted_footprint(
6971 store: &AtlasStore,
6972 project: ProjectInstanceId,
6973 root: &Path,
6974 affected_paths: &BTreeSet<String>,
6975 control: &IndexWorkControl,
6976) -> Result<RepositoryAffectedSourceFootprint, CliError> {
6977 control.check(IndexWorkStage::SymbolParsing)?;
6978 let footprint = store.repository_affected_source_footprint(
6979 project,
6980 &affected_paths.iter().cloned().collect::<Vec<_>>(),
6981 u32::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(u32::MAX),
6982 )?;
6983 control.check(IndexWorkStage::SymbolParsing)?;
6984 if footprint.truncated {
6985 return Err(dependency_closure_limit(
6986 root,
6987 affected_paths.iter().cloned(),
6988 usize::try_from(footprint.rows).unwrap_or(usize::MAX),
6989 ));
6990 }
6991 enforce_incremental_projection_budget(
6992 root,
6993 affected_paths,
6994 footprint.rows,
6995 footprint.retained_bytes,
6996 )?;
6997 Ok(footprint)
6998}
6999
7000fn enforce_incremental_projection_limits(
7002 root: &Path,
7003 affected_paths: &BTreeSet<String>,
7004 persisted: RepositoryAffectedSourceFootprint,
7005 staged: &StagedRepositoryGraph,
7006) -> Result<(), CliError> {
7007 let staged_rows = [
7008 affected_paths.len(),
7009 staged.entities.len(),
7010 staged.relations.len(),
7011 staged.occurrences.len(),
7012 staged.coverage.len(),
7013 staged.entity_exports.len(),
7014 staged.relation_dependencies.len(),
7015 staged.document_unresolved_reasons.len(),
7016 ]
7017 .into_iter()
7018 .fold(0_u64, |total, count| {
7019 total.saturating_add(u64::try_from(count).unwrap_or(u64::MAX))
7020 });
7021 enforce_incremental_projection_budget(
7022 root,
7023 affected_paths,
7024 persisted.rows.saturating_add(staged_rows),
7025 persisted
7026 .retained_bytes
7027 .saturating_add(staged.retained_bytes),
7028 )
7029}
7030
7031fn enforce_incremental_projection_budget(
7033 root: &Path,
7034 affected_paths: &BTreeSet<String>,
7035 rows: u64,
7036 retained_bytes: u64,
7037) -> Result<(), CliError> {
7038 if rows > MAX_INCREMENTAL_GRAPH_ROWS || retained_bytes > MAX_INCREMENTAL_GRAPH_BYTES {
7039 return Err(dependency_closure_limit(
7040 root,
7041 affected_paths.iter().cloned(),
7042 affected_paths.len(),
7043 ));
7044 }
7045 Ok(())
7046}
7047
7048fn dependency_closure_limit(
7050 root: &Path,
7051 sample: impl IntoIterator<Item = String>,
7052 observed: usize,
7053) -> CliError {
7054 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7055 project_root: lossless_project_root_display(root),
7056 worktree: None,
7057 status: IndexReadStatus::RefreshRequired,
7058 reason: IndexRefreshReason::DependencyClosureLimit,
7059 scope: IndexRefreshScope::Full,
7060 changed: observed,
7061 added: 0,
7062 removed: 0,
7063 modified: observed,
7064 sample_paths: sample
7065 .into_iter()
7066 .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
7067 .collect(),
7068 }))
7069}
7070
7071fn invalid_graph_contract(error: GraphContractError) -> CliError {
7073 CliError::from(error)
7074}
7075
7076impl From<GraphContractError> for CliError {
7077 fn from(error: GraphContractError) -> Self {
7078 Self::InvalidInput(format!("repository graph projection failed: {error}"))
7079 }
7080}
7081
7082fn resolution_retained_bytes(projection: &ResolutionKeyProjection) -> u64 {
7084 projection
7085 .source_keys()
7086 .iter()
7087 .chain(
7088 projection
7089 .symbol_keys()
7090 .iter()
7091 .flat_map(projectatlas_symbols::SymbolResolutionKeys::keys),
7092 )
7093 .chain(
7094 projection
7095 .relation_keys()
7096 .iter()
7097 .flat_map(projectatlas_symbols::RelationResolutionKeys::keys),
7098 )
7099 .fold(0_u64, |bytes, key| {
7100 bytes
7101 .saturating_add(32)
7102 .saturating_add(key.canonical_identity().len() as u64)
7103 })
7104}
7105
7106fn resolution_projection_map_entry_retained_bytes(
7108 path: &str,
7109 projection: &ResolutionKeyProjection,
7110) -> u64 {
7111 STAGED_GRAPH_ROW_BYTES
7112 .saturating_add(path.len() as u64)
7113 .saturating_add(resolution_retained_bytes(projection))
7114}
7115
7116fn resolution_projection_map_retained_bytes(
7118 projections: &BTreeMap<String, ResolutionKeyProjection>,
7119) -> u64 {
7120 projections.iter().fold(0_u64, |bytes, (path, projection)| {
7121 bytes.saturating_add(resolution_projection_map_entry_retained_bytes(
7122 path, projection,
7123 ))
7124 })
7125}
7126
7127#[cfg(test)]
7128mod tests {
7129 use super::{
7130 CliError, DOCUMENT_PROJECTION_ROW_BYTES, DocumentResolutionIndex, DocumentTargetIdentity,
7131 GRAPH_STAGE_DATABASE_FILE_NAME, GRAPH_STAGE_DIRECTORY_PREFIX, GraphIdentityAdmission,
7132 GraphOwners, GraphSymbolIndex, MAX_IN_MEMORY_GRAPH_WORK_BYTES, MAX_INCREMENTAL_GRAPH_BYTES,
7133 MAX_INCREMENTAL_GRAPH_ROWS, PARTIAL_COVERAGE_REASON, PackageIndex,
7134 ProjectResolutionRegistry, QUALIFIED_SYMBOL_SCOPE_PREFIX, RepositoryGraphMutation,
7135 StagedRepositoryGraph, build_entity_projection, build_entity_projection_with_config,
7136 build_entity_projection_with_config_limit, cleanup_abandoned_graph_staging,
7137 coverage_for_graph, document_casefold_resolution_key, document_coverage,
7138 document_fact_map_retained_bytes, document_projection_retained_bytes,
7139 enforce_incremental_projection_budget, enforce_incremental_projection_limits,
7140 enforce_resolution_staging_budget, explicit_external_selector, finish_projection,
7141 finish_projection_in_database, finish_projection_in_database_with_documents,
7142 finish_projection_with_documents, identity_rejection_keys_retained_bytes, insert_relation,
7143 is_cargo_manifest_path, normalize_document_target, project_document_rows,
7144 qualified_symbol_identity, qualified_symbol_parents, registry_resolution_matches,
7145 relation_resolution, remove_owned_graph_stage_payload, repository_path_belongs_to,
7146 resolution_projection_map_retained_bytes, resolution_registry_from_exports,
7147 rust_toolchain_identity, source_symbol_identity, stage_full_repository_graph,
7148 stage_incremental_repository_graph, stage_incremental_repository_graph_with_test_limit,
7149 try_graph_stage_lease,
7150 };
7151 use crate::runtime::{
7152 IndexRefreshReason, IndexRefreshScope, SymbolBuildReport, SymbolBuildStage,
7153 SymbolParseSuccess, SymbolProjectionChange,
7154 };
7155 use projectatlas_core::graph::{
7156 CanonicalResolutionKey, Completeness, ConfidenceClass, CoverageRecord, CoverageScope,
7157 CoverageState, DocumentTargetUnresolvedReason, EntityResolutionKey, EntitySelector,
7158 ExtendedRelationKind, GraphEntity, GraphIdentityField, GraphIdentityRejectionReason,
7159 GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind, LogicalRelation,
7160 MAX_GRAPH_IDENTITY_BYTES, PackageSelector, ProjectInstanceId, RelationDependencyKey,
7161 RelationResolution, RepositoryFilePath, RepositoryNodePath, ResolutionKeyDomain,
7162 ReusableTargetSelector, SymbolSelector,
7163 };
7164 use projectatlas_core::relation_capabilities::{
7165 RELATION_FAMILY_CAPABILITIES, RelationFamilyState,
7166 };
7167 use projectatlas_core::symbols::{
7168 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
7169 SymbolRelation,
7170 };
7171 use projectatlas_core::{
7172 IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
7173 IndexWorkStage, Node, NodeKind,
7174 };
7175 use projectatlas_db::{
7176 AtlasStore, RepositoryAffectedSourceFootprint, RepositoryGraphRelationQuery,
7177 };
7178 use projectatlas_fs::{RootScanPolicy, ScanOptions};
7179 use projectatlas_symbols::extract_symbol_graph;
7180 use projectatlas_symbols::{
7181 ConfiguredModuleResolution, EcmaScriptConfigKind, EcmaScriptModuleConfig,
7182 EcmaScriptPathMapping, MAX_DOCUMENT_LINK_CANDIDATES, MAX_DOCUMENT_SELECTOR_BYTES,
7183 MAX_MARKDOWN_EVIDENCE_BYTES, MAX_MARKDOWN_LABEL_BYTES, MarkdownFactLimit,
7184 };
7185 use rusqlite::Connection;
7186 use std::borrow::Cow;
7187 use std::collections::{BTreeMap, BTreeSet};
7188 use std::error::Error;
7189 use std::fmt::Debug;
7190 use std::fs;
7191 use std::io;
7192 use std::num::NonZeroU32;
7193 use std::path::Path;
7194 use std::thread;
7195 use std::time::{Duration, Instant};
7196
7197 #[test]
7198 fn paired_multiline_import_rejection_is_one_parser_fact() -> Result<(), Box<dyn Error>> {
7199 let graph = extract_symbol_graph(
7200 "src/page.ts",
7201 Some("typescript"),
7202 "import {\n helper\n} from './LeakedIdentity\u{0}module';\n",
7203 );
7204 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7205 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
7206 require_eq(
7207 &report.rejected_facts_for("src/page.ts"),
7208 &1,
7209 "paired multiline import omission count",
7210 )?;
7211 require_eq(
7212 &report.rejections.len(),
7213 &3,
7214 "paired multiline import typed detail count",
7215 )?;
7216 require(
7217 report.rejections.iter().all(|rejection| {
7218 rejection.fact_index == report.rejections[0].fact_index
7219 && rejection.span.start_line() == 1
7220 && rejection.span.end_line() == 1
7221 }),
7222 "paired multiline import details did not share one parser span",
7223 )?;
7224 for field in [
7225 GraphIdentityField::RelationTarget,
7226 GraphIdentityField::Signature,
7227 GraphIdentityField::Symbol,
7228 ] {
7229 require(
7230 report
7231 .rejections
7232 .iter()
7233 .any(|rejection| rejection.field == field),
7234 "paired multiline import lost a typed invalid field",
7235 )?;
7236 }
7237 require(
7238 admitted.symbols.is_empty() && admitted.relations.is_empty(),
7239 "paired multiline import retained an invalid parser fact",
7240 )?;
7241 Ok(())
7242 }
7243
7244 #[test]
7245 fn paired_import_admission_scans_scale_bound_once_and_deduplicates_replay()
7246 -> Result<(), Box<dyn Error>> {
7247 const IMPORT_SYMBOLS: usize = 4_000;
7248 const IMPORT_RELATIONS: usize = 8_000;
7249 const PAIRING_WORK_ITEMS: usize = IMPORT_SYMBOLS + IMPORT_RELATIONS;
7250 let graph = |path: &str, invalid: bool| SymbolGraph {
7251 path: path.to_string(),
7252 language: Some("typescript".to_string()),
7253 parser: ParserKind::TreeSitter,
7254 symbols: (0..IMPORT_SYMBOLS)
7255 .map(|index| {
7256 let name = if invalid {
7257 format!("invalid\0import-{index}")
7258 } else {
7259 format!("import-{index}")
7260 };
7261 CodeSymbol {
7262 path: path.to_string(),
7263 language: Some("typescript".to_string()),
7264 name: name.clone(),
7265 kind: SymbolKind::Import,
7266 signature: name,
7267 exported: false,
7268 documentation: None,
7269 line_start: 1,
7270 line_end: 2,
7271 source_selector: None,
7272 parent: None,
7273 parser: ParserKind::TreeSitter,
7274 detail: Some("import_statement".to_string()),
7275 }
7276 })
7277 .collect(),
7278 relations: (0..IMPORT_RELATIONS)
7279 .map(|index| SymbolRelation {
7280 path: path.to_string(),
7281 source_name: path.to_string(),
7282 target_name: if invalid {
7283 format!("invalid\0module-{index}")
7284 } else {
7285 format!("module-{index}")
7286 },
7287 kind: RelationKind::Imports,
7288 line: 1,
7289 context: "import".to_string(),
7290 parser: ParserKind::TreeSitter,
7291 })
7292 .collect(),
7293 };
7294 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7295
7296 let (valid, valid_report) = super::admit_symbol_graph(
7297 Cow::Owned(graph("src/import-scale-valid.ts", false)),
7298 &control,
7299 )?;
7300 require_eq(
7301 &valid_report.paired_import_pairing_work,
7302 &PAIRING_WORK_ITEMS,
7303 "valid import pairing work",
7304 )?;
7305 require_eq(
7306 &valid.symbols.len(),
7307 &IMPORT_SYMBOLS,
7308 "valid import symbol count",
7309 )?;
7310 require_eq(
7311 &valid.relations.len(),
7312 &IMPORT_RELATIONS,
7313 "valid import relation count",
7314 )?;
7315 require_eq(
7316 &valid_report.rejected_facts_for("src/import-scale-valid.ts"),
7317 &0,
7318 "valid import rejection count",
7319 )?;
7320
7321 let invalid_graph = graph("src/import-scale-invalid.ts", true);
7322 let (invalid, mut invalid_report) =
7323 super::admit_symbol_graph(Cow::Owned(invalid_graph.clone()), &control)?;
7324 require_eq(
7325 &invalid_report.paired_import_pairing_work,
7326 &PAIRING_WORK_ITEMS,
7327 "invalid import pairing work",
7328 )?;
7329 require(
7330 invalid.symbols.is_empty() && invalid.relations.is_empty(),
7331 "invalid import facts survived admission",
7332 )?;
7333 require_eq(
7334 &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
7335 &u64::try_from(IMPORT_RELATIONS)?,
7336 "invalid import rejection count",
7337 )?;
7338 require_eq(
7339 &invalid_report.rejections.len(),
7340 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7341 "invalid import detail ceiling",
7342 )?;
7343
7344 let (replayed, replay_report) =
7345 super::admit_symbol_graph(Cow::Owned(invalid_graph), &control)?;
7346 require(
7347 replayed.symbols.is_empty() && replayed.relations.is_empty(),
7348 "replayed invalid import facts survived admission",
7349 )?;
7350 require_eq(
7351 &replay_report.paired_import_pairing_work,
7352 &PAIRING_WORK_ITEMS,
7353 "replayed import pairing work",
7354 )?;
7355 invalid_report.merge(replay_report, &control)?;
7356 require_eq(
7357 &invalid_report.paired_import_pairing_work,
7358 &(PAIRING_WORK_ITEMS * 2),
7359 "merged replay import pairing work",
7360 )?;
7361 require_eq(
7362 &invalid_report.rejected_facts_for("src/import-scale-invalid.ts"),
7363 &u64::try_from(IMPORT_RELATIONS)?,
7364 "replayed import rejection count",
7365 )?;
7366 require_eq(
7367 &invalid_report.rejections.len(),
7368 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7369 "replayed import detail ceiling",
7370 )?;
7371 Ok(())
7372 }
7373
7374 #[test]
7375 fn admission_counts_distinct_same_span_facts_but_deduplicates_replays()
7376 -> Result<(), Box<dyn Error>> {
7377 let mut admission = GraphIdentityAdmission::default();
7378 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7379 let span = super::IdentitySpan {
7380 start_line: 7,
7381 start_column: 0,
7382 end_line: 7,
7383 end_column: 0,
7384 };
7385 let failure = [(
7386 GraphIdentityField::RelationTarget,
7387 GraphIdentityRejectionReason::Empty,
7388 )];
7389 admission.record(
7390 "src/facts.ts",
7391 span,
7392 ParserKind::TreeSitter,
7393 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7394 &failure,
7395 &control,
7396 )?;
7397 admission.record(
7398 "src/facts.ts",
7399 span,
7400 ParserKind::TreeSitter,
7401 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
7402 &failure,
7403 &control,
7404 )?;
7405 admission.record(
7406 "src/facts.ts",
7407 span,
7408 ParserKind::TreeSitter,
7409 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7410 &failure,
7411 &control,
7412 )?;
7413 require_eq(
7414 &admission.rejected_facts_for("src/facts.ts"),
7415 &2,
7416 "same-span distinct rejection count",
7417 )?;
7418 require_eq(
7419 &admission.rejections.len(),
7420 &2,
7421 "same-span distinct typed detail count",
7422 )?;
7423 require(
7424 admission.rejections[0].fact_index != admission.rejections[1].fact_index,
7425 "same-span distinct facts shared an internal identity",
7426 )?;
7427 Ok(())
7428 }
7429
7430 #[test]
7431 fn rejection_membership_preserves_distinct_fields_across_replay_and_merge()
7432 -> Result<(), Box<dyn Error>> {
7433 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7434 let span = super::IdentitySpan {
7435 start_line: 7,
7436 start_column: 0,
7437 end_line: 7,
7438 end_column: 0,
7439 };
7440 let failures = [
7441 (
7442 GraphIdentityField::RelationSource,
7443 GraphIdentityRejectionReason::ControlCharacters,
7444 ),
7445 (
7446 GraphIdentityField::RelationTarget,
7447 GraphIdentityRejectionReason::ControlCharacters,
7448 ),
7449 (
7450 GraphIdentityField::Parent,
7451 GraphIdentityRejectionReason::ControlCharacters,
7452 ),
7453 (
7454 GraphIdentityField::Signature,
7455 GraphIdentityRejectionReason::ControlCharacters,
7456 ),
7457 ];
7458 let mut admission = GraphIdentityAdmission::default();
7459 admission.record(
7460 "src/multi-field.ts",
7461 span,
7462 ParserKind::TreeSitter,
7463 17,
7464 &failures,
7465 &control,
7466 )?;
7467 admission.record(
7468 "src/multi-field.ts",
7469 span,
7470 ParserKind::TreeSitter,
7471 17,
7472 &failures,
7473 &control,
7474 )?;
7475 require_eq(
7476 &admission.rejections.len(),
7477 &failures.len(),
7478 "same-fact replay collapsed distinct rejection fields",
7479 )?;
7480 for &(field, reason) in &failures {
7481 require(
7482 admission
7483 .rejections
7484 .iter()
7485 .any(|rejection| rejection.field == field && rejection.reason == reason),
7486 "same-fact rejection field was lost",
7487 )?;
7488 }
7489
7490 let mut aggregated = Vec::new();
7491 let mut aggregated_keys = BTreeSet::new();
7492 super::extend_bounded_identity_rejections(
7493 &mut aggregated,
7494 &mut aggregated_keys,
7495 admission.rejections.iter().cloned(),
7496 )?;
7497 super::extend_bounded_identity_rejections(
7498 &mut aggregated,
7499 &mut aggregated_keys,
7500 admission.rejections,
7501 )?;
7502 require_eq(
7503 &aggregated.len(),
7504 &failures.len(),
7505 "bounded aggregation collapsed distinct rejection fields",
7506 )?;
7507 require_eq(
7508 &aggregated_keys.len(),
7509 &failures.len(),
7510 "bounded aggregation membership did not deduplicate replay",
7511 )?;
7512 Ok(())
7513 }
7514
7515 #[test]
7516 fn admission_merge_deduplicates_replayed_observed_facts() -> Result<(), Box<dyn Error>> {
7517 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7518 let span = super::IdentitySpan {
7519 start_line: 7,
7520 start_column: 0,
7521 end_line: 7,
7522 end_column: 0,
7523 };
7524 let failure = [(
7525 GraphIdentityField::RelationTarget,
7526 GraphIdentityRejectionReason::Empty,
7527 )];
7528 let mut first = GraphIdentityAdmission::default();
7529 for fact_index in 1..=2 {
7530 first.record(
7531 "src/merged.ts",
7532 span,
7533 ParserKind::TreeSitter,
7534 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7535 &failure,
7536 &control,
7537 )?;
7538 }
7539 let mut replay = GraphIdentityAdmission::default();
7540 for fact_index in 1..=3 {
7541 replay.record(
7542 "src/merged.ts",
7543 span,
7544 ParserKind::TreeSitter,
7545 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7546 &failure,
7547 &control,
7548 )?;
7549 }
7550 first.merge(replay, &control)?;
7551 require_eq(
7552 &first.rejected_facts_for("src/merged.ts"),
7553 &3,
7554 "merged replayed identity count",
7555 )?;
7556 require_eq(
7557 &first.rejections.len(),
7558 &3,
7559 "merged replayed typed detail count",
7560 )?;
7561 Ok(())
7562 }
7563
7564 #[test]
7565 fn admission_deduplicates_replayed_facts_after_detail_ceiling() -> Result<(), Box<dyn Error>> {
7566 let mut admission = GraphIdentityAdmission::default();
7567 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7568 let span = super::IdentitySpan {
7569 start_line: 7,
7570 start_column: 0,
7571 end_line: 7,
7572 end_column: 0,
7573 };
7574 let failure = [(
7575 GraphIdentityField::RelationTarget,
7576 GraphIdentityRejectionReason::Empty,
7577 )];
7578 for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
7579 admission.record(
7580 "src/capped.ts",
7581 span,
7582 ParserKind::TreeSitter,
7583 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7584 &failure,
7585 &control,
7586 )?;
7587 }
7588 require_eq(
7589 &admission.rejected_facts_for("src/capped.ts"),
7590 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
7591 "capped distinct rejection count",
7592 )?;
7593 require_eq(
7594 &admission.rejections.len(),
7595 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7596 "capped typed detail count",
7597 )?;
7598 admission.record(
7599 "src/capped.ts",
7600 span,
7601 ParserKind::TreeSitter,
7602 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
7603 &failure,
7604 &control,
7605 )?;
7606 require_eq(
7607 &admission.rejected_facts_for("src/capped.ts"),
7608 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS)?,
7609 "replayed capped rejection count",
7610 )?;
7611 require_eq(
7612 &admission.rejections.len(),
7613 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7614 "replayed capped typed detail count",
7615 )?;
7616 admission.record(
7617 "src/capped.ts",
7618 span,
7619 ParserKind::TreeSitter,
7620 super::parser_fact_index(
7621 super::RELATION_FACT_INDEX_NAMESPACE,
7622 super::MAX_GRAPH_IDENTITY_REJECTIONS,
7623 ),
7624 &failure,
7625 &control,
7626 )?;
7627 require_eq(
7628 &admission.rejected_facts_for("src/capped.ts"),
7629 &u64::try_from(super::MAX_GRAPH_IDENTITY_REJECTIONS + 1)?,
7630 "new capped rejection count",
7631 )?;
7632 require_eq(
7633 &admission.rejections.len(),
7634 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7635 "new capped typed detail count",
7636 )?;
7637 let mut aggregated = Vec::new();
7638 let mut aggregated_keys = BTreeSet::new();
7639 super::extend_bounded_identity_rejections(
7640 &mut aggregated,
7641 &mut aggregated_keys,
7642 admission.rejections.iter().cloned(),
7643 )?;
7644 super::extend_bounded_identity_rejections(
7645 &mut aggregated,
7646 &mut aggregated_keys,
7647 admission.rejections,
7648 )?;
7649 require_eq(
7650 &aggregated.len(),
7651 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7652 "bounded aggregation changed the distinct detail cardinality",
7653 )?;
7654 require_eq(
7655 &aggregated_keys.len(),
7656 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7657 "bounded aggregation membership lost a distinct detail",
7658 )?;
7659 Ok(())
7660 }
7661
7662 #[test]
7663 fn identity_rejection_limit_marker_is_causal_and_path_scoped() -> Result<(), Box<dyn Error>> {
7664 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7665 let failure = [(
7666 GraphIdentityField::Symbol,
7667 GraphIdentityRejectionReason::Empty,
7668 )];
7669 let span = |line| super::IdentitySpan {
7670 start_line: line,
7671 start_column: 0,
7672 end_line: line,
7673 end_column: 0,
7674 };
7675 let mut admission = GraphIdentityAdmission::default();
7676 for fact_index in 0..super::MAX_GRAPH_IDENTITY_REJECTIONS {
7677 admission.record(
7678 "src/exact-cap.ts",
7679 span(fact_index + 1),
7680 ParserKind::TreeSitter,
7681 super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, fact_index),
7682 &failure,
7683 &control,
7684 )?;
7685 }
7686 require(
7687 admission.rejection_details_dropped_by_path.is_empty(),
7688 "an exactly full retained detail set claimed an eviction",
7689 )?;
7690
7691 let graph = |path: &str, parser: ParserKind| SymbolGraph {
7692 path: path.to_string(),
7693 language: Some("test".to_string()),
7694 parser,
7695 symbols: Vec::new(),
7696 relations: Vec::new(),
7697 };
7698 let exact_cap = super::coverage_for_graph(
7699 &graph("src/exact-cap.ts", ParserKind::TreeSitter),
7700 IndexGeneration::new(1),
7701 &admission,
7702 &GraphIdentityAdmission::default(),
7703 )?;
7704 require_eq(
7705 &exact_cap.reached_limit(),
7706 &None,
7707 "exactly full retained details reported an eviction",
7708 )?;
7709
7710 admission.record(
7711 "src/exact-parser.ts",
7712 span(1),
7713 ParserKind::TreeSitter,
7714 super::parser_fact_index(super::SYMBOL_FACT_INDEX_NAMESPACE, 10_000),
7715 &failure,
7716 &control,
7717 )?;
7718 admission.record(
7719 "src/markdown-structural.md",
7720 span(1),
7721 ParserKind::Structural,
7722 super::parser_fact_index(super::MARKDOWN_FACT_INDEX_NAMESPACE, 0),
7723 &failure,
7724 &control,
7725 )?;
7726 require(
7727 admission
7728 .rejection_details_dropped_by_path
7729 .contains("src/exact-parser.ts")
7730 && admission
7731 .rejection_details_dropped_by_path
7732 .contains("src/markdown-structural.md"),
7733 "distinct exact and structural evictions were not marked by path",
7734 )?;
7735 for (path, parser) in [
7736 ("src/exact-parser.ts", ParserKind::TreeSitter),
7737 ("src/markdown-structural.md", ParserKind::Structural),
7738 ] {
7739 let coverage = super::coverage_for_graph(
7740 &graph(path, parser),
7741 IndexGeneration::new(1),
7742 &admission,
7743 &GraphIdentityAdmission::default(),
7744 )?;
7745 require_eq(
7746 &coverage.reached_limit(),
7747 &Some(GraphLimitKind::Rows),
7748 "causal identity eviction did not reach path coverage",
7749 )?;
7750 }
7751
7752 for parser in [ParserKind::Structural, ParserKind::Fallback] {
7753 let coverage = super::coverage_for_graph(
7754 &graph("src/baseline.rs", parser),
7755 IndexGeneration::new(1),
7756 &GraphIdentityAdmission::default(),
7757 &GraphIdentityAdmission::default(),
7758 )?;
7759 require_eq(
7760 &coverage.reached_limit(),
7761 &None,
7762 "parser baseline omission claimed identity-detail eviction",
7763 )?;
7764 }
7765 let unrelated = super::coverage_for_graph(
7766 &graph("src/unrelated.rs", ParserKind::TreeSitter),
7767 IndexGeneration::new(1),
7768 &admission,
7769 &GraphIdentityAdmission::default(),
7770 )?;
7771 require_eq(
7772 &unrelated.reached_limit(),
7773 &None,
7774 "an unrelated path inherited another path's identity eviction",
7775 )?;
7776 Ok(())
7777 }
7778
7779 #[test]
7780 fn admission_merges_many_graphs_with_incremental_retained_bytes() -> Result<(), Box<dyn Error>>
7781 {
7782 const GRAPH_COUNT: usize = 1_000;
7783 const FACTS_PER_GRAPH: usize = 10;
7784 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7785 let span = super::IdentitySpan {
7786 start_line: 7,
7787 start_column: 0,
7788 end_line: 7,
7789 end_column: 0,
7790 };
7791 let failure = [(
7792 GraphIdentityField::RelationTarget,
7793 GraphIdentityRejectionReason::Empty,
7794 )];
7795 let mut admission = GraphIdentityAdmission::default();
7796 let mut expected_bytes = 0_u64;
7797 for graph_index in 0..GRAPH_COUNT {
7798 let path = format!("src/many-graphs-{graph_index}.ts");
7799 let mut graph = GraphIdentityAdmission::default();
7800 for fact_index in 0..FACTS_PER_GRAPH {
7801 graph.record(
7802 &path,
7803 span,
7804 ParserKind::TreeSitter,
7805 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7806 &failure,
7807 &control,
7808 )?;
7809 }
7810 admission.merge(graph, &control)?;
7811 let key_bytes = super::identity_rejection_key_retained_bytes(&path)?
7812 .checked_mul(u64::try_from(FACTS_PER_GRAPH)?)
7813 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7814 let graph_bytes = super::identity_observed_path_retained_bytes(&path, FACTS_PER_GRAPH)?
7815 .checked_add(super::identity_count_path_retained_bytes(&path)?)
7816 .and_then(|bytes| bytes.checked_add(key_bytes))
7817 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7818 expected_bytes = expected_bytes
7819 .checked_add(graph_bytes)
7820 .ok_or_else(|| io::Error::other("many-graph identity bytes overflowed"))?;
7821 require_eq(
7822 &admission.observed_fact_bytes,
7823 &expected_bytes,
7824 "incremental identity byte cache",
7825 )?;
7826 }
7827 require_eq(
7828 &admission.rejections.len(),
7829 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7830 "many-graph retained detail ceiling",
7831 )?;
7832 require_eq(
7833 &admission.rejection_keys.len(),
7834 &super::MAX_GRAPH_IDENTITY_REJECTIONS,
7835 "many-graph keyed detail ceiling",
7836 )?;
7837 require_eq(
7838 &super::identity_admission_retained_bytes(&admission),
7839 &expected_bytes,
7840 "incremental identity byte cache remained authoritative",
7841 )?;
7842
7843 let replay_path = "src/many-graphs-999.ts";
7844 let mut replay = GraphIdentityAdmission::default();
7845 for fact_index in 0..FACTS_PER_GRAPH {
7846 replay.record(
7847 replay_path,
7848 span,
7849 ParserKind::TreeSitter,
7850 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, fact_index),
7851 &failure,
7852 &control,
7853 )?;
7854 }
7855 admission.merge(replay, &control)?;
7856 require_eq(
7857 &admission.observed_fact_bytes,
7858 &expected_bytes,
7859 "replayed graph did not inflate identity byte cache",
7860 )?;
7861 Ok(())
7862 }
7863
7864 #[test]
7865 fn observed_identity_facts_charge_budget_at_minus_equal_plus_boundaries()
7866 -> Result<(), Box<dyn Error>> {
7867 let path = "src/budget.ts";
7868 let span = super::IdentitySpan {
7869 start_line: 7,
7870 start_column: 0,
7871 end_line: 7,
7872 end_column: 0,
7873 };
7874 let failure = [(
7875 GraphIdentityField::RelationTarget,
7876 GraphIdentityRejectionReason::Empty,
7877 )];
7878 let additional = super::identity_fact_retained_bytes(path, true, true)?
7879 .checked_add(super::identity_rejection_key_retained_bytes(path)?)
7880 .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
7881 let next_fact_additional = super::STAGED_GRAPH_ROW_BYTES
7882 .checked_add(super::identity_rejection_key_retained_bytes(path)?)
7883 .ok_or_else(|| io::Error::other("identity budget fixture overflowed"))?;
7884
7885 let control = IndexWorkControl::new(IndexCancellation::new(), None);
7886 let mut below = GraphIdentityAdmission {
7887 observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional + 1,
7888 ..GraphIdentityAdmission::default()
7889 };
7890 let error = below
7891 .record(
7892 path,
7893 span,
7894 ParserKind::TreeSitter,
7895 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7896 &failure,
7897 &control,
7898 )
7899 .err()
7900 .ok_or_else(|| io::Error::other("one byte over the identity budget was retained"))?;
7901 require(
7902 matches!(
7903 error,
7904 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
7905 stage: IndexWorkStage::SymbolParsing,
7906 resource: IndexWorkResource::OutputBytes,
7907 limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7908 observed,
7909 }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + 1
7910 ),
7911 "identity budget overflow did not return its typed refusal",
7912 )?;
7913 require(
7914 below.observed_facts.is_empty() && below.rejected_facts_by_path.is_empty(),
7915 "identity budget refusal retained partial state",
7916 )?;
7917
7918 let mut equal = GraphIdentityAdmission {
7919 observed_fact_bytes: MAX_IN_MEMORY_GRAPH_WORK_BYTES - additional,
7920 ..GraphIdentityAdmission::default()
7921 };
7922 equal.record(
7923 path,
7924 span,
7925 ParserKind::TreeSitter,
7926 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
7927 &failure,
7928 &control,
7929 )?;
7930 require_eq(
7931 &equal.observed_fact_bytes,
7932 &MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7933 "identity budget exact boundary",
7934 )?;
7935 require_eq(
7936 &equal.rejected_facts_for(path),
7937 &1,
7938 "identity budget exact boundary count",
7939 )?;
7940
7941 let error = equal
7942 .record(
7943 path,
7944 span,
7945 ParserKind::TreeSitter,
7946 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 2),
7947 &failure,
7948 &control,
7949 )
7950 .err()
7951 .ok_or_else(|| io::Error::other("one additional identity fact exceeded the budget"))?;
7952 require(
7953 matches!(
7954 error,
7955 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
7956 stage: IndexWorkStage::SymbolParsing,
7957 resource: IndexWorkResource::OutputBytes,
7958 limit: MAX_IN_MEMORY_GRAPH_WORK_BYTES,
7959 observed,
7960 }) if observed == MAX_IN_MEMORY_GRAPH_WORK_BYTES + next_fact_additional
7961 ),
7962 "identity budget plus boundary did not return its typed refusal",
7963 )?;
7964 require_eq(
7965 &equal.rejected_facts_for(path),
7966 &1,
7967 "identity plus boundary changed retained count",
7968 )?;
7969 Ok(())
7970 }
7971
7972 #[test]
7973 fn observed_identity_facts_honor_cancellation_before_retention() -> Result<(), Box<dyn Error>> {
7974 let cancellation = IndexCancellation::new();
7975 let control = IndexWorkControl::new(cancellation.clone(), None);
7976 cancellation.cancel();
7977 let mut admission = GraphIdentityAdmission::default();
7978 let error = admission
7979 .record(
7980 "src/canceled.ts",
7981 super::IdentitySpan {
7982 start_line: 1,
7983 start_column: 0,
7984 end_line: 1,
7985 end_column: 0,
7986 },
7987 ParserKind::TreeSitter,
7988 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
7989 &[(
7990 GraphIdentityField::RelationTarget,
7991 GraphIdentityRejectionReason::Empty,
7992 )],
7993 &control,
7994 )
7995 .err()
7996 .ok_or_else(|| io::Error::other("canceled identity admission retained a fact"))?;
7997 require(
7998 matches!(
7999 error,
8000 CliError::IndexWork(IndexWorkFailure::Cancelled {
8001 stage: IndexWorkStage::SymbolParsing
8002 })
8003 ),
8004 "identity admission cancellation was not typed",
8005 )?;
8006 require(
8007 admission.observed_facts.is_empty()
8008 && admission.rejected_facts_by_path.is_empty()
8009 && admission.observed_fact_bytes == 0,
8010 "canceled identity admission retained partial state",
8011 )?;
8012 Ok(())
8013 }
8014
8015 #[test]
8016 fn reused_reconciliation_paths_charge_all_maps_at_injected_boundaries()
8017 -> Result<(), Box<dyn Error>> {
8018 let mut report = GraphIdentityAdmission::default();
8019 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8020 for index in 0..32 {
8021 let path = format!("src/reused-{index}.ts");
8022 for _ in 0..4 {
8023 report.reserve_reused_path_bytes(
8024 &path,
8025 false,
8026 &control,
8027 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8028 )?;
8029 }
8030 report.reused_rejection_counts.insert(path.clone(), 2);
8031 report
8032 .reused_parser_rejection_counts
8033 .insert(path.clone(), 1);
8034 report
8035 .reused_rejection_detail_counts
8036 .insert(path.clone(), 0);
8037 report.reused_rejection_details_incomplete.insert(path);
8038 }
8039 let retained = super::identity_admission_retained_bytes(&report);
8040 require(
8041 retained > 0,
8042 "reused reconciliation bytes were not retained",
8043 )?;
8044
8045 let below = retained - 1;
8046 let error = super::checked_identity_admission_budget(&report, &control, below)
8047 .err()
8048 .ok_or_else(|| io::Error::other("below-limit reused paths were accepted"))?;
8049 require(
8050 matches!(
8051 error,
8052 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8053 stage: IndexWorkStage::SymbolParsing,
8054 resource: IndexWorkResource::OutputBytes,
8055 limit,
8056 observed,
8057 }) if limit == below && observed == retained
8058 ),
8059 "below-limit reused paths did not return their typed refusal",
8060 )?;
8061 require_eq(
8062 &super::checked_identity_admission_budget(&report, &control, retained)?,
8063 &retained,
8064 "equal-limit reused paths",
8065 )?;
8066 require_eq(
8067 &super::checked_identity_admission_budget(&report, &control, retained + 1)?,
8068 &retained,
8069 "above-limit reused paths",
8070 )?;
8071 Ok(())
8072 }
8073
8074 #[test]
8075 fn reused_reconciliation_retention_honors_cancellation_before_insertion()
8076 -> Result<(), Box<dyn Error>> {
8077 let cancellation = IndexCancellation::new();
8078 let control = IndexWorkControl::new(cancellation.clone(), None);
8079 cancellation.cancel();
8080 let mut report = GraphIdentityAdmission::default();
8081 let error = report
8082 .reserve_reused_path_bytes(
8083 "src/canceled-reuse.ts",
8084 false,
8085 &control,
8086 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8087 )
8088 .err()
8089 .ok_or_else(|| io::Error::other("canceled reused path was retained"))?;
8090 require(
8091 matches!(
8092 error,
8093 CliError::IndexWork(IndexWorkFailure::Cancelled {
8094 stage: IndexWorkStage::SymbolParsing
8095 })
8096 ),
8097 "reused path cancellation was not typed",
8098 )?;
8099 require(
8100 report.reused_rejection_counts.is_empty()
8101 && report.reused_parser_rejection_counts.is_empty()
8102 && report.reused_rejection_detail_counts.is_empty()
8103 && report.reused_rejection_details_incomplete.is_empty()
8104 && report.observed_fact_bytes == 0,
8105 "canceled reused path retained reconciliation state",
8106 )?;
8107
8108 let mut incoming = GraphIdentityAdmission::default();
8109 incoming
8110 .reused_rejection_counts
8111 .insert("src/incoming.ts".to_string(), 1);
8112 let error = report
8113 .merge(incoming, &control)
8114 .err()
8115 .ok_or_else(|| io::Error::other("canceled merge retained reconciliation state"))?;
8116 require(
8117 matches!(
8118 error,
8119 CliError::IndexWork(IndexWorkFailure::Cancelled {
8120 stage: IndexWorkStage::SymbolParsing
8121 })
8122 ),
8123 "merge cancellation was not typed",
8124 )?;
8125 require(
8126 report.reused_rejection_counts.is_empty() && report.observed_fact_bytes == 0,
8127 "canceled merge retained reconciliation state",
8128 )?;
8129 Ok(())
8130 }
8131
8132 #[test]
8133 fn reused_identity_counts_preserve_new_and_removed_derived_outcomes()
8134 -> Result<(), Box<dyn Error>> {
8135 let path = "src/reused.ts";
8136 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8137 let failure = [(
8138 GraphIdentityField::ResolutionKey,
8139 GraphIdentityRejectionReason::Oversized,
8140 )];
8141 let mut admission = GraphIdentityAdmission::default();
8142 for _ in 0..2 {
8143 admission.reserve_reused_path_bytes(
8144 path,
8145 false,
8146 &control,
8147 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8148 )?;
8149 }
8150 admission
8151 .reused_rejection_counts
8152 .insert(path.to_string(), 3);
8153 admission
8154 .reused_rejection_detail_counts
8155 .insert(path.to_string(), 2);
8156 for fact_index in 0..2 {
8157 admission.record(
8158 path,
8159 super::IdentitySpan {
8160 start_line: fact_index + 1,
8161 start_column: 0,
8162 end_line: fact_index + 1,
8163 end_column: 0,
8164 },
8165 ParserKind::TreeSitter,
8166 fact_index as u64,
8167 &failure,
8168 &control,
8169 )?;
8170 }
8171 let persisted_facts = admission
8172 .observed_facts
8173 .get(path)
8174 .cloned()
8175 .ok_or("persisted identity fact keys are missing")?;
8176 admission.reserve_identity_bytes(
8177 super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
8178 &control,
8179 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8180 )?;
8181 admission
8182 .reused_rejection_facts
8183 .insert(path.to_string(), persisted_facts);
8184 require_eq(
8185 &admission.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
8186 &3,
8187 "reused unchanged rejection count",
8188 )?;
8189
8190 let mut derived = GraphIdentityAdmission::default();
8191 derived.record(
8192 path,
8193 super::IdentitySpan {
8194 start_line: 3,
8195 start_column: 0,
8196 end_line: 3,
8197 end_column: 0,
8198 },
8199 ParserKind::TreeSitter,
8200 2,
8201 &failure,
8202 &control,
8203 )?;
8204 require_eq(
8205 &admission.rejected_facts_for_graph(path, &derived)?,
8206 &4,
8207 "reused genuinely new rejection count",
8208 )?;
8209
8210 let mut removed = GraphIdentityAdmission::default();
8211 for _ in 0..2 {
8212 removed.reserve_reused_path_bytes(
8213 path,
8214 false,
8215 &control,
8216 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8217 )?;
8218 }
8219 removed.reused_rejection_counts.insert(path.to_string(), 2);
8220 removed
8221 .reused_rejection_detail_counts
8222 .insert(path.to_string(), 2);
8223 removed.reused_rejection_facts.insert(
8224 path.to_string(),
8225 admission
8226 .reused_rejection_facts
8227 .get(path)
8228 .cloned()
8229 .ok_or("persisted identity fact keys are missing")?,
8230 );
8231 let persisted_facts = removed
8232 .reused_rejection_facts
8233 .get(path)
8234 .cloned()
8235 .ok_or("persisted identity fact keys are missing")?;
8236 removed.reserve_identity_bytes(
8237 super::identity_fact_set_retained_bytes(path, &persisted_facts)?,
8238 &control,
8239 MAX_IN_MEMORY_GRAPH_WORK_BYTES,
8240 )?;
8241 removed.record(
8242 path,
8243 super::IdentitySpan {
8244 start_line: 1,
8245 start_column: 0,
8246 end_line: 1,
8247 end_column: 0,
8248 },
8249 ParserKind::TreeSitter,
8250 0,
8251 &failure,
8252 &control,
8253 )?;
8254 require_eq(
8255 &removed.rejected_facts_for_graph(path, &GraphIdentityAdmission::default())?,
8256 &1,
8257 "reused removed rejection count",
8258 )?;
8259 require_eq(
8260 &removed.rejected_facts_for_graph(path, &derived)?,
8261 &2,
8262 "reused removed plus new rejection count",
8263 )?;
8264 Ok(())
8265 }
8266
8267 #[test]
8268 fn relation_rejections_keep_original_parser_ordinals_through_reopen_and_retry()
8269 -> Result<(), Box<dyn Error>> {
8270 let temp = tempfile::tempdir()?;
8271 let root = fs::canonicalize(temp.path())?;
8272 fs::create_dir_all(root.join(".projectatlas"))?;
8273 let database = root.join(".projectatlas/projectatlas.db");
8274 let components = (0..20)
8275 .map(|_| "d".repeat(200))
8276 .collect::<Vec<_>>()
8277 .join("/");
8278 let path = format!("src/{components}/page.ts");
8279 let invalid_target = "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1);
8280 let oversized_resolved_module = "m".repeat(100);
8281 let graph = SymbolGraph {
8282 path: path.clone(),
8283 language: Some("typescript".to_string()),
8284 parser: ParserKind::TreeSitter,
8285 symbols: vec![CodeSymbol {
8286 path: path.clone(),
8287 language: Some("typescript".to_string()),
8288 name: "caller".to_string(),
8289 kind: SymbolKind::Function,
8290 signature: "function caller()".to_string(),
8291 exported: true,
8292 documentation: None,
8293 line_start: 1,
8294 line_end: 1,
8295 source_selector: None,
8296 parent: None,
8297 parser: ParserKind::TreeSitter,
8298 detail: None,
8299 }],
8300 relations: vec![
8301 SymbolRelation {
8302 path: path.clone(),
8303 source_name: "caller".to_string(),
8304 target_name: invalid_target,
8305 kind: RelationKind::Calls,
8306 line: 1,
8307 context: "caller()".to_string(),
8308 parser: ParserKind::TreeSitter,
8309 },
8310 SymbolRelation {
8311 path: path.clone(),
8312 source_name: "<module>".to_string(),
8313 target_name: format!("import {{ run }} from './{oversized_resolved_module}';"),
8314 kind: RelationKind::Imports,
8315 line: 1,
8316 context: "relation ordinal fixture".to_string(),
8317 parser: ParserKind::TreeSitter,
8318 },
8319 ],
8320 };
8321 let nodes = vec![test_file_node(&path, "typescript")];
8322 let mut store = AtlasStore::open_for_project(&database, &root)?;
8323 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8324 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
8325 let symbols = symbol_build_stage_for_graphs(vec![graph.clone()]);
8326 let staged = stage_full_repository_graph(
8327 &store,
8328 &root,
8329 IndexGeneration::ZERO,
8330 &nodes,
8331 &scan_policy,
8332 &symbols,
8333 &control,
8334 )?;
8335 let relation_rejections = staged
8336 .identity_rejections
8337 .iter()
8338 .filter(|row| {
8339 matches!(
8340 row.field,
8341 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8342 )
8343 })
8344 .collect::<Vec<_>>();
8345 require_eq(
8346 &relation_rejections.len(),
8347 &2,
8348 "same-line relation rejection detail count",
8349 )?;
8350 require(
8351 relation_rejections.iter().all(|row| {
8352 row.path.as_str() == path
8353 && row.span.start_line() == 1
8354 && row.span.end_line() == 1
8355 && row.parser == ParserKind::TreeSitter
8356 && row.reason == GraphIdentityRejectionReason::Oversized
8357 }),
8358 "same-line relation rejection provenance was not exact",
8359 )?;
8360 let mut fact_indices = relation_rejections
8361 .iter()
8362 .map(|row| row.fact_index)
8363 .collect::<Vec<_>>();
8364 fact_indices.sort_unstable();
8365 require_eq(
8366 &fact_indices,
8367 &vec![
8368 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 0),
8369 super::parser_fact_index(super::RELATION_FACT_INDEX_NAMESPACE, 1),
8370 ],
8371 "same-line relation parser ordinals",
8372 )?;
8373 require_eq(
8374 &staged.relations.len(),
8375 &1,
8376 "valid relation retained after source admission",
8377 )?;
8378 publish_full_staged_graph(
8379 &mut store,
8380 &nodes,
8381 &staged,
8382 &control,
8383 "relation-ordinal-full",
8384 )?;
8385 let first_generation = store
8386 .index_publication()?
8387 .ok_or("relation ordinal full publication is missing")?
8388 .generation;
8389 let path_key = RepositoryNodePath::new(Path::new(&path))?;
8390 let persisted = store.repository_graph_identity_rejections(
8391 store
8392 .project_instance_id()?
8393 .ok_or("relation ordinal project identity is missing")?,
8394 std::slice::from_ref(&path_key),
8395 16,
8396 None,
8397 )?;
8398 let persisted_fact_indices = persisted
8399 .iter()
8400 .filter(|row| {
8401 matches!(
8402 row.field,
8403 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8404 )
8405 })
8406 .map(|row| row.fact_index)
8407 .collect::<Vec<_>>();
8408 require_eq(
8409 &persisted_fact_indices,
8410 &fact_indices,
8411 "persisted same-line relation parser ordinals",
8412 )?;
8413 let persisted_wire = serde_json::to_string(&persisted)?;
8414 require(
8415 !persisted_wire.contains(&"x".repeat(32)),
8416 "persisted relation rejection retained invalid identity text",
8417 )?;
8418 drop(store);
8419
8420 let mut store = AtlasStore::open_for_project(&database, &root)?;
8421 let reopened = store.repository_graph_identity_rejections(
8422 store
8423 .project_instance_id()?
8424 .ok_or("reopened relation ordinal project identity is missing")?,
8425 std::slice::from_ref(&path_key),
8426 16,
8427 None,
8428 )?;
8429 require_eq(
8430 &reopened
8431 .iter()
8432 .filter(|row| {
8433 matches!(
8434 row.field,
8435 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8436 )
8437 })
8438 .map(|row| row.fact_index)
8439 .collect::<Vec<_>>(),
8440 &fact_indices,
8441 "reopened same-line relation parser ordinals",
8442 )?;
8443
8444 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
8445 let incremental_policy =
8446 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
8447 let fault_graph = graph.clone();
8448 let incremental_symbols = symbol_build_stage_for_graphs(vec![graph]);
8449 let incremental = stage_incremental_repository_graph(
8450 &store,
8451 &root,
8452 first_generation,
8453 &nodes,
8454 std::slice::from_ref(&path),
8455 &incremental_policy,
8456 &incremental_symbols,
8457 &incremental_control,
8458 )?;
8459 let incremental_rejections = incremental
8460 .identity_rejections
8461 .iter()
8462 .filter(|row| {
8463 matches!(
8464 row.field,
8465 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8466 )
8467 })
8468 .map(|row| row.fact_index)
8469 .collect::<Vec<_>>();
8470 require_eq(
8471 &incremental_rejections,
8472 &fact_indices,
8473 "incremental same-line relation parser ordinals",
8474 )?;
8475 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
8476 canceled.cancel();
8477 {
8478 let mut publication = store.begin_index_publication("relation-ordinal-cancel")?;
8479 require(
8480 incremental.apply(&mut publication, &canceled).is_err(),
8481 "relation ordinal cancellation did not fail publication",
8482 )?;
8483 }
8484 require_eq(
8485 &store
8486 .index_publication()?
8487 .map(|publication| publication.generation),
8488 &Some(first_generation),
8489 "generation after canceled relation ordinal publication",
8490 )?;
8491 {
8492 let mut publication = store.begin_index_publication("relation-ordinal-incremental")?;
8493 incremental.apply(&mut publication, &incremental_control)?;
8494 publication.complete()?;
8495 }
8496 let second_generation = store
8497 .index_publication()?
8498 .ok_or("relation ordinal incremental publication is missing")?
8499 .generation;
8500 drop(store);
8501 let mut store = AtlasStore::open_for_project(&database, &root)?;
8502 let reopened_incremental = store.repository_graph_identity_rejections(
8503 store
8504 .project_instance_id()?
8505 .ok_or("reopened incremental project identity is missing")?,
8506 std::slice::from_ref(&path_key),
8507 16,
8508 None,
8509 )?;
8510 require(
8511 reopened_incremental.iter().any(|row| {
8512 matches!(
8513 row.field,
8514 GraphIdentityField::RelationTarget | GraphIdentityField::ResolutionKey
8515 )
8516 }),
8517 "reopened incremental relation rejection is missing",
8518 )?;
8519 let mut fault = stage_incremental_repository_graph(
8520 &store,
8521 &root,
8522 second_generation,
8523 &nodes,
8524 std::slice::from_ref(&path),
8525 &incremental_policy,
8526 &symbol_build_stage_for_graphs(vec![fault_graph]),
8527 &incremental_control,
8528 )?;
8529 fault.identity_rejections.resize(
8530 usize::try_from(GraphLimits::MAX_ROWS)
8531 .unwrap_or(usize::MAX)
8532 .saturating_add(1),
8533 fault.identity_rejections[0].clone(),
8534 );
8535 {
8536 let mut publication = store.begin_index_publication("relation-ordinal-fault")?;
8537 require(
8538 fault.apply(&mut publication, &incremental_control).is_err(),
8539 "relation ordinal late fault did not fail publication",
8540 )?;
8541 }
8542 require_eq(
8543 &store
8544 .index_publication()?
8545 .map(|publication| publication.generation),
8546 &Some(second_generation),
8547 "generation after relation ordinal late fault",
8548 )?;
8549 Ok(())
8550 }
8551
8552 #[test]
8553 fn sqlite_recovered_invalid_manifest_context_uses_shared_admission()
8554 -> Result<(), Box<dyn Error>> {
8555 let temp = tempfile::tempdir()?;
8556 let root = fs::canonicalize(temp.path())?;
8557 let database = root.join(".projectatlas/projectatlas.db");
8558 fs::create_dir_all(root.join(".projectatlas"))?;
8559 let invalid_manifest = package_graph("Cargo.toml", "bad\0package");
8560 let direct_graph = function_graph("src/lib.rs", 1);
8561 let mut store = AtlasStore::open_for_project(&database, &root)?;
8562 store.replace_symbol_graph(&invalid_manifest)?;
8563 store.replace_symbol_graph(&direct_graph)?;
8564 drop(store);
8565
8566 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
8567 let recovered = reopened
8568 .load_symbol_graphs_for_paths(&["Cargo.toml".to_string(), "src/lib.rs".to_string()])?;
8569 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8570 let (admitted, report) =
8571 super::admit_symbol_graphs(recovered.into_iter().map(Cow::Owned).collect(), &control)?;
8572 require_eq(
8573 &report.rejected_facts_for("Cargo.toml"),
8574 &1,
8575 "recovered invalid manifest rejection count",
8576 )?;
8577 let manifest = admitted
8578 .iter()
8579 .find(|graph| graph.as_ref().path == "Cargo.toml")
8580 .ok_or("recovered manifest graph is missing")?;
8581 require(
8582 manifest.as_ref().symbols.is_empty(),
8583 "invalid recovered package identity reached package context",
8584 )?;
8585 let admitted_graphs = admitted.iter().map(Cow::as_ref).collect::<Vec<_>>();
8586 let packages = PackageIndex::from_graphs(&admitted_graphs)?;
8587 require_eq(
8588 &packages.package_name("src/lib.rs"),
8589 &None,
8590 "invalid recovered manifest silently supplied package ownership",
8591 )?;
8592 Ok(())
8593 }
8594
8595 fn drop_native_worktree_identity_schema(connection: &Connection) -> rusqlite::Result<()> {
8597 connection.execute_batch(
8598 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
8599 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
8600 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
8601 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
8602 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
8603 )
8604 }
8605 #[cfg(unix)]
8606 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
8607 std::os::unix::fs::symlink(target, link)
8608 }
8609
8610 #[cfg(windows)]
8611 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
8612 match std::os::windows::fs::symlink_dir(target, link) {
8613 Ok(()) => Ok(()),
8614 Err(source) if source.raw_os_error() == Some(1314) => {
8615 let status = std::process::Command::new("cmd")
8616 .arg("/C")
8617 .arg("mklink")
8618 .arg("/J")
8619 .arg(link)
8620 .arg(target)
8621 .status()?;
8622 if status.success() {
8623 Ok(())
8624 } else {
8625 Err(source)
8626 }
8627 }
8628 Err(source) => Err(source),
8629 }
8630 }
8631
8632 #[cfg(unix)]
8633 fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
8634 std::os::unix::fs::symlink(target, link)
8635 }
8636
8637 #[cfg(windows)]
8638 fn create_file_link(target: &Path, link: &Path) -> io::Result<()> {
8639 std::os::windows::fs::symlink_file(target, link)
8640 }
8641
8642 #[test]
8643 fn cargo_package_ownership_uses_the_longest_repository_prefix() -> Result<(), Box<dyn Error>> {
8644 let graphs = vec![
8645 package_graph("Cargo.toml", "workspace"),
8646 package_graph("crates/member/Cargo.toml", "member"),
8647 ];
8648 let packages = PackageIndex::from_graphs(&graphs)?;
8649 require_eq(
8650 &packages.package_name("src/lib.rs"),
8651 &Some("workspace"),
8652 "root package ownership",
8653 )?;
8654 require_eq(
8655 &packages.package_name("crates/member/src/lib.rs"),
8656 &Some("member"),
8657 "nested package ownership",
8658 )?;
8659 require(
8660 repository_path_belongs_to("crates/member/src/lib.rs", "crates/member"),
8661 "member path was not owned by its package",
8662 )?;
8663 require(
8664 !repository_path_belongs_to("crates/membership/src/lib.rs", "crates/member"),
8665 "package prefix matched a partial segment",
8666 )?;
8667 require(is_cargo_manifest_path("Cargo.toml"), "root manifest")?;
8668 require(
8669 is_cargo_manifest_path("crates/member/Cargo.toml"),
8670 "nested manifest",
8671 )?;
8672 require(
8673 !is_cargo_manifest_path("docs/Cargo.toml.example"),
8674 "manifest suffix lookalike",
8675 )?;
8676 Ok(())
8677 }
8678
8679 #[test]
8680 fn incremental_projection_budget_requests_one_complete_refresh() -> Result<(), Box<dyn Error>> {
8681 let root = Path::new("repository");
8682 let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
8683 for (rows, retained_bytes) in [
8684 (MAX_INCREMENTAL_GRAPH_ROWS + 1, 0),
8685 (0, MAX_INCREMENTAL_GRAPH_BYTES + 1),
8686 ] {
8687 let error =
8688 enforce_incremental_projection_budget(root, &affected_paths, rows, retained_bytes)
8689 .err()
8690 .ok_or_else(|| io::Error::other("oversized closure reached publication"))?;
8691 let CliError::RefreshRequired(report) = error else {
8692 return Err(io::Error::other(format!(
8693 "expected typed full-refresh guidance, found {error:?}"
8694 ))
8695 .into());
8696 };
8697 require_eq(
8698 &report.reason,
8699 &IndexRefreshReason::DependencyClosureLimit,
8700 "incremental budget reason",
8701 )?;
8702 require_eq(
8703 &report.scope,
8704 &IndexRefreshScope::Full,
8705 "incremental budget scope",
8706 )?;
8707 require_eq(&report.changed, &1, "incremental changed paths")?;
8708 require_eq(
8709 &report.sample_paths,
8710 &vec!["src/lib.rs".to_string()],
8711 "incremental sample paths",
8712 )?;
8713 }
8714 Ok(())
8715 }
8716
8717 #[test]
8718 fn admitted_resolution_keys_move_once_and_count_at_budget_boundary()
8719 -> Result<(), Box<dyn Error>> {
8720 let graph = function_graph("src/lib.rs", 1);
8721 let packages = PackageIndex::from_graphs(std::slice::from_ref(&graph))?;
8722 let project = ProjectInstanceId::from_bytes([31; 16])?;
8723 let admitted_projection = projectatlas_symbols::derive_resolution_keys(
8724 project,
8725 packages.package_name(&graph.path),
8726 &graph,
8727 )?;
8728 let admitted_key_bytes = super::resolution_retained_bytes(&admitted_projection);
8729 let mut admitted = BTreeMap::from([(graph.path.clone(), admitted_projection)]);
8730 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8731 let entities = build_entity_projection_with_config(
8732 project,
8733 IndexGeneration::new(1),
8734 &[],
8735 std::slice::from_ref(&graph),
8736 &packages,
8737 &ConfiguredModuleResolution::default(),
8738 Some(&mut admitted),
8739 true,
8740 &control,
8741 )?;
8742 require(
8743 admitted.is_empty(),
8744 "admitted resolution map retained moved keys",
8745 )?;
8746 require(
8747 entities.retained_bytes >= admitted_key_bytes,
8748 "entity projection budget omitted live resolution-key bytes",
8749 )?;
8750
8751 let mut registry = ProjectResolutionRegistry {
8752 retained_bytes: super::super::MAX_PUBLICATION_STAGING_BYTES
8753 .saturating_sub(entities.retained_bytes),
8754 ..ProjectResolutionRegistry::default()
8755 };
8756 require(
8757 enforce_resolution_staging_budget(&entities, ®istry).is_ok(),
8758 "exact staging budget boundary was rejected",
8759 )?;
8760 registry.retained_bytes = registry.retained_bytes.saturating_add(1);
8761 require(
8762 enforce_resolution_staging_budget(&entities, ®istry).is_err(),
8763 "staging budget ignored live resolution-key bytes at the boundary",
8764 )?;
8765 Ok(())
8766 }
8767
8768 #[test]
8769 fn multi_graph_resolution_peak_moves_each_entry_before_entity_allocation()
8770 -> Result<(), Box<dyn Error>> {
8771 let project = ProjectInstanceId::from_bytes([32; 16])?;
8772 let generation = IndexGeneration::new(1);
8773 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8774 let graphs = vec![
8775 package_graph("Cargo.toml", "peak-workspace"),
8776 function_graph("src/target.rs", 64),
8777 function_graph("src/caller.rs", 64),
8778 ];
8779 let packages = PackageIndex::from_graphs(&graphs)?;
8780 let fresh_admitted = || {
8781 graphs
8782 .iter()
8783 .map(|graph| {
8784 Ok::<_, Box<dyn Error>>((
8785 graph.path.clone(),
8786 projectatlas_symbols::derive_resolution_keys(
8787 project,
8788 packages.package_name(&graph.path),
8789 graph,
8790 )?,
8791 ))
8792 })
8793 .collect::<Result<BTreeMap<_, _>, _>>()
8794 };
8795 let mut admitted = fresh_admitted()?;
8796 require_eq(
8797 &admitted.len(),
8798 &graphs.len(),
8799 "multi-graph admitted resolution projection count",
8800 )?;
8801 let map_bytes = resolution_projection_map_retained_bytes(&admitted);
8802 require(
8803 map_bytes > 0,
8804 "multi-graph projection map had no retained bytes",
8805 )?;
8806 let first = build_entity_projection_with_config_limit(
8807 project,
8808 generation,
8809 &[],
8810 &graphs,
8811 &packages,
8812 &ConfiguredModuleResolution::default(),
8813 Some(&mut admitted),
8814 true,
8815 &control,
8816 super::super::MAX_PUBLICATION_STAGING_BYTES,
8817 )?;
8818 require(
8819 admitted.is_empty(),
8820 "full multi-graph projection retained admitted map entries",
8821 )?;
8822 require_eq(
8823 &first.projection_removals_before_entities,
8824 &graphs
8825 .iter()
8826 .map(|graph| graph.path.clone())
8827 .collect::<Vec<_>>(),
8828 "full projection removal order",
8829 )?;
8830 require(
8831 first.peak_retained_bytes >= map_bytes,
8832 "full peak accounting omitted the admitted projection map",
8833 )?;
8834 let peak = first.peak_retained_bytes;
8835 let mut at_peak_admitted = fresh_admitted()?;
8836 let at_peak = build_entity_projection_with_config_limit(
8837 project,
8838 generation.checked_next().ok_or("generation overflow")?,
8839 &[],
8840 &graphs,
8841 &packages,
8842 &ConfiguredModuleResolution::default(),
8843 Some(&mut at_peak_admitted),
8844 true,
8845 &control,
8846 peak,
8847 )?;
8848 require(
8849 at_peak_admitted.is_empty(),
8850 "exact-peak multi-graph projection retained admitted map entries",
8851 )?;
8852 require_eq(
8853 &at_peak.projection_removals_before_entities,
8854 &graphs
8855 .iter()
8856 .map(|graph| graph.path.clone())
8857 .collect::<Vec<_>>(),
8858 "exact-peak projection removal order",
8859 )?;
8860 require(
8861 at_peak.peak_retained_bytes <= peak,
8862 "exact-peak projection exceeded the measured full peak unexpectedly",
8863 )?;
8864 let below_peak = peak.checked_sub(1).ok_or("multi-graph peak was zero")?;
8865 require_eq(
8866 &peak.saturating_sub(below_peak),
8867 &1,
8868 "multi-graph below-peak budget was not exactly one byte lower",
8869 )?;
8870 let mut below_peak_admitted = fresh_admitted()?;
8871 let below_peak_result = build_entity_projection_with_config_limit(
8872 project,
8873 generation.checked_next().ok_or("generation overflow")?,
8874 &[],
8875 &graphs,
8876 &packages,
8877 &ConfiguredModuleResolution::default(),
8878 Some(&mut below_peak_admitted),
8879 true,
8880 &control,
8881 below_peak,
8882 );
8883 require(
8884 matches!(
8885 &below_peak_result,
8886 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8887 resource: IndexWorkResource::OutputBytes,
8888 limit,
8889 observed,
8890 ..
8891 })) if *limit == below_peak && *observed == peak
8892 ),
8893 "one byte below the measured full peak did not fail at the measured peak",
8894 )?;
8895 Ok(())
8896 }
8897
8898 #[test]
8899 fn incremental_direct_and_inbound_keys_are_counted_once_at_budget_boundary()
8900 -> Result<(), Box<dyn Error>> {
8901 let temp = tempfile::tempdir()?;
8902 let root = temp.path().join("incremental-resolution-budget");
8903 fs::create_dir_all(root.join(".projectatlas"))?;
8904 let database = root.join(".projectatlas/projectatlas.db");
8905 let mut store = AtlasStore::open_for_project(&database, &root)?;
8906 let manifest_path = "Cargo.toml";
8907 let root_module_path = "src/lib.rs";
8908 let direct_path = "src/target.rs";
8909 let inbound_path = "src/caller.rs";
8910 let manifest_source =
8911 "[package]\nname = \"dependency-refresh\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
8912 let root_module_source = "mod caller;\nmod target;\n";
8913 let direct_source = "pub fn target() {}\n";
8914 let inbound_source = "pub fn caller() { target(); }\n";
8915 fs::create_dir_all(root.join("src"))?;
8916 fs::write(root.join(manifest_path), manifest_source)?;
8917 fs::write(root.join(root_module_path), root_module_source)?;
8918 fs::write(root.join(direct_path), direct_source)?;
8919 fs::write(root.join(inbound_path), inbound_source)?;
8920 let nodes = vec![
8921 test_file_node(manifest_path, "cargo-manifest"),
8922 test_file_node(root_module_path, "rust"),
8923 test_file_node(direct_path, "rust"),
8924 test_file_node(inbound_path, "rust"),
8925 ];
8926 let graphs = vec![
8927 extract_symbol_graph(manifest_path, Some("cargo-manifest"), manifest_source),
8928 extract_symbol_graph(root_module_path, Some("rust"), root_module_source),
8929 extract_symbol_graph(direct_path, Some("rust"), direct_source),
8930 extract_symbol_graph(inbound_path, Some("rust"), inbound_source),
8931 ];
8932 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8933 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
8934 let full = stage_full_repository_graph(
8935 &store,
8936 &root,
8937 IndexGeneration::ZERO,
8938 &nodes,
8939 &scan_policy,
8940 &symbol_build_stage_for_graphs(graphs.clone()),
8941 &control,
8942 )?;
8943 publish_full_staged_graph(
8944 &mut store,
8945 &nodes,
8946 &full,
8947 &control,
8948 "incremental-budget-full",
8949 )?;
8950 for graph in &graphs {
8951 store.replace_symbol_graph(graph)?;
8952 }
8953 drop(store);
8954 let store = AtlasStore::open_for_project(&database, &root)?;
8955 let base_generation = store
8956 .index_publication()?
8957 .ok_or("incremental budget full publication is missing")?
8958 .generation;
8959 let incremental = stage_incremental_repository_graph(
8960 &store,
8961 &root,
8962 base_generation,
8963 &nodes,
8964 &[direct_path.to_string()],
8965 &scan_policy,
8966 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
8967 &control,
8968 )?;
8969 let affected_paths = BTreeSet::from([direct_path.to_string(), inbound_path.to_string()]);
8970 require(
8971 matches!(
8972 &incremental.mutation,
8973 RepositoryGraphMutation::AffectedPaths(paths)
8974 if paths.iter().cloned().collect::<BTreeSet<_>>() == affected_paths
8975 ),
8976 "incremental budget fixture did not include its inbound graph",
8977 )?;
8978 let expected_generation = base_generation
8979 .checked_next()
8980 .ok_or("incremental budget generation overflowed")?;
8981 for path in [&direct_path, &inbound_path] {
8982 let derivations = incremental
8983 .resolution_derivations
8984 .get(&(path.to_string(), expected_generation))
8985 .copied();
8986 require_eq(
8987 &derivations,
8988 &Some(1),
8989 "incremental direct/inbound resolution derivation count",
8990 )?;
8991 }
8992 let peak = incremental.peak_retained_bytes;
8993 require(peak > 0, "incremental projection measured no retained peak")?;
8994 let expected_removals = vec![direct_path.to_string(), inbound_path.to_string()];
8995 require_eq(
8996 &incremental.projection_removals_before_entities,
8997 &expected_removals,
8998 "incremental projection removal order",
8999 )?;
9000 let unrelated_manifest_derivations = incremental
9001 .resolution_derivations
9002 .get(&(manifest_path.to_string(), expected_generation))
9003 .copied();
9004 require_eq(
9005 &unrelated_manifest_derivations,
9006 &None,
9007 "incremental unrelated manifest resolution derivation count",
9008 )?;
9009 let unrelated_root_derivations = incremental
9010 .resolution_derivations
9011 .get(&(root_module_path.to_string(), expected_generation))
9012 .copied();
9013 require_eq(
9014 &unrelated_root_derivations,
9015 &None,
9016 "incremental unrelated root-module resolution derivation count",
9017 )?;
9018 let rows = u64::try_from(incremental.entities.len())?;
9019 let retained_bytes = incremental.retained_bytes;
9020 drop(incremental);
9021 let at_peak = stage_incremental_repository_graph_with_test_limit(
9022 &store,
9023 &root,
9024 base_generation,
9025 &nodes,
9026 &[direct_path.to_string()],
9027 &scan_policy,
9028 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
9029 &control,
9030 peak,
9031 )?;
9032 require_eq(
9033 &at_peak.projection_removals_before_entities,
9034 &expected_removals,
9035 "exact-peak incremental projection removal order",
9036 )?;
9037 require(
9038 at_peak.peak_retained_bytes <= peak,
9039 "exact-peak incremental projection exceeded its measured peak",
9040 )?;
9041 for path in [&direct_path, &inbound_path] {
9042 require_eq(
9043 &at_peak
9044 .resolution_derivations
9045 .get(&(path.to_string(), expected_generation))
9046 .copied(),
9047 &Some(1),
9048 "exact-peak incremental derivation count",
9049 )?;
9050 }
9051 require_eq(
9052 &at_peak
9053 .resolution_derivations
9054 .get(&(manifest_path.to_string(), expected_generation))
9055 .copied(),
9056 &None,
9057 "exact-peak unrelated manifest derivation count",
9058 )?;
9059 require_eq(
9060 &at_peak
9061 .resolution_derivations
9062 .get(&(root_module_path.to_string(), expected_generation))
9063 .copied(),
9064 &None,
9065 "exact-peak unrelated root-module derivation count",
9066 )?;
9067 drop(at_peak);
9068 let below_peak = peak.checked_sub(1).ok_or("incremental peak was zero")?;
9069 require_eq(
9070 &peak.saturating_sub(below_peak),
9071 &1,
9072 "incremental below-peak budget was not exactly one byte lower",
9073 )?;
9074 let below_peak_result = stage_incremental_repository_graph_with_test_limit(
9075 &store,
9076 &root,
9077 base_generation,
9078 &nodes,
9079 &[direct_path.to_string()],
9080 &scan_policy,
9081 &symbol_build_stage_for_graphs(vec![graphs[2].clone()]),
9082 &control,
9083 below_peak,
9084 );
9085 require(
9086 matches!(
9087 &below_peak_result,
9088 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
9089 resource: IndexWorkResource::OutputBytes,
9090 limit,
9091 observed,
9092 ..
9093 })) if *limit == below_peak && *observed == peak
9094 ),
9095 "one byte below the measured incremental peak did not fail at the measured peak",
9096 )?;
9097 require(
9098 retained_bytes > 0,
9099 "incremental budget fixture did not retain projected key bytes",
9100 )?;
9101 require(
9102 enforce_incremental_projection_budget(&root, &affected_paths, rows, retained_bytes)
9103 .is_ok(),
9104 "incremental budget rejected actual direct and inbound projection",
9105 )?;
9106 let available = MAX_INCREMENTAL_GRAPH_BYTES.saturating_sub(retained_bytes);
9107 require(
9108 enforce_incremental_projection_budget(
9109 &root,
9110 &affected_paths,
9111 rows,
9112 retained_bytes.saturating_add(available),
9113 )
9114 .is_ok(),
9115 "incremental exact byte boundary was rejected",
9116 )?;
9117 require(
9118 enforce_incremental_projection_budget(
9119 &root,
9120 &affected_paths,
9121 rows,
9122 retained_bytes.saturating_add(available).saturating_add(1),
9123 )
9124 .is_err(),
9125 "incremental byte boundary ignored one additional byte",
9126 )?;
9127 Ok(())
9128 }
9129
9130 #[test]
9131 fn incremental_projection_budget_combines_old_and_new_work() -> Result<(), Box<dyn Error>> {
9132 let temp = tempfile::tempdir()?;
9133 let root = temp.path();
9134 let affected_paths = BTreeSet::from(["src/lib.rs".to_string()]);
9135 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9136 let persisted = RepositoryAffectedSourceFootprint {
9137 rows: MAX_INCREMENTAL_GRAPH_ROWS,
9138 retained_bytes: 0,
9139 truncated: false,
9140 };
9141 let staged = StagedRepositoryGraph {
9142 project: ProjectInstanceId::from_bytes([1; 16])?,
9143 mutation: RepositoryGraphMutation::AffectedPaths(vec!["src/lib.rs".to_string()]),
9144 entities: Vec::new(),
9145 relations: Vec::new(),
9146 occurrences: Vec::new(),
9147 coverage: Vec::new(),
9148 entity_exports: Vec::new(),
9149 relation_dependencies: Vec::new(),
9150 document_unresolved_reasons: Vec::new(),
9151 identity_rejections: Vec::new(),
9152 resolution_derivations: BTreeMap::new(),
9153 peak_retained_bytes: 0,
9154 projection_removals_before_entities: Vec::new(),
9155 scan_policy: RootScanPolicy::discover(root, &ScanOptions::default(), &control)?,
9156 document_target_states: Vec::new(),
9157 database: None,
9158 retained_bytes: 0,
9159 };
9160 let error =
9161 enforce_incremental_projection_limits(root, &affected_paths, persisted, &staged)
9162 .err()
9163 .ok_or_else(|| io::Error::other("combined old and new work was admitted"))?;
9164 let CliError::RefreshRequired(report) = error else {
9165 return Err(io::Error::other(format!(
9166 "expected typed full-refresh guidance, found {error:?}"
9167 ))
9168 .into());
9169 };
9170 require_eq(
9171 &report.reason,
9172 &IndexRefreshReason::DependencyClosureLimit,
9173 "combined budget reason",
9174 )?;
9175 require_eq(
9176 &report.scope,
9177 &IndexRefreshScope::Full,
9178 "combined budget scope",
9179 )?;
9180 Ok(())
9181 }
9182
9183 #[test]
9184 fn document_projection_memory_budget_includes_reason_validation_state()
9185 -> Result<(), Box<dyn Error>> {
9186 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9187 let facts: BTreeMap<String, Cow<'static, projectatlas_symbols::MarkdownFacts>> = (0..12)
9188 .map(|document| {
9189 let source = (0..1_024)
9190 .map(|link| format!("[missing]({document}-{link}.md)"))
9191 .collect::<Vec<_>>()
9192 .join("\n");
9193 (
9194 format!("docs/source-{document}.md"),
9195 Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
9196 )
9197 })
9198 .collect::<BTreeMap<_, _>>();
9199 let candidate_count = facts
9200 .values()
9201 .map(|document| document.link_candidates.len())
9202 .sum::<usize>();
9203 require_eq(&candidate_count, &12_288, "document candidate fixture size")?;
9204 let fact_bytes = document_fact_map_retained_bytes(&facts);
9205 let projection_bytes = document_projection_retained_bytes(&facts, &control)?;
9206 require(
9207 projection_bytes >= u64::try_from(candidate_count)? * DOCUMENT_PROJECTION_ROW_BYTES,
9208 "generated document projection state was under-accounted",
9209 )?;
9210 require(
9211 fact_bytes.saturating_add(projection_bytes) < 512 * 1_024 * 1_024,
9212 "bounded document projection fixture exceeded the in-memory envelope",
9213 )?;
9214 Ok(())
9215 }
9216
9217 #[test]
9218 fn document_projection_estimator_deadline_preserves_generation_and_allows_retry()
9219 -> Result<(), Box<dyn Error>> {
9220 const DOCUMENT_COUNT: usize = 513;
9221 const CANDIDATES_PER_DOCUMENT: usize = 1_024;
9222 let temp = tempfile::tempdir()?;
9223 let root = fs::canonicalize(temp.path())?;
9224 let database = root.join("projectatlas.db");
9225 let mut store = AtlasStore::open_for_project(&database, &root)?;
9226 let project = store
9227 .project_instance_id()?
9228 .ok_or("estimator cancellation project identity is missing")?;
9229 let publication_control = IndexWorkControl::new(IndexCancellation::new(), None);
9230 let baseline_graph = function_graph("src/lib.rs", 1);
9231 let baseline_packages = PackageIndex::from_graphs(std::slice::from_ref(&baseline_graph))?;
9232 let baseline_entities = build_entity_projection(
9233 project,
9234 IndexGeneration::new(1),
9235 &[],
9236 std::slice::from_ref(&baseline_graph),
9237 &baseline_packages,
9238 true,
9239 &publication_control,
9240 )?;
9241 let baseline_candidates =
9242 resolution_registry_from_exports(&baseline_entities, &publication_control)?;
9243 let baseline_staged = finish_projection(
9244 project,
9245 IndexGeneration::new(1),
9246 RepositoryGraphMutation::Full,
9247 std::slice::from_ref(&baseline_graph),
9248 baseline_entities,
9249 &baseline_candidates,
9250 &publication_control,
9251 )?;
9252 let baseline_node = test_file_node("src/lib.rs", "rust");
9253 {
9254 let mut publication = store.begin_index_publication("estimator-cancellation")?;
9255 publication.begin_scan_replacement()?;
9256 publication.upsert_scan_node_batch(std::slice::from_ref(&baseline_node))?;
9257 publication.finish_scan_replacement()?;
9258 baseline_staged.apply(&mut publication, &publication_control)?;
9259 publication.complete()?;
9260 }
9261 let publication_before = store
9262 .index_publication()?
9263 .ok_or("estimator cancellation baseline publication is missing")?;
9264
9265 let document_facts: BTreeMap<String, Cow<'_, projectatlas_symbols::MarkdownFacts>> = (0
9266 ..DOCUMENT_COUNT)
9267 .map(|document| {
9268 let path = format!("content/source-{document:03}.md");
9269 let file_name = path
9270 .rsplit_once('/')
9271 .map_or(path.as_str(), |(_parent, file_name)| file_name);
9272 let source = (0..CANDIDATES_PER_DOCUMENT)
9273 .map(|index| format!("[self-{index:04}]({file_name})"))
9274 .collect::<Vec<_>>()
9275 .join("\n");
9276 (
9277 path,
9278 Cow::Owned(projectatlas_symbols::extract_markdown_facts(&source)),
9279 )
9280 })
9281 .collect::<BTreeMap<_, _>>();
9282 let candidate_count = document_facts
9283 .values()
9284 .map(|facts| facts.link_candidates.len())
9285 .sum::<usize>();
9286 require_eq(
9287 &candidate_count,
9288 &(DOCUMENT_COUNT * CANDIDATES_PER_DOCUMENT),
9289 "estimator cancellation candidate count",
9290 )?;
9291
9292 let expired_control =
9293 IndexWorkControl::with_deadline(IndexCancellation::new(), Instant::now());
9294 let error = document_projection_retained_bytes(&document_facts, &expired_control)
9295 .err()
9296 .ok_or("expired estimator unexpectedly traversed all candidates")?;
9297 require(
9298 matches!(
9299 error,
9300 CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
9301 stage: IndexWorkStage::SymbolParsing,
9302 })
9303 ),
9304 "estimator deadline did not return its typed graph-work failure",
9305 )?;
9306 require_eq(
9307 &store.index_publication()?,
9308 &Some(publication_before.clone()),
9309 "estimator deadline changed the current generation",
9310 )?;
9311
9312 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
9313 let retained_bytes = document_projection_retained_bytes(&document_facts, &retry_control)?;
9314 require_eq(
9315 &retained_bytes,
9316 &0,
9317 "same-file candidates entered the retained projection estimate",
9318 )?;
9319 let retry_graphs = document_facts
9320 .iter()
9321 .map(|(path, facts)| facts.symbol_graph(path, Some("markdown")))
9322 .collect::<Vec<_>>();
9323 let retry_nodes = document_facts
9324 .keys()
9325 .map(|path| test_file_node(path, "markdown"))
9326 .collect::<Vec<_>>();
9327 let retry_scan_policy =
9328 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
9329 let retry_packages = PackageIndex::from_graphs(&retry_graphs)?;
9330 let retry_entities = build_entity_projection(
9331 project,
9332 IndexGeneration::new(2),
9333 &retry_nodes,
9334 &retry_graphs,
9335 &retry_packages,
9336 false,
9337 &retry_control,
9338 )?;
9339 let retry_candidates = resolution_registry_from_exports(&retry_entities, &retry_control)?;
9340 let retried_projection = finish_projection_with_documents(
9341 project,
9342 IndexGeneration::new(2),
9343 RepositoryGraphMutation::Full,
9344 &retry_graphs,
9345 &root,
9346 &retry_nodes,
9347 &document_facts,
9348 &GraphIdentityAdmission::default(),
9349 retry_entities,
9350 &retry_candidates,
9351 &retry_scan_policy,
9352 &retry_control,
9353 )?;
9354 require(
9355 retried_projection.relations.is_empty()
9356 && retried_projection.document_unresolved_reasons.is_empty(),
9357 "same-file retry emitted document projection rows",
9358 )?;
9359 require_eq(
9360 &store.index_publication()?,
9361 &Some(publication_before),
9362 "estimator retry changed the current generation without publication",
9363 )?;
9364 Ok(())
9365 }
9366
9367 #[test]
9368 fn incremental_document_projection_overflow_requests_full_refresh_staging()
9369 -> Result<(), Box<dyn Error>> {
9370 const DOCUMENT_COUNT: usize = 513;
9371 let temp = tempfile::tempdir()?;
9372 let root = fs::canonicalize(temp.path())?;
9373 let database = root.join("projectatlas.db");
9374 let mut store = AtlasStore::open_for_project(&database, &root)?;
9375 let project = store
9376 .project_instance_id()?
9377 .ok_or("incremental document budget project identity is missing")?;
9378 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9379 let template = projectatlas_symbols::extract_markdown_facts("[missing](shared.md)");
9380 let candidate = template
9381 .link_candidates
9382 .first()
9383 .cloned()
9384 .ok_or("document budget candidate fixture is missing")?;
9385 let mut template = template;
9386 while template.link_candidates.len() < MAX_DOCUMENT_LINK_CANDIDATES {
9387 template.link_candidates.push(candidate.clone());
9388 }
9389
9390 let mut nodes = Vec::with_capacity(DOCUMENT_COUNT);
9391 let mut graphs = Vec::with_capacity(DOCUMENT_COUNT);
9392 let mut changes = Vec::with_capacity(DOCUMENT_COUNT);
9393 let mut paths = Vec::with_capacity(DOCUMENT_COUNT);
9394 for document in 0..DOCUMENT_COUNT {
9395 let path = format!("content/source-{document:03}.md");
9396 let graph = template.symbol_graph(&path, Some("markdown"));
9397 nodes.push(test_file_node(&path, "markdown"));
9398 paths.push(path.clone());
9399 graphs.push(graph.clone());
9400 changes.push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
9401 path,
9402 graph,
9403 markdown_facts: Some(Box::new(template.clone())),
9404 source_parser: ParserKind::Structural,
9405 summary: String::new(),
9406 summary_is_structural: true,
9407 purpose_suggestion: None,
9408 }));
9409 }
9410 store.replace_scan(&nodes)?;
9411 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
9412 let packages = PackageIndex::from_graphs(&graphs)?;
9413 let entities = build_entity_projection(
9414 project,
9415 IndexGeneration::new(1),
9416 &nodes,
9417 &graphs,
9418 &packages,
9419 false,
9420 &control,
9421 )?;
9422 let candidates = resolution_registry_from_exports(&entities, &control)?;
9423 let projected_facts = paths
9424 .iter()
9425 .map(|path| (path.clone(), Cow::Borrowed(&template)))
9426 .collect::<BTreeMap<_, _>>();
9427 let pre_document_bytes = entities
9428 .retained_bytes
9429 .saturating_add(candidates.retained_bytes)
9430 .saturating_add(document_fact_map_retained_bytes(&projected_facts));
9431 let document_projection_bytes =
9432 document_projection_retained_bytes(&projected_facts, &control)?;
9433 require(
9434 pre_document_bytes < MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9435 "pre-document incremental state already exceeded the staging budget",
9436 )?;
9437 require(
9438 pre_document_bytes.saturating_add(document_projection_bytes)
9439 > MAX_IN_MEMORY_GRAPH_WORK_BYTES,
9440 "document projection did not cross the aggregate staging budget",
9441 )?;
9442
9443 let mut symbols = empty_symbol_build_stage();
9444 symbols.changes = changes;
9445 let direct_paths = paths.clone();
9446 let error = stage_incremental_repository_graph(
9447 &store,
9448 &root,
9449 IndexGeneration::new(0),
9450 &nodes,
9451 &direct_paths,
9452 &scan_policy,
9453 &symbols,
9454 &control,
9455 )
9456 .err()
9457 .ok_or("document projection overflow was admitted incrementally")?;
9458 let CliError::RefreshRequired(report) = error else {
9459 return Err(io::Error::other(format!(
9460 "expected typed full-refresh guidance, found {error:?}"
9461 ))
9462 .into());
9463 };
9464 require_eq(
9465 &report.reason,
9466 &IndexRefreshReason::DependencyClosureLimit,
9467 "document projection overflow reason",
9468 )?;
9469 require_eq(
9470 &report.scope,
9471 &IndexRefreshScope::Full,
9472 "document projection overflow scope",
9473 )?;
9474
9475 let staged = stage_full_repository_graph(
9476 &store,
9477 &root,
9478 IndexGeneration::new(0),
9479 &nodes,
9480 &scan_policy,
9481 &symbols,
9482 &control,
9483 )?;
9484 require(
9485 staged.database.is_some(),
9486 "full-refresh guidance did not select disposable SQLite staging",
9487 )?;
9488 Ok(())
9489 }
9490
9491 #[test]
9492 fn resolution_registry_reuses_staged_export_entities() -> Result<(), Box<dyn Error>> {
9493 let project = ProjectInstanceId::from_bytes([2; 16])?;
9494 let generation = IndexGeneration::new(1);
9495 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9496 let graphs = vec![
9497 extract_symbol_graph(
9498 "Cargo.toml",
9499 Some("cargo-manifest"),
9500 "[package]\nname = \"atlas\"\n",
9501 ),
9502 extract_symbol_graph("src/lib.rs", Some("rust"), "pub fn run() {}\n"),
9503 ];
9504 let packages = PackageIndex::from_graphs(&graphs)?;
9505 let projection =
9506 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
9507 let registry = resolution_registry_from_exports(&projection, &control)?;
9508 let registered_bindings = registry
9509 .candidate_digests_by_key
9510 .values()
9511 .map(BTreeSet::len)
9512 .sum::<usize>();
9513
9514 require_eq(
9515 ®istry.supplemental_entities_by_digest.len(),
9516 &0,
9517 "duplicate registry-owned entity count",
9518 )?;
9519 require_eq(
9520 ®istered_bindings,
9521 &projection.entity_exports.len(),
9522 "registry key binding count",
9523 )?;
9524 require(
9525 registry
9526 .candidate_digests_by_key
9527 .values()
9528 .flatten()
9529 .all(|digest| projection.entity_by_digest.contains_key(digest)),
9530 "resolution key referenced an unstaged entity digest",
9531 )?;
9532 let mut bindings_per_entity = BTreeMap::<&str, usize>::new();
9533 for digest in registry.candidate_digests_by_key.values().flatten() {
9534 *bindings_per_entity.entry(digest).or_default() += 1;
9535 }
9536 require(
9537 bindings_per_entity.values().any(|count| *count > 1),
9538 "fixture did not exercise one entity exported under multiple canonical keys",
9539 )?;
9540 Ok(())
9541 }
9542
9543 #[test]
9544 fn restart_cleanup_removes_only_inactive_owned_graph_stages() -> Result<(), Box<dyn Error>> {
9545 let temp = tempfile::tempdir()?;
9546 let root = temp.path().join("restart-cleanup");
9547 let atlas_dir = root.join(".projectatlas");
9548 fs::create_dir_all(&atlas_dir)?;
9549 let database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9550 let store = AtlasStore::open_for_project(&database, &root)?;
9551 let project = store
9552 .project_instance_id()?
9553 .ok_or("bound project identity is missing")?;
9554
9555 let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}owned"));
9556 fs::create_dir(&owned)?;
9557 let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9558 drop(AtlasStore::create_repository_graph_staging(
9559 &owned_database,
9560 &root,
9561 project,
9562 )?);
9563 let owned_payload = owned.join("payload");
9564 fs::create_dir(&owned_payload)?;
9565 fs::write(owned_payload.join("row"), "discard")?;
9566 let owned_link_target = temp.path().join("owned-link-target");
9567 fs::create_dir(&owned_link_target)?;
9568 fs::write(owned_link_target.join("sentinel"), "preserve")?;
9569 let owned_payload_link = owned.join("linked-payload");
9570 create_directory_link(&owned_link_target, &owned_payload_link)?;
9571 let interrupted_shell =
9572 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}interrupted-shell"));
9573 fs::create_dir(&interrupted_shell)?;
9574 let unvalidated_nonempty = atlas_dir.join(format!(
9575 "{GRAPH_STAGE_DIRECTORY_PREFIX}unvalidated-nonempty"
9576 ));
9577 fs::create_dir(&unvalidated_nonempty)?;
9578 fs::write(unvalidated_nonempty.join("sentinel"), "preserve")?;
9579 let lookalike = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}lookalike"));
9580 fs::create_dir(&lookalike)?;
9581 drop(AtlasStore::open_for_project(
9582 &lookalike.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9583 &root,
9584 )?);
9585 let foreign_root = temp.path().join("foreign-project");
9586 fs::create_dir(&foreign_root)?;
9587 let foreign_store =
9588 AtlasStore::open_for_project(&foreign_root.join("projectatlas.db"), &foreign_root)?;
9589 let foreign_project = foreign_store
9590 .project_instance_id()?
9591 .ok_or("foreign project identity is missing")?;
9592 let foreign_project_stage =
9593 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-project"));
9594 fs::create_dir(&foreign_project_stage)?;
9595 drop(AtlasStore::create_repository_graph_staging(
9596 &foreign_project_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9597 &root,
9598 foreign_project,
9599 )?);
9600 let foreign_root_stage =
9601 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}foreign-root"));
9602 fs::create_dir(&foreign_root_stage)?;
9603 drop(AtlasStore::create_repository_graph_staging(
9604 &foreign_root_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9605 &foreign_root,
9606 project,
9607 )?);
9608 let linked_stage_target = temp.path().join("linked-stage-target");
9609 fs::create_dir(&linked_stage_target)?;
9610 fs::write(linked_stage_target.join("sentinel"), "preserve")?;
9611 drop(AtlasStore::create_repository_graph_staging(
9612 &linked_stage_target.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9613 &root,
9614 project,
9615 )?);
9616 let linked_stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked"));
9617 create_directory_link(&linked_stage_target, &linked_stage)?;
9618
9619 let linked_database_target = temp.path().join("linked-stage-database.db");
9620 drop(AtlasStore::create_repository_graph_staging(
9621 &linked_database_target,
9622 &root,
9623 project,
9624 )?);
9625 let linked_database_stage =
9626 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}linked-database"));
9627 fs::create_dir(&linked_database_stage)?;
9628 let linked_database = linked_database_stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9629 let linked_database_created =
9630 match create_file_link(&linked_database_target, &linked_database) {
9631 Ok(()) => true,
9632 #[cfg(windows)]
9633 Err(source) if source.raw_os_error() == Some(1314) => false,
9634 Err(source) => return Err(source.into()),
9635 };
9636 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9637
9638 let lease = try_graph_stage_lease(&atlas_dir)?
9639 .ok_or("test could not acquire graph staging lease")?;
9640 remove_owned_graph_stage_payload(&owned, &owned_database, Some(&control))?;
9641 require(
9642 owned_database.is_file() && !owned_payload.exists(),
9643 "payload cleanup did not retain the ownership database until last",
9644 )?;
9645 require(
9646 fs::symlink_metadata(&owned_payload_link).is_err()
9647 && owned_link_target.join("sentinel").is_file(),
9648 "payload cleanup followed or retained a linked child",
9649 )?;
9650 cleanup_abandoned_graph_staging(&root, project, &control)?;
9651 require(
9652 owned.exists(),
9653 "restart cleanup removed an actively leased stage",
9654 )?;
9655 drop(lease);
9656
9657 let canceled_control = IndexWorkControl::new(IndexCancellation::new(), None);
9658 canceled_control.cancel();
9659 let canceled = cleanup_abandoned_graph_staging(&root, project, &canceled_control)
9660 .err()
9661 .ok_or("canceled restart cleanup unexpectedly succeeded")?;
9662 require(
9663 matches!(
9664 canceled,
9665 CliError::IndexWork(IndexWorkFailure::Cancelled {
9666 stage: IndexWorkStage::Publication
9667 })
9668 ),
9669 "restart cleanup did not preserve typed cancellation",
9670 )?;
9671 require(
9672 owned.exists(),
9673 "canceled restart cleanup removed an owned stage",
9674 )?;
9675
9676 cleanup_abandoned_graph_staging(&root, project, &control)?;
9677 require(
9678 !owned.exists(),
9679 "restart cleanup retained an inactive owned stage",
9680 )?;
9681 require(
9682 !interrupted_shell.exists(),
9683 "restart cleanup retained an empty interrupted stage shell",
9684 )?;
9685 require(
9686 unvalidated_nonempty.join("sentinel").is_file(),
9687 "restart cleanup removed a non-empty unvalidated stage",
9688 )?;
9689 require(
9690 lookalike.exists(),
9691 "restart cleanup removed an unvalidated lookalike stage",
9692 )?;
9693 require(
9694 foreign_project_stage.exists(),
9695 "restart cleanup removed a valid stage owned by another project",
9696 )?;
9697 require(
9698 foreign_root_stage.exists(),
9699 "restart cleanup removed a valid stage bound to another root",
9700 )?;
9701 require(
9702 fs::symlink_metadata(&linked_stage).is_ok()
9703 && linked_stage_target.join("sentinel").is_file(),
9704 "restart cleanup followed a linked stage directory",
9705 )?;
9706 if linked_database_created {
9707 require(
9708 fs::symlink_metadata(&linked_database).is_ok() && linked_database_target.is_file(),
9709 "restart cleanup followed a linked staging database",
9710 )?;
9711 }
9712 Ok(())
9713 }
9714
9715 #[test]
9716 fn restart_cleanup_reclaims_schema_nineteen_owned_graph_stage() -> Result<(), Box<dyn Error>> {
9717 let temp = tempfile::tempdir()?;
9718 let root = temp.path().join("schema-19-restart-cleanup");
9719 let atlas_dir = root.join(".projectatlas");
9720 fs::create_dir_all(&atlas_dir)?;
9721 let main_database = atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9722 let store = AtlasStore::open_for_project(&main_database, &root)?;
9723 let project = store
9724 .project_instance_id()?
9725 .ok_or("bound project identity is missing")?;
9726 let prepare_schema_nineteen =
9727 |stage: &Path, stage_root: &Path, stage_project: ProjectInstanceId| {
9728 fs::create_dir(stage)?;
9729 let database = stage.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9730 drop(AtlasStore::create_repository_graph_staging(
9731 &database,
9732 stage_root,
9733 stage_project,
9734 )?);
9735 let connection = Connection::open(database)?;
9736 drop_native_worktree_identity_schema(&connection)?;
9737 connection.execute_batch(
9738 "DROP TABLE project_root_identity;
9739 DROP TABLE IF EXISTS graph_identity_rejections;
9740 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9741 )?;
9742 Ok::<(), Box<dyn Error>>(())
9743 };
9744
9745 let owned = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-owned"));
9746 prepare_schema_nineteen(&owned, &root, project)?;
9747 let owned_database = owned.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9748 let owned_matches =
9749 AtlasStore::repository_graph_staging_belongs_to(&owned_database, &root, project)?;
9750 #[cfg(windows)]
9751 require(
9752 owned_matches,
9753 "schema-19 owned staging database was not admitted",
9754 )?;
9755 #[cfg(unix)]
9756 require(
9757 owned_matches,
9758 "schema-19 staging database was not admitted by its durable staging ownership",
9759 )?;
9760 fs::write(owned.join("large-graph-payload"), b"stale graph payload")?;
9761
9762 let foreign_root = temp.path().join("schema-19-foreign-root");
9763 fs::create_dir(&foreign_root)?;
9764 let foreign = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema19-foreign"));
9765 let foreign_root_project = ProjectInstanceId::from_bytes([9; 16])?;
9766 prepare_schema_nineteen(&foreign, &foreign_root, foreign_root_project)?;
9767 let foreign_database = foreign.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9768 require(
9769 !AtlasStore::repository_graph_staging_belongs_to(&foreign_database, &root, project)?,
9770 "schema-19 unrelated-root staging database was admitted",
9771 )?;
9772
9773 let schema_eighteen = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}schema18"));
9774 prepare_schema_nineteen(&schema_eighteen, &root, project)?;
9775 let schema_eighteen_database = schema_eighteen.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9776 let connection = Connection::open(&schema_eighteen_database)?;
9777 connection.execute(
9778 "UPDATE metadata SET value = '18' WHERE key = 'schema_version'",
9779 [],
9780 )?;
9781 require(
9782 !AtlasStore::repository_graph_staging_belongs_to(
9783 &schema_eighteen_database,
9784 &root,
9785 project,
9786 )?,
9787 "schema-18 staging database was admitted as a schema-19 predecessor",
9788 )?;
9789
9790 let incomplete_current =
9791 atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}current-incomplete"));
9792 fs::create_dir(&incomplete_current)?;
9793 let incomplete_current_database = incomplete_current.join(GRAPH_STAGE_DATABASE_FILE_NAME);
9794 drop(AtlasStore::create_repository_graph_staging(
9795 &incomplete_current_database,
9796 &root,
9797 project,
9798 )?);
9799 let connection = Connection::open(&incomplete_current_database)?;
9800 connection.execute_batch(
9801 "DROP TABLE project_root_identity; DROP TABLE IF EXISTS graph_identity_rejections;",
9802 )?;
9803 require(
9804 !AtlasStore::repository_graph_staging_belongs_to(
9805 &incomplete_current_database,
9806 &root,
9807 project,
9808 )
9809 .unwrap_or(false),
9810 "current staging database without native identity was admitted",
9811 )?;
9812
9813 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9814 cleanup_abandoned_graph_staging(&root, project, &control)?;
9815 #[cfg(windows)]
9816 require(
9817 !owned.exists(),
9818 "restart cleanup retained an owned schema-19 staging database",
9819 )?;
9820 #[cfg(unix)]
9821 require(
9822 !owned.exists(),
9823 "restart cleanup retained an owned schema-19 staging database",
9824 )?;
9825 require(
9826 foreign.exists(),
9827 "restart cleanup removed an unrelated schema-19 staging database",
9828 )?;
9829 require(
9830 schema_eighteen.exists(),
9831 "restart cleanup removed a non-predecessor schema-18 staging database",
9832 )?;
9833 require(
9834 incomplete_current.exists(),
9835 "restart cleanup removed a current staging database without native identity",
9836 )?;
9837 Ok(())
9838 }
9839
9840 #[test]
9841 fn restart_cleanup_observes_cancellation_between_owned_stages() -> Result<(), Box<dyn Error>> {
9842 const STAGE_COUNT: usize = 64;
9843 const FILES_PER_STAGE: usize = 64;
9844
9845 let temp = tempfile::tempdir()?;
9846 let root = temp.path().join("restart-cleanup-cancellation");
9847 let atlas_dir = root.join(".projectatlas");
9848 fs::create_dir_all(&atlas_dir)?;
9849 let store =
9850 AtlasStore::open_for_project(&atlas_dir.join(GRAPH_STAGE_DATABASE_FILE_NAME), &root)?;
9851 let project = store
9852 .project_instance_id()?
9853 .ok_or("bound project identity is missing")?;
9854 let mut stages = Vec::with_capacity(STAGE_COUNT);
9855 for stage_index in 0..STAGE_COUNT {
9856 let stage = atlas_dir.join(format!("{GRAPH_STAGE_DIRECTORY_PREFIX}{stage_index:03}"));
9857 fs::create_dir(&stage)?;
9858 drop(AtlasStore::create_repository_graph_staging(
9859 &stage.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9860 &root,
9861 project,
9862 )?);
9863 let payload = stage.join("payload");
9864 fs::create_dir(&payload)?;
9865 for file_index in 0..FILES_PER_STAGE {
9866 fs::write(payload.join(format!("{file_index:03}")), b"x")?;
9867 }
9868 stages.push(stage);
9869 }
9870
9871 let cancellation = IndexCancellation::new();
9872 let control = IndexWorkControl::new(cancellation.clone(), None);
9873 let worker_root = root;
9874 let worker =
9875 thread::spawn(move || cleanup_abandoned_graph_staging(&worker_root, project, &control));
9876 let observation_deadline = Instant::now() + Duration::from_secs(30);
9877 loop {
9878 let remaining = stages.iter().filter(|stage| stage.exists()).count();
9879 if remaining < STAGE_COUNT {
9880 cancellation.cancel();
9881 break;
9882 }
9883 if worker.is_finished() {
9884 return Err(io::Error::other(
9885 "restart cleanup completed before in-flight cancellation was observed",
9886 )
9887 .into());
9888 }
9889 if Instant::now() >= observation_deadline {
9890 cancellation.cancel();
9891 return Err(io::Error::other(
9892 "restart cleanup removed no stage within the test deadline",
9893 )
9894 .into());
9895 }
9896 thread::yield_now();
9897 }
9898 let result = worker
9899 .join()
9900 .map_err(|_panic| io::Error::other("restart cleanup worker panicked"))?;
9901 require(
9902 matches!(
9903 result,
9904 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
9905 stage: IndexWorkStage::Publication
9906 }))
9907 ),
9908 "restart cleanup did not return typed in-flight cancellation",
9909 )?;
9910 let remaining = stages.iter().filter(|stage| stage.exists()).count();
9911 require(
9912 remaining > 0 && remaining < STAGE_COUNT,
9913 "in-flight cancellation did not preserve a partial cleanup boundary",
9914 )
9915 }
9916
9917 #[test]
9918 fn staged_database_owner_retains_incomplete_creation() -> Result<(), Box<dyn Error>> {
9919 let temp = tempfile::tempdir()?;
9920 let atlas_dir = temp.path().join(".projectatlas");
9921 fs::create_dir(&atlas_dir)?;
9922 let directory = tempfile::Builder::new()
9923 .prefix(GRAPH_STAGE_DIRECTORY_PREFIX)
9924 .tempdir_in(&atlas_dir)?;
9925 let staging_path = directory.path().to_path_buf();
9926 fs::write(
9927 staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME),
9928 "incomplete",
9929 )?;
9930 fs::write(staging_path.join("payload"), "preserve")?;
9931 let lease = try_graph_stage_lease(&atlas_dir)?
9932 .ok_or("test could not acquire graph staging lease")?;
9933 let owner = super::StagedGraphDatabase {
9934 store: None,
9935 directory: Some(directory),
9936 _lease: lease,
9937 };
9938
9939 drop(owner);
9940
9941 require(
9942 staging_path.join(GRAPH_STAGE_DATABASE_FILE_NAME).is_file()
9943 && staging_path.join("payload").is_file(),
9944 "incomplete staging creation was recursively deleted",
9945 )
9946 }
9947
9948 #[test]
9949 fn database_staging_publishes_and_removes_its_disposable_store() -> Result<(), Box<dyn Error>> {
9950 let temp = tempfile::tempdir()?;
9951 let root = temp.path().join("database-staging");
9952 let atlas_dir = root.join(".projectatlas");
9953 fs::create_dir_all(&atlas_dir)?;
9954 let database = atlas_dir.join("projectatlas.db");
9955 let mut store = AtlasStore::open_for_project(&database, &root)?;
9956 let project = store
9957 .project_instance_id()?
9958 .ok_or("bound project identity is missing")?;
9959 let generation = IndexGeneration::new(1);
9960 let control = IndexWorkControl::new(IndexCancellation::new(), None);
9961 let graphs = vec![extract_symbol_graph(
9962 "src/lib.rs",
9963 Some("rust"),
9964 "pub fn caller() { helper(); }\nfn helper() {}\n",
9965 )];
9966 let nodes = vec![test_file_node("src/lib.rs", "rust")];
9967 let packages = PackageIndex::from_graphs(&graphs)?;
9968 let projection = build_entity_projection(
9969 project, generation, &nodes, &graphs, &packages, true, &control,
9970 )?;
9971 let candidates = resolution_registry_from_exports(&projection, &control)?;
9972 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
9973 let staged = finish_projection_in_database(
9974 &root,
9975 &nodes,
9976 project,
9977 generation,
9978 &graphs,
9979 projection,
9980 &candidates,
9981 &scan_policy,
9982 &control,
9983 )?;
9984 let staging_path = staged
9985 .database
9986 .as_ref()
9987 .ok_or("database staging was not selected")?
9988 .directory()?
9989 .path()
9990 .to_path_buf();
9991 let drop_payload = staging_path.join("drop-payload");
9992 fs::create_dir(&drop_payload)?;
9993 fs::write(drop_payload.join("row"), "discard")?;
9994 let drop_link_target = temp.path().join("drop-link-target");
9995 fs::create_dir(&drop_link_target)?;
9996 fs::write(drop_link_target.join("sentinel"), "preserve")?;
9997 create_directory_link(&drop_link_target, &staging_path.join("drop-linked-payload"))?;
9998 require(
9999 staging_path.exists(),
10000 "database staging directory is missing",
10001 )?;
10002 {
10003 let mut publication = store.begin_index_publication("database-staging")?;
10004 publication.begin_scan_replacement()?;
10005 publication.upsert_scan_node_batch(&nodes)?;
10006 publication.finish_scan_replacement()?;
10007 staged.apply(&mut publication, &control)?;
10008 publication.complete()?;
10009 }
10010 drop(staged);
10011 require(
10012 !staging_path.exists(),
10013 "database staging directory survived publication",
10014 )?;
10015 require(
10016 drop_link_target.join("sentinel").is_file(),
10017 "database staging drop followed a linked payload",
10018 )?;
10019 drop(store);
10020
10021 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
10022 let coverage = reader.repository_graph_coverage(
10023 project,
10024 &CoverageScope::Path {
10025 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
10026 },
10027 8,
10028 )?;
10029 require(
10030 !coverage.truncated,
10031 "database-staged coverage was truncated",
10032 )?;
10033 require(
10034 !coverage.rows.is_empty(),
10035 "database-staged graph rows were not published",
10036 )?;
10037 Ok(())
10038 }
10039
10040 #[test]
10041 fn staged_identity_rejections_count_toward_normal_and_database_bytes()
10042 -> Result<(), Box<dyn Error>> {
10043 let temp = tempfile::tempdir()?;
10044 let root = temp.path().join("identity-rejection-bytes");
10045 fs::create_dir_all(root.join("src"))?;
10046 fs::write(root.join("src/lib.rs"), "pub fn indexed() {}\n")?;
10047 let database = root.join("projectatlas.db");
10048 let store = AtlasStore::open_for_project(&database, &root)?;
10049 let project = store
10050 .project_instance_id()?
10051 .ok_or("identity rejection fixture project identity is missing")?;
10052 let generation = IndexGeneration::new(1);
10053 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10054 let graphs = vec![extract_symbol_graph(
10055 "src/lib.rs",
10056 Some("rust"),
10057 "pub fn indexed() {}\n",
10058 )];
10059 let nodes = vec![test_file_node("src/lib.rs", "rust")];
10060 let packages = PackageIndex::from_graphs(&graphs)?;
10061 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
10062 let mut admission = GraphIdentityAdmission::default();
10063 let rejection_path = format!("src/{}.rs", "long-rejection-path".repeat(64));
10064 admission.record(
10065 &rejection_path,
10066 super::IdentitySpan {
10067 start_line: 4,
10068 start_column: 0,
10069 end_line: 4,
10070 end_column: 12,
10071 },
10072 ParserKind::TreeSitter,
10073 7,
10074 &[(
10075 GraphIdentityField::Symbol,
10076 GraphIdentityRejectionReason::Oversized,
10077 )],
10078 &control,
10079 )?;
10080 let rejection_bytes = identity_rejection_keys_retained_bytes(&admission.rejection_keys)?;
10081 require(
10082 rejection_bytes > 1,
10083 "identity rejection fixture did not retain a bounded detail payload",
10084 )?;
10085 let build_projection = || {
10086 let projection = build_entity_projection(
10087 project, generation, &nodes, &graphs, &packages, true, &control,
10088 )?;
10089 let candidates = resolution_registry_from_exports(&projection, &control)?;
10090 Result::<_, Box<dyn Error>>::Ok((projection, candidates))
10091 };
10092
10093 let (projection, candidates) = build_projection()?;
10094 let baseline = finish_projection_with_documents(
10095 project,
10096 generation,
10097 RepositoryGraphMutation::Full,
10098 &graphs,
10099 &root,
10100 &nodes,
10101 &BTreeMap::new(),
10102 &GraphIdentityAdmission::default(),
10103 projection,
10104 &candidates,
10105 &scan_policy,
10106 &control,
10107 )?;
10108 let (projection, candidates) = build_projection()?;
10109 let in_memory = finish_projection_with_documents(
10110 project,
10111 generation,
10112 RepositoryGraphMutation::Full,
10113 &graphs,
10114 &root,
10115 &nodes,
10116 &BTreeMap::new(),
10117 &admission,
10118 projection,
10119 &candidates,
10120 &scan_policy,
10121 &control,
10122 )?;
10123 require_eq(
10124 &in_memory.retained_bytes(),
10125 &baseline.retained_bytes().saturating_add(rejection_bytes),
10126 "in-memory identity rejection retained bytes",
10127 )?;
10128 require_eq(
10129 &in_memory.identity_rejections.len(),
10130 &1,
10131 "in-memory identity rejection detail count",
10132 )?;
10133 let parent_prefix = super::super::MAX_PUBLICATION_STAGING_BYTES
10134 .checked_sub(in_memory.retained_bytes())
10135 .ok_or("identity rejection fixture exceeded the publication budget")?;
10136 require(
10137 super::super::enforce_publication_staging_budget(
10138 parent_prefix.saturating_add(in_memory.retained_bytes()),
10139 )
10140 .is_ok(),
10141 "parent publication budget rejected exact staged identity bytes",
10142 )?;
10143 require(
10144 super::super::enforce_publication_staging_budget(
10145 parent_prefix
10146 .saturating_add(in_memory.retained_bytes())
10147 .saturating_add(1),
10148 )
10149 .is_err(),
10150 "parent publication budget accepted one byte over staged identity bytes",
10151 )?;
10152 drop(in_memory);
10153 drop(baseline);
10154
10155 let (projection, candidates) = build_projection()?;
10156 let baseline_database = finish_projection_in_database_with_documents(
10157 &root,
10158 &nodes,
10159 project,
10160 generation,
10161 &graphs,
10162 &BTreeMap::new(),
10163 &GraphIdentityAdmission::default(),
10164 projection,
10165 &candidates,
10166 &scan_policy,
10167 &control,
10168 )?;
10169 let baseline_database_path_bytes = baseline_database
10170 .database
10171 .as_ref()
10172 .ok_or("database staging baseline was not selected")?
10173 .directory()?
10174 .path()
10175 .join(GRAPH_STAGE_DATABASE_FILE_NAME)
10176 .as_os_str()
10177 .as_encoded_bytes()
10178 .len() as u64;
10179 require_eq(
10180 &baseline_database.retained_bytes(),
10181 &baseline_database_path_bytes,
10182 "database staging baseline retained bytes",
10183 )?;
10184 drop(baseline_database);
10185
10186 let (projection, candidates) = build_projection()?;
10187 let database_staged = finish_projection_in_database_with_documents(
10188 &root,
10189 &nodes,
10190 project,
10191 generation,
10192 &graphs,
10193 &BTreeMap::new(),
10194 &admission,
10195 projection,
10196 &candidates,
10197 &scan_policy,
10198 &control,
10199 )?;
10200 let database_path_bytes = database_staged
10201 .database
10202 .as_ref()
10203 .ok_or("database staging was not selected")?
10204 .directory()?
10205 .path()
10206 .join(GRAPH_STAGE_DATABASE_FILE_NAME)
10207 .as_os_str()
10208 .as_encoded_bytes()
10209 .len() as u64;
10210 require_eq(
10211 &database_staged.retained_bytes(),
10212 &database_path_bytes.saturating_add(rejection_bytes),
10213 "database staging identity rejection retained bytes",
10214 )?;
10215 Ok(())
10216 }
10217
10218 #[test]
10219 fn accepted_relation_families_publish_and_reopen() -> Result<(), Box<dyn Error>> {
10220 let temp = tempfile::tempdir()?;
10221 let root = temp.path().join("accepted-relation-families");
10222 for directory in ["src", "tests", "config", "infra", "data"] {
10223 fs::create_dir_all(root.join(directory))?;
10224 }
10225 let database = root.join("projectatlas.db");
10226 let mut store = AtlasStore::open_for_project(&database, &root)?;
10227 let project = store
10228 .project_instance_id()?
10229 .ok_or_else(|| io::Error::other("relation inventory identity is missing"))?;
10230 let generation = IndexGeneration::new(1);
10231 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10232 let graphs = vec![
10233 extract_symbol_graph(
10234 "Cargo.toml",
10235 Some("cargo-manifest"),
10236 concat!(
10237 "[package]\nname = \"relation-fixture\"\nversion = \"0.1.0\"\n",
10238 "\n[dependencies]\nserde = \"1\"\n",
10239 ),
10240 ),
10241 extract_symbol_graph(
10242 "src/lib.rs",
10243 Some("rust"),
10244 concat!(
10245 "use std::fs;\n",
10246 "pub struct Router { pub enabled: bool }\n",
10247 "impl Router { pub fn install(&self) {} }\n",
10248 "pub fn handler() {}\n",
10249 "pub fn register() {\n",
10250 " route(\"/health\", handler);\n",
10251 " let _ = std::env::var(\"ATLAS_MODE\").unwrap_or_else(|_| \"super-secret\".into());\n",
10252 " let _ = fs::read_to_string(\"data/input.txt\");\n",
10253 " let _ = fs::write(\"data/output.txt\", \"ok\");\n",
10254 "}\n",
10255 "fn route(_path: &str, _handler: fn()) {}\n",
10256 ),
10257 ),
10258 extract_symbol_graph(
10259 "tests/feature_test.rs",
10260 Some("rust"),
10261 "fn subject() {}\nfn verifies_subject() { subject(); }\n",
10262 ),
10263 extract_symbol_graph(
10264 "config/appsettings.json",
10265 Some("json"),
10266 "{\"token\":\"super-secret\"}\n",
10267 ),
10268 extract_symbol_graph(
10269 "infra/main.tf",
10270 Some("terraform"),
10271 "resource \"null_resource\" \"fixture\" {}\n",
10272 ),
10273 extract_symbol_graph("data/input.txt", None, "input\n"),
10274 extract_symbol_graph("data/output.txt", None, ""),
10275 ];
10276 let nodes = graphs
10277 .iter()
10278 .map(|graph| {
10279 test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
10280 })
10281 .collect::<Vec<_>>();
10282 let packages = PackageIndex::from_graphs(&graphs)?;
10283 let projection = build_entity_projection(
10284 project, generation, &nodes, &graphs, &packages, true, &control,
10285 )?;
10286 let candidates = resolution_registry_from_exports(&projection, &control)?;
10287 let staged = finish_projection(
10288 project,
10289 generation,
10290 RepositoryGraphMutation::Full,
10291 &graphs,
10292 projection,
10293 &candidates,
10294 &control,
10295 )?;
10296 {
10297 let mut publication = store.begin_index_publication("accepted-relation-families")?;
10298 publication.begin_scan_replacement()?;
10299 publication.upsert_scan_node_batch(&nodes)?;
10300 publication.finish_scan_replacement()?;
10301 staged.apply(&mut publication, &control)?;
10302 publication.complete()?;
10303 }
10304 drop(store);
10305
10306 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
10307 for capability in RELATION_FAMILY_CAPABILITIES
10308 .iter()
10309 .filter(|capability| capability.state == RelationFamilyState::Active)
10310 {
10311 for &family in capability.graph_relations {
10312 let page = reader.repository_graph_relations(
10313 RepositoryGraphRelationQuery::Family { relation: family },
10314 128,
10315 )?;
10316 require(!page.truncated, &format!("{family:?} page was truncated"))?;
10317 require(
10318 !page.rows.is_empty(),
10319 &format!("{family:?} had no reopened persisted relation"),
10320 )?;
10321 for relation in page.rows {
10322 let occurrences = reader.repository_graph_occurrences(&relation, 32)?;
10323 require(
10324 !occurrences.rows.is_empty() && !occurrences.truncated,
10325 &format!("{family:?} lost exact source occurrences"),
10326 )?;
10327 require(
10328 !format!("{relation:?}").contains("super-secret"),
10329 &format!("secret value escaped into persisted {family:?} relation"),
10330 )?;
10331 }
10332 }
10333 }
10334 Ok(())
10335 }
10336
10337 #[test]
10338 fn accepted_relation_families_abstain_without_static_evidence() -> Result<(), Box<dyn Error>> {
10339 let project = ProjectInstanceId::from_bytes([13; 16])?;
10340 let generation = IndexGeneration::new(1);
10341 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10342 let graphs = vec![
10343 extract_symbol_graph(
10344 "src/dynamic.rs",
10345 Some("rust"),
10346 concat!(
10347 "use std::fs;\n",
10348 "struct Client;\n",
10349 "impl Client { fn get(&self, _path: &str, _handler: fn()) {} }\n",
10350 "fn handler() {}\n",
10351 "fn dynamic(client: &Client, route_path: &str, key: &str, file: &str) {\n",
10352 " client.get(\"/health\", handler);\n",
10353 " route(route_path, handler);\n",
10354 " let _ = std::env::var(key);\n",
10355 " let _ = fs::read_to_string(file);\n",
10356 " let _ = fs::write(file, \"super-secret\");\n",
10357 "}\n",
10358 "fn route(_path: &str, _handler: fn()) {}\n",
10359 ),
10360 ),
10361 extract_symbol_graph(
10362 "src/escaping.rs",
10363 Some("rust"),
10364 concat!(
10365 "use std::fs;\n",
10366 "fn unsafe_paths() {\n",
10367 " let _ = fs::read_to_string(\"../secret.txt\");\n",
10368 " let _ = fs::write(\"C:/secret.txt\", \"super-secret\");\n",
10369 "}\n",
10370 ),
10371 ),
10372 extract_symbol_graph(
10373 "src/dynamic.js",
10374 Some("javascript"),
10375 concat!(
10376 "function handler() {}\n",
10377 "function middleware() {}\n",
10378 "function route(...args) {}\n",
10379 "route(\"/health\", handler, middleware);\n",
10380 ),
10381 ),
10382 extract_symbol_graph("docs/main.tf.example", None, "not infrastructure\n"),
10383 extract_symbol_graph("docs/k8s/README.md", None, "not infrastructure\n"),
10384 extract_symbol_graph("docs/cloudformation-notes.md", None, "not infrastructure\n"),
10385 extract_symbol_graph("config/settings.json.bak", None, "super-secret\n"),
10386 ];
10387 let packages = PackageIndex::from_graphs(&graphs)?;
10388 let projection =
10389 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
10390 let candidates = resolution_registry_from_exports(&projection, &control)?;
10391 let staged = finish_projection(
10392 project,
10393 generation,
10394 RepositoryGraphMutation::Full,
10395 &graphs,
10396 projection,
10397 &candidates,
10398 &control,
10399 )?;
10400 for family in [
10401 ExtendedRelationKind::Tests,
10402 ExtendedRelationKind::RoutesTo,
10403 ExtendedRelationKind::Configures,
10404 ExtendedRelationKind::Deploys,
10405 ExtendedRelationKind::Reads,
10406 ExtendedRelationKind::Writes,
10407 ] {
10408 require(
10409 staged
10410 .relations
10411 .iter()
10412 .all(|relation| relation.kind() != GraphRelationKind::Extended(family)),
10413 &format!("dynamic or lookalike input fabricated {family:?}"),
10414 )?;
10415 }
10416 require(
10417 staged
10418 .entities
10419 .iter()
10420 .all(|entity| !entity.key().canonical_identity().contains("super-secret")),
10421 "negative fixture leaked a secret into graph identity",
10422 )?;
10423 Ok(())
10424 }
10425
10426 #[test]
10427 fn qualified_symbol_identity_preserves_boundaries_and_compacts_stably()
10428 -> Result<(), Box<dyn Error>> {
10429 let name = GraphIdentityText::new("leaf")?;
10430 require_eq(
10431 &qualified_symbol_identity(&GraphIdentityText::new("outer")?, &name)?,
10432 &GraphIdentityText::new("outer::leaf")?,
10433 "shallow qualified identity",
10434 )?;
10435
10436 let exact_parent = GraphIdentityText::new(
10437 "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len()),
10438 )?;
10439 let exact = qualified_symbol_identity(&exact_parent, &name)?;
10440 require_eq(
10441 &exact.as_str().len(),
10442 &MAX_GRAPH_IDENTITY_BYTES,
10443 "exact-boundary qualified identity bytes",
10444 )?;
10445 require(
10446 exact.as_str().ends_with("::leaf"),
10447 "exact-boundary qualified identity changed its readable suffix",
10448 )?;
10449
10450 let first_overbound_parent = GraphIdentityText::new(
10451 "x".repeat(MAX_GRAPH_IDENTITY_BYTES - "::".len() - name.as_str().len() + 1),
10452 )?;
10453 let compact = qualified_symbol_identity(&first_overbound_parent, &name)?;
10454 require(
10455 compact.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES,
10456 "first overbound qualified identity remained oversized",
10457 )?;
10458 require(
10459 compact.as_str().ends_with("::leaf"),
10460 "compacted qualified identity lost its nearest symbol name",
10461 )?;
10462 require_eq(
10463 &qualified_symbol_identity(&first_overbound_parent, &name)?,
10464 &compact,
10465 "repeated compact qualified identity",
10466 )?;
10467
10468 let multibyte_parent = GraphIdentityText::new(format!(
10469 "{}x",
10470 "é".repeat((MAX_GRAPH_IDENTITY_BYTES - "::".len() - "界".len() - 1) / "é".len())
10471 ))?;
10472 let multibyte =
10473 qualified_symbol_identity(&multibyte_parent, &GraphIdentityText::new("界")?)?;
10474 require_eq(
10475 &multibyte.as_str().len(),
10476 &MAX_GRAPH_IDENTITY_BYTES,
10477 "multibyte exact-boundary qualified identity bytes",
10478 )?;
10479 require(
10480 multibyte.as_str().ends_with("::界"),
10481 "multibyte qualified identity changed its readable suffix",
10482 )?;
10483
10484 let parent_a = GraphIdentityText::new("a".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
10485 let parent_b = GraphIdentityText::new("b".repeat(MAX_GRAPH_IDENTITY_BYTES))?;
10486 let scoped_a = qualified_symbol_identity(&parent_a, &name)?;
10487 let scoped_b = qualified_symbol_identity(&parent_b, &name)?;
10488 require(
10489 scoped_a != scoped_b,
10490 "distinct deep ancestors with an equal suffix shared one identity",
10491 )?;
10492 require(
10493 scoped_a != qualified_symbol_identity(&parent_a, &GraphIdentityText::new("other")?)?,
10494 "distinct overbound candidates shared one compact identity",
10495 )?;
10496 let (literal_parent, _) = scoped_a
10497 .as_str()
10498 .rsplit_once("::")
10499 .ok_or_else(|| io::Error::other("compact scope omitted its readable suffix"))?;
10500 require(
10501 source_symbol_identity(literal_parent.to_string()).is_err(),
10502 "compact scope namespace remained admissible as an exact source parent",
10503 )?;
10504 Ok(())
10505 }
10506
10507 #[test]
10508 fn qualified_symbol_parents_reject_invalid_raw_components() -> Result<(), Box<dyn Error>> {
10509 for (name, parent) in [
10510 ("bad\nname".to_string(), None),
10511 ("valid".to_string(), Some("bad\nparent".to_string())),
10512 (
10513 format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal"),
10514 Some("parent".to_string()),
10515 ),
10516 (
10517 "valid".to_string(),
10518 Some(format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}literal")),
10519 ),
10520 (
10521 "x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
10522 Some("parent".to_string()),
10523 ),
10524 ] {
10525 let graph = SymbolGraph {
10526 path: "src/invalid.rs".to_string(),
10527 language: Some("rust".to_string()),
10528 parser: ParserKind::TreeSitter,
10529 symbols: vec![CodeSymbol {
10530 path: "src/invalid.rs".to_string(),
10531 language: Some("rust".to_string()),
10532 name,
10533 kind: SymbolKind::Function,
10534 signature: "fn valid()".to_string(),
10535 exported: false,
10536 documentation: None,
10537 line_start: 1,
10538 line_end: 1,
10539 source_selector: None,
10540 parent,
10541 parser: ParserKind::TreeSitter,
10542 detail: Some("function_item".to_string()),
10543 }],
10544 relations: Vec::new(),
10545 };
10546 require(
10547 qualified_symbol_parents(&graph).is_err(),
10548 "invalid raw symbol identity reached qualified derivation",
10549 )?;
10550 }
10551 Ok(())
10552 }
10553
10554 #[test]
10555 fn qualified_symbol_parents_bound_four_thousand_deep_scopes() -> Result<(), Box<dyn Error>> {
10556 const DEPTH: usize = 4_000;
10557 let names = (0..DEPTH)
10558 .map(|index| format!("scope_{index:04}_{}", "x".repeat(229)))
10559 .collect::<Vec<_>>();
10560 let graph = SymbolGraph {
10561 path: "src/deep.rs".to_string(),
10562 language: Some("rust".to_string()),
10563 parser: ParserKind::TreeSitter,
10564 symbols: names
10565 .iter()
10566 .enumerate()
10567 .map(|(index, name)| CodeSymbol {
10568 path: "src/deep.rs".to_string(),
10569 language: Some("rust".to_string()),
10570 name: name.clone(),
10571 kind: SymbolKind::Module,
10572 signature: name.clone(),
10573 exported: false,
10574 documentation: None,
10575 line_start: index + 1,
10576 line_end: DEPTH * 2 - index,
10577 source_selector: None,
10578 parent: index.checked_sub(1).map(|parent| names[parent].clone()),
10579 parser: ParserKind::TreeSitter,
10580 detail: Some("mod_item".to_string()),
10581 })
10582 .collect(),
10583 relations: Vec::new(),
10584 };
10585 let first = qualified_symbol_parents(&graph)?;
10586 require_eq(&first.len(), &DEPTH, "deep qualified parent count")?;
10587 require(
10588 first.first().is_some_and(Option::is_none),
10589 "deep root unexpectedly gained a parent",
10590 )?;
10591 require(
10592 first
10593 .iter()
10594 .flatten()
10595 .all(|parent| parent.as_str().len() <= MAX_GRAPH_IDENTITY_BYTES),
10596 "deep qualification retained an oversized parent",
10597 )?;
10598 require(
10599 first
10600 .iter()
10601 .flatten()
10602 .any(|parent| parent.as_str().starts_with("@projectatlas.scope.v1:")),
10603 "deep qualification never exercised compact scope identity",
10604 )?;
10605 require_eq(
10606 &qualified_symbol_parents(&graph)?,
10607 &first,
10608 "repeated deep qualified parents",
10609 )?;
10610 Ok(())
10611 }
10612
10613 #[test]
10614 fn qualified_symbol_scopes_produce_distinct_graph_entity_keys() -> Result<(), Box<dyn Error>> {
10615 let project = ProjectInstanceId::from_bytes([3; 16])?;
10616 let generation = IndexGeneration::new(1);
10617 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10618 for (path, language, source, expected_parents) in [
10619 (
10620 "src/lib.rs",
10621 "rust",
10622 concat!(
10623 "mod first { struct Runner; impl Runner { fn run(&self) {} } }\n",
10624 "mod second { struct Runner; impl Runner { fn run(&self) {} } }\n",
10625 ),
10626 ["first::Runner", "second::Runner"],
10627 ),
10628 (
10629 "src/Runner.java",
10630 "java",
10631 concat!(
10632 "class First { class Runner { void run() {} } }\n",
10633 "class Second { class Runner { void run() {} } }\n",
10634 ),
10635 ["First::Runner", "Second::Runner"],
10636 ),
10637 ] {
10638 let graph = extract_symbol_graph(path, Some(language), source);
10639 let method_indices = graph
10640 .symbols
10641 .iter()
10642 .enumerate()
10643 .filter_map(|(index, symbol)| {
10644 (symbol.kind == SymbolKind::Method && symbol.name == "run").then_some(index)
10645 })
10646 .collect::<Vec<_>>();
10647 require_eq(&method_indices.len(), &2, "scoped method count")?;
10648 let first_symbol = &graph.symbols[method_indices[0]];
10649 let second_symbol = &graph.symbols[method_indices[1]];
10650 require_eq(
10651 &first_symbol.parent.as_deref(),
10652 &Some("Runner"),
10653 "legacy leaf parent",
10654 )?;
10655 require_eq(
10656 &first_symbol.parent,
10657 &second_symbol.parent,
10658 "same legacy leaf parent",
10659 )?;
10660 require_eq(
10661 &first_symbol.signature,
10662 &second_symbol.signature,
10663 "same declaration signature",
10664 )?;
10665
10666 let graphs = vec![graph];
10667 let packages = PackageIndex::from_graphs(&graphs)?;
10668 let projection = build_entity_projection(
10669 project,
10670 generation,
10671 &[],
10672 &graphs,
10673 &packages,
10674 true,
10675 &control,
10676 )?;
10677 let owners = projection
10678 .owners_by_graph
10679 .get(path)
10680 .ok_or("scoped graph owners are missing")?;
10681 let first = owners.symbol_digests[method_indices[0]]
10682 .as_ref()
10683 .and_then(|digest| projection.entity_by_digest.get(digest))
10684 .ok_or("first scoped method entity is missing")?;
10685 let second = owners.symbol_digests[method_indices[1]]
10686 .as_ref()
10687 .and_then(|digest| projection.entity_by_digest.get(digest))
10688 .ok_or("second scoped method entity is missing")?;
10689 let EntitySelector::Symbol {
10690 symbol: first_selector,
10691 } = first.selector()
10692 else {
10693 return Err("first scoped entity is not a symbol".into());
10694 };
10695 let EntitySelector::Symbol {
10696 symbol: second_selector,
10697 } = second.selector()
10698 else {
10699 return Err("second scoped entity is not a symbol".into());
10700 };
10701 require_eq(
10702 &first_selector
10703 .parent
10704 .as_ref()
10705 .map(GraphIdentityText::as_str),
10706 &Some(expected_parents[0]),
10707 "first graph identity parent",
10708 )?;
10709 require_eq(
10710 &second_selector
10711 .parent
10712 .as_ref()
10713 .map(GraphIdentityText::as_str),
10714 &Some(expected_parents[1]),
10715 "second graph identity parent",
10716 )?;
10717 require(
10718 first.key() != second.key(),
10719 "independent semantic scopes shared one graph entity key",
10720 )?;
10721 }
10722 Ok(())
10723 }
10724
10725 #[test]
10726 fn external_classification_follows_effective_semantic_provider() -> Result<(), Box<dyn Error>> {
10727 for (language, target, system, identity) in [
10728 ("html", "import fs from \"node:fs\";", "node", "fs"),
10729 ("svelte", "import url from \"node:url\";", "node", "url"),
10730 ("vue", "import path from \"node:path\";", "node", "path"),
10731 ] {
10732 let case = resolution_case(language, RelationKind::Imports, target, &[]);
10733 let external = explicit_external_selector(&case.graph, &case.relation)?
10734 .ok_or("embedded ECMAScript external classification is missing")?;
10735 require_eq(&external.system.as_str(), &system, "external system")?;
10736 require_eq(&external.identity.as_str(), &identity, "external identity")?;
10737 }
10738
10739 let lock = resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]);
10740 require(
10741 explicit_external_selector(&lock.graph, &lock.relation)?.is_none(),
10742 "Cargo.lock was misclassified as an explicit external dependency",
10743 )?;
10744 Ok(())
10745 }
10746
10747 #[test]
10748 fn grouped_rust_imports_use_only_their_common_external_module() -> Result<(), Box<dyn Error>> {
10749 let grouped = resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]);
10750 require_eq(
10751 &rust_toolchain_identity(&grouped.relation),
10752 &Some("std".to_string()),
10753 "grouped Rust external root",
10754 )?;
10755 let nested = resolution_case(
10756 "rust",
10757 RelationKind::Imports,
10758 "use std::fs::{read, write};",
10759 &[],
10760 );
10761 require_eq(
10762 &rust_toolchain_identity(&nested.relation),
10763 &Some("std::fs".to_string()),
10764 "nested grouped Rust external module",
10765 )?;
10766 let ordinary = resolution_case("rust", RelationKind::Imports, "use std::fs;", &[]);
10767 require_eq(
10768 &rust_toolchain_identity(&ordinary.relation),
10769 &Some("std::fs".to_string()),
10770 "ordinary Rust external module",
10771 )?;
10772 Ok(())
10773 }
10774
10775 #[test]
10776 fn registry_candidate_merge_deduplicates_exactly_and_observes_cancellation()
10777 -> Result<(), Box<dyn Error>> {
10778 let project = ProjectInstanceId::from_bytes([6; 16])?;
10779 let generation = IndexGeneration::new(1);
10780 let first_key = test_resolution_key(project, "first-key")?;
10781 let second_key = test_resolution_key(project, "second-key")?;
10782 let first = test_symbol_entity(project, generation, "src/first.rs", "first")?;
10783 let second = test_symbol_entity(project, generation, "src/second.rs", "second")?;
10784 let mut registry = ProjectResolutionRegistry::default();
10785 registry.insert_candidate(&first_key, &first)?;
10786 registry.insert_candidate(&second_key, &first)?;
10787 registry.insert_candidate(&second_key, &second)?;
10788 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10789 let staged_entities = BTreeMap::new();
10790 let matches = registry_resolution_matches(
10791 &[first_key.clone(), second_key.clone()],
10792 ®istry,
10793 &staged_entities,
10794 &control,
10795 )?;
10796 require_eq(&matches.count, &2, "distinct merged candidate count")?;
10797 let expected_first = [&first, &second]
10798 .into_iter()
10799 .min_by_key(|entity| entity.key().digest())
10800 .ok_or("candidate fixture is empty")?;
10801 require_eq(
10802 &matches.first.map(|entity| entity.key().digest()),
10803 &Some(expected_first.key().digest()),
10804 "stable first candidate",
10805 )?;
10806
10807 let cancellation = IndexCancellation::new();
10808 let canceled_control = IndexWorkControl::new(cancellation.clone(), None);
10809 cancellation.cancel();
10810 require(
10811 registry_resolution_matches(
10812 &[first_key, second_key],
10813 ®istry,
10814 &staged_entities,
10815 &canceled_control,
10816 )
10817 .is_err(),
10818 "candidate merge ignored cancellation",
10819 )?;
10820 Ok(())
10821 }
10822
10823 #[test]
10824 fn duplicate_ambiguous_relations_keep_the_largest_candidate_count() -> Result<(), Box<dyn Error>>
10825 {
10826 let project = ProjectInstanceId::from_bytes([15; 16])?;
10827 let generation = IndexGeneration::new(1);
10828 let source = test_file_entity(project, generation, "src/duplicate.ts")?;
10829 let relation = |candidates| -> Result<LogicalRelation, Box<dyn Error>> {
10830 Ok(LogicalRelation::new(
10831 &source,
10832 GraphRelationKind::from_legacy(RelationKind::Contains),
10833 RelationResolution::Ambiguous {
10834 reference: GraphIdentityText::new("declarations")?,
10835 candidates: NonZeroU32::new(candidates).ok_or("candidate count was zero")?,
10836 },
10837 ConfidenceClass::Exact,
10838 Completeness::Complete,
10839 generation,
10840 )?)
10841 };
10842 let mut relations = BTreeMap::new();
10843 insert_relation(
10844 &mut relations,
10845 relation(2)?,
10846 "src/duplicate.ts",
10847 "logical",
10848 0,
10849 )?;
10850 insert_relation(
10851 &mut relations,
10852 relation(3)?,
10853 "src/duplicate.ts",
10854 "logical",
10855 1,
10856 )?;
10857 insert_relation(
10858 &mut relations,
10859 relation(2)?,
10860 "src/duplicate.ts",
10861 "logical",
10862 2,
10863 )?;
10864 let retained = relations
10865 .into_values()
10866 .next()
10867 .ok_or("deduplicated relation was not retained")?;
10868 require(
10869 matches!(
10870 retained.resolution(),
10871 RelationResolution::Ambiguous { candidates, .. } if candidates.get() == 3
10872 ),
10873 "deduplicated ambiguity did not retain the largest candidate count",
10874 )?;
10875
10876 let mut conflicting = BTreeMap::new();
10877 insert_relation(
10878 &mut conflicting,
10879 relation(2)?,
10880 "src/duplicate.ts",
10881 "logical",
10882 0,
10883 )?;
10884 let different_confidence = LogicalRelation::new(
10885 &source,
10886 GraphRelationKind::from_legacy(RelationKind::Contains),
10887 RelationResolution::Ambiguous {
10888 reference: GraphIdentityText::new("declarations")?,
10889 candidates: NonZeroU32::new(2).ok_or("candidate count was zero")?,
10890 },
10891 ConfidenceClass::High,
10892 Completeness::Complete,
10893 generation,
10894 )?;
10895 require(
10896 insert_relation(
10897 &mut conflicting,
10898 different_confidence,
10899 "src/duplicate.ts",
10900 "logical",
10901 1,
10902 )
10903 .is_err(),
10904 "a non-ambiguity conflict was merged",
10905 )?;
10906 Ok(())
10907 }
10908
10909 #[test]
10910 fn same_file_private_calls_resolve_and_duplicate_declarations_stay_ambiguous()
10911 -> Result<(), Box<dyn Error>> {
10912 let project = ProjectInstanceId::from_bytes([5; 16])?;
10913 let generation = IndexGeneration::new(1);
10914 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10915 let private_graph = extract_symbol_graph(
10916 "src/private.rs",
10917 Some("rust"),
10918 "pub fn caller() { helper(); }\nfn helper() {}\n",
10919 );
10920 require(
10921 private_graph
10922 .symbols
10923 .iter()
10924 .any(|symbol| symbol.name == "helper" && !symbol.exported),
10925 "fixture helper was not private",
10926 )?;
10927 let packages = PackageIndex::from_graphs(std::slice::from_ref(&private_graph))?;
10928 let projection = build_entity_projection(
10929 project,
10930 generation,
10931 &[],
10932 std::slice::from_ref(&private_graph),
10933 &packages,
10934 true,
10935 &control,
10936 )?;
10937 let candidates = resolution_registry_from_exports(&projection, &control)?;
10938 let staged = finish_projection(
10939 project,
10940 generation,
10941 RepositoryGraphMutation::Full,
10942 std::slice::from_ref(&private_graph),
10943 projection,
10944 &candidates,
10945 &control,
10946 )?;
10947 let helper_call = staged
10948 .relations
10949 .iter()
10950 .find(|relation| {
10951 matches!(
10952 relation.resolution(),
10953 RelationResolution::Resolved {
10954 selector: ReusableTargetSelector::Symbol { symbol },
10955 ..
10956 } if symbol.name.as_str() == "helper"
10957 && symbol.file.as_str() == "src/private.rs"
10958 )
10959 })
10960 .ok_or("private same-file helper call did not resolve")?;
10961 require_eq(
10962 &helper_call.kind(),
10963 &GraphRelationKind::from_legacy(RelationKind::Calls),
10964 "private same-file relation kind",
10965 )?;
10966
10967 let duplicate_graph = SymbolGraph {
10968 path: "src/duplicate.rs".to_string(),
10969 language: Some("rust".to_string()),
10970 parser: ParserKind::TreeSitter,
10971 symbols: vec![
10972 test_code_symbol("src/duplicate.rs", "caller", Some("Owner"), "fn caller()"),
10973 test_code_symbol(
10974 "src/duplicate.rs",
10975 "helper",
10976 Some("Owner"),
10977 "fn helper(first: u8)",
10978 ),
10979 test_code_symbol(
10980 "src/duplicate.rs",
10981 "helper",
10982 Some("Owner"),
10983 "fn helper(second: u16)",
10984 ),
10985 test_code_symbol(
10986 "src/duplicate.rs",
10987 "helper",
10988 Some("Unrelated"),
10989 "fn helper()",
10990 ),
10991 ],
10992 relations: vec![SymbolRelation {
10993 path: "src/duplicate.rs".to_string(),
10994 source_name: "caller".to_string(),
10995 target_name: "helper".to_string(),
10996 kind: RelationKind::Calls,
10997 line: 1,
10998 context: "helper()".to_string(),
10999 parser: ParserKind::TreeSitter,
11000 }],
11001 };
11002 let packages = PackageIndex::from_graphs(std::slice::from_ref(&duplicate_graph))?;
11003 let projection = build_entity_projection(
11004 project,
11005 generation,
11006 &[],
11007 std::slice::from_ref(&duplicate_graph),
11008 &packages,
11009 true,
11010 &control,
11011 )?;
11012 let candidates = resolution_registry_from_exports(&projection, &control)?;
11013 let staged = finish_projection(
11014 project,
11015 generation,
11016 RepositoryGraphMutation::Full,
11017 std::slice::from_ref(&duplicate_graph),
11018 projection,
11019 &candidates,
11020 &control,
11021 )?;
11022 require(
11023 staged.relations.iter().any(|relation| {
11024 matches!(
11025 relation.resolution(),
11026 RelationResolution::Ambiguous {
11027 reference,
11028 candidates,
11029 } if reference.as_str() == "helper" && candidates.get() == 2
11030 )
11031 }),
11032 "duplicate same-file declarations did not remain ambiguous",
11033 )?;
11034 Ok(())
11035 }
11036
11037 #[test]
11038 fn php_scoped_and_case_insensitive_calls_resolve_without_member_edges()
11039 -> Result<(), Box<dyn Error>> {
11040 let project = ProjectInstanceId::from_bytes([31; 16])?;
11041 let generation = IndexGeneration::new(1);
11042 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11043 let php_graph = extract_symbol_graph(
11044 "src/service.php",
11045 Some("php"),
11046 r"<?php
11047class Service {
11048 public string $boot;
11049 public static function prepare(): void {}
11050 public static function finish(): void {}
11051 public static function boot(): void {}
11052 public function run(): void {
11053 self::prepare();
11054 static::finish();
11055 Service::boot();
11056 SERVICE::BOOT();
11057 parent::inherited();
11058 $this->boot();
11059 }
11060}
11061function helper(): void {}
11062function caller(): void { HELPER(); }
11063namespace Atlas\Domain;
11064class QualifiedService {
11065 public static function boot(): void {}
11066 public static function qualified_run(): void {
11067 \Atlas\Domain\QualifiedService::boot();
11068 \Missing\Domain\QualifiedService::boot();
11069 QualifiedService::boot();
11070 }
11071}
11072namespace Other\Domain;
11073class QualifiedService {
11074 public static function boot(): void {}
11075}
11076function relative_run(): void {
11077 Atlas\Domain\QualifiedService::boot();
11078 Alias\QualifiedService::boot();
11079 namespace\QualifiedService::boot();
11080}
11081namespace TopLevel;
11082function top_level_helper(): void {}
11083top_level_helper();
11084namespace Foo;
11085function helper(): void {}
11086function qualified_helper_run(): void {
11087 \Foo\helper();
11088}
11089function rooted_global_helper_run(): void {
11090 \helper();
11091}
11092function missing_helper_run(): void {
11093 \Missing\helper();
11094}
11095class NamespacedService {
11096 public function namespaced_run(): void {
11097 helper();
11098 }
11099}
11100class DuplicateA {
11101 public static function prepare(): void {}
11102 public static function collision_run(): void {
11103 self::prepare();
11104 }
11105}
11106class DuplicateB {
11107 public static function prepare(): void {}
11108 public static function collision_run(): void {
11109 self::prepare();
11110 }
11111}
11112",
11113 );
11114 require(
11115 php_graph
11116 .relations
11117 .iter()
11118 .filter(|relation| relation.kind == RelationKind::Calls)
11119 .all(|relation| relation.target_name != "boot"),
11120 "dynamic member calls must not become unscoped PHP calls",
11121 )?;
11122 let finish_graph = |graph: &SymbolGraph| {
11123 let packages = PackageIndex::from_graphs(std::slice::from_ref(graph))?;
11124 let projection = build_entity_projection(
11125 project,
11126 generation,
11127 &[],
11128 std::slice::from_ref(graph),
11129 &packages,
11130 true,
11131 &control,
11132 )?;
11133 let candidates = resolution_registry_from_exports(&projection, &control)?;
11134 finish_projection(
11135 project,
11136 generation,
11137 RepositoryGraphMutation::Full,
11138 std::slice::from_ref(graph),
11139 projection,
11140 &candidates,
11141 &control,
11142 )
11143 };
11144 let staged = finish_graph(&php_graph)?;
11145 for declaration in [
11146 "class Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
11147 "interface Service {\nconst FLAG = 1;\nfunction run();\n}",
11148 "trait Service {\nconst FLAG = 1;\npublic $value;\nfunction run() {}\n}",
11149 "enum Service {\ncase Ready;\nconst FLAG = 1;\nfunction run() {}\n}",
11150 ] {
11151 for (source, namespaces) in [
11152 (
11153 format!(
11154 "<?php namespace A {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
11155 ),
11156 ["A", "B"],
11157 ),
11158 (
11159 format!("<?php namespace A;\n{declaration}\nnamespace B;\n{declaration}"),
11160 ["A", "B"],
11161 ),
11162 (
11163 format!(
11164 "<?php namespace Service {{\n{declaration}\n}}\nnamespace B {{\n{declaration}\n}}"
11165 ),
11166 ["Service", "B"],
11167 ),
11168 (
11169 format!("<?php namespace Service;\n{declaration}\nnamespace B;\n{declaration}"),
11170 ["Service", "B"],
11171 ),
11172 ] {
11173 let graph = extract_symbol_graph("src/containment.php", Some("php"), &source);
11174 require_eq(
11175 &graph
11176 .symbols
11177 .iter()
11178 .filter(|symbol| {
11179 symbol.name == "run" && symbol.parent.as_deref() == Some("Service")
11180 })
11181 .count(),
11182 &2,
11183 "PHP parser retains unqualified member parents",
11184 )?;
11185 let contained = finish_graph(&graph)?;
11186 for namespace in namespaces {
11187 let parent = format!("{namespace}::Service");
11188 for name in ["run", "FLAG"] {
11189 require(
11190 contained.relations.iter().any(|relation| {
11191 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11192 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11193 if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent.as_str()))
11194 && contained.entities.iter().any(|entity| entity.key() == relation.source()
11195 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
11196 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace)))
11197 }),
11198 &format!("PHP containment must keep its exact namespace owner: {parent}::{name}"),
11199 )?;
11200 }
11201 require(
11202 contained.relations.iter().any(|relation| {
11203 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11204 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11205 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(namespace))
11206 && contained.entities.iter().any(|entity| entity.key() == relation.source()
11207 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == namespace))
11208 }),
11209 "PHP namespace containment retains its declared type",
11210 )?;
11211 }
11212 }
11213 }
11214 for declaration in [
11215 "class N { function caller() { helper(); } }",
11216 "trait N { function caller() { helper(); } }",
11217 "enum N { case Ready; function caller() { helper(); } }",
11218 ] {
11219 for source in [
11220 format!(
11221 "<?php namespace N {{ function helper() {{}} }}\nnamespace M {{ function helper() {{}} {declaration} }}"
11222 ),
11223 format!(
11224 "<?php namespace M {{ function helper() {{}} {declaration} }}\nnamespace N {{ function helper() {{}} }}"
11225 ),
11226 format!(
11227 "<?php namespace N; function helper() {{}}\nnamespace M; function helper() {{}} {declaration}"
11228 ),
11229 format!(
11230 "<?php namespace M; function helper() {{}} {declaration}\nnamespace N; function helper() {{}}"
11231 ),
11232 ] {
11233 let graph = extract_symbol_graph("src/type-namespace.php", Some("php"), &source);
11234 let projected = finish_graph(&graph)?;
11235 let calls = projected
11236 .relations
11237 .iter()
11238 .filter(|relation| {
11239 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11240 })
11241 .collect::<Vec<_>>();
11242 require_eq(
11243 &calls.len(),
11244 &1,
11245 "PHP type/namespace collision retains its call",
11246 )?;
11247 require(
11248 matches!(calls[0].resolution(), RelationResolution::Resolved {
11249 selector: ReusableTargetSelector::Symbol { symbol }, ..
11250 } if symbol.name.as_str() == "helper"
11251 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("M")),
11252 "PHP containing type must retain its namespace despite a same-named module",
11253 )?;
11254 }
11255 }
11256 let overlapping = extract_symbol_graph(
11257 "src/containment.php",
11258 Some("php"),
11259 "<?php namespace A { class Service { function run() {} } } namespace B { class Service { function run() {} } }",
11260 );
11261 let contained = finish_graph(&overlapping)?;
11262 require(
11263 contained.relations.iter().any(|relation| {
11264 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11265 && !matches!(relation.resolution(), RelationResolution::Resolved { .. })
11266 }),
11267 "PHP overlapping line-only owners remain unresolved",
11268 )?;
11269 require(
11270 !contained.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11271 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. } if symbol.name.as_str() == "run")),
11272 "PHP overlapping line-only owners must not invent member containment",
11273 )?;
11274 for source in [
11275 "<?php namespace Service { function boot() {} } namespace { class Service {} Service::boot(); }",
11276 "<?php namespace Service {} namespace { class Service { static function boot() {} } \\Service\\boot(); }",
11277 ] {
11278 let graph = extract_symbol_graph("src/call-kind.php", Some("php"), source);
11279 let staged = finish_graph(&graph)?;
11280 let calls: Vec<_> = staged
11281 .relations
11282 .iter()
11283 .filter(|relation| {
11284 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11285 })
11286 .collect();
11287 require_eq(&calls.len(), &1, "PHP callable-kind collision fixture")?;
11288 require(
11289 matches!(calls[0].resolution(), RelationResolution::Unresolved { .. }),
11290 "PHP calls must not resolve across function and method kinds",
11291 )?;
11292 }
11293 for (source, parent) in [
11294 (
11295 "<?php namespace N; function outer() { function inner() {} } function caller() { inner(); }",
11296 Some("N"),
11297 ),
11298 (
11299 "<?php namespace N { class OuterType { function outer() { function inner() {} } } function caller() { inner(); } }",
11300 Some("N"),
11301 ),
11302 (
11303 "<?php function outer() { function inner() {} } function caller() { inner(); }",
11304 None,
11305 ),
11306 ] {
11307 let graph = extract_symbol_graph("src/nested-call.php", Some("php"), source);
11308 let staged = finish_graph(&graph)?;
11309 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}"))?;
11310 }
11311 let calls = staged
11312 .relations
11313 .iter()
11314 .filter(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11315 .collect::<Vec<_>>();
11316 let staged_call = |target: &str, source: &str| {
11317 let parser_relation = php_graph.relations.iter().find(|relation| {
11318 relation.kind == RelationKind::Calls
11319 && relation.target_name == target
11320 && relation.source_name == source
11321 })?;
11322 let line = u32::try_from(parser_relation.line).ok()?;
11323 staged
11324 .occurrences
11325 .iter()
11326 .filter(|occurrence| occurrence.span().start_line() == line)
11327 .filter_map(|occurrence| {
11328 staged
11329 .relations
11330 .iter()
11331 .find(|relation| relation.key() == occurrence.relation())
11332 })
11333 .find(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls))
11334 };
11335 require(
11336 matches!(
11337 staged_call("\\Atlas\\Domain\\QualifiedService::boot", "qualified_run")
11338 .map(projectatlas_core::graph::LogicalRelation::resolution),
11339 Some(RelationResolution::Resolved {
11340 selector: ReusableTargetSelector::Symbol { symbol },
11341 ..
11342 }) if symbol.name.as_str() == "boot"
11343 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11344 == Some("Atlas\\Domain::QualifiedService")
11345 ),
11346 "fully qualified PHP call did not resolve to the local class method",
11347 )?;
11348 require(
11349 matches!(
11350 staged_call("\\Missing\\Domain\\QualifiedService::boot", "qualified_run")
11351 .map(projectatlas_core::graph::LogicalRelation::resolution),
11352 Some(RelationResolution::Unresolved { reference })
11353 if reference.as_str() == "\\Missing\\Domain\\QualifiedService::boot"
11354 ),
11355 "qualified PHP call with an invalid namespace must remain unresolved",
11356 )?;
11357 require(
11358 matches!(
11359 staged_call("QualifiedService::boot", "qualified_run")
11360 .map(projectatlas_core::graph::LogicalRelation::resolution),
11361 Some(RelationResolution::Resolved {
11362 selector: ReusableTargetSelector::Symbol { symbol },
11363 ..
11364 }) if symbol.name.as_str() == "boot"
11365 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11366 == Some("Atlas\\Domain::QualifiedService")
11367 ),
11368 "unqualified PHP class scope must select its caller namespace",
11369 )?;
11370 for target in [
11371 "Atlas\\Domain\\QualifiedService::boot",
11372 "Alias\\QualifiedService::boot",
11373 ] {
11374 require(
11375 matches!(
11376 staged_call(target, "relative_run")
11377 .map(projectatlas_core::graph::LogicalRelation::resolution),
11378 Some(RelationResolution::Unresolved { reference })
11379 if reference.as_str() == target
11380 ),
11381 &format!("non-leading qualified PHP scope must remain unresolved: {target}"),
11382 )?;
11383 }
11384 require(
11385 matches!(
11386 staged_call("namespace\\QualifiedService::boot", "relative_run")
11387 .map(projectatlas_core::graph::LogicalRelation::resolution),
11388 Some(RelationResolution::Resolved {
11389 selector: ReusableTargetSelector::Symbol { symbol },
11390 ..
11391 }) if symbol.parent.as_ref().map(GraphIdentityText::as_str)
11392 == Some("Other\\Domain::QualifiedService")
11393 ),
11394 "explicit namespace-relative PHP static scope must resolve in its caller namespace",
11395 )?;
11396 require(
11397 matches!(
11398 staged_call("top_level_helper", "TopLevel")
11399 .map(projectatlas_core::graph::LogicalRelation::resolution),
11400 Some(RelationResolution::Resolved {
11401 selector: ReusableTargetSelector::Symbol { symbol },
11402 ..
11403 }) if symbol.name.as_str() == "top_level_helper"
11404 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11405 == Some("TopLevel")
11406 ),
11407 "semicolon namespace top-level call did not resolve locally",
11408 )?;
11409 require(
11410 matches!(
11411 staged_call("helper", "namespaced_run")
11412 .map(projectatlas_core::graph::LogicalRelation::resolution),
11413 Some(RelationResolution::Resolved {
11414 selector: ReusableTargetSelector::Symbol { symbol },
11415 ..
11416 }) if symbol.name.as_str() == "helper"
11417 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11418 ),
11419 "namespaced PHP method call did not prefer its namespace helper",
11420 )?;
11421 require(
11422 matches!(
11423 staged_call("\\Foo\\helper", "qualified_helper_run")
11424 .map(projectatlas_core::graph::LogicalRelation::resolution),
11425 Some(RelationResolution::Resolved {
11426 selector: ReusableTargetSelector::Symbol { symbol },
11427 ..
11428 }) if symbol.name.as_str() == "helper"
11429 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11430 ),
11431 "fully qualified PHP function call did not resolve to its namespace function",
11432 )?;
11433 require(
11434 matches!(
11435 staged_call("\\helper", "rooted_global_helper_run")
11436 .map(projectatlas_core::graph::LogicalRelation::resolution),
11437 Some(RelationResolution::Resolved {
11438 selector: ReusableTargetSelector::Symbol { symbol },
11439 ..
11440 }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
11441 ),
11442 "rooted PHP global function call did not resolve explicitly to the global helper",
11443 )?;
11444 require(
11445 matches!(
11446 staged_call("\\Missing\\helper", "missing_helper_run")
11447 .map(projectatlas_core::graph::LogicalRelation::resolution),
11448 Some(RelationResolution::Unresolved { reference })
11449 if reference.as_str() == "\\Missing\\helper"
11450 ),
11451 "fully qualified PHP function call must not fall back to a global helper",
11452 )?;
11453 let duplicate_method_calls = php_graph
11454 .relations
11455 .iter()
11456 .filter(|relation| {
11457 relation.kind == RelationKind::Calls
11458 && relation.target_name == "self::prepare"
11459 && relation.source_name == "collision_run"
11460 })
11461 .count();
11462 require_eq(
11463 &duplicate_method_calls,
11464 &2,
11465 "duplicate PHP method fixture call count",
11466 )?;
11467 let duplicate_method_parents = calls
11468 .iter()
11469 .filter_map(|relation| match relation.resolution() {
11470 RelationResolution::Resolved {
11471 selector: ReusableTargetSelector::Symbol { symbol },
11472 ..
11473 } if symbol.name.as_str() == "prepare" => {
11474 symbol.parent.as_ref().map(GraphIdentityText::as_str)
11475 }
11476 _ => None,
11477 })
11478 .collect::<BTreeSet<_>>();
11479 require_eq(
11480 &duplicate_method_parents,
11481 &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB", "Service"]),
11482 "PHP method callers retain their distinct source owners",
11483 )?;
11484 let duplicate_sources = calls
11485 .iter()
11486 .filter_map(|relation| {
11487 let entity = staged
11488 .entities
11489 .iter()
11490 .find(|entity| entity.key() == relation.source())?;
11491 match entity.selector() {
11492 EntitySelector::Symbol { symbol }
11493 if symbol.name.as_str() == "collision_run" =>
11494 {
11495 symbol.parent.as_ref().map(GraphIdentityText::as_str)
11496 }
11497 _ => None,
11498 }
11499 })
11500 .collect::<BTreeSet<_>>();
11501 require_eq(
11502 &duplicate_sources,
11503 &BTreeSet::from(["Foo::DuplicateA", "Foo::DuplicateB"]),
11504 "duplicate PHP calls must originate from their actual method entities",
11505 )?;
11506 let namespace_call = staged_call("top_level_helper", "TopLevel");
11507 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")?;
11508 for (source, name, parent) in [
11509 (
11510 "<?php class caller { function caller() { helper(); } } function helper() {}",
11511 "caller",
11512 "caller",
11513 ),
11514 (
11515 "<?php\nnamespace Foo;\nfunction Foo() {\nhelper();\n}\nfunction helper() {}",
11516 "Foo",
11517 "Foo",
11518 ),
11519 ] {
11520 let graph = extract_symbol_graph("src/source-collision.php", Some("php"), source);
11521 let staged = finish_graph(&graph)?;
11522 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")?;
11523 }
11524 let trait_graph = extract_symbol_graph(
11525 "src/trait.php",
11526 Some("php"),
11527 "<?php trait T { function run() { self::target(); } function target() {} } class C { use T; function target() {} }",
11528 );
11529 let trait_staged = finish_graph(&trait_graph)?;
11530 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")?;
11531 let enum_graph = extract_symbol_graph(
11532 "src/enum.php",
11533 Some("php"),
11534 r"<?php
11535namespace {
11536function helper(): void {}
11537}
11538namespace Foo {
11539function helper(): void {}
11540enum NamespacedState {
11541 case Ready;
11542 public function enum_run(): void {
11543 helper();
11544 }
11545}
11546}
11547namespace Bar {
11548function fallback_run(): void {
11549 helper();
11550}
11551}
11552",
11553 );
11554 let enum_staged = finish_graph(&enum_graph)?;
11555 let enum_call = |target: &str, source: &str| {
11556 let parser_relation = enum_graph.relations.iter().find(|relation| {
11557 relation.target_name == target && relation.source_name == source
11558 })?;
11559 let line = u32::try_from(parser_relation.line).ok()?;
11560 let occurrence = enum_staged
11561 .occurrences
11562 .iter()
11563 .find(|occurrence| occurrence.span().start_line() == line)?;
11564 enum_staged
11565 .relations
11566 .iter()
11567 .find(|relation| relation.key() == occurrence.relation())
11568 };
11569 require(
11570 matches!(
11571 enum_call("helper", "enum_run")
11572 .map(projectatlas_core::graph::LogicalRelation::resolution),
11573 Some(RelationResolution::Resolved {
11574 selector: ReusableTargetSelector::Symbol { symbol },
11575 ..
11576 }) if symbol.name.as_str() == "helper"
11577 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Foo")
11578 ),
11579 "namespaced PHP enum method call did not prefer its namespace helper",
11580 )?;
11581 require(
11582 matches!(
11583 enum_call("helper", "fallback_run")
11584 .map(projectatlas_core::graph::LogicalRelation::resolution),
11585 Some(RelationResolution::Resolved {
11586 selector: ReusableTargetSelector::Symbol { symbol },
11587 ..
11588 }) if symbol.name.as_str() == "helper" && symbol.parent.is_none()
11589 ),
11590 "namespaced PHP function call did not fall back to the global helper",
11591 )?;
11592 let mixed_call_line = u32::try_from(
11593 php_graph
11594 .relations
11595 .iter()
11596 .find(|relation| relation.target_name == "SERVICE::BOOT")
11597 .ok_or("mixed-case PHP call fixture was missing")?
11598 .line,
11599 )?;
11600 let mixed_call_occurrence = staged
11601 .occurrences
11602 .iter()
11603 .find(|occurrence| occurrence.span().start_line() == mixed_call_line)
11604 .ok_or("mixed-case PHP call occurrence was missing")?;
11605 let mixed_call_relation = staged
11606 .relations
11607 .iter()
11608 .find(|relation| relation.key() == mixed_call_occurrence.relation())
11609 .ok_or("mixed-case PHP logical call was missing")?;
11610 require(
11611 matches!(
11612 mixed_call_relation.resolution(),
11613 RelationResolution::Resolved {
11614 selector: ReusableTargetSelector::Symbol { symbol },
11615 ..
11616 } if symbol.name.as_str() == "boot"
11617 && symbol.kind == SymbolKind::Method
11618 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("Service")
11619 ),
11620 "mixed-case scoped PHP call did not resolve to Service::boot method",
11621 )?;
11622 for (name, parent) in [
11623 ("prepare", "Service"),
11624 ("boot", "Service"),
11625 ("helper", "<module>"),
11626 ] {
11627 require(
11628 calls.iter().any(|relation| {
11629 matches!(
11630 relation.resolution(),
11631 RelationResolution::Resolved {
11632 selector: ReusableTargetSelector::Symbol { symbol },
11633 ..
11634 } if symbol.name.as_str() == name
11635 && symbol.parent.as_ref().map(GraphIdentityText::as_str)
11636 == (parent != "<module>").then_some(parent)
11637 )
11638 }),
11639 &format!("PHP call did not resolve to {parent}::{name}"),
11640 )?;
11641 }
11642 require(
11643 matches!(
11644 staged_call("static::finish", "run")
11645 .map(projectatlas_core::graph::LogicalRelation::resolution),
11646 Some(RelationResolution::Unresolved { reference })
11647 if reference.as_str() == "static::finish"
11648 ),
11649 "late-static PHP calls must remain unresolved without override proof",
11650 )?;
11651 require(
11652 calls.iter().any(|relation| {
11653 matches!(
11654 relation.resolution(),
11655 RelationResolution::Unresolved { reference } if reference.as_str() == "parent::inherited"
11656 )
11657 }),
11658 "parent-scoped PHP calls must remain unresolved without a proven base type",
11659 )?;
11660 require(
11661 calls.iter().all(|relation| {
11662 !matches!(
11663 relation.resolution(),
11664 RelationResolution::Resolved {
11665 selector: ReusableTargetSelector::Symbol { symbol },
11666 ..
11667 } if symbol.name.as_str() == "boot"
11668 && symbol.parent.is_none()
11669 )
11670 }),
11671 "PHP scoped calls must not resolve through an unrelated global member",
11672 )?;
11673
11674 let mut ambiguous_graph = php_graph;
11675 let mut duplicate = ambiguous_graph
11676 .symbols
11677 .iter()
11678 .find(|symbol| symbol.name == "helper")
11679 .cloned()
11680 .ok_or("PHP ambiguity fixture helper was missing")?;
11681 duplicate.name = "HELPER".to_string();
11682 ambiguous_graph.symbols.push(duplicate);
11683 let ambiguous = finish_graph(&ambiguous_graph)?;
11684 require(
11685 ambiguous.relations.iter().any(|relation| {
11686 matches!(
11687 relation.resolution(),
11688 RelationResolution::Ambiguous {
11689 reference,
11690 candidates,
11691 } if reference.as_str() == "HELPER" && candidates.get() == 2
11692 )
11693 }),
11694 "case-insensitive PHP duplicate declarations must remain ambiguous",
11695 )?;
11696
11697 let mut scope_failures = Vec::new();
11698 for (source, target, expected_parent) in [
11699 (
11700 "<?php\nclass Service { public static function boot() {} }\nService::boot();\n$callable();",
11701 "partial global static call",
11702 Some("Service"),
11703 ),
11704 (
11705 "<?php\nfunction helper() {}\nhelper();\n$callable();",
11706 "partial global function call",
11707 None,
11708 ),
11709 (
11710 "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); }",
11711 "require and local helper",
11712 None,
11713 ),
11714 (
11715 "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); }",
11716 "other include forms and local helper",
11717 None,
11718 ),
11719 (
11720 "<?php\nrequire 'bootstrap.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11721 "partial require and local helper",
11722 None,
11723 ),
11724 (
11725 "<?php\ninclude 'a.php'; include_once 'b.php'; require_once 'c.php';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11726 "partial include forms and local helper",
11727 None,
11728 ),
11729 (
11730 "<?php\nrequire/* comment */('bootstrap'); include_once(\"config\"); REQUIRE_ONCE # comment\n'library';\nfunction helper() {}\nfunction run() { helper(); $callable(); }",
11731 "partial commented and parenthesized includes",
11732 None,
11733 ),
11734 (
11735 "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); $callable(); }",
11736 "partial trait import and local static call",
11737 Some("Service"),
11738 ),
11739 (
11740 "<?php\ntrait Shared {}\nclass Service { use Shared; public static function boot() {} }\nfunction run() { Service::boot(); }",
11741 "trait import and local static call",
11742 Some("Service"),
11743 ),
11744 (
11745 "<?php\nnamespace Foo;\nfunction run() { Sub\\helper(); }\nnamespace Foo\\Sub;\nfunction helper() {}",
11746 "relative qualified helper",
11747 Some("Foo\\Sub"),
11748 ),
11749 (
11750 "<?php\nnamespace { function run() { Sub\\helper(); } }\nnamespace Sub { function helper() {} }",
11751 "global qualified helper",
11752 Some("Sub"),
11753 ),
11754 (
11755 "<?php\nnamespace Foo;\nfunction run() { Sub\\Service::boot(); }\nnamespace Foo\\Sub;\nclass Service { public static function boot() {} }",
11756 "relative qualified static method",
11757 Some("Foo\\Sub::Service"),
11758 ),
11759 (
11760 "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
11761 "Service::boot",
11762 Some("Service"),
11763 ),
11764 (
11765 "<?php\nclass Service { public static function boot() {} }\nService::boot();",
11766 "Service::boot",
11767 Some("Service"),
11768 ),
11769 (
11770 "<?php\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
11771 "namespace\\helper",
11772 None,
11773 ),
11774 (
11775 "<?php\nnamespace Foo;\nfunction helper() {}\nfunction run() { namespace\\helper(); }",
11776 "namespace\\helper",
11777 Some("Foo"),
11778 ),
11779 (
11780 "<?php\nnamespace Foo { use function Other\\helper; function run() { namespace\\helper(); } }\nnamespace Foo { function helper() {} }",
11781 "namespace\\helper",
11782 Some("Foo"),
11783 ),
11784 (
11785 "<?php\nnamespace Foo;\nfunction helper() {}\nNaMeSpAcE\\helper();",
11786 "NaMeSpAcE\\helper",
11787 Some("Foo"),
11788 ),
11789 (
11790 "<?php\nnamespace Foo;\nfunction run() { namespace\\Child\\helper(); }\nnamespace Foo\\Child;\nfunction helper() {}",
11791 "namespace\\Child\\helper",
11792 Some("Foo\\Child"),
11793 ),
11794 ] {
11795 let graph = extract_symbol_graph("src/explicit-scope.php", Some("php"), source);
11796 let staged = finish_graph(&graph)?;
11797 if !staged.relations.iter().any(|relation| {
11798 matches!(
11799 relation.resolution(),
11800 RelationResolution::Resolved {
11801 selector: ReusableTargetSelector::Symbol { symbol },
11802 ..
11803 } if symbol.parent.as_ref().map(GraphIdentityText::as_str) == expected_parent
11804 ) && relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11805 }) {
11806 scope_failures.push(target);
11807 }
11808 }
11809 require(
11810 scope_failures.is_empty(),
11811 &format!("proven PHP call scopes did not resolve: {scope_failures:?}"),
11812 )?;
11813
11814 let mut unknown_caller = extract_symbol_graph(
11815 "src/unknown-scope.php",
11816 Some("php"),
11817 "<?php\nclass Service { public static function boot() {} }\nfunction run() { Service::boot(); }",
11818 );
11819 for relation in &mut unknown_caller.relations {
11820 if relation.kind == RelationKind::Calls {
11821 relation.source_name = "unknown_caller".to_string();
11822 }
11823 }
11824 let ambiguous_caller = extract_symbol_graph(
11825 "src/ambiguous-scope.php",
11826 Some("php"),
11827 "<?php namespace { class Service { public static function boot() {} } function run() { Service::boot(); } } namespace Foo { function run() {} }",
11828 );
11829 for graph in [&unknown_caller, &ambiguous_caller] {
11830 let staged = finish_graph(graph)?;
11831 require(
11832 staged
11833 .relations
11834 .iter()
11835 .filter(|relation| {
11836 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11837 })
11838 .all(|relation| {
11839 staged.entities.iter().any(|entity| {
11840 entity.key() == relation.source()
11841 && matches!(entity.selector(), EntitySelector::File { .. })
11842 })
11843 }),
11844 "unknown and same-line ambiguous PHP callers must retain file ownership",
11845 )?;
11846 require(
11847 staged.relations.iter().any(|relation| {
11848 matches!(
11849 relation.resolution(),
11850 RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
11851 )
11852 }),
11853 "unknown or ambiguous PHP callers must not be treated as proven global functions",
11854 )?;
11855 }
11856 for source in [
11857 "<?php namespace Foo; function Foo() {} Foo();",
11858 "<?php namespace Foo; function Foo() { helper(); } function helper() {}",
11859 ] {
11860 let graph = extract_symbol_graph("src/namespace-boundary.php", Some("php"), source);
11861 let staged = finish_graph(&graph)?;
11862 require(
11863 staged
11864 .relations
11865 .iter()
11866 .filter(|relation| {
11867 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11868 })
11869 .all(|relation| {
11870 staged.entities.iter().any(|entity| {
11871 entity.key() == relation.source()
11872 && matches!(entity.selector(), EntitySelector::File { .. })
11873 })
11874 }),
11875 "PHP namespace/callable boundary-line collisions must remain file-owned",
11876 )?;
11877 }
11878
11879 for source in [
11880 r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\{helper, other}; function after() { helper(); } }",
11881 r"<?php namespace A; function helper() {} function before() { helper(); } use function Vendor\helper, Vendor\other; function after() { helper(); }",
11882 r"<?php namespace A { use function Vendor\{helper, other}; } namespace B { function helper() {} function before() { helper(); } use function Remote\helper; function after() { helper(); } }",
11883 ] {
11884 let graph = extract_symbol_graph("src/import-groups.php", Some("php"), source);
11885 let grouped = finish_graph(&graph)?;
11886 for caller in ["before", "after"] {
11887 require(
11888 grouped.relations.iter().any(|relation| {
11889 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
11890 && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
11891 && grouped.entities.iter().any(|entity| entity.key() == relation.source()
11892 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
11893 }),
11894 "PHP grouped imports with unmatched source occurrences must stay conservative",
11895 )?;
11896 }
11897 }
11898
11899 let collision = extract_symbol_graph(
11900 "src/containment-collision.php",
11901 Some("php"),
11902 "<?php namespace Service { class Service { function run() {} } function outside() {} }",
11903 );
11904 let contained = finish_graph(&collision)?;
11905 for (name, parent) in [
11906 ("Service", "Service"),
11907 ("run", "Service::Service"),
11908 ("outside", "Service"),
11909 ] {
11910 require(
11911 contained.relations.iter().any(|relation| {
11912 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11913 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11914 if symbol.name.as_str() == name && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some(parent))
11915 && contained.entities.iter().any(|entity| entity.key() == relation.source()
11916 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
11917 if symbol.name.as_str() == "Service" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == if name == "run" { Some("Service") } else { None }))
11918 }),
11919 &format!("PHP same-line namespace and type collisions must use exact containment spans: {name} {parent}"),
11920 )?;
11921 }
11922
11923 for source in [
11924 "<?php class A { public $run; function run() {} const run = 1; }",
11925 "<?php trait A { public $run; function run() {} const run = 1; }",
11926 ] {
11927 let graph = extract_symbol_graph("src/member-kinds.php", Some("php"), source);
11928 let members = finish_graph(&graph)?;
11929 require_eq(
11930 &graph
11931 .symbols
11932 .iter()
11933 .filter(|symbol| symbol.name == "run")
11934 .count(),
11935 &3,
11936 "PHP same-name fixture must retain property, method, and constant",
11937 )?;
11938 for expected in graph.symbols.iter().filter(|symbol| symbol.name == "run") {
11939 require(
11940 members.relations.iter().any(|relation| {
11941 relation.kind() == GraphRelationKind::Legacy(RelationKind::Contains)
11942 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
11943 if symbol.name.as_str() == "run" && symbol.kind == expected.kind && symbol.signature.as_str() == expected.signature)
11944 && members.entities.iter().any(|entity| entity.key() == relation.source()
11945 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == "A"))
11946 }),
11947 &format!("same-line PHP {} must retain its distinct containment edge", expected.signature),
11948 )?;
11949 }
11950 }
11951
11952 let imported_graph = extract_symbol_graph(
11953 "src/imports.php",
11954 Some("php"),
11955 r"<?php
11956namespace Local {
11957 use Remote\Service;
11958 use Remote\NamespaceAlias as Sub;
11959 use function Remote\helper;
11960 function run() { Service::boot(); helper(); Sub\helper(); }
11961}
11962namespace Local {
11963 class Service { public static function boot() {} }
11964 function helper() {}
11965}
11966namespace Local\Sub { function helper() {} }
11967",
11968 );
11969 let staged = finish_graph(&imported_graph)?;
11970 for target in ["Service::boot", "helper", "Sub\\helper"] {
11971 require(
11972 staged.relations.iter().any(|relation| {
11973 matches!(
11974 relation.resolution(),
11975 RelationResolution::Unresolved { reference } if reference.as_str() == target
11976 )
11977 }),
11978 "PHP imports must not be bypassed by same-file unqualified call matching",
11979 )?;
11980 }
11981
11982 for source in [
11983 r"<?php
11984namespace A { use function Vendor\helper; function run_a() { helper(); } }
11985namespace B { function helper() {} function run_b() { helper(); } }
11986",
11987 r"<?php
11988namespace A;
11989use function Vendor\helper;
11990function run_a() { helper(); }
11991namespace B;
11992function helper() {}
11993function run_b() { helper(); }
11994",
11995 ] {
11996 let graph = extract_symbol_graph("src/import-scopes.php", Some("php"), source);
11997 let scoped = finish_graph(&graph)?;
11998 require(
11999 scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12000 && matches!(relation.resolution(), RelationResolution::Resolved { selector: ReusableTargetSelector::Symbol { symbol }, .. }
12001 if symbol.name.as_str() == "helper" && symbol.parent.as_ref().map(GraphIdentityText::as_str) == Some("B"))),
12002 "PHP import in namespace A must not suppress a proven namespace B call",
12003 )?;
12004 require(
12005 scoped.relations.iter().any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12006 && matches!(relation.resolution(), RelationResolution::Unresolved { reference } if reference.as_str() == "helper")),
12007 "PHP imported alias remains unresolved in its owning namespace",
12008 )?;
12009 }
12010
12011 for source in [
12012 r"<?php namespace A { function helper() {} function before() { helper(); } use function Vendor\helper; function after() { helper(); } }",
12013 r"<?php namespace A; function before() { Sub\helper(); } use Vendor as Sub; function after() { Sub\helper(); } namespace A\Sub; function helper() {}",
12014 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(); } }",
12015 r"<?php
12016namespace { function helper() {} }
12017namespace A {
12018function before() { helper(); }
12019use function Vendor\helper;
12020function after() { helper(); }
12021}
12022",
12023 r"<?php
12024namespace A;
12025function before() { Sub\helper(); }
12026use Vendor as Sub;
12027function after() { Sub\helper(); }
12028namespace A\Sub;
12029function helper() {}
12030",
12031 ] {
12032 let graph = extract_symbol_graph("src/import-order.php", Some("php"), source);
12033 let staged = finish_graph(&graph)?;
12034 for (caller, resolved) in [("before", true), ("after", false)] {
12035 require(
12036 staged.relations.iter().any(|relation| {
12037 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12038 && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
12039 && staged.entities.iter().any(|entity| entity.key() == relation.source()
12040 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
12041 }),
12042 &format!("PHP {caller} call must apply only imports already declared"),
12043 )?;
12044 }
12045 }
12046
12047 for source in [
12048 r"<?php
12049namespace A {
12050use function Vendor\helper;
12051function imported() { helper(); }
12052}
12053namespace A {
12054function helper() {}
12055function local() { helper(); }
12056}
12057",
12058 r"<?php
12059namespace A;
12060use Vendor as Sub;
12061function imported() { Sub\helper(); }
12062namespace A;
12063function local() { Sub\helper(); }
12064namespace A\Sub;
12065function helper() {}
12066",
12067 ] {
12068 let graph = extract_symbol_graph("src/reopened-namespace.php", Some("php"), source);
12069 let staged = finish_graph(&graph)?;
12070 for (caller, resolved) in [("imported", false), ("local", true)] {
12071 require(
12072 staged.relations.iter().any(|relation| {
12073 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12074 && matches!(relation.resolution(), RelationResolution::Resolved { .. }) == resolved
12075 && staged.entities.iter().any(|entity| entity.key() == relation.source()
12076 && matches!(entity.selector(), EntitySelector::Symbol { symbol } if symbol.name.as_str() == caller))
12077 }),
12078 &format!("PHP {caller} call must use only its namespace declaration block"),
12079 )?;
12080 }
12081 }
12082
12083 for source in [
12084 "<?php namespace Foo { function helper() {} } namespace Foo { helper(); }",
12085 "<?php\nnamespace Foo;\nfunction helper() {}\nnamespace Foo;\nhelper();",
12086 ] {
12087 let graph = extract_symbol_graph("src/reopened-top-level.php", Some("php"), source);
12088 let staged = finish_graph(&graph)?;
12089 require(
12090 staged.relations.iter().any(|relation| {
12091 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12092 && staged.entities.iter().any(|entity| {
12093 entity.key() == relation.source()
12094 && matches!(entity.selector(), EntitySelector::Symbol { symbol }
12095 if symbol.name.as_str() == "Foo" && symbol.kind == SymbolKind::Module)
12096 })
12097 }),
12098 "Reopened PHP namespace must retain its top-level call source",
12099 )?;
12100 }
12101
12102 for (source, remove_namespaces) in [
12103 (
12104 "<?php namespace A { use function Vendor\\helper; } namespace A { function helper() {} function local() { helper(); } }",
12105 false,
12106 ),
12107 (
12108 "<?php\nnamespace A {\nuse function Vendor\\helper;\n}\nnamespace A {\nfunction helper() {}\nfunction local() { helper(); }\n}",
12109 true,
12110 ),
12111 ] {
12112 let mut graph =
12113 extract_symbol_graph("src/uncertain-namespace.php", Some("php"), source);
12114 if remove_namespaces {
12115 graph
12116 .symbols
12117 .retain(|symbol| symbol.kind != SymbolKind::Module);
12118 }
12119 let staged = finish_graph(&graph)?;
12120 require(
12121 staged.relations.iter().any(|relation| {
12122 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12123 && matches!(relation.resolution(), RelationResolution::Unresolved { .. })
12124 }),
12125 "PHP imports with tied or missing namespace boundaries remain unresolved",
12126 )?;
12127 require(
12128 !staged.relations.iter().any(|relation| {
12129 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12130 && matches!(relation.resolution(), RelationResolution::Resolved { .. })
12131 }),
12132 "PHP uncertain namespace boundaries must not invent call resolution",
12133 )?;
12134 }
12135
12136 let oversized_prefix = "N".repeat(241);
12137 for import in [
12138 format!("use Vendor\\{oversized_prefix} as Sub;"),
12139 format!("use Vendor\\{oversized_prefix}\\Sub;"),
12140 format!("use {oversized_prefix}\\{{Sub}};"),
12141 format!("use {}\\{{Sub}};", "N".repeat(238)),
12142 format!("use Vendor\\Kept, Vendor\\{oversized_prefix} as Sub;"),
12143 ] {
12144 let source = format!(
12145 "<?php namespace Foo {{ {import} function run() {{ Sub\\helper(); namespace\\Sub\\helper(); }} function absolute() {{ \\Foo\\Sub\\helper(); }} }} namespace Foo\\Sub {{ function helper() {{}} }}"
12146 );
12147 let graph = extract_symbol_graph("src/oversized-imports.php", Some("php"), &source);
12148 let staged = finish_graph(&graph)?;
12149 require(
12150 staged.relations.iter().any(|relation| {
12151 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12152 if reference.as_str() == "Sub\\helper")
12153 }),
12154 &format!("omitted PHP import must retain alias uncertainty: {import}"),
12155 )?;
12156 require_eq(
12157 &staged
12158 .relations
12159 .iter()
12160 .filter(|relation| {
12161 relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)
12162 && matches!(relation.resolution(), RelationResolution::Resolved { .. })
12163 })
12164 .count(),
12165 &2,
12166 "rooted and namespace-relative PHP calls must remain independently resolvable",
12167 )?;
12168 }
12169
12170 let mut partial_imports = imported_graph;
12171 partial_imports.parser = ParserKind::Fallback;
12172 partial_imports
12173 .symbols
12174 .retain(|symbol| symbol.kind != SymbolKind::Import);
12175 let partial_staged = finish_graph(&partial_imports)?;
12176 require(
12177 partial_staged.relations.iter().any(|relation| {
12178 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12179 if reference.as_str() == "Sub\\helper")
12180 }),
12181 "partial PHP import evidence must not prove absence of namespace aliases",
12182 )?;
12183 for context in [
12184 "require\\Sub",
12185 "include_once\\Sub",
12186 "requireé",
12187 "require\u{a0}Alias",
12188 "",
12189 "use function Vendor\\helper;",
12190 ] {
12191 for relation in &mut partial_imports.relations {
12192 if relation.kind == RelationKind::Imports {
12193 relation.context = context.to_string();
12194 }
12195 }
12196 let staged = finish_graph(&partial_imports)?;
12197 require(
12198 staged.relations.iter().any(|relation| {
12199 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12200 if reference.as_str() == "Sub\\helper")
12201 }),
12202 "unknown, namespace-prefix, and fallback use contexts must retain alias uncertainty",
12203 )?;
12204 }
12205 let missing_relative = extract_symbol_graph(
12206 "src/missing-relative.php",
12207 Some("php"),
12208 "<?php\nnamespace { function helper() {} }\nnamespace Foo { function run() { Missing\\helper(); } }",
12209 );
12210 let staged = finish_graph(&missing_relative)?;
12211 require(
12212 staged.relations.iter().any(|relation| {
12213 matches!(relation.resolution(), RelationResolution::Unresolved { reference }
12214 if reference.as_str() == "Missing\\helper")
12215 }),
12216 "qualified PHP functions must not fall back to the global function",
12217 )?;
12218
12219 let wrong_namespace_graph = extract_symbol_graph(
12220 "src/scoped.php",
12221 Some("php"),
12222 "<?php\nnamespace Caller { function run(): void { Service::boot(); } }\nnamespace Other { class Service { public static function boot(): void {} } }\n",
12223 );
12224 let staged = finish_graph(&wrong_namespace_graph)?;
12225 require(
12226 staged.relations.iter().any(|relation| {
12227 matches!(
12228 relation.resolution(),
12229 RelationResolution::Unresolved { reference } if reference.as_str() == "Service::boot"
12230 )
12231 }),
12232 "unqualified PHP static calls must not resolve to a class in another namespace",
12233 )?;
12234
12235 let case_sensitive_graph = extract_symbol_graph(
12236 "src/case-sensitive.js",
12237 Some("javascript"),
12238 "function helper() {}\nfunction caller() { HELPER(); }\n",
12239 );
12240 let staged = finish_graph(&case_sensitive_graph)?;
12241 require(
12242 staged.relations.iter().any(|relation| {
12243 matches!(
12244 relation.resolution(),
12245 RelationResolution::Unresolved { reference } if reference.as_str() == "HELPER"
12246 )
12247 }),
12248 "non-PHP call matching must remain case-sensitive",
12249 )?;
12250 Ok(())
12251 }
12252
12253 #[test]
12254 fn configured_module_targets_reuse_graph_ambiguity_ownership() -> Result<(), Box<dyn Error>> {
12255 let project = ProjectInstanceId::from_bytes([19; 16])?;
12256 let generation = IndexGeneration::new(1);
12257 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12258 let graphs = vec![
12259 extract_symbol_graph(
12260 "src/first/controller.ts",
12261 Some("typescript"),
12262 "export function useController() { return 'first'; }\n",
12263 ),
12264 extract_symbol_graph(
12265 "src/second/controller.ts",
12266 Some("typescript"),
12267 "export function useController() { return 'second'; }\n",
12268 ),
12269 extract_symbol_graph(
12270 "src/page.ts",
12271 Some("typescript"),
12272 "import { useController } from '@/controller';\nexport const value = useController();\n",
12273 ),
12274 ];
12275 let packages = PackageIndex::from_graphs(&graphs)?;
12276 let configured = ConfiguredModuleResolution::new(vec![EcmaScriptModuleConfig::new(
12277 "tsconfig.json",
12278 EcmaScriptConfigKind::TypeScript,
12279 None,
12280 vec![EcmaScriptPathMapping::new(
12281 "@/*",
12282 vec!["src/first/*".to_string(), "src/second/*".to_string()],
12283 )?],
12284 )?])?;
12285 let projection = build_entity_projection_with_config(
12286 project,
12287 generation,
12288 &[],
12289 &graphs,
12290 &packages,
12291 &configured,
12292 None,
12293 true,
12294 &control,
12295 )?;
12296 let candidates = resolution_registry_from_exports(&projection, &control)?;
12297 let staged = finish_projection(
12298 project,
12299 generation,
12300 RepositoryGraphMutation::Full,
12301 &graphs,
12302 projection,
12303 &candidates,
12304 &control,
12305 )?;
12306 for kind in [RelationKind::Imports, RelationKind::Calls] {
12307 require(
12308 staged.relations.iter().any(|relation| {
12309 relation.kind() == GraphRelationKind::from_legacy(kind)
12310 && matches!(
12311 relation.resolution(),
12312 RelationResolution::Ambiguous { candidates, .. }
12313 if candidates.get() == 2
12314 )
12315 }),
12316 "configured mapping did not retain all ambiguity candidates",
12317 )?;
12318 }
12319 Ok(())
12320 }
12321
12322 #[test]
12323 fn extracted_provider_matrix_survives_sqlite_publication_and_reopen()
12324 -> Result<(), Box<dyn Error>> {
12325 let temp = tempfile::tempdir()?;
12326 let root = temp.path().join("extracted-provider-matrix");
12327 fs::create_dir_all(&root)?;
12328 let database = root.join("projectatlas.db");
12329 let mut store = AtlasStore::open_for_project(&database, &root)?;
12330 let project = store
12331 .project_instance_id()?
12332 .ok_or("bound project identity is missing")?;
12333 let generation = IndexGeneration::new(1);
12334 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12335 let graphs = vec![
12336 extract_symbol_graph(
12337 "src/lib.rs",
12338 Some("rust"),
12339 "use std::{fs, io};\npub fn caller() { private_helper(); }\nfn private_helper() {}\n",
12340 ),
12341 extract_symbol_graph(
12342 "src/config.rs",
12343 Some("rust"),
12344 "pub fn load_timeout_millis() -> u64 { 250 }\n",
12345 ),
12346 extract_symbol_graph(
12347 "src/handler.rs",
12348 Some("rust"),
12349 "use crate::config;\npub fn health_response() { config::load_timeout_millis(); }\n",
12350 ),
12351 extract_symbol_graph(
12352 "src/router.rs",
12353 Some("rust"),
12354 "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
12355 ),
12356 extract_symbol_graph(
12357 "src/app.js",
12358 Some("javascript"),
12359 "import path from \"node:path\";\nexport function run() { return path.join('a', 'b'); }\n",
12360 ),
12361 extract_symbol_graph(
12362 "src/app.py",
12363 Some("python"),
12364 "import requests\n\ndef run():\n return requests.get('https://example.test')\n",
12365 ),
12366 extract_symbol_graph(
12367 "Cargo.toml",
12368 Some("cargo-manifest"),
12369 "[package]\nname = \"matrix-app\"\nversion = \"0.1.0\"\n\n[dependencies]\nduplicate = \"1\"\n",
12370 ),
12371 extract_symbol_graph(
12372 "vendor/first/Cargo.toml",
12373 Some("cargo-manifest"),
12374 "[package]\nname = \"duplicate\"\nversion = \"1.0.0\"\n",
12375 ),
12376 extract_symbol_graph(
12377 "vendor/second/Cargo.toml",
12378 Some("cargo-manifest"),
12379 "[package]\nname = \"duplicate\"\nversion = \"2.0.0\"\n",
12380 ),
12381 extract_symbol_graph(
12382 "public/index.html",
12383 Some("html"),
12384 "<script type=\"module\">\nimport fs from \"node:fs\";\nexport function boot() { return fs.readFile; }\n</script>\n",
12385 ),
12386 extract_symbol_graph(
12387 "src/Page.svelte",
12388 Some("svelte"),
12389 "<script lang=\"ts\">\nimport url from \"node:url\";\nexport function page() { return url.parse('https://example.test'); }\n</script>\n",
12390 ),
12391 ];
12392 for (language, expected_relation) in [
12393 ("rust", "std"),
12394 ("javascript", "node:path"),
12395 ("python", "requests"),
12396 ("cargo-manifest", "duplicate"),
12397 ("html", "node:fs"),
12398 ("svelte", "node:url"),
12399 ] {
12400 require(
12401 graphs.iter().any(|graph| {
12402 graph.language.as_deref() == Some(language)
12403 && graph
12404 .relations
12405 .iter()
12406 .any(|relation| relation.target_name.contains(expected_relation))
12407 }),
12408 &format!("extracted {language} provider relation is missing"),
12409 )?;
12410 }
12411 let nodes = graphs
12412 .iter()
12413 .map(|graph| {
12414 test_file_node(&graph.path, graph.language.as_deref().unwrap_or("unknown"))
12415 })
12416 .collect::<Vec<_>>();
12417 let packages = PackageIndex::from_graphs(&graphs)?;
12418 let projection = build_entity_projection(
12419 project, generation, &nodes, &graphs, &packages, true, &control,
12420 )?;
12421 let candidates = resolution_registry_from_exports(&projection, &control)?;
12422 let staged = finish_projection(
12423 project,
12424 generation,
12425 RepositoryGraphMutation::Full,
12426 &graphs,
12427 projection,
12428 &candidates,
12429 &control,
12430 )?;
12431 let source_keys = staged
12432 .entities
12433 .iter()
12434 .map(|entity| entity.key().clone())
12435 .collect::<Vec<_>>();
12436 {
12437 let mut publication = store.begin_index_publication("extracted-provider-matrix")?;
12438 publication.begin_scan_replacement()?;
12439 publication.upsert_scan_node_batch(&nodes)?;
12440 publication.finish_scan_replacement()?;
12441 staged.apply(&mut publication, &control)?;
12442 publication.complete()?;
12443 }
12444 drop(store);
12445
12446 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12447 let mut reopened = Vec::new();
12448 for source in source_keys {
12449 let page = reader.repository_graph_relations(
12450 RepositoryGraphRelationQuery::Outbound { source },
12451 128,
12452 )?;
12453 require(!page.truncated, "extracted provider matrix was truncated")?;
12454 reopened.extend(page.rows);
12455 }
12456 let mut external = BTreeSet::new();
12457 let mut unresolved = BTreeSet::new();
12458 let mut ambiguous = BTreeMap::new();
12459 let mut resolved_symbols = BTreeSet::new();
12460 for relation in reopened {
12461 require_eq(
12462 &relation.generation(),
12463 &generation,
12464 "extracted relation generation",
12465 )?;
12466 match relation.resolution() {
12467 RelationResolution::External {
12468 external: target, ..
12469 } => {
12470 external.insert((
12471 target.system.as_str().to_string(),
12472 target.identity.as_str().to_string(),
12473 ));
12474 }
12475 RelationResolution::Unresolved { reference } => {
12476 unresolved.insert(reference.as_str().to_string());
12477 }
12478 RelationResolution::Ambiguous {
12479 reference,
12480 candidates,
12481 } => {
12482 ambiguous.insert(reference.as_str().to_string(), candidates.get());
12483 }
12484 RelationResolution::Resolved { selector, .. } => {
12485 if let ReusableTargetSelector::Symbol { symbol } = selector {
12486 resolved_symbols.insert((
12487 symbol.file.as_str().to_string(),
12488 symbol.name.as_str().to_string(),
12489 ));
12490 }
12491 }
12492 }
12493 }
12494 for expected in [
12495 ("rust-toolchain".to_string(), "std".to_string()),
12496 ("node".to_string(), "path".to_string()),
12497 ("node".to_string(), "fs".to_string()),
12498 ("node".to_string(), "url".to_string()),
12499 ] {
12500 require(
12501 external.contains(&expected),
12502 &format!("reopened external target is missing: {expected:?}"),
12503 )?;
12504 }
12505 require(
12506 unresolved
12507 .iter()
12508 .any(|reference| reference.contains("requests")),
12509 "reopened Python unresolved import is missing",
12510 )?;
12511 require_eq(
12512 &ambiguous.get("duplicate"),
12513 &Some(&2),
12514 "reopened Cargo duplicate ambiguity",
12515 )?;
12516 require(
12517 resolved_symbols.contains(&("src/lib.rs".to_string(), "private_helper".to_string())),
12518 "reopened private Rust helper resolution is missing",
12519 )?;
12520 require(
12521 resolved_symbols.contains(&(
12522 "src/config.rs".to_string(),
12523 "load_timeout_millis".to_string(),
12524 )),
12525 "reopened Rust module-qualified call resolution is missing",
12526 )?;
12527 require(
12528 resolved_symbols
12529 .contains(&("src/handler.rs".to_string(), "health_response".to_string())),
12530 "reopened Rust callback resolution is missing",
12531 )?;
12532 reader.finish_index_read_snapshot()?;
12533 Ok(())
12534 }
12535
12536 #[test]
12537 fn closed_resolution_states_survive_sqlite_publication_and_reopen() -> Result<(), Box<dyn Error>>
12538 {
12539 let temp = tempfile::tempdir()?;
12540 let root = temp.path().join("closed-resolution-states");
12541 fs::create_dir_all(&root)?;
12542 let database = root.join("projectatlas.db");
12543 let mut store = AtlasStore::open_for_project(&database, &root)?;
12544 let project = store
12545 .project_instance_id()?
12546 .ok_or("bound project identity is missing")?;
12547 let generation = IndexGeneration::new(1);
12548 let control = IndexWorkControl::new(IndexCancellation::new(), None);
12549 let source = test_file_entity(project, generation, "src/main.rs")?;
12550 let unique_target = test_symbol_entity(project, generation, "src/unique.rs", "unique")?;
12551 let first_shared = test_symbol_entity(project, generation, "src/first.rs", "shared")?;
12552 let second_shared = test_symbol_entity(project, generation, "src/second.rs", "shared")?;
12553 let local_package = GraphEntity::new(
12554 project,
12555 EntitySelector::Package {
12556 package: PackageSelector {
12557 manager: GraphIdentityText::new("cargo")?,
12558 name: GraphIdentityText::new("local-package")?,
12559 manifest: RepositoryFilePath::new(Path::new("vendor/local/Cargo.toml"))?,
12560 },
12561 },
12562 generation,
12563 )?;
12564 let unique_key = test_resolution_key(project, "unique")?;
12565 let shared_key = test_resolution_key(project, "shared")?;
12566 let local_package_key = test_resolution_key(project, "local-package")?;
12567 let mut registry = ProjectResolutionRegistry::default();
12568 registry.insert_candidate(&unique_key, &unique_target)?;
12569 registry.insert_candidate(&shared_key, &first_shared)?;
12570 registry.insert_candidate(&shared_key, &second_shared)?;
12571 registry.insert_candidate(&local_package_key, &local_package)?;
12572 let source_digest = source.key().digest().to_string();
12573 let owners = GraphOwners {
12574 file_digest: source_digest.clone(),
12575 symbol_digests: Vec::new(),
12576 };
12577 let staged_entities = BTreeMap::from([(source_digest, source.clone())]);
12578 let mut external_entities = BTreeMap::new();
12579 let cases = [
12580 resolution_case("rust", RelationKind::Calls, "unique", &[&unique_key]),
12581 resolution_case("rust", RelationKind::Calls, "shared", &[&shared_key]),
12582 resolution_case("rust", RelationKind::Calls, "missing", &[]),
12583 resolution_case("rust", RelationKind::Imports, "use std::{fs, io};", &[]),
12584 resolution_case("cargo-manifest", RelationKind::DependsOn, "serde", &[]),
12585 resolution_case("cargo-lock", RelationKind::DependsOn, "serde-lock", &[]),
12586 resolution_case(
12587 "javascript",
12588 RelationKind::Imports,
12589 "import path from \"node:path\";",
12590 &[],
12591 ),
12592 resolution_case(
12593 "html",
12594 RelationKind::Imports,
12595 "import fs from \"node:fs\";",
12596 &[],
12597 ),
12598 resolution_case(
12599 "svelte",
12600 RelationKind::Imports,
12601 "import url from \"node:url\";",
12602 &[],
12603 ),
12604 resolution_case(
12605 "javascript",
12606 RelationKind::Imports,
12607 "import value from \"left-pad\";",
12608 &[],
12609 ),
12610 resolution_case("python", RelationKind::Imports, "import requests", &[]),
12611 resolution_case(
12612 "cargo-manifest",
12613 RelationKind::DependsOn,
12614 "local-package",
12615 &[&local_package_key],
12616 ),
12617 ];
12618 let mut relations = Vec::new();
12619 let mut dependencies = Vec::new();
12620 for case in &cases {
12621 let symbol_index = GraphSymbolIndex::new(&case.graph, &control)?;
12622 let resolution = relation_resolution(
12623 project,
12624 generation,
12625 &case.relation,
12626 None,
12627 &owners,
12628 &case.graph,
12629 &symbol_index,
12630 &case.keys,
12631 ®istry,
12632 &staged_entities,
12633 &mut external_entities,
12634 &control,
12635 )?;
12636 let relation = LogicalRelation::new(
12637 &source,
12638 GraphRelationKind::from_legacy(case.relation.kind),
12639 resolution,
12640 ConfidenceClass::High,
12641 Completeness::Complete,
12642 generation,
12643 )?;
12644 for key in &case.keys {
12645 dependencies.push(RelationDependencyKey::new(
12646 relation.key().clone(),
12647 key.clone(),
12648 )?);
12649 }
12650 relations.push(relation);
12651 }
12652 let external_keys = external_entities
12653 .values()
12654 .map(|entity| entity.key().clone())
12655 .collect::<Vec<_>>();
12656 let entities = [
12657 source.clone(),
12658 unique_target,
12659 first_shared,
12660 second_shared,
12661 local_package,
12662 ]
12663 .into_iter()
12664 .chain(external_entities.into_values())
12665 .collect::<Vec<_>>();
12666 let exports = [
12667 EntityResolutionKey::new(entities[1].key().clone(), unique_key.clone())?,
12668 EntityResolutionKey::new(entities[2].key().clone(), shared_key.clone())?,
12669 EntityResolutionKey::new(entities[3].key().clone(), shared_key.clone())?,
12670 EntityResolutionKey::new(entities[4].key().clone(), local_package_key.clone())?,
12671 ];
12672 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
12673 let staged = StagedRepositoryGraph {
12674 project,
12675 mutation: RepositoryGraphMutation::Full,
12676 entities,
12677 relations,
12678 occurrences: Vec::new(),
12679 coverage: Vec::new(),
12680 entity_exports: exports.into(),
12681 relation_dependencies: dependencies,
12682 document_unresolved_reasons: Vec::new(),
12683 identity_rejections: Vec::new(),
12684 resolution_derivations: BTreeMap::new(),
12685 peak_retained_bytes: 0,
12686 projection_removals_before_entities: Vec::new(),
12687 scan_policy,
12688 document_target_states: Vec::new(),
12689 database: None,
12690 retained_bytes: 0,
12691 };
12692 {
12693 let mut publication = store.begin_index_publication("closed-resolution-states")?;
12694 publication.begin_scan_replacement()?;
12695 publication.upsert_scan_node_batch(&[
12696 test_file_node("src/main.rs", "rust"),
12697 test_file_node("src/unique.rs", "rust"),
12698 test_file_node("src/first.rs", "rust"),
12699 test_file_node("src/second.rs", "rust"),
12700 test_file_node("vendor/local/Cargo.toml", "cargo-manifest"),
12701 ])?;
12702 publication.finish_scan_replacement()?;
12703 staged.apply(&mut publication, &control)?;
12704 publication.complete()?;
12705 }
12706 drop(store);
12707
12708 let reader = AtlasStore::open_read_only_for_project(&database, &root)?;
12709 let page = reader.repository_graph_relations(
12710 RepositoryGraphRelationQuery::Outbound {
12711 source: source.key().clone(),
12712 },
12713 32,
12714 )?;
12715 require(!page.truncated, "closed resolution proof was truncated")?;
12716 require_eq(&page.rows.len(), &cases.len(), "reopened relation count")?;
12717 let mut resolved = BTreeSet::new();
12718 let mut ambiguous = BTreeMap::new();
12719 let mut unresolved = BTreeSet::new();
12720 let mut external = BTreeSet::new();
12721 for relation in &page.rows {
12722 require_eq(
12723 &relation.generation(),
12724 &generation,
12725 "reopened relation generation",
12726 )?;
12727 match relation.resolution() {
12728 RelationResolution::Resolved {
12729 selector,
12730 generation: target_generation,
12731 ..
12732 } => {
12733 require_eq(target_generation, &generation, "resolved target generation")?;
12734 match selector {
12735 ReusableTargetSelector::Symbol { symbol } => {
12736 resolved.insert(symbol.name.as_str().to_string());
12737 }
12738 ReusableTargetSelector::Package { package } => {
12739 resolved.insert(package.name.as_str().to_string());
12740 }
12741 ReusableTargetSelector::Folder { .. }
12742 | ReusableTargetSelector::File { .. } => {}
12743 }
12744 }
12745 RelationResolution::Ambiguous {
12746 reference,
12747 candidates,
12748 } => {
12749 ambiguous.insert(reference.as_str().to_string(), candidates.get());
12750 }
12751 RelationResolution::Unresolved { reference } => {
12752 unresolved.insert(reference.as_str().to_string());
12753 }
12754 RelationResolution::External {
12755 external: selector,
12756 generation: target_generation,
12757 ..
12758 } => {
12759 require_eq(target_generation, &generation, "external target generation")?;
12760 external.insert((
12761 selector.system.as_str().to_string(),
12762 selector.identity.as_str().to_string(),
12763 ));
12764 }
12765 }
12766 }
12767 require_eq(
12768 &resolved,
12769 &BTreeSet::from(["local-package".to_string(), "unique".to_string()]),
12770 "resolved targets",
12771 )?;
12772 require_eq(
12773 &ambiguous,
12774 &BTreeMap::from([("shared".to_string(), 2)]),
12775 "ambiguous targets",
12776 )?;
12777 require_eq(
12778 &unresolved,
12779 &BTreeSet::from([
12780 "import requests".to_string(),
12781 "import value from \"left-pad\";".to_string(),
12782 "missing".to_string(),
12783 "serde-lock".to_string(),
12784 ]),
12785 "unresolved targets",
12786 )?;
12787 require_eq(
12788 &external,
12789 &BTreeSet::from([
12790 ("cargo".to_string(), "serde".to_string()),
12791 ("node".to_string(), "fs".to_string()),
12792 ("node".to_string(), "path".to_string()),
12793 ("node".to_string(), "url".to_string()),
12794 ("rust-toolchain".to_string(), "std".to_string()),
12795 ]),
12796 "external targets",
12797 )?;
12798 for key in &external_keys {
12799 let entity = reader
12800 .repository_graph_entity(key)?
12801 .ok_or("reopened external entity is missing")?;
12802 require(
12803 matches!(entity.selector(), EntitySelector::External { .. }),
12804 "external relation target reopened as a local entity",
12805 )?;
12806 require_eq(
12807 &entity.generation(),
12808 &generation,
12809 "external entity generation",
12810 )?;
12811 }
12812 let affected = reader.repository_affected_source_paths(
12813 project,
12814 &[unique_key, shared_key, local_package_key],
12815 32,
12816 )?;
12817 require(!affected.truncated, "dependency source proof was truncated")?;
12818 require_eq(
12819 &affected.rows,
12820 &vec![RepositoryFilePath::new(Path::new("src/main.rs"))?],
12821 "reopened dependency source paths",
12822 )?;
12823 reader.finish_index_read_snapshot()?;
12824 Ok(())
12825 }
12826
12827 struct ResolutionCase {
12828 graph: SymbolGraph,
12829 relation: SymbolRelation,
12830 keys: Vec<CanonicalResolutionKey>,
12831 }
12832
12833 fn resolution_case(
12834 language: &str,
12835 kind: RelationKind,
12836 target: &str,
12837 keys: &[&CanonicalResolutionKey],
12838 ) -> ResolutionCase {
12839 let parser = if language.starts_with("cargo-") {
12840 ParserKind::Manifest
12841 } else {
12842 ParserKind::TreeSitter
12843 };
12844 ResolutionCase {
12845 graph: SymbolGraph {
12846 path: "src/main.rs".to_string(),
12847 language: Some(language.to_string()),
12848 parser,
12849 symbols: Vec::new(),
12850 relations: Vec::new(),
12851 },
12852 relation: SymbolRelation {
12853 path: "src/main.rs".to_string(),
12854 source_name: "src/main.rs".to_string(),
12855 target_name: target.to_string(),
12856 kind,
12857 line: 1,
12858 context: target.to_string(),
12859 parser,
12860 },
12861 keys: keys.iter().map(|key| (*key).clone()).collect(),
12862 }
12863 }
12864
12865 fn test_resolution_key(
12866 project: ProjectInstanceId,
12867 identity: &str,
12868 ) -> Result<CanonicalResolutionKey, Box<dyn Error>> {
12869 let provider = GraphIdentityText::new("test-provider")?;
12870 let language = GraphIdentityText::new("test-language")?;
12871 let identity = GraphIdentityText::new(identity)?;
12872 Ok(CanonicalResolutionKey::new(
12873 project,
12874 ResolutionKeyDomain::Declaration,
12875 &provider,
12876 &language,
12877 None,
12878 None,
12879 None,
12880 &identity,
12881 ))
12882 }
12883
12884 fn test_file_entity(
12885 project: ProjectInstanceId,
12886 generation: IndexGeneration,
12887 path: &str,
12888 ) -> Result<GraphEntity, Box<dyn Error>> {
12889 Ok(GraphEntity::new(
12890 project,
12891 EntitySelector::File {
12892 path: RepositoryFilePath::new(Path::new(path))?,
12893 },
12894 generation,
12895 )?)
12896 }
12897
12898 fn test_symbol_entity(
12899 project: ProjectInstanceId,
12900 generation: IndexGeneration,
12901 path: &str,
12902 name: &str,
12903 ) -> Result<GraphEntity, Box<dyn Error>> {
12904 Ok(GraphEntity::new(
12905 project,
12906 EntitySelector::Symbol {
12907 symbol: SymbolSelector {
12908 file: RepositoryFilePath::new(Path::new(path))?,
12909 name: GraphIdentityText::new(name)?,
12910 kind: SymbolKind::Function,
12911 parent: None,
12912 signature: GraphIdentityText::new(format!("fn {name}()"))?,
12913 },
12914 },
12915 generation,
12916 )?)
12917 }
12918
12919 fn test_code_symbol(
12920 path: &str,
12921 name: &str,
12922 parent: Option<&str>,
12923 signature: &str,
12924 ) -> CodeSymbol {
12925 CodeSymbol {
12926 path: path.to_string(),
12927 language: Some("rust".to_string()),
12928 name: name.to_string(),
12929 kind: SymbolKind::Function,
12930 signature: signature.to_string(),
12931 exported: false,
12932 documentation: None,
12933 line_start: 1,
12934 line_end: 1,
12935 source_selector: None,
12936 parent: parent.map(ToString::to_string),
12937 parser: ParserKind::TreeSitter,
12938 detail: Some("function_item".to_string()),
12939 }
12940 }
12941
12942 #[test]
12943 fn source_graph_admission_keeps_valid_rows_and_typed_rejection_coverage()
12944 -> Result<(), Box<dyn Error>> {
12945 let valid = test_code_symbol("src/lib.rs", "valid", None, "fn valid()");
12946 let invalid_name = test_code_symbol("src/lib.rs", "bad\u{0}name", None, "fn bad()");
12947 let invalid_signature = test_code_symbol("src/lib.rs", "padded", None, " fn padded() ");
12948 let invalid_parent = test_code_symbol("src/lib.rs", "child", Some(" parent "), "child()");
12949 let invalid_reserved = test_code_symbol(
12950 "src/lib.rs",
12951 &format!("{QUALIFIED_SYMBOL_SCOPE_PREFIX}derived"),
12952 None,
12953 "fn derived()",
12954 );
12955 let invalid_oversized = test_code_symbol(
12956 "src/lib.rs",
12957 &"x".repeat(MAX_GRAPH_IDENTITY_BYTES + 1),
12958 None,
12959 "fn oversized()",
12960 );
12961 let graph = SymbolGraph {
12962 path: "src/lib.rs".to_string(),
12963 language: Some("rust".to_string()),
12964 parser: ParserKind::TreeSitter,
12965 symbols: vec![
12966 valid,
12967 invalid_name,
12968 invalid_signature,
12969 invalid_parent,
12970 invalid_reserved,
12971 invalid_oversized,
12972 ],
12973 relations: vec![
12974 SymbolRelation {
12975 path: "src/lib.rs".to_string(),
12976 source_name: "valid".to_string(),
12977 target_name: "helper".to_string(),
12978 kind: RelationKind::Calls,
12979 line: 1,
12980 context: "helper()".to_string(),
12981 parser: ParserKind::TreeSitter,
12982 },
12983 SymbolRelation {
12984 path: "src/lib.rs".to_string(),
12985 source_name: "valid".to_string(),
12986 target_name: "bad\u{0}target".to_string(),
12987 kind: RelationKind::Calls,
12988 line: 2,
12989 context: "bad()".to_string(),
12990 parser: ParserKind::TreeSitter,
12991 },
12992 SymbolRelation {
12993 path: "src/lib.rs".to_string(),
12994 source_name: " valid ".to_string(),
12995 target_name: "helper".to_string(),
12996 kind: RelationKind::Calls,
12997 line: 3,
12998 context: "helper()".to_string(),
12999 parser: ParserKind::TreeSitter,
13000 },
13001 ],
13002 };
13003 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13004 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
13005 require_eq(&admitted.symbols.len(), &1, "valid symbol admission")?;
13006 require_eq(&admitted.relations.len(), &1, "valid relation admission")?;
13007 require_eq(
13008 &report.rejected_facts_for("src/lib.rs"),
13009 &7,
13010 "rejected fact count",
13011 )?;
13012 require_eq(
13013 &admitted.symbols[0].signature,
13014 &"fn valid()".to_string(),
13015 "valid signature preservation",
13016 )?;
13017
13018 let coverage = super::coverage_for_graph(
13019 &admitted,
13020 IndexGeneration::new(1),
13021 &report,
13022 &super::GraphIdentityAdmission::default(),
13023 )?;
13024 require_eq(
13025 &coverage.state(),
13026 &CoverageState::Partial,
13027 "admission coverage state",
13028 )?;
13029 require_eq(&coverage.covered(), &2, "admission covered count")?;
13030 require_eq(&coverage.omitted(), &7, "admission omitted count")?;
13031 let reason = coverage
13032 .reason()
13033 .ok_or_else(|| io::Error::other("admission coverage omitted its reason"))?
13034 .as_str();
13035 require_eq(
13036 &reason,
13037 &PARTIAL_COVERAGE_REASON,
13038 "admission coverage keeps the stable coarse reason",
13039 )?;
13040 require_eq(&report.rejections.len(), &7, "typed rejection detail count")?;
13041 require(
13042 report.rejections.iter().all(|rejection| {
13043 rejection.path.as_str() == "src/lib.rs"
13044 && rejection.parser == ParserKind::TreeSitter
13045 && rejection.span.start_line() >= 1
13046 && rejection.span.end_line() >= rejection.span.start_line()
13047 }),
13048 "typed rejection details lost path/parser/span ownership",
13049 )?;
13050 require(
13051 report
13052 .rejections
13053 .iter()
13054 .any(|rejection| rejection.field == GraphIdentityField::RelationTarget),
13055 "typed rejection details lost relation-target ownership",
13056 )?;
13057
13058 let temp = tempfile::tempdir()?;
13059 fs::create_dir_all(temp.path().join("src"))?;
13060 let nodes = vec![test_file_node("src/lib.rs", "rust")];
13061 let project = ProjectInstanceId::from_bytes([71; 16])?;
13062 let generation = IndexGeneration::new(1);
13063 let packages = PackageIndex::from_graphs(std::slice::from_ref(&admitted))?;
13064 let projection = build_entity_projection(
13065 project,
13066 generation,
13067 &nodes,
13068 std::slice::from_ref(&admitted),
13069 &packages,
13070 true,
13071 &control,
13072 )?;
13073 let candidates = resolution_registry_from_exports(&projection, &control)?;
13074 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13075 let staged = super::finish_projection_with_documents(
13076 project,
13077 generation,
13078 RepositoryGraphMutation::Full,
13079 std::slice::from_ref(&admitted),
13080 temp.path(),
13081 &nodes,
13082 &BTreeMap::new(),
13083 &report,
13084 projection,
13085 &candidates,
13086 &scan_policy,
13087 &control,
13088 )?;
13089 require_eq(&staged.relations.len(), &1, "valid relation publication")?;
13090 require(
13091 staged.entities.iter().any(|entity| {
13092 matches!(
13093 entity.selector(),
13094 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "valid"
13095 )
13096 }),
13097 "valid symbol publication was lost with invalid siblings",
13098 )?;
13099 require_eq(
13100 &staged.identity_rejections.len(),
13101 &7,
13102 "typed rejection coverage was not staged with valid rows",
13103 )?;
13104 Ok(())
13105 }
13106
13107 #[test]
13108 fn invalid_sibling_does_not_fail_symbol_only_coverage() -> Result<(), Box<dyn Error>> {
13109 let graph = SymbolGraph {
13110 path: "src/symbol-only.rs".to_string(),
13111 language: Some("rust".to_string()),
13112 parser: ParserKind::TreeSitter,
13113 symbols: vec![
13114 test_code_symbol("src/symbol-only.rs", "valid", None, "fn valid()"),
13115 test_code_symbol("src/symbol-only.rs", "bad\u{0}name", None, "fn bad()"),
13116 ],
13117 relations: Vec::new(),
13118 };
13119 let control = IndexWorkControl::new(IndexCancellation::new(), None);
13120 let (admitted, report) = super::admit_symbol_graph(Cow::Owned(graph), &control)?;
13121 let coverage = super::coverage_for_graph(
13122 &admitted,
13123 IndexGeneration::new(1),
13124 &report,
13125 &super::GraphIdentityAdmission::default(),
13126 )?;
13127 require_eq(
13128 &coverage.state(),
13129 &CoverageState::Partial,
13130 "valid symbols keep mixed identity coverage partial",
13131 )?;
13132 require_eq(
13133 &coverage.covered(),
13134 &1,
13135 "valid symbol is counted as covered",
13136 )?;
13137 require_eq(
13138 &coverage.omitted(),
13139 &1,
13140 "invalid sibling is counted as omitted",
13141 )?;
13142 require_eq(
13143 &coverage.reason().map(GraphIdentityText::as_str),
13144 &Some(PARTIAL_COVERAGE_REASON),
13145 "symbol-only coverage keeps the coarse reason",
13146 )?;
13147 Ok(())
13148 }
13149
13150 #[test]
13151 fn document_target_normalization_is_relative_bounded_and_platform_neutral()
13152 -> Result<(), Box<dyn Error>> {
13153 require_eq(
13154 &normalize_document_target("docs/guide.md", "../src/lib.rs:L12-L20?view=raw#entry")
13155 .map_err(|reason| io::Error::other(reason.to_string()))?,
13156 &DocumentTargetIdentity {
13157 path: "src/lib.rs".to_string(),
13158 fragment: Some("entry".to_string()),
13159 },
13160 "relative document target",
13161 )?;
13162 require_eq(
13163 &normalize_document_target("guide.md", "README")
13164 .map_err(|reason| io::Error::other(reason.to_string()))?,
13165 &DocumentTargetIdentity {
13166 path: "README".to_string(),
13167 fragment: None,
13168 },
13169 "root extensionless target",
13170 )?;
13171 require_eq(
13172 &normalize_document_target("docs/guide.md", "../../../private.txt"),
13173 &Err(DocumentTargetUnresolvedReason::OutsideRoot),
13174 "outside-root selector",
13175 )?;
13176 require_eq(
13177 &normalize_document_target("docs/guide.md", "target.md#runtime value"),
13178 &Err(DocumentTargetUnresolvedReason::NoStaticTarget),
13179 "non-static fragment refusal",
13180 )?;
13181 Ok(())
13182 }
13183
13184 #[test]
13185 fn document_rows_resolve_exact_files_and_headings_with_typed_absence()
13186 -> Result<(), Box<dyn Error>> {
13187 let temp = tempfile::tempdir()?;
13188 let root = temp.path();
13189 fs::create_dir_all(root.join("docs"))?;
13190 fs::write(root.join(".gitignore"), "docs/ignored.md\n")?;
13191 fs::write(root.join("docs/ignored.md"), "ignored")?;
13192 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";
13193 let source_facts = projectatlas_symbols::extract_markdown_facts(source);
13194 let target_facts = projectatlas_symbols::extract_markdown_facts("# API\n");
13195 let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
13196 let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
13197 let source_code_graph = SymbolGraph {
13198 path: "src/lib.rs".to_string(),
13199 language: Some("rust".to_string()),
13200 parser: ParserKind::TreeSitter,
13201 symbols: Vec::new(),
13202 relations: Vec::new(),
13203 };
13204 let graphs = vec![source_graph, target_graph, source_code_graph];
13205 let mut nodes = vec![
13206 test_file_node("docs/guide.md", "markdown"),
13207 test_file_node("docs/target.md", "markdown"),
13208 test_file_node("src/lib.rs", "rust"),
13209 ];
13210 let mut folder = test_file_node("src", "unknown");
13211 folder.kind = NodeKind::Folder;
13212 folder.language = None;
13213 folder.extension = None;
13214 nodes.push(folder);
13215 let project = ProjectInstanceId::from_bytes([31; 16])?;
13216 let generation = IndexGeneration::new(4);
13217 let packages = PackageIndex::from_graphs(&graphs)?;
13218 let control = super::super::standalone_index_work_control();
13219 let mut projection = build_entity_projection(
13220 project, generation, &nodes, &graphs, &packages, true, &control,
13221 )?;
13222 let candidates = resolution_registry_from_exports(&projection, &control)?;
13223 let owners = projection
13224 .owners_by_graph
13225 .remove("docs/guide.md")
13226 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13227 let scan_policy = RootScanPolicy::discover(root, &ScanOptions::default(), &control)?;
13228 let index = DocumentResolutionIndex::new(root, &nodes, &scan_policy)?;
13229 let rows = project_document_rows(
13230 project,
13231 generation,
13232 &graphs[0],
13233 &source_facts,
13234 &owners,
13235 &index,
13236 &candidates,
13237 &projection.entity_by_digest,
13238 &control,
13239 )?;
13240 require_eq(&rows.relations.len(), &9, "document relation count")?;
13241 require_eq(&rows.occurrences.len(), &9, "document occurrence count")?;
13242 require_eq(
13243 &rows
13244 .relations
13245 .iter()
13246 .filter(|relation| {
13247 matches!(relation.resolution(), RelationResolution::Resolved { .. })
13248 })
13249 .count(),
13250 &2,
13251 "resolved document targets",
13252 )?;
13253 let reasons = rows
13254 .document_unresolved_reasons
13255 .iter()
13256 .map(|(_key, reason)| *reason)
13257 .collect::<BTreeSet<_>>();
13258 require_eq(
13259 &reasons,
13260 &BTreeSet::from([
13261 DocumentTargetUnresolvedReason::Missing,
13262 DocumentTargetUnresolvedReason::Ignored,
13263 DocumentTargetUnresolvedReason::OutsideRoot,
13264 DocumentTargetUnresolvedReason::CaseConflict,
13265 DocumentTargetUnresolvedReason::Unsupported,
13266 ]),
13267 "typed unresolved reasons",
13268 )?;
13269 require(
13270 rows.relations.iter().any(|relation| {
13271 matches!(
13272 relation.resolution(),
13273 RelationResolution::Resolved {
13274 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13275 symbol
13276 },
13277 ..
13278 } if symbol.kind == SymbolKind::Heading && symbol.signature.as_str() == "api"
13279 )
13280 }),
13281 "heading fragment did not resolve to its heading entity",
13282 )?;
13283 Ok(())
13284 }
13285
13286 #[test]
13287 fn markdown_identity_admission_keeps_valid_document_siblings() -> Result<(), Box<dyn Error>> {
13288 let temp = tempfile::tempdir()?;
13289 let root = fs::canonicalize(temp.path())?;
13290 fs::create_dir_all(root.join("docs"))?;
13291 let guide_source = "# Guide\n\n[invalid](<target.md\u{1}>)\n[valid](target.md#target)\n";
13292 fs::write(root.join("docs/guide.md"), guide_source)?;
13293 fs::write(root.join("docs/target.md"), "# Target\n")?;
13294 let source_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
13295 require_eq(
13296 &source_facts.link_candidates.len(),
13297 &2,
13298 "parser Markdown sibling candidate count",
13299 )?;
13300 require_eq(
13301 &source_facts.link_candidates[0].selector,
13302 &"target.md\u{1}".to_string(),
13303 "parser Markdown control selector",
13304 )?;
13305 let source_graph = source_facts.symbol_graph("docs/guide.md", Some("markdown"));
13306 let target_facts = projectatlas_symbols::extract_markdown_facts("# Target\n");
13307 let target_graph = target_facts.symbol_graph("docs/target.md", Some("markdown"));
13308 let nodes = vec![
13309 test_file_node("docs/guide.md", "markdown"),
13310 test_file_node("docs/target.md", "markdown"),
13311 ];
13312 let control = super::super::standalone_index_work_control();
13313 let mut symbols = empty_symbol_build_stage();
13314 symbols.report.candidates = 2;
13315 symbols.report.parsed = 2;
13316 symbols.report.summaries = 2;
13317 symbols.changes = vec![
13318 SymbolProjectionChange::Parsed(SymbolParseSuccess {
13319 path: "docs/guide.md".to_string(),
13320 graph: source_graph.clone(),
13321 markdown_facts: Some(Box::new(source_facts)),
13322 source_parser: ParserKind::Structural,
13323 summary: "Guide".to_string(),
13324 summary_is_structural: true,
13325 purpose_suggestion: None,
13326 }),
13327 SymbolProjectionChange::Parsed(SymbolParseSuccess {
13328 path: "docs/target.md".to_string(),
13329 graph: target_graph.clone(),
13330 markdown_facts: Some(Box::new(target_facts)),
13331 source_parser: ParserKind::Structural,
13332 summary: "Target".to_string(),
13333 summary_is_structural: true,
13334 purpose_suggestion: None,
13335 }),
13336 ];
13337 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
13338 let admitted_counts = symbols
13339 .changes
13340 .iter()
13341 .filter_map(|change| match change {
13342 SymbolProjectionChange::Parsed(parsed) => {
13343 Some((parsed.graph.symbols.len(), parsed.graph.relations.len()))
13344 }
13345 SymbolProjectionChange::Clear { .. } => None,
13346 })
13347 .fold(
13348 (0, 0),
13349 |(symbols, relations), (next_symbols, next_relations)| {
13350 (symbols + next_symbols, relations + next_relations)
13351 },
13352 );
13353 require_eq(
13354 &(symbols.report.symbols, symbols.report.relations),
13355 &admitted_counts,
13356 "post-admission symbol report counts",
13357 )?;
13358 require_eq(
13359 &symbols.identity_admission.rejections.len(),
13360 &1,
13361 "Markdown rejection detail count",
13362 )?;
13363 let rejection = symbols
13364 .identity_admission
13365 .rejections
13366 .first()
13367 .ok_or_else(|| io::Error::other("Markdown rejection detail is missing"))?;
13368 require_eq(
13369 &rejection.path.as_str(),
13370 &"docs/guide.md",
13371 "Markdown rejection path",
13372 )?;
13373 require_eq(
13374 &rejection.parser,
13375 &ParserKind::Structural,
13376 "Markdown rejection parser",
13377 )?;
13378 require_eq(
13379 &rejection.field,
13380 &GraphIdentityField::RelationTarget,
13381 "Markdown rejection field",
13382 )?;
13383 require_eq(
13384 &rejection.reason,
13385 &GraphIdentityRejectionReason::ControlCharacters,
13386 "Markdown rejection reason",
13387 )?;
13388 require_eq(
13389 &rejection.span.start_line(),
13390 &3,
13391 "Markdown rejection start line",
13392 )?;
13393 require_eq(
13394 &rejection.span.start_column(),
13395 &0,
13396 "Markdown rejection start column",
13397 )?;
13398 require_eq(
13399 &rejection.span.end_line(),
13400 &3,
13401 "Markdown rejection end line",
13402 )?;
13403 require_eq(
13404 &rejection.span.end_column(),
13405 &23,
13406 "Markdown rejection end column",
13407 )?;
13408 let database = root.join("projectatlas.db");
13409 let mut store = AtlasStore::open_for_project(&database, &root)?;
13410 store.replace_scan(&nodes)?;
13411 store.replace_symbol_graph(&source_graph)?;
13412 store.replace_symbol_graph(&target_graph)?;
13413 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
13414 let staged = stage_full_repository_graph(
13415 &store,
13416 &root,
13417 IndexGeneration::ZERO,
13418 &nodes,
13419 &scan_policy,
13420 &symbols,
13421 &control,
13422 )?;
13423 require_eq(
13424 &staged.identity_rejections.len(),
13425 &1,
13426 "staged Markdown rejection detail count",
13427 )?;
13428 require_eq(
13429 &staged.relations.len(),
13430 &1,
13431 "staged valid Markdown relation count",
13432 )?;
13433 require(
13434 staged.coverage.iter().any(|coverage| {
13435 matches!(
13436 coverage.scope(),
13437 CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
13438 ) && coverage.state() == CoverageState::Complete
13439 && coverage.covered() == 1
13440 && coverage.omitted() == 0
13441 }),
13442 "invalid Markdown selector changed valid-sibling coverage semantics",
13443 )?;
13444 require(
13445 staged.relations.iter().any(|relation| {
13446 matches!(
13447 relation.resolution(),
13448 RelationResolution::Resolved {
13449 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13450 symbol
13451 },
13452 ..
13453 } if symbol.file.as_str() == "docs/target.md"
13454 && symbol.signature.as_str() == "target"
13455 )
13456 }),
13457 "staged valid Markdown sibling was not resolved",
13458 )?;
13459 publish_full_staged_graph(&mut store, &nodes, &staged, &control, "markdown-admission")?;
13460 drop(store);
13461 let mut store = AtlasStore::open_for_project(&database, &root)?;
13462 let project = store
13463 .project_instance_id()?
13464 .ok_or("Markdown admission project identity is missing")?;
13465 let paths = nodes
13466 .iter()
13467 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
13468 .collect::<Result<Vec<_>, _>>()?;
13469 let persisted_rejections =
13470 store.repository_graph_identity_rejections(project, &paths, 16, None)?;
13471 require_eq(
13472 &persisted_rejections.len(),
13473 &1,
13474 "persisted Markdown rejection detail count",
13475 )?;
13476 let persisted_rejection = persisted_rejections
13477 .first()
13478 .ok_or("persisted Markdown rejection detail is missing")?;
13479 require_eq(
13480 &persisted_rejection.span.start_line(),
13481 &3,
13482 "persisted Markdown rejection start line",
13483 )?;
13484 require_eq(
13485 &persisted_rejection.span.start_column(),
13486 &0,
13487 "persisted Markdown rejection start column",
13488 )?;
13489 require_eq(
13490 &persisted_rejection.span.end_line(),
13491 &3,
13492 "persisted Markdown rejection end line",
13493 )?;
13494 require_eq(
13495 &persisted_rejection.span.end_column(),
13496 &23,
13497 "persisted Markdown rejection end column",
13498 )?;
13499 let persisted_wire = serde_json::to_string(&persisted_rejections)?;
13500 require(
13501 !persisted_wire.contains("\\u0001") && !persisted_wire.contains("target.md"),
13502 "persisted Markdown rejection retained the invalid selector",
13503 )?;
13504 let persisted_relations = store.repository_graph_relation_rows(
13505 RepositoryGraphRelationQuery::Family {
13506 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
13507 },
13508 GraphLimits::MAX_ROWS,
13509 None,
13510 )?;
13511 require_eq(
13512 &persisted_relations.rows.len(),
13513 &1,
13514 "reopened valid Markdown relation count",
13515 )?;
13516 require(
13517 persisted_relations.rows.iter().any(|relation| {
13518 matches!(
13519 relation.relation.resolution(),
13520 RelationResolution::Resolved {
13521 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol {
13522 symbol
13523 },
13524 ..
13525 } if symbol.file.as_str() == "docs/target.md"
13526 && symbol.signature.as_str() == "target"
13527 )
13528 }),
13529 "reopened valid Markdown sibling was not resolved",
13530 )?;
13531
13532 let base_generation = store
13533 .index_publication()?
13534 .ok_or("Markdown admission publication is missing")?
13535 .generation;
13536 let mut incremental_symbols = symbol_build_stage_for_markdown(
13537 source_graph.clone(),
13538 projectatlas_symbols::extract_markdown_facts(guide_source),
13539 );
13540 incremental_symbols.identity_admission =
13541 super::admit_symbol_build_stage(&mut incremental_symbols, &control)?;
13542 let incremental_stage = stage_incremental_repository_graph(
13543 &store,
13544 &root,
13545 base_generation,
13546 &nodes,
13547 &["docs/guide.md".to_string()],
13548 &scan_policy,
13549 &incremental_symbols,
13550 &control,
13551 )?;
13552 require_eq(
13553 &incremental_stage.identity_rejections.len(),
13554 &1,
13555 "incremental Markdown rejection detail count",
13556 )?;
13557 require_eq(
13558 &incremental_stage.relations.len(),
13559 &1,
13560 "incremental valid Markdown relation count",
13561 )?;
13562 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
13563 canceled.cancel();
13564 {
13565 let mut publication = store.begin_index_publication("markdown-admission-cancel")?;
13566 let error = incremental_stage.apply(&mut publication, &canceled).err();
13567 require(
13568 matches!(
13569 error,
13570 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
13571 stage: IndexWorkStage::Publication
13572 }))
13573 ),
13574 "incremental Markdown cancellation was not observed",
13575 )?;
13576 }
13577 require_eq(
13578 &store
13579 .index_publication()?
13580 .map(|publication| publication.generation),
13581 &Some(base_generation),
13582 "publication after canceled Markdown refresh",
13583 )?;
13584 {
13585 let mut publication =
13586 store.begin_index_publication("markdown-admission-incremental")?;
13587 incremental_stage.apply(&mut publication, &control)?;
13588 publication.complete()?;
13589 }
13590 let incremental_publication = store
13591 .index_publication()?
13592 .ok_or("incremental Markdown publication is missing")?;
13593 require_eq(
13594 &incremental_publication.generation,
13595 &base_generation
13596 .checked_next()
13597 .ok_or("Markdown generation overflowed")?,
13598 "incremental Markdown generation",
13599 )?;
13600 drop(store);
13601 let mut store = AtlasStore::open_for_project(&database, &root)?;
13602 let reopened_incremental =
13603 store.repository_graph_identity_rejections(project, &paths, 16, None)?;
13604 require_eq(
13605 &reopened_incremental,
13606 &persisted_rejections,
13607 "reopened incremental Markdown rejection details",
13608 )?;
13609
13610 let fault_generation = store
13611 .index_publication()?
13612 .ok_or("incremental Markdown publication disappeared")?
13613 .generation;
13614 let mut fault_symbols = symbol_build_stage_for_markdown(
13615 source_graph.clone(),
13616 projectatlas_symbols::extract_markdown_facts(guide_source),
13617 );
13618 fault_symbols.identity_admission =
13619 super::admit_symbol_build_stage(&mut fault_symbols, &control)?;
13620 let mut fault_stage = stage_incremental_repository_graph(
13621 &store,
13622 &root,
13623 fault_generation,
13624 &nodes,
13625 &["docs/guide.md".to_string()],
13626 &scan_policy,
13627 &fault_symbols,
13628 &control,
13629 )?;
13630 fault_stage.identity_rejections.resize(
13631 usize::try_from(GraphLimits::MAX_ROWS)
13632 .unwrap_or(usize::MAX)
13633 .saturating_add(1),
13634 fault_stage.identity_rejections[0].clone(),
13635 );
13636 {
13637 let mut publication = store.begin_index_publication("markdown-admission-fault")?;
13638 require(
13639 fault_stage.apply(&mut publication, &control).is_err(),
13640 "late Markdown rejection-detail fault did not fail",
13641 )?;
13642 }
13643 require_eq(
13644 &store
13645 .index_publication()?
13646 .map(|publication| publication.generation),
13647 &Some(fault_generation),
13648 "generation after late Markdown rejection-detail fault",
13649 )?;
13650 require_eq(
13651 &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
13652 &reopened_incremental,
13653 "previous Markdown generation after late fault",
13654 )?;
13655 let retry_symbols = symbol_build_stage_for_markdown(
13656 source_graph,
13657 projectatlas_symbols::extract_markdown_facts(guide_source),
13658 );
13659 let retry_stage = stage_incremental_repository_graph(
13660 &store,
13661 &root,
13662 fault_generation,
13663 &nodes,
13664 &["docs/guide.md".to_string()],
13665 &scan_policy,
13666 &retry_symbols,
13667 &control,
13668 )?;
13669 {
13670 let mut publication = store.begin_index_publication("markdown-admission-retry")?;
13671 retry_stage.apply(&mut publication, &control)?;
13672 publication.complete()?;
13673 }
13674 require_eq(
13675 &store.repository_graph_identity_rejections(project, &paths, 16, None)?,
13676 &reopened_incremental,
13677 "deterministic Markdown retry",
13678 )?;
13679 Ok(())
13680 }
13681
13682 #[test]
13683 fn document_rows_use_file_identity_and_deduplicate_across_headings()
13684 -> Result<(), Box<dyn Error>> {
13685 let temp = tempfile::tempdir()?;
13686 fs::create_dir_all(temp.path().join("src"))?;
13687 fs::write(temp.path().join("src/lib.rs"), "pub fn entry() {}\n")?;
13688 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";
13689 let facts = projectatlas_symbols::extract_markdown_facts(source);
13690 let enclosing_heading_bytes = facts
13691 .link_candidates
13692 .iter()
13693 .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
13694 .sum::<u64>();
13695 let mut facts_without_heading_owners = facts.clone();
13696 for candidate in &mut facts_without_heading_owners.link_candidates {
13697 candidate.enclosing_heading = None;
13698 }
13699 let retained_with_heading_owners = document_fact_map_retained_bytes(&BTreeMap::from([(
13700 "docs/guide.md".to_string(),
13701 Cow::Owned(facts.clone()),
13702 )]));
13703 let retained_without_heading_owners =
13704 document_fact_map_retained_bytes(&BTreeMap::from([(
13705 "docs/guide.md".to_string(),
13706 Cow::Owned(facts_without_heading_owners),
13707 )]));
13708 require_eq(
13709 &retained_with_heading_owners.saturating_sub(retained_without_heading_owners),
13710 &enclosing_heading_bytes,
13711 "document registry enclosing-heading bytes",
13712 )?;
13713 let source_graph = facts.symbol_graph("docs/guide.md", Some("markdown"));
13714 let target_graph = SymbolGraph {
13715 path: "src/lib.rs".to_string(),
13716 language: Some("rust".to_string()),
13717 parser: ParserKind::TreeSitter,
13718 symbols: Vec::new(),
13719 relations: Vec::new(),
13720 };
13721 let graphs = vec![source_graph, target_graph];
13722 let nodes = vec![
13723 test_file_node("docs/guide.md", "markdown"),
13724 test_file_node("src/lib.rs", "rust"),
13725 ];
13726 let project = ProjectInstanceId::from_bytes([33; 16])?;
13727 let generation = IndexGeneration::new(5);
13728 let packages = PackageIndex::from_graphs(&graphs)?;
13729 let control = super::super::standalone_index_work_control();
13730 let mut projection = build_entity_projection(
13731 project, generation, &nodes, &graphs, &packages, true, &control,
13732 )?;
13733 let candidates = resolution_registry_from_exports(&projection, &control)?;
13734 let owners = projection
13735 .owners_by_graph
13736 .remove("docs/guide.md")
13737 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13738 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13739 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13740 let rows = project_document_rows(
13741 project,
13742 generation,
13743 &graphs[0],
13744 &facts,
13745 &owners,
13746 &index,
13747 &candidates,
13748 &projection.entity_by_digest,
13749 &control,
13750 )?;
13751 require_eq(&rows.relations.len(), &3, "file-owned relation count")?;
13752 require_eq(&rows.occurrences.len(), &7, "distinct link occurrences")?;
13753 require(
13754 rows.relations.iter().all(|relation| {
13755 projection
13756 .entity_by_digest
13757 .get(relation.source().digest())
13758 .is_some_and(|entity| {
13759 matches!(
13760 entity.selector(),
13761 EntitySelector::File { path } if path.as_str() == "docs/guide.md"
13762 )
13763 })
13764 }),
13765 "document relations were not owned by the document file",
13766 )?;
13767 let heading_targets = rows
13768 .relations
13769 .iter()
13770 .filter_map(|relation| {
13771 let RelationResolution::Resolved {
13772 selector: projectatlas_core::graph::ReusableTargetSelector::Symbol { symbol },
13773 ..
13774 } = relation.resolution()
13775 else {
13776 return None;
13777 };
13778 (symbol.kind == SymbolKind::Heading).then(|| symbol.signature.as_str().to_string())
13779 })
13780 .collect::<BTreeSet<_>>();
13781 require_eq(
13782 &heading_targets,
13783 &BTreeSet::from(["first".to_string(), "second".to_string()]),
13784 "file-owned heading targets",
13785 )?;
13786 let source_target_occurrences = rows
13787 .occurrences
13788 .iter()
13789 .filter(|occurrence| occurrence.file().as_str() == "docs/guide.md")
13790 .count();
13791 require_eq(
13792 &source_target_occurrences,
13793 &7,
13794 "file-owned relation occurrences",
13795 )?;
13796 Ok(())
13797 }
13798
13799 #[test]
13800 fn complete_document_without_static_candidates_reports_no_candidates()
13801 -> Result<(), Box<dyn Error>> {
13802 let facts = projectatlas_symbols::extract_markdown_facts(
13803 "# Overview\n\nLong prose without a repository reference.\n",
13804 );
13805 let coverage = document_coverage("docs/overview.md", &facts, IndexGeneration::new(6))?;
13806 require_eq(
13807 &coverage.state(),
13808 &CoverageState::NoCandidates,
13809 "empty document relation coverage",
13810 )?;
13811 require_eq(&coverage.total(), &0, "empty document relation total")?;
13812 Ok(())
13813 }
13814
13815 #[test]
13816 fn partial_markdown_evidence_limit_maps_to_intermediate_bytes_coverage()
13817 -> Result<(), Box<dyn Error>> {
13818 let label = "l".repeat(MAX_MARKDOWN_LABEL_BYTES);
13819 let selector = format!(
13820 "src/{}.rs",
13821 "s".repeat(MAX_DOCUMENT_SELECTOR_BYTES - "src/".len() - ".rs".len())
13822 );
13823 let evidence_bytes = label.len() + selector.len();
13824 let source = format!("[{label}]({selector})\n")
13825 .repeat(MAX_MARKDOWN_EVIDENCE_BYTES / evidence_bytes + 1);
13826 let facts = projectatlas_symbols::extract_markdown_facts(&source);
13827 require(
13828 facts
13829 .coverage
13830 .limits
13831 .contains(&MarkdownFactLimit::EvidenceBytes),
13832 "real Markdown extraction did not reach its evidence-byte limit",
13833 )?;
13834 require(
13835 !facts.link_candidates.is_empty(),
13836 "evidence-limited Markdown extraction lost every valid candidate",
13837 )?;
13838
13839 let coverage = document_coverage("docs/limited.md", &facts, IndexGeneration::new(7))?;
13840 require_eq(
13841 &coverage.state(),
13842 &CoverageState::Partial,
13843 "evidence-limited document coverage state",
13844 )?;
13845 require_eq(
13846 &coverage.reached_limit(),
13847 &Some(GraphLimitKind::IntermediateBytes),
13848 "evidence-limited document graph limit",
13849 )?;
13850 Ok(())
13851 }
13852
13853 #[test]
13854 fn document_cycles_emit_only_canonical_bounded_edges() -> Result<(), Box<dyn Error>> {
13855 let temp = tempfile::tempdir()?;
13856 fs::create_dir_all(temp.path().join("docs"))?;
13857 fs::write(temp.path().join("docs/a.md"), "# A\n\n[b](b.md#b)\n")?;
13858 fs::write(temp.path().join("docs/b.md"), "# B\n\n[a](a.md#a)\n")?;
13859 let facts = [
13860 projectatlas_symbols::extract_markdown_facts("# A\n\n[b](b.md#b)\n"),
13861 projectatlas_symbols::extract_markdown_facts("# B\n\n[a](a.md#a)\n"),
13862 ];
13863 let graphs = vec![
13864 facts[0].symbol_graph("docs/a.md", Some("markdown")),
13865 facts[1].symbol_graph("docs/b.md", Some("markdown")),
13866 ];
13867 let nodes = vec![
13868 test_file_node("docs/a.md", "markdown"),
13869 test_file_node("docs/b.md", "markdown"),
13870 ];
13871 let project = ProjectInstanceId::from_bytes([35; 16])?;
13872 let generation = IndexGeneration::new(1);
13873 let packages = PackageIndex::from_graphs(&graphs)?;
13874 let control = super::super::standalone_index_work_control();
13875 let mut projection = build_entity_projection(
13876 project, generation, &nodes, &graphs, &packages, true, &control,
13877 )?;
13878 let candidates = resolution_registry_from_exports(&projection, &control)?;
13879 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13880 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13881 let mut relations = Vec::new();
13882 for (graph, facts) in graphs.iter().zip(&facts) {
13883 let owners = projection
13884 .owners_by_graph
13885 .remove(&graph.path)
13886 .ok_or_else(|| io::Error::other("document owners were not projected"))?;
13887 relations.extend(
13888 project_document_rows(
13889 project,
13890 generation,
13891 graph,
13892 facts,
13893 &owners,
13894 &index,
13895 &candidates,
13896 &projection.entity_by_digest,
13897 &control,
13898 )?
13899 .relations,
13900 );
13901 }
13902 require_eq(&relations.len(), &2, "document cycle relation count")?;
13903 require(
13904 relations.iter().all(|relation| {
13905 relation.kind() == GraphRelationKind::Extended(ExtendedRelationKind::Documents)
13906 && matches!(
13907 relation.resolution(),
13908 RelationResolution::Resolved {
13909 selector: ReusableTargetSelector::Symbol { symbol },
13910 ..
13911 } if symbol.kind == SymbolKind::Heading
13912 )
13913 }),
13914 "document cycle emitted a non-canonical or unresolved edge",
13915 )?;
13916 Ok(())
13917 }
13918
13919 #[test]
13920 fn document_casefold_collisions_refuse_exact_winners_and_share_invalidation_keys()
13921 -> Result<(), Box<dyn Error>> {
13922 let temp = tempfile::tempdir()?;
13923 let nodes = vec![
13924 test_file_node("src/lib.rs", "rust"),
13925 test_file_node("SRC/lib.rs", "rust"),
13926 ];
13927 let control = super::super::standalone_index_work_control();
13928 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13929 let index = DocumentResolutionIndex::new(temp.path(), &nodes, &scan_policy)?;
13930 require_eq(
13931 &index.unresolved_reason("src/lib.rs")?,
13932 &Some(DocumentTargetUnresolvedReason::CaseConflict),
13933 "exact case-collision target",
13934 )?;
13935 let project = ProjectInstanceId::from_bytes([32; 16])?;
13936 require_eq(
13937 &document_casefold_resolution_key(project, "src/lib.rs")?,
13938 &document_casefold_resolution_key(project, "SRC/lib.rs")?,
13939 "casefold invalidation key",
13940 )?;
13941 Ok(())
13942 }
13943
13944 #[test]
13945 fn document_target_state_change_refuses_stale_publication() -> Result<(), Box<dyn Error>> {
13946 let temp = tempfile::tempdir()?;
13947 let control = super::super::standalone_index_work_control();
13948 let scan_policy = RootScanPolicy::discover(temp.path(), &ScanOptions::default(), &control)?;
13949 let index = DocumentResolutionIndex::new(temp.path(), &[], &scan_policy)?;
13950 let actual = index
13951 .unresolved_reason("docs/target.md")?
13952 .ok_or_else(|| std::io::Error::other("absent target unexpectedly resolved"))?;
13953 let stale = if actual == DocumentTargetUnresolvedReason::Missing {
13954 DocumentTargetUnresolvedReason::Ignored
13955 } else {
13956 DocumentTargetUnresolvedReason::Missing
13957 };
13958 let document_target_states = vec![("docs/target.md".to_string(), stale)];
13959 drop(index);
13960 let staged = StagedRepositoryGraph {
13961 project: ProjectInstanceId::from_bytes([34; 16])?,
13962 mutation: RepositoryGraphMutation::Full,
13963 entities: Vec::new(),
13964 relations: Vec::new(),
13965 occurrences: Vec::new(),
13966 coverage: Vec::new(),
13967 entity_exports: Vec::new(),
13968 relation_dependencies: Vec::new(),
13969 document_unresolved_reasons: Vec::new(),
13970 identity_rejections: Vec::new(),
13971 resolution_derivations: BTreeMap::new(),
13972 peak_retained_bytes: 0,
13973 projection_removals_before_entities: Vec::new(),
13974 scan_policy,
13975 document_target_states,
13976 database: None,
13977 retained_bytes: 0,
13978 };
13979 require(
13980 matches!(
13981 staged.revalidate_document_targets(temp.path()),
13982 Err(CliError::RefreshRequired(_))
13983 ),
13984 "changed non-indexed target state reached publication",
13985 )?;
13986 Ok(())
13987 }
13988
13989 fn test_file_node(path: &str, language: &str) -> Node {
13990 Node {
13991 path: path.to_string(),
13992 kind: NodeKind::File,
13993 parent_path: path
13994 .rsplit_once('/')
13995 .map(|(parent, _name)| parent.to_string()),
13996 extension: Path::new(path)
13997 .extension()
13998 .map(|extension| format!(".{}", extension.to_string_lossy())),
13999 language: Some(language.to_string()),
14000 size_bytes: Some(1),
14001 mtime_ns: Some(1),
14002 content_hash: Some(format!("hash:{path}")),
14003 }
14004 }
14005
14006 #[test]
14007 fn large_document_projection_publishes_in_memory_staging_and_incremental()
14008 -> Result<(), Box<dyn Error>> {
14009 const DOCUMENT_COUNT: usize = 10;
14010 const LINKS_PER_DOCUMENT: usize = 1_024;
14011 const INCREMENTAL_LINKS: usize = 1_024;
14012 let temp = tempfile::tempdir()?;
14013 let root = fs::canonicalize(temp.path())?;
14014 fs::create_dir_all(root.join("content/real-target"))?;
14015 fs::write(root.join("content/resolved.md"), "# Resolved\n")?;
14016 let symlink_available = match create_directory_link(
14017 &root.join("content/real-target"),
14018 &root.join("content/alias"),
14019 ) {
14020 Ok(()) => true,
14021 Err(source) if cfg!(windows) && source.raw_os_error() == Some(1314) => {
14022 fs::create_dir(root.join("content/alias"))?;
14023 false
14024 }
14025 Err(source) => return Err(source.into()),
14026 };
14027 let database = root.join("projectatlas.db");
14028 let mut store = AtlasStore::open_for_project(&database, &root)?;
14029 let project = store
14030 .project_instance_id()?
14031 .ok_or("large document fixture project identity is missing")?;
14032 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14033 let mut nodes = Vec::with_capacity(DOCUMENT_COUNT + 2);
14034 let mut docs_node = test_file_node("content", "unknown");
14035 docs_node.kind = NodeKind::Folder;
14036 docs_node.language = None;
14037 docs_node.extension = None;
14038 nodes.push(docs_node);
14039 let mut graphs = Vec::with_capacity(DOCUMENT_COUNT + 1);
14040 let mut document_facts: BTreeMap<
14041 String,
14042 Cow<'static, projectatlas_symbols::MarkdownFacts>,
14043 > = BTreeMap::new();
14044 for document in 0..DOCUMENT_COUNT {
14045 let path = format!("content/source-{document:02}.md");
14046 let mut links = Vec::with_capacity(LINKS_PER_DOCUMENT);
14047 if document == 0 {
14048 links.push("[resolved](resolved.md)".to_string());
14049 links.push("[symlink](alias)".to_string());
14050 }
14051 while links.len() < LINKS_PER_DOCUMENT {
14052 let link = links.len();
14053 links.push(format!(
14054 "[missing-{document:02}-{link:04}](missing-{document:02}-{link:04}.md)"
14055 ));
14056 }
14057 let facts = projectatlas_symbols::extract_markdown_facts(&links.join("\n"));
14058 require_eq(
14059 &facts.link_candidates.len(),
14060 &LINKS_PER_DOCUMENT,
14061 "large document candidate count",
14062 )?;
14063 graphs.push(facts.symbol_graph(&path, Some("markdown")));
14064 document_facts.insert(path.clone(), Cow::Owned(facts));
14065 nodes.push(test_file_node(&path, "markdown"));
14066 }
14067 let resolved_facts = projectatlas_symbols::extract_markdown_facts("# Resolved\n");
14068 graphs.push(resolved_facts.symbol_graph("content/resolved.md", Some("markdown")));
14069 nodes.push(test_file_node("content/resolved.md", "markdown"));
14070 let candidate_count = document_facts
14071 .values()
14072 .map(|facts| facts.link_candidates.len())
14073 .sum::<usize>();
14074 require_eq(
14075 &candidate_count,
14076 &(DOCUMENT_COUNT * LINKS_PER_DOCUMENT),
14077 "large document projection candidate total",
14078 )?;
14079 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14080 let packages = PackageIndex::from_graphs(&graphs)?;
14081
14082 let generation = IndexGeneration::new(1);
14083 let entities = build_entity_projection(
14084 project, generation, &nodes, &graphs, &packages, true, &control,
14085 )?;
14086 let candidates = resolution_registry_from_exports(&entities, &control)?;
14087 let in_memory = finish_projection_with_documents(
14088 project,
14089 generation,
14090 RepositoryGraphMutation::Full,
14091 &graphs,
14092 &root,
14093 &nodes,
14094 &document_facts,
14095 &GraphIdentityAdmission::default(),
14096 entities,
14097 &candidates,
14098 &scan_policy,
14099 &control,
14100 )?;
14101 require(
14102 in_memory.database.is_none(),
14103 "normal projection unexpectedly selected disposable staging",
14104 )?;
14105 require(
14106 in_memory.document_unresolved_reasons.len() > GraphLimits::MAX_ROWS as usize,
14107 "normal projection did not retain a multi-ceiling reason vector",
14108 )?;
14109 require_eq(
14110 &in_memory.relations.len(),
14111 &candidate_count,
14112 "normal projection relation count",
14113 )?;
14114 require(
14115 in_memory.relations.iter().any(|relation| {
14116 matches!(relation.resolution(), RelationResolution::Resolved { .. })
14117 }),
14118 "normal projection lost the resolved document result",
14119 )?;
14120 let symlink_relation = in_memory
14121 .relations
14122 .iter()
14123 .find(|relation| {
14124 matches!(
14125 relation.resolution(),
14126 RelationResolution::Unresolved { reference }
14127 if reference.as_str() == "alias"
14128 )
14129 })
14130 .ok_or("normal projection lost the symlink document result")?;
14131 let symlink_reason = in_memory
14132 .document_unresolved_reasons
14133 .iter()
14134 .find(|(key, _reason)| key == symlink_relation.key())
14135 .map(|(_key, reason)| *reason);
14136 require_eq(
14137 &symlink_reason,
14138 &Some(DocumentTargetUnresolvedReason::Unsupported),
14139 "symlink document result did not retain its closed reason",
14140 )?;
14141 require(
14142 !symlink_available || root.join("content/alias").exists(),
14143 "symlink document fixture disappeared before publication",
14144 )?;
14145 {
14146 let mut publication = store.begin_index_publication("large-document-projection")?;
14147 publication.upsert_scan_node_batch(&nodes)?;
14148 in_memory.apply(&mut publication, &control)?;
14149 publication.complete()?;
14150 }
14151 let published_page = store.repository_graph_relation_rows(
14152 RepositoryGraphRelationQuery::Family {
14153 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
14154 },
14155 GraphLimits::MAX_ROWS,
14156 None,
14157 )?;
14158 require(
14159 published_page.truncated && published_page.rows.len() == GraphLimits::MAX_ROWS as usize,
14160 "normal projection publication did not expose a bounded multi-page result",
14161 )?;
14162
14163 let incremental_path = "content/source-00.md".to_string();
14164 let incremental_source = (0..INCREMENTAL_LINKS)
14165 .map(|link| format!("[incremental-{link:04}](incremental-{link:04}.md)"))
14166 .collect::<Vec<_>>()
14167 .join("\n");
14168 let incremental_facts = projectatlas_symbols::extract_markdown_facts(&incremental_source);
14169 let incremental_graph = incremental_facts.symbol_graph(&incremental_path, Some("markdown"));
14170 let incremental_nodes = vec![test_file_node(&incremental_path, "markdown")];
14171 let incremental_packages =
14172 PackageIndex::from_graphs(std::slice::from_ref(&incremental_graph))?;
14173 let incremental_entities = build_entity_projection(
14174 project,
14175 IndexGeneration::new(2),
14176 &incremental_nodes,
14177 std::slice::from_ref(&incremental_graph),
14178 &incremental_packages,
14179 false,
14180 &control,
14181 )?;
14182 let incremental_candidates =
14183 resolution_registry_from_exports(&incremental_entities, &control)?;
14184 let incremental_staged = finish_projection_with_documents(
14185 project,
14186 IndexGeneration::new(2),
14187 RepositoryGraphMutation::AffectedPaths(vec![incremental_path.clone()]),
14188 std::slice::from_ref(&incremental_graph),
14189 &root,
14190 &nodes,
14191 &BTreeMap::from([(incremental_path.clone(), Cow::Owned(incremental_facts))]),
14192 &GraphIdentityAdmission::default(),
14193 incremental_entities,
14194 &incremental_candidates,
14195 &scan_policy,
14196 &control,
14197 )?;
14198 require(
14199 incremental_staged.document_unresolved_reasons.len() <= GraphLimits::MAX_ROWS as usize,
14200 "incremental projection exceeded its aggregate row budget",
14201 )?;
14202 enforce_incremental_projection_limits(
14203 &root,
14204 &BTreeSet::from([incremental_path]),
14205 RepositoryAffectedSourceFootprint {
14206 rows: 0,
14207 retained_bytes: 0,
14208 truncated: false,
14209 },
14210 &incremental_staged,
14211 )?;
14212 {
14213 let mut publication =
14214 store.begin_index_projection_refresh("large-document-projection")?;
14215 incremental_staged.apply(&mut publication, &control)?;
14216 publication.complete()?;
14217 }
14218 require_eq(
14219 &store.repository_graph_generation()?,
14220 &Some(IndexGeneration::new(2)),
14221 "incremental publication generation",
14222 )?;
14223
14224 let staging_generation = IndexGeneration::new(3);
14225 let staging_entities = build_entity_projection(
14226 project,
14227 staging_generation,
14228 &nodes,
14229 &graphs,
14230 &packages,
14231 true,
14232 &control,
14233 )?;
14234 let staging_candidates = resolution_registry_from_exports(&staging_entities, &control)?;
14235 let staged = finish_projection_in_database_with_documents(
14236 &root,
14237 &nodes,
14238 project,
14239 staging_generation,
14240 &graphs,
14241 &document_facts,
14242 &GraphIdentityAdmission::default(),
14243 staging_entities,
14244 &staging_candidates,
14245 &scan_policy,
14246 &control,
14247 )?;
14248 let database_stage = staged
14249 .database
14250 .as_ref()
14251 .ok_or("large projection did not select disposable staging")?;
14252 let staged_database_bytes = fs::metadata(
14253 database_stage
14254 .directory()?
14255 .path()
14256 .join(GRAPH_STAGE_DATABASE_FILE_NAME),
14257 )?
14258 .len();
14259 require(
14260 staged_database_bytes > 0,
14261 "disposable staging database retained no durable bytes",
14262 )?;
14263 {
14264 let mut publication =
14265 store.begin_index_projection_refresh("large-document-projection")?;
14266 publication.upsert_scan_node_batch(&nodes)?;
14267 staged.apply(&mut publication, &control)?;
14268 publication.complete()?;
14269 }
14270 let staged_page = store.repository_graph_relation_rows(
14271 RepositoryGraphRelationQuery::Family {
14272 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
14273 },
14274 GraphLimits::MAX_ROWS,
14275 None,
14276 )?;
14277 require(
14278 staged_page.truncated && staged_page.rows.len() == GraphLimits::MAX_ROWS as usize,
14279 "disposable staging publication did not expose a bounded multi-page result",
14280 )?;
14281 Ok(())
14282 }
14283
14284 #[test]
14285 fn incremental_document_admission_uses_emitted_rows() -> Result<(), Box<dyn Error>> {
14286 const DISCARDED_DOCUMENT_COUNT: usize = 513;
14287 const EMITTED_DOCUMENT_COUNT: usize = 10;
14288 const CANDIDATES_PER_DOCUMENT: usize = 1_024;
14289 let temp = tempfile::tempdir()?;
14290 let root = fs::canonicalize(temp.path())?;
14291 fs::create_dir_all(root.join("content"))?;
14292 let database = root.join("projectatlas.db");
14293 let mut store = AtlasStore::open_for_project(&database, &root)?;
14294 let paths = (0..DISCARDED_DOCUMENT_COUNT)
14295 .map(|index| format!("content/links-{index:03}.md"))
14296 .collect::<Vec<_>>();
14297 let mut nodes = paths
14298 .iter()
14299 .map(|path| test_file_node(path, "markdown"))
14300 .collect::<Vec<_>>();
14301 store.replace_scan(&nodes)?;
14302 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14303 let symbols = empty_symbol_build_stage();
14304
14305 for path in &paths {
14306 let file_name = path
14307 .rsplit_once('/')
14308 .map(|(_parent, file_name)| file_name)
14309 .ok_or("same-file document fixture path has no parent")?;
14310 let self_source = (0..CANDIDATES_PER_DOCUMENT)
14311 .map(|index| format!("[self-{index:05}]({file_name})"))
14312 .collect::<Vec<_>>()
14313 .join("\n");
14314 fs::write(root.join(path), &self_source)?;
14315 let self_facts = projectatlas_symbols::extract_markdown_facts(&self_source);
14316 require_eq(
14317 &self_facts.link_candidates.len(),
14318 &CANDIDATES_PER_DOCUMENT,
14319 "same-file raw candidates per document",
14320 )?;
14321 store.replace_symbol_graph(&self_facts.symbol_graph(path, Some("markdown")))?;
14322 }
14323 for (node, path) in nodes.iter_mut().zip(&paths) {
14324 let bytes = fs::read(root.join(path))?;
14325 node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
14326 }
14327 store.replace_scan(&nodes)?;
14328 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14329 let incremental = stage_incremental_repository_graph(
14330 &store,
14331 &root,
14332 IndexGeneration::new(0),
14333 &nodes,
14334 &paths,
14335 &scan_policy,
14336 &symbols,
14337 &control,
14338 )?;
14339 require(
14340 matches!(
14341 &incremental.mutation,
14342 RepositoryGraphMutation::AffectedPaths(affected) if affected == &paths
14343 ),
14344 "same-file candidates were not retained as an incremental projection",
14345 )?;
14346 require(
14347 incremental.relations.is_empty() && incremental.document_unresolved_reasons.is_empty(),
14348 "same-file candidates emitted document rows despite the no-fragment filter",
14349 )?;
14350 require(
14351 store.repository_graph_generation()?.is_none(),
14352 "incremental admission test unexpectedly published a generation",
14353 )?;
14354
14355 let emitted_paths = paths
14356 .iter()
14357 .take(EMITTED_DOCUMENT_COUNT)
14358 .cloned()
14359 .collect::<Vec<_>>();
14360 for path in &emitted_paths {
14361 let unsupported_source = (0..CANDIDATES_PER_DOCUMENT)
14362 .map(|index| format!("[external-{index:05}](target-{index:05}.md#invalid?)"))
14363 .collect::<Vec<_>>()
14364 .join("\n");
14365 fs::write(root.join(path), &unsupported_source)?;
14366 let unsupported_facts =
14367 projectatlas_symbols::extract_markdown_facts(&unsupported_source);
14368 require_eq(
14369 &unsupported_facts.link_candidates.len(),
14370 &CANDIDATES_PER_DOCUMENT,
14371 "unsupported raw candidates per document",
14372 )?;
14373 store.replace_symbol_graph(&unsupported_facts.symbol_graph(path, Some("markdown")))?;
14374 }
14375 for (node, path) in nodes.iter_mut().zip(&paths) {
14376 let bytes = fs::read(root.join(path))?;
14377 node.content_hash = Some(blake3::hash(&bytes).to_hex().to_string());
14378 }
14379 store.replace_scan(&nodes)?;
14380 let error = stage_incremental_repository_graph(
14381 &store,
14382 &root,
14383 IndexGeneration::new(0),
14384 &nodes,
14385 &emitted_paths,
14386 &scan_policy,
14387 &symbols,
14388 &control,
14389 )
14390 .err()
14391 .ok_or("emitted document rows over the ceiling were admitted incrementally")?;
14392 let CliError::RefreshRequired(report) = error else {
14393 return Err(io::Error::other(format!(
14394 "expected typed full-refresh guidance, found {error:?}"
14395 ))
14396 .into());
14397 };
14398 require_eq(
14399 &report.reason,
14400 &IndexRefreshReason::DependencyClosureLimit,
14401 "emitted-row overflow reason",
14402 )?;
14403 require_eq(
14404 &report.scope,
14405 &IndexRefreshScope::Full,
14406 "emitted-row overflow scope",
14407 )?;
14408 require(
14409 store.repository_graph_generation()?.is_none(),
14410 "emitted-row overflow changed the current generation",
14411 )?;
14412 Ok(())
14413 }
14414
14415 #[test]
14416 fn reopened_source_parse_success_does_not_promote_fallback_graph_facts()
14417 -> Result<(), Box<dyn Error>> {
14418 let temp = tempfile::tempdir()?;
14419 let database = temp.path().join("projectatlas.db");
14420 let mut store = AtlasStore::open(&database)?;
14421 let graph = SymbolGraph {
14422 path: "src/optional.lang".to_string(),
14423 language: Some("optional-language".to_string()),
14424 parser: ParserKind::Fallback,
14425 symbols: vec![CodeSymbol {
14426 path: "src/optional.lang".to_string(),
14427 language: Some("optional-language".to_string()),
14428 name: "entry".to_string(),
14429 kind: SymbolKind::Function,
14430 signature: "entry()".to_string(),
14431 exported: false,
14432 documentation: None,
14433 line_start: 1,
14434 line_end: 1,
14435 source_selector: None,
14436 parent: None,
14437 parser: ParserKind::Fallback,
14438 detail: None,
14439 }],
14440 relations: vec![SymbolRelation {
14441 path: "src/optional.lang".to_string(),
14442 source_name: "entry".to_string(),
14443 target_name: "helper".to_string(),
14444 kind: RelationKind::Calls,
14445 line: 1,
14446 context: "helper()".to_string(),
14447 parser: ParserKind::Fallback,
14448 }],
14449 };
14450 store.replace_symbol_graph_with_metadata(
14451 &graph,
14452 &SourceParseMetadata {
14453 path: graph.path.clone(),
14454 language: graph.language.clone(),
14455 parser: ParserKind::TreeSitter,
14456 symbol_count: graph.symbols.len(),
14457 relation_count: graph.relations.len(),
14458 },
14459 )?;
14460 drop(store);
14461
14462 let reader = AtlasStore::open_read_only(&database)?;
14463 let graphs = reader.load_symbol_graphs_for_paths(std::slice::from_ref(&graph.path))?;
14464 require_eq(&graphs, &vec![graph], "reopened fact graph")?;
14465 let project = ProjectInstanceId::from_bytes([7; 16])?;
14466 let generation = IndexGeneration::new(1);
14467 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14468 let packages = PackageIndex::from_graphs(&graphs)?;
14469 let entities =
14470 build_entity_projection(project, generation, &[], &graphs, &packages, true, &control)?;
14471 let candidates = resolution_registry_from_exports(&entities, &control)?;
14472 let staged = finish_projection(
14473 project,
14474 generation,
14475 RepositoryGraphMutation::Full,
14476 &graphs,
14477 entities,
14478 &candidates,
14479 &control,
14480 )?;
14481 require_eq(&staged.relations.len(), &1, "normalized relation count")?;
14482 require_eq(
14483 &staged.relations[0].confidence(),
14484 &ConfidenceClass::Low,
14485 "fallback relation confidence after reopen",
14486 )?;
14487 require_eq(
14488 &staged.relations[0].completeness(),
14489 &Completeness::Partial,
14490 "fallback relation completeness after reopen",
14491 )?;
14492 require(
14493 staged
14494 .relations
14495 .iter()
14496 .any(|relation| relation.kind() == GraphRelationKind::Legacy(RelationKind::Calls)),
14497 "reopened graph lost its legacy call relation",
14498 )?;
14499 require_eq(&staged.coverage.len(), &1, "normalized coverage count")?;
14500 require_eq(
14501 &staged.coverage[0].state(),
14502 &CoverageState::Partial,
14503 "fallback coverage after reopen",
14504 )?;
14505 reader.finish_index_read_snapshot()?;
14506 Ok(())
14507 }
14508
14509 #[test]
14510 fn partial_php_graph_publishes_existing_partial_coverage() -> Result<(), Box<dyn Error>> {
14511 let graph = extract_symbol_graph(
14512 "src/dynamic.php",
14513 Some("php"),
14514 "<?php function run(): void { $callable(); helper(); }",
14515 );
14516 require_eq(
14517 &graph.parser,
14518 &ParserKind::Fallback,
14519 "partial PHP fact parser",
14520 )?;
14521 let coverage = coverage_for_graph(
14522 &graph,
14523 IndexGeneration::new(1),
14524 &GraphIdentityAdmission::default(),
14525 &GraphIdentityAdmission::default(),
14526 )?;
14527 require_eq(
14528 &coverage.state(),
14529 &CoverageState::Partial,
14530 "partial PHP coverage state",
14531 )?;
14532 require_eq(&coverage.covered(), &1, "partial PHP covered relations")?;
14533 require_eq(&coverage.omitted(), &1, "partial PHP omitted relations")?;
14534 require(
14535 coverage.reason().is_some(),
14536 "partial PHP coverage must disclose its reason",
14537 )?;
14538 Ok(())
14539 }
14540
14541 #[test]
14542 fn mixed_php_graph_publishes_complete_coverage() -> Result<(), Box<dyn Error>> {
14543 for (path, source, symbol_name) in [
14544 (
14545 "src/inline-output.php",
14546 "//x<?php function marker(): void { helper(); }",
14547 "marker",
14548 ),
14549 (
14550 "src/inline-echo.php",
14551 "#output<?= $value ?><?php function after_echo(): void { helper(); }",
14552 "after_echo",
14553 ),
14554 ] {
14555 let graph = extract_symbol_graph(path, Some("php"), source);
14556 require_eq(
14557 &graph.parser,
14558 &ParserKind::TreeSitter,
14559 "mixed PHP fact parser",
14560 )?;
14561 require(
14562 graph
14563 .symbols
14564 .iter()
14565 .any(|symbol| symbol.name == symbol_name),
14566 "mixed PHP declaration is missing before coverage projection",
14567 )?;
14568 let coverage = coverage_for_graph(
14569 &graph,
14570 IndexGeneration::new(1),
14571 &GraphIdentityAdmission::default(),
14572 &GraphIdentityAdmission::default(),
14573 )?;
14574 require_eq(
14575 &coverage.state(),
14576 &CoverageState::Complete,
14577 "mixed PHP coverage state",
14578 )?;
14579 require(
14580 coverage.covered() > 0,
14581 "mixed PHP complete coverage must retain static relations",
14582 )?;
14583 require_eq(&coverage.omitted(), &0, "mixed PHP omitted relations")?;
14584 require(
14585 coverage.reason().is_none(),
14586 "complete mixed PHP coverage must not disclose a partial reason",
14587 )?;
14588 }
14589 Ok(())
14590 }
14591
14592 #[test]
14593 fn persisted_closure_overflow_preserves_the_complete_generation() -> Result<(), Box<dyn Error>>
14594 {
14595 let temp = tempfile::tempdir()?;
14596 let root = temp.path().join("persisted-closure-overflow");
14597 fs::create_dir_all(&root)?;
14598 let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
14599 let project = store
14600 .project_instance_id()?
14601 .ok_or("bound project identity is missing")?;
14602 let control = IndexWorkControl::new(IndexCancellation::new(), None);
14603 let graphs = vec![function_graph("src/lib.rs", 1)];
14604 let packages = PackageIndex::from_graphs(&graphs)?;
14605 let entity_projection = build_entity_projection(
14606 project,
14607 IndexGeneration::new(1),
14608 &[],
14609 &graphs,
14610 &packages,
14611 true,
14612 &control,
14613 )?;
14614 let dependency_keys = entity_projection
14615 .keys_by_graph
14616 .values()
14617 .flat_map(projectatlas_symbols::ResolutionKeyProjection::relation_keys)
14618 .flat_map(|relation| relation.keys().iter().cloned())
14619 .collect::<BTreeSet<_>>()
14620 .into_iter()
14621 .collect::<Vec<_>>();
14622 let candidates = resolution_registry_from_exports(&entity_projection, &control)?;
14623 let staged = finish_projection(
14624 project,
14625 IndexGeneration::new(1),
14626 RepositoryGraphMutation::Full,
14627 &graphs,
14628 entity_projection,
14629 &candidates,
14630 &control,
14631 )?;
14632 let file_key = staged
14633 .entities
14634 .iter()
14635 .find(|entity| matches!(entity.selector(), EntitySelector::File { .. }))
14636 .ok_or("staged file entity is missing")?
14637 .key()
14638 .clone();
14639 {
14640 let mut publication = store.begin_index_publication("closure-overflow")?;
14641 publication.begin_scan_replacement()?;
14642 publication.upsert_scan_node_batch(&[Node {
14643 path: "src/lib.rs".to_string(),
14644 kind: NodeKind::File,
14645 parent_path: Some("src".to_string()),
14646 extension: Some(".rs".to_string()),
14647 language: Some("rust".to_string()),
14648 size_bytes: Some(1),
14649 mtime_ns: Some(1),
14650 content_hash: Some("initial".to_string()),
14651 }])?;
14652 publication.finish_scan_replacement()?;
14653 staged.apply(&mut publication, &control)?;
14654 publication.complete()?;
14655 }
14656
14657 store.replace_symbol_graph(&function_graph(
14658 "src/lib.rs",
14659 usize::try_from(MAX_INCREMENTAL_GRAPH_ROWS).unwrap_or(usize::MAX),
14660 ))?;
14661 let publication_before = store
14662 .index_publication()?
14663 .ok_or("complete publication is missing")?;
14664 let entity_before = store
14665 .repository_graph_entity(&file_key)?
14666 .ok_or("published file entity is missing")?;
14667 let exports_before =
14668 store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?;
14669 let dependencies_before =
14670 store.repository_affected_source_paths(project, &dependency_keys, 100)?;
14671 let empty_symbols = empty_symbol_build_stage();
14672 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
14673
14674 let error = stage_incremental_repository_graph(
14675 &store,
14676 &root,
14677 publication_before.generation,
14678 &[],
14679 &["src/lib.rs".to_string()],
14680 &scan_policy,
14681 &empty_symbols,
14682 &control,
14683 )
14684 .err()
14685 .ok_or_else(|| io::Error::other("oversized closure acquired the publication writer"))?;
14686 let CliError::RefreshRequired(report) = error else {
14687 return Err(io::Error::other(format!(
14688 "expected typed full-refresh guidance, found {error:?}"
14689 ))
14690 .into());
14691 };
14692 require_eq(
14693 &report.reason,
14694 &IndexRefreshReason::DependencyClosureLimit,
14695 "persisted overflow reason",
14696 )?;
14697 require_eq(
14698 &report.scope,
14699 &IndexRefreshScope::Full,
14700 "persisted overflow scope",
14701 )?;
14702 require_eq(
14703 &store.index_publication()?,
14704 &Some(publication_before),
14705 "publication after overflow",
14706 )?;
14707 require_eq(
14708 &store.repository_graph_entity(&file_key)?,
14709 &Some(entity_before),
14710 "file entity after overflow",
14711 )?;
14712 require_eq(
14713 &store.repository_export_keys_for_paths(project, &["src/lib.rs".to_string()], 100)?,
14714 &exports_before,
14715 "export keys after overflow",
14716 )?;
14717 require_eq(
14718 &store.repository_affected_source_paths(project, &dependency_keys, 100)?,
14719 &dependencies_before,
14720 "dependency owners after overflow",
14721 )?;
14722 Ok(())
14723 }
14724
14725 #[test]
14726 fn semantic_provider_key_rejection_keeps_valid_exports_and_relations()
14727 -> Result<(), Box<dyn Error>> {
14728 let temp = tempfile::tempdir()?;
14729 let root = temp.path().join("semantic-resolution-key-admission");
14730 fs::create_dir_all(&root)?;
14731 let long_path = |folder: &str, component: char, file: &str| {
14732 let components = (0..20)
14733 .map(|_| std::iter::repeat_n(component, 200).collect::<String>())
14734 .collect::<Vec<_>>()
14735 .join("/");
14736 format!("src/{folder}/{components}/{file}")
14737 };
14738 let path = long_path("first", 'd', "page.rs");
14739 let sibling_path = long_path("second", 'e', "sibling.rs");
14740 let nodes = vec![
14741 test_file_node(&path, "rust"),
14742 test_file_node(&sibling_path, "rust"),
14743 ];
14744 let project_database = root.join(".projectatlas/projectatlas.db");
14745 fs::create_dir_all(root.join(".projectatlas"))?;
14746 let mut store = AtlasStore::open_for_project(&project_database, &root)?;
14747 let project = store
14748 .project_instance_id()?
14749 .ok_or("semantic resolution project identity is missing")?;
14750 let initial_graphs = vec![
14751 semantic_resolution_key_graph(&path, true),
14752 semantic_resolution_key_graph(&sibling_path, false),
14753 ];
14754 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
14755 let scan_policy =
14756 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
14757 let initial_stage = stage_full_repository_graph(
14758 &store,
14759 &root,
14760 IndexGeneration::ZERO,
14761 &nodes,
14762 &scan_policy,
14763 &symbol_build_stage_for_graphs(initial_graphs.clone()),
14764 &initial_control,
14765 )?;
14766 require(
14767 initial_stage
14768 .identity_rejections
14769 .iter()
14770 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14771 .count()
14772 >= 2,
14773 "semantic provider full-stage did not retain every invalid resolution-key fact",
14774 )?;
14775 let initial_coverage = initial_stage
14776 .coverage
14777 .iter()
14778 .find(|coverage| {
14779 matches!(
14780 coverage.scope(),
14781 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
14782 )
14783 })
14784 .ok_or("semantic provider coverage row is missing")?;
14785 require_eq(
14786 &initial_coverage.state(),
14787 &CoverageState::Partial,
14788 "semantic provider valid-symbol coverage state",
14789 )?;
14790 require(
14791 initial_coverage.covered() > 0,
14792 "semantic provider valid symbols were not counted as covered",
14793 )?;
14794 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
14795 canceled.cancel();
14796 {
14797 let mut publication = store.begin_index_publication("semantic-resolution-cancel")?;
14798 publication.begin_scan_replacement()?;
14799 publication.upsert_scan_node_batch(&nodes)?;
14800 publication.finish_scan_replacement()?;
14801 let error = initial_stage.apply(&mut publication, &canceled).err();
14802 require(
14803 matches!(
14804 error,
14805 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
14806 stage: IndexWorkStage::Publication
14807 }))
14808 ),
14809 "semantic provider cancellation was not observed after admission",
14810 )?;
14811 }
14812 require_eq(
14813 &store.index_publication()?,
14814 &None,
14815 "publication after semantic provider cancellation",
14816 )?;
14817 publish_full_staged_graph(
14818 &mut store,
14819 &nodes,
14820 &initial_stage,
14821 &initial_control,
14822 "semantic-resolution-initial",
14823 )?;
14824 let base_generation = store
14825 .index_publication()?
14826 .ok_or("semantic resolution initial publication is missing")?
14827 .generation;
14828 let rejection_paths = nodes
14829 .iter()
14830 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
14831 .collect::<Result<Vec<_>, _>>()?;
14832 let rejections =
14833 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
14834 let rejections = rejections
14835 .into_iter()
14836 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14837 .collect::<Vec<_>>();
14838 require(
14839 rejections.len() >= 2,
14840 "semantic provider full-stage rejection detail count",
14841 )?;
14842 require(
14843 rejections.iter().all(|row| {
14844 row.path.as_str() == path
14845 && row.reason == GraphIdentityRejectionReason::Oversized
14846 && row.parser == ParserKind::TreeSitter
14847 }),
14848 "semantic provider rejection provenance was not exact",
14849 )?;
14850 require(
14851 rejections.iter().any(|row| row.span.start_line() == 4)
14852 && rejections.iter().any(|row| row.span.start_line() == 5),
14853 "semantic provider did not retain distinct invalid symbol spans",
14854 )?;
14855 let rejection_wire = serde_json::to_string(&rejections)?;
14856 require(
14857 !rejection_wire.contains("LeakedIdentity"),
14858 "semantic provider rejection retained raw identity text",
14859 )?;
14860
14861 let file_entities = store.repository_graph_entities_by_path(
14862 project,
14863 &RepositoryNodePath::new(Path::new(&path))?,
14864 64,
14865 )?;
14866 require(
14867 file_entities.rows.iter().any(|entity| {
14868 matches!(
14869 entity.selector(),
14870 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "page_helper"
14871 )
14872 }),
14873 "valid semantic sibling export was dropped",
14874 )?;
14875 let sibling_entities = store.repository_graph_entities_by_path(
14876 project,
14877 &RepositoryNodePath::new(Path::new(&sibling_path))?,
14878 64,
14879 )?;
14880 require(
14881 sibling_entities.rows.iter().any(|entity| {
14882 matches!(
14883 entity.selector(),
14884 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "sibling_helper"
14885 )
14886 }),
14887 "valid semantic sibling-folder export was dropped",
14888 )?;
14889 let calls = store.repository_graph_relations(
14890 RepositoryGraphRelationQuery::Family {
14891 relation: GraphRelationKind::Legacy(RelationKind::Calls),
14892 },
14893 32,
14894 )?;
14895 require(
14896 calls
14897 .rows
14898 .iter()
14899 .any(|relation| relation.resolution().resolved_target().is_some()),
14900 "valid semantic sibling relation was not resolved",
14901 )?;
14902 let imports = store.repository_graph_relations(
14903 RepositoryGraphRelationQuery::Family {
14904 relation: GraphRelationKind::Legacy(RelationKind::Imports),
14905 },
14906 32,
14907 )?;
14908 require(
14909 !imports.rows.is_empty(),
14910 "valid semantic import relation was dropped beside invalid imports",
14911 )?;
14912 let exports =
14913 store.repository_export_keys_for_paths(project, std::slice::from_ref(&path), 128)?;
14914 require(
14915 exports.rows.len() > 2,
14916 "valid semantic provider resolution exports were dropped",
14917 )?;
14918 let sibling_exports = store.repository_export_keys_for_paths(
14919 project,
14920 std::slice::from_ref(&sibling_path),
14921 128,
14922 )?;
14923 require(
14924 sibling_exports.rows.len() > 2,
14925 "valid semantic sibling-folder resolution exports were dropped",
14926 )?;
14927 let sibling_exports_before_incremental = sibling_exports.rows;
14928
14929 drop(store);
14930 let mut store = AtlasStore::open_for_project(&project_database, &root)?;
14931 require_eq(
14932 &store.project_instance_id()?,
14933 &Some(project),
14934 "semantic provider project identity after SQLite reopen",
14935 )?;
14936 let reopened_sibling_exports = store.repository_export_keys_for_paths(
14937 project,
14938 std::slice::from_ref(&sibling_path),
14939 128,
14940 )?;
14941 require_eq(
14942 &reopened_sibling_exports.rows,
14943 &sibling_exports_before_incremental,
14944 "semantic provider valid exports after SQLite reopen",
14945 )?;
14946
14947 let invalid_sibling_control = IndexWorkControl::new(IndexCancellation::new(), None);
14948 let invalid_sibling_policy =
14949 RootScanPolicy::discover(&root, &ScanOptions::default(), &invalid_sibling_control)?;
14950 let invalid_sibling_stage = stage_incremental_repository_graph(
14951 &store,
14952 &root,
14953 base_generation,
14954 &nodes,
14955 std::slice::from_ref(&sibling_path),
14956 &invalid_sibling_policy,
14957 &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
14958 &sibling_path,
14959 true,
14960 )]),
14961 &invalid_sibling_control,
14962 )?;
14963 let incremental_generation = base_generation
14964 .checked_next()
14965 .ok_or("semantic resolution incremental generation overflowed")?;
14966 let direct_derivations = invalid_sibling_stage
14967 .resolution_derivations
14968 .get(&(sibling_path.clone(), incremental_generation))
14969 .copied();
14970 require_eq(
14971 &direct_derivations,
14972 &Some(1),
14973 "semantic provider direct resolution derivation count",
14974 )?;
14975 require(
14976 invalid_sibling_stage
14977 .identity_rejections
14978 .iter()
14979 .filter(|row| row.field == GraphIdentityField::ResolutionKey)
14980 .count()
14981 >= 2,
14982 "semantic provider incremental resolution-key rejection",
14983 )?;
14984 {
14985 let mut publication =
14986 store.begin_index_publication("semantic-resolution-incremental-invalid")?;
14987 invalid_sibling_stage.apply(&mut publication, &invalid_sibling_control)?;
14988 publication.complete()?;
14989 }
14990 let incremented_generation = store
14991 .index_publication()?
14992 .ok_or("semantic resolution incremental publication is missing")?
14993 .generation;
14994 let with_two_rejections =
14995 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
14996 require(
14997 with_two_rejections.len() >= 4,
14998 "semantic provider incremental rejection detail count",
14999 )?;
15000 require(
15001 with_two_rejections
15002 .iter()
15003 .any(|row| row.path.as_str() == sibling_path),
15004 "semantic provider incremental rejection was not added for the changed path",
15005 )?;
15006 let invalid_sibling_exports = store.repository_export_keys_for_paths(
15007 project,
15008 std::slice::from_ref(&sibling_path),
15009 128,
15010 )?;
15011 require(
15012 sibling_exports_before_incremental
15013 .iter()
15014 .all(|key| invalid_sibling_exports.rows.contains(key)),
15015 "semantic provider valid exports were lost beside incremental rejection",
15016 )?;
15017 let invalid_sibling_calls = store.repository_graph_relations(
15018 RepositoryGraphRelationQuery::Family {
15019 relation: GraphRelationKind::Legacy(RelationKind::Calls),
15020 },
15021 32,
15022 )?;
15023 require(
15024 invalid_sibling_calls
15025 .rows
15026 .iter()
15027 .any(|relation| relation.resolution().resolved_target().is_some()),
15028 "semantic provider valid resolved relation was lost beside incremental rejection",
15029 )?;
15030
15031 let repaired_control = IndexWorkControl::new(IndexCancellation::new(), None);
15032 let repaired_policy =
15033 RootScanPolicy::discover(&root, &ScanOptions::default(), &repaired_control)?;
15034 let repaired_stage = stage_incremental_repository_graph(
15035 &store,
15036 &root,
15037 incremented_generation,
15038 &nodes,
15039 std::slice::from_ref(&sibling_path),
15040 &repaired_policy,
15041 &symbol_build_stage_for_graphs(vec![semantic_resolution_key_graph(
15042 &sibling_path,
15043 false,
15044 )]),
15045 &repaired_control,
15046 )?;
15047 {
15048 let mut publication =
15049 store.begin_index_publication("semantic-resolution-incremental-repair")?;
15050 repaired_stage.apply(&mut publication, &repaired_control)?;
15051 publication.complete()?;
15052 }
15053 let repaired_generation = store
15054 .index_publication()?
15055 .ok_or("semantic resolution repair publication is missing")?
15056 .generation;
15057 let repaired_rejections =
15058 store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15059 require(
15060 repaired_rejections.len() >= 2,
15061 "semantic provider repaired rejection detail count",
15062 )?;
15063 require(
15064 repaired_rejections
15065 .iter()
15066 .all(|row| row.path.as_str() == path),
15067 "semantic provider repair removed or replaced an unrelated path detail",
15068 )?;
15069 let repaired_sibling_exports = store.repository_export_keys_for_paths(
15070 project,
15071 std::slice::from_ref(&sibling_path),
15072 128,
15073 )?;
15074 require_eq(
15075 &repaired_sibling_exports.rows,
15076 &sibling_exports_before_incremental,
15077 "semantic provider repair did not restore valid exports",
15078 )?;
15079
15080 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15081 let fault_policy =
15082 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15083 let mut fault_stage = stage_full_repository_graph(
15084 &store,
15085 &root,
15086 repaired_generation,
15087 &nodes,
15088 &fault_policy,
15089 &symbol_build_stage_for_graphs(initial_graphs.clone()),
15090 &fault_control,
15091 )?;
15092 fault_stage.identity_rejections.resize(
15093 usize::try_from(GraphLimits::MAX_ROWS)
15094 .unwrap_or(usize::MAX)
15095 .saturating_add(1),
15096 fault_stage.identity_rejections[0].clone(),
15097 );
15098 {
15099 let mut publication = store.begin_index_publication("semantic-resolution-fault")?;
15100 publication.begin_scan_replacement()?;
15101 publication.upsert_scan_node_batch(&nodes)?;
15102 publication.finish_scan_replacement()?;
15103 require(
15104 fault_stage.apply(&mut publication, &fault_control).is_err(),
15105 "semantic provider late rejection-detail fault did not fail",
15106 )?;
15107 }
15108 require_eq(
15109 &store
15110 .index_publication()?
15111 .map(|publication| publication.generation),
15112 &Some(repaired_generation),
15113 "semantic provider generation after late rejection-detail fault",
15114 )?;
15115 require_eq(
15116 &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
15117 &repaired_rejections,
15118 "semantic provider prior generation after fault",
15119 )?;
15120
15121 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15122 let retry_policy =
15123 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15124 let retry_stage = stage_full_repository_graph(
15125 &store,
15126 &root,
15127 repaired_generation,
15128 &nodes,
15129 &retry_policy,
15130 &symbol_build_stage_for_graphs(initial_graphs),
15131 &retry_control,
15132 )?;
15133 publish_full_staged_graph(
15134 &mut store,
15135 &nodes,
15136 &retry_stage,
15137 &retry_control,
15138 "semantic-resolution-retry",
15139 )?;
15140 require_eq(
15141 &store.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?,
15142 &rejections,
15143 "semantic provider deterministic retry",
15144 )?;
15145 Ok(())
15146 }
15147
15148 #[test]
15149 fn full_stage_admits_siblings_and_reopens_typed_rejections_with_cancel_retry()
15150 -> Result<(), Box<dyn Error>> {
15151 let temp = tempfile::tempdir()?;
15152 let root = temp.path().join("full-identity-admission");
15153 fs::create_dir_all(root.join("src"))?;
15154 fs::create_dir_all(root.join("tests"))?;
15155 fs::write(
15156 root.join("src/one.rs"),
15157 "pub fn caller() { helper(); }\nfn helper() {}\n",
15158 )?;
15159 fs::write(
15160 root.join("tests/two.rs"),
15161 "pub fn caller() { helper(); }\nfn helper() {}\n",
15162 )?;
15163 let database = root.join(".projectatlas/projectatlas.db");
15164 fs::create_dir_all(root.join(".projectatlas"))?;
15165 let mut store = AtlasStore::open_for_project(&database, &root)?;
15166 let project = store
15167 .project_instance_id()?
15168 .ok_or("full admission project identity is missing")?;
15169 let nodes = vec![
15170 test_file_node("src/one.rs", "rust"),
15171 test_file_node("tests/two.rs", "rust"),
15172 ];
15173 let mut first_graph =
15174 identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget);
15175 first_graph.relations.push(SymbolRelation {
15176 path: "src/one.rs".to_string(),
15177 source_name: "bad\u{0}source".to_string(),
15178 target_name: "bad\u{0}target".to_string(),
15179 kind: RelationKind::Calls,
15180 line: 4,
15181 context: "identity admission dual-field fixture".to_string(),
15182 parser: ParserKind::TreeSitter,
15183 });
15184 let graphs = vec![
15185 first_graph,
15186 identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationSource),
15187 ];
15188 let symbols = symbol_build_stage_for_graphs(graphs);
15189 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15190 let scan_policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15191 let staged = stage_full_repository_graph(
15192 &store,
15193 &root,
15194 IndexGeneration::ZERO,
15195 &nodes,
15196 &scan_policy,
15197 &symbols,
15198 &control,
15199 )?;
15200 require_eq(
15201 &staged.identity_rejections.len(),
15202 &4,
15203 "full-stage typed rejection count",
15204 )?;
15205 let staged_dual_rejections = staged
15206 .identity_rejections
15207 .iter()
15208 .filter(|row| {
15209 row.path.as_str() == "src/one.rs"
15210 && row.span.start_line() == 4
15211 && row.reason == GraphIdentityRejectionReason::ControlCharacters
15212 })
15213 .collect::<Vec<_>>();
15214 require_eq(
15215 &staged_dual_rejections.len(),
15216 &2,
15217 "full-stage dual-field rejection details",
15218 )?;
15219 require(
15220 staged_dual_rejections
15221 .iter()
15222 .any(|row| row.field == GraphIdentityField::RelationSource)
15223 && staged_dual_rejections
15224 .iter()
15225 .any(|row| row.field == GraphIdentityField::RelationTarget),
15226 "full-stage dual-field rejection lost source or target provenance",
15227 )?;
15228 require(
15229 staged_dual_rejections
15230 .iter()
15231 .map(|row| row.fact_index)
15232 .all(|fact_index| fact_index == staged_dual_rejections[0].fact_index),
15233 "full-stage dual-field rejection lost parser fact identity",
15234 )?;
15235 let staged_first_coverage = staged
15236 .coverage
15237 .iter()
15238 .find(|coverage| {
15239 matches!(
15240 coverage.scope(),
15241 CoverageScope::Path { path } if path.as_str() == "src/one.rs"
15242 )
15243 })
15244 .ok_or("full-stage first coverage row is missing")?;
15245 require_eq(
15246 &staged_first_coverage.omitted(),
15247 &2,
15248 "full-stage dual-field rejection counted once per parser fact",
15249 )?;
15250 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
15251 canceled.cancel();
15252 {
15253 let mut publication = store.begin_index_publication("full-identity-cancel")?;
15254 publication.begin_scan_replacement()?;
15255 publication.upsert_scan_node_batch(&nodes)?;
15256 publication.finish_scan_replacement()?;
15257 let error = staged.apply(&mut publication, &canceled).err();
15258 require(
15259 matches!(
15260 error,
15261 Some(CliError::IndexWork(IndexWorkFailure::Cancelled {
15262 stage: IndexWorkStage::Publication
15263 }))
15264 ),
15265 "full-stage cancellation was not observed after admission",
15266 )?;
15267 }
15268 require_eq(
15269 &store.index_publication()?,
15270 &None,
15271 "publication after canceled full-stage admission",
15272 )?;
15273
15274 publish_full_staged_graph(&mut store, &nodes, &staged, &control, "full-identity")?;
15275 let first_publication = store
15276 .index_publication()?
15277 .ok_or("full identity publication is missing")?;
15278 require_eq(
15279 &first_publication.generation,
15280 &IndexGeneration::new(1),
15281 "first full identity generation",
15282 )?;
15283 drop(store);
15284
15285 let reopened = AtlasStore::open_read_only_for_project(&database, &root)?;
15286 let rejection_paths = nodes
15287 .iter()
15288 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
15289 .collect::<Result<Vec<_>, _>>()?;
15290 let reopened_rejections =
15291 reopened.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15292 require_eq(
15293 &reopened_rejections.len(),
15294 &4,
15295 "reopened full-stage typed rejection count",
15296 )?;
15297 let reopened_dual_rejections = reopened_rejections
15298 .iter()
15299 .filter(|row| {
15300 row.path.as_str() == "src/one.rs"
15301 && row.span.start_line() == 4
15302 && row.reason == GraphIdentityRejectionReason::ControlCharacters
15303 })
15304 .collect::<Vec<_>>();
15305 require_eq(
15306 &reopened_dual_rejections.len(),
15307 &2,
15308 "reopened dual-field rejection details",
15309 )?;
15310 require(
15311 reopened_dual_rejections
15312 .iter()
15313 .any(|row| row.field == GraphIdentityField::RelationSource)
15314 && reopened_dual_rejections
15315 .iter()
15316 .any(|row| row.field == GraphIdentityField::RelationTarget),
15317 "reopened dual-field rejection lost source or target provenance",
15318 )?;
15319 require(
15320 reopened_dual_rejections
15321 .iter()
15322 .map(|row| row.fact_index)
15323 .all(|fact_index| fact_index == reopened_dual_rejections[0].fact_index),
15324 "reopened dual-field rejection lost parser fact identity",
15325 )?;
15326 let target_rejection = reopened_rejections
15327 .iter()
15328 .find(|row| {
15329 row.field == GraphIdentityField::RelationTarget
15330 && row.path.as_str() == "src/one.rs"
15331 && row.span.start_line() == 3
15332 })
15333 .ok_or("relation-target rejection is missing")?;
15334 require_eq(
15335 &target_rejection.path.as_str(),
15336 &"src/one.rs",
15337 "relation-target rejection path",
15338 )?;
15339 require_eq(
15340 &target_rejection.parser,
15341 &ParserKind::TreeSitter,
15342 "relation-target rejection parser",
15343 )?;
15344 require_eq(
15345 &target_rejection.reason,
15346 &GraphIdentityRejectionReason::ControlCharacters,
15347 "relation-target rejection reason",
15348 )?;
15349 require_eq(
15350 &target_rejection.span.start_line(),
15351 &3,
15352 "relation-target rejection start line",
15353 )?;
15354 require_eq(
15355 &target_rejection.span.end_line(),
15356 &3,
15357 "relation-target rejection end line",
15358 )?;
15359 let source_rejection = reopened_rejections
15360 .iter()
15361 .find(|row| {
15362 row.field == GraphIdentityField::RelationSource
15363 && row.path.as_str() == "tests/two.rs"
15364 })
15365 .ok_or("relation-source rejection is missing")?;
15366 require_eq(
15367 &source_rejection.path.as_str(),
15368 &"tests/two.rs",
15369 "relation-source rejection path",
15370 )?;
15371 require_eq(
15372 &source_rejection.parser,
15373 &ParserKind::TreeSitter,
15374 "relation-source rejection parser",
15375 )?;
15376 require_eq(
15377 &source_rejection.reason,
15378 &GraphIdentityRejectionReason::ControlCharacters,
15379 "relation-source rejection reason",
15380 )?;
15381 require_eq(
15382 &source_rejection.span.start_line(),
15383 &3,
15384 "relation-source rejection start line",
15385 )?;
15386 require_eq(
15387 &source_rejection.span.end_line(),
15388 &3,
15389 "relation-source rejection end line",
15390 )?;
15391 let wire = serde_json::to_string(&reopened_rejections)?;
15392 require(
15393 !wire.contains("bad") && !wire.contains("target\u{0}"),
15394 "full-stage typed rejection retained raw invalid identity material",
15395 )?;
15396 for path in &rejection_paths {
15397 let entities = reopened.repository_graph_entities_by_path(project, path, 64)?;
15398 require(
15399 entities.rows.iter().any(|entity| {
15400 matches!(
15401 entity.selector(),
15402 EntitySelector::Symbol { symbol } if symbol.name.as_str() == "caller"
15403 )
15404 }),
15405 "valid sibling symbol was not navigable after SQLite reopen",
15406 )?;
15407 }
15408 let calls = reopened.repository_graph_relations(
15409 RepositoryGraphRelationQuery::Family {
15410 relation: GraphRelationKind::Legacy(RelationKind::Calls),
15411 },
15412 16,
15413 )?;
15414 require_eq(
15415 &calls.rows.len(),
15416 &2,
15417 "valid sibling call relations after SQLite reopen",
15418 )?;
15419 reopened.finish_index_read_snapshot()?;
15420
15421 let mut writer = AtlasStore::open_for_project(&database, &root)?;
15422 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15423 let fault_policy =
15424 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15425 let mut fault_stage = stage_full_repository_graph(
15426 &writer,
15427 &root,
15428 first_publication.generation,
15429 &nodes,
15430 &fault_policy,
15431 &symbols,
15432 &fault_control,
15433 )?;
15434 fault_stage.identity_rejections.resize(
15435 usize::try_from(GraphLimits::MAX_ROWS)
15436 .unwrap_or(usize::MAX)
15437 .saturating_add(1),
15438 fault_stage.identity_rejections[0].clone(),
15439 );
15440 {
15441 let mut publication = writer.begin_index_publication("full-identity-fault")?;
15442 publication.begin_scan_replacement()?;
15443 publication.upsert_scan_node_batch(&nodes)?;
15444 publication.finish_scan_replacement()?;
15445 require(
15446 fault_stage.apply(&mut publication, &fault_control).is_err(),
15447 "oversized rejection detail did not fault after graph replacement",
15448 )?;
15449 }
15450 require_eq(
15451 &writer
15452 .index_publication()?
15453 .map(|publication| publication.generation),
15454 &Some(first_publication.generation),
15455 "generation after late rejection-detail fault",
15456 )?;
15457 let retained =
15458 writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15459 require_eq(
15460 &retained,
15461 &reopened_rejections,
15462 "prior complete typed rejection generation after fault",
15463 )?;
15464 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15465 let retry_policy =
15466 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15467 let retry_stage = stage_full_repository_graph(
15468 &writer,
15469 &root,
15470 first_publication.generation,
15471 &nodes,
15472 &retry_policy,
15473 &symbols,
15474 &retry_control,
15475 )?;
15476 publish_full_staged_graph(
15477 &mut writer,
15478 &nodes,
15479 &retry_stage,
15480 &retry_control,
15481 "full-identity-retry",
15482 )?;
15483 let retried =
15484 writer.repository_graph_identity_rejections(project, &rejection_paths, 16, None)?;
15485 require_eq(
15486 &retried,
15487 &reopened_rejections,
15488 "deterministic full-stage retry",
15489 )?;
15490 Ok(())
15491 }
15492
15493 #[test]
15494 fn full_stage_reuse_carries_persisted_rejection_coverage_and_replaces_it()
15495 -> Result<(), Box<dyn Error>> {
15496 let temp = tempfile::tempdir()?;
15497 let root = temp.path().join("full-reuse-identity-admission");
15498 fs::create_dir_all(root.join("src"))?;
15499 fs::write(
15500 root.join("src/reused.rs"),
15501 "pub fn caller() { helper(); }\nfn helper() {}\n",
15502 )?;
15503 let database = root.join(".projectatlas/projectatlas.db");
15504 fs::create_dir_all(root.join(".projectatlas"))?;
15505 let mut store = AtlasStore::open_for_project(&database, &root)?;
15506 let project = store
15507 .project_instance_id()?
15508 .ok_or("full reuse project identity is missing")?;
15509 let path = "src/reused.rs";
15510 let nodes = vec![test_file_node(path, "rust")];
15511 let graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
15512 let first_control = IndexWorkControl::new(IndexCancellation::new(), None);
15513 let first_policy =
15514 RootScanPolicy::discover(&root, &ScanOptions::default(), &first_control)?;
15515 let mut first_symbols = symbol_build_stage_for_graphs(vec![graph]);
15516 first_symbols.identity_admission =
15517 super::admit_symbol_build_stage(&mut first_symbols, &first_control)?;
15518 let first_graph = first_symbols
15519 .changes
15520 .iter()
15521 .find_map(|change| match change {
15522 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15523 SymbolProjectionChange::Clear { .. } => None,
15524 })
15525 .ok_or("full reuse parsed graph is missing")?;
15526 let first_stage = stage_full_repository_graph(
15527 &store,
15528 &root,
15529 IndexGeneration::ZERO,
15530 &nodes,
15531 &first_policy,
15532 &first_symbols,
15533 &first_control,
15534 )?;
15535 require_eq(
15536 &first_stage.identity_rejections.len(),
15537 &1,
15538 "full reuse initial rejection detail count",
15539 )?;
15540 publish_full_staged_graph(
15541 &mut store,
15542 &nodes,
15543 &first_stage,
15544 &first_control,
15545 "full-reuse-initial",
15546 )?;
15547 store.replace_symbol_graph(&first_graph)?;
15550 let first_generation = store
15551 .index_publication()?
15552 .ok_or("full reuse initial publication is missing")?
15553 .generation;
15554 drop(store);
15555
15556 let mut store = AtlasStore::open_for_project(&database, &root)?;
15557 let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
15558 let reuse_policy =
15559 RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
15560 let reused_stage = stage_full_repository_graph(
15561 &store,
15562 &root,
15563 first_generation,
15564 &nodes,
15565 &reuse_policy,
15566 &empty_symbol_build_stage(),
15567 &reuse_control,
15568 )?;
15569 require_eq(
15570 &reused_stage.identity_rejections,
15571 &first_stage.identity_rejections,
15572 "full reuse retained exact persisted rejection details",
15573 )?;
15574 let reused_coverage = reused_stage
15575 .coverage
15576 .iter()
15577 .find(|coverage| {
15578 matches!(
15579 coverage.scope(),
15580 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15581 ) && coverage.relation().is_none()
15582 })
15583 .ok_or("full reuse coverage row is missing")?;
15584 require_eq(
15585 &reused_coverage.state(),
15586 &CoverageState::Partial,
15587 "full reuse retained partial coverage",
15588 )?;
15589 require_eq(
15590 &reused_coverage.omitted(),
15591 &1,
15592 "full reuse retained rejection omission count",
15593 )?;
15594 require(
15595 reused_stage
15596 .relations
15597 .iter()
15598 .any(|relation| relation.resolution().resolved_target().is_some()),
15599 "full reuse dropped the valid sibling relation",
15600 )?;
15601 publish_full_staged_graph(
15602 &mut store,
15603 &nodes,
15604 &reused_stage,
15605 &reuse_control,
15606 "full-reuse-republish",
15607 )?;
15608 let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
15609 let persisted =
15610 store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?;
15611 require_eq(
15612 &persisted,
15613 &first_stage.identity_rejections,
15614 "full reuse persisted exact rejection details",
15615 )?;
15616
15617 let cancel_control = IndexWorkControl::new(IndexCancellation::new(), None);
15618 let canceled_stage = stage_full_repository_graph(
15619 &store,
15620 &root,
15621 first_generation
15622 .checked_next()
15623 .ok_or("full reuse generation overflow")?,
15624 &nodes,
15625 &reuse_policy,
15626 &empty_symbol_build_stage(),
15627 &cancel_control,
15628 )?;
15629 cancel_control.cancel();
15630 {
15631 let mut publication = store.begin_index_publication("full-reuse-cancel")?;
15632 publication.begin_scan_replacement()?;
15633 publication.upsert_scan_node_batch(&nodes)?;
15634 publication.finish_scan_replacement()?;
15635 require(
15636 canceled_stage
15637 .apply(&mut publication, &cancel_control)
15638 .is_err(),
15639 "full reuse cancellation was ignored",
15640 )?;
15641 }
15642 let retained_generation = store
15643 .index_publication()?
15644 .ok_or("full reuse republished generation is missing")?
15645 .generation;
15646 require_eq(
15647 &retained_generation,
15648 &first_generation
15649 .checked_next()
15650 .ok_or("full reuse generation overflow")?,
15651 "full reuse cancellation changed the current generation",
15652 )?;
15653 require_eq(
15654 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15655 &persisted,
15656 "full reuse cancellation changed rejection details",
15657 )?;
15658
15659 let fault_control = IndexWorkControl::new(IndexCancellation::new(), None);
15660 let fault_policy =
15661 RootScanPolicy::discover(&root, &ScanOptions::default(), &fault_control)?;
15662 let mut fault_stage = stage_full_repository_graph(
15663 &store,
15664 &root,
15665 retained_generation,
15666 &nodes,
15667 &fault_policy,
15668 &empty_symbol_build_stage(),
15669 &fault_control,
15670 )?;
15671 fault_stage.identity_rejections.resize(
15672 usize::try_from(GraphLimits::MAX_ROWS)
15673 .unwrap_or(usize::MAX)
15674 .saturating_add(1),
15675 fault_stage.identity_rejections[0].clone(),
15676 );
15677 {
15678 let mut publication = store.begin_index_publication("full-reuse-fault")?;
15679 publication.begin_scan_replacement()?;
15680 publication.upsert_scan_node_batch(&nodes)?;
15681 publication.finish_scan_replacement()?;
15682 require(
15683 fault_stage.apply(&mut publication, &fault_control).is_err(),
15684 "full reuse late rejection-detail fault was ignored",
15685 )?;
15686 }
15687 require_eq(
15688 &store
15689 .index_publication()?
15690 .ok_or("full reuse fault lost publication")?
15691 .generation,
15692 &retained_generation,
15693 "full reuse late fault changed current generation",
15694 )?;
15695 require_eq(
15696 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15697 &persisted,
15698 "full reuse late fault changed rejection details",
15699 )?;
15700 let retry_control = IndexWorkControl::new(IndexCancellation::new(), None);
15701 let retry_policy =
15702 RootScanPolicy::discover(&root, &ScanOptions::default(), &retry_control)?;
15703 let retry_stage = stage_full_repository_graph(
15704 &store,
15705 &root,
15706 retained_generation,
15707 &nodes,
15708 &retry_policy,
15709 &empty_symbol_build_stage(),
15710 &retry_control,
15711 )?;
15712 publish_full_staged_graph(
15713 &mut store,
15714 &nodes,
15715 &retry_stage,
15716 &retry_control,
15717 "full-reuse-retry",
15718 )?;
15719 require_eq(
15720 &store.repository_graph_identity_rejections(project, &persisted_paths, 16, None)?,
15721 &persisted,
15722 "full reuse retry changed rejection details",
15723 )?;
15724 let retained_generation = store
15725 .index_publication()?
15726 .ok_or("full reuse retry publication is missing")?
15727 .generation;
15728
15729 let valid_graph = extract_symbol_graph(
15730 path,
15731 Some("rust"),
15732 "pub fn caller() { helper(); }\nfn helper() {}\n",
15733 );
15734 let valid_control = IndexWorkControl::new(IndexCancellation::new(), None);
15735 let valid_policy =
15736 RootScanPolicy::discover(&root, &ScanOptions::default(), &valid_control)?;
15737 let valid_stage = stage_full_repository_graph(
15738 &store,
15739 &root,
15740 retained_generation,
15741 &nodes,
15742 &valid_policy,
15743 &symbol_build_stage_for_graphs(vec![valid_graph]),
15744 &valid_control,
15745 )?;
15746 require(
15747 valid_stage.identity_rejections.is_empty(),
15748 "full reuse changed graph retained stale rejection detail",
15749 )?;
15750 publish_full_staged_graph(
15751 &mut store,
15752 &nodes,
15753 &valid_stage,
15754 &valid_control,
15755 "full-reuse-repair",
15756 )?;
15757 require(
15758 store
15759 .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
15760 .is_empty(),
15761 "full reuse repaired graph retained stale rejection detail",
15762 )?;
15763 Ok(())
15764 }
15765
15766 #[test]
15767 fn full_stage_reuse_preserves_capped_fallback_omission_count_without_details()
15768 -> Result<(), Box<dyn Error>> {
15769 let temp = tempfile::tempdir()?;
15770 let root = temp.path().join("full-reuse-fallback-identity-admission");
15771 fs::create_dir_all(root.join("src"))?;
15772 fs::write(
15773 root.join("src/reused.rs"),
15774 "pub fn caller() { helper(); }\nfn helper() {}\n",
15775 )?;
15776 let database = root.join(".projectatlas/projectatlas.db");
15777 fs::create_dir_all(root.join(".projectatlas"))?;
15778 let mut store = AtlasStore::open_for_project(&database, &root)?;
15779 let project = store
15780 .project_instance_id()?
15781 .ok_or("fallback reuse project identity is missing")?;
15782 let path = "src/reused.rs";
15783 let nodes = vec![test_file_node(path, "rust")];
15784 let mut graph = identity_sibling_graph(path, GraphIdentityField::RelationTarget);
15785 graph.parser = ParserKind::Fallback;
15786 for symbol in &mut graph.symbols {
15787 symbol.parser = ParserKind::Fallback;
15788 }
15789 for relation in &mut graph.relations {
15790 relation.parser = ParserKind::Fallback;
15791 }
15792 let control = IndexWorkControl::new(IndexCancellation::new(), None);
15793 let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &control)?;
15794 let mut symbols = symbol_build_stage_for_graphs(vec![graph]);
15795 symbols.identity_admission = super::admit_symbol_build_stage(&mut symbols, &control)?;
15796 let persisted_graph = symbols
15797 .changes
15798 .iter()
15799 .find_map(|change| match change {
15800 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15801 SymbolProjectionChange::Clear { .. } => None,
15802 })
15803 .ok_or("fallback sanitized graph is missing")?;
15804 let mut stage = stage_full_repository_graph(
15805 &store,
15806 &root,
15807 IndexGeneration::ZERO,
15808 &nodes,
15809 &policy,
15810 &symbols,
15811 &control,
15812 )?;
15813 require_eq(
15814 &stage.identity_rejections.len(),
15815 &1,
15816 "fallback initial typed rejection detail count",
15817 )?;
15818 let coverage = stage
15819 .coverage
15820 .iter_mut()
15821 .find(|coverage| {
15822 matches!(
15823 coverage.scope(),
15824 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15825 ) && coverage.relation().is_none()
15826 })
15827 .ok_or("fallback initial coverage row is missing")?;
15828 let scope = coverage.scope().clone();
15829 let state = coverage.state();
15830 let covered = coverage.covered();
15831 let generation = coverage.generation();
15832 let reason = coverage.reason().cloned();
15833 let reached_limit = coverage.reached_limit();
15834 *coverage = CoverageRecord::new(
15835 scope,
15836 None,
15837 state,
15838 covered,
15839 2,
15840 generation,
15841 reason,
15842 reached_limit,
15843 )?;
15844 stage.identity_rejections.clear();
15847 publish_full_staged_graph(
15848 &mut store,
15849 &nodes,
15850 &stage,
15851 &control,
15852 "fallback-reuse-initial",
15853 )?;
15854 store.replace_symbol_graph(&persisted_graph)?;
15855 let base_generation = store
15856 .index_publication()?
15857 .ok_or("fallback initial publication is missing")?
15858 .generation;
15859 let persisted_paths = vec![RepositoryNodePath::new(Path::new(path))?];
15860 require(
15861 store
15862 .repository_graph_identity_rejections(project, &persisted_paths, 16, None)?
15863 .is_empty(),
15864 "fallback capped publication retained an unexpected detail row",
15865 )?;
15866
15867 let reuse_control = IndexWorkControl::new(IndexCancellation::new(), None);
15868 let reuse_policy =
15869 RootScanPolicy::discover(&root, &ScanOptions::default(), &reuse_control)?;
15870 let reused = stage_full_repository_graph(
15871 &store,
15872 &root,
15873 base_generation,
15874 &nodes,
15875 &reuse_policy,
15876 &empty_symbol_build_stage(),
15877 &reuse_control,
15878 )?;
15879 require(
15880 reused.identity_rejections.is_empty(),
15881 "fallback reuse fabricated a detail row after cap",
15882 )?;
15883 let reused_coverage = reused
15884 .coverage
15885 .iter()
15886 .find(|coverage| {
15887 matches!(
15888 coverage.scope(),
15889 CoverageScope::Path { path: coverage_path } if coverage_path.as_str() == path
15890 ) && coverage.relation().is_none()
15891 })
15892 .ok_or("fallback reused coverage row is missing")?;
15893 require_eq(
15894 &reused_coverage.omitted(),
15895 &2,
15896 "fallback reused persisted omission count",
15897 )?;
15898 require_eq(
15899 &reused_coverage.state(),
15900 &CoverageState::Partial,
15901 "fallback reused coverage state",
15902 )?;
15903 Ok(())
15904 }
15905
15906 #[test]
15907 fn incremental_reuse_hydrates_unchanged_affected_dependent_rejections()
15908 -> Result<(), Box<dyn Error>> {
15909 let temp = tempfile::tempdir()?;
15910 let root = temp
15911 .path()
15912 .join("incremental-reuse-dependent-identity-admission");
15913 fs::create_dir_all(root.join("src"))?;
15914 fs::write(root.join("src/provider.rs"), "pub fn changed() {}\n")?;
15915 fs::write(
15916 root.join("src/consumer.rs"),
15917 "pub fn caller() { changed(); }\n",
15918 )?;
15919 let database = root.join(".projectatlas/projectatlas.db");
15920 fs::create_dir_all(root.join(".projectatlas"))?;
15921 let mut store = AtlasStore::open_for_project(&database, &root)?;
15922 let project = store
15923 .project_instance_id()?
15924 .ok_or("incremental dependent project identity is missing")?;
15925 let provider_path = "src/provider.rs";
15926 let consumer_path = "src/consumer.rs";
15927 let nodes = vec![
15928 test_file_node(provider_path, "rust"),
15929 test_file_node(consumer_path, "rust"),
15930 ];
15931 let provider = extract_symbol_graph(provider_path, Some("rust"), "pub fn changed() {}\n");
15932 let mut consumer = extract_symbol_graph(
15933 consumer_path,
15934 Some("rust"),
15935 "pub fn caller() { changed(); }\n",
15936 );
15937 consumer.relations.push(SymbolRelation {
15938 path: consumer_path.to_string(),
15939 source_name: "caller".to_string(),
15940 target_name: "bad\0target".to_string(),
15941 kind: RelationKind::Calls,
15942 line: 3,
15943 context: "incremental unchanged dependent fixture".to_string(),
15944 parser: ParserKind::TreeSitter,
15945 });
15946 let mut initial_symbols = symbol_build_stage_for_graphs(vec![provider, consumer]);
15947 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
15948 initial_symbols.identity_admission =
15949 super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
15950 let persisted_graphs = initial_symbols
15951 .changes
15952 .iter()
15953 .filter_map(|change| match change {
15954 SymbolProjectionChange::Parsed(parsed) => Some(parsed.graph.clone()),
15955 SymbolProjectionChange::Clear { .. } => None,
15956 })
15957 .collect::<Vec<_>>();
15958 let policy = RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
15959 let initial_stage = stage_full_repository_graph(
15960 &store,
15961 &root,
15962 IndexGeneration::ZERO,
15963 &nodes,
15964 &policy,
15965 &initial_symbols,
15966 &initial_control,
15967 )?;
15968 require(
15969 initial_stage
15970 .identity_rejections
15971 .iter()
15972 .any(|rejection| rejection.path.as_str() == consumer_path),
15973 "initial dependent rejection was not admitted",
15974 )?;
15975 publish_full_staged_graph(
15976 &mut store,
15977 &nodes,
15978 &initial_stage,
15979 &initial_control,
15980 "incremental-dependent-initial",
15981 )?;
15982 for graph in &persisted_graphs {
15983 store.replace_symbol_graph(graph)?;
15984 }
15985 let base_generation = store
15986 .index_publication()?
15987 .ok_or("incremental dependent initial publication is missing")?
15988 .generation;
15989 let consumer_key = RepositoryNodePath::new(Path::new(consumer_path))?;
15990 let initial_rows = store.repository_graph_identity_rejections(
15991 project,
15992 std::slice::from_ref(&consumer_key),
15993 16,
15994 None,
15995 )?;
15996 require_eq(
15997 &initial_rows.len(),
15998 &1,
15999 "initial unchanged dependent rejection rows",
16000 )?;
16001
16002 let replacement =
16003 extract_symbol_graph(provider_path, Some("rust"), "pub fn replacement() {}\n");
16004 let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
16005 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16006 let incremental_policy =
16007 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
16008 let incremental_stage = stage_incremental_repository_graph(
16009 &store,
16010 &root,
16011 base_generation,
16012 &nodes,
16013 std::slice::from_ref(&provider_path.to_string()),
16014 &incremental_policy,
16015 &replacement_symbols,
16016 &incremental_control,
16017 )?;
16018 require(
16019 incremental_stage
16020 .identity_rejections
16021 .iter()
16022 .any(|rejection| rejection.path.as_str() == consumer_path),
16023 "incremental affected dependent did not hydrate rejection details",
16024 )?;
16025 let staged_coverage = incremental_stage
16026 .coverage
16027 .iter()
16028 .find(|coverage| {
16029 matches!(
16030 coverage.scope(),
16031 CoverageScope::Path { path } if path.as_str() == consumer_path
16032 ) && coverage.relation().is_none()
16033 })
16034 .ok_or("incremental affected dependent coverage is missing")?;
16035 require_eq(
16036 &staged_coverage.omitted(),
16037 &1,
16038 "incremental affected dependent omission count",
16039 )?;
16040 {
16041 let mut publication = store.begin_index_publication("incremental-dependent-reuse")?;
16042 incremental_stage.apply(&mut publication, &incremental_control)?;
16043 publication.complete()?;
16044 }
16045 require_eq(
16046 &store.repository_graph_identity_rejections(
16047 project,
16048 std::slice::from_ref(&consumer_key),
16049 16,
16050 None,
16051 )?,
16052 &initial_rows,
16053 "incremental affected dependent persisted rejection rows",
16054 )?;
16055 Ok(())
16056 }
16057
16058 #[test]
16059 fn incremental_reuse_reconciles_markdown_and_semantic_rejections_once()
16060 -> Result<(), Box<dyn Error>> {
16061 let temp = tempfile::tempdir()?;
16062 let root = fs::canonicalize(temp.path())?;
16063 let semantic_components = (0..20)
16064 .map(|_| "s".repeat(200))
16065 .collect::<Vec<_>>()
16066 .join("/");
16067 let semantic_path = format!("src/semantic/{semantic_components}/graph.rs");
16068 fs::create_dir_all(root.join("src"))?;
16069 fs::create_dir_all(root.join("docs"))?;
16070 fs::create_dir_all(
16071 root.join(&semantic_path)
16072 .parent()
16073 .ok_or("semantic fixture parent is missing")?,
16074 )?;
16075 let guide_source = "[invalid](<../src/worker.rs#bad\u{1}>)\n[worker](../src/worker.rs)\n";
16076 fs::write(root.join("src/worker.rs"), "pub fn worker() {}\n")?;
16077 fs::write(root.join(&semantic_path), "use crate::worker;\n")?;
16078 fs::write(root.join("docs/guide.md"), guide_source)?;
16079 let database = root.join(".projectatlas/projectatlas.db");
16080 fs::create_dir_all(root.join(".projectatlas"))?;
16081 let mut store = AtlasStore::open_for_project(&database, &root)?;
16082 let project = store
16083 .project_instance_id()?
16084 .ok_or("combined reuse project identity is missing")?;
16085 let mut nodes = vec![
16086 test_file_node("src/worker.rs", "rust"),
16087 test_file_node(&semantic_path, "rust"),
16088 test_file_node("docs/guide.md", "markdown"),
16089 ];
16090 let worker_graph =
16091 extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn worker() {}\n");
16092 let semantic_graph = semantic_resolution_key_graph(&semantic_path, true);
16093 let guide_facts = projectatlas_symbols::extract_markdown_facts(guide_source);
16094 let guide_graph = guide_facts.symbol_graph("docs/guide.md", Some("markdown"));
16095 let guide_bytes = guide_source.as_bytes();
16096 nodes[2].size_bytes = Some(u64::try_from(guide_bytes.len())?);
16097 nodes[2].content_hash = Some(blake3::hash(guide_bytes).to_hex().to_string());
16098 let mut initial_symbols = symbol_build_stage_for_graphs(vec![worker_graph, semantic_graph]);
16099 initial_symbols.report.candidates = initial_symbols.report.candidates.saturating_add(1);
16100 initial_symbols.report.parsed = initial_symbols.report.parsed.saturating_add(1);
16101 initial_symbols.report.summaries = initial_symbols.report.summaries.saturating_add(1);
16102 initial_symbols
16103 .changes
16104 .push(SymbolProjectionChange::Parsed(SymbolParseSuccess {
16105 path: "docs/guide.md".to_string(),
16106 graph: guide_graph,
16107 markdown_facts: Some(Box::new(guide_facts)),
16108 source_parser: ParserKind::Structural,
16109 summary: "combined reuse Markdown fixture".to_string(),
16110 summary_is_structural: true,
16111 purpose_suggestion: None,
16112 }));
16113 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
16114 initial_symbols.identity_admission =
16115 super::admit_symbol_build_stage(&mut initial_symbols, &initial_control)?;
16116 let scan_policy =
16117 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
16118 let initial_stage = stage_full_repository_graph(
16119 &store,
16120 &root,
16121 IndexGeneration::ZERO,
16122 &nodes,
16123 &scan_policy,
16124 &initial_symbols,
16125 &initial_control,
16126 )?;
16127 require(
16128 initial_stage
16129 .identity_rejections
16130 .iter()
16131 .any(|row| row.path.as_str() == "docs/guide.md"),
16132 "combined reuse fixture did not retain Markdown rejection",
16133 )?;
16134 require(
16135 initial_stage.identity_rejections.iter().any(|row| {
16136 row.path.as_str() == semantic_path && row.field == GraphIdentityField::ResolutionKey
16137 }),
16138 "combined reuse fixture did not retain semantic rejection",
16139 )?;
16140 publish_full_staged_graph(
16141 &mut store,
16142 &nodes,
16143 &initial_stage,
16144 &initial_control,
16145 "combined-reuse-initial",
16146 )?;
16147 for change in &initial_symbols.changes {
16148 if let SymbolProjectionChange::Parsed(parsed) = change {
16149 store.replace_symbol_graph(&parsed.graph)?;
16150 }
16151 }
16152 let base_generation = store
16153 .index_publication()?
16154 .ok_or("combined reuse initial publication is missing")?
16155 .generation;
16156 let paths = nodes
16157 .iter()
16158 .map(|node| RepositoryNodePath::new(Path::new(&node.path)))
16159 .collect::<Result<Vec<_>, _>>()?;
16160 let initial_rows = store.repository_graph_identity_rejections(
16161 project,
16162 &paths,
16163 GraphLimits::MAX_ROWS,
16164 None,
16165 )?;
16166 let replacement =
16167 extract_symbol_graph("src/worker.rs", Some("rust"), "pub fn replacement() {}\n");
16168 let replacement_symbols = symbol_build_stage_for_graphs(vec![replacement]);
16169 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16170 let incremental_stage = stage_incremental_repository_graph(
16171 &store,
16172 &root,
16173 base_generation,
16174 &nodes,
16175 &["src/worker.rs".to_string()],
16176 &scan_policy,
16177 &replacement_symbols,
16178 &incremental_control,
16179 )?;
16180 let guide_coverage = incremental_stage
16181 .coverage
16182 .iter()
16183 .find(|coverage| {
16184 matches!(
16185 coverage.scope(),
16186 CoverageScope::Path { path } if path.as_str() == "docs/guide.md"
16187 ) && coverage.relation().is_none()
16188 })
16189 .ok_or("combined reuse Markdown coverage is missing")?;
16190 let semantic_coverage = incremental_stage
16191 .coverage
16192 .iter()
16193 .find(|coverage| {
16194 matches!(
16195 coverage.scope(),
16196 CoverageScope::Path { path } if path.as_str() == semantic_path
16197 ) && coverage.relation().is_none()
16198 })
16199 .ok_or("combined reuse semantic coverage is missing")?;
16200 let initial_guide_omitted = initial_rows
16201 .iter()
16202 .filter(|row| row.path.as_str() == "docs/guide.md")
16203 .count();
16204 let initial_semantic_omitted = initial_rows
16205 .iter()
16206 .filter(|row| row.path.as_str() == semantic_path)
16207 .count();
16208 require_eq(
16209 &guide_coverage.omitted(),
16210 &u64::try_from(initial_guide_omitted)?,
16211 "reused Markdown omission count",
16212 )?;
16213 require_eq(
16214 &semantic_coverage.omitted(),
16215 &u64::try_from(initial_semantic_omitted)?,
16216 "reused semantic omission count",
16217 )?;
16218 require_eq(
16219 &incremental_stage
16220 .identity_rejections
16221 .iter()
16222 .filter(|row| row.path.as_str() == "docs/guide.md")
16223 .count(),
16224 &initial_guide_omitted,
16225 "reused Markdown detail count",
16226 )?;
16227 require_eq(
16228 &incremental_stage
16229 .identity_rejections
16230 .iter()
16231 .filter(|row| row.path.as_str() == semantic_path)
16232 .count(),
16233 &initial_semantic_omitted,
16234 "reused semantic detail count",
16235 )?;
16236 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
16237 canceled.cancel();
16238 {
16239 let mut publication = store.begin_index_publication("combined-reuse-cancel")?;
16240 require(
16241 incremental_stage
16242 .apply(&mut publication, &canceled)
16243 .is_err(),
16244 "combined reuse cancellation reached publication",
16245 )?;
16246 }
16247 require_eq(
16248 &store
16249 .index_publication()?
16250 .map(|publication| publication.generation),
16251 &Some(base_generation),
16252 "combined reuse cancellation changed generation",
16253 )?;
16254 {
16255 let mut publication = store.begin_index_publication("combined-reuse-incremental")?;
16256 incremental_stage.apply(&mut publication, &incremental_control)?;
16257 publication.complete()?;
16258 }
16259 let persisted_rows = store.repository_graph_identity_rejections(
16260 project,
16261 &paths,
16262 GraphLimits::MAX_ROWS,
16263 None,
16264 )?;
16265 require_eq(
16266 &persisted_rows,
16267 &initial_rows,
16268 "reused Markdown and semantic persisted rows",
16269 )?;
16270 let retry_generation = store
16271 .index_publication()?
16272 .ok_or("combined reuse incremental publication is missing")?
16273 .generation;
16274 let retry_stage = stage_incremental_repository_graph(
16275 &store,
16276 &root,
16277 retry_generation,
16278 &nodes,
16279 &["src/worker.rs".to_string()],
16280 &scan_policy,
16281 &replacement_symbols,
16282 &incremental_control,
16283 )?;
16284 require_eq(
16285 &retry_stage
16286 .identity_rejections
16287 .iter()
16288 .filter(|row| row.path.as_str() == "docs/guide.md")
16289 .count(),
16290 &initial_guide_omitted,
16291 "reused Markdown deterministic retry detail count",
16292 )?;
16293 require_eq(
16294 &retry_stage
16295 .identity_rejections
16296 .iter()
16297 .filter(|row| row.path.as_str() == semantic_path)
16298 .count(),
16299 &initial_semantic_omitted,
16300 "reused semantic deterministic retry detail count",
16301 )?;
16302 Ok(())
16303 }
16304
16305 #[test]
16306 fn incremental_stage_repairs_and_removes_only_affected_rejection_paths()
16307 -> Result<(), Box<dyn Error>> {
16308 let temp = tempfile::tempdir()?;
16309 let root = temp.path().join("incremental-identity-admission");
16310 for directory in ["src", "tests", "docs"] {
16311 fs::create_dir_all(root.join(directory))?;
16312 }
16313 for path in ["src/one.rs", "tests/two.rs", "docs/three.rs"] {
16314 fs::write(
16315 root.join(path),
16316 "pub fn caller() { helper(); }\nfn helper() {}\n",
16317 )?;
16318 }
16319 let database = root.join(".projectatlas/projectatlas.db");
16320 fs::create_dir_all(root.join(".projectatlas"))?;
16321 let mut store = AtlasStore::open_for_project(&database, &root)?;
16322 let project = store
16323 .project_instance_id()?
16324 .ok_or("incremental admission project identity is missing")?;
16325 let initial_nodes = [
16326 test_file_node("src/one.rs", "rust"),
16327 test_file_node("tests/two.rs", "rust"),
16328 test_file_node("docs/three.rs", "rust"),
16329 ];
16330 let initial_graphs = vec![
16331 identity_sibling_graph("src/one.rs", GraphIdentityField::RelationTarget),
16332 identity_sibling_graph("tests/two.rs", GraphIdentityField::RelationTarget),
16333 identity_sibling_graph("docs/three.rs", GraphIdentityField::RelationTarget),
16334 ];
16335 let initial_symbols = symbol_build_stage_for_graphs(initial_graphs);
16336 let initial_control = IndexWorkControl::new(IndexCancellation::new(), None);
16337 let initial_policy =
16338 RootScanPolicy::discover(&root, &ScanOptions::default(), &initial_control)?;
16339 let initial_stage = stage_full_repository_graph(
16340 &store,
16341 &root,
16342 IndexGeneration::ZERO,
16343 &initial_nodes,
16344 &initial_policy,
16345 &initial_symbols,
16346 &initial_control,
16347 )?;
16348 publish_full_staged_graph(
16349 &mut store,
16350 &initial_nodes,
16351 &initial_stage,
16352 &initial_control,
16353 "incremental-identity-initial",
16354 )?;
16355 let base_generation = store
16356 .index_publication()?
16357 .ok_or("incremental initial publication is missing")?
16358 .generation;
16359 let src_path = RepositoryNodePath::new(Path::new("src/one.rs"))?;
16360 let removed_path = RepositoryNodePath::new(Path::new("tests/two.rs"))?;
16361 let retained_path = RepositoryNodePath::new(Path::new("docs/three.rs"))?;
16362 let initial_rows = store.repository_graph_identity_rejections(
16363 project,
16364 &[
16365 src_path.clone(),
16366 removed_path.clone(),
16367 retained_path.clone(),
16368 ],
16369 16,
16370 None,
16371 )?;
16372 require_eq(
16373 &initial_rows.len(),
16374 &3,
16375 "initial incremental rejection rows",
16376 )?;
16377
16378 let repaired_graph = extract_symbol_graph(
16379 "src/one.rs",
16380 Some("rust"),
16381 "pub fn caller() { helper(); }\nfn helper() {}\n",
16382 );
16383 let repaired_symbols = symbol_build_stage_for_graphs(vec![repaired_graph]);
16384 let expected_nodes = vec![initial_nodes[0].clone(), initial_nodes[2].clone()];
16385 let incremental_control = IndexWorkControl::new(IndexCancellation::new(), None);
16386 let incremental_policy =
16387 RootScanPolicy::discover(&root, &ScanOptions::default(), &incremental_control)?;
16388 let incremental_stage = stage_incremental_repository_graph(
16389 &store,
16390 &root,
16391 base_generation,
16392 &expected_nodes,
16393 &["src/one.rs".to_string(), "tests/two.rs".to_string()],
16394 &incremental_policy,
16395 &repaired_symbols,
16396 &incremental_control,
16397 )?;
16398 {
16399 let mut publication = store.begin_index_publication("incremental-identity-repair")?;
16400 incremental_stage.apply(&mut publication, &incremental_control)?;
16401 publication.complete()?;
16402 }
16403 let repaired_rows = store.repository_graph_identity_rejections(
16404 project,
16405 &[
16406 src_path.clone(),
16407 removed_path.clone(),
16408 retained_path.clone(),
16409 ],
16410 16,
16411 None,
16412 )?;
16413 require(
16414 repaired_rows
16415 .iter()
16416 .all(|row| row.path != src_path && row.path != removed_path),
16417 "incremental repair/removal retained affected rejection detail",
16418 )?;
16419 require(
16420 repaired_rows.iter().any(|row| row.path == retained_path),
16421 "incremental repair removed unrelated rejection detail",
16422 )?;
16423 require_eq(
16424 &repaired_rows.len(),
16425 &1,
16426 "incremental unaffected rejection rows",
16427 )?;
16428 Ok(())
16429 }
16430
16431 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
16432 if condition {
16433 Ok(())
16434 } else {
16435 Err(io::Error::other(message).into())
16436 }
16437 }
16438
16439 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
16440 where
16441 T: Debug + PartialEq,
16442 {
16443 if actual == expected {
16444 Ok(())
16445 } else {
16446 Err(io::Error::other(format!(
16447 "{label} mismatch: actual={actual:?}, expected={expected:?}"
16448 ))
16449 .into())
16450 }
16451 }
16452
16453 fn publish_full_staged_graph(
16454 store: &mut AtlasStore,
16455 nodes: &[Node],
16456 staged: &StagedRepositoryGraph,
16457 control: &IndexWorkControl,
16458 label: &str,
16459 ) -> Result<(), Box<dyn Error>> {
16460 let mut publication = store.begin_index_publication(label)?;
16461 publication.begin_scan_replacement()?;
16462 publication.upsert_scan_node_batch(nodes)?;
16463 publication.finish_scan_replacement()?;
16464 staged.apply(&mut publication, control)?;
16465 publication.complete()?;
16466 Ok(())
16467 }
16468
16469 fn symbol_build_stage_for_markdown(
16470 graph: SymbolGraph,
16471 markdown_facts: projectatlas_symbols::MarkdownFacts,
16472 ) -> SymbolBuildStage {
16473 let mut stage = empty_symbol_build_stage();
16474 stage.report.candidates = 1;
16475 stage.report.parsed = 1;
16476 stage.report.summaries = 1;
16477 stage.changes = vec![SymbolProjectionChange::Parsed(SymbolParseSuccess {
16478 path: graph.path.clone(),
16479 source_parser: ParserKind::Structural,
16480 graph,
16481 markdown_facts: Some(Box::new(markdown_facts)),
16482 summary: "markdown identity admission fixture".to_string(),
16483 summary_is_structural: true,
16484 purpose_suggestion: None,
16485 })];
16486 stage
16487 }
16488
16489 fn symbol_build_stage_for_graphs(graphs: Vec<SymbolGraph>) -> SymbolBuildStage {
16490 let mut stage = empty_symbol_build_stage();
16491 stage.report.candidates = graphs.len();
16492 stage.report.parsed = graphs.len();
16493 stage.report.symbols = graphs.iter().map(|graph| graph.symbols.len()).sum();
16494 stage.report.relations = graphs.iter().map(|graph| graph.relations.len()).sum();
16495 stage.changes = graphs
16496 .into_iter()
16497 .map(|graph| {
16498 let path = graph.path.clone();
16499 let source_parser = graph.parser;
16500 SymbolProjectionChange::Parsed(SymbolParseSuccess {
16501 path,
16502 graph,
16503 markdown_facts: None,
16504 source_parser,
16505 summary: "identity admission fixture".to_string(),
16506 summary_is_structural: false,
16507 purpose_suggestion: None,
16508 })
16509 })
16510 .collect();
16511 stage
16512 }
16513
16514 fn identity_sibling_graph(path: &str, invalid_field: GraphIdentityField) -> SymbolGraph {
16515 let mut graph = extract_symbol_graph(
16516 path,
16517 Some("rust"),
16518 "pub fn caller() { helper(); }\nfn helper() {}\n",
16519 );
16520 graph.relations.push(SymbolRelation {
16521 path: path.to_string(),
16522 source_name: if invalid_field == GraphIdentityField::RelationSource {
16523 "bad\0source".to_string()
16524 } else {
16525 "caller".to_string()
16526 },
16527 target_name: if invalid_field == GraphIdentityField::RelationTarget {
16528 "bad\0target".to_string()
16529 } else {
16530 "helper".to_string()
16531 },
16532 kind: RelationKind::Calls,
16533 line: 3,
16534 context: "identity admission fixture".to_string(),
16535 parser: ParserKind::TreeSitter,
16536 });
16537 graph
16538 }
16539
16540 fn semantic_resolution_key_graph(path: &str, include_invalid: bool) -> SymbolGraph {
16541 let helper = if path.contains("sibling") {
16542 "sibling_helper"
16543 } else {
16544 "page_helper"
16545 };
16546 let parent = "LeakedIdentity".repeat(16);
16547 let invalid_methods = if include_invalid {
16548 format!(
16549 "pub struct {parent};\nimpl {parent} {{\n pub fn first(&self) {{}}\n pub fn second(&self) {{}}\n}}\n"
16550 )
16551 } else {
16552 String::new()
16553 };
16554 extract_symbol_graph(
16555 path,
16556 Some("rust"),
16557 &format!(
16558 "use crate::worker;\n{invalid_methods}pub fn caller() {{ {helper}(); worker(); }}\npub fn {helper}() {{}}\n"
16559 ),
16560 )
16561 }
16562
16563 fn empty_symbol_build_stage() -> SymbolBuildStage {
16564 SymbolBuildStage {
16565 report: SymbolBuildReport {
16566 candidates: 0,
16567 parsed: 0,
16568 unchanged: 0,
16569 too_large: 0,
16570 binary_or_non_utf8: 0,
16571 timed_out: 0,
16572 max_workers: 1,
16573 timeout_seconds: None,
16574 symbols: 0,
16575 relations: 0,
16576 summaries: 0,
16577 purpose_suggestions: 0,
16578 },
16579 changes: Vec::new(),
16580 retained_bytes: 0,
16581 identity_admission: GraphIdentityAdmission::default(),
16582 }
16583 }
16584
16585 fn function_graph(path: &str, symbol_count: usize) -> SymbolGraph {
16586 SymbolGraph {
16587 path: path.to_string(),
16588 language: Some("rust".to_string()),
16589 parser: ParserKind::TreeSitter,
16590 symbols: (0..symbol_count)
16591 .map(|index| CodeSymbol {
16592 path: path.to_string(),
16593 language: Some("rust".to_string()),
16594 name: format!("symbol_{index}"),
16595 kind: SymbolKind::Function,
16596 signature: format!("fn symbol_{index}()"),
16597 exported: index == 0,
16598 documentation: None,
16599 line_start: index + 1,
16600 line_end: index + 1,
16601 source_selector: None,
16602 parent: None,
16603 parser: ParserKind::TreeSitter,
16604 detail: Some("function_item".to_string()),
16605 })
16606 .collect(),
16607 relations: vec![SymbolRelation {
16608 path: path.to_string(),
16609 source_name: "symbol_0".to_string(),
16610 target_name: "dependency".to_string(),
16611 kind: RelationKind::Calls,
16612 line: 1,
16613 context: "dependency()".to_string(),
16614 parser: ParserKind::TreeSitter,
16615 }],
16616 }
16617 }
16618
16619 fn package_graph(path: &str, name: &str) -> SymbolGraph {
16620 SymbolGraph {
16621 path: path.to_string(),
16622 language: Some("cargo-manifest".to_string()),
16623 parser: ParserKind::Manifest,
16624 symbols: vec![CodeSymbol {
16625 path: path.to_string(),
16626 language: Some("cargo-manifest".to_string()),
16627 name: name.to_string(),
16628 kind: SymbolKind::Package,
16629 signature: format!("name = \"{name}\""),
16630 exported: true,
16631 documentation: None,
16632 line_start: 1,
16633 line_end: 1,
16634 source_selector: None,
16635 parent: None,
16636 parser: ParserKind::Manifest,
16637 detail: Some("cargo-package".to_string()),
16638 }],
16639 relations: Vec::new(),
16640 }
16641 }
16642}