@dev-loops/core 1.0.0-rc.6 → 1.0.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 +8 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/issue-refinement-artifact.mjs +186 -42
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +11 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/retrospective-checkpoint.mjs +59 -1
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +2 -28
- package/src/security/secret-scan.mjs +330 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-merge actions runner (config family: `postMerge.actions`, #1457).
|
|
3
|
+
*
|
|
4
|
+
* Runs a repo's declared `postMerge.actions` sequentially, in declared order,
|
|
5
|
+
* after the dev-loop's merge succeeds — sync a checkout, restart a local
|
|
6
|
+
* service, run a smoke check. Mirrors the `uiReview.run` recipe's shape and
|
|
7
|
+
* execution precedent (`packages/core/src/loop/ui-review-provision.mjs`).
|
|
8
|
+
*
|
|
9
|
+
* SECURITY: `action.run` / `action.verify` are executed VERBATIM as the
|
|
10
|
+
* operator declared them in the repo's own committed `.devloops` (same trust
|
|
11
|
+
* level as `uiReview.run.command`) — this module never builds a command string
|
|
12
|
+
* by concatenating runtime data (changed-file paths, verify output) into it.
|
|
13
|
+
* `onlyIfChanged` scoping matches changed-file paths as DATA (plain substring
|
|
14
|
+
* compare), never by shelling them out. Every `run`/`verify` invocation is
|
|
15
|
+
* bounded by its own timeout; `verify` polling is bounded by `verifyTimeoutMs`.
|
|
16
|
+
*
|
|
17
|
+
* Pure orchestration: the shell-out (`exec`) and clock (`now`/`delay`) are
|
|
18
|
+
* injected so this module is fully deterministic under test. The CLI wires the
|
|
19
|
+
* real seams (scripts/loop/run-post-merge-actions.mjs).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when at least one changed path contains at least one `onlyIfChanged`
|
|
24
|
+
* pattern (plain substring match — never a shell-out, never a regex).
|
|
25
|
+
*/
|
|
26
|
+
function matchesOnlyIfChanged(patterns, changedPaths) {
|
|
27
|
+
return patterns.some((pattern) => changedPaths.some((changedPath) => changedPath.includes(pattern)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decide whether `action` runs. Returns a skip-reason string, or `null` to run
|
|
32
|
+
* it. `changedPaths === null` means the changed-file list could not be
|
|
33
|
+
* resolved (no PR number, `gh pr diff` failure): AC5 requires an
|
|
34
|
+
* `onlyIfChanged` action to still run in that case (never silently skipped),
|
|
35
|
+
* so the bypass is a run, not a skip.
|
|
36
|
+
*/
|
|
37
|
+
function decideSkip(action, changedPaths) {
|
|
38
|
+
if (!Array.isArray(action.onlyIfChanged) || action.onlyIfChanged.length === 0) return null;
|
|
39
|
+
if (changedPaths === null) return null; // AC5: scoping unresolved — run unscoped
|
|
40
|
+
if (matchesOnlyIfChanged(action.onlyIfChanged, changedPaths)) return null;
|
|
41
|
+
return `no changed file matched onlyIfChanged (${action.onlyIfChanged.join(", ")})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run `command` bounded by `timeoutMs` via the injected `exec` seam. */
|
|
45
|
+
async function execBounded(exec, command, cwd, timeoutMs) {
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await exec(command, { cwd, timeoutMs });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { ok: false, detail: (err && err.message) || String(err) };
|
|
51
|
+
}
|
|
52
|
+
if (result?.killed) return { ok: false, detail: `timed out after ${timeoutMs}ms` };
|
|
53
|
+
if (result?.code !== 0) {
|
|
54
|
+
const stderr = typeof result?.stderr === "string" ? result.stderr.trim() : "";
|
|
55
|
+
return { ok: false, detail: `exit code ${result?.code}${stderr ? `: ${stderr}` : ""}` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, detail: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Poll `verifyCommand` (bounded per-attempt by the remaining budget) until it
|
|
62
|
+
* exits 0 or `verifyTimeoutMs` elapses. Mirrors the readiness poll in
|
|
63
|
+
* `ui-review-provision.mjs`'s `provisionAndBoot` (never a fixed sleep).
|
|
64
|
+
*/
|
|
65
|
+
async function pollVerify(exec, verifyCommand, cwd, verifyTimeoutMs, verifyIntervalMs, { delay, now }) {
|
|
66
|
+
const deadline = now() + verifyTimeoutMs;
|
|
67
|
+
let lastDetail = "verify never ran";
|
|
68
|
+
while (now() <= deadline) {
|
|
69
|
+
const attemptBudget = Math.max(1, deadline - now());
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await exec(verifyCommand, { cwd, timeoutMs: attemptBudget });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
result = { code: 1, killed: false, stdout: "", stderr: (err && err.message) || String(err) };
|
|
75
|
+
}
|
|
76
|
+
if (!result?.killed && result?.code === 0) return { ok: true, detail: null };
|
|
77
|
+
const verifyStderr = typeof result?.stderr === "string" ? result.stderr.trim() : "";
|
|
78
|
+
lastDetail = result?.killed
|
|
79
|
+
? "verify command timed out"
|
|
80
|
+
: `exit code ${result?.code}${verifyStderr ? `: ${verifyStderr}` : ""}`;
|
|
81
|
+
if (now() + verifyIntervalMs > deadline) break; // would overshoot the deadline
|
|
82
|
+
await delay(verifyIntervalMs);
|
|
83
|
+
}
|
|
84
|
+
return { ok: false, detail: `verify exhausted after ${verifyTimeoutMs}ms (${lastDetail})` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run every declared action sequentially, in order, with cwd set to `cwd`
|
|
89
|
+
* (the resolved main checkout).
|
|
90
|
+
*
|
|
91
|
+
* @param {object} input
|
|
92
|
+
* @param {{name:string,run:string,onlyIfChanged:string[]|null,verify:string|null,
|
|
93
|
+
* timeoutMs:number,verifyTimeoutMs:number,verifyIntervalMs:number}[]} input.actions
|
|
94
|
+
* @param {string[]|null} input.changedPaths - Changed file paths of the merged
|
|
95
|
+
* PR, or `null` when unresolved (AC5 bypass).
|
|
96
|
+
* @param {string} [input.changedPathsUnavailableReason] - Why `changedPaths` is
|
|
97
|
+
* `null` (e.g. "no PR number", "gh pr diff failed: ..."), surfaced in the
|
|
98
|
+
* bypass warning so the AC5 "states why scoping was bypassed" reads clearly.
|
|
99
|
+
* @param {string} input.cwd - Absolute path to the main checkout.
|
|
100
|
+
* @param {object} seams
|
|
101
|
+
* @param {(command:string, opts:{cwd:string,timeoutMs:number})=>Promise<{code:number|null,killed:boolean,stdout:string,stderr:string}>} seams.exec
|
|
102
|
+
* @param {(ms:number)=>Promise<void>} [seams.delay]
|
|
103
|
+
* @param {()=>number} [seams.now]
|
|
104
|
+
* @param {(msg:string)=>void} [seams.log]
|
|
105
|
+
* @returns {Promise<{ok:boolean, results:{name:string,status:"ok"|"skipped"|"failed",detail:string|null}[]}>}
|
|
106
|
+
*/
|
|
107
|
+
export async function runPostMergeActions(
|
|
108
|
+
{ actions = [], changedPaths = null, changedPathsUnavailableReason = "unknown reason", cwd },
|
|
109
|
+
{ exec, delay = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), log = () => {} } = {},
|
|
110
|
+
) {
|
|
111
|
+
if (changedPaths === null && actions.some((a) => Array.isArray(a.onlyIfChanged) && a.onlyIfChanged.length > 0)) {
|
|
112
|
+
log(`WARNING: changed-file list unavailable (${changedPathsUnavailableReason}) — onlyIfChanged scoping bypassed (affected actions run unscoped)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const results = [];
|
|
116
|
+
for (const action of actions) {
|
|
117
|
+
const skipReason = decideSkip(action, changedPaths);
|
|
118
|
+
if (skipReason) {
|
|
119
|
+
log(`skip ${action.name}: ${skipReason}`);
|
|
120
|
+
results.push({ name: action.name, status: "skipped", detail: skipReason });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log(`run ${action.name}: ${action.run}`);
|
|
125
|
+
const runResult = await execBounded(exec, action.run, cwd, action.timeoutMs);
|
|
126
|
+
if (!runResult.ok) {
|
|
127
|
+
const detail = `run failed: ${runResult.detail}`;
|
|
128
|
+
log(`FAILED ${action.name}: ${detail}`);
|
|
129
|
+
results.push({ name: action.name, status: "failed", detail });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (action.verify) {
|
|
134
|
+
log(`verify ${action.name}: ${action.verify}`);
|
|
135
|
+
const verifyResult = await pollVerify(exec, action.verify, cwd, action.verifyTimeoutMs, action.verifyIntervalMs, { delay, now });
|
|
136
|
+
if (!verifyResult.ok) {
|
|
137
|
+
log(`FAILED ${action.name}: ${verifyResult.detail}`);
|
|
138
|
+
results.push({ name: action.name, status: "failed", detail: verifyResult.detail });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
log(`ok ${action.name}`);
|
|
144
|
+
results.push({ name: action.name, status: "ok", detail: null });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { ok: results.every((r) => r.status !== "failed"), results };
|
|
148
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-budget-driven human-approval-required merge gate (phase 3 of the
|
|
3
|
+
* fail-closed PR size budget). Pure, no I/O: consumes an
|
|
4
|
+
* already-resolved size-budget outcome (see check-size-budget.mjs's
|
|
5
|
+
* computeSizeBudget/evaluatePrSizeBudget — this module never recomputes it)
|
|
6
|
+
* plus a human-scoped review decision, and decides whether merge must wait
|
|
7
|
+
* for a human APPROVED review with zero unresolved CHANGES_REQUESTED.
|
|
8
|
+
*
|
|
9
|
+
* This gate is consulted IN ADDITION TO resolveEffectiveMergeAuthorized /
|
|
10
|
+
* humanMergeOnly (@dev-loops/core/config) — never instead of, and never as a
|
|
11
|
+
* relaxation. A `pass` outcome that never touches the T1 tier carries no
|
|
12
|
+
* size-imposed requirement at all; normal merge authorization applies
|
|
13
|
+
* unchanged.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { SUBMITTED_REVIEW_STATES, isCopilotLogin } from "../github/copilot-helpers.mjs";
|
|
17
|
+
|
|
18
|
+
const VALID_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve whether an escalated/T1 PR's merge must wait for a human APPROVED
|
|
22
|
+
* review with zero unresolved CHANGES_REQUESTED.
|
|
23
|
+
*
|
|
24
|
+
* FAILS CLOSED: an unreadable `sizeOutcome`, a non-boolean `touchesT1`, a
|
|
25
|
+
* `reviewDecision` that is not exactly `"APPROVED"`, or a non-zero/unreadable
|
|
26
|
+
* `unresolvedChangesRequestedCount` all require human approval (return
|
|
27
|
+
* `true`). A `pass` outcome that never touches the T1 tier returns `false`
|
|
28
|
+
* (no size-imposed requirement).
|
|
29
|
+
*
|
|
30
|
+
* `sizeOutcome === "block"` is treated at least as strictly as `"escalate"`:
|
|
31
|
+
* the issue's own wording names only "escalated or T1", but a block outcome
|
|
32
|
+
* reaching this gate (e.g. new commits landed T1-heavy code after an earlier
|
|
33
|
+
* `pass`/`escalate` draft-gate check) must never require LESS than escalate
|
|
34
|
+
* does.
|
|
35
|
+
*
|
|
36
|
+
* `reviewDecision` MUST already be scoped to human reviewers only — see
|
|
37
|
+
* {@link resolveHumanReviewDecision}, which derives it from raw PR reviews
|
|
38
|
+
* and treats a Copilot review as never satisfying it.
|
|
39
|
+
*
|
|
40
|
+
* `touchesT1` is unprefixed here, but the persisted verdict field a caller
|
|
41
|
+
* would source it from is size-namespaced (`sizeTouchesT1` in
|
|
42
|
+
* copilot-helpers.mjs's detect-checkpoint-evidence output) — a caller wiring
|
|
43
|
+
* that evidence in MUST remap the field name, not spread it as-is.
|
|
44
|
+
*
|
|
45
|
+
* @param {{
|
|
46
|
+
* sizeOutcome?: "pass"|"escalate"|"block"|null,
|
|
47
|
+
* touchesT1?: boolean,
|
|
48
|
+
* reviewDecision?: "APPROVED"|"CHANGES_REQUESTED"|null,
|
|
49
|
+
* unresolvedChangesRequestedCount?: number,
|
|
50
|
+
* }} [input]
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveSizeBudgetHumanApprovalRequired({
|
|
54
|
+
sizeOutcome,
|
|
55
|
+
touchesT1,
|
|
56
|
+
reviewDecision,
|
|
57
|
+
unresolvedChangesRequestedCount,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!VALID_SIZE_OUTCOMES.has(sizeOutcome)) return true; // size evidence absent/unreadable
|
|
60
|
+
if (typeof touchesT1 !== "boolean") return true; // T1-touch signal missing/unreadable
|
|
61
|
+
|
|
62
|
+
const requiresEscalatedReview = sizeOutcome === "escalate" || sizeOutcome === "block" || touchesT1 === true;
|
|
63
|
+
if (!requiresEscalatedReview) return false; // pass, T1 untouched — no size-imposed requirement
|
|
64
|
+
|
|
65
|
+
if (reviewDecision !== "APPROVED") return true; // absent / CHANGES_REQUESTED / Copilot-only / unknown
|
|
66
|
+
if (typeof unresolvedChangesRequestedCount !== "number" || unresolvedChangesRequestedCount !== 0) return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reduce raw PR reviews to each human login's LATEST submitted state,
|
|
72
|
+
* excluding the Copilot bot login entirely (a Copilot review can never
|
|
73
|
+
* satisfy or block this gate) and any state outside
|
|
74
|
+
* {@link SUBMITTED_REVIEW_STATES} (e.g. a review payload that already
|
|
75
|
+
* dropped to a bare object). Reviews are assumed ordered oldest-first (the
|
|
76
|
+
* order GitHub's REST/GraphQL review lists are returned in), so the last
|
|
77
|
+
* occurrence of a login wins.
|
|
78
|
+
*
|
|
79
|
+
* @param {Array<{ login?: string, state?: string }>} reviews
|
|
80
|
+
* @returns {Map<string, string>}
|
|
81
|
+
*/
|
|
82
|
+
function latestHumanReviewStatesByLogin(reviews) {
|
|
83
|
+
const latestByLogin = new Map();
|
|
84
|
+
for (const review of Array.isArray(reviews) ? reviews : []) {
|
|
85
|
+
const login = typeof review?.login === "string" ? review.login : null;
|
|
86
|
+
const state = typeof review?.state === "string" ? review.state : null;
|
|
87
|
+
if (!login || !state || isCopilotLogin(login) || !SUBMITTED_REVIEW_STATES.has(state)) continue;
|
|
88
|
+
latestByLogin.set(login, state);
|
|
89
|
+
}
|
|
90
|
+
return latestByLogin;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Derive a human-scoped review decision from raw PR reviews, so a Copilot
|
|
95
|
+
* review can never satisfy {@link resolveSizeBudgetHumanApprovalRequired}'s
|
|
96
|
+
* `reviewDecision === "APPROVED"` check. A CHANGES_REQUESTED from any human
|
|
97
|
+
* login wins over an APPROVED from another (matching GitHub's own
|
|
98
|
+
* reviewDecision semantics: any outstanding requested change blocks).
|
|
99
|
+
*
|
|
100
|
+
* @param {Array<{ login?: string, state?: string }>} reviews
|
|
101
|
+
* @returns {"APPROVED"|"CHANGES_REQUESTED"|null}
|
|
102
|
+
*/
|
|
103
|
+
export function resolveHumanReviewDecision(reviews) {
|
|
104
|
+
const states = [...latestHumanReviewStatesByLogin(reviews).values()];
|
|
105
|
+
if (states.includes("CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
|
|
106
|
+
if (states.includes("APPROVED")) return "APPROVED";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Count human logins whose LATEST submitted review is CHANGES_REQUESTED (a
|
|
112
|
+
* login who later re-reviewed with APPROVED/COMMENTED, superseding their own
|
|
113
|
+
* earlier CHANGES_REQUESTED, does not count). Copilot is excluded.
|
|
114
|
+
*
|
|
115
|
+
* @param {Array<{ login?: string, state?: string }>} reviews
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
export function countUnresolvedHumanChangesRequested(reviews) {
|
|
119
|
+
const states = [...latestHumanReviewStatesByLogin(reviews).values()];
|
|
120
|
+
return states.filter((state) => state === "CHANGES_REQUESTED").length;
|
|
121
|
+
}
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* business fields
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
33
|
+
import { normalizeGateReviewVerdict } from "./policy-constants.mjs";
|
|
34
|
+
|
|
32
35
|
/** Stable state name constants for the tracker-first story-to-PR lifecycle. */
|
|
33
36
|
export const TRACKER_PR_STATE = Object.freeze({
|
|
34
37
|
/**
|
|
@@ -120,19 +123,6 @@ export const REVERSE_SYNC_ACTION = Object.freeze({
|
|
|
120
123
|
[TRACKER_PR_STATE.BLOCKED_NEEDS_USER_DECISION]: "none",
|
|
121
124
|
});
|
|
122
125
|
|
|
123
|
-
const GATE_REVIEW_VERDICT_SET = new Set(["clean", "findings_present", "blocked"]);
|
|
124
|
-
|
|
125
|
-
function normalizeSha(value) {
|
|
126
|
-
return typeof value === "string" && value.trim().length > 0
|
|
127
|
-
? value.trim()
|
|
128
|
-
: null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function normalizeGateReviewVerdict(value) {
|
|
132
|
-
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
133
|
-
return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
126
|
function hasCleanVisibleCurrentHeadDraftGate(snapshot) {
|
|
137
127
|
return snapshot.prHeadSha !== null
|
|
138
128
|
&& snapshot.draftGateCommentVisible
|
|
@@ -236,9 +226,9 @@ export function normalizeTrackerPrSnapshot(raw) {
|
|
|
236
226
|
prDraft: normalizeBooleanLike(raw.prDraft),
|
|
237
227
|
prMerged: normalizeBooleanLike(raw.prMerged),
|
|
238
228
|
prClosed: normalizeBooleanLike(raw.prClosed),
|
|
239
|
-
prHeadSha: prExists ?
|
|
229
|
+
prHeadSha: prExists ? trimmedOrNull(raw.prHeadSha) : null,
|
|
240
230
|
draftGateCommentVisible: normalizeBooleanLike(raw.draftGateCommentVisible),
|
|
241
|
-
draftGateCommentHeadSha: prExists ?
|
|
231
|
+
draftGateCommentHeadSha: prExists ? trimmedOrNull(raw.draftGateCommentHeadSha) : null,
|
|
242
232
|
draftGateCommentVerdict: normalizeGateReviewVerdict(raw.draftGateCommentVerdict),
|
|
243
233
|
};
|
|
244
234
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// UI designer/vision recorded-evidence auto-scoping — UI half of ADR 0041
|
|
2
|
+
// (RFC issue #1438, decision #0041), tracked by issue #1443.
|
|
3
|
+
//
|
|
4
|
+
// Deterministic, path-triggered, fail-closed criterion modeled on the UI e2e
|
|
5
|
+
// scoping check (./ui-e2e-scoping.mjs, issue #976): a PR that adds or modifies
|
|
6
|
+
// a *rendered* HTML artifact (docs/articles/*.html or docs/presentations/*.html)
|
|
7
|
+
// MUST carry recorded designer/vision review evidence for every touched
|
|
8
|
+
// rendered artifact, and each recorded outcome MUST be the loop's satisfied
|
|
9
|
+
// state (`ui_review_satisfied`). The check is grounded in the designer/vision
|
|
10
|
+
// review loop's EXISTING outcome + artifact bundle contract — no new evidence
|
|
11
|
+
// schema (ADR 0041: "reuse the designer/vision loop's existing outcome +
|
|
12
|
+
// artifact contract rather than a new shape"). Accessibility facts come from
|
|
13
|
+
// the captured axe.json, never judged from pixels, and no human is required by
|
|
14
|
+
// default — the same autonomy the gate-evidence path already has.
|
|
15
|
+
//
|
|
16
|
+
// Inclusion is triggered by the changed-file set, never by annotating the PR.
|
|
17
|
+
// Light-mode and spike-mode relaxed-gate carve-outs are honored: when the PR
|
|
18
|
+
// is light-dispatched / under the light threshold or a spike run, the required
|
|
19
|
+
// designer/vision check is exempt (the requirement is relaxed exactly like the
|
|
20
|
+
// other gates), letting small and exploratory work stay cheap (ADR 0041).
|
|
21
|
+
|
|
22
|
+
import { classifyRenderedArtifactPath } from "./ui-e2e-scoping.mjs";
|
|
23
|
+
|
|
24
|
+
// The recorded outcome the required check treats as the satisfied state. The
|
|
25
|
+
// designer/vision loop emits exactly one of these (ui-designer-review-loop.md);
|
|
26
|
+
// any other recorded outcome (continue_ui_fix_loop, blocked_needs_human_decision)
|
|
27
|
+
// or an absent outcome blocks.
|
|
28
|
+
export const DESIGNER_REVIEW_SATISFIED_OUTCOME = "ui_review_satisfied";
|
|
29
|
+
|
|
30
|
+
// The loop's existing outcome enum, re-used verbatim (no new schema).
|
|
31
|
+
export const DESIGNER_REVIEW_OUTCOMES = Object.freeze([
|
|
32
|
+
DESIGNER_REVIEW_SATISFIED_OUTCOME,
|
|
33
|
+
"continue_ui_fix_loop",
|
|
34
|
+
"blocked_needs_human_decision",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a designer/vision recorded-evidence value into an artifact-id →
|
|
39
|
+
* outcome map, tolerating both the array and keyed-object shapes.
|
|
40
|
+
*
|
|
41
|
+
* The evidence reuses the loop's existing outcome + artifact-bundle record: an
|
|
42
|
+
* entry identifies the rendered artifact by its full repo-relative path and
|
|
43
|
+
* carries the loop's `outcome`. A record may also carry its `artifactBundle`
|
|
44
|
+
* (the loop's existing bundle) - carried through untouched, never validated to
|
|
45
|
+
* a new shape here.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{artifact:string,outcome:string,artifactBundle?:object}>|Record<string,{outcome:string,artifactBundle?:object}>|null} evidence
|
|
48
|
+
* @returns {Map<string,{outcome:string,artifactBundle?:object}>}
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeDesignerReviewEvidence(evidence) {
|
|
51
|
+
const byArtifact = new Map();
|
|
52
|
+
if (evidence == null) return byArtifact;
|
|
53
|
+
if (Array.isArray(evidence)) {
|
|
54
|
+
for (const entry of evidence) {
|
|
55
|
+
if (entry && typeof entry === "object" && typeof entry.artifact === "string" && entry.artifact.length > 0) {
|
|
56
|
+
byArtifact.set(entry.artifact, entry);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return byArtifact;
|
|
60
|
+
}
|
|
61
|
+
if (typeof evidence === "object") {
|
|
62
|
+
for (const [artifact, record] of Object.entries(evidence)) {
|
|
63
|
+
if (record && typeof record === "object") {
|
|
64
|
+
byArtifact.set(artifact, record);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return byArtifact;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deterministic designer/vision recorded-evidence scoping check.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} changedPaths - PR changed-file paths.
|
|
75
|
+
* @param {{
|
|
76
|
+
* designerReviewEvidence?: Array|Record|null,
|
|
77
|
+
* designerReviewExempt?: boolean,
|
|
78
|
+
* }} [opts]
|
|
79
|
+
* designerReviewEvidence: the loop's recorded outcome + artifact bundle for
|
|
80
|
+
* the artifacts in scope (null/undefined = "not recorded" → fails closed).
|
|
81
|
+
* designerReviewExempt: true when a light-mode/spike relaxed-gate carve-out
|
|
82
|
+
* applies (relaxes the requirement entirely).
|
|
83
|
+
* @returns {{
|
|
84
|
+
* required: boolean,
|
|
85
|
+
* artifacts: Array<{path,kind,id,registered}>,
|
|
86
|
+
* missing: string[],
|
|
87
|
+
* unsatisfied: string[],
|
|
88
|
+
* satisfied: boolean,
|
|
89
|
+
* reason: string|null,
|
|
90
|
+
* }}
|
|
91
|
+
*/
|
|
92
|
+
export function evaluateUiDesignerReviewScoping(changedPaths = [], {
|
|
93
|
+
designerReviewEvidence = null,
|
|
94
|
+
designerReviewExempt = false,
|
|
95
|
+
} = {}) {
|
|
96
|
+
const artifacts = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
|
|
99
|
+
const descriptor = classifyRenderedArtifactPath(p);
|
|
100
|
+
// The designer/vision recorded-evidence requirement applies ONLY to
|
|
101
|
+
// rendered HTML deliverables (docs/articles|presentations/*.html). The
|
|
102
|
+
// shared classifier also recognizes the headless viewer source path
|
|
103
|
+
// (kind "viewer"), which is a runtime script, not a rendered artifact -
|
|
104
|
+
// exclude it so a viewer-source change does not demand designer evidence.
|
|
105
|
+
if (descriptor && descriptor.kind !== "viewer" && !seen.has(descriptor.path)) {
|
|
106
|
+
seen.add(descriptor.path);
|
|
107
|
+
artifacts.push(descriptor);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const required = artifacts.length > 0;
|
|
112
|
+
if (!required) {
|
|
113
|
+
return { required: false, artifacts, missing: [], unsatisfied: [], satisfied: true, reason: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Honor the light/spike relaxed-gate carve-outs (ADR 0041): the requirement
|
|
117
|
+
// is exempt, so a rendered artifact change in those runs does not block.
|
|
118
|
+
if (designerReviewExempt === true) {
|
|
119
|
+
return {
|
|
120
|
+
required: true,
|
|
121
|
+
artifacts,
|
|
122
|
+
missing: [],
|
|
123
|
+
unsatisfied: [],
|
|
124
|
+
satisfied: true,
|
|
125
|
+
reason: "exempted_by_relaxed_gate_profile",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const byArtifact = normalizeDesignerReviewEvidence(designerReviewEvidence);
|
|
130
|
+
|
|
131
|
+
// Fail closed: any touched rendered artifact with NO recorded designer/vision
|
|
132
|
+
// evidence blocks and names itself so the fix is unambiguous (record the loop's
|
|
133
|
+
// outcome+bundle for it).
|
|
134
|
+
const missing = artifacts.filter((a) => !byArtifact.has(a.id)).map((a) => a.id);
|
|
135
|
+
if (missing.length > 0) {
|
|
136
|
+
return {
|
|
137
|
+
required: true,
|
|
138
|
+
artifacts,
|
|
139
|
+
missing,
|
|
140
|
+
unsatisfied: [],
|
|
141
|
+
satisfied: false,
|
|
142
|
+
reason:
|
|
143
|
+
`Designer/vision review evidence is required: this PR changes rendered artifact(s) ` +
|
|
144
|
+
`${missing.join(", ")} that have no recorded designer/vision review outcome + artifact bundle. ` +
|
|
145
|
+
`Run the designer/vision review loop for the artifact(s) and record its outcome so the required ` +
|
|
146
|
+
`recorded-evidence check can pass before this gate can proceed.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fail closed: recorded evidence exists but the recorded outcome is not the
|
|
151
|
+
// loop's satisfied state (continue_ui_fix_loop, blocked_needs_human_decision).
|
|
152
|
+
const unsatisfied = artifacts
|
|
153
|
+
.filter((a) => (byArtifact.get(a.id)?.outcome ?? null) !== DESIGNER_REVIEW_SATISFIED_OUTCOME)
|
|
154
|
+
.map((a) => a.id);
|
|
155
|
+
if (unsatisfied.length > 0) {
|
|
156
|
+
return {
|
|
157
|
+
required: true,
|
|
158
|
+
artifacts,
|
|
159
|
+
missing: [],
|
|
160
|
+
unsatisfied,
|
|
161
|
+
satisfied: false,
|
|
162
|
+
reason:
|
|
163
|
+
`Designer/vision review is not satisfied: this PR changes rendered artifact(s) ` +
|
|
164
|
+
`${unsatisfied.join(", ")}, but the recorded designer/vision review outcome is not ` +
|
|
165
|
+
`\`${DESIGNER_REVIEW_SATISFIED_OUTCOME}\`. Complete the designer/vision review loop until the ` +
|
|
166
|
+
`recorded outcome is the satisfied state before this gate can proceed.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { required: true, artifacts, missing: [], unsatisfied: [], satisfied: true, reason: null };
|
|
171
|
+
}
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* posting, visual-regression/pixel-diffing, cross-browser matrix.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
28
|
+
|
|
27
29
|
const MUST_FIX = "must-fix";
|
|
28
30
|
|
|
29
31
|
/** Request header the drive advertises its drive-session id on, so a cooperating
|
|
@@ -282,7 +284,7 @@ export async function driveUiReview(
|
|
|
282
284
|
// No-retry is a fixed policy — log it every run so the bound is never implicit.
|
|
283
285
|
record(`caps: maxScreenshots=${resolvedCaps.maxScreenshots}, maxFlows=${resolvedCaps.maxFlows}, maxStepsPerFlow=${resolvedCaps.maxStepsPerFlow}, retries=${resolvedCaps.retries} (no-retry)`);
|
|
284
286
|
|
|
285
|
-
const session =
|
|
287
|
+
const session = trimmedOrNull(driveSession);
|
|
286
288
|
const base = () => ({ appUrl: appUrl ?? null, logs, driveSession: session });
|
|
287
289
|
|
|
288
290
|
// 1. Authenticate as the target role. Fail closed: no session -> STOP, drive
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { isClaudeHarness } from "./run-context.mjs";
|
|
24
24
|
import { sanitizeCopilotSummonTokens } from "../github/copilot-helpers.mjs";
|
|
25
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
25
26
|
|
|
26
27
|
/** Findings past this cap are dropped from the artifact and the drop is logged. */
|
|
27
28
|
export const ARTIFACT_MAX_FINDINGS = 100;
|
|
@@ -34,10 +35,6 @@ export const ARTIFACT_MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
|
34
35
|
* a server-log exception the request raised. */
|
|
35
36
|
const SERVER_ERROR_KINDS = new Set(["error-response", "server-log-exception"]);
|
|
36
37
|
|
|
37
|
-
function normalizeSha(value) {
|
|
38
|
-
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
38
|
/** A confirmed user-facing server error: a must-fix error-response / server-log
|
|
42
39
|
* exception. This is the single-source predicate the severity policy keys off. */
|
|
43
40
|
function isBlockingFinding(finding) {
|
|
@@ -195,7 +192,7 @@ export function buildReviewInput({ findings = [], headSha = null, hosting = null
|
|
|
195
192
|
const verdict = list.length === 0 ? "APPROVE" : (blocking ? "REQUEST_CHANGES" : "COMMENT");
|
|
196
193
|
|
|
197
194
|
return {
|
|
198
|
-
headSha:
|
|
195
|
+
headSha: trimmedOrNull(headSha),
|
|
199
196
|
verdict,
|
|
200
197
|
inlineComments,
|
|
201
198
|
summaryFindings,
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* rather than dropping anything.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
40
|
+
|
|
39
41
|
const ROW_STATUS = Object.freeze({
|
|
40
42
|
DROPPED: "dropped",
|
|
41
43
|
DROP_FAILED: "drop-failed",
|
|
@@ -247,7 +249,7 @@ export async function teardown(
|
|
|
247
249
|
// accretes one secret entry per run; deleting it keeps the hosting target
|
|
248
250
|
// from piling up. Only ever acts on an explicit gist id from the report
|
|
249
251
|
// result; a missing id is NONE (nothing published, or Claude-hosted).
|
|
250
|
-
const gistId =
|
|
252
|
+
const gistId = trimmedOrNull(gist?.id);
|
|
251
253
|
let gistLedger;
|
|
252
254
|
if (!gistId) {
|
|
253
255
|
gistLedger = { id: null, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.NONE, detail: "no hosting gist to prune" };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
2
|
-
import { parseJsonText } from "../github/review-threads.mjs";
|
|
3
2
|
import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
|
|
3
|
+
import { ghGraphql } from "../github/gh.mjs";
|
|
4
4
|
|
|
5
5
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -30,32 +30,6 @@ function validateRepo(repo) {
|
|
|
30
30
|
return repo;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// ── API helpers ──────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
36
|
-
const fieldArgs = [];
|
|
37
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
38
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
39
|
-
}
|
|
40
|
-
const result = await runChild(
|
|
41
|
-
"gh",
|
|
42
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
43
|
-
env,
|
|
44
|
-
);
|
|
45
|
-
if (result.code !== 0) {
|
|
46
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
47
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
48
|
-
}
|
|
49
|
-
const payload = parseJsonText(result.stdout);
|
|
50
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
51
|
-
throw Object.assign(
|
|
52
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
53
|
-
{ code: "GRAPHQL_ERROR" },
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
return payload;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
33
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
60
34
|
|
|
61
35
|
const GET_USER_ID = [
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
2
|
-
import { parseJsonText } from "../github/review-threads.mjs";
|
|
3
2
|
import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
|
|
4
3
|
import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
|
|
5
4
|
import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
|
|
5
|
+
import { ghGraphql } from "../github/gh.mjs";
|
|
6
6
|
|
|
7
7
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -29,32 +29,6 @@ function validateRepo(repo) {
|
|
|
29
29
|
return repo;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
// ── API helpers ──────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
35
|
-
const fieldArgs = [];
|
|
36
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
37
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
38
|
-
}
|
|
39
|
-
const result = await runChild(
|
|
40
|
-
"gh",
|
|
41
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
42
|
-
env,
|
|
43
|
-
);
|
|
44
|
-
if (result.code !== 0) {
|
|
45
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
46
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
47
|
-
}
|
|
48
|
-
const payload = parseJsonText(result.stdout);
|
|
49
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
50
|
-
throw Object.assign(
|
|
51
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
52
|
-
{ code: "GRAPHQL_ERROR" },
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return payload;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
32
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
59
33
|
|
|
60
34
|
const GET_USER_ID = [
|
|
@@ -395,7 +369,7 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
|
395
369
|
{ code: "CONFIG_ERROR" },
|
|
396
370
|
);
|
|
397
371
|
}
|
|
398
|
-
const decision = await runPickupRefinementGate({ issueNumber, repo, env, runChild: child, auto: false });
|
|
372
|
+
const decision = await runPickupRefinementGate({ issueNumber, repo, env, runChild: child, auto: false, repoRoot: cwd });
|
|
399
373
|
refinement = { refined: decision.action === "enqueue" };
|
|
400
374
|
}
|
|
401
375
|
}
|