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,650 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="patchahead-token" content="__PATCHAHEAD_TOKEN__" />
7
+ <title>PatchAhead</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%237c8cff'/%3E%3Cpath d='M9 17.5l4.5 4.5L23 12' stroke='white' stroke-width='3.2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
9
+ <style>
10
+ :root {
11
+ --bg: #0d1017; --panel: #151a23; --panel-2: #1b212c; --line: #262d3a;
12
+ --text: #e8edf5; --muted: #98a3b6; --faint: #6b7688;
13
+ --red: #ff6b6b; --green: #37d39b; --amber: #ffb64a; --accent: #7c8cff;
14
+ --green-bg: rgba(55, 211, 155, 0.10); --red-bg: rgba(255, 107, 107, 0.10);
15
+ --amber-bg: rgba(255, 182, 74, 0.10); --accent-bg: rgba(124, 140, 255, 0.10);
16
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
17
+ --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
18
+ }
19
+ @media (prefers-color-scheme: light) {
20
+ :root {
21
+ --bg: #f7f8fa; --panel: #fff; --panel-2: #f1f3f7; --line: #e0e4ec;
22
+ --text: #12151c; --muted: #5b6575; --faint: #8b94a3;
23
+ --red: #b52d2d; --green: #10704a; --amber: #8a5a00; --accent: #3d4bc7;
24
+ --green-bg: rgba(16, 112, 74, 0.08); --red-bg: rgba(181, 45, 45, 0.07);
25
+ --amber-bg: rgba(138, 90, 0, 0.08); --accent-bg: rgba(61, 75, 199, 0.07);
26
+ }
27
+ }
28
+ * { box-sizing: border-box; }
29
+ body {
30
+ margin: 0; background: var(--bg); color: var(--text);
31
+ font-family: var(--sans); line-height: 1.55; padding-bottom: 72px;
32
+ }
33
+ .wrap { max-width: 1120px; margin: 0 auto; padding: 0 20px; }
34
+
35
+ /* ---------- header ---------- */
36
+ header { border-bottom: 1px solid var(--line); background: var(--panel); }
37
+ .bar { display: flex; align-items: baseline; gap: 14px; padding: 16px 0; flex-wrap: wrap; }
38
+ .logo { font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
39
+ .logo span { color: var(--accent); }
40
+ .spacer { flex: 1; }
41
+ /* The repository path has no spaces to break on and can be long, so it is
42
+ allowed to break anywhere rather than pushing the page sideways. */
43
+ .tag { font-family: var(--mono); font-size: 12px; color: var(--faint);
44
+ overflow-wrap: anywhere; min-width: 0; }
45
+ .pitch { padding: 0 0 18px; max-width: 74ch; color: var(--muted); font-size: 14.5px; }
46
+ .pitch b { color: var(--text); font-weight: 600; }
47
+
48
+ /* ---------- pipeline rail ---------- */
49
+ .rail { display: flex; gap: 6px; flex-wrap: wrap; padding: 14px 0 0; }
50
+ .rail .step {
51
+ display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--faint);
52
+ border: 1px solid var(--line); border-radius: 20px; padding: 4px 11px 4px 5px;
53
+ background: var(--panel-2);
54
+ }
55
+ .rail .step b {
56
+ display: inline-flex; align-items: center; justify-content: center;
57
+ width: 18px; height: 18px; border-radius: 50%; font-size: 11px;
58
+ background: var(--line); color: var(--muted); font-weight: 600;
59
+ }
60
+ .rail .step.on { color: var(--text); border-color: var(--accent); }
61
+ .rail .step.on b { background: var(--accent); color: #fff; }
62
+
63
+ /* ---------- scenario picker ---------- */
64
+ .picker { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }
65
+ .scenario {
66
+ text-align: left; cursor: pointer; border: 1px solid var(--line); border-radius: 9px;
67
+ background: var(--panel); color: var(--text); padding: 11px 13px; font-family: var(--sans);
68
+ display: flex; flex-direction: column; gap: 5px; line-height: 1.35;
69
+ }
70
+ .scenario:hover { border-color: var(--accent); }
71
+ .scenario[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-bg); }
72
+ .scenario .name { font-weight: 600; font-size: 14px; }
73
+ .scenario .fam { font-family: var(--mono); font-size: 11px; color: var(--faint); }
74
+ .expects {
75
+ align-self: flex-start; font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase;
76
+ border-radius: 4px; padding: 1px 6px; font-weight: 600;
77
+ }
78
+ .expects.verified { color: var(--green); background: var(--green-bg); }
79
+ .expects.refused { color: var(--amber); background: var(--amber-bg); }
80
+ .expects.validation_failed { color: var(--red); background: var(--red-bg); }
81
+ .expects.patched_unverified { color: var(--amber); background: var(--amber-bg); }
82
+
83
+ .runbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 14px 0 4px; }
84
+ select, button {
85
+ font-family: var(--sans); font-size: 14px; border-radius: 7px;
86
+ border: 1px solid var(--line); background: var(--panel); color: var(--text); padding: 9px 13px;
87
+ }
88
+ button { cursor: pointer; }
89
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
90
+ button:disabled { opacity: 0.55; cursor: progress; }
91
+ .headline { color: var(--muted); font-size: 14px; padding: 8px 0 0; max-width: 78ch; }
92
+ .headline code { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
93
+
94
+ .banner {
95
+ background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--amber);
96
+ border-radius: 7px; padding: 11px 14px; font-size: 13px; color: var(--muted); margin: 18px 0 0;
97
+ }
98
+ .banner code { font-family: var(--mono); color: var(--text); }
99
+
100
+ /* ---------- outcome ---------- */
101
+ .verdict { border-radius: 11px; border: 1px solid var(--line); padding: 18px 20px; margin: 22px 0 6px; }
102
+ .verdict .label { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
103
+ .verdict .said { font-size: 14px; color: var(--muted); margin-top: 5px; }
104
+ .verdict .watch { font-size: 13.5px; margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--line); color: var(--muted); }
105
+ .verdict .watch b { color: var(--text); }
106
+ .verdict.good { border-color: var(--green); background: var(--green-bg); }
107
+ .verdict.good .label { color: var(--green); }
108
+ .verdict.warn { border-color: var(--amber); background: var(--amber-bg); }
109
+ .verdict.warn .label { color: var(--amber); }
110
+ .verdict.bad { border-color: var(--red); background: var(--red-bg); }
111
+ .verdict.bad .label { color: var(--red); }
112
+
113
+ /* ---------- step sections ---------- */
114
+ section.step { margin: 26px 0 0; }
115
+ section.step > h2 {
116
+ display: flex; align-items: center; gap: 10px; margin: 0 0 10px;
117
+ font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
118
+ color: var(--muted);
119
+ }
120
+ section.step > h2 i {
121
+ font-style: normal; display: inline-flex; align-items: center; justify-content: center;
122
+ width: 21px; height: 21px; border-radius: 50%; background: var(--accent); color: #fff;
123
+ font-size: 11.5px; font-weight: 700;
124
+ }
125
+ section.step > h2 small { font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--faint); font-size: 12.5px; }
126
+
127
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
128
+ .card > .body { padding: 14px 16px; }
129
+ .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
130
+ @media (max-width: 820px) { .cols { grid-template-columns: 1fr; } }
131
+
132
+ dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; margin: 0; font-size: 14px; }
133
+ dt { color: var(--muted); }
134
+ dd { margin: 0; }
135
+
136
+ .beforeafter { display: grid; gap: 8px; font-family: var(--mono); font-size: 12.5px; }
137
+ .beforeafter div { padding: 8px 11px; border-radius: 7px; background: var(--panel-2); }
138
+ .beforeafter .was { border-left: 3px solid var(--red); }
139
+ .beforeafter .now { border-left: 3px solid var(--green); }
140
+ .beforeafter span { display: block; font-family: var(--sans); font-size: 11px; color: var(--faint); margin-bottom: 2px; }
141
+
142
+ .pill {
143
+ display: inline-block; font-family: var(--mono); font-size: 11px;
144
+ padding: 2px 8px; border-radius: 20px; border: 1px solid var(--line); background: var(--panel-2);
145
+ }
146
+ .pill.high, .pill.passed { color: var(--green); }
147
+ .pill.medium, .pill.skipped { color: var(--amber); }
148
+ .pill.low { color: var(--faint); }
149
+ .pill.failed { color: var(--red); }
150
+ .yes { color: var(--green); font-weight: 600; }
151
+ .no { color: var(--faint); }
152
+
153
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
154
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
155
+ th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
156
+ tr:last-child td { border-bottom: 0; }
157
+ td.mono, .mono { font-family: var(--mono); font-size: 12px; }
158
+ .scroll { overflow-x: auto; }
159
+
160
+ pre {
161
+ margin: 0; font-family: var(--mono); font-size: 12.5px; line-height: 1.55;
162
+ overflow-x: auto; padding: 14px 16px; background: var(--panel-2); white-space: pre;
163
+ }
164
+ .diff span { display: block; }
165
+ .diff .add { color: var(--green); background: var(--green-bg); }
166
+ .diff .del { color: var(--red); background: var(--red-bg); }
167
+ .diff .hunk { color: var(--accent); margin-top: 4px; }
168
+ .diff .meta { color: var(--faint); }
169
+ .diff .ctx { color: var(--muted); }
170
+ /* The release note is prose, not code: wrap it rather than scrolling it. */
171
+ pre.note-source { white-space: pre-wrap; word-break: break-word; max-height: 430px; overflow-y: auto; }
172
+
173
+ /* ---------- gates ---------- */
174
+ .gates { display: grid; gap: 1px; background: var(--line); }
175
+ .gate { display: grid; grid-template-columns: 24px 168px 78px 1fr; gap: 10px; align-items: baseline;
176
+ padding: 11px 16px; background: var(--panel); font-size: 13px; }
177
+ @media (max-width: 720px) { .gate { grid-template-columns: 24px 1fr; } .gate .detail { grid-column: 1 / -1; } }
178
+ .gate .mark { font-size: 14px; line-height: 1.3; }
179
+ .gate .gname { font-family: var(--mono); font-size: 12.5px; }
180
+ .gate .detail { color: var(--muted); font-size: 12.5px; }
181
+ .gate.passed .mark { color: var(--green); }
182
+ .gate.failed .mark { color: var(--red); }
183
+ .gate.skipped .mark { color: var(--faint); }
184
+ .gate.failed .gname { color: var(--red); }
185
+
186
+ .note { color: var(--muted); font-size: 13px; }
187
+ .reason { color: var(--faint); font-size: 12px; }
188
+ .empty { color: var(--faint); font-size: 13px; padding: 10px 0; }
189
+ details > summary { cursor: pointer; color: var(--muted); font-size: 13px; padding: 5px 0; }
190
+ .changeblock { border-top: 1px solid var(--line); margin-top: 30px; padding-top: 6px; }
191
+ .changeblock:first-of-type { border-top: 0; margin-top: 0; }
192
+ .changelabel { font-size: 12px; color: var(--faint); font-family: var(--mono); padding-top: 14px; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <header>
197
+ <div class="wrap">
198
+ <div class="bar">
199
+ <div class="logo">Patch<span>Ahead</span></div>
200
+ <div class="tag" id="context-line">loading…</div>
201
+ <div class="spacer"></div>
202
+ <div class="tag" id="version"></div>
203
+ </div>
204
+ <p class="pitch">
205
+ An upstream API changes. <b>Which of your code breaks, what is the smallest
206
+ correct fix, and can it be proven to work?</b> PatchAhead reads the release
207
+ note, finds the affected call sites, patches a throwaway copy of the
208
+ repository, and runs your tests against it. A migration counts as done only
209
+ when a test that failed before the patch passes after it.
210
+ </p>
211
+ <div class="rail" id="rail"></div>
212
+ <div style="height:14px"></div>
213
+ </div>
214
+ </header>
215
+
216
+ <div class="wrap">
217
+ <div id="chooser"></div>
218
+ <p class="headline" id="headline"></p>
219
+ <div class="runbar">
220
+ <button id="run" class="primary">Run this scenario</button>
221
+ <button id="analyze">Analyze only (read-only)</button>
222
+ <span class="note" id="status"></span>
223
+ </div>
224
+ <div class="banner">
225
+ PatchAhead never writes to the repository it is pointed at. <b>Run</b> copies it to a
226
+ temporary directory, patches the copy, and runs <code id="test-command">its test command</code>
227
+ there. A test command is ordinary code running as you — the copy is isolation from
228
+ <i>writes</i>, not a sandbox. Nothing here is applied or merged; a human approves.
229
+ </div>
230
+ <div id="results"></div>
231
+ </div>
232
+
233
+ <script>
234
+ const $ = (id) => document.getElementById(id);
235
+ let context = null;
236
+ let selected = null;
237
+
238
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
239
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
240
+
241
+ /* Backtick-quoted fragments in engine prose are code; render them as code.
242
+ Escaping happens first, so this can never introduce markup. */
243
+ const prose = (s) => esc(s).replace(/`([^`]+)`/g, "<code>$1</code>");
244
+
245
+ const pill = (value, cls) => `<span class="pill ${cls || value}">${esc(value)}</span>`;
246
+
247
+ const STEPS = [
248
+ "Upstream change", "Impact", "Migration plan", "Patch", "Verification", "Outcome",
249
+ ];
250
+
251
+ function renderRail(active) {
252
+ $("rail").innerHTML = STEPS.map((name, i) =>
253
+ `<div class="step ${active > i ? "on" : ""}"><b>${i + 1}</b>${esc(name)}</div>`
254
+ ).join("");
255
+ }
256
+
257
+ /* ---------- step 1: the upstream change ---------- */
258
+
259
+ function renderChange(change, source) {
260
+ const t = change.target || {};
261
+ const rename = t.symbol && t.replacement
262
+ ? `<div class="beforeafter">
263
+ <div class="was"><span>before</span>${esc(t.symbol)}</div>
264
+ <div class="now"><span>after</span>${esc(t.replacement)}</div>
265
+ </div>`
266
+ : "";
267
+ return `
268
+ <section class="step">
269
+ <h2><i>1</i> Upstream change <small>what the vendor says broke</small></h2>
270
+ <div class="cols">
271
+ <div class="card"><div class="body">
272
+ <dl>
273
+ <dt>title</dt><dd>${esc(change.title)}</dd>
274
+ <dt>detected as</dt><dd>${pill(change.kind, "high")}</dd>
275
+ <dt>severity</dt><dd>${pill(change.severity)}</dd>
276
+ <dt>read with</dt><dd>${pill(change.confidence)} confidence</dd>
277
+ ${t.owner ? `<dt>on</dt><dd class="mono">${esc(t.owner)}${
278
+ t.owner_is_explicit ? "" : ' <span class="reason">(illustrated, not asserted)</span>'}</dd>` : ""}
279
+ </dl>
280
+ <div style="height:10px"></div>
281
+ ${rename}
282
+ ${change.old_behavior ? `<p class="reason" style="margin:10px 0 0">${prose(change.old_behavior)}</p>` : ""}
283
+ ${change.new_behavior ? `<p class="reason" style="margin:3px 0 0">${prose(change.new_behavior)}</p>` : ""}
284
+ <p class="reason" style="margin:10px 0 0">${prose(change.classification_reason)}</p>
285
+ ${change.evidence?.length ? `<details><summary>Why it was read that way</summary>
286
+ <ul class="reason">${change.evidence.map((e) =>
287
+ `<li>“${esc(e.quote)}”${e.note ? ` — ${esc(e.note)}` : ""}</li>`).join("")}</ul></details>` : ""}
288
+ </div></div>
289
+ <div class="card">
290
+ <div class="body"><div class="reason">the release note, as published</div></div>
291
+ <pre class="note-source">${esc(source || "(not loaded)")}</pre>
292
+ </div>
293
+ </div>
294
+ </section>`;
295
+ }
296
+
297
+ /* ---------- step 2: impact ---------- */
298
+
299
+ function renderImpact(impact) {
300
+ let inner;
301
+ if (impact.unsupported_reason) {
302
+ inner = `<div class="card"><div class="body"><p class="note">${prose(impact.unsupported_reason)}</p></div></div>`;
303
+ } else if (!impact.findings.length) {
304
+ inner = `<div class="card"><div class="body"><div class="empty">No affected code found in this repository.</div></div></div>`;
305
+ } else {
306
+ const rows = impact.findings.map((f) => `
307
+ <tr>
308
+ <td class="mono">${esc(f.reference.path)}:${f.reference.line}</td>
309
+ <td class="mono">${esc(f.matched_contract)}</td>
310
+ <td>${pill(f.confidence)}</td>
311
+ <td>${f.patchable
312
+ ? '<span class="yes">rewrite</span>'
313
+ : '<span class="no">leave alone</span>'}</td>
314
+ <td class="reason">${prose(f.patchable ? f.reason : (f.unpatchable_reason || f.reason))}</td>
315
+ </tr>`).join("");
316
+ inner = `
317
+ <div class="card">
318
+ <div class="scroll"><table>
319
+ <thead><tr><th>Location</th><th>Matched</th><th>Confidence</th><th>Decision</th><th>Why</th></tr></thead>
320
+ <tbody>${rows}</tbody>
321
+ </table></div>
322
+ <div class="body"><p class="reason">
323
+ Scanned ${impact.files_scanned} file(s) in ${impact.analysis_ms}ms.
324
+ ${impact.related_tests.length
325
+ ? `Tests mapped to the affected modules: <span class="mono">${impact.related_tests.map(esc).join(", ")}</span>`
326
+ : "No tests were mapped to the affected modules."}
327
+ </p></div>
328
+ </div>`;
329
+ }
330
+ return `<section class="step">
331
+ <h2><i>2</i> Impact <small>every site the name matches, and what was decided about it</small></h2>
332
+ ${inner}</section>`;
333
+ }
334
+
335
+ /* ---------- step 3: plan ---------- */
336
+
337
+ function renderPlan(plan) {
338
+ if (!plan) return "";
339
+ let inner;
340
+ if (plan.blocked_reason) {
341
+ inner = `<div class="card"><div class="body">
342
+ <p class="note"><b>No plan.</b> ${prose(plan.blocked_reason)}</p>
343
+ ${plan.skipped.length ? `<details open><summary>${plan.skipped.length} site(s) deliberately left alone</summary>
344
+ <ul class="reason">${plan.skipped.map((s) => `<li>${prose(s)}</li>`).join("")}</ul></details>` : ""}
345
+ </div></div>`;
346
+ } else {
347
+ const rows = plan.transformations.map((t) => `
348
+ <tr>
349
+ <td class="mono">${esc(t.reference.path)}:${t.reference.line}</td>
350
+ <td class="mono">${esc(t.symbol)}</td>
351
+ <td class="mono" style="color:var(--red)">${esc(t.old)}</td>
352
+ <td class="mono" style="color:var(--green)">${esc(t.new)}</td>
353
+ <td>${pill(t.confidence)}</td>
354
+ </tr>`).join("");
355
+ inner = `
356
+ <div class="card">
357
+ <div class="scroll"><table>
358
+ <thead><tr><th>Location</th><th>In</th><th>From</th><th>To</th><th>Confidence</th></tr></thead>
359
+ <tbody>${rows}</tbody>
360
+ </table></div>
361
+ <div class="body">
362
+ <dl>
363
+ <dt>handler</dt><dd class="mono">${esc(plan.handler)}</dd>
364
+ <dt>risk</dt><dd>${pill(plan.risk)}</dd>
365
+ </dl>
366
+ <p class="reason" style="margin-top:8px">${prose(plan.rationale)}</p>
367
+ ${plan.skipped.length ? `<details><summary>${plan.skipped.length} site(s) deliberately left alone</summary>
368
+ <ul class="reason">${plan.skipped.map((s) => `<li>${prose(s)}</li>`).join("")}</ul></details>` : ""}
369
+ </div>
370
+ </div>`;
371
+ }
372
+ return `<section class="step">
373
+ <h2><i>3</i> Migration plan <small>what it intends to change, before touching anything</small></h2>
374
+ ${inner}</section>`;
375
+ }
376
+
377
+ /* ---------- step 4: patch ---------- */
378
+
379
+ function renderDiff(diff) {
380
+ /* Every line is a block-level span so added and removed rows can carry a
381
+ full-width background. They are joined with nothing, not with newlines:
382
+ a block element already ends the line, and doing both doubles the spacing. */
383
+ return diff.replace(/\n+$/, "").split("\n").map((line) => {
384
+ const safe = esc(line) || "&nbsp;";
385
+ let cls = "ctx";
386
+ if (line.startsWith("+++") || line.startsWith("---")) cls = "meta";
387
+ else if (line.startsWith("@@")) cls = "hunk";
388
+ else if (line.startsWith("+")) cls = "add";
389
+ else if (line.startsWith("-")) cls = "del";
390
+ return `<span class="${cls}">${safe}</span>`;
391
+ }).join("");
392
+ }
393
+
394
+ function renderPatch(proposal, outcome) {
395
+ let inner;
396
+ if (!proposal || !proposal.diff) {
397
+ /* "Nothing here" is a result, not an absence, and the reason differs by
398
+ outcome. Saying which one it is keeps the story readable end to end. */
399
+ const why = {
400
+ not_plannable: "No patch — and that is the outcome. PatchAhead found the affected code, "
401
+ + "explained it, and declined to rewrite it rather than guess.",
402
+ unsupported_change: "No patch: this kind of change has no handler in this version.",
403
+ no_impact: "No patch: nothing in this repository uses the old contract.",
404
+ dry_run: "No patch: this was a dry run, which stops after planning.",
405
+ }[outcome] || "No patch was produced.";
406
+ inner = `<div class="card"><div class="body"><div class="empty">${esc(why)}</div></div></div>`;
407
+ } else {
408
+ inner = `
409
+ <div class="card">
410
+ <div class="body"><p class="reason" style="margin:0">
411
+ <b>${proposal.diff_line_count}</b> changed line(s) across
412
+ <b>${proposal.changed_files.length}</b> file(s), by
413
+ ${esc(proposal.engine)} edits to exact source ranges — comments, blank lines and
414
+ formatting outside the edited spans are untouched.
415
+ </p></div>
416
+ <pre class="diff">${renderDiff(proposal.diff)}</pre>
417
+ </div>`;
418
+ }
419
+ return `<section class="step">
420
+ <h2><i>4</i> Patch <small>applied to a temporary copy, never to the repository</small></h2>
421
+ ${inner}</section>`;
422
+ }
423
+
424
+ /* ---------- step 5: the five gates ---------- */
425
+
426
+ const GATE_ORDER = ["syntax", "scope", "targeted_tests", "regression_tests", "migration_assertion"];
427
+ const GATE_BLURB = {
428
+ syntax: "the patched files still parse",
429
+ scope: "only the files the plan named changed",
430
+ targeted_tests: "the tests covering the changed modules",
431
+ regression_tests: "nothing that passed before fails now",
432
+ migration_assertion: "a test that failed before now passes",
433
+ };
434
+ const MARK = { passed: "✔", failed: "✘", skipped: "–" };
435
+
436
+ function renderGates(validation, outcome) {
437
+ let inner;
438
+ if (!validation) {
439
+ const why = outcome === "not_plannable"
440
+ ? "The gates did not run: nothing was patched, so there is nothing to validate. "
441
+ + "A refusal is decided before any code is copied or executed."
442
+ : "The gates did not run.";
443
+ inner = `<div class="card"><div class="body"><div class="empty">${esc(why)}</div></div></div>`;
444
+ } else {
445
+ const byName = Object.fromEntries(validation.gates.map((g) => [g.name, g]));
446
+ const rows = GATE_ORDER.map((name, i) => {
447
+ const gate = byName[name] || { status: "skipped", detail: "did not run" };
448
+ return `<div class="gate ${esc(gate.status)}">
449
+ <div class="mark">${MARK[gate.status] || "?"}</div>
450
+ <div class="gname">${i + 1}. ${esc(name)}</div>
451
+ <div>${pill(gate.status)}</div>
452
+ <div class="detail">${prose(gate.detail)}<div class="reason">${esc(GATE_BLURB[name])}</div></div>
453
+ </div>`;
454
+ }).join("");
455
+ inner = `<div class="card"><div class="gates">${rows}</div></div>`;
456
+ }
457
+ return `<section class="step">
458
+ <h2><i>5</i> Verification <small>five gates, in order; the cheap decisive ones run before any code executes</small></h2>
459
+ ${inner}</section>`;
460
+ }
461
+
462
+ /* ---------- step 6: outcome ---------- */
463
+
464
+ const VERDICT = {
465
+ migrated: { tone: "good", label: "VERIFIED MIGRATION",
466
+ said: "A test failed before this patch and passes after it. That is the evidence, and it is the only thing that earns this label." },
467
+ patched_unverified: { tone: "warn", label: "PATCHED, NOT VERIFIED",
468
+ said: "A patch was produced and no test proved it works. The diff may well be correct — nothing here demonstrates that, so nothing here claims it." },
469
+ validation_failed: { tone: "bad", label: "REJECTED BY THE TESTS",
470
+ said: "A patch was produced and a gate rejected it. The diff is still shown, so you can see exactly what was tried and why it was not accepted." },
471
+ not_plannable: { tone: "warn", label: "REFUSED",
472
+ said: "The affected code was found and explained, and PatchAhead declined to rewrite it. Refusing is the correct answer when the evidence does not support an edit." },
473
+ unsupported_change: { tone: "warn", label: "UNSUPPORTED CHANGE",
474
+ said: "This kind of change has no handler in this version. It is reported as unsupported rather than forced into a family that happens to fit." },
475
+ no_impact: { tone: "warn", label: "NO IMPACT",
476
+ said: "Nothing in this repository uses the old contract. Not an error — there is simply nothing to migrate." },
477
+ patch_failed: { tone: "bad", label: "PATCH FAILED",
478
+ said: "Planning succeeded and generating the patch did not." },
479
+ dry_run: { tone: "warn", label: "DRY RUN",
480
+ said: "Stopped after planning, by request. Nothing was patched and no tests ran." },
481
+ };
482
+
483
+ function renderVerdict(result, scenario) {
484
+ const v = VERDICT[result.outcome] || { tone: "warn", label: result.outcome, said: "" };
485
+ return `
486
+ <div class="verdict ${v.tone}">
487
+ <div class="label">${esc(v.label)}</div>
488
+ <div class="said">${esc(v.said)}</div>
489
+ <div class="said mono" style="margin-top:8px">${prose(result.message)}</div>
490
+ ${scenario ? `<div class="watch"><b>What to look at:</b> ${prose(scenario.watch_for)}</div>` : ""}
491
+ </div>`;
492
+ }
493
+
494
+ function renderResult(result, prSummary, scenario, source, index, total) {
495
+ const label = total > 1
496
+ ? `<div class="changelabel">change ${index + 1} of ${total} in this document</div>` : "";
497
+ return `
498
+ <div class="changeblock">
499
+ ${label}
500
+ ${renderVerdict(result, index === 0 ? scenario : null)}
501
+ ${renderChange(result.impact.change, index === 0 ? source : "")}
502
+ ${renderImpact(result.impact)}
503
+ ${renderPlan(result.plan)}
504
+ ${renderPatch(result.proposal, result.outcome)}
505
+ ${renderGates(result.validation, result.outcome)}
506
+ <section class="step">
507
+ <h2><i>6</i> Outcome <small>what a reviewer would receive</small></h2>
508
+ <div class="card"><div class="body">
509
+ ${prSummary
510
+ ? `<details><summary>Reviewable pull-request summary (Markdown)</summary><pre>${esc(prSummary)}</pre></details>`
511
+ : '<div class="empty">No summary.</div>'}
512
+ </div></div>
513
+ </section>
514
+ </div>`;
515
+ }
516
+
517
+ function renderAnalysis(data) {
518
+ return data.reports.map((report) => `
519
+ <div class="changeblock">
520
+ ${renderChange(report.change, "")}
521
+ ${renderImpact(report)}
522
+ </div>`).join("");
523
+ }
524
+
525
+ /* ---------- driving ---------- */
526
+
527
+ async function call(path, params, method = "POST") {
528
+ const query = new URLSearchParams(params).toString();
529
+ const token = document.querySelector('meta[name="patchahead-token"]').content;
530
+ const response = await fetch(`${path}?${query}`, {
531
+ method,
532
+ headers: { "X-PatchAhead-Token": token },
533
+ });
534
+ if (!response.ok) {
535
+ const body = await response.json().catch(() => ({ detail: response.statusText }));
536
+ throw new Error(body.detail || `request failed (${response.status})`);
537
+ }
538
+ return response.json();
539
+ }
540
+
541
+ function currentDocument() {
542
+ if (selected) return selected.document;
543
+ const picker = $("document");
544
+ return picker ? picker.value : "";
545
+ }
546
+
547
+ async function documentSource(name) {
548
+ try {
549
+ const data = await call("/api/document", { document: name }, "GET");
550
+ return data.text;
551
+ } catch {
552
+ return "";
553
+ }
554
+ }
555
+
556
+ async function act(kind) {
557
+ const name = currentDocument();
558
+ if (!name) return;
559
+ const buttons = [$("run"), $("analyze")];
560
+ buttons.forEach((b) => (b.disabled = true));
561
+ $("status").textContent = kind === "analyze"
562
+ ? "analyzing (nothing is copied or executed)…"
563
+ : "copying the repository, patching the copy, running its tests…";
564
+ $("results").innerHTML = "";
565
+ renderRail(1);
566
+ try {
567
+ if (kind === "analyze") {
568
+ const data = await call("/api/analyze", { document: name });
569
+ $("results").innerHTML = renderAnalysis(data);
570
+ $("status").textContent = `${data.total_findings} site(s) found — read-only, nothing was changed`;
571
+ renderRail(2);
572
+ } else {
573
+ const params = selected
574
+ ? { document: name, scenario: selected.id }
575
+ : { document: name };
576
+ const [data, source] = await Promise.all([
577
+ call("/api/migrate", params),
578
+ documentSource(name),
579
+ ]);
580
+ $("results").innerHTML = data.results
581
+ .map((r, i) => renderResult(r, data.pr_summaries?.[i], selected, source, i, data.results.length))
582
+ .join("");
583
+ $("status").textContent = data.succeeded
584
+ ? "verified by the tests"
585
+ : "not verified — see the outcome above";
586
+ renderRail(6);
587
+ $("results").scrollIntoView({ behavior: "smooth", block: "start" });
588
+ }
589
+ } catch (error) {
590
+ $("status").textContent = "";
591
+ $("results").innerHTML =
592
+ `<div class="card"><div class="body"><p class="note">${esc(error.message)}</p></div></div>`;
593
+ } finally {
594
+ buttons.forEach((b) => (b.disabled = false));
595
+ }
596
+ }
597
+
598
+ function selectScenario(id) {
599
+ selected = context.scenarios.find((s) => s.id === id) || context.scenarios[0];
600
+ document.querySelectorAll(".scenario").forEach((node) => {
601
+ node.setAttribute("aria-pressed", String(node.dataset.id === selected.id));
602
+ });
603
+ $("headline").innerHTML = prose(selected.headline)
604
+ + (selected.run_tests ? "" : ' <b>Tests are switched off for this one.</b>');
605
+ $("results").innerHTML = "";
606
+ renderRail(0);
607
+ }
608
+
609
+ function renderChooser() {
610
+ if (context.demo && context.scenarios.length) {
611
+ $("chooser").innerHTML = `<div class="picker">${context.scenarios.map((s) => `
612
+ <button class="scenario" data-id="${esc(s.id)}" aria-pressed="false">
613
+ <span class="name">${esc(s.title)}</span>
614
+ <span class="fam">${esc(s.family)}</span>
615
+ <span class="expects ${esc(s.expect)}">${esc(s.expect.replace(/_/g, " "))}</span>
616
+ </button>`).join("")}</div>`;
617
+ document.querySelectorAll(".scenario").forEach((node) =>
618
+ node.addEventListener("click", () => selectScenario(node.dataset.id)));
619
+ const wanted = new URLSearchParams(location.search).get("scenario");
620
+ selectScenario(wanted || context.scenarios[0].id);
621
+ } else {
622
+ $("chooser").innerHTML = `<div class="runbar" style="padding-top:0">
623
+ <select id="document" aria-label="Change document">${
624
+ context.documents.map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("")
625
+ || '<option value="">no change documents found</option>'}</select></div>`;
626
+ $("headline").textContent =
627
+ "Pick a change document and run it against this repository.";
628
+ }
629
+ }
630
+
631
+ async function init() {
632
+ renderRail(0);
633
+ try {
634
+ context = await (await fetch("/api/context")).json();
635
+ } catch {
636
+ $("context-line").textContent = "could not reach the server";
637
+ return;
638
+ }
639
+ $("version").textContent = `v${context.version} · localhost`;
640
+ $("context-line").textContent = context.repo;
641
+ $("test-command").textContent = context.config.test_command;
642
+ renderChooser();
643
+ }
644
+
645
+ $("run").addEventListener("click", () => act("migrate"));
646
+ $("analyze").addEventListener("click", () => act("analyze"));
647
+ init();
648
+ </script>
649
+ </body>
650
+ </html>