-
Notifications
You must be signed in to change notification settings - Fork 278
feat: add breaking change detection #1482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jochenchrist
merged 21 commits into
datacontract:main
from
pierre-monnet:breaking_change
Sep 2, 2026
+1,174
−32
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
9b796b0
feat: add breaking change detection
pierre-monnet 7d470d3
fix: sidebar_position
pierre-monnet 26b79bf
Merge branch 'main' into breaking_change
pierre-monnet d3a19ca
test: add CLI tests for breaking changes
pierre-monnet 8a08c4d
feat: implement breaking changes detection and response structure
pierre-monnet 4bf85d9
Merge branch 'main' into breaking_change
pierre-monnet 02d4dc3
Merge branch 'main' into breaking_change
pierre-monnet a73dc81
Merge branch 'main' into breaking_change
pierre-monnet 52fea4d
Grade the removal of a nested property as breaking
jschoedl aec84fd
Grade an added optional column as informational
jschoedl 61ea42a
Do not grade changes inside a newly added element
jschoedl 8b920ae
Assert the full breaking output against a golden file
jschoedl c5a71fb
Drop the breaking CLI test duplicated in test_breaking.py
jschoedl e6bc008
Drop the unused BreakingChangeResult helpers and package re-exports
jschoedl 7433350
Summarize from the prefix index without re-filtering it
jschoedl 4193d58
Add the breaking tag to the OpenAPI spec
jschoedl 3f741e4
Describe the breaking changes response accurately
jschoedl 5ddebc8
Document POST /breaking and DataContract.breaking()
jschoedl 6fe198f
Stop committing the generated breaking command page
jschoedl bdb748a
Link the changelog and breaking command pages to their guide
jschoedl 68ea066
Reference the issue instead of the PR in the changelog entry
jschoedl File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| from collections import defaultdict | ||
| from collections.abc import Iterable | ||
|
|
||
| from datacontract.breaking.rules import DEFAULT_RULES, BreakingChangeRule | ||
| from datacontract.model.breaking import BreakingChangeEntry, BreakingChangeLevel, BreakingChangeResult | ||
| from datacontract.model.changelog import ChangelogEntry, ChangelogResult, ChangelogType | ||
|
|
||
| _LEVEL_ORDER = { | ||
| BreakingChangeLevel.INFO: 0, | ||
| BreakingChangeLevel.WARNING: 1, | ||
| BreakingChangeLevel.ERROR: 2, | ||
| } | ||
|
|
||
|
|
||
| class BreakingChangeDetector: | ||
| """Classify detailed changelog entries using an ordered rule set.""" | ||
|
|
||
| def __init__(self, rules: Iterable[BreakingChangeRule] = DEFAULT_RULES): | ||
| self._rules = tuple(rules) | ||
| if not self._rules: | ||
| raise ValueError("At least one breaking-change rule is required") | ||
|
|
||
| def detect(self, changelog: ChangelogResult) -> BreakingChangeResult: | ||
| entries = [self._classify(entry) for entry in changelog.entries] | ||
| # Nothing inside a newly added element can break a consumer: it did not exist before. | ||
| added = {entry.path for entry in entries if entry.change_type == ChangelogType.added} | ||
| for entry in entries: | ||
| segments = entry.path.split(".") | ||
| if any(".".join(segments[:i]) in added for i in range(1, len(segments))): | ||
| entry.level = BreakingChangeLevel.INFO | ||
| entries_by_prefix = defaultdict(list) | ||
| for entry in entries: | ||
| prefix = "" | ||
| for segment in entry.path.split("."): | ||
| prefix = f"{prefix}.{segment}" if prefix else segment | ||
| entries_by_prefix[prefix].append(entry) | ||
| summary = [self._summarize(entry, entries_by_prefix.get(entry.path, [])) for entry in changelog.summary] | ||
| return BreakingChangeResult(v1=changelog.v1, v2=changelog.v2, summary=summary, entries=entries) | ||
|
jschoedl marked this conversation as resolved.
|
||
|
|
||
| def _classify(self, entry: ChangelogEntry) -> BreakingChangeEntry: | ||
| for rule in self._rules: | ||
| evaluation = rule.evaluate(entry) | ||
| if evaluation is not None: | ||
| return BreakingChangeEntry( | ||
| path=entry.path, | ||
| change_type=entry.type, | ||
| level=evaluation.level, | ||
| message=evaluation.message, | ||
| rule_id=evaluation.rule_id, | ||
| old_value=entry.old_value, | ||
| new_value=entry.new_value, | ||
| ) | ||
| raise ValueError(f"No breaking-change rule classified {entry.path}") | ||
|
|
||
| @staticmethod | ||
| def _summarize(summary_entry: ChangelogEntry, entries: list[BreakingChangeEntry]) -> BreakingChangeEntry: | ||
| if not entries: | ||
| return BreakingChangeEntry( | ||
| path=summary_entry.path, | ||
| change_type=summary_entry.type, | ||
| level=BreakingChangeLevel.INFO, | ||
| message=f"Changed contract at {summary_entry.path}", | ||
| rule_id="summary-fallback", | ||
| ) | ||
| highest = max(entries, key=lambda entry: _LEVEL_ORDER[entry.level]) | ||
| return BreakingChangeEntry( | ||
| path=summary_entry.path, | ||
| change_type=summary_entry.type, | ||
| level=highest.level, | ||
| message=f"{highest.level.value.capitalize()} impact at {summary_entry.path}", | ||
| rule_id=f"summary:{highest.rule_id}", | ||
| old_value=highest.old_value, | ||
| new_value=highest.new_value, | ||
| ) | ||
|
jschoedl marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| from abc import ABC, abstractmethod | ||
| from dataclasses import dataclass | ||
| from datetime import date | ||
|
|
||
| from datacontract.model.breaking import BreakingChangeLevel | ||
| from datacontract.model.changelog import ChangelogEntry, ChangelogType | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RuleEvaluation: | ||
| rule_id: str | ||
| level: BreakingChangeLevel | ||
| message: str | ||
|
|
||
|
|
||
| class BreakingChangeRule(ABC): | ||
| rule_id: str | ||
|
|
||
| @abstractmethod | ||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| """Return a classification when this rule applies to ``entry``.""" | ||
|
|
||
|
|
||
| class SchemaRemovedRule(BreakingChangeRule): | ||
| rule_id = "schema-removed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| segments = entry.path.split(".") | ||
| if entry.type == ChangelogType.removed and len(segments) == 2 and segments[0] == "schema": | ||
| return RuleEvaluation(self.rule_id, BreakingChangeLevel.ERROR, f"Removed schema {segments[1]}") | ||
| return None | ||
|
|
||
|
|
||
| class FieldRemovedRule(BreakingChangeRule): | ||
| rule_id = "field-removed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| segments = entry.path.split(".") | ||
| if entry.type != ChangelogType.removed or not _is_schema_property_path(segments): | ||
| return None | ||
| property_name = segments[-1] | ||
| return RuleEvaluation(self.rule_id, BreakingChangeLevel.ERROR, f"Removed property {property_name}") | ||
|
|
||
|
|
||
| class RequiredChangedRule(BreakingChangeRule): | ||
| rule_id = "required-changed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| if not entry.path.endswith(".required"): | ||
| return None | ||
| old = _parse_bool(entry.old_value) | ||
| new = _parse_bool(entry.new_value) | ||
| if entry.type == ChangelogType.added and new is True: | ||
| level = BreakingChangeLevel.ERROR | ||
| elif entry.type == ChangelogType.removed or new is False: | ||
| # Dropping the requirement, or writing out the optional default, only loosens the contract. | ||
| level = BreakingChangeLevel.INFO | ||
| elif entry.type == ChangelogType.updated and old is False and new is True: | ||
| level = BreakingChangeLevel.ERROR | ||
| elif entry.type in (ChangelogType.added, ChangelogType.updated): | ||
| level = BreakingChangeLevel.WARNING | ||
| else: | ||
| return None | ||
| return RuleEvaluation(self.rule_id, level, _change_message("requiredness", entry)) | ||
|
|
||
|
|
||
| class TypeChangedRule(BreakingChangeRule): | ||
| rule_id = "type-changed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| if not entry.path.endswith((".logicalType", ".physicalType")): | ||
| return None | ||
| if entry.type == ChangelogType.updated: | ||
| level = BreakingChangeLevel.ERROR | ||
| elif entry.type == ChangelogType.added: | ||
| level = BreakingChangeLevel.INFO | ||
| elif entry.type == ChangelogType.removed: | ||
| level = BreakingChangeLevel.WARNING | ||
| else: | ||
| return None | ||
| return RuleEvaluation(self.rule_id, level, _change_message("type", entry)) | ||
|
|
||
|
|
||
| class UniqueConstraintRule(BreakingChangeRule): | ||
| rule_id = "unique-constraint-changed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| if not entry.path.endswith(".unique"): | ||
| return None | ||
| old = _parse_bool(entry.old_value) | ||
| new = _parse_bool(entry.new_value) | ||
| if new is True and old is not True: | ||
| level = BreakingChangeLevel.ERROR | ||
| elif old is True and new is not True: | ||
| level = BreakingChangeLevel.INFO | ||
| else: | ||
| level = BreakingChangeLevel.WARNING | ||
| return RuleEvaluation(self.rule_id, level, _change_message("uniqueness", entry)) | ||
|
|
||
|
|
||
| class KeyConstraintRule(BreakingChangeRule): | ||
| rule_id = "key-constraint-changed" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| if not entry.path.endswith((".primaryKey", ".primary_key")): | ||
| return None | ||
| return RuleEvaluation(self.rule_id, BreakingChangeLevel.WARNING, _change_message("key constraint", entry)) | ||
|
|
||
|
|
||
| class ValidationConstraintRule(BreakingChangeRule): | ||
| rule_id = "validation-constraint-changed" | ||
| _suffixes = ( | ||
| ".pattern", | ||
| ".minLength", | ||
| ".maxLength", | ||
| ".minimum", | ||
| ".maximum", | ||
| ".exclusiveMinimum", | ||
| ".exclusiveMaximum", | ||
| ) | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation | None: | ||
| if not entry.path.endswith(self._suffixes): | ||
| return None | ||
| old = _parse_number(entry.old_value) | ||
| new = _parse_number(entry.new_value) | ||
| if old is None or new is None: | ||
| level = BreakingChangeLevel.WARNING | ||
| elif _is_tightening(entry.path, old, new): | ||
| level = BreakingChangeLevel.ERROR | ||
| else: | ||
| level = BreakingChangeLevel.INFO | ||
|
jschoedl marked this conversation as resolved.
|
||
| return RuleEvaluation(self.rule_id, level, _change_message("validation constraint", entry)) | ||
|
|
||
|
|
||
| class MetadataFallbackRule(BreakingChangeRule): | ||
| rule_id = "metadata-or-unknown-change" | ||
|
|
||
| def evaluate(self, entry: ChangelogEntry) -> RuleEvaluation: | ||
| return RuleEvaluation(self.rule_id, BreakingChangeLevel.INFO, _change_message("contract", entry)) | ||
|
|
||
|
|
||
| def _is_schema_property_path(segments: list[str]) -> bool: | ||
| return len(segments) >= 3 and segments[0] == "schema" and segments[-2] == "properties" | ||
|
|
||
|
|
||
| def _parse_bool(value: str | None) -> bool | None: | ||
| if value is None: | ||
| return None | ||
| normalized = value.strip().lower() | ||
| if normalized in {"true", "1"}: | ||
| return True | ||
| if normalized in {"false", "0"}: | ||
| return False | ||
| return None | ||
|
|
||
|
|
||
| def _parse_number(value: str | None) -> float | None: | ||
| if value is None: | ||
| return None | ||
| normalized = value.strip() | ||
| try: | ||
| return float(normalized) | ||
| except ValueError: | ||
| try: | ||
| return float(date.fromisoformat(normalized).toordinal()) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| def _is_tightening(path: str, old: float, new: float) -> bool: | ||
| if path.endswith((".minLength", ".minimum", ".exclusiveMinimum")): | ||
| return new > old | ||
| if path.endswith((".maxLength", ".maximum", ".exclusiveMaximum")): | ||
| return new < old | ||
| return True | ||
|
|
||
|
|
||
| def _change_message(subject: str, entry: ChangelogEntry) -> str: | ||
| if entry.type == ChangelogType.added: | ||
| return f"Added {subject} at {entry.path}" | ||
| if entry.type == ChangelogType.removed: | ||
| return f"Removed {subject} at {entry.path}" | ||
| return f"Changed {subject} at {entry.path} from {entry.old_value!r} to {entry.new_value!r}" | ||
|
|
||
|
|
||
| DEFAULT_RULES: tuple[BreakingChangeRule, ...] = ( | ||
| RequiredChangedRule(), | ||
| SchemaRemovedRule(), | ||
| FieldRemovedRule(), | ||
| TypeChangedRule(), | ||
| UniqueConstraintRule(), | ||
| KeyConstraintRule(), | ||
| ValidationConstraintRule(), | ||
| MetadataFallbackRule(), | ||
| ) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.