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
patchahead/engine.py ADDED
@@ -0,0 +1,609 @@
1
+ """The orchestrator: the one code path the CLI, the web UI, and the tests share.
2
+
3
+ Two entry points:
4
+
5
+ :func:`analyze`
6
+ Parse the change document, index the repository, run the matching handler's
7
+ analysis, and return an :class:`~patchahead.domain.result.AnalysisResult`.
8
+ Read-only -- nothing is copied, nothing is executed, nothing is written.
9
+
10
+ :func:`migrate`
11
+ Analysis, then plan, then patch **inside an isolated copy**, then validate,
12
+ then report. The user's repository is never written to.
13
+
14
+ There is no demo-only branch anywhere in here. The bundled example repository
15
+ that ``patchahead demo`` serves goes through exactly this path, which is the
16
+ point: if the demo works, the same code made it work for any other repository.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from patchahead import handlers
27
+ from patchahead.analysis import edits as edit_utils
28
+ from patchahead.analysis.index import RepoIndex
29
+ from patchahead.config import Config
30
+ from patchahead.domain.change import BreakingChange
31
+ from patchahead.domain.impact import ImpactGraph, ImpactReport
32
+ from patchahead.domain.patch import FileEdit, PatchProposal
33
+ from patchahead.domain.plan import MigrationPlan
34
+ from patchahead.domain.result import (
35
+ AnalysisResult,
36
+ MigrationResult,
37
+ MigrationRun,
38
+ Outcome,
39
+ )
40
+ from patchahead.domain.validation import GateName, TestRun
41
+ from patchahead.ingest import parse_file
42
+ from patchahead.observability import Timer
43
+ from patchahead.testing import discovery, runner
44
+ from patchahead.validation import ValidationEngine, ValidationOptions, completeness
45
+ from patchahead.workspace import Repository, Workspace
46
+
47
+ log = logging.getLogger(__name__)
48
+
49
+
50
+ @dataclass
51
+ class EngineOptions:
52
+ """Everything the CLI can vary about a run."""
53
+
54
+ #: Stop after planning; do not patch, do not run tests.
55
+ dry_run: bool = False
56
+ #: Let an LLM propose when a deterministic handler declines.
57
+ use_llm: bool = False
58
+ #: Run the gates that execute repository code.
59
+ run_tests: bool = True
60
+ #: Write artifacts (diff, plan, report) to the output directory.
61
+ write_artifacts: bool = True
62
+ #: Keep the workspace on disk so a user can inspect the patched tree.
63
+ keep_workspace: bool = False
64
+ #: Override the repository's configured test command.
65
+ test_command: str = ""
66
+
67
+
68
+ def _index(repository: Repository, timer: Timer) -> RepoIndex:
69
+ with timer.stage("index"):
70
+ index = repository.index()
71
+ log.info("indexed %d Python file(s) under %s", index.file_count, repository.root)
72
+ if index.skipped:
73
+ log.warning("%d file(s) could not be analyzed; run with -v to see why", len(index.skipped))
74
+ return index
75
+
76
+
77
+ def _load_changes(change_path: str | Path, timer: Timer) -> list[BreakingChange]:
78
+ with timer.stage("parse_change"):
79
+ changes = parse_file(change_path)
80
+ log.info(
81
+ "parsed %d breaking change(s) from %s: %s",
82
+ len(changes),
83
+ change_path,
84
+ ", ".join(c.kind.value for c in changes),
85
+ )
86
+ return changes
87
+
88
+
89
+ def _analyze_one(
90
+ change: BreakingChange, index: RepoIndex, config: Config, timer: Timer
91
+ ) -> ImpactReport:
92
+ """Run the matching handler's analysis, or return an unsupported report."""
93
+ if not change.is_actionable:
94
+ return ImpactReport(
95
+ change=change,
96
+ files_scanned=index.file_count,
97
+ unsupported_reason=(
98
+ change.classification_reason
99
+ or f"`{change.kind.value}` changes cannot be migrated by PatchAhead v1"
100
+ ),
101
+ )
102
+
103
+ handler = handlers.find_handler(change)
104
+ if handler is None:
105
+ return ImpactReport(
106
+ change=change,
107
+ files_scanned=index.file_count,
108
+ unsupported_reason=(
109
+ f"`{change.kind.value}` was recognized, but no handler accepted it. "
110
+ + (
111
+ "The change document does not name both the old and the new "
112
+ "symbol, which a rename migration needs."
113
+ if not change.target.is_rename
114
+ else "This is a bug; please report it."
115
+ )
116
+ ),
117
+ )
118
+
119
+ with timer.stage(f"analyze:{change.kind.value}"):
120
+ report = handler.analyze(change, index, config)
121
+
122
+ report.analysis_ms = timer.durations.get(f"analyze:{change.kind.value}", 0)
123
+ report.graph = ImpactGraph.build(
124
+ change,
125
+ report.findings,
126
+ discovery.tests_by_file(index, report.affected_files),
127
+ )
128
+ log.info(
129
+ "%s: %d finding(s) across %d file(s)",
130
+ change.describe(),
131
+ len(report.findings),
132
+ len(report.affected_files),
133
+ )
134
+ return report
135
+
136
+
137
+ def analyze(
138
+ repo_path: str | Path,
139
+ change_path: str | Path,
140
+ config: Config | None = None,
141
+ ) -> AnalysisResult:
142
+ """Find downstream code affected by a change document. Read-only."""
143
+ repository = Repository.open(repo_path, config)
144
+ timer = Timer()
145
+ changes = _load_changes(change_path, timer)
146
+ index = _index(repository, timer)
147
+
148
+ result = AnalysisResult(repo=str(repository.root), change_document=str(change_path))
149
+ for change in changes:
150
+ result.reports.append(_analyze_one(change, index, repository.config, timer))
151
+
152
+ if index.skipped:
153
+ result.warnings.append(
154
+ f"{len(index.skipped)} file(s) were skipped and not analyzed: "
155
+ + "; ".join(f"{path} ({why})" for path, why in list(index.skipped.items())[:3])
156
+ + (" ..." if len(index.skipped) > 3 else "")
157
+ )
158
+ if not index.file_count:
159
+ result.warnings.append(
160
+ f"no Python files were found under {repository.root}. Check the path, "
161
+ f"and any `source_dirs`/`exclude` settings in its PatchAhead config."
162
+ )
163
+
164
+ result.timings = timer.as_dict()
165
+ return result
166
+
167
+
168
+ def migrate(
169
+ repo_path: str | Path,
170
+ change_path: str | Path,
171
+ options: EngineOptions | None = None,
172
+ config: Config | None = None,
173
+ ) -> MigrationRun:
174
+ """Analyze, plan, patch in an isolated workspace, validate, and report.
175
+
176
+ Two phases, for a reason worth stating.
177
+
178
+ **Phase 1 -- patch every change into one workspace.** A release note
179
+ describing three breaking changes should produce one patched tree, not three
180
+ that each fix a third of the problem. Each change is re-analyzed against the
181
+ workspace as it stands, so line numbers reflect the patches already applied.
182
+
183
+ **Phase 2 -- validate the result once.** Changes in one document are often
184
+ interdependent: renaming ``fetch_orders`` to ``list_orders`` leaves the call
185
+ broken until the ``timeout_seconds`` rename lands too. Validating after each
186
+ individual patch would fail both of them for the absence of the other.
187
+ Tests are run against the combined result, which is the state a reviewer
188
+ would actually merge.
189
+ """
190
+ options = options or EngineOptions()
191
+ repository = Repository.open(repo_path, config)
192
+ timer = Timer()
193
+ changes = _load_changes(change_path, timer)
194
+
195
+ run = MigrationRun(repo=str(repository.root), change_document=str(change_path))
196
+
197
+ if options.dry_run:
198
+ index = _index(repository, timer)
199
+ if index.skipped:
200
+ run.warnings.append(f"{len(index.skipped)} file(s) were skipped and not analyzed")
201
+ for change in changes:
202
+ run.results.append(_plan_only(change, index, repository.config, timer))
203
+ return run
204
+
205
+ workspace = Workspace.materialize(repository)
206
+ try:
207
+ _run_migration(run, changes, workspace, repository, options, timer)
208
+ return run
209
+ finally:
210
+ if options.keep_workspace:
211
+ workspace.keep()
212
+ else:
213
+ workspace.cleanup()
214
+
215
+
216
+ def _run_migration(
217
+ run: MigrationRun,
218
+ changes: list[BreakingChange],
219
+ workspace: Workspace,
220
+ repository: Repository,
221
+ options: EngineOptions,
222
+ timer: Timer,
223
+ ) -> None:
224
+ config = repository.config
225
+ command = options.test_command or config.test_command
226
+
227
+ # Baselines, captured before any patch.
228
+ full_baseline: TestRun | None = None
229
+ if options.run_tests:
230
+ with timer.stage("baseline_tests"):
231
+ full_baseline = runner.run_tests(
232
+ workspace, command, timeout=config.test_timeout_seconds
233
+ )
234
+ log.info("baseline full suite: %s", full_baseline.summary)
235
+
236
+ # ---- phase 1: patch everything ------------------------------------
237
+ patched: list[MigrationResult] = []
238
+ for change in changes:
239
+ index = workspace.index()
240
+ if index.skipped and not run.warnings:
241
+ run.warnings.append(f"{len(index.skipped)} file(s) were skipped and not analyzed")
242
+ result = _patch_one(change, workspace, index, repository, options, timer, full_baseline)
243
+ run.results.append(result)
244
+ if result.proposal is not None and result.proposal.ok:
245
+ patched.append(result)
246
+
247
+ if not patched:
248
+ _flag_red_baseline(run, full_baseline)
249
+ return
250
+
251
+ # ---- phase 2: validate the combined result once --------------------
252
+ combined = _combine(patched, workspace)
253
+ run.diff = combined.diff
254
+ targeted_baseline = full_baseline
255
+ if options.run_tests and combined.plan.expected_tests:
256
+ scoped = discovery.scoped_command(command, combined.plan.expected_tests)
257
+ if scoped != command:
258
+ # The baseline for the assertion gate must cover the same tests the
259
+ # gate will run, measured before anything was patched. Recover it by
260
+ # restoring the workspace, measuring, and re-applying.
261
+ with timer.stage("baseline_tests"):
262
+ targeted_baseline = _baseline_for(workspace, scoped, config, combined)
263
+
264
+ with timer.stage("validate"):
265
+ validation = ValidationEngine(config).validate(
266
+ combined,
267
+ workspace,
268
+ ValidationOptions(
269
+ run_tests=options.run_tests,
270
+ baseline=targeted_baseline,
271
+ full_baseline=full_baseline,
272
+ test_command=command,
273
+ ),
274
+ )
275
+
276
+ run.validation = validation
277
+ with timer.stage("completeness"):
278
+ _check_completeness(patched, workspace, repository.config)
279
+ for result in patched:
280
+ result.validation = validation
281
+ result.baseline_tests = targeted_baseline
282
+ changed = len(result.proposal.changed_files) if result.proposal else 0
283
+
284
+ if not validation.passed:
285
+ result.outcome = Outcome.VALIDATION_FAILED
286
+ result.message = f"migration not accepted: {validation.summary()}"
287
+ elif not validation.verified:
288
+ # No gate objected, but nothing *proved* the break was fixed: either
289
+ # no tests ran, or they were already green and so cannot evidence a
290
+ # migration. Saying "migrated" would claim verification that did not
291
+ # happen -- the distinction the whole validation subsystem exists for.
292
+ assertion = validation.get(GateName.MIGRATION_ASSERTION)
293
+ why = assertion.detail if assertion else "no migration evidence"
294
+ result.outcome = Outcome.PATCHED_UNVERIFIED
295
+ result.message = (
296
+ f"patched {changed} file(s), but the migration is unverified: {why} "
297
+ f"Review the diff before applying it."
298
+ )
299
+ else:
300
+ result.outcome = Outcome.MIGRATED
301
+ result.message = f"migrated {changed} file(s); {validation.summary()}"
302
+
303
+ result.timings = timer.as_dict()
304
+ if options.write_artifacts:
305
+ result.artifacts = write_artifacts(result, config)
306
+
307
+ if len(patched) > 1:
308
+ run.warnings.append(
309
+ f"{len(patched)} changes were patched into one workspace and validated "
310
+ f"together; the gate results above apply to the combined result"
311
+ )
312
+
313
+
314
+ def _baseline_for(
315
+ workspace: Workspace, scoped_command: str, config: Config, combined: PatchProposal
316
+ ) -> TestRun:
317
+ """Measure the targeted tests against the *pre-patch* tree, then restore.
318
+
319
+ Needed because the targeted test set is only known once every change has
320
+ been planned, by which point the workspace is already patched.
321
+ """
322
+ current = {path: workspace.read(path) for path in combined.changed_files}
323
+ for path in combined.changed_files:
324
+ workspace.restore(path)
325
+ try:
326
+ return runner.run_tests(workspace, scoped_command, timeout=config.test_timeout_seconds)
327
+ finally:
328
+ for path, contents in current.items():
329
+ workspace.write(path, contents)
330
+
331
+
332
+ def _combine(results: list[MigrationResult], workspace: Workspace) -> PatchProposal:
333
+ """Merge several accepted proposals into one, for validation and reporting."""
334
+ first = results[0].proposal
335
+ assert first is not None
336
+ plan = MigrationPlan(
337
+ change=first.plan.change,
338
+ handler="+".join(dict.fromkeys(r.proposal.plan.handler for r in results if r.proposal)),
339
+ risk=max(
340
+ (r.proposal.plan.risk for r in results if r.proposal),
341
+ key=lambda risk: ["low", "medium", "high"].index(risk.value),
342
+ ),
343
+ rationale="; ".join(
344
+ r.proposal.plan.rationale for r in results if r.proposal and r.proposal.plan.rationale
345
+ ),
346
+ )
347
+ for result in results:
348
+ proposal = result.proposal
349
+ assert proposal is not None
350
+ plan.transformations.extend(proposal.plan.transformations)
351
+ for test in proposal.plan.expected_tests:
352
+ if test not in plan.expected_tests:
353
+ plan.expected_tests.append(test)
354
+ plan.skipped.extend(proposal.plan.skipped)
355
+
356
+ # One FileEdit per file, spanning every change that touched it.
357
+ originals: dict[str, str] = {}
358
+ for result in results:
359
+ for file_edit in result.proposal.files: # type: ignore[union-attr]
360
+ originals.setdefault(file_edit.path, file_edit.old_source)
361
+
362
+ files = [
363
+ FileEdit(
364
+ path=path,
365
+ old_source=original,
366
+ new_source=workspace.read(path),
367
+ edit_count=sum(
368
+ len(r.proposal.plan.edits_for(path)) # type: ignore[union-attr]
369
+ for r in results
370
+ ),
371
+ )
372
+ for path, original in sorted(originals.items())
373
+ ]
374
+ return PatchProposal(
375
+ plan=plan,
376
+ files=files,
377
+ diff=edit_utils.combined_diff([(f.path, f.old_source, f.new_source) for f in files]),
378
+ engine="+".join(dict.fromkeys(r.proposal.engine for r in results if r.proposal)),
379
+ explanation=plan.rationale,
380
+ )
381
+
382
+
383
+ def _plan_only(
384
+ change: BreakingChange, index: RepoIndex, config: Config, timer: Timer
385
+ ) -> MigrationResult:
386
+ """The ``--dry-run`` path: analyze and plan, touch nothing."""
387
+ report = _analyze_one(change, index, config, timer)
388
+ if report.unsupported_reason:
389
+ return MigrationResult(
390
+ outcome=Outcome.UNSUPPORTED_CHANGE,
391
+ impact=report,
392
+ message=report.unsupported_reason,
393
+ timings=timer.as_dict(),
394
+ )
395
+ if not report.has_impact:
396
+ return MigrationResult(
397
+ outcome=Outcome.NO_IMPACT,
398
+ impact=report,
399
+ message=f"no code uses the old contract for {change.describe()}",
400
+ timings=timer.as_dict(),
401
+ )
402
+
403
+ handler = handlers.find_handler(change)
404
+ assert handler is not None
405
+ with timer.stage("plan"):
406
+ plan = handler.plan(change, report, index, config)
407
+ return MigrationResult(
408
+ outcome=Outcome.DRY_RUN,
409
+ impact=report,
410
+ plan=plan,
411
+ message=(
412
+ f"dry run: planned {len(plan.transformations)} transformation(s) across "
413
+ f"{len(plan.target_files)} file(s); nothing was changed"
414
+ if not plan.blocked_reason
415
+ else f"dry run: no migration could be planned -- {plan.blocked_reason}"
416
+ ),
417
+ timings=timer.as_dict(),
418
+ )
419
+
420
+
421
+ def _check_completeness(
422
+ results: list[MigrationResult], workspace: Workspace, config: Config
423
+ ) -> None:
424
+ """Record, per patched change, where its old name survives in the patched copy."""
425
+ index = workspace.index()
426
+ for result in results:
427
+ change = result.impact.change
428
+ handler = handlers.find_handler(change)
429
+ if handler is None: # pragma: no cover - a patched change always has one
430
+ continue
431
+ remaining = handler.analyze(change, index, config)
432
+ result.completeness = completeness.scan(change, remaining, index, workspace.root, config)
433
+
434
+
435
+ def _flag_red_baseline(run: MigrationRun, baseline: TestRun | None) -> None:
436
+ """Qualify "nothing to migrate" when the suite was failing before any patch.
437
+
438
+ A release note PatchAhead misread produces no findings, and no findings
439
+ reads as "nothing to migrate" -- while the tests it just ran fail on exactly
440
+ the change the note describes. Failing tests do not prove the note was
441
+ misread (a repository can be broken for other reasons), so the outcome stays
442
+ `no_impact`; but the message says what was seen, and the baseline is kept on
443
+ the result so the exit code does not report success.
444
+ """
445
+ if baseline is None or baseline.passed or baseline.errored:
446
+ return
447
+ failing = len(baseline.failing_tests)
448
+ count = f"{failing} test(s) fail" if failing else "the test suite fails"
449
+ for result in run.results:
450
+ if result.outcome is not Outcome.NO_IMPACT:
451
+ continue
452
+ result.baseline_tests = baseline
453
+ result.message += (
454
+ f" But {count} before any patch, so this may be a change PatchAhead "
455
+ f"did not recognise rather than one that does not apply. Check the "
456
+ f"failures before concluding nothing needs migrating."
457
+ )
458
+
459
+
460
+ def _patch_one(
461
+ change: BreakingChange,
462
+ workspace: Workspace,
463
+ index: RepoIndex,
464
+ repository: Repository,
465
+ options: EngineOptions,
466
+ timer: Timer,
467
+ baseline: TestRun | None,
468
+ ) -> MigrationResult:
469
+ """Phase 1 for one change: analyze, plan, patch. No validation here."""
470
+ config = repository.config
471
+ report = _analyze_one(change, index, config, timer)
472
+
473
+ if report.unsupported_reason:
474
+ return MigrationResult(
475
+ outcome=Outcome.UNSUPPORTED_CHANGE,
476
+ impact=report,
477
+ message=report.unsupported_reason,
478
+ timings=timer.as_dict(),
479
+ )
480
+ if not report.has_impact:
481
+ return MigrationResult(
482
+ outcome=Outcome.NO_IMPACT,
483
+ impact=report,
484
+ message=(
485
+ f"no code in {repository.root.name} uses the old contract for "
486
+ f"{change.describe()}. Nothing to migrate."
487
+ ),
488
+ timings=timer.as_dict(),
489
+ )
490
+
491
+ handler = handlers.find_handler(change)
492
+ assert handler is not None # _analyze_one guarantees this
493
+ with timer.stage("plan"):
494
+ plan = handler.plan(change, report, index, config)
495
+
496
+ proposal: PatchProposal
497
+ if plan.blocked_reason:
498
+ proposal = _llm_or_blocked(
499
+ change, report, plan, workspace, repository, options, baseline, timer
500
+ )
501
+ else:
502
+ with timer.stage("generate_patch"):
503
+ proposal = handler.generate(plan, workspace)
504
+
505
+ if not proposal.ok:
506
+ outcome = Outcome.NOT_PLANNABLE if plan.blocked_reason else Outcome.PATCH_FAILED
507
+ return MigrationResult(
508
+ outcome=outcome,
509
+ impact=report,
510
+ plan=plan,
511
+ proposal=proposal,
512
+ message=proposal.error or "no patch could be generated",
513
+ workspace_path=str(workspace.root) if options.keep_workspace else "",
514
+ timings=timer.as_dict(),
515
+ )
516
+
517
+ return MigrationResult(
518
+ # Provisional. Phase 2 replaces this once validation has run.
519
+ outcome=Outcome.VALIDATION_FAILED,
520
+ impact=report,
521
+ plan=plan,
522
+ proposal=proposal,
523
+ workspace_path=str(workspace.root) if options.keep_workspace else "",
524
+ message="patched; awaiting validation",
525
+ timings=timer.as_dict(),
526
+ )
527
+
528
+
529
+ def _llm_or_blocked(
530
+ change: BreakingChange,
531
+ report: ImpactReport,
532
+ plan: MigrationPlan,
533
+ workspace: Workspace,
534
+ repository: Repository,
535
+ options: EngineOptions,
536
+ baseline: TestRun | None,
537
+ timer: Timer,
538
+ ) -> PatchProposal:
539
+ """Try the LLM when a deterministic handler declined, if that is permitted."""
540
+ config = repository.config
541
+ permitted, refusal = config.llm_permitted(options.use_llm)
542
+
543
+ if refusal:
544
+ log.warning("%s", refusal)
545
+ return PatchProposal(plan=plan, engine="deterministic", error=refusal)
546
+ if not permitted:
547
+ hint = (
548
+ " Re-run with `--use-llm` to let a model propose a migration for this "
549
+ "shape; the same validation gates still apply."
550
+ )
551
+ return PatchProposal(plan=plan, engine="deterministic", error=plan.blocked_reason + hint)
552
+
553
+ from patchahead.llm import LLMProposer, available
554
+
555
+ usable, why_not = available()
556
+ if not usable:
557
+ # Check before assembling a prompt, so the user gets the actionable
558
+ # message ("no API key") rather than a failure after the work is done.
559
+ return PatchProposal(
560
+ plan=plan,
561
+ engine="llm",
562
+ error=(f"{plan.blocked_reason} `--use-llm` cannot run: {why_not}."),
563
+ )
564
+
565
+ index = workspace.index()
566
+ with timer.stage("llm_propose"):
567
+ proposal = LLMProposer(config).propose(change, report, index, workspace, plan, baseline)
568
+ return proposal
569
+
570
+
571
+ def write_artifacts(result: MigrationResult, config: Config) -> dict[str, str]:
572
+ """Write the diff, the plan, and the full result to the output directory."""
573
+ output_dir = Path(config.output_dir)
574
+ try:
575
+ output_dir.mkdir(parents=True, exist_ok=True)
576
+ except OSError as exc:
577
+ log.warning("could not create the output directory %s: %s", output_dir, exc)
578
+ return {}
579
+
580
+ slug = _slug(result.impact.change)
581
+ artifacts: dict[str, str] = {}
582
+ files: list[tuple[str, str, str]] = []
583
+
584
+ if result.proposal and result.proposal.diff:
585
+ files.append(("diff", f"{slug}.diff", result.proposal.diff))
586
+ if result.plan:
587
+ files.append(("plan", f"{slug}.plan.json", json.dumps(result.plan.to_dict(), indent=2)))
588
+ files.append(("result", f"{slug}.result.json", json.dumps(result.to_dict(), indent=2)))
589
+
590
+ for key, name, content in files:
591
+ path = output_dir / name
592
+ try:
593
+ path.write_text(content, encoding="utf-8")
594
+ except OSError as exc:
595
+ log.warning("could not write %s: %s", path, exc)
596
+ continue
597
+ artifacts[key] = str(path)
598
+
599
+ log.debug("wrote %d artifact(s) to %s", len(artifacts), output_dir)
600
+ return artifacts
601
+
602
+
603
+ def _slug(change: BreakingChange) -> str:
604
+ """A filesystem-safe name for one change's artifacts."""
605
+ parts = [change.kind.value]
606
+ if change.target.symbol:
607
+ parts.append(change.target.symbol)
608
+ slug = "-".join(parts)
609
+ return "".join(c if (c.isalnum() or c in "-_") else "-" for c in slug)[:80]
@@ -0,0 +1,35 @@
1
+ """Migration handlers, one per migration family.
2
+
3
+ Importing this package registers the built-in handlers. To add a family, write a
4
+ module here that subclasses
5
+ :class:`~patchahead.handlers.base.MigrationHandler`, call
6
+ :func:`~patchahead.handlers.base.register` at the bottom of it, add a
7
+ :class:`~patchahead.domain.change.ChangeKind` member, and import the module
8
+ below. See ``docs/migrations.md`` for the full walkthrough.
9
+ """
10
+
11
+ from patchahead.handlers.base import (
12
+ MigrationHandler,
13
+ find_handler,
14
+ register,
15
+ registered,
16
+ selftest_registry,
17
+ supported_kinds,
18
+ )
19
+
20
+ # Imported for their registration side effects. Registration order is match
21
+ # order; the built-ins claim disjoint kinds, so the order among them is
22
+ # immaterial and is kept alphabetical.
23
+ from patchahead.handlers import field_rename as field_rename # noqa: E402,F401 isort:skip
24
+ from patchahead.handlers import kwarg_rename as kwarg_rename # noqa: E402,F401 isort:skip
25
+ from patchahead.handlers import method_rename as method_rename # noqa: E402,F401 isort:skip
26
+ from patchahead.handlers import pagination as pagination # noqa: E402,F401 isort:skip
27
+
28
+ __all__ = [
29
+ "MigrationHandler",
30
+ "find_handler",
31
+ "register",
32
+ "registered",
33
+ "selftest_registry",
34
+ "supported_kinds",
35
+ ]