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
|
@@ -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)
|
patchahead/reporting.py
ADDED
|
@@ -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
|
+
]
|