@dev-loops/core 0.7.2 → 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.
@@ -23,6 +23,7 @@ export const DEV_LOOP_PUBLIC_INTENT = Object.freeze({
23
23
  CONTINUE_CURRENT: "continue_current",
24
24
  AUTO_CONTINUE_CURRENT: "auto_continue_current",
25
25
  INSPECT_STATE: "inspect_state",
26
+ REVIEW_PR_UI: "review_pr_ui",
26
27
  });
27
28
 
28
29
  export const DEV_LOOP_TARGET_KIND = Object.freeze({
@@ -76,6 +77,7 @@ export const DEV_LOOP_GATE = Object.freeze({
76
77
  EXTERNAL_PR_FOLLOWUP: "external_pr_followup",
77
78
  REVIEWER_FIXER: "reviewer_fixer",
78
79
  COPILOT_PR_FOLLOWUP: "copilot_pr_followup",
80
+ UI_REVIEW: "ui_review",
79
81
  FAIL_CLOSED_RECONCILE: "fail_closed_reconcile",
80
82
  });
81
83
 
@@ -87,6 +89,7 @@ export const INTERNAL_DEV_LOOP_STRATEGY = Object.freeze({
87
89
  REVIEWER_FIXER: "reviewer_fixer",
88
90
  WAIT_WATCH: "wait_watch",
89
91
  FINAL_APPROVAL: "final_approval",
92
+ UI_REVIEW: "ui_review",
90
93
  NONE: null,
91
94
  });
92
95
 
@@ -267,6 +270,12 @@ export const PUBLIC_DEV_LOOP_GATE_CONTRACT = Object.freeze([
267
270
  selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP,
268
271
  summary: "Copilot-owned PR state routes to Copilot PR follow-up; an already-linked open PR stays the canonical artifact for that issue until reconciled",
269
272
  }),
273
+ Object.freeze({
274
+ gate: DEV_LOOP_GATE.UI_REVIEW,
275
+ routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
276
+ selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
277
+ summary: "an explicit UI-review request on a PR target routes to the ui_review running-app review strategy",
278
+ }),
270
279
  Object.freeze({
271
280
  gate: DEV_LOOP_GATE.FAIL_CLOSED_RECONCILE,
272
281
  routeKind: DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE,
@@ -461,7 +461,7 @@ function toRoutableCanonicalState(canonicalState) {
461
461
  };
462
462
  }
463
463
 
464
- function selectGateForState(canonicalState) {
464
+ function selectGateForState(canonicalState, { uiReviewRequested = false } = {}) {
465
465
  if (canonicalState.status === DEV_LOOP_STATUS.BLOCKED || canonicalState.authorization === DEV_LOOP_AUTHORIZATION.NOT_AUTHORIZED) {
466
466
  return DEV_LOOP_GATE.STOP_BLOCKED_OR_NOT_AUTHORIZED;
467
467
  }
@@ -499,6 +499,15 @@ function selectGateForState(canonicalState) {
499
499
  return DEV_LOOP_GATE.ISSUE_INTAKE;
500
500
  }
501
501
 
502
+ // An explicit UI-review request intercepts a PR target ahead of the
503
+ // ownership-derived PR gates: the running-app review is requested regardless
504
+ // of who owns the PR. It stays after the authoritative lifecycle stop/terminal/
505
+ // approval/waiting gates so it can never bypass them. Absent the signal this
506
+ // branch is inert, so existing routes stay byte-identical.
507
+ if (uiReviewRequested && canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR) {
508
+ return DEV_LOOP_GATE.UI_REVIEW;
509
+ }
510
+
502
511
  if (canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR && canonicalState.ownership === DEV_LOOP_ACTOR.EXTERNAL_HUMAN) {
503
512
  return DEV_LOOP_GATE.EXTERNAL_PR_FOLLOWUP;
504
513
  }
@@ -581,10 +590,11 @@ function routeForState(
581
590
  issueAssignmentState = null,
582
591
  gateReviewEvidence = null,
583
592
  targetPreference = null,
593
+ uiReviewRequested = false,
584
594
  } = {},
585
595
  ) {
586
596
  const routableCanonicalState = toRoutableCanonicalState(canonicalState);
587
- const selectedGate = selectGateForState(routableCanonicalState);
597
+ const selectedGate = selectGateForState(routableCanonicalState, { uiReviewRequested });
588
598
  if (
589
599
  selectedGate === DEV_LOOP_GATE.FINAL_APPROVAL
590
600
  && routableCanonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR
@@ -763,6 +773,18 @@ function routeForState(
763
773
  });
764
774
  }
765
775
 
776
+ if (selectedGate === DEV_LOOP_GATE.UI_REVIEW) {
777
+ return buildResult({
778
+ selectedGate,
779
+ routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
780
+ selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
781
+ executionMode,
782
+ canonicalState: routableCanonicalState,
783
+ nextAction: "Run the UI-review route for the current PR: prove the change in the running app from an isolated worktree. Do not write product code; keep any outward review pending/draft; acknowledge destructive migrations before running them.",
784
+ reason: "An explicit UI-review request on a PR target routes to the ui_review strategy — the running-app review sibling of the reviewer/fixer route.",
785
+ });
786
+ }
787
+
766
788
  return buildReconcile(
767
789
  "The canonical current state does not map cleanly to any first-slice internal strategy.",
768
790
  routableCanonicalState,
@@ -1171,6 +1193,7 @@ export function resolveAuthoritativeStartupResumeBundle(input = {}) {
1171
1193
  issueAssignmentState,
1172
1194
  gateReviewEvidence,
1173
1195
  targetPreference,
1196
+ uiReviewRequested: intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI,
1174
1197
  });
1175
1198
  if (routed.routeKind === DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE) {
1176
1199
  return buildStartupResumeBundleReconcile({
@@ -1703,6 +1726,23 @@ export function evaluatePublicDevLoopRouting(input = {}) {
1703
1726
  ));
1704
1727
  }
1705
1728
 
1729
+ if (intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI) {
1730
+ if (!explicitTarget || explicitTarget.kind !== DEV_LOOP_TARGET_KIND.PR) {
1731
+ return buildInputReconcile("`review_pr_ui` requires a PR target.", null, effectiveMode);
1732
+ }
1733
+ if (!explicitState || explicitState.target.kind !== DEV_LOOP_TARGET_KIND.PR) {
1734
+ return buildInputReconcile("`review_pr_ui` requires a valid canonical PR state.", explicitState, effectiveMode);
1735
+ }
1736
+ if (explicitState.target.pr !== explicitTarget.pr) {
1737
+ return buildInputReconcile("`review_pr_ui` target conflicts with the canonical current PR state.", explicitState, effectiveMode);
1738
+ }
1739
+
1740
+ return finalizeRoutingResult(applyWatchValidation(
1741
+ routeForState(explicitState, { ...routingOptions, executionMode: effectiveMode, uiReviewRequested: true }),
1742
+ watchRequested,
1743
+ ));
1744
+ }
1745
+
1706
1746
  if (intent === DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT) {
1707
1747
  if (!explicitState) {
1708
1748
  return buildInputReconcile("`continue_current` requires a valid canonical current state.", null, effectiveMode);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deterministic state machine for the refinement/grill sub-loop.
3
+ *
4
+ * The refinement loop runs the grill as a CLOSED, DETERMINISTIC sub-loop:
5
+ * detect-gaps -> auto-answer -> synthesize -> re-grill -> terminal. The
6
+ * iteration lives entirely in the transition graph below; the LLM answer and
7
+ * synthesis enter ONLY as a bounded input consumed at the `await_answers`
8
+ * state (and reflected in the `synthesized` snapshot flag), never as hidden
9
+ * orchestration inside a deterministic coordinator script (keeps
10
+ * OPS-NO-INLINE-INTERPRETER, #1224, clean).
11
+ *
12
+ * Mirrors the shape of `reviewer-loop-state.mjs` / `copilot-loop-state.mjs`:
13
+ * a frozen STATE vocabulary, a frozen TRANSITIONS adjacency table, a
14
+ * `normalize*Snapshot` canonicalizer, and a pure `interpret*State` that maps a
15
+ * point-in-time snapshot to exactly one current state plus its legal exits.
16
+ *
17
+ * Honest handoff: when a gap is genuinely unanswerable (only-`inferred`, no
18
+ * citation), the machine reaches `needs_human_handoff` naming the question
19
+ * rather than fabricating an answer to force convergence.
20
+ */
21
+
22
+ export const GRILL_STATE = Object.freeze({
23
+ LOAD_TARGET: "load_target",
24
+ DETECT_GAPS: "detect_gaps",
25
+ AWAIT_ANSWERS: "await_answers",
26
+ SYNTHESIZE: "synthesize",
27
+ RE_GRILL: "re_grill",
28
+ GRILL_CLEAN: "grill_clean",
29
+ NEEDS_HUMAN_HANDOFF: "needs_human_handoff",
30
+ BLOCKED_NEEDS_USER_DECISION: "blocked_needs_user_decision",
31
+ });
32
+
33
+ // The iterate-to-clean loop: detect_gaps -> await_answers -> synthesize ->
34
+ // re_grill, with re_grill either re-entering detect_gaps (a new answerable gap
35
+ // surfaced) or terminating at grill_clean (fixed point). Any I/O/parse failure
36
+ // fails closed to blocked_needs_user_decision; any unresolved (uncitable) gap
37
+ // terminates honestly at needs_human_handoff.
38
+ export const GRILL_TRANSITIONS = Object.freeze({
39
+ [GRILL_STATE.LOAD_TARGET]: [
40
+ GRILL_STATE.DETECT_GAPS,
41
+ GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
42
+ ],
43
+ [GRILL_STATE.DETECT_GAPS]: [
44
+ GRILL_STATE.AWAIT_ANSWERS,
45
+ GRILL_STATE.GRILL_CLEAN,
46
+ GRILL_STATE.NEEDS_HUMAN_HANDOFF,
47
+ GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
48
+ ],
49
+ [GRILL_STATE.AWAIT_ANSWERS]: [
50
+ GRILL_STATE.SYNTHESIZE,
51
+ GRILL_STATE.NEEDS_HUMAN_HANDOFF,
52
+ GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
53
+ ],
54
+ [GRILL_STATE.SYNTHESIZE]: [
55
+ GRILL_STATE.RE_GRILL,
56
+ GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
57
+ ],
58
+ [GRILL_STATE.RE_GRILL]: [
59
+ GRILL_STATE.DETECT_GAPS,
60
+ GRILL_STATE.GRILL_CLEAN,
61
+ GRILL_STATE.NEEDS_HUMAN_HANDOFF,
62
+ GRILL_STATE.BLOCKED_NEEDS_USER_DECISION,
63
+ ],
64
+ [GRILL_STATE.GRILL_CLEAN]: [],
65
+ [GRILL_STATE.NEEDS_HUMAN_HANDOFF]: [],
66
+ [GRILL_STATE.BLOCKED_NEEDS_USER_DECISION]: [],
67
+ });
68
+
69
+ const GRILL_NEXT_ACTIONS = Object.freeze({
70
+ [GRILL_STATE.LOAD_TARGET]: "Load the target issue/PR/plan body for grilling",
71
+ [GRILL_STATE.DETECT_GAPS]: "Run the loop-grill gap detectors on the loaded spec",
72
+ [GRILL_STATE.AWAIT_ANSWERS]: "Consume the bounded answer input: --auto self-answer with a citation, or ask the human interactively",
73
+ [GRILL_STATE.SYNTHESIZE]: "Synthesize Acceptance criteria / Definition of done / Non-goals into the body; write raw Q&A only to the ephemeral tmp artifact",
74
+ [GRILL_STATE.RE_GRILL]: "Re-run gap detection to check for a fixed point",
75
+ [GRILL_STATE.GRILL_CLEAN]: "Grill reached a fixed point; synthesized spec is clean",
76
+ [GRILL_STATE.NEEDS_HUMAN_HANDOFF]: "Stop and hand off the named unanswerable question(s) to the human; headless parks with the recorded reason",
77
+ [GRILL_STATE.BLOCKED_NEEDS_USER_DECISION]: "Stop and request explicit user direction",
78
+ });
79
+
80
+ const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
81
+
82
+ function normalizeCount(value) {
83
+ return typeof value === "number" && Number.isFinite(value) && value > 0
84
+ ? Math.floor(value)
85
+ : 0;
86
+ }
87
+
88
+ function normalizeStringOrNull(value) {
89
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
90
+ }
91
+
92
+ /**
93
+ * Canonicalize a raw grill snapshot into a deterministic shape.
94
+ *
95
+ * @param {object} raw
96
+ * @returns {object}
97
+ */
98
+ export function normalizeGrillSnapshot(raw) {
99
+ if (!raw || typeof raw !== "object") {
100
+ throw new Error("Snapshot must be a non-null object");
101
+ }
102
+
103
+ return {
104
+ surface: VALID_SURFACES.has(raw.surface) ? raw.surface : "issue",
105
+ targetRef: normalizeStringOrNull(raw.targetRef),
106
+
107
+ loaded: Boolean(raw.loaded),
108
+ loadFailed: Boolean(raw.loadFailed),
109
+
110
+ detectRan: Boolean(raw.detectRan),
111
+ // answerable gaps still awaiting an answer this pass
112
+ openGapCount: normalizeCount(raw.openGapCount),
113
+ // uncitable gaps that must hand off honestly (never fabricated)
114
+ unresolvedGapCount: normalizeCount(raw.unresolvedGapCount),
115
+
116
+ // the bounded LLM answer input, consumed at await_answers
117
+ answersReady: Boolean(raw.answersReady),
118
+ // synthesized AC/DoD/Non-goals applied to the body this iteration
119
+ synthesized: Boolean(raw.synthesized),
120
+
121
+ // post-synthesis re-grill fixed-point signals
122
+ reGrillRan: Boolean(raw.reGrillRan),
123
+ reGrillFixedPoint: Boolean(raw.reGrillFixedPoint),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Deterministically interpret the current refinement-grill state.
129
+ *
130
+ * @param {object} snapshot
131
+ * @returns {{state: string, allowedTransitions: string[], nextAction: string}}
132
+ */
133
+ export function interpretRefinementGrillState(snapshot) {
134
+ const s = normalizeGrillSnapshot(snapshot);
135
+
136
+ let state;
137
+
138
+ if (s.loadFailed) {
139
+ // Fail closed on any load/parse failure, from any point in the loop.
140
+ state = GRILL_STATE.BLOCKED_NEEDS_USER_DECISION;
141
+ } else if (!s.loaded) {
142
+ state = GRILL_STATE.LOAD_TARGET;
143
+ } else if (s.unresolvedGapCount > 0) {
144
+ // Honest handoff outranks everything else: never fabricate to converge.
145
+ state = GRILL_STATE.NEEDS_HUMAN_HANDOFF;
146
+ } else if (s.synthesized) {
147
+ if (!s.reGrillRan) {
148
+ // Synthesis applied -> re-grill to check the fixed point.
149
+ state = GRILL_STATE.RE_GRILL;
150
+ } else if (s.reGrillFixedPoint) {
151
+ state = GRILL_STATE.GRILL_CLEAN;
152
+ } else {
153
+ // Re-grill surfaced a new answerable gap -> iterate.
154
+ state = GRILL_STATE.DETECT_GAPS;
155
+ }
156
+ } else if (s.answersReady) {
157
+ // Bounded answer input present -> apply synthesis.
158
+ state = GRILL_STATE.SYNTHESIZE;
159
+ } else if (s.detectRan) {
160
+ // Detection ran with no unresolved and no pending answers:
161
+ // open gaps -> await answers; zero gaps -> clean fixed point
162
+ // (also the already-refined, zero-iteration path).
163
+ state = s.openGapCount > 0 ? GRILL_STATE.AWAIT_ANSWERS : GRILL_STATE.GRILL_CLEAN;
164
+ } else {
165
+ state = GRILL_STATE.DETECT_GAPS;
166
+ }
167
+
168
+ return {
169
+ state,
170
+ allowedTransitions: [...GRILL_TRANSITIONS[state]],
171
+ nextAction: GRILL_NEXT_ACTIONS[state],
172
+ };
173
+ }
@@ -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
+ }