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#[derive(
15 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum CompatibilityStatus {
19 Breaking,
21 PotentiallyBreaking,
23 Compatible,
25 Unknown,
27 Incomparable,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
33pub struct CompatibilityFinding {
34 pub code: String,
36 pub path: String,
38 pub status: CompatibilityStatus,
40 pub factors: Vec<String>,
42 pub evidence: Vec<String>,
44 pub recommended_validations: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct CompatibilityReport {
51 pub status: CompatibilityStatus,
53 pub before_fingerprint: String,
55 pub after_fingerprint: String,
57 pub findings: Vec<CompatibilityFinding>,
59}
60
61#[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#[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#[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#[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#[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#[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}