@dev-loops/core 1.0.2-slim.0 → 1.0.3
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 +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -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 +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Canonical closing-reference primitives shared by the create-pr / edit-pr
|
|
2
|
+
// wrappers so both guard a body's closing reference against the branch's own
|
|
3
|
+
// resolved issue with one implementation. A body swap that re-points the
|
|
4
|
+
// reference at a different issue would otherwise pass silently, and a merge
|
|
5
|
+
// would then close the wrong issue — this is the fail-closed backstop against
|
|
6
|
+
// that data-integrity hole.
|
|
7
|
+
|
|
8
|
+
import { extractClosingIssueNumbers as extractCanonicalClosingRefs } from "../loop/issue-refinement-artifact.mjs";
|
|
9
|
+
|
|
10
|
+
// Every issue number the body's closing references name. Delegates to the ONE
|
|
11
|
+
// canonical body-spec parser so the closing-keyword vocabulary (close/closes/
|
|
12
|
+
// closed, fix/fixes/fixed, resolve/resolves/resolved, any case), the cross-repo
|
|
13
|
+
// `owner/repo#N` form, fenced/inline-code stripping (a `Closes #N` inside a
|
|
14
|
+
// ```fenced``` example or `inline code` span does not auto-close on GitHub and
|
|
15
|
+
// must not spoof the guard), and de-duplication stay owned in ONE place — the
|
|
16
|
+
// guard never re-implements them.
|
|
17
|
+
export function extractClosingIssueNumbers(body) {
|
|
18
|
+
if (!body || typeof body !== "string") return [];
|
|
19
|
+
return extractCanonicalClosingRefs(body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// True when the body carries any closing keyword.
|
|
23
|
+
export function detectClosingKeyword(body) {
|
|
24
|
+
return extractClosingIssueNumbers(body).length > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The issue number from the body's first closing reference, or null when the
|
|
28
|
+
// body carries none. Back-compat surface (create-pr's `--issue` missing-reference
|
|
29
|
+
// check); the mismatch guard uses extractClosingIssueNumbers to see every one.
|
|
30
|
+
export function extractClosingIssueNumber(body) {
|
|
31
|
+
const all = extractClosingIssueNumbers(body);
|
|
32
|
+
return all.length > 0 ? all[0] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Branch slug `[<prefix>/]issue-<N>[-<slug>]` -> N. Matches the dev-loop
|
|
36
|
+
// worktree default branch name and the prefixed form (e.g. a `dl/`-prefixed
|
|
37
|
+
// slug). Returns null when the branch encodes no issue number.
|
|
38
|
+
const BRANCH_ISSUE_PATTERN = /(?:^|\/)issue-(\d+)(?:-|$)/u;
|
|
39
|
+
export function extractIssueFromBranchSlug(branch) {
|
|
40
|
+
if (!branch || typeof branch !== "string") return null;
|
|
41
|
+
const match = BRANCH_ISSUE_PATTERN.exec(branch.trim());
|
|
42
|
+
return match ? Number(match[1]) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve the issue a PR is expected to close from its own facts. The branch
|
|
46
|
+
// slug is authoritative (it encodes the issue the loop cut the branch for);
|
|
47
|
+
// the PR's GitHub-derived closingIssuesReferences is the fallback. Returns null
|
|
48
|
+
// when neither yields an issue — a genuinely issue-less PR, which is exempt.
|
|
49
|
+
export function resolveExpectedIssueFromPrContext(ctx) {
|
|
50
|
+
if (!ctx || typeof ctx !== "object") return null;
|
|
51
|
+
const fromBranch = extractIssueFromBranchSlug(ctx.headRefName);
|
|
52
|
+
if (fromBranch !== null) return fromBranch;
|
|
53
|
+
const refs = Array.isArray(ctx.closingIssuesReferences) ? ctx.closingIssuesReferences : [];
|
|
54
|
+
for (const ref of refs) {
|
|
55
|
+
const n = typeof ref === "number" ? ref : Number(ref?.number);
|
|
56
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Compare the body's closing reference against the branch's resolved issue.
|
|
62
|
+
// Returns a named refusal string when they disagree, else null. A waiver
|
|
63
|
+
// bypasses; an unresolved expected issue (issue-less) is exempt; a body with no
|
|
64
|
+
// closing reference has nothing to mislink and is exempt (only a present-and-
|
|
65
|
+
// disagreeing reference is refused, never a missing one).
|
|
66
|
+
export function resolveClosingRefMismatch({ body, expectedIssue, allowCrossIssue = false }) {
|
|
67
|
+
if (allowCrossIssue) return null;
|
|
68
|
+
if (!Number.isInteger(expectedIssue)) return null;
|
|
69
|
+
const closing = extractClosingIssueNumbers(body);
|
|
70
|
+
if (closing.length === 0) return null;
|
|
71
|
+
// Refuse when ANY closing reference disagrees — GitHub closes every one, so a
|
|
72
|
+
// correct first reference does not excuse a wrong second (a single-issue
|
|
73
|
+
// dev-loop PR closes only its branch's issue; a deliberate multi/cross-issue
|
|
74
|
+
// reference uses the waiver).
|
|
75
|
+
const disagreeing = closing.find((n) => n !== expectedIssue);
|
|
76
|
+
if (disagreeing !== undefined) {
|
|
77
|
+
return `CLOSING-REF-BRANCH-MISMATCH: the body closes #${disagreeing} but the branch resolves to issue #${expectedIssue} — refusing a mismatched closing reference (pass --allow-cross-issue to record a deliberate cross-issue reference)`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
@@ -10,6 +10,22 @@ 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 COMMENTED review summary opens with a disposition header whose
|
|
15
|
+
// emoji is the authoritative signal: "### 🟡 Changes recommended" (findings)
|
|
16
|
+
// vs "### 🟢 Approval recommended" (clean). Keying on the 🟡 marker means a
|
|
17
|
+
// clean body that merely quotes the phrase "changes recommended" — with or
|
|
18
|
+
// without markdown emphasis ("No **changes recommended**", "No _changes
|
|
19
|
+
// recommended_") — is never a false finding. The strong no-emoji signal is
|
|
20
|
+
// already covered by the CHANGES_REQUESTED state.
|
|
21
|
+
const COPILOT_CHANGES_RECOMMENDED_MARKER = "🟡";
|
|
22
|
+
|
|
23
|
+
export function copilotReviewBodySignalsChanges(state, body) {
|
|
24
|
+
const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
|
|
25
|
+
if (normalizedState === "CHANGES_REQUESTED") return true;
|
|
26
|
+
if (normalizedState !== "COMMENTED") return false;
|
|
27
|
+
return typeof body === "string" && body.includes(COPILOT_CHANGES_RECOMMENDED_MARKER);
|
|
28
|
+
}
|
|
13
29
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
14
30
|
// `review` is a RECOGNIZED gate header that carries no draft/pre-approval
|
|
15
31
|
// evidence by design. Recognizing it lets
|
|
@@ -700,6 +716,7 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
700
716
|
let hasPendingReviewOnCurrentHead = false;
|
|
701
717
|
let hasSubmittedReviewOnCurrentHead = false;
|
|
702
718
|
let latestSubmittedReviewOnCurrentHeadAt = null;
|
|
719
|
+
let hasBodyFindingOnCurrentHead = false;
|
|
703
720
|
let completedCopilotReviewRounds = 0;
|
|
704
721
|
|
|
705
722
|
for (const review of effectiveReviews) {
|
|
@@ -722,9 +739,18 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
722
739
|
|
|
723
740
|
if (SUBMITTED_REVIEW_STATES.has(state)) {
|
|
724
741
|
hasSubmittedReviewOnCurrentHead = true;
|
|
725
|
-
const submittedAt = typeof review?.submittedAt === "string"
|
|
742
|
+
const submittedAt = typeof review?.submittedAt === "string"
|
|
743
|
+
? review.submittedAt
|
|
744
|
+
: (typeof review?.submitted_at === "string" ? review.submitted_at : null);
|
|
726
745
|
if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
|
|
727
746
|
latestSubmittedReviewOnCurrentHeadAt = submittedAt;
|
|
747
|
+
hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
|
|
748
|
+
} else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
|
|
749
|
+
// Equal-timestamp tie on the same head: fail toward surfacing so array
|
|
750
|
+
// order never silently drops a finding when two reviews share a timestamp.
|
|
751
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
|
|
752
|
+
} else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
|
|
753
|
+
hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
|
|
728
754
|
}
|
|
729
755
|
}
|
|
730
756
|
}
|
|
@@ -740,5 +766,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
|
|
|
740
766
|
hasPendingReviewOnCurrentHead,
|
|
741
767
|
hasSubmittedReviewOnCurrentHead,
|
|
742
768
|
latestSubmittedReviewOnCurrentHeadAt,
|
|
769
|
+
hasBodyFindingOnCurrentHead,
|
|
743
770
|
};
|
|
744
771
|
}
|
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
|
+
}
|
|
@@ -8,9 +8,65 @@
|
|
|
8
8
|
* Pure and side-effect free.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The dev-loops repo itself. Retained ONLY for the Pi extension's
|
|
13
|
+
* (`extension/post-merge-update.ts`) dev-loops-repo SELF-UPDATE scoping (`markPendingUpdate` /
|
|
14
|
+
* `queueIfEligible`), which is legitimately anchored to this one repo, not to whatever repo the
|
|
15
|
+
* harness happens to run in. Both harness guard suites — the Claude PreToolUse Bash gate
|
|
16
|
+
* (`decideBashGate` in `hook-decisions.mjs`) and the Pi extension's `gh pr ready`/`gh pr merge`
|
|
17
|
+
* guards (`post-merge-update.ts`) — now resolve the managed repo dynamically via
|
|
18
|
+
* `deriveInManagedRepo` below instead of comparing against this hardcoded slug, so every guard
|
|
19
|
+
* also applies in a dev-loops-managed consumer repo.
|
|
20
|
+
*/
|
|
12
21
|
export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
13
22
|
|
|
23
|
+
/** `.devloops` config file extensions checked (in order) to decide whether a repo root is
|
|
24
|
+
* dev-loops-managed. Shared by every harness that resolves `inManagedContext` from the
|
|
25
|
+
* filesystem (`fs.existsSync(path.join(repoRoot, \`.devloops${ext}\`))`). */
|
|
26
|
+
export const DEVLOOPS_CONFIG_VARIANTS = ["", ".yaml", ".yml", ".json"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the current repo counts as "managed" for guard-gating purposes: inside a
|
|
30
|
+
* dev-loops-managed context (a `.devloops` config exists at its root) AND, when the managed
|
|
31
|
+
* repo's identity resolves, the cwd repo IS that managed repo. FAIL CLOSED: inside a managed
|
|
32
|
+
* context whose identity can't be resolved (`managedRepoSlug` null), the guard suite still
|
|
33
|
+
* applies rather than silently allowing everything.
|
|
34
|
+
* @param {Object} [params]
|
|
35
|
+
* @param {boolean} [params.inManagedContext] - Whether a `.devloops` config exists at the repo root.
|
|
36
|
+
* @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the managed repo, or null.
|
|
37
|
+
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo, or null.
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function deriveInManagedRepo({ inManagedContext = false, managedRepoSlug = null, repoSlug = null } = {}) {
|
|
41
|
+
const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
|
|
42
|
+
const cwdSlug = (repoSlug ?? "").trim().toLowerCase() || null;
|
|
43
|
+
return Boolean(inManagedContext) && (managedSlug === null || cwdSlug === managedSlug);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether an explicit `--repo`/`-R` (or `GH_REPO=`) target is PROVABLY a different repo than the
|
|
48
|
+
* managed one — both slugs must resolve and differ. An unresolvable managed slug never proves
|
|
49
|
+
* foreign-ness (fail closed: the guard stays active rather than waving an explicit flag through).
|
|
50
|
+
* @param {string|null} explicitRepo
|
|
51
|
+
* @param {string|null} managedRepoSlug
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function explicitRepoProvenForeign(explicitRepo, managedRepoSlug) {
|
|
55
|
+
const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
|
|
56
|
+
const explicit = (explicitRepo ?? "").trim().toLowerCase() || null;
|
|
57
|
+
if (managedSlug === null || explicit === null) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
// Both sides must be clean owner/name identities before we can prove foreign: a
|
|
61
|
+
// managed slug that bypassed the normalizer (e.g. a hostile `acme/widgets;id` test
|
|
62
|
+
// double) can't be trusted to prove anything about the explicit `--repo` — fail
|
|
63
|
+
// closed (the managed-repo guard still applies) rather than wave it through.
|
|
64
|
+
if (!isCleanRepoSlug(managedSlug) || !isCleanRepoSlug(explicit)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return explicit !== managedSlug;
|
|
68
|
+
}
|
|
69
|
+
|
|
14
70
|
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
15
71
|
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
16
72
|
|
|
@@ -21,7 +77,7 @@ export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
|
21
77
|
* strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
|
|
22
78
|
* the gate. Used by all segment-splitting sites (DRY).
|
|
23
79
|
*/
|
|
24
|
-
const SHELL_SEGMENT_SEPARATOR = /\s*(
|
|
80
|
+
const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||&|\n|\r)\s*/;
|
|
25
81
|
|
|
26
82
|
/**
|
|
27
83
|
* Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
|
|
@@ -67,8 +123,32 @@ export function trimToNull(value) {
|
|
|
67
123
|
return trimmed ? trimmed : null;
|
|
68
124
|
}
|
|
69
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Strict GitHub owner/name identity shape: each of the two path segments is
|
|
128
|
+
* `[A-Za-z0-9._-]+` — the character set GitHub itself allows in an owner or repo name. A slug
|
|
129
|
+
* outside this shape (a shell metacharacter, whitespace, path traversal, or an extra `/` segment)
|
|
130
|
+
* can never be a real GitHub identity.
|
|
131
|
+
*/
|
|
132
|
+
const CLEAN_REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether `slug` is a clean `owner/name` GitHub identity (see `CLEAN_REPO_SLUG_RE`). Exported so a
|
|
136
|
+
* sink that interpolates a repo slug into a shell command string (e.g. the Pi extension's
|
|
137
|
+
* `gateCommand` in `extension/post-merge-update.ts`) can defense-in-depth guard the interpolation,
|
|
138
|
+
* on top of `normalizeGitHubRepoSlug` already enforcing this shape at the source.
|
|
139
|
+
* @param {string|null|undefined} slug @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isCleanRepoSlug(slug) {
|
|
142
|
+
return typeof slug === "string" && CLEAN_REPO_SLUG_RE.test(slug);
|
|
143
|
+
}
|
|
144
|
+
|
|
70
145
|
/**
|
|
71
146
|
* Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
|
|
147
|
+
* A hostile remote (e.g. `git@github.com:acme/widgets;id`) never yields a slug carrying the
|
|
148
|
+
* injected metacharacters — the extracted candidate must match `CLEAN_REPO_SLUG_RE` or this
|
|
149
|
+
* returns null instead, so every caller (both harnesses' managed-repo scope checks, and any sink
|
|
150
|
+
* that interpolates the slug into a shell command) sees either a real GitHub identity or null,
|
|
151
|
+
* never shell-metacharacter-bearing text.
|
|
72
152
|
* @param {string} remoteUrl
|
|
73
153
|
* @returns {string|null}
|
|
74
154
|
*/
|
|
@@ -91,7 +171,8 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
|
91
171
|
if (!match) {
|
|
92
172
|
continue;
|
|
93
173
|
}
|
|
94
|
-
|
|
174
|
+
const slug = trimToNull(match[1])?.toLowerCase() ?? null;
|
|
175
|
+
return isCleanRepoSlug(slug) ? slug : null;
|
|
95
176
|
}
|
|
96
177
|
|
|
97
178
|
return null;
|
|
@@ -486,6 +567,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
|
|
|
486
567
|
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
|
|
487
568
|
}
|
|
488
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
|
|
572
|
+
* PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
|
|
573
|
+
* managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
574
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
575
|
+
*/
|
|
576
|
+
export function extractRepoFlagsFromGhPrMergeSegments(command) {
|
|
577
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
|
|
582
|
+
* PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
583
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
584
|
+
*/
|
|
585
|
+
export function extractRepoFlagsFromGhPrReadySegments(command) {
|
|
586
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
|
|
587
|
+
}
|
|
588
|
+
|
|
489
589
|
/** @param {string} command @returns {number|null} */
|
|
490
590
|
export function extractPrNumberFromGhPrMerge(command) {
|
|
491
591
|
return extractPrNumberFromGhPrVerb(command, "merge");
|
|
@@ -574,13 +674,22 @@ export function extractGhApiEndpointSegments(command) {
|
|
|
574
674
|
return out;
|
|
575
675
|
}
|
|
576
676
|
|
|
577
|
-
/** The `gh api` segments whose endpoint is the
|
|
578
|
-
* slug-embedded form (`repos
|
|
579
|
-
* which gh api resolves against the cwd repo — the
|
|
580
|
-
* on `
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
677
|
+
/** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
|
|
678
|
+
* absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
|
|
679
|
+
* bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
|
|
680
|
+
* decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
|
|
681
|
+
* (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
|
|
682
|
+
* case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
|
|
683
|
+
* so an absolute write must be denied regardless of which repo it targets, not waved through for
|
|
684
|
+
* lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
|
|
685
|
+
* in a legitimate repo name must match literally, not as a wildcard) and matches
|
|
686
|
+
* case-insensitively (GitHub repo identity is case-insensitive). */
|
|
687
|
+
function managedGhApiPathRegex(suffix, managedSlug) {
|
|
688
|
+
if (!managedSlug) {
|
|
689
|
+
return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
|
|
690
|
+
}
|
|
691
|
+
const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
692
|
+
return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
|
|
584
693
|
}
|
|
585
694
|
|
|
586
695
|
/** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
|
|
@@ -617,15 +726,18 @@ function ghApiSegmentHasWriteMethod(segment) {
|
|
|
617
726
|
}
|
|
618
727
|
|
|
619
728
|
/**
|
|
620
|
-
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
621
|
-
* repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
622
|
-
* wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
623
|
-
* direct path to sub-issue writes. Anchored on the
|
|
624
|
-
* method, so a `gh api` read or another repo's `sub_issues` write
|
|
625
|
-
*
|
|
729
|
+
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
|
|
730
|
+
* dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
|
|
731
|
+
* `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
|
|
732
|
+
* agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
|
|
733
|
+
* segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
|
|
734
|
+
* passes through (no false deny).
|
|
735
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
736
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
737
|
+
* @returns {boolean}
|
|
626
738
|
*/
|
|
627
|
-
export function commandContainsSubIssueAdHocBypass(command) {
|
|
628
|
-
const re =
|
|
739
|
+
export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
|
|
740
|
+
const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
|
|
629
741
|
return extractGhApiEndpointSegments(command).some(
|
|
630
742
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
631
743
|
);
|
|
@@ -633,12 +745,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
|
|
|
633
745
|
|
|
634
746
|
/**
|
|
635
747
|
* COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
|
|
636
|
-
* `.../pulls/<n>/comments/<m>/replies` on the
|
|
637
|
-
* through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
638
|
-
*
|
|
748
|
+
* `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
|
|
749
|
+
* that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
|
|
750
|
+
* direct reply path.
|
|
751
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
752
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
753
|
+
* @returns {boolean}
|
|
639
754
|
*/
|
|
640
|
-
export function commandContainsReplyResolveBypass(command) {
|
|
641
|
-
const re =
|
|
755
|
+
export function commandContainsReplyResolveBypass(command, managedSlug = null) {
|
|
756
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
|
|
642
757
|
return extractGhApiEndpointSegments(command).some(
|
|
643
758
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
644
759
|
);
|
|
@@ -657,12 +772,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
|
|
|
657
772
|
|
|
658
773
|
/**
|
|
659
774
|
* COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
|
|
660
|
-
* `.../pulls/<n>/requested_reviewers` on the
|
|
661
|
-
* flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
662
|
-
* @param {string} command @
|
|
775
|
+
* `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
|
|
776
|
+
* request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
777
|
+
* @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
|
|
778
|
+
* null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
|
|
779
|
+
* @returns {boolean}
|
|
663
780
|
*/
|
|
664
|
-
export function commandContainsCopilotRequestBypass(command) {
|
|
665
|
-
const re =
|
|
781
|
+
export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
|
|
782
|
+
const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
|
|
666
783
|
return extractGhApiEndpointSegments(command).some(
|
|
667
784
|
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
668
785
|
);
|