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,620 @@
|
|
|
1
|
+
"""LLM-assisted patch proposal, with the model treated as untrusted.
|
|
2
|
+
|
|
3
|
+
When to use it
|
|
4
|
+
--------------
|
|
5
|
+
|
|
6
|
+
The LLM path exists for exactly one situation: a deterministic handler found
|
|
7
|
+
real impact but **refused to plan** because the code shape was not one it
|
|
8
|
+
recognizes. That is where semantic reasoning genuinely adds something. It is not
|
|
9
|
+
used when a deterministic plan exists, because a mechanical AST rename is both
|
|
10
|
+
cheaper and more reliable than asking a model to do the same thing.
|
|
11
|
+
|
|
12
|
+
What the model is given
|
|
13
|
+
-----------------------
|
|
14
|
+
|
|
15
|
+
The breaking change, the failing test output, and **only the functions the
|
|
16
|
+
impact findings point at** -- not the file, and never the repository. Repository
|
|
17
|
+
source is the user's proprietary code and every line sent is a line disclosed.
|
|
18
|
+
|
|
19
|
+
What comes back is checked
|
|
20
|
+
--------------------------
|
|
21
|
+
|
|
22
|
+
A proposal is rejected, not repaired, when it:
|
|
23
|
+
|
|
24
|
+
* is not valid JSON matching the expected shape,
|
|
25
|
+
* names a file the impact report did not implicate,
|
|
26
|
+
* fails to parse as Python,
|
|
27
|
+
* changes anything about the function's contract -- ``async``-ness, name,
|
|
28
|
+
parameters and their kinds, defaults, annotations, return annotation, or
|
|
29
|
+
decorators (see :class:`FunctionContract`),
|
|
30
|
+
* or changes more of the file than the plan allowed.
|
|
31
|
+
|
|
32
|
+
Then the ordinary validation gates run on it like any other patch. The model is
|
|
33
|
+
a proposal engine; the gates are the authority.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import ast
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
import re
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
|
|
44
|
+
from patchahead.analysis import analyze_source
|
|
45
|
+
from patchahead.analysis import edits as edit_utils
|
|
46
|
+
from patchahead.analysis.index import RepoIndex
|
|
47
|
+
from patchahead.config import Config
|
|
48
|
+
from patchahead.domain.change import BreakingChange, Confidence
|
|
49
|
+
from patchahead.domain.impact import ImpactReport
|
|
50
|
+
from patchahead.domain.patch import FileEdit, PatchProposal
|
|
51
|
+
from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
|
|
52
|
+
from patchahead.domain.validation import TestRun
|
|
53
|
+
from patchahead.llm.client import LLMClient, LLMError
|
|
54
|
+
from patchahead.workspace import Workspace
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
#: Maximum characters of source sent in one request. A migration that needs more
|
|
59
|
+
#: context than this is one PatchAhead should decline rather than guess at.
|
|
60
|
+
MAX_SOURCE_CHARS = 24_000
|
|
61
|
+
#: Maximum test output characters included.
|
|
62
|
+
MAX_TEST_OUTPUT_CHARS = 4_000
|
|
63
|
+
|
|
64
|
+
SYSTEM_PROMPT = """\
|
|
65
|
+
You migrate Python code to a changed upstream API contract.
|
|
66
|
+
|
|
67
|
+
You will be given a breaking change, the functions in a downstream repository \
|
|
68
|
+
that use the old contract, and the failing test output.
|
|
69
|
+
|
|
70
|
+
Rules, all mandatory:
|
|
71
|
+
1. Change as little as possible. Rewrite only what the breaking change requires.
|
|
72
|
+
2. Reproduce the function's entire signature line exactly as given, including
|
|
73
|
+
`async`, the name, every parameter and its order, positional-only (`/`) and
|
|
74
|
+
keyword-only (`*`) markers, defaults, type annotations, and the return
|
|
75
|
+
annotation. Reproduce every decorator, unchanged and in the same order.
|
|
76
|
+
Only the function body may differ.
|
|
77
|
+
3. Never add imports, helper functions, comments, or type annotations that the \
|
|
78
|
+
migration does not require.
|
|
79
|
+
4. Never reformat, reorder, or "improve" code you are not migrating.
|
|
80
|
+
5. If you cannot make the change safely, say so instead of guessing.
|
|
81
|
+
|
|
82
|
+
Rule 2 is checked structurally, not by reading your answer. A reply that
|
|
83
|
+
changes any part of the contract is discarded in full.
|
|
84
|
+
|
|
85
|
+
Reply with a single JSON object and nothing else. No prose, no markdown fences.
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
"can_migrate": true,
|
|
89
|
+
"reasoning": "one or two sentences on what you changed and why",
|
|
90
|
+
"functions": [
|
|
91
|
+
{
|
|
92
|
+
"path": "app/order_sync.py",
|
|
93
|
+
"function": "sync_all_orders",
|
|
94
|
+
"new_source": "def sync_all_orders(api_client):\\n ..."
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
`new_source` must be the complete replacement for that function definition, \
|
|
100
|
+
correctly indented for its position in the file, with the same `def` line as \
|
|
101
|
+
the original.
|
|
102
|
+
|
|
103
|
+
If you cannot migrate safely, reply:
|
|
104
|
+
|
|
105
|
+
{"can_migrate": false, "reasoning": "why not", "functions": []}
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class ProposalRejection:
|
|
111
|
+
"""Why an LLM proposal was refused."""
|
|
112
|
+
|
|
113
|
+
reason: str
|
|
114
|
+
detail: str = ""
|
|
115
|
+
|
|
116
|
+
def __str__(self) -> str:
|
|
117
|
+
return f"{self.reason}: {self.detail}" if self.detail else self.reason
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class FunctionSpan:
|
|
122
|
+
"""A function definition located in a file, with its exact source range."""
|
|
123
|
+
|
|
124
|
+
path: str
|
|
125
|
+
name: str
|
|
126
|
+
line: int
|
|
127
|
+
end_line: int
|
|
128
|
+
col: int
|
|
129
|
+
end_col: int
|
|
130
|
+
source: str
|
|
131
|
+
contract: FunctionContract
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_function_span(module, symbol: str) -> FunctionSpan | None:
|
|
135
|
+
"""Locate a (possibly nested) function by its dotted symbol name."""
|
|
136
|
+
target = symbol.split(".")
|
|
137
|
+
|
|
138
|
+
def search(node: ast.AST, prefix: list[str]) -> ast.AST | None:
|
|
139
|
+
for child in ast.iter_child_nodes(node):
|
|
140
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
141
|
+
path = prefix + [child.name]
|
|
142
|
+
if path == target and not isinstance(child, ast.ClassDef):
|
|
143
|
+
return child
|
|
144
|
+
found = search(child, path)
|
|
145
|
+
if found is not None:
|
|
146
|
+
return found
|
|
147
|
+
else:
|
|
148
|
+
found = search(child, prefix)
|
|
149
|
+
if found is not None:
|
|
150
|
+
return found
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
node = search(module.tree, [])
|
|
154
|
+
if node is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
lines = module.source.splitlines(keepends=True)
|
|
158
|
+
start_line = min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])])
|
|
159
|
+
end_line = node.end_lineno or node.lineno
|
|
160
|
+
source = "".join(lines[start_line - 1 : end_line])
|
|
161
|
+
return FunctionSpan(
|
|
162
|
+
path=module.path,
|
|
163
|
+
name=symbol,
|
|
164
|
+
line=start_line,
|
|
165
|
+
end_line=end_line,
|
|
166
|
+
col=0,
|
|
167
|
+
end_col=len(lines[end_line - 1].rstrip("\r\n")) if end_line <= len(lines) else 0,
|
|
168
|
+
source=source,
|
|
169
|
+
contract=FunctionContract.of(node),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _expr(node: ast.expr | None) -> str | None:
|
|
174
|
+
"""Normalized source for an annotation or default, or ``None`` if absent.
|
|
175
|
+
|
|
176
|
+
``ast.unparse`` normalizes formatting, so this compares what the expression
|
|
177
|
+
*means* rather than how it was typed -- ``int|None`` and ``int | None`` are
|
|
178
|
+
the same contract, while ``int`` and ``str`` are not.
|
|
179
|
+
"""
|
|
180
|
+
return None if node is None else ast.unparse(node)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class FunctionContract:
|
|
185
|
+
"""Everything about a function that callers depend on, except its body.
|
|
186
|
+
|
|
187
|
+
A migration rewrites a function's *implementation*. Anything in here
|
|
188
|
+
changing means the function's callers, its type checker, or its framework
|
|
189
|
+
registration may break -- which is a different and much larger change than
|
|
190
|
+
the one PatchAhead asked for, and not one a model may make on its own.
|
|
191
|
+
|
|
192
|
+
The prototype's check compared only the name and the parameter names, so a
|
|
193
|
+
model could quietly drop ``async``, remove a default, change an annotation,
|
|
194
|
+
or delete a decorator and still be accepted.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
name: str
|
|
198
|
+
is_async: bool
|
|
199
|
+
#: (kind, name, annotation) for every parameter, in order. ``kind``
|
|
200
|
+
#: distinguishes positional-only / normal / ``*args`` / keyword-only /
|
|
201
|
+
#: ``**kwargs``, so moving a parameter between them is caught.
|
|
202
|
+
parameters: tuple[tuple[str, str, str | None], ...]
|
|
203
|
+
#: Defaults for positional parameters, as normalized source.
|
|
204
|
+
defaults: tuple[str, ...]
|
|
205
|
+
#: Defaults for keyword-only parameters; ``None`` where one is required.
|
|
206
|
+
kw_defaults: tuple[str | None, ...]
|
|
207
|
+
returns: str | None
|
|
208
|
+
decorators: tuple[str, ...]
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def of(cls, node: ast.FunctionDef | ast.AsyncFunctionDef) -> FunctionContract:
|
|
212
|
+
args = node.args
|
|
213
|
+
parameters: list[tuple[str, str, str | None]] = []
|
|
214
|
+
for argument in getattr(args, "posonlyargs", []):
|
|
215
|
+
parameters.append(("positional-only", argument.arg, _expr(argument.annotation)))
|
|
216
|
+
for argument in args.args:
|
|
217
|
+
parameters.append(("positional", argument.arg, _expr(argument.annotation)))
|
|
218
|
+
if args.vararg:
|
|
219
|
+
parameters.append(("*args", args.vararg.arg, _expr(args.vararg.annotation)))
|
|
220
|
+
for argument in args.kwonlyargs:
|
|
221
|
+
parameters.append(("keyword-only", argument.arg, _expr(argument.annotation)))
|
|
222
|
+
if args.kwarg:
|
|
223
|
+
parameters.append(("**kwargs", args.kwarg.arg, _expr(args.kwarg.annotation)))
|
|
224
|
+
|
|
225
|
+
return cls(
|
|
226
|
+
name=node.name,
|
|
227
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
228
|
+
parameters=tuple(parameters),
|
|
229
|
+
defaults=tuple(ast.unparse(d) for d in args.defaults),
|
|
230
|
+
kw_defaults=tuple(_expr(d) for d in args.kw_defaults),
|
|
231
|
+
returns=_expr(node.returns),
|
|
232
|
+
decorators=tuple(ast.unparse(d) for d in node.decorator_list),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def render(self) -> str:
|
|
236
|
+
"""A one-line rendering, for naming the difference in a rejection."""
|
|
237
|
+
params = ", ".join(
|
|
238
|
+
f"{name}: {annotation}" if annotation else name
|
|
239
|
+
for _, name, annotation in self.parameters
|
|
240
|
+
)
|
|
241
|
+
prefix = "async def" if self.is_async else "def"
|
|
242
|
+
suffix = f" -> {self.returns}" if self.returns else ""
|
|
243
|
+
return f"{prefix} {self.name}({params}){suffix}"
|
|
244
|
+
|
|
245
|
+
def difference(self, other: FunctionContract) -> str:
|
|
246
|
+
"""The first way ``other`` differs from this contract, or ``""``."""
|
|
247
|
+
if self.name != other.name:
|
|
248
|
+
return f"renamed the function: `{self.name}` became `{other.name}`"
|
|
249
|
+
if self.is_async != other.is_async:
|
|
250
|
+
was = "async" if self.is_async else "sync"
|
|
251
|
+
now = "async" if other.is_async else "sync"
|
|
252
|
+
return f"changed the function from {was} to {now}"
|
|
253
|
+
if self.parameters != other.parameters:
|
|
254
|
+
return f"changed the parameters: `{self.render()}` became `{other.render()}`"
|
|
255
|
+
if self.defaults != other.defaults:
|
|
256
|
+
return (
|
|
257
|
+
f"changed positional defaults: {list(self.defaults)} became {list(other.defaults)}"
|
|
258
|
+
)
|
|
259
|
+
if self.kw_defaults != other.kw_defaults:
|
|
260
|
+
return (
|
|
261
|
+
f"changed keyword-only defaults: {list(self.kw_defaults)} became "
|
|
262
|
+
f"{list(other.kw_defaults)}"
|
|
263
|
+
)
|
|
264
|
+
if self.returns != other.returns:
|
|
265
|
+
return f"changed the return annotation: {self.returns!r} became {other.returns!r}"
|
|
266
|
+
if self.decorators != other.decorators:
|
|
267
|
+
return (
|
|
268
|
+
f"changed the decorators: {list(self.decorators)} became {list(other.decorators)}"
|
|
269
|
+
)
|
|
270
|
+
return ""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _strip_fences(text: str) -> str:
|
|
274
|
+
"""Remove markdown fences the model was told not to emit but sometimes does."""
|
|
275
|
+
stripped = text.strip()
|
|
276
|
+
fence = re.match(r"^```(?:json)?\s*\n(.*?)\n?```\s*$", stripped, re.DOTALL)
|
|
277
|
+
return fence.group(1) if fence else stripped
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_prompt(
|
|
281
|
+
change: BreakingChange,
|
|
282
|
+
spans: list[FunctionSpan],
|
|
283
|
+
failing_tests: TestRun | None,
|
|
284
|
+
blocked_reason: str,
|
|
285
|
+
) -> str:
|
|
286
|
+
"""Assemble the user message from a bounded, named set of inputs."""
|
|
287
|
+
parts = [
|
|
288
|
+
"## Breaking change",
|
|
289
|
+
f"Title: {change.title}",
|
|
290
|
+
f"Kind: {change.kind.value}",
|
|
291
|
+
]
|
|
292
|
+
if change.old_behavior:
|
|
293
|
+
parts.append(f"Before: {change.old_behavior}")
|
|
294
|
+
if change.new_behavior:
|
|
295
|
+
parts.append(f"After: {change.new_behavior}")
|
|
296
|
+
if change.migration_hint:
|
|
297
|
+
parts.append(f"Migration guidance: {change.migration_hint}")
|
|
298
|
+
if change.evidence:
|
|
299
|
+
parts.append("Evidence from the release notes:")
|
|
300
|
+
parts.extend(f" - {e.quote}" for e in change.evidence[:5])
|
|
301
|
+
|
|
302
|
+
parts += [
|
|
303
|
+
"",
|
|
304
|
+
"## Why the deterministic migration declined",
|
|
305
|
+
blocked_reason or "(not stated)",
|
|
306
|
+
"",
|
|
307
|
+
"## Functions to migrate",
|
|
308
|
+
]
|
|
309
|
+
for span in spans:
|
|
310
|
+
parts += [
|
|
311
|
+
f"### {span.path} :: {span.name}",
|
|
312
|
+
"```python",
|
|
313
|
+
span.source.rstrip(),
|
|
314
|
+
"```",
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
if failing_tests and not failing_tests.passed:
|
|
318
|
+
output = (failing_tests.stdout + failing_tests.stderr)[-MAX_TEST_OUTPUT_CHARS:]
|
|
319
|
+
parts += ["", "## Failing test output", "```", output.strip(), "```"]
|
|
320
|
+
|
|
321
|
+
parts += [
|
|
322
|
+
"",
|
|
323
|
+
"## Constraints",
|
|
324
|
+
"- You may only change the functions listed above.",
|
|
325
|
+
"- Keep every function's name and parameter list exactly as given.",
|
|
326
|
+
"- Return the JSON object described in the system prompt, nothing else.",
|
|
327
|
+
]
|
|
328
|
+
return "\n".join(parts)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class LLMProposer:
|
|
332
|
+
"""Produces a :class:`PatchProposal` from a model, or an explicit rejection."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, config: Config, client: LLMClient | None = None) -> None:
|
|
335
|
+
self.config = config
|
|
336
|
+
self.client = client or LLMClient()
|
|
337
|
+
|
|
338
|
+
def propose(
|
|
339
|
+
self,
|
|
340
|
+
change: BreakingChange,
|
|
341
|
+
report: ImpactReport,
|
|
342
|
+
index: RepoIndex,
|
|
343
|
+
workspace: Workspace,
|
|
344
|
+
blocked_plan: MigrationPlan,
|
|
345
|
+
failing_tests: TestRun | None = None,
|
|
346
|
+
) -> PatchProposal:
|
|
347
|
+
"""Ask the model for a migration and validate what comes back."""
|
|
348
|
+
spans = self._collect_spans(report, index)
|
|
349
|
+
if not spans:
|
|
350
|
+
return PatchProposal(
|
|
351
|
+
plan=blocked_plan,
|
|
352
|
+
engine="llm",
|
|
353
|
+
error="no enclosing function could be located for the impact findings",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
total = sum(len(span.source) for span in spans)
|
|
357
|
+
if total > MAX_SOURCE_CHARS:
|
|
358
|
+
return PatchProposal(
|
|
359
|
+
plan=blocked_plan,
|
|
360
|
+
engine="llm",
|
|
361
|
+
error=(
|
|
362
|
+
f"the affected functions total {total} characters, above the "
|
|
363
|
+
f"{MAX_SOURCE_CHARS}-character limit for one LLM request. "
|
|
364
|
+
f"PatchAhead will not send this much source; narrow the scope "
|
|
365
|
+
f"with `source_dirs` or migrate by hand."
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
prompt = build_prompt(change, spans, failing_tests, blocked_plan.blocked_reason)
|
|
370
|
+
log.info(
|
|
371
|
+
"asking the model to migrate %d function(s) (%d chars of source)",
|
|
372
|
+
len(spans),
|
|
373
|
+
total,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
response = self.client.complete(SYSTEM_PROMPT, prompt)
|
|
378
|
+
except LLMError as exc:
|
|
379
|
+
# Fail loudly and structurally. The prototype swallowed this.
|
|
380
|
+
return PatchProposal(
|
|
381
|
+
plan=blocked_plan, engine="llm", error=f"LLM request failed: {exc}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
parsed, rejection = self._parse(response.text)
|
|
385
|
+
if rejection:
|
|
386
|
+
return PatchProposal(
|
|
387
|
+
plan=blocked_plan, engine="llm", error=f"rejected LLM proposal -- {rejection}"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not parsed.get("can_migrate"):
|
|
391
|
+
reason = str(parsed.get("reasoning") or "no reason given")
|
|
392
|
+
return PatchProposal(
|
|
393
|
+
plan=blocked_plan,
|
|
394
|
+
engine="llm",
|
|
395
|
+
error=f"the model declined to migrate this code: {reason}",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return self._apply(parsed, spans, change, blocked_plan, workspace)
|
|
399
|
+
|
|
400
|
+
# -- internals ---------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _collect_spans(self, report: ImpactReport, index: RepoIndex) -> list[FunctionSpan]:
|
|
403
|
+
"""Locate the enclosing function of each finding, deduplicated."""
|
|
404
|
+
seen: set[tuple[str, str]] = set()
|
|
405
|
+
spans: list[FunctionSpan] = []
|
|
406
|
+
for finding in report.findings:
|
|
407
|
+
key = (finding.path, finding.symbol)
|
|
408
|
+
if key in seen or finding.symbol == "<module>":
|
|
409
|
+
continue
|
|
410
|
+
seen.add(key)
|
|
411
|
+
module = index.modules.get(finding.path)
|
|
412
|
+
if module is None:
|
|
413
|
+
continue
|
|
414
|
+
span = find_function_span(module, finding.symbol)
|
|
415
|
+
if span is not None:
|
|
416
|
+
spans.append(span)
|
|
417
|
+
return spans
|
|
418
|
+
|
|
419
|
+
def _parse(self, text: str) -> tuple[dict, ProposalRejection | None]:
|
|
420
|
+
try:
|
|
421
|
+
data = json.loads(_strip_fences(text))
|
|
422
|
+
except json.JSONDecodeError as exc:
|
|
423
|
+
return {}, ProposalRejection("the model did not return valid JSON", str(exc))
|
|
424
|
+
if not isinstance(data, dict):
|
|
425
|
+
return {}, ProposalRejection(
|
|
426
|
+
"the model returned JSON that is not an object", type(data).__name__
|
|
427
|
+
)
|
|
428
|
+
if "can_migrate" not in data:
|
|
429
|
+
return {}, ProposalRejection("the model's JSON has no `can_migrate` field")
|
|
430
|
+
functions = data.get("functions")
|
|
431
|
+
if functions is not None and not isinstance(functions, list):
|
|
432
|
+
return {}, ProposalRejection("`functions` is not a list")
|
|
433
|
+
return data, None
|
|
434
|
+
|
|
435
|
+
def _apply(
|
|
436
|
+
self,
|
|
437
|
+
parsed: dict,
|
|
438
|
+
spans: list[FunctionSpan],
|
|
439
|
+
change: BreakingChange,
|
|
440
|
+
blocked_plan: MigrationPlan,
|
|
441
|
+
workspace: Workspace,
|
|
442
|
+
) -> PatchProposal:
|
|
443
|
+
by_key = {(span.path, span.name): span for span in spans}
|
|
444
|
+
allowed_paths = {span.path for span in spans}
|
|
445
|
+
|
|
446
|
+
plan = MigrationPlan(
|
|
447
|
+
change=change,
|
|
448
|
+
handler=f"{blocked_plan.handler}+llm",
|
|
449
|
+
expected_tests=list(blocked_plan.expected_tests),
|
|
450
|
+
risk=Risk.HIGH,
|
|
451
|
+
rationale=(
|
|
452
|
+
"Proposed by an LLM because the deterministic handler could not "
|
|
453
|
+
"recognize the code shape. " + str(parsed.get("reasoning") or "")
|
|
454
|
+
).strip(),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
edits_by_path: dict[str, list[TextEdit]] = {}
|
|
458
|
+
for entry in parsed.get("functions") or []:
|
|
459
|
+
if not isinstance(entry, dict):
|
|
460
|
+
return _reject(plan, "an entry in `functions` is not an object")
|
|
461
|
+
|
|
462
|
+
path = str(entry.get("path", ""))
|
|
463
|
+
name = str(entry.get("function", ""))
|
|
464
|
+
new_source = entry.get("new_source")
|
|
465
|
+
|
|
466
|
+
if path not in allowed_paths:
|
|
467
|
+
return _reject(
|
|
468
|
+
plan,
|
|
469
|
+
f"the model proposed changing `{path}`, which the impact report "
|
|
470
|
+
f"does not implicate. Allowed: {', '.join(sorted(allowed_paths))}",
|
|
471
|
+
)
|
|
472
|
+
span = by_key.get((path, name))
|
|
473
|
+
if span is None:
|
|
474
|
+
return _reject(
|
|
475
|
+
plan,
|
|
476
|
+
f"the model proposed changing `{name}` in `{path}`, which was not "
|
|
477
|
+
f"one of the functions it was given",
|
|
478
|
+
)
|
|
479
|
+
if not isinstance(new_source, str) or not new_source.strip():
|
|
480
|
+
return _reject(plan, f"`new_source` for `{name}` is missing or empty")
|
|
481
|
+
|
|
482
|
+
rejection = self._check_function(span, new_source)
|
|
483
|
+
if rejection:
|
|
484
|
+
return _reject(plan, str(rejection))
|
|
485
|
+
|
|
486
|
+
# Written in the file's own line ending, so a Windows-style file
|
|
487
|
+
# does not come back with one function's worth of Unix lines in it.
|
|
488
|
+
newline = edit_utils.newline_of(span.source)
|
|
489
|
+
new_text = new_source.replace("\r\n", "\n").rstrip("\n").replace("\n", newline)
|
|
490
|
+
edits_by_path.setdefault(path, []).append(
|
|
491
|
+
TextEdit(
|
|
492
|
+
line=span.line,
|
|
493
|
+
col=0,
|
|
494
|
+
end_line=span.end_line,
|
|
495
|
+
end_col=span.end_col,
|
|
496
|
+
new_text=new_text,
|
|
497
|
+
description=f"LLM-proposed replacement for `{name}`",
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
plan.transformations.append(
|
|
501
|
+
Transformation(
|
|
502
|
+
reference=_reference(path, span),
|
|
503
|
+
old=f"{span.contract.render()} (original body)",
|
|
504
|
+
new=f"{span.contract.render()} (LLM-proposed body)",
|
|
505
|
+
symbol=name,
|
|
506
|
+
confidence=Confidence.LOW,
|
|
507
|
+
edit=edits_by_path[path][-1],
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if not plan.transformations:
|
|
512
|
+
return _reject(plan, "the model said it could migrate but proposed no changes")
|
|
513
|
+
|
|
514
|
+
# Apply and diff, exactly like a deterministic proposal. Every file is
|
|
515
|
+
# patched and checked before any is written: a rejection on the second
|
|
516
|
+
# file must not leave the first one modified in the shared workspace,
|
|
517
|
+
# where the next change's scope gate would trip over it.
|
|
518
|
+
names_by_path: dict[str, list[str]] = {}
|
|
519
|
+
for transformation in plan.transformations:
|
|
520
|
+
names_by_path.setdefault(transformation.reference.path, []).append(
|
|
521
|
+
transformation.symbol
|
|
522
|
+
)
|
|
523
|
+
staged: list[tuple[str, str, str, int]] = []
|
|
524
|
+
for path, path_edits in edits_by_path.items():
|
|
525
|
+
original = workspace.read(path)
|
|
526
|
+
try:
|
|
527
|
+
patched = edit_utils.apply_edits(original, path_edits)
|
|
528
|
+
except edit_utils.EditError as exc:
|
|
529
|
+
return _reject(plan, f"the proposed edits do not apply cleanly: {exc}")
|
|
530
|
+
|
|
531
|
+
ok, error = edit_utils.is_parseable(patched, path)
|
|
532
|
+
if not ok:
|
|
533
|
+
return _reject(plan, f"the patched file does not parse: {error}")
|
|
534
|
+
|
|
535
|
+
# The contract check reads the proposal on its own, so it cannot
|
|
536
|
+
# see *where* the function landed. A method sent back at column 0
|
|
537
|
+
# still parses -- as a module-level function after the class.
|
|
538
|
+
moved = _moved_functions(patched, path, names_by_path[path])
|
|
539
|
+
if moved:
|
|
540
|
+
return _reject(
|
|
541
|
+
plan,
|
|
542
|
+
f"the proposed {', '.join(f'`{name}`' for name in moved)} no longer "
|
|
543
|
+
f"sits where the original did (wrong indentation moves a method "
|
|
544
|
+
f"out of its class)",
|
|
545
|
+
)
|
|
546
|
+
staged.append((path, original, patched, len(path_edits)))
|
|
547
|
+
|
|
548
|
+
files: list[FileEdit] = []
|
|
549
|
+
entries: list[tuple[str, str, str]] = []
|
|
550
|
+
workspace.contains_model_code = True
|
|
551
|
+
for path, original, patched, edit_count in staged:
|
|
552
|
+
workspace.write(path, patched)
|
|
553
|
+
files.append(
|
|
554
|
+
FileEdit(
|
|
555
|
+
path=path,
|
|
556
|
+
old_source=original,
|
|
557
|
+
new_source=patched,
|
|
558
|
+
edit_count=edit_count,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
entries.append((path, original, patched))
|
|
562
|
+
|
|
563
|
+
proposal = PatchProposal(
|
|
564
|
+
plan=plan,
|
|
565
|
+
files=files,
|
|
566
|
+
diff=edit_utils.combined_diff(entries),
|
|
567
|
+
engine="llm",
|
|
568
|
+
explanation=plan.rationale,
|
|
569
|
+
)
|
|
570
|
+
log.info(
|
|
571
|
+
"accepted LLM proposal: %d file(s), %d diff line(s)",
|
|
572
|
+
len(proposal.changed_files),
|
|
573
|
+
proposal.diff_line_count,
|
|
574
|
+
)
|
|
575
|
+
return proposal
|
|
576
|
+
|
|
577
|
+
def _check_function(self, span: FunctionSpan, new_source: str) -> ProposalRejection | None:
|
|
578
|
+
"""Structural checks on one proposed function body."""
|
|
579
|
+
try:
|
|
580
|
+
tree = ast.parse(new_source.strip())
|
|
581
|
+
except SyntaxError as exc:
|
|
582
|
+
return ProposalRejection(
|
|
583
|
+
f"the proposed `{span.name}` is not valid Python",
|
|
584
|
+
f"line {exc.lineno}: {exc.msg}",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
definitions = [
|
|
588
|
+
node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
589
|
+
]
|
|
590
|
+
if len(tree.body) != len(definitions) or len(definitions) != 1:
|
|
591
|
+
return ProposalRejection(
|
|
592
|
+
f"the proposed replacement for `{span.name}` is not exactly one "
|
|
593
|
+
f"function definition",
|
|
594
|
+
f"got {len(tree.body)} top-level statement(s)",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
difference = span.contract.difference(FunctionContract.of(definitions[0]))
|
|
598
|
+
if difference:
|
|
599
|
+
return ProposalRejection(
|
|
600
|
+
"the model changed the function's contract, not just its body",
|
|
601
|
+
difference,
|
|
602
|
+
)
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _moved_functions(source: str, path: str, names: list[str]) -> list[str]:
|
|
607
|
+
"""Functions that can no longer be found at their dotted name in ``source``."""
|
|
608
|
+
module = analyze_source(source, path)
|
|
609
|
+
return [name for name in names if find_function_span(module, name) is None]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _reference(path: str, span: FunctionSpan):
|
|
613
|
+
from patchahead.domain.impact import CodeReference
|
|
614
|
+
|
|
615
|
+
return CodeReference(path=path, line=span.line, end_line=span.end_line)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _reject(plan: MigrationPlan, detail: str) -> PatchProposal:
|
|
619
|
+
log.warning("rejected LLM proposal: %s", detail)
|
|
620
|
+
return PatchProposal(plan=plan, engine="llm", error=f"rejected LLM proposal -- {detail}")
|