Skip to main content

code_system_graph_core/
change_analysis.rs

1//! Pure semantic mapping and impact propagation for fingerprinted change sets.
2
3use 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
17/// Semantic version of the change-analysis algorithm and report contract.
18pub 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/// Deterministic bounds and presentation controls for semantic change analysis.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29pub struct ChangeAnalysisOptions {
30    /// Maximum changed files mapped before coverage becomes incomplete.
31    pub max_changed_files: usize,
32    /// Maximum distinct graph nodes retained from changed-file mappings.
33    pub max_changed_nodes: usize,
34    /// Maximum exact changed boundary nodes used as impact targets.
35    pub max_impact_targets: usize,
36    /// Direction in which impact propagates from each changed boundary.
37    pub direction: ImpactDirection,
38    /// Maximum graph depth for each impact traversal.
39    pub max_depth: usize,
40    /// Zero-based offset over the stable changed-entity order.
41    pub offset: usize,
42    /// Maximum changed entities returned after the offset.
43    pub limit: usize,
44    /// Omit detailed changed entities and impact items while retaining aggregates.
45    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/// Side of a changed path and hunk used for evidence matching.
64#[derive(
65    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
66)]
67#[serde(rename_all = "snake_case")]
68pub enum ChangedPathSide {
69    /// Original path and old-side hunk range.
70    Old,
71    /// Resulting path and new-side hunk range.
72    New,
73}
74
75/// Strength of one path-and-line evidence match.
76#[derive(
77    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
78)]
79#[serde(rename_all = "snake_case")]
80pub enum EvidenceMatchKind {
81    /// Persisted evidence lines intersect an exact changed hunk range.
82    HunkIntersection,
83    /// Only the exact repository-relative file path could be matched.
84    FilePathFallback,
85}
86
87/// Position-only explanation of why evidence matched a changed file.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89pub struct HunkLineMatch {
90    /// Matched persisted evidence.
91    pub evidence_id: EvidenceId,
92    /// Old or new path and hunk side used by the match.
93    pub path_side: ChangedPathSide,
94    /// Match strength.
95    pub kind: EvidenceMatchKind,
96    /// Zero-based hunk index for an exact line intersection.
97    pub hunk_index: Option<usize>,
98    /// Inclusive changed-hunk start line.
99    pub hunk_start: Option<u32>,
100    /// Inclusive changed-hunk end line.
101    pub hunk_end: Option<u32>,
102    /// Inclusive persisted-evidence start line.
103    pub evidence_start: Option<u32>,
104    /// Inclusive persisted-evidence end line.
105    pub evidence_end: Option<u32>,
106}
107
108/// Explicit completeness of one changed-file semantic mapping.
109#[derive(
110    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
111)]
112#[serde(rename_all = "snake_case")]
113pub enum MappingCompleteness {
114    /// Exact path and hunk evidence reached at least one boundary node.
115    Complete,
116    /// Useful path, evidence, or graph mapping exists but is not sufficient to prove completeness.
117    Partial,
118    /// The native path or required persisted evidence could not be mapped.
119    Unknown,
120}
121
122/// Semantic graph mapping for one changed file and source layer.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124pub struct ChangedArtifactMapping {
125    /// Original repository-relative native path.
126    pub old_path: Option<NativePath>,
127    /// Resulting repository-relative native path.
128    pub new_path: Option<NativePath>,
129    /// Git-level change status.
130    pub status: ChangedFileStatus,
131    /// Git state layer that supplied the file.
132    pub layer: ChangeSourceLayer,
133    /// Sorted evidence identifiers matched by exact path and position.
134    pub matched_evidence_ids: Vec<EvidenceId>,
135    /// Sorted artifact nodes reached through matched edge evidence.
136    pub artifact_node_ids: Vec<NodeId>,
137    /// Sorted `SymbolRef` nodes reached through matched edge evidence.
138    pub symbol_ref_node_ids: Vec<NodeId>,
139    /// Sorted contract or boundary nodes reached through matched edge evidence.
140    pub boundary_node_ids: Vec<NodeId>,
141    /// Position-only explanations for evidence matches.
142    pub matches: Vec<HunkLineMatch>,
143    /// Conservative mapping completeness.
144    pub completeness: MappingCompleteness,
145    /// Deterministically ordered mapping limitations.
146    pub gaps: Vec<String>,
147}
148
149/// Role through which a graph node was associated with changed evidence.
150#[derive(
151    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
152)]
153#[serde(rename_all = "snake_case")]
154pub enum ChangedEntityRole {
155    /// Boundary-defining artifact.
156    Artifact,
157    /// Repository-local symbol reference.
158    SymbolRef,
159    /// Contract or integration boundary.
160    Boundary,
161}
162
163/// One deduplicated graph entity associated with the analyzed change.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165pub struct ChangedEntity {
166    /// Exact graph node.
167    pub node: Node,
168    /// Sorted semantic roles observed across changed files.
169    pub roles: Vec<ChangedEntityRole>,
170    /// Lossless changed paths that contributed this entity.
171    pub changed_paths: Vec<NativePath>,
172}
173
174/// Precomputed compatibility result keyed by an exact contract and optional source file.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct ContractCompatibilityInput {
177    /// Optional repository-relative path that must belong to the changed mapping.
178    pub file_path: Option<String>,
179    /// Exact contract node to which the comparison applies.
180    pub contract_node_id: NodeId,
181    /// Precomputed comparison, or `None` when either side was unavailable.
182    pub report: Option<CompatibilityReport>,
183}
184
185/// Compatibility change for one exact changed contract.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
187pub struct SemanticContractDelta {
188    /// Exact changed contract node.
189    pub contract: Node,
190    /// Contract node kind copied explicitly for schema consumers.
191    pub contract_kind: NodeKind,
192    /// Conservative compatibility status.
193    pub status: CompatibilityStatus,
194    /// Structured previous-contract fingerprint when available.
195    pub before_fingerprint: Option<String>,
196    /// Structured candidate-contract fingerprint when available.
197    pub after_fingerprint: Option<String>,
198    /// Deterministically ordered compatibility findings.
199    pub findings: Vec<CompatibilityFinding>,
200    /// Deduplicated factors extracted from findings.
201    pub factors: Vec<String>,
202    /// Deduplicated recommended validations extracted from findings.
203    pub validations: Vec<String>,
204}
205
206/// Coverage accounting that prevents false-safe change conclusions.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
208pub struct ChangeAnalysisCoverage {
209    /// Whether every changed file, mapped node, and impact target was handled within exact bounds.
210    pub complete: bool,
211    /// Number of changed files in the exact input.
212    pub total_changed_files: usize,
213    /// Number of changed files mapped within the configured file bound.
214    pub analyzed_changed_files: usize,
215    /// Number of file mappings with at least one matched evidence record.
216    pub mapped_changed_files: usize,
217    /// Number of completely mapped changed files.
218    pub complete_changed_files: usize,
219    /// Number of distinct changed graph nodes before node truncation.
220    pub total_changed_nodes: usize,
221    /// Number of changed graph nodes retained within the configured bound.
222    pub retained_changed_nodes: usize,
223    /// Number of exact changed boundary nodes before target truncation.
224    pub total_impact_targets: usize,
225    /// Number of exact changed boundary nodes analyzed for impact.
226    pub analyzed_impact_targets: usize,
227    /// Whether any configured bound truncated analysis or presentation inputs.
228    pub truncated: bool,
229    /// Deterministically ordered limitations and remediation context.
230    pub gaps: Vec<String>,
231}
232
233/// Conservative summary conclusion for a semantic change report.
234#[derive(
235    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
236)]
237#[serde(rename_all = "snake_case")]
238pub enum ChangeConclusion {
239    /// Complete analysis found semantic entities or propagated impact.
240    ImpactDetected,
241    /// Complete analysis found no semantic impact.
242    NoSemanticImpactDetected,
243    /// Incomplete coverage or truncation prevents a safe conclusion.
244    Unknown,
245}
246
247/// Compact aggregate suitable for summary-only callers.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249pub struct ChangeAnalysisSummary {
250    /// Coverage-aware conclusion.
251    pub conclusion: ChangeConclusion,
252    /// Highest propagated risk, forced to `Unknown` when coverage is incomplete.
253    pub highest_risk: RiskLevel,
254    /// Number of deduplicated changed graph entities before pagination.
255    pub changed_entities: usize,
256    /// Number of exact changed contracts.
257    pub changed_contracts: usize,
258    /// Number of exact boundary impact reports produced.
259    pub impact_reports: usize,
260}
261
262/// Complete semantic mapping and propagation tied to one exact change-set input.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
264pub struct ChangeImpactReport {
265    /// Semantic version of the analyzer implementation.
266    pub analyzer_version: String,
267    /// BLAKE3 fingerprint of every pure analyzer input and option.
268    pub analyzer_fingerprint: String,
269    /// Exact position-only change set whose identities and fingerprints own this result.
270    pub change_set: ChangeSet,
271    /// Stable changed-file mappings before entity pagination.
272    pub mappings: Vec<ChangedArtifactMapping>,
273    /// Stable, deduplicated changed entities after requested pagination.
274    pub changed_entities: Vec<ChangedEntity>,
275    /// Semantic compatibility deltas for exact changed contract nodes.
276    pub contract_deltas: Vec<SemanticContractDelta>,
277    /// One bounded impact result per analyzed exact changed boundary.
278    pub impacts: Vec<ImpactReport>,
279    /// Repositories touched directly or through propagated impact.
280    pub touched_repositories: Vec<RepoId>,
281    /// Services touched directly or through propagated impact.
282    pub touched_services: Vec<Node>,
283    /// Communities containing directly or transitively touched nodes.
284    pub touched_communities: Vec<CommunityId>,
285    /// Contracts touched directly or through propagated impact.
286    pub touched_contracts: Vec<Node>,
287    /// Coverage accounting controlling the summary conclusion.
288    pub coverage: ChangeAnalysisCoverage,
289    /// Coverage-aware aggregate summary.
290    pub summary: ChangeAnalysisSummary,
291}
292
293/// Invalid input or graph state encountered during pure change analysis.
294#[derive(Debug, Error)]
295pub enum ChangeAnalysisError {
296    /// One or more configured bounds are zero or exceed supported limits.
297    #[error("change-analysis bounds are outside supported limits")]
298    InvalidBounds,
299    /// Pure analyzer inputs could not be serialized for fingerprinting.
300    #[error("change-analysis inputs could not be fingerprinted: {0}")]
301    Fingerprint(#[source] serde_json::Error),
302    /// Bounded impact analysis failed for one exact changed boundary.
303    #[error("impact analysis failed for changed boundary `{target}`: {source}")]
304    Impact {
305        /// Exact boundary node that failed analysis.
306        target: String,
307        /// Underlying impact validation or traversal error.
308        #[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/// Maps an exact change set to semantic entities and propagates bounded graph impact.
326///
327/// The function is pure: it does not read files, execute Git, invoke providers, or retain source
328/// and diff bodies. Compatibility inputs must have been computed before this call.
329///
330/// # Errors
331///
332/// Returns [`ChangeAnalysisError`] for invalid bounds, fingerprint serialization failures, or an
333/// invalid graph context encountered by bounded impact analysis.
334#[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/// Validates whether the exact change-set inputs owning a report remain current.
611#[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, &current);
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, &current);
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}