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,582 @@
1
+ """Page-based -> cursor-based pagination.
2
+
3
+ This is the family the prototype got most wrong. Its "migration" was a literal
4
+ string containing the demo's own function, pasted over whatever function it
5
+ found; pointed at another repository it renamed the user's function, changed its
6
+ signature, and swapped its response key (``docs/assessment.md`` §2.1).
7
+
8
+ This implementation recognizes a *shape* and rewrites four specific spans inside
9
+ it, leaving everything else -- the function name, the signature, the accumulator,
10
+ any logging, the formatting -- exactly as the author wrote it.
11
+
12
+ The recognized shape
13
+ --------------------
14
+
15
+ .. code-block:: python
16
+
17
+ page = 1 # (A) initializer
18
+ while True: # (B) unconditional loop
19
+ response = client.get_orders(page=page) # (C) call passing the page
20
+ ...
21
+ if page >= response["total_pages"]: # (D) guard on the page count
22
+ break
23
+ ...
24
+ page += 1 # (E) advance
25
+
26
+ becomes
27
+
28
+ .. code-block:: python
29
+
30
+ cursor = None # (A)
31
+ while True: # (B) untouched
32
+ response = client.get_orders(cursor=cursor) # (C)
33
+ ...
34
+ if not response.get("has_more"): # (D)
35
+ break
36
+ ...
37
+ cursor = response.get("next_cursor") # (E)
38
+
39
+ Statements between the numbered lines are not touched. Only the four spans
40
+ change, so a 40-line sync function produces a four-line diff.
41
+
42
+ Failing closed
43
+ --------------
44
+
45
+ If any of (A)-(E) is missing, or the page variable is used anywhere *else* in
46
+ the function (logged, returned, stored), the handler refuses: it reports what it
47
+ found, states which part of the shape it could not match, and produces no patch.
48
+ A pagination loop that does something unusual is exactly the case where a
49
+ confident wrong answer is most expensive, and it is the case
50
+ ``--use-llm`` exists for.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import ast
56
+ import logging
57
+ from dataclasses import dataclass, field
58
+
59
+ from patchahead.analysis.index import RepoIndex
60
+ from patchahead.analysis.python_ast import (
61
+ ColumnMap,
62
+ ModuleAnalysis,
63
+ SourceRange,
64
+ iter_own_scope,
65
+ )
66
+ from patchahead.config import Config
67
+ from patchahead.domain.change import BreakingChange, ChangeKind, Confidence, PaginationContract
68
+ from patchahead.domain.impact import AccessKind, CodeReference, ImpactFinding, ImpactReport
69
+ from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
70
+ from patchahead.handlers.base import MigrationHandler, register
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+
75
+ @dataclass
76
+ class PageLoop:
77
+ """A recognized page-based pagination loop, with every span to rewrite."""
78
+
79
+ #: Name of the integer page counter, e.g. ``page``.
80
+ page_var: str
81
+ #: Name the paginated response is bound to, e.g. ``response``.
82
+ response_var: str
83
+ #: Enclosing function.
84
+ symbol: str
85
+ #: (A) ``page = 1``
86
+ init_range: SourceRange
87
+ #: (C) the ``page=page`` keyword inside the call
88
+ call_keyword_range: SourceRange
89
+ #: (D) the ``if`` test expression guarding the break
90
+ guard_range: SourceRange
91
+ #: (E) ``page += 1``
92
+ advance_range: SourceRange
93
+ #: Line of the ``while`` statement, for reporting.
94
+ loop_line: int
95
+ #: Names already bound in the enclosing function, for collision avoidance.
96
+ bound_names: set[str] = field(default_factory=set)
97
+
98
+
99
+ @dataclass
100
+ class LoopRejection:
101
+ """A ``while True`` loop that looked like pagination but was not migratable."""
102
+
103
+ symbol: str
104
+ line: int
105
+ reason: str
106
+
107
+
108
+ def _is_true_literal(node: ast.expr) -> bool:
109
+ return isinstance(node, ast.Constant) and node.value is True
110
+
111
+
112
+ def _response_key_access(node: ast.AST, key: str) -> str:
113
+ """The variable name in ``<name>[key]`` or ``<name>.get(key)``, else ``""``."""
114
+ if (
115
+ isinstance(node, ast.Subscript)
116
+ and isinstance(node.slice, ast.Constant)
117
+ and node.slice.value == key
118
+ and isinstance(node.value, ast.Name)
119
+ ):
120
+ return node.value.id
121
+ if (
122
+ isinstance(node, ast.Call)
123
+ and isinstance(node.func, ast.Attribute)
124
+ and node.func.attr == "get"
125
+ and isinstance(node.func.value, ast.Name)
126
+ and node.args
127
+ and isinstance(node.args[0], ast.Constant)
128
+ and node.args[0].value == key
129
+ ):
130
+ return node.func.value.id
131
+ return ""
132
+
133
+
134
+ def _contains_break(statements: list[ast.stmt]) -> bool:
135
+ for statement in statements:
136
+ for node in ast.walk(statement):
137
+ if isinstance(node, ast.Break):
138
+ return True
139
+ return False
140
+
141
+
142
+ def _is_increment(node: ast.AST, name: str) -> bool:
143
+ """``name += 1`` or ``name = name + 1``."""
144
+ if (
145
+ isinstance(node, ast.AugAssign)
146
+ and isinstance(node.op, ast.Add)
147
+ and isinstance(node.target, ast.Name)
148
+ and node.target.id == name
149
+ and isinstance(node.value, ast.Constant)
150
+ and node.value.value == 1
151
+ ):
152
+ return True
153
+ return (
154
+ isinstance(node, ast.Assign)
155
+ and len(node.targets) == 1
156
+ and isinstance(node.targets[0], ast.Name)
157
+ and node.targets[0].id == name
158
+ and isinstance(node.value, ast.BinOp)
159
+ and isinstance(node.value.op, ast.Add)
160
+ and isinstance(node.value.left, ast.Name)
161
+ and node.value.left.id == name
162
+ and isinstance(node.value.right, ast.Constant)
163
+ and node.value.right.value == 1
164
+ )
165
+
166
+
167
+ def _functions(tree: ast.Module):
168
+ """Every function definition, with its dotted name."""
169
+
170
+ def walk(node: ast.AST, prefix: list[str]):
171
+ for child in ast.iter_child_nodes(node):
172
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
173
+ name = ".".join(prefix + [child.name])
174
+ yield name, child
175
+ yield from walk(child, prefix + [child.name])
176
+ elif isinstance(child, ast.ClassDef):
177
+ yield from walk(child, prefix + [child.name])
178
+ else:
179
+ yield from walk(child, prefix)
180
+
181
+ yield from walk(tree, [])
182
+
183
+
184
+ def find_page_loops(
185
+ module: ModuleAnalysis, contract: PaginationContract
186
+ ) -> tuple[list[PageLoop], list[LoopRejection]]:
187
+ """Find every migratable page loop, and every near-miss with its reason."""
188
+ loops: list[PageLoop] = []
189
+ rejections: list[LoopRejection] = []
190
+
191
+ columns = module.columns or ColumnMap(module.source)
192
+
193
+ for symbol, function in _functions(module.tree):
194
+ # Names bound anywhere at or below this function, so a generated cursor
195
+ # variable cannot collide with one a nested scope already uses.
196
+ bound = {
197
+ node.id
198
+ for node in ast.walk(function)
199
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)
200
+ }
201
+ bound |= {argument.arg for argument in function.args.args}
202
+
203
+ # Own scope only. A loop inside a nested `def` belongs to that function,
204
+ # and `_functions` yields it separately -- walking into it here would
205
+ # match the same loop twice and emit overlapping edits for it.
206
+ for loop in (n for n in iter_own_scope(function) if isinstance(n, ast.While)):
207
+ if not _is_true_literal(loop.test):
208
+ continue
209
+
210
+ match, reason = _match_loop(function, loop, symbol, contract, bound, columns)
211
+ if match is not None:
212
+ loops.append(match)
213
+ elif reason:
214
+ rejections.append(LoopRejection(symbol=symbol, line=loop.lineno, reason=reason))
215
+
216
+ return loops, rejections
217
+
218
+
219
+ def _match_loop(
220
+ function: ast.AST,
221
+ loop: ast.While,
222
+ symbol: str,
223
+ contract: PaginationContract,
224
+ bound: set[str],
225
+ columns: ColumnMap,
226
+ ) -> tuple[PageLoop | None, str]:
227
+ """Match one ``while True`` loop against the recognized shape."""
228
+ # (C) a call passing the page parameter, assigned to a name.
229
+ call_keyword: ast.keyword | None = None
230
+ page_var = ""
231
+ response_var = ""
232
+ for node in ast.walk(loop):
233
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
234
+ continue
235
+ if not isinstance(node.targets[0], ast.Name) or not isinstance(node.value, ast.Call):
236
+ continue
237
+ for keyword in node.value.keywords:
238
+ if keyword.arg == contract.page_param and isinstance(keyword.value, ast.Name):
239
+ call_keyword = keyword
240
+ page_var = keyword.value.id
241
+ response_var = node.targets[0].id
242
+ break
243
+ if call_keyword is not None:
244
+ break
245
+
246
+ if call_keyword is None:
247
+ # Not a page loop at all; this `while True` is something else entirely.
248
+ return None, ""
249
+
250
+ # (D) an `if` guarding a break, testing the total-pages key on the response.
251
+ guard: ast.expr | None = None
252
+ for node in ast.walk(loop):
253
+ if not isinstance(node, ast.If) or not _contains_break(node.body):
254
+ continue
255
+ for inner in ast.walk(node.test):
256
+ if _response_key_access(inner, contract.total_pages_key) == response_var:
257
+ guard = node.test
258
+ break
259
+ if guard is not None:
260
+ break
261
+
262
+ if guard is None:
263
+ return None, (
264
+ f"found a `{contract.page_param}=` paginated call but no `if ...: break` "
265
+ f'guarded by `{response_var}["{contract.total_pages_key}"]`, so the loop\'s '
266
+ f"termination condition could not be identified"
267
+ )
268
+
269
+ # (E) the page advance.
270
+ advance: ast.stmt | None = None
271
+ for node in ast.walk(loop):
272
+ if isinstance(node, (ast.AugAssign, ast.Assign)) and _is_increment(node, page_var):
273
+ advance = node
274
+ break
275
+
276
+ if advance is None:
277
+ return None, (
278
+ f"found a `{contract.page_param}=` paginated call and a "
279
+ f"`{contract.total_pages_key}` guard, but no `{page_var} += 1` advance"
280
+ )
281
+
282
+ # (A) the initializer, before the loop, in the enclosing function.
283
+ init: ast.stmt | None = None
284
+ for node in iter_own_scope(function):
285
+ if (
286
+ isinstance(node, ast.Assign)
287
+ and len(node.targets) == 1
288
+ and isinstance(node.targets[0], ast.Name)
289
+ and node.targets[0].id == page_var
290
+ and isinstance(node.value, ast.Constant)
291
+ and isinstance(node.value.value, int)
292
+ and node.lineno < loop.lineno
293
+ ):
294
+ init = node
295
+ break
296
+
297
+ if init is None:
298
+ return None, (
299
+ f"found a page loop over `{page_var}` but no integer initializer "
300
+ f"(`{page_var} = 1`) before the loop"
301
+ )
302
+
303
+ # The page variable must be used *only* in the four spans we rewrite. If it
304
+ # is logged, returned, or stored anywhere else, replacing it with a cursor
305
+ # would silently change behavior.
306
+ allowed: set[int] = set()
307
+ for node in ast.walk(init):
308
+ allowed.add(id(node))
309
+ for node in ast.walk(call_keyword):
310
+ allowed.add(id(node))
311
+ for node in ast.walk(guard):
312
+ allowed.add(id(node))
313
+ for node in ast.walk(advance):
314
+ allowed.add(id(node))
315
+
316
+ stray = [
317
+ node
318
+ for node in ast.walk(function)
319
+ if isinstance(node, ast.Name) and node.id == page_var and id(node) not in allowed
320
+ ]
321
+ if stray:
322
+ lines = ", ".join(str(node.lineno) for node in sorted(stray, key=lambda n: n.lineno)[:4])
323
+ return None, (
324
+ f"`{page_var}` is also used outside the pagination loop's own "
325
+ f"bookkeeping (line(s) {lines}); replacing it with a cursor could change "
326
+ f"behavior, so this loop is left for a human"
327
+ )
328
+
329
+ return (
330
+ PageLoop(
331
+ page_var=page_var,
332
+ response_var=response_var,
333
+ symbol=symbol,
334
+ init_range=SourceRange.of(init, columns),
335
+ call_keyword_range=SourceRange.of(call_keyword, columns),
336
+ guard_range=SourceRange.of(guard, columns),
337
+ advance_range=SourceRange.of(advance, columns),
338
+ loop_line=loop.lineno,
339
+ bound_names=bound,
340
+ ),
341
+ "",
342
+ )
343
+
344
+
345
+ def choose_cursor_name(preferred: str, taken: set[str]) -> str:
346
+ """Pick a cursor variable name that does not collide with an existing one."""
347
+ if preferred not in taken:
348
+ return preferred
349
+ for suffix in ("_token", "_value", "2"):
350
+ candidate = f"{preferred}{suffix}"
351
+ if candidate not in taken:
352
+ return candidate
353
+ index = 2
354
+ while f"{preferred}{index}" in taken:
355
+ index += 1
356
+ return f"{preferred}{index}"
357
+
358
+
359
+ class PaginationHandler(MigrationHandler):
360
+ """Migrates a page-based pagination loop to a cursor-based one."""
361
+
362
+ name = "pagination_page_to_cursor"
363
+ kinds = (ChangeKind.PAGINATION_PAGE_TO_CURSOR,)
364
+ summary = "Rewrite a page-based pagination loop to use a cursor"
365
+ limitations = (
366
+ "Only the documented `while True` + guarded-`break` shape. A `while page "
367
+ "<= total_pages:` loop, a recursive pager, or a generator is reported and "
368
+ "refused.",
369
+ "The page variable must be used only by the loop's own bookkeeping. If it "
370
+ "is logged or returned, the loop is left for a human.",
371
+ "Assumes the new API exposes `has_more` and `next_cursor` (configurable "
372
+ "per change document). It cannot verify that from the code.",
373
+ )
374
+
375
+ def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
376
+ contract = change.pagination
377
+ findings: list[ImpactFinding] = []
378
+
379
+ for path in index.non_test_paths():
380
+ module = index.modules[path]
381
+ loops, rejections = find_page_loops(module, contract)
382
+ migratable_lines = {loop.loop_line for loop in loops}
383
+
384
+ for loop in loops:
385
+ findings.append(
386
+ ImpactFinding(
387
+ reference=CodeReference(
388
+ path=path,
389
+ line=loop.loop_line,
390
+ snippet=module.line_text(loop.loop_line),
391
+ ),
392
+ symbol=loop.symbol,
393
+ matched_contract=(
394
+ f"while True: ... {contract.page_param}={loop.page_var} ... "
395
+ f'{loop.response_var}["{contract.total_pages_key}"] ... '
396
+ f"{loop.page_var} += 1"
397
+ ),
398
+ access=AccessKind.PAGE_LOOP,
399
+ reason=(
400
+ f"page-based pagination loop over `{loop.page_var}`, "
401
+ f"terminating on `{contract.total_pages_key}`, which the "
402
+ f"new API no longer returns"
403
+ ),
404
+ confidence=Confidence.HIGH,
405
+ source_text="",
406
+ patchable=True,
407
+ )
408
+ )
409
+
410
+ for rejection in rejections:
411
+ findings.append(
412
+ ImpactFinding(
413
+ reference=CodeReference(
414
+ path=path,
415
+ line=rejection.line,
416
+ snippet=module.line_text(rejection.line),
417
+ ),
418
+ symbol=rejection.symbol,
419
+ matched_contract=f"while True: ... {contract.page_param}=...",
420
+ access=AccessKind.PAGE_LOOP,
421
+ reason=rejection.reason,
422
+ confidence=Confidence.MEDIUM,
423
+ source_text="",
424
+ patchable=False,
425
+ unpatchable_reason=rejection.reason,
426
+ )
427
+ )
428
+
429
+ # Any remaining read of the removed key is a real break even when it
430
+ # is not inside a loop we can rewrite -- report it so nothing is lost.
431
+ for access in module.subscripts + module.get_calls: # type: ignore[operator]
432
+ if access.key != contract.total_pages_key:
433
+ continue
434
+ if any(abs(access.range.line - line) < 12 for line in migratable_lines):
435
+ continue # already covered by a migratable loop
436
+ findings.append(
437
+ ImpactFinding(
438
+ reference=CodeReference(
439
+ path=path,
440
+ line=access.range.line,
441
+ col=access.range.col,
442
+ end_line=access.range.end_line,
443
+ end_col=access.range.end_col,
444
+ snippet=module.line_text(access.range.line),
445
+ ),
446
+ symbol=access.symbol,
447
+ matched_contract=(
448
+ f'{access.receiver or "<expr>"}["{contract.total_pages_key}"]'
449
+ ),
450
+ access=AccessKind.SUBSCRIPT,
451
+ reason=(
452
+ f"reads `{contract.total_pages_key}`, which the new API no "
453
+ f"longer returns, but not inside a pagination loop this "
454
+ f"handler recognizes"
455
+ ),
456
+ confidence=Confidence.HIGH,
457
+ source_text="",
458
+ patchable=False,
459
+ unpatchable_reason=(
460
+ "not part of a recognized page loop; rewriting it in "
461
+ "isolation would need the surrounding logic to change too"
462
+ ),
463
+ )
464
+ )
465
+
466
+ findings.sort(key=lambda f: (f.reference.path, f.reference.line))
467
+ return ImpactReport(
468
+ change=change,
469
+ findings=findings,
470
+ related_tests=_related_tests(index, findings),
471
+ files_scanned=index.file_count,
472
+ skipped_files=dict(index.skipped),
473
+ )
474
+
475
+ def plan(
476
+ self,
477
+ change: BreakingChange,
478
+ report: ImpactReport,
479
+ index: RepoIndex,
480
+ config: Config,
481
+ ) -> MigrationPlan:
482
+ contract = change.pagination
483
+ plan = MigrationPlan(
484
+ change=change,
485
+ handler=self.name,
486
+ expected_tests=list(report.related_tests),
487
+ risk=Risk.MEDIUM,
488
+ rationale=(
489
+ f"Rewrite each recognized page loop to iterate on "
490
+ f"`{contract.cursor_param}`/`{contract.next_cursor_key}` and terminate "
491
+ f"on `{contract.has_more_key}`. Four spans change per loop: the "
492
+ f"initializer, the call's `{contract.page_param}=` argument, the break "
493
+ f"guard, and the advance. The enclosing function's name, signature, "
494
+ f"and every other statement are untouched."
495
+ ),
496
+ )
497
+
498
+ # Planning rebuilds the loop structures from the index analysis used, so
499
+ # the spans it edits are exactly the spans analysis matched.
500
+ for path in report.affected_files:
501
+ module = index.modules.get(path)
502
+ if module is None:
503
+ continue
504
+ loops, _ = find_page_loops(module, contract)
505
+ for loop in loops:
506
+ cursor_var = choose_cursor_name(contract.cursor_param, loop.bound_names)
507
+ specs = [
508
+ (
509
+ loop.init_range,
510
+ f"{loop.page_var} = <int>",
511
+ f"{cursor_var} = None",
512
+ "start from no cursor instead of page 1",
513
+ ),
514
+ (
515
+ loop.call_keyword_range,
516
+ f"{contract.page_param}={loop.page_var}",
517
+ f"{contract.cursor_param}={cursor_var}",
518
+ "pass the cursor instead of the page number",
519
+ ),
520
+ (
521
+ loop.guard_range,
522
+ f'{loop.page_var} ... {loop.response_var}["{contract.total_pages_key}"]',
523
+ f'not {loop.response_var}.get("{contract.has_more_key}")',
524
+ "terminate on has_more instead of the page count",
525
+ ),
526
+ (
527
+ loop.advance_range,
528
+ f"{loop.page_var} += 1",
529
+ f'{cursor_var} = {loop.response_var}.get("{contract.next_cursor_key}")',
530
+ "advance by cursor instead of incrementing the page",
531
+ ),
532
+ ]
533
+ for source_range, old, new, description in specs:
534
+ plan.transformations.append(
535
+ Transformation(
536
+ reference=CodeReference(
537
+ path=path,
538
+ line=source_range.line,
539
+ col=source_range.col,
540
+ end_line=source_range.end_line,
541
+ end_col=source_range.end_col,
542
+ snippet=module.line_text(source_range.line),
543
+ ),
544
+ old=old,
545
+ new=new,
546
+ symbol=loop.symbol,
547
+ confidence=Confidence.HIGH,
548
+ edit=TextEdit(
549
+ line=source_range.line,
550
+ col=source_range.col,
551
+ end_line=source_range.end_line,
552
+ end_col=source_range.end_col,
553
+ new_text=new,
554
+ description=description,
555
+ ),
556
+ )
557
+ )
558
+ log.debug("planned cursor migration for %s in %s", loop.symbol, path)
559
+
560
+ for finding in report.findings:
561
+ if not finding.patchable:
562
+ plan.skipped.append(
563
+ f"{finding.reference} ({finding.symbol}): {finding.unpatchable_reason}"
564
+ )
565
+
566
+ if not plan.transformations:
567
+ plan.blocked_reason = "no pagination loop matching the supported shape was found. " + (
568
+ "PatchAhead found page-based code it could not safely rewrite; "
569
+ "see the skipped list for why, and consider `--use-llm`."
570
+ if report.findings
571
+ else "No page-based pagination was found in this repository."
572
+ )
573
+ return plan
574
+
575
+
576
+ def _related_tests(index: RepoIndex, findings: list[ImpactFinding]) -> list[str]:
577
+ from patchahead.testing import discovery
578
+
579
+ return discovery.tests_for_paths(index, [f.path for f in findings])
580
+
581
+
582
+ register(PaginationHandler())
@@ -0,0 +1,32 @@
1
+ """Change ingestion: turning upstream change documents into BreakingChanges.
2
+
3
+ Importing this package registers the built-in parsers. Adding a format (OpenAPI
4
+ spec diffs, a vendor changelog API) means adding a module here that subclasses
5
+ :class:`~patchahead.ingest.base.ChangeParser` and calls
6
+ :func:`~patchahead.ingest.base.register`.
7
+ """
8
+
9
+ from patchahead.ingest.base import (
10
+ ChangeDocument,
11
+ ChangeParser,
12
+ IngestError,
13
+ parse_document,
14
+ parse_file,
15
+ register,
16
+ registered,
17
+ )
18
+
19
+ # Import for the registration side effect. Order matters only in that later
20
+ # registrations are tried first; the two built-ins select on disjoint suffixes.
21
+ from patchahead.ingest import markdown as markdown # noqa: E402,F401 isort:skip
22
+ from patchahead.ingest import structured as structured # noqa: E402,F401 isort:skip
23
+
24
+ __all__ = [
25
+ "ChangeDocument",
26
+ "ChangeParser",
27
+ "IngestError",
28
+ "parse_document",
29
+ "parse_file",
30
+ "register",
31
+ "registered",
32
+ ]