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
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
|
+
]
|