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,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}")