@dev-loops/core 0.8.0 → 0.9.0
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/package.json +6 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +348 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +287 -0
- package/src/loop/ui-review-teardown.mjs +250 -0
|
@@ -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,287 @@
|
|
|
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 -> fail closed with a
|
|
16
|
+
* stated reason and a follow-up marker — no hosted link this stage).
|
|
17
|
+
*
|
|
18
|
+
* All IO (reading the diagnose output + the screenshot bytes, writing the HTML,
|
|
19
|
+
* invoking the poster) lives in the thin CLI. This module reads only its inputs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isClaudeHarness } from "./run-context.mjs";
|
|
23
|
+
import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
|
|
24
|
+
|
|
25
|
+
/** Follow-up marker for the descoped GitHub-native hosting fallback. */
|
|
26
|
+
export const HOSTING_FOLLOWUP = "#1285";
|
|
27
|
+
|
|
28
|
+
/** Findings past this cap are dropped from the artifact and the drop is logged. */
|
|
29
|
+
export const ARTIFACT_MAX_FINDINGS = 100;
|
|
30
|
+
|
|
31
|
+
/** A screenshot whose data URI exceeds this is omitted from the artifact (logged). */
|
|
32
|
+
export const ARTIFACT_MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** The drive kinds that count as a confirmed user-facing server error. Both are
|
|
35
|
+
* must-fix in the drive's classification: an error response the app returned and
|
|
36
|
+
* a server-log exception the request raised. */
|
|
37
|
+
const SERVER_ERROR_KINDS = new Set(["error-response", "server-log-exception"]);
|
|
38
|
+
|
|
39
|
+
function normalizeSha(value) {
|
|
40
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A confirmed user-facing server error: a must-fix error-response / server-log
|
|
44
|
+
* exception. This is the single-source predicate the severity policy keys off. */
|
|
45
|
+
function isBlockingFinding(finding) {
|
|
46
|
+
return finding?.severity === "must-fix" && SERVER_ERROR_KINDS.has(finding?.kind);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render a finding's reproduced exception as one line, falling back to its
|
|
50
|
+
* message when the drive captured no parseable exception. */
|
|
51
|
+
function reproducedLine(finding) {
|
|
52
|
+
const type = finding?.exception?.type;
|
|
53
|
+
const message = finding?.exception?.message;
|
|
54
|
+
if (typeof type === "string" && type.length > 0) {
|
|
55
|
+
return message ? `${type}: ${message}` : type;
|
|
56
|
+
}
|
|
57
|
+
return typeof finding?.message === "string" && finding.message.length > 0
|
|
58
|
+
? finding.message
|
|
59
|
+
: "Captured failure (no exception detail)";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A short, kind-specific fix direction. Deliberately generic — the goal is to
|
|
63
|
+
* point the author at the changed line, not to prescribe the patch. */
|
|
64
|
+
function fixDirection(kind) {
|
|
65
|
+
switch (kind) {
|
|
66
|
+
case "error-response":
|
|
67
|
+
return "Fix the request path so it no longer returns an error response.";
|
|
68
|
+
case "server-log-exception":
|
|
69
|
+
case "page-error":
|
|
70
|
+
return "Guard the throwing code path; the exception above was raised here.";
|
|
71
|
+
case "request-failed":
|
|
72
|
+
return "The request from this change failed at the wire; verify the endpoint/URL.";
|
|
73
|
+
default:
|
|
74
|
+
return "Address the reproduced failure on this changed line.";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Inline-comment body for an anchorable finding: reproduced exception + fix direction. */
|
|
79
|
+
export function formatInlineBody(finding) {
|
|
80
|
+
return `Reproduced in the running app: ${reproducedLine(finding)}\nFix direction: ${fixDirection(finding?.kind)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Severity->event policy (pure). A confirmed user-facing server error maps to
|
|
85
|
+
* REQUEST_CHANGES ONLY when submit is authorized; otherwise the review stays
|
|
86
|
+
* pending (event null) with the severity recorded. Never auto-submits.
|
|
87
|
+
*
|
|
88
|
+
* @param {{findings?: object[], submitAuthorized?: boolean}} [input]
|
|
89
|
+
* @returns {{event: "REQUEST_CHANGES"|null, blocking: boolean, submitAuthorized: boolean, severity: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function severityToEvent({ findings = [], submitAuthorized = false } = {}) {
|
|
92
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
93
|
+
const blocking = list.some(isBlockingFinding);
|
|
94
|
+
const severity = blocking
|
|
95
|
+
? "must-fix"
|
|
96
|
+
: (list.some((f) => f?.severity === "must-fix") ? "must-fix" : (list.length > 0 ? "note" : "none"));
|
|
97
|
+
return {
|
|
98
|
+
event: submitAuthorized && blocking ? "REQUEST_CHANGES" : null,
|
|
99
|
+
blocking,
|
|
100
|
+
submitAuthorized: Boolean(submitAuthorized),
|
|
101
|
+
severity,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Harness-aware hosting directive (pure). Claude Code -> a publishable Artifacts
|
|
107
|
+
* directive for the orchestrator to host (this module never calls an agent tool
|
|
108
|
+
* itself). Any other harness / Artifacts unavailable -> fail closed with a
|
|
109
|
+
* stated reason and the follow-up marker. The self-contained HTML is produced
|
|
110
|
+
* regardless; only this link step is harness-aware.
|
|
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 {
|
|
119
|
+
hosting: "unavailable",
|
|
120
|
+
publishable: false,
|
|
121
|
+
htmlPath: htmlPath ?? null,
|
|
122
|
+
reason: "no hosted-artifact publisher on this harness; GitHub-native fallback is deferred",
|
|
123
|
+
followup: HOSTING_FOLLOWUP,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One review-body line describing where the screenshot artifact lives. Links a
|
|
128
|
+
* real hosted URL when one exists; otherwise states the harness-aware status so
|
|
129
|
+
* the review never blocks on hosting. */
|
|
130
|
+
function artifactBodyLine({ hosting, hostedUrl }) {
|
|
131
|
+
if (typeof hostedUrl === "string" && hostedUrl.length > 0) {
|
|
132
|
+
return `Screenshot artifact: ${hostedUrl}`;
|
|
133
|
+
}
|
|
134
|
+
if (hosting?.hosting === "claude-artifact") {
|
|
135
|
+
return "Screenshot artifact prepared for Claude Artifacts hosting (published by the harness; see run output).";
|
|
136
|
+
}
|
|
137
|
+
const reason = hosting?.reason ? ` (${hosting.reason})` : "";
|
|
138
|
+
const followup = hosting?.followup ? ` [follow-up ${hosting.followup}]` : "";
|
|
139
|
+
return `Screenshot artifact is unhosted this stage${reason}${followup}. Findings are included below.`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A finding is inlineable ONLY with a complete anchor buildDraftReviewPayload
|
|
143
|
+
* will keep: a non-blank path and a line > 0 (exactly the payload's inline
|
|
144
|
+
* filter), side RIGHT. A finding flagged anchorable but carrying a
|
|
145
|
+
* malformed/incomplete anchor (blank path, line <= 0) falls back to the body
|
|
146
|
+
* (summaryFindings) instead of being dropped by the payload's inline filter. */
|
|
147
|
+
function hasValidAnchor(finding) {
|
|
148
|
+
const a = finding?.anchor;
|
|
149
|
+
return Boolean(
|
|
150
|
+
finding?.anchorable &&
|
|
151
|
+
a &&
|
|
152
|
+
typeof a.path === "string" && a.path.trim().length > 0 &&
|
|
153
|
+
typeof a.line === "number" && a.line > 0 &&
|
|
154
|
+
a.side === "RIGHT"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Body line for a non-anchorable finding: it is kept, never dropped. */
|
|
159
|
+
function nonAnchorableBodyMessage(finding) {
|
|
160
|
+
const reason = finding?.nonAnchorableReason ? ` — not inlined: ${finding.nonAnchorableReason}` : "";
|
|
161
|
+
return `${reproducedLine(finding)}${reason}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Map Stage-3 findings + hosting status into the merged-result input that the
|
|
166
|
+
* shared buildDraftReviewPayload consumes. Anchorable findings become inline
|
|
167
|
+
* comments on their exact {path,line,side:RIGHT} anchors; the artifact line and
|
|
168
|
+
* every non-anchorable finding are retained as summary (body) findings.
|
|
169
|
+
*
|
|
170
|
+
* @param {{findings?: object[], headSha?: string|null, hosting?: object, hostedUrl?: string}} input
|
|
171
|
+
*/
|
|
172
|
+
export function buildReviewInput({ findings = [], headSha = null, hosting = null, hostedUrl = null } = {}) {
|
|
173
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
174
|
+
const anchorable = list.filter(hasValidAnchor);
|
|
175
|
+
const nonAnchorable = list.filter((f) => !hasValidAnchor(f));
|
|
176
|
+
|
|
177
|
+
// Untrusted target-app text (exception type/message, log lines, nonAnchorableReason)
|
|
178
|
+
// flows into these bodies. Sanitize copilot-summon tokens before they enter the
|
|
179
|
+
// payload buildDraftReviewPayload posts verbatim — every sibling posting path does.
|
|
180
|
+
const inlineComments = anchorable.map((f) => ({
|
|
181
|
+
path: f.anchor.path,
|
|
182
|
+
line: f.anchor.line,
|
|
183
|
+
message: sanitizeCopilotSummonTokens(formatInlineBody(f)),
|
|
184
|
+
severity: f.severity ?? "note",
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const summaryFindings = [
|
|
188
|
+
{ message: sanitizeCopilotSummonTokens(artifactBodyLine({ hosting, hostedUrl })), severity: "note" },
|
|
189
|
+
...nonAnchorable.map((f) => ({ message: sanitizeCopilotSummonTokens(nonAnchorableBodyMessage(f)), severity: f.severity ?? "note" })),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const blocking = list.some(isBlockingFinding);
|
|
193
|
+
const verdict = list.length === 0 ? "APPROVE" : (blocking ? "REQUEST_CHANGES" : "COMMENT");
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
headSha: normalizeSha(headSha),
|
|
197
|
+
verdict,
|
|
198
|
+
inlineComments,
|
|
199
|
+
summaryFindings,
|
|
200
|
+
totalFindings: list.length,
|
|
201
|
+
runsMerged: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function escapeHtml(value) {
|
|
206
|
+
return String(value).replace(/[&<>"']/gu, (c) => (
|
|
207
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
208
|
+
));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const ARTIFACT_STYLE = [
|
|
212
|
+
"body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1a1a1a;background:#fafafa}",
|
|
213
|
+
"h1{font-size:20px;margin:0 0 4px}",
|
|
214
|
+
".meta{color:#666;margin-bottom:20px}",
|
|
215
|
+
".finding{border:1px solid #ddd;border-radius:6px;padding:12px 16px;margin:0 0 12px;background:#fff}",
|
|
216
|
+
".finding.blocking{border-left:4px solid #c0392b}",
|
|
217
|
+
".finding.note{border-left:4px solid #999}",
|
|
218
|
+
".sev{font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.05em}",
|
|
219
|
+
".anchor{color:#2c3e50;font-family:ui-monospace,monospace;font-size:12px}",
|
|
220
|
+
".exc{font-family:ui-monospace,monospace;white-space:pre-wrap;margin:6px 0}",
|
|
221
|
+
".reason{color:#c0392b;font-size:12px}",
|
|
222
|
+
"img{max-width:100%;border:1px solid #ddd;border-radius:4px;margin-top:12px}",
|
|
223
|
+
].join("");
|
|
224
|
+
|
|
225
|
+
const ARTIFACT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the self-contained, CSP-safe HTML artifact (ranked findings + inline
|
|
229
|
+
* screenshot evidence). Fully inlined: no external scripts/styles/fonts/images.
|
|
230
|
+
* Bounded caps (findings past ARTIFACT_MAX_FINDINGS, an oversized screenshot)
|
|
231
|
+
* are applied here and returned in `caps` so the CLI can log them — never a
|
|
232
|
+
* silent truncation.
|
|
233
|
+
*
|
|
234
|
+
* @param {{findings?: object[], counts?: object, pr?: object, screenshot?: {path:string,dataUri:string}|null, generatedAt?: string}} input
|
|
235
|
+
* @returns {{html: string, caps: string[]}}
|
|
236
|
+
*/
|
|
237
|
+
export function buildArtifactHtml({ findings = [], counts = {}, pr = {}, screenshot = null, generatedAt } = {}) {
|
|
238
|
+
const caps = [];
|
|
239
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
240
|
+
|
|
241
|
+
let shown = list;
|
|
242
|
+
if (list.length > ARTIFACT_MAX_FINDINGS) {
|
|
243
|
+
shown = list.slice(0, ARTIFACT_MAX_FINDINGS);
|
|
244
|
+
caps.push(`artifact: findings truncated to ${ARTIFACT_MAX_FINDINGS} of ${list.length} (${list.length - ARTIFACT_MAX_FINDINGS} not rendered)`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let screenshotHtml = "";
|
|
248
|
+
if (screenshot && typeof screenshot.dataUri === "string") {
|
|
249
|
+
if (screenshot.dataUri.length > ARTIFACT_MAX_SCREENSHOT_BYTES) {
|
|
250
|
+
caps.push(`artifact: screenshot omitted (${screenshot.dataUri.length} bytes > ${ARTIFACT_MAX_SCREENSHOT_BYTES} cap): ${screenshot.path ?? "unknown"}`);
|
|
251
|
+
} else {
|
|
252
|
+
screenshotHtml = `<h2>Reproduced evidence</h2><img alt="reproduced state" src="${escapeHtml(screenshot.dataUri)}" />`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const findingsHtml = shown.map((f) => {
|
|
257
|
+
const blocking = isBlockingFinding(f);
|
|
258
|
+
const cls = blocking ? "blocking" : "note";
|
|
259
|
+
const anchor = f?.anchor
|
|
260
|
+
? `<div class="anchor">${escapeHtml(f.anchor.path)}:${escapeHtml(f.anchor.line)} (${escapeHtml(f.anchor.side)})</div>`
|
|
261
|
+
: `<div class="reason">not inlined: ${escapeHtml(f?.nonAnchorableReason ?? "no anchor")}</div>`;
|
|
262
|
+
return [
|
|
263
|
+
`<div class="finding ${cls}">`,
|
|
264
|
+
`<div class="sev">${escapeHtml(f?.severity ?? "note")} · ${escapeHtml(f?.kind ?? "finding")}</div>`,
|
|
265
|
+
`<div class="exc">${escapeHtml(reproducedLine(f))}</div>`,
|
|
266
|
+
anchor,
|
|
267
|
+
"</div>",
|
|
268
|
+
].join("");
|
|
269
|
+
}).join("");
|
|
270
|
+
|
|
271
|
+
const total = Number.isFinite(counts?.total) ? counts.total : list.length;
|
|
272
|
+
const html = [
|
|
273
|
+
"<!doctype html>",
|
|
274
|
+
'<html lang="en"><head><meta charset="utf-8" />',
|
|
275
|
+
`<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}" />`,
|
|
276
|
+
"<title>UI review findings</title>",
|
|
277
|
+
`<style>${ARTIFACT_STYLE}</style>`,
|
|
278
|
+
"</head><body>",
|
|
279
|
+
`<h1>UI review findings — PR #${escapeHtml(pr?.number ?? "?")}</h1>`,
|
|
280
|
+
`<div class="meta">head ${escapeHtml(pr?.headSha ?? "?")} · ${escapeHtml(total)} finding(s) · ${escapeHtml(counts?.anchorable ?? 0)} anchorable · generated ${escapeHtml(generatedAt ?? "")}</div>`,
|
|
281
|
+
findingsHtml,
|
|
282
|
+
screenshotHtml,
|
|
283
|
+
"</body></html>",
|
|
284
|
+
].join("");
|
|
285
|
+
|
|
286
|
+
return { html, caps };
|
|
287
|
+
}
|