@shadowclone/cli 0.0.27 → 0.0.29
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.
- package/README.md +11 -1
- package/bin/shadowclone.mjs +6 -4
- package/dist/shadowclone.js +354 -354
- package/evals.md +1 -1
- package/package.json +2 -1
- package/plugins/shadowclone/.claude-plugin/plugin.json +1 -1
- package/plugins/shadowclone/.codex-plugin/plugin.json +1 -1
- package/plugins/shadowclone/plugin.json +1 -1
- package/skills/guided-pr-review/SKILL.md +87 -0
- package/skills/guided-pr-review/assets/document.mjs +197 -0
- package/skills/guided-pr-review/assets/guide.mjs +196 -0
- package/skills/guided-pr-review/assets/notes.mjs +195 -0
- package/skills/guided-pr-review/assets/presentation.mjs +180 -0
- package/skills/guided-pr-review/assets/review.html +18 -0
- package/skills/guided-pr-review/references/report.md +118 -0
- package/skills/guided-pr-review/scripts/build.mjs +117 -0
- package/skills/guided-pr-review/scripts/check.mjs +193 -0
- package/skills/guided-pr-review/scripts/format.mjs +198 -0
- package/skills/guided-pr-review/scripts/git.mjs +199 -0
- package/skills/guided-pr-review/scripts/render.mjs +195 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
let selectedContext = null;
|
|
2
|
+
let editingNote = null;
|
|
3
|
+
let dragStart = null;
|
|
4
|
+
|
|
5
|
+
function showReviewPanel(view) {
|
|
6
|
+
const panel = document.getElementById("notes-panel");
|
|
7
|
+
panel.dataset.view = view;
|
|
8
|
+
panel.hidden = false;
|
|
9
|
+
panel.scrollTop = 0;
|
|
10
|
+
showFindings = view === "findings";
|
|
11
|
+
document.getElementById("toggle-findings").classList.toggle("selected", showFindings);
|
|
12
|
+
renderNotes();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function changed() {
|
|
16
|
+
review.annotations.revision += 1;
|
|
17
|
+
dirty = true;
|
|
18
|
+
document.getElementById("save-status").textContent = "Unsaved changes";
|
|
19
|
+
try {
|
|
20
|
+
localStorage.setItem("guided-review-v2:" + review.id, JSON.stringify(review.annotations));
|
|
21
|
+
} catch {
|
|
22
|
+
document.getElementById("save-status").textContent =
|
|
23
|
+
"Unsaved changes. This browser cannot keep a recovery copy.";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderNotes() {
|
|
28
|
+
document.querySelectorAll(".inline-notes").forEach((element) => {
|
|
29
|
+
element.innerHTML = review.annotations.notes
|
|
30
|
+
.filter((note) => !note.dismissed && note.anchor === element.dataset.notesAt)
|
|
31
|
+
.map(noteMarkup)
|
|
32
|
+
.join("");
|
|
33
|
+
});
|
|
34
|
+
document.querySelectorAll(".group-findings").forEach((element) => {
|
|
35
|
+
const group = review.groups.find((group) => group.id === element.dataset.findingsFor);
|
|
36
|
+
const remaining = [];
|
|
37
|
+
for (const finding of review.pulls.flatMap((pull) => pull.findings)) {
|
|
38
|
+
if (!group.files.some((file) => file.pr === finding.pr && file.path === finding.path))
|
|
39
|
+
continue;
|
|
40
|
+
const rows = [...element.closest(".reading-step").querySelectorAll(".code-line")];
|
|
41
|
+
const row = rows.find(
|
|
42
|
+
(row) =>
|
|
43
|
+
row.dataset.file === finding.pr + ":" + finding.path &&
|
|
44
|
+
row.dataset.side === "HEAD" &&
|
|
45
|
+
Number(row.dataset.line) === finding.line,
|
|
46
|
+
);
|
|
47
|
+
if (showFindings && row && row.closest(".file-card")?.hidden !== true)
|
|
48
|
+
row.nextElementSibling.insertAdjacentHTML("beforeend", findingMarkup(finding));
|
|
49
|
+
else remaining.push(finding);
|
|
50
|
+
}
|
|
51
|
+
element.hidden = !showFindings;
|
|
52
|
+
element.innerHTML = remaining.map(findingMarkup).join("");
|
|
53
|
+
});
|
|
54
|
+
document.getElementById("note-count").textContent = review.annotations.notes.filter(
|
|
55
|
+
(note) => !note.dismissed,
|
|
56
|
+
).length;
|
|
57
|
+
document.getElementById("notes-list").innerHTML = review.annotations.notes.length
|
|
58
|
+
? review.annotations.notes.map(noteMarkup).join("")
|
|
59
|
+
: '<p class="muted">Select code or an explanation, then choose Comment.</p>';
|
|
60
|
+
document.getElementById("findings-heading").hidden = !showFindings;
|
|
61
|
+
document.getElementById("findings-list").innerHTML = showFindings
|
|
62
|
+
? review.pulls
|
|
63
|
+
.flatMap((pull) => pull.findings)
|
|
64
|
+
.map(findingMarkup)
|
|
65
|
+
.join("")
|
|
66
|
+
: "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function captureSelection(event) {
|
|
70
|
+
if (!document.getElementById("composer").hidden) return;
|
|
71
|
+
if (event.target.closest("#composer, #notes-panel, .note, .finding, button, textarea, select"))
|
|
72
|
+
return;
|
|
73
|
+
const selection = window.getSelection();
|
|
74
|
+
if (!selection?.rangeCount || !selection.toString().trim()) return;
|
|
75
|
+
const range = selection.getRangeAt(0);
|
|
76
|
+
const start = selection.anchorNode.parentElement;
|
|
77
|
+
const end = selection.focusNode.parentElement;
|
|
78
|
+
const card = start.closest(".file-card, .source-card, #source-dialog");
|
|
79
|
+
const rows =
|
|
80
|
+
card && end.closest(".file-card, .source-card, #source-dialog") === card
|
|
81
|
+
? [...card.querySelectorAll(".code-line")].filter((row) =>
|
|
82
|
+
range.intersectsNode(row.querySelector("code")),
|
|
83
|
+
)
|
|
84
|
+
: [];
|
|
85
|
+
if (rows.length) selectedContext = codeContext(rows);
|
|
86
|
+
else {
|
|
87
|
+
const passage = start.closest(".passage");
|
|
88
|
+
if (!passage || end.closest(".passage") !== passage) return;
|
|
89
|
+
selectedContext = {
|
|
90
|
+
anchor: passage.dataset.anchor,
|
|
91
|
+
quote: selection.toString(),
|
|
92
|
+
ranges: [],
|
|
93
|
+
prs: passage.dataset.prs?.split(",") ?? review.pulls.map((pull) => pull.id),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!selectedContext) return;
|
|
97
|
+
const button = document.getElementById("selection-comment");
|
|
98
|
+
const dialog = start.closest("dialog");
|
|
99
|
+
(dialog ?? document.body).append(button);
|
|
100
|
+
button.hidden = false;
|
|
101
|
+
const bounds = range.getBoundingClientRect();
|
|
102
|
+
button.style.left = Math.max(12, Math.min(bounds.left, innerWidth - 110)) + "px";
|
|
103
|
+
button.style.top = Math.max(70, bounds.top - 40) + "px";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function openComposer(note) {
|
|
107
|
+
if (!document.getElementById("composer").hidden) return;
|
|
108
|
+
editingNote = note?.id ?? null;
|
|
109
|
+
if (note)
|
|
110
|
+
selectedContext = {
|
|
111
|
+
anchor: note.anchor,
|
|
112
|
+
quote: note.quote,
|
|
113
|
+
ranges: note.ranges,
|
|
114
|
+
prs: [note.pr],
|
|
115
|
+
};
|
|
116
|
+
if (!selectedContext) return;
|
|
117
|
+
const composer = document.getElementById("composer");
|
|
118
|
+
(document.getElementById("source-dialog").open
|
|
119
|
+
? document.getElementById("source-dialog")
|
|
120
|
+
: document.body
|
|
121
|
+
).append(composer);
|
|
122
|
+
document.getElementById("selection-comment").hidden = true;
|
|
123
|
+
document.getElementById("selected-quote").textContent = selectedContext.quote;
|
|
124
|
+
document.getElementById("target-pr").innerHTML =
|
|
125
|
+
(selectedContext.prs.length > 1 ? '<option value="">Choose a PR</option>' : "") +
|
|
126
|
+
selectedContext.prs
|
|
127
|
+
.map(
|
|
128
|
+
(id) =>
|
|
129
|
+
'<option value="' +
|
|
130
|
+
id +
|
|
131
|
+
'">#' +
|
|
132
|
+
review.pulls.find((pull) => pull.id === id).number +
|
|
133
|
+
"</option>",
|
|
134
|
+
)
|
|
135
|
+
.join("");
|
|
136
|
+
document.getElementById("target-label").hidden = selectedContext.prs.length === 1;
|
|
137
|
+
document.getElementById("note-body").value = note?.body ?? "";
|
|
138
|
+
composer.hidden = false;
|
|
139
|
+
document.getElementById("note-body").focus();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function notesAction(event) {
|
|
143
|
+
const button = event.target.closest("button");
|
|
144
|
+
if (!button) return;
|
|
145
|
+
const note = review.annotations.notes.find(
|
|
146
|
+
(note) => note.id === (button.dataset.editNote ?? button.dataset.dismissNote),
|
|
147
|
+
);
|
|
148
|
+
if (button.dataset.editNote && note) openComposer(note);
|
|
149
|
+
if (button.dataset.dismissNote && note) {
|
|
150
|
+
note.dismissed = !note.dismissed;
|
|
151
|
+
changed();
|
|
152
|
+
renderNotes();
|
|
153
|
+
}
|
|
154
|
+
const finding = button.dataset.includeFinding ?? button.dataset.dismissFinding;
|
|
155
|
+
if (finding) {
|
|
156
|
+
const state = review.annotations.decisions[finding];
|
|
157
|
+
review.annotations.decisions[finding] = button.dataset.includeFinding
|
|
158
|
+
? state === "included"
|
|
159
|
+
? null
|
|
160
|
+
: "included"
|
|
161
|
+
: state === "dismissed"
|
|
162
|
+
? null
|
|
163
|
+
: "dismissed";
|
|
164
|
+
changed();
|
|
165
|
+
renderNotes();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function startLineSelection(event) {
|
|
170
|
+
if (!document.getElementById("composer").hidden) return;
|
|
171
|
+
if (!event.target.closest(".line-number")) return;
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
dragStart = event.target.closest(".code-line");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function finishLineSelection(event) {
|
|
177
|
+
if (!dragStart) return;
|
|
178
|
+
const end = event.target.closest(".code-line");
|
|
179
|
+
if (
|
|
180
|
+
end &&
|
|
181
|
+
dragStart.closest(".file-card") === end.closest(".file-card") &&
|
|
182
|
+
dragStart.dataset.file === end.dataset.file
|
|
183
|
+
) {
|
|
184
|
+
const parent = dragStart.closest(".code-lines");
|
|
185
|
+
const rows = [...parent.querySelectorAll(".code-line")];
|
|
186
|
+
const indices = [rows.indexOf(dragStart), rows.indexOf(end)].sort(
|
|
187
|
+
(first, second) => first - second,
|
|
188
|
+
);
|
|
189
|
+
selectedContext = codeContext(rows.slice(indices[0], indices[1] + 1));
|
|
190
|
+
const selectedRows = rows.slice(indices[0], indices[1] + 1);
|
|
191
|
+
rows.forEach((row) => row.classList.toggle("chosen", selectedRows.includes(row)));
|
|
192
|
+
openComposer();
|
|
193
|
+
}
|
|
194
|
+
dragStart = null;
|
|
195
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { escapeHtml, fileMarkup } from "../scripts/format.mjs";
|
|
2
|
+
|
|
3
|
+
export const reviewStyle = String.raw`:root { color-scheme: dark; --background: #090a10; --panel: #141620; --border: #303246; --text: #e5e4ed; --muted: #9c9bae; --accent: #b6a2ee; }
|
|
4
|
+
* { box-sizing: border-box; }
|
|
5
|
+
[hidden] { display: none !important; }
|
|
6
|
+
body { margin: 0; background: var(--background); color: var(--text); font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
7
|
+
button, input, textarea, select { font: inherit; color: inherit; }
|
|
8
|
+
button { cursor: pointer; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 7px 12px; }
|
|
9
|
+
button:hover, button.selected { border-color: var(--accent); color: var(--accent); }
|
|
10
|
+
button:disabled { cursor: default; opacity: .45; }
|
|
11
|
+
a, .source-link { color: var(--accent); }
|
|
12
|
+
input, textarea, select { background: var(--background); border: 1px solid var(--border); border-radius: 5px; padding: 8px; }
|
|
13
|
+
textarea { resize: vertical; }
|
|
14
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
15
|
+
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 24px; background: #10121b; border-bottom: 1px solid var(--border); }
|
|
16
|
+
.brand { white-space: nowrap; font-size: 14px; }
|
|
17
|
+
.brand span { color: var(--accent); }
|
|
18
|
+
.header-actions, .pr-tabs, .view-tabs, .file-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
19
|
+
.header-actions { font-size: 13px; }
|
|
20
|
+
#save-review { background: var(--accent); color: var(--background); }
|
|
21
|
+
#save-status { max-width: 260px; color: var(--muted); font-size: 12px; }
|
|
22
|
+
.document { display: grid; grid-template-columns: 236px minmax(0, 1fr); max-width: 1440px; margin: auto; }
|
|
23
|
+
.route { position: sticky; top: 64px; height: calc(100vh - 64px); overflow: auto; padding: 24px 14px; border-right: 1px solid var(--border); }
|
|
24
|
+
.route h2 { margin: 0 10px 16px; color: var(--muted); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; }
|
|
25
|
+
.route-step { display: flex; gap: 12px; width: 100%; margin: 4px 0; padding: 12px 10px; text-align: left; border-color: transparent; background: transparent; }
|
|
26
|
+
.route-step.selected { border-left: 2px solid var(--accent); background: var(--panel); }
|
|
27
|
+
.route-step small { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
|
|
28
|
+
.route-footer { margin: 28px 10px; color: var(--muted); font-size: 12px; }
|
|
29
|
+
main { min-width: 0; padding: 24px 40px 64px; }
|
|
30
|
+
.overview { margin-bottom: 28px; }
|
|
31
|
+
.overview h2 { margin: 0; font-size: 17px; }
|
|
32
|
+
.overview > p { margin: 8px 0 16px; color: var(--muted); font-size: 14px; }
|
|
33
|
+
.pr-tabs { margin: 16px 0; font-size: 12px; }
|
|
34
|
+
.view-tabs { margin: 12px 0; }
|
|
35
|
+
.view-tabs button { padding: 5px 12px; font-size: 12px; }
|
|
36
|
+
.pr-details { color: var(--muted); font-size: 12px; }
|
|
37
|
+
summary { cursor: pointer; }
|
|
38
|
+
.pr-details p { padding-left: 18px; }
|
|
39
|
+
.step-meta { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
|
40
|
+
h1 { margin: 14px 0 18px; font-size: 27px; line-height: 1.25; }
|
|
41
|
+
h3 { margin: 0 0 6px; font-size: 16px; }
|
|
42
|
+
p { margin: 10px 0; }
|
|
43
|
+
.why { padding-left: 14px; border-left: 2px solid var(--accent); }
|
|
44
|
+
.before-after { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 22px 0; }
|
|
45
|
+
.before-after > div { padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); }
|
|
46
|
+
.before-after span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
47
|
+
.before-after p { margin-bottom: 0; font-size: 14px; }
|
|
48
|
+
.flow { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0; }
|
|
49
|
+
.flow > div { flex: 1; min-width: 130px; padding: 12px; border: 1px solid #554875; border-radius: 7px; font-size: 13px; }
|
|
50
|
+
.flow span { color: var(--accent); }
|
|
51
|
+
.flow-proof, .sources { margin: 8px 0 18px; color: var(--muted); font-size: 12px; }
|
|
52
|
+
.source-link { padding: 0; border: 0; background: none; font-size: 12px; text-decoration: underline; }
|
|
53
|
+
.passage { margin: 20px 0; }
|
|
54
|
+
.step-files { margin: 24px 0; }
|
|
55
|
+
.file-card { margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
56
|
+
.file-card > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--panel); font: 12px/1.5 ui-monospace, monospace; overflow-wrap: anywhere; }
|
|
57
|
+
.file-stat { flex-shrink: 0; color: var(--muted); font-size: 11px; }
|
|
58
|
+
.file-stat b { color: #8fc9a5; }
|
|
59
|
+
.file-toolbar { padding: 10px 14px; border-top: 1px solid var(--border); font-size: 12px; }
|
|
60
|
+
.file-toolbar button { padding: 3px 10px; font-size: 12px; }
|
|
61
|
+
.file-toolbar a { margin-left: auto; }
|
|
62
|
+
.file-content { max-height: 540px; overflow: auto; background: #0b1011; }
|
|
63
|
+
.source-card { margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
64
|
+
.source-card figcaption { padding: 10px 14px; background: var(--panel); color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
65
|
+
.source-card figcaption a { float: right; margin-left: 12px; }
|
|
66
|
+
.source-card .code-lines { overflow: auto; background: #0b1011; }
|
|
67
|
+
.source-card .code-line { grid-template-columns: 40px 0 0 minmax(max-content, 1fr) auto; }
|
|
68
|
+
.source-card .code-line > span.line-number { visibility: hidden; padding: 0; }
|
|
69
|
+
.code-line { display: grid; grid-template-columns: 34px 34px 18px minmax(max-content, 1fr) auto; min-width: max-content; padding-right: 12px; font: 12px/1.8 ui-monospace, SFMono-Regular, monospace; }
|
|
70
|
+
.code-line.add { background: #10251e; }
|
|
71
|
+
.code-line.remove { background: #2b171c; }
|
|
72
|
+
.code-line.chosen { background: #332a4b; }
|
|
73
|
+
.line-number { padding: 0 6px; text-align: right; color: #76818a; border: 0; border-radius: 0; background: none; user-select: none; font: inherit; }
|
|
74
|
+
.line-number:hover { color: var(--accent); }
|
|
75
|
+
.sign, .owner { user-select: none; color: var(--muted); }
|
|
76
|
+
.owner { padding-left: 18px; font-size: 10px; }
|
|
77
|
+
.code-line code { white-space: pre; color: #c6d8d3; }
|
|
78
|
+
.hunk { padding: 6px 14px; background: #181827; color: var(--muted); font: 11px ui-monospace, monospace; }
|
|
79
|
+
.metadata, .markdown { margin: 0; padding: 16px; }
|
|
80
|
+
.markdown pre, pre { overflow: auto; font-size: 12px; }
|
|
81
|
+
code { font-family: ui-monospace, SFMono-Regular, monospace; }
|
|
82
|
+
.dry-run { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--border); border-radius: 7px; }
|
|
83
|
+
.dry-run summary span { float: right; color: var(--muted); font-size: 11px; }
|
|
84
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
85
|
+
th, td { padding: 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
|
|
86
|
+
th { color: var(--muted); font-size: 12px; }
|
|
87
|
+
.step-navigation { display: flex; justify-content: space-between; margin-top: 28px; }
|
|
88
|
+
.step-navigation button { max-width: 48%; text-align: left; }
|
|
89
|
+
.reading-step { scroll-margin-top: 80px; }
|
|
90
|
+
.note, .finding { margin: 12px 16px; padding: 14px; border-left: 3px solid var(--accent); border-radius: 6px; background: #211b31; font-size: 13px; }
|
|
91
|
+
.finding { background: #171d29; border-color: #879fc4; }
|
|
92
|
+
.note-meta { display: flex; gap: 10px; align-items: center; color: var(--muted); font-size: 11px; }
|
|
93
|
+
.note blockquote, .finding blockquote { margin: 8px 0; padding-left: 10px; color: var(--muted); border-left: 1px solid var(--border); white-space: pre-wrap; }
|
|
94
|
+
.note-actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
95
|
+
.note-actions button { padding: 2px 8px; font-size: 11px; }
|
|
96
|
+
.finding.dismissed { opacity: .55; }
|
|
97
|
+
.finding.included { border-color: var(--accent); }
|
|
98
|
+
#notes-panel { position: fixed; z-index: 40; top: 64px; right: 0; bottom: 0; width: min(460px, 100vw); overflow: auto; padding: 24px 16px; background: #10121b; border-left: 1px solid var(--border); box-shadow: -12px 0 40px #0006; }
|
|
99
|
+
#notes-panel h2 { margin: 18px 14px 8px; font-size: 18px; }
|
|
100
|
+
.human-notes > p { margin: 12px 14px; color: var(--muted); font-size: 13px; }
|
|
101
|
+
#notes-panel[data-view="findings"] .human-notes, #notes-panel[data-view="notes"] .agent-findings { display: none; }
|
|
102
|
+
#close-notes { float: right; }
|
|
103
|
+
#composer { position: fixed; z-index: 60; right: 24px; top: 96px; width: min(380px, calc(100vw - 32px)); padding: 18px; border: 1px solid var(--accent); border-radius: 10px; background: var(--panel); box-shadow: 0 12px 60px #0008; }
|
|
104
|
+
.composer-title { display: flex; justify-content: space-between; align-items: center; }
|
|
105
|
+
#composer blockquote { max-height: 100px; overflow: auto; margin: 12px 0; padding-left: 10px; border-left: 2px solid var(--border); color: var(--muted); white-space: pre-wrap; font-size: 12px; }
|
|
106
|
+
#target-label { display: flex; gap: 8px; align-items: center; font-size: 12px; }
|
|
107
|
+
#composer textarea { width: 100%; min-height: 100px; margin: 12px 0; }
|
|
108
|
+
#selection-comment { position: fixed; z-index: 70; background: var(--accent); color: var(--background); padding: 4px 12px; box-shadow: 0 4px 16px #0008; }
|
|
109
|
+
dialog { position: fixed; width: min(820px, 90vw); max-height: 85vh; overflow: auto; padding: 24px; color: var(--text); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
|
|
110
|
+
dialog::backdrop { background: #0008; }
|
|
111
|
+
dialog h2 { font-size: 17px; }
|
|
112
|
+
#close-source { float: right; }
|
|
113
|
+
#source-pin { color: var(--muted); font-size: 12px; }
|
|
114
|
+
#source-code { white-space: pre; }
|
|
115
|
+
#file-search { width: 100%; margin: 8px 0; }
|
|
116
|
+
.prototype { color: #e4c486; }
|
|
117
|
+
.muted { color: var(--muted); }
|
|
118
|
+
@media (max-width: 760px) {
|
|
119
|
+
header { padding: 10px 14px; flex-wrap: wrap; }
|
|
120
|
+
.document { display: block; }
|
|
121
|
+
.route { position: static; display: flex; height: auto; padding: 10px; overflow: auto; border-bottom: 1px solid var(--border); }
|
|
122
|
+
.route h2, .route-footer { display: none; }
|
|
123
|
+
.route-step { min-width: 180px; }
|
|
124
|
+
main { padding: 20px 16px 40px; }
|
|
125
|
+
.before-after { grid-template-columns: 1fr; }
|
|
126
|
+
.file-card > summary { align-items: flex-start; flex-direction: column; }
|
|
127
|
+
.header-actions { gap: 6px; }
|
|
128
|
+
#composer { right: 16px; top: 128px; }
|
|
129
|
+
}
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
export function sourceExcerptMarkup(options) {
|
|
133
|
+
const { item, pulls } = options;
|
|
134
|
+
const pull = pulls.find((pull) => pull.id === item.pr);
|
|
135
|
+
const file = {
|
|
136
|
+
id: "evidence:" + item.id,
|
|
137
|
+
pr: item.pr,
|
|
138
|
+
rows: item.text.split("\n").map((text, index) => ({
|
|
139
|
+
kind: "context",
|
|
140
|
+
side: item.side,
|
|
141
|
+
text,
|
|
142
|
+
oldLine: item.start + index,
|
|
143
|
+
newLine: item.start + index,
|
|
144
|
+
owner: item.pr,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
147
|
+
return `<figure class="source-card"><figcaption>#${pull.number} · ${escapeHtml(item.path)}:${item.start} to ${item.end} · ${item.side}<a href="${escapeHtml(item.url)}" target="_blank" rel="noreferrer">Open pinned source</a></figcaption>${fileMarkup({ file, pulls, sha: { BASE: item.sha, HEAD: item.sha } })}</figure>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function findingMarkup(finding) {
|
|
151
|
+
const state = review.annotations.decisions[finding.id];
|
|
152
|
+
const pull = review.pulls.find((pull) => pull.id === finding.pr);
|
|
153
|
+
const sourceUrl =
|
|
154
|
+
pull.url.replace(
|
|
155
|
+
/\/pull\/\d+$/,
|
|
156
|
+
"/blob/" + pull.headSha + "/" + finding.path.split("/").map(encodeURIComponent).join("/"),
|
|
157
|
+
) +
|
|
158
|
+
"#L" +
|
|
159
|
+
finding.line;
|
|
160
|
+
const proof = finding.evidence
|
|
161
|
+
.map(
|
|
162
|
+
(item) =>
|
|
163
|
+
"<p><code>" +
|
|
164
|
+
escapeHtml(item.location) +
|
|
165
|
+
"</code></p><blockquote>" +
|
|
166
|
+
escapeHtml(item.quote) +
|
|
167
|
+
"</blockquote>",
|
|
168
|
+
)
|
|
169
|
+
.join("");
|
|
170
|
+
return `<aside class="finding ${escapeHtml(state ?? "")}" data-finding="${escapeHtml(finding.id)}"><div class="note-meta"><strong>Agent</strong><span>#${escapeHtml(pull.number)} · <a href="${escapeHtml(sourceUrl)}" target="_blank" rel="noreferrer">${escapeHtml(finding.path)}:${escapeHtml(finding.line)}</a></span><span>${state === "included" ? "in your review" : state === "dismissed" ? "dismissed" : "proposal"}</span></div><p>${escapeHtml(finding.explanation)}</p><details><summary>Inspect the reasoning</summary><p><strong>Trigger:</strong> ${escapeHtml(finding.failureScenario)}</p>${proof}<p><strong>Countercheck:</strong> ${escapeHtml(finding.refutation)}</p><p><strong>Suggested direction:</strong> ${escapeHtml(finding.suggestion ?? "Inspect the affected contract before choosing a fix.")}</p></details><div class="note-actions"><button data-include-finding="${escapeHtml(finding.id)}" type="button">${state === "included" ? "Remove from review" : "Add to my review"}</button><button data-dismiss-finding="${escapeHtml(finding.id)}" type="button">${state === "dismissed" ? "Restore" : "Dismiss"}</button></div></aside>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function setSourceLink(options) {
|
|
174
|
+
options.element.removeAttribute("href");
|
|
175
|
+
options.element.textContent = "Source link unavailable.";
|
|
176
|
+
if (options.url.startsWith("https://")) {
|
|
177
|
+
options.element.href = options.url;
|
|
178
|
+
options.element.textContent = "Open pinned source";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
<title>{{TITLE}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
{{STYLE}}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
{{BODY}}
|
|
13
|
+
<script type="application/json" id="review-data">
|
|
14
|
+
{{DATA}}
|
|
15
|
+
</script>
|
|
16
|
+
<script type="module" id="review-runtime"></script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Report contract
|
|
2
|
+
|
|
3
|
+
Create UTF-8 JSON outside the public checkout. The builder reads only pinned Git objects and local review results.
|
|
4
|
+
It refuses an existing output file. It collects all per-PR diffs and verifies every quoted excerpt.
|
|
5
|
+
|
|
6
|
+
## Input
|
|
7
|
+
|
|
8
|
+
Use these fields:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"title": "Review the job retry",
|
|
13
|
+
"summary": "Follow the result and notification through failures.",
|
|
14
|
+
"pulls": [
|
|
15
|
+
{
|
|
16
|
+
"id": "pr-1",
|
|
17
|
+
"repository": "example/project",
|
|
18
|
+
"number": 1,
|
|
19
|
+
"url": "<PR HTTPS URL>",
|
|
20
|
+
"title": "Retry the job",
|
|
21
|
+
"checkout": "<private local checkout>",
|
|
22
|
+
"baseSha": "<full SHA>",
|
|
23
|
+
"headSha": "<full SHA>",
|
|
24
|
+
"reviewFile": "<private local shadowclone-review JSON>",
|
|
25
|
+
"ci": []
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"evidence": [
|
|
29
|
+
{
|
|
30
|
+
"id": "retry-boundary",
|
|
31
|
+
"pr": "pr-1",
|
|
32
|
+
"path": "src/job.ts",
|
|
33
|
+
"side": "HEAD",
|
|
34
|
+
"start": 12,
|
|
35
|
+
"end": 14,
|
|
36
|
+
"quote": "<exact committed source>"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"groups": [
|
|
40
|
+
{
|
|
41
|
+
"id": "job",
|
|
42
|
+
"title": "Follow the retry boundary",
|
|
43
|
+
"prs": ["pr-1"],
|
|
44
|
+
"summary": "The callback now repeats after a failure.",
|
|
45
|
+
"why": "A retry can repeat completed writes.",
|
|
46
|
+
"before": "One attempt.",
|
|
47
|
+
"after": "Up to three attempts.",
|
|
48
|
+
"files": [{ "pr": "pr-1", "path": "src/job.ts" }],
|
|
49
|
+
"evidence": ["retry-boundary"],
|
|
50
|
+
"steps": [
|
|
51
|
+
{
|
|
52
|
+
"title": "Read the callback",
|
|
53
|
+
"text": "<plain Markdown explanation>",
|
|
54
|
+
"evidence": ["retry-boundary"],
|
|
55
|
+
"definitions": ["retry-boundary"],
|
|
56
|
+
"files": [{ "pr": "pr-1", "path": "src/job.ts" }]
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"diagram": { "nodes": [], "edges": [] },
|
|
60
|
+
"dryRuns": []
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Local reviewer JSON contains pull.repository, pull.baseSha, pull.headSha, findings, and skippedPaths.
|
|
67
|
+
The stock CLI produces those fields. A host-agent review records the same pins and its actual investigation output.
|
|
68
|
+
Findings retain the stock path, line, severity, explanation, failureScenario, evidence, suggestion, and refutation fields.
|
|
69
|
+
Do not copy benchmark findings into another PR. Do not rewrite their claims while building the document.
|
|
70
|
+
|
|
71
|
+
CI entries use name and conclusion. They become short status counts, not a badge wall.
|
|
72
|
+
Markdown explanations escape raw HTML. The actual Markdown diff and rendered base and head remain inspectable.
|
|
73
|
+
|
|
74
|
+
Order passages by behavior. Define the main contract before showing its callers, effects, and supporting changes.
|
|
75
|
+
steps.definitions lists evidence identifiers for signature excerpts shown beside the explanation.
|
|
76
|
+
steps.files orders the actual diffs after that passage. Each file must belong to the group's files list.
|
|
77
|
+
Without steps.files, the builder follows changed files in steps.evidence order.
|
|
78
|
+
The group's remaining files follow its main passages. Every file starts expanded and links to its exact PR diff.
|
|
79
|
+
|
|
80
|
+
## Complete coverage
|
|
81
|
+
|
|
82
|
+
Every changed PR/file pair must appear in groups.files. Repeating a file across related groups does not inflate totals.
|
|
83
|
+
The builder rejects missing files for a complete guide. Generated artifacts and tests need an explained supporting group.
|
|
84
|
+
If you did not examine a file, say so in that group's explanation. Listing its diff does not establish investigation.
|
|
85
|
+
Use status=prototype only for a clearly labeled early UX check. Never present that artifact as the complete review.
|
|
86
|
+
|
|
87
|
+
The builder derives each PR's merge base. It derives a combined head only when one supplied revision contains every requested head.
|
|
88
|
+
Combined added lines use Git blame and the PR commit ranges for ownership.
|
|
89
|
+
If removed text occurs in several PRs, its label names those possible owners. Inspect the per-PR patches before staging.
|
|
90
|
+
Per-PR views remain authoritative for exact reviewer-visible anchors.
|
|
91
|
+
|
|
92
|
+
## Visuals and dry runs
|
|
93
|
+
|
|
94
|
+
A diagram node has id, label, and evidence identifiers.
|
|
95
|
+
An edge has from, to, label, and evidence identifiers.
|
|
96
|
+
Every node and transition needs committed evidence. Keep labels short; put citations behind the source disclosure.
|
|
97
|
+
|
|
98
|
+
A dry run has title, kind (reasoned or executed), input, steps, and outcome.
|
|
99
|
+
Each step has condition, state, effect, outcome, and evidence identifiers.
|
|
100
|
+
An executed run also explains its command, fixture boundary, and observed output in the surrounding text.
|
|
101
|
+
Never promote a source trace into an executed result.
|
|
102
|
+
|
|
103
|
+
## Saved annotations
|
|
104
|
+
|
|
105
|
+
The output embeds one application/json script named review-data, with version=2 and a stable document id.
|
|
106
|
+
It contains the pinned facts, local findings, route, code evidence, and annotations.
|
|
107
|
+
Read this data block without executing the HTML's scripts when staging.
|
|
108
|
+
|
|
109
|
+
annotations contains notes, decisions, reviewed group identifiers, and a revision number.
|
|
110
|
+
A human note contains id, pr, anchor, quote, ranges, body, originalBody, and dismissed.
|
|
111
|
+
A source range contains path, side, start, end, and sha. Mixed diff selections can contain several source ranges.
|
|
112
|
+
Prose notes have an empty ranges array. The staging workflow resolves them to current changed lines.
|
|
113
|
+
decisions maps finding identifiers to included, dismissed, or null.
|
|
114
|
+
Preserve removed notes and dismissed findings as inactive records. They must not reappear in a staged review.
|
|
115
|
+
|
|
116
|
+
Save review requests a handle to the original HTML. It checks document identity and the prior annotation state before writing.
|
|
117
|
+
It updates the embedded JSON only and preserves the document's remaining text.
|
|
118
|
+
The original file, not browser storage or a separate JSON export, carries the portable review.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { collectReview, source } from "./git.mjs";
|
|
4
|
+
import { checkInput, checkCoverage } from "./check.mjs";
|
|
5
|
+
import { renderReview } from "./render.mjs";
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const [inputPath, outputPath] = process.argv.slice(2);
|
|
9
|
+
if (!inputPath || !outputPath) throw new Error("Usage: build.mjs <input.json> <review.html>");
|
|
10
|
+
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
|
11
|
+
checkInput(input);
|
|
12
|
+
const { pulls, combined } = await collectReview(input);
|
|
13
|
+
for (const pull of input.pulls) {
|
|
14
|
+
const review = pull.reviewFile ? JSON.parse(await readFile(pull.reviewFile, "utf8")) : null;
|
|
15
|
+
if (
|
|
16
|
+
review &&
|
|
17
|
+
(review.pull?.headSha !== pull.headSha ||
|
|
18
|
+
review.pull?.baseSha !== pull.baseSha ||
|
|
19
|
+
review.pull?.repository !== pull.repository)
|
|
20
|
+
)
|
|
21
|
+
throw new Error("The local findings do not match the pinned PR.");
|
|
22
|
+
const result = pulls.find((entry) => entry.id === pull.id);
|
|
23
|
+
result.findings = (review?.findings ?? []).map((finding, index) => ({
|
|
24
|
+
...finding,
|
|
25
|
+
id: pull.id + "-finding-" + index,
|
|
26
|
+
pr: pull.id,
|
|
27
|
+
}));
|
|
28
|
+
result.skippedPaths = review?.skippedPaths ?? [];
|
|
29
|
+
}
|
|
30
|
+
const evidence = input.evidence.map((item) => {
|
|
31
|
+
const pull = input.pulls.find((pull) => pull.id === item.pr);
|
|
32
|
+
const sha =
|
|
33
|
+
item.side === "BASE"
|
|
34
|
+
? pulls.find((entry) => entry.id === pull.id).mergeBaseSha
|
|
35
|
+
: pull.headSha;
|
|
36
|
+
const text = source({ checkout: pull.checkout, sha, path: item.path })
|
|
37
|
+
?.split("\n")
|
|
38
|
+
.slice(item.start - 1, item.end)
|
|
39
|
+
.join("\n");
|
|
40
|
+
if (text === undefined || text !== item.quote)
|
|
41
|
+
throw new Error("Source evidence does not match: " + item.path + ":" + item.start);
|
|
42
|
+
return {
|
|
43
|
+
...item,
|
|
44
|
+
sha,
|
|
45
|
+
text,
|
|
46
|
+
url:
|
|
47
|
+
pull.url.replace(
|
|
48
|
+
/\/pull\/\d+$/,
|
|
49
|
+
"/blob/" + sha + "/" + item.path.split("/").map(encodeURIComponent).join("/"),
|
|
50
|
+
) +
|
|
51
|
+
"#L" +
|
|
52
|
+
item.start,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
const groups = input.groups.map((group) => {
|
|
56
|
+
const paths = [
|
|
57
|
+
...group.steps.flatMap((step) =>
|
|
58
|
+
(
|
|
59
|
+
step.files ?? (step.evidence ?? []).map((id) => evidence.find((item) => item.id === id))
|
|
60
|
+
).map((file) => file.path),
|
|
61
|
+
),
|
|
62
|
+
...group.files.map((file) => file.path),
|
|
63
|
+
];
|
|
64
|
+
return {
|
|
65
|
+
...group,
|
|
66
|
+
files: [...group.files].sort(
|
|
67
|
+
(first, second) => paths.indexOf(first.path) - paths.indexOf(second.path),
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const id = createHash("sha256")
|
|
72
|
+
.update(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
pins: pulls.map((pull) => [pull.repository, pull.number, pull.baseSha, pull.headSha]),
|
|
75
|
+
title: input.title,
|
|
76
|
+
groups: input.groups,
|
|
77
|
+
evidence,
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
.digest("hex");
|
|
81
|
+
const review = {
|
|
82
|
+
version: 2,
|
|
83
|
+
id,
|
|
84
|
+
title: input.title,
|
|
85
|
+
summary: input.summary,
|
|
86
|
+
status: input.status ?? "complete",
|
|
87
|
+
pulls,
|
|
88
|
+
groups,
|
|
89
|
+
fileOrder: [
|
|
90
|
+
...new Set([
|
|
91
|
+
...groups.flatMap((group) => group.files.map((file) => file.pr + ":" + file.path)),
|
|
92
|
+
...pulls.flatMap((pull) => pull.files.map((file) => file.id)),
|
|
93
|
+
]),
|
|
94
|
+
],
|
|
95
|
+
evidence,
|
|
96
|
+
combined,
|
|
97
|
+
annotations: { notes: [], decisions: {}, reviewed: [], revision: 0 },
|
|
98
|
+
};
|
|
99
|
+
const coverage = checkCoverage(review);
|
|
100
|
+
await writeFile(outputPath, await renderReview(review), { flag: "wx", mode: 0o600 });
|
|
101
|
+
console.log(
|
|
102
|
+
"build: " +
|
|
103
|
+
review.pulls.length +
|
|
104
|
+
" PRs, " +
|
|
105
|
+
review.groups.length +
|
|
106
|
+
" reading steps, " +
|
|
107
|
+
coverage.covered +
|
|
108
|
+
"/" +
|
|
109
|
+
coverage.total +
|
|
110
|
+
" changed files, " +
|
|
111
|
+
review.pulls.reduce((count, pull) => count + pull.findings.length, 0) +
|
|
112
|
+
" local findings",
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(error.message);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|