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,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