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/config.py ADDED
@@ -0,0 +1,284 @@
1
+ """Project configuration.
2
+
3
+ Read from ``[tool.patchahead]`` in the target repository's ``pyproject.toml``,
4
+ or from a ``.patchahead.toml`` at its root. Every setting has a default that
5
+ works, so a repository with no configuration at all is fully supported.
6
+
7
+ Configuration belongs to the *repository being analyzed*, not to PatchAhead, so
8
+ it is loaded from the repo path rather than from the current directory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass, field, replace
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from patchahead.domain.change import Confidence
19
+
20
+ try: # Python >= 3.11
21
+ import tomllib
22
+ except ModuleNotFoundError: # pragma: no cover - exercised on 3.10 only
23
+ try:
24
+ import tomli as tomllib # type: ignore[no-redef]
25
+ except ModuleNotFoundError: # pragma: no cover
26
+ tomllib = None # type: ignore[assignment]
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ CONFIG_FILENAME = ".patchahead.toml"
31
+ PYPROJECT = "pyproject.toml"
32
+
33
+ #: Directories never walked when discovering source files. Keeps analysis fast
34
+ #: and stops PatchAhead from proposing edits to vendored or generated code.
35
+ DEFAULT_EXCLUDE = (
36
+ ".git",
37
+ ".hg",
38
+ ".svn",
39
+ ".venv",
40
+ "venv",
41
+ "env",
42
+ "__pycache__",
43
+ ".mypy_cache",
44
+ ".pytest_cache",
45
+ ".ruff_cache",
46
+ ".tox",
47
+ ".nox",
48
+ "node_modules",
49
+ "build",
50
+ "dist",
51
+ "site-packages",
52
+ ".eggs",
53
+ ".patchahead",
54
+ )
55
+
56
+
57
+ class ConfigError(Exception):
58
+ """Raised when a configuration file exists but cannot be used."""
59
+
60
+
61
+ @dataclass
62
+ class Config:
63
+ """Effective configuration for one PatchAhead run."""
64
+
65
+ #: Shell command used to run tests inside the isolated workspace.
66
+ test_command: str = "python -m pytest"
67
+ #: Directories to analyze, relative to the repo root. Empty means the
68
+ #: whole repo minus ``exclude``.
69
+ source_dirs: list[str] = field(default_factory=list)
70
+ #: Directory names or glob patterns to skip.
71
+ exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE))
72
+ #: A proposal touching more files than this fails the scope gate. Guards
73
+ #: against a runaway rename or an LLM rewriting half the repository.
74
+ max_changed_files: int = 10
75
+ #: A proposal larger than this (added + removed lines) fails the scope gate.
76
+ max_diff_lines: int = 400
77
+ #: Findings below this confidence are reported but never patched.
78
+ min_confidence: Confidence = Confidence.MEDIUM
79
+ #: Whether this repository permits sending source code to an LLM. The CLI's
80
+ #: ``--use-llm`` cannot override a ``false`` here.
81
+ allow_llm: bool = True
82
+ #: Whether test files are migrated too. Tests that call the old API break
83
+ #: with it, so migrating them is part of the job; a test the patch edited
84
+ #: is never counted as evidence that the patch worked.
85
+ migrate_tests: bool = True
86
+ #: Where artifacts (diff, plan, report) are written, relative to cwd.
87
+ output_dir: str = ".patchahead"
88
+ #: Seconds before a test command is killed.
89
+ test_timeout_seconds: int = 300
90
+ #: Refuse to copy a repository with more files than this into a workspace.
91
+ max_workspace_files: int = 20000
92
+
93
+ #: Absolute path of the file this config came from, or "" for defaults.
94
+ source_path: str = ""
95
+
96
+ def merged_with_cli(
97
+ self,
98
+ *,
99
+ test_command: str | None = None,
100
+ output_dir: str | None = None,
101
+ min_confidence: Confidence | None = None,
102
+ max_changed_files: int | None = None,
103
+ ) -> Config:
104
+ """Apply CLI overrides on top of file configuration.
105
+
106
+ Precedence is CLI > config file > defaults, except ``allow_llm``, which
107
+ the CLI can only narrow (see :meth:`llm_permitted`).
108
+ """
109
+ updates: dict[str, Any] = {}
110
+ if test_command:
111
+ updates["test_command"] = test_command
112
+ if output_dir:
113
+ updates["output_dir"] = output_dir
114
+ if min_confidence is not None:
115
+ updates["min_confidence"] = min_confidence
116
+ if max_changed_files is not None:
117
+ updates["max_changed_files"] = max_changed_files
118
+ return replace(self, **updates) if updates else self
119
+
120
+ def llm_permitted(self, requested: bool) -> tuple[bool, str]:
121
+ """Decide whether the LLM path may run, and say why if it may not.
122
+
123
+ ``allow_llm = false`` in a repository's config is a policy statement by
124
+ whoever owns that source code. ``--use-llm`` does not override it.
125
+ """
126
+ if not requested:
127
+ return False, ""
128
+ if not self.allow_llm:
129
+ return False, (
130
+ "LLM mode requested but `allow_llm = false` in this repository's "
131
+ "PatchAhead configuration; refusing to send source code to an LLM"
132
+ )
133
+ return True, ""
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ return {
137
+ "test_command": self.test_command,
138
+ "source_dirs": self.source_dirs,
139
+ "exclude": self.exclude,
140
+ "max_changed_files": self.max_changed_files,
141
+ "max_diff_lines": self.max_diff_lines,
142
+ "min_confidence": self.min_confidence.value,
143
+ "allow_llm": self.allow_llm,
144
+ "migrate_tests": self.migrate_tests,
145
+ "output_dir": self.output_dir,
146
+ "test_timeout_seconds": self.test_timeout_seconds,
147
+ "max_workspace_files": self.max_workspace_files,
148
+ "source_path": self.source_path,
149
+ }
150
+
151
+
152
+ def _as_str_list(value: Any, key: str) -> list[str]:
153
+ if isinstance(value, str):
154
+ return [value]
155
+ if isinstance(value, list) and all(isinstance(v, str) for v in value):
156
+ return list(value)
157
+ raise ConfigError(f"`{key}` must be a string or a list of strings, got {value!r}")
158
+
159
+
160
+ def _as_int(value: Any, key: str) -> int:
161
+ if isinstance(value, bool) or not isinstance(value, int):
162
+ raise ConfigError(f"`{key}` must be an integer, got {value!r}")
163
+ if value < 0:
164
+ raise ConfigError(f"`{key}` must not be negative, got {value!r}")
165
+ return value
166
+
167
+
168
+ def _as_bool(value: Any, key: str) -> bool:
169
+ if not isinstance(value, bool):
170
+ raise ConfigError(f"`{key}` must be true or false, got {value!r}")
171
+ return value
172
+
173
+
174
+ def from_mapping(data: dict[str, Any], source_path: str = "") -> Config:
175
+ """Build a :class:`Config` from a parsed ``[tool.patchahead]`` table.
176
+
177
+ Unknown keys are a hard error rather than a silent no-op: a typo'd
178
+ ``max_changed_file`` that quietly does nothing is worse than a message.
179
+ """
180
+ config = Config(source_path=source_path)
181
+ known = {
182
+ "test_command",
183
+ "source_dirs",
184
+ "exclude",
185
+ "max_changed_files",
186
+ "max_diff_lines",
187
+ "min_confidence",
188
+ "allow_llm",
189
+ "migrate_tests",
190
+ "output_dir",
191
+ "test_timeout_seconds",
192
+ "max_workspace_files",
193
+ }
194
+ unknown = sorted(set(data) - known)
195
+ if unknown:
196
+ raise ConfigError(
197
+ f"unknown PatchAhead config key(s): {', '.join(unknown)}. "
198
+ f"Valid keys: {', '.join(sorted(known))}"
199
+ )
200
+
201
+ if "test_command" in data:
202
+ value = data["test_command"]
203
+ if not isinstance(value, str) or not value.strip():
204
+ raise ConfigError(f"`test_command` must be a non-empty string, got {value!r}")
205
+ config.test_command = value.strip()
206
+ if "source_dirs" in data:
207
+ config.source_dirs = _as_str_list(data["source_dirs"], "source_dirs")
208
+ if "exclude" in data:
209
+ # Additive: a project extending the exclude list should not have to
210
+ # restate the defaults, which exist to keep analysis correct and fast.
211
+ extra = _as_str_list(data["exclude"], "exclude")
212
+ config.exclude = list(DEFAULT_EXCLUDE) + [e for e in extra if e not in DEFAULT_EXCLUDE]
213
+ if "max_changed_files" in data:
214
+ config.max_changed_files = _as_int(data["max_changed_files"], "max_changed_files")
215
+ if "max_diff_lines" in data:
216
+ config.max_diff_lines = _as_int(data["max_diff_lines"], "max_diff_lines")
217
+ if "min_confidence" in data:
218
+ value = data["min_confidence"]
219
+ try:
220
+ config.min_confidence = Confidence(str(value).strip().lower())
221
+ except ValueError:
222
+ raise ConfigError(
223
+ f"`min_confidence` must be one of high, medium, low; got {value!r}"
224
+ ) from None
225
+ if "allow_llm" in data:
226
+ config.allow_llm = _as_bool(data["allow_llm"], "allow_llm")
227
+ if "migrate_tests" in data:
228
+ config.migrate_tests = _as_bool(data["migrate_tests"], "migrate_tests")
229
+ if "output_dir" in data:
230
+ value = data["output_dir"]
231
+ if not isinstance(value, str) or not value.strip():
232
+ raise ConfigError(f"`output_dir` must be a non-empty string, got {value!r}")
233
+ config.output_dir = value.strip()
234
+ if "test_timeout_seconds" in data:
235
+ config.test_timeout_seconds = _as_int(data["test_timeout_seconds"], "test_timeout_seconds")
236
+ if "max_workspace_files" in data:
237
+ config.max_workspace_files = _as_int(data["max_workspace_files"], "max_workspace_files")
238
+ return config
239
+
240
+
241
+ def _load_toml(path: Path) -> dict[str, Any]:
242
+ if tomllib is None: # pragma: no cover - only on 3.10 without tomli
243
+ raise ConfigError(
244
+ f"cannot read {path}: no TOML parser available. "
245
+ "Install `tomli` (Python 3.10) or use Python 3.11+."
246
+ )
247
+ try:
248
+ with path.open("rb") as handle:
249
+ return tomllib.load(handle)
250
+ except OSError as exc:
251
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
252
+ except Exception as exc: # tomllib.TOMLDecodeError and friends
253
+ raise ConfigError(f"{path} is not valid TOML: {exc}") from exc
254
+
255
+
256
+ def load(repo_root: Path) -> Config:
257
+ """Load configuration for ``repo_root``.
258
+
259
+ Looks for ``.patchahead.toml`` first (a dedicated file wins over a shared
260
+ one), then ``[tool.patchahead]`` in ``pyproject.toml``. Returns defaults if
261
+ neither exists. Raises :class:`ConfigError` if one exists but is unusable --
262
+ a broken config is reported, never silently ignored.
263
+ """
264
+ repo_root = Path(repo_root)
265
+
266
+ dedicated = repo_root / CONFIG_FILENAME
267
+ if dedicated.is_file():
268
+ data = _load_toml(dedicated)
269
+ table = data.get("tool", {}).get("patchahead", data)
270
+ if not isinstance(table, dict):
271
+ raise ConfigError(f"{dedicated}: expected a table of settings")
272
+ log.debug("loaded config from %s", dedicated)
273
+ return from_mapping(table, source_path=str(dedicated))
274
+
275
+ pyproject = repo_root / PYPROJECT
276
+ if pyproject.is_file():
277
+ data = _load_toml(pyproject)
278
+ table = data.get("tool", {}).get("patchahead")
279
+ if isinstance(table, dict):
280
+ log.debug("loaded config from %s [tool.patchahead]", pyproject)
281
+ return from_mapping(table, source_path=f"{pyproject} [tool.patchahead]")
282
+
283
+ log.debug("no PatchAhead config found under %s; using defaults", repo_root)
284
+ return Config()
@@ -0,0 +1,256 @@
1
+ """The bundled demo: fixtures, and the scenarios that narrate them.
2
+
3
+ ``patchahead demo`` exists so that someone who has never seen this project can
4
+ run one command and watch a real migration happen. Everything here is staging --
5
+ *which* repository, *which* change document, and a sentence about what to look
6
+ at. None of it is migration logic: the demo calls
7
+ :func:`patchahead.engine.migrate` exactly as the CLI does, against a real
8
+ directory, running real tests. A demo with its own code path proves nothing
9
+ about the product, which is the mistake the prototype made.
10
+
11
+ Two things make the staging honest rather than decorative:
12
+
13
+ * every scenario declares the outcome it expects, and
14
+ ``tests/test_demo.py`` runs the real engine and asserts reality matches. A
15
+ scenario whose story stops being true fails the build.
16
+ * the scenario list deliberately includes migrations that do **not** succeed.
17
+ A demo composed only of green checkmarks would be advertising the opposite of
18
+ what this tool is for: the interesting claim is not "it rewrites code", it is
19
+ "it knows when not to, and it will not call a patch verified without
20
+ evidence".
21
+
22
+ The fixtures live inside the package (``patchahead/demo/fixtures/``) rather than
23
+ in a top-level ``examples/`` directory, because ``patchahead demo`` has to work
24
+ from an install in an empty directory, not only from a git checkout. The extra
25
+ that carries its dependencies is ``[demo]``: the web UI plus the pytest that
26
+ turns a patch into a verified migration.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import enum
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ __all__ = [
36
+ "DemoError",
37
+ "Expectation",
38
+ "Scenario",
39
+ "SCENARIOS",
40
+ "changes_root",
41
+ "find",
42
+ "fixtures_root",
43
+ "repo_root",
44
+ "scenarios",
45
+ ]
46
+
47
+
48
+ class DemoError(Exception):
49
+ """The bundled demo data is missing or unreadable."""
50
+
51
+
52
+ class Expectation(str, enum.Enum):
53
+ """What a scenario is expected to demonstrate.
54
+
55
+ These are the four states a migration run can end in, and telling them apart
56
+ is the whole point of the demo. ``VERIFIED`` is the only one that means "this
57
+ worked"; the other three are the ones a tool that overclaims would blur
58
+ together.
59
+ """
60
+
61
+ #: Tests failed before the patch and pass after it. The one honest success.
62
+ VERIFIED = "verified"
63
+ #: A patch was produced and nothing verified it. Not a success.
64
+ UNVERIFIED = "patched_unverified"
65
+ #: Impact was found and PatchAhead declined to rewrite it, with a reason.
66
+ REFUSED = "refused"
67
+ #: A patch was produced and the tests rejected it.
68
+ FAILED = "validation_failed"
69
+
70
+ def __str__(self) -> str: # pragma: no cover - trivial
71
+ return self.value
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Scenario:
76
+ """One bundled story: a change document, and what it demonstrates."""
77
+
78
+ #: URL-safe identifier, used by ``--scenario`` and by the web UI.
79
+ id: str
80
+ #: Short label for the picker.
81
+ title: str
82
+ #: Migration family, shown as a chip. Free text, not a ``ChangeKind``: one
83
+ #: scenario deliberately carries two families in a single document.
84
+ family: str
85
+ #: Filename inside ``fixtures/changes``.
86
+ document: str
87
+ expect: Expectation
88
+ #: One sentence: what this scenario is for.
89
+ headline: str
90
+ #: One sentence: where to look on screen once it finishes.
91
+ watch_for: str
92
+ #: ``False`` runs the migration with test execution disabled, which is the
93
+ #: only way to reach ``patched_unverified`` on this repository -- its suite
94
+ #: is red before the patch, so every other path produces evidence one way or
95
+ #: the other.
96
+ run_tests: bool = True
97
+
98
+ @property
99
+ def change_path(self) -> Path:
100
+ path = changes_root() / self.document
101
+ if not path.is_file():
102
+ raise DemoError(f"bundled change document is missing: {path}")
103
+ return path
104
+
105
+ def to_dict(self) -> dict[str, object]:
106
+ return {
107
+ "id": self.id,
108
+ "title": self.title,
109
+ "family": self.family,
110
+ "document": self.document,
111
+ "expect": self.expect.value,
112
+ "headline": self.headline,
113
+ "watch_for": self.watch_for,
114
+ "run_tests": self.run_tests,
115
+ }
116
+
117
+
118
+ #: Ordered deliberately: the clean red-to-green migration first, because it is
119
+ #: the claim everything else qualifies, then the two harder successes, then the
120
+ #: three ways a run can end without one.
121
+ SCENARIOS: tuple[Scenario, ...] = (
122
+ Scenario(
123
+ id="field-rename",
124
+ title="A renamed field",
125
+ family="field_rename",
126
+ document="field-rename.md",
127
+ expect=Expectation.VERIFIED,
128
+ headline=(
129
+ "The upstream `order` object renamed `total` to `amount`. Two reads break; "
130
+ "a display string that merely contains the word does not."
131
+ ),
132
+ watch_for=(
133
+ '`TOTAL_LABEL = "total"` is untouched in the diff. It is a string, not a '
134
+ "field access, so it was never a candidate."
135
+ ),
136
+ ),
137
+ Scenario(
138
+ id="pagination",
139
+ title="Page numbers to cursors",
140
+ family="pagination_page_to_cursor",
141
+ document="pagination-cursor.md",
142
+ expect=Expectation.VERIFIED,
143
+ headline=(
144
+ "The endpoint dropped `page`/`total_pages` for `cursor`/`next_cursor`/"
145
+ "`has_more`. This is a loop rewrite, not a rename."
146
+ ),
147
+ watch_for=(
148
+ "The function keeps its name, signature, docstring and accumulator. Four "
149
+ "spans inside one loop change; nothing else does."
150
+ ),
151
+ ),
152
+ Scenario(
153
+ id="sdk-v2",
154
+ title="Two changes, one call site",
155
+ family="method_rename + kwarg_rename",
156
+ document="sdk-v2.md",
157
+ expect=Expectation.VERIFIED,
158
+ headline=(
159
+ "One release note, two breaking changes, both landing on the same line. "
160
+ "Neither works alone -- they have to be applied together."
161
+ ),
162
+ watch_for=(
163
+ "Two plans, one workspace, one validation run. Run the `method-rename` "
164
+ "scenario to see what half of this migration does."
165
+ ),
166
+ ),
167
+ Scenario(
168
+ id="receiver-mismatch",
169
+ title="Same field name, different object",
170
+ family="field_rename",
171
+ document="invoice-field-rename.md",
172
+ expect=Expectation.REFUSED,
173
+ headline=(
174
+ "A change document about `invoice` objects, run against code that only "
175
+ "touches `order` objects. The field name matches. Nothing else does."
176
+ ),
177
+ watch_for=(
178
+ "Both sites are found, explained and graded low -- then left alone. This "
179
+ "is the scenario that matters: a tool that rewrote them would be wrong."
180
+ ),
181
+ ),
182
+ Scenario(
183
+ id="method-rename",
184
+ title="Half a migration",
185
+ family="method_rename",
186
+ document="method-rename.md",
187
+ expect=Expectation.FAILED,
188
+ headline=(
189
+ "Only the method rename, without the keyword-argument rename that shipped "
190
+ "with it. The patch is correct and the result still does not work."
191
+ ),
192
+ watch_for=(
193
+ "`targeted_tests` fails, so the run is rejected. PatchAhead does not "
194
+ "report a migration it cannot stand behind."
195
+ ),
196
+ ),
197
+ Scenario(
198
+ id="no-evidence",
199
+ title="The same patch, with the tests switched off",
200
+ family="field_rename",
201
+ document="field-rename.md",
202
+ expect=Expectation.UNVERIFIED,
203
+ run_tests=False,
204
+ headline=(
205
+ "Byte-for-byte the diff from the first scenario, with test execution "
206
+ "disabled -- the shape of any repository whose tests do not cover a change."
207
+ ),
208
+ watch_for=(
209
+ "Identical diff, different verdict: PATCHED, NOT VERIFIED. Without a test "
210
+ "that failed before and passes after, there is nothing to verify it."
211
+ ),
212
+ ),
213
+ )
214
+
215
+
216
+ def fixtures_root() -> Path:
217
+ """Directory holding the bundled demo data.
218
+
219
+ Package data, so it is present in a wheel install and in an editable
220
+ checkout alike.
221
+ """
222
+ root = Path(__file__).resolve().parent / "fixtures"
223
+ if not root.is_dir():
224
+ raise DemoError(
225
+ f"the bundled demo fixtures are missing from the installed package "
226
+ f"(expected {root}). Reinstall patchahead, or run from a git checkout."
227
+ )
228
+ return root
229
+
230
+
231
+ def repo_root() -> Path:
232
+ """The bundled example repository. Read-only as far as the engine is concerned."""
233
+ repo = fixtures_root() / "orders-service"
234
+ if not repo.is_dir():
235
+ raise DemoError(f"the bundled example repository is missing: {repo}")
236
+ return repo
237
+
238
+
239
+ def changes_root() -> Path:
240
+ changes = fixtures_root() / "changes"
241
+ if not changes.is_dir():
242
+ raise DemoError(f"the bundled change documents are missing: {changes}")
243
+ return changes
244
+
245
+
246
+ def scenarios() -> tuple[Scenario, ...]:
247
+ """Every bundled scenario, in presentation order."""
248
+ return SCENARIOS
249
+
250
+
251
+ def find(scenario_id: str) -> Scenario:
252
+ for scenario in SCENARIOS:
253
+ if scenario.id == scenario_id:
254
+ return scenario
255
+ known = ", ".join(scenario.id for scenario in SCENARIOS)
256
+ raise DemoError(f"no such demo scenario: {scenario_id!r}. Available: {known}")
@@ -0,0 +1,14 @@
1
+ # Orders API — v2.0.0 Release Notes (Orders schema)
2
+
3
+ ## Breaking changes
4
+
5
+ ### Order field renamed: `total` → `amount`
6
+
7
+ The monetary field on each `order` object was renamed.
8
+
9
+ - **Before:** each order object had a `total` field.
10
+ - **After:** the field is now named `amount`. `total` has been **removed**.
11
+ - **Migration:** read `amount` instead of `total`.
12
+
13
+ > Risk: HIGH — code reading `order["total"]` will raise `KeyError` and
14
+ > revenue numbers will silently break.
@@ -0,0 +1,21 @@
1
+ # Billing API — v3.0.0 Release Notes
2
+
3
+ A release note from a *different* upstream service than the rest of the
4
+ documents here. It is bundled so the demo can show what PatchAhead does when a
5
+ change looks applicable and is not.
6
+
7
+ `orders-service` reads `order["total"]`. This document is about `invoice`
8
+ objects. The field name is identical; the object is not, and no amount of
9
+ string matching can tell the difference.
10
+
11
+ ## Breaking changes
12
+
13
+ ### Invoice field renamed: `total` → `amount`
14
+
15
+ The monetary field on each `invoice` object was renamed.
16
+
17
+ - **Before:** each invoice object had a `total` field.
18
+ - **After:** the field is now named `amount`. `total` has been **removed**.
19
+ - **Migration:** read `amount` instead of `total` on invoices.
20
+
21
+ > Risk: HIGH — billing code reading `invoice["total"]` will raise `KeyError`.
@@ -0,0 +1,14 @@
1
+ # Orders SDK — v2.0.0 Release Notes
2
+
3
+ ## Breaking changes
4
+
5
+ ### Keyword argument renamed: `timeout_seconds` → `timeout`
6
+
7
+ The `timeout_seconds` keyword argument on `fetch_orders` was renamed to `timeout`.
8
+ The unit is unchanged (seconds); only the parameter name changed.
9
+
10
+ - **Before:** `client.fetch_orders(limit=10, timeout_seconds=30)`
11
+ - **After:** `client.fetch_orders(limit=10, timeout=30)`
12
+ - **Migration:** rename the `timeout_seconds=` keyword argument to `timeout=`.
13
+
14
+ > Risk: MEDIUM — calls raise `TypeError: unexpected keyword argument`.
@@ -0,0 +1,12 @@
1
+ # Orders SDK — v2.0.0 Release Notes
2
+
3
+ ## Breaking changes
4
+
5
+ ### Client method renamed: `fetch_orders` → `list_orders`
6
+
7
+ - **Before:** `client.fetch_orders(limit=10)`
8
+ - **After:** `client.list_orders(limit=10)`
9
+ - The deprecated `fetch_orders` method has been **removed**.
10
+ - **Migration:** call `list_orders` instead of `fetch_orders`. The signature is unchanged.
11
+
12
+ > Risk: MEDIUM — calls raise `AttributeError` at runtime.
@@ -0,0 +1,24 @@
1
+ {
2
+ "title": "Pagination is now cursor-based",
3
+ "kind": "pagination_page_to_cursor",
4
+ "old_behavior": "get_orders(page=N) returned `page` and `total_pages`",
5
+ "new_behavior": "get_orders(cursor=C) returns `next_cursor` and `has_more`",
6
+ "migration_hint": "Iterate with cursor/next_cursor; stop when has_more is false.",
7
+ "severity": "high",
8
+ "confidence": "high",
9
+ "target": {
10
+ "symbol": "page",
11
+ "replacement": "cursor",
12
+ "owner": "get_orders"
13
+ },
14
+ "pagination": {
15
+ "page_param": "page",
16
+ "total_pages_key": "total_pages",
17
+ "cursor_param": "cursor",
18
+ "next_cursor_key": "next_cursor",
19
+ "has_more_key": "has_more"
20
+ },
21
+ "evidence": [
22
+ "The `page` and `total_pages` response fields have been removed."
23
+ ]
24
+ }
@@ -0,0 +1,20 @@
1
+ # Orders API — v2.0.0 Release Notes
2
+
3
+ ## Breaking changes
4
+
5
+ ### Pagination is now cursor-based
6
+
7
+ The Orders endpoint no longer uses page-based pagination.
8
+
9
+ - **Before:** `get_orders(page=1)` returned `page` and `total_pages`.
10
+ - **After:** `get_orders(cursor=<cursor>)` returns `next_cursor` and `has_more`.
11
+ - The `page` and `total_pages` response fields have been **removed**.
12
+ - **Migration:** iterate using `cursor` / `next_cursor` and stop when `has_more` is `false`.
13
+
14
+ > Risk: HIGH — integrations that read `total_pages` will raise `KeyError`
15
+ > and may silently sync incomplete data.
16
+
17
+ ## Non-breaking changes
18
+
19
+ - Added `created_at` to each order object.
20
+ - Improved rate-limit headers.
@@ -0,0 +1,31 @@
1
+ # Orders SDK — v2.0.0 Release Notes
2
+
3
+ Two breaking changes in this release. Both affect the same call site, so they
4
+ have to be migrated together — which is what this document demonstrates:
5
+ PatchAhead applies every change from one document into one workspace, in order.
6
+
7
+ ## Breaking changes
8
+
9
+ ### Client method renamed: `fetch_orders` → `list_orders`
10
+
11
+ - **Before:** `client.fetch_orders(limit=10)`
12
+ - **After:** `client.list_orders(limit=10)`
13
+ - The deprecated `fetch_orders` method has been **removed**.
14
+ - **Migration:** call `list_orders` instead of `fetch_orders`.
15
+
16
+ > Risk: MEDIUM — calls raise `AttributeError` at runtime.
17
+
18
+ ### Keyword argument renamed: `timeout_seconds` → `timeout`
19
+
20
+ The `timeout_seconds` keyword argument on `list_orders` was renamed to `timeout`.
21
+ The unit is unchanged (seconds); only the parameter name changed.
22
+
23
+ - **Before:** `client.list_orders(limit=10, timeout_seconds=30)`
24
+ - **After:** `client.list_orders(limit=10, timeout=30)`
25
+ - **Migration:** rename the `timeout_seconds=` keyword argument to `timeout=`.
26
+
27
+ > Risk: MEDIUM — calls raise `TypeError: unexpected keyword argument`.
28
+
29
+ ## Non-breaking changes
30
+
31
+ - Added `created_at` to each order object.