@dev-loops/core 0.8.0 → 1.0.0-rc.1

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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Provision + boot orchestrator for the ui_review route (Stage 1).
3
+ *
4
+ * Provisions an isolated worktree for a PR head and boots the branch's app to a
5
+ * ready state, then hands off a booted app for the running-app review stages.
6
+ * The orchestration is a fail-closed sequence:
7
+ *
8
+ * 1. create-or-reuse the PR worktree (fetch before) and provision it
9
+ * 2. refuse to operate in the primary checkout (worktree guard)
10
+ * 3. install ONLY the dependency-lock delta vs. the primary checkout
11
+ * 4. run pending dev-DB migrations; a destructive one stops for explicit ack
12
+ * 5. resolve a per-project run recipe (no app is ever guessed)
13
+ * 6. boot the app and poll an HTTP readiness probe (never a fixed sleep)
14
+ *
15
+ * Every bounded cap (install skipped, migration ack required, boot timeout) is
16
+ * logged — no silent truncation. This module is pure orchestration: all IO
17
+ * (git/worktree, config, spawn, HTTP probe, clock) is injected so it is fully
18
+ * testable against a fixture project. The thin CLI wires the real seams.
19
+ *
20
+ * Out of scope (later stages): browser driving, auth, screenshots, review
21
+ * posting, production DB.
22
+ */
23
+
24
+ import path from "node:path";
25
+
26
+ const MUST_FIX = "must-fix";
27
+
28
+ /**
29
+ * Run the provision+boot sequence.
30
+ *
31
+ * @param {object} input
32
+ * @param {string} input.repoRoot - Absolute path to the primary checkout.
33
+ * @param {number} input.pr - PR number whose head is provisioned.
34
+ * @param {string} [input.branch] - Branch to check out (default: pr-<n>).
35
+ * @param {boolean} [input.ackDestructiveMigration] - Explicit ack unblocking a
36
+ * destructive/blocked migration. Fail-closed: absent means "not acknowledged".
37
+ * @param {object} seams - Injected IO (all required except clock/log defaults).
38
+ * @param {(a:{repoRoot:string,pr:number,branch?:string})=>Promise<{path:string,created:boolean,reused:boolean}>} seams.ensureWorktree
39
+ * @param {(a:{worktreePath:string,repoRoot:string})=>{ok:boolean,message?:string,mainWorktreePath?:string|null}} seams.assertNotPrimary
40
+ * @param {(a:{repoRoot:string,worktreePath:string})=>Promise<{changed:boolean,detail:string}>} seams.detectDepDelta
41
+ * @param {(a:{worktreePath:string})=>Promise<{ok:boolean,detail:string}>} seams.installDeps
42
+ * @param {(worktreePath:string)=>Promise<object|null>} seams.resolveRunRecipe
43
+ * @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{pending:string[],destructive:string[],detail:string}>} seams.inspectMigrations — MUST run in `runCwd` (the guard-validated absolute cwd), never re-derive it
44
+ * @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{ok:boolean,applied:number,detail:string}>} seams.applyMigrations — MUST run in `runCwd`
45
+ * @param {(a:{worktreePath:string,recipe:object,runCwd:string})=>Promise<{pid:number|null,detail:string}>} seams.bootApp — MUST run in `runCwd`
46
+ * @param {(url:string)=>Promise<boolean>} seams.probe
47
+ * @param {(ms:number)=>Promise<false> & {clear?:()=>void}} [seams.probeTimeout] -
48
+ * Per-attempt cap: resolves false once `ms` elapses, so a hung probe can't
49
+ * outlive the budget. May expose `clear()`; the poll calls it after each race
50
+ * resolves so a pending timer is cancelled rather than left to fire.
51
+ * @param {(ms:number)=>Promise<void>} [seams.delay]
52
+ * @param {()=>number} [seams.now]
53
+ * @param {(msg:string)=>void} [seams.log]
54
+ * @returns {Promise<object>} A result envelope (see fields assembled below).
55
+ */
56
+ export async function provisionAndBoot(
57
+ { repoRoot, pr, branch, ackDestructiveMigration = false },
58
+ {
59
+ ensureWorktree,
60
+ assertNotPrimary,
61
+ detectDepDelta,
62
+ installDeps,
63
+ resolveRunRecipe,
64
+ inspectMigrations,
65
+ applyMigrations,
66
+ bootApp,
67
+ probe,
68
+ probeTimeout = (ms) => {
69
+ let t;
70
+ const p = /** @type {Promise<false> & {clear?:()=>void}} */ (
71
+ new Promise((resolve) => {
72
+ t = setTimeout(() => resolve(false), ms);
73
+ })
74
+ );
75
+ // Self-clearing: the poll calls clear() after the race resolves, so a
76
+ // still-pending timer is cancelled outright (never left to fire, never
77
+ // holds the process open) — bounding any injected probe seam.
78
+ p.clear = () => clearTimeout(t);
79
+ return p;
80
+ },
81
+ delay = (ms) => new Promise((r) => setTimeout(r, ms)),
82
+ now = () => Date.now(),
83
+ log = () => {},
84
+ } = {},
85
+ ) {
86
+ const logs = [];
87
+ const findings = [];
88
+ const record = (msg) => {
89
+ logs.push(msg);
90
+ log(msg);
91
+ };
92
+ const base = () => ({ pr, branch: branch ?? null, findings, logs });
93
+ const stop = (stopReason, finding, extra = {}) => {
94
+ if (finding) findings.push(finding);
95
+ record(`STOP: ${stopReason}`);
96
+ return { ok: false, stopped: true, stopReason, ...base(), ...extra };
97
+ };
98
+
99
+ // 1. Create-or-reuse the PR worktree (ensureWorktree fetches + provisions).
100
+ const wt = await ensureWorktree({ repoRoot, pr, branch });
101
+ const worktreePath = wt.path;
102
+ record(`worktree ${wt.created ? "created" : "reused"}: ${worktreePath}`);
103
+
104
+ // 2. Fail closed if that path is the primary checkout — never operate there.
105
+ const guard = assertNotPrimary({ worktreePath, repoRoot });
106
+ if (!guard.ok) {
107
+ return stop(
108
+ "worktree guard: refusing to operate in the primary checkout",
109
+ { kind: "worktree-guard", severity: MUST_FIX, message: guard.message ?? "resolved worktree is the primary checkout" },
110
+ { worktreePath },
111
+ );
112
+ }
113
+
114
+ // 3. Install only the dependency-lock delta vs. the primary checkout. No delta
115
+ // => deps are shared; installing anything would be a blind re-install.
116
+ const delta = await detectDepDelta({ repoRoot, worktreePath });
117
+ let depInstall = { installed: false, detail: delta.detail };
118
+ if (delta.changed) {
119
+ record(`dependency-lock delta detected (${delta.detail}); installing branch deps`);
120
+ const inst = await installDeps({ worktreePath });
121
+ depInstall = { installed: inst.ok, detail: inst.detail };
122
+ record(`dependency install ${inst.ok ? "ok" : "FAILED"}: ${inst.detail}`);
123
+ if (!inst.ok) {
124
+ return stop(
125
+ "dependency install failed",
126
+ { kind: "dep-install", severity: MUST_FIX, message: inst.detail },
127
+ { worktreePath, depInstall },
128
+ );
129
+ }
130
+ } else {
131
+ record(`dependency install skipped: no lock delta vs primary checkout (${delta.detail})`);
132
+ }
133
+
134
+ // 4. Resolve the per-project run recipe before migrate/boot (no app guessed).
135
+ const recipe = await resolveRunRecipe(worktreePath);
136
+ if (!recipe) {
137
+ return stop(
138
+ "no run recipe: the branch declares no uiReview.run recipe (cannot boot the app)",
139
+ { kind: "run-recipe-missing", severity: MUST_FIX, message: "declare uiReview.run.command + readyUrl in .devloops" },
140
+ { worktreePath, depInstall },
141
+ );
142
+ }
143
+
144
+ // 4b. Resolve + validate the run recipe's cwd ONCE, here — the single source
145
+ // of cwd truth. The recipe's cwd is worktree-relative; a recipe that
146
+ // escapes the worktree (e.g. cwd "../..") would run migrate/boot in
147
+ // another checkout, so fail closed unless the resolved cwd stays inside
148
+ // the provisioned tree. The validated ABSOLUTE path (`runCwd`) is what the
149
+ // migrate/boot seams execute in — they consume it verbatim and never
150
+ // re-derive it, so the guarded path and the executed path cannot drift.
151
+ let runCwd = worktreePath;
152
+ if (recipe.cwd) {
153
+ const resolvedCwd = path.resolve(worktreePath, recipe.cwd);
154
+ const insideWorktree = resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + path.sep);
155
+ if (!insideWorktree) {
156
+ return stop(
157
+ "run recipe cwd escapes the provisioned worktree",
158
+ { kind: "cwd-traversal", severity: MUST_FIX, message: `uiReview.run.cwd (${recipe.cwd}) resolves outside the worktree: ${resolvedCwd}` },
159
+ { worktreePath, depInstall },
160
+ );
161
+ }
162
+ runCwd = resolvedCwd;
163
+ }
164
+
165
+ // 5. Dev-DB migrations. A destructive/blocked migration fails closed to a
166
+ // finding requiring explicit ack; nothing is applied until acknowledged.
167
+ let migrations = { pending: 0, applied: 0, destructive: [], detail: "no migrate recipe" };
168
+ if (recipe.migrate) {
169
+ const mig = await inspectMigrations({ worktreePath, recipe, runCwd });
170
+ record(`migration status: ${mig.pending.length} pending, ${mig.destructive.length} destructive (${mig.detail})`);
171
+ if (mig.destructive.length > 0 && !ackDestructiveMigration) {
172
+ return stop(
173
+ "destructive migration requires explicit acknowledgement",
174
+ {
175
+ kind: "destructive-migration",
176
+ severity: MUST_FIX,
177
+ requiresAck: true,
178
+ message: `${mig.destructive.length} destructive migration(s) blocked pending ack`,
179
+ destructive: mig.destructive,
180
+ },
181
+ { worktreePath, depInstall, migrations: { pending: mig.pending.length, applied: 0, destructive: mig.destructive, detail: mig.detail } },
182
+ );
183
+ }
184
+ if (mig.pending.length > 0) {
185
+ if (mig.destructive.length > 0) {
186
+ record(`destructive migration(s) acknowledged; applying ${mig.pending.length} pending migration(s)`);
187
+ }
188
+ const applied = await applyMigrations({ worktreePath, recipe, runCwd });
189
+ if (!applied.ok) {
190
+ return stop(
191
+ "migration apply failed",
192
+ { kind: "migration-apply", severity: MUST_FIX, message: applied.detail },
193
+ { worktreePath, depInstall, migrations: { pending: mig.pending.length, applied: 0, destructive: mig.destructive, detail: applied.detail } },
194
+ );
195
+ }
196
+ migrations = { pending: mig.pending.length, applied: applied.applied, destructive: mig.destructive, detail: applied.detail };
197
+ record(`migrations applied: ${applied.applied} (${applied.detail})`);
198
+ } else {
199
+ migrations = { pending: 0, applied: 0, destructive: mig.destructive, detail: "no pending migrations" };
200
+ record("no pending migrations");
201
+ }
202
+ } else {
203
+ record("migrations skipped: branch declares no migrate recipe");
204
+ }
205
+
206
+ // 6. Boot the app, then poll the readiness probe against an explicit deadline.
207
+ const boot = await bootApp({ worktreePath, recipe, runCwd });
208
+ record(`app booting (pid ${boot.pid ?? "n/a"}): ${boot.detail}`);
209
+
210
+ const timeoutMs = recipe.readyTimeoutMs;
211
+ const intervalMs = recipe.readyIntervalMs;
212
+ const deadline = now() + timeoutMs;
213
+ let ready = false;
214
+ let attempts = 0;
215
+ // Bounded poll (never a fixed sleep): probe, then wait one interval, until the
216
+ // deadline. The injected clock/delay make the timeout deterministic in tests.
217
+ while (now() <= deadline) {
218
+ attempts += 1;
219
+ // Fail closed: a probe that throws/rejects counts as "not ready yet" so the
220
+ // deadline path produces the clean boot-timeout stop instead of crashing.
221
+ // Bound each attempt by the remaining budget (raced against probeTimeout) so
222
+ // a slow/hung probe can't exceed the deadline or block forever — a per-attempt
223
+ // timeout is just "not ready yet", keeping boot-timeout deterministic.
224
+ let probeOk = false;
225
+ const timeout = probeTimeout(Math.max(0, deadline - now()));
226
+ try {
227
+ probeOk = await Promise.race([Promise.resolve(probe(recipe.readyUrl)), timeout]);
228
+ } catch {
229
+ probeOk = false;
230
+ } finally {
231
+ timeout.clear?.(); // cancel the pending per-attempt timer once the race is decided
232
+ }
233
+ if (probeOk) {
234
+ ready = true;
235
+ break;
236
+ }
237
+ if (now() + intervalMs > deadline) break; // would overshoot the deadline
238
+ await delay(intervalMs);
239
+ }
240
+
241
+ const bootResult = { pid: boot.pid ?? null, ready, attempts, readyUrl: recipe.readyUrl, timeoutMs, intervalMs };
242
+ if (!ready) {
243
+ record(`boot timeout: not ready after ${timeoutMs}ms (${attempts} probe attempt(s)) at ${recipe.readyUrl}`);
244
+ return stop(
245
+ `readiness probe timed out after ${timeoutMs}ms (${attempts} attempt(s) at ${recipe.readyUrl})`,
246
+ { kind: "boot-timeout", severity: MUST_FIX, message: `app never became ready within ${timeoutMs}ms` },
247
+ { worktreePath, depInstall, migrations, boot: bootResult },
248
+ );
249
+ }
250
+
251
+ record(`app ready after ${attempts} probe attempt(s) at ${recipe.readyUrl}`);
252
+ return {
253
+ ok: true,
254
+ stopped: false,
255
+ stopReason: null,
256
+ worktreePath,
257
+ created: wt.created,
258
+ reused: wt.reused,
259
+ depInstall,
260
+ migrations,
261
+ boot: bootResult,
262
+ ...base(),
263
+ };
264
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Report for the ui_review route (Stage 4, terminal reporting stage).
3
+ *
4
+ * Pure decision layer. Maps the Stage-3 ranked findings into:
5
+ * - a pending draft-review input (consumed by buildDraftReviewPayload — the
6
+ * shared poster's contract: {path,line,body,side:RIGHT} inline comments, no
7
+ * `event`), with anchorable findings inlined on their exact diff anchors and
8
+ * non-anchorable findings retained in the review body,
9
+ * - a severity->event policy (a confirmed user-facing server error maps to
10
+ * REQUEST_CHANGES ONLY when the caller authorizes submit; otherwise the
11
+ * review stays pending with the severity recorded — never auto-submit),
12
+ * - a self-contained, CSP-safe HTML artifact string (ranked findings + inline
13
+ * screenshot evidence), and
14
+ * - a harness-aware hosting directive (Claude Code -> a publishable Artifacts
15
+ * directive for the orchestrator; any other harness -> a GitHub-native
16
+ * gist-publish directive the CLI executes, which yields a real per-run URL
17
+ * or fails closed with a stated reason — never a fake link).
18
+ *
19
+ * All IO (reading the diagnose output + the screenshot bytes, writing the HTML,
20
+ * invoking the poster) lives in the thin CLI. This module reads only its inputs.
21
+ */
22
+
23
+ import { isClaudeHarness } from "./run-context.mjs";
24
+ import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
25
+
26
+ /** Findings past this cap are dropped from the artifact and the drop is logged. */
27
+ export const ARTIFACT_MAX_FINDINGS = 100;
28
+
29
+ /** A screenshot whose data URI exceeds this is omitted from the artifact (logged). */
30
+ export const ARTIFACT_MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
31
+
32
+ /** The drive kinds that count as a confirmed user-facing server error. Both are
33
+ * must-fix in the drive's classification: an error response the app returned and
34
+ * a server-log exception the request raised. */
35
+ const SERVER_ERROR_KINDS = new Set(["error-response", "server-log-exception"]);
36
+
37
+ function normalizeSha(value) {
38
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
39
+ }
40
+
41
+ /** A confirmed user-facing server error: a must-fix error-response / server-log
42
+ * exception. This is the single-source predicate the severity policy keys off. */
43
+ function isBlockingFinding(finding) {
44
+ return finding?.severity === "must-fix" && SERVER_ERROR_KINDS.has(finding?.kind);
45
+ }
46
+
47
+ /** Render a finding's reproduced exception as one line, falling back to its
48
+ * message when the drive captured no parseable exception. */
49
+ function reproducedLine(finding) {
50
+ const type = finding?.exception?.type;
51
+ const message = finding?.exception?.message;
52
+ if (typeof type === "string" && type.length > 0) {
53
+ return message ? `${type}: ${message}` : type;
54
+ }
55
+ return typeof finding?.message === "string" && finding.message.length > 0
56
+ ? finding.message
57
+ : "Captured failure (no exception detail)";
58
+ }
59
+
60
+ /** A short, kind-specific fix direction. Deliberately generic — the goal is to
61
+ * point the author at the changed line, not to prescribe the patch. */
62
+ function fixDirection(kind) {
63
+ switch (kind) {
64
+ case "error-response":
65
+ return "Fix the request path so it no longer returns an error response.";
66
+ case "server-log-exception":
67
+ case "page-error":
68
+ return "Guard the throwing code path; the exception above was raised here.";
69
+ case "request-failed":
70
+ return "The request from this change failed at the wire; verify the endpoint/URL.";
71
+ default:
72
+ return "Address the reproduced failure on this changed line.";
73
+ }
74
+ }
75
+
76
+ /** Inline-comment body for an anchorable finding: reproduced exception + fix direction. */
77
+ export function formatInlineBody(finding) {
78
+ return `Reproduced in the running app: ${reproducedLine(finding)}\nFix direction: ${fixDirection(finding?.kind)}`;
79
+ }
80
+
81
+ /**
82
+ * Severity->event policy (pure). A confirmed user-facing server error maps to
83
+ * REQUEST_CHANGES ONLY when submit is authorized; otherwise the review stays
84
+ * pending (event null) with the severity recorded. Never auto-submits.
85
+ *
86
+ * @param {{findings?: object[], submitAuthorized?: boolean}} [input]
87
+ * @returns {{event: "REQUEST_CHANGES"|null, blocking: boolean, submitAuthorized: boolean, severity: string}}
88
+ */
89
+ export function severityToEvent({ findings = [], submitAuthorized = false } = {}) {
90
+ const list = Array.isArray(findings) ? findings : [];
91
+ const blocking = list.some(isBlockingFinding);
92
+ const severity = blocking
93
+ ? "must-fix"
94
+ : (list.some((f) => f?.severity === "must-fix") ? "must-fix" : (list.length > 0 ? "note" : "none"));
95
+ return {
96
+ event: submitAuthorized && blocking ? "REQUEST_CHANGES" : null,
97
+ blocking,
98
+ submitAuthorized: Boolean(submitAuthorized),
99
+ severity,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Harness-aware hosting directive (pure). Claude Code -> a publishable Artifacts
105
+ * directive for the orchestrator to host (this module never calls an agent tool
106
+ * itself). Any other harness -> the portable GitHub-native default: publish the
107
+ * self-contained HTML as a secret GitHub Gist (a real per-run URL, zero repo
108
+ * pollution). This module decides the STRATEGY only; the CLI performs the gist
109
+ * publish IO and fails closed with a stated reason if it does not yield a URL.
110
+ * The self-contained HTML is produced regardless; only this link step differs.
111
+ *
112
+ * @param {{htmlPath: string, env?: Record<string,string|undefined>}} input
113
+ */
114
+ export function decideHosting({ htmlPath, env = process.env } = {}) {
115
+ if (isClaudeHarness(env)) {
116
+ return { hosting: "claude-artifact", publishable: true, htmlPath: htmlPath ?? null };
117
+ }
118
+ return { hosting: "github-gist", publishable: true, htmlPath: htmlPath ?? null };
119
+ }
120
+
121
+ /** One review-body line describing where the screenshot artifact lives. Links a
122
+ * real hosted URL when one exists; otherwise states the harness-aware status so
123
+ * the review never blocks on hosting. */
124
+ function artifactBodyLine({ hosting, hostedUrl }) {
125
+ if (typeof hostedUrl === "string" && hostedUrl.length > 0) {
126
+ // Only an ACTUALLY-published gist gets the source-rendered caveat: an explicit
127
+ // --hosted-url override leaves the strategy as github-gist but sets no gist, so
128
+ // a self-hosted (maybe live-rendered) URL falls through to the neutral line.
129
+ const rawUrl = hosting?.gist?.rawUrl;
130
+ if (rawUrl) {
131
+ // A gist renders HTML as source, not a live page; the raw file is the
132
+ // download/plain-text view — surface it so "open raw" is actually actionable.
133
+ return `Screenshot artifact (GitHub Gist — renders as source; open the raw file to view/download the HTML): ${hostedUrl} (raw: ${rawUrl})`;
134
+ }
135
+ return `Screenshot artifact: ${hostedUrl}`;
136
+ }
137
+ if (hosting?.hosting === "claude-artifact") {
138
+ return "Screenshot artifact prepared for Claude Artifacts hosting (published by the harness; see run output).";
139
+ }
140
+ const reason = hosting?.reason ? ` (${hosting.reason})` : "";
141
+ return `Screenshot artifact is unhosted this stage${reason}. Findings are included below.`;
142
+ }
143
+
144
+ /** A finding is inlineable ONLY with a complete anchor buildDraftReviewPayload
145
+ * will keep: a non-blank path and a line > 0 (exactly the payload's inline
146
+ * filter), side RIGHT. A finding flagged anchorable but carrying a
147
+ * malformed/incomplete anchor (blank path, line <= 0) falls back to the body
148
+ * (summaryFindings) instead of being dropped by the payload's inline filter. */
149
+ function hasValidAnchor(finding) {
150
+ const a = finding?.anchor;
151
+ return Boolean(
152
+ finding?.anchorable &&
153
+ a &&
154
+ typeof a.path === "string" && a.path.trim().length > 0 &&
155
+ typeof a.line === "number" && a.line > 0 &&
156
+ a.side === "RIGHT"
157
+ );
158
+ }
159
+
160
+ /** Body line for a non-anchorable finding: it is kept, never dropped. */
161
+ function nonAnchorableBodyMessage(finding) {
162
+ const reason = finding?.nonAnchorableReason ? ` — not inlined: ${finding.nonAnchorableReason}` : "";
163
+ return `${reproducedLine(finding)}${reason}`;
164
+ }
165
+
166
+ /**
167
+ * Map Stage-3 findings + hosting status into the merged-result input that the
168
+ * shared buildDraftReviewPayload consumes. Anchorable findings become inline
169
+ * comments on their exact {path,line,side:RIGHT} anchors; the artifact line and
170
+ * every non-anchorable finding are retained as summary (body) findings.
171
+ *
172
+ * @param {{findings?: object[], headSha?: string|null, hosting?: object, hostedUrl?: string}} input
173
+ */
174
+ export function buildReviewInput({ findings = [], headSha = null, hosting = null, hostedUrl = null } = {}) {
175
+ const list = Array.isArray(findings) ? findings : [];
176
+ const anchorable = list.filter(hasValidAnchor);
177
+ const nonAnchorable = list.filter((f) => !hasValidAnchor(f));
178
+
179
+ // Untrusted target-app text (exception type/message, log lines, nonAnchorableReason)
180
+ // flows into these bodies. Sanitize copilot-summon tokens before they enter the
181
+ // payload buildDraftReviewPayload posts verbatim — every sibling posting path does.
182
+ const inlineComments = anchorable.map((f) => ({
183
+ path: f.anchor.path,
184
+ line: f.anchor.line,
185
+ message: sanitizeCopilotSummonTokens(formatInlineBody(f)),
186
+ severity: f.severity ?? "note",
187
+ }));
188
+
189
+ const summaryFindings = [
190
+ { message: sanitizeCopilotSummonTokens(artifactBodyLine({ hosting, hostedUrl })), severity: "note" },
191
+ ...nonAnchorable.map((f) => ({ message: sanitizeCopilotSummonTokens(nonAnchorableBodyMessage(f)), severity: f.severity ?? "note" })),
192
+ ];
193
+
194
+ const blocking = list.some(isBlockingFinding);
195
+ const verdict = list.length === 0 ? "APPROVE" : (blocking ? "REQUEST_CHANGES" : "COMMENT");
196
+
197
+ return {
198
+ headSha: normalizeSha(headSha),
199
+ verdict,
200
+ inlineComments,
201
+ summaryFindings,
202
+ totalFindings: list.length,
203
+ runsMerged: 0,
204
+ };
205
+ }
206
+
207
+ function escapeHtml(value) {
208
+ return String(value).replace(/[&<>"']/gu, (c) => (
209
+ { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
210
+ ));
211
+ }
212
+
213
+ const ARTIFACT_STYLE = [
214
+ "body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1a1a1a;background:#fafafa}",
215
+ "h1{font-size:20px;margin:0 0 4px}",
216
+ ".meta{color:#666;margin-bottom:20px}",
217
+ ".finding{border:1px solid #ddd;border-radius:6px;padding:12px 16px;margin:0 0 12px;background:#fff}",
218
+ ".finding.blocking{border-left:4px solid #c0392b}",
219
+ ".finding.note{border-left:4px solid #999}",
220
+ ".sev{font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}",
221
+ ".anchor{color:#2c3e50;font-family:ui-monospace,monospace;font-size:12px}",
222
+ ".exc{font-family:ui-monospace,monospace;white-space:pre-wrap;margin:6px 0}",
223
+ ".reason{color:#c0392b;font-size:12px}",
224
+ "img{max-width:100%;border:1px solid #ddd;border-radius:4px;margin-top:12px}",
225
+ ].join("");
226
+
227
+ const ARTIFACT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
228
+
229
+ /**
230
+ * Build the self-contained, CSP-safe HTML artifact (ranked findings + inline
231
+ * screenshot evidence). Fully inlined: no external scripts/styles/fonts/images.
232
+ * Bounded caps (findings past ARTIFACT_MAX_FINDINGS, an oversized screenshot)
233
+ * are applied here and returned in `caps` so the CLI can log them — never a
234
+ * silent truncation.
235
+ *
236
+ * @param {{findings?: object[], counts?: object, pr?: object, screenshot?: {path:string,dataUri:string}|null, generatedAt?: string}} input
237
+ * @returns {{html: string, caps: string[]}}
238
+ */
239
+ export function buildArtifactHtml({ findings = [], counts = {}, pr = {}, screenshot = null, generatedAt } = {}) {
240
+ const caps = [];
241
+ const list = Array.isArray(findings) ? findings : [];
242
+
243
+ let shown = list;
244
+ if (list.length > ARTIFACT_MAX_FINDINGS) {
245
+ shown = list.slice(0, ARTIFACT_MAX_FINDINGS);
246
+ caps.push(`artifact: findings truncated to ${ARTIFACT_MAX_FINDINGS} of ${list.length} (${list.length - ARTIFACT_MAX_FINDINGS} not rendered)`);
247
+ }
248
+
249
+ let screenshotHtml = "";
250
+ if (screenshot && typeof screenshot.dataUri === "string") {
251
+ if (screenshot.dataUri.length > ARTIFACT_MAX_SCREENSHOT_BYTES) {
252
+ caps.push(`artifact: screenshot omitted (${screenshot.dataUri.length} bytes > ${ARTIFACT_MAX_SCREENSHOT_BYTES} cap): ${screenshot.path ?? "unknown"}`);
253
+ } else {
254
+ screenshotHtml = `<h2>Reproduced evidence</h2><img alt="reproduced state" src="${escapeHtml(screenshot.dataUri)}" />`;
255
+ }
256
+ }
257
+
258
+ const findingsHtml = shown.map((f) => {
259
+ const blocking = isBlockingFinding(f);
260
+ const cls = blocking ? "blocking" : "note";
261
+ const anchor = f?.anchor
262
+ ? `<div class="anchor">${escapeHtml(f.anchor.path)}:${escapeHtml(f.anchor.line)} (${escapeHtml(f.anchor.side)})</div>`
263
+ : `<div class="reason">not inlined: ${escapeHtml(f?.nonAnchorableReason ?? "no anchor")}</div>`;
264
+ return [
265
+ `<div class="finding ${cls}">`,
266
+ `<div class="sev">${escapeHtml(f?.severity ?? "note")} · ${escapeHtml(f?.kind ?? "finding")}</div>`,
267
+ `<div class="exc">${escapeHtml(reproducedLine(f))}</div>`,
268
+ anchor,
269
+ "</div>",
270
+ ].join("");
271
+ }).join("");
272
+
273
+ const total = Number.isFinite(counts?.total) ? counts.total : list.length;
274
+ const html = [
275
+ "<!doctype html>",
276
+ '<html lang="en"><head><meta charset="utf-8" />',
277
+ `<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}" />`,
278
+ "<title>UI review findings</title>",
279
+ `<style>${ARTIFACT_STYLE}</style>`,
280
+ "</head><body>",
281
+ `<h1>UI review findings — PR #${escapeHtml(pr?.number ?? "?")}</h1>`,
282
+ `<div class="meta">head ${escapeHtml(pr?.headSha ?? "?")} · ${escapeHtml(total)} finding(s) · ${escapeHtml(counts?.anchorable ?? 0)} anchorable · generated ${escapeHtml(generatedAt ?? "")}</div>`,
283
+ findingsHtml,
284
+ screenshotHtml,
285
+ "</body></html>",
286
+ ].join("");
287
+
288
+ return { html, caps };
289
+ }