@dev-loops/core 1.0.2 → 1.0.4-pre.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 +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -133,6 +133,26 @@ gates:
|
|
|
133
133
|
mandatory: true
|
|
134
134
|
persona: review
|
|
135
135
|
prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
|
|
136
|
+
- name: holistic
|
|
137
|
+
mandatory: true
|
|
138
|
+
persona: review
|
|
139
|
+
prompt: >-
|
|
140
|
+
Review the WHOLE change holistically, on its merits, as a senior
|
|
141
|
+
engineer doing a final read of the entire diff. You are independent and
|
|
142
|
+
un-briefed: you receive only the spec (acceptance criteria, definition
|
|
143
|
+
of done, non-goals) and the diff — no author or developer brief and no
|
|
144
|
+
steering from the reviewed party. Your mandate is broad, not a single
|
|
145
|
+
named lens. Read the whole change end to end and judge whether, taken
|
|
146
|
+
together, it correctly and completely does what the spec asks, is
|
|
147
|
+
internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
|
|
148
|
+
problems that no narrow angle owns: mismatches between parts of the
|
|
149
|
+
change, gaps between the diff and the acceptance criteria, unintended
|
|
150
|
+
interactions across modules, missing pieces the spec implies, and
|
|
151
|
+
defects that would otherwise surface later in Copilot review or a
|
|
152
|
+
consumer repo. Cite concrete file:line evidence for each finding and
|
|
153
|
+
give a minimal fix. Respect declared non-goals — do not manufacture
|
|
154
|
+
scope. If the change is coherent and complete against the spec, return
|
|
155
|
+
clean.
|
|
136
156
|
# #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
|
|
137
157
|
# deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
|
|
138
158
|
# deslop-step.md) — flag surviving binary-contrast constructions so the gate
|
|
@@ -221,6 +241,14 @@ gates:
|
|
|
221
241
|
angles: [srp, soc, ocp, lsp, isp, dip]
|
|
222
242
|
- name: finalization
|
|
223
243
|
angles: [correctness-final, ui-validation]
|
|
244
|
+
# The holistic reviewer reads the whole diff, so it gets its own
|
|
245
|
+
# reviewer rather than being auto-chunked with unrelated leftover
|
|
246
|
+
# angles — one reviewer reviews the whole change holistically.
|
|
247
|
+
# A consumer repo that overrides gates.fanout.groups replaces this
|
|
248
|
+
# table wholesale (shallow merge) and must restate this singleton to
|
|
249
|
+
# keep holistic un-batched.
|
|
250
|
+
- name: holistic
|
|
251
|
+
angles: [holistic]
|
|
224
252
|
preApproval:
|
|
225
253
|
angles:
|
|
226
254
|
- name: dry
|
|
@@ -294,6 +322,26 @@ gates:
|
|
|
294
322
|
require a matrix on the PR — the matrix lives on the issue; the PR carries the derived
|
|
295
323
|
checklists. The boundary is explicit: the deterministic block enforces completeness (nothing
|
|
296
324
|
left unchecked/forgotten); you verify each [x] is real and faithfully derived.
|
|
325
|
+
- name: holistic
|
|
326
|
+
mandatory: true
|
|
327
|
+
persona: review
|
|
328
|
+
prompt: >-
|
|
329
|
+
Review the WHOLE change holistically, on its merits, as a senior
|
|
330
|
+
engineer doing a final read of the entire diff. You are independent and
|
|
331
|
+
un-briefed: you receive only the spec (acceptance criteria, definition
|
|
332
|
+
of done, non-goals) and the diff — no author or developer brief and no
|
|
333
|
+
steering from the reviewed party. Your mandate is broad, not a single
|
|
334
|
+
named lens. Read the whole change end to end and judge whether, taken
|
|
335
|
+
together, it correctly and completely does what the spec asks, is
|
|
336
|
+
internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
|
|
337
|
+
problems that no narrow angle owns: mismatches between parts of the
|
|
338
|
+
change, gaps between the diff and the acceptance criteria, unintended
|
|
339
|
+
interactions across modules, missing pieces the spec implies, and
|
|
340
|
+
defects that would otherwise surface later in Copilot review or a
|
|
341
|
+
consumer repo. Cite concrete file:line evidence for each finding and
|
|
342
|
+
give a minimal fix. Respect declared non-goals — do not manufacture
|
|
343
|
+
scope. If the change is coherent and complete against the spec, return
|
|
344
|
+
clean.
|
|
297
345
|
- contradiction-lens
|
|
298
346
|
- correctness-final
|
|
299
347
|
- ui-validation
|
|
@@ -10,6 +10,73 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
|
10
10
|
// acting on the gate's behalf must agree with the gate about what a submitted
|
|
11
11
|
// review is.
|
|
12
12
|
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
13
|
+
|
|
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
|
+
});
|
|
44
|
+
|
|
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) {
|
|
52
|
+
const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
|
|
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;
|
|
79
|
+
}
|
|
13
80
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
14
81
|
// `review` is a RECOGNIZED gate header that carries no draft/pre-approval
|
|
15
82
|
// evidence by design. Recognizing it lets
|
|
@@ -700,6 +767,7 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
700
767
|
let hasPendingReviewOnCurrentHead = false;
|
|
701
768
|
let hasSubmittedReviewOnCurrentHead = false;
|
|
702
769
|
let latestSubmittedReviewOnCurrentHeadAt = null;
|
|
770
|
+
let hasBodyFindingOnCurrentHead = false;
|
|
703
771
|
let completedCopilotReviewRounds = 0;
|
|
704
772
|
|
|
705
773
|
for (const review of effectiveReviews) {
|
|
@@ -722,9 +790,18 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
722
790
|
|
|
723
791
|
if (SUBMITTED_REVIEW_STATES.has(state)) {
|
|
724
792
|
hasSubmittedReviewOnCurrentHead = true;
|
|
725
|
-
const submittedAt = typeof review?.submittedAt === "string"
|
|
793
|
+
const submittedAt = typeof review?.submittedAt === "string"
|
|
794
|
+
? review.submittedAt
|
|
795
|
+
: (typeof review?.submitted_at === "string" ? review.submitted_at : null);
|
|
726
796
|
if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
|
|
727
797
|
latestSubmittedReviewOnCurrentHeadAt = submittedAt;
|
|
798
|
+
hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
|
|
799
|
+
} else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
|
|
800
|
+
// Equal-timestamp tie on the same head: fail toward surfacing so array
|
|
801
|
+
// order never silently drops a finding when two reviews share a timestamp.
|
|
802
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
|
|
803
|
+
} else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
|
|
804
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
|
|
728
805
|
}
|
|
729
806
|
}
|
|
730
807
|
}
|
|
@@ -740,5 +817,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
740
817
|
hasPendingReviewOnCurrentHead,
|
|
741
818
|
hasSubmittedReviewOnCurrentHead,
|
|
742
819
|
latestSubmittedReviewOnCurrentHeadAt,
|
|
820
|
+
hasBodyFindingOnCurrentHead,
|
|
743
821
|
};
|
|
744
822
|
}
|
package/src/github/issue-ops.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
|
4
4
|
import { parseJsonText } from "./review-threads.mjs";
|
|
5
5
|
import { parseRepoSlug } from "./repo-slug.mjs";
|
|
6
6
|
import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
|
|
7
|
+
import { assertGithubWriteStubbedInTestMode } from "./test-mode-write-guard.mjs";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Core `gh issue` operations, extracted from the thin CLI wrappers under
|
|
@@ -88,6 +89,7 @@ export async function resolveCreateBody(options) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
92
|
+
assertGithubWriteStubbedInTestMode(run, "issue create", { env });
|
|
91
93
|
const body = await resolveCreateBody(options);
|
|
92
94
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
93
95
|
const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
|
|
@@ -182,6 +184,7 @@ export function buildStateChangeArgs(options) {
|
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
187
|
+
assertGithubWriteStubbedInTestMode(run, "issue edit/close", { env });
|
|
185
188
|
const { args, edited } = await buildEditArgs(options);
|
|
186
189
|
// Skip the edit call entirely when --state is the only change requested —
|
|
187
190
|
// `gh issue edit` with no field flags errors ("no changed fields").
|
|
@@ -227,6 +230,7 @@ export async function resolveCommentBody(options) {
|
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
233
|
+
assertGithubWriteStubbedInTestMode(run, "issue comment", { env });
|
|
230
234
|
const body = await resolveCommentBody(options);
|
|
231
235
|
// ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
|
|
232
236
|
// issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
|
package/src/github/repo-slug.mjs
CHANGED
|
@@ -83,10 +83,33 @@ export function dedupeRepoSlugOptions(options) {
|
|
|
83
83
|
}
|
|
84
84
|
return uniqueOptions;
|
|
85
85
|
}
|
|
86
|
+
// Parses a git remote URL into an <owner/name> slug, but only for github.com
|
|
87
|
+
// remotes. The URI form (scheme://[user@]host[:port]/path) is checked before
|
|
88
|
+
// the scp form (host:path), because the scp regex also matches URIs like
|
|
89
|
+
// https://... — checking scp first would misparse the host out of the scheme.
|
|
90
|
+
function parseGitHubRemoteSlug(url) {
|
|
91
|
+
let host, path;
|
|
92
|
+
const uri = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/);
|
|
93
|
+
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
|
|
94
|
+
if (uri) {
|
|
95
|
+
host = uri[1];
|
|
96
|
+
path = uri[2];
|
|
97
|
+
} else if (scp) {
|
|
98
|
+
host = scp[1];
|
|
99
|
+
path = scp[2];
|
|
100
|
+
} else {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (host.toLowerCase() !== "github.com") return null;
|
|
104
|
+
const seg = path.match(/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
105
|
+
if (!seg) return null;
|
|
106
|
+
return `${seg[1]}/${seg[2]}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
/**
|
|
87
110
|
* Auto-detect <owner/name> from `git remote get-url origin`.
|
|
88
111
|
* Returns the slug string on success, or null when detection fails
|
|
89
|
-
* (no origin remote, not a git repo, or unparseable URL).
|
|
112
|
+
* (no origin remote, not a git repo, non-github.com host, or unparseable URL).
|
|
90
113
|
* Does NOT throw — callers should add their own context-specific error messages.
|
|
91
114
|
*/
|
|
92
115
|
export function detectRepoSlug(cwd) {
|
|
@@ -96,9 +119,7 @@ export function detectRepoSlug(cwd) {
|
|
|
96
119
|
encoding: "utf8",
|
|
97
120
|
stdio: ["ignore", "pipe", "pipe"],
|
|
98
121
|
}).trim();
|
|
99
|
-
|
|
100
|
-
if (!match) return null;
|
|
101
|
-
return `${match[1]}/${match[2]}`;
|
|
122
|
+
return parseGitHubRemoteSlug(url);
|
|
102
123
|
} catch {
|
|
103
124
|
return null;
|
|
104
125
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed test-mode guard for GitHub WRITE helpers.
|
|
3
|
+
*
|
|
4
|
+
* A GitHub write helper (issue/PR create/edit/close/comment/merge) must never
|
|
5
|
+
* reach a live GitHub write path from a test. The real incident: an unstubbed
|
|
6
|
+
* `bun run verify` drove a dedup test's followUpDraft through
|
|
7
|
+
* applyFollowUpIssues -> ensureFollowUpIssue -> createIssue and FILED A REAL
|
|
8
|
+
* ISSUE against the repo. The DI stub seam existed; the test simply did not
|
|
9
|
+
* thread it, and nothing failed closed to stop the live write.
|
|
10
|
+
*
|
|
11
|
+
* This guard is TEST-MODE ONLY. In production (`NODE_ENV !== "test"`) it is a
|
|
12
|
+
* no-op — it never changes production write behavior (non-goal: no production
|
|
13
|
+
* behavior change; no general network sandbox). It only asserts, when a test is
|
|
14
|
+
* running, that the write is stubbed by one of the two sanctioned seams:
|
|
15
|
+
*
|
|
16
|
+
* Test mode is derived from the EXECUTING PROCESS's own env (`process.env`),
|
|
17
|
+
* never from the write-target `env` a caller passes in. A caller can build a
|
|
18
|
+
* sparse `env` (e.g. `{ GH_TOKEN }` with no `NODE_ENV`) to shape the `gh`
|
|
19
|
+
* child's environment; if that sparse env decided test mode, it could turn
|
|
20
|
+
* the guard off from under a test simply by omitting `NODE_ENV`. Reading the
|
|
21
|
+
* real process env instead makes that impossible: a caller-supplied env can
|
|
22
|
+
* never disable the guard.
|
|
23
|
+
*
|
|
24
|
+
* (a) In-process DI seam: the helper's `run`/`runChild` was replaced with a
|
|
25
|
+
* stub (e.g. `makeGhMock`), so it is no longer the live child-exec seam.
|
|
26
|
+
* (b) Process-boundary seam: a subprocess test installed a fake `gh` on PATH
|
|
27
|
+
* (`writeGhStub`) and attests it via `DEV_LOOPS_GH_STUB` in the child env.
|
|
28
|
+
*
|
|
29
|
+
* Anything else in test mode fails closed at the call site, before any network
|
|
30
|
+
* call, with an actionable error naming both seams.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { runChild as liveRunChild } from "../cli/primitives.mjs";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Env var a process-boundary gh stub sets to attest that `gh` itself is a stub
|
|
37
|
+
* (set automatically by the `writeGhStub` test helper). Only consulted in test
|
|
38
|
+
* mode, so it is not a production escape hatch.
|
|
39
|
+
*/
|
|
40
|
+
export const GH_STUB_ATTESTATION_ENV = "DEV_LOOPS_GH_STUB";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* True when `run` is the live child-exec seam — i.e. NO in-process DI stub was
|
|
44
|
+
* injected. `null`/`undefined` (a helper with no run seam, e.g. the spawn-based
|
|
45
|
+
* create-pr path) is treated as live too, so it fails closed rather than open.
|
|
46
|
+
* @param {unknown} run
|
|
47
|
+
*/
|
|
48
|
+
export function isLiveExecutor(run) {
|
|
49
|
+
return run == null || run === liveRunChild;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Assert a GitHub write is stubbed when running under a test. No-op unless the
|
|
54
|
+
* EXECUTING PROCESS is in test mode (`processEnv.NODE_ENV === "test"`) — never
|
|
55
|
+
* decided by the caller-supplied write-target `env`, so a sparse `env` (e.g.
|
|
56
|
+
* missing `NODE_ENV`) cannot bypass the guard. Throws (code
|
|
57
|
+
* `GH_WRITE_UNSTUBBED_IN_TEST`) when the write would reach the live path with
|
|
58
|
+
* neither sanctioned stub seam present.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} run - the helper's `run`/`runChild` seam (omit for spawn-only
|
|
61
|
+
* helpers with no in-process seam).
|
|
62
|
+
* @param {string} op - human-readable op name, e.g. `"issue create"`.
|
|
63
|
+
* @param {{ env?: NodeJS.ProcessEnv, processEnv?: NodeJS.ProcessEnv }} [opts]
|
|
64
|
+
* `env` is the write-target env (consulted only for the stub attestation);
|
|
65
|
+
* `processEnv` (defaults to `process.env`, injectable for tests) is the
|
|
66
|
+
* real executing process's env and is the sole source of test-mode.
|
|
67
|
+
*/
|
|
68
|
+
export function assertGithubWriteStubbedInTestMode(run, op, { env = process.env, processEnv = process.env } = {}) {
|
|
69
|
+
if (processEnv?.NODE_ENV !== "test") return; // production / non-test: never guards
|
|
70
|
+
if (!isLiveExecutor(run)) return; // (a) in-process DI stub injected
|
|
71
|
+
if (processEnv?.[GH_STUB_ATTESTATION_ENV] || env?.[GH_STUB_ATTESTATION_ENV]) return; // (b) process-boundary gh stub
|
|
72
|
+
throw Object.assign(
|
|
73
|
+
new Error(
|
|
74
|
+
`GitHub write helper "${op}" was called in test mode without an injected stub — ` +
|
|
75
|
+
`refusing to reach a live GitHub write path (a test must never mutate the real repo). ` +
|
|
76
|
+
`Inject the in-process DI seam (pass { run } / { runChild }, e.g. makeGhMock) or stub gh ` +
|
|
77
|
+
`at the process boundary (writeGhStub, which sets ${GH_STUB_ATTESTATION_ENV}). See issue 2216.`,
|
|
78
|
+
),
|
|
79
|
+
{ code: "GH_WRITE_UNSTUBBED_IN_TEST" },
|
|
80
|
+
);
|
|
81
|
+
}
|