@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.
- package/package.json +7 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/handoff-envelope.mjs +30 -0
- package/src/loop/issue-refinement-artifact.mjs +42 -0
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +20 -2
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/refinement-grill-state.mjs +173 -0
- 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
|
@@ -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
|
+
}
|