@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4
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 +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
|
@@ -143,15 +143,16 @@ function shellSegments(command) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Leading prefix a
|
|
147
|
-
*
|
|
148
|
-
*
|
|
146
|
+
* Leading prefix a command segment may carry before its real executable: a run of `NAME=value`
|
|
147
|
+
* env assignments, optional `command`/`env`/`exec` wrapper words, and an absolute/relative path on
|
|
148
|
+
* the binary (`/usr/bin/gh`, `/usr/bin/git`). Shared by every classifier in this file that must
|
|
149
|
+
* catch its verb behind these forms (`gh pr <verb>`, `git stash`, ...).
|
|
149
150
|
*
|
|
150
151
|
* Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
|
|
151
152
|
* `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
|
|
152
153
|
* `\gh` forms are deliberately out of scope.
|
|
153
154
|
*/
|
|
154
|
-
const
|
|
155
|
+
const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
|
|
155
156
|
|
|
156
157
|
/**
|
|
157
158
|
* Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
|
|
@@ -163,7 +164,36 @@ const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env
|
|
|
163
164
|
* match — their first token is `node`, not `gh`.
|
|
164
165
|
*/
|
|
165
166
|
function ghSubcmdVerbRegex(subcmd, verb) {
|
|
166
|
-
return new RegExp(`^${
|
|
167
|
+
return new RegExp(`^${SHELL_EXEC_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A run of git global options that may appear between `git` and the subcommand: `-C <path>`,
|
|
172
|
+
* `-c <name>=<value>` (each consumes the following token as its value), `--git-dir=<path>`,
|
|
173
|
+
* `--work-tree=<path>`, or any other bare flag (`-x`, `--long-option`) that takes no value.
|
|
174
|
+
* Pragmatic normalizer, not a full git CLI parser.
|
|
175
|
+
*/
|
|
176
|
+
const GIT_GLOBAL_OPTION_RUN =
|
|
177
|
+
"(?:(?:-C|-c)\\s+\\S+\\s+|--(?:git-dir|work-tree)=\\S+\\s+|--?[A-Za-z][\\w-]*\\s+)*";
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether `command` contains a `git stash` invocation (any subcommand: bare, `push`, `pop`,
|
|
181
|
+
* `apply`, `save`, `list`, ...) in ANY shell segment — including behind the same env-assignment /
|
|
182
|
+
* `command`/`env`/`exec` wrapper / binary-path prefix (`GIT_DIR=.git git stash`, `command git
|
|
183
|
+
* stash`, `/usr/bin/git stash`) and git global options between `git` and `stash` (`git -C /tmp
|
|
184
|
+
* stash`, `git -c name=value stash pop`) that the sibling `gh` classifiers in this file already
|
|
185
|
+
* tolerate. Anchored per-segment, so `git stashed`, `git commit -m "git stash"`, or a path literal
|
|
186
|
+
* containing "git stash" never match. `refs/stash` is a single ref shared by every worktree over
|
|
187
|
+
* this repo's one `.git` directory, so a stash from one worktree can pop into another's — the
|
|
188
|
+
* PreToolUse gate blocks it outright on the target repo (see
|
|
189
|
+
* `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
|
|
190
|
+
* @param {string} command @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
export function commandContainsGitStash(command) {
|
|
193
|
+
const re = new RegExp(`^${SHELL_EXEC_PREFIX}git\\s+${GIT_GLOBAL_OPTION_RUN}stash(?:\\s|$)`, "i");
|
|
194
|
+
return command
|
|
195
|
+
.split(SHELL_SEGMENT_SEPARATOR)
|
|
196
|
+
.some((segment) => re.test(segment.trim()));
|
|
167
197
|
}
|
|
168
198
|
|
|
169
199
|
/** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - The evaluator is purely functional; no I/O or side effects
|
|
18
18
|
* - Callers use evaluateConductorRouting as the single routing authority
|
|
19
19
|
*
|
|
20
|
-
* Integration boundary (see docs/conductor-routing-contract.md):
|
|
20
|
+
* Integration boundary (see skills/docs/conductor-routing-contract.md):
|
|
21
21
|
* - This module starts after active-run identity and ownership are already resolved
|
|
22
22
|
* - It consumes already-detected family-local lifecycle states as inputs
|
|
23
23
|
* - It derives the routing outcome directly from states; it does not take a
|
|
@@ -5,6 +5,58 @@ const STATUS_CONTEXT_FAILURE_STATES = new Set(["FAILURE", "ERROR"]);
|
|
|
5
5
|
const STATUS_CONTEXT_PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
|
|
6
6
|
const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Name of the explicit commit STATUS dev-loops posts on its own pull requests
|
|
10
|
+
* from `.github/workflows/gate-evidence.yml`. Its conclusion is DERIVED from
|
|
11
|
+
* the loop's own progress (a clean current-head pre_approval_gate verdict),
|
|
12
|
+
* not an independent build/test signal — so the loop must exclude it when
|
|
13
|
+
* deriving the CI status that gates its own pre_approval step. Otherwise the
|
|
14
|
+
* loop could never post the very verdict that would turn this check green
|
|
15
|
+
* (#1358). This constant names ONLY the status context; the workflow also
|
|
16
|
+
* surfaces as a check run, so anything partitioning check runs must use
|
|
17
|
+
* `LOOP_DERIVED_CI_CHECK_NAMES` below. It remains the label reported in
|
|
18
|
+
* `excludedFailureDetails` for either shape, since both name one workflow.
|
|
19
|
+
*/
|
|
20
|
+
export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The same workflow ALSO surfaces as a check run under its job id
|
|
24
|
+
* (`gate-evidence-runner`) beside the commit status named above, and both are
|
|
25
|
+
* the loop's own derived signal. Excluding only the status context left the
|
|
26
|
+
* runner's conclusion gating the loop's own pre_approval step: once the
|
|
27
|
+
* workflow gained job-level concurrency, a superseded run is cancelled as
|
|
28
|
+
* normal operation, and a cancelled run is deliberately NOT treated as green
|
|
29
|
+
* (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
|
|
30
|
+
* the whole head read "none" and the loop waited on CI forever.
|
|
31
|
+
*/
|
|
32
|
+
export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
|
|
33
|
+
|
|
34
|
+
function checkEntryName(entry) {
|
|
35
|
+
if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
|
|
36
|
+
if (typeof entry?.context === "string" && entry.context.length > 0) return entry.context;
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split rollup/check-run entries into those matching `targetName` and the rest.
|
|
42
|
+
* Shared by both statusCheckRollup-shaped and check-runs-shaped payloads —
|
|
43
|
+
* both use `.name` (check-runs also use `.context` for legacy StatusContext).
|
|
44
|
+
*
|
|
45
|
+
* @param {Array<object>} entries
|
|
46
|
+
* @param {string|Array<string>} targetName One name, or several to match.
|
|
47
|
+
* @returns {{ matched: Array<object>, rest: Array<object> }}
|
|
48
|
+
*/
|
|
49
|
+
export function partitionEntriesByCheckName(entries, targetName) {
|
|
50
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
51
|
+
const targets = new Set(Array.isArray(targetName) ? targetName : [targetName]);
|
|
52
|
+
const matched = [];
|
|
53
|
+
const rest = [];
|
|
54
|
+
for (const entry of list) {
|
|
55
|
+
(targets.has(checkEntryName(entry)) ? matched : rest).push(entry);
|
|
56
|
+
}
|
|
57
|
+
return { matched, rest };
|
|
58
|
+
}
|
|
59
|
+
|
|
8
60
|
function normalizeHeadScopedCiStatus(status) {
|
|
9
61
|
return VALID_HEAD_SCOPED_CI_STATUSES.has(status) ? status : "none";
|
|
10
62
|
}
|
|
@@ -253,3 +305,27 @@ export function normalizeHeadScopedCiContract({
|
|
|
253
305
|
|
|
254
306
|
return buildCiContract(overallStatus);
|
|
255
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
|
|
311
|
+
* `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
|
|
312
|
+
* workflow's own `gate-evidence-runner` check run) are excluded from the
|
|
313
|
+
* status computation before it can block, and surfaced separately so a
|
|
314
|
+
* genuinely failing check right beside it can never be masked. Every reason
|
|
315
|
+
* gate-evidence can be red (missing draft_gate/pre_approval evidence,
|
|
316
|
+
* unresolved threads, a stale runner) is independently tracked elsewhere in
|
|
317
|
+
* the loop snapshot, so excluding it here loses no real signal: `status`
|
|
318
|
+
* stays a plain "success" (not an "unconfirmed" crediblyGreen) when it is the
|
|
319
|
+
* only excluded failure and everything else is green.
|
|
320
|
+
*
|
|
321
|
+
* @param {Array<object>} rollup
|
|
322
|
+
* @returns {{ status: "success"|"failure"|"pending"|"none", excludedFailureDetails: Array<string> }}
|
|
323
|
+
*/
|
|
324
|
+
export function deriveLoopCiStatusFromRollup(rollup) {
|
|
325
|
+
const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAMES);
|
|
326
|
+
const status = normalizeStatusCheckRollupStatus(rest);
|
|
327
|
+
const excludedFailureDetails = matched.length > 0 && normalizeStatusCheckRollupStatus(matched) === "failure"
|
|
328
|
+
? [LOOP_DERIVED_CI_CHECK_NAME]
|
|
329
|
+
: [];
|
|
330
|
+
return { status, excludedFailureDetails };
|
|
331
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
|
|
1
|
+
import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
|
|
2
2
|
|
|
3
3
|
const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
4
|
-
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
5
4
|
|
|
6
5
|
function normalizeReviewRequestEvents(events) {
|
|
7
6
|
if (!Array.isArray(events)) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* becomes an explicit bounded input (agentFixStatus) rather than hidden orchestration behavior.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
|
|
16
16
|
|
|
17
17
|
/** Stable state name constants for the async Copilot review/fix loop. */
|
|
18
18
|
export const STATE = Object.freeze({
|
|
@@ -191,7 +191,7 @@ export function isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRo
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
export function normalizeCiStatus(rollup) {
|
|
194
|
-
return
|
|
194
|
+
return deriveLoopCiStatusFromRollup(rollup).status;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
export function buildSnapshotFromPrFacts({
|
|
@@ -206,11 +206,15 @@ export function buildSnapshotFromPrFacts({
|
|
|
206
206
|
ciStatus,
|
|
207
207
|
lastCopilotRoundMaxSignal = null,
|
|
208
208
|
failureDetails = [],
|
|
209
|
-
excludedFailureDetails
|
|
209
|
+
excludedFailureDetails,
|
|
210
210
|
}) {
|
|
211
211
|
const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
|
|
212
212
|
const prMerged = prState === "MERGED";
|
|
213
213
|
const prClosed = prState === "CLOSED";
|
|
214
|
+
// Default derivation excludes the loop's own gate-evidence check (#1358) so a
|
|
215
|
+
// caller that never threads an explicit ciStatus (e.g. gate-coordination
|
|
216
|
+
// detection) still never treats it as a blocking CI failure.
|
|
217
|
+
const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
|
|
214
218
|
|
|
215
219
|
return normalizeSnapshot({
|
|
216
220
|
prExists: true,
|
|
@@ -225,9 +229,9 @@ export function buildSnapshotFromPrFacts({
|
|
|
225
229
|
actionableThreadCount,
|
|
226
230
|
copilotReviewRoundCount,
|
|
227
231
|
lastCopilotRoundMaxSignal,
|
|
228
|
-
ciStatus: ciStatus ??
|
|
232
|
+
ciStatus: ciStatus ?? rollupDerivation.status,
|
|
229
233
|
failureDetails,
|
|
230
|
-
excludedFailureDetails,
|
|
234
|
+
excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
|
|
231
235
|
});
|
|
232
236
|
}
|
|
233
237
|
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A shell working directory can silently reset to the primary checkout — after a
|
|
6
|
+
* subprocess run, or when a `cd` inside a compound command does not persist. An
|
|
7
|
+
* agent that then runs a relative-path `git commit && git push` executes it in
|
|
8
|
+
* the PRIMARY checkout on the DEFAULT branch, so the change lands straight on
|
|
9
|
+
* the remote's default branch and skips the PR flow entirely. Prose in a
|
|
10
|
+
* contract cannot stop that; the shell never read it.
|
|
11
|
+
*
|
|
12
|
+
* These hooks make the dangerous operation itself fail. Linked worktrees DO run
|
|
13
|
+
* them — git resolves hooks from the common directory, which every worktree
|
|
14
|
+
* shares — so what spares the loop's own work is the branch/ref check, not the
|
|
15
|
+
* hook's absence. A sanctioned release or reconcile sets DEVLOOPS_ALLOW_MAIN=1
|
|
16
|
+
* to proceed deliberately.
|
|
17
|
+
*/
|
|
18
|
+
export const GUARD_MARKER = "dev-loops:default-branch-guard";
|
|
19
|
+
export const GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
|
|
20
|
+
// pre-merge-commit, not pre-commit, is what git runs for `git merge` (a plain
|
|
21
|
+
// `git commit` never fires during a merge); omitting it let a merge onto a
|
|
22
|
+
// guarded branch land while a plain commit on the same branch was refused.
|
|
23
|
+
export const GUARDED_HOOKS = Object.freeze(["pre-commit", "pre-merge-commit", "pre-push"]);
|
|
24
|
+
|
|
25
|
+
const REFUSAL_BODY = (what, branchExpr) => ` echo "dev-loops: refusing to ${what} ($${branchExpr}) from this checkout." >&2
|
|
26
|
+
echo " The dev-loop works in a linked worktree; a cwd that silently reset to the" >&2
|
|
27
|
+
echo " primary checkout is the usual cause. Re-run from the worktree, addressing it" >&2
|
|
28
|
+
echo " explicitly (git -C <absolute-worktree-path> ...)." >&2
|
|
29
|
+
echo " For a sanctioned release or reconcile: ${GUARD_OVERRIDE_ENV}=1 <command>" >&2`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Git's ref rules forbid spaces and control characters but permit `$`, backtick,
|
|
33
|
+
* quotes, `;`, `&`, `|` and parentheses — every one of which sh expands inside
|
|
34
|
+
* the double-quoted assignment below. A branch named `main$(id)` would execute
|
|
35
|
+
* on every commit, and `main$HOME` would expand to something that never matches
|
|
36
|
+
* the real branch, leaving the default silently unguarded. Only names matching
|
|
37
|
+
* this are baked in. An unsafe DEFAULT branch refuses the install; an unsafe EXPLICIT base is dropped (recorded in droppedExplicitBranches) while the default guard installs.
|
|
38
|
+
*/
|
|
39
|
+
const SHELL_SAFE_BRANCH = /^[A-Za-z0-9._/-]+$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a `defaultBranches` argument (a single branch name, an array of
|
|
43
|
+
* them, or nothing) to a deduped array of trimmed, non-empty strings. More
|
|
44
|
+
* than one branch is guarded when a caller's own default (git's advertised
|
|
45
|
+
* `<remote>/HEAD`) and its resolved working base genuinely differ — e.g. a
|
|
46
|
+
* `.devloops` `workflow.baseBranch` of `develop` in a repo whose real default
|
|
47
|
+
* is still `main`: a stray commit on EITHER must be refused.
|
|
48
|
+
*/
|
|
49
|
+
function normalizeBranchList(branches) {
|
|
50
|
+
const list = branches == null ? [] : Array.isArray(branches) ? branches : [branches];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const raw of list) {
|
|
54
|
+
if (typeof raw !== "string") continue;
|
|
55
|
+
const trimmed = raw.trim();
|
|
56
|
+
if (trimmed.length === 0 || seen.has(trimmed)) continue;
|
|
57
|
+
seen.add(trimmed);
|
|
58
|
+
out.push(trimmed);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {(typeof GUARDED_HOOKS)[number]} hookName one of GUARDED_HOOKS —
|
|
65
|
+
* "pre-commit", "pre-merge-commit", or "pre-push".
|
|
66
|
+
* @param {string|string[]|null} defaultBranches the STICKY set, resolved at
|
|
67
|
+
* INSTALL time and unioned across installs by installDefaultBranchGuard.
|
|
68
|
+
* Baking them in beats re-deriving in shell: `origin/HEAD` is often absent,
|
|
69
|
+
* and a main-or-master guess picks a stale local `main` in a `master` repo,
|
|
70
|
+
* which would guard the wrong branch and leave the real default open.
|
|
71
|
+
* @param {string|string[]|null} explicitBranches an operator-scoped set
|
|
72
|
+
* (e.g. an explicit `--base`) that REPLACES rather than unions across
|
|
73
|
+
* installs — see installDefaultBranchGuard's explicitBaseBranches.
|
|
74
|
+
* @throws {Error} when hookName is not a guarded hook, or any branch is
|
|
75
|
+
* non-empty and not shell-safe. `hookName` and every branch name are
|
|
76
|
+
* interpolated straight into the generated script, so THIS function — not
|
|
77
|
+
* just its installDefaultBranchGuard caller — is the trust boundary and
|
|
78
|
+
* must refuse on its own rather than rely on every caller re-checking first.
|
|
79
|
+
*/
|
|
80
|
+
export function renderGuardHook(hookName, defaultBranches = null, explicitBranches = null) {
|
|
81
|
+
if (!GUARDED_HOOKS.includes(hookName)) {
|
|
82
|
+
throw new Error(`renderGuardHook: unknown hook ${JSON.stringify(hookName)}; expected one of ${GUARDED_HOOKS.join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
const branches = normalizeBranchList(defaultBranches);
|
|
85
|
+
const explicits = normalizeBranchList(explicitBranches);
|
|
86
|
+
const unsafe = [...branches, ...explicits].find((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
87
|
+
if (unsafe) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`default branch ${JSON.stringify(unsafe)} contains characters the generated hook's shell would expand; refusing to render a hook that could execute it`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const defaults = branches.join(" ");
|
|
93
|
+
const explicitDefaults = explicits.join(" ");
|
|
94
|
+
const header = `#!/bin/sh
|
|
95
|
+
# ${GUARD_MARKER}
|
|
96
|
+
# Refuses a ${hookName} that would land on a guarded default branch. Installed
|
|
97
|
+
# in the common hook directory, so linked worktrees run it too — their branch
|
|
98
|
+
# is not one of the guarded ones, which is what lets their work through.
|
|
99
|
+
if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
|
|
100
|
+
exit 0
|
|
101
|
+
fi
|
|
102
|
+
defaults="${defaults}"
|
|
103
|
+
explicit_defaults="${explicitDefaults}"
|
|
104
|
+
if [ -z "$defaults" ] && [ -z "$explicit_defaults" ]; then
|
|
105
|
+
# Not resolvable at install time; fail OPEN rather than guess a branch and
|
|
106
|
+
# protect the wrong one. The install reports this so it is not silent.
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
// pre-commit and pre-merge-commit both fire with HEAD already on the branch
|
|
112
|
+
// the commit would land on (a plain commit vs. finishing a merge) — same
|
|
113
|
+
// check, just under the name git actually invokes for each operation.
|
|
114
|
+
if (hookName !== "pre-push") {
|
|
115
|
+
return `${header}
|
|
116
|
+
# Full ref, not --short: git DISAMBIGUATES a --short symbolic-ref to
|
|
117
|
+
# "heads/main" when a tag also named "main" exists, so comparing the short
|
|
118
|
+
# form against a bare branch name would never match and let the commit land.
|
|
119
|
+
branch=$(git symbolic-ref --quiet HEAD 2>/dev/null) || exit 0
|
|
120
|
+
for default in $defaults $explicit_defaults; do
|
|
121
|
+
if [ "$branch" = "refs/heads/$default" ]; then
|
|
122
|
+
${REFUSAL_BODY("commit on the default branch", "default")}
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
done
|
|
126
|
+
exit 0
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// pre-push receives "<local ref> <local sha> <remote ref> <remote sha>" per
|
|
131
|
+
// line on stdin. Checking the CURRENT branch instead would miss every
|
|
132
|
+
// explicit refspec — `git push origin HEAD:main` from a feature branch is the
|
|
133
|
+
// exact shape that moved a remote default in testing.
|
|
134
|
+
return `${header}
|
|
135
|
+
blocked=0
|
|
136
|
+
blocked_default=""
|
|
137
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
138
|
+
[ -n "$remote_ref" ] || continue
|
|
139
|
+
for default in $defaults $explicit_defaults; do
|
|
140
|
+
if [ "$remote_ref" = "refs/heads/$default" ]; then
|
|
141
|
+
blocked=1
|
|
142
|
+
blocked_default="$default"
|
|
143
|
+
fi
|
|
144
|
+
done
|
|
145
|
+
done
|
|
146
|
+
if [ "$blocked" = "1" ]; then
|
|
147
|
+
${REFUSAL_BODY("push to the default branch", "blocked_default")}
|
|
148
|
+
exit 1
|
|
149
|
+
fi
|
|
150
|
+
exit 0
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Ownership is decided by the marker on a line of its OWN, exactly as the
|
|
155
|
+
// renderer emits it. A bare substring test would claim any file that merely
|
|
156
|
+
// mentions the sentinel — a user wrapper commented "# chains to
|
|
157
|
+
// dev-loops:default-branch-guard" reads as ours and gets overwritten, which is
|
|
158
|
+
// the one thing installDefaultBranchGuard promises never to do.
|
|
159
|
+
const GUARD_MARKER_LINE = new RegExp(`^# ${GUARD_MARKER}$`, "mu");
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pull a baked-in branch list back out of a line this guard itself wrote
|
|
163
|
+
* (`defaults="..."` or `explicit_defaults="..."`), re-validating every entry
|
|
164
|
+
* against SHELL_SAFE_BRANCH rather than trusting the file. A hand-edited (or
|
|
165
|
+
* otherwise corrupted) baked value would otherwise make renderGuardHook THROW
|
|
166
|
+
* on re-install — after earlier hook slots may already have been renamed into
|
|
167
|
+
* place — which breaks the documented "guard.ok: false means nothing was
|
|
168
|
+
* written" invariant. Dropping the unsafe entry here keeps the contract:
|
|
169
|
+
* install refuses on genuinely new bad input, and self-heals a tampered file.
|
|
170
|
+
*/
|
|
171
|
+
function extractBakedBranches(contents, varName) {
|
|
172
|
+
const match = contents.match(new RegExp(`^${varName}="([^"]*)"$`, "mu"));
|
|
173
|
+
if (!match) return [];
|
|
174
|
+
return match[1].split(/\s+/u).filter((branch) => branch.length > 0 && SHELL_SAFE_BRANCH.test(branch));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readHookState(hookPath) {
|
|
178
|
+
if (!fs.existsSync(hookPath)) return { ours: true, absent: true, existingBranches: [], existingExplicitBranches: [] };
|
|
179
|
+
const contents = fs.readFileSync(hookPath, "utf8");
|
|
180
|
+
const ours = GUARD_MARKER_LINE.test(contents);
|
|
181
|
+
return {
|
|
182
|
+
ours,
|
|
183
|
+
absent: false,
|
|
184
|
+
existingBranches: ours ? extractBakedBranches(contents, "defaults") : [],
|
|
185
|
+
existingExplicitBranches: ours ? extractBakedBranches(contents, "explicit_defaults") : [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Install the guard hooks into a repository's hook directory.
|
|
191
|
+
*
|
|
192
|
+
* Idempotent: re-installing rewrites only hooks this guard authored. A hook the
|
|
193
|
+
* user (or another tool) wrote is NEVER clobbered — that file is left exactly as
|
|
194
|
+
* found and reported as `skipped`, because silently replacing someone's hook is
|
|
195
|
+
* a worse failure than not installing ours.
|
|
196
|
+
*
|
|
197
|
+
* `defaultBranches` and `explicitBaseBranches` are tracked as two SEPARATE
|
|
198
|
+
* slots baked into every hook, because they need opposite persistence rules:
|
|
199
|
+
* - `defaultBranches` (a repo's own default) is UNIONED across installs — a
|
|
200
|
+
* later call resolving fewer/none (a transient fetch hiccup) must never
|
|
201
|
+
* un-guard a branch an earlier install already protected.
|
|
202
|
+
* - `explicitBaseBranches` (an operator's `--base`, or a configured
|
|
203
|
+
* `workflow.baseBranch`) is REPLACED wholesale by whatever this call
|
|
204
|
+
* passes — a later call with a different (or no) explicit base must be
|
|
205
|
+
* able to replace or drop it, never stack it forever. Unioning this slot
|
|
206
|
+
* too would permanently guard every branch anyone ever stacked a worktree
|
|
207
|
+
* off, refusing that branch's OWN commits with no way to undo it.
|
|
208
|
+
*
|
|
209
|
+
* @param {{ gitDir: string, defaultBranches?: string|string[]|null, explicitBaseBranches?: string|string[]|null, hooksPathOverride?: string|null }} target
|
|
210
|
+
* `hooksPathOverride` is the repo's `core.hooksPath` when set. Installing into
|
|
211
|
+
* `$GIT_DIR/hooks` while git reads elsewhere would report success for a guard
|
|
212
|
+
* that can never fire, so that case refuses instead.
|
|
213
|
+
*/
|
|
214
|
+
export function installDefaultBranchGuard({
|
|
215
|
+
gitDir,
|
|
216
|
+
defaultBranches = null,
|
|
217
|
+
explicitBaseBranches = null,
|
|
218
|
+
hooksPathOverride = null,
|
|
219
|
+
}) {
|
|
220
|
+
const refuse = (reason, skipReason) => ({
|
|
221
|
+
ok: false,
|
|
222
|
+
installed: [],
|
|
223
|
+
refreshed: [],
|
|
224
|
+
skipped: GUARDED_HOOKS.map((hook) => ({ hook, reason: skipReason })),
|
|
225
|
+
reason,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Any STRING means core.hooksPath is set (`git config --get` exits 0), even
|
|
229
|
+
// to "" — which git treats as "run no hooks at all", not "unset". A caller
|
|
230
|
+
// that collapsed exit-0-empty and exit-1-unset to the same null would make
|
|
231
|
+
// this refusal unreachable for exactly the config value it exists to catch.
|
|
232
|
+
if (typeof hooksPathOverride === "string") {
|
|
233
|
+
const configured = hooksPathOverride.trim();
|
|
234
|
+
return configured.length > 0
|
|
235
|
+
? refuse(
|
|
236
|
+
`core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
|
|
237
|
+
`core.hooksPath is set to ${JSON.stringify(configured)}, so hooks in $GIT_DIR/hooks would never run`,
|
|
238
|
+
)
|
|
239
|
+
: refuse(
|
|
240
|
+
`core.hooksPath is set to an empty string — git runs no hooks at all; unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
|
|
241
|
+
"core.hooksPath is set to an empty string, so git runs no hooks at all",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A relative or empty gitDir resolves against the caller's cwd, which writes a
|
|
246
|
+
// stray hooks/ directory into the working tree and reports success for a guard
|
|
247
|
+
// git will never read.
|
|
248
|
+
if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
|
|
249
|
+
return refuse(
|
|
250
|
+
`gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`,
|
|
251
|
+
"no absolute git directory to install into",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Absoluteness alone does not make it a GIT dir: a caller slip (the worktree
|
|
256
|
+
// root instead of its .git) would otherwise pass, then mkdirSync a stray
|
|
257
|
+
// hooks/ tree there and report success for a guard git will never read.
|
|
258
|
+
// Every real git dir — bare, a main checkout, or a linked worktree's own
|
|
259
|
+
// gitdir — has a HEAD file; that is the cheapest reliable probe.
|
|
260
|
+
if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
|
|
261
|
+
return refuse(
|
|
262
|
+
`gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`,
|
|
263
|
+
"gitDir does not look like a git directory",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A linked worktree's OWN per-worktree gitdir (`.git/worktrees/<name>`) also
|
|
268
|
+
// has a HEAD file, so the probe above alone lets it through — hooks written
|
|
269
|
+
// there are never resolved by git (hooks always come from the COMMON dir),
|
|
270
|
+
// so this would report ok:true for a guard that can never fire. `commondir`
|
|
271
|
+
// exists only in a linked worktree's own gitdir, never in the common one:
|
|
272
|
+
// a one-line, same-cost discriminator against exactly that gitdir.
|
|
273
|
+
if (fs.existsSync(path.join(gitDir, "commondir"))) {
|
|
274
|
+
return refuse(
|
|
275
|
+
`gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`,
|
|
276
|
+
"gitDir is a linked worktree's own git directory (has a commondir file), not the common one hooks are resolved from",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const branches = normalizeBranchList(defaultBranches);
|
|
281
|
+
const allExplicitBranches = normalizeBranchList(explicitBaseBranches);
|
|
282
|
+
const unsafeBranch = branches.find((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
283
|
+
|
|
284
|
+
if (unsafeBranch) {
|
|
285
|
+
return refuse(
|
|
286
|
+
`default branch ${JSON.stringify(unsafeBranch)} contains characters the generated hook's shell would expand; refusing rather than installing a hook that could execute it or silently guard the wrong branch`,
|
|
287
|
+
"the resolved default branch name is not shell-safe",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A shell-unsafe EXPLICIT base (git-legal names like "feat(auth)" or
|
|
292
|
+
// "fix#123") must not kill the whole install — that would leave the real
|
|
293
|
+
// default branch unguarded while reporting the worktree ok. Drop only the
|
|
294
|
+
// unsafe explicit entries, install the default guard regardless, and record
|
|
295
|
+
// what was dropped so the caller can surface it.
|
|
296
|
+
const droppedExplicitBranches = allExplicitBranches.filter((branch) => !SHELL_SAFE_BRANCH.test(branch));
|
|
297
|
+
const explicitBranches = allExplicitBranches.filter((branch) => SHELL_SAFE_BRANCH.test(branch));
|
|
298
|
+
|
|
299
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
300
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
301
|
+
|
|
302
|
+
// Read every guarded slot's on-disk state ONCE, up front — not inside the
|
|
303
|
+
// write loop — so the union below is computed repo-wide. Computing it
|
|
304
|
+
// per-hook instead let a slot that just became free (a foreign hook
|
|
305
|
+
// removed) miss what its siblings already had baked in, e.g. a hook
|
|
306
|
+
// re-installed after its foreign occupant is gone could get written INERT
|
|
307
|
+
// while its siblings still enforced the real default.
|
|
308
|
+
const hookStates = GUARDED_HOOKS.map((hook) => ({ hook, hookPath: path.join(hooksDir, hook), ...readHookState(path.join(hooksDir, hook)) }));
|
|
309
|
+
|
|
310
|
+
// Sticky slot: union across whatever this call resolved (`branches`) with
|
|
311
|
+
// what ANY hook of ours already had baked in — never a straight overwrite.
|
|
312
|
+
// A caller's resolution can legitimately come back empty or narrower on a
|
|
313
|
+
// later call (a transient fetch/network hiccup, a remote HEAD that briefly
|
|
314
|
+
// stopped advertising); rewriting to that smaller set would silently
|
|
315
|
+
// un-guard a branch an earlier install already protected.
|
|
316
|
+
const stickyBranches = new Set(branches);
|
|
317
|
+
for (const state of hookStates) {
|
|
318
|
+
if (!state.ours) continue;
|
|
319
|
+
for (const branch of state.existingBranches) stickyBranches.add(branch);
|
|
320
|
+
}
|
|
321
|
+
const finalStickyBranches = [...stickyBranches];
|
|
322
|
+
|
|
323
|
+
// Explicit-base slot: REPLACED wholesale by whatever this call passed, never
|
|
324
|
+
// unioned with what a PRIOR call baked in — that is what makes the slot
|
|
325
|
+
// droppable (a later call with a different, or no, explicit base) instead
|
|
326
|
+
// of accumulating every branch anyone ever stacked a worktree off.
|
|
327
|
+
const finalExplicitBranches = explicitBranches;
|
|
328
|
+
|
|
329
|
+
const installed = [];
|
|
330
|
+
const refreshed = [];
|
|
331
|
+
const skipped = [];
|
|
332
|
+
let anyWritten = false;
|
|
333
|
+
|
|
334
|
+
for (const state of hookStates) {
|
|
335
|
+
const { hook, hookPath, ours, absent } = state;
|
|
336
|
+
if (!ours) {
|
|
337
|
+
skipped.push({ hook, reason: "a pre-existing hook is present and was left untouched" });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// Write + chmod a temp file in the SAME directory, then rename into place.
|
|
341
|
+
// A direct writeFileSync is visible to git mid-write: the common hooks dir
|
|
342
|
+
// is shared, so a concurrent ensureWorktree call (or a real commit racing
|
|
343
|
+
// an install) can exec the file while it is still header-only, falling
|
|
344
|
+
// through to `exit 0` and letting a default-branch commit land. A same-dir
|
|
345
|
+
// rename is atomic, so any reader sees either the old hook or the new one,
|
|
346
|
+
// never a partial one.
|
|
347
|
+
const tmpPath = path.join(hooksDir, `.${hook}.tmp-${process.pid}-${Date.now()}`);
|
|
348
|
+
fs.writeFileSync(tmpPath, renderGuardHook(hook, finalStickyBranches, finalExplicitBranches), { mode: 0o755 });
|
|
349
|
+
fs.chmodSync(tmpPath, 0o755); // mode above is umask-limited; force it
|
|
350
|
+
fs.renameSync(tmpPath, hookPath);
|
|
351
|
+
anyWritten = true;
|
|
352
|
+
(absent ? installed : refreshed).push(hook);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Report only what a hook actually enforces: a branch reads as guarded when
|
|
356
|
+
// at least one slot was WRITTEN with it, never merely requested. Seeding
|
|
357
|
+
// this from `branches` before the loop (the prior bug) claimed enforcement
|
|
358
|
+
// — ok: true, defaultBranches: ["main"] — even when every slot was foreign
|
|
359
|
+
// and nothing was written.
|
|
360
|
+
const reportedBranches = anyWritten ? [...new Set([...finalStickyBranches, ...finalExplicitBranches])] : [];
|
|
361
|
+
let reason;
|
|
362
|
+
if (reportedBranches.length === 0) {
|
|
363
|
+
reason = anyWritten
|
|
364
|
+
? "default branch could not be resolved at install time; the hooks are inert rather than guessing which branch to protect"
|
|
365
|
+
: "every guarded hook slot is already occupied by a foreign hook; nothing was written, so nothing is enforced";
|
|
366
|
+
}
|
|
367
|
+
if (droppedExplicitBranches.length > 0) {
|
|
368
|
+
const dropNote = `explicit base ${droppedExplicitBranches.map((branch) => JSON.stringify(branch)).join(", ")} contains characters the generated hook's shell would expand; the default guard was installed without it`;
|
|
369
|
+
reason = reason ? `${reason}; ${dropNote}` : dropNote;
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
installed,
|
|
374
|
+
refreshed,
|
|
375
|
+
skipped,
|
|
376
|
+
defaultBranches: reportedBranches,
|
|
377
|
+
...(droppedExplicitBranches.length > 0 ? { droppedExplicitBranches } : {}),
|
|
378
|
+
...(reason ? { reason } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* caller records the carried verdict with provenance pointing at the PRIOR head's
|
|
18
18
|
* reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
|
|
19
19
|
* did not touch), clearly marked as carried — see
|
|
20
|
-
* docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
20
|
+
* skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
21
21
|
* `carriedFromHead` provenance field.
|
|
22
22
|
*
|
|
23
23
|
* The angle -> review-surface mapping is DERIVED from the single source of truth
|
|
@@ -125,7 +125,13 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
125
125
|
const name = typeof angle === "string" ? angle.trim() : "";
|
|
126
126
|
if (name.length === 0) return { kind: "unknown" };
|
|
127
127
|
if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
|
|
128
|
-
|
|
128
|
+
// Case-insensitive: callers key angles as base+lowercase while configs may
|
|
129
|
+
// carry case drift ("Correctness"); normalizing HERE keeps producer and
|
|
130
|
+
// consumer on one predicate instead of each caller pre-lowercasing.
|
|
131
|
+
if (alwaysRerun) {
|
|
132
|
+
const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
|
|
133
|
+
if (normalized.has(name.toLowerCase())) return { kind: "always" };
|
|
134
|
+
}
|
|
129
135
|
const kinds = ANGLE_SURFACE_KINDS.get(name);
|
|
130
136
|
if (!kinds || kinds.size === 0) return { kind: "unknown" };
|
|
131
137
|
return { kind: "kinds", kinds: new Set(kinds) };
|
|
@@ -149,6 +155,24 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
149
155
|
* is carry-forward-eligible.
|
|
150
156
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
151
157
|
*/
|
|
158
|
+
|
|
159
|
+
// A path whose change rewrites the dev-loop review system itself — the angle
|
|
160
|
+
// pool, mandatory floor, and reviewer personas/prompts — rather than a
|
|
161
|
+
// reviewed surface. A clean verdict produced under the OLD config cannot
|
|
162
|
+
// carry across such a delta, and a converged Copilot round cannot be treated
|
|
163
|
+
// as still-converged either. classifyFile correctly reports these as
|
|
164
|
+
// "config"; this predicate is the carry-forward-specific override. The
|
|
165
|
+
// shipped defaults file is in this class too: it is the layer that ships the
|
|
166
|
+
// angle pool and reviewer prompts, so a delta touching it must never be
|
|
167
|
+
// carried across or reviewed under a reduced diff-class tier.
|
|
168
|
+
const DEV_LOOP_CONFIG_SOURCE_RE = /(^|\/)(\.devloops(\.(ya?ml|json))?|\.pi\/dev-loop\/(settings|defaults)\.[^/]+|packages\/core\/src\/config\/extension-defaults\.yaml)$/;
|
|
169
|
+
export function isDevLoopConfigSourcePath(filePath) {
|
|
170
|
+
if (typeof filePath !== "string") return false;
|
|
171
|
+
// Normalize Windows separators like classifyFile does, so a
|
|
172
|
+
// ".pi\\dev-loop\\settings.yaml" delta is still detected on Windows.
|
|
173
|
+
return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
153
177
|
if (prevVerdict !== "clean") {
|
|
154
178
|
return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
|
|
@@ -164,6 +188,9 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
|
|
|
164
188
|
return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
|
|
165
189
|
}
|
|
166
190
|
for (const file of changedFiles) {
|
|
191
|
+
if (isDevLoopConfigSourcePath(file)) {
|
|
192
|
+
return { carryForward: false, reason: `delta rewrites the dev-loop config source (reviewer pool/prompts): ${file}` };
|
|
193
|
+
}
|
|
167
194
|
const kind = classifyFile(file);
|
|
168
195
|
if (kind === "unknown") {
|
|
169
196
|
return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };
|