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,211 @@
|
|
|
1
|
+
"""The migration handler interface and registry.
|
|
2
|
+
|
|
3
|
+
A *migration family* is one class. It answers four questions, in order:
|
|
4
|
+
|
|
5
|
+
``supports(change)``
|
|
6
|
+
Is this change mine?
|
|
7
|
+
``analyze(change, index, config)``
|
|
8
|
+
Where in this repository is the old contract used, and how sure am I?
|
|
9
|
+
``plan(change, report, config)``
|
|
10
|
+
Which of those sites will I change, into what, and why not the others?
|
|
11
|
+
``generate(plan, workspace)``
|
|
12
|
+
Apply the plan and produce a diff.
|
|
13
|
+
|
|
14
|
+
Nothing outside this package needs an ``if change.kind == ...`` branch. The
|
|
15
|
+
engine selects a handler through :func:`find_handler` and calls the interface,
|
|
16
|
+
so adding a fifth family never touches the engine: it needs the new
|
|
17
|
+
``ChangeKind``, the classifier's signals for it, the handler module, and the
|
|
18
|
+
registry import in ``handlers/__init__.py`` (see ``docs/contributing.md``).
|
|
19
|
+
|
|
20
|
+
Handlers must **fail closed**. A handler that cannot recognize the code shape it
|
|
21
|
+
is looking at returns a plan with ``blocked_reason`` set, never a guess. The
|
|
22
|
+
prototype's pagination transform guessed, and overwrote user code with the
|
|
23
|
+
demo's function body (``docs/assessment.md`` §2.1).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
|
|
31
|
+
from patchahead.analysis.index import RepoIndex
|
|
32
|
+
from patchahead.config import Config
|
|
33
|
+
from patchahead.domain.change import BreakingChange, ChangeKind
|
|
34
|
+
from patchahead.domain.impact import ImpactReport
|
|
35
|
+
from patchahead.domain.patch import FileEdit, PatchProposal
|
|
36
|
+
from patchahead.domain.plan import MigrationPlan
|
|
37
|
+
from patchahead.workspace import Workspace
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def analyzed_paths(index: RepoIndex, config: Config) -> list[str]:
|
|
43
|
+
"""The modules a handler reads: the code, and its tests when ``migrate_tests``.
|
|
44
|
+
|
|
45
|
+
Tests that call the old API break with it, so migrating them is part of
|
|
46
|
+
the job. What keeps that honest is in the validation engine: a test this
|
|
47
|
+
patch edited is never counted as evidence that the patch worked.
|
|
48
|
+
"""
|
|
49
|
+
return index.paths() if config.migrate_tests else index.non_test_paths()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MigrationHandler(ABC):
|
|
53
|
+
"""Base class for a migration family."""
|
|
54
|
+
|
|
55
|
+
#: Stable identifier, recorded on plans and shown by ``patchahead handlers``.
|
|
56
|
+
name: str = ""
|
|
57
|
+
#: The change kinds this handler claims.
|
|
58
|
+
kinds: tuple[ChangeKind, ...] = ()
|
|
59
|
+
#: One-line description for ``patchahead handlers``.
|
|
60
|
+
summary: str = ""
|
|
61
|
+
#: What this handler explicitly does *not* do, shown in docs and the CLI.
|
|
62
|
+
limitations: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
def supports(self, change: BreakingChange) -> bool:
|
|
65
|
+
"""Whether this handler can act on ``change``.
|
|
66
|
+
|
|
67
|
+
The default is kind matching. Override to add preconditions -- for
|
|
68
|
+
example, the rename handlers also require both symbol names.
|
|
69
|
+
"""
|
|
70
|
+
return change.kind in self.kinds
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
|
|
74
|
+
"""Find every use of the old contract, with graded confidence."""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def plan(
|
|
78
|
+
self,
|
|
79
|
+
change: BreakingChange,
|
|
80
|
+
report: ImpactReport,
|
|
81
|
+
index: RepoIndex,
|
|
82
|
+
config: Config,
|
|
83
|
+
) -> MigrationPlan:
|
|
84
|
+
"""Decide which findings to rewrite and into what.
|
|
85
|
+
|
|
86
|
+
Takes the index as well as the report because some families need the
|
|
87
|
+
code again -- the pagination handler rebuilds its loop structures to
|
|
88
|
+
emit edits. Re-parsing inside the handler would cost a second pass and
|
|
89
|
+
could disagree with what analysis saw.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def generate(self, plan: MigrationPlan, workspace: Workspace) -> PatchProposal:
|
|
93
|
+
"""Apply a plan inside a workspace and produce a diff.
|
|
94
|
+
|
|
95
|
+
The default implementation applies the plan's text edits file by file
|
|
96
|
+
and is correct for every deterministic handler; the edits themselves are
|
|
97
|
+
where a handler's intelligence lives. Override only for a family that
|
|
98
|
+
cannot express its change as range edits.
|
|
99
|
+
"""
|
|
100
|
+
from patchahead.analysis import edits as edit_utils
|
|
101
|
+
|
|
102
|
+
if plan.blocked_reason:
|
|
103
|
+
return PatchProposal(plan=plan, engine="deterministic", error=plan.blocked_reason)
|
|
104
|
+
if plan.is_empty:
|
|
105
|
+
return PatchProposal(
|
|
106
|
+
plan=plan,
|
|
107
|
+
engine="deterministic",
|
|
108
|
+
error="the plan contains no transformations",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
files: list[FileEdit] = []
|
|
112
|
+
entries: list[tuple[str, str, str]] = []
|
|
113
|
+
for path in plan.target_files:
|
|
114
|
+
try:
|
|
115
|
+
original = workspace.read(path)
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
return PatchProposal(
|
|
118
|
+
plan=plan, engine="deterministic", error=f"cannot read {path}: {exc}"
|
|
119
|
+
)
|
|
120
|
+
path_edits = plan.edits_for(path)
|
|
121
|
+
try:
|
|
122
|
+
patched = edit_utils.apply_edits(original, path_edits)
|
|
123
|
+
except edit_utils.EditError as exc:
|
|
124
|
+
return PatchProposal(
|
|
125
|
+
plan=plan,
|
|
126
|
+
engine="deterministic",
|
|
127
|
+
error=f"cannot apply edits to {path}: {exc}",
|
|
128
|
+
)
|
|
129
|
+
workspace.write(path, patched)
|
|
130
|
+
files.append(
|
|
131
|
+
FileEdit(
|
|
132
|
+
path=path,
|
|
133
|
+
old_source=original,
|
|
134
|
+
new_source=patched,
|
|
135
|
+
edit_count=len(path_edits),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
entries.append((path, original, patched))
|
|
139
|
+
|
|
140
|
+
return PatchProposal(
|
|
141
|
+
plan=plan,
|
|
142
|
+
files=files,
|
|
143
|
+
diff=edit_utils.combined_diff(entries),
|
|
144
|
+
engine="deterministic",
|
|
145
|
+
explanation=plan.rationale,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_HANDLERS: list[MigrationHandler] = []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def register(handler: MigrationHandler) -> MigrationHandler:
|
|
153
|
+
"""Add a handler to the registry."""
|
|
154
|
+
_HANDLERS.append(handler)
|
|
155
|
+
return handler
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def registered() -> list[MigrationHandler]:
|
|
159
|
+
return list(_HANDLERS)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def find_handler(change: BreakingChange) -> MigrationHandler | None:
|
|
163
|
+
"""The first registered handler that supports ``change``, or ``None``."""
|
|
164
|
+
for handler in _HANDLERS:
|
|
165
|
+
if handler.supports(change):
|
|
166
|
+
return handler
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def supported_kinds() -> list[ChangeKind]:
|
|
171
|
+
"""Every change kind some registered handler claims."""
|
|
172
|
+
kinds: list[ChangeKind] = []
|
|
173
|
+
for handler in _HANDLERS:
|
|
174
|
+
for kind in handler.kinds:
|
|
175
|
+
if kind not in kinds:
|
|
176
|
+
kinds.append(kind)
|
|
177
|
+
return kinds
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def selftest_registry() -> list[str]:
|
|
181
|
+
"""Check the registry's invariants. Returns a list of problems.
|
|
182
|
+
|
|
183
|
+
Enforces the project rule from ``docs/migrations.md``: every actionable
|
|
184
|
+
:class:`~patchahead.domain.change.ChangeKind` has a handler, every handler
|
|
185
|
+
is identifiable, and no two handlers claim the same kind. This is asserted
|
|
186
|
+
by the test suite so a half-added migration family fails CI rather than
|
|
187
|
+
shipping as a kind nothing can migrate.
|
|
188
|
+
"""
|
|
189
|
+
problems: list[str] = []
|
|
190
|
+
claimed: dict[ChangeKind, str] = {}
|
|
191
|
+
|
|
192
|
+
for handler in _HANDLERS:
|
|
193
|
+
if not handler.name:
|
|
194
|
+
problems.append(f"{type(handler).__name__} has no `name`")
|
|
195
|
+
if not handler.kinds:
|
|
196
|
+
problems.append(f"handler `{handler.name}` claims no change kinds")
|
|
197
|
+
if not handler.summary:
|
|
198
|
+
problems.append(f"handler `{handler.name}` has no `summary`")
|
|
199
|
+
for kind in handler.kinds:
|
|
200
|
+
if kind in claimed:
|
|
201
|
+
problems.append(
|
|
202
|
+
f"change kind `{kind.value}` is claimed by both "
|
|
203
|
+
f"`{claimed[kind]}` and `{handler.name}`"
|
|
204
|
+
)
|
|
205
|
+
claimed[kind] = handler.name
|
|
206
|
+
|
|
207
|
+
for kind in ChangeKind:
|
|
208
|
+
if kind.is_actionable and kind not in claimed:
|
|
209
|
+
problems.append(f"change kind `{kind.value}` has no handler")
|
|
210
|
+
|
|
211
|
+
return problems
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Field rename: ``order["total"]`` -> ``order["amount"]``.
|
|
2
|
+
|
|
3
|
+
The hardest part of this family is not rewriting -- it is deciding *what not to
|
|
4
|
+
rewrite*. A field named ``total``, ``name``, ``id``, or ``status`` is a word that
|
|
5
|
+
appears all over a codebase for unrelated reasons. The prototype matched the
|
|
6
|
+
word as text and corrupted three out of four sites in a four-line test
|
|
7
|
+
(``docs/assessment.md`` §2.2).
|
|
8
|
+
|
|
9
|
+
Two mechanisms keep this one honest.
|
|
10
|
+
|
|
11
|
+
**Syntactic filtering.** Only three constructs can be a field access:
|
|
12
|
+
``obj["total"]``, ``obj.get("total")``, and ``obj.total``. A bare string literal
|
|
13
|
+
(``LABEL = "total"``) is not one of them and is invisible to the AST walk, so it
|
|
14
|
+
cannot be a false positive at all -- not "filtered out later", but never a
|
|
15
|
+
candidate.
|
|
16
|
+
|
|
17
|
+
**Confidence grading by receiver.** Among real accesses, the receiver decides:
|
|
18
|
+
|
|
19
|
+
======================================== ========== ====================
|
|
20
|
+
Site (change document names ``order``) Confidence Patched by default?
|
|
21
|
+
======================================== ========== ====================
|
|
22
|
+
``order["total"]`` HIGH yes
|
|
23
|
+
``self.order["total"]`` HIGH yes
|
|
24
|
+
``customer["total"]`` LOW no -- reported
|
|
25
|
+
``o["total"]`` LOW no -- reported
|
|
26
|
+
``df.total`` LOW no -- reported
|
|
27
|
+
======================================== ========== ====================
|
|
28
|
+
|
|
29
|
+
When the change document names an owner, **a receiver that is not that owner is
|
|
30
|
+
never patched**, however plausible the key looks. ``order["total"]`` and
|
|
31
|
+
``customer["total"]`` on the same line are different fields that happen to share
|
|
32
|
+
a name, and rewriting both is precisely the class of corruption this handler
|
|
33
|
+
exists to prevent. Such sites are still *reported*, with the mismatch stated, so
|
|
34
|
+
a human can decide.
|
|
35
|
+
|
|
36
|
+
The cost is real and accepted: ``for o in orders: o["total"]`` is not patched
|
|
37
|
+
automatically, because ``o`` cannot be shown to be an ``order`` without type
|
|
38
|
+
inference PatchAhead does not do. A false negative is recoverable by hand; a
|
|
39
|
+
silent wrong edit in unrelated code is not.
|
|
40
|
+
|
|
41
|
+
When the document names **no** owner there is nothing to check the receiver
|
|
42
|
+
against, so subscript and ``.get()`` accesses on a named receiver are graded
|
|
43
|
+
MEDIUM (a constant string key matching a renamed field is strong evidence on its
|
|
44
|
+
own) and attribute access stays LOW. A key on a nested or computed expression --
|
|
45
|
+
``charge["customer"]["amount"]`` -- is LOW too: it reads a field of whatever the
|
|
46
|
+
inner expression returns, which may be a different object entirely.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import logging
|
|
52
|
+
|
|
53
|
+
from patchahead.analysis import receiver_matches_owner
|
|
54
|
+
from patchahead.analysis.index import RepoIndex, is_test_path
|
|
55
|
+
from patchahead.config import Config
|
|
56
|
+
from patchahead.domain.change import BreakingChange, ChangeKind, Confidence
|
|
57
|
+
from patchahead.domain.impact import AccessKind, CodeReference, ImpactFinding, ImpactReport
|
|
58
|
+
from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
|
|
59
|
+
from patchahead.handlers.base import MigrationHandler, analyzed_paths, register
|
|
60
|
+
|
|
61
|
+
log = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def quoted_replacement(original_quote: str, new_value: str) -> str:
|
|
65
|
+
"""Re-quote a string literal, preserving the author's quote style.
|
|
66
|
+
|
|
67
|
+
``'total'`` becomes ``'amount'`` and ``"total"`` becomes ``"amount"``. A
|
|
68
|
+
rename should not also restyle the file.
|
|
69
|
+
"""
|
|
70
|
+
quote = "'" if original_quote.lstrip("rbuRBU").startswith("'") else '"'
|
|
71
|
+
prefix = original_quote[: len(original_quote) - len(original_quote.lstrip("rbuRBU"))]
|
|
72
|
+
return f"{prefix}{quote}{new_value}{quote}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FieldRenameHandler(MigrationHandler):
|
|
76
|
+
"""Renames a data field accessed by subscript, ``.get()``, or attribute."""
|
|
77
|
+
|
|
78
|
+
name = "field_rename"
|
|
79
|
+
kinds = (ChangeKind.FIELD_RENAME,)
|
|
80
|
+
summary = 'Rename a response/data field: obj["old"], obj.get("old"), obj.old'
|
|
81
|
+
limitations = (
|
|
82
|
+
"Only constant string keys. `order[key]` with a variable key is reported, never rewritten.",
|
|
83
|
+
"Attribute access on a receiver that does not match the declared owner is "
|
|
84
|
+
"graded LOW and left for a human.",
|
|
85
|
+
'Does not follow the field through assignment: `t = order["total"]` is '
|
|
86
|
+
"renamed, but a later use of `t` is not traced.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def supports(self, change: BreakingChange) -> bool:
|
|
90
|
+
return change.kind in self.kinds and change.target.is_rename
|
|
91
|
+
|
|
92
|
+
# -- analysis ----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
|
|
95
|
+
old = change.target.symbol
|
|
96
|
+
# Only an *asserted* owner constrains which receiver may be patched.
|
|
97
|
+
owner = change.target.owner if change.target.owner_is_explicit else ""
|
|
98
|
+
hint = "" if change.target.owner_is_explicit else change.target.owner
|
|
99
|
+
findings: list[ImpactFinding] = []
|
|
100
|
+
|
|
101
|
+
for path in analyzed_paths(index, config):
|
|
102
|
+
module = index.modules[path]
|
|
103
|
+
in_test = is_test_path(path)
|
|
104
|
+
|
|
105
|
+
for access in module.subscripts:
|
|
106
|
+
if access.key != old:
|
|
107
|
+
continue
|
|
108
|
+
confidence, reason, patchable, blocked = _in_tests_only_on_the_owner(
|
|
109
|
+
self._grade_subscript(access.receiver, owner, hint),
|
|
110
|
+
in_test,
|
|
111
|
+
access.receiver,
|
|
112
|
+
owner or hint,
|
|
113
|
+
)
|
|
114
|
+
findings.append(
|
|
115
|
+
ImpactFinding(
|
|
116
|
+
reference=CodeReference(
|
|
117
|
+
path=path,
|
|
118
|
+
line=access.key_range.line,
|
|
119
|
+
col=access.key_range.col,
|
|
120
|
+
end_line=access.key_range.end_line,
|
|
121
|
+
end_col=access.key_range.end_col,
|
|
122
|
+
snippet=module.line_text(access.range.line),
|
|
123
|
+
),
|
|
124
|
+
symbol=access.symbol,
|
|
125
|
+
matched_contract=f'{access.receiver or "<expr>"}["{old}"]',
|
|
126
|
+
access=AccessKind.SUBSCRIPT,
|
|
127
|
+
reason=reason,
|
|
128
|
+
confidence=confidence,
|
|
129
|
+
source_text=_span(module.source, access.key_range),
|
|
130
|
+
patchable=patchable,
|
|
131
|
+
unpatchable_reason=blocked,
|
|
132
|
+
other_object=_other_object(access.receiver, owner),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for access in module.get_calls:
|
|
137
|
+
if access.key != old:
|
|
138
|
+
continue
|
|
139
|
+
confidence, reason, patchable, blocked = _in_tests_only_on_the_owner(
|
|
140
|
+
self._grade_subscript(access.receiver, owner, hint),
|
|
141
|
+
in_test,
|
|
142
|
+
access.receiver,
|
|
143
|
+
owner or hint,
|
|
144
|
+
)
|
|
145
|
+
findings.append(
|
|
146
|
+
ImpactFinding(
|
|
147
|
+
reference=CodeReference(
|
|
148
|
+
path=path,
|
|
149
|
+
line=access.key_range.line,
|
|
150
|
+
col=access.key_range.col,
|
|
151
|
+
end_line=access.key_range.end_line,
|
|
152
|
+
end_col=access.key_range.end_col,
|
|
153
|
+
snippet=module.line_text(access.range.line),
|
|
154
|
+
),
|
|
155
|
+
symbol=access.symbol,
|
|
156
|
+
matched_contract=f'{access.receiver or "<expr>"}.get("{old}")',
|
|
157
|
+
access=AccessKind.DICT_GET,
|
|
158
|
+
reason=reason,
|
|
159
|
+
confidence=confidence,
|
|
160
|
+
source_text=_span(module.source, access.key_range),
|
|
161
|
+
patchable=patchable,
|
|
162
|
+
unpatchable_reason=blocked,
|
|
163
|
+
other_object=_other_object(access.receiver, owner),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
for access in module.attributes:
|
|
168
|
+
if access.attr != old:
|
|
169
|
+
continue
|
|
170
|
+
confidence, reason, patchable, blocked = _in_tests_only_on_the_owner(
|
|
171
|
+
self._grade_attribute(access.receiver, owner, hint),
|
|
172
|
+
in_test,
|
|
173
|
+
access.receiver,
|
|
174
|
+
owner or hint,
|
|
175
|
+
)
|
|
176
|
+
findings.append(
|
|
177
|
+
ImpactFinding(
|
|
178
|
+
reference=CodeReference(
|
|
179
|
+
path=path,
|
|
180
|
+
line=access.attr_range.line,
|
|
181
|
+
col=access.attr_range.col,
|
|
182
|
+
end_line=access.attr_range.end_line,
|
|
183
|
+
end_col=access.attr_range.end_col,
|
|
184
|
+
snippet=module.line_text(access.range.line),
|
|
185
|
+
),
|
|
186
|
+
symbol=access.symbol,
|
|
187
|
+
matched_contract=f"{access.receiver or '<expr>'}.{old}",
|
|
188
|
+
access=AccessKind.ATTRIBUTE,
|
|
189
|
+
reason=reason,
|
|
190
|
+
confidence=confidence,
|
|
191
|
+
source_text=_span(module.source, access.attr_range),
|
|
192
|
+
patchable=patchable,
|
|
193
|
+
unpatchable_reason=blocked,
|
|
194
|
+
other_object=_other_object(access.receiver, owner),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
findings.sort(key=lambda f: (f.reference.path, f.reference.line, f.reference.col))
|
|
199
|
+
return ImpactReport(
|
|
200
|
+
change=change,
|
|
201
|
+
findings=findings,
|
|
202
|
+
related_tests=_related_tests(index, findings),
|
|
203
|
+
files_scanned=index.file_count,
|
|
204
|
+
skipped_files=dict(index.skipped),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _grade_subscript(
|
|
208
|
+
self, receiver: str, owner: str, hint: str = ""
|
|
209
|
+
) -> tuple[Confidence, str, bool, str]:
|
|
210
|
+
"""Grade a dict-style access.
|
|
211
|
+
|
|
212
|
+
Returns ``(confidence, reason, patchable, blocked_reason)``.
|
|
213
|
+
|
|
214
|
+
A constant string key exactly matching a renamed field is strong
|
|
215
|
+
evidence on its own -- dicts are how API responses arrive in Python --
|
|
216
|
+
but only when there is nothing to contradict it. A named owner that the
|
|
217
|
+
receiver does not match *is* such a contradiction, and it wins.
|
|
218
|
+
"""
|
|
219
|
+
if owner and receiver_matches_owner(receiver, owner):
|
|
220
|
+
return (
|
|
221
|
+
Confidence.HIGH,
|
|
222
|
+
f"dict access with the renamed key on `{owner}`, the object the "
|
|
223
|
+
f"change document names",
|
|
224
|
+
True,
|
|
225
|
+
"",
|
|
226
|
+
)
|
|
227
|
+
if owner:
|
|
228
|
+
return (
|
|
229
|
+
Confidence.LOW,
|
|
230
|
+
f"dict access with the renamed key, but on "
|
|
231
|
+
f"`{receiver or '<expression>'}` rather than `{owner}`, which the "
|
|
232
|
+
f"change document names as the owner of this field",
|
|
233
|
+
False,
|
|
234
|
+
f"receiver is `{receiver or '<expression>'}`, not the declared owner `{owner}`",
|
|
235
|
+
)
|
|
236
|
+
if hint and receiver_matches_owner(receiver, hint):
|
|
237
|
+
return (
|
|
238
|
+
Confidence.HIGH,
|
|
239
|
+
f"dict access with the renamed key on `{receiver}`, matching the "
|
|
240
|
+
f"receiver used in the change document's example",
|
|
241
|
+
True,
|
|
242
|
+
"",
|
|
243
|
+
)
|
|
244
|
+
if not receiver:
|
|
245
|
+
# `charge["customer"]["amount"]`: the key belongs to whatever the
|
|
246
|
+
# inner expression returns, which is a different object from the one
|
|
247
|
+
# the access chain starts at. With no owner to check, there is no
|
|
248
|
+
# evidence it is the renamed field rather than a nested one.
|
|
249
|
+
return (
|
|
250
|
+
Confidence.LOW,
|
|
251
|
+
"dict access with the renamed key on a nested or computed "
|
|
252
|
+
"expression; the change document asserts no owning object, so "
|
|
253
|
+
"there is nothing to tell this field from one on a nested object",
|
|
254
|
+
False,
|
|
255
|
+
"receiver is a nested or computed expression and no owner is asserted",
|
|
256
|
+
)
|
|
257
|
+
return (
|
|
258
|
+
Confidence.MEDIUM,
|
|
259
|
+
"dict access with the renamed key; the change document asserts no "
|
|
260
|
+
"owning object, so the receiver could not be checked",
|
|
261
|
+
True,
|
|
262
|
+
"",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _grade_attribute(
|
|
266
|
+
self, receiver: str, owner: str, hint: str = ""
|
|
267
|
+
) -> tuple[Confidence, str, bool, str]:
|
|
268
|
+
"""Grade an attribute access.
|
|
269
|
+
|
|
270
|
+
Weaker than a subscript in the unowned case: ``.total`` on an arbitrary
|
|
271
|
+
object is poor evidence, because attribute names collide across
|
|
272
|
+
unrelated libraries. Only a receiver match makes it actionable.
|
|
273
|
+
"""
|
|
274
|
+
if owner and receiver_matches_owner(receiver, owner):
|
|
275
|
+
return (
|
|
276
|
+
Confidence.HIGH,
|
|
277
|
+
f"attribute access on `{owner}`, the object the change document names",
|
|
278
|
+
True,
|
|
279
|
+
"",
|
|
280
|
+
)
|
|
281
|
+
if owner:
|
|
282
|
+
return (
|
|
283
|
+
Confidence.LOW,
|
|
284
|
+
f"attribute with the renamed name on "
|
|
285
|
+
f"`{receiver or '<expression>'}` rather than `{owner}`, which the "
|
|
286
|
+
f"change document names as the owner of this field",
|
|
287
|
+
False,
|
|
288
|
+
f"receiver is `{receiver or '<expression>'}`, not the declared owner `{owner}`",
|
|
289
|
+
)
|
|
290
|
+
if hint and receiver_matches_owner(receiver, hint):
|
|
291
|
+
return (
|
|
292
|
+
Confidence.HIGH,
|
|
293
|
+
f"attribute access on `{receiver}`, matching the receiver used in "
|
|
294
|
+
f"the change document's example",
|
|
295
|
+
True,
|
|
296
|
+
"",
|
|
297
|
+
)
|
|
298
|
+
return (
|
|
299
|
+
Confidence.LOW,
|
|
300
|
+
"attribute access with a matching name, but the change document "
|
|
301
|
+
"asserts no owning object, so this cannot be distinguished from an "
|
|
302
|
+
"unrelated attribute",
|
|
303
|
+
False,
|
|
304
|
+
"attribute access with no asserted owner to check the receiver against",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# -- planning ----------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def plan(
|
|
310
|
+
self,
|
|
311
|
+
change: BreakingChange,
|
|
312
|
+
report: ImpactReport,
|
|
313
|
+
index: RepoIndex,
|
|
314
|
+
config: Config,
|
|
315
|
+
) -> MigrationPlan:
|
|
316
|
+
old, new = change.target.symbol, change.target.replacement
|
|
317
|
+
plan = MigrationPlan(
|
|
318
|
+
change=change,
|
|
319
|
+
handler=self.name,
|
|
320
|
+
expected_tests=list(report.related_tests),
|
|
321
|
+
risk=Risk.LOW,
|
|
322
|
+
rationale=(
|
|
323
|
+
f"Rename the `{old}` field to `{new}` at every data access that "
|
|
324
|
+
f"reads it. Only subscript, `.get()`, and attribute accesses are "
|
|
325
|
+
f"rewritten; string literals that merely contain the word `{old}` "
|
|
326
|
+
f"are not field accesses and are left alone."
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
for finding in report.findings:
|
|
331
|
+
if not finding.patchable:
|
|
332
|
+
plan.skipped.append(
|
|
333
|
+
f"{finding.reference} ({finding.matched_contract}): "
|
|
334
|
+
f"{finding.unpatchable_reason}"
|
|
335
|
+
)
|
|
336
|
+
continue
|
|
337
|
+
if finding.confidence < config.min_confidence:
|
|
338
|
+
plan.skipped.append(
|
|
339
|
+
f"{finding.reference} ({finding.matched_contract}): confidence "
|
|
340
|
+
f"{finding.confidence.value} is below the `{config.min_confidence.value}` "
|
|
341
|
+
f"threshold"
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
reference = finding.reference
|
|
346
|
+
if finding.access is AccessKind.ATTRIBUTE:
|
|
347
|
+
old_text, new_text = old, new
|
|
348
|
+
else:
|
|
349
|
+
# The recorded span is the quoted literal; preserve quote style.
|
|
350
|
+
old_text = finding.source_text or f'"{old}"'
|
|
351
|
+
new_text = quoted_replacement(old_text, new)
|
|
352
|
+
|
|
353
|
+
plan.transformations.append(
|
|
354
|
+
Transformation(
|
|
355
|
+
reference=reference,
|
|
356
|
+
old=old_text,
|
|
357
|
+
new=new_text,
|
|
358
|
+
symbol=finding.symbol,
|
|
359
|
+
confidence=finding.confidence,
|
|
360
|
+
edit=TextEdit(
|
|
361
|
+
line=reference.line,
|
|
362
|
+
col=reference.col,
|
|
363
|
+
end_line=reference.end_line or reference.line,
|
|
364
|
+
end_col=reference.end_col or reference.col,
|
|
365
|
+
new_text=new_text,
|
|
366
|
+
description=f"rename `{old}` to `{new}` ({finding.access.value})",
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if not plan.transformations:
|
|
372
|
+
plan.blocked_reason = (
|
|
373
|
+
f"found {len(report.findings)} reference(s) to `{old}` but none met "
|
|
374
|
+
f"the `{config.min_confidence.value}` confidence threshold for "
|
|
375
|
+
f"automatic rewriting"
|
|
376
|
+
if report.findings
|
|
377
|
+
else f"no field accesses to `{old}` were found"
|
|
378
|
+
)
|
|
379
|
+
return plan
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _span(source: str, source_range) -> str:
|
|
383
|
+
"""The exact source text a single-line range covers."""
|
|
384
|
+
lines = source.splitlines()
|
|
385
|
+
if not (1 <= source_range.line <= len(lines)):
|
|
386
|
+
return ""
|
|
387
|
+
if source_range.end_line != source_range.line:
|
|
388
|
+
return ""
|
|
389
|
+
return lines[source_range.line - 1][source_range.col : source_range.end_col]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _related_tests(index: RepoIndex, findings: list[ImpactFinding]) -> list[str]:
|
|
393
|
+
from patchahead.testing import discovery
|
|
394
|
+
|
|
395
|
+
return discovery.tests_for_paths(index, [f.path for f in findings])
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
register(FieldRenameHandler())
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _other_object(receiver: str, owner: str) -> bool:
|
|
402
|
+
"""Whether an *asserted* owner rules this receiver out."""
|
|
403
|
+
return bool(owner) and not receiver_matches_owner(receiver, owner)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _in_tests_only_on_the_owner(
|
|
407
|
+
graded: tuple[Confidence, str, bool, str], in_test: bool, receiver: str, owner: str
|
|
408
|
+
) -> tuple[Confidence, str, bool, str]:
|
|
409
|
+
"""In a test, a field is renamed only on the object the change names.
|
|
410
|
+
|
|
411
|
+
A test builds fake upstream responses -- which do change -- but it also
|
|
412
|
+
asserts on the application's own output, which does not: `report["total"]`
|
|
413
|
+
in a test is the report's field, whatever the API renamed. Without an owner
|
|
414
|
+
to tell the two apart, a test site is reported rather than rewritten.
|
|
415
|
+
"""
|
|
416
|
+
confidence, reason, patchable, blocked = graded
|
|
417
|
+
if in_test and patchable and not receiver_matches_owner(receiver, owner):
|
|
418
|
+
return (
|
|
419
|
+
Confidence.LOW,
|
|
420
|
+
f"{reason}; in a test, a field is renamed only on the object the change "
|
|
421
|
+
f"document names, since tests also check the code's own output",
|
|
422
|
+
False,
|
|
423
|
+
"a test site whose receiver is not the named owner",
|
|
424
|
+
)
|
|
425
|
+
return graded
|