unitbob 0.5.0 → 0.6.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/README.md +37 -0
- package/dist/cli.js +9 -1
- package/dist/config.js +37 -1
- package/dist/files/behavioral.js +210 -10
- package/dist/files/guardrails.js +1 -1
- package/dist/files/suiteBuild.js +1 -1
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/proc.js +122 -2
- package/dist/runner/bdd.js +126 -48
- package/dist/runner/bootcheck.js +32 -20
- package/dist/runner/docker.js +139 -0
- package/dist/runner/place.js +181 -0
- package/dist/runner/placeAdvice.js +52 -0
- package/dist/runner/placeEnvironment.js +111 -0
- package/dist/runner/precheck.js +35 -0
- package/dist/runner/provision.js +63 -29
- package/dist/runner/pytest.js +9 -10
- package/dist/runner/pytestBddPlugin.js +11 -3
- package/dist/runner/rspec.js +14 -12
- package/dist/runner/toolchain.js +49 -11
- package/dist/runner/types.js +45 -1
- package/dist/runner/vitest.js +9 -10
- package/dist/runner/worldProbe.js +26 -14
- package/dist/surfaces/routeInventory.js +14 -12
- package/dist/verbs/codexInstall.js +51 -16
- package/dist/verbs/mapPrepare.js +16 -5
- package/dist/verbs/putSuiteBuild.js +9 -2
- package/dist/verbs/run.js +10 -2
- package/dist/verbs/runLocal.js +17 -2
- package/dist/verbs/suitePrepare.js +101 -9
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
|
|
3
|
+
import { placeProblem } from "../runner/place.js";
|
|
4
|
+
import { placeAdvice } from "../runner/placeAdvice.js";
|
|
5
|
+
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
3
6
|
import { validateStack } from "../runner/precheck.js";
|
|
4
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
5
8
|
import { runStructuralByRunner } from "./run.js";
|
|
@@ -12,6 +15,13 @@ export async function runLocal(config, args = [], deps) {
|
|
|
12
15
|
stdout: process.stdout,
|
|
13
16
|
...deps,
|
|
14
17
|
};
|
|
18
|
+
// Spec 36, criteria 7 and 6. A run that cannot reach the place its
|
|
19
|
+
// dependencies live in has nothing to report but noise — and neither has one
|
|
20
|
+
// whose runner was installed somewhere else, which looks ready because
|
|
21
|
+
// readiness here is a file existing.
|
|
22
|
+
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
23
|
+
if (unusable)
|
|
24
|
+
throw new Error(unusable);
|
|
15
25
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
16
26
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
17
27
|
const wanted = selectBranches(request, args);
|
|
@@ -124,7 +134,12 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
124
134
|
: await d.runStructural(config.projectRoot, runner, suitePaths);
|
|
125
135
|
}
|
|
126
136
|
catch (err) {
|
|
127
|
-
|
|
137
|
+
// The second and last dead end (spec 36, §7.1). This one does not throw —
|
|
138
|
+
// it prints and returns zero, so it never reaches the one `catch` that adds
|
|
139
|
+
// this advice everywhere else. And it is the case that matters most: the
|
|
140
|
+
// suite exists by now, and the person is trying to run it.
|
|
141
|
+
const advice = placeAdvice(config.projectRoot);
|
|
142
|
+
d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
128
143
|
return null;
|
|
129
144
|
}
|
|
130
145
|
d.stdout.write(report(result));
|
|
@@ -163,7 +178,7 @@ function outputTail(result) {
|
|
|
163
178
|
}
|
|
164
179
|
// The suite blob's own project-relative paths, exactly as the runners expect
|
|
165
180
|
// them: the main file first, then every other file of the branch. The main file
|
|
166
|
-
// stopped being the whole suite in spec
|
|
181
|
+
// stopped being the whole suite in spec 43, §6 — a branch is one file per
|
|
167
182
|
// assignment now — and running it alone would exercise a fraction of what the
|
|
168
183
|
// answer claims to guard.
|
|
169
184
|
//
|
|
@@ -2,10 +2,14 @@ import { clearRunState } from "../runner/failureDigest.js";
|
|
|
2
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
3
3
|
import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
4
4
|
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
|
+
import { bddStepLoading } from "../runner/bdd.js";
|
|
5
6
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
6
|
-
import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
|
|
7
|
+
import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
|
|
7
8
|
import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
|
|
9
|
+
import { placeProblem } from "../runner/place.js";
|
|
10
|
+
import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
|
|
8
11
|
import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
|
|
12
|
+
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
9
13
|
import { probeBehavioralWorld } from "../runner/worldProbe.js";
|
|
10
14
|
import { Wire } from "../wire.js";
|
|
11
15
|
// The complete envelope for one branch, or null when this machine cannot
|
|
@@ -70,9 +74,30 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
70
74
|
stdout: process.stdout,
|
|
71
75
|
...deps,
|
|
72
76
|
};
|
|
77
|
+
// Spec 36, criterion 7. Before the first byte is written and long before the
|
|
78
|
+
// first call to the server: a place that cannot be used is a fact we can learn
|
|
79
|
+
// now, and learning it after a suite has been generated and run means the
|
|
80
|
+
// evidence disappeared with the container.
|
|
81
|
+
//
|
|
82
|
+
// Not a `ToolchainUnavailableError`: the place is named in the config and the
|
|
83
|
+
// message below already says what to do about it. Suggesting a container to
|
|
84
|
+
// somebody whose container is the problem is noise.
|
|
85
|
+
const unusable = placeProblem(config.projectRoot);
|
|
86
|
+
if (unusable)
|
|
87
|
+
throw new Error(`${unusable}\nNothing was written and nothing was uploaded.`);
|
|
73
88
|
const check = actual.precheck(config.projectRoot);
|
|
89
|
+
// Deliberately a plain stop. This one says "none of the three stacks is here",
|
|
90
|
+
// which is read off files — a Gemfile, a package.json, a requirements.txt —
|
|
91
|
+
// and those are on this machine whatever place the run happens in. A container
|
|
92
|
+
// is never the answer to it.
|
|
74
93
|
if (!check.ok)
|
|
75
94
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
95
|
+
// An environment installed somewhere else is not an environment (spec 36, §6).
|
|
96
|
+
// Here, where it can be built again, and before anything asks whether a runner
|
|
97
|
+
// is ready.
|
|
98
|
+
const replaced = alignRunnerEnvironmentWithPlace(config.projectRoot);
|
|
99
|
+
if (replaced)
|
|
100
|
+
actual.stdout.write(`${replaced}\n`);
|
|
76
101
|
// Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
|
|
77
102
|
// project it touched, so a Flask app and a NestJS app each came away with a
|
|
78
103
|
// Ruby file they never asked for and cannot run — the product leaving another
|
|
@@ -92,16 +117,17 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
92
117
|
const provisioned = await actual.ensureStructuralRunner(config.projectRoot, check.runner);
|
|
93
118
|
if (provisioned.status === 'fixable') {
|
|
94
119
|
const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
|
|
95
|
-
throw new
|
|
96
|
-
`${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded
|
|
120
|
+
throw new ToolchainUnavailableError(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
|
|
121
|
+
`${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded.`, config.projectRoot);
|
|
97
122
|
}
|
|
98
123
|
setupNotices.push(...(provisioned.checklist ?? []));
|
|
99
124
|
// Confirm rather than assume. Provisioning reporting success and the runner
|
|
100
125
|
// actually being startable are two different facts, and this is the cheap
|
|
101
126
|
// one to check before a whole generation is built on it.
|
|
102
127
|
const ready = actual.confirmRunner(config.projectRoot, check.runner);
|
|
103
|
-
if (!ready.ok)
|
|
104
|
-
throw new
|
|
128
|
+
if (!ready.ok) {
|
|
129
|
+
throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
|
|
130
|
+
}
|
|
105
131
|
}
|
|
106
132
|
// Spec 32-6. Before anything is fetched or written, find out whether the suite
|
|
107
133
|
// would get off the ground at all. It runs here, after the boot helper exists
|
|
@@ -117,8 +143,15 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
117
143
|
// the same thing: on Python that would shell out to pytest all over again.
|
|
118
144
|
const structuralRunner = check.runner ?? null;
|
|
119
145
|
const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
|
|
120
|
-
if (boot.status === 'broken')
|
|
121
|
-
|
|
146
|
+
if (boot.status === 'broken') {
|
|
147
|
+
// "Your environment is not ready" is the one of the two that a container can
|
|
148
|
+
// answer — the toolchain is missing here and may be sitting in one. A defect
|
|
149
|
+
// found in the code is a defect wherever it runs, and offering a container
|
|
150
|
+
// for it would be the noise this spec is trying to remove.
|
|
151
|
+
throw boot.cause === 'environment_not_ready'
|
|
152
|
+
? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
|
|
153
|
+
: new Error(bootFinding(boot, structuralRunner));
|
|
154
|
+
}
|
|
122
155
|
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
123
156
|
const packets = await actual.getSuitePacketsBatch();
|
|
124
157
|
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
@@ -139,8 +172,14 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
139
172
|
fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
|
|
140
173
|
continue;
|
|
141
174
|
}
|
|
175
|
+
// Every BDD runner with a connector-owned harness gets it here, before its
|
|
176
|
+
// branch is offered to the host (spec 35-1). Only Ruby is probed by
|
|
177
|
+
// running it: the Ruby World integrates deeply with Rails, and the probe
|
|
178
|
+
// needs an application to integrate with. The JS and Python harnesses do
|
|
179
|
+
// one thing — refuse connections that leave the machine — and their
|
|
180
|
+
// guards are executed for real in the connector's own suite.
|
|
181
|
+
materializeBehavioralWorld(config.projectRoot, runner);
|
|
142
182
|
if (runner === 'cucumber') {
|
|
143
|
-
materializeBehavioralWorld(config.projectRoot);
|
|
144
183
|
const probe = await actual.worldProbe(config.projectRoot);
|
|
145
184
|
if (probe.status === 'fixable') {
|
|
146
185
|
fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
|
|
@@ -156,6 +195,11 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
156
195
|
const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
|
|
157
196
|
if (!manifest)
|
|
158
197
|
return { packet, runner, branch: null };
|
|
198
|
+
// Spec 44, §3.2. The rule for which step files this runner loads travels
|
|
199
|
+
// with the branch that will be written against it, so nobody has to read
|
|
200
|
+
// the connector's own source to find it out — which is exactly what two
|
|
201
|
+
// coordinators did.
|
|
202
|
+
const stepLoading = packet.suite_kind === 'behavioral' && runner ? bddStepLoading(runner) : null;
|
|
159
203
|
return {
|
|
160
204
|
packet,
|
|
161
205
|
runner,
|
|
@@ -166,6 +210,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
166
210
|
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
167
211
|
assignment: packet.assignment,
|
|
168
212
|
runner_manifest: manifest,
|
|
213
|
+
...(stepLoading ? { step_loading: stepLoading } : {}),
|
|
169
214
|
},
|
|
170
215
|
};
|
|
171
216
|
}));
|
|
@@ -200,6 +245,28 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
200
245
|
`finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
|
|
201
246
|
`runner that runs after publishing, so you never have to guess the command), repair broken harness steps while application failures remain red, ` +
|
|
202
247
|
`then run ${nextCommand}.\n`);
|
|
248
|
+
// Printed as well as written, because a rule nobody reads is a rule nobody
|
|
249
|
+
// follows — and this one is silent when broken: a step file the runner does
|
|
250
|
+
// not collect produces no error, only a green run over no scenarios.
|
|
251
|
+
for (const { packet, runner, branch } of prepared) {
|
|
252
|
+
if (!branch || packet.suite_kind !== 'behavioral' || !runner)
|
|
253
|
+
continue;
|
|
254
|
+
// Before the steps are written, not after they misbehave: what this runner
|
|
255
|
+
// does and does not load is the fact a worker needs while deciding what a
|
|
256
|
+
// step may assume (spec 35-1, criterion 2).
|
|
257
|
+
const harness = behavioralHarnessNotice(runner);
|
|
258
|
+
if (harness)
|
|
259
|
+
actual.stdout.write(harness);
|
|
260
|
+
actual.stdout.write(branch.step_loading
|
|
261
|
+
? stepLoadingNotice(runner, branch.step_loading)
|
|
262
|
+
// Said rather than left blank. This connector has no strategy for that
|
|
263
|
+
// runner, so it does not know which files it loads — and silence here
|
|
264
|
+
// reads as "any name will do", which is the failure this whole notice
|
|
265
|
+
// exists to prevent.
|
|
266
|
+
: `\nBehavioral steps run under "${runner}", and this connector does not know how that runner ` +
|
|
267
|
+
'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
|
|
268
|
+
'them, and this connector will not be able to run the branch either.\n');
|
|
269
|
+
}
|
|
203
270
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
204
271
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
205
272
|
if (setupNotices.length > 0) {
|
|
@@ -221,6 +288,24 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
221
288
|
'\n');
|
|
222
289
|
}
|
|
223
290
|
}
|
|
291
|
+
// The runner's own rule for which step files it will load, in the words of the
|
|
292
|
+
// side that loads them (spec 44, §3.2). The same object is in `request.json`, on
|
|
293
|
+
// the behavioral branch; this is the copy the coordinator sees without opening a
|
|
294
|
+
// file.
|
|
295
|
+
//
|
|
296
|
+
// A null pattern is printed as a null pattern. A runner whose rule is not one
|
|
297
|
+
// pattern says what it does know and admits the rest — inventing a pattern here
|
|
298
|
+
// would recreate, in the connector this time, exactly the retelling this
|
|
299
|
+
// replaced.
|
|
300
|
+
function stepLoadingNotice(runner, loading) {
|
|
301
|
+
const rule = loading.step_files
|
|
302
|
+
? `it loads \`${loading.step_files}\` from \`.unitbob/behavioral/step_definitions/\` — put the capability id ` +
|
|
303
|
+
'where the `*` is, and a file named anything else is not loaded at all'
|
|
304
|
+
: 'its rule for which files it loads is not one pattern, and this connector will not state one for it';
|
|
305
|
+
return (`\nBehavioral steps run under "${runner}", and ${rule}. What else has to be true of a step file there:\n - ` +
|
|
306
|
+
loading.requirements.join('\n - ') +
|
|
307
|
+
'\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
|
|
308
|
+
}
|
|
224
309
|
// What the boot check found, in the vibecoder's terms. Printed on every run,
|
|
225
310
|
// including the quiet ones: "we looked and it starts" and "we could not look"
|
|
226
311
|
// are both worth a line, and a check nobody hears about is a check nobody
|
|
@@ -250,7 +335,8 @@ function bootFinding(boot, runner) {
|
|
|
250
335
|
// Not checked is not broken, and nothing downstream may treat it as such.
|
|
251
336
|
// Conflating the two would block honest projects — the whole reason this
|
|
252
337
|
// state is named for what happened rather than for what we know.
|
|
253
|
-
|
|
338
|
+
const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
|
|
339
|
+
return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
|
|
254
340
|
}
|
|
255
341
|
const headline = boot.cause === 'defect_in_code'
|
|
256
342
|
? 'Found a defect that stops your test suite from starting.'
|
|
@@ -307,6 +393,12 @@ const NOT_CHECKED_REASON = {
|
|
|
307
393
|
'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
|
|
308
394
|
timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
|
|
309
395
|
nothing_to_load: 'Did not check whether the suite can start: there was nothing to load yet.',
|
|
396
|
+
// Spec 36, criterion 8. Docker refused, or the container went away between the
|
|
397
|
+
// check and the spawn. Nothing here says anything about the project, and the
|
|
398
|
+
// one thing this must never turn into is "we found a defect in your code".
|
|
399
|
+
place_failed: 'Did not check whether the suite can start: the place this project runs in did not carry the command ' +
|
|
400
|
+
'out. That is a fault of the container or the docker daemon, not of your code, and nothing was learned ' +
|
|
401
|
+
'about your code either way.',
|
|
310
402
|
};
|
|
311
403
|
function knownDefectContext(args) {
|
|
312
404
|
const defect = option(args, '--known-defect=');
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBehavioral, } from "../files/behavioral.js";
|
|
6
6
|
import { runBddSuite } from "../runner/bdd.js";
|
|
7
7
|
import { boundReport } from "../runner/boundReport.js";
|
|
8
|
+
import { placeOf } from "../runner/place.js";
|
|
8
9
|
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
10
|
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
10
11
|
const actual = {
|
|
@@ -54,6 +55,19 @@ async function runCandidateInProject(projectRoot, output, revision) {
|
|
|
54
55
|
return { revision, run_result: report };
|
|
55
56
|
}
|
|
56
57
|
async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
58
|
+
// Spec 36, Non-Goals. The worktree below is created under the system's
|
|
59
|
+
// temporary directory — outside anything a container has mounted, so in there
|
|
60
|
+
// it does not exist at all. Said plainly rather than run into. The obvious
|
|
61
|
+
// repair, moving the worktree under `.unitbob/`, puts a whole second copy of
|
|
62
|
+
// the application inside the tree graphify scans, and a copy left behind by a
|
|
63
|
+
// failure builds the next map out of two applications.
|
|
64
|
+
const place = placeOf(projectRoot);
|
|
65
|
+
if (place.kind === 'docker') {
|
|
66
|
+
throw new Error("Reviewing at a fixed revision is not supported while this project's tests run inside a container " +
|
|
67
|
+
`(\`${place.container}\`): the review needs a git worktree outside the project, which the container ` +
|
|
68
|
+
'cannot see. Review against the working tree instead (drop the fixed revision), or run this project ' +
|
|
69
|
+
'on this machine.');
|
|
70
|
+
}
|
|
57
71
|
const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
|
|
58
72
|
cwd: projectRoot,
|
|
59
73
|
encoding: 'utf8',
|
package/dist/wire.js
CHANGED
|
@@ -86,7 +86,7 @@ export class Wire {
|
|
|
86
86
|
// carries one result per suite_kind.
|
|
87
87
|
//
|
|
88
88
|
// `dryRun` is the same route, the same body and the same server-side
|
|
89
|
-
// validation, stopped before the first write (spec
|
|
89
|
+
// validation, stopped before the first write (spec 43, §1). It answers
|
|
90
90
|
// `would_publish` instead of `created`, and it is deliberately not a route of
|
|
91
91
|
// its own: a second route would grow a second implementation, which is the
|
|
92
92
|
// defect this whole spec removes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.6.0 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|