mandrel 1.89.0 → 1.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +4 -0
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/epic-deliver-preflight.js +37 -1
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
- package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
- package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/single-story-init.js +22 -0
- package/.agents/workflows/deliver.md +8 -0
- package/.agents/workflows/helpers/deliver-epic.md +11 -0
- package/.agents/workflows/helpers/single-story-deliver.md +8 -0
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -300,6 +300,7 @@ top-level keys are validation errors.
|
|
|
300
300
|
| `ci.watch.maxPolls` | No | `integer` | — | — |
|
|
301
301
|
| `ci.watch.maxResumes` | No | `integer` | — | — |
|
|
302
302
|
| `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | — | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
|
|
303
|
+
| `ci.requireChecks` | No | `boolean` | — | Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path. |
|
|
303
304
|
| `preflight` | No | `object` | — | Story #2899 (Epic #2880, F13). Thresholds consumed by `.agents/scripts/epic-deliver-preflight.js`. When any value is exceeded the preflight envelope flags a breach and /deliver Phase 1 surfaces it via agent::blocked. |
|
|
304
305
|
| `preflight.maxStories` | No | `integer` | — | — |
|
|
305
306
|
| `preflight.maxWaves` | No | `integer` | — | — |
|
|
@@ -1456,6 +1456,10 @@
|
|
|
1456
1456
|
"type": "string",
|
|
1457
1457
|
"enum": ["trust-ci", "strict"],
|
|
1458
1458
|
"description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate."
|
|
1459
|
+
},
|
|
1460
|
+
"requireChecks": {
|
|
1461
|
+
"type": "boolean",
|
|
1462
|
+
"description": "Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path."
|
|
1459
1463
|
}
|
|
1460
1464
|
},
|
|
1461
1465
|
"additionalProperties": false
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/epic.blocked.schema.json",
|
|
4
4
|
"title": "epic.blocked",
|
|
5
|
-
"description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation)
|
|
5
|
+
"description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation), MergeWatcher (on a merge-watch timeout), and — in headless runs (Story #4472) — AutomergePredicate (on a predicate refusal that would otherwise silently park) and AutomergeArmer (on a genuine arm failure) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, merge-predicate:refused, merge-arm:failed, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": ["reason"],
|
|
8
8
|
"properties": {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from './lib/coverage-capture.js';
|
|
34
34
|
|
|
35
35
|
import { Logger } from './lib/Logger.js';
|
|
36
|
+
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
36
37
|
|
|
37
38
|
function parseArgs(argv) {
|
|
38
39
|
const out = {
|
|
@@ -59,6 +60,22 @@ function main() {
|
|
|
59
60
|
return 0;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Story #4473 — detect the "missing npm script" misconfiguration
|
|
64
|
+
// distinctly. `close-validation/gates.js` already declines to register
|
|
65
|
+
// this gate when `test:coverage` is absent, so reaching here without the
|
|
66
|
+
// script means a direct/pre-push invocation in a consumer that never
|
|
67
|
+
// defined it. Surface a one-line, fix-naming diagnostic instead of
|
|
68
|
+
// spawning `npm run test:coverage` only to propagate npm's opaque
|
|
69
|
+
// "Missing script" exit code.
|
|
70
|
+
if (!hasNpmScript(readPackageScripts(args.cwd), 'test:coverage')) {
|
|
71
|
+
Logger.error(
|
|
72
|
+
'[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
|
|
73
|
+
'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
|
|
74
|
+
'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
|
|
75
|
+
);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
if (args.skipWhenNoCrapFiles) {
|
|
63
80
|
let changed;
|
|
64
81
|
try {
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* `storyCount`, `installCostSeconds`, `dependencyDepth`,
|
|
12
12
|
* `githubApiRequests`, `claudeQuotaTokens`, plus `breaches`
|
|
13
13
|
* (the non-empty subset of `delivery.preflight.max*` thresholds the
|
|
14
|
-
* estimate exceeds)
|
|
14
|
+
* estimate exceeds), plus `remoteVerified` / `remoteProbe` — the
|
|
15
|
+
* issue #4483 deterministic remote evidence (`git remote get-url
|
|
16
|
+
* origin` + bounded `git ls-remote origin HEAD`). On
|
|
17
|
+
* `remoteVerified: false` the workflow MUST flip the Epic to
|
|
18
|
+
* `agent::blocked` quoting `remoteProbe.detail` — inline delivery to
|
|
19
|
+
* local `main` is never a sanctioned fallback.
|
|
15
20
|
* 2. When `--post` is set (and `--dry-run` is not), an upserted
|
|
16
21
|
* `delivery-preflight` structured comment on the Epic ticket so
|
|
17
22
|
* reviewers reading the Epic discover the same numbers without
|
|
@@ -66,6 +71,7 @@ import {
|
|
|
66
71
|
computeBaseSha,
|
|
67
72
|
writePreflightCache,
|
|
68
73
|
} from './lib/orchestration/preflight-cache.js';
|
|
74
|
+
import { verifyRemote } from './lib/orchestration/remote-verifier.js';
|
|
69
75
|
import { upsertStructuredComment } from './lib/orchestration/ticketing.js';
|
|
70
76
|
import { createProvider } from './lib/provider-factory.js';
|
|
71
77
|
|
|
@@ -218,10 +224,23 @@ export function renderPreflightBody({
|
|
|
218
224
|
estimate,
|
|
219
225
|
breaches,
|
|
220
226
|
thresholds,
|
|
227
|
+
remote,
|
|
221
228
|
}) {
|
|
222
229
|
const lines = [];
|
|
223
230
|
lines.push(`### 🛫 Delivery preflight — Epic #${epicId}`);
|
|
224
231
|
lines.push('');
|
|
232
|
+
// Issue #4483 — verified remote evidence at entry. Rendered before the
|
|
233
|
+
// metric table so a reviewer (and the orchestrating agent) sees the
|
|
234
|
+
// land-or-block fact first. Omitted when the caller has no probe result
|
|
235
|
+
// (legacy callers / tests that only exercise the estimate math).
|
|
236
|
+
if (remote) {
|
|
237
|
+
lines.push(
|
|
238
|
+
remote.remoteVerified
|
|
239
|
+
? `✅ **remoteVerified: true** — ${remote.detail}`
|
|
240
|
+
: `⛔ **remoteVerified: false** — ${remote.detail} — \`/deliver\` MUST transition the Epic to \`agent::blocked\` quoting this probe; inline delivery to local \`main\` is forbidden.`,
|
|
241
|
+
);
|
|
242
|
+
lines.push('');
|
|
243
|
+
}
|
|
225
244
|
lines.push('| Metric | Estimate | Threshold |');
|
|
226
245
|
lines.push('| --- | ---: | ---: |');
|
|
227
246
|
const rows = [
|
|
@@ -276,6 +295,7 @@ export function renderPreflightBody({
|
|
|
276
295
|
* perStoryClaudeTokens?: number,
|
|
277
296
|
* injectedProvider?: object,
|
|
278
297
|
* injectedConfig?: object,
|
|
298
|
+
* verifyRemoteFn?: typeof verifyRemote,
|
|
279
299
|
* }} args
|
|
280
300
|
*/
|
|
281
301
|
export async function runPreflight({
|
|
@@ -288,6 +308,7 @@ export async function runPreflight({
|
|
|
288
308
|
perStoryClaudeTokens,
|
|
289
309
|
injectedProvider,
|
|
290
310
|
injectedConfig,
|
|
311
|
+
verifyRemoteFn = verifyRemote,
|
|
291
312
|
}) {
|
|
292
313
|
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
293
314
|
throw new TypeError('runPreflight: --epic must be a positive integer');
|
|
@@ -297,6 +318,13 @@ export async function runPreflight({
|
|
|
297
318
|
const provider = injectedProvider ?? createProvider(config);
|
|
298
319
|
const thresholds = getPreflight(config);
|
|
299
320
|
|
|
321
|
+
// Issue #4483 — deterministic remote evidence at entry. Probe BEFORE any
|
|
322
|
+
// provider work so the envelope always carries verified fact (not the
|
|
323
|
+
// agent's perception) about whether a live, pushable `origin` exists.
|
|
324
|
+
// The CLI records; the `/deliver` workflow owns the `agent::blocked`
|
|
325
|
+
// transition on `remoteVerified: false` (same split as breach handling).
|
|
326
|
+
const remote = verifyRemoteFn({ cwd });
|
|
327
|
+
|
|
300
328
|
// Compose the same two phases /deliver Phase 1 runs so the
|
|
301
329
|
// preflight numbers match the actual dispatch plan.
|
|
302
330
|
const ctx = { epicId, provider };
|
|
@@ -348,6 +376,13 @@ export async function runPreflight({
|
|
|
348
376
|
thresholds,
|
|
349
377
|
baseSha,
|
|
350
378
|
cacheWritten,
|
|
379
|
+
// Issue #4483 — verified remote evidence. `remoteVerified: false`
|
|
380
|
+
// REQUIRES the workflow to block explicitly (never build inline).
|
|
381
|
+
remoteVerified: remote.remoteVerified,
|
|
382
|
+
remoteProbe: {
|
|
383
|
+
remoteUrl: remote.remoteUrl,
|
|
384
|
+
detail: remote.detail,
|
|
385
|
+
},
|
|
351
386
|
};
|
|
352
387
|
|
|
353
388
|
if (post && !dryRun) {
|
|
@@ -356,6 +391,7 @@ export async function runPreflight({
|
|
|
356
391
|
estimate,
|
|
357
392
|
breaches,
|
|
358
393
|
thresholds,
|
|
394
|
+
remote,
|
|
359
395
|
});
|
|
360
396
|
await upsertStructuredComment(provider, epicId, 'delivery-preflight', body);
|
|
361
397
|
envelope.commentUpserted = true;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* runner (`INDEPENDENT_GATE_NAMES` / `partitionGates`).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
|
|
9
10
|
import {
|
|
10
11
|
buildFormatHint,
|
|
11
12
|
FORMAT_CHECK_FALLBACK,
|
|
@@ -87,17 +88,30 @@ function isCrapGateEnabled(config) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
91
|
+
* The gates run in the Story worktree, whose `package.json` is the committed
|
|
92
|
+
* one the consumer ships — the presence of a `test:coverage` script is a
|
|
93
|
+
* committed fact, so probing at the gate cwd is authoritative. See
|
|
94
|
+
* `lib/npm-scripts.js` for the shared reader.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Conditionally produce the standalone `test` gate entry.
|
|
95
99
|
*
|
|
96
|
-
*
|
|
100
|
+
* The plain `test` gate is the canonical test runner UNLESS the
|
|
101
|
+
* coverage-capture gate is taking that role — which happens only when the
|
|
102
|
+
* CRAP gate is enabled (Story #1798) AND the consumer actually ships a
|
|
103
|
+
* `test:coverage` script for coverage-capture to run (#4473). When CRAP is
|
|
104
|
+
* enabled but `test:coverage` is absent, coverage-capture is dropped from
|
|
105
|
+
* the gate list, so the `test` gate MUST come back — otherwise the consumer
|
|
106
|
+
* has NO working test gate at all. Splitting this out keeps
|
|
107
|
+
* `buildDefaultGates` flat for the CRAP-cyclomatic gate.
|
|
108
|
+
*
|
|
109
|
+
* @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
|
|
110
|
+
* is registered as the test runner for this build.
|
|
97
111
|
* @returns {Gate[]}
|
|
98
112
|
*/
|
|
99
|
-
function buildTestGateEntry(
|
|
100
|
-
if (
|
|
113
|
+
function buildTestGateEntry(coverageCaptureActive) {
|
|
114
|
+
if (coverageCaptureActive) return [];
|
|
101
115
|
return [{ name: 'test', cmd: 'npm', args: ['test'] }];
|
|
102
116
|
}
|
|
103
117
|
|
|
@@ -105,10 +119,13 @@ function buildTestGateEntry(config) {
|
|
|
105
119
|
* Build the canonical close-validation gate list.
|
|
106
120
|
*
|
|
107
121
|
* Ordering (cheapest fast-fail first): typecheck → lint → [test] →
|
|
108
|
-
* format → coverage-capture → check-baselines. The standalone `test`
|
|
109
|
-
* gate is dropped when
|
|
110
|
-
*
|
|
111
|
-
*
|
|
122
|
+
* format → [coverage-capture] → check-baselines. The standalone `test`
|
|
123
|
+
* gate is dropped when coverage-capture is the active test runner — i.e.
|
|
124
|
+
* `crap.enabled === true` (Story #1798) AND a `test:coverage` script
|
|
125
|
+
* exists (Story #4473) — because coverage-capture then carries
|
|
126
|
+
* test-failure signalling under c8. When CRAP is on but `test:coverage` is
|
|
127
|
+
* absent, coverage-capture is dropped and the `test` gate is restored so
|
|
128
|
+
* there is always a working test gate.
|
|
112
129
|
*
|
|
113
130
|
* `typecheck` is mandatory; consumers may customise the command via
|
|
114
131
|
* `project.commands.typecheck` (default `npm run typecheck`).
|
|
@@ -127,15 +144,34 @@ function buildTestGateEntry(config) {
|
|
|
127
144
|
* re-discovered inherited main-vs-epic drift in untouched files as phantom
|
|
128
145
|
* regressions and worked around it by hand-setting `BASELINE_REF`.
|
|
129
146
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
147
|
+
* Story #4473 — the coverage-capture gate spawns `npm run test:coverage`,
|
|
148
|
+
* so it is registered ONLY when the consumer actually ships that script.
|
|
149
|
+
* When CRAP is enabled but `test:coverage` is absent, coverage-capture is
|
|
150
|
+
* dropped and the plain `test` gate is restored (see `buildTestGateEntry`),
|
|
151
|
+
* so a consumer without a coverage script gets a working degraded test gate
|
|
152
|
+
* instead of a deterministic close failure with no test gate at all. The
|
|
153
|
+
* probe reads `package.json` at `cwd` (the gate execution directory).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ config?: object, epicBranch?: string, cwd?: string, packageScripts?: Record<string, string> }} [opts]
|
|
156
|
+
* `config` is the canonical resolved config (`{ project, delivery, ... }`);
|
|
157
|
+
* gate commands resolve from `project.commands` and the CRAP toggle from
|
|
133
158
|
* `delivery.quality.gates.crap.enabled`. `epicBranch` is the close run's
|
|
134
159
|
* integration branch (`epic/<id>` for Epic-attached Stories, the base
|
|
135
|
-
* branch for standalone Stories).
|
|
160
|
+
* branch for standalone Stories). `cwd` is where the `package.json`
|
|
161
|
+
* coverage-script probe reads from (defaults to `process.cwd()`);
|
|
162
|
+
* `packageScripts` injects the scripts map directly (tests) and short-
|
|
163
|
+
* circuits the disk read.
|
|
136
164
|
* @returns {Gate[]}
|
|
137
165
|
*/
|
|
138
|
-
export function buildDefaultGates({
|
|
166
|
+
export function buildDefaultGates({
|
|
167
|
+
config,
|
|
168
|
+
epicBranch,
|
|
169
|
+
cwd,
|
|
170
|
+
packageScripts,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const scripts = packageScripts ?? readPackageScripts(cwd);
|
|
173
|
+
const coverageCaptureActive =
|
|
174
|
+
isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
|
|
139
175
|
const typecheckCmdString = resolveTypecheckCommand(config);
|
|
140
176
|
const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
|
|
141
177
|
.split(/\s+/)
|
|
@@ -158,7 +194,7 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
|
|
|
158
194
|
hint: TYPECHECK_HINT,
|
|
159
195
|
},
|
|
160
196
|
{ name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
|
|
161
|
-
...buildTestGateEntry(
|
|
197
|
+
...buildTestGateEntry(coverageCaptureActive),
|
|
162
198
|
{
|
|
163
199
|
// Gate name kept generic ("format") so the close-orchestrator log line
|
|
164
200
|
// and the per-gate phase-timer key don't shift when a repo swaps biome
|
|
@@ -172,12 +208,16 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
|
|
|
172
208
|
? { changedFileScope: formatChangedFileScope }
|
|
173
209
|
: {}),
|
|
174
210
|
},
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
...(coverageCaptureActive
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
name: 'coverage-capture',
|
|
215
|
+
cmd: 'node',
|
|
216
|
+
args: ['.agents/scripts/coverage-capture.js'],
|
|
217
|
+
hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
181
221
|
{
|
|
182
222
|
// Story #2210 — unified `check-baselines` gate is the only path for
|
|
183
223
|
// per-kind regression enforcement. The legacy per-kind in-process
|
|
@@ -12,12 +12,19 @@
|
|
|
12
12
|
* waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
|
|
13
13
|
* (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
|
|
14
14
|
* required checks pass, `"strict"` additionally requires a clean review gate.
|
|
15
|
+
*
|
|
16
|
+
* Story #4472 adds `requireChecks` (default `false`): when `true` the
|
|
17
|
+
* AutomergePredicate treats a checks-less repo ("no checks reported") as a
|
|
18
|
+
* hard block rather than green, so a consumer that wants fail-closed-without-
|
|
19
|
+
* checks as policy opts into it explicitly instead of the framework blocking
|
|
20
|
+
* implicitly.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
export const CI_DELIVERY_DEFAULTS = Object.freeze({
|
|
18
24
|
skipForStoryPushes: true,
|
|
19
25
|
earlyPr: true,
|
|
20
26
|
autoMerge: 'trust-ci',
|
|
27
|
+
requireChecks: false,
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -28,7 +35,7 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
|
|
|
28
35
|
* defaults; only the scalar knobs carry framework defaults here.
|
|
29
36
|
*
|
|
30
37
|
* @param {object | null | undefined} config
|
|
31
|
-
* @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
|
|
38
|
+
* @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', requireChecks: boolean, watch: object | undefined }}
|
|
32
39
|
*/
|
|
33
40
|
export function getCiDelivery(config) {
|
|
34
41
|
const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
|
|
@@ -45,6 +52,10 @@ export function getCiDelivery(config) {
|
|
|
45
52
|
ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
|
|
46
53
|
? ci.autoMerge
|
|
47
54
|
: CI_DELIVERY_DEFAULTS.autoMerge,
|
|
55
|
+
requireChecks:
|
|
56
|
+
typeof ci.requireChecks === 'boolean'
|
|
57
|
+
? ci.requireChecks
|
|
58
|
+
: CI_DELIVERY_DEFAULTS.requireChecks,
|
|
48
59
|
watch:
|
|
49
60
|
ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
|
|
50
61
|
};
|
|
@@ -264,6 +264,13 @@ const CI_DELIVERY_SCHEMA = {
|
|
|
264
264
|
earlyPr: { type: 'boolean' },
|
|
265
265
|
watch: CI_WATCH_SCHEMA,
|
|
266
266
|
autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
|
|
267
|
+
// Story #4472 — fail-closed-without-checks policy. When `true`, the
|
|
268
|
+
// AutomergePredicate refuses to arm merge in a repo that reports zero
|
|
269
|
+
// required checks ("no checks reported"), treating the absence of a CI
|
|
270
|
+
// gate as a hard block instead of green. Defaults to `false` so a
|
|
271
|
+
// checks-less repo with green close-validation gates lands headlessly
|
|
272
|
+
// rather than parking on the operator-merges path.
|
|
273
|
+
requireChecks: { type: 'boolean' },
|
|
267
274
|
},
|
|
268
275
|
additionalProperties: false,
|
|
269
276
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-scripts.js — shared `package.json` scripts probe (Story #4473).
|
|
3
|
+
*
|
|
4
|
+
* A single, dependency-free reader used wherever the framework must decide
|
|
5
|
+
* whether a consumer actually ships a given npm script before spawning
|
|
6
|
+
* `npm run <name>`. Two call sites depend on it:
|
|
7
|
+
* - `close-validation/gates.js` — only registers the coverage-capture gate
|
|
8
|
+
* when a `test:coverage` script exists (otherwise a consumer without it
|
|
9
|
+
* turns the gate into a guaranteed first-try close failure).
|
|
10
|
+
* - `coverage-capture.js` — fails fast with a one-line, fix-naming
|
|
11
|
+
* diagnostic instead of surfacing npm's opaque "Missing script" exit when
|
|
12
|
+
* invoked without the script.
|
|
13
|
+
*
|
|
14
|
+
* The reader is deliberately forgiving: any failure (missing file,
|
|
15
|
+
* unreadable, unparseable, or no `scripts` object) resolves to an empty map
|
|
16
|
+
* so callers treat "cannot prove the script exists" as "absent" without
|
|
17
|
+
* throwing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the `scripts` map from the `package.json` at `cwd`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|undefined|null} cwd - Directory containing `package.json`.
|
|
27
|
+
* Defaults to `process.cwd()`.
|
|
28
|
+
* @returns {Record<string, string>} The scripts map, or `{}` on any failure.
|
|
29
|
+
*/
|
|
30
|
+
export function readPackageScripts(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const pkgPath = path.join(cwd || process.cwd(), 'package.json');
|
|
33
|
+
if (!existsSync(pkgPath)) return {};
|
|
34
|
+
const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
35
|
+
return parsed && typeof parsed.scripts === 'object' && parsed.scripts
|
|
36
|
+
? parsed.scripts
|
|
37
|
+
: {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does the consumer define a runnable npm script by this name? A script is
|
|
45
|
+
* "runnable" when it is a present, non-empty string.
|
|
46
|
+
*
|
|
47
|
+
* @param {Record<string, string>} scripts - A scripts map (from
|
|
48
|
+
* `readPackageScripts`).
|
|
49
|
+
* @param {string} name - The script name to check (e.g. `test:coverage`).
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function hasNpmScript(scripts, name) {
|
|
53
|
+
const s = scripts?.[name];
|
|
54
|
+
return typeof s === 'string' && s.trim().length > 0;
|
|
55
|
+
}
|
|
@@ -48,9 +48,13 @@
|
|
|
48
48
|
*
|
|
49
49
|
* The schema declares `additionalProperties: false`, so this emitter's
|
|
50
50
|
* signature is deliberately narrow: only the schema-allowed fields are
|
|
51
|
-
* accepted. `blockClass` MUST be
|
|
52
|
-
* `merge-block-class.js` —
|
|
53
|
-
*
|
|
51
|
+
* accepted. `blockClass` MUST be a valid `merge.unlanded` attribution from
|
|
52
|
+
* `merge-block-class.js` (`MERGE_UNLANDED_BLOCK_CLASSES` — the four
|
|
53
|
+
* `classifyMergeBlock` outputs plus the directly-emitted `predicate-refused`,
|
|
54
|
+
* Story #4472). For a post-arm poll-exhaustion block, pass the classifier's
|
|
55
|
+
* verdict straight through (`classifyMergeBlock(...)` returns
|
|
56
|
+
* `{ blockClass, reason }`); the predicate/armer refusal paths pass
|
|
57
|
+
* `predicate-refused` / a classified arm failure directly.
|
|
54
58
|
*/
|
|
55
59
|
|
|
56
60
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
@@ -98,8 +102,9 @@ function getValidator() {
|
|
|
98
102
|
* @param {number} opts.ticketId epicId when `scope === 'epic'`,
|
|
99
103
|
* storyId when `scope === 'story'`.
|
|
100
104
|
* @param {number} opts.prNumber The PR number that did not land.
|
|
101
|
-
* @param {string} opts.blockClass
|
|
102
|
-
* `
|
|
105
|
+
* @param {string} opts.blockClass A valid `merge.unlanded` attribution
|
|
106
|
+
* (`MERGE_UNLANDED_BLOCK_CLASSES` in
|
|
107
|
+
* `merge-block-class.js`).
|
|
103
108
|
* @param {string} opts.reason Free-form diagnosis detail — pass
|
|
104
109
|
* the classifier's `reason`.
|
|
105
110
|
* @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
|