1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5
6use code_system_graph_model::{
7 CommunityId, CommunitySnapshot, Edge, Evidence, EvidenceId, NativePath, NativePathEncoding, Node, NodeId, NodeKind, RepoFreshness, RepoFreshnessState, RepoId
8};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::{
14 ChangeSet, ChangeSourceLayer, ChangeValidity, ChangeValidityInput, ChangedFile, ChangedFileStatus, CompatibilityFinding, CompatibilityInput, CompatibilityReport, CompatibilityStatus, ImpactCompatibilityStatus, ImpactContext, ImpactDirection, ImpactError, ImpactOptions, ImpactReport, ImpactRequest, ImpactTarget, RiskLevel, analyze_impact, validate_change_set
15};
16
17pub const CHANGE_ANALYZER_VERSION: &str = "1.0.0";
19
20const MAX_CHANGED_FILES: usize = 100_000;
21const MAX_CHANGED_NODES: usize = 100_000;
22const MAX_IMPACT_TARGETS: usize = 10_000;
23const MAX_DEPTH: usize = 128;
24const MAX_PAGE_LIMIT: usize = 10_000;
25const MAX_PAGE_OFFSET: usize = 1_000_000;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29pub struct ChangeAnalysisOptions {
30 pub max_changed_files: usize,
32 pub max_changed_nodes: usize,
34 pub max_impact_targets: usize,
36 pub direction: ImpactDirection,
38 pub max_depth: usize,
40 pub offset: usize,
42 pub limit: usize,
44 pub summary_only: bool,
46}
47
48impl Default for ChangeAnalysisOptions {
49 fn default() -> Self {
50 Self {
51 max_changed_files: 1_000,
52 max_changed_nodes: 10_000,
53 max_impact_targets: 1_000,
54 direction: ImpactDirection::Upstream,
55 max_depth: 8,
56 offset: 0,
57 limit: 100,
58 summary_only: false,
59 }
60 }
61}
62
63#[derive(
65 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum ChangedPathSide {
69 Old,
71 New,
73}
74
75#[derive(
77 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
78)]
79#[serde(rename_all = "snake_case")]
80pub enum EvidenceMatchKind {
81 HunkIntersection,
83 FilePathFallback,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89pub struct HunkLineMatch {
90 pub evidence_id: EvidenceId,
92 pub path_side: ChangedPathSide,
94 pub kind: EvidenceMatchKind,
96 pub hunk_index: Option<usize>,
98 pub hunk_start: Option<u32>,
100 pub hunk_end: Option<u32>,
102 pub evidence_start: Option<u32>,
104 pub evidence_end: Option<u32>,
106}
107
108#[derive(
110 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
111)]
112#[serde(rename_all = "snake_case")]
113pub enum MappingCompleteness {
114 Complete,
116 Partial,
118 Unknown,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124pub struct ChangedArtifactMapping {
125 pub old_path: Option<NativePath>,
127 pub new_path: Option<NativePath>,
129 pub status: ChangedFileStatus,
131 pub layer: ChangeSourceLayer,
133 pub matched_evidence_ids: Vec<EvidenceId>,
135 pub artifact_node_ids: Vec<NodeId>,
137 pub symbol_ref_node_ids: Vec<NodeId>,
139 pub boundary_node_ids: Vec<NodeId>,
141 pub matches: Vec<HunkLineMatch>,
143 pub completeness: MappingCompleteness,
145 pub gaps: Vec<String>,
147}
148
149#[derive(
151 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
152)]
153#[serde(rename_all = "snake_case")]
154pub enum ChangedEntityRole {
155 Artifact,
157 SymbolRef,
159 Boundary,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165pub struct ChangedEntity {
166 pub node: Node,
168 pub roles: Vec<ChangedEntityRole>,
170 pub changed_paths: Vec<NativePath>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct ContractCompatibilityInput {
177 pub file_path: Option<String>,
179 pub contract_node_id: NodeId,
181 pub report: Option<CompatibilityReport>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187pub struct SemanticContractDelta {
188 pub contract: Node,
190 pub contract_kind: NodeKind,
192 pub status: CompatibilityStatus,
194 pub before_fingerprint: Option<String>,
196 pub after_fingerprint: Option<String>,
198 pub findings: Vec<CompatibilityFinding>,
200 pub factors: Vec<String>,
202 pub validations: Vec<String>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
208pub struct ChangeAnalysisCoverage {
209 pub complete: bool,
211 pub total_changed_files: usize,
213 pub analyzed_changed_files: usize,
215 pub mapped_changed_files: usize,
217 pub complete_changed_files: usize,
219 pub total_changed_nodes: usize,
221 pub retained_changed_nodes: usize,
223 pub total_impact_targets: usize,
225 pub analyzed_impact_targets: usize,
227 pub truncated: bool,
229 pub gaps: Vec<String>,
231}
232
233#[derive(
235 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
236)]
237#[serde(rename_all = "snake_case")]
238pub enum ChangeConclusion {
239 ImpactDetected,
241 NoSemanticImpactDetected,
243 Unknown,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249pub struct ChangeAnalysisSummary {
250 pub conclusion: ChangeConclusion,
252 pub highest_risk: RiskLevel,
254 pub changed_entities: usize,
256 pub changed_contracts: usize,
258 pub impact_reports: usize,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
264pub struct ChangeImpactReport {
265 pub analyzer_version: String,
267 pub analyzer_fingerprint: String,
269 pub change_set: ChangeSet,
271 pub mappings: Vec<ChangedArtifactMapping>,
273 pub changed_entities: Vec<ChangedEntity>,
275 pub contract_deltas: Vec<SemanticContractDelta>,
277 pub impacts: Vec<ImpactReport>,
279 pub touched_repositories: Vec<RepoId>,
281 pub touched_services: Vec<Node>,
283 pub touched_communities: Vec<CommunityId>,
285 pub touched_contracts: Vec<Node>,
287 pub coverage: ChangeAnalysisCoverage,
289 pub summary: ChangeAnalysisSummary,
291}
292
293#[derive(Debug, Error)]
295pub enum ChangeAnalysisError {
296 #[error("change-analysis bounds are outside supported limits")]
298 InvalidBounds,
299 #[error("change-analysis inputs could not be fingerprinted: {0}")]
301 Fingerprint(#[source] serde_json::Error),
302 #[error("impact analysis failed for changed boundary `{target}`: {source}")]
304 Impact {
305 target: String,
307 #[source]
309 source: ImpactError,
310 },
311}
312
313#[derive(Debug)]
314struct MappingWork {
315 mapping: ChangedArtifactMapping,
316 representable_paths: BTreeSet<String>,
317}
318
319#[derive(Debug, Default)]
320struct EntityWork {
321 roles: BTreeSet<ChangedEntityRole>,
322 paths: BTreeSet<NativePath>,
323}
324
325#[expect(
335 clippy::too_many_arguments,
336 reason = "the pure boundary keeps every immutable analysis input explicit"
337)]
338#[expect(
339 clippy::too_many_lines,
340 reason = "the orchestration remains linear so coverage degradation is auditable"
341)]
342#[must_use = "change reports and validation errors must be handled"]
343pub fn analyze_changes(
344 change_set: &ChangeSet,
345 nodes: &[Node],
346 edges: &[Edge],
347 evidence: &[Evidence],
348 community: Option<&CommunitySnapshot>,
349 freshness: &[RepoFreshness],
350 compatibility: &[ContractCompatibilityInput],
351 options: &ChangeAnalysisOptions,
352) -> Result<ChangeImpactReport, ChangeAnalysisError> {
353 validate_options(options)?;
354 let analyzer_fingerprint = analyzer_fingerprint(
355 change_set,
356 nodes,
357 edges,
358 evidence,
359 community,
360 freshness,
361 compatibility,
362 options,
363 )?;
364
365 let node_index = nodes
366 .iter()
367 .map(|node| (node.id.clone(), node))
368 .collect::<BTreeMap<_, _>>();
369 let evidence_index = evidence
370 .iter()
371 .map(|item| (item.id.clone(), item))
372 .collect::<BTreeMap<_, _>>();
373 let mut ordered_files = change_set.files.iter().collect::<Vec<_>>();
374 ordered_files.sort_by(|left, right| changed_file_order(left, right));
375 let total_changed_files = ordered_files.len();
376 ordered_files.truncate(options.max_changed_files);
377
378 let mut mapping_work = ordered_files
379 .into_iter()
380 .map(|file| {
381 map_changed_file(
382 file,
383 &change_set.repo_id,
384 edges,
385 &node_index,
386 &evidence_index,
387 )
388 })
389 .collect::<Vec<_>>();
390 mapping_work.sort_by(|left, right| mapping_order(&left.mapping, &right.mapping));
391
392 let mut coverage_gaps = mapping_work
393 .iter()
394 .flat_map(|work| work.mapping.gaps.iter().cloned())
395 .collect::<BTreeSet<_>>();
396 let mut truncated = total_changed_files > mapping_work.len();
397 if truncated {
398 coverage_gaps.insert(format!(
399 "changed files truncated at {}; {} files were present",
400 options.max_changed_files, total_changed_files
401 ));
402 }
403
404 let all_changed_node_ids = mapping_work
405 .iter()
406 .flat_map(|work| {
407 work.mapping
408 .artifact_node_ids
409 .iter()
410 .chain(&work.mapping.symbol_ref_node_ids)
411 .chain(&work.mapping.boundary_node_ids)
412 .cloned()
413 })
414 .collect::<BTreeSet<_>>();
415 let total_changed_nodes = all_changed_node_ids.len();
416 let retained_node_ids = all_changed_node_ids
417 .iter()
418 .take(options.max_changed_nodes)
419 .cloned()
420 .collect::<BTreeSet<_>>();
421 if total_changed_nodes > retained_node_ids.len() {
422 truncated = true;
423 coverage_gaps.insert(format!(
424 "changed graph nodes truncated at {}; {} nodes were mapped",
425 options.max_changed_nodes, total_changed_nodes
426 ));
427 for work in &mut mapping_work {
428 retain_node_ids(&mut work.mapping, &retained_node_ids);
429 degrade_mapping(
430 &mut work.mapping,
431 "mapped node identities were truncated by the configured node bound",
432 );
433 }
434 }
435
436 let mut entities = build_changed_entities(&mapping_work, &node_index);
437 let changed_entity_count = entities.len();
438 let changed_entities = if options.summary_only {
439 Vec::new()
440 } else {
441 entities
442 .drain(..)
443 .skip(options.offset.min(changed_entity_count))
444 .take(options.limit)
445 .collect()
446 };
447
448 let changed_boundary_ids = mapping_work
449 .iter()
450 .flat_map(|work| work.mapping.boundary_node_ids.iter().cloned())
451 .collect::<BTreeSet<_>>();
452 let total_impact_targets = changed_boundary_ids.len();
453 let impact_target_ids = changed_boundary_ids
454 .iter()
455 .take(options.max_impact_targets)
456 .cloned()
457 .collect::<Vec<_>>();
458 if total_impact_targets > impact_target_ids.len() {
459 truncated = true;
460 coverage_gaps.insert(format!(
461 "impact targets truncated at {}; {} exact changed boundaries were mapped",
462 options.max_impact_targets, total_impact_targets
463 ));
464 }
465
466 let contract_deltas = build_contract_deltas(
467 &changed_boundary_ids,
468 &mapping_work,
469 &node_index,
470 compatibility,
471 );
472 if contract_deltas.iter().any(|delta| {
473 matches!(
474 delta.status,
475 CompatibilityStatus::Unknown | CompatibilityStatus::Incomparable
476 )
477 }) {
478 coverage_gaps.insert(
479 "one or more changed contracts lack complete before/after compatibility data"
480 .to_owned(),
481 );
482 }
483
484 let mapped_changed_files = mapping_work
485 .iter()
486 .filter(|work| !work.mapping.matched_evidence_ids.is_empty())
487 .count();
488 let complete_changed_files = mapping_work
489 .iter()
490 .filter(|work| work.mapping.completeness == MappingCompleteness::Complete)
491 .count();
492 let provisional_complete = !truncated
493 && mapping_work.len() == total_changed_files
494 && complete_changed_files == total_changed_files
495 && !contract_deltas.iter().any(|delta| {
496 matches!(
497 delta.status,
498 CompatibilityStatus::Unknown | CompatibilityStatus::Incomparable
499 )
500 });
501
502 let normalized_freshness = freshness_for_change_set(change_set, freshness);
503 let impact_compatibility = contract_deltas
504 .iter()
505 .map(impact_compatibility_input)
506 .collect::<Vec<_>>();
507 let service_memberships = derive_service_memberships(nodes, edges);
508 let impact_context = ImpactContext {
509 nodes: nodes.to_vec(),
510 edges: edges.to_vec(),
511 communities: community.cloned(),
512 freshness: normalized_freshness,
513 compatibility: impact_compatibility,
514 local_enrichment: Vec::new(),
515 public_contracts: changed_boundary_ids.iter().cloned().collect(),
516 criticality: Vec::new(),
517 centrality: BTreeMap::new(),
518 service_memberships,
519 environments: Vec::new(),
520 recommended_commands: Vec::new(),
521 graph_complete: provisional_complete,
522 coverage_gaps: coverage_gaps.iter().cloned().collect(),
523 };
524 let mut impacts = Vec::with_capacity(impact_target_ids.len());
525 for target in &impact_target_ids {
526 let request = ImpactRequest {
527 target: ImpactTarget::NodeId(target.clone()),
528 direction: options.direction,
529 options: ImpactOptions {
530 max_depth: options.max_depth,
531 node_limit: options.max_changed_nodes,
532 edge_limit: 50_000,
533 confirmed_confidence: 0.8,
534 offset: 0,
535 limit: options.limit,
536 summary_only: options.summary_only,
537 include_depth_buckets: true,
538 },
539 };
540 impacts.push(analyze_impact(&request, &impact_context).map_err(|source| {
541 ChangeAnalysisError::Impact {
542 target: target.as_str().to_owned(),
543 source,
544 }
545 })?);
546 }
547 impacts.sort_by(|left, right| left.target.node.id.cmp(&right.target.node.id));
548
549 for impact in &impacts {
550 coverage_gaps.extend(impact.coverage.gaps.iter().cloned());
551 if impact.truncation.is_some() || !impact.coverage.sufficient_for_score {
552 truncated |= impact.truncation.is_some();
553 }
554 }
555 let complete = provisional_complete
556 && impacts
557 .iter()
558 .all(|impact| impact.coverage.sufficient_for_score && impact.truncation.is_none());
559 let coverage = ChangeAnalysisCoverage {
560 complete,
561 total_changed_files,
562 analyzed_changed_files: mapping_work.len(),
563 mapped_changed_files,
564 complete_changed_files,
565 total_changed_nodes,
566 retained_changed_nodes: retained_node_ids.len(),
567 total_impact_targets,
568 analyzed_impact_targets: impacts.len(),
569 truncated,
570 gaps: coverage_gaps.into_iter().collect(),
571 };
572
573 let mappings = mapping_work
574 .into_iter()
575 .map(|work| work.mapping)
576 .collect::<Vec<_>>();
577 let (touched_repositories, touched_services, touched_communities, touched_contracts) =
578 aggregate_touched(
579 change_set,
580 &retained_node_ids,
581 &node_index,
582 community,
583 &contract_deltas,
584 &impacts,
585 );
586 let summary = summarize(
587 &coverage,
588 changed_entity_count,
589 contract_deltas.len(),
590 &impacts,
591 );
592
593 Ok(ChangeImpactReport {
594 analyzer_version: CHANGE_ANALYZER_VERSION.to_owned(),
595 analyzer_fingerprint,
596 change_set: change_set.clone(),
597 mappings,
598 changed_entities,
599 contract_deltas,
600 impacts,
601 touched_repositories,
602 touched_services,
603 touched_communities,
604 touched_contracts,
605 coverage,
606 summary,
607 })
608}
609
610#[must_use]
612pub fn validate_change_analysis(
613 report: &ChangeImpactReport,
614 current: &ChangeValidityInput,
615) -> ChangeValidity {
616 validate_change_set(&report.change_set, current)
617}
618
619fn validate_options(options: &ChangeAnalysisOptions) -> Result<(), ChangeAnalysisError> {
620 if options.max_changed_files == 0
621 || options.max_changed_files > MAX_CHANGED_FILES
622 || options.max_changed_nodes == 0
623 || options.max_changed_nodes > MAX_CHANGED_NODES
624 || options.max_impact_targets == 0
625 || options.max_impact_targets > MAX_IMPACT_TARGETS
626 || options.max_depth == 0
627 || options.max_depth > MAX_DEPTH
628 || options.limit == 0
629 || options.limit > MAX_PAGE_LIMIT
630 || options.offset > MAX_PAGE_OFFSET
631 {
632 return Err(ChangeAnalysisError::InvalidBounds);
633 }
634 Ok(())
635}
636
637#[expect(
638 clippy::too_many_arguments,
639 reason = "the fingerprint intentionally covers every pure analyzer input"
640)]
641fn analyzer_fingerprint(
642 change_set: &ChangeSet,
643 nodes: &[Node],
644 edges: &[Edge],
645 evidence: &[Evidence],
646 community: Option<&CommunitySnapshot>,
647 freshness: &[RepoFreshness],
648 compatibility: &[ContractCompatibilityInput],
649 options: &ChangeAnalysisOptions,
650) -> Result<String, ChangeAnalysisError> {
651 #[derive(Serialize)]
652 struct Material<'a> {
653 analyzer_version: &'static str,
654 change_set: &'a ChangeSet,
655 nodes: Vec<&'a Node>,
656 edges: Vec<&'a Edge>,
657 evidence: Vec<&'a Evidence>,
658 community: Option<&'a CommunitySnapshot>,
659 freshness: Vec<&'a RepoFreshness>,
660 compatibility: Vec<&'a ContractCompatibilityInput>,
661 options: &'a ChangeAnalysisOptions,
662 }
663
664 let mut ordered_nodes = nodes.iter().collect::<Vec<_>>();
665 ordered_nodes.sort_by_key(|node| &node.id);
666 let mut ordered_edges = edges.iter().collect::<Vec<_>>();
667 ordered_edges.sort_by_key(|edge| &edge.id);
668 let mut ordered_evidence = evidence.iter().collect::<Vec<_>>();
669 ordered_evidence.sort_by_key(|item| &item.id);
670 let mut ordered_freshness = freshness.iter().collect::<Vec<_>>();
671 ordered_freshness.sort_by_key(|item| (&item.repo_id, &item.checkout_id));
672 let mut ordered_compatibility = compatibility.iter().collect::<Vec<_>>();
673 ordered_compatibility.sort_by(|left, right| {
674 (
675 &left.contract_node_id,
676 left.file_path.as_deref().unwrap_or_default(),
677 )
678 .cmp(&(
679 &right.contract_node_id,
680 right.file_path.as_deref().unwrap_or_default(),
681 ))
682 });
683 let encoded = serde_json::to_vec(&Material {
684 analyzer_version: CHANGE_ANALYZER_VERSION,
685 change_set,
686 nodes: ordered_nodes,
687 edges: ordered_edges,
688 evidence: ordered_evidence,
689 community,
690 freshness: ordered_freshness,
691 compatibility: ordered_compatibility,
692 options,
693 })
694 .map_err(ChangeAnalysisError::Fingerprint)?;
695 Ok(blake3::hash(&encoded).to_hex().to_string())
696}
697
698fn changed_file_order(left: &ChangedFile, right: &ChangedFile) -> Ordering {
699 (
700 left.source,
701 left.status,
702 left.old_path.as_ref(),
703 left.new_path.as_ref(),
704 )
705 .cmp(&(
706 right.source,
707 right.status,
708 right.old_path.as_ref(),
709 right.new_path.as_ref(),
710 ))
711}
712
713fn mapping_order(left: &ChangedArtifactMapping, right: &ChangedArtifactMapping) -> Ordering {
714 (
715 left.layer,
716 left.status,
717 left.old_path.as_ref(),
718 left.new_path.as_ref(),
719 )
720 .cmp(&(
721 right.layer,
722 right.status,
723 right.old_path.as_ref(),
724 right.new_path.as_ref(),
725 ))
726}
727
728#[expect(
729 clippy::too_many_lines,
730 reason = "old/new path, line, and graph evidence decisions remain auditable together"
731)]
732fn map_changed_file(
733 file: &ChangedFile,
734 repo_id: &RepoId,
735 edges: &[Edge],
736 nodes: &BTreeMap<NodeId, &Node>,
737 evidence: &BTreeMap<EvidenceId, &Evidence>,
738) -> MappingWork {
739 let mut gaps = BTreeSet::new();
740 let mut matched_evidence = BTreeSet::new();
741 let mut explanations = BTreeSet::new();
742 let mut representable_paths = BTreeSet::new();
743 let sides = relevant_sides(file);
744 let mut unrepresentable = false;
745 let mut matched_sides = BTreeSet::new();
746 let mut line_match = false;
747 let mut fallback_match = false;
748
749 for (side, path) in sides {
750 let Some(path_text) = native_path_text(path) else {
751 unrepresentable = true;
752 gaps.insert(format!(
753 "{} path cannot be represented as Unicode for evidence matching",
754 side_name(side)
755 ));
756 continue;
757 };
758 representable_paths.insert(path_text.clone());
759 for item in evidence.values().copied() {
760 if item.repo_id.as_ref() != Some(repo_id)
761 || item
762 .file_path
763 .as_deref()
764 .is_none_or(|candidate| !same_evidence_path(candidate, &path_text))
765 {
766 continue;
767 }
768 let ranges = hunk_ranges(file, side);
769 let evidence_range = evidence_range(item);
770 let intersections = evidence_range.map_or_else(Vec::new, |range| {
771 ranges
772 .iter()
773 .filter(|(_, hunk_range)| ranges_intersect(*hunk_range, range))
774 .copied()
775 .collect()
776 });
777 if !file.binary
778 && file.status != ChangedFileStatus::Untracked
779 && !intersections.is_empty()
780 {
781 line_match = true;
782 matched_sides.insert(side);
783 matched_evidence.insert(item.id.clone());
784 for (hunk_index, range) in intersections {
785 explanations.insert(match_explanation(
786 item,
787 side,
788 EvidenceMatchKind::HunkIntersection,
789 Some(hunk_index),
790 Some(range),
791 ));
792 }
793 } else if file.binary
794 || file.status == ChangedFileStatus::Untracked
795 || evidence_range.is_none()
796 || ranges.is_empty()
797 {
798 fallback_match = true;
799 matched_sides.insert(side);
800 matched_evidence.insert(item.id.clone());
801 explanations.insert(match_explanation(
802 item,
803 side,
804 EvidenceMatchKind::FilePathFallback,
805 None,
806 None,
807 ));
808 }
809 }
810 }
811
812 if file.binary {
813 gaps.insert("binary change has no line-level semantic evidence".to_owned());
814 }
815 if file.status == ChangedFileStatus::Untracked {
816 gaps.insert("untracked file body and line changes were not collected".to_owned());
817 }
818 if matched_evidence.is_empty() {
819 gaps.insert("no persisted evidence matched the changed path and hunk ranges".to_owned());
820 }
821 if fallback_match {
822 gaps.insert("one or more evidence records matched only at file-path level".to_owned());
823 }
824 if matched_sides.len() < expected_side_count(file) {
825 gaps.insert(
826 "not every required old/new path neighborhood matched persisted evidence".to_owned(),
827 );
828 }
829
830 let (artifact_node_ids, symbol_ref_node_ids, boundary_node_ids) =
831 nodes_for_evidence(&matched_evidence, edges, nodes);
832 if !matched_evidence.is_empty()
833 && artifact_node_ids.is_empty()
834 && symbol_ref_node_ids.is_empty()
835 && boundary_node_ids.is_empty()
836 {
837 gaps.insert(
838 "matched evidence is not attached to a supported semantic graph node".to_owned(),
839 );
840 }
841 if boundary_node_ids.is_empty() {
842 gaps.insert(
843 "no exact changed boundary node was established for impact propagation".to_owned(),
844 );
845 }
846
847 let completeness = if unrepresentable || matched_evidence.is_empty() {
848 MappingCompleteness::Unknown
849 } else if !file.binary
850 && file.status != ChangedFileStatus::Untracked
851 && line_match
852 && !fallback_match
853 && matched_sides.len() == expected_side_count(file)
854 && !boundary_node_ids.is_empty()
855 {
856 MappingCompleteness::Complete
857 } else {
858 MappingCompleteness::Partial
859 };
860
861 MappingWork {
862 mapping: ChangedArtifactMapping {
863 old_path: file.old_path.clone(),
864 new_path: file.new_path.clone(),
865 status: file.status,
866 layer: file.source,
867 matched_evidence_ids: matched_evidence.into_iter().collect(),
868 artifact_node_ids,
869 symbol_ref_node_ids,
870 boundary_node_ids,
871 matches: explanations.into_iter().collect(),
872 completeness,
873 gaps: gaps.into_iter().collect(),
874 },
875 representable_paths,
876 }
877}
878
879fn relevant_sides(file: &ChangedFile) -> Vec<(ChangedPathSide, &NativePath)> {
880 match file.status {
881 ChangedFileStatus::Deleted => file
882 .old_path
883 .as_ref()
884 .map(|path| vec![(ChangedPathSide::Old, path)])
885 .unwrap_or_default(),
886 ChangedFileStatus::Renamed => {
887 let mut sides = Vec::with_capacity(2);
888 if let Some(path) = &file.old_path {
889 sides.push((ChangedPathSide::Old, path));
890 }
891 if let Some(path) = &file.new_path {
892 sides.push((ChangedPathSide::New, path));
893 }
894 sides
895 }
896 _ => file
897 .new_path
898 .as_ref()
899 .or(file.old_path.as_ref())
900 .map(|path| vec![(ChangedPathSide::New, path)])
901 .unwrap_or_default(),
902 }
903}
904
905fn expected_side_count(file: &ChangedFile) -> usize {
906 match file.status {
907 ChangedFileStatus::Renamed => {
908 usize::from(file.old_path.is_some()) + usize::from(file.new_path.is_some())
909 }
910 _ => usize::from(file.old_path.is_some() || file.new_path.is_some()),
911 }
912}
913
914fn side_name(side: ChangedPathSide) -> &'static str {
915 match side {
916 ChangedPathSide::Old => "old",
917 ChangedPathSide::New => "new",
918 }
919}
920
921fn native_path_text(path: &NativePath) -> Option<String> {
922 match path.encoding {
923 NativePathEncoding::UnixBytes | NativePathEncoding::Utf8 => {
924 std::str::from_utf8(&path.bytes).ok().map(ToOwned::to_owned)
925 }
926 NativePathEncoding::WindowsWide => {
927 let (chunks, remainder) = path.bytes.as_chunks::<2>();
928 if !remainder.is_empty() {
929 return None;
930 }
931 let units = chunks.iter().map(|chunk| u16::from_le_bytes(*chunk));
932 char::decode_utf16(units)
933 .collect::<Result<String, _>>()
934 .ok()
935 }
936 }
937}
938
939fn same_evidence_path(left: &str, right: &str) -> bool {
940 left == right
941}
942
943fn hunk_ranges(file: &ChangedFile, side: ChangedPathSide) -> Vec<(usize, (u32, u32))> {
944 file.hunks
945 .iter()
946 .enumerate()
947 .filter_map(|(index, hunk)| {
948 let (start, count) = match side {
949 ChangedPathSide::Old => (hunk.old_start, hunk.old_count),
950 ChangedPathSide::New => (hunk.new_start, hunk.new_count),
951 };
952 inclusive_range(start, count).map(|range| (index, range))
953 })
954 .collect()
955}
956
957fn inclusive_range(start: u32, count: u32) -> Option<(u32, u32)> {
958 (count > 0).then(|| (start, start.saturating_add(count - 1)))
959}
960
961fn evidence_range(evidence: &Evidence) -> Option<(u32, u32)> {
962 match (evidence.start_line, evidence.end_line) {
963 (Some(start), Some(end)) if start <= end => Some((start, end)),
964 _ => None,
965 }
966}
967
968fn ranges_intersect(left: (u32, u32), right: (u32, u32)) -> bool {
969 left.0 <= right.1 && right.0 <= left.1
970}
971
972fn match_explanation(
973 evidence: &Evidence,
974 path_side: ChangedPathSide,
975 kind: EvidenceMatchKind,
976 hunk_index: Option<usize>,
977 hunk_range: Option<(u32, u32)>,
978) -> HunkLineMatch {
979 HunkLineMatch {
980 evidence_id: evidence.id.clone(),
981 path_side,
982 kind,
983 hunk_index,
984 hunk_start: hunk_range.map(|range| range.0),
985 hunk_end: hunk_range.map(|range| range.1),
986 evidence_start: evidence.start_line,
987 evidence_end: evidence.end_line,
988 }
989}
990
991impl PartialOrd for HunkLineMatch {
992 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
993 Some(self.cmp(other))
994 }
995}
996
997impl Ord for HunkLineMatch {
998 fn cmp(&self, other: &Self) -> Ordering {
999 (
1000 &self.evidence_id,
1001 self.path_side,
1002 self.kind,
1003 self.hunk_index,
1004 self.hunk_start,
1005 self.hunk_end,
1006 self.evidence_start,
1007 self.evidence_end,
1008 )
1009 .cmp(&(
1010 &other.evidence_id,
1011 other.path_side,
1012 other.kind,
1013 other.hunk_index,
1014 other.hunk_start,
1015 other.hunk_end,
1016 other.evidence_start,
1017 other.evidence_end,
1018 ))
1019 }
1020}
1021
1022fn nodes_for_evidence(
1023 evidence_ids: &BTreeSet<EvidenceId>,
1024 edges: &[Edge],
1025 nodes: &BTreeMap<NodeId, &Node>,
1026) -> (Vec<NodeId>, Vec<NodeId>, Vec<NodeId>) {
1027 let mut artifact = BTreeSet::new();
1028 let mut symbol = BTreeSet::new();
1029 let mut boundary = BTreeSet::new();
1030 for edge in edges {
1031 if !edge.evidence.iter().any(|id| evidence_ids.contains(id)) {
1032 continue;
1033 }
1034 for id in [&edge.source, &edge.target] {
1035 let Some(node) = nodes.get(id).copied() else {
1036 continue;
1037 };
1038 match node.kind {
1039 NodeKind::Artifact => {
1040 artifact.insert(id.clone());
1041 }
1042 NodeKind::SymbolRef => {
1043 symbol.insert(id.clone());
1044 }
1045 kind if is_boundary_kind(kind) => {
1046 boundary.insert(id.clone());
1047 }
1048 _ => {}
1049 }
1050 }
1051 }
1052 (
1053 artifact.into_iter().collect(),
1054 symbol.into_iter().collect(),
1055 boundary.into_iter().collect(),
1056 )
1057}
1058
1059fn is_boundary_kind(kind: NodeKind) -> bool {
1060 matches!(
1061 kind,
1062 NodeKind::Package
1063 | NodeKind::HttpOperation
1064 | NodeKind::GraphqlOperation
1065 | NodeKind::RpcMethod
1066 | NodeKind::EventChannel
1067 | NodeKind::EventSchema
1068 | NodeKind::Database
1069 | NodeKind::DatabaseTable
1070 | NodeKind::DatabaseColumn
1071 | NodeKind::ConfigKey
1072 )
1073}
1074
1075fn retain_node_ids(mapping: &mut ChangedArtifactMapping, retained: &BTreeSet<NodeId>) {
1076 mapping.artifact_node_ids.retain(|id| retained.contains(id));
1077 mapping
1078 .symbol_ref_node_ids
1079 .retain(|id| retained.contains(id));
1080 mapping.boundary_node_ids.retain(|id| retained.contains(id));
1081}
1082
1083fn degrade_mapping(mapping: &mut ChangedArtifactMapping, gap: &str) {
1084 mapping.completeness = MappingCompleteness::Partial;
1085 mapping.gaps.push(gap.to_owned());
1086 mapping.gaps.sort();
1087 mapping.gaps.dedup();
1088}
1089
1090fn build_changed_entities(
1091 mappings: &[MappingWork],
1092 nodes: &BTreeMap<NodeId, &Node>,
1093) -> Vec<ChangedEntity> {
1094 let mut work = BTreeMap::<NodeId, EntityWork>::new();
1095 for mapping in mappings {
1096 for (role, ids) in [
1097 (
1098 ChangedEntityRole::Artifact,
1099 &mapping.mapping.artifact_node_ids,
1100 ),
1101 (
1102 ChangedEntityRole::SymbolRef,
1103 &mapping.mapping.symbol_ref_node_ids,
1104 ),
1105 (
1106 ChangedEntityRole::Boundary,
1107 &mapping.mapping.boundary_node_ids,
1108 ),
1109 ] {
1110 for id in ids {
1111 let entity = work.entry(id.clone()).or_default();
1112 entity.roles.insert(role);
1113 entity
1114 .paths
1115 .extend(mapping.mapping.old_path.iter().cloned());
1116 entity
1117 .paths
1118 .extend(mapping.mapping.new_path.iter().cloned());
1119 }
1120 }
1121 }
1122 work.into_iter()
1123 .filter_map(|(id, work)| {
1124 nodes.get(&id).map(|node| ChangedEntity {
1125 node: (*node).clone(),
1126 roles: work.roles.into_iter().collect(),
1127 changed_paths: work.paths.into_iter().collect(),
1128 })
1129 })
1130 .collect()
1131}
1132
1133fn build_contract_deltas(
1134 contract_ids: &BTreeSet<NodeId>,
1135 mappings: &[MappingWork],
1136 nodes: &BTreeMap<NodeId, &Node>,
1137 inputs: &[ContractCompatibilityInput],
1138) -> Vec<SemanticContractDelta> {
1139 contract_ids
1140 .iter()
1141 .filter_map(|contract_id| {
1142 let contract = nodes.get(contract_id).copied()?;
1143 let paths = mappings
1144 .iter()
1145 .filter(|work| work.mapping.boundary_node_ids.contains(contract_id))
1146 .flat_map(|work| work.representable_paths.iter().cloned())
1147 .collect::<BTreeSet<_>>();
1148 let matching = inputs
1149 .iter()
1150 .filter(|input| {
1151 input.contract_node_id == *contract_id
1152 && input.file_path.as_ref().is_none_or(|path| {
1153 paths
1154 .iter()
1155 .any(|candidate| same_evidence_path(candidate, path))
1156 })
1157 })
1158 .collect::<Vec<_>>();
1159 Some(merge_contract_delta(contract, &matching))
1160 })
1161 .collect()
1162}
1163
1164fn merge_contract_delta(
1165 contract: &Node,
1166 inputs: &[&ContractCompatibilityInput],
1167) -> SemanticContractDelta {
1168 let reports = inputs
1169 .iter()
1170 .filter_map(|input| input.report.as_ref())
1171 .collect::<Vec<_>>();
1172 let mut status = reports
1173 .iter()
1174 .map(|report| report.status)
1175 .max_by_key(|value| compatibility_severity(*value))
1176 .unwrap_or(CompatibilityStatus::Unknown);
1177 let before = unique_fingerprint(
1178 reports
1179 .iter()
1180 .map(|report| report.before_fingerprint.as_str()),
1181 );
1182 let after = unique_fingerprint(
1183 reports
1184 .iter()
1185 .map(|report| report.after_fingerprint.as_str()),
1186 );
1187 if before.is_none() || after.is_none() {
1188 status = CompatibilityStatus::Unknown;
1189 }
1190 let mut findings = reports
1191 .iter()
1192 .flat_map(|report| report.findings.iter().cloned())
1193 .collect::<Vec<_>>();
1194 findings.sort_by(|left, right| {
1195 (
1196 compatibility_severity(left.status),
1197 &left.code,
1198 &left.path,
1199 &left.factors,
1200 &left.evidence,
1201 )
1202 .cmp(&(
1203 compatibility_severity(right.status),
1204 &right.code,
1205 &right.path,
1206 &right.factors,
1207 &right.evidence,
1208 ))
1209 .reverse()
1210 });
1211 findings.dedup();
1212 let factors = findings
1213 .iter()
1214 .flat_map(|finding| finding.factors.iter().cloned())
1215 .collect::<BTreeSet<_>>()
1216 .into_iter()
1217 .collect();
1218 let validations = findings
1219 .iter()
1220 .flat_map(|finding| finding.recommended_validations.iter().cloned())
1221 .collect::<BTreeSet<_>>()
1222 .into_iter()
1223 .collect();
1224 SemanticContractDelta {
1225 contract: contract.clone(),
1226 contract_kind: contract.kind,
1227 status,
1228 before_fingerprint: before,
1229 after_fingerprint: after,
1230 findings,
1231 factors,
1232 validations,
1233 }
1234}
1235
1236fn compatibility_severity(status: CompatibilityStatus) -> u8 {
1237 match status {
1238 CompatibilityStatus::Compatible => 0,
1239 CompatibilityStatus::Incomparable => 1,
1240 CompatibilityStatus::Unknown => 2,
1241 CompatibilityStatus::PotentiallyBreaking => 3,
1242 CompatibilityStatus::Breaking => 4,
1243 }
1244}
1245
1246fn unique_fingerprint<'a>(values: impl Iterator<Item = &'a str>) -> Option<String> {
1247 let values = values
1248 .filter(|value| !value.is_empty() && *value != "unavailable")
1249 .collect::<BTreeSet<_>>();
1250 (values.len() == 1).then(|| values.into_iter().next().unwrap_or_default().to_owned())
1251}
1252
1253fn impact_compatibility_input(delta: &SemanticContractDelta) -> CompatibilityInput {
1254 CompatibilityInput {
1255 contract_node_id: delta.contract.id.clone(),
1256 status: match delta.status {
1257 CompatibilityStatus::Breaking => ImpactCompatibilityStatus::Breaking,
1258 CompatibilityStatus::PotentiallyBreaking => {
1259 ImpactCompatibilityStatus::PotentiallyBreaking
1260 }
1261 CompatibilityStatus::Compatible => ImpactCompatibilityStatus::Compatible,
1262 CompatibilityStatus::Unknown | CompatibilityStatus::Incomparable => {
1263 ImpactCompatibilityStatus::Unknown
1264 }
1265 },
1266 evidence: delta
1267 .findings
1268 .iter()
1269 .map(|finding| finding.code.clone())
1270 .chain(delta.factors.iter().cloned())
1271 .collect::<BTreeSet<_>>()
1272 .into_iter()
1273 .collect(),
1274 recommended_validations: delta.validations.clone(),
1275 }
1276}
1277
1278fn freshness_for_change_set(
1279 change_set: &ChangeSet,
1280 freshness: &[RepoFreshness],
1281) -> Vec<RepoFreshness> {
1282 let mut normalized = freshness.to_vec();
1283 for item in &mut normalized {
1284 if item.repo_id != change_set.repo_id {
1285 continue;
1286 }
1287 if item.checkout_id != change_set.checkout_id {
1288 item.state = RepoFreshnessState::Unknown;
1289 item.reason =
1290 Some("snapshot checkout does not match the analyzed change set".to_owned());
1291 } else if item.head_commit.as_deref() != Some(change_set.checkout_head_sha.as_str()) {
1292 item.state = RepoFreshnessState::CommitsBehind;
1293 item.reason = Some("snapshot HEAD does not match the analyzed change set".to_owned());
1294 } else if item.manifest_hash != change_set.workspace_manifest_hash {
1295 item.state = RepoFreshnessState::ConfigChanged;
1296 item.reason =
1297 Some("snapshot manifest does not match the analyzed change set".to_owned());
1298 }
1299 }
1300 normalized.sort_by(|left, right| {
1301 (&left.repo_id, &left.checkout_id).cmp(&(&right.repo_id, &right.checkout_id))
1302 });
1303 normalized
1304}
1305
1306fn derive_service_memberships(nodes: &[Node], edges: &[Edge]) -> BTreeMap<NodeId, Vec<NodeId>> {
1307 let kinds = nodes
1308 .iter()
1309 .map(|node| (node.id.clone(), node.kind))
1310 .collect::<BTreeMap<_, _>>();
1311 let mut memberships = BTreeMap::<NodeId, BTreeSet<NodeId>>::new();
1312 for edge in edges {
1313 if edge.kind != code_system_graph_model::EdgeKind::Contains {
1314 continue;
1315 }
1316 match (kinds.get(&edge.source), kinds.get(&edge.target)) {
1317 (Some(NodeKind::Service), Some(_)) => {
1318 memberships
1319 .entry(edge.target.clone())
1320 .or_default()
1321 .insert(edge.source.clone());
1322 }
1323 (Some(_), Some(NodeKind::Service)) => {
1324 memberships
1325 .entry(edge.source.clone())
1326 .or_default()
1327 .insert(edge.target.clone());
1328 }
1329 _ => {}
1330 }
1331 }
1332 memberships
1333 .into_iter()
1334 .map(|(member, services)| (member, services.into_iter().collect()))
1335 .collect()
1336}
1337
1338fn aggregate_touched(
1339 change_set: &ChangeSet,
1340 changed_node_ids: &BTreeSet<NodeId>,
1341 nodes: &BTreeMap<NodeId, &Node>,
1342 community: Option<&CommunitySnapshot>,
1343 deltas: &[SemanticContractDelta],
1344 impacts: &[ImpactReport],
1345) -> (Vec<RepoId>, Vec<Node>, Vec<CommunityId>, Vec<Node>) {
1346 let mut repositories = BTreeSet::from([change_set.repo_id.clone()]);
1347 let mut services = BTreeMap::<NodeId, Node>::new();
1348 let mut communities = BTreeSet::new();
1349 let mut contracts = BTreeMap::<NodeId, Node>::new();
1350 for id in changed_node_ids {
1351 if let Some(node) = nodes.get(id).copied() {
1352 repositories.extend(node.repo_id.iter().cloned());
1353 if node.kind == NodeKind::Service {
1354 services.insert(node.id.clone(), node.clone());
1355 }
1356 if is_boundary_kind(node.kind) {
1357 contracts.insert(node.id.clone(), node.clone());
1358 }
1359 }
1360 }
1361 for delta in deltas {
1362 contracts.insert(delta.contract.id.clone(), delta.contract.clone());
1363 }
1364 for impact in impacts {
1365 repositories.extend(
1366 impact
1367 .affected_repositories
1368 .iter()
1369 .map(|item| item.repo_id.clone()),
1370 );
1371 services.extend(
1372 impact
1373 .affected_services
1374 .iter()
1375 .map(|item| (item.service.id.clone(), item.service.clone())),
1376 );
1377 communities.extend(
1378 impact
1379 .affected_communities
1380 .iter()
1381 .map(|item| item.community_id.clone()),
1382 );
1383 contracts.extend(
1384 impact
1385 .affected_contracts
1386 .iter()
1387 .map(|item| (item.contract.id.clone(), item.contract.clone())),
1388 );
1389 }
1390 if let Some(snapshot) = community {
1391 for item in &snapshot.communities {
1392 if item
1393 .members
1394 .iter()
1395 .any(|member| changed_node_ids.contains(member))
1396 {
1397 communities.insert(item.id.clone());
1398 }
1399 }
1400 }
1401 (
1402 repositories.into_iter().collect(),
1403 services.into_values().collect(),
1404 communities.into_iter().collect(),
1405 contracts.into_values().collect(),
1406 )
1407}
1408
1409fn summarize(
1410 coverage: &ChangeAnalysisCoverage,
1411 changed_entities: usize,
1412 changed_contracts: usize,
1413 impacts: &[ImpactReport],
1414) -> ChangeAnalysisSummary {
1415 let highest_risk = if coverage.complete {
1416 impacts
1417 .iter()
1418 .map(|impact| impact.risk)
1419 .max()
1420 .unwrap_or(RiskLevel::Low)
1421 } else {
1422 RiskLevel::Unknown
1423 };
1424 let conclusion = if !coverage.complete {
1425 ChangeConclusion::Unknown
1426 } else if changed_entities > 0 || changed_contracts > 0 || !impacts.is_empty() {
1427 ChangeConclusion::ImpactDetected
1428 } else {
1429 ChangeConclusion::NoSemanticImpactDetected
1430 };
1431 ChangeAnalysisSummary {
1432 conclusion,
1433 highest_risk,
1434 changed_entities,
1435 changed_contracts,
1436 impact_reports: impacts.len(),
1437 }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use std::collections::BTreeMap;
1443
1444 use code_system_graph_model::{
1445 CheckoutId, Community, CommunityAlgorithm, CommunityConfig, CommunityId, CommunityMetrics, CommunityScope, EdgeId, EdgeKind, EpistemicStatus, NativePathEncoding, Provenance
1446 };
1447
1448 use super::*;
1449 use crate::{ChangeHunk, ChangeScope};
1450
1451 type Fixture = (
1452 ChangeSet,
1453 Vec<Node>,
1454 Vec<Edge>,
1455 Vec<Evidence>,
1456 Vec<RepoFreshness>,
1457 Vec<ContractCompatibilityInput>,
1458 );
1459
1460 fn path(value: &str) -> NativePath {
1461 NativePath {
1462 encoding: NativePathEncoding::Utf8,
1463 bytes: value.as_bytes().to_vec(),
1464 display: value.to_owned(),
1465 }
1466 }
1467
1468 fn hunk(start: u32, count: u32) -> ChangeHunk {
1469 ChangeHunk {
1470 old_start: start,
1471 old_count: count,
1472 new_start: start,
1473 new_count: count,
1474 lines: Vec::new(),
1475 }
1476 }
1477
1478 fn file(status: ChangedFileStatus, value: &str) -> ChangedFile {
1479 ChangedFile {
1480 status,
1481 old_path: (status != ChangedFileStatus::Added
1482 && status != ChangedFileStatus::Untracked)
1483 .then(|| path(value)),
1484 new_path: (status != ChangedFileStatus::Deleted).then(|| path(value)),
1485 binary: false,
1486 hunks: vec![hunk(10, 2)],
1487 source: if status == ChangedFileStatus::Untracked {
1488 ChangeSourceLayer::Untracked
1489 } else {
1490 ChangeSourceLayer::Worktree
1491 },
1492 }
1493 }
1494
1495 fn rename(old: &str, new: &str) -> ChangedFile {
1496 ChangedFile {
1497 status: ChangedFileStatus::Renamed,
1498 old_path: Some(path(old)),
1499 new_path: Some(path(new)),
1500 binary: false,
1501 hunks: vec![hunk(10, 2)],
1502 source: ChangeSourceLayer::Commit,
1503 }
1504 }
1505
1506 fn change_set(files: Vec<ChangedFile>) -> ChangeSet {
1507 ChangeSet {
1508 repo_id: RepoId::new("repo:api"),
1509 checkout_id: CheckoutId::new("checkout:repo:api"),
1510 worktree: path("/workspace/api"),
1511 git_common_dir: path("/workspace/api/.git"),
1512 scope: ChangeScope::All,
1513 checkout_head_ref: Some("refs/heads/main".to_owned()),
1514 checkout_head_sha: "head".to_owned(),
1515 base_ref: Some("main".to_owned()),
1516 head_ref: Some("HEAD".to_owned()),
1517 head_sha: "head".to_owned(),
1518 staged_hash: "staged".to_owned(),
1519 worktree_hash: "worktree".to_owned(),
1520 exact_diff_fingerprint: "diff".to_owned(),
1521 workspace_manifest_hash: "manifest".to_owned(),
1522 contract_registry_hash: "registry".to_owned(),
1523 analyzer_versions: BTreeMap::from([("extractor".to_owned(), "1.0.0".to_owned())]),
1524 files,
1525 }
1526 }
1527
1528 fn node(id: &str, kind: NodeKind, repository: &str) -> Node {
1529 Node {
1530 id: NodeId::new(id),
1531 kind,
1532 repo_id: Some(RepoId::new(repository)),
1533 stable_key: id.to_owned(),
1534 label: id.to_owned(),
1535 }
1536 }
1537
1538 fn evidence(id: &str, file_path: &str, start: Option<u32>, end: Option<u32>) -> Evidence {
1539 Evidence {
1540 id: EvidenceId::new(id),
1541 repo_id: Some(RepoId::new("repo:api")),
1542 file_path: Some(file_path.to_owned()),
1543 start_line: start,
1544 end_line: end,
1545 extractor: "test".to_owned(),
1546 extractor_version: "1.0.0".to_owned(),
1547 provenance: Provenance::Extracted,
1548 confidence: 1.0,
1549 observed_at_commit: Some("head".to_owned()),
1550 content_hash: Some("content".to_owned()),
1551 note: None,
1552 }
1553 }
1554
1555 fn edge(id: &str, source: &str, target: &str, evidence_id: &str) -> Edge {
1556 Edge {
1557 id: EdgeId::new(id),
1558 source: NodeId::new(source),
1559 target: NodeId::new(target),
1560 kind: EdgeKind::Consumes,
1561 confidence: 1.0,
1562 status: EpistemicStatus::Confirmed,
1563 evidence: vec![EvidenceId::new(evidence_id)],
1564 }
1565 }
1566
1567 fn freshness(repository: &str) -> RepoFreshness {
1568 RepoFreshness {
1569 repo_id: RepoId::new(repository),
1570 checkout_id: CheckoutId::new(format!("checkout:{repository}")),
1571 head_commit: Some("head".to_owned()),
1572 manifest_hash: "manifest".to_owned(),
1573 state: RepoFreshnessState::Fresh,
1574 reason: None,
1575 }
1576 }
1577
1578 fn finding(status: CompatibilityStatus) -> CompatibilityFinding {
1579 CompatibilityFinding {
1580 code: "http.operation_removed".to_owned(),
1581 path: "GET /orders".to_owned(),
1582 status,
1583 factors: vec!["operation was removed".to_owned()],
1584 evidence: vec!["line:10".to_owned()],
1585 recommended_validations: vec!["run contract tests".to_owned()],
1586 }
1587 }
1588
1589 fn compatibility(status: CompatibilityStatus) -> ContractCompatibilityInput {
1590 ContractCompatibilityInput {
1591 file_path: Some("src/api.rs".to_owned()),
1592 contract_node_id: NodeId::new("contract"),
1593 report: Some(CompatibilityReport {
1594 status,
1595 before_fingerprint: "before".to_owned(),
1596 after_fingerprint: "after".to_owned(),
1597 findings: vec![finding(status)],
1598 }),
1599 }
1600 }
1601
1602 fn complete_fixture() -> Fixture {
1603 (
1604 change_set(vec![file(ChangedFileStatus::Modified, "src/api.rs")]),
1605 vec![
1606 node("contract", NodeKind::HttpOperation, "repo:api"),
1607 node("direct", NodeKind::Service, "repo:web"),
1608 node("transitive", NodeKind::TestCase, "repo:test"),
1609 ],
1610 vec![
1611 edge("edge:direct", "direct", "contract", "ev"),
1612 edge("edge:transitive", "transitive", "direct", "other"),
1613 ],
1614 vec![evidence("ev", "src/api.rs", Some(10), Some(11))],
1615 vec![
1616 freshness("repo:api"),
1617 freshness("repo:web"),
1618 freshness("repo:test"),
1619 ],
1620 vec![compatibility(CompatibilityStatus::Breaking)],
1621 )
1622 }
1623
1624 fn analyze_fixture(
1625 options: &ChangeAnalysisOptions,
1626 ) -> Result<ChangeImpactReport, ChangeAnalysisError> {
1627 let (changes, nodes, edges, evidence, freshness, compatibility) = complete_fixture();
1628 analyze_changes(
1629 &changes,
1630 &nodes,
1631 &edges,
1632 &evidence,
1633 None,
1634 &freshness,
1635 &compatibility,
1636 options,
1637 )
1638 }
1639
1640 #[test]
1641 fn mapping_should_intersect_exact_hunk_lines() {
1642 let changed = file(ChangedFileStatus::Modified, "src/api.rs");
1643 let nodes = BTreeMap::from([
1644 (
1645 NodeId::new("contract"),
1646 node("contract", NodeKind::HttpOperation, "repo:api"),
1647 ),
1648 (
1649 NodeId::new("consumer"),
1650 node("consumer", NodeKind::Service, "repo:web"),
1651 ),
1652 ]);
1653 let evidence = BTreeMap::from([(
1654 EvidenceId::new("ev"),
1655 evidence("ev", "src/api.rs", Some(11), Some(12)),
1656 )]);
1657 let node_refs = nodes
1658 .iter()
1659 .map(|(id, item)| (id.clone(), item))
1660 .collect::<BTreeMap<_, _>>();
1661 let evidence_refs = evidence
1662 .iter()
1663 .map(|(id, item)| (id.clone(), item))
1664 .collect::<BTreeMap<_, _>>();
1665
1666 let result = map_changed_file(
1667 &changed,
1668 &RepoId::new("repo:api"),
1669 &[edge("edge", "consumer", "contract", "ev")],
1670 &node_refs,
1671 &evidence_refs,
1672 );
1673
1674 assert_eq!(result.mapping.completeness, MappingCompleteness::Complete);
1675 }
1676
1677 #[test]
1678 fn mapping_should_exclude_evidence_outside_hunks() {
1679 let changed = file(ChangedFileStatus::Modified, "src/api.rs");
1680 let stored = evidence("ev", "src/api.rs", Some(30), Some(31));
1681 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1682
1683 let result = map_changed_file(
1684 &changed,
1685 &RepoId::new("repo:api"),
1686 &[],
1687 &BTreeMap::new(),
1688 &evidence_refs,
1689 );
1690
1691 assert!(result.mapping.matched_evidence_ids.is_empty());
1692 }
1693
1694 #[test]
1695 fn mapping_should_not_normalize_distinct_native_path_bytes() {
1696 let changed = file(ChangedFileStatus::Modified, "src\\api.rs");
1697 let stored = evidence("ev", "src/api.rs", Some(10), Some(11));
1698 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1699
1700 let result = map_changed_file(
1701 &changed,
1702 &RepoId::new("repo:api"),
1703 &[],
1704 &BTreeMap::new(),
1705 &evidence_refs,
1706 );
1707
1708 assert!(result.mapping.matched_evidence_ids.is_empty());
1709 }
1710
1711 #[test]
1712 fn mapping_should_use_file_fallback_without_line_evidence() {
1713 let changed = file(ChangedFileStatus::Modified, "src/api.rs");
1714 let stored = evidence("ev", "src/api.rs", None, None);
1715 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1716
1717 let result = map_changed_file(
1718 &changed,
1719 &RepoId::new("repo:api"),
1720 &[],
1721 &BTreeMap::new(),
1722 &evidence_refs,
1723 );
1724
1725 assert!(matches!(
1726 result.mapping.matches.as_slice(),
1727 [HunkLineMatch {
1728 kind: EvidenceMatchKind::FilePathFallback,
1729 ..
1730 }]
1731 ));
1732 }
1733
1734 #[test]
1735 fn rename_should_include_old_and_new_neighborhoods() {
1736 let changed = rename("src/old.rs", "src/new.rs");
1737 let old = evidence("old", "src/old.rs", Some(10), Some(10));
1738 let new = evidence("new", "src/new.rs", Some(10), Some(10));
1739 let evidence_refs = BTreeMap::from([(old.id.clone(), &old), (new.id.clone(), &new)]);
1740
1741 let result = map_changed_file(
1742 &changed,
1743 &RepoId::new("repo:api"),
1744 &[],
1745 &BTreeMap::new(),
1746 &evidence_refs,
1747 );
1748
1749 assert_eq!(result.mapping.matched_evidence_ids.len(), 2);
1750 }
1751
1752 #[test]
1753 fn deletion_should_map_old_path_and_old_hunk() {
1754 let changed = file(ChangedFileStatus::Deleted, "src/old.rs");
1755 let old = evidence("old", "src/old.rs", Some(10), Some(10));
1756 let evidence_refs = BTreeMap::from([(old.id.clone(), &old)]);
1757
1758 let result = map_changed_file(
1759 &changed,
1760 &RepoId::new("repo:api"),
1761 &[],
1762 &BTreeMap::new(),
1763 &evidence_refs,
1764 );
1765
1766 assert_eq!(result.mapping.matches[0].path_side, ChangedPathSide::Old);
1767 }
1768
1769 #[test]
1770 fn binary_change_should_remain_incomplete() {
1771 let mut changed = file(ChangedFileStatus::Modified, "asset.bin");
1772 changed.binary = true;
1773 let stored = evidence("ev", "asset.bin", Some(10), Some(10));
1774 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1775
1776 let result = map_changed_file(
1777 &changed,
1778 &RepoId::new("repo:api"),
1779 &[],
1780 &BTreeMap::new(),
1781 &evidence_refs,
1782 );
1783
1784 assert_ne!(result.mapping.completeness, MappingCompleteness::Complete);
1785 }
1786
1787 #[test]
1788 fn untracked_change_should_remain_incomplete() {
1789 let changed = file(ChangedFileStatus::Untracked, "src/new.rs");
1790 let stored = evidence("ev", "src/new.rs", None, None);
1791 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1792
1793 let result = map_changed_file(
1794 &changed,
1795 &RepoId::new("repo:api"),
1796 &[],
1797 &BTreeMap::new(),
1798 &evidence_refs,
1799 );
1800
1801 assert_eq!(result.mapping.completeness, MappingCompleteness::Partial);
1802 }
1803
1804 #[test]
1805 fn non_unicode_native_path_should_be_unknown() {
1806 let mut changed = file(ChangedFileStatus::Modified, "ignored");
1807 changed.new_path = Some(NativePath {
1808 encoding: NativePathEncoding::UnixBytes,
1809 bytes: vec![0xff],
1810 display: "�".to_owned(),
1811 });
1812
1813 let result = map_changed_file(
1814 &changed,
1815 &RepoId::new("repo:api"),
1816 &[],
1817 &BTreeMap::new(),
1818 &BTreeMap::new(),
1819 );
1820
1821 assert_eq!(result.mapping.completeness, MappingCompleteness::Unknown);
1822 }
1823
1824 #[test]
1825 fn edge_evidence_should_map_artifact_symbol_and_boundary_nodes() {
1826 let changed = file(ChangedFileStatus::Modified, "src/api.rs");
1827 let node_values = [
1828 node("artifact", NodeKind::Artifact, "repo:api"),
1829 node("symbol", NodeKind::SymbolRef, "repo:api"),
1830 node("contract", NodeKind::HttpOperation, "repo:api"),
1831 ];
1832 let nodes = node_values
1833 .iter()
1834 .map(|item| (item.id.clone(), item))
1835 .collect::<BTreeMap<_, _>>();
1836 let stored = evidence("ev", "src/api.rs", Some(10), Some(10));
1837 let evidence_refs = BTreeMap::from([(stored.id.clone(), &stored)]);
1838 let edges = vec![
1839 edge("one", "artifact", "symbol", "ev"),
1840 edge("two", "symbol", "contract", "ev"),
1841 ];
1842
1843 let result = map_changed_file(
1844 &changed,
1845 &RepoId::new("repo:api"),
1846 &edges,
1847 &nodes,
1848 &evidence_refs,
1849 );
1850
1851 assert!(
1852 result.mapping.artifact_node_ids == vec![NodeId::new("artifact")]
1853 && result.mapping.symbol_ref_node_ids == vec![NodeId::new("symbol")]
1854 && result.mapping.boundary_node_ids == vec![NodeId::new("contract")]
1855 );
1856 }
1857
1858 #[test]
1859 fn breaking_delta_should_propagate_direct_and_transitive_impact()
1860 -> Result<(), ChangeAnalysisError> {
1861 let result = analyze_fixture(&ChangeAnalysisOptions::default())?;
1862
1863 assert!(
1864 result.contract_deltas[0].status == CompatibilityStatus::Breaking
1865 && result.impacts[0]
1866 .direct_consumers
1867 .iter()
1868 .any(|item| item.node.id == NodeId::new("direct"))
1869 && result.impacts[0]
1870 .transitive_consumers
1871 .iter()
1872 .any(|item| item.node.id == NodeId::new("transitive"))
1873 );
1874 Ok(())
1875 }
1876
1877 #[test]
1878 fn changed_nodes_should_aggregate_community() -> Result<(), ChangeAnalysisError> {
1879 let (changes, nodes, edges, evidence, freshness, compatibility) = complete_fixture();
1880 let community = CommunitySnapshot {
1881 snapshot_id: "snapshot".to_owned(),
1882 engine_version: "1.0.0".to_owned(),
1883 config: CommunityConfig {
1884 algorithm: CommunityAlgorithm::ConnectedComponents,
1885 scope: CommunityScope::Federated,
1886 seed: 0,
1887 resolution: 1.0,
1888 minimum_confidence: 0.8,
1889 edge_weights: Vec::new(),
1890 max_iterations: 10,
1891 },
1892 communities: vec![Community {
1893 id: CommunityId::new("community"),
1894 label: "orders".to_owned(),
1895 members: vec![NodeId::new("contract"), NodeId::new("direct")],
1896 central_nodes: vec![NodeId::new("contract")],
1897 repositories: vec![RepoId::new("repo:api"), RepoId::new("repo:web")],
1898 services: vec![NodeId::new("direct")],
1899 inbound_contracts: Vec::new(),
1900 outbound_contracts: vec![NodeId::new("contract")],
1901 metrics: CommunityMetrics {
1902 size: 2,
1903 density: 1.0,
1904 cohesion: 1.0,
1905 coupling: 0.0,
1906 cross_community_edges: 0,
1907 },
1908 label_evidence: Vec::new(),
1909 limitations: Vec::new(),
1910 }],
1911 };
1912
1913 let result = analyze_changes(
1914 &changes,
1915 &nodes,
1916 &edges,
1917 &evidence,
1918 Some(&community),
1919 &freshness,
1920 &compatibility,
1921 &ChangeAnalysisOptions::default(),
1922 )?;
1923
1924 assert_eq!(
1925 result.touched_communities,
1926 vec![CommunityId::new("community")]
1927 );
1928 Ok(())
1929 }
1930
1931 #[test]
1932 fn shuffled_inputs_should_produce_same_fingerprint_and_output()
1933 -> Result<(), ChangeAnalysisError> {
1934 let (changes, mut nodes, mut edges, mut evidence, mut freshness, compatibility) =
1935 complete_fixture();
1936 let first = analyze_changes(
1937 &changes,
1938 &nodes,
1939 &edges,
1940 &evidence,
1941 None,
1942 &freshness,
1943 &compatibility,
1944 &ChangeAnalysisOptions::default(),
1945 )?;
1946 nodes.reverse();
1947 edges.reverse();
1948 evidence.reverse();
1949 freshness.reverse();
1950 let second = analyze_changes(
1951 &changes,
1952 &nodes,
1953 &edges,
1954 &evidence,
1955 None,
1956 &freshness,
1957 &compatibility,
1958 &ChangeAnalysisOptions::default(),
1959 )?;
1960
1961 assert!(
1962 first.analyzer_fingerprint == second.analyzer_fingerprint
1963 && first.mappings == second.mappings
1964 && first.impacts == second.impacts
1965 );
1966 Ok(())
1967 }
1968
1969 #[test]
1970 fn changed_file_limit_should_force_unknown_coverage() -> Result<(), ChangeAnalysisError> {
1971 let (mut changes, nodes, edges, evidence, freshness, compatibility) = complete_fixture();
1972 changes
1973 .files
1974 .push(file(ChangedFileStatus::Modified, "src/other.rs"));
1975 let options = ChangeAnalysisOptions {
1976 max_changed_files: 1,
1977 ..ChangeAnalysisOptions::default()
1978 };
1979
1980 let result = analyze_changes(
1981 &changes,
1982 &nodes,
1983 &edges,
1984 &evidence,
1985 None,
1986 &freshness,
1987 &compatibility,
1988 &options,
1989 )?;
1990
1991 assert!(
1992 result.coverage.truncated
1993 && result.summary.conclusion == ChangeConclusion::Unknown
1994 && result.summary.highest_risk == RiskLevel::Unknown
1995 );
1996 Ok(())
1997 }
1998
1999 #[test]
2000 fn changed_node_limit_should_bound_entities() -> Result<(), ChangeAnalysisError> {
2001 let (changes, mut nodes, mut edges, evidence, freshness, compatibility) =
2002 complete_fixture();
2003 nodes.push(node("artifact", NodeKind::Artifact, "repo:api"));
2004 edges.push(edge("edge:artifact", "artifact", "contract", "ev"));
2005 let options = ChangeAnalysisOptions {
2006 max_changed_nodes: 1,
2007 ..ChangeAnalysisOptions::default()
2008 };
2009
2010 let result = analyze_changes(
2011 &changes,
2012 &nodes,
2013 &edges,
2014 &evidence,
2015 None,
2016 &freshness,
2017 &compatibility,
2018 &options,
2019 )?;
2020
2021 assert!(
2022 result.coverage.retained_changed_nodes == 1
2023 && result.coverage.total_changed_nodes == 2
2024 && result.coverage.truncated
2025 );
2026 Ok(())
2027 }
2028
2029 #[test]
2030 fn impact_target_limit_should_bound_reports() -> Result<(), ChangeAnalysisError> {
2031 let (changes, mut nodes, mut edges, evidence, freshness, mut compatibility) =
2032 complete_fixture();
2033 nodes.push(node("contract-two", NodeKind::RpcMethod, "repo:api"));
2034 edges.push(edge("edge:two", "direct", "contract-two", "ev"));
2035 compatibility.push(ContractCompatibilityInput {
2036 file_path: Some("src/api.rs".to_owned()),
2037 contract_node_id: NodeId::new("contract-two"),
2038 report: Some(CompatibilityReport {
2039 status: CompatibilityStatus::Compatible,
2040 before_fingerprint: "before-two".to_owned(),
2041 after_fingerprint: "after-two".to_owned(),
2042 findings: Vec::new(),
2043 }),
2044 });
2045 let options = ChangeAnalysisOptions {
2046 max_impact_targets: 1,
2047 ..ChangeAnalysisOptions::default()
2048 };
2049
2050 let result = analyze_changes(
2051 &changes,
2052 &nodes,
2053 &edges,
2054 &evidence,
2055 None,
2056 &freshness,
2057 &compatibility,
2058 &options,
2059 )?;
2060
2061 assert!(
2062 result.impacts.len() == 1
2063 && result.coverage.total_impact_targets == 2
2064 && result.coverage.truncated
2065 );
2066 Ok(())
2067 }
2068
2069 #[test]
2070 fn entity_pagination_should_follow_stable_node_order() -> Result<(), ChangeAnalysisError> {
2071 let (changes, mut nodes, mut edges, evidence, freshness, compatibility) =
2072 complete_fixture();
2073 nodes.push(node("artifact", NodeKind::Artifact, "repo:api"));
2074 nodes.push(node("symbol", NodeKind::SymbolRef, "repo:api"));
2075 edges.push(edge("edge:artifact", "artifact", "symbol", "ev"));
2076 edges.push(edge("edge:symbol", "symbol", "contract", "ev"));
2077 let options = ChangeAnalysisOptions {
2078 offset: 1,
2079 limit: 1,
2080 ..ChangeAnalysisOptions::default()
2081 };
2082
2083 let result = analyze_changes(
2084 &changes,
2085 &nodes,
2086 &edges,
2087 &evidence,
2088 None,
2089 &freshness,
2090 &compatibility,
2091 &options,
2092 )?;
2093
2094 assert_eq!(result.changed_entities[0].node.id, NodeId::new("contract"));
2095 Ok(())
2096 }
2097
2098 #[test]
2099 fn summary_only_should_omit_detailed_entities_and_impact_items()
2100 -> Result<(), ChangeAnalysisError> {
2101 let options = ChangeAnalysisOptions {
2102 summary_only: true,
2103 ..ChangeAnalysisOptions::default()
2104 };
2105
2106 let result = analyze_fixture(&options)?;
2107
2108 assert!(
2109 result.changed_entities.is_empty()
2110 && result.impacts[0].direct_consumers.is_empty()
2111 && result.summary.impact_reports == 1
2112 );
2113 Ok(())
2114 }
2115
2116 #[test]
2117 fn missing_compatibility_input_should_be_unknown() -> Result<(), ChangeAnalysisError> {
2118 let (changes, nodes, edges, evidence, freshness, _) = complete_fixture();
2119
2120 let result = analyze_changes(
2121 &changes,
2122 &nodes,
2123 &edges,
2124 &evidence,
2125 None,
2126 &freshness,
2127 &[],
2128 &ChangeAnalysisOptions::default(),
2129 )?;
2130
2131 assert_eq!(
2132 result.contract_deltas[0].status,
2133 CompatibilityStatus::Unknown
2134 );
2135 Ok(())
2136 }
2137
2138 #[test]
2139 fn absent_contract_fingerprint_should_be_unknown() -> Result<(), ChangeAnalysisError> {
2140 let (changes, nodes, edges, evidence, freshness, mut compatibility) = complete_fixture();
2141 compatibility[0].report = Some(CompatibilityReport {
2142 status: CompatibilityStatus::Compatible,
2143 before_fingerprint: String::new(),
2144 after_fingerprint: "after".to_owned(),
2145 findings: Vec::new(),
2146 });
2147
2148 let result = analyze_changes(
2149 &changes,
2150 &nodes,
2151 &edges,
2152 &evidence,
2153 None,
2154 &freshness,
2155 &compatibility,
2156 &ChangeAnalysisOptions::default(),
2157 )?;
2158
2159 assert_eq!(
2160 result.contract_deltas[0].status,
2161 CompatibilityStatus::Unknown
2162 );
2163 Ok(())
2164 }
2165
2166 #[test]
2167 fn validation_should_invalidate_changed_head() -> Result<(), ChangeAnalysisError> {
2168 let report = analyze_fixture(&ChangeAnalysisOptions::default())?;
2169 let mut current = ChangeValidityInput::from(&report.change_set);
2170 current.checkout_head_sha = "advanced".to_owned();
2171
2172 let result = validate_change_analysis(&report, ¤t);
2173
2174 assert!(matches!(result, ChangeValidity::Stale { .. }));
2175 Ok(())
2176 }
2177
2178 #[test]
2179 fn validation_should_invalidate_changed_manifest() -> Result<(), ChangeAnalysisError> {
2180 let report = analyze_fixture(&ChangeAnalysisOptions::default())?;
2181 let mut current = ChangeValidityInput::from(&report.change_set);
2182 current.workspace_manifest_hash = "new-manifest".to_owned();
2183
2184 let result = validate_change_analysis(&report, ¤t);
2185
2186 assert!(matches!(result, ChangeValidity::Stale { .. }));
2187 Ok(())
2188 }
2189
2190 #[test]
2191 fn zero_bounds_should_be_rejected() {
2192 let options = ChangeAnalysisOptions {
2193 max_impact_targets: 0,
2194 ..ChangeAnalysisOptions::default()
2195 };
2196
2197 let result = analyze_fixture(&options);
2198
2199 assert!(matches!(result, Err(ChangeAnalysisError::InvalidBounds)));
2200 }
2201
2202 #[test]
2203 fn serialized_report_should_contain_positions_but_no_diff_body()
2204 -> Result<(), Box<dyn std::error::Error>> {
2205 let report = analyze_fixture(&ChangeAnalysisOptions::default())?;
2206
2207 let encoded = serde_json::to_string(&report)?;
2208
2209 assert!(
2210 encoded.contains("\"old_start\":10")
2211 && encoded.contains("\"new_start\":10")
2212 && !encoded.contains("\"source_body\"")
2213 && !encoded.contains("\"diff_body\"")
2214 );
2215 Ok(())
2216 }
2217}