unitbob 0.7.15 → 0.7.16
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 +1 -1
- package/dist/files/behavioral.js +5 -1
- package/dist/files/workerPlan.js +5 -3
- package/dist/runner/bootcheck.js +6 -4
- package/dist/runner/worldProbe.js +14 -4
- package/dist/surfaces/nextRoutes.js +21 -17
- package/dist/surfaces/routeInventory.js +35 -11
- package/dist/verbs/putMapBuild.js +1 -1
- package/dist/verbs/suitePrepare.js +3 -0
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Codex:
|
|
|
35
35
|
```
|
|
36
36
|
codex plugin marketplace add sergeygershun/unitbob-connector
|
|
37
37
|
codex plugin add unitbob@unitbob
|
|
38
|
-
npx -y unitbob@0.7.
|
|
38
|
+
npx -y unitbob@0.7.16 codex-install
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Then start a new chat. A chat opened before the install does not know about
|
package/dist/files/behavioral.js
CHANGED
|
@@ -217,6 +217,10 @@ net.Socket.prototype.connect = function unitbobGuardedConnect(...args) {
|
|
|
217
217
|
// database. Those stay in host-owned shared steps, as on every other stack.
|
|
218
218
|
// The one thing this file says about the database is "no": a `DATABASE_URL`
|
|
219
219
|
// whose database is not named like a test one does not get a door.
|
|
220
|
+
// How long the World gives Next to come up in `BeforeAll`: a cold `.next` on
|
|
221
|
+
// a large application compiles for a while. One constant, read by the probe
|
|
222
|
+
// too, so the probe's clock can never be the shorter one (ADR 0001).
|
|
223
|
+
export const NEXT_WORLD_BOOT_TIMEOUT_MS = 180_000;
|
|
220
224
|
const NEXT_DOOR_JS = `
|
|
221
225
|
// ---------------------------------------------------------------------------
|
|
222
226
|
// The door into this Next.js application (spec 56-1).
|
|
@@ -276,7 +280,7 @@ function unitbobDatabaseName(url) {
|
|
|
276
280
|
}
|
|
277
281
|
}
|
|
278
282
|
|
|
279
|
-
BeforeAll({ timeout:
|
|
283
|
+
BeforeAll({ timeout: ${NEXT_WORLD_BOOT_TIMEOUT_MS} }, async function () {
|
|
280
284
|
// The runner already sets NODE_ENV=test; kept here so Next reads .env.test
|
|
281
285
|
// even when this file is loaded some other way.
|
|
282
286
|
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -73,9 +73,11 @@ export function assignedSurfaces(projectRoot) {
|
|
|
73
73
|
}
|
|
74
74
|
// Spec 55-1, §4. The marker the request minted for each capability and each
|
|
75
75
|
// structural interface, by id — the `@ubc_…` tag a Scenario must carry and the
|
|
76
|
-
// `[ubc_…]` a structural test names. This
|
|
77
|
-
//
|
|
78
|
-
//
|
|
76
|
+
// `[ubc_…]` a structural test names. This reads them off the request and
|
|
77
|
+
// answers one question: which markers were this slice's (the verb then finds
|
|
78
|
+
// them in the slice's files, `runner/failureDigest.ts` in a run report — both
|
|
79
|
+
// by the same 12-hex shape). What a marker *means* on the map is the server's,
|
|
80
|
+
// and nothing here goes near it.
|
|
79
81
|
export function assignedMarkers(projectRoot) {
|
|
80
82
|
const request = readRequest(projectRoot);
|
|
81
83
|
const byId = new Map();
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -380,10 +380,12 @@ function classify(projectRoot, runner, result, verdict) {
|
|
|
380
380
|
return { status: 'broken', cause: causeOf(output, projectRoot, runner), message, detail };
|
|
381
381
|
}
|
|
382
382
|
// Spec 55-1, §1. Vite's import-analysis stopped on a file this runner has no
|
|
383
|
-
// reading of — a `.py` the map named, say.
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
383
|
+
// reading of — a `.py` the map named, say. The probe imports only runner files
|
|
384
|
+
// (`vitestProbeSource`), so such a file reaches vite either through the list
|
|
385
|
+
// before that filter existed or through the project's own import of it; in
|
|
386
|
+
// both cases the answer names the file, and the second is a defect the message
|
|
387
|
+
// still points at. Only vite says this; the other runners never see such a
|
|
388
|
+
// file (their probes skip it) and their parse errors mean what they say.
|
|
387
389
|
const FOREIGN_PARSE = /vite:import-analysis|Failed to parse source for import analysis/;
|
|
388
390
|
const REPORTED_FILE = /^\s*File:\s*(\S+?)(?::\d+)*\s*$/m;
|
|
389
391
|
function foreignFileParsed(output, projectRoot, runner) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { BEHAVIORAL_DIR, BEHAVIORAL_WORLD_JS_PATH, BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
|
|
3
|
+
import { BEHAVIORAL_DIR, BEHAVIORAL_WORLD_JS_PATH, BEHAVIORAL_WORLD_PATH, NEXT_WORLD_BOOT_TIMEOUT_MS } from "../files/behavioral.js";
|
|
4
4
|
import { BEHAVIORAL_GEMFILE } from "./bdd.js";
|
|
5
5
|
import { firstErrorLine } from "./bootcheck.js";
|
|
6
6
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
@@ -8,8 +8,14 @@ import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
|
8
8
|
import { looksLikeNext } from "../surfaces/nextRoutes.js";
|
|
9
9
|
const PROBE_ROOT = '.unitbob/suite-build/world-probe';
|
|
10
10
|
const defaultDeps = {
|
|
11
|
-
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
|
|
11
|
+
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS }),
|
|
12
12
|
};
|
|
13
|
+
// What both probes leave to the run itself, said with every `ok`. The probe
|
|
14
|
+
// loads the connector's World and its own two or three steps; the project's
|
|
15
|
+
// `step_definitions/` — and any `Before` hook a coordinator writes there — are
|
|
16
|
+
// first loaded by the suite. So a green probe means the door opens, not that
|
|
17
|
+
// every hook behind it is sound.
|
|
18
|
+
const LEFT_TO_THE_RUN = "It ran the connector's own steps only; your step_definitions/ and the hooks in them are first loaded by the suite itself.";
|
|
13
19
|
// Which Worlds are probed by running them: the ones that open a door into the
|
|
14
20
|
// application — Ruby's, which integrates with Rails, and Next's, which starts
|
|
15
21
|
// the application (spec 56-1). A harness that only guards the network
|
|
@@ -49,7 +55,7 @@ export async function probeBehavioralWorld(projectRoot, deps = defaultDeps) {
|
|
|
49
55
|
},
|
|
50
56
|
});
|
|
51
57
|
if (result.code === 0)
|
|
52
|
-
return { status: 'ok' };
|
|
58
|
+
return { status: 'ok', message: LEFT_TO_THE_RUN };
|
|
53
59
|
const detail = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean).join('\n') || `exit ${result.code}`;
|
|
54
60
|
return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
|
|
55
61
|
}
|
|
@@ -90,9 +96,13 @@ export async function probeNextWorld(projectRoot, deps = defaultDeps) {
|
|
|
90
96
|
CUCUMBER_PUBLISH_QUIET: 'true',
|
|
91
97
|
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
92
98
|
},
|
|
99
|
+
// The run gives `BeforeAll` this long to start Next (ADR 0001: the probe
|
|
100
|
+
// may be looser than the run, never stricter), plus one request through
|
|
101
|
+
// the door once it is open.
|
|
102
|
+
timeoutMs: NEXT_WORLD_BOOT_TIMEOUT_MS + PROVISION_TIMEOUT_MS,
|
|
93
103
|
});
|
|
94
104
|
if (result.code === 0)
|
|
95
|
-
return { status: 'ok' };
|
|
105
|
+
return { status: 'ok', message: LEFT_TO_THE_RUN };
|
|
96
106
|
// cucumber-js opens every run on a Node it has not been tested with by
|
|
97
107
|
// saying so; that line is not the error and must not be quoted as one.
|
|
98
108
|
const lines = [result.stdout, result.stderr]
|
|
@@ -39,38 +39,40 @@ export function nextGate(projectRoot) {
|
|
|
39
39
|
}
|
|
40
40
|
const PAGE_FILE = /^page\.(tsx|jsx|js|ts)$/;
|
|
41
41
|
const ROUTE_FILE = /^route\.(tsx|jsx|js|ts)$/;
|
|
42
|
-
// Every address under the app directory, sorted by path so the inventory reads
|
|
43
|
-
// like a route table rather than like a directory listing. Files are named
|
|
44
|
-
// relative to the project root with forward slashes, which is how the graph
|
|
45
|
-
// names them too.
|
|
46
42
|
export function nextAppRoutes(projectRoot) {
|
|
47
43
|
const appDir = nextAppDir(projectRoot);
|
|
44
|
+
const routing = { rows: [], unread: [] };
|
|
48
45
|
if (!appDir)
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return
|
|
46
|
+
return routing;
|
|
47
|
+
walk(join(projectRoot, appDir), appDir.split(/[\\/]/).join('/'), [], routing);
|
|
48
|
+
routing.rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
49
|
+
return routing;
|
|
53
50
|
}
|
|
54
|
-
function walk(dir, relative, segments,
|
|
51
|
+
function walk(dir, relative, segments, routing) {
|
|
55
52
|
const path = urlOfSegments(segments);
|
|
56
53
|
// A subtree with no URL has no addresses anywhere below it.
|
|
57
54
|
if (path === null)
|
|
58
55
|
return;
|
|
59
56
|
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
60
57
|
if (entry.isDirectory()) {
|
|
61
|
-
walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name],
|
|
58
|
+
walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name], routing);
|
|
62
59
|
continue;
|
|
63
60
|
}
|
|
64
61
|
if (!entry.isFile())
|
|
65
62
|
continue;
|
|
66
63
|
const file = `${relative}/${entry.name}`;
|
|
67
64
|
if (PAGE_FILE.test(entry.name)) {
|
|
68
|
-
rows.push({ verb: 'GET', path, file, method: 'default' });
|
|
65
|
+
routing.rows.push({ verb: 'GET', path, file, method: 'default' });
|
|
69
66
|
}
|
|
70
67
|
else if (ROUTE_FILE.test(entry.name)) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const text = readFileSync(join(dir, entry.name), 'utf8');
|
|
69
|
+
const verbs = exportedHttpMethods(text);
|
|
70
|
+
for (const verb of verbs)
|
|
71
|
+
routing.rows.push({ verb, path, file, method: verb });
|
|
72
|
+
// Whatever it declares itself, a file that re-exports has addresses this
|
|
73
|
+
// reading cannot see; the model finds them in the graph.
|
|
74
|
+
if (REEXPORTS_ALL.test(text))
|
|
75
|
+
routing.unread.push(file);
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -107,9 +109,11 @@ const METHOD = HTTP_METHODS.join('|');
|
|
|
107
109
|
// export const { GET, POST } = handlers (the Auth.js shape)
|
|
108
110
|
//
|
|
109
111
|
// Not read: `export * from './handlers'` — the file names no method, and
|
|
110
|
-
// following the import would be a second module resolver. Such
|
|
111
|
-
//
|
|
112
|
-
// the graph
|
|
112
|
+
// following the import would be a second module resolver. Such a file goes to
|
|
113
|
+
// `NextRouting.unread` instead, and the recipe tells the model to find its
|
|
114
|
+
// addresses in the graph and name that file as their `source_file`; that is
|
|
115
|
+
// the one address the inventory check accepts beyond the inventory.
|
|
116
|
+
const REEXPORTS_ALL = /^\s*export\s+\*/m;
|
|
113
117
|
const DECLARED = [
|
|
114
118
|
new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s+(${METHOD})\\b`, 'gm'),
|
|
115
119
|
new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+(${METHOD})\\s*[=:]`, 'gm'),
|
|
@@ -52,7 +52,7 @@ async function askRailsRouter(projectRoot, deps) {
|
|
|
52
52
|
// declaration. `gate` names the `proxy.ts`/`middleware.ts` every address goes
|
|
53
53
|
// through — not an address itself, and the recipe says so.
|
|
54
54
|
function readNextFiles(projectRoot) {
|
|
55
|
-
const rows = nextAppRoutes(projectRoot);
|
|
55
|
+
const { rows, unread } = nextAppRoutes(projectRoot);
|
|
56
56
|
// An `app/` with no `page.*` or `route.*` declares nothing. As on Rails, we do
|
|
57
57
|
// not claim "no addresses" for a reading that may simply have missed them.
|
|
58
58
|
if (rows.length === 0)
|
|
@@ -60,7 +60,15 @@ function readNextFiles(projectRoot) {
|
|
|
60
60
|
const nodes = graphNodes(projectRoot);
|
|
61
61
|
const surfaces = rows.map((row) => toNextSurface(row, nodes));
|
|
62
62
|
const gate = nextGate(projectRoot);
|
|
63
|
-
return writeInventory(projectRoot, {
|
|
63
|
+
return writeInventory(projectRoot, {
|
|
64
|
+
declared_by: 'next app router',
|
|
65
|
+
environment: 'default',
|
|
66
|
+
...(gate ? { gate } : {}),
|
|
67
|
+
// The files this reading could not see into (ADR 0001): named here so
|
|
68
|
+
// the sentence below and `inventoryProblems` both know the gap.
|
|
69
|
+
...(unread.length > 0 ? { unread } : {}),
|
|
70
|
+
surfaces,
|
|
71
|
+
}, 'files');
|
|
64
72
|
}
|
|
65
73
|
// The inventory, and only the inventory. `surfaces.json` is not written here,
|
|
66
74
|
// however tempting: the recipe turns this file into that one with a single
|
|
@@ -88,6 +96,7 @@ function writeInventory(projectRoot, document, source) {
|
|
|
88
96
|
linked: document.surfaces.filter((surface) => surface.handler_symbol).length,
|
|
89
97
|
environment: document.environment,
|
|
90
98
|
source,
|
|
99
|
+
...(document.unread ? { unread: document.unread.length } : {}),
|
|
91
100
|
};
|
|
92
101
|
}
|
|
93
102
|
// One question, up to two environments. Everything that can be decided from the
|
|
@@ -152,7 +161,9 @@ export function describeRouteInventory(result) {
|
|
|
152
161
|
return (`Route inventory written to ${result.path}: ${result.routes} ${plural(result.routes, 'address', 'addresses')} ` +
|
|
153
162
|
`from the ${result.source === 'files' ? 'file system' : 'router'}` +
|
|
154
163
|
`${result.source === 'router' && result.environment === 'default' ? ' (read in the default environment — ' +
|
|
155
|
-
'the test one would not load)' : ''}, ${result.linked} tied to a graph node
|
|
164
|
+
'the test one would not load)' : ''}, ${result.linked} tied to a graph node` +
|
|
165
|
+
`${result.unread ? `; ${result.unread} route ${plural(result.unread, 'file re-exports its handlers and is', 'files re-export their handlers and are')} ` +
|
|
166
|
+
'listed under `unread` — their addresses are not in the inventory' : ''}. The extract_surfaces recipe turns ` +
|
|
156
167
|
'this file into surfaces.json with one command, then adds the job, table and external surfaces to it.');
|
|
157
168
|
}
|
|
158
169
|
return `No route inventory: ${becauseOf(result)}. The extract_surfaces recipe reads the source instead.`;
|
|
@@ -162,7 +173,7 @@ export function describeRouteInventory(result) {
|
|
|
162
173
|
function becauseOf(result) {
|
|
163
174
|
switch (result.reason) {
|
|
164
175
|
case 'unsupported_stack':
|
|
165
|
-
return 'this project has no router Unitbob can ask yet (Rails and
|
|
176
|
+
return 'this project has no router Unitbob can ask yet (Rails and Next.js (app router) so far)';
|
|
166
177
|
case 'app_did_not_load':
|
|
167
178
|
return `the router could not be asked — ${result.detail}`;
|
|
168
179
|
case 'did_not_finish':
|
|
@@ -403,22 +414,29 @@ function findNode(nodes, file, action) {
|
|
|
403
414
|
export function inventoryProblems(inventory, surfaces) {
|
|
404
415
|
const declared = routeEntriesIn(inventory);
|
|
405
416
|
if (declared === null) {
|
|
406
|
-
return ['the route inventory is not readable, so what the
|
|
417
|
+
return ['the route inventory is not readable, so what the application declared cannot be confirmed'];
|
|
407
418
|
}
|
|
408
419
|
const written = routeEntriesIn(surfaces);
|
|
409
420
|
if (written === null)
|
|
410
421
|
return ['surfaces.json has no readable `surfaces` array'];
|
|
411
422
|
const problems = [];
|
|
412
423
|
const missing = [...declared.keys()].filter((id) => !written.has(id));
|
|
413
|
-
|
|
424
|
+
// ADR 0001: the check is no stricter than the inventory's own claim. A file
|
|
425
|
+
// the inventory lists under `unread` is one it could not see into, so an
|
|
426
|
+
// address served by that file is the model's to add — and only that one.
|
|
427
|
+
const unread = unreadFilesIn(inventory);
|
|
428
|
+
const invented = [...written.keys()].filter((id) => !declared.has(id) && !unread.has(written.get(id)?.source_file));
|
|
414
429
|
if (missing.length > 0) {
|
|
415
|
-
problems.push(`surfaces.json is missing ${missing.length} address the
|
|
430
|
+
problems.push(`surfaces.json is missing ${missing.length} address the inventory declared: ${list(missing)}. ` +
|
|
416
431
|
'Copy every entry of the route inventory across unchanged.');
|
|
417
432
|
}
|
|
418
433
|
if (invented.length > 0) {
|
|
419
|
-
problems.push(`surfaces.json carries ${invented.length} route the
|
|
420
|
-
'The
|
|
421
|
-
'in a copied id or invented.'
|
|
434
|
+
problems.push(`surfaces.json carries ${invented.length} route the inventory does not declare: ${list(invented)}. ` +
|
|
435
|
+
'The inventory is what the application declared about itself — its router\'s answer, or its route ' +
|
|
436
|
+
'files; an address it does not know is either a typo in a copied id or invented.' +
|
|
437
|
+
(unread.size > 0
|
|
438
|
+
? ' The one exception is an address served by a route file listed under `unread`, with that file as its `source_file`.'
|
|
439
|
+
: ''));
|
|
422
440
|
}
|
|
423
441
|
// Two sentences rather than one, because the two kinds of rewrite are not the
|
|
424
442
|
// same mistake and the person reading has to fix the right thing: one sends a
|
|
@@ -432,7 +450,7 @@ export function inventoryProblems(inventory, surfaces) {
|
|
|
432
450
|
const relabelled = alteredFields(declared, written, LABEL_FIELDS);
|
|
433
451
|
if (relabelled.length > 0) {
|
|
434
452
|
problems.push(`surfaces.json reworded ${relabelled.length} handler_label: ${list(relabelled)}. ` +
|
|
435
|
-
'That name is what the
|
|
453
|
+
'That name is what the inventory itself printed; the human names for the map are the ' +
|
|
436
454
|
'capability titles in the next step, not this field.');
|
|
437
455
|
}
|
|
438
456
|
return problems;
|
|
@@ -462,6 +480,12 @@ function alteredFields(declared, written, fields) {
|
|
|
462
480
|
}
|
|
463
481
|
return altered;
|
|
464
482
|
}
|
|
483
|
+
// The `unread` list of a Next.js inventory, as a set; empty on a router's
|
|
484
|
+
// inventory, which has no such list because a router declares all it serves.
|
|
485
|
+
function unreadFilesIn(inventory) {
|
|
486
|
+
const unread = inventory?.unread;
|
|
487
|
+
return new Set(Array.isArray(unread) ? unread.filter((file) => typeof file === 'string') : []);
|
|
488
|
+
}
|
|
465
489
|
function routeEntriesIn(document) {
|
|
466
490
|
const surfaces = document?.surfaces;
|
|
467
491
|
if (!Array.isArray(surfaces))
|
|
@@ -59,7 +59,7 @@ function refuseAlteredAddresses(inventoryPath, surfaces) {
|
|
|
59
59
|
const problems = inventoryProblems(inventory, surfaces);
|
|
60
60
|
if (problems.length === 0)
|
|
61
61
|
return;
|
|
62
|
-
throw new Error(`The routes in surfaces.json do not match the ones this project's
|
|
62
|
+
throw new Error(`The routes in surfaces.json do not match the ones this project declared (its router's answer, or its route files), so nothing ` +
|
|
63
63
|
`was uploaded and the previous map stays current:\n- ${problems.join('\n- ')}`);
|
|
64
64
|
}
|
|
65
65
|
// Spec 32-7, Task 1.11. The addresses are now a command's answer rather than the
|
|
@@ -199,6 +199,9 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
199
199
|
fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
|
|
200
200
|
continue;
|
|
201
201
|
}
|
|
202
|
+
// ADR 0001: the probe's account of what it did not cover is part of
|
|
203
|
+
// the answer, printed with the good news rather than kept in a comment.
|
|
204
|
+
actual.stdout.write(`Checked that the connector-owned World profile works on this project: it does.${probe.message ? ` ${probe.message}` : ''}\n`);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
204
207
|
buildable.push({ packet, runner });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
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.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.16 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
|