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,223 @@
1
+ """Logging, timing, and optional error reporting.
2
+
3
+ Three rules this module exists to enforce:
4
+
5
+ 1. **Nothing fails silently.** Optional integrations degrade to a no-op, but the
6
+ degradation itself is logged at debug level with the reason.
7
+ 2. **Nothing sensitive is logged.** :func:`redact` scrubs values that look like
8
+ credentials, and source code is never logged above debug level.
9
+ 3. **Timing is measured, not claimed.** :class:`Timer` records real durations so
10
+ performance statements come from instrumentation.
11
+
12
+ Sentry is entirely optional; PatchAhead has no runtime dependency on it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import logging
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ from collections.abc import Iterator
24
+ from typing import Any
25
+
26
+ log = logging.getLogger("patchahead")
27
+
28
+ #: Environment variables whose values must never reach a log or an error report.
29
+ _SECRET_PATTERN = re.compile(
30
+ r"(?i)(api[_-]?key|secret|token|password|passwd|authorization|dsn|credential)"
31
+ )
32
+ _SECRET_VALUE = re.compile(r"(?i)\b(sk-[A-Za-z0-9_\-]{8,}|ghp_[A-Za-z0-9]{8,})\b")
33
+
34
+
35
+ def redact(value: Any) -> Any:
36
+ """Return ``value`` with anything credential-shaped replaced.
37
+
38
+ Applied to every structured log field. Conservative by design: a redacted
39
+ log line is a mild inconvenience, a leaked key is an incident.
40
+ """
41
+ if isinstance(value, str):
42
+ return _SECRET_VALUE.sub("[REDACTED]", value)
43
+ if isinstance(value, dict):
44
+ return {
45
+ key: ("[REDACTED]" if _SECRET_PATTERN.search(str(key)) else redact(val))
46
+ for key, val in value.items()
47
+ }
48
+ if isinstance(value, (list, tuple)):
49
+ return type(value)(redact(v) for v in value)
50
+ return value
51
+
52
+
53
+ class _RedactingFilter(logging.Filter):
54
+ """Scrub credential-shaped substrings from every formatted message."""
55
+
56
+ def filter(self, record: logging.LogRecord) -> bool:
57
+ if isinstance(record.msg, str):
58
+ record.msg = _SECRET_VALUE.sub("[REDACTED]", record.msg)
59
+ if record.args:
60
+ record.args = redact(record.args)
61
+ return True
62
+
63
+
64
+ class _Formatter(logging.Formatter):
65
+ """Plain, greppable output. Level prefix only when it matters."""
66
+
67
+ def format(self, record: logging.LogRecord) -> str:
68
+ message = record.getMessage()
69
+ if record.levelno >= logging.WARNING:
70
+ prefix = f"{record.levelname.lower()}: "
71
+ elif record.levelno <= logging.DEBUG:
72
+ prefix = f"debug [{record.name}] "
73
+ else:
74
+ prefix = ""
75
+ if record.exc_info:
76
+ message = f"{message}\n{self.formatException(record.exc_info)}"
77
+ return f"{prefix}{message}"
78
+
79
+
80
+ def configure_logging(level: int = logging.INFO, stream: Any = None) -> None:
81
+ """Install PatchAhead's log handler. Idempotent.
82
+
83
+ Logs go to stderr so that ``--json`` output on stdout stays machine-readable
84
+ even at debug verbosity.
85
+ """
86
+ logger = logging.getLogger("patchahead")
87
+ logger.setLevel(level)
88
+ logger.propagate = False
89
+
90
+ for handler in list(logger.handlers):
91
+ logger.removeHandler(handler)
92
+
93
+ handler = logging.StreamHandler(stream if stream is not None else sys.stderr)
94
+ handler.setFormatter(_Formatter())
95
+ handler.addFilter(_RedactingFilter())
96
+ logger.addHandler(handler)
97
+
98
+
99
+ class Timer:
100
+ """Accumulates named wall-clock durations for one run.
101
+
102
+ Used for the ``timings`` field on results, so any performance claim
103
+ PatchAhead makes is backed by a measurement the user can see.
104
+ """
105
+
106
+ def __init__(self) -> None:
107
+ self.durations: dict[str, int] = {}
108
+
109
+ @contextlib.contextmanager
110
+ def stage(self, name: str) -> Iterator[None]:
111
+ start = time.perf_counter()
112
+ try:
113
+ yield
114
+ finally:
115
+ elapsed = int((time.perf_counter() - start) * 1000)
116
+ self.durations[name] = self.durations.get(name, 0) + elapsed
117
+ log.debug("stage %s took %dms", name, elapsed)
118
+
119
+ def as_dict(self) -> dict[str, int]:
120
+ return dict(self.durations)
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+ # Optional Sentry integration
125
+ # --------------------------------------------------------------------------
126
+
127
+ _sentry_state: dict[str, Any] = {
128
+ "initialized": False,
129
+ "enabled": False,
130
+ "reason": "not initialized",
131
+ }
132
+
133
+
134
+ def init_error_reporting() -> bool:
135
+ """Initialize Sentry if ``SENTRY_DSN`` is set and the SDK is installed.
136
+
137
+ Returns whether reporting is active. Never raises: a broken optional
138
+ integration must not break a migration run. The reason for any degradation
139
+ is recorded and available from :func:`error_reporting_status`.
140
+ """
141
+ if _sentry_state["initialized"]:
142
+ return bool(_sentry_state["enabled"])
143
+ _sentry_state["initialized"] = True
144
+
145
+ dsn = os.environ.get("SENTRY_DSN", "").strip()
146
+ if not dsn:
147
+ _sentry_state["reason"] = "SENTRY_DSN not set"
148
+ log.debug("error reporting disabled: %s", _sentry_state["reason"])
149
+ return False
150
+ try:
151
+ import sentry_sdk
152
+ except ImportError:
153
+ _sentry_state["reason"] = "sentry-sdk not installed (pip install 'patchahead[sentry]')"
154
+ log.debug("error reporting disabled: %s", _sentry_state["reason"])
155
+ return False
156
+ try:
157
+ from patchahead import __version__
158
+
159
+ sentry_sdk.init(
160
+ dsn=dsn,
161
+ release=f"patchahead@{__version__}",
162
+ traces_sample_rate=float(os.environ.get("PATCHAHEAD_TRACES_SAMPLE_RATE", "0")),
163
+ # Repository source is the user's proprietary code. Never attach it.
164
+ send_default_pii=False,
165
+ # On by default in sentry-sdk: every stack frame would carry its
166
+ # local variables, and here those are `source`, `original`, a
167
+ # patched file -- the repository's code, in full.
168
+ include_local_variables=False,
169
+ max_request_body_size="never",
170
+ before_send=_scrub_event,
171
+ )
172
+ except Exception as exc:
173
+ _sentry_state["reason"] = f"sentry_sdk.init failed: {exc}"
174
+ log.warning("error reporting could not start: %s", _sentry_state["reason"])
175
+ return False
176
+
177
+ _sentry_state["enabled"] = True
178
+ _sentry_state["reason"] = "active"
179
+ log.debug("error reporting enabled")
180
+ return True
181
+
182
+
183
+ def _scrub_event(event: dict[str, Any], _hint: Any) -> dict[str, Any]:
184
+ """Strip environment variables, request bodies, and frame locals from events.
185
+
186
+ Frame locals are also disabled at ``init``; removing them here as well means
187
+ a future change to that setting cannot quietly start sending source code.
188
+ """
189
+ event.pop("request", None)
190
+ for section in ("exception", "threads"):
191
+ for value in (event.get(section) or {}).get("values") or []:
192
+ for frame in (value.get("stacktrace") or {}).get("frames") or []:
193
+ frame.pop("vars", None)
194
+ contexts = event.get("contexts")
195
+ if isinstance(contexts, dict):
196
+ contexts.pop("env", None)
197
+ extra = event.get("extra")
198
+ if isinstance(extra, dict):
199
+ event["extra"] = redact(extra)
200
+ return event
201
+
202
+
203
+ def error_reporting_status() -> str:
204
+ """Human-readable state of the optional error reporting integration."""
205
+ if not _sentry_state["initialized"]:
206
+ return "not initialized"
207
+ return "active" if _sentry_state["enabled"] else f"disabled ({_sentry_state['reason']})"
208
+
209
+
210
+ def capture_exception(exc: BaseException, **context: Any) -> None:
211
+ """Report an exception if Sentry is active. Always logs it either way."""
212
+ log.debug("captured exception: %s", exc, exc_info=exc)
213
+ if not _sentry_state.get("enabled"):
214
+ return
215
+ try:
216
+ import sentry_sdk
217
+
218
+ with sentry_sdk.push_scope() as scope:
219
+ for key, value in redact(context).items():
220
+ scope.set_extra(key, value)
221
+ sentry_sdk.capture_exception(exc)
222
+ except Exception: # pragma: no cover - reporting must never break a run
223
+ log.debug("failed to report exception to Sentry", exc_info=True)
@@ -0,0 +1,451 @@
1
+ """Rendering results for humans: terminal text and a review-ready Markdown PR.
2
+
3
+ Every number here comes from a result object. Nothing is computed a second time,
4
+ so what the CLI prints and what the JSON output says cannot drift apart.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from patchahead.domain.change import BreakingChange, Confidence
13
+ from patchahead.domain.completeness import CompletenessReport, ResidualKind
14
+ from patchahead.domain.impact import ImpactReport
15
+ from patchahead.domain.result import AnalysisResult, MigrationResult, MigrationRun, Outcome
16
+ from patchahead.domain.validation import GateStatus, ValidationResult
17
+
18
+ _COLORS = {
19
+ "bold": "1",
20
+ "dim": "90",
21
+ "red": "91",
22
+ "green": "92",
23
+ "yellow": "93",
24
+ "cyan": "96",
25
+ }
26
+
27
+
28
+ def _use_color(stream) -> bool:
29
+ if os.environ.get("NO_COLOR"):
30
+ return False
31
+ return bool(getattr(stream, "isatty", lambda: False)())
32
+
33
+
34
+ class Style:
35
+ """Colors, switched off when output is redirected or ``NO_COLOR`` is set."""
36
+
37
+ def __init__(self, enabled: bool) -> None:
38
+ self.enabled = enabled
39
+
40
+ def __call__(self, text: str, color: str) -> str:
41
+ if not self.enabled or color not in _COLORS:
42
+ return text
43
+ return f"\033[{_COLORS[color]}m{text}\033[0m"
44
+
45
+ @classmethod
46
+ def for_stream(cls, stream) -> Style:
47
+ return cls(_use_color(stream))
48
+
49
+
50
+ _CONFIDENCE_COLOR = {
51
+ Confidence.HIGH: "green",
52
+ Confidence.MEDIUM: "yellow",
53
+ Confidence.LOW: "dim",
54
+ }
55
+
56
+ _OUTCOME_COLOR = {
57
+ Outcome.MIGRATED: "green",
58
+ Outcome.VALIDATION_FAILED: "red",
59
+ Outcome.PATCHED_UNVERIFIED: "yellow",
60
+ Outcome.PATCH_FAILED: "red",
61
+ Outcome.NO_IMPACT: "dim",
62
+ Outcome.UNSUPPORTED_CHANGE: "yellow",
63
+ Outcome.NOT_PLANNABLE: "yellow",
64
+ Outcome.DRY_RUN: "cyan",
65
+ }
66
+
67
+
68
+ def relative(path: str) -> str:
69
+ """Shorten an absolute path against the working directory, when shorter."""
70
+ try:
71
+ candidate = os.path.relpath(path, Path.cwd())
72
+ except (ValueError, OSError):
73
+ return path
74
+ return candidate if len(candidate) < len(path) else path
75
+
76
+
77
+ # --------------------------------------------------------------------------
78
+ # terminal rendering
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ def render_change(change: BreakingChange, style: Style) -> list[str]:
83
+ lines = [
84
+ style(change.title, "bold"),
85
+ f" kind {style(change.kind.value, 'cyan')}",
86
+ f" severity {change.severity.value} confidence {change.confidence.value}",
87
+ ]
88
+ if change.target.is_rename:
89
+ owner = f" on `{change.target.owner}`" if change.target.owner else ""
90
+ lines.append(
91
+ f" rename `{change.target.symbol}` -> `{change.target.replacement}`{owner}"
92
+ )
93
+ if change.old_behavior:
94
+ lines.append(f" before {change.old_behavior}")
95
+ if change.new_behavior:
96
+ lines.append(f" after {change.new_behavior}")
97
+ if change.classification_reason:
98
+ lines.append(style(f" why {change.classification_reason}", "dim"))
99
+ return lines
100
+
101
+
102
+ def render_impact(report: ImpactReport, style: Style, verbose: bool = False) -> list[str]:
103
+ if report.unsupported_reason:
104
+ return [style(f" unsupported: {report.unsupported_reason}", "yellow")]
105
+ if not report.findings:
106
+ return [style(" no affected code found", "dim")]
107
+
108
+ lines = [
109
+ f" {len(report.findings)} finding(s) in "
110
+ f"{len(report.affected_files)} file(s), "
111
+ f"scanned {report.files_scanned} file(s) in {report.analysis_ms}ms",
112
+ ]
113
+ for finding in report.findings:
114
+ marker = "+" if finding.patchable else "-"
115
+ color = _CONFIDENCE_COLOR[finding.confidence]
116
+ lines.append(
117
+ f" {style(marker, color)} {finding.reference}"
118
+ f" {style(finding.confidence.value, color)}"
119
+ f" {finding.symbol} {finding.matched_contract}"
120
+ )
121
+ if verbose or not finding.patchable:
122
+ lines.append(style(f" {finding.reason}", "dim"))
123
+ if finding.reference.snippet:
124
+ lines.append(style(f" | {finding.reference.snippet}", "dim"))
125
+
126
+ if report.related_tests:
127
+ lines.append(f" relevant tests: {', '.join(report.related_tests)}")
128
+ if report.graph and verbose:
129
+ lines.append("")
130
+ lines.extend(f" {line}" for line in report.graph.render().splitlines())
131
+ return lines
132
+
133
+
134
+ def render_validation(validation: ValidationResult, style: Style) -> list[str]:
135
+ marks = {
136
+ GateStatus.PASSED: (style("pass", "green"), "green"),
137
+ GateStatus.FAILED: (style("FAIL", "red"), "red"),
138
+ GateStatus.SKIPPED: (style("skip", "dim"), "dim"),
139
+ }
140
+ lines = []
141
+ for gate in validation.gates:
142
+ mark, _ = marks[gate.status]
143
+ timing = f" ({gate.duration_ms}ms)" if gate.duration_ms else ""
144
+ lines.append(f" [{mark}] {gate.name.value:<20}{timing} {gate.detail}")
145
+ return lines
146
+
147
+
148
+ def render_analysis(result: AnalysisResult, verbose: bool = False, stream=None) -> str:
149
+ style = Style.for_stream(stream)
150
+ lines = [
151
+ style(f"repository {relative(result.repo)}", "dim"),
152
+ style(f"change document {relative(result.change_document)}", "dim"),
153
+ "",
154
+ ]
155
+ for report in result.reports:
156
+ lines.extend(render_change(report.change, style))
157
+ lines.extend(render_impact(report, style, verbose))
158
+ lines.append("")
159
+
160
+ for warning in result.warnings:
161
+ lines.append(style(f"warning: {warning}", "yellow"))
162
+
163
+ if result.total_findings:
164
+ lines.append(
165
+ style(
166
+ f"{result.total_findings} finding(s). Run `patchahead migrate` with "
167
+ f"the same arguments to propose a migration.",
168
+ "bold",
169
+ )
170
+ )
171
+ else:
172
+ lines.append(style("No affected code found.", "dim"))
173
+ if verbose and result.timings:
174
+ lines.append(
175
+ style(
176
+ "timings: " + ", ".join(f"{k}={v}ms" for k, v in sorted(result.timings.items())),
177
+ "dim",
178
+ )
179
+ )
180
+ return "\n".join(lines)
181
+
182
+
183
+ def render_migration(
184
+ result: MigrationResult, verbose: bool = False, show_diff: bool = True, stream=None
185
+ ) -> str:
186
+ style = Style.for_stream(stream)
187
+ color = _OUTCOME_COLOR.get(result.outcome, "bold")
188
+ lines = list(render_change(result.impact.change, style))
189
+ lines.append("")
190
+ lines.extend(render_impact(result.impact, style, verbose))
191
+ lines.append("")
192
+
193
+ if result.plan and not result.plan.blocked_reason:
194
+ lines.append(style("plan", "bold"))
195
+ lines.extend(f" {line}" for line in result.plan.render().splitlines()[1:])
196
+ lines.append("")
197
+ elif result.plan and result.plan.blocked_reason:
198
+ lines.append(style(f"plan blocked: {result.plan.blocked_reason}", "yellow"))
199
+ for skipped in result.plan.skipped:
200
+ lines.append(style(f" skipped {skipped}", "dim"))
201
+ lines.append("")
202
+
203
+ if show_diff and result.diff:
204
+ lines.append(style("proposed diff", "bold"))
205
+ for line in result.diff.splitlines():
206
+ if line.startswith("+++") or line.startswith("---"):
207
+ lines.append(style(f" {line}", "dim"))
208
+ elif line.startswith("+"):
209
+ lines.append(style(f" {line}", "green"))
210
+ elif line.startswith("-"):
211
+ lines.append(style(f" {line}", "red"))
212
+ elif line.startswith("@@"):
213
+ lines.append(style(f" {line}", "cyan"))
214
+ else:
215
+ lines.append(f" {line}")
216
+ lines.append("")
217
+
218
+ if result.validation:
219
+ lines.append(style("validation", "bold"))
220
+ lines.extend(render_validation(result.validation, style))
221
+ lines.append("")
222
+
223
+ if result.completeness:
224
+ lines.append(style("completeness", "bold"))
225
+ lines.extend(render_completeness(result.completeness, style, verbose))
226
+ lines.append("")
227
+
228
+ lines.append(style(f"{result.outcome.value}: {result.message}", color))
229
+ if result.artifacts:
230
+ for key, path in sorted(result.artifacts.items()):
231
+ lines.append(style(f" {key:<8} {relative(path)}", "dim"))
232
+ if result.workspace_path:
233
+ lines.append(style(f" workspace {result.workspace_path}", "dim"))
234
+ if verbose and result.timings:
235
+ lines.append(
236
+ style(
237
+ " timings: " + ", ".join(f"{k}={v}ms" for k, v in sorted(result.timings.items())),
238
+ "dim",
239
+ )
240
+ )
241
+ return "\n".join(lines)
242
+
243
+
244
+ #: How many unfinished residuals the terminal lists before summarizing the rest.
245
+ _RESIDUALS_SHOWN = 10
246
+ _MENTION_KINDS = (
247
+ ResidualKind.STRING,
248
+ ResidualKind.COMMENT,
249
+ ResidualKind.CONFIG,
250
+ ResidualKind.DOCS,
251
+ )
252
+
253
+
254
+ def render_completeness(
255
+ report: CompletenessReport, style: Style, verbose: bool = False
256
+ ) -> list[str]:
257
+ """Where the old name survives: unfinished work first, mentions summarized."""
258
+ names = f"`{report.old}` -> `{report.new}`"
259
+ unfinished = report.unfinished
260
+ lines = [
261
+ style(f" {names}: no code, dynamic access, or test still uses `{report.old}`", "green")
262
+ if not unfinished
263
+ else style(f" {names}: {len(unfinished)} place(s) still use `{report.old}`", "yellow")
264
+ ]
265
+ for residual in unfinished[:_RESIDUALS_SHOWN]:
266
+ lines.append(
267
+ f" [{residual.kind.value:<7}] {residual.path}:{residual.line} {residual.snippet}"
268
+ )
269
+ lines.append(style(f" {residual.reason}", "dim"))
270
+ if len(unfinished) > _RESIDUALS_SHOWN:
271
+ lines.append(style(f" ... and {len(unfinished) - _RESIDUALS_SHOWN} more (--json)", "dim"))
272
+
273
+ other = report.count(ResidualKind.OTHER_OBJECT)
274
+ if other:
275
+ lines.append(
276
+ style(f" {other} site(s) on a different object, left alone on purpose", "dim")
277
+ )
278
+ mentions = [(kind, report.count(kind)) for kind in _MENTION_KINDS if report.count(kind)]
279
+ if mentions:
280
+ counted = ", ".join(f"{n} {kind.value}" for kind, n in mentions)
281
+ lines.append(style(f" mentions to review: {counted}", "dim"))
282
+ if verbose:
283
+ for residual in report.residuals:
284
+ if residual.kind in _MENTION_KINDS:
285
+ lines.append(
286
+ style(f" {residual.path}:{residual.line} {residual.snippet}", "dim")
287
+ )
288
+ return lines
289
+
290
+
291
+ def render_run(
292
+ run: MigrationRun, verbose: bool = False, show_diff: bool = True, stream=None
293
+ ) -> str:
294
+ style = Style.for_stream(stream)
295
+ blocks = [
296
+ style(f"repository {relative(run.repo)}", "dim"),
297
+ style(f"change document {relative(run.change_document)}", "dim"),
298
+ "",
299
+ ]
300
+ for index, result in enumerate(run.results):
301
+ if index:
302
+ blocks.append(style("-" * 60, "dim"))
303
+ blocks.append(render_migration(result, verbose, show_diff, stream))
304
+ for warning in run.warnings:
305
+ blocks.append(style(f"warning: {warning}", "yellow"))
306
+ if not run.results:
307
+ blocks.append(style("No breaking changes were parsed from the document.", "yellow"))
308
+ return "\n".join(blocks)
309
+
310
+
311
+ # --------------------------------------------------------------------------
312
+ # Markdown, for a pull request body
313
+ # --------------------------------------------------------------------------
314
+
315
+
316
+ def render_pr_markdown(result: MigrationResult) -> str:
317
+ """A review-ready Markdown summary of one migration.
318
+
319
+ Ordered by what a reviewer needs to decide: what upstream changed, where it
320
+ hit us, exactly what we propose to do about it, and what evidence says it
321
+ worked. Then the diff. The checklist at the end is what the human is being
322
+ asked to confirm -- PatchAhead does not merge anything.
323
+ """
324
+ change = result.impact.change
325
+ report = result.impact
326
+ lines: list[str] = []
327
+
328
+ title = (
329
+ f"Migrate `{change.target.symbol}` -> `{change.target.replacement}`"
330
+ if change.target.is_rename
331
+ else f"Migrate for: {change.title}"
332
+ )
333
+ lines += [
334
+ f"# {title}",
335
+ "",
336
+ "> Proposed by **PatchAhead**. Not merged, not applied. Review before approving.",
337
+ "",
338
+ "## 1. Upstream change",
339
+ "",
340
+ f"- **Kind:** `{change.kind.value}`",
341
+ f"- **Severity:** {change.severity.value}",
342
+ f"- **Reading confidence:** {change.confidence.value} ({change.classification_reason})",
343
+ ]
344
+ if change.old_behavior:
345
+ lines.append(f"- **Before:** {change.old_behavior}")
346
+ if change.new_behavior:
347
+ lines.append(f"- **After:** {change.new_behavior}")
348
+ if change.migration_hint:
349
+ lines.append(f"- **Migration guidance:** {change.migration_hint}")
350
+ if change.evidence:
351
+ lines += ["", "<details><summary>Evidence from the change document</summary>", ""]
352
+ for evidence in change.evidence:
353
+ location = f" (line {evidence.line})" if evidence.line else ""
354
+ lines.append(f"- {evidence.quote}{location}")
355
+ lines += ["", "</details>"]
356
+
357
+ lines += ["", "## 2. Downstream impact", ""]
358
+ if report.findings:
359
+ lines += [
360
+ "| Location | Symbol | Matched | Confidence | Patched |",
361
+ "|---|---|---|---|---|",
362
+ ]
363
+ for finding in report.findings:
364
+ lines.append(
365
+ f"| `{finding.reference}` | `{finding.symbol}` | "
366
+ f"`{finding.matched_contract}` | {finding.confidence.value} | "
367
+ f"{'yes' if finding.patchable else 'no'} |"
368
+ )
369
+ unpatched = [f for f in report.findings if not f.patchable]
370
+ if unpatched:
371
+ lines += ["", "**Reported but not changed:**", ""]
372
+ lines += [f"- `{f.reference}` -- {f.unpatchable_reason or f.reason}" for f in unpatched]
373
+ else:
374
+ lines.append("_No affected code was found._")
375
+
376
+ lines += ["", "## 3. Proposed change", ""]
377
+ if result.plan and not result.plan.blocked_reason:
378
+ lines += [
379
+ f"- **Handler:** `{result.plan.handler}`",
380
+ f"- **Risk:** {result.plan.risk.value}",
381
+ f"- **Rationale:** {result.plan.rationale}",
382
+ "",
383
+ "```",
384
+ result.plan.render(),
385
+ "```",
386
+ ]
387
+ elif result.plan:
388
+ lines.append(f"_No migration could be planned:_ {result.plan.blocked_reason}")
389
+
390
+ lines += ["", "## 4. Evidence", ""]
391
+ if result.validation:
392
+ lines += ["| Gate | Status | Detail |", "|---|---|---|"]
393
+ for gate in result.validation.gates:
394
+ lines.append(f"| `{gate.name.value}` | {gate.status.value} | {gate.detail} |")
395
+ else:
396
+ lines.append("_Validation did not run._")
397
+
398
+ if result.baseline_tests:
399
+ lines += [
400
+ "",
401
+ f"Tests before the patch: `{result.baseline_tests.summary}`",
402
+ ]
403
+
404
+ if result.diff:
405
+ lines += ["", "## 5. Diff", "", "```diff", result.diff.rstrip(), "```"]
406
+
407
+ if result.completeness:
408
+ lines += ["", "## 6. What is left of the old API", ""]
409
+ lines += _completeness_markdown(result.completeness)
410
+
411
+ lines += [
412
+ "",
413
+ "## 7. Human review",
414
+ "",
415
+ f"- **Outcome:** `{result.outcome.value}` -- {result.message}",
416
+ "- **Auto-merge:** disabled. PatchAhead proposes; a human approves.",
417
+ "",
418
+ "### Checklist",
419
+ "",
420
+ "- [ ] The upstream change is characterized correctly",
421
+ "- [ ] The diff is minimal and changes no unrelated code",
422
+ "- [ ] The tests genuinely exercise the migrated behavior",
423
+ "- [ ] Findings reported but not patched have been looked at",
424
+ "- [ ] Every place the old name survives (section 6) has been looked at",
425
+ "",
426
+ ]
427
+ return "\n".join(lines)
428
+
429
+
430
+ def _completeness_markdown(report: CompletenessReport) -> list[str]:
431
+ unfinished = report.unfinished
432
+ if unfinished:
433
+ lines = [
434
+ f"**{len(unfinished)} place(s) still use `{report.old}`.** Passing tests do "
435
+ f"not cover these.",
436
+ "",
437
+ "| Location | Kind | Code | Why |",
438
+ "|---|---|---|---|",
439
+ ]
440
+ lines += [
441
+ f"| `{r.path}:{r.line}` | {r.kind.value} | `{r.snippet}` | {r.reason} |"
442
+ for r in unfinished
443
+ ]
444
+ else:
445
+ lines = [f"No code, dynamic access, or test still uses `{report.old}`."]
446
+ rest = [r for r in report.residuals if not r.kind.unfinished]
447
+ if rest:
448
+ lines += ["", "<details><summary>Left on purpose, and mentions to review</summary>", ""]
449
+ lines += [f"- `{r.path}:{r.line}` ({r.kind.value}) -- {r.reason}" for r in rest]
450
+ lines += ["", "</details>"]
451
+ return lines
@@ -0,0 +1,22 @@
1
+ """Test discovery and execution."""
2
+
3
+ from patchahead.testing import discovery, runner
4
+ from patchahead.testing.discovery import (
5
+ has_any_tests,
6
+ scoped_command,
7
+ tests_by_file,
8
+ tests_for_path,
9
+ tests_for_paths,
10
+ )
11
+ from patchahead.testing.runner import run_tests
12
+
13
+ __all__ = [
14
+ "discovery",
15
+ "run_tests",
16
+ "runner",
17
+ "has_any_tests",
18
+ "scoped_command",
19
+ "tests_by_file",
20
+ "tests_for_path",
21
+ "tests_for_paths",
22
+ ]