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,269 @@
1
+ """The upstream side of the domain model: what broke, and how sure we are.
2
+
3
+ A :class:`BreakingChange` is the output of change ingestion and the input to
4
+ everything else. It carries two things that the prototype conflated:
5
+
6
+ * what the change *is* (:class:`ChangeKind` plus a :class:`SymbolTarget`), and
7
+ * how confident we are that we read the document correctly
8
+ (:class:`Confidence` plus the :class:`Evidence` we based it on).
9
+
10
+ Uncertain extraction is represented as uncertain. A document PatchAhead cannot
11
+ classify yields ``ChangeKind.UNKNOWN``, not a plausible-looking guess.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import enum
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+
21
+ class ChangeKind(str, enum.Enum):
22
+ """The migration families PatchAhead knows about.
23
+
24
+ Every member except :attr:`UNKNOWN` and :attr:`UNSUPPORTED` has a handler in
25
+ :mod:`patchahead.handlers` with analysis, planning, patching, validation,
26
+ tests, and documentation. Adding a member without a handler is a bug --
27
+ :func:`patchahead.handlers.selftest_registry` asserts this.
28
+ """
29
+
30
+ FIELD_RENAME = "field_rename"
31
+ METHOD_RENAME = "method_rename"
32
+ KWARG_RENAME = "kwarg_rename"
33
+ PAGINATION_PAGE_TO_CURSOR = "pagination_page_to_cursor"
34
+
35
+ #: Recognized as a breaking change, but no v1 handler can migrate it.
36
+ UNSUPPORTED = "unsupported"
37
+ #: The document could not be classified at all.
38
+ UNKNOWN = "unknown"
39
+
40
+ @property
41
+ def is_actionable(self) -> bool:
42
+ return self not in (ChangeKind.UNSUPPORTED, ChangeKind.UNKNOWN)
43
+
44
+ def __str__(self) -> str: # pragma: no cover - trivial
45
+ return self.value
46
+
47
+
48
+ class Confidence(str, enum.Enum):
49
+ """Graded confidence, ordered.
50
+
51
+ Deliberately coarse. A float implies a calibration we do not have; three
52
+ buckets can be explained to a reviewer and tested against fixtures.
53
+ """
54
+
55
+ HIGH = "high"
56
+ MEDIUM = "medium"
57
+ LOW = "low"
58
+
59
+ @property
60
+ def rank(self) -> int:
61
+ return {"low": 0, "medium": 1, "high": 2}[self.value]
62
+
63
+ def __ge__(self, other: object) -> bool:
64
+ if not isinstance(other, Confidence):
65
+ return NotImplemented
66
+ return self.rank >= other.rank
67
+
68
+ def __gt__(self, other: object) -> bool:
69
+ if not isinstance(other, Confidence):
70
+ return NotImplemented
71
+ return self.rank > other.rank
72
+
73
+ def __le__(self, other: object) -> bool:
74
+ if not isinstance(other, Confidence):
75
+ return NotImplemented
76
+ return self.rank <= other.rank
77
+
78
+ def __lt__(self, other: object) -> bool:
79
+ if not isinstance(other, Confidence):
80
+ return NotImplemented
81
+ return self.rank < other.rank
82
+
83
+ @classmethod
84
+ def parse(cls, value: object, default: Confidence = None) -> Confidence:
85
+ """Coerce user/LLM-supplied text to a member, falling back to ``default``."""
86
+ if isinstance(value, Confidence):
87
+ return value
88
+ if isinstance(value, str):
89
+ try:
90
+ return cls(value.strip().lower())
91
+ except ValueError:
92
+ pass
93
+ if default is None:
94
+ raise ValueError(f"not a confidence level: {value!r}")
95
+ return default
96
+
97
+
98
+ class Severity(str, enum.Enum):
99
+ """How bad the break is if left unmigrated. Reported, never acted upon."""
100
+
101
+ HIGH = "high"
102
+ MEDIUM = "medium"
103
+ LOW = "low"
104
+
105
+ @classmethod
106
+ def parse(cls, value: object, default: Severity = None) -> Severity:
107
+ if isinstance(value, Severity):
108
+ return value
109
+ if isinstance(value, str):
110
+ try:
111
+ return cls(value.strip().lower())
112
+ except ValueError:
113
+ pass
114
+ if default is None:
115
+ raise ValueError(f"not a severity: {value!r}")
116
+ return default
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Evidence:
121
+ """A quotation from the change document that justifies a conclusion.
122
+
123
+ Evidence always points back at the source text. If PatchAhead asserts
124
+ something about a change, a reviewer can find the line it came from.
125
+ """
126
+
127
+ quote: str
128
+ #: 1-indexed line in the source document, when known.
129
+ line: int | None = None
130
+ #: Free-text note on why this quote mattered, e.g. "matched 'renamed to'".
131
+ note: str = ""
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {"quote": self.quote, "line": self.line, "note": self.note}
135
+
136
+
137
+ @dataclass
138
+ class SymbolTarget:
139
+ """The concrete syntactic thing a migration renames or rewrites.
140
+
141
+ ``symbol`` and ``replacement`` are the old and new names. ``owner`` scopes
142
+ them: for a field rename it is the object the field hangs off
143
+ (``order["total"]`` -> owner ``order``), and for a kwarg rename it is the
144
+ function being called (``fetch_orders(timeout_seconds=...)`` -> owner
145
+ ``fetch_orders``). ``owner`` is the main tool for suppressing false
146
+ positives, and it is optional because release notes often omit it.
147
+
148
+ ``owner_is_explicit`` records *how strongly* the document asserted the
149
+ owner, and it decides whether a receiver mismatch refuses or merely lowers
150
+ confidence. The distinction is real:
151
+
152
+ * "the field on each ``order`` object was renamed" and
153
+ "``client.fetch_orders`` -> ``client.list_orders``" **assert** ownership.
154
+ A mismatched receiver is then evidence of a *different* object, and the
155
+ site is reported rather than patched.
156
+ * "**Before:** ``client.fetch_orders(limit=10)``" is the vendor's
157
+ illustrative variable name. It says nothing about what a downstream
158
+ repository calls its variable, and treating it as a constraint would
159
+ refuse to migrate ``api_client.fetch_orders()`` -- the same SDK call,
160
+ spelled with a different local name.
161
+
162
+ Structured change documents always set this to ``True``: someone typed the
163
+ owner into a field named ``owner``, which is an assertion by construction.
164
+ """
165
+
166
+ symbol: str = ""
167
+ replacement: str = ""
168
+ owner: str = ""
169
+ #: Whether the document *asserted* the owner rather than it being inferred
170
+ #: from an illustrative snippet. See the class docstring.
171
+ owner_is_explicit: bool = False
172
+
173
+ @property
174
+ def is_rename(self) -> bool:
175
+ return bool(self.symbol and self.replacement and self.symbol != self.replacement)
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ return {
179
+ "symbol": self.symbol,
180
+ "replacement": self.replacement,
181
+ "owner": self.owner,
182
+ "owner_is_explicit": self.owner_is_explicit,
183
+ }
184
+
185
+
186
+ @dataclass
187
+ class PaginationContract:
188
+ """Field and parameter names for a page-based -> cursor-based migration.
189
+
190
+ Defaults are the overwhelmingly common names. They are overridable so the
191
+ handler is not tied to one vendor's vocabulary.
192
+ """
193
+
194
+ page_param: str = "page"
195
+ total_pages_key: str = "total_pages"
196
+ cursor_param: str = "cursor"
197
+ next_cursor_key: str = "next_cursor"
198
+ has_more_key: str = "has_more"
199
+
200
+ def to_dict(self) -> dict[str, Any]:
201
+ return {
202
+ "page_param": self.page_param,
203
+ "total_pages_key": self.total_pages_key,
204
+ "cursor_param": self.cursor_param,
205
+ "next_cursor_key": self.next_cursor_key,
206
+ "has_more_key": self.has_more_key,
207
+ }
208
+
209
+ @classmethod
210
+ def from_dict(cls, data: dict[str, Any] | None) -> PaginationContract:
211
+ data = data or {}
212
+ base = cls()
213
+ return cls(
214
+ page_param=str(data.get("page_param") or base.page_param),
215
+ total_pages_key=str(data.get("total_pages_key") or base.total_pages_key),
216
+ cursor_param=str(data.get("cursor_param") or base.cursor_param),
217
+ next_cursor_key=str(data.get("next_cursor_key") or base.next_cursor_key),
218
+ has_more_key=str(data.get("has_more_key") or base.has_more_key),
219
+ )
220
+
221
+
222
+ @dataclass
223
+ class BreakingChange:
224
+ """One breaking change, extracted from one change document.
225
+
226
+ A document may describe several; ingestion returns a list of these.
227
+ """
228
+
229
+ title: str
230
+ kind: ChangeKind
231
+ target: SymbolTarget = field(default_factory=SymbolTarget)
232
+ old_behavior: str = ""
233
+ new_behavior: str = ""
234
+ migration_hint: str = ""
235
+ severity: Severity = Severity.MEDIUM
236
+ #: How confident ingestion is that this reading of the document is correct.
237
+ confidence: Confidence = Confidence.MEDIUM
238
+ evidence: list[Evidence] = field(default_factory=list)
239
+ pagination: PaginationContract = field(default_factory=PaginationContract)
240
+ #: Which parser produced this: "markdown", "structured", or "llm".
241
+ source: str = "markdown"
242
+ #: Why classification landed where it did -- shown to the user verbatim.
243
+ classification_reason: str = ""
244
+
245
+ @property
246
+ def is_actionable(self) -> bool:
247
+ return self.kind.is_actionable
248
+
249
+ def describe(self) -> str:
250
+ """A one-line human summary, used in logs and reports."""
251
+ if self.target.is_rename:
252
+ return f"{self.kind.value}: `{self.target.symbol}` -> `{self.target.replacement}`"
253
+ return f"{self.kind.value}: {self.title}"
254
+
255
+ def to_dict(self) -> dict[str, Any]:
256
+ return {
257
+ "title": self.title,
258
+ "kind": self.kind.value,
259
+ "target": self.target.to_dict(),
260
+ "old_behavior": self.old_behavior,
261
+ "new_behavior": self.new_behavior,
262
+ "migration_hint": self.migration_hint,
263
+ "severity": self.severity.value,
264
+ "confidence": self.confidence.value,
265
+ "evidence": [e.to_dict() for e in self.evidence],
266
+ "pagination": self.pagination.to_dict(),
267
+ "source": self.source,
268
+ "classification_reason": self.classification_reason,
269
+ }
@@ -0,0 +1,91 @@
1
+ """What is left of the old API after a migration.
2
+
3
+ Tests that pass after a patch show that the code they exercise works. They do
4
+ not show that the migration is *finished*: a wrapper no test calls, a
5
+ ``getattr(order, "total")``, an import of the old name, or a test that still
6
+ mocks the old method can all survive a green run. A completeness report lists
7
+ every place the old name still appears in the patched copy, sorted by how much
8
+ it needs a person's attention.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import enum
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ class ResidualKind(str, enum.Enum):
19
+ """Where an old name survived, from most to least in need of attention."""
20
+
21
+ #: Code that still uses the old name and was not rewritten.
22
+ CODE = "code"
23
+ #: The old name as a string handed to ``getattr``/``hasattr``/``setattr``:
24
+ #: an access no static rewrite can see, which fails only at runtime.
25
+ DYNAMIC = "dynamic"
26
+ #: A test that still uses the old name.
27
+ TEST = "test"
28
+ #: The same name on an object the change document says is not affected.
29
+ OTHER_OBJECT = "other_object"
30
+ #: A string, comment, configuration file, or document mentioning the name.
31
+ STRING = "string"
32
+ COMMENT = "comment"
33
+ CONFIG = "config"
34
+ DOCS = "docs"
35
+
36
+ @property
37
+ def unfinished(self) -> bool:
38
+ """Whether a residual of this kind means the migration is not done."""
39
+ return self in (ResidualKind.CODE, ResidualKind.DYNAMIC, ResidualKind.TEST)
40
+
41
+ def __str__(self) -> str: # pragma: no cover - trivial
42
+ return self.value
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Residual:
47
+ """One place the old name still appears after patching."""
48
+
49
+ path: str
50
+ line: int
51
+ kind: ResidualKind
52
+ snippet: str
53
+ reason: str
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ return {
57
+ "path": self.path,
58
+ "line": self.line,
59
+ "kind": self.kind.value,
60
+ "snippet": self.snippet,
61
+ "reason": self.reason,
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class CompletenessReport:
67
+ """Every surviving use of one change's old name, in the patched copy."""
68
+
69
+ old: str
70
+ new: str
71
+ residuals: list[Residual] = field(default_factory=list)
72
+
73
+ @property
74
+ def unfinished(self) -> list[Residual]:
75
+ return [r for r in self.residuals if r.kind.unfinished]
76
+
77
+ @property
78
+ def complete(self) -> bool:
79
+ """No code, dynamic access, or test still uses the old name."""
80
+ return not self.unfinished
81
+
82
+ def count(self, kind: ResidualKind) -> int:
83
+ return sum(1 for residual in self.residuals if residual.kind is kind)
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ return {
87
+ "old": self.old,
88
+ "new": self.new,
89
+ "complete": self.complete,
90
+ "residuals": [r.to_dict() for r in self.residuals],
91
+ }
@@ -0,0 +1,248 @@
1
+ """The downstream side of the domain model: what the change touches.
2
+
3
+ The chain the prototype could not express, and which every report now walks:
4
+
5
+ .. code-block:: text
6
+
7
+ BreakingChange
8
+ -> affected API symbol (BreakingChange.target)
9
+ -> call sites / data accesses (CodeReference)
10
+ -> affected functions (ImpactFinding.symbol)
11
+ -> affected files (ImpactFinding.reference.path)
12
+ -> relevant tests (ImpactReport.related_tests)
13
+
14
+ :class:`ImpactGraph` materializes that chain so migration planning can be
15
+ explained rather than asserted.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import enum
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from patchahead.domain.change import BreakingChange, Confidence
25
+
26
+
27
+ class AccessKind(str, enum.Enum):
28
+ """The syntactic shape of an old-contract usage, as seen by the AST."""
29
+
30
+ #: ``obj["name"]``
31
+ SUBSCRIPT = "subscript"
32
+ #: ``obj.get("name")``
33
+ DICT_GET = "dict_get"
34
+ #: ``obj.name``
35
+ ATTRIBUTE = "attribute"
36
+ #: ``name(...)`` or ``obj.name(...)``
37
+ CALL = "call"
38
+ #: ``f(name=...)``
39
+ KEYWORD_ARG = "keyword_arg"
40
+ #: A ``while``-loop paginating over an integer page counter.
41
+ PAGE_LOOP = "page_loop"
42
+ #: ``from sdk import name``
43
+ IMPORT = "import"
44
+
45
+ def __str__(self) -> str: # pragma: no cover - trivial
46
+ return self.value
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CodeReference:
51
+ """A precise location in a source file.
52
+
53
+ Columns are 0-indexed **character** offsets into the line -- not the byte
54
+ offsets ``ast`` reports. The two agree only while a line is pure ASCII, and
55
+ :mod:`patchahead.analysis.python_ast` converts at the single point where
56
+ ``ast`` data enters the system, so everything downstream of it (this class
57
+ included) is character-based and can index a Python ``str`` directly.
58
+
59
+ ``line`` is 1-indexed, matching every editor and traceback. Ranges are
60
+ half-open on the end column, matching ``ast``.
61
+ """
62
+
63
+ #: Path relative to the repository root, always POSIX-style.
64
+ path: str
65
+ line: int
66
+ col: int = 0
67
+ end_line: int | None = None
68
+ end_col: int | None = None
69
+ #: The source line, stripped -- so a report is readable without the file.
70
+ snippet: str = ""
71
+
72
+ def __str__(self) -> str:
73
+ return f"{self.path}:{self.line}"
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ return {
77
+ "path": self.path,
78
+ "line": self.line,
79
+ "col": self.col,
80
+ "end_line": self.end_line,
81
+ "end_col": self.end_col,
82
+ "snippet": self.snippet,
83
+ }
84
+
85
+
86
+ @dataclass
87
+ class ImpactFinding:
88
+ """One place in the downstream repository affected by the change.
89
+
90
+ Every field here exists because a reviewer needs it: *where* (reference),
91
+ *in what* (symbol), *what matched* (matched_contract, access), *why we think
92
+ so* (reason), and *how sure* (confidence).
93
+ """
94
+
95
+ reference: CodeReference
96
+ #: Dotted name of the enclosing function/method/class, or "<module>".
97
+ symbol: str
98
+ #: The old contract this matched, e.g. ``order["total"]``.
99
+ matched_contract: str
100
+ access: AccessKind
101
+ reason: str
102
+ confidence: Confidence
103
+ #: The exact source text the reference covers, e.g. ``"total"`` including
104
+ #: its quotes. Recorded at analysis time so planning can build a
105
+ #: replacement that preserves quote style without re-reading the file.
106
+ source_text: str = ""
107
+ #: True when a handler can mechanically rewrite this exact site.
108
+ patchable: bool = True
109
+ #: Set when ``patchable`` is False: why not.
110
+ unpatchable_reason: str = ""
111
+ #: True when the change document *asserted* what owns the renamed name and
112
+ #: this site belongs to something else -- `customer["total"]` for a rename
113
+ #: on `order`. Left alone on purpose, so not unfinished work.
114
+ other_object: bool = False
115
+
116
+ @property
117
+ def path(self) -> str:
118
+ return self.reference.path
119
+
120
+ def to_dict(self) -> dict[str, Any]:
121
+ return {
122
+ "reference": self.reference.to_dict(),
123
+ "symbol": self.symbol,
124
+ "matched_contract": self.matched_contract,
125
+ "access": self.access.value,
126
+ "reason": self.reason,
127
+ "confidence": self.confidence.value,
128
+ "source_text": self.source_text,
129
+ "patchable": self.patchable,
130
+ "unpatchable_reason": self.unpatchable_reason,
131
+ "other_object": self.other_object,
132
+ }
133
+
134
+
135
+ @dataclass
136
+ class ImpactGraph:
137
+ """The change -> symbol -> site -> function -> file -> test chain.
138
+
139
+ Not a graph database; a set of adjacency views over the findings, computed
140
+ once so reports and plans do not each re-derive them.
141
+ """
142
+
143
+ change_title: str
144
+ api_symbol: str
145
+ #: file path -> enclosing symbols affected in it
146
+ functions_by_file: dict[str, list[str]] = field(default_factory=dict)
147
+ #: file path -> test files believed relevant to it
148
+ tests_by_file: dict[str, list[str]] = field(default_factory=dict)
149
+
150
+ @classmethod
151
+ def build(
152
+ cls,
153
+ change: BreakingChange,
154
+ findings: list[ImpactFinding],
155
+ tests_by_file: dict[str, list[str]] | None = None,
156
+ ) -> ImpactGraph:
157
+ functions_by_file: dict[str, list[str]] = {}
158
+ for finding in findings:
159
+ bucket = functions_by_file.setdefault(finding.path, [])
160
+ if finding.symbol not in bucket:
161
+ bucket.append(finding.symbol)
162
+ return cls(
163
+ change_title=change.title,
164
+ api_symbol=change.target.symbol or change.title,
165
+ functions_by_file=functions_by_file,
166
+ tests_by_file=dict(tests_by_file or {}),
167
+ )
168
+
169
+ def render(self) -> str:
170
+ """An indented text rendering, used by ``patchahead analyze``."""
171
+ lines = [f"BreakingChange: {self.change_title}", f" symbol: {self.api_symbol}"]
172
+ for path in sorted(self.functions_by_file):
173
+ lines.append(f" file: {path}")
174
+ for symbol in self.functions_by_file[path]:
175
+ lines.append(f" function: {symbol}")
176
+ for test in self.tests_by_file.get(path, []):
177
+ lines.append(f" test: {test}")
178
+ return "\n".join(lines)
179
+
180
+ def to_dict(self) -> dict[str, Any]:
181
+ return {
182
+ "change_title": self.change_title,
183
+ "api_symbol": self.api_symbol,
184
+ "functions_by_file": self.functions_by_file,
185
+ "tests_by_file": self.tests_by_file,
186
+ }
187
+
188
+
189
+ @dataclass
190
+ class ImpactReport:
191
+ """Everything analysis learned about one change against one repository."""
192
+
193
+ change: BreakingChange
194
+ findings: list[ImpactFinding] = field(default_factory=list)
195
+ graph: ImpactGraph | None = None
196
+ #: Test files judged relevant to the affected modules.
197
+ related_tests: list[str] = field(default_factory=list)
198
+ #: Number of Python files actually parsed (for timing/debug reporting).
199
+ files_scanned: int = 0
200
+ #: Files that could not be parsed, as ``path: reason``.
201
+ skipped_files: dict[str, str] = field(default_factory=dict)
202
+ analysis_ms: int = 0
203
+ #: Set when the handler declined to analyze at all.
204
+ unsupported_reason: str = ""
205
+
206
+ @property
207
+ def has_impact(self) -> bool:
208
+ return bool(self.findings)
209
+
210
+ @property
211
+ def affected_files(self) -> list[str]:
212
+ seen: list[str] = []
213
+ for finding in self.findings:
214
+ if finding.path not in seen:
215
+ seen.append(finding.path)
216
+ return seen
217
+
218
+ @property
219
+ def affected_symbols(self) -> list[str]:
220
+ seen: list[str] = []
221
+ for finding in self.findings:
222
+ if finding.symbol not in seen:
223
+ seen.append(finding.symbol)
224
+ return seen
225
+
226
+ def at_least(self, minimum: Confidence) -> list[ImpactFinding]:
227
+ """Findings meeting a confidence floor -- the patching threshold."""
228
+ return [f for f in self.findings if f.confidence >= minimum]
229
+
230
+ @property
231
+ def highest_confidence(self) -> Confidence | None:
232
+ if not self.findings:
233
+ return None
234
+ return max((f.confidence for f in self.findings), key=lambda c: c.rank)
235
+
236
+ def to_dict(self) -> dict[str, Any]:
237
+ return {
238
+ "change": self.change.to_dict(),
239
+ "findings": [f.to_dict() for f in self.findings],
240
+ "graph": self.graph.to_dict() if self.graph else None,
241
+ "related_tests": self.related_tests,
242
+ "affected_files": self.affected_files,
243
+ "affected_symbols": self.affected_symbols,
244
+ "files_scanned": self.files_scanned,
245
+ "skipped_files": self.skipped_files,
246
+ "analysis_ms": self.analysis_ms,
247
+ "unsupported_reason": self.unsupported_reason,
248
+ }
@@ -0,0 +1,81 @@
1
+ """Patch proposals: the new file contents a plan produces, plus a diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from patchahead.domain.plan import MigrationPlan
9
+
10
+
11
+ @dataclass
12
+ class FileEdit:
13
+ """The before and after contents of one file."""
14
+
15
+ path: str
16
+ old_source: str
17
+ new_source: str
18
+ #: Number of :class:`~patchahead.domain.plan.TextEdit` s applied.
19
+ edit_count: int = 0
20
+
21
+ @property
22
+ def changed(self) -> bool:
23
+ return self.old_source != self.new_source
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ # Source is deliberately excluded: run logs are for reading, and the
27
+ # diff already carries the content a reviewer needs.
28
+ return {"path": self.path, "edit_count": self.edit_count, "changed": self.changed}
29
+
30
+
31
+ @dataclass
32
+ class PatchProposal:
33
+ """A proposed, not-yet-validated set of file edits.
34
+
35
+ "Proposal" is the operative word: a :class:`PatchProposal` has passed no
36
+ gates. Only a :class:`~patchahead.domain.validation.ValidationResult` can
37
+ say whether it is any good.
38
+ """
39
+
40
+ plan: MigrationPlan
41
+ files: list[FileEdit] = field(default_factory=list)
42
+ #: Unified diff across every changed file.
43
+ diff: str = ""
44
+ #: "deterministic" or "llm".
45
+ engine: str = "deterministic"
46
+ explanation: str = ""
47
+ #: Set when generation failed; ``files`` is then empty.
48
+ error: str = ""
49
+
50
+ @property
51
+ def ok(self) -> bool:
52
+ return not self.error and any(f.changed for f in self.files)
53
+
54
+ @property
55
+ def changed_files(self) -> list[str]:
56
+ return [f.path for f in self.files if f.changed]
57
+
58
+ @property
59
+ def edit_count(self) -> int:
60
+ return sum(f.edit_count for f in self.files)
61
+
62
+ @property
63
+ def diff_line_count(self) -> int:
64
+ """Added + removed lines, used by the diff-size validation gate."""
65
+ return sum(
66
+ 1
67
+ for line in self.diff.splitlines()
68
+ if (line.startswith(("+", "-")) and not line.startswith(("+++", "---")))
69
+ )
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ return {
73
+ "engine": self.engine,
74
+ "explanation": self.explanation,
75
+ "error": self.error,
76
+ "files": [f.to_dict() for f in self.files],
77
+ "changed_files": self.changed_files,
78
+ "edit_count": self.edit_count,
79
+ "diff_line_count": self.diff_line_count,
80
+ "diff": self.diff,
81
+ }