Skip to main content

code_system_graph_core/
contract_compat.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    BoundaryRole, DataDocument, DatabaseColumn, DatabaseForeignKey, DatabaseIndex, DatabaseTable, DependencyScope, EventDocument, EventObservation, GraphqlDocument, GraphqlFieldDefinition, GraphqlTypeDefinition, GraphqlTypeRef, HttpBoundary, PackageCoordinate, PackageDependency, PackageManifest, PackageManifestValue, ProtoEnum, ProtoFieldCardinality, ProtoFile, ProtoMessage
8};
9
10const MAX_FINDING_VALUES: usize = 8;
11const MAX_FINDING_VALUE_CHARS: usize = 512;
12
13/// Conservative compatibility classification for one contract comparison.
14#[derive(
15    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum CompatibilityStatus {
19    /// The modeled change is known to break at least one supported contract rule.
20    Breaking,
21    /// The modeled change can break consumers but requires runtime or policy confirmation.
22    PotentiallyBreaking,
23    /// Every modeled rule is compatible and both inputs are complete.
24    Compatible,
25    /// Coverage is insufficient to claim compatibility.
26    Unknown,
27    /// The supplied contracts cannot be compared under the modeled rules.
28    Incomparable,
29}
30
31/// One evidence-backed compatibility rule result.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33pub struct CompatibilityFinding {
34    /// Stable machine-readable rule code.
35    pub code: String,
36    /// Contract coordinate affected by the rule.
37    pub path: String,
38    /// Rule classification.
39    pub status: CompatibilityStatus,
40    /// Factors that caused the classification.
41    pub factors: Vec<String>,
42    /// Bounded evidence locations or declarations.
43    pub evidence: Vec<String>,
44    /// Concrete validations recommended to the caller.
45    pub recommended_validations: Vec<String>,
46}
47
48/// Complete compatibility result with exact before/after fingerprints.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct CompatibilityReport {
51    /// Most conservative aggregate status.
52    pub status: CompatibilityStatus,
53    /// BLAKE3 fingerprint of the structured previous contract.
54    pub before_fingerprint: String,
55    /// BLAKE3 fingerprint of the structured candidate contract.
56    pub after_fingerprint: String,
57    /// Deterministically ordered findings.
58    pub findings: Vec<CompatibilityFinding>,
59}
60
61/// Compares GraphQL schemas and persisted operations using conservative breaking-change rules.
62///
63/// Missing types/fields/enum values, new required arguments, narrowed types, and invalidated
64/// persisted operations are breaking. Incomplete extraction returns [`CompatibilityStatus::Unknown`].
65#[must_use]
66pub fn compare_graphql_contracts(
67    before: &GraphqlDocument,
68    after: &GraphqlDocument,
69) -> CompatibilityReport {
70    let mut findings = Vec::new();
71    let (before_fingerprint, after_fingerprint) =
72        fingerprints(before, after, "graphql", &mut findings);
73    if !before.complete || !after.complete {
74        findings.push(finding(
75            "graphql.extraction_incomplete",
76            "graphql",
77            CompatibilityStatus::Unknown,
78            vec!["one or both GraphQL documents are incomplete".to_owned()],
79            vec![before.source_path.clone(), after.source_path.clone()],
80            vec!["resolve extraction warnings before compatibility analysis".to_owned()],
81        ));
82    }
83
84    let after_types = after
85        .types
86        .iter()
87        .map(|definition| (definition.name.as_str(), definition))
88        .collect::<BTreeMap<_, _>>();
89    for previous_type in &before.types {
90        let Some(current_type) = after_types.get(previous_type.name.as_str()) else {
91            findings.push(graphql_breaking(
92                "graphql.type_removed",
93                &previous_type.name,
94                previous_type.lines.start,
95                "type is absent from the candidate schema",
96            ));
97            continue;
98        };
99        compare_graphql_type(previous_type, current_type, &mut findings);
100    }
101    compare_persisted_operations(before, after, &mut findings);
102    finish_report(before_fingerprint, after_fingerprint, findings)
103}
104
105/// Compares event channels and payload schemas using conservative breaking-change rules.
106#[must_use]
107pub fn compare_event_contracts(
108    before: &EventDocument,
109    after: &EventDocument,
110) -> CompatibilityReport {
111    let mut findings = Vec::new();
112    let (before_fingerprint, after_fingerprint) =
113        fingerprints(before, after, "event", &mut findings);
114    if before.incomplete || after.incomplete {
115        findings.push(finding(
116            "event.extraction_incomplete",
117            "event",
118            CompatibilityStatus::Unknown,
119            vec!["one or both event documents are incomplete".to_owned()],
120            vec![
121                before.source_path.clone().unwrap_or_default(),
122                after.source_path.clone().unwrap_or_default(),
123            ],
124            vec!["resolve event extraction warnings before compatibility analysis".to_owned()],
125        ));
126    }
127    let current = after
128        .observations
129        .iter()
130        .filter_map(|observation| event_key(observation).map(|key| (key, observation)))
131        .collect::<BTreeMap<_, _>>();
132    for previous in &before.observations {
133        let Some(key) = event_key(previous) else {
134            continue;
135        };
136        let Some(candidate) = current.get(&key) else {
137            findings.push(event_breaking(
138                "event.channel_removed",
139                &key,
140                previous,
141                "channel role is absent from the candidate contract",
142            ));
143            continue;
144        };
145        compare_event_observation(previous, candidate, &key, &mut findings);
146    }
147    finish_report(before_fingerprint, after_fingerprint, findings)
148}
149
150/// Compares protobuf/gRPC contracts using field-number and wire-compatibility rules.
151#[must_use]
152pub fn compare_protobuf_contracts(before: &ProtoFile, after: &ProtoFile) -> CompatibilityReport {
153    let mut findings = Vec::new();
154    let (before_fingerprint, after_fingerprint) =
155        fingerprints(before, after, "protobuf", &mut findings);
156    if before.package != after.package {
157        findings.push(proto_breaking(
158            "protobuf.package_renamed",
159            before.package.as_deref().unwrap_or("<root>"),
160            before.package_line.unwrap_or(1),
161            "protobuf package changed",
162        ));
163    }
164    compare_proto_messages(before, after, &mut findings);
165    compare_proto_enums(before, after, &mut findings);
166    compare_proto_services(before, after, &mut findings);
167    finish_report(before_fingerprint, after_fingerprint, findings)
168}
169
170/// Compares the currently modeled HTTP endpoint, method, and boundary-role inventory.
171///
172/// This comparator never treats boundary-only extraction as proof of request or response schema
173/// compatibility. Duplicate operations are incomparable, incomplete operations are unknown,
174/// exact removals are breaking, and uniquely attributable path or method changes are potentially
175/// breaking.
176#[must_use]
177pub fn compare_http_contracts(
178    before: &[HttpBoundary],
179    after: &[HttpBoundary],
180) -> CompatibilityReport {
181    let mut findings = Vec::new();
182    let before_contract = http_fingerprint_contract(before);
183    let after_contract = http_fingerprint_contract(after);
184    let (before_fingerprint, after_fingerprint) =
185        fingerprints(&before_contract, &after_contract, "http", &mut findings);
186    let before_inventory = http_inventory(before, "before", &mut findings);
187    let after_inventory = http_inventory(after, "after", &mut findings);
188    let mut consumed_additions = BTreeSet::new();
189
190    for (key, previous) in &before_inventory {
191        if after_inventory.contains_key(key) {
192            continue;
193        }
194        let candidates = after_inventory
195            .iter()
196            .filter(|(candidate_key, _)| {
197                !consumed_additions.contains(*candidate_key)
198                    && !before_inventory.contains_key(*candidate_key)
199                    && key.0 == candidate_key.0
200                    && ((key.1 == candidate_key.1) ^ (key.2 == candidate_key.2))
201            })
202            .collect::<Vec<_>>();
203        if candidates.len() == 1 {
204            let (candidate_key, candidate) = candidates[0];
205            consumed_additions.insert(candidate_key.clone());
206            findings.push(finding(
207                "http.operation_changed",
208                &http_key_path(key),
209                CompatibilityStatus::PotentiallyBreaking,
210                vec![format!(
211                    "operation changed from {} {} to {} {}",
212                    key.1, key.2, candidate_key.1, candidate_key.2
213                )],
214                combined_http_evidence(previous, candidate),
215                vec![
216                    "confirm the path or method migration with every HTTP consumer".to_owned(),
217                    "run request and response contract tests against the candidate operation"
218                        .to_owned(),
219                ],
220            ));
221        } else {
222            findings.push(finding(
223                "http.operation_removed",
224                &http_key_path(key),
225                CompatibilityStatus::Breaking,
226                vec!["endpoint and method pair is absent from the candidate inventory".to_owned()],
227                http_evidence(previous),
228                vec!["run affected HTTP consumers against the candidate provider".to_owned()],
229            ));
230        }
231    }
232
233    for (key, candidate) in &after_inventory {
234        if !before_inventory.contains_key(key) && !consumed_additions.contains(key) {
235            findings.push(finding(
236                "http.operation_added",
237                &http_key_path(key),
238                CompatibilityStatus::Compatible,
239                vec!["an exact endpoint and method pair was added".to_owned()],
240                http_evidence(candidate),
241                vec!["validate routing and authorization before exposing the operation".to_owned()],
242            ));
243        }
244    }
245    findings.push(finding(
246        "http.schema_semantics_unmodeled",
247        "http",
248        CompatibilityStatus::Incomparable,
249        vec![
250            "HTTP boundaries model endpoint, method, and role but not parameters or schemas"
251                .to_owned(),
252        ],
253        http_inventory_evidence(before, after),
254        vec![
255            "compare complete OpenAPI request, response, media-type, status, and security schemas"
256                .to_owned(),
257        ],
258    ));
259    finish_report(before_fingerprint, after_fingerprint, findings)
260}
261
262/// Compares package coordinates, exports, dependencies, features, and workspace declarations.
263///
264/// Changes that require package-manager resolution or type-declaration analysis remain unknown.
265#[must_use]
266pub fn compare_package_contracts(
267    before: &PackageManifest,
268    after: &PackageManifest,
269) -> CompatibilityReport {
270    let mut findings = Vec::new();
271    let (before_fingerprint, after_fingerprint) =
272        fingerprints(before, after, "package", &mut findings);
273    compare_package_coordinates(before, after, &mut findings);
274    compare_manifest_values(
275        "package.export",
276        &before.exports,
277        &after.exports,
278        CompatibilityStatus::Breaking,
279        CompatibilityStatus::Compatible,
280        &mut findings,
281    );
282    compare_package_dependencies(&before.dependencies, &after.dependencies, &mut findings);
283    compare_manifest_values(
284        "package.feature",
285        &before.features,
286        &after.features,
287        CompatibilityStatus::PotentiallyBreaking,
288        CompatibilityStatus::Compatible,
289        &mut findings,
290    );
291    compare_manifest_values(
292        "package.workspace",
293        &before.workspace_members,
294        &after.workspace_members,
295        CompatibilityStatus::Unknown,
296        CompatibilityStatus::Unknown,
297        &mut findings,
298    );
299    if before.workspace_members != after.workspace_members {
300        findings.push(finding(
301            "package.workspace_cycle_unmodeled",
302            "package.workspace",
303            CompatibilityStatus::Unknown,
304            vec!["workspace membership changed but dependency cycles are not modeled".to_owned()],
305            manifest_value_evidence(&before.workspace_members, &after.workspace_members),
306            vec![
307                "resolve the candidate workspace graph and reject newly introduced cycles"
308                    .to_owned(),
309            ],
310        ));
311    }
312    findings.push(finding(
313        "package.type_declarations_unmodeled",
314        "package.types",
315        CompatibilityStatus::Unknown,
316        vec!["package manifests do not model public type declaration compatibility".to_owned()],
317        package_manifest_evidence(before, after),
318        vec![
319            "run the ecosystem type checker against representative downstream consumers".to_owned(),
320        ],
321    ));
322    finish_report(before_fingerprint, after_fingerprint, findings)
323}
324
325/// Compares directly modeled database tables, columns, constraints, and migration ancestry.
326///
327/// Cross-repository consumer impact is not represented by one [`DataDocument`] and is therefore
328/// always reported as unknown rather than inferred.
329#[must_use]
330pub fn compare_database_contracts(
331    before: &DataDocument,
332    after: &DataDocument,
333) -> CompatibilityReport {
334    let mut findings = Vec::new();
335    let (before_fingerprint, after_fingerprint) =
336        fingerprints(before, after, "database", &mut findings);
337    if before.incomplete
338        || after.incomplete
339        || !before.warnings.is_empty()
340        || !after.warnings.is_empty()
341    {
342        findings.push(finding(
343            "database.extraction_incomplete",
344            "database",
345            CompatibilityStatus::Unknown,
346            vec!["one or both database documents contain extraction limitations".to_owned()],
347            vec![before.source_path.clone(), after.source_path.clone()],
348            vec!["resolve database extraction warnings before compatibility analysis".to_owned()],
349        ));
350    }
351    let before_tables = database_inventory(before, "before", &mut findings);
352    let after_tables = database_inventory(after, "after", &mut findings);
353    for (key, previous) in &before_tables {
354        let Some(candidate) = after_tables.get(key) else {
355            findings.push(database_finding(
356                "database.table_removed",
357                key,
358                CompatibilityStatus::Breaking,
359                previous.evidence.get(),
360                "table is absent from the candidate schema",
361            ));
362            continue;
363        };
364        compare_database_table(key, previous, candidate, &mut findings);
365    }
366    compare_migrations(before, after, &mut findings);
367    findings.push(finding(
368        "database.shared_consumer_impact_unmodeled",
369        "database.consumers",
370        CompatibilityStatus::Unknown,
371        vec!["one artifact cannot establish the impact on every shared-table consumer".to_owned()],
372        vec![before.source_path.clone(), after.source_path.clone()],
373        vec!["query the repository graph for all readers and writers, then run their integration tests".to_owned()],
374    ));
375    finish_report(before_fingerprint, after_fingerprint, findings)
376}
377
378const _: fn(&[HttpBoundary], &[HttpBoundary]) -> CompatibilityReport = compare_http_contracts;
379const _: fn(&PackageManifest, &PackageManifest) -> CompatibilityReport = compare_package_contracts;
380const _: fn(&DataDocument, &DataDocument) -> CompatibilityReport = compare_database_contracts;
381
382fn http_fingerprint_contract(
383    boundaries: &[HttpBoundary],
384) -> Vec<(String, String, String, String, u32, u32, u32)> {
385    boundaries
386        .iter()
387        .map(|boundary| {
388            (
389                http_role(boundary.role).to_owned(),
390                boundary.method.clone(),
391                boundary.path.clone(),
392                boundary.evidence.file_path.clone().unwrap_or_default(),
393                boundary.evidence.start_line.unwrap_or_default(),
394                boundary.evidence.end_line.unwrap_or_default(),
395                boundary.evidence.confidence.to_bits(),
396            )
397        })
398        .collect()
399}
400
401fn http_inventory<'a>(
402    boundaries: &'a [HttpBoundary],
403    side: &str,
404    findings: &mut Vec<CompatibilityFinding>,
405) -> BTreeMap<(String, String, String), &'a HttpBoundary> {
406    let mut inventory = BTreeMap::new();
407    for boundary in boundaries {
408        let key = (
409            http_role(boundary.role).to_owned(),
410            boundary.method.trim().to_ascii_uppercase(),
411            boundary.path.trim().to_owned(),
412        );
413        if key.1.is_empty()
414            || key.2.is_empty()
415            || !key.2.starts_with('/')
416            || !boundary.evidence.confidence.is_finite()
417            || boundary.evidence.confidence < 1.0
418        {
419            findings.push(finding(
420                "http.operation_incomplete",
421                &http_key_path(&key),
422                CompatibilityStatus::Unknown,
423                vec![format!("{side} operation lacks an exact method, canonical path, or full-confidence evidence")],
424                http_evidence(boundary),
425                vec!["rerun bounded HTTP extraction and resolve dynamic operation evidence".to_owned()],
426            ));
427        }
428        if let Some(previous) = inventory.insert(key.clone(), boundary) {
429            findings.push(finding(
430                "http.operation_duplicate",
431                &http_key_path(&key),
432                CompatibilityStatus::Incomparable,
433                vec![format!(
434                    "{side} inventory contains duplicate operation coordinates"
435                )],
436                combined_http_evidence(previous, boundary),
437                vec!["deduplicate providers or disambiguate the operation declarations".to_owned()],
438            ));
439        }
440    }
441    inventory
442}
443
444fn http_role(role: BoundaryRole) -> &'static str {
445    match role {
446        BoundaryRole::Provider => "provider",
447        BoundaryRole::Consumer => "consumer",
448    }
449}
450
451fn http_key_path(key: &(String, String, String)) -> String {
452    format!("{}:{} {}", key.0, key.1, key.2)
453}
454
455fn http_evidence(boundary: &HttpBoundary) -> Vec<String> {
456    let source = boundary
457        .evidence
458        .file_path
459        .as_deref()
460        .unwrap_or("<unknown>");
461    let locator = match (boundary.evidence.start_line, boundary.evidence.end_line) {
462        (Some(start), Some(end)) => format!("{source}:{start}-{end}"),
463        (Some(start), None) => format!("{source}:{start}"),
464        _ => source.to_owned(),
465    };
466    vec![locator, boundary.node.stable_key.clone()]
467}
468
469fn combined_http_evidence(before: &HttpBoundary, after: &HttpBoundary) -> Vec<String> {
470    http_evidence(before)
471        .into_iter()
472        .chain(http_evidence(after))
473        .collect()
474}
475
476fn http_inventory_evidence(before: &[HttpBoundary], after: &[HttpBoundary]) -> Vec<String> {
477    before.iter().chain(after).flat_map(http_evidence).collect()
478}
479
480fn compare_package_coordinates(
481    before: &PackageManifest,
482    after: &PackageManifest,
483    findings: &mut Vec<CompatibilityFinding>,
484) {
485    let before_packages = package_inventory(&before.packages, "before", findings);
486    let after_packages = package_inventory(&after.packages, "after", findings);
487    let mut consumed = BTreeSet::new();
488    for (key, previous) in &before_packages {
489        if let Some(candidate) = after_packages.get(key) {
490            compare_package_version(previous, candidate, findings);
491            continue;
492        }
493        let rename_candidates = after_packages
494            .iter()
495            .filter(|(candidate_key, candidate)| {
496                !consumed.contains(*candidate_key)
497                    && key.0 == candidate_key.0
498                    && previous.source_path == candidate.source_path
499            })
500            .collect::<Vec<_>>();
501        if let [(candidate_key, candidate)] = rename_candidates.as_slice() {
502            consumed.insert((*candidate_key).clone());
503            findings.push(finding(
504                "package.coordinate_renamed",
505                &format!("{}:{}", key.0, key.1),
506                CompatibilityStatus::Breaking,
507                vec![format!(
508                    "package coordinate changed from {} to {}",
509                    previous.name, candidate.name
510                )],
511                package_coordinate_evidence(previous, candidate),
512                vec![
513                    "publish a compatibility package or migrate every downstream dependency"
514                        .to_owned(),
515                ],
516            ));
517        } else {
518            findings.push(finding(
519                "package.coordinate_removed",
520                &format!("{}:{}", key.0, key.1),
521                CompatibilityStatus::Breaking,
522                vec!["package coordinate is absent from the candidate manifest".to_owned()],
523                vec![package_locator(previous)],
524                vec![
525                    "verify all registry and workspace consumers before removing the package"
526                        .to_owned(),
527                ],
528            ));
529        }
530    }
531    for (key, candidate) in &after_packages {
532        if !before_packages.contains_key(key) && !consumed.contains(key) {
533            findings.push(finding(
534                "package.coordinate_added",
535                &format!("{}:{}", key.0, key.1),
536                CompatibilityStatus::Compatible,
537                vec!["a distinct package coordinate was added".to_owned()],
538                vec![package_locator(candidate)],
539                vec!["validate publication metadata before release".to_owned()],
540            ));
541        }
542    }
543}
544
545fn package_inventory<'a>(
546    packages: &'a [PackageCoordinate],
547    side: &str,
548    findings: &mut Vec<CompatibilityFinding>,
549) -> BTreeMap<(String, String), &'a PackageCoordinate> {
550    let mut inventory = BTreeMap::new();
551    for package in packages {
552        let key = (format!("{:?}", package.ecosystem), package.name.clone());
553        if let Some(previous) = inventory.insert(key.clone(), package) {
554            findings.push(finding(
555                "package.coordinate_duplicate",
556                &format!("{}:{}", key.0, key.1),
557                CompatibilityStatus::Incomparable,
558                vec![format!(
559                    "{side} manifest contains duplicate package coordinates"
560                )],
561                package_coordinate_evidence(previous, package),
562                vec!["deduplicate package declarations before comparison".to_owned()],
563            ));
564        }
565    }
566    inventory
567}
568
569fn compare_package_version(
570    before: &PackageCoordinate,
571    after: &PackageCoordinate,
572    findings: &mut Vec<CompatibilityFinding>,
573) {
574    if before.version == after.version {
575        return;
576    }
577    let status = match (&before.version, &after.version) {
578        (Some(previous), Some(candidate))
579            if leading_version_major(previous) != leading_version_major(candidate) =>
580        {
581            CompatibilityStatus::Breaking
582        }
583        (Some(_), Some(_)) => CompatibilityStatus::PotentiallyBreaking,
584        _ => CompatibilityStatus::Unknown,
585    };
586    findings.push(finding(
587        "package.declared_version_changed",
588        &format!("{:?}:{}", before.ecosystem, before.name),
589        status,
590        vec![format!(
591            "declared package version changed from {} to {}",
592            before.version.as_deref().unwrap_or("<unspecified>"),
593            after.version.as_deref().unwrap_or("<unspecified>")
594        )],
595        package_coordinate_evidence(before, after),
596        vec![
597            "resolve the published versions and run downstream package compatibility tests"
598                .to_owned(),
599        ],
600    ));
601}
602
603fn leading_version_major(value: &str) -> Option<u64> {
604    let start = value.find(|character: char| character.is_ascii_digit())?;
605    value[start..]
606        .split(|character: char| !character.is_ascii_digit())
607        .next()?
608        .parse()
609        .ok()
610}
611
612fn compare_manifest_values(
613    prefix: &str,
614    before: &[PackageManifestValue],
615    after: &[PackageManifestValue],
616    removed_status: CompatibilityStatus,
617    added_status: CompatibilityStatus,
618    findings: &mut Vec<CompatibilityFinding>,
619) {
620    let before_values = manifest_value_inventory(prefix, "before", before, findings);
621    let after_values = manifest_value_inventory(prefix, "after", after, findings);
622    for (value, previous) in &before_values {
623        if !after_values.contains_key(value) {
624            findings.push(finding(
625                &format!("{prefix}_removed"),
626                value,
627                removed_status,
628                vec![format!(
629                    "{prefix} declaration is absent from the candidate manifest"
630                )],
631                vec![manifest_value_locator(previous)],
632                vec![format!(
633                    "validate downstream consumers of `{value}` before release"
634                )],
635            ));
636        }
637    }
638    for (value, candidate) in &after_values {
639        if !before_values.contains_key(value) {
640            findings.push(finding(
641                &format!("{prefix}_added"),
642                value,
643                added_status,
644                vec![format!("{prefix} declaration was added")],
645                vec![manifest_value_locator(candidate)],
646                vec![format!(
647                    "validate the new `{value}` declaration with the package manager"
648                )],
649            ));
650        }
651    }
652}
653
654fn manifest_value_inventory<'a>(
655    prefix: &str,
656    side: &str,
657    values: &'a [PackageManifestValue],
658    findings: &mut Vec<CompatibilityFinding>,
659) -> BTreeMap<String, &'a PackageManifestValue> {
660    let mut inventory = BTreeMap::new();
661    for value in values {
662        if let Some(previous) = inventory.insert(value.value.clone(), value) {
663            findings.push(finding(
664                &format!("{prefix}_duplicate"),
665                &value.value,
666                CompatibilityStatus::Incomparable,
667                vec![format!("{side} manifest contains duplicate declarations")],
668                vec![
669                    manifest_value_locator(previous),
670                    manifest_value_locator(value),
671                ],
672                vec!["deduplicate declarations before comparison".to_owned()],
673            ));
674        }
675    }
676    inventory
677}
678
679fn compare_package_dependencies(
680    before: &[PackageDependency],
681    after: &[PackageDependency],
682    findings: &mut Vec<CompatibilityFinding>,
683) {
684    let before_dependencies = dependency_inventory(before, "before", findings);
685    let after_dependencies = dependency_inventory(after, "after", findings);
686    for (key, previous) in &before_dependencies {
687        let Some(candidate) = after_dependencies.get(key) else {
688            findings.push(package_dependency_finding(
689                "package.dependency_removed",
690                key,
691                dependency_drift_status(previous.scope),
692                previous,
693                None,
694                "dependency declaration is absent from the candidate manifest",
695            ));
696            continue;
697        };
698        if previous.version_or_range != candidate.version_or_range
699            || previous.scope != candidate.scope
700            || previous.optional != candidate.optional
701            || previous.condition != candidate.condition
702        {
703            let status =
704                if previous.version_or_range.is_none() || candidate.version_or_range.is_none() {
705                    CompatibilityStatus::Unknown
706                } else {
707                    dependency_drift_status(previous.scope)
708                };
709            findings.push(package_dependency_finding(
710                "package.dependency_drift",
711                key,
712                status,
713                previous,
714                Some(candidate),
715                "version range, scope, optionality, or condition changed",
716            ));
717        }
718    }
719    for (key, candidate) in &after_dependencies {
720        if !before_dependencies.contains_key(key) {
721            let status = if candidate.scope == DependencyScope::Peer {
722                CompatibilityStatus::PotentiallyBreaking
723            } else {
724                CompatibilityStatus::Unknown
725            };
726            findings.push(package_dependency_finding(
727                "package.dependency_added",
728                key,
729                status,
730                candidate,
731                None,
732                "dependency declaration was added",
733            ));
734        }
735    }
736}
737
738fn dependency_inventory<'a>(
739    dependencies: &'a [PackageDependency],
740    side: &str,
741    findings: &mut Vec<CompatibilityFinding>,
742) -> BTreeMap<(String, String), &'a PackageDependency> {
743    let mut inventory = BTreeMap::new();
744    for dependency in dependencies {
745        let key = (
746            format!("{:?}", dependency.ecosystem),
747            dependency.name.clone(),
748        );
749        if let Some(previous) = inventory.insert(key.clone(), dependency) {
750            findings.push(finding(
751                "package.dependency_duplicate",
752                &format!("{}:{}", key.0, key.1),
753                CompatibilityStatus::Incomparable,
754                vec![format!(
755                    "{side} manifest contains duplicate dependency coordinates"
756                )],
757                vec![
758                    package_dependency_locator(previous),
759                    package_dependency_locator(dependency),
760                ],
761                vec!["deduplicate dependency declarations before comparison".to_owned()],
762            ));
763        }
764    }
765    inventory
766}
767
768fn dependency_drift_status(scope: DependencyScope) -> CompatibilityStatus {
769    match scope {
770        DependencyScope::Dev | DependencyScope::Test => CompatibilityStatus::Unknown,
771        DependencyScope::Runtime
772        | DependencyScope::Build
773        | DependencyScope::Peer
774        | DependencyScope::Optional => CompatibilityStatus::PotentiallyBreaking,
775    }
776}
777
778fn package_dependency_finding(
779    code: &str,
780    key: &(String, String),
781    status: CompatibilityStatus,
782    before: &PackageDependency,
783    after: Option<&PackageDependency>,
784    factor: &str,
785) -> CompatibilityFinding {
786    let evidence = std::iter::once(package_dependency_locator(before))
787        .chain(after.map(package_dependency_locator))
788        .collect();
789    finding(
790        code,
791        &format!("{}:{}", key.0, key.1),
792        status,
793        vec![factor.to_owned()],
794        evidence,
795        vec!["resolve dependency ranges and run package-manager peer validation".to_owned()],
796    )
797}
798
799fn package_locator(package: &PackageCoordinate) -> String {
800    format!("{}:{}", package.source_path, package.evidence.line)
801}
802
803fn package_coordinate_evidence(
804    before: &PackageCoordinate,
805    after: &PackageCoordinate,
806) -> Vec<String> {
807    vec![package_locator(before), package_locator(after)]
808}
809
810fn package_dependency_locator(dependency: &PackageDependency) -> String {
811    format!("{}:{}", dependency.source_path, dependency.evidence.line)
812}
813
814fn manifest_value_locator(value: &PackageManifestValue) -> String {
815    format!("{}:{}", value.source_path, value.evidence.line)
816}
817
818fn manifest_value_evidence(
819    before: &[PackageManifestValue],
820    after: &[PackageManifestValue],
821) -> Vec<String> {
822    before
823        .iter()
824        .chain(after)
825        .map(manifest_value_locator)
826        .collect()
827}
828
829fn package_manifest_evidence(before: &PackageManifest, after: &PackageManifest) -> Vec<String> {
830    before
831        .packages
832        .iter()
833        .chain(&after.packages)
834        .map(package_locator)
835        .chain(
836            before
837                .exports
838                .iter()
839                .chain(&after.exports)
840                .map(manifest_value_locator),
841        )
842        .collect()
843}
844
845fn database_inventory<'a>(
846    document: &'a DataDocument,
847    side: &str,
848    findings: &mut Vec<CompatibilityFinding>,
849) -> BTreeMap<String, &'a DatabaseTable> {
850    let mut inventory = BTreeMap::new();
851    for table in &document.tables {
852        let key = database_table_key(table);
853        if let Some(previous) = inventory.insert(key.clone(), table) {
854            findings.push(finding(
855                "database.table_duplicate",
856                &key,
857                CompatibilityStatus::Incomparable,
858                vec![format!(
859                    "{side} document contains duplicate table coordinates"
860                )],
861                vec![
862                    data_locator(document, previous.evidence.get()),
863                    data_locator(document, table.evidence.get()),
864                ],
865                vec!["deduplicate or qualify table declarations before comparison".to_owned()],
866            ));
867        }
868    }
869    inventory
870}
871
872fn database_table_key(table: &DatabaseTable) -> String {
873    [
874        table.database.as_deref(),
875        table.schema.as_deref(),
876        Some(&table.name),
877    ]
878    .into_iter()
879    .flatten()
880    .collect::<Vec<_>>()
881    .join(".")
882}
883
884fn compare_database_table(
885    path: &str,
886    before: &DatabaseTable,
887    after: &DatabaseTable,
888    findings: &mut Vec<CompatibilityFinding>,
889) {
890    let before_columns = database_column_inventory(path, "before", before, findings);
891    let after_columns = database_column_inventory(path, "after", after, findings);
892    for (name, previous) in before_columns {
893        let column_path = format!("{path}.{name}");
894        let Some(candidate) = after_columns.get(name) else {
895            findings.push(database_finding(
896                "database.column_removed",
897                &column_path,
898                CompatibilityStatus::Breaking,
899                previous.evidence.get(),
900                "column is absent from the candidate table",
901            ));
902            continue;
903        };
904        compare_database_column(&column_path, previous, candidate, findings);
905    }
906    compare_database_indexes(path, &before.indexes, &after.indexes, findings);
907    compare_database_foreign_keys(path, &before.foreign_keys, &after.foreign_keys, findings);
908}
909
910fn database_column_inventory<'a>(
911    path: &str,
912    side: &str,
913    table: &'a DatabaseTable,
914    findings: &mut Vec<CompatibilityFinding>,
915) -> BTreeMap<&'a str, &'a DatabaseColumn> {
916    let mut inventory = BTreeMap::new();
917    for column in &table.columns {
918        if let Some(previous) = inventory.insert(column.name.as_str(), column) {
919            findings.push(finding(
920                "database.column_duplicate",
921                &format!("{path}.{}", column.name),
922                CompatibilityStatus::Incomparable,
923                vec![format!(
924                    "{side} table contains duplicate column declarations"
925                )],
926                vec![
927                    format!("line:{}", previous.evidence.get()),
928                    format!("line:{}", column.evidence.get()),
929                ],
930                vec!["deduplicate column declarations before comparison".to_owned()],
931            ));
932        }
933    }
934    inventory
935}
936
937fn compare_database_column(
938    path: &str,
939    before: &DatabaseColumn,
940    after: &DatabaseColumn,
941    findings: &mut Vec<CompatibilityFinding>,
942) {
943    if before.data_type != after.data_type {
944        let status = match (&before.data_type, &after.data_type) {
945            (Some(previous), Some(candidate)) if database_type_narrowed(previous, candidate) => {
946                CompatibilityStatus::Breaking
947            }
948            (Some(_), Some(_)) => CompatibilityStatus::PotentiallyBreaking,
949            _ => CompatibilityStatus::Unknown,
950        };
951        findings.push(database_finding(
952            "database.column_type_changed",
953            path,
954            status,
955            after.evidence.get(),
956            "declared column type changed",
957        ));
958    }
959    if before.nullable != after.nullable {
960        let status = match (before.nullable, after.nullable) {
961            (Some(true), Some(false)) if after.default_present => {
962                CompatibilityStatus::PotentiallyBreaking
963            }
964            (Some(true), Some(false)) => CompatibilityStatus::Breaking,
965            (Some(_), Some(_)) => CompatibilityStatus::Compatible,
966            _ => CompatibilityStatus::Unknown,
967        };
968        findings.push(database_finding(
969            "database.column_nullability_changed",
970            path,
971            status,
972            after.evidence.get(),
973            "declared column nullability changed",
974        ));
975    }
976    if before.primary_key && !after.primary_key {
977        findings.push(database_finding(
978            "database.primary_key_removed",
979            path,
980            CompatibilityStatus::Breaking,
981            after.evidence.get(),
982            "primary-key constraint was removed",
983        ));
984    }
985    if before.unique && !after.unique {
986        findings.push(database_finding(
987            "database.unique_constraint_removed",
988            path,
989            CompatibilityStatus::PotentiallyBreaking,
990            after.evidence.get(),
991            "unique constraint was removed",
992        ));
993    }
994}
995
996fn database_type_narrowed(before: &str, after: &str) -> bool {
997    let before = before.to_ascii_lowercase().replace(' ', "");
998    let after = after.to_ascii_lowercase().replace(' ', "");
999    let integer_rank = |value: &str| match value {
1000        "tinyint" => Some(0_u8),
1001        "smallint" => Some(1),
1002        "int" | "integer" => Some(2),
1003        "bigint" => Some(3),
1004        _ => None,
1005    };
1006    if let (Some(previous), Some(candidate)) = (integer_rank(&before), integer_rank(&after)) {
1007        return candidate < previous;
1008    }
1009    for prefix in ["char", "varchar", "binary", "varbinary"] {
1010        if let (Some(previous), Some(candidate)) =
1011            (type_width(&before, prefix), type_width(&after, prefix))
1012        {
1013            return candidate < previous;
1014        }
1015    }
1016    false
1017}
1018
1019fn type_width(value: &str, prefix: &str) -> Option<u64> {
1020    value
1021        .strip_prefix(prefix)?
1022        .strip_prefix('(')?
1023        .strip_suffix(')')?
1024        .parse()
1025        .ok()
1026}
1027
1028fn compare_database_indexes(
1029    path: &str,
1030    before: &[DatabaseIndex],
1031    after: &[DatabaseIndex],
1032    findings: &mut Vec<CompatibilityFinding>,
1033) {
1034    let current = after
1035        .iter()
1036        .map(database_index_key)
1037        .collect::<BTreeSet<_>>();
1038    for index in before {
1039        let key = database_index_key(index);
1040        if !current.contains(&key) {
1041            findings.push(database_finding(
1042                "database.index_removed",
1043                &format!("{path}.index:{key}"),
1044                CompatibilityStatus::PotentiallyBreaking,
1045                index.evidence.get(),
1046                "index or unique-index constraint was removed",
1047            ));
1048        }
1049    }
1050}
1051
1052fn database_index_key(index: &DatabaseIndex) -> String {
1053    format!("{}:{}", index.unique, index.columns.join(","))
1054}
1055
1056fn compare_database_foreign_keys(
1057    path: &str,
1058    before: &[DatabaseForeignKey],
1059    after: &[DatabaseForeignKey],
1060    findings: &mut Vec<CompatibilityFinding>,
1061) {
1062    let current = after
1063        .iter()
1064        .map(database_foreign_key)
1065        .collect::<BTreeSet<_>>();
1066    for foreign_key in before {
1067        let key = database_foreign_key(foreign_key);
1068        if !current.contains(&key) {
1069            findings.push(database_finding(
1070                "database.foreign_key_removed",
1071                &format!("{path}.foreign_key:{key}"),
1072                CompatibilityStatus::PotentiallyBreaking,
1073                foreign_key.evidence.get(),
1074                "foreign-key constraint was removed",
1075            ));
1076        }
1077    }
1078}
1079
1080fn database_foreign_key(foreign_key: &DatabaseForeignKey) -> String {
1081    format!(
1082        "{}->{}:{}",
1083        foreign_key.columns.join(","),
1084        foreign_key.referenced_table,
1085        foreign_key.referenced_columns.join(",")
1086    )
1087}
1088
1089fn compare_migrations(
1090    before: &DataDocument,
1091    after: &DataDocument,
1092    findings: &mut Vec<CompatibilityFinding>,
1093) {
1094    match (&before.migration, &after.migration) {
1095        (Some(previous), Some(candidate)) => {
1096            if previous.revision == candidate.revision
1097                && previous.down_revision != candidate.down_revision
1098            {
1099                findings.push(finding(
1100                    "database.migration_predecessor_changed",
1101                    "database.migration",
1102                    CompatibilityStatus::Breaking,
1103                    vec!["the predecessor of an existing migration revision changed".to_owned()],
1104                    vec![
1105                        data_locator(before, previous.evidence.get()),
1106                        data_locator(after, candidate.evidence.get()),
1107                    ],
1108                    vec![
1109                        "validate the full migration DAG on a production-like snapshot".to_owned(),
1110                    ],
1111                ));
1112            } else if previous.revision != candidate.revision
1113                && candidate.down_revision != previous.revision
1114            {
1115                findings.push(finding(
1116                    "database.migration_predecessor_conflict",
1117                    "database.migration",
1118                    CompatibilityStatus::PotentiallyBreaking,
1119                    vec![
1120                        "candidate migration does not directly follow the previous revision"
1121                            .to_owned(),
1122                    ],
1123                    vec![
1124                        data_locator(before, previous.evidence.get()),
1125                        data_locator(after, candidate.evidence.get()),
1126                    ],
1127                    vec![
1128                        "resolve migration branches and validate the complete predecessor graph"
1129                            .to_owned(),
1130                    ],
1131                ));
1132            }
1133            if previous
1134                .order_hint
1135                .zip(candidate.order_hint)
1136                .is_some_and(|(previous_order, candidate_order)| candidate_order < previous_order)
1137            {
1138                findings.push(finding(
1139                    "database.migration_order_regressed",
1140                    "database.migration",
1141                    CompatibilityStatus::Breaking,
1142                    vec!["candidate migration order precedes the previous order hint".to_owned()],
1143                    vec![
1144                        data_locator(before, previous.evidence.get()),
1145                        data_locator(after, candidate.evidence.get()),
1146                    ],
1147                    vec![
1148                        "apply the complete migration sequence to an empty and upgraded database"
1149                            .to_owned(),
1150                    ],
1151                ));
1152            }
1153        }
1154        (Some(previous), None) => findings.push(finding(
1155            "database.migration_metadata_removed",
1156            "database.migration",
1157            CompatibilityStatus::Unknown,
1158            vec!["candidate document lacks previously modeled migration metadata".to_owned()],
1159            vec![data_locator(before, previous.evidence.get())],
1160            vec!["restore migration ancestry metadata before release".to_owned()],
1161        )),
1162        (None, Some(candidate)) => findings.push(finding(
1163            "database.migration_metadata_added",
1164            "database.migration",
1165            CompatibilityStatus::Unknown,
1166            vec![
1167                "migration ancestry was added without a comparable predecessor document".to_owned(),
1168            ],
1169            vec![data_locator(after, candidate.evidence.get())],
1170            vec!["validate the candidate against the deployed migration head".to_owned()],
1171        )),
1172        (None, None) => {}
1173    }
1174}
1175
1176fn database_finding(
1177    code: &str,
1178    path: &str,
1179    status: CompatibilityStatus,
1180    line: u32,
1181    factor: &str,
1182) -> CompatibilityFinding {
1183    finding(
1184        code,
1185        path,
1186        status,
1187        vec![factor.to_owned()],
1188        vec![format!("line:{line}")],
1189        vec!["run database migration and affected reader/writer integration tests".to_owned()],
1190    )
1191}
1192
1193fn data_locator(document: &DataDocument, line: u32) -> String {
1194    format!("{}:{line}", document.source_path)
1195}
1196
1197fn compare_graphql_type(
1198    before: &GraphqlTypeDefinition,
1199    after: &GraphqlTypeDefinition,
1200    findings: &mut Vec<CompatibilityFinding>,
1201) {
1202    let after_fields = after
1203        .fields
1204        .iter()
1205        .map(|field| (field.name.as_str(), field))
1206        .collect::<BTreeMap<_, _>>();
1207    for previous_field in &before.fields {
1208        let Some(current_field) = after_fields.get(previous_field.name.as_str()) else {
1209            findings.push(graphql_breaking(
1210                "graphql.field_removed",
1211                &previous_field.coordinate,
1212                previous_field.lines.start,
1213                "field is absent from the candidate schema",
1214            ));
1215            continue;
1216        };
1217        compare_graphql_field(previous_field, current_field, findings);
1218    }
1219    let after_enum_values = after.enum_values.iter().collect::<BTreeSet<_>>();
1220    for value in &before.enum_values {
1221        if !after_enum_values.contains(value) {
1222            findings.push(graphql_breaking(
1223                "graphql.enum_value_removed",
1224                &format!("{}.{}", before.name, value),
1225                before.lines.start,
1226                "enum value is absent from the candidate schema",
1227            ));
1228        }
1229    }
1230}
1231
1232fn compare_graphql_field(
1233    before: &GraphqlFieldDefinition,
1234    after: &GraphqlFieldDefinition,
1235    findings: &mut Vec<CompatibilityFinding>,
1236) {
1237    if type_narrowed(&before.type_ref, &after.type_ref) {
1238        findings.push(graphql_breaking(
1239            "graphql.return_type_narrowed",
1240            &before.coordinate,
1241            after.lines.start,
1242            &format!(
1243                "return type changed from {} to {}",
1244                before.type_ref.as_graphql(),
1245                after.type_ref.as_graphql()
1246            ),
1247        ));
1248    }
1249    let before_arguments = before
1250        .arguments
1251        .iter()
1252        .map(|argument| argument.name.as_str())
1253        .collect::<BTreeSet<_>>();
1254    for argument in &after.arguments {
1255        if !before_arguments.contains(argument.name.as_str())
1256            && type_is_non_null(&argument.type_ref)
1257            && argument.default_value_kind.is_none()
1258        {
1259            findings.push(graphql_breaking(
1260                "graphql.required_argument_added",
1261                &format!("{}({})", before.coordinate, argument.name),
1262                argument.lines.start,
1263                "new argument is non-null and has no default",
1264            ));
1265        }
1266    }
1267}
1268
1269fn compare_persisted_operations(
1270    before: &GraphqlDocument,
1271    after: &GraphqlDocument,
1272    findings: &mut Vec<CompatibilityFinding>,
1273) {
1274    let current = after
1275        .persisted_operations
1276        .iter()
1277        .map(|operation| operation.id.as_str())
1278        .collect::<BTreeSet<_>>();
1279    for operation in &before.persisted_operations {
1280        if !current.contains(operation.id.as_str()) {
1281            findings.push(graphql_breaking(
1282                "graphql.persisted_operation_invalidated",
1283                &operation.id,
1284                operation.lines.start,
1285                "persisted operation identifier is absent from the candidate manifest",
1286            ));
1287        }
1288    }
1289}
1290
1291fn event_key(observation: &EventObservation) -> Option<String> {
1292    observation.channel.as_ref().map(|channel| {
1293        format!(
1294            "{:?}:{}:{channel}:{:?}",
1295            observation.broker,
1296            observation.namespace.as_deref().unwrap_or(""),
1297            observation.role
1298        )
1299    })
1300}
1301
1302fn compare_event_observation(
1303    before: &EventObservation,
1304    after: &EventObservation,
1305    path: &str,
1306    findings: &mut Vec<CompatibilityFinding>,
1307) {
1308    if before.routing_key != after.routing_key || before.partition_key != after.partition_key {
1309        findings.push(event_breaking(
1310            "event.routing_key_changed",
1311            path,
1312            after,
1313            "routing or partition key declaration changed",
1314        ));
1315    }
1316    let (Some(before_schema), Some(after_schema)) = (&before.schema, &after.schema) else {
1317        if before.schema.is_some() != after.schema.is_some() {
1318            findings.push(finding(
1319                "event.schema_coverage_changed",
1320                path,
1321                CompatibilityStatus::Unknown,
1322                vec!["payload schema is absent from one side of the comparison".to_owned()],
1323                event_evidence(after),
1324                vec!["validate producer and consumer payloads at runtime".to_owned()],
1325            ));
1326        }
1327        return;
1328    };
1329    if before_schema.version != after_schema.version
1330        && before_schema.version.is_some()
1331        && after_schema.version.is_some()
1332    {
1333        findings.push(event_breaking(
1334            "event.schema_version_changed",
1335            path,
1336            after,
1337            "explicit schema version changed",
1338        ));
1339    }
1340    let before_fields = before_schema
1341        .fields
1342        .iter()
1343        .map(|field| (field.name.as_str(), field))
1344        .collect::<BTreeMap<_, _>>();
1345    let after_fields = after_schema
1346        .fields
1347        .iter()
1348        .map(|field| (field.name.as_str(), field))
1349        .collect::<BTreeMap<_, _>>();
1350    for (name, previous) in &before_fields {
1351        let Some(candidate) = after_fields.get(name) else {
1352            findings.push(event_breaking(
1353                "event.field_removed",
1354                &format!("{path}.{name}"),
1355                after,
1356                "payload field is absent from the candidate schema",
1357            ));
1358            continue;
1359        };
1360        if previous.field_type != candidate.field_type {
1361            findings.push(event_breaking(
1362                "event.field_type_changed",
1363                &format!("{path}.{name}"),
1364                after,
1365                "payload field type changed",
1366            ));
1367        }
1368    }
1369    for (name, candidate) in after_fields {
1370        if candidate.required && !before_fields.contains_key(name) {
1371            findings.push(event_breaking(
1372                "event.required_field_added",
1373                &format!("{path}.{name}"),
1374                after,
1375                "new payload field is required",
1376            ));
1377        }
1378    }
1379}
1380
1381fn compare_proto_messages(
1382    before: &ProtoFile,
1383    after: &ProtoFile,
1384    findings: &mut Vec<CompatibilityFinding>,
1385) {
1386    let before_messages = flatten_messages(&before.messages);
1387    let after_messages = flatten_messages(&after.messages);
1388    for (name, previous) in before_messages {
1389        let Some(candidate) = after_messages.get(name) else {
1390            findings.push(proto_breaking(
1391                "protobuf.message_removed",
1392                name,
1393                previous.line,
1394                "message is absent from the candidate schema",
1395            ));
1396            continue;
1397        };
1398        let fields_by_number = candidate
1399            .fields
1400            .iter()
1401            .map(|field| (field.number, field))
1402            .collect::<BTreeMap<_, _>>();
1403        let fields_by_name = candidate
1404            .fields
1405            .iter()
1406            .map(|field| (field.name.as_str(), field))
1407            .collect::<BTreeMap<_, _>>();
1408        for field in &previous.fields {
1409            let Some(number_match) = fields_by_number.get(&field.number) else {
1410                findings.push(proto_breaking(
1411                    "protobuf.field_removed",
1412                    &format!("{name}.{}", field.name),
1413                    field.line,
1414                    "field number is absent from the candidate message",
1415                ));
1416                continue;
1417            };
1418            if number_match.name != field.name {
1419                findings.push(proto_breaking(
1420                    "protobuf.field_number_reused",
1421                    &format!("{name}.{}", field.number),
1422                    number_match.line,
1423                    "field number is assigned to a different name",
1424                ));
1425            }
1426            if number_match.wire_type != field.wire_type
1427                || number_match.type_name != field.type_name
1428                || number_match.cardinality != field.cardinality
1429            {
1430                findings.push(proto_breaking(
1431                    "protobuf.field_wire_incompatible",
1432                    &format!("{name}.{}", field.number),
1433                    number_match.line,
1434                    "field type, wire encoding, or cardinality changed",
1435                ));
1436            }
1437            if let Some(name_match) = fields_by_name.get(field.name.as_str())
1438                && name_match.number != field.number
1439            {
1440                findings.push(proto_breaking(
1441                    "protobuf.field_number_changed",
1442                    &format!("{name}.{}", field.name),
1443                    name_match.line,
1444                    "field name moved to a different number",
1445                ));
1446            }
1447        }
1448        for field in &candidate.fields {
1449            if field.cardinality == ProtoFieldCardinality::Required
1450                && !previous
1451                    .fields
1452                    .iter()
1453                    .any(|previous| previous.name == field.name)
1454            {
1455                findings.push(proto_breaking(
1456                    "protobuf.required_field_added",
1457                    &format!("{name}.{}", field.name),
1458                    field.line,
1459                    "required proto2 field was added",
1460                ));
1461            }
1462        }
1463    }
1464}
1465
1466fn compare_proto_enums(
1467    before: &ProtoFile,
1468    after: &ProtoFile,
1469    findings: &mut Vec<CompatibilityFinding>,
1470) {
1471    let before_enums = flatten_enums(&before.messages, &before.enums);
1472    let after_enums = flatten_enums(&after.messages, &after.enums);
1473    for (name, previous) in before_enums {
1474        let Some(candidate) = after_enums.get(name) else {
1475            findings.push(proto_breaking(
1476                "protobuf.enum_removed",
1477                name,
1478                previous.line,
1479                "enum is absent from the candidate schema",
1480            ));
1481            continue;
1482        };
1483        let current_numbers = candidate
1484            .values
1485            .iter()
1486            .map(|value| (value.number, value.name.as_str()))
1487            .collect::<BTreeMap<_, _>>();
1488        for value in &previous.values {
1489            if current_numbers.get(&value.number) != Some(&value.name.as_str()) {
1490                findings.push(proto_breaking(
1491                    "protobuf.enum_numeric_changed",
1492                    &format!("{name}.{}", value.name),
1493                    value.line,
1494                    "enum numeric value was removed or reassigned",
1495                ));
1496            }
1497        }
1498    }
1499}
1500
1501fn compare_proto_services(
1502    before: &ProtoFile,
1503    after: &ProtoFile,
1504    findings: &mut Vec<CompatibilityFinding>,
1505) {
1506    let current = after
1507        .services
1508        .iter()
1509        .map(|service| (service.full_name.as_str(), service))
1510        .collect::<BTreeMap<_, _>>();
1511    for service in &before.services {
1512        let Some(candidate) = current.get(service.full_name.as_str()) else {
1513            findings.push(proto_breaking(
1514                "protobuf.service_removed",
1515                &service.full_name,
1516                service.line,
1517                "service is absent from the candidate schema",
1518            ));
1519            continue;
1520        };
1521        let methods = candidate
1522            .methods
1523            .iter()
1524            .map(|method| (method.name.as_str(), method))
1525            .collect::<BTreeMap<_, _>>();
1526        for method in &service.methods {
1527            let path = format!("{}/{}", service.full_name, method.name);
1528            let Some(current_method) = methods.get(method.name.as_str()) else {
1529                findings.push(proto_breaking(
1530                    "protobuf.method_removed",
1531                    &path,
1532                    method.line,
1533                    "RPC method is absent from the candidate service",
1534                ));
1535                continue;
1536            };
1537            if method.request_type != current_method.request_type
1538                || method.response_type != current_method.response_type
1539                || method.client_streaming != current_method.client_streaming
1540                || method.server_streaming != current_method.server_streaming
1541            {
1542                findings.push(proto_breaking(
1543                    "protobuf.method_signature_changed",
1544                    &path,
1545                    current_method.line,
1546                    "RPC request, response, or streaming mode changed",
1547                ));
1548            }
1549        }
1550    }
1551}
1552
1553fn flatten_messages(messages: &[ProtoMessage]) -> BTreeMap<&str, &ProtoMessage> {
1554    fn append<'a>(messages: &'a [ProtoMessage], output: &mut BTreeMap<&'a str, &'a ProtoMessage>) {
1555        for message in messages {
1556            output.insert(message.full_name.as_str(), message);
1557            append(&message.messages, output);
1558        }
1559    }
1560    let mut output = BTreeMap::new();
1561    append(messages, &mut output);
1562    output
1563}
1564
1565fn flatten_enums<'a>(
1566    messages: &'a [ProtoMessage],
1567    enums: &'a [ProtoEnum],
1568) -> BTreeMap<&'a str, &'a ProtoEnum> {
1569    fn append<'a>(messages: &'a [ProtoMessage], output: &mut BTreeMap<&'a str, &'a ProtoEnum>) {
1570        for message in messages {
1571            for enumeration in &message.enums {
1572                output.insert(enumeration.full_name.as_str(), enumeration);
1573            }
1574            append(&message.messages, output);
1575        }
1576    }
1577    let mut output = enums
1578        .iter()
1579        .map(|enumeration| (enumeration.full_name.as_str(), enumeration))
1580        .collect::<BTreeMap<_, _>>();
1581    append(messages, &mut output);
1582    output
1583}
1584
1585fn type_narrowed(before: &GraphqlTypeRef, after: &GraphqlTypeRef) -> bool {
1586    if before == after {
1587        return false;
1588    }
1589    match (before, after) {
1590        (
1591            GraphqlTypeRef::Named {
1592                name: before_name,
1593                non_null: before_non_null,
1594            },
1595            GraphqlTypeRef::Named {
1596                name: after_name,
1597                non_null: after_non_null,
1598            },
1599        ) => before_name != after_name || (!before_non_null && *after_non_null),
1600        (
1601            GraphqlTypeRef::List {
1602                element: before_element,
1603                non_null: before_non_null,
1604            },
1605            GraphqlTypeRef::List {
1606                element: after_element,
1607                non_null: after_non_null,
1608            },
1609        ) => (!before_non_null && *after_non_null) || type_narrowed(before_element, after_element),
1610        _ => true,
1611    }
1612}
1613
1614fn type_is_non_null(type_ref: &GraphqlTypeRef) -> bool {
1615    match type_ref {
1616        GraphqlTypeRef::Named { non_null, .. } | GraphqlTypeRef::List { non_null, .. } => *non_null,
1617    }
1618}
1619
1620fn graphql_breaking(code: &str, path: &str, line: u32, factor: &str) -> CompatibilityFinding {
1621    finding(
1622        code,
1623        path,
1624        CompatibilityStatus::Breaking,
1625        vec![factor.to_owned()],
1626        vec![format!("line:{line}")],
1627        vec!["run affected GraphQL consumer operations against the candidate schema".to_owned()],
1628    )
1629}
1630
1631fn event_breaking(
1632    code: &str,
1633    path: &str,
1634    observation: &EventObservation,
1635    factor: &str,
1636) -> CompatibilityFinding {
1637    finding(
1638        code,
1639        path,
1640        CompatibilityStatus::Breaking,
1641        vec![factor.to_owned()],
1642        event_evidence(observation),
1643        vec!["validate all event producers and consumers against the candidate schema".to_owned()],
1644    )
1645}
1646
1647fn event_evidence(observation: &EventObservation) -> Vec<String> {
1648    observation
1649        .evidence
1650        .iter()
1651        .map(|evidence| format!("line:{}:{}", evidence.line, evidence.text))
1652        .collect()
1653}
1654
1655fn proto_breaking(code: &str, path: &str, line: u32, factor: &str) -> CompatibilityFinding {
1656    finding(
1657        code,
1658        path,
1659        CompatibilityStatus::Breaking,
1660        vec![factor.to_owned()],
1661        vec![format!("line:{line}")],
1662        vec![
1663            "regenerate clients and run protobuf wire-compatibility tests before release"
1664                .to_owned(),
1665        ],
1666    )
1667}
1668
1669fn finding(
1670    code: &str,
1671    path: &str,
1672    status: CompatibilityStatus,
1673    factors: Vec<String>,
1674    evidence: Vec<String>,
1675    recommended_validations: Vec<String>,
1676) -> CompatibilityFinding {
1677    CompatibilityFinding {
1678        code: code.to_owned(),
1679        path: path.to_owned(),
1680        status,
1681        factors: bounded_values(factors),
1682        evidence: bounded_values(evidence),
1683        recommended_validations: bounded_values(recommended_validations),
1684    }
1685}
1686
1687fn bounded_values(values: Vec<String>) -> Vec<String> {
1688    values
1689        .into_iter()
1690        .take(MAX_FINDING_VALUES)
1691        .map(|value| value.chars().take(MAX_FINDING_VALUE_CHARS).collect())
1692        .collect()
1693}
1694
1695fn fingerprints<T: Serialize>(
1696    before: &T,
1697    after: &T,
1698    family: &str,
1699    findings: &mut Vec<CompatibilityFinding>,
1700) -> (String, String) {
1701    let before = structured_fingerprint(before);
1702    let after = structured_fingerprint(after);
1703    if before.is_none() || after.is_none() {
1704        findings.push(finding(
1705            &format!("{family}.fingerprint_unavailable"),
1706            family,
1707            CompatibilityStatus::Unknown,
1708            vec!["structured contract serialization failed".to_owned()],
1709            Vec::new(),
1710            vec!["report the serialization failure and rerun extraction".to_owned()],
1711        ));
1712    }
1713    (
1714        before.unwrap_or_else(|| "unavailable".to_owned()),
1715        after.unwrap_or_else(|| "unavailable".to_owned()),
1716    )
1717}
1718
1719fn structured_fingerprint<T: Serialize>(value: &T) -> Option<String> {
1720    serde_json::to_vec(value)
1721        .ok()
1722        .map(|encoded| blake3::hash(&encoded).to_hex().to_string())
1723}
1724
1725fn finish_report(
1726    before_fingerprint: String,
1727    after_fingerprint: String,
1728    mut findings: Vec<CompatibilityFinding>,
1729) -> CompatibilityReport {
1730    findings.sort_by(|left, right| left.path.cmp(&right.path).then(left.code.cmp(&right.code)));
1731    findings.dedup_by(|left, right| left.path == right.path && left.code == right.code);
1732    let status = if findings
1733        .iter()
1734        .any(|finding| finding.status == CompatibilityStatus::Breaking)
1735    {
1736        CompatibilityStatus::Breaking
1737    } else if findings
1738        .iter()
1739        .any(|finding| finding.status == CompatibilityStatus::PotentiallyBreaking)
1740    {
1741        CompatibilityStatus::PotentiallyBreaking
1742    } else if findings
1743        .iter()
1744        .any(|finding| finding.status == CompatibilityStatus::Incomparable)
1745    {
1746        CompatibilityStatus::Incomparable
1747    } else if findings
1748        .iter()
1749        .any(|finding| finding.status == CompatibilityStatus::Unknown)
1750    {
1751        CompatibilityStatus::Unknown
1752    } else {
1753        CompatibilityStatus::Compatible
1754    };
1755    CompatibilityReport {
1756        status,
1757        before_fingerprint,
1758        after_fingerprint,
1759        findings,
1760    }
1761}
1762
1763#[cfg(test)]
1764mod tests {
1765    use code_system_graph_model::RepoId;
1766
1767    use super::{
1768        CompatibilityStatus, compare_database_contracts, compare_event_contracts, compare_graphql_contracts, compare_http_contracts, compare_package_contracts, compare_protobuf_contracts
1769    };
1770    use crate::{
1771        DataWarning, MigrationMetadata, extract_asyncapi, extract_data_artifact, extract_graphql_document, extract_openapi, extract_package_manifest, extract_protobuf
1772    };
1773
1774    const OPENAPI_PREFIX: &str =
1775        r#"{"openapi":"3.0.0","info":{"title":"test","version":"1"},"paths":{"#;
1776    const OPENAPI_SUFFIX: &str = "}}";
1777
1778    fn http_contract(operations: &str) -> Vec<crate::HttpBoundary> {
1779        let source = format!("{OPENAPI_PREFIX}{operations}{OPENAPI_SUFFIX}");
1780        extract_openapi(&RepoId::new("repo:test"), "openapi.json", &source)
1781            .expect("test OpenAPI contract should parse")
1782    }
1783
1784    fn package_contract(source: &str) -> crate::PackageManifest {
1785        extract_package_manifest("package.json", source)
1786            .expect("test package manifest should parse")
1787    }
1788
1789    fn database_contract(path: &str, source: &str) -> crate::DataDocument {
1790        extract_data_artifact(path, source).expect("test database contract should parse")
1791    }
1792
1793    #[test]
1794    fn graphql_comparison_should_detect_removed_field_and_required_argument() {
1795        let before = extract_graphql_document(
1796            "before.graphql",
1797            include_str!("../../../fixtures/contracts/protocols/graphql/before.graphql"),
1798        );
1799        let after = extract_graphql_document(
1800            "after.graphql",
1801            include_str!("../../../fixtures/contracts/protocols/graphql/after.graphql"),
1802        );
1803        let report = before
1804            .as_ref()
1805            .ok()
1806            .zip(after.as_ref().ok())
1807            .map(|(before, after)| compare_graphql_contracts(before, after));
1808
1809        assert!(matches!(
1810            report,
1811            Some(report)
1812                if report.status == CompatibilityStatus::Breaking
1813                    && report.findings.iter().any(|finding| {
1814                        finding.code == "graphql.field_removed"
1815                            && finding.path == "Order.status"
1816                    })
1817                    && report.findings.iter().any(|finding| {
1818                        finding.code == "graphql.required_argument_added"
1819                            && finding.path == "Query.order(region)"
1820                    })
1821        ));
1822    }
1823
1824    #[test]
1825    fn event_comparison_should_detect_required_field_and_type_changes() {
1826        let before = extract_asyncapi(
1827            "before.yaml",
1828            include_str!("../../../fixtures/contracts/protocols/event/before.yaml"),
1829        );
1830        let after = extract_asyncapi(
1831            "after.yaml",
1832            include_str!("../../../fixtures/contracts/protocols/event/after.yaml"),
1833        );
1834        let report = before
1835            .as_ref()
1836            .ok()
1837            .zip(after.as_ref().ok())
1838            .map(|(before, after)| compare_event_contracts(before, after));
1839
1840        assert!(matches!(
1841            report,
1842            Some(report)
1843                if report.status == CompatibilityStatus::Breaking
1844                    && report
1845                        .findings
1846                        .iter()
1847                        .any(|finding| finding.code == "event.required_field_added")
1848                    && report
1849                        .findings
1850                        .iter()
1851                        .any(|finding| finding.code == "event.field_type_changed")
1852        ));
1853    }
1854
1855    #[test]
1856    fn protobuf_comparison_should_detect_field_number_wire_reuse() {
1857        let before = extract_protobuf(
1858            "before.proto",
1859            include_str!("../../../fixtures/contracts/protocols/protobuf/before.proto"),
1860        );
1861        let after = extract_protobuf(
1862            "after.proto",
1863            include_str!("../../../fixtures/contracts/protocols/protobuf/after.proto"),
1864        );
1865        let report = before
1866            .as_ref()
1867            .ok()
1868            .zip(after.as_ref().ok())
1869            .map(|(before, after)| compare_protobuf_contracts(before, after));
1870
1871        assert!(matches!(
1872            report,
1873            Some(report)
1874                if report.status == CompatibilityStatus::Breaking
1875                    && report.findings.iter().any(|finding| {
1876                        finding.code == "protobuf.field_number_reused"
1877                    })
1878                    && report.findings.iter().any(|finding| {
1879                        finding.code == "protobuf.field_wire_incompatible"
1880                    })
1881        ));
1882    }
1883
1884    #[test]
1885    fn http_comparison_should_report_removed_operation_as_breaking() {
1886        let before =
1887            http_contract(r#""/orders":{"get":{"responses":{"200":{"description":"ok"}}}}"#);
1888        let after = Vec::new();
1889
1890        let report = compare_http_contracts(&before, &after);
1891
1892        assert!(matches!(
1893            report,
1894            report
1895                if report.status == CompatibilityStatus::Breaking
1896                    && report.before_fingerprint != report.after_fingerprint
1897                    && report.findings.iter().any(|finding| {
1898                        finding.code == "http.operation_removed"
1899                            && !finding.evidence.is_empty()
1900                            && !finding.recommended_validations.is_empty()
1901                    })
1902        ));
1903    }
1904
1905    #[test]
1906    fn http_comparison_should_report_exact_addition_but_not_full_schema_compatibility() {
1907        let after =
1908            http_contract(r#""/orders":{"post":{"responses":{"201":{"description":"created"}}}}"#);
1909
1910        let report = compare_http_contracts(&[], &after);
1911
1912        assert!(matches!(
1913            report,
1914            report
1915                if report.status == CompatibilityStatus::Incomparable
1916                    && report.findings.iter().any(|finding| {
1917                        finding.code == "http.operation_added"
1918                            && finding.status == CompatibilityStatus::Compatible
1919                    })
1920                    && report.findings.iter().any(|finding| {
1921                        finding.code == "http.schema_semantics_unmodeled"
1922                    })
1923        ));
1924    }
1925
1926    #[test]
1927    fn http_comparison_should_report_unique_method_change_as_potentially_breaking() {
1928        let before =
1929            http_contract(r#""/orders":{"get":{"responses":{"200":{"description":"ok"}}}}"#);
1930        let mut after = before.clone();
1931        after[0].method = "POST".to_owned();
1932
1933        let report = compare_http_contracts(&before, &after);
1934
1935        assert!(matches!(
1936            report,
1937            report
1938                if report.status == CompatibilityStatus::PotentiallyBreaking
1939                    && report
1940                        .findings
1941                        .iter()
1942                        .any(|finding| finding.code == "http.operation_changed")
1943        ));
1944    }
1945
1946    #[test]
1947    fn http_comparison_should_report_unique_path_change_as_potentially_breaking() {
1948        let before =
1949            http_contract(r#""/orders":{"get":{"responses":{"200":{"description":"ok"}}}}"#);
1950        let mut after = before.clone();
1951        after[0].path = "/v2/orders".to_owned();
1952
1953        let report = compare_http_contracts(&before, &after);
1954
1955        assert!(matches!(
1956            report,
1957            report
1958                if report.status == CompatibilityStatus::PotentiallyBreaking
1959                    && report
1960                        .findings
1961                        .iter()
1962                        .any(|finding| finding.code == "http.operation_changed")
1963        ));
1964    }
1965
1966    #[test]
1967    fn http_comparison_should_report_duplicate_and_incomplete_inventory() {
1968        let mut before =
1969            http_contract(r#""/orders":{"get":{"responses":{"200":{"description":"ok"}}}}"#);
1970        before[0].evidence.confidence = 0.5;
1971        before.push(before[0].clone());
1972
1973        let report = compare_http_contracts(&before, &before);
1974
1975        assert!(matches!(
1976            report,
1977            report
1978                if report.status == CompatibilityStatus::Incomparable
1979                    && report.findings.iter().any(|finding| {
1980                        finding.code == "http.operation_duplicate"
1981                    })
1982                    && report.findings.iter().any(|finding| {
1983                        finding.code == "http.operation_incomplete"
1984                            && finding.status == CompatibilityStatus::Unknown
1985                    })
1986        ));
1987    }
1988
1989    #[test]
1990    fn package_comparison_should_report_removed_export_as_breaking() {
1991        let before = package_contract(
1992            r#"{"name":"demo","version":"1.0.0","exports":{".":"./index.js","./admin":"./admin.js"}}"#,
1993        );
1994        let after =
1995            package_contract(r#"{"name":"demo","version":"1.0.0","exports":{".":"./index.js"}}"#);
1996
1997        let report = compare_package_contracts(&before, &after);
1998
1999        assert!(matches!(
2000            report,
2001            report
2002                if report.status == CompatibilityStatus::Breaking
2003                    && report.findings.iter().any(|finding| {
2004                        finding.code == "package.export_removed" && finding.path == "./admin"
2005                    })
2006        ));
2007    }
2008
2009    #[test]
2010    fn package_comparison_should_report_coordinate_rename_as_breaking() {
2011        let before = package_contract(r#"{"name":"old-name","version":"1.0.0"}"#);
2012        let after = package_contract(r#"{"name":"new-name","version":"1.0.0"}"#);
2013
2014        let report = compare_package_contracts(&before, &after);
2015
2016        assert!(matches!(
2017            report,
2018            report
2019                if report.status == CompatibilityStatus::Breaking
2020                    && report
2021                        .findings
2022                        .iter()
2023                        .any(|finding| finding.code == "package.coordinate_renamed")
2024        ));
2025    }
2026
2027    #[test]
2028    fn package_comparison_should_report_major_version_and_peer_range_drift() {
2029        let before = package_contract(
2030            r#"{"name":"demo","version":"1.0.0","dependencies":{"router":"^1.0.0"},"peerDependencies":{"react":"^18.0.0"}}"#,
2031        );
2032        let after = package_contract(
2033            r#"{"name":"demo","version":"2.0.0","dependencies":{"router":"^2.0.0"},"peerDependencies":{"react":"^19.0.0"}}"#,
2034        );
2035
2036        let report = compare_package_contracts(&before, &after);
2037
2038        assert!(matches!(
2039            report,
2040            report
2041                if report.status == CompatibilityStatus::Breaking
2042                    && report.findings.iter().any(|finding| {
2043                        finding.code == "package.declared_version_changed"
2044                            && finding.status == CompatibilityStatus::Breaking
2045                    })
2046                    && report.findings.iter().any(|finding| {
2047                        finding.code == "package.dependency_drift"
2048                            && finding.status == CompatibilityStatus::PotentiallyBreaking
2049                    })
2050                    && report
2051                        .findings
2052                        .iter()
2053                        .filter(|finding| finding.code == "package.dependency_drift")
2054                        .count()
2055                        == 2
2056        ));
2057    }
2058
2059    #[test]
2060    fn package_comparison_should_report_feature_workspace_and_cycle_limitations() {
2061        let mut before = package_contract(
2062            r#"{"name":"demo","version":"1.0.0","exports":{"./feature":"./feature.js"},"workspaces":["a"]}"#,
2063        );
2064        let mut after = before.clone();
2065        before.features = before.exports.clone();
2066        after.features.clear();
2067        after.workspace_members[0].value = "b".to_owned();
2068
2069        let report = compare_package_contracts(&before, &after);
2070
2071        assert!(matches!(
2072            report,
2073            report
2074                if report.status == CompatibilityStatus::PotentiallyBreaking
2075                    && report
2076                        .findings
2077                        .iter()
2078                        .any(|finding| finding.code == "package.feature_removed")
2079                    && report.findings.iter().any(|finding| {
2080                        finding.code == "package.workspace_cycle_unmodeled"
2081                            && finding.status == CompatibilityStatus::Unknown
2082                    })
2083        ));
2084    }
2085
2086    #[test]
2087    fn package_comparison_should_report_unmodeled_types_and_duplicate_exports() {
2088        let mut manifest =
2089            package_contract(r#"{"name":"demo","version":"1.0.0","exports":{".":"./index.js"}}"#);
2090        manifest.exports.push(manifest.exports[0].clone());
2091
2092        let report = compare_package_contracts(&manifest, &manifest);
2093
2094        assert!(matches!(
2095            report,
2096            report
2097                if report.status == CompatibilityStatus::Incomparable
2098                    && report.findings.iter().any(|finding| {
2099                        finding.code == "package.export_duplicate"
2100                    })
2101                    && report.findings.iter().any(|finding| {
2102                        finding.code == "package.type_declarations_unmodeled"
2103                            && finding.status == CompatibilityStatus::Unknown
2104                    })
2105        ));
2106    }
2107
2108    #[test]
2109    fn database_comparison_should_report_removed_table_and_column_as_breaking() {
2110        let before = database_contract(
2111            "schema.sql",
2112            "CREATE TABLE users (id BIGINT PRIMARY KEY, email TEXT); CREATE TABLE audit (id BIGINT);",
2113        );
2114        let after = database_contract("schema.sql", "CREATE TABLE users (id BIGINT PRIMARY KEY);");
2115
2116        let report = compare_database_contracts(&before, &after);
2117
2118        assert!(matches!(
2119            report,
2120            report
2121                if report.status == CompatibilityStatus::Breaking
2122                    && report
2123                        .findings
2124                        .iter()
2125                        .any(|finding| finding.code == "database.table_removed")
2126                    && report
2127                        .findings
2128                        .iter()
2129                        .any(|finding| finding.code == "database.column_removed")
2130        ));
2131    }
2132
2133    #[test]
2134    fn database_comparison_should_report_type_narrowing_and_nullability() {
2135        let before = database_contract(
2136            "schema.sql",
2137            "CREATE TABLE users (id BIGINT, email VARCHAR(255) NULL);",
2138        );
2139        let after = database_contract(
2140            "schema.sql",
2141            "CREATE TABLE users (id INTEGER, email VARCHAR(64) NOT NULL);",
2142        );
2143
2144        let report = compare_database_contracts(&before, &after);
2145
2146        assert!(matches!(
2147            report,
2148            report
2149                if report.status == CompatibilityStatus::Breaking
2150                    && report.findings.iter().filter(|finding| {
2151                        finding.code == "database.column_type_changed"
2152                            && finding.status == CompatibilityStatus::Breaking
2153                    }).count() == 2
2154                    && report.findings.iter().any(|finding| {
2155                        finding.code == "database.column_nullability_changed"
2156                            && finding.status == CompatibilityStatus::Breaking
2157                    })
2158        ));
2159    }
2160
2161    #[test]
2162    fn database_comparison_should_report_defaulted_non_null_as_potentially_breaking() {
2163        let before = database_contract("schema.sql", "CREATE TABLE users (email TEXT NULL);");
2164        let after = database_contract(
2165            "schema.sql",
2166            "CREATE TABLE users (email TEXT DEFAULT 'redacted' NOT NULL);",
2167        );
2168
2169        let report = compare_database_contracts(&before, &after);
2170
2171        assert!(report.findings.iter().any(|finding| {
2172            finding.code == "database.column_nullability_changed"
2173                && finding.status == CompatibilityStatus::PotentiallyBreaking
2174        }));
2175    }
2176
2177    #[test]
2178    fn database_comparison_should_report_unclassified_type_change_as_potentially_breaking() {
2179        let before = database_contract("schema.sql", "CREATE TABLE users (id UUID);");
2180        let after = database_contract("schema.sql", "CREATE TABLE users (id TEXT);");
2181
2182        let report = compare_database_contracts(&before, &after);
2183
2184        assert!(report.findings.iter().any(|finding| {
2185            finding.code == "database.column_type_changed"
2186                && finding.status == CompatibilityStatus::PotentiallyBreaking
2187        }));
2188    }
2189
2190    #[test]
2191    fn database_comparison_should_report_removed_constraints() {
2192        let before = database_contract(
2193            "schema.sql",
2194            "CREATE TABLE orgs (id BIGINT PRIMARY KEY); \
2195             CREATE TABLE users (id BIGINT PRIMARY KEY, email TEXT UNIQUE, org_id BIGINT, \
2196             FOREIGN KEY (org_id) REFERENCES orgs(id)); \
2197             CREATE INDEX users_email_idx ON users(email);",
2198        );
2199        let after = database_contract(
2200            "schema.sql",
2201            "CREATE TABLE orgs (id BIGINT PRIMARY KEY); \
2202             CREATE TABLE users (id BIGINT, email TEXT, org_id BIGINT);",
2203        );
2204
2205        let report = compare_database_contracts(&before, &after);
2206
2207        assert!(matches!(
2208            report,
2209            report
2210                if report.status == CompatibilityStatus::Breaking
2211                    && report
2212                        .findings
2213                        .iter()
2214                        .any(|finding| finding.code == "database.primary_key_removed")
2215                    && report.findings.iter().any(|finding| {
2216                        finding.code == "database.unique_constraint_removed"
2217                    })
2218                    && report
2219                        .findings
2220                        .iter()
2221                        .any(|finding| finding.code == "database.index_removed")
2222                    && report
2223                        .findings
2224                        .iter()
2225                        .any(|finding| finding.code == "database.foreign_key_removed")
2226        ));
2227    }
2228
2229    #[test]
2230    fn database_comparison_should_report_migration_predecessor_and_order_conflicts() {
2231        let mut before = database_contract(
2232            "migrations/002_users.sql",
2233            "CREATE TABLE users (id BIGINT);",
2234        );
2235        let mut after = before.clone();
2236        let evidence = before.tables[0].evidence;
2237        before.migration = Some(MigrationMetadata {
2238            revision: Some("002".to_owned()),
2239            down_revision: Some("001".to_owned()),
2240            order_hint: Some(2),
2241            reversible: false,
2242            evidence,
2243        });
2244        after.migration = Some(MigrationMetadata {
2245            revision: Some("002".to_owned()),
2246            down_revision: Some("000".to_owned()),
2247            order_hint: Some(1),
2248            reversible: false,
2249            evidence,
2250        });
2251
2252        let report = compare_database_contracts(&before, &after);
2253
2254        assert!(matches!(
2255            report,
2256            report
2257                if report.status == CompatibilityStatus::Breaking
2258                    && report.findings.iter().any(|finding| {
2259                        finding.code == "database.migration_predecessor_changed"
2260                    })
2261                    && report.findings.iter().any(|finding| {
2262                        finding.code == "database.migration_order_regressed"
2263                    })
2264        ));
2265    }
2266
2267    #[test]
2268    fn database_comparison_should_report_extraction_and_shared_consumer_unknowns() {
2269        let before = database_contract("schema.sql", "CREATE TABLE users (id BIGINT);");
2270        let mut after = before.clone();
2271        after.incomplete = true;
2272        after.warnings.push(DataWarning::UnsupportedConstruct);
2273
2274        let report = compare_database_contracts(&before, &after);
2275
2276        assert!(matches!(
2277            report,
2278            report
2279                if report.status == CompatibilityStatus::Unknown
2280                    && report.findings.iter().any(|finding| {
2281                        finding.code == "database.extraction_incomplete"
2282                    })
2283                    && report.findings.iter().any(|finding| {
2284                        finding.code == "database.shared_consumer_impact_unmodeled"
2285                    })
2286        ));
2287    }
2288}