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,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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) || " ";
|
|
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>
|