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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|