@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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Diagnose + anchor for the ui_review route (Stage 3).
3
+ *
4
+ * Consumes the structured captured-failures feed from the drive stage and maps
5
+ * each failure — exception + JS stack or server-log traceback context — to a
6
+ * source location (the top in-repo stack frame), then to a diff line on the PR
7
+ * head so the poster stage can anchor an inline comment on a real changed line.
8
+ *
9
+ * This module is PURE: the diff text and the drive result are inputs. The thin
10
+ * CLI wires the real IO (loop info for PR state + the PR diff fetch).
11
+ *
12
+ * Contract: a failure is NEVER silently dropped. One that has no source
13
+ * location, whose file is not in the diff, whose line is not on a changed diff
14
+ * line, or whose file maps ambiguously to more than one changed file, is
15
+ * RETAINED as a finding flagged non-anchorable (with a stated reason) so the
16
+ * poster body-attaches it instead of inlining.
17
+ *
18
+ * Out of scope (later stages): review posting, artifact publishing, auto-fixing.
19
+ */
20
+
21
+ const RIGHT = "RIGHT";
22
+
23
+ /** Severity ordering for the ranked findings list. Unknown severities sort last
24
+ * but before nothing — a finding is never dropped for an unrecognized severity. */
25
+ const SEVERITY_RANK = Object.freeze({ "must-fix": 0, note: 1 });
26
+ const severityRank = (s) => (s in SEVERITY_RANK ? SEVERITY_RANK[s] : 2);
27
+
28
+ /** Frames whose file matches a vendor/framework/runtime marker are NOT in-repo:
29
+ * the diagnosis anchors the change's own code, not a dependency's internals. The
30
+ * default is deliberately conservative — a project with an unusual layout injects
31
+ * its own predicate rather than loosening this shared default. */
32
+ const VENDOR_FRAME = /node_modules|[/\\]gems[/\\]|[/\\]vendor[/\\]|\bwebpack:\/\/|^node:|[/\\]ruby[/\\]|[/\\]dist-packages[/\\]/u;
33
+
34
+ /** Default in-repo predicate: a non-empty file path that is not a vendor frame. */
35
+ export function isInRepoFrame(file) {
36
+ return typeof file === "string" && file.length > 0 && !VENDOR_FRAME.test(file);
37
+ }
38
+
39
+ /** Extract the exception type + message from stack/traceback text. Matches the
40
+ * first exception token and the text that follows it: a `SomethingError`/
41
+ * `SomethingException`-suffixed name (JS `TypeError: msg`, Ruby `NoMethodError
42
+ * (msg)`, Python/dotted `django.core.exceptions.ValidationError: msg`) OR a Ruby
43
+ * `::`-namespaced constant like `ActiveRecord::RecordNotFound` /
44
+ * `Mongoid::Errors::DocumentNotFound`, captured whole. The `::` alternative
45
+ * requires at least one namespace segment so it signals a class, not an
46
+ * arbitrary identifier. Returns nulls when no recognizable exception name is
47
+ * present. */
48
+ export function parseException(text = "") {
49
+ const m = String(text).match(/([A-Z]\w*(?:::[A-Z]\w*)+|[A-Za-z_][\w.]*(?:Error|Exception))\b[:\s(]*([^\n)]*)/u);
50
+ if (!m) return { type: null, message: null };
51
+ const message = m[2].trim();
52
+ return { type: m[1], message: message.length > 0 ? message : null };
53
+ }
54
+
55
+ /** Parse a single stack/traceback line into `{file, line}` or null. Tries the
56
+ * three shapes the drive feed can carry: Python `File "path", line N`, JS
57
+ * `at ... (file:line:col)`, then a generic `path.ext:line` (Ruby, and JS frames
58
+ * without an `at` prefix). */
59
+ function extractFrameFromLine(line) {
60
+ let m = line.match(/File "([^"]+)", line (\d+)/u);
61
+ if (m) return { file: m[1], line: Number(m[2]) };
62
+ // The file capture excludes only whitespace/parens (not `:`) and is lazy, so a
63
+ // served URL (`http://host:3000/assets/x.js`) is captured whole up to the
64
+ // trailing `:line:col` — normalizeFrameFile then strips the scheme/authority.
65
+ m = line.match(/\bat\s+(?:.*\()?([^\s()]+?):(\d+)(?::\d+)?\)?\s*$/u);
66
+ if (m) return { file: m[1], line: Number(m[2]) };
67
+ m = line.match(/([^\s():]+\.[A-Za-z0-9_]+):(\d+)\b/u);
68
+ if (m) return { file: m[1], line: Number(m[2]) };
69
+ return null;
70
+ }
71
+
72
+ /** Extract all frames from multi-line stack/traceback text, preserving order. */
73
+ export function extractFrames(text = "") {
74
+ const frames = [];
75
+ for (const line of String(text).split("\n")) {
76
+ const frame = extractFrameFromLine(line);
77
+ if (frame) frames.push(frame);
78
+ }
79
+ return frames;
80
+ }
81
+
82
+ /** The top in-repo frame drives the anchor: the first frame (topmost, closest to
83
+ * the throw) whose file passes the in-repo predicate. We do NOT search deeper for
84
+ * a frame that happens to be in the diff — anchoring a lower frame would point at
85
+ * a caller, not the failing line. A deeper frame that is in-repo but not in the
86
+ * diff is handled downstream as non-anchorable, never by guessing past the top. */
87
+ export function topInRepoFrame(frames) {
88
+ return frames.find((f) => isInRepoFrame(f.file)) ?? null;
89
+ }
90
+
91
+ /**
92
+ * Parse a unified diff into a map of changed file -> set of ADDED head line
93
+ * numbers (the RIGHT side). EVERY changed file is a key (registered from its
94
+ * `diff --git`/`+++ ` header) so a file that is changed but adds no anchorable
95
+ * line (deletion-only, binary, or mode-only) still reports as changed with an
96
+ * empty set — distinct from a file the PR never touched. Only added (`+`) lines
97
+ * are anchor targets: an inline comment on an added line points at code the PR
98
+ * introduced. Unchanged context lines advance the head counter but are not
99
+ * anchor targets — a defect on an unchanged line is body-attached, not falsely
100
+ * pinned to "changed" code.
101
+ *
102
+ * @param {string} diffOutput - raw `gh pr diff` / `git diff` unified output.
103
+ * @returns {Map<string, Set<number>>}
104
+ */
105
+ export function parseDiffAnchors(diffOutput = "") {
106
+ const map = new Map();
107
+ const register = (p) => {
108
+ if (p !== null && !map.has(p)) map.set(p, new Set());
109
+ };
110
+ let currentPath = null;
111
+ let newLine = 0;
112
+ let inHunk = false;
113
+ for (const line of String(diffOutput).split("\n")) {
114
+ if (line.startsWith("diff --git")) {
115
+ // The only hunk terminator: a bare `diff --git` can never be hunk content
116
+ // (content lines always carry a `+`/`-`/space prefix), so it always resets.
117
+ currentPath = null;
118
+ inHunk = false;
119
+ // Register the RIGHT-side path so binary/mode-only changes (which carry no
120
+ // `+++ ` header) still count as changed files.
121
+ const m = line.match(/^diff --git a\/.+ b\/(.+)$/u);
122
+ if (m) register(m[1]);
123
+ continue;
124
+ }
125
+ // File headers appear only before the first hunk. Inside a hunk a line
126
+ // beginning `+++ `/`--- ` is content (an added `++ x` / a deleted `-- x`),
127
+ // so it must fall through to the `+`/`-` content handling below, not be
128
+ // misread as a header that rebinds the path or drops the rest of the hunk.
129
+ if (!inHunk && line.startsWith("+++ ")) {
130
+ const p = line.slice(4).trim();
131
+ currentPath = p === "/dev/null" ? null : p.replace(/^b\//u, "");
132
+ // Register even deletion-only files (they keep a `+++ b/path` header but
133
+ // add no line) so they report as changed rather than not-among-changed.
134
+ register(currentPath);
135
+ continue;
136
+ }
137
+ if (!inHunk && line.startsWith("--- ")) {
138
+ continue;
139
+ }
140
+ if (line.startsWith("@@")) {
141
+ const m = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
142
+ newLine = m ? Number(m[1]) : 0;
143
+ inHunk = Boolean(m);
144
+ continue;
145
+ }
146
+ if (!inHunk || currentPath === null) continue;
147
+ if (line.startsWith("+")) {
148
+ if (!map.has(currentPath)) map.set(currentPath, new Set());
149
+ map.get(currentPath).add(newLine);
150
+ newLine += 1;
151
+ } else if (line.startsWith("-")) {
152
+ // Deleted line: present only on the old side, so it does not advance the head counter.
153
+ } else if (line.startsWith("\\")) {
154
+ // "": a marker, not a content line.
155
+ } else {
156
+ // Context line: on both sides, so it advances the head counter but is not an anchor target.
157
+ newLine += 1;
158
+ }
159
+ }
160
+ return map;
161
+ }
162
+
163
+ /** Normalize a stack-frame file to a repo-relative-comparable form: strip a URL
164
+ * scheme+authority (`http://host/assets/x.js` -> `/assets/x.js`) and any
165
+ * query/hash, then fold Windows `\` separators to `/`, so an absolute, served,
166
+ * or Windows-style path can be suffix-matched to a (forward-slash) diff path. */
167
+ function normalizeFrameFile(file) {
168
+ let s = String(file);
169
+ const scheme = s.match(/^[a-z][a-z0-9+.\-]*:\/\/[^/]*(\/.*)$/iu);
170
+ if (scheme) s = scheme[1];
171
+ return s.split(/[?#]/u)[0].replace(/\\/gu, "/");
172
+ }
173
+
174
+ /** The source-file -> changed-file mapping is the fragile axis (bundlers, moved
175
+ * code, served paths). Match a frame file to a diff path by exact match or path
176
+ * suffix. Return every match so an ambiguous mapping (more than one changed file
177
+ * is a suffix of the frame path) is flagged rather than guessed. */
178
+ function matchDiffPaths(frameFile, anchorPaths) {
179
+ const nf = normalizeFrameFile(frameFile);
180
+ return anchorPaths.filter((p) => nf === p || nf.endsWith(`/${p}`));
181
+ }
182
+
183
+ /**
184
+ * Map one Stage-2 failure to a finding: parse its exception + source location,
185
+ * resolve an anchor on the diff, or retain it flagged non-anchorable.
186
+ *
187
+ * @param {object} failure - a `classifyFailures` entry `{kind, severity, message, ...}`.
188
+ * @param {Map<string,Set<number>>} anchorsByPath
189
+ * @param {object|null} evidence - the reproduced-evidence reference to attach.
190
+ */
191
+ function diagnoseOne(failure, anchorsByPath, evidence) {
192
+ // page-error carries `stack`; server-log-exception carries `context`; the
193
+ // wire-level failures (error/request) carry neither -> no source location.
194
+ const sourceText =
195
+ failure.kind === "page-error" ? failure.stack :
196
+ failure.kind === "server-log-exception" ? failure.context :
197
+ "";
198
+ const exception = parseException(sourceText || failure.message || "");
199
+ const finding = {
200
+ severity: failure.severity ?? null,
201
+ kind: failure.kind,
202
+ message: failure.message ?? null,
203
+ exception,
204
+ source: null,
205
+ anchor: null,
206
+ anchorable: false,
207
+ nonAnchorableReason: null,
208
+ evidence,
209
+ };
210
+
211
+ const frame = topInRepoFrame(extractFrames(sourceText || ""));
212
+ if (!frame) {
213
+ finding.nonAnchorableReason = "no source location (no in-repo stack frame in the captured failure)";
214
+ return finding;
215
+ }
216
+ finding.source = frame;
217
+
218
+ const matches = matchDiffPaths(frame.file, [...anchorsByPath.keys()]);
219
+ if (matches.length === 0) {
220
+ finding.nonAnchorableReason = "source file is not among the PR's changed files";
221
+ return finding;
222
+ }
223
+ if (matches.length > 1) {
224
+ finding.nonAnchorableReason = `ambiguous: source file maps to more than one changed file (${matches.join(", ")})`;
225
+ return finding;
226
+ }
227
+ const path = matches[0];
228
+ if (!anchorsByPath.get(path).has(frame.line)) {
229
+ finding.nonAnchorableReason = "source line is not on an added diff line";
230
+ return finding;
231
+ }
232
+ finding.anchor = { path, line: frame.line, side: RIGHT };
233
+ finding.anchorable = true;
234
+ return finding;
235
+ }
236
+
237
+ /**
238
+ * Rank findings deterministically — no wall-clock, no input-order dependence.
239
+ * Order: severity (must-fix first), then anchorable-first (the poster can inline
240
+ * these), then kind, then source file, then source line. Every key is a total
241
+ * order over the data so the result is stable for a given input set.
242
+ */
243
+ export function rankFindings(findings) {
244
+ const key = (f) => [
245
+ severityRank(f.severity),
246
+ f.anchorable ? 0 : 1,
247
+ f.kind ?? "",
248
+ f.source?.file ?? "",
249
+ f.source?.line ?? 0,
250
+ ];
251
+ return [...findings].sort((a, b) => {
252
+ const ka = key(a);
253
+ const kb = key(b);
254
+ for (let i = 0; i < ka.length; i += 1) {
255
+ if (ka[i] < kb[i]) return -1;
256
+ if (ka[i] > kb[i]) return 1;
257
+ }
258
+ return 0;
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Diagnose the drive stage's captured failures into a ranked findings list with
264
+ * diff-line anchors or explicit non-anchorable flags plus reproduced-evidence
265
+ * references. Pure.
266
+ *
267
+ * @param {object} input
268
+ * @param {object[]} [input.failures] - the drive stage's `failures`.
269
+ * @param {object[]} [input.captures] - the drive stage's `captures` (evidence).
270
+ * @param {string} [input.diffOutput] - the PR's unified diff.
271
+ * @returns {{ok:boolean, findings:object[], counts:{total:number,anchorable:number,nonAnchorable:number}}}
272
+ */
273
+ export function diagnoseFailures({ failures = [], captures = [], diffOutput = "" } = {}) {
274
+ const anchorsByPath = parseDiffAnchors(diffOutput);
275
+ // ponytail: one reproduced-evidence reference per finding — the drive's final
276
+ // captured state (the last screenshot/state pair). Per-step attribution would
277
+ // need brittle parsing of the step-failure message; wire flow/step through the
278
+ // drive feed first if a stage needs frame-accurate evidence.
279
+ const last = captures.length > 0 ? captures[captures.length - 1] : null;
280
+ const evidence = last
281
+ ? { flow: last.flow ?? null, step: last.step ?? null, screenshotPath: last.screenshotPath ?? null, statePath: last.statePath ?? null }
282
+ : null;
283
+
284
+ const findings = rankFindings(failures.map((f) => diagnoseOne(f, anchorsByPath, evidence)));
285
+ const anchorable = findings.filter((f) => f.anchorable).length;
286
+ return {
287
+ ok: findings.length === 0,
288
+ findings,
289
+ counts: { total: findings.length, anchorable, nonAnchorable: findings.length - anchorable },
290
+ };
291
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Drive orchestrator for the ui_review route (Stage 2).
3
+ *
4
+ * Authenticates as the change's target role via a project-provided dev-login
5
+ * recipe, then walks the changed UI flows against the arbitrary running-app URL
6
+ * handed off by Stage 1 — rendering each page, exercising its declared
7
+ * interactions, and capturing an ordered set of step screenshots. While it
8
+ * drives, response/requestfailed/pageerror listeners and a server-log tail run
9
+ * so a swallowed error response (a 500 the UI hides) is still recorded.
10
+ *
11
+ * This module is PURE orchestration: all browser/page IO, the auth recipe, the
12
+ * interstitial dismissal, the per-step capture, the event collection, and the
13
+ * server-log tail are injected seams. The thin CLI/harness wires real Playwright
14
+ * (WebKit). The decision logic that lives here is: which flows to drive
15
+ * (a bounded changed-flow heuristic over an explicit allowlist), cap
16
+ * enforcement (max screenshots, screens skipped, no-retry) with explicit logs,
17
+ * and failure classification (collating error responses, request failures,
18
+ * page errors, and server-log exceptions into one structured list).
19
+ *
20
+ * Fail closed: a can't-authenticate condition STOPS with a stated reason and
21
+ * drives nothing. The structured captured-failures list feeds the next stage.
22
+ *
23
+ * Out of scope (later stages): exception -> source-line mapping, review
24
+ * posting, visual-regression/pixel-diffing, cross-browser matrix.
25
+ */
26
+
27
+ const MUST_FIX = "must-fix";
28
+
29
+ /** The one owner of the error-response threshold: an error response is anything
30
+ * outside 2xx/3xx. 3xx redirects are normal navigation (login/canonical), not
31
+ * errors, so they are not flagged. Shared by the CLI listener's pre-filter (for
32
+ * buffer bounding) and the classifier, so the policy has a single source. */
33
+ export function isErrorResponseStatus(status) {
34
+ return typeof status === "number" && (status < 200 || status >= 400);
35
+ }
36
+
37
+ /** Bound the stack text carried onto a page-error failure so a runaway stack
38
+ * (or a synthetic error with a huge stack) can't bloat the feed envelope. Keeps
39
+ * the head — the top frames, where the throwing file:line sits. */
40
+ const PAGE_ERROR_STACK_MAX_CHARS = 4000;
41
+
42
+ /** Lines of context to preserve on each side of a matching server-log line, so
43
+ * the traceback frames that carry file:line (often on adjacent, non-matching
44
+ * lines) survive into the Stage 3 feed. */
45
+ const SERVER_LOG_CONTEXT_LINES = 4;
46
+ /** Char cap on the preserved server-log context window per failure entry. */
47
+ const SERVER_LOG_CONTEXT_MAX_CHARS = 2000;
48
+
49
+ /** Bounded caps. A project cannot raise these past the ceilings — the walker is
50
+ * a diagnostic pass over the changed flows, never an unbounded crawl. */
51
+ export const DEFAULT_DRIVE_CAPS = Object.freeze({
52
+ maxScreenshots: 40,
53
+ maxFlows: 12,
54
+ maxStepsPerFlow: 20,
55
+ // No-retry is a fixed policy, not a tunable: a flaky step is a finding, not
56
+ // something to paper over by re-running. Logged explicitly on every run.
57
+ retries: 0,
58
+ });
59
+
60
+ /** Merge project caps onto the defaults, clamping each to its ceiling so a
61
+ * recipe can only tighten a cap, never loosen it past the diagnostic budget. */
62
+ export function resolveCaps(caps = {}) {
63
+ const clamp = (v, ceiling) =>
64
+ Number.isInteger(v) && v >= 0 ? Math.min(v, ceiling) : ceiling;
65
+ return {
66
+ maxScreenshots: clamp(caps.maxScreenshots, DEFAULT_DRIVE_CAPS.maxScreenshots),
67
+ maxFlows: clamp(caps.maxFlows, DEFAULT_DRIVE_CAPS.maxFlows),
68
+ maxStepsPerFlow: clamp(caps.maxStepsPerFlow, DEFAULT_DRIVE_CAPS.maxStepsPerFlow),
69
+ retries: 0,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Changed-flow discovery: pick which allowlisted flows to drive.
75
+ *
76
+ * This is a DOCUMENTED HEURISTIC over an EXPLICIT allowlist, never an unbounded
77
+ * crawl. Each flow declares `pathPatterns` (plain substrings matched against the
78
+ * PR's changed file paths). A flow is in scope when any changed path contains
79
+ * any of its patterns. A flow with no `pathPatterns` is always in scope (the
80
+ * project opted it into every run). When `changedPaths` is empty/absent the diff
81
+ * is unknown, so every allowlisted flow is driven — the safe over-approximation.
82
+ * The selection is then capped at `caps.maxFlows`; the overflow is skipped and
83
+ * logged, never silently dropped.
84
+ *
85
+ * @returns {{ selected: object[], skipped: {name:string, reason:string}[] }}
86
+ */
87
+ export function selectFlows({ flows = [], changedPaths = [], caps = DEFAULT_DRIVE_CAPS } = {}) {
88
+ const paths = Array.isArray(changedPaths) ? changedPaths : [];
89
+ const haveDiff = paths.length > 0;
90
+ const matched = [];
91
+ const skipped = [];
92
+ for (const flow of flows) {
93
+ const patterns = Array.isArray(flow.pathPatterns) ? flow.pathPatterns : [];
94
+ let inScope;
95
+ if (!haveDiff || patterns.length === 0) {
96
+ inScope = true; // unknown diff, or an always-on flow
97
+ } else {
98
+ inScope = patterns.some((p) => paths.some((cp) => cp.includes(p)));
99
+ }
100
+ if (inScope) matched.push(flow);
101
+ else skipped.push({ name: flow.name, reason: "no changed path matched its pathPatterns" });
102
+ }
103
+ const selected = matched.slice(0, caps.maxFlows);
104
+ for (const flow of matched.slice(caps.maxFlows)) {
105
+ skipped.push({ name: flow.name, reason: `maxFlows cap (${caps.maxFlows}) reached` });
106
+ }
107
+ return { selected, skipped };
108
+ }
109
+
110
+ /**
111
+ * Classify raw captured events + the server-log tail into one structured failure
112
+ * list. Pure. This is where a swallowed error response surfaces twice — once
113
+ * from the response listener and once from the server-log tail — so a 500 the UI
114
+ * hid is still recorded.
115
+ *
116
+ * @param {object} input
117
+ * @param {{url?:string,status:number}[]} [input.responses] - from page.on('response')
118
+ * @param {{url?:string,failure?:string}[]} [input.requestFailures] - from page.on('requestfailed')
119
+ * @param {{message?:string,stack?:string|null}[]} [input.pageErrors] - from page.on('pageerror'); `stack` (file:line) feeds Stage 3
120
+ * @param {string} [input.serverLogTail] - tail text of the project server log
121
+ * @param {string} [input.serverLogExceptionPattern] - regex (source) flagging a log exception line
122
+ * @returns {{kind:string, severity:string, message:string, [k:string]:unknown}[]}
123
+ */
124
+ export function classifyFailures({
125
+ responses = [],
126
+ requestFailures = [],
127
+ pageErrors = [],
128
+ serverLogTail = "",
129
+ serverLogExceptionPattern,
130
+ } = {}) {
131
+ const failures = [];
132
+
133
+ for (const r of responses) {
134
+ // A swallowed 500 lands here even when the page rendered a success state,
135
+ // because the listener sees the wire. The error-response threshold has one
136
+ // owner: isErrorResponseStatus.
137
+ if (isErrorResponseStatus(r.status)) {
138
+ failures.push({
139
+ kind: "error-response",
140
+ severity: MUST_FIX,
141
+ status: r.status,
142
+ url: r.url ?? null,
143
+ message: `error response ${r.status}${r.url ? ` at ${r.url}` : ""}`,
144
+ });
145
+ }
146
+ }
147
+
148
+ for (const f of requestFailures) {
149
+ failures.push({
150
+ kind: "request-failed",
151
+ severity: MUST_FIX,
152
+ url: f.url ?? null,
153
+ message: `request failed${f.url ? ` at ${f.url}` : ""}${f.failure ? `: ${f.failure}` : ""}`,
154
+ });
155
+ }
156
+
157
+ for (const e of pageErrors) {
158
+ // Carry the bounded stack so Stage 3's exception -> source-line mapping has
159
+ // the file:line signal; null when the listener captured no stack.
160
+ const stack = typeof e.stack === "string" && e.stack.length > 0 ? e.stack.slice(0, PAGE_ERROR_STACK_MAX_CHARS) : null;
161
+ failures.push({
162
+ kind: "page-error",
163
+ severity: MUST_FIX,
164
+ message: `uncaught page error: ${e.message ?? "(no message)"}`,
165
+ stack,
166
+ });
167
+ }
168
+
169
+ if (serverLogTail && serverLogExceptionPattern) {
170
+ // Config validates the pattern only on the CLI path; a direct caller can
171
+ // pass an invalid regex. Guard the compile so a bad pattern degrades to a
172
+ // surfaced note instead of throwing and breaking the whole drive envelope.
173
+ let re;
174
+ try {
175
+ re = new RegExp(serverLogExceptionPattern, "iu");
176
+ } catch (err) {
177
+ failures.push({
178
+ kind: "server-log-pattern-invalid",
179
+ severity: "note",
180
+ message: `server-log exception pattern is not a valid regex; skipped server-log classification: ${err?.message ?? String(err)}`,
181
+ });
182
+ }
183
+ if (re) {
184
+ const lines = serverLogTail.split("\n");
185
+ for (let i = 0; i < lines.length; i += 1) {
186
+ const trimmed = lines[i].trim();
187
+ if (trimmed.length > 0 && re.test(trimmed)) {
188
+ // Preserve the contiguous frames around the match: the file:line the
189
+ // traceback carries usually sits on adjacent, non-matching lines that
190
+ // the per-line match alone would drop. Bounded on both axes.
191
+ const from = Math.max(0, i - SERVER_LOG_CONTEXT_LINES);
192
+ const to = Math.min(lines.length, i + SERVER_LOG_CONTEXT_LINES + 1);
193
+ const context = lines.slice(from, to).join("\n").slice(0, SERVER_LOG_CONTEXT_MAX_CHARS);
194
+ failures.push({
195
+ kind: "server-log-exception",
196
+ severity: MUST_FIX,
197
+ message: `server log exception: ${trimmed.slice(0, 500)}`,
198
+ context,
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ return failures;
206
+ }
207
+
208
+ /**
209
+ * Run the auth + drive sequence over the changed flows.
210
+ *
211
+ * @param {object} input
212
+ * @param {string} input.appUrl - The arbitrary running-app URL from Stage 1.
213
+ * @param {object} input.login - Resolved dev-login recipe (loginUrl + selectors).
214
+ * @param {object[]} [input.flows] - Allowlisted changed-flow definitions.
215
+ * @param {object[]} [input.interstitials] - Config-declared dismiss selectors.
216
+ * @param {string[]} [input.changedPaths] - Changed file paths (drives selection).
217
+ * @param {string} [input.serverLogExceptionPattern] - regex source for log-tail classification.
218
+ * @param {object} [input.caps] - Project cap overrides (clamped to the ceilings).
219
+ * @param {object} seams - Injected IO.
220
+ * @param {(a:{appUrl:string,login:object})=>Promise<{ok:boolean,detail:string}>} seams.authenticate
221
+ * @param {(a:{interstitials:object[]})=>Promise<{dismissed:string[]}>} [seams.dismissInterstitials]
222
+ * @param {(a:{appUrl:string,flow:object,step:object,index:number})=>Promise<{screenshotPath?:string,statePath?:string,ok?:boolean,detail?:string}>} seams.runStep
223
+ * @param {()=>{responses?:object[],requestFailures?:object[],pageErrors?:object[]}} seams.getCapturedEvents
224
+ * @param {()=>Promise<string>} [seams.readServerLogTail]
225
+ * @param {(msg:string)=>void} [seams.log]
226
+ * @returns {Promise<object>} Result envelope (steps, captures, failures, caps, logs).
227
+ */
228
+ export async function driveUiReview(
229
+ { appUrl, login, flows = [], interstitials = [], changedPaths = [], serverLogExceptionPattern, caps = {} },
230
+ {
231
+ authenticate,
232
+ dismissInterstitials = async () => ({ dismissed: [] }),
233
+ runStep,
234
+ getCapturedEvents,
235
+ readServerLogTail = async () => "",
236
+ log = () => {},
237
+ } = {},
238
+ ) {
239
+ const logs = [];
240
+ const record = (msg) => {
241
+ logs.push(msg);
242
+ log(msg);
243
+ };
244
+ const resolvedCaps = resolveCaps(caps);
245
+ // No-retry is a fixed policy — log it every run so the bound is never implicit.
246
+ record(`caps: maxScreenshots=${resolvedCaps.maxScreenshots}, maxFlows=${resolvedCaps.maxFlows}, maxStepsPerFlow=${resolvedCaps.maxStepsPerFlow}, retries=${resolvedCaps.retries} (no-retry)`);
247
+
248
+ const base = () => ({ appUrl: appUrl ?? null, logs });
249
+
250
+ // 1. Authenticate as the target role. Fail closed: no session -> STOP, drive
251
+ // nothing (a review that never reached the app is worthless, not empty).
252
+ const auth = await authenticate({ appUrl, login });
253
+ if (!auth.ok) {
254
+ const stopReason = `cannot authenticate: ${auth.detail ?? "dev-login recipe did not yield a session"}`;
255
+ record(`STOP: ${stopReason}`);
256
+ return {
257
+ ok: false,
258
+ stopped: true,
259
+ stopReason,
260
+ steps: [],
261
+ captures: [],
262
+ failures: [{ kind: "auth-failed", severity: MUST_FIX, message: stopReason }],
263
+ caps: resolvedCaps,
264
+ ...base(),
265
+ };
266
+ }
267
+ record(`authenticated: ${auth.detail ?? "session established"}`);
268
+
269
+ // 2. Dismiss known interstitials ONCE per browser context (config-declared).
270
+ const dismiss = await dismissInterstitials({ interstitials });
271
+ if (dismiss.dismissed?.length) record(`interstitials dismissed: ${dismiss.dismissed.join(", ")}`);
272
+
273
+ // 3. Select the changed flows (bounded heuristic over the explicit allowlist).
274
+ const { selected, skipped } = selectFlows({ flows, changedPaths, caps: resolvedCaps });
275
+ for (const s of skipped) record(`flow skipped: ${s.name} (${s.reason})`);
276
+ record(`driving ${selected.length} flow(s)`);
277
+
278
+ // 4. Walk each flow's steps, capturing every step, until the screenshot cap.
279
+ // No retry: a step that throws is recorded as a step failure and the walk
280
+ // moves on — deterministic, bounded, never re-run.
281
+ const steps = [];
282
+ const captures = [];
283
+ let screenshots = 0;
284
+ let screensSkipped = 0;
285
+ for (const flow of selected) {
286
+ const declaredSteps = Array.isArray(flow.steps) ? flow.steps : [];
287
+ const flowSteps = declaredSteps.slice(0, resolvedCaps.maxStepsPerFlow);
288
+ if (declaredSteps.length > flowSteps.length) {
289
+ record(`steps skipped: ${flow.name} truncated to ${flowSteps.length} step(s) at the maxStepsPerFlow cap (${resolvedCaps.maxStepsPerFlow})`);
290
+ }
291
+ for (let i = 0; i < flowSteps.length; i += 1) {
292
+ const step = flowSteps[i];
293
+ if (screenshots >= resolvedCaps.maxScreenshots) {
294
+ screensSkipped += 1;
295
+ continue;
296
+ }
297
+ let outcome;
298
+ try {
299
+ outcome = await runStep({ appUrl, flow, step, index: screenshots });
300
+ } catch (err) {
301
+ outcome = { ok: false, detail: (err?.message ?? String(err)).slice(0, 500) };
302
+ }
303
+ const ok = outcome?.ok !== false;
304
+ screenshots += 1;
305
+ const entry = {
306
+ flow: flow.name,
307
+ step: step.name ?? step.action ?? `step-${i}`,
308
+ order: screenshots,
309
+ ok,
310
+ screenshotPath: outcome?.screenshotPath ?? null,
311
+ statePath: outcome?.statePath ?? null,
312
+ detail: outcome?.detail ?? null,
313
+ };
314
+ steps.push(entry);
315
+ if (entry.screenshotPath) captures.push({ flow: flow.name, step: entry.step, screenshotPath: entry.screenshotPath, statePath: entry.statePath });
316
+ if (!ok) record(`step failed (no retry): ${flow.name} / ${entry.step}: ${entry.detail ?? "unknown"}`);
317
+ }
318
+ }
319
+ if (screensSkipped > 0) record(`screens skipped: ${screensSkipped} step(s) past the maxScreenshots cap (${resolvedCaps.maxScreenshots})`);
320
+
321
+ // 5. Collate the out-of-band captures: listener events + the server-log tail.
322
+ const events = getCapturedEvents() ?? {};
323
+ const serverLogTail = await readServerLogTail();
324
+ const failures = classifyFailures({
325
+ responses: events.responses ?? [],
326
+ requestFailures: events.requestFailures ?? [],
327
+ pageErrors: events.pageErrors ?? [],
328
+ serverLogTail,
329
+ serverLogExceptionPattern,
330
+ });
331
+ // A step that threw is a drive failure too — surface it in the structured list.
332
+ for (const s of steps) {
333
+ if (!s.ok) failures.push({ kind: "step-failure", severity: MUST_FIX, message: `step failed: ${s.flow} / ${s.step}${s.detail ? `: ${s.detail}` : ""}` });
334
+ }
335
+ record(`captured ${failures.length} failure(s) across ${steps.length} step(s)`);
336
+
337
+ return {
338
+ ok: failures.length === 0,
339
+ stopped: false,
340
+ stopReason: null,
341
+ steps,
342
+ captures,
343
+ failures,
344
+ caps: resolvedCaps,
345
+ screensSkipped,
346
+ ...base(),
347
+ };
348
+ }