@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.
@@ -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" ? review.submittedAt : null;
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
  }
@@ -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
@@ -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
- const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
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
+ }