1mod impact;
4
5#[cfg(test)]
6mod analysis_test_observer {
7 use std::cell::RefCell;
8
9 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
11 pub(super) enum AnalysisPhaseEvent {
12 Traversal,
14 SymbolHydration,
16 Composition,
18 DeadCodeDiscovery,
20 OutputRendering,
22 }
23
24 type Observer = Box<dyn FnMut(AnalysisPhaseEvent)>;
26
27 thread_local! {
28 static OBSERVER: RefCell<Option<Observer>> = RefCell::new(None);
30 }
31
32 struct ObserverGuard {
34 previous: Option<Observer>,
36 }
37
38 impl Drop for ObserverGuard {
39 fn drop(&mut self) {
40 OBSERVER.with(|slot| {
41 drop(slot.replace(self.previous.take()));
42 });
43 }
44 }
45
46 pub(super) fn observe_analysis_phase<T>(
48 observer: impl FnMut(AnalysisPhaseEvent) + 'static,
49 operation: impl FnOnce() -> T,
50 ) -> T {
51 let previous = OBSERVER.with(|slot| slot.replace(Some(Box::new(observer))));
52 let _guard = ObserverGuard { previous };
53 operation()
54 }
55
56 pub(super) fn notify(event: AnalysisPhaseEvent) {
58 OBSERVER.with(|slot| {
59 if let Some(observer) = slot.borrow_mut().as_mut() {
60 observer(event);
61 }
62 });
63 }
64}
65
66use super::relations::{
67 ExternalRelationIdentity, external_relation_identities, load_detailed_relations,
68};
69use super::{
70 DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery, DetailedRelationReport,
71 DetailedRelationWork, RelationAnchor, RelationDirection, RelationNextCall, RelationPurpose,
72 RelationResolutionFilter, RelationTotalState, ServiceError, ServiceResult,
73 selected_project_binding,
74};
75use impact::{LoadedVcs, digest_vcs_paths, impact_findings, load_vcs_paths};
76use projectatlas_core::graph::{
77 Completeness, ConfidenceClass, CoverageState, EntitySelector, ExtendedRelationKind,
78 GraphEntity, GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind,
79 ProjectInstanceId, RelationResolution,
80};
81use projectatlas_core::symbols::{CodeSymbol, RelationKind};
82use projectatlas_core::{IndexCancellation, IndexWorkControl, IndexWorkStage};
83use projectatlas_db::{
84 AtlasStore, DbError, MAX_REPOSITORY_GRAPH_FRONTIER, MAX_SYMBOL_BATCH_DECODED_BYTES,
85 MAX_SYMBOL_BATCH_PATHS, MAX_SYMBOL_BATCH_ROWS, RepositoryGraphAdjacencyContinuation,
86 RepositoryGraphDirection, RepositoryGraphReadBudget, SymbolBatchReadBudget,
87 SymbolBatchReadLimit,
88};
89use serde::{Deserialize, Serialize};
90use std::collections::{BTreeMap, BTreeSet, VecDeque};
91use std::io::{self, Write};
92use std::path::Path;
93use std::time::{Duration, Instant};
94
95const ANALYSIS_CURSOR_VERSION: u16 = 1;
97const ANALYSIS_CURSOR_MAX_BYTES: usize = 256 * 1024;
99const MAX_ANALYSIS_NODES: u32 = 512;
101const MAX_ANALYSIS_EDGES: u32 = 2_048;
103
104#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(rename_all = "snake_case")]
107pub enum RelationAnalysisMode {
108 Architecture,
110 Impact,
112 Trace,
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum GitImpactSelection {
120 WorkingTree,
122 Index,
124 RevisionRange {
126 base: String,
128 head: String,
130 },
131}
132
133#[derive(Clone, Debug)]
135pub struct RelationAnalysisQuery {
136 pub relations: DetailedRelationQuery,
138 pub mode: RelationAnalysisMode,
140 pub trace_target: Option<RelationAnchor>,
142 pub vcs: Option<GitImpactSelection>,
144 pub include_communities: bool,
146 pub include_cycles: bool,
148 pub include_dead_code: bool,
150}
151
152#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum AnalysisStatus {
156 Confirmed,
158 Candidate,
160 Absent,
162 Inconclusive,
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum AnalysisFindingKind {
170 Component,
172 Community,
174 DependencyCycle,
176 PurposeAlignment,
178 PurposeDrift,
180 StructuralComplexity,
182 Bottleneck,
184 Impact,
186 DeadCode,
188 StaticTrace,
190 ResolutionGap,
192}
193
194#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
196pub struct AnalysisFinding {
197 pub kind: AnalysisFindingKind,
199 pub status: AnalysisStatus,
201 pub summary: String,
203 pub nodes: Vec<AnalysisNode>,
205 pub metric: Option<u64>,
207 pub evidence: Option<AnalysisRelationEvidence>,
209}
210
211#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
213pub struct AnalysisNode {
214 pub node: DetailedRelationNode,
216 pub next_call: Option<RelationNextCall>,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
222pub struct AnalysisRelationEvidence {
223 pub relation: projectatlas_core::graph::LogicalRelation,
225 pub next_call: Option<RelationAnalysisNextCall>,
227}
228
229#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
231pub struct RelationAnalysisNextCall {
232 pub anchor: RelationAnchor,
234 pub direction: RelationDirection,
236 pub relation: GraphRelationKind,
238 pub resolution: RelationResolutionFilter,
240 pub minimum_confidence: ConfidenceClass,
242}
243
244#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
246#[serde(tag = "state", rename_all = "snake_case")]
247pub enum VcsImpact {
248 NotRequested,
250 Available {
252 selection: GitImpactSelection,
254 changed_path_count: u64,
256 },
257 Unavailable {
259 selection: GitImpactSelection,
261 reason: String,
263 },
264}
265
266#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
268pub struct RelationAnalysisWork {
269 pub relations: DetailedRelationWork,
271 pub closure_inspected_edges: u32,
273 pub closure_decoded_bytes: u64,
275 pub vcs_retained_bytes: u64,
277 pub analyzed_nodes: u32,
279 pub analyzed_edges: u32,
281 pub hydrated_symbols: u32,
283 pub hydrated_symbol_bytes: u64,
285 pub symbol_hydration_truncated: bool,
287 pub retained_composition_bytes: u64,
289 pub peak_intermediate_bytes: u64,
291 pub composition_truncated: bool,
293 pub rendered_output_bytes: u64,
295}
296
297#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
299pub struct RelationAnalysisReport {
300 pub mode: RelationAnalysisMode,
302 pub anchor: DetailedRelationNode,
304 pub generation: projectatlas_core::IndexGeneration,
306 pub authored_purpose_revision: u64,
308 pub continuation: Option<String>,
310 pub returned: u32,
312 pub total: RelationTotalState,
314 pub truncated: bool,
316 pub reached_limits: Vec<GraphLimitKind>,
318 pub vcs: VcsImpact,
320 pub work: RelationAnalysisWork,
322 pub findings: Vec<AnalysisFinding>,
324}
325
326pub struct RelationAnalysisDraft {
328 report: RelationAnalysisReport,
330 output_bytes: u32,
332 budget: DetailedRelationBudget,
334 cursor_binding: AnalysisCursorBinding,
336 cursor_snapshot: AnalysisCursorSnapshot,
338 replay_relation_cursor: Option<String>,
340 finding_offset: u32,
342 vcs_digest: Option<[u8; 32]>,
344 external_relation_identities: BTreeSet<ExternalRelationIdentity>,
346 control: IndexWorkControl,
348}
349
350impl RelationAnalysisDraft {
351 #[must_use]
353 pub const fn candidate_report(&self) -> &RelationAnalysisReport {
354 &self.report
355 }
356
357 pub(super) const fn control(&self) -> &IndexWorkControl {
359 &self.control
360 }
361
362 pub(super) fn take_external_relation_identities(
364 &mut self,
365 ) -> BTreeSet<ExternalRelationIdentity> {
366 std::mem::take(&mut self.external_relation_identities)
367 }
368
369 pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(RelationAnalysisReport, O), E>
376 where
377 F: FnMut(&RelationAnalysisReport, &IndexWorkControl) -> Result<O, E>,
378 E: From<ServiceError>,
379 O: AsRef<[u8]>,
380 {
381 check_control(Some(&self.control)).map_err(E::from)?;
382 #[cfg(test)]
383 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::OutputRendering);
384 let original_report_bytes =
385 serialized_bytes_controlled(&self.report, Some(&self.control)).map_err(E::from)?;
386 let construction_peak = self.report.work.peak_intermediate_bytes;
387 let mut low = 0;
388 let mut high = self.report.findings.len();
389 let mut best = None;
390 let mut output_limited = false;
391 let mut intermediate_limited = false;
392 let mut empty_output_oversized = false;
393 let mut empty_intermediate_oversized = false;
394 while low <= high {
395 check_control(Some(&self.control)).map_err(E::from)?;
396 let middle = low + (high - low) / 2;
397 let fit_limits = [
398 output_limited.then_some(GraphLimitKind::OutputBytes),
399 intermediate_limited.then_some(GraphLimitKind::IntermediateBytes),
400 ];
401 let mut candidate =
402 analysis_prefix(&self.report, middle, fit_limits.into_iter().flatten());
403 if middle < self.report.findings.len() {
404 let middle = u32::try_from(middle).map_err(|_overflow| {
405 E::from(ServiceError::InvalidInput(
406 "analysis finding offset overflowed".to_string(),
407 ))
408 })?;
409 let finding_offset = self.finding_offset.checked_add(middle).ok_or_else(|| {
410 E::from(ServiceError::InvalidInput(
411 "analysis finding offset overflowed".to_string(),
412 ))
413 })?;
414 candidate.continuation = Some(
415 encode_analysis_cursor(
416 self.replay_relation_cursor.as_deref(),
417 finding_offset,
418 &self.cursor_binding,
419 self.cursor_snapshot,
420 self.vcs_digest,
421 self.budget,
422 )
423 .map_err(E::from)?,
424 );
425 }
426 check_control(Some(&self.control)).map_err(E::from)?;
427 let mut encoded = encode(&candidate, &self.control)?;
428 check_control(Some(&self.control)).map_err(E::from)?;
429 let mut stable = false;
430 for _ in 0..8 {
431 check_control(Some(&self.control)).map_err(E::from)?;
432 let rendered = u64::try_from(encoded.as_ref().len()).map_err(|source| {
433 E::from(ServiceError::InvalidInput(format!(
434 "analysis rendered byte count overflowed: {source}"
435 )))
436 })?;
437 let candidate_report_bytes =
438 serialized_bytes_controlled(&candidate, Some(&self.control))
439 .map_err(E::from)?;
440 let fitting_peak = original_report_bytes
441 .checked_add(candidate_report_bytes)
442 .and_then(|bytes| bytes.checked_add(rendered))
443 .ok_or_else(|| {
444 E::from(ServiceError::InvalidInput(
445 "analysis output fitting byte count overflowed".to_string(),
446 ))
447 })?;
448 let peak = construction_peak.max(fitting_peak);
449 if candidate.work.rendered_output_bytes == rendered
450 && candidate.work.peak_intermediate_bytes == peak
451 {
452 stable = true;
453 break;
454 }
455 candidate.work.rendered_output_bytes = rendered;
456 candidate.work.peak_intermediate_bytes = peak;
457 drop(encoded);
458 encoded = encode(&candidate, &self.control)?;
459 check_control(Some(&self.control)).map_err(E::from)?;
460 }
461 if !stable {
462 return Err(E::from(ServiceError::InvalidInput(
463 "analysis output accounting did not stabilize".to_string(),
464 )));
465 }
466 let output_fits = encoded.as_ref().len() <= self.output_bytes as usize;
467 let intermediate_fits =
468 candidate.work.peak_intermediate_bytes <= self.budget.intermediate_bytes();
469 if output_fits && intermediate_fits {
470 best = Some((candidate, encoded));
471 low = middle.saturating_add(1);
472 } else {
473 if middle == 0 {
474 empty_output_oversized = !output_fits;
475 empty_intermediate_oversized = !intermediate_fits;
476 break;
477 }
478 let newly_output_limited = !output_fits && !output_limited;
479 let newly_intermediate_limited = !intermediate_fits && !intermediate_limited;
480 output_limited |= !output_fits;
481 intermediate_limited |= !intermediate_fits;
482 if newly_output_limited || newly_intermediate_limited {
483 best = None;
484 low = 0;
485 }
486 high = middle - 1;
487 }
488 }
489 check_control(Some(&self.control)).map_err(E::from)?;
490 best.ok_or_else(|| {
491 let message = if empty_intermediate_oversized {
492 "empty analysis envelope exceeds the aggregate intermediate-byte budget"
493 } else if empty_output_oversized {
494 "graph output byte limit is too small for the empty analysis envelope"
495 } else if intermediate_limited {
496 "analysis output fitting exceeds the aggregate intermediate-byte budget"
497 } else {
498 "graph output byte limit is too small for the empty analysis envelope"
499 };
500 E::from(ServiceError::InvalidInput(message.to_string()))
501 })
502 }
503}
504
505#[derive(Clone, Serialize)]
506struct LocalEdge {
508 source: String,
510 target: String,
512 kind: GraphRelationKind,
514 complete: bool,
516}
517
518#[derive(Default)]
519struct SupplementalWork {
521 hydrated_symbols: u32,
523 hydrated_symbol_bytes: u64,
525 hydrated_symbol_peak_bytes: u64,
527 symbol_hydration_truncated: bool,
529 reached_limits: Vec<GraphLimitKind>,
531 retained_composition_bytes: u64,
533 composition_truncated: bool,
535}
536
537#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
538struct AnalysisCursorBinding {
540 root_digest: [u8; 32],
542 anchor: RelationAnchor,
544 direction: RelationDirection,
546 relation: Option<GraphRelationKind>,
548 minimum_confidence: ConfidenceClass,
550 resolution: RelationResolutionFilter,
552 options: AnalysisCursorOptions,
554 budget: DetailedRelationBudget,
556 algorithm_version: u16,
558 ordering_version: u16,
560 mode: RelationAnalysisMode,
562 trace_target: Option<RelationAnchor>,
564 vcs: Option<GitImpactSelection>,
566}
567
568#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
569#[serde(rename_all = "snake_case")]
570enum AnalysisFeatureSelection {
572 Excluded,
574 Included,
576}
577
578impl From<bool> for AnalysisFeatureSelection {
579 fn from(included: bool) -> Self {
580 if included {
581 Self::Included
582 } else {
583 Self::Excluded
584 }
585 }
586}
587
588#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
589struct AnalysisCursorOptions {
591 relation_occurrences: AnalysisFeatureSelection,
593 communities: AnalysisFeatureSelection,
595 cycles: AnalysisFeatureSelection,
597 dead_code: AnalysisFeatureSelection,
599}
600
601#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
602struct AnalysisCursorSnapshot {
604 project: ProjectInstanceId,
606 generation: projectatlas_core::IndexGeneration,
608 authored_purpose_revision: u64,
610}
611
612#[derive(Deserialize, Serialize)]
613#[serde(deny_unknown_fields)]
614struct AnalysisCursor {
616 version: u16,
618 binding: AnalysisCursorBinding,
620 snapshot: AnalysisCursorSnapshot,
622 relation_cursor: Option<String>,
624 finding_offset: u32,
626 vcs_digest: Option<[u8; 32]>,
628}
629
630pub fn load_relation_analysis(
637 store: &AtlasStore,
638 query: &RelationAnalysisQuery,
639 control: Option<&IndexWorkControl>,
640) -> ServiceResult<RelationAnalysisDraft> {
641 load_relation_analysis_with_closure_deadline(store, query, control, None, false)
642}
643
644pub(super) fn load_relation_analysis_for_federation(
646 store: &AtlasStore,
647 query: &RelationAnalysisQuery,
648 control: Option<&IndexWorkControl>,
649) -> ServiceResult<RelationAnalysisDraft> {
650 load_relation_analysis_with_closure_deadline(store, query, control, None, true)
651}
652
653fn load_relation_analysis_with_closure_deadline(
655 store: &AtlasStore,
656 query: &RelationAnalysisQuery,
657 control: Option<&IndexWorkControl>,
658 closure_deadline_ceiling: Option<Instant>,
659 retain_external_relation_identities: bool,
660) -> ServiceResult<RelationAnalysisDraft> {
661 validate_analysis_query(query)?;
662 let started = Instant::now();
663 let deadline = started
664 .checked_add(Duration::from_millis(query.relations.budget.deadline_ms()))
665 .unwrap_or(started);
666 let deadline = control
667 .and_then(IndexWorkControl::deadline)
668 .map_or(deadline, |caller_deadline| caller_deadline.min(deadline));
669 let analysis_control = control.map_or_else(
670 || IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
671 |caller| {
672 caller.with_timeout_ceiling(deadline.saturating_duration_since(caller.started_at()))
673 },
674 );
675 let control = Some(&analysis_control);
676 check_control(control)?;
677 let selected_binding = selected_project_binding(store)?;
678 let cursor_binding = analysis_cursor_binding(query, &selected_binding.project_root);
679 let decoded_cursor = query
680 .relations
681 .cursor
682 .as_deref()
683 .map(|cursor| decode_analysis_cursor(cursor, &cursor_binding))
684 .transpose()?;
685 let mut relation_query = query.relations.clone();
686 relation_query.budget = bounded_analysis_budget(query.relations.budget)?;
687 relation_query.cursor = decoded_cursor
688 .as_ref()
689 .and_then(|cursor| cursor.relation_cursor.clone());
690 let replay_relation_cursor = relation_query.cursor.clone();
691 let finding_offset = decoded_cursor
692 .as_ref()
693 .map_or(0, |cursor| cursor.finding_offset);
694 let relations = load_detailed_relations(store, &relation_query, control)?;
695 let external_relation_identities = if retain_external_relation_identities {
696 external_relation_identities(&relations)
697 } else {
698 BTreeSet::new()
699 };
700 let external_relation_identity_bytes =
701 serialized_bytes_controlled(&external_relation_identities, control)?;
702 let cursor_snapshot = AnalysisCursorSnapshot {
703 project: relations.anchor.entity.key().project(),
704 generation: relations.generation,
705 authored_purpose_revision: relations.authored_purpose_revision,
706 };
707 if decoded_cursor
708 .as_ref()
709 .is_some_and(|cursor| cursor.snapshot != cursor_snapshot)
710 {
711 return Err(ServiceError::RelationCursorStale {
712 field: "analysis snapshot",
713 });
714 }
715 check_control(control)?;
716 let mut nodes = collect_nodes(&relations, control)?;
717 let mut edges = collect_report_edges(&relations, control)?;
718 let mut closure_query = query.clone();
719 closure_query.relations = relation_query;
720 let closure = close_induced_edges(
721 store,
722 &closure_query,
723 &relations.work,
724 closure_deadline_ceiling.map_or(deadline, |ceiling| ceiling.min(deadline)),
725 &nodes,
726 &mut edges,
727 control,
728 )?;
729 check_control(control)?;
730 let evidence_complete = relation_evidence_complete(&relations, &nodes, &edges, query, &closure);
731 let dead_code_scope_complete = dead_code_scope_complete(&relations, query);
732 check_control(control)?;
733 let vcs_load = if query.mode == RelationAnalysisMode::Impact {
734 let selection = query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree);
735 load_vcs_paths(
736 Path::new(&selected_binding.project_root),
737 selection,
738 query.relations.budget.intermediate_bytes().saturating_sub(
739 relations
740 .work
741 .intermediate_bytes
742 .saturating_add(closure.decoded_bytes)
743 .saturating_add(external_relation_identity_bytes),
744 ),
745 deadline,
746 control,
747 )
748 } else {
749 LoadedVcs {
750 report: VcsImpact::NotRequested,
751 changed_paths: Vec::new(),
752 retained_bytes: 0,
753 }
754 };
755 let vcs = vcs_load.report;
756 let vcs_digest = (query.mode == RelationAnalysisMode::Impact)
757 .then(|| digest_vcs_paths(&vcs_load.changed_paths, control))
758 .transpose()?;
759 if decoded_cursor
760 .as_ref()
761 .is_some_and(|cursor| cursor.vcs_digest != vcs_digest)
762 {
763 return Err(ServiceError::RelationCursorStale {
764 field: "VCS evidence",
765 });
766 }
767 let mut supplemental_work = SupplementalWork::default();
768 let analysis_allowance = query.relations.budget.intermediate_bytes().saturating_sub(
769 relations
770 .work
771 .intermediate_bytes
772 .saturating_add(closure.decoded_bytes)
773 .saturating_add(external_relation_identity_bytes),
774 );
775 let projection_allowance = analysis_allowance.saturating_sub(vcs_load.retained_bytes);
776 #[cfg(test)]
777 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Composition);
778 let mut topology_bytes =
779 serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
780 let mut gaps = resolution_gap_findings(&relations, control)?;
781 gaps.extend(closure.resolution_gaps.iter().cloned());
782 check_control(control)?;
783 gaps.sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
784 gaps.dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
785 check_control(control)?;
786 let gap_bytes = serialized_bytes_controlled(&gaps, control)?;
787 let projection_safe = topology_bytes.saturating_add(gap_bytes) <= projection_allowance;
788 let mut findings = if projection_safe {
789 gaps
790 } else {
791 supplemental_work.composition_truncated = true;
792 push_limit(
793 &mut supplemental_work.reached_limits,
794 GraphLimitKind::IntermediateBytes,
795 );
796 edges.clear();
797 nodes.retain(|_, node| node.entity.key() == relations.anchor.entity.key());
798 topology_bytes =
799 serialized_bytes_controlled(&(nodes.values().collect::<Vec<_>>(), &edges), control)?;
800 vec![AnalysisFinding {
801 kind: AnalysisFindingKind::Component,
802 status: AnalysisStatus::Inconclusive,
803 summary: "analysis composition crossed the shared intermediate-byte budget".to_string(),
804 nodes: vec![analysis_node(&relations.anchor)],
805 metric: None,
806 evidence: None,
807 }]
808 };
809 let initial_finding_bytes = serialized_bytes_controlled(&findings, control)?;
810 let symbol_byte_budget = projection_allowance
811 .saturating_sub(topology_bytes)
812 .saturating_sub(initial_finding_bytes);
813 if projection_safe {
814 findings.extend(match query.mode {
815 RelationAnalysisMode::Architecture => architecture_findings(
816 store,
817 &nodes,
818 &edges,
819 evidence_complete,
820 query,
821 symbol_byte_budget,
822 &mut supplemental_work,
823 control,
824 )?,
825 RelationAnalysisMode::Impact => impact_findings(
826 store,
827 &nodes,
828 &edges,
829 evidence_complete,
830 dead_code_scope_complete,
831 &vcs,
832 &vcs_load.changed_paths,
833 query,
834 symbol_byte_budget,
835 &mut supplemental_work,
836 control,
837 )?,
838 RelationAnalysisMode::Trace => {
839 trace_findings(&relations, query.trace_target.as_ref(), evidence_complete)?
840 }
841 });
842 }
843 let generated_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
844 ServiceError::InvalidInput("analysis finding count overflowed".to_string())
845 })?;
846 let mut finding_bytes = serialized_bytes_controlled(&findings, control)?;
847 supplemental_work.retained_composition_bytes = vcs_load
848 .retained_bytes
849 .saturating_add(topology_bytes)
850 .saturating_add(finding_bytes);
851 while supplemental_work.retained_composition_bytes > analysis_allowance && findings.len() > 1 {
852 check_control(control)?;
853 findings.pop();
854 supplemental_work.composition_truncated = true;
855 push_limit(
856 &mut supplemental_work.reached_limits,
857 GraphLimitKind::IntermediateBytes,
858 );
859 finding_bytes = serialized_bytes_controlled(&findings, control)?;
860 supplemental_work.retained_composition_bytes = vcs_load
861 .retained_bytes
862 .saturating_add(topology_bytes)
863 .saturating_add(finding_bytes);
864 }
865 if supplemental_work.retained_composition_bytes > analysis_allowance {
866 findings.clear();
867 supplemental_work.composition_truncated = true;
868 push_limit(
869 &mut supplemental_work.reached_limits,
870 GraphLimitKind::IntermediateBytes,
871 );
872 finding_bytes = serialized_bytes_controlled(&findings, control)?;
873 supplemental_work.retained_composition_bytes = vcs_load
874 .retained_bytes
875 .saturating_add(topology_bytes)
876 .saturating_add(finding_bytes);
877 }
878 let hydration_peak = relations
879 .work
880 .intermediate_bytes
881 .saturating_add(closure.decoded_bytes)
882 .saturating_add(vcs_load.retained_bytes)
883 .saturating_add(topology_bytes)
884 .saturating_add(initial_finding_bytes)
885 .saturating_add(external_relation_identity_bytes)
886 .saturating_add(supplemental_work.hydrated_symbol_peak_bytes);
887 let final_peak = relations
888 .work
889 .intermediate_bytes
890 .saturating_add(closure.decoded_bytes)
891 .saturating_add(supplemental_work.retained_composition_bytes)
892 .saturating_add(external_relation_identity_bytes);
893 let peak_intermediate_bytes = hydration_peak.max(final_peak);
894 let full_finding_count = u32::try_from(findings.len()).map_err(|_overflow| {
895 ServiceError::InvalidInput("analysis finding count overflowed".to_string())
896 })?;
897 if finding_offset > full_finding_count {
898 return Err(ServiceError::RelationCursorInvalid {
899 reason: "analysis finding offset exceeds the recomputed page",
900 });
901 }
902 findings.drain(..finding_offset as usize);
903 let mut reached_limits = relations.reached_limits.clone();
904 if !closure.complete {
905 push_limit(
906 &mut reached_limits,
907 if closure.deadline_reached {
908 GraphLimitKind::Deadline
909 } else {
910 GraphLimitKind::Edges
911 },
912 );
913 }
914 for limit in &supplemental_work.reached_limits {
915 push_limit(&mut reached_limits, *limit);
916 }
917 let work = RelationAnalysisWork {
918 relations: relations.work.clone(),
919 closure_inspected_edges: closure.inspected_edges,
920 closure_decoded_bytes: closure.decoded_bytes,
921 vcs_retained_bytes: vcs_load.retained_bytes,
922 analyzed_nodes: u32::try_from(nodes.len()).unwrap_or(u32::MAX),
923 analyzed_edges: u32::try_from(edges.len()).unwrap_or(u32::MAX),
924 hydrated_symbols: supplemental_work.hydrated_symbols,
925 hydrated_symbol_bytes: supplemental_work.hydrated_symbol_bytes,
926 symbol_hydration_truncated: supplemental_work.symbol_hydration_truncated,
927 retained_composition_bytes: supplemental_work.retained_composition_bytes,
928 peak_intermediate_bytes,
929 composition_truncated: supplemental_work.composition_truncated,
930 rendered_output_bytes: 0,
931 };
932 let analysis_truncated = (!evidence_complete && relations.truncated)
933 || !closure.complete
934 || supplemental_work.symbol_hydration_truncated
935 || supplemental_work.composition_truncated;
936 let returned = u32::try_from(findings.len()).unwrap_or(u32::MAX);
937 let total = if analysis_truncated {
938 RelationTotalState::AtLeast(u64::from(generated_finding_count))
939 } else {
940 RelationTotalState::Exact(u64::from(full_finding_count))
941 };
942 let continuation = if evidence_complete {
943 None
944 } else {
945 relations
946 .continuation
947 .as_deref()
948 .map(|cursor| {
949 encode_analysis_cursor(
950 Some(cursor),
951 0,
952 &cursor_binding,
953 cursor_snapshot,
954 vcs_digest,
955 query.relations.budget,
956 )
957 })
958 .transpose()?
959 };
960 let report = RelationAnalysisReport {
961 mode: query.mode,
962 anchor: relations.anchor,
963 generation: relations.generation,
964 authored_purpose_revision: relations.authored_purpose_revision,
965 continuation,
966 returned,
967 total,
968 truncated: analysis_truncated,
969 reached_limits,
970 vcs,
971 work,
972 findings,
973 };
974 nodes.clear();
975 check_control(control)?;
976 Ok(RelationAnalysisDraft {
977 report,
978 output_bytes: query.relations.budget.output_bytes(),
979 budget: query.relations.budget,
980 cursor_binding,
981 cursor_snapshot,
982 replay_relation_cursor,
983 finding_offset,
984 vcs_digest,
985 external_relation_identities,
986 control: analysis_control,
987 })
988}
989
990fn validate_analysis_query(query: &RelationAnalysisQuery) -> ServiceResult<()> {
992 if query.mode == RelationAnalysisMode::Trace {
993 let Some(target) = query.trace_target.as_ref() else {
994 return Err(ServiceError::InvalidInput(
995 "analysis trace requires an exact file or symbol target".to_string(),
996 ));
997 };
998 if matches!(
999 target,
1000 RelationAnchor::Symbol {
1001 symbol_kind: None,
1002 ..
1003 }
1004 ) || matches!(
1005 target,
1006 RelationAnchor::Symbol {
1007 signature: None,
1008 ..
1009 }
1010 ) {
1011 return Err(ServiceError::InvalidInput(
1012 "analysis trace symbol targets require exact kind and signature".to_string(),
1013 ));
1014 }
1015 } else if query.trace_target.is_some() {
1016 return Err(ServiceError::InvalidInput(
1017 "trace_target is valid only for static trace analysis".to_string(),
1018 ));
1019 }
1020 if query.mode != RelationAnalysisMode::Impact && query.vcs.is_some() {
1021 return Err(ServiceError::InvalidInput(
1022 "VCS selection is valid only for impact analysis".to_string(),
1023 ));
1024 }
1025 if query.mode != RelationAnalysisMode::Architecture
1026 && (query.include_communities || query.include_cycles)
1027 {
1028 return Err(ServiceError::InvalidInput(
1029 "community and cycle controls are valid only for architecture analysis".to_string(),
1030 ));
1031 }
1032 if query.mode != RelationAnalysisMode::Impact && query.include_dead_code {
1033 return Err(ServiceError::InvalidInput(
1034 "dead-code controls are valid only for impact analysis".to_string(),
1035 ));
1036 }
1037 Ok(())
1038}
1039
1040fn bounded_analysis_budget(
1042 budget: DetailedRelationBudget,
1043) -> ServiceResult<DetailedRelationBudget> {
1044 let limits = GraphLimits::new(
1045 budget.page_rows().min(MAX_ANALYSIS_NODES),
1046 budget.occurrences_per_relation(),
1047 budget.depth(),
1048 budget.output_bytes(),
1049 )
1050 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1051 DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
1052 Some(budget.edges().min(MAX_ANALYSIS_EDGES)),
1053 Some(budget.nodes().min(MAX_ANALYSIS_NODES)),
1054 Some(budget.visited().min(MAX_ANALYSIS_NODES)),
1055 Some(budget.occurrences_total()),
1056 Some(budget.intermediate_bytes()),
1057 Some(budget.deadline_ms()),
1058 )
1059}
1060
1061fn analysis_cursor_binding(
1063 query: &RelationAnalysisQuery,
1064 project_root: &str,
1065) -> AnalysisCursorBinding {
1066 AnalysisCursorBinding {
1067 root_digest: *blake3::hash(
1068 format!("projectatlas:analysis-root:v1\0{project_root}").as_bytes(),
1069 )
1070 .as_bytes(),
1071 anchor: query.relations.anchor.clone(),
1072 direction: query.relations.direction,
1073 relation: query.relations.relation,
1074 minimum_confidence: query.relations.minimum_confidence,
1075 resolution: query.relations.resolution,
1076 options: AnalysisCursorOptions {
1077 relation_occurrences: query.relations.include_occurrences.into(),
1078 communities: query.include_communities.into(),
1079 cycles: query.include_cycles.into(),
1080 dead_code: query.include_dead_code.into(),
1081 },
1082 budget: query.relations.budget,
1083 algorithm_version: ANALYSIS_CURSOR_VERSION,
1084 ordering_version: 1,
1085 mode: query.mode,
1086 trace_target: query.trace_target.clone(),
1087 vcs: (query.mode == RelationAnalysisMode::Impact)
1088 .then(|| query.vcs.clone().unwrap_or(GitImpactSelection::WorkingTree)),
1089 }
1090}
1091
1092fn decode_analysis_cursor(
1094 encoded: &str,
1095 expected: &AnalysisCursorBinding,
1096) -> ServiceResult<AnalysisCursor> {
1097 if encoded.is_empty() || encoded.len() > ANALYSIS_CURSOR_MAX_BYTES {
1098 return Err(ServiceError::RelationCursorInvalid {
1099 reason: "analysis cursor length is empty or above the product ceiling",
1100 });
1101 }
1102 let cursor: AnalysisCursor = serde_json::from_str(encoded).map_err(|_malformed| {
1103 ServiceError::RelationCursorInvalid {
1104 reason: "analysis cursor JSON is malformed or contains unknown fields",
1105 }
1106 })?;
1107 if cursor.version != ANALYSIS_CURSOR_VERSION {
1108 return Err(ServiceError::RelationCursorStale {
1109 field: "analysis algorithm version",
1110 });
1111 }
1112 if cursor.binding != *expected {
1113 return Err(ServiceError::RelationCursorMismatched {
1114 field: "analysis query",
1115 });
1116 }
1117 Ok(cursor)
1118}
1119
1120fn encode_analysis_cursor(
1122 relation_cursor: Option<&str>,
1123 finding_offset: u32,
1124 binding: &AnalysisCursorBinding,
1125 snapshot: AnalysisCursorSnapshot,
1126 vcs_digest: Option<[u8; 32]>,
1127 budget: DetailedRelationBudget,
1128) -> ServiceResult<String> {
1129 let encoded = serde_json::to_string(&AnalysisCursor {
1130 version: ANALYSIS_CURSOR_VERSION,
1131 binding: binding.clone(),
1132 snapshot,
1133 relation_cursor: relation_cursor.map(str::to_string),
1134 finding_offset,
1135 vcs_digest,
1136 })?;
1137 if encoded.len() > ANALYSIS_CURSOR_MAX_BYTES
1138 || encoded.len() > budget.intermediate_bytes() as usize
1139 {
1140 return Err(ServiceError::RelationCursorInvalid {
1141 reason: "encoded analysis cursor exceeds the intermediate-state ceiling",
1142 });
1143 }
1144 Ok(encoded)
1145}
1146
1147fn collect_nodes(
1149 report: &DetailedRelationReport,
1150 control: Option<&IndexWorkControl>,
1151) -> ServiceResult<BTreeMap<String, DetailedRelationNode>> {
1152 let mut nodes = BTreeMap::new();
1153 insert_node(&mut nodes, &report.anchor);
1154 for row in &report.rows {
1155 check_control(control)?;
1156 insert_node(&mut nodes, &row.source);
1157 if let Some(target) = &row.target {
1158 insert_node(&mut nodes, target);
1159 }
1160 for node in &row.path {
1161 insert_node(&mut nodes, node);
1162 }
1163 }
1164 Ok(nodes)
1165}
1166
1167fn resolution_gap_findings(
1169 report: &DetailedRelationReport,
1170 control: Option<&IndexWorkControl>,
1171) -> ServiceResult<Vec<AnalysisFinding>> {
1172 let mut findings = Vec::new();
1173 for row in &report.rows {
1174 check_control(control)?;
1175 if matches!(
1176 row.relation.resolution(),
1177 RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
1178 ) {
1179 findings.push(resolution_gap_finding(&row.relation, &row.source));
1180 }
1181 }
1182 Ok(findings)
1183}
1184
1185fn resolution_gap_finding(
1187 relation: &projectatlas_core::graph::LogicalRelation,
1188 source: &DetailedRelationNode,
1189) -> AnalysisFinding {
1190 AnalysisFinding {
1191 kind: AnalysisFindingKind::ResolutionGap,
1192 status: AnalysisStatus::Inconclusive,
1193 summary: "ambiguous or unresolved relation blocks a closed structural conclusion"
1194 .to_string(),
1195 nodes: vec![analysis_node(source)],
1196 metric: None,
1197 evidence: Some(AnalysisRelationEvidence {
1198 relation: relation.clone(),
1199 next_call: relation_gap_next_call(relation, &source.entity),
1200 }),
1201 }
1202}
1203
1204fn resolution_gap_identity(finding: &AnalysisFinding) -> &str {
1206 finding
1207 .evidence
1208 .as_ref()
1209 .map_or("", |evidence| evidence.relation.key().canonical_identity())
1210}
1211
1212fn relation_gap_next_call(
1214 relation: &projectatlas_core::graph::LogicalRelation,
1215 source: &GraphEntity,
1216) -> Option<RelationAnalysisNextCall> {
1217 let anchor = relation_anchor_for_entity(source)?;
1218 let resolution = match relation.resolution() {
1219 RelationResolution::Ambiguous { .. } => RelationResolutionFilter::Ambiguous,
1220 RelationResolution::Unresolved { .. } => RelationResolutionFilter::Unresolved,
1221 RelationResolution::Resolved { .. } => RelationResolutionFilter::Resolved,
1222 RelationResolution::External { .. } => RelationResolutionFilter::External,
1223 };
1224 Some(RelationAnalysisNextCall {
1225 anchor,
1226 direction: RelationDirection::Outbound,
1227 relation: relation.kind(),
1228 resolution,
1229 minimum_confidence: relation.confidence(),
1230 })
1231}
1232
1233fn relation_anchor_for_entity(entity: &GraphEntity) -> Option<RelationAnchor> {
1235 match entity.selector() {
1236 EntitySelector::File { path } => Some(RelationAnchor::File { file: path.clone() }),
1237 EntitySelector::Symbol { symbol } => Some(RelationAnchor::Symbol {
1238 file: symbol.file.clone(),
1239 name: symbol.name.as_str().to_string(),
1240 symbol_kind: Some(symbol.kind),
1241 parent: symbol
1242 .parent
1243 .as_ref()
1244 .map(|parent| parent.as_str().to_string()),
1245 signature: Some(symbol.signature.as_str().to_string()),
1246 }),
1247 EntitySelector::Project
1248 | EntitySelector::Folder { .. }
1249 | EntitySelector::Package { .. }
1250 | EntitySelector::External { .. } => None,
1251 }
1252}
1253
1254fn insert_node(nodes: &mut BTreeMap<String, DetailedRelationNode>, node: &DetailedRelationNode) {
1256 if !matches!(node.entity.selector(), EntitySelector::External { .. }) {
1257 nodes
1258 .entry(node.entity.key().canonical_identity().to_string())
1259 .or_insert_with(|| node.clone());
1260 }
1261}
1262
1263fn collect_report_edges(
1265 report: &DetailedRelationReport,
1266 control: Option<&IndexWorkControl>,
1267) -> ServiceResult<Vec<LocalEdge>> {
1268 let mut edges = Vec::new();
1269 for row in &report.rows {
1270 check_control(control)?;
1271 if let Some(edge) = local_edge(&row.relation, &row.source.entity, row.target.as_ref()) {
1272 edges.push(edge);
1273 }
1274 }
1275 Ok(edges)
1276}
1277
1278fn local_edge(
1280 relation: &projectatlas_core::graph::LogicalRelation,
1281 source: &GraphEntity,
1282 target: Option<&DetailedRelationNode>,
1283) -> Option<LocalEdge> {
1284 let target = target?;
1285 if matches!(target.entity.selector(), EntitySelector::External { .. }) {
1286 return None;
1287 }
1288 Some(LocalEdge {
1289 source: source.key().canonical_identity().to_string(),
1290 target: target.entity.key().canonical_identity().to_string(),
1291 kind: relation.kind(),
1292 complete: relation.completeness() == Completeness::Complete,
1293 })
1294}
1295
1296#[derive(Default)]
1298struct ClosureWork {
1299 complete: bool,
1301 deadline_reached: bool,
1303 induced_scope_closed: bool,
1305 inspected_edges: u32,
1307 decoded_bytes: u64,
1309 resolution_gaps: Vec<AnalysisFinding>,
1311}
1312
1313fn close_induced_edges(
1315 store: &AtlasStore,
1316 query: &RelationAnalysisQuery,
1317 relation_work: &DetailedRelationWork,
1318 deadline: Instant,
1319 nodes: &BTreeMap<String, DetailedRelationNode>,
1320 edges: &mut Vec<LocalEdge>,
1321 control: Option<&IndexWorkControl>,
1322) -> ServiceResult<ClosureWork> {
1323 let mut work = ClosureWork {
1324 complete: true,
1325 induced_scope_closed: query.relations.direction == RelationDirection::Outbound,
1326 ..ClosureWork::default()
1327 };
1328 let mut keys = Vec::with_capacity(nodes.len());
1329 let mut known = BTreeSet::new();
1330 for (key, node) in nodes {
1331 check_control(control)?;
1332 keys.push(node.entity.key().clone());
1333 known.insert(key.clone());
1334 }
1335 #[cfg(test)]
1336 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::Traversal);
1337 check_control(control)?;
1338 for chunk in keys.chunks(MAX_REPOSITORY_GRAPH_FRONTIER) {
1339 let mut continuation: Option<RepositoryGraphAdjacencyContinuation> = None;
1340 loop {
1341 if Instant::now() >= deadline {
1342 work.complete = false;
1343 work.deadline_reached = true;
1344 break;
1345 }
1346 check_control(control)?;
1347 let remaining = query.relations.budget.edges().saturating_sub(
1348 relation_work
1349 .inspected_edges
1350 .saturating_add(work.inspected_edges),
1351 );
1352 if remaining == 0 {
1353 work.complete = false;
1354 break;
1355 }
1356 let page_limit = remaining.min(query.relations.budget.page_rows()).max(1);
1357 let decoded_remaining = query
1358 .relations
1359 .budget
1360 .intermediate_bytes()
1361 .saturating_sub(
1362 relation_work
1363 .intermediate_bytes
1364 .saturating_add(work.decoded_bytes),
1365 )
1366 .min(RepositoryGraphReadBudget::MAX_DECODED_BYTES);
1367 if decoded_remaining == 0 {
1368 work.complete = false;
1369 break;
1370 }
1371 let endpoints = page_limit.saturating_add(1).saturating_mul(2);
1372 let budget = RepositoryGraphReadBudget::new(
1373 u32::try_from(chunk.len()).map_err(|_overflow| {
1374 ServiceError::InvalidInput("analysis frontier overflowed".to_string())
1375 })?,
1376 page_limit,
1377 decoded_remaining,
1378 endpoints,
1379 endpoints,
1380 )
1381 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
1382 let read = store.repository_graph_adjacency_page_filtered_bounded(
1383 chunk,
1384 RepositoryGraphDirection::Outbound,
1385 query.relations.relation,
1386 continuation.as_ref(),
1387 page_limit,
1388 budget,
1389 control,
1390 )?;
1391 work.inspected_edges = work
1392 .inspected_edges
1393 .checked_add(u32::try_from(read.page.rows.len()).map_err(|_overflow| {
1394 ServiceError::InvalidInput("analysis edge work overflowed".to_string())
1395 })?)
1396 .ok_or_else(|| {
1397 ServiceError::InvalidInput("analysis edge work overflowed".to_string())
1398 })?;
1399 work.decoded_bytes = work.decoded_bytes.saturating_add(read.work.decoded_bytes);
1400 for row in read.page.rows {
1401 check_control(control)?;
1402 if !analysis_relation_matches(&row.detail.relation, &query.relations) {
1403 continue;
1404 }
1405 let source_key = row.detail.source.key().canonical_identity().to_string();
1406 if matches!(
1407 row.detail.relation.resolution(),
1408 RelationResolution::Ambiguous { .. } | RelationResolution::Unresolved { .. }
1409 ) {
1410 work.induced_scope_closed = false;
1411 if let Some(source) = nodes.get(&source_key) {
1412 work.resolution_gaps
1413 .push(resolution_gap_finding(&row.detail.relation, source));
1414 }
1415 continue;
1416 }
1417 let Some(target) = row.detail.target else {
1418 work.induced_scope_closed = false;
1419 continue;
1420 };
1421 let target_key = target.key().canonical_identity().to_string();
1422 if known.contains(&source_key) && known.contains(&target_key) {
1423 edges.push(LocalEdge {
1424 source: source_key,
1425 target: target_key,
1426 kind: row.detail.relation.kind(),
1427 complete: row.detail.relation.completeness() == Completeness::Complete,
1428 });
1429 } else {
1430 work.induced_scope_closed = false;
1431 }
1432 }
1433 if read.page.truncated {
1434 continuation = read.page.continuation;
1435 if continuation.is_none() {
1436 return Err(ServiceError::InvalidInput(
1437 "truncated analysis closure omitted its continuation".to_string(),
1438 ));
1439 }
1440 } else {
1441 break;
1442 }
1443 }
1444 if !work.complete {
1445 break;
1446 }
1447 }
1448 edges.sort_by(|left, right| {
1449 (&left.source, &left.target, format!("{:?}", left.kind)).cmp(&(
1450 &right.source,
1451 &right.target,
1452 format!("{:?}", right.kind),
1453 ))
1454 });
1455 edges.dedup_by(|left, right| {
1456 left.source == right.source && left.target == right.target && left.kind == right.kind
1457 });
1458 work.resolution_gaps
1459 .sort_by(|left, right| resolution_gap_identity(left).cmp(resolution_gap_identity(right)));
1460 work.resolution_gaps
1461 .dedup_by(|left, right| resolution_gap_identity(left) == resolution_gap_identity(right));
1462 Ok(work)
1463}
1464
1465fn relation_evidence_complete(
1467 report: &DetailedRelationReport,
1468 nodes: &BTreeMap<String, DetailedRelationNode>,
1469 edges: &[LocalEdge],
1470 query: &RelationAnalysisQuery,
1471 closure: &ClosureWork,
1472) -> bool {
1473 closure.complete
1474 && closure.induced_scope_closed
1475 && report.reached_limits.is_empty()
1476 && query.relations.direction == RelationDirection::Outbound
1477 && edges.iter().all(|edge| edge.complete)
1478 && nodes.values().all(|node| match node.entity.selector() {
1479 EntitySelector::Project => true,
1480 EntitySelector::Folder { .. }
1481 | EntitySelector::File { .. }
1482 | EntitySelector::Package { .. }
1483 | EntitySelector::Symbol { .. } => {
1484 !node.coverage.is_empty()
1485 && node
1486 .coverage
1487 .iter()
1488 .all(|coverage| coverage.state() == CoverageState::Complete)
1489 }
1490 EntitySelector::External { .. } => false,
1491 })
1492}
1493
1494fn dead_code_scope_complete(
1496 report: &DetailedRelationReport,
1497 query: &RelationAnalysisQuery,
1498) -> bool {
1499 let exact_symbol_anchor = matches!(
1500 &query.relations.anchor,
1501 RelationAnchor::Symbol {
1502 symbol_kind: Some(_),
1503 signature: Some(_),
1504 ..
1505 }
1506 );
1507 let exact_total = matches!(
1508 report.total,
1509 RelationTotalState::Exact(total) if total == u64::from(report.returned)
1510 );
1511 query.relations.direction == RelationDirection::Inbound
1512 && query.relations.relation.is_none()
1513 && query.relations.resolution == RelationResolutionFilter::Resolved
1514 && query.relations.minimum_confidence == ConfidenceClass::Low
1515 && exact_symbol_anchor
1516 && exact_total
1517 && !report.truncated
1518 && report.continuation.is_none()
1519 && report.reached_limits.is_empty()
1520 && !report.anchor.coverage.is_empty()
1521 && report
1522 .anchor
1523 .coverage
1524 .iter()
1525 .all(|coverage| coverage.state() == CoverageState::Complete)
1526 && report.rows.iter().all(|row| {
1527 matches!(
1528 row.relation.resolution(),
1529 RelationResolution::Resolved { .. }
1530 ) && row.relation.completeness() == Completeness::Complete
1531 })
1532}
1533
1534fn analysis_relation_matches(
1536 relation: &projectatlas_core::graph::LogicalRelation,
1537 query: &DetailedRelationQuery,
1538) -> bool {
1539 query.relation.is_none_or(|kind| relation.kind() == kind)
1540 && confidence_rank(relation.confidence()) >= confidence_rank(query.minimum_confidence)
1541 && match query.resolution {
1542 RelationResolutionFilter::Any => true,
1543 RelationResolutionFilter::Resolved => {
1544 matches!(relation.resolution(), RelationResolution::Resolved { .. })
1545 }
1546 RelationResolutionFilter::Ambiguous => {
1547 matches!(relation.resolution(), RelationResolution::Ambiguous { .. })
1548 }
1549 RelationResolutionFilter::Unresolved => {
1550 matches!(relation.resolution(), RelationResolution::Unresolved { .. })
1551 }
1552 RelationResolutionFilter::External => {
1553 matches!(relation.resolution(), RelationResolution::External { .. })
1554 }
1555 }
1556}
1557
1558const fn confidence_rank(value: ConfidenceClass) -> u8 {
1560 match value {
1561 ConfidenceClass::Exact => 4,
1562 ConfidenceClass::High => 3,
1563 ConfidenceClass::Medium => 2,
1564 ConfidenceClass::Low => 1,
1565 }
1566}
1567
1568fn architecture_findings(
1570 store: &AtlasStore,
1571 nodes: &BTreeMap<String, DetailedRelationNode>,
1572 edges: &[LocalEdge],
1573 complete: bool,
1574 query: &RelationAnalysisQuery,
1575 symbol_byte_budget: u64,
1576 supplemental_work: &mut SupplementalWork,
1577 control: Option<&IndexWorkControl>,
1578) -> ServiceResult<Vec<AnalysisFinding>> {
1579 let mut findings = structural_findings(
1580 store,
1581 nodes,
1582 edges,
1583 complete,
1584 symbol_byte_budget,
1585 supplemental_work,
1586 control,
1587 )?;
1588 if !complete {
1589 findings.push(AnalysisFinding {
1590 kind: AnalysisFindingKind::Component,
1591 status: AnalysisStatus::Inconclusive,
1592 summary: "architecture candidates are incomplete because traversal or local coverage is partial"
1593 .to_string(),
1594 nodes: analysis_nodes_for(nodes, &nodes.keys().cloned().collect::<Vec<_>>()),
1595 metric: Some(nodes.len() as u64),
1596 evidence: None,
1597 });
1598 }
1599 let components = weak_components(nodes, edges, false);
1600 for component in &components {
1601 findings.push(AnalysisFinding {
1602 kind: AnalysisFindingKind::Component,
1603 status: AnalysisStatus::Candidate,
1604 summary: "component candidate from a weakly connected admitted relation set"
1605 .to_string(),
1606 nodes: analysis_nodes_for(nodes, component),
1607 metric: Some(component.len() as u64),
1608 evidence: None,
1609 });
1610 findings.push(purpose_finding(nodes, component, complete));
1611 }
1612 if query.include_communities {
1613 for community in weak_components(nodes, edges, true) {
1614 findings.push(AnalysisFinding {
1615 kind: AnalysisFindingKind::Community,
1616 status: AnalysisStatus::Candidate,
1617 summary: "relationship-derived community with containment edges excluded"
1618 .to_string(),
1619 nodes: analysis_nodes_for(nodes, &community),
1620 metric: Some(community.len() as u64),
1621 evidence: None,
1622 });
1623 }
1624 }
1625 if query.include_cycles {
1626 let dependency_edges = edges
1627 .iter()
1628 .filter(|edge| dependency_relation(edge.kind))
1629 .cloned()
1630 .collect::<Vec<_>>();
1631 let cycles = strongly_connected_components(nodes, &dependency_edges)
1632 .into_iter()
1633 .filter(|component| {
1634 component.len() > 1
1635 || dependency_edges.iter().any(|edge| {
1636 component.first() == Some(&edge.source) && edge.source == edge.target
1637 })
1638 })
1639 .collect::<Vec<_>>();
1640 if cycles.is_empty() {
1641 findings.push(AnalysisFinding {
1642 kind: AnalysisFindingKind::DependencyCycle,
1643 status: if complete {
1644 AnalysisStatus::Absent
1645 } else {
1646 AnalysisStatus::Inconclusive
1647 },
1648 summary: if complete {
1649 "no dependency cycle exists in the complete admitted bounded scope"
1650 } else {
1651 "no cycle was observed, but traversal or coverage is incomplete"
1652 }
1653 .to_string(),
1654 nodes: Vec::new(),
1655 metric: Some(0),
1656 evidence: None,
1657 });
1658 } else {
1659 for cycle in cycles {
1660 findings.push(AnalysisFinding {
1661 kind: AnalysisFindingKind::DependencyCycle,
1662 status: AnalysisStatus::Candidate,
1663 summary: "iterative dependency-family SCC found a static cycle candidate"
1664 .to_string(),
1665 nodes: analysis_nodes_for(nodes, &cycle),
1666 metric: Some(cycle.len() as u64),
1667 evidence: None,
1668 });
1669 }
1670 }
1671 }
1672 Ok(findings)
1673}
1674
1675fn dependency_relation(kind: GraphRelationKind) -> bool {
1677 matches!(
1678 kind,
1679 GraphRelationKind::Legacy(
1680 RelationKind::Imports | RelationKind::Calls | RelationKind::DependsOn
1681 ) | GraphRelationKind::Extended(
1682 ExtendedRelationKind::Tests
1683 | ExtendedRelationKind::RoutesTo
1684 | ExtendedRelationKind::Configures
1685 )
1686 )
1687}
1688
1689fn purpose_finding(
1691 nodes: &BTreeMap<String, DetailedRelationNode>,
1692 component: &[String],
1693 complete: bool,
1694) -> AnalysisFinding {
1695 let mut purposes = BTreeSet::new();
1696 let mut unavailable = false;
1697 for key in component {
1698 match nodes.get(key).map(|node| &node.purpose) {
1699 Some(RelationPurpose::Approved { purpose, .. }) => {
1700 purposes.insert(purpose.clone());
1701 }
1702 Some(RelationPurpose::Unavailable { .. } | RelationPurpose::NotApplicable) | None => {
1703 unavailable = true;
1704 }
1705 }
1706 }
1707 let (kind, status, summary) = if purposes.len() > 1 {
1708 (
1709 AnalysisFindingKind::PurposeDrift,
1710 AnalysisStatus::Candidate,
1711 "connected owners retain multiple approved purpose responsibilities",
1712 )
1713 } else if purposes.len() == 1 && !unavailable && complete {
1714 (
1715 AnalysisFindingKind::PurposeAlignment,
1716 AnalysisStatus::Confirmed,
1717 "connected owners share one approved purpose responsibility",
1718 )
1719 } else {
1720 (
1721 AnalysisFindingKind::PurposeAlignment,
1722 AnalysisStatus::Inconclusive,
1723 if complete {
1724 "purpose alignment is unavailable for at least one admitted owner"
1725 } else {
1726 "purpose alignment is inconclusive under partial traversal or coverage"
1727 },
1728 )
1729 };
1730 AnalysisFinding {
1731 kind,
1732 status,
1733 summary: summary.to_string(),
1734 nodes: analysis_nodes_for(nodes, component),
1735 metric: Some(purposes.len() as u64),
1736 evidence: None,
1737 }
1738}
1739
1740fn structural_findings(
1742 store: &AtlasStore,
1743 nodes: &BTreeMap<String, DetailedRelationNode>,
1744 edges: &[LocalEdge],
1745 topology_complete: bool,
1746 symbol_byte_budget: u64,
1747 supplemental_work: &mut SupplementalWork,
1748 control: Option<&IndexWorkControl>,
1749) -> ServiceResult<Vec<AnalysisFinding>> {
1750 let degrees = degrees(nodes, edges);
1751 let symbols_by_file = load_admitted_symbols(store, nodes, symbol_byte_budget, control)?;
1752 supplemental_work.hydrated_symbols = supplemental_work
1753 .hydrated_symbols
1754 .saturating_add(symbols_by_file.rows_retained);
1755 supplemental_work.hydrated_symbol_bytes = supplemental_work
1756 .hydrated_symbol_bytes
1757 .saturating_add(symbols_by_file.retained_bytes);
1758 supplemental_work.hydrated_symbol_peak_bytes = supplemental_work
1759 .hydrated_symbol_peak_bytes
1760 .max(symbols_by_file.peak_bytes);
1761 supplemental_work.symbol_hydration_truncated |= !symbols_by_file.complete;
1762 supplemental_work
1763 .reached_limits
1764 .extend(symbols_by_file.reached_limits.iter().copied());
1765 let mut candidates = Vec::new();
1766 for (key, node) in nodes {
1767 check_control(control)?;
1768 let Some((path, name, kind, parent, signature)) = symbol_identity(&node.entity) else {
1769 continue;
1770 };
1771 if let Some(symbol) = symbols_by_file.rows_for_path(path).and_then(|symbols| {
1772 symbols.iter().find(|candidate| {
1773 candidate.name == name
1774 && candidate.kind == kind
1775 && candidate.parent.as_deref() == parent
1776 && candidate.signature == signature
1777 })
1778 }) {
1779 let span = symbol
1780 .line_end
1781 .saturating_sub(symbol.line_start)
1782 .saturating_add(1) as u64;
1783 let degree = degrees.get(key).copied().unwrap_or_default() as u64;
1784 candidates.push((span, degree, key.clone()));
1785 }
1786 }
1787 let symbols_complete = symbols_by_file.complete;
1788 drop(symbols_by_file);
1789 let mut findings = Vec::new();
1790 if let Some((span, _degree, key)) = candidates.iter().max_by(std::cmp::Ord::cmp) {
1791 findings.push(AnalysisFinding {
1792 kind: AnalysisFindingKind::StructuralComplexity,
1793 status: if symbols_complete && topology_complete {
1794 AnalysisStatus::Candidate
1795 } else {
1796 AnalysisStatus::Inconclusive
1797 },
1798 summary: if symbols_complete && topology_complete {
1799 "largest language-valid declaration span in the admitted scope; not cyclomatic complexity"
1800 } else {
1801 "structural candidate observed, but bounded symbol hydration omitted admitted declarations"
1802 }
1803 .to_string(),
1804 nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
1805 metric: Some(*span),
1806 evidence: None,
1807 });
1808 }
1809 if let Some((_span, degree, key)) = candidates
1810 .iter()
1811 .max_by(|left, right| (left.1, left.0, &left.2).cmp(&(right.1, right.0, &right.2)))
1812 {
1813 findings.push(AnalysisFinding {
1814 kind: AnalysisFindingKind::Bottleneck,
1815 status: if symbols_complete && topology_complete {
1816 AnalysisStatus::Candidate
1817 } else {
1818 AnalysisStatus::Inconclusive
1819 },
1820 summary: "highest admitted static fan-in plus fan-out junction".to_string(),
1821 nodes: analysis_nodes_for(nodes, std::slice::from_ref(key)),
1822 metric: Some(*degree),
1823 evidence: None,
1824 });
1825 }
1826 Ok(findings)
1827}
1828
1829struct AdmittedSymbols {
1831 rows: Vec<CodeSymbol>,
1833 ranges: Vec<SymbolPathRange>,
1835 complete: bool,
1837 rows_retained: u32,
1839 retained_bytes: u64,
1841 peak_bytes: u64,
1843 reached_limits: Vec<GraphLimitKind>,
1845}
1846
1847#[derive(Serialize)]
1848struct SymbolPathRange {
1850 path: String,
1852 start: usize,
1854 end: usize,
1856}
1857
1858impl AdmittedSymbols {
1859 fn rows_for_path(&self, path: &str) -> Option<&[CodeSymbol]> {
1861 let index = self
1862 .ranges
1863 .binary_search_by(|range| range.path.as_str().cmp(path))
1864 .ok()?;
1865 let range = &self.ranges[index];
1866 self.rows.get(range.start..range.end)
1867 }
1868}
1869
1870fn load_admitted_symbols(
1872 store: &AtlasStore,
1873 nodes: &BTreeMap<String, DetailedRelationNode>,
1874 byte_budget: u64,
1875 control: Option<&IndexWorkControl>,
1876) -> ServiceResult<AdmittedSymbols> {
1877 let path_limit = usize::try_from(MAX_SYMBOL_BATCH_PATHS).map_err(|source| {
1878 ServiceError::InvalidInput(format!("symbol path ceiling overflowed: {source}"))
1879 })?;
1880 let mut paths = Vec::new();
1881 let mut path_truncated = false;
1882 for node in nodes.values() {
1883 check_control(control)?;
1884 let EntitySelector::Symbol { symbol } = node.entity.selector() else {
1885 continue;
1886 };
1887 let path = symbol.file.as_str();
1888 let Err(index) = paths.binary_search_by(|candidate: &String| candidate.as_str().cmp(path))
1889 else {
1890 continue;
1891 };
1892 paths.insert(index, path.to_string());
1893 if paths.len() > path_limit {
1894 paths.pop();
1895 path_truncated = true;
1896 }
1897 }
1898 let paths = paths.into_boxed_slice().into_vec();
1899 let byte_limit = byte_budget.min(MAX_SYMBOL_BATCH_DECODED_BYTES);
1900 let mut reached_limits = Vec::new();
1901 if path_truncated {
1902 push_limit(&mut reached_limits, GraphLimitKind::Rows);
1903 }
1904 if paths.is_empty() {
1905 return Ok(AdmittedSymbols {
1906 rows: Vec::new(),
1907 ranges: Vec::new(),
1908 complete: !path_truncated,
1909 rows_retained: 0,
1910 retained_bytes: 0,
1911 peak_bytes: symbol_path_request_bytes(&paths, control)?,
1912 reached_limits,
1913 });
1914 }
1915 #[cfg(test)]
1916 analysis_test_observer::notify(analysis_test_observer::AnalysisPhaseEvent::SymbolHydration);
1917 check_control(control)?;
1918 let grouping_reserve = symbol_hydration_reserve_bytes(&paths, control)?;
1919 let decoded_byte_limit = byte_limit.saturating_sub(grouping_reserve);
1920 if decoded_byte_limit == 0 {
1921 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1922 return Ok(AdmittedSymbols {
1923 rows: Vec::new(),
1924 ranges: Vec::new(),
1925 complete: false,
1926 rows_retained: 0,
1927 retained_bytes: 0,
1928 peak_bytes: symbol_path_request_bytes(&paths, control)?,
1929 reached_limits,
1930 });
1931 }
1932 let read = store.load_symbols_for_paths_bounded(
1933 &paths,
1934 SymbolBatchReadBudget::new(
1935 MAX_SYMBOL_BATCH_PATHS,
1936 MAX_SYMBOL_BATCH_ROWS,
1937 decoded_byte_limit.min(MAX_SYMBOL_BATCH_DECODED_BYTES),
1938 )?,
1939 control,
1940 )?;
1941 match read.reached_limit {
1942 Some(SymbolBatchReadLimit::Paths | SymbolBatchReadLimit::Rows) => {
1943 push_limit(&mut reached_limits, GraphLimitKind::Rows);
1944 }
1945 Some(SymbolBatchReadLimit::DecodedBytes) => {
1946 push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
1947 }
1948 None => {}
1949 }
1950 let ranges = symbol_path_ranges(&read.rows, control)?;
1951 let retained_bytes = read
1952 .work
1953 .decoded_bytes
1954 .saturating_add(symbol_range_index_bytes(&ranges, control)?);
1955 let peak_bytes = retained_bytes.saturating_add(symbol_path_request_bytes(&paths, control)?);
1956 Ok(AdmittedSymbols {
1957 rows: read.rows,
1958 ranges,
1959 complete: !path_truncated && !read.truncated,
1960 rows_retained: read.work.returned_rows,
1961 retained_bytes,
1962 peak_bytes,
1963 reached_limits,
1964 })
1965}
1966
1967fn symbol_path_ranges(
1969 rows: &[CodeSymbol],
1970 control: Option<&IndexWorkControl>,
1971) -> ServiceResult<Vec<SymbolPathRange>> {
1972 let mut ranges = Vec::new();
1973 let mut start = 0;
1974 while start < rows.len() {
1975 check_control(control)?;
1976 let mut end = start.saturating_add(1);
1977 while end < rows.len() && rows[end].path == rows[start].path {
1978 check_control(control)?;
1979 end = end.saturating_add(1);
1980 }
1981 ranges.push(SymbolPathRange {
1982 path: rows[start].path.clone(),
1983 start,
1984 end,
1985 });
1986 start = end;
1987 }
1988 Ok(ranges.into_boxed_slice().into_vec())
1989}
1990
1991fn symbol_hydration_reserve_bytes(
1993 paths: &[String],
1994 control: Option<&IndexWorkControl>,
1995) -> ServiceResult<u64> {
1996 let mut ranges = Vec::with_capacity(paths.len());
1997 for path in paths {
1998 check_control(control)?;
1999 ranges.push(SymbolPathRange {
2000 path: path.clone(),
2001 start: 0,
2002 end: 0,
2003 });
2004 }
2005 Ok(symbol_path_request_bytes(paths, control)?
2006 .saturating_add(serialized_bytes_controlled(&ranges, control)?))
2007}
2008
2009fn symbol_path_request_bytes(
2011 paths: &[String],
2012 control: Option<&IndexWorkControl>,
2013) -> ServiceResult<u64> {
2014 serialized_bytes_controlled(paths, control)
2015}
2016
2017fn symbol_range_index_bytes(
2019 ranges: &[SymbolPathRange],
2020 control: Option<&IndexWorkControl>,
2021) -> ServiceResult<u64> {
2022 serialized_bytes_controlled(ranges, control)
2023}
2024
2025struct SerializedByteCounter<'a> {
2027 bytes: u64,
2029 control: Option<&'a IndexWorkControl>,
2031 interrupted: bool,
2033}
2034
2035impl Write for SerializedByteCounter<'_> {
2036 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
2037 if self
2038 .control
2039 .is_some_and(|control| control.check(IndexWorkStage::RepositoryTraversal).is_err())
2040 {
2041 self.interrupted = true;
2042 return Err(io::Error::other("analysis serialization interrupted"));
2046 }
2047 self.bytes = self
2048 .bytes
2049 .checked_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX))
2050 .ok_or_else(|| io::Error::other("analysis serialized byte count overflowed"))?;
2051 Ok(buffer.len())
2052 }
2053
2054 fn flush(&mut self) -> io::Result<()> {
2055 Ok(())
2056 }
2057}
2058
2059fn serialized_bytes_controlled<T: Serialize + ?Sized>(
2061 value: &T,
2062 control: Option<&IndexWorkControl>,
2063) -> ServiceResult<u64> {
2064 let mut counter = SerializedByteCounter {
2065 bytes: 0,
2066 control,
2067 interrupted: false,
2068 };
2069 let result = serde_json::to_writer(&mut counter, value);
2070 if counter.interrupted {
2071 check_control(control)?;
2072 }
2073 result?;
2074 check_control(control)?;
2075 Ok(counter.bytes)
2076}
2077
2078fn trace_findings(
2080 report: &DetailedRelationReport,
2081 target: Option<&RelationAnchor>,
2082 evidence_complete: bool,
2083) -> ServiceResult<Vec<AnalysisFinding>> {
2084 let target = target.ok_or_else(|| {
2085 ServiceError::InvalidInput("analysis trace requires an exact target".to_string())
2086 })?;
2087 let mut matching = BTreeSet::new();
2088 if entity_matches_anchor(&report.anchor.entity, target) {
2089 matching.insert(report.anchor.entity.key().canonical_identity().to_string());
2090 }
2091 for row in &report.rows {
2092 for node in &row.path {
2093 if entity_matches_anchor(&node.entity, target) {
2094 matching.insert(node.entity.key().canonical_identity().to_string());
2095 }
2096 }
2097 }
2098 if matching.len() > 1 {
2099 return Err(ServiceError::InvalidInput(
2100 "analysis trace target is ambiguous in the admitted graph scope".to_string(),
2101 ));
2102 }
2103 let target_key = matching.iter().next();
2104 if target_key.is_some_and(|key| key == report.anchor.entity.key().canonical_identity()) {
2105 return Ok(vec![AnalysisFinding {
2106 kind: AnalysisFindingKind::StaticTrace,
2107 status: AnalysisStatus::Confirmed,
2108 summary: "static trace target is the selected anchor".to_string(),
2109 nodes: vec![analysis_node(&report.anchor)],
2110 metric: Some(0),
2111 evidence: None,
2112 }]);
2113 }
2114 if let Some(row) = target_key.and_then(|target_key| {
2115 report.rows.iter().find(|row| {
2116 row.path
2117 .last()
2118 .is_some_and(|node| node.entity.key().canonical_identity() == target_key.as_str())
2119 })
2120 }) {
2121 return Ok(vec![AnalysisFinding {
2122 kind: AnalysisFindingKind::StaticTrace,
2123 status: AnalysisStatus::Confirmed,
2124 summary: "node-simple static relation path; not a runtime execution trace".to_string(),
2125 nodes: row.path.iter().map(analysis_node).collect(),
2126 metric: Some(u64::from(row.depth)),
2127 evidence: None,
2128 }]);
2129 }
2130 Ok(vec![AnalysisFinding {
2131 kind: AnalysisFindingKind::StaticTrace,
2132 status: if evidence_complete {
2133 AnalysisStatus::Absent
2134 } else {
2135 AnalysisStatus::Inconclusive
2136 },
2137 summary: if evidence_complete {
2138 "target is not reachable in the complete admitted static scope"
2139 } else {
2140 "target was not observed before the bounded traversal or coverage stopped"
2141 }
2142 .to_string(),
2143 nodes: Vec::new(),
2144 metric: None,
2145 evidence: None,
2146 }])
2147}
2148
2149fn weak_components(
2151 nodes: &BTreeMap<String, DetailedRelationNode>,
2152 edges: &[LocalEdge],
2153 exclude_contains: bool,
2154) -> Vec<Vec<String>> {
2155 let mut adjacency = nodes
2156 .keys()
2157 .map(|key| (key.clone(), Vec::new()))
2158 .collect::<BTreeMap<_, _>>();
2159 for edge in edges {
2160 if exclude_contains && edge.kind == GraphRelationKind::Legacy(RelationKind::Contains) {
2161 continue;
2162 }
2163 adjacency
2164 .entry(edge.source.clone())
2165 .or_default()
2166 .push(edge.target.clone());
2167 adjacency
2168 .entry(edge.target.clone())
2169 .or_default()
2170 .push(edge.source.clone());
2171 }
2172 let mut visited = BTreeSet::new();
2173 let mut components = Vec::new();
2174 for start in nodes.keys() {
2175 if !visited.insert(start.clone()) {
2176 continue;
2177 }
2178 let mut queue = VecDeque::from([start.clone()]);
2179 let mut component = Vec::new();
2180 while let Some(node) = queue.pop_front() {
2181 component.push(node.clone());
2182 if let Some(neighbors) = adjacency.get(&node) {
2183 for neighbor in neighbors {
2184 if visited.insert(neighbor.clone()) {
2185 queue.push_back(neighbor.clone());
2186 }
2187 }
2188 }
2189 }
2190 component.sort();
2191 components.push(component);
2192 }
2193 components
2194}
2195
2196fn strongly_connected_components(
2198 nodes: &BTreeMap<String, DetailedRelationNode>,
2199 edges: &[LocalEdge],
2200) -> Vec<Vec<String>> {
2201 let keys = nodes.keys().cloned().collect::<Vec<_>>();
2202 let indices = keys
2203 .iter()
2204 .enumerate()
2205 .map(|(index, key)| (key.clone(), index))
2206 .collect::<BTreeMap<_, _>>();
2207 let mut forward = vec![Vec::new(); keys.len()];
2208 let mut reverse = vec![Vec::new(); keys.len()];
2209 for edge in edges {
2210 let (Some(&source), Some(&target)) = (indices.get(&edge.source), indices.get(&edge.target))
2211 else {
2212 continue;
2213 };
2214 forward[source].push(target);
2215 reverse[target].push(source);
2216 }
2217 let mut seen = vec![false; keys.len()];
2218 let mut order = Vec::new();
2219 for start in 0..keys.len() {
2220 if seen[start] {
2221 continue;
2222 }
2223 let mut stack = vec![(start, false)];
2224 while let Some((node, expanded)) = stack.pop() {
2225 if expanded {
2226 order.push(node);
2227 continue;
2228 }
2229 if seen[node] {
2230 continue;
2231 }
2232 seen[node] = true;
2233 stack.push((node, true));
2234 for &next in forward[node].iter().rev() {
2235 if !seen[next] {
2236 stack.push((next, false));
2237 }
2238 }
2239 }
2240 }
2241 seen.fill(false);
2242 let mut components = Vec::new();
2243 for &start in order.iter().rev() {
2244 if seen[start] {
2245 continue;
2246 }
2247 seen[start] = true;
2248 let mut stack = vec![start];
2249 let mut component = Vec::new();
2250 while let Some(node) = stack.pop() {
2251 component.push(keys[node].clone());
2252 for &next in &reverse[node] {
2253 if !seen[next] {
2254 seen[next] = true;
2255 stack.push(next);
2256 }
2257 }
2258 }
2259 component.sort();
2260 components.push(component);
2261 }
2262 components
2263}
2264
2265fn degrees(
2267 nodes: &BTreeMap<String, DetailedRelationNode>,
2268 edges: &[LocalEdge],
2269) -> BTreeMap<String, usize> {
2270 let mut values = nodes
2271 .keys()
2272 .map(|key| (key.clone(), 0))
2273 .collect::<BTreeMap<_, _>>();
2274 for edge in edges {
2275 *values.entry(edge.source.clone()).or_default() += 1;
2276 *values.entry(edge.target.clone()).or_default() += 1;
2277 }
2278 values
2279}
2280
2281fn usage_indegrees(
2286 nodes: &BTreeMap<String, DetailedRelationNode>,
2287 edges: &[LocalEdge],
2288 control: Option<&IndexWorkControl>,
2289) -> ServiceResult<BTreeMap<String, usize>> {
2290 let mut values = BTreeMap::new();
2291 for key in nodes.keys() {
2292 check_control(control)?;
2293 values.insert(key.clone(), 0);
2294 }
2295 for edge in edges
2296 .iter()
2297 .filter(|edge| edge.kind != GraphRelationKind::Legacy(RelationKind::Contains))
2298 {
2299 check_control(control)?;
2300 *values.entry(edge.target.clone()).or_default() += 1;
2301 }
2302 Ok(values)
2303}
2304
2305fn analysis_nodes_for(
2307 nodes: &BTreeMap<String, DetailedRelationNode>,
2308 keys: &[String],
2309) -> Vec<AnalysisNode> {
2310 keys.iter()
2311 .filter_map(|key| nodes.get(key))
2312 .map(analysis_node)
2313 .collect()
2314}
2315
2316fn analysis_node(node: &DetailedRelationNode) -> AnalysisNode {
2318 let next_call = match node.entity.selector() {
2319 EntitySelector::Folder { path } => Some(RelationNextCall::Files {
2320 folder: path.clone(),
2321 }),
2322 EntitySelector::File { path } => Some(RelationNextCall::Summary { file: path.clone() }),
2323 EntitySelector::Package { package } => Some(RelationNextCall::Summary {
2324 file: package.manifest.clone(),
2325 }),
2326 EntitySelector::Symbol { symbol } => Some(RelationNextCall::SymbolSlice {
2327 symbol: symbol.clone(),
2328 }),
2329 EntitySelector::Project | EntitySelector::External { .. } => None,
2330 };
2331 AnalysisNode {
2332 node: node.clone(),
2333 next_call,
2334 }
2335}
2336
2337fn entity_matches_anchor(entity: &GraphEntity, target: &RelationAnchor) -> bool {
2339 match (entity.selector(), target) {
2340 (EntitySelector::File { path }, RelationAnchor::File { file }) => path == file,
2341 (
2342 EntitySelector::Symbol { symbol },
2343 RelationAnchor::Symbol {
2344 file,
2345 name,
2346 symbol_kind,
2347 parent,
2348 signature,
2349 },
2350 ) => {
2351 symbol.file == *file
2352 && symbol.name.as_str() == name
2353 && symbol_kind.is_none_or(|kind| symbol.kind == kind)
2354 && symbol.parent.as_ref().map(GraphIdentityText::as_str) == parent.as_deref()
2355 && signature
2356 .as_deref()
2357 .is_none_or(|signature| symbol.signature.as_str() == signature)
2358 }
2359 _ => false,
2360 }
2361}
2362
2363fn entity_path(entity: &GraphEntity) -> Option<&str> {
2365 match entity.selector() {
2366 EntitySelector::Folder { path } => Some(path.as_str()),
2367 EntitySelector::File { path } => Some(path.as_str()),
2368 EntitySelector::Package { package } => Some(package.manifest.as_str()),
2369 EntitySelector::Symbol { symbol } => Some(symbol.file.as_str()),
2370 EntitySelector::Project | EntitySelector::External { .. } => None,
2371 }
2372}
2373
2374fn symbol_identity(
2376 entity: &GraphEntity,
2377) -> Option<(
2378 &str,
2379 &str,
2380 projectatlas_core::symbols::SymbolKind,
2381 Option<&str>,
2382 &str,
2383)> {
2384 let EntitySelector::Symbol { symbol } = entity.selector() else {
2385 return None;
2386 };
2387 Some((
2388 symbol.file.as_str(),
2389 symbol.name.as_str(),
2390 symbol.kind,
2391 symbol.parent.as_ref().map(GraphIdentityText::as_str),
2392 symbol.signature.as_str(),
2393 ))
2394}
2395
2396fn analysis_prefix(
2398 report: &RelationAnalysisReport,
2399 rows: usize,
2400 reached_limits: impl IntoIterator<Item = GraphLimitKind>,
2401) -> RelationAnalysisReport {
2402 let mut candidate = report.clone();
2403 if rows < candidate.findings.len() {
2404 candidate.findings.truncate(rows);
2405 candidate.truncated = true;
2406 for limit in reached_limits {
2407 push_limit(&mut candidate.reached_limits, limit);
2408 }
2409 }
2410 candidate.returned = u32::try_from(candidate.findings.len()).unwrap_or(u32::MAX);
2411 candidate.work.rendered_output_bytes = 0;
2412 candidate
2413}
2414
2415fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
2417 if !limits.contains(&limit) {
2418 limits.push(limit);
2419 }
2420}
2421
2422fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
2424 if let Some(control) = control {
2425 control
2426 .check(IndexWorkStage::RepositoryTraversal)
2427 .map_err(DbError::from)?;
2428 }
2429 Ok(())
2430}
2431
2432#[cfg(test)]
2433#[path = "analysis/tests.rs"]
2434mod tests;