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,269 @@
|
|
|
1
|
+
"""The upstream side of the domain model: what broke, and how sure we are.
|
|
2
|
+
|
|
3
|
+
A :class:`BreakingChange` is the output of change ingestion and the input to
|
|
4
|
+
everything else. It carries two things that the prototype conflated:
|
|
5
|
+
|
|
6
|
+
* what the change *is* (:class:`ChangeKind` plus a :class:`SymbolTarget`), and
|
|
7
|
+
* how confident we are that we read the document correctly
|
|
8
|
+
(:class:`Confidence` plus the :class:`Evidence` we based it on).
|
|
9
|
+
|
|
10
|
+
Uncertain extraction is represented as uncertain. A document PatchAhead cannot
|
|
11
|
+
classify yields ``ChangeKind.UNKNOWN``, not a plausible-looking guess.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import enum
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChangeKind(str, enum.Enum):
|
|
22
|
+
"""The migration families PatchAhead knows about.
|
|
23
|
+
|
|
24
|
+
Every member except :attr:`UNKNOWN` and :attr:`UNSUPPORTED` has a handler in
|
|
25
|
+
:mod:`patchahead.handlers` with analysis, planning, patching, validation,
|
|
26
|
+
tests, and documentation. Adding a member without a handler is a bug --
|
|
27
|
+
:func:`patchahead.handlers.selftest_registry` asserts this.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
FIELD_RENAME = "field_rename"
|
|
31
|
+
METHOD_RENAME = "method_rename"
|
|
32
|
+
KWARG_RENAME = "kwarg_rename"
|
|
33
|
+
PAGINATION_PAGE_TO_CURSOR = "pagination_page_to_cursor"
|
|
34
|
+
|
|
35
|
+
#: Recognized as a breaking change, but no v1 handler can migrate it.
|
|
36
|
+
UNSUPPORTED = "unsupported"
|
|
37
|
+
#: The document could not be classified at all.
|
|
38
|
+
UNKNOWN = "unknown"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_actionable(self) -> bool:
|
|
42
|
+
return self not in (ChangeKind.UNSUPPORTED, ChangeKind.UNKNOWN)
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
45
|
+
return self.value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Confidence(str, enum.Enum):
|
|
49
|
+
"""Graded confidence, ordered.
|
|
50
|
+
|
|
51
|
+
Deliberately coarse. A float implies a calibration we do not have; three
|
|
52
|
+
buckets can be explained to a reviewer and tested against fixtures.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
HIGH = "high"
|
|
56
|
+
MEDIUM = "medium"
|
|
57
|
+
LOW = "low"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def rank(self) -> int:
|
|
61
|
+
return {"low": 0, "medium": 1, "high": 2}[self.value]
|
|
62
|
+
|
|
63
|
+
def __ge__(self, other: object) -> bool:
|
|
64
|
+
if not isinstance(other, Confidence):
|
|
65
|
+
return NotImplemented
|
|
66
|
+
return self.rank >= other.rank
|
|
67
|
+
|
|
68
|
+
def __gt__(self, other: object) -> bool:
|
|
69
|
+
if not isinstance(other, Confidence):
|
|
70
|
+
return NotImplemented
|
|
71
|
+
return self.rank > other.rank
|
|
72
|
+
|
|
73
|
+
def __le__(self, other: object) -> bool:
|
|
74
|
+
if not isinstance(other, Confidence):
|
|
75
|
+
return NotImplemented
|
|
76
|
+
return self.rank <= other.rank
|
|
77
|
+
|
|
78
|
+
def __lt__(self, other: object) -> bool:
|
|
79
|
+
if not isinstance(other, Confidence):
|
|
80
|
+
return NotImplemented
|
|
81
|
+
return self.rank < other.rank
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def parse(cls, value: object, default: Confidence = None) -> Confidence:
|
|
85
|
+
"""Coerce user/LLM-supplied text to a member, falling back to ``default``."""
|
|
86
|
+
if isinstance(value, Confidence):
|
|
87
|
+
return value
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
try:
|
|
90
|
+
return cls(value.strip().lower())
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass
|
|
93
|
+
if default is None:
|
|
94
|
+
raise ValueError(f"not a confidence level: {value!r}")
|
|
95
|
+
return default
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Severity(str, enum.Enum):
|
|
99
|
+
"""How bad the break is if left unmigrated. Reported, never acted upon."""
|
|
100
|
+
|
|
101
|
+
HIGH = "high"
|
|
102
|
+
MEDIUM = "medium"
|
|
103
|
+
LOW = "low"
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def parse(cls, value: object, default: Severity = None) -> Severity:
|
|
107
|
+
if isinstance(value, Severity):
|
|
108
|
+
return value
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
try:
|
|
111
|
+
return cls(value.strip().lower())
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
if default is None:
|
|
115
|
+
raise ValueError(f"not a severity: {value!r}")
|
|
116
|
+
return default
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Evidence:
|
|
121
|
+
"""A quotation from the change document that justifies a conclusion.
|
|
122
|
+
|
|
123
|
+
Evidence always points back at the source text. If PatchAhead asserts
|
|
124
|
+
something about a change, a reviewer can find the line it came from.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
quote: str
|
|
128
|
+
#: 1-indexed line in the source document, when known.
|
|
129
|
+
line: int | None = None
|
|
130
|
+
#: Free-text note on why this quote mattered, e.g. "matched 'renamed to'".
|
|
131
|
+
note: str = ""
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
return {"quote": self.quote, "line": self.line, "note": self.note}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class SymbolTarget:
|
|
139
|
+
"""The concrete syntactic thing a migration renames or rewrites.
|
|
140
|
+
|
|
141
|
+
``symbol`` and ``replacement`` are the old and new names. ``owner`` scopes
|
|
142
|
+
them: for a field rename it is the object the field hangs off
|
|
143
|
+
(``order["total"]`` -> owner ``order``), and for a kwarg rename it is the
|
|
144
|
+
function being called (``fetch_orders(timeout_seconds=...)`` -> owner
|
|
145
|
+
``fetch_orders``). ``owner`` is the main tool for suppressing false
|
|
146
|
+
positives, and it is optional because release notes often omit it.
|
|
147
|
+
|
|
148
|
+
``owner_is_explicit`` records *how strongly* the document asserted the
|
|
149
|
+
owner, and it decides whether a receiver mismatch refuses or merely lowers
|
|
150
|
+
confidence. The distinction is real:
|
|
151
|
+
|
|
152
|
+
* "the field on each ``order`` object was renamed" and
|
|
153
|
+
"``client.fetch_orders`` -> ``client.list_orders``" **assert** ownership.
|
|
154
|
+
A mismatched receiver is then evidence of a *different* object, and the
|
|
155
|
+
site is reported rather than patched.
|
|
156
|
+
* "**Before:** ``client.fetch_orders(limit=10)``" is the vendor's
|
|
157
|
+
illustrative variable name. It says nothing about what a downstream
|
|
158
|
+
repository calls its variable, and treating it as a constraint would
|
|
159
|
+
refuse to migrate ``api_client.fetch_orders()`` -- the same SDK call,
|
|
160
|
+
spelled with a different local name.
|
|
161
|
+
|
|
162
|
+
Structured change documents always set this to ``True``: someone typed the
|
|
163
|
+
owner into a field named ``owner``, which is an assertion by construction.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
symbol: str = ""
|
|
167
|
+
replacement: str = ""
|
|
168
|
+
owner: str = ""
|
|
169
|
+
#: Whether the document *asserted* the owner rather than it being inferred
|
|
170
|
+
#: from an illustrative snippet. See the class docstring.
|
|
171
|
+
owner_is_explicit: bool = False
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def is_rename(self) -> bool:
|
|
175
|
+
return bool(self.symbol and self.replacement and self.symbol != self.replacement)
|
|
176
|
+
|
|
177
|
+
def to_dict(self) -> dict[str, Any]:
|
|
178
|
+
return {
|
|
179
|
+
"symbol": self.symbol,
|
|
180
|
+
"replacement": self.replacement,
|
|
181
|
+
"owner": self.owner,
|
|
182
|
+
"owner_is_explicit": self.owner_is_explicit,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class PaginationContract:
|
|
188
|
+
"""Field and parameter names for a page-based -> cursor-based migration.
|
|
189
|
+
|
|
190
|
+
Defaults are the overwhelmingly common names. They are overridable so the
|
|
191
|
+
handler is not tied to one vendor's vocabulary.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
page_param: str = "page"
|
|
195
|
+
total_pages_key: str = "total_pages"
|
|
196
|
+
cursor_param: str = "cursor"
|
|
197
|
+
next_cursor_key: str = "next_cursor"
|
|
198
|
+
has_more_key: str = "has_more"
|
|
199
|
+
|
|
200
|
+
def to_dict(self) -> dict[str, Any]:
|
|
201
|
+
return {
|
|
202
|
+
"page_param": self.page_param,
|
|
203
|
+
"total_pages_key": self.total_pages_key,
|
|
204
|
+
"cursor_param": self.cursor_param,
|
|
205
|
+
"next_cursor_key": self.next_cursor_key,
|
|
206
|
+
"has_more_key": self.has_more_key,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_dict(cls, data: dict[str, Any] | None) -> PaginationContract:
|
|
211
|
+
data = data or {}
|
|
212
|
+
base = cls()
|
|
213
|
+
return cls(
|
|
214
|
+
page_param=str(data.get("page_param") or base.page_param),
|
|
215
|
+
total_pages_key=str(data.get("total_pages_key") or base.total_pages_key),
|
|
216
|
+
cursor_param=str(data.get("cursor_param") or base.cursor_param),
|
|
217
|
+
next_cursor_key=str(data.get("next_cursor_key") or base.next_cursor_key),
|
|
218
|
+
has_more_key=str(data.get("has_more_key") or base.has_more_key),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class BreakingChange:
|
|
224
|
+
"""One breaking change, extracted from one change document.
|
|
225
|
+
|
|
226
|
+
A document may describe several; ingestion returns a list of these.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
title: str
|
|
230
|
+
kind: ChangeKind
|
|
231
|
+
target: SymbolTarget = field(default_factory=SymbolTarget)
|
|
232
|
+
old_behavior: str = ""
|
|
233
|
+
new_behavior: str = ""
|
|
234
|
+
migration_hint: str = ""
|
|
235
|
+
severity: Severity = Severity.MEDIUM
|
|
236
|
+
#: How confident ingestion is that this reading of the document is correct.
|
|
237
|
+
confidence: Confidence = Confidence.MEDIUM
|
|
238
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
239
|
+
pagination: PaginationContract = field(default_factory=PaginationContract)
|
|
240
|
+
#: Which parser produced this: "markdown", "structured", or "llm".
|
|
241
|
+
source: str = "markdown"
|
|
242
|
+
#: Why classification landed where it did -- shown to the user verbatim.
|
|
243
|
+
classification_reason: str = ""
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def is_actionable(self) -> bool:
|
|
247
|
+
return self.kind.is_actionable
|
|
248
|
+
|
|
249
|
+
def describe(self) -> str:
|
|
250
|
+
"""A one-line human summary, used in logs and reports."""
|
|
251
|
+
if self.target.is_rename:
|
|
252
|
+
return f"{self.kind.value}: `{self.target.symbol}` -> `{self.target.replacement}`"
|
|
253
|
+
return f"{self.kind.value}: {self.title}"
|
|
254
|
+
|
|
255
|
+
def to_dict(self) -> dict[str, Any]:
|
|
256
|
+
return {
|
|
257
|
+
"title": self.title,
|
|
258
|
+
"kind": self.kind.value,
|
|
259
|
+
"target": self.target.to_dict(),
|
|
260
|
+
"old_behavior": self.old_behavior,
|
|
261
|
+
"new_behavior": self.new_behavior,
|
|
262
|
+
"migration_hint": self.migration_hint,
|
|
263
|
+
"severity": self.severity.value,
|
|
264
|
+
"confidence": self.confidence.value,
|
|
265
|
+
"evidence": [e.to_dict() for e in self.evidence],
|
|
266
|
+
"pagination": self.pagination.to_dict(),
|
|
267
|
+
"source": self.source,
|
|
268
|
+
"classification_reason": self.classification_reason,
|
|
269
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What is left of the old API after a migration.
|
|
2
|
+
|
|
3
|
+
Tests that pass after a patch show that the code they exercise works. They do
|
|
4
|
+
not show that the migration is *finished*: a wrapper no test calls, a
|
|
5
|
+
``getattr(order, "total")``, an import of the old name, or a test that still
|
|
6
|
+
mocks the old method can all survive a green run. A completeness report lists
|
|
7
|
+
every place the old name still appears in the patched copy, sorted by how much
|
|
8
|
+
it needs a person's attention.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResidualKind(str, enum.Enum):
|
|
19
|
+
"""Where an old name survived, from most to least in need of attention."""
|
|
20
|
+
|
|
21
|
+
#: Code that still uses the old name and was not rewritten.
|
|
22
|
+
CODE = "code"
|
|
23
|
+
#: The old name as a string handed to ``getattr``/``hasattr``/``setattr``:
|
|
24
|
+
#: an access no static rewrite can see, which fails only at runtime.
|
|
25
|
+
DYNAMIC = "dynamic"
|
|
26
|
+
#: A test that still uses the old name.
|
|
27
|
+
TEST = "test"
|
|
28
|
+
#: The same name on an object the change document says is not affected.
|
|
29
|
+
OTHER_OBJECT = "other_object"
|
|
30
|
+
#: A string, comment, configuration file, or document mentioning the name.
|
|
31
|
+
STRING = "string"
|
|
32
|
+
COMMENT = "comment"
|
|
33
|
+
CONFIG = "config"
|
|
34
|
+
DOCS = "docs"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def unfinished(self) -> bool:
|
|
38
|
+
"""Whether a residual of this kind means the migration is not done."""
|
|
39
|
+
return self in (ResidualKind.CODE, ResidualKind.DYNAMIC, ResidualKind.TEST)
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
42
|
+
return self.value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Residual:
|
|
47
|
+
"""One place the old name still appears after patching."""
|
|
48
|
+
|
|
49
|
+
path: str
|
|
50
|
+
line: int
|
|
51
|
+
kind: ResidualKind
|
|
52
|
+
snippet: str
|
|
53
|
+
reason: str
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"path": self.path,
|
|
58
|
+
"line": self.line,
|
|
59
|
+
"kind": self.kind.value,
|
|
60
|
+
"snippet": self.snippet,
|
|
61
|
+
"reason": self.reason,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class CompletenessReport:
|
|
67
|
+
"""Every surviving use of one change's old name, in the patched copy."""
|
|
68
|
+
|
|
69
|
+
old: str
|
|
70
|
+
new: str
|
|
71
|
+
residuals: list[Residual] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def unfinished(self) -> list[Residual]:
|
|
75
|
+
return [r for r in self.residuals if r.kind.unfinished]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def complete(self) -> bool:
|
|
79
|
+
"""No code, dynamic access, or test still uses the old name."""
|
|
80
|
+
return not self.unfinished
|
|
81
|
+
|
|
82
|
+
def count(self, kind: ResidualKind) -> int:
|
|
83
|
+
return sum(1 for residual in self.residuals if residual.kind is kind)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"old": self.old,
|
|
88
|
+
"new": self.new,
|
|
89
|
+
"complete": self.complete,
|
|
90
|
+
"residuals": [r.to_dict() for r in self.residuals],
|
|
91
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The downstream side of the domain model: what the change touches.
|
|
2
|
+
|
|
3
|
+
The chain the prototype could not express, and which every report now walks:
|
|
4
|
+
|
|
5
|
+
.. code-block:: text
|
|
6
|
+
|
|
7
|
+
BreakingChange
|
|
8
|
+
-> affected API symbol (BreakingChange.target)
|
|
9
|
+
-> call sites / data accesses (CodeReference)
|
|
10
|
+
-> affected functions (ImpactFinding.symbol)
|
|
11
|
+
-> affected files (ImpactFinding.reference.path)
|
|
12
|
+
-> relevant tests (ImpactReport.related_tests)
|
|
13
|
+
|
|
14
|
+
:class:`ImpactGraph` materializes that chain so migration planning can be
|
|
15
|
+
explained rather than asserted.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import enum
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from patchahead.domain.change import BreakingChange, Confidence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AccessKind(str, enum.Enum):
|
|
28
|
+
"""The syntactic shape of an old-contract usage, as seen by the AST."""
|
|
29
|
+
|
|
30
|
+
#: ``obj["name"]``
|
|
31
|
+
SUBSCRIPT = "subscript"
|
|
32
|
+
#: ``obj.get("name")``
|
|
33
|
+
DICT_GET = "dict_get"
|
|
34
|
+
#: ``obj.name``
|
|
35
|
+
ATTRIBUTE = "attribute"
|
|
36
|
+
#: ``name(...)`` or ``obj.name(...)``
|
|
37
|
+
CALL = "call"
|
|
38
|
+
#: ``f(name=...)``
|
|
39
|
+
KEYWORD_ARG = "keyword_arg"
|
|
40
|
+
#: A ``while``-loop paginating over an integer page counter.
|
|
41
|
+
PAGE_LOOP = "page_loop"
|
|
42
|
+
#: ``from sdk import name``
|
|
43
|
+
IMPORT = "import"
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
46
|
+
return self.value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CodeReference:
|
|
51
|
+
"""A precise location in a source file.
|
|
52
|
+
|
|
53
|
+
Columns are 0-indexed **character** offsets into the line -- not the byte
|
|
54
|
+
offsets ``ast`` reports. The two agree only while a line is pure ASCII, and
|
|
55
|
+
:mod:`patchahead.analysis.python_ast` converts at the single point where
|
|
56
|
+
``ast`` data enters the system, so everything downstream of it (this class
|
|
57
|
+
included) is character-based and can index a Python ``str`` directly.
|
|
58
|
+
|
|
59
|
+
``line`` is 1-indexed, matching every editor and traceback. Ranges are
|
|
60
|
+
half-open on the end column, matching ``ast``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
#: Path relative to the repository root, always POSIX-style.
|
|
64
|
+
path: str
|
|
65
|
+
line: int
|
|
66
|
+
col: int = 0
|
|
67
|
+
end_line: int | None = None
|
|
68
|
+
end_col: int | None = None
|
|
69
|
+
#: The source line, stripped -- so a report is readable without the file.
|
|
70
|
+
snippet: str = ""
|
|
71
|
+
|
|
72
|
+
def __str__(self) -> str:
|
|
73
|
+
return f"{self.path}:{self.line}"
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"path": self.path,
|
|
78
|
+
"line": self.line,
|
|
79
|
+
"col": self.col,
|
|
80
|
+
"end_line": self.end_line,
|
|
81
|
+
"end_col": self.end_col,
|
|
82
|
+
"snippet": self.snippet,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ImpactFinding:
|
|
88
|
+
"""One place in the downstream repository affected by the change.
|
|
89
|
+
|
|
90
|
+
Every field here exists because a reviewer needs it: *where* (reference),
|
|
91
|
+
*in what* (symbol), *what matched* (matched_contract, access), *why we think
|
|
92
|
+
so* (reason), and *how sure* (confidence).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
reference: CodeReference
|
|
96
|
+
#: Dotted name of the enclosing function/method/class, or "<module>".
|
|
97
|
+
symbol: str
|
|
98
|
+
#: The old contract this matched, e.g. ``order["total"]``.
|
|
99
|
+
matched_contract: str
|
|
100
|
+
access: AccessKind
|
|
101
|
+
reason: str
|
|
102
|
+
confidence: Confidence
|
|
103
|
+
#: The exact source text the reference covers, e.g. ``"total"`` including
|
|
104
|
+
#: its quotes. Recorded at analysis time so planning can build a
|
|
105
|
+
#: replacement that preserves quote style without re-reading the file.
|
|
106
|
+
source_text: str = ""
|
|
107
|
+
#: True when a handler can mechanically rewrite this exact site.
|
|
108
|
+
patchable: bool = True
|
|
109
|
+
#: Set when ``patchable`` is False: why not.
|
|
110
|
+
unpatchable_reason: str = ""
|
|
111
|
+
#: True when the change document *asserted* what owns the renamed name and
|
|
112
|
+
#: this site belongs to something else -- `customer["total"]` for a rename
|
|
113
|
+
#: on `order`. Left alone on purpose, so not unfinished work.
|
|
114
|
+
other_object: bool = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def path(self) -> str:
|
|
118
|
+
return self.reference.path
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"reference": self.reference.to_dict(),
|
|
123
|
+
"symbol": self.symbol,
|
|
124
|
+
"matched_contract": self.matched_contract,
|
|
125
|
+
"access": self.access.value,
|
|
126
|
+
"reason": self.reason,
|
|
127
|
+
"confidence": self.confidence.value,
|
|
128
|
+
"source_text": self.source_text,
|
|
129
|
+
"patchable": self.patchable,
|
|
130
|
+
"unpatchable_reason": self.unpatchable_reason,
|
|
131
|
+
"other_object": self.other_object,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class ImpactGraph:
|
|
137
|
+
"""The change -> symbol -> site -> function -> file -> test chain.
|
|
138
|
+
|
|
139
|
+
Not a graph database; a set of adjacency views over the findings, computed
|
|
140
|
+
once so reports and plans do not each re-derive them.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
change_title: str
|
|
144
|
+
api_symbol: str
|
|
145
|
+
#: file path -> enclosing symbols affected in it
|
|
146
|
+
functions_by_file: dict[str, list[str]] = field(default_factory=dict)
|
|
147
|
+
#: file path -> test files believed relevant to it
|
|
148
|
+
tests_by_file: dict[str, list[str]] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def build(
|
|
152
|
+
cls,
|
|
153
|
+
change: BreakingChange,
|
|
154
|
+
findings: list[ImpactFinding],
|
|
155
|
+
tests_by_file: dict[str, list[str]] | None = None,
|
|
156
|
+
) -> ImpactGraph:
|
|
157
|
+
functions_by_file: dict[str, list[str]] = {}
|
|
158
|
+
for finding in findings:
|
|
159
|
+
bucket = functions_by_file.setdefault(finding.path, [])
|
|
160
|
+
if finding.symbol not in bucket:
|
|
161
|
+
bucket.append(finding.symbol)
|
|
162
|
+
return cls(
|
|
163
|
+
change_title=change.title,
|
|
164
|
+
api_symbol=change.target.symbol or change.title,
|
|
165
|
+
functions_by_file=functions_by_file,
|
|
166
|
+
tests_by_file=dict(tests_by_file or {}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def render(self) -> str:
|
|
170
|
+
"""An indented text rendering, used by ``patchahead analyze``."""
|
|
171
|
+
lines = [f"BreakingChange: {self.change_title}", f" symbol: {self.api_symbol}"]
|
|
172
|
+
for path in sorted(self.functions_by_file):
|
|
173
|
+
lines.append(f" file: {path}")
|
|
174
|
+
for symbol in self.functions_by_file[path]:
|
|
175
|
+
lines.append(f" function: {symbol}")
|
|
176
|
+
for test in self.tests_by_file.get(path, []):
|
|
177
|
+
lines.append(f" test: {test}")
|
|
178
|
+
return "\n".join(lines)
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict[str, Any]:
|
|
181
|
+
return {
|
|
182
|
+
"change_title": self.change_title,
|
|
183
|
+
"api_symbol": self.api_symbol,
|
|
184
|
+
"functions_by_file": self.functions_by_file,
|
|
185
|
+
"tests_by_file": self.tests_by_file,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class ImpactReport:
|
|
191
|
+
"""Everything analysis learned about one change against one repository."""
|
|
192
|
+
|
|
193
|
+
change: BreakingChange
|
|
194
|
+
findings: list[ImpactFinding] = field(default_factory=list)
|
|
195
|
+
graph: ImpactGraph | None = None
|
|
196
|
+
#: Test files judged relevant to the affected modules.
|
|
197
|
+
related_tests: list[str] = field(default_factory=list)
|
|
198
|
+
#: Number of Python files actually parsed (for timing/debug reporting).
|
|
199
|
+
files_scanned: int = 0
|
|
200
|
+
#: Files that could not be parsed, as ``path: reason``.
|
|
201
|
+
skipped_files: dict[str, str] = field(default_factory=dict)
|
|
202
|
+
analysis_ms: int = 0
|
|
203
|
+
#: Set when the handler declined to analyze at all.
|
|
204
|
+
unsupported_reason: str = ""
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def has_impact(self) -> bool:
|
|
208
|
+
return bool(self.findings)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def affected_files(self) -> list[str]:
|
|
212
|
+
seen: list[str] = []
|
|
213
|
+
for finding in self.findings:
|
|
214
|
+
if finding.path not in seen:
|
|
215
|
+
seen.append(finding.path)
|
|
216
|
+
return seen
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def affected_symbols(self) -> list[str]:
|
|
220
|
+
seen: list[str] = []
|
|
221
|
+
for finding in self.findings:
|
|
222
|
+
if finding.symbol not in seen:
|
|
223
|
+
seen.append(finding.symbol)
|
|
224
|
+
return seen
|
|
225
|
+
|
|
226
|
+
def at_least(self, minimum: Confidence) -> list[ImpactFinding]:
|
|
227
|
+
"""Findings meeting a confidence floor -- the patching threshold."""
|
|
228
|
+
return [f for f in self.findings if f.confidence >= minimum]
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def highest_confidence(self) -> Confidence | None:
|
|
232
|
+
if not self.findings:
|
|
233
|
+
return None
|
|
234
|
+
return max((f.confidence for f in self.findings), key=lambda c: c.rank)
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, Any]:
|
|
237
|
+
return {
|
|
238
|
+
"change": self.change.to_dict(),
|
|
239
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
240
|
+
"graph": self.graph.to_dict() if self.graph else None,
|
|
241
|
+
"related_tests": self.related_tests,
|
|
242
|
+
"affected_files": self.affected_files,
|
|
243
|
+
"affected_symbols": self.affected_symbols,
|
|
244
|
+
"files_scanned": self.files_scanned,
|
|
245
|
+
"skipped_files": self.skipped_files,
|
|
246
|
+
"analysis_ms": self.analysis_ms,
|
|
247
|
+
"unsupported_reason": self.unsupported_reason,
|
|
248
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Patch proposals: the new file contents a plan produces, plus a diff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from patchahead.domain.plan import MigrationPlan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FileEdit:
|
|
13
|
+
"""The before and after contents of one file."""
|
|
14
|
+
|
|
15
|
+
path: str
|
|
16
|
+
old_source: str
|
|
17
|
+
new_source: str
|
|
18
|
+
#: Number of :class:`~patchahead.domain.plan.TextEdit` s applied.
|
|
19
|
+
edit_count: int = 0
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def changed(self) -> bool:
|
|
23
|
+
return self.old_source != self.new_source
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
# Source is deliberately excluded: run logs are for reading, and the
|
|
27
|
+
# diff already carries the content a reviewer needs.
|
|
28
|
+
return {"path": self.path, "edit_count": self.edit_count, "changed": self.changed}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PatchProposal:
|
|
33
|
+
"""A proposed, not-yet-validated set of file edits.
|
|
34
|
+
|
|
35
|
+
"Proposal" is the operative word: a :class:`PatchProposal` has passed no
|
|
36
|
+
gates. Only a :class:`~patchahead.domain.validation.ValidationResult` can
|
|
37
|
+
say whether it is any good.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
plan: MigrationPlan
|
|
41
|
+
files: list[FileEdit] = field(default_factory=list)
|
|
42
|
+
#: Unified diff across every changed file.
|
|
43
|
+
diff: str = ""
|
|
44
|
+
#: "deterministic" or "llm".
|
|
45
|
+
engine: str = "deterministic"
|
|
46
|
+
explanation: str = ""
|
|
47
|
+
#: Set when generation failed; ``files`` is then empty.
|
|
48
|
+
error: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def ok(self) -> bool:
|
|
52
|
+
return not self.error and any(f.changed for f in self.files)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def changed_files(self) -> list[str]:
|
|
56
|
+
return [f.path for f in self.files if f.changed]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def edit_count(self) -> int:
|
|
60
|
+
return sum(f.edit_count for f in self.files)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def diff_line_count(self) -> int:
|
|
64
|
+
"""Added + removed lines, used by the diff-size validation gate."""
|
|
65
|
+
return sum(
|
|
66
|
+
1
|
|
67
|
+
for line in self.diff.splitlines()
|
|
68
|
+
if (line.startswith(("+", "-")) and not line.startswith(("+++", "---")))
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"engine": self.engine,
|
|
74
|
+
"explanation": self.explanation,
|
|
75
|
+
"error": self.error,
|
|
76
|
+
"files": [f.to_dict() for f in self.files],
|
|
77
|
+
"changed_files": self.changed_files,
|
|
78
|
+
"edit_count": self.edit_count,
|
|
79
|
+
"diff_line_count": self.diff_line_count,
|
|
80
|
+
"diff": self.diff,
|
|
81
|
+
}
|