@shadowclone/skills 0.0.0-stage → 0.0.30

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 (32) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/package.json +15 -4
  4. package/skills/choose-by-consequence/SKILL.md +66 -0
  5. package/skills/design-deep-modules/SKILL.md +51 -0
  6. package/skills/diagnose-before-editing/SKILL.md +58 -0
  7. package/skills/guided-pr-review/SKILL.md +87 -0
  8. package/skills/guided-pr-review/assets/document.mjs +197 -0
  9. package/skills/guided-pr-review/assets/guide.mjs +196 -0
  10. package/skills/guided-pr-review/assets/notes.mjs +195 -0
  11. package/skills/guided-pr-review/assets/presentation.mjs +180 -0
  12. package/skills/guided-pr-review/assets/review.html +18 -0
  13. package/skills/guided-pr-review/references/report.md +118 -0
  14. package/skills/guided-pr-review/scripts/build.mjs +117 -0
  15. package/skills/guided-pr-review/scripts/check.mjs +193 -0
  16. package/skills/guided-pr-review/scripts/format.mjs +198 -0
  17. package/skills/guided-pr-review/scripts/git.mjs +199 -0
  18. package/skills/guided-pr-review/scripts/render.mjs +195 -0
  19. package/skills/plan-with-review-page/SKILL.md +73 -0
  20. package/skills/plan-with-review-page/assets/review-page.html +160 -0
  21. package/skills/plan-with-review-page/scripts/validate-page.mjs +158 -0
  22. package/skills/research-primary-sources/SKILL.md +51 -0
  23. package/skills/resolve-conflicts-by-intent/SKILL.md +53 -0
  24. package/skills/scope-confirmed-changes/SKILL.md +63 -0
  25. package/skills/shadowclone-review/SKILL.md +76 -0
  26. package/skills/shadowclone-work/SKILL.md +47 -0
  27. package/skills/tests-that-catch-bugs/SKILL.md +73 -0
  28. package/skills/typescript-type-safety/SKILL.md +52 -0
  29. package/skills/verify-and-review/SKILL.md +58 -0
  30. package/skills/verify-review-findings/SKILL.md +59 -0
  31. package/skills/write-plain-english/SKILL.md +69 -0
  32. package/skills/write-plain-english/scripts/check-ste.mjs +200 -0
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+
4
+ function requireText(value, label) {
5
+ if (typeof value !== "string" || !value.trim()) throw new Error(label + " must contain text.");
6
+ }
7
+
8
+ function unique(items, label) {
9
+ const values = new Set();
10
+ for (const item of items) {
11
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(item.id) || values.has(item.id))
12
+ throw new Error("Invalid or duplicate " + label + " id.");
13
+ values.add(item.id);
14
+ }
15
+ return values;
16
+ }
17
+
18
+ export function checkInput(input) {
19
+ requireText(input.title, "Review title");
20
+ if (
21
+ !Array.isArray(input.pulls) ||
22
+ !input.pulls.length ||
23
+ !Array.isArray(input.groups) ||
24
+ !input.groups.length ||
25
+ !Array.isArray(input.evidence)
26
+ )
27
+ throw new Error("The guide needs PRs, reading groups, and evidence.");
28
+ const pullIds = unique(input.pulls, "PR");
29
+ const evidenceIds = unique(input.evidence, "evidence");
30
+ unique(input.groups, "group");
31
+ for (const pull of input.pulls) {
32
+ if (![pull.baseSha, pull.headSha].every((sha) => /^[a-f0-9]{40}$/.test(sha)))
33
+ throw new Error("Pin each PR's full base and head SHA.");
34
+ if (
35
+ !/^[\w.-]+\/[\w.-]+$/.test(pull.repository) ||
36
+ !Number.isInteger(pull.number) ||
37
+ pull.number < 1
38
+ )
39
+ throw new Error("Each PR needs its repository and number.");
40
+ const url = new URL(pull.url);
41
+ if (
42
+ url.protocol !== "https:" ||
43
+ url.pathname !== "/" + pull.repository + "/pull/" + pull.number
44
+ )
45
+ throw new Error("The PR URL does not match its scope.");
46
+ }
47
+ for (const evidence of input.evidence) {
48
+ if (
49
+ !pullIds.has(evidence.pr) ||
50
+ !["BASE", "HEAD"].includes(evidence.side) ||
51
+ !Number.isInteger(evidence.start) ||
52
+ !Number.isInteger(evidence.end) ||
53
+ evidence.start < 1 ||
54
+ evidence.end < evidence.start ||
55
+ evidence.end - evidence.start > 100
56
+ )
57
+ throw new Error("Evidence needs a PR, side, and bounded source range.");
58
+ if (
59
+ typeof evidence.path !== "string" ||
60
+ evidence.path.startsWith("/") ||
61
+ evidence.path.split("/").some((part) => part === ".." || part === ".") ||
62
+ evidence.path.includes("\0")
63
+ )
64
+ throw new Error("Evidence paths must stay inside the repository.");
65
+ requireText(evidence.quote, "Evidence quote");
66
+ }
67
+ for (const group of input.groups) {
68
+ requireText(group.title, "Reading step");
69
+ requireText(group.summary, "Step explanation");
70
+ if (!Array.isArray(group.files) || !Array.isArray(group.steps))
71
+ throw new Error("Each reading step needs files and passages.");
72
+ for (const file of group.files)
73
+ if (!pullIds.has(file.pr)) throw new Error("A file refers to another PR.");
74
+ for (const file of group.steps.flatMap((step) => step.files ?? []))
75
+ if (!group.files.some((entry) => entry.pr === file.pr && entry.path === file.path))
76
+ throw new Error("A passage file must belong to its reading group.");
77
+ const references = [
78
+ ...(group.evidence ?? []),
79
+ ...group.steps.flatMap((step) => [...(step.evidence ?? []), ...(step.definitions ?? [])]),
80
+ ...(group.diagram?.nodes ?? []).flatMap((node) => node.evidence ?? []),
81
+ ...(group.diagram?.edges ?? []).flatMap((edge) => edge.evidence ?? []),
82
+ ...(group.dryRuns ?? []).flatMap((run) => run.steps.flatMap((step) => step.evidence ?? [])),
83
+ ];
84
+ if (references.some((id) => !evidenceIds.has(id)))
85
+ throw new Error("A passage or visual refers to missing evidence.");
86
+ for (const node of group.diagram?.nodes ?? [])
87
+ if (!node.evidence?.length) throw new Error("Every diagram node needs code evidence.");
88
+ for (const edge of group.diagram?.edges ?? [])
89
+ if (!edge.evidence?.length) throw new Error("Every diagram transition needs code evidence.");
90
+ }
91
+ }
92
+
93
+ export function checkCoverage(review) {
94
+ const files = new Set(review.pulls.flatMap((pull) => pull.files.map((file) => file.id)));
95
+ const covered = new Set(
96
+ review.groups.flatMap((group) => group.files.map((file) => file.pr + ":" + file.path)),
97
+ );
98
+ for (const key of covered)
99
+ if (!files.has(key)) throw new Error("A reading step refers to an unchanged file: " + key);
100
+ const missing = [...files].filter((key) => !covered.has(key));
101
+ if (missing.length && review.status !== "prototype")
102
+ throw new Error(
103
+ "The reading route omits " + missing.length + " changed files:\n" + missing.join("\n"),
104
+ );
105
+ return { total: files.size, covered: files.size - missing.length, missing };
106
+ }
107
+
108
+ export function readReviewData(html) {
109
+ const match = /<script type="application\/json" id="review-data">([\s\S]*?)<\/script>/.exec(html);
110
+ if (!match) throw new Error("The HTML has no embedded review data.");
111
+ const review = JSON.parse(match[1]);
112
+ if (review.version !== 2 || !/^[a-f0-9]{64}$/.test(review.id))
113
+ throw new Error("The review format or document identity is invalid.");
114
+ const ids = new Set(review.pulls.map((pull) => pull.id));
115
+ if (!Array.isArray(review.annotations?.notes))
116
+ throw new Error("The review annotations are invalid.");
117
+ for (const note of review.annotations.notes) {
118
+ if (
119
+ !ids.has(note.pr) ||
120
+ typeof note.id !== "string" ||
121
+ typeof note.body !== "string" ||
122
+ typeof note.quote !== "string" ||
123
+ typeof note.anchor !== "string" ||
124
+ !Array.isArray(note.ranges)
125
+ )
126
+ throw new Error("An annotation has invalid text or source references.");
127
+ }
128
+ checkCoverage(review);
129
+ return review;
130
+ }
131
+
132
+ if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file:").href) {
133
+ try {
134
+ const html = await readFile(process.argv[2], "utf8");
135
+ const review = readReviewData(html);
136
+ if (/<script[^>]+src=|<link[^>]+href=https?:/i.test(html))
137
+ throw new Error("The review contains remote runtime assets.");
138
+ const coverage = checkCoverage(review);
139
+ console.log(
140
+ "check: " +
141
+ review.pulls.length +
142
+ " PRs, " +
143
+ coverage.covered +
144
+ "/" +
145
+ coverage.total +
146
+ " files in the route, " +
147
+ review.annotations.notes.length +
148
+ " human notes",
149
+ );
150
+ } catch (error) {
151
+ console.error(error.message);
152
+ process.exitCode = 1;
153
+ }
154
+ }
155
+
156
+ export function ancestor(options) {
157
+ const result = spawnSync("git", [
158
+ "-C",
159
+ options.checkout,
160
+ "merge-base",
161
+ "--is-ancestor",
162
+ options.base,
163
+ options.head,
164
+ ]);
165
+ if (result.status !== 0 && result.status !== 1)
166
+ throw new Error("Git ancestry could not be established.");
167
+ return result.status === 0;
168
+ }
169
+
170
+ export function patchRows(patch) {
171
+ const rows = [];
172
+ let oldLine = 0;
173
+ let newLine = 0;
174
+ let active = false;
175
+ for (const line of patch.split("\n")) {
176
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
177
+ if (hunk) {
178
+ oldLine = Number(hunk[1]);
179
+ newLine = Number(hunk[2]);
180
+ active = true;
181
+ rows.push({ kind: "hunk", text: line, oldLine: null, newLine: null });
182
+ } else if (active && /^[ +\-]/.test(line)) {
183
+ const kind = line[0] === "+" ? "add" : line[0] === "-" ? "remove" : "context";
184
+ rows.push({
185
+ kind,
186
+ text: line.slice(1),
187
+ oldLine: kind === "add" ? null : oldLine++,
188
+ newLine: kind === "remove" ? null : newLine++,
189
+ });
190
+ }
191
+ }
192
+ return rows;
193
+ }
@@ -0,0 +1,198 @@
1
+ export function escapeHtml(value) {
2
+ return String(value).replace(
3
+ /[&<>"']/g,
4
+ (character) =>
5
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character],
6
+ );
7
+ }
8
+
9
+ export function jsonForHtml(value) {
10
+ return JSON.stringify(value)
11
+ .replace(/</g, "\\u003c")
12
+ .replace(/\u2028/g, "\\u2028")
13
+ .replace(/\u2029/g, "\\u2029");
14
+ }
15
+
16
+ export function markdown(value) {
17
+ const inline = (text) =>
18
+ escapeHtml(text)
19
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
20
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
21
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
22
+ .replace(
23
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|#[^\s)]+)\)/g,
24
+ '<a href="$2" target="_blank" rel="noreferrer">$1</a>',
25
+ );
26
+ let fenced = false;
27
+ let list = false;
28
+ let table = false;
29
+ const output = [];
30
+ for (const line of String(value ?? "").split("\n")) {
31
+ if (table && !line.startsWith("|")) {
32
+ output.push("</table>");
33
+ table = false;
34
+ }
35
+ if (line.startsWith("```")) {
36
+ if (list) {
37
+ output.push("</ul>");
38
+ list = false;
39
+ }
40
+ output.push(fenced ? "</code></pre>" : "<pre><code>");
41
+ fenced = !fenced;
42
+ } else if (fenced) output.push(escapeHtml(line) + "\n");
43
+ else if (line.startsWith("|")) {
44
+ if (!table) {
45
+ output.push("<table>");
46
+ table = true;
47
+ }
48
+ if (!/^\|[\s:|-]+\|$/.test(line))
49
+ output.push(
50
+ "<tr>" +
51
+ line
52
+ .split("|")
53
+ .slice(1, -1)
54
+ .map((cell) => "<td>" + inline(cell.trim()) + "</td>")
55
+ .join("") +
56
+ "</tr>",
57
+ );
58
+ } else if (/^[-*] /.test(line)) {
59
+ if (!list) {
60
+ output.push("<ul>");
61
+ list = true;
62
+ }
63
+ output.push("<li>" + inline(line.slice(2)) + "</li>");
64
+ } else {
65
+ if (list) {
66
+ output.push("</ul>");
67
+ list = false;
68
+ }
69
+ const heading = /^(#{1,6}) (.*)$/.exec(line);
70
+ if (heading)
71
+ output.push(
72
+ "<h" + heading[1].length + ">" + inline(heading[2]) + "</h" + heading[1].length + ">",
73
+ );
74
+ else if (line.trim()) output.push("<p>" + inline(line) + "</p>");
75
+ }
76
+ }
77
+ if (fenced) output.push("</code></pre>");
78
+ if (table) output.push("</table>");
79
+ if (list) output.push("</ul>");
80
+ return output.join("");
81
+ }
82
+
83
+ export function fileMarkup(options) {
84
+ const { file, view = "diff", pulls, sha } = options;
85
+ if (view !== "diff") {
86
+ const text = view === "base" ? file.baseMarkdown : file.headMarkdown;
87
+ const prs =
88
+ file.pr === "stack"
89
+ ? pulls
90
+ .filter((pull) => pull.files.some((item) => item.path === file.path))
91
+ .map((pull) => pull.id)
92
+ : [file.pr];
93
+ const anchor = escapeHtml(file.id + ":" + view);
94
+ return text === null
95
+ ? '<p class="muted">This side has no Markdown file.</p>'
96
+ : `<div class="markdown passage" data-anchor="${anchor}" data-prs="${escapeHtml(prs.join(","))}">${markdown(text)}<div class="inline-notes" data-notes-at="${anchor}"></div></div>`;
97
+ }
98
+ if (!file.rows.length)
99
+ return (
100
+ '<pre class="metadata">' +
101
+ escapeHtml(file.patch || "No net change at the stack head. Inspect the per-PR diff.") +
102
+ "</pre>"
103
+ );
104
+ return (
105
+ '<div class="code-lines">' +
106
+ file.rows
107
+ .map((row, index) => {
108
+ if (row.kind === "hunk") return '<div class="hunk">' + escapeHtml(row.text) + "</div>";
109
+ const side = row.side ?? (row.kind === "remove" ? "BASE" : "HEAD");
110
+ const line = side === "BASE" ? row.oldLine : row.newLine;
111
+ const owners = row.owner
112
+ .split(",")
113
+ .map((id) => pulls.find((pull) => pull.id === id))
114
+ .filter(Boolean);
115
+ const owner = pulls.length > 1 ? owners.map((pull) => "#" + pull.number).join(" + ") : "";
116
+ return `<div class="code-line ${escapeHtml(row.kind)}" data-anchor="${escapeHtml(file.id + ":" + index)}" data-file="${escapeHtml(file.id)}" data-side="${escapeHtml(side)}" data-line="${escapeHtml(line)}" data-sha="${escapeHtml(sha[side])}" data-owner="${escapeHtml(row.owner)}"><button class="line-number" type="button" aria-label="Select line ${escapeHtml(line)}">${escapeHtml(row.oldLine ?? "")}</button><span class="line-number">${escapeHtml(row.newLine ?? "")}</span><span class="sign">${row.kind === "add" ? "+" : row.kind === "remove" ? "-" : ""}</span><code>${escapeHtml(row.text)}</code><span class="owner">${escapeHtml(owner)}</span></div><div class="inline-notes" data-notes-at="${escapeHtml(file.id + ":" + index)}"></div>`;
117
+ })
118
+ .join("") +
119
+ "</div>"
120
+ );
121
+ }
122
+
123
+ export function fileCard(options) {
124
+ const url = new URL(options.url);
125
+ url.pathname += "/files";
126
+ url.search = "";
127
+ url.hash = options.file.diffAnchor;
128
+ return `<details class="file-card" data-file="${escapeHtml(options.file.id)}" ${options.open ? "open" : ""}><summary><span>${escapeHtml(options.file.path)}</span><span class="file-stat">${escapeHtml(options.prLabel)} <b>+${escapeHtml(options.file.additions)}</b> / -${escapeHtml(options.file.deletions)}</span></summary><div class="file-toolbar"><button data-view="diff" class="selected" type="button">Diff</button>${options.file.baseMarkdown !== null || options.file.headMarkdown !== null ? '<button data-view="base" type="button">Base Markdown</button><button data-view="head" type="button">Head Markdown</button>' : ""}<a href="${escapeHtml(url.href)}" target="_blank" rel="noreferrer">Open file in PR</a></div><div class="file-content"></div></details>`;
129
+ }
130
+
131
+ export function noteMarkup(note) {
132
+ const pull = review.pulls.find((pull) => pull.id === note.pr);
133
+ return `<aside class="note ${note.dismissed ? "dismissed" : ""}" data-note="${escapeHtml(note.id)}"><div class="note-meta"><strong>You</strong><span>#${escapeHtml(pull.number)} · ${note.dismissed ? "removed" : "draft"}</span></div><blockquote>${escapeHtml(note.quote)}</blockquote><p>${escapeHtml(note.body)}</p><div class="note-actions"><button data-edit-note="${escapeHtml(note.id)}" type="button">Edit</button><button data-dismiss-note="${escapeHtml(note.id)}" type="button">${note.dismissed ? "Restore" : "Remove"}</button></div></aside>`;
134
+ }
135
+
136
+ export function behaviorMarkup(options) {
137
+ const { group, sources } = options;
138
+ const nodes = group.diagram?.nodes ?? [];
139
+ const flow = nodes.length
140
+ ? '<div class="flow">' +
141
+ nodes
142
+ .map(
143
+ (node, nodeIndex) =>
144
+ "<div><strong>" +
145
+ escapeHtml(node.label) +
146
+ "</strong>" +
147
+ (nodeIndex < nodes.length - 1 ? '<span aria-hidden="true"> →</span>' : "") +
148
+ "</div>",
149
+ )
150
+ .join("") +
151
+ '</div><details class="flow-proof"><summary>Follow the branches and source</summary>' +
152
+ (group.diagram.edges ?? [])
153
+ .map(
154
+ (edge) =>
155
+ "<p>" +
156
+ escapeHtml(nodes.find((node) => node.id === edge.from)?.label ?? edge.from) +
157
+ " → " +
158
+ escapeHtml(nodes.find((node) => node.id === edge.to)?.label ?? edge.to) +
159
+ ": " +
160
+ escapeHtml(edge.label) +
161
+ " " +
162
+ sources(edge.evidence) +
163
+ "</p>",
164
+ )
165
+ .join("") +
166
+ "</details>"
167
+ : "";
168
+ const dryRuns = (group.dryRuns ?? [])
169
+ .map(
170
+ (run) =>
171
+ '<details class="dry-run"><summary>' +
172
+ escapeHtml(run.title) +
173
+ "<span>" +
174
+ escapeHtml(run.kind ?? "reasoned") +
175
+ "</span></summary><p>" +
176
+ escapeHtml(run.input) +
177
+ "</p><table><thead><tr><th>Branch</th><th>State</th><th>Effect and outcome</th></tr></thead><tbody>" +
178
+ run.steps
179
+ .map(
180
+ (step) =>
181
+ "<tr><td>" +
182
+ escapeHtml(step.condition ?? step.when ?? step.branch) +
183
+ "</td><td>" +
184
+ escapeHtml(step.state) +
185
+ "</td><td>" +
186
+ escapeHtml((step.effect ?? step.effects ?? "") + " " + (step.outcome ?? "")) +
187
+ " " +
188
+ sources(step.evidence ?? []) +
189
+ "</td></tr>",
190
+ )
191
+ .join("") +
192
+ "</tbody></table><p>" +
193
+ escapeHtml(run.outcome) +
194
+ "</p></details>",
195
+ )
196
+ .join("");
197
+ return { flow, dryRuns };
198
+ }