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/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.
|