patchahead 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. patchahead-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,170 @@
1
+ """Migration planning: deciding *how* to fix what analysis found.
2
+
3
+ Finding the problem and deciding how to fix it are separate stages with separate
4
+ outputs. A :class:`MigrationPlan` is produced, serialized, and inspectable
5
+ *before* any source file is touched -- ``patchahead migrate --dry-run`` stops
6
+ exactly here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import enum
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from patchahead.domain.change import BreakingChange, Confidence
16
+ from patchahead.domain.impact import CodeReference
17
+
18
+
19
+ class Risk(str, enum.Enum):
20
+ """How risky applying this plan is, independent of the change's severity.
21
+
22
+ Severity says "how bad if we do nothing". Risk says "how bad if we do this".
23
+ A HIGH-severity change can have a LOW-risk migration (a scoped rename), and
24
+ that distinction is what a reviewer actually needs.
25
+ """
26
+
27
+ LOW = "low"
28
+ MEDIUM = "medium"
29
+ HIGH = "high"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TextEdit:
34
+ """A replacement of one source range with new text.
35
+
36
+ The unit of patching. Editing ranges rather than regenerating files is what
37
+ keeps diffs minimal and leaves comments, blank lines, and formatting outside
38
+ the edited span exactly as the author wrote them.
39
+
40
+ Coordinates follow ``ast``: ``line`` is 1-indexed, columns are 0-indexed
41
+ into the line, and ``end_col`` is exclusive.
42
+ """
43
+
44
+ line: int
45
+ col: int
46
+ end_line: int
47
+ end_col: int
48
+ new_text: str
49
+ #: What this edit accomplishes, shown in the plan.
50
+ description: str = ""
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "line": self.line,
55
+ "col": self.col,
56
+ "end_line": self.end_line,
57
+ "end_col": self.end_col,
58
+ "new_text": self.new_text,
59
+ "description": self.description,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class Transformation:
65
+ """One concrete edit the plan intends to make, with its provenance.
66
+
67
+ Carries both the human-readable ``old``/``new`` (for the plan a person
68
+ reads) and the machine-applicable ``edit`` (for the patcher).
69
+ """
70
+
71
+ reference: CodeReference
72
+ old: str
73
+ new: str
74
+ edit: TextEdit
75
+ #: The enclosing function/method this edit lands in.
76
+ symbol: str = ""
77
+ confidence: Confidence = Confidence.MEDIUM
78
+
79
+ @property
80
+ def path(self) -> str:
81
+ return self.reference.path
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ return {
85
+ "reference": self.reference.to_dict(),
86
+ "old": self.old,
87
+ "new": self.new,
88
+ "symbol": self.symbol,
89
+ "confidence": self.confidence.value,
90
+ "edit": self.edit.to_dict(),
91
+ }
92
+
93
+
94
+ @dataclass
95
+ class MigrationPlan:
96
+ """What will be changed, where, why, and what should verify it.
97
+
98
+ Serializable and inspectable by design: ``patchahead migrate --dry-run
99
+ --json`` emits exactly this, and a reviewer can read it without trusting
100
+ that the patcher did what the plan says.
101
+ """
102
+
103
+ change: BreakingChange
104
+ #: The handler that produced this plan, e.g. "field_rename".
105
+ handler: str
106
+ transformations: list[Transformation] = field(default_factory=list)
107
+ #: Tests that should exercise the migrated behavior.
108
+ expected_tests: list[str] = field(default_factory=list)
109
+ risk: Risk = Risk.MEDIUM
110
+ #: One-paragraph explanation of the strategy, shown in reports.
111
+ rationale: str = ""
112
+ #: Sites analysis found but the plan deliberately leaves alone.
113
+ skipped: list[str] = field(default_factory=list)
114
+ #: Set when the handler could not produce a plan at all.
115
+ blocked_reason: str = ""
116
+
117
+ @property
118
+ def is_empty(self) -> bool:
119
+ return not self.transformations
120
+
121
+ @property
122
+ def target_files(self) -> list[str]:
123
+ seen: list[str] = []
124
+ for transformation in self.transformations:
125
+ if transformation.path not in seen:
126
+ seen.append(transformation.path)
127
+ return seen
128
+
129
+ def edits_for(self, path: str) -> list[TextEdit]:
130
+ return [t.edit for t in self.transformations if t.path == path]
131
+
132
+ def render(self) -> str:
133
+ """The compact text rendering used by the CLI."""
134
+ lines = [
135
+ "MigrationPlan",
136
+ f"- change_kind: {self.change.kind.value}",
137
+ f"- handler: {self.handler}",
138
+ f"- risk: {self.risk.value.upper()}",
139
+ ]
140
+ for path in self.target_files:
141
+ lines.append(f"- target_file: {path}")
142
+ for transformation in self.transformations:
143
+ if transformation.path != path:
144
+ continue
145
+ where = f"{transformation.symbol}:{transformation.reference.line}"
146
+ lines.append(f" - {where}")
147
+ lines.append(f" old: {transformation.old}")
148
+ lines.append(f" new: {transformation.new}")
149
+ if self.expected_tests:
150
+ lines.append("- expected_tests:")
151
+ lines.extend(f" {test}" for test in self.expected_tests)
152
+ if self.skipped:
153
+ lines.append("- skipped (not patched):")
154
+ lines.extend(f" {item}" for item in self.skipped)
155
+ if self.blocked_reason:
156
+ lines.append(f"- blocked: {self.blocked_reason}")
157
+ return "\n".join(lines)
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ return {
161
+ "change": self.change.to_dict(),
162
+ "handler": self.handler,
163
+ "risk": self.risk.value,
164
+ "rationale": self.rationale,
165
+ "transformations": [t.to_dict() for t in self.transformations],
166
+ "target_files": self.target_files,
167
+ "expected_tests": self.expected_tests,
168
+ "skipped": self.skipped,
169
+ "blocked_reason": self.blocked_reason,
170
+ }
@@ -0,0 +1,210 @@
1
+ """Top-level results returned by the engine to the CLI, the web UI, and tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from patchahead.domain.completeness import CompletenessReport
10
+ from patchahead.domain.impact import ImpactReport
11
+ from patchahead.domain.patch import PatchProposal
12
+ from patchahead.domain.plan import MigrationPlan
13
+ from patchahead.domain.validation import TestRun, ValidationResult
14
+
15
+
16
+ class Outcome(str, enum.Enum):
17
+ """Why a migration run ended the way it did.
18
+
19
+ Distinguishing these is the whole point: "we could not find anything to
20
+ change", "we refused to try", and "we tried and the tests failed" are three
21
+ very different messages to a user, and the prototype reported all of them as
22
+ the same boolean.
23
+ """
24
+
25
+ #: Patch generated and every gate passed.
26
+ MIGRATED = "migrated"
27
+ #: Patch generated, at least one gate failed. Diff is still available.
28
+ VALIDATION_FAILED = "validation_failed"
29
+ #: Patch generated and no gate objected, but no test gate ran -- so nothing
30
+ #: verified it. Produced by `--no-tests`, or a repository with no tests.
31
+ PATCHED_UNVERIFIED = "patched_unverified"
32
+ #: Analysis found nothing to change. Not an error.
33
+ NO_IMPACT = "no_impact"
34
+ #: The change kind has no handler in this version.
35
+ UNSUPPORTED_CHANGE = "unsupported_change"
36
+ #: A handler matched but declined to patch (e.g. unrecognized code shape).
37
+ NOT_PLANNABLE = "not_plannable"
38
+ #: Planning succeeded but patch generation failed.
39
+ PATCH_FAILED = "patch_failed"
40
+ #: ``--dry-run``: stopped after planning by request.
41
+ DRY_RUN = "dry_run"
42
+
43
+ def __str__(self) -> str: # pragma: no cover - trivial
44
+ return self.value
45
+
46
+
47
+ @dataclass
48
+ class AnalysisResult:
49
+ """What ``patchahead analyze`` returns."""
50
+
51
+ repo: str
52
+ change_document: str
53
+ reports: list[ImpactReport] = field(default_factory=list)
54
+ #: Wall-clock timings per stage, in milliseconds.
55
+ timings: dict[str, int] = field(default_factory=dict)
56
+ warnings: list[str] = field(default_factory=list)
57
+
58
+ @property
59
+ def has_impact(self) -> bool:
60
+ return any(r.has_impact for r in self.reports)
61
+
62
+ @property
63
+ def total_findings(self) -> int:
64
+ return sum(len(r.findings) for r in self.reports)
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ return {
68
+ "repo": self.repo,
69
+ "change_document": self.change_document,
70
+ "has_impact": self.has_impact,
71
+ "total_findings": self.total_findings,
72
+ "reports": [r.to_dict() for r in self.reports],
73
+ "timings": self.timings,
74
+ "warnings": self.warnings,
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class MigrationResult:
80
+ """What ``patchahead migrate`` returns, for one breaking change."""
81
+
82
+ outcome: Outcome
83
+ impact: ImpactReport
84
+ plan: MigrationPlan | None = None
85
+ proposal: PatchProposal | None = None
86
+ validation: ValidationResult | None = None
87
+ #: Test state before patching, used by the migration-assertion gate.
88
+ baseline_tests: TestRun | None = None
89
+ #: Absolute path of the isolated workspace, when it was kept.
90
+ workspace_path: str = ""
91
+ #: Paths of artifacts written to the output directory.
92
+ artifacts: dict[str, str] = field(default_factory=dict)
93
+ #: Human-readable explanation of the outcome. Always populated.
94
+ message: str = ""
95
+ timings: dict[str, int] = field(default_factory=dict)
96
+ #: What is left of the old API in the patched copy. Set only when a patch
97
+ #: was produced and validated.
98
+ completeness: CompletenessReport | None = None
99
+
100
+ @property
101
+ def succeeded(self) -> bool:
102
+ """A migration succeeded only if the tests verified it. No other path."""
103
+ return self.outcome is Outcome.MIGRATED and bool(
104
+ self.validation and self.validation.verified
105
+ )
106
+
107
+ @property
108
+ def diff(self) -> str:
109
+ return self.proposal.diff if self.proposal else ""
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "outcome": self.outcome.value,
114
+ "succeeded": self.succeeded,
115
+ "message": self.message,
116
+ "impact": self.impact.to_dict(),
117
+ "plan": self.plan.to_dict() if self.plan else None,
118
+ "proposal": self.proposal.to_dict() if self.proposal else None,
119
+ "validation": self.validation.to_dict() if self.validation else None,
120
+ "baseline_tests": self.baseline_tests.to_dict() if self.baseline_tests else None,
121
+ "completeness": self.completeness.to_dict() if self.completeness else None,
122
+ "workspace_path": self.workspace_path,
123
+ "artifacts": self.artifacts,
124
+ "timings": self.timings,
125
+ }
126
+
127
+
128
+ @dataclass
129
+ class MigrationRun:
130
+ """All migration results for one change document against one repository."""
131
+
132
+ repo: str
133
+ change_document: str
134
+ results: list[MigrationResult] = field(default_factory=list)
135
+ #: The verdict on the combined result of every change in the document. All
136
+ #: changes are patched into one workspace and validated together, because
137
+ #: changes in one release note are frequently interdependent.
138
+ validation: ValidationResult | None = None
139
+ warnings: list[str] = field(default_factory=list)
140
+ #: One unified diff of every change the run patched, against the original
141
+ #: repository -- the patch a reviewer would apply. Empty when nothing was
142
+ #: patched.
143
+ diff: str = ""
144
+
145
+ @property
146
+ def actionable_results(self) -> list[MigrationResult]:
147
+ """Results for changes that had work to do.
148
+
149
+ A change with no downstream impact, or one this version cannot migrate,
150
+ is not a failure -- but it is also not a migration, so it does not count
151
+ toward (or against) the run's success.
152
+ """
153
+ return [
154
+ result
155
+ for result in self.results
156
+ if result.outcome not in (Outcome.NO_IMPACT, Outcome.UNSUPPORTED_CHANGE)
157
+ ]
158
+
159
+ @property
160
+ def succeeded(self) -> bool:
161
+ """True when there was work to do and all of it passed validation."""
162
+ actionable = self.actionable_results
163
+ return bool(actionable) and all(result.succeeded for result in actionable)
164
+
165
+ @property
166
+ def complete(self) -> bool:
167
+ """No patched change left code, a dynamic access, or a test on its old name."""
168
+ return all(r.completeness.complete for r in self.results if r.completeness)
169
+
170
+ @property
171
+ def outcome(self) -> Outcome | None:
172
+ """The run's verdict in one word, for a CI step or a badge.
173
+
174
+ `migrated` only when :attr:`succeeded`. Otherwise the result that most
175
+ needs a reader's attention: a failed check outranks a refusal, which
176
+ outranks an unverified patch, which outranks "nothing to do".
177
+ """
178
+ if self.succeeded:
179
+ return Outcome.MIGRATED
180
+ present = {result.outcome for result in self.results}
181
+ for outcome in _ATTENTION_ORDER:
182
+ if outcome in present:
183
+ return outcome
184
+ return None
185
+
186
+ def to_dict(self) -> dict[str, Any]:
187
+ return {
188
+ "repo": self.repo,
189
+ "change_document": self.change_document,
190
+ "succeeded": self.succeeded,
191
+ "complete": self.complete,
192
+ "outcome": self.outcome.value if self.outcome else None,
193
+ "validation": self.validation.to_dict() if self.validation else None,
194
+ "results": [r.to_dict() for r in self.results],
195
+ "warnings": self.warnings,
196
+ "diff": self.diff,
197
+ }
198
+
199
+
200
+ #: Most to least in need of attention; see :attr:`MigrationRun.outcome`.
201
+ _ATTENTION_ORDER = (
202
+ Outcome.VALIDATION_FAILED,
203
+ Outcome.PATCH_FAILED,
204
+ Outcome.NOT_PLANNABLE,
205
+ Outcome.PATCHED_UNVERIFIED,
206
+ Outcome.MIGRATED,
207
+ Outcome.DRY_RUN,
208
+ Outcome.UNSUPPORTED_CHANGE,
209
+ Outcome.NO_IMPACT,
210
+ )
@@ -0,0 +1,200 @@
1
+ """Validation results: the gates a proposal must pass to be called a migration.
2
+
3
+ Two properties, and the difference between them is the point.
4
+ :attr:`ValidationResult.passed` is true when no gate failed and at least one
5
+ actually ran -- necessary, and not sufficient.
6
+ :attr:`ValidationResult.verified` additionally requires the
7
+ ``migration_assertion`` gate to have *passed*, meaning a test that failed before
8
+ the patch passes after it.
9
+
10
+ :attr:`~patchahead.domain.result.MigrationResult.succeeded` is defined in terms
11
+ of ``verified``, not ``passed``: a run where every gate was happy but nothing
12
+ demonstrated the break was fixed reports ``patched_unverified``, which is not a
13
+ success. Nothing else in the codebase is permitted to decide that a migration
14
+ worked.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import enum
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+
24
+ class GateName(str, enum.Enum):
25
+ """The five gates, in the order they run.
26
+
27
+ Cheap and decisive gates run first so an unsafe proposal never reaches the
28
+ stage that executes repository code.
29
+ """
30
+
31
+ SYNTAX = "syntax"
32
+ SCOPE = "scope"
33
+ TARGETED_TESTS = "targeted_tests"
34
+ REGRESSION_TESTS = "regression_tests"
35
+ MIGRATION_ASSERTION = "migration_assertion"
36
+
37
+ def __str__(self) -> str: # pragma: no cover - trivial
38
+ return self.value
39
+
40
+
41
+ class GateStatus(str, enum.Enum):
42
+ PASSED = "passed"
43
+ FAILED = "failed"
44
+ #: Not run, for a stated reason (e.g. no tests found, ``--no-tests``).
45
+ SKIPPED = "skipped"
46
+
47
+ def __str__(self) -> str: # pragma: no cover - trivial
48
+ return self.value
49
+
50
+
51
+ @dataclass
52
+ class TestRun:
53
+ """The structured result of executing a test command."""
54
+
55
+ #: Tells pytest not to try to collect this as a test class. It is a domain
56
+ #: object whose name happens to start with "Test".
57
+ __test__ = False
58
+
59
+ command: str
60
+ returncode: int
61
+ stdout: str = ""
62
+ stderr: str = ""
63
+ failing_tests: list[str] = field(default_factory=list)
64
+ summary: str = ""
65
+ duration_ms: int = 0
66
+ #: True when the command could not be run at all (not found, timed out).
67
+ errored: bool = False
68
+
69
+ @property
70
+ def passed(self) -> bool:
71
+ return self.returncode == 0 and not self.errored
72
+
73
+ def to_dict(self, *, tail: int = 2000) -> dict[str, Any]:
74
+ return {
75
+ "command": self.command,
76
+ "returncode": self.returncode,
77
+ "passed": self.passed,
78
+ "errored": self.errored,
79
+ "failing_tests": self.failing_tests,
80
+ "summary": self.summary,
81
+ "duration_ms": self.duration_ms,
82
+ "stdout_tail": self.stdout[-tail:],
83
+ "stderr_tail": self.stderr[-tail:],
84
+ }
85
+
86
+
87
+ @dataclass
88
+ class GateResult:
89
+ """One gate's verdict, with the detail needed to debug a failure."""
90
+
91
+ name: GateName
92
+ status: GateStatus
93
+ #: One line explaining the verdict. Always populated.
94
+ detail: str
95
+ duration_ms: int = 0
96
+ test_run: TestRun | None = None
97
+
98
+ @property
99
+ def passed(self) -> bool:
100
+ return self.status is GateStatus.PASSED
101
+
102
+ @property
103
+ def failed(self) -> bool:
104
+ return self.status is GateStatus.FAILED
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return {
108
+ "name": self.name.value,
109
+ "status": self.status.value,
110
+ "detail": self.detail,
111
+ "duration_ms": self.duration_ms,
112
+ "test_run": self.test_run.to_dict() if self.test_run else None,
113
+ }
114
+
115
+
116
+ @dataclass
117
+ class ValidationResult:
118
+ """The verdict on a patch proposal: every gate, in order, with reasons."""
119
+
120
+ gates: list[GateResult] = field(default_factory=list)
121
+
122
+ @property
123
+ def passed(self) -> bool:
124
+ """True only if no gate failed and at least one gate actually ran.
125
+
126
+ An all-skipped result is not a pass. Refusing to check something is not
127
+ evidence that it is correct.
128
+ """
129
+ if not self.gates:
130
+ return False
131
+ if any(g.failed for g in self.gates):
132
+ return False
133
+ return any(g.passed for g in self.gates)
134
+
135
+ @property
136
+ def verified(self) -> bool:
137
+ """Passed *and* the tests proved the break was fixed.
138
+
139
+ Distinct from :attr:`passed` because "no gate objected" and "the tests
140
+ confirmed it" are different claims. Verification requires the
141
+ migration-assertion gate to have *passed*, which means a test that
142
+ failed before the patch passes after it.
143
+
144
+ Everything short of that is a patch without evidence:
145
+
146
+ ========================= ===========================================
147
+ Situation Assertion gate
148
+ ========================= ===========================================
149
+ red before, green after PASSED -> verified
150
+ green before, green after SKIPPED -> the tests do not cover the change
151
+ red before, still red SKIPPED -> nothing was repaired to point at
152
+ no runnable tests SKIPPED -> nothing ran
153
+ a test this patch broke the regression gate FAILS first
154
+ ========================= ===========================================
155
+
156
+ Verification requires affirmative evidence, never merely the absence of
157
+ a regression -- which is why every row but the first lands short of it.
158
+ """
159
+ assertion = self.get(GateName.MIGRATION_ASSERTION)
160
+ return self.passed and assertion is not None and assertion.passed
161
+
162
+ @property
163
+ def tests_ran(self) -> bool:
164
+ return any(
165
+ gate.name in (GateName.TARGETED_TESTS, GateName.REGRESSION_TESTS)
166
+ and gate.status is not GateStatus.SKIPPED
167
+ for gate in self.gates
168
+ )
169
+
170
+ @property
171
+ def failed_gates(self) -> list[GateResult]:
172
+ return [g for g in self.gates if g.failed]
173
+
174
+ @property
175
+ def skipped_gates(self) -> list[GateResult]:
176
+ return [g for g in self.gates if g.status is GateStatus.SKIPPED]
177
+
178
+ def get(self, name: GateName) -> GateResult | None:
179
+ for gate in self.gates:
180
+ if gate.name is name:
181
+ return gate
182
+ return None
183
+
184
+ def summary(self) -> str:
185
+ if not self.gates:
186
+ return "no gates ran"
187
+ if self.passed:
188
+ skipped = len(self.skipped_gates)
189
+ tail = f" ({skipped} skipped)" if skipped else ""
190
+ return f"{len(self.gates) - skipped}/{len(self.gates)} gates passed{tail}"
191
+ first = self.failed_gates[0]
192
+ return f"gate `{first.name.value}` failed: {first.detail}"
193
+
194
+ def to_dict(self) -> dict[str, Any]:
195
+ return {
196
+ "passed": self.passed,
197
+ "verified": self.verified,
198
+ "summary": self.summary(),
199
+ "gates": [g.to_dict() for g in self.gates],
200
+ }