@dev-loops/core 1.0.3 → 1.0.4-pre.1
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 +1 -1
- package/src/analysis/change-classifier.mjs +49 -28
- package/src/analysis/diff-analyzer.mjs +127 -31
- package/src/claude/asset-generation.mjs +36 -1
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +198 -86
- package/src/config/extension-defaults.yaml +66 -6
- package/src/github/copilot-helpers.mjs +85 -20
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +58 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +186 -5
- package/src/loop/pr-gate-coordination.mjs +339 -67
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +40 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -11,20 +11,71 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
|
11
11
|
// review is.
|
|
12
12
|
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
13
13
|
|
|
14
|
-
// Copilot's COMMENTED review
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
14
|
+
// Copilot's `ccr-overview-v2` COMMENTED review opens with ONE of three
|
|
15
|
+
// disposition headers: "### 🟢 Approval recommended" (clean), "### 🟡 Changes
|
|
16
|
+
// recommended" (findings), or "### 🔵 Needs a closer look" (non-approval, the
|
|
17
|
+
// reviewer is uncertain). All Copilot reviews are COMMENTED, so this header is
|
|
18
|
+
// the only body signal. We match the disposition by its TEXT, not the emoji:
|
|
19
|
+
// the old 🟡-only key read the 🔵 "Needs a closer look" non-approval as clean
|
|
20
|
+
// and would fail-open on any future glyph change. Line-anchor the disposition
|
|
21
|
+
// header (`^###\s+`, multiline) with the emoji optional so a body merely
|
|
22
|
+
// QUOTING a phrase ("No changes recommended") never false-positives.
|
|
23
|
+
const COPILOT_DISPOSITION_HEADER_RE = /^###\s+(.+)$/mu;
|
|
24
|
+
// Strip a leading emoji/glyph run (any leading non-letters) so the text alone
|
|
25
|
+
// is compared, then lowercase for a case-insensitive disposition lookup.
|
|
26
|
+
const COPILOT_DISPOSITION_LEADING_GLYPHS_RE = /^[^\p{L}]+/u;
|
|
27
|
+
const COPILOT_CLEAN_DISPOSITION = "approval recommended";
|
|
28
|
+
const COPILOT_CHANGES_RECOMMENDED_DISPOSITION = "changes recommended";
|
|
29
|
+
const COPILOT_NEEDS_CLOSER_LOOK_DISPOSITION = "needs a closer look";
|
|
30
|
+
|
|
31
|
+
// Canonical current-head Copilot review body dispositions. `changes_recommended`
|
|
32
|
+
// (🟡) and `unrecognized` are actionable non-approvals; `needs_closer_look` (🔵)
|
|
33
|
+
// is a soft, conductor-overridable non-approval; `clean` (🟢) and `none` (no
|
|
34
|
+
// disposition header) are not findings. Both the loop-block signal
|
|
35
|
+
// (copilotReviewBodySignalsChanges) and the merge-convergence precondition read
|
|
36
|
+
// the SAME classification, so the two can never drift.
|
|
37
|
+
export const COPILOT_DISPOSITION = Object.freeze({
|
|
38
|
+
CLEAN: "clean",
|
|
39
|
+
CHANGES_RECOMMENDED: "changes_recommended",
|
|
40
|
+
NEEDS_CLOSER_LOOK: "needs_closer_look",
|
|
41
|
+
UNRECOGNIZED: "unrecognized",
|
|
42
|
+
NONE: "none",
|
|
43
|
+
});
|
|
22
44
|
|
|
23
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Classify a Copilot review's current-head body disposition. Text-matched (not
|
|
47
|
+
* emoji-keyed) so a glyph change never silently reclassifies. Fails closed: an
|
|
48
|
+
* unrecognized `### ` disposition header returns UNRECOGNIZED (an actionable
|
|
49
|
+
* non-approval), so a future format change degrades safe.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyCopilotReviewBodyDisposition(state, body) {
|
|
24
52
|
const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
|
|
25
|
-
|
|
26
|
-
if (normalizedState
|
|
27
|
-
|
|
53
|
+
// A human-style CHANGES_REQUESTED is always actionable regardless of body.
|
|
54
|
+
if (normalizedState === "CHANGES_REQUESTED") return COPILOT_DISPOSITION.CHANGES_RECOMMENDED;
|
|
55
|
+
if (normalizedState !== "COMMENTED") return COPILOT_DISPOSITION.NONE;
|
|
56
|
+
if (typeof body !== "string") return COPILOT_DISPOSITION.NONE;
|
|
57
|
+
|
|
58
|
+
const headerMatch = body.match(COPILOT_DISPOSITION_HEADER_RE);
|
|
59
|
+
// No disposition header at all (empty body, generic footer, legacy format):
|
|
60
|
+
// no body signal, so this is not a finding.
|
|
61
|
+
if (!headerMatch) return COPILOT_DISPOSITION.NONE;
|
|
62
|
+
|
|
63
|
+
const disposition = headerMatch[1]
|
|
64
|
+
.replace(COPILOT_DISPOSITION_LEADING_GLYPHS_RE, "")
|
|
65
|
+
.trim()
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
if (disposition === COPILOT_CLEAN_DISPOSITION) return COPILOT_DISPOSITION.CLEAN;
|
|
68
|
+
if (disposition === COPILOT_CHANGES_RECOMMENDED_DISPOSITION) return COPILOT_DISPOSITION.CHANGES_RECOMMENDED;
|
|
69
|
+
if (disposition === COPILOT_NEEDS_CLOSER_LOOK_DISPOSITION) return COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK;
|
|
70
|
+
// Fail closed on an unrecognized disposition header on the current head.
|
|
71
|
+
return COPILOT_DISPOSITION.UNRECOGNIZED;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function copilotReviewBodySignalsChanges(state, body) {
|
|
75
|
+
const disposition = classifyCopilotReviewBodyDisposition(state, body);
|
|
76
|
+
return disposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED
|
|
77
|
+
|| disposition === COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK
|
|
78
|
+
|| disposition === COPILOT_DISPOSITION.UNRECOGNIZED;
|
|
28
79
|
}
|
|
29
80
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
30
81
|
// `review` is a RECOGNIZED gate header that carries no draft/pre-approval
|
|
@@ -213,7 +264,7 @@ export function sanitizeCopilotSummonTokens(text) {
|
|
|
213
264
|
// line) from `text`, leaving only bare-text markdown to scan. Unlike
|
|
214
265
|
// transformNonFencedLines, fenced content here must be REMOVED, not kept:
|
|
215
266
|
// leaving it would let bare text inside a fence still match the summon scan.
|
|
216
|
-
function stripMarkdownCodeForScan(text) {
|
|
267
|
+
export function stripMarkdownCodeForScan(text) {
|
|
217
268
|
const lines = String(text).split(/\r?\n/);
|
|
218
269
|
let inFencedBlock = false;
|
|
219
270
|
let fencedDelimiter = "";
|
|
@@ -259,7 +310,8 @@ export function normalizeTimestamp(value) {
|
|
|
259
310
|
export function extractReviewCommitSha(review) {
|
|
260
311
|
const graphqlSha = typeof review?.commit?.oid === "string" ? review.commit.oid.trim() : "";
|
|
261
312
|
const restSha = typeof review?.commit_id === "string" ? review.commit_id.trim() : "";
|
|
262
|
-
const
|
|
313
|
+
const camelCaseSha = typeof review?.commitId === "string" ? review.commitId.trim() : "";
|
|
314
|
+
const sha = graphqlSha || restSha || camelCaseSha;
|
|
263
315
|
return sha.length > 0 ? sha : null;
|
|
264
316
|
}
|
|
265
317
|
|
|
@@ -717,6 +769,11 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
717
769
|
let hasSubmittedReviewOnCurrentHead = false;
|
|
718
770
|
let latestSubmittedReviewOnCurrentHeadAt = null;
|
|
719
771
|
let hasBodyFindingOnCurrentHead = false;
|
|
772
|
+
// The id of the review whose body set hasBodyFindingOnCurrentHead: the
|
|
773
|
+
// review a copilot-body-disposition record must name to clear the finding.
|
|
774
|
+
// Updated on every branch that updates hasBodyFindingOnCurrentHead so the two
|
|
775
|
+
// never drift.
|
|
776
|
+
let bodyFindingReviewId = null;
|
|
720
777
|
let completedCopilotReviewRounds = 0;
|
|
721
778
|
|
|
722
779
|
for (const review of effectiveReviews) {
|
|
@@ -742,21 +799,28 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
742
799
|
const submittedAt = typeof review?.submittedAt === "string"
|
|
743
800
|
? review.submittedAt
|
|
744
801
|
: (typeof review?.submitted_at === "string" ? review.submitted_at : null);
|
|
802
|
+
const reviewId = review?.id !== null && review?.id !== undefined ? String(review.id) : null;
|
|
745
803
|
if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
|
|
746
804
|
latestSubmittedReviewOnCurrentHeadAt = submittedAt;
|
|
747
805
|
hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
806
|
+
bodyFindingReviewId = hasBodyFindingOnCurrentHead ? reviewId : null;
|
|
807
|
+
} else if (submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
|
|
808
|
+
// Equal-timestamp (or both-null) tie on the same head: fail toward
|
|
809
|
+
// surfacing so array order never silently drops a finding. When two
|
|
810
|
+
// tied reviews both signal, no single review owns the finding, so
|
|
811
|
+
// bodyFindingReviewId is null and no disposition record can clear it.
|
|
812
|
+
const tieSignals = copilotReviewBodySignalsChanges(state, review?.body);
|
|
813
|
+
if (tieSignals) {
|
|
814
|
+
bodyFindingReviewId = hasBodyFindingOnCurrentHead ? null : reviewId;
|
|
815
|
+
}
|
|
816
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || tieSignals;
|
|
754
817
|
}
|
|
755
818
|
}
|
|
756
819
|
}
|
|
757
820
|
|
|
758
821
|
return {
|
|
759
822
|
copilotReviews,
|
|
823
|
+
effectiveCopilotReviews: effectiveReviews,
|
|
760
824
|
copilotReviewIds: copilotReviews
|
|
761
825
|
.map((review) => review?.id)
|
|
762
826
|
.filter((id) => id !== null && id !== undefined)
|
|
@@ -767,5 +831,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
767
831
|
hasSubmittedReviewOnCurrentHead,
|
|
768
832
|
latestSubmittedReviewOnCurrentHeadAt,
|
|
769
833
|
hasBodyFindingOnCurrentHead,
|
|
834
|
+
bodyFindingReviewId,
|
|
770
835
|
};
|
|
771
836
|
}
|
package/src/github/gh.mjs
CHANGED
|
@@ -67,7 +67,8 @@ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRu
|
|
|
67
67
|
* @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
|
|
68
68
|
* @param {object} [opts]
|
|
69
69
|
* @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
|
|
70
|
-
* in the response is returned instead of thrown.
|
|
70
|
+
* in the response is returned instead of thrown. This includes a non-zero
|
|
71
|
+
* `gh` exit whose stdout is JSON with an `errors` array.
|
|
71
72
|
*/
|
|
72
73
|
export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
|
|
73
74
|
const fieldArgs = [];
|
|
@@ -80,6 +81,18 @@ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, {
|
|
|
80
81
|
env,
|
|
81
82
|
);
|
|
82
83
|
if (result.code !== 0) {
|
|
84
|
+
// `gh api graphql` exits non-zero when the response carries GraphQL
|
|
85
|
+
// `errors` (e.g. NOT_FOUND) but still prints the JSON on stdout. With
|
|
86
|
+
// allowErrors the caller wants that errors array, so return it.
|
|
87
|
+
if (allowErrors) {
|
|
88
|
+
let errorPayload = null;
|
|
89
|
+
try {
|
|
90
|
+
errorPayload = JSON.parse(result.stdout);
|
|
91
|
+
} catch {
|
|
92
|
+
// not JSON: fall through to GH_API_ERROR
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(errorPayload?.errors)) return errorPayload;
|
|
95
|
+
}
|
|
83
96
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
84
97
|
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
85
98
|
}
|
|
@@ -121,6 +121,11 @@ export function parseReviewThreads(payload) {
|
|
|
121
121
|
const threads = rawThreads.map((thread, threadIndex) => {
|
|
122
122
|
const threadId = normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`);
|
|
123
123
|
const isResolved = Boolean(thread?.isResolved);
|
|
124
|
+
// The review that opened the thread: the root (first) comment's
|
|
125
|
+
// pullRequestReview id. null when the payload does not carry it, so a
|
|
126
|
+
// consumer that needs attribution treats the thread as unattributed.
|
|
127
|
+
const rootReviewId = extractRawComments(thread)[0]?.pullRequestReview?.id;
|
|
128
|
+
const reviewId = typeof rootReviewId === "string" && rootReviewId.length > 0 ? rootReviewId : null;
|
|
124
129
|
const normalizedComments = extractRawComments(thread)
|
|
125
130
|
.map((comment, commentIndex) => normalizeComment(comment, threadId, commentIndex, { isResolved }))
|
|
126
131
|
.sort((left, right) => compareIds(left.id, right.id));
|
|
@@ -141,6 +146,7 @@ export function parseReviewThreads(payload) {
|
|
|
141
146
|
return {
|
|
142
147
|
id: threadId,
|
|
143
148
|
isResolved,
|
|
149
|
+
reviewId,
|
|
144
150
|
isActionable: actionableCommentIds.length > 0,
|
|
145
151
|
commentIds,
|
|
146
152
|
commentDatabaseIds,
|
|
@@ -235,6 +235,34 @@ function shellSegments(command) {
|
|
|
235
235
|
*/
|
|
236
236
|
const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Leading prefix a code-verification/build command may carry before its real executable: the
|
|
240
|
+
* shared `SHELL_EXEC_PREFIX` (env assignments, `command`/`env`/`exec` wrapper words, binary path)
|
|
241
|
+
* plus `nice`/`timeout` process wrappers, scoped to this classifier only so the sibling `gh`/`git`
|
|
242
|
+
* classifiers above are not broadened by wrapper forms they never need to tolerate. Covers bare
|
|
243
|
+
* `nice`, `nice -n <N>`, bare `timeout <duration>`, and `timeout` carrying `-s <sig>`/`-k <dur>`/
|
|
244
|
+
* `--signal=<sig>`/`--kill-after=<dur>`/`--preserve-status`/`--foreground` before the duration —
|
|
245
|
+
* the wrapper forms the coordinator's daily verify/build commands are routinely run behind
|
|
246
|
+
* (`timeout 600 bun run verify`, `nice -n 10 bun run verify`). Not a full flag parser: other
|
|
247
|
+
* `timeout`/`nice` flags are a known, deliberately uncovered ceiling.
|
|
248
|
+
*
|
|
249
|
+
* The `env` wrapper word additionally tolerates zero-or-more trailing `NAME=value` assignments
|
|
250
|
+
* before the real executable (`env CI=1 bun run verify`, `env CI=1 FOO=bar npm test`) — the common
|
|
251
|
+
* everyday `env VAR=value ... cmd` CI-invocation shape, on top of the bare-leading-assignment form
|
|
252
|
+
* (`CI=1 bun run verify`) already covered by the shared assignment run at the front of this prefix.
|
|
253
|
+
* It also tolerates the common `env` OPTION forms (mixed freely with `NAME=value` assignments, in
|
|
254
|
+
* any order/count): `-i`/`--ignore-environment`, `-u <NAME>`/`--unset=<NAME>`, `-C <dir>`/
|
|
255
|
+
* `--chdir=<dir>`, `-S <str>`/`--split-string=<str>`, a bare `-`, and `--` — so
|
|
256
|
+
* `env -u DEVLOOPS_COORDINATOR_READONLY bun run verify`, `env -i bun run verify`, and
|
|
257
|
+
* `env -u FOO CI=1 npm test` all match. Closes the cheap classifier gap where an `env` flag (rather
|
|
258
|
+
* than a `NAME=value` assignment) reached the executable unclassified. Not a full `env` flag parser:
|
|
259
|
+
* any other/exotic `env` option is a known, deliberately uncovered ceiling (documented, not chased).
|
|
260
|
+
* `command`/`exec` do not get the same trailing-assignment/option tolerance — no known daily
|
|
261
|
+
* invocation shape needs it, and adding it would only widen the pattern without a use case.
|
|
262
|
+
*/
|
|
263
|
+
const VERIFY_EXEC_PREFIX =
|
|
264
|
+
"(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:env(?:\\s+(?:[A-Za-z_][A-Za-z0-9_]*=\\S*|-i|--ignore-environment|-u\\s+\\S+|--unset=\\S+|-C\\s+\\S+|--chdir=\\S+|-S\\s+\\S+|--split-string=\\S+|--|-))*\\s+|(?:command|exec)\\s+|nice(?:\\s+-n\\s+\\S+)?\\s+|timeout(?:\\s+(?:-s\\s+\\S+|-k\\s+\\S+|--signal=\\S+|--kill-after=\\S+|--preserve-status|--foreground))*\\s+\\S+\\s+)*(?:\\S*/)?";
|
|
265
|
+
|
|
238
266
|
/**
|
|
239
267
|
* Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
|
|
240
268
|
* Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
|
|
@@ -802,29 +830,197 @@ export function commandContainsCopilotSummonComment(command) {
|
|
|
802
830
|
}
|
|
803
831
|
|
|
804
832
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
833
|
+
* Blank the CONTENTS of quoted string literals ('...' and "...") to a space, EXCEPT a quote that is
|
|
834
|
+
* the payload of an executable-code flag — a short-flag cluster CONTAINING `c` ANYWHERE in the
|
|
835
|
+
* cluster (`sh -c '…'`, `bash -c '…'`, `bash -lc '…'`, `bash -ec '…'`, `bash -cl '…'`, `bash -ci '…'`,
|
|
836
|
+
* `bash -cx '…'`) — bash treats ANY `-c`-containing short-flag cluster as command-execution
|
|
837
|
+
* regardless of where `c` sits in the cluster, not only one that ENDS in `c` — optionally followed by
|
|
838
|
+
* a `--` terminator (`bash -c -- '…'`), a long `--command` flag, or `eval` — that payload is REAL
|
|
839
|
+
* shell syntax to be executed, not inert data, so blanking it would hide an actual poll-loop
|
|
840
|
+
* construct wrapped in one of these forms. A real poll loop's structural tokens
|
|
841
|
+
* (`while`/`until`/`for`/`do`/`sleep`/`done`, a `[ -f … ]` file test) are UNQUOTED shell syntax; a
|
|
842
|
+
* quoted issue body, `--body` payload, or quoted example that merely mentions them carries them
|
|
843
|
+
* INSIDE quotes as inert data. Blanking those quoted contents is what lets the poll-loop matchers key
|
|
844
|
+
* on an actual loop CONSTRUCT rather than the token sequence appearing anywhere in a command
|
|
845
|
+
* (`gh issue create --body "while … sleep … done"` must not be flagged). The `s` (dotAll) flag lets
|
|
846
|
+
* `.` match a newline too, so a MULTI-LINE quoted `--body` (a real issue body commonly spans lines) is
|
|
847
|
+
* stripped in full, not just its first line.
|
|
848
|
+
* A quoted string that itself contains a command substitution (`$(...)`) or a backtick (`` `...` ``)
|
|
849
|
+
* is executable code, not inert data — its inner command runs regardless of the surrounding quotes.
|
|
850
|
+
* Blanking it would hide a real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5;
|
|
851
|
+
* done` that the gh/loop-state ban already denies, so such a quoted literal is PRESERVED (fail-closed
|
|
852
|
+
* direction) ahead of the `-c`/`--command`/`eval` exemption check below.
|
|
853
|
+
*
|
|
854
|
+
* The `-c`-cluster/`--command`/`eval` exemption is intentionally coarse in the fail-closed direction:
|
|
855
|
+
* it looks only for a `c` anywhere in a preceding short-flag cluster (or `--command`/`eval`), not for
|
|
856
|
+
* a shell-interpreter anchor. A non-shell command carrying `-c` (e.g. `grep -ci '<loop text>'`,
|
|
857
|
+
* `wc -c`) may therefore have its quoted argument preserved too and get over-denied — an accepted
|
|
858
|
+
* benign false positive, because tightening the exemption to a shell-interpreter anchor would risk a
|
|
859
|
+
* fail-open (missing a real `sh -c` poll loop), the worse direction.
|
|
860
|
+
*
|
|
861
|
+
* ponytail: blanks balanced quote pairs only, with the command-substitution/backtick preserve rule
|
|
862
|
+
* and the `-c`-cluster/`--command`/`eval` exemption above — no full shell tokenizer (mismatched/
|
|
863
|
+
* partial quotes and other exec-wrapper flags stay out of scope). Accepted ceiling: a deliberately
|
|
864
|
+
* quoted structural keyword (e.g. `"sleep"`) placed inside a real UNQUOTED loop is blanked like any
|
|
865
|
+
* other quoted literal and can therefore evade the ban — accepted as a deliberate-evasion class, not
|
|
866
|
+
* a natural shape a genuine poll loop takes.
|
|
867
|
+
* @param {string} command @returns {string}
|
|
868
|
+
*/
|
|
869
|
+
function stripQuotedLiterals(command) {
|
|
870
|
+
return command.replace(/(['"])((?:(?!\1).)*)\1/gs, (match, _quote, inner, offset, full) => {
|
|
871
|
+
// A quoted string containing a command substitution ($()) or backtick is executable code, not
|
|
872
|
+
// inert data — its command runs regardless of the surrounding quotes. Blanking it would hide a
|
|
873
|
+
// real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5; done` that the
|
|
874
|
+
// gh/loop-state ban already denied. Preserve it (fail-closed direction).
|
|
875
|
+
if (/\$\(|`/.test(inner)) {
|
|
876
|
+
return match;
|
|
877
|
+
}
|
|
878
|
+
const before = full.slice(0, offset);
|
|
879
|
+
if (/(?:^|\s)(?:-[A-Za-z]*c[A-Za-z]*(?:\s+--)?|--command|eval)\s*$/.test(before)) {
|
|
880
|
+
return match; // executable -c/eval payload — leave the real shell syntax intact
|
|
881
|
+
}
|
|
882
|
+
return " ";
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Whether COMMAND is (or contains) a sleep-poll loop over `gh`/`loop-state` — a `while`/`until`/
|
|
888
|
+
* `for` loop whose body contains both a `sleep` and a `gh` or `loop-state` call. Checked on the
|
|
889
|
+
* WHOLE command (not per-segment): the loop body is `;`-delimited, so a per-segment split would
|
|
890
|
+
* separate the loop head from its `sleep`/`gh` body calls and miss the pattern. `gh` must be a
|
|
891
|
+
* standalone token (not `grep gh-notes`), and `loop-state` must sit at a command-head position
|
|
892
|
+
* (not a substring inside `grep loop-state x`). Quoted literals are blanked first (see
|
|
893
|
+
* `stripQuotedLiterals`) so a quoted body/example that merely contains the tokens is not flagged.
|
|
894
|
+
* @param {string} command @returns {boolean}
|
|
895
|
+
*/
|
|
896
|
+
export function commandIsSleepPollLoop(command) {
|
|
897
|
+
const whole = stripQuotedLiterals(command.trim());
|
|
898
|
+
return (
|
|
899
|
+
/(?:while|until|for)\b/i.test(whole) &&
|
|
900
|
+
/\bsleep\b/.test(whole) &&
|
|
901
|
+
/\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Whether COMMAND is (or contains) a bare FILE-MARKER poll loop: a `while`/`until`/`for` loop that
|
|
907
|
+
* repeatedly tests for a file's existence/type/permission (`[ -f … ]`, `[[ -e … ]]`, `test -f …`,
|
|
908
|
+
* `[ -r … ]`, `[ -L … ]`) and sleeps, with NO gated wait-tool. This is the orphan pattern under
|
|
909
|
+
* Claude Code — a `<tasks>/<id>.done` sentinel Claude Code never writes (completion arrives via
|
|
910
|
+
* async notification), so the loop never exits and, once backgrounded, orphans with no async wake to
|
|
911
|
+
* reap it. Distinct from `commandIsSleepPollLoop`, which keys on a `gh`/`loop-state` CALL: this keys
|
|
912
|
+
* on any bash FILE-test operator (existence, type, or permission — `efsdrwxugkOGLNShb`; string/numeric
|
|
913
|
+
* tests like `-z`/`-n`/`-t` are deliberately excluded, as those never test a marker FILE), catching a
|
|
914
|
+
* marker poll that calls no gh/loop-state at all. Verb-independent (while/until/for). Quoted literals
|
|
915
|
+
* are blanked first (see `stripQuotedLiterals`) so a quoted example or `--body` payload that merely
|
|
916
|
+
* contains the tokens is NOT flagged.
|
|
917
|
+
* @param {string} command @returns {boolean}
|
|
918
|
+
*/
|
|
919
|
+
export function commandIsFileMarkerPollLoop(command) {
|
|
920
|
+
const whole = stripQuotedLiterals(command.trim());
|
|
921
|
+
return (
|
|
922
|
+
/(?:while|until|for)\b/i.test(whole) &&
|
|
923
|
+
/\bsleep\b/.test(whole) &&
|
|
924
|
+
/(?:\[\[?|\btest\b)\s+(?:!\s+)?-[a-hkprsuwxGLNOS]\b/.test(whole)
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Whether COMMAND contains a bare `&` backgrounding control operator — not `&&` (logical AND) and
|
|
930
|
+
* not a redirection (`2>&1`, `>&2`, `&>file`, `&>>file`). A coarse whole-string check (no shell
|
|
931
|
+
* parse): redirection forms are stripped first, then any surviving lone `&` (not immediately
|
|
932
|
+
* preceded or followed by another `&`) counts.
|
|
933
|
+
* @param {string} command @returns {boolean}
|
|
934
|
+
*/
|
|
935
|
+
function commandHasBareBackgroundOperator(command) {
|
|
936
|
+
const withoutRedir = command
|
|
937
|
+
.replace(/\d*>&\d*-?/g, " ") // 2>&1, 1>&2, >&2, >&-
|
|
938
|
+
.replace(/&>>?/g, " "); // &>file, &>>file
|
|
939
|
+
return /(?<!&)&(?!&)/.test(withoutRedir);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The wait/probe helper FAMILY: the Copilot/CI wait tools that MUST run as a bounded FOREGROUND
|
|
944
|
+
* probe — the `.mjs` helpers (`probe-copilot-review`, `wait-pr-checks`, `detect-copilot-loop-state`,
|
|
945
|
+
* `run-watch-cycle`, `probe-ci-status` — the sanctioned `ci-status`/`watch-ci` CI-status wait,
|
|
946
|
+
* skills/dev-loop/SKILL.md's "PR checks/status" entry), `gh run watch`, and the
|
|
947
|
+
* `dev-loops`/`dev-loops-run` `watch-cycle`/`watch-ci`/`watch-initial`/`gate probe-copilot` CLI
|
|
948
|
+
* verbs. A coarse ANYWHERE-in-the-string substring/family match (deliberately NOT exec-position
|
|
949
|
+
* anchored) — see `commandContainsDetachedWaitTool`'s JSDoc for the fail-closed rationale.
|
|
950
|
+
*/
|
|
951
|
+
const WAIT_PROBE_FAMILY_RE = new RegExp(
|
|
952
|
+
[
|
|
953
|
+
"probe-copilot-review\\.mjs",
|
|
954
|
+
"wait-pr-checks\\.mjs",
|
|
955
|
+
"detect-copilot-loop-state\\.mjs",
|
|
956
|
+
"run-watch-cycle\\.mjs",
|
|
957
|
+
"probe-ci-status\\.mjs",
|
|
958
|
+
"gh\\s+run\\s+watch",
|
|
959
|
+
"watch-cycle",
|
|
960
|
+
"watch-ci",
|
|
961
|
+
"watch-initial",
|
|
962
|
+
"probe-copilot",
|
|
963
|
+
].join("|"),
|
|
964
|
+
"i",
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait. This is a UNION of three
|
|
969
|
+
* independent deny conditions — NOT one big AND (an AND-condition here would inadvertently
|
|
970
|
+
* narrow the unconditional detach-wrapper ban below to "detach AND family reference", wrongly
|
|
971
|
+
* allowing a family-less `nohup node build.mjs &`):
|
|
972
|
+
*
|
|
973
|
+
* (1) `nohup`/`disown`/`tmux new-session`/`screen -dm` anywhere in a command segment — denied
|
|
974
|
+
* UNCONDITIONALLY, with NO wait/probe-family requirement.
|
|
975
|
+
* (2) A `while`/`until`/`for` sleep-poll loop over `gh`/`loop-state` (`commandIsSleepPollLoop`) —
|
|
976
|
+
* denied UNCONDITIONALLY — it IS the backgrounding signal.
|
|
977
|
+
* (3) OPTION-C, prevention-only scope: a bare `&` background (`commandHasBareBackgroundOperator`,
|
|
978
|
+
* including a `timeout …`/`env …`/`sh -c` wrapper of it) that ALSO references the wait/probe
|
|
979
|
+
* FAMILY anywhere in the command string (`WAIT_PROBE_FAMILY_RE`) — a coarse substring/family
|
|
980
|
+
* match, deliberately NOT exec-position anchored.
|
|
981
|
+
*
|
|
982
|
+
* Because (3)'s family match is coarse (anywhere in the string, not the executed token), NO wrapper
|
|
983
|
+
* can hide the reference from it: `timeout N … &`, `env … &`, `sh -c '… &'`, or a node loader flag
|
|
984
|
+
* (`node -r ./loader.mjs …/probe-copilot-review.mjs &`, `--require`/`--loader`/`--import`) all still
|
|
985
|
+
* carry the family token in the backgrounded command text, so all are denied. This trades precision
|
|
986
|
+
* for guaranteed coverage: a background command that merely MENTIONS a family name as an unrelated
|
|
987
|
+
* argument (`echo "see probe-copilot-review.mjs" &`) is also denied — a benign false positive,
|
|
988
|
+
* sanctioned by the issue's non-goals (this is a prevention gate, not an exec-position parser; a
|
|
989
|
+
* denied benign command simply falls back to the sanctioned foreground path). The precise
|
|
990
|
+
* exec-position parser this replaced (and the SubagentStop background-shell reaper it fed) is
|
|
991
|
+
* deferred to a follow-up safety-net effort.
|
|
992
|
+
*
|
|
993
|
+
* Actor-independent at the decideBashGate call site: the coordinator/main agent — not only a
|
|
994
|
+
* subagent — is the actor that leaves these orphaned under Claude Code (no async wake to join a
|
|
995
|
+
* backgrounded wait), so the gate catches its backgrounding too.
|
|
809
996
|
* @param {string} command @returns {boolean}
|
|
810
997
|
*/
|
|
811
998
|
export function commandContainsDetachedWaitTool(command) {
|
|
812
999
|
const whole = command.trim();
|
|
813
|
-
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
return true;
|
|
819
|
-
}
|
|
820
|
-
return shellSegments(command).some((segment) => {
|
|
821
|
-
// `nohup`/`disown` only detach when they head a command (segment start, or right after a shell
|
|
822
|
-
// operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
|
|
1000
|
+
|
|
1001
|
+
// (1) Unconditional detach-wrapper ban — no wait/probe-family requirement.
|
|
1002
|
+
const hasDetachWrapper = shellSegments(command).some((segment) => {
|
|
1003
|
+
// `nohup`/`disown` only detach when they head a command (segment start, or right after a
|
|
1004
|
+
// shell operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
|
|
823
1005
|
if (/(?:^|[;&|])\s*(?:nohup|disown)\b/.test(segment)) return true;
|
|
824
1006
|
if (/^tmux\s+new-session\b/i.test(segment)) return true;
|
|
825
1007
|
if (/^screen\s+-dm/i.test(segment)) return true;
|
|
826
1008
|
return false;
|
|
827
1009
|
});
|
|
1010
|
+
if (hasDetachWrapper) {
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// (2) Unconditional sleep-poll-loop ban — a gh/loop-state poll OR a bare file-marker poll.
|
|
1015
|
+
if (commandIsSleepPollLoop(whole) || commandIsFileMarkerPollLoop(whole)) {
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// (3) OPTION-C: bare-`&` background AND a wait/probe family reference.
|
|
1020
|
+
if (!commandHasBareBackgroundOperator(whole)) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
return WAIT_PROBE_FAMILY_RE.test(whole);
|
|
828
1024
|
}
|
|
829
1025
|
|
|
830
1026
|
/** Build a `node`/`python`/`python3` command-head matcher (env/wrapper/path prefix tolerated). */
|
|
@@ -875,3 +1071,44 @@ export function commandContainsInlineInterpreter(command) {
|
|
|
875
1071
|
return false;
|
|
876
1072
|
});
|
|
877
1073
|
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A package-manager `test`/`verify`/`build` script/task invocation, the `run` keyword optional
|
|
1077
|
+
* (`npm test`, `npm run test`, `bun run verify`, `yarn build`, `pnpm run build`, ...). Anchored on
|
|
1078
|
+
* the executable HEAD (env-assignment/wrapper/path/nice/timeout prefix tolerated via
|
|
1079
|
+
* `VERIFY_EXEC_PREFIX`) so a path that merely contains the word "test" (`cat test/foo.test.mjs`)
|
|
1080
|
+
* never matches — the head token must literally be one of these four package-manager binaries.
|
|
1081
|
+
*
|
|
1082
|
+
* Tolerates a run of binary flags between the binary and `run`/the script name (`bun --bun run
|
|
1083
|
+
* verify`), and a `:`-namespaced sub-script (`test:extension`, `test:core`, `verify:docs`, ...) —
|
|
1084
|
+
* the tail is `(?:[:\s]|$)` rather than `(?:\s|$)` so `npm run test:unit` / `yarn test:ci` match
|
|
1085
|
+
* while `npm run build-docs` (hyphen form, a genuinely different script name) still does not.
|
|
1086
|
+
*/
|
|
1087
|
+
const PACKAGE_MANAGER_VERIFY_RUN_RE = new RegExp(
|
|
1088
|
+
`^${VERIFY_EXEC_PREFIX}(?:bun|npm|yarn|pnpm)(?:\\s+--\\S+)*\\s+(?:run\\s+)?(?:test|verify|build)(?:[:\\s]|$)`,
|
|
1089
|
+
"i",
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* `vitest` run directly (any args: `vitest`, `vitest run`, `vitest --coverage`), or via the
|
|
1094
|
+
* `npx`/`bunx` package-runner (`npx vitest run`, `bunx vitest`) or `bun`'s `x` subcommand
|
|
1095
|
+
* (`bun x vitest`).
|
|
1096
|
+
*/
|
|
1097
|
+
const VITEST_RE = new RegExp(`^${VERIFY_EXEC_PREFIX}(?:(?:npx|bunx)\\s+|bun\\s+x\\s+)?vitest(?:\\s|$)`, "i");
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* COORDINATOR-VERIFY-DELEGATION: whether `command` contains a known code-verification/
|
|
1101
|
+
* build entrypoint in ANY shell segment — `bun test`/`bun run verify`/`bun run build`, `vitest`,
|
|
1102
|
+
* `npm test`/`npm run test`/`npm run build`, and the `yarn`/`pnpm` `test`/`build` equivalents
|
|
1103
|
+
* (with or without the `run` keyword). PreToolUse gate use only: the dev-loop COORDINATOR must
|
|
1104
|
+
* delegate these to a fresh WORKER subagent instead of running them inline; a worker subagent may
|
|
1105
|
+
* run them freely (the actor scoping lives in `decideBashGate`, not here).
|
|
1106
|
+
*
|
|
1107
|
+
* Compact orchestration commands the coordinator MAY still run inline never match — their head
|
|
1108
|
+
* token is not a package-manager binary or `vitest` (`dev-loops queue list`, `gh pr checks --json
|
|
1109
|
+
* --jq`, `detect-checkpoint-evidence`, `git log --oneline -1`, `git status --short`).
|
|
1110
|
+
* @param {string} command @returns {boolean}
|
|
1111
|
+
*/
|
|
1112
|
+
export function commandContainsCodeVerificationEntrypoint(command) {
|
|
1113
|
+
return shellSegments(command).some((segment) => PACKAGE_MANAGER_VERIFY_RUN_RE.test(segment) || VITEST_RE.test(segment));
|
|
1114
|
+
}
|
|
@@ -20,16 +20,33 @@ const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
|
|
|
20
20
|
export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The same workflow ALSO surfaces as
|
|
24
|
-
* (`gate-evidence-runner
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* The same workflow ALSO surfaces as check runs under its two job ids
|
|
24
|
+
* (`gate-evidence-runner`, the compute-heavy detector, and
|
|
25
|
+
* `gate-evidence-reporter`, the always-settling job that owns the status
|
|
26
|
+
* above — see docs/decisions/0076) beside the commit status named above, and
|
|
27
|
+
* all are the loop's own derived signal. Excluding only the status context
|
|
28
|
+
* left either job's conclusion gating the loop's own pre_approval step: once
|
|
29
|
+
* the workflow gained job-level concurrency, a superseded run is cancelled as
|
|
28
30
|
* normal operation, and a cancelled run is deliberately NOT treated as green
|
|
29
31
|
* (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
|
|
30
32
|
* the whole head read "none" and the loop waited on CI forever.
|
|
31
33
|
*/
|
|
32
|
-
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
|
|
34
|
+
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
|
|
35
|
+
LOOP_DERIVED_CI_CHECK_NAME,
|
|
36
|
+
"gate-evidence-runner",
|
|
37
|
+
"gate-evidence-reporter",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The two Gate-evidence JOB check-run names — the detector and the reporter.
|
|
42
|
+
* These (and ONLY these, never the `gate-evidence` status name) are the runs
|
|
43
|
+
* whose superseded CANCELLED check-runs `classifyBenignGateEvidenceUnstable`
|
|
44
|
+
* treats as cosmetic UNSTABLE noise.
|
|
45
|
+
*/
|
|
46
|
+
export const GATE_EVIDENCE_JOB_CHECK_NAMES = Object.freeze([
|
|
47
|
+
"gate-evidence-runner",
|
|
48
|
+
"gate-evidence-reporter",
|
|
49
|
+
]);
|
|
33
50
|
|
|
34
51
|
function checkEntryName(entry) {
|
|
35
52
|
if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
|
|
@@ -141,6 +158,27 @@ export function normalizeStatusCheckRollupStatus(rollup) {
|
|
|
141
158
|
return "none";
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the normalized status of ONE named context/check within a
|
|
163
|
+
* `statusCheckRollup` (or check-runs-shaped) payload — e.g. whether the
|
|
164
|
+
* required `gate-evidence` context itself (as opposed to the loop's own
|
|
165
|
+
* exclusion of it, see `deriveLoopCiStatusFromRollup`) is success, failure,
|
|
166
|
+
* pending, or absent. Reuses the same name matching
|
|
167
|
+
* (`partitionEntriesByCheckName`) and state normalization
|
|
168
|
+
* (`normalizeStatusCheckRollupStatus`) the rollup helpers on this module
|
|
169
|
+
* already use, so a caller that needs one context's own state (e.g.
|
|
170
|
+
* merge-pr.mjs naming the real cause of a block on `gate-evidence`) does not
|
|
171
|
+
* re-derive name matching or status normalization.
|
|
172
|
+
*
|
|
173
|
+
* @param {Array<object>} rollup
|
|
174
|
+
* @param {string} contextName
|
|
175
|
+
* @returns {"success"|"failure"|"pending"|"none"}
|
|
176
|
+
*/
|
|
177
|
+
export function resolveNamedContextState(rollup, contextName) {
|
|
178
|
+
const { matched } = partitionEntriesByCheckName(rollup, contextName);
|
|
179
|
+
return normalizeStatusCheckRollupStatus(matched);
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
/**
|
|
145
183
|
* Summarize the GitHub check-runs API payload for one head SHA.
|
|
146
184
|
*
|
|
@@ -317,6 +355,78 @@ export function normalizeHeadScopedCiContract({
|
|
|
317
355
|
return buildCiContract(overallStatus);
|
|
318
356
|
}
|
|
319
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Classify a `mergeStateStatus === "UNSTABLE"` as BENIGN when the required
|
|
360
|
+
* `gate-evidence` commit status is itself `success` and the ONLY non-success
|
|
361
|
+
* rollup entries are superseded Gate-evidence job check-runs (the
|
|
362
|
+
* `gate-evidence-runner` detector OR the `gate-evidence-reporter`, conclusion
|
|
363
|
+
* `CANCELLED`).
|
|
364
|
+
*
|
|
365
|
+
* Both jobs cancel superseded runs: the detector via `cancel-in-progress`, the
|
|
366
|
+
* reporter via its non-cancelling group cancelling a still-queued run superseded
|
|
367
|
+
* by a newer one. Each leaves a `cancelled` check-run on the head, so
|
|
368
|
+
* `mergeStateStatus` reads `UNSTABLE` on nearly every PR even when the required
|
|
369
|
+
* `gate-evidence` status on the head is green. The cancellation is correct and
|
|
370
|
+
* stays (docs/decisions/0076); this classifier only lets a reader distinguish
|
|
371
|
+
* that cosmetic noise from a real non-success.
|
|
372
|
+
*
|
|
373
|
+
* Fail-closed: only an actual `UNSTABLE` with a `success` `gate-evidence` status
|
|
374
|
+
* and no other non-success entry is benign. A failed (not cancelled) Gate-evidence
|
|
375
|
+
* job, a non-success `gate-evidence` status, or any other failing/pending check
|
|
376
|
+
* makes it non-benign.
|
|
377
|
+
*
|
|
378
|
+
* ponytail: `gh pr view --json statusCheckRollup` returns a single bounded page
|
|
379
|
+
* (~100 contexts); a very chatty PR could exceed it and hide a real failure,
|
|
380
|
+
* failing this open. Acceptable because this is a display-only surface — the
|
|
381
|
+
* merge path never consults it. Do NOT wire this classifier into a merge
|
|
382
|
+
* decision without adding pagination.
|
|
383
|
+
*
|
|
384
|
+
* @param {Array<object>} rollup A `gh pr view --json statusCheckRollup` payload.
|
|
385
|
+
* @param {string|null} mergeStateStatus
|
|
386
|
+
* @returns {{ benign: boolean, reason: string }}
|
|
387
|
+
*/
|
|
388
|
+
export function classifyBenignGateEvidenceUnstable(rollup, mergeStateStatus) {
|
|
389
|
+
const state = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : "";
|
|
390
|
+
if (state !== "UNSTABLE") {
|
|
391
|
+
return { benign: false, reason: "mergeStateStatus is not UNSTABLE" };
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(rollup)) {
|
|
394
|
+
return { benign: false, reason: "status rollup unavailable" };
|
|
395
|
+
}
|
|
396
|
+
// The required gate-evidence signal is a commit STATUS (a StatusContext,
|
|
397
|
+
// `.context`), never a check-run (`.name`). Anchor the success guard on the
|
|
398
|
+
// StatusContext alone so a same-named success check-run can never stand in for
|
|
399
|
+
// an absent required status (partitionEntriesByCheckName matches `.name` OR
|
|
400
|
+
// `.context`, so it would otherwise accept either).
|
|
401
|
+
const gateEvidenceStatusEntries = rollup.filter(
|
|
402
|
+
(entry) => entry?.context === LOOP_DERIVED_CI_CHECK_NAME && typeof entry?.state === "string",
|
|
403
|
+
);
|
|
404
|
+
if (normalizeStatusCheckRollupStatus(gateEvidenceStatusEntries) !== "success") {
|
|
405
|
+
return { benign: false, reason: "gate-evidence status is not success" };
|
|
406
|
+
}
|
|
407
|
+
const offenders = [];
|
|
408
|
+
for (const entry of rollup) {
|
|
409
|
+
if (normalizeStatusCheckRollupStatus([entry]) === "success") continue;
|
|
410
|
+
const name = checkEntryName(entry);
|
|
411
|
+
const conclusion = typeof entry?.conclusion === "string" ? entry.conclusion.toUpperCase() : "";
|
|
412
|
+
// Only a cancelled Gate-evidence JOB check-run (runner or reporter) is the
|
|
413
|
+
// superseded-run artifact this classifier ignores — an explicit two-job
|
|
414
|
+
// allowlist, not LOOP_DERIVED_CI_CHECK_NAMES (which also carries the
|
|
415
|
+
// `gate-evidence` status name), so a cancelled entry named `gate-evidence`
|
|
416
|
+
// can never be waved through as benign.
|
|
417
|
+
if (GATE_EVIDENCE_JOB_CHECK_NAMES.includes(name) && conclusion === "CANCELLED") continue;
|
|
418
|
+
const status = typeof entry?.status === "string" ? entry.status.toUpperCase() : "";
|
|
419
|
+
offenders.push(`${name || "unknown"}=${conclusion || status || "?"}`);
|
|
420
|
+
}
|
|
421
|
+
if (offenders.length > 0) {
|
|
422
|
+
return { benign: false, reason: `non-benign non-success checks present: ${offenders.join(", ")}` };
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
benign: true,
|
|
426
|
+
reason: "no non-success entry other than superseded Gate-evidence job cancellations; gate-evidence status is success",
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
320
430
|
/**
|
|
321
431
|
* Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
|
|
322
432
|
* `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
|