unitbob 0.7.4 → 0.7.7
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/dist/cli.js +5 -2
- package/dist/files/behavioral.js +44 -9
- package/dist/files/guardrails.js +21 -0
- package/dist/files/suiteBuild.js +127 -9
- package/dist/files/workerPlan.js +52 -10
- package/dist/proc.js +37 -8
- package/dist/runner/bootcheck.js +22 -14
- package/dist/runner/provision.js +17 -1
- package/dist/runner/pytest.js +2 -2
- package/dist/runner/rspec.js +2 -2
- package/dist/runner/vitest.js +2 -2
- package/dist/verbs/putSuiteBuild.js +27 -4
- package/dist/verbs/runLocal.js +12 -15
- package/dist/verbs/suitePrepare.js +45 -22
- package/dist/verbs/validateBuild.js +39 -9
- package/dist/verbs/validateWorkerCheckpoints.js +72 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +10 -2
- package/plugin/codex/agents/suite-worker.toml +16 -0
package/dist/cli.js
CHANGED
|
@@ -57,8 +57,11 @@ Verbs:
|
|
|
57
57
|
every slice it names, before fan-out.
|
|
58
58
|
validate-worker-checkpoints
|
|
59
59
|
Internal: validate every worker checkpoint before assembly or repair.
|
|
60
|
-
put-suite-build
|
|
61
|
-
|
|
60
|
+
put-suite-build [branch]
|
|
61
|
+
Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
62
|
+
then run every branch it published and report the server's results. Name a branch
|
|
63
|
+
to publish that one alone, as soon as it is finished; with no argument both are
|
|
64
|
+
expected, and one the answer never mentions is reported.
|
|
62
65
|
run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
|
|
63
66
|
that will run it afterwards. No argument runs every branch the build asked for.
|
|
64
67
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
package/dist/files/behavioral.js
CHANGED
|
@@ -331,19 +331,33 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
|
331
331
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
332
332
|
if (!existsSync(behavioralRoot))
|
|
333
333
|
return [];
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
artifact.path,
|
|
337
|
-
...(artifact.support_files ?? []).map((file) => file.path),
|
|
338
|
-
...(connectorWorld ? [connectorWorld.path] : []),
|
|
339
|
-
]);
|
|
340
|
-
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
334
|
+
const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
|
|
335
|
+
const kept = behavioralKeptByConnector(runner);
|
|
341
336
|
return readdirSync(behavioralRoot)
|
|
342
|
-
.filter((entry) => !
|
|
337
|
+
.filter((entry) => !kept.has(entry))
|
|
343
338
|
.flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
|
|
344
|
-
.filter((path) => !listed.has(path))
|
|
339
|
+
.filter((path) => !listed.has(path) && !kept.has(path.slice(BEHAVIORAL_DIR.length + 1)) && !isRuntimeByProduct(path))
|
|
345
340
|
.sort();
|
|
346
341
|
}
|
|
342
|
+
// What under the behavioral root is the connector's or the runner's rather than
|
|
343
|
+
// the build's, as paths relative to that root: the installed environment, the
|
|
344
|
+
// connector-owned World, and the report files the run itself writes. One set,
|
|
345
|
+
// read by two callers that must agree — the review warning above, which stays
|
|
346
|
+
// quiet about these, and `movePreviousRunAside` (spec 49), which leaves them in
|
|
347
|
+
// place while everything else the last build wrote goes to `previous/`. A
|
|
348
|
+
// second hand-kept list would let the warning and the move disagree about
|
|
349
|
+
// whose file something is.
|
|
350
|
+
//
|
|
351
|
+
// A runner this connector has no table for keeps nothing but the run's own
|
|
352
|
+
// artifacts, which is the same answer `materializeBehavioral` gives it.
|
|
353
|
+
export function behavioralKeptByConnector(runner) {
|
|
354
|
+
const world = runner ? behavioralWorldFor(runner) : undefined;
|
|
355
|
+
return new Set([
|
|
356
|
+
...(runner ? RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES : EMPTY_ENTRIES),
|
|
357
|
+
...(world ? [world.path.slice(BEHAVIORAL_DIR.length + 1)] : []),
|
|
358
|
+
...CONNECTOR_RUN_ARTIFACTS,
|
|
359
|
+
]);
|
|
360
|
+
}
|
|
347
361
|
// The real files under `relative`. Symlinks are not followed: materialization
|
|
348
362
|
// removes the link, not what it points at, and naming the target here would read
|
|
349
363
|
// as a warning about a file that was never in danger.
|
|
@@ -355,6 +369,27 @@ function filesUnder(projectRoot, relative) {
|
|
|
355
369
|
return [];
|
|
356
370
|
return readdirSync(join(projectRoot, relative)).flatMap((entry) => filesUnder(projectRoot, `${relative}/${entry}`));
|
|
357
371
|
}
|
|
372
|
+
// What the branch's own run leaves behind while it runs: Python's byte-code
|
|
373
|
+
// cache beside every step file, the SQLite database the World opens at start
|
|
374
|
+
// and its journal. Nobody wrote them, the next run makes them again, and the
|
|
375
|
+
// answer could not list them if it tried. On the bench, 2026-09-11, the review
|
|
376
|
+
// warning named fourteen `.pyc` files on microblog and the World's own database
|
|
377
|
+
// on soul as things "running it will delete" — the same way the connector's
|
|
378
|
+
// report files used to be named, and for the same cost: a warning that fires on
|
|
379
|
+
// every review is a warning nobody reads when a step file really is forgotten.
|
|
380
|
+
//
|
|
381
|
+
// Matched anywhere in the path, not only at the top: `__pycache__` sits inside
|
|
382
|
+
// `step_definitions/`, where the top-level filter above never looks.
|
|
383
|
+
//
|
|
384
|
+
// Exported for `movePreviousRunAside` (spec 49), which deletes exactly these
|
|
385
|
+
// rather than carrying them to `previous/` — the same list, so what the warning
|
|
386
|
+
// does not name is what the move does not keep.
|
|
387
|
+
const RUNTIME_BY_PRODUCT_DIRS = new Set(['__pycache__', '.pytest_cache']);
|
|
388
|
+
const RUNTIME_BY_PRODUCT_FILE = /\.(pyc|sqlite|sqlite3|db|db-shm|db-wal|db-journal)$/;
|
|
389
|
+
export function isRuntimeByProduct(relativePath) {
|
|
390
|
+
const parts = relativePath.split('/');
|
|
391
|
+
return parts.some((part) => RUNTIME_BY_PRODUCT_DIRS.has(part)) || RUNTIME_BY_PRODUCT_FILE.test(parts[parts.length - 1]);
|
|
392
|
+
}
|
|
358
393
|
export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
|
|
359
394
|
const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
360
395
|
for (const entry of entries) {
|
package/dist/files/guardrails.js
CHANGED
|
@@ -13,6 +13,27 @@ export const HELPER_FILE = 'unitbob_helper.rb';
|
|
|
13
13
|
// the project's own .rspec (stray --require lines, extra stdout formatters)
|
|
14
14
|
// out of guardrail runs, whose JSON output must stay parseable.
|
|
15
15
|
export const OPTIONS_FILE = 'rspec.opts';
|
|
16
|
+
// The report each structural runner writes beside the suite. Named here rather
|
|
17
|
+
// than in `runner/{rspec,vitest,pytest}.ts`, which build their full paths from
|
|
18
|
+
// these: those modules import this one, and the names are needed below — the
|
|
19
|
+
// same reason `BDD_RUN_ARTIFACTS` is listed where it is written.
|
|
20
|
+
export const RSPEC_RESULT_NAME = 'rspec_result.json';
|
|
21
|
+
export const VITEST_RESULT_NAME = 'vitest_result.json';
|
|
22
|
+
export const PYTEST_RESULT_NAME = 'pytest_result.xml';
|
|
23
|
+
// What under the structural root is the connector's rather than the build's,
|
|
24
|
+
// as paths relative to that root (spec 49): the Ruby boot kit and the runners'
|
|
25
|
+
// reports. `movePreviousRunAside` leaves these in place and moves everything
|
|
26
|
+
// else — including `_setup.ts` and a `conftest.py`, which are the *last*
|
|
27
|
+
// build's preparation, written against its files. The same set whatever the
|
|
28
|
+
// runner: a Ruby helper in a Python project is litter `suite-prepare` no longer
|
|
29
|
+
// writes, and moving it to `previous/` would only move the litter.
|
|
30
|
+
export const STRUCTURAL_KEPT_BY_CONNECTOR = new Set([
|
|
31
|
+
HELPER_FILE,
|
|
32
|
+
OPTIONS_FILE,
|
|
33
|
+
RSPEC_RESULT_NAME,
|
|
34
|
+
VITEST_RESULT_NAME,
|
|
35
|
+
PYTEST_RESULT_NAME,
|
|
36
|
+
]);
|
|
16
37
|
// The one place that decides whether a host-provided suite path is safe to
|
|
17
38
|
// write: relative, anchored under .unitbob/guardrails/, no traversal. Anything
|
|
18
39
|
// else throws and nothing is written.
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
|
+
import { BEHAVIORAL_DIR, behavioralKeptByConnector, isRuntimeByProduct } from "./behavioral.js";
|
|
6
|
+
import { GUARDRAILS_DIR, STRUCTURAL_KEPT_BY_CONNECTOR } from "./guardrails.js";
|
|
5
7
|
import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
|
|
6
8
|
export function requestPath(projectRoot) {
|
|
7
9
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
@@ -18,14 +20,29 @@ export function reviewRequestPath(projectRoot) {
|
|
|
18
20
|
export function candidateRunPath(projectRoot) {
|
|
19
21
|
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
20
22
|
}
|
|
21
|
-
// Spec 37-2, criterion 3. What a
|
|
23
|
+
// Spec 37-2, criterion 3. What a build leaves behind, in the order a reader
|
|
22
24
|
// meets it: the plan, the checkpoints written against that plan, and the answer
|
|
23
25
|
// assembled from them. All three are bound to the `request.json` this run is
|
|
24
26
|
// about to overwrite, so from the next line on they are a previous run's papers
|
|
25
27
|
// wearing this run's filenames — which is exactly the confusion the coordinator
|
|
26
28
|
// used to spend a turn untangling before fan-out.
|
|
27
|
-
const
|
|
29
|
+
const BUILD_PAPERS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
|
|
30
|
+
// Spec 49. And the three the review writes, bound to the candidate that build
|
|
31
|
+
// ran: left behind, a stale `behavioral_review.json` fails `validate-build` with
|
|
32
|
+
// a sentence about the reviewer.
|
|
33
|
+
const PREVIOUS_RUN_ARTIFACTS = [...BUILD_PAPERS, 'behavioral_review.json', 'candidate-run.json', 'review-request.json'];
|
|
28
34
|
const PREVIOUS_DIR = 'previous';
|
|
35
|
+
// Is the `suite-prepare` about to run the first step of a new build, or a
|
|
36
|
+
// repeat inside one? Spec 49, criterion 3. A new build is one with a plan, a
|
|
37
|
+
// checkpoint set or an answer on disk from the build before — or no
|
|
38
|
+
// `request.json` at all, which is a machine that has never built here (and may
|
|
39
|
+
// hold a suite that `check` materialized from the published one). A request
|
|
40
|
+
// with no plan yet is the coordinator's second question of the probe (spec 39):
|
|
41
|
+
// nothing moves then, so the `_setup.ts` written between the two runs survives.
|
|
42
|
+
export function isNewBuild(projectRoot) {
|
|
43
|
+
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
44
|
+
return !existsSync(requestPath(projectRoot)) || BUILD_PAPERS.some((name) => existsSync(join(buildDir, name)));
|
|
45
|
+
}
|
|
29
46
|
// Moved, never removed. A run costs hours and real money, and one interrupt plus
|
|
30
47
|
// one restart must not be able to spend that twice — `previous/` is one line
|
|
31
48
|
// more than `rmSync` and it is the line that makes a restart survivable.
|
|
@@ -37,18 +54,98 @@ const PREVIOUS_DIR = 'previous';
|
|
|
37
54
|
// finished checkpoints and answer of the run before it with no way back. A
|
|
38
55
|
// `previous/` holding pieces of two runs is worth strictly more than an empty
|
|
39
56
|
// one, and every piece in it is named by the file it kept.
|
|
40
|
-
|
|
57
|
+
//
|
|
58
|
+
// Since spec 49 the same rule covers the branches' own directories. The
|
|
59
|
+
// behavioral runner loads its directory whole, so a `.feature` the last build
|
|
60
|
+
// wrote runs under this build's markers and a dead step file argues with a live
|
|
61
|
+
// one over the same step text; on the bench (2026-09-11) two workers reworded
|
|
62
|
+
// their steps to dodge files nobody had written this build. Every file the last
|
|
63
|
+
// build wrote under `.unitbob/structural/` and `.unitbob/behavioral/` goes to
|
|
64
|
+
// `previous/<branch>/` under the same relative path — `_setup.ts` and a
|
|
65
|
+
// `conftest.py` included, since they are the last build's preparation, written
|
|
66
|
+
// against its files. What stays is what the connector and the runner own
|
|
67
|
+
// (`STRUCTURAL_KEPT_BY_CONNECTOR`, `behavioralKeptByConnector`); what the run
|
|
68
|
+
// itself dropped — byte-code caches, the World's database — is deleted, because
|
|
69
|
+
// nobody wrote it and the next run makes it again.
|
|
70
|
+
//
|
|
71
|
+
// `behavioralRunner` is the runner that branch is under, so the move knows
|
|
72
|
+
// which installed environment and which World are that runner's; null when
|
|
73
|
+
// none is known, and then only what every runner shares is kept. The structural
|
|
74
|
+
// side needs no runner: its kept set is the same for all three.
|
|
75
|
+
export function movePreviousRunAside(projectRoot, behavioralRunner) {
|
|
41
76
|
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
42
|
-
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
43
|
-
if (found.length === 0)
|
|
44
|
-
return [];
|
|
45
77
|
const previous = join(buildDir, PREVIOUS_DIR);
|
|
46
|
-
|
|
78
|
+
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
79
|
+
if (found.length > 0)
|
|
80
|
+
mkdirSync(previous, { recursive: true });
|
|
47
81
|
for (const name of found) {
|
|
48
82
|
rmSync(join(previous, name), { recursive: true, force: true });
|
|
49
83
|
renameSync(join(buildDir, name), join(previous, name));
|
|
50
84
|
}
|
|
51
|
-
return
|
|
85
|
+
return {
|
|
86
|
+
artifacts: found,
|
|
87
|
+
branches: {
|
|
88
|
+
structural: moveBranchAside(join(projectRoot, GUARDRAILS_DIR), join(previous, 'structural'), STRUCTURAL_KEPT_BY_CONNECTOR),
|
|
89
|
+
behavioral: moveBranchAside(join(projectRoot, BEHAVIORAL_DIR), join(previous, 'behavioral'), behavioralKeptByConnector(behavioralRunner)),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// One branch directory: the build's files to `target` under the same relative
|
|
94
|
+
// paths, `kept` left where it is, by-products deleted. Returns how many files
|
|
95
|
+
// moved.
|
|
96
|
+
//
|
|
97
|
+
// Collected first, moved second, so that a branch with nothing of the build's
|
|
98
|
+
// in it leaves the `previous/<branch>/` of the build before alone — the
|
|
99
|
+
// per-artifact rule above, applied to a directory. When there is something to
|
|
100
|
+
// move, the target is replaced whole: `previous/` holds one previous build, not
|
|
101
|
+
// an archive. The clearing happens before the first rename, so a rename that
|
|
102
|
+
// fails halfway (a full disk, a permission) leaves `previous/<branch>/` holding
|
|
103
|
+
// only what moved so far; the files that did not move are still where they
|
|
104
|
+
// were, and nothing is lost, which is the promise this function makes.
|
|
105
|
+
//
|
|
106
|
+
// The directories the files leave behind are removed too, so the branch root
|
|
107
|
+
// reads as what it is — empty of the last build — rather than as its skeleton.
|
|
108
|
+
// Deepest first, and only when empty: a directory holding a kept file (the
|
|
109
|
+
// World inside `step_definitions/`) keeps its file and stays.
|
|
110
|
+
function moveBranchAside(root, target, kept) {
|
|
111
|
+
if (!existsSync(root))
|
|
112
|
+
return 0;
|
|
113
|
+
const moving = [];
|
|
114
|
+
collectBuildFiles(root, '', kept, moving);
|
|
115
|
+
if (moving.length === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
rmSync(target, { recursive: true, force: true });
|
|
118
|
+
for (const relative of moving) {
|
|
119
|
+
const destination = join(target, relative);
|
|
120
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
121
|
+
renameSync(join(root, relative), destination);
|
|
122
|
+
}
|
|
123
|
+
const emptied = new Set(moving.map((relative) => dirname(relative)).filter((dir) => dir !== '.'));
|
|
124
|
+
for (const dir of [...emptied].sort((a, b) => b.length - a.length)) {
|
|
125
|
+
for (let ancestor = dir; ancestor !== '.'; ancestor = dirname(ancestor)) {
|
|
126
|
+
if (readdirSync(join(root, ancestor)).length > 0)
|
|
127
|
+
break;
|
|
128
|
+
rmSync(join(root, ancestor), { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return moving.length;
|
|
132
|
+
}
|
|
133
|
+
// A symlink is a file here: it moves as a link, and what it points at is never
|
|
134
|
+
// touched — the same reading `filesUnder` gives the review warning.
|
|
135
|
+
function collectBuildFiles(root, relative, kept, into) {
|
|
136
|
+
for (const entry of readdirSync(join(root, relative))) {
|
|
137
|
+
const path = relative ? `${relative}/${entry}` : entry;
|
|
138
|
+
if (kept.has(path))
|
|
139
|
+
continue;
|
|
140
|
+
if (isRuntimeByProduct(path)) {
|
|
141
|
+
rmSync(join(root, path), { recursive: true, force: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (lstatSync(join(root, path)).isDirectory())
|
|
145
|
+
collectBuildFiles(root, path, kept, into);
|
|
146
|
+
else
|
|
147
|
+
into.push(path);
|
|
148
|
+
}
|
|
52
149
|
}
|
|
53
150
|
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
54
151
|
const metadata = output.test_metadata;
|
|
@@ -284,6 +381,27 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
|
|
|
284
381
|
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
285
382
|
return request;
|
|
286
383
|
}
|
|
384
|
+
// Which branches a command was told to work on. No name means all of them — the
|
|
385
|
+
// "one suite, one run" shape both recipes insist on, so the default never teaches
|
|
386
|
+
// the habit the recipes forbid. A name narrows: `run-local` uses it for the
|
|
387
|
+
// repair loop, where re-running the finished peer is pure cost, and
|
|
388
|
+
// `put-suite-build` for publishing a branch the moment it is done (spec 41,
|
|
389
|
+
// criterion 3).
|
|
390
|
+
//
|
|
391
|
+
// One parse and one sentence for both, because it is one rule. They had a copy
|
|
392
|
+
// each and worded the same user error two different ways, which makes a person
|
|
393
|
+
// who has met one of them read the other as a different problem.
|
|
394
|
+
export function namedBranches(request, args) {
|
|
395
|
+
const all = request.branches.map((branch) => branch.suite_kind);
|
|
396
|
+
const named = args.filter((arg) => !arg.startsWith('-'));
|
|
397
|
+
if (named.length === 0)
|
|
398
|
+
return [];
|
|
399
|
+
const unknown = named.filter((name) => !all.includes(name));
|
|
400
|
+
if (unknown.length > 0) {
|
|
401
|
+
throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
|
|
402
|
+
}
|
|
403
|
+
return named;
|
|
404
|
+
}
|
|
287
405
|
export function readSuiteBuildRequest(projectRoot) {
|
|
288
406
|
const path = requestPath(projectRoot);
|
|
289
407
|
if (!existsSync(path)) {
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -21,6 +21,48 @@ export function requestDigest(projectRoot) {
|
|
|
21
21
|
export function workerPlanDigest(projectRoot) {
|
|
22
22
|
return exactFileDigest(workerPlanPath(projectRoot));
|
|
23
23
|
}
|
|
24
|
+
// The addresses the request handed to each capability, indexed by id. The
|
|
25
|
+
// checkpoint gate needs them for one question only: was this address given to
|
|
26
|
+
// this slice at all (spec 41, criterion 1). What a slice *left* is not worked out
|
|
27
|
+
// anywhere in this repo — answering that means reading how much of a capability
|
|
28
|
+
// is guarded, which is Rails' to say and, as the architecture guard notes, not a
|
|
29
|
+
// sentence `src/` is even allowed to write.
|
|
30
|
+
//
|
|
31
|
+
// A capability whose assignment lists no surfaces is absent from the map rather
|
|
32
|
+
// than present with an empty list: "this assignment does not say" and "this
|
|
33
|
+
// capability has no addresses" are different, and only the first must leave
|
|
34
|
+
// membership unchecked.
|
|
35
|
+
export function assignedSurfaces(projectRoot) {
|
|
36
|
+
const request = readRequest(projectRoot);
|
|
37
|
+
const byId = new Map();
|
|
38
|
+
for (const branch of Array.isArray(request.branches) ? request.branches : []) {
|
|
39
|
+
const assignment = branch.assignment;
|
|
40
|
+
for (const entry of Array.isArray(assignment?.capabilities) ? assignment.capabilities : []) {
|
|
41
|
+
const capability = entry;
|
|
42
|
+
const id = capability?.capability_id;
|
|
43
|
+
const surfaces = capability?.surfaces;
|
|
44
|
+
if (!isNonEmptyString(id) || !Array.isArray(surfaces) || surfaces.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
byId.set(id, surfaces.filter(isNonEmptyString));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return byId;
|
|
50
|
+
}
|
|
51
|
+
// The task, read as loosely typed JSON.
|
|
52
|
+
//
|
|
53
|
+
// `readSuiteBuildRequest` in `suiteBuild.ts` returns the same file typed, and is
|
|
54
|
+
// the obvious thing to call — but that module imports this one, so calling it
|
|
55
|
+
// back would close an import cycle. This is the price, written down so the next
|
|
56
|
+
// reader does not spend the same minutes finding out why.
|
|
57
|
+
function readRequest(projectRoot) {
|
|
58
|
+
const path = requestPath(projectRoot);
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`${path} is not valid JSON: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
24
66
|
export function readWorkerPlan(projectRoot) {
|
|
25
67
|
const path = workerPlanPath(projectRoot);
|
|
26
68
|
if (!existsSync(path))
|
|
@@ -97,9 +139,15 @@ function seedFor(item, request_digest, plan_digest) {
|
|
|
97
139
|
written_paths: [],
|
|
98
140
|
decisions: [],
|
|
99
141
|
known_problems: [],
|
|
100
|
-
// Behavioral only, and absent rather than empty elsewhere —
|
|
101
|
-
//
|
|
102
|
-
|
|
142
|
+
// Behavioral only, and absent rather than empty elsewhere — they are about
|
|
143
|
+
// addresses, and the structural branch has none.
|
|
144
|
+
//
|
|
145
|
+
// `unreachable_surfaces` is seeded empty because empty is the honest common
|
|
146
|
+
// answer and the gate wants the key present either way. Its peer bucket,
|
|
147
|
+
// `deferred_surfaces`, is deliberately not here: what a slice did not take is
|
|
148
|
+
// the remainder of what it did, and it is worked out where the upload is
|
|
149
|
+
// assembled. Two places to answer one question is how the two drift.
|
|
150
|
+
...(item.branch === 'behavioral' ? { surface_coverage: [], unreachable_surfaces: [] } : {}),
|
|
103
151
|
facts: [],
|
|
104
152
|
};
|
|
105
153
|
}
|
|
@@ -111,13 +159,7 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
111
159
|
const errors = [];
|
|
112
160
|
const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
|
|
113
161
|
const plan = readWorkerPlan(projectRoot);
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
request = JSON.parse(readFileSync(requestPath(projectRoot), 'utf8'));
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
throw new Error(`${requestPath(projectRoot)} is not valid JSON: ${error.message}`);
|
|
120
|
-
}
|
|
162
|
+
const request = readRequest(projectRoot);
|
|
121
163
|
if (!plan || typeof plan !== 'object')
|
|
122
164
|
return ['worker plan must be an object'];
|
|
123
165
|
if (plan.request_digest !== requestDigest(projectRoot))
|
package/dist/proc.js
CHANGED
|
@@ -66,18 +66,47 @@ export function runProcess(command, args = [], options = {}) {
|
|
|
66
66
|
child.on('close', (code) => finish({ stdout, stderr, code: timedOut ? null : code }));
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
// The oldest graphify the map may be built with. Below it, `detect.py` drops
|
|
70
|
+
// any file whose name ends in `token`, `secret`, `password` or `credential` as
|
|
71
|
+
// a probable secret store — by the name alone, before the file is parsed, and
|
|
72
|
+
// without a word on stdout: `graphify update` never prints the list it keeps.
|
|
73
|
+
// On the bench, 2026-09-11, that took `app/api/tokens.py` out of microblog and
|
|
74
|
+
// `src/controllers/auth/token.js` out of soul, so both maps were built without
|
|
75
|
+
// the sign-in code and nothing said so. `.graphifyignore` cannot re-include a
|
|
76
|
+
// file graphify has decided is sensitive, so the only cure is the release that
|
|
77
|
+
// exempts real source files (`.py`, `.js`, `.ts`, `.rb`) from that rule, and
|
|
78
|
+
// 0.9.18 is the first one that does — checked release by release.
|
|
79
|
+
export const GRAPHIFY_MIN_VERSION = '0.9.18';
|
|
80
|
+
const GRAPHIFY_INSTALL = '`pip install graphifyy && graphify install` (PyPI package "graphifyy", command "graphify", needs Python 3.10+)';
|
|
81
|
+
export async function requireGraphify(run = runProcess) {
|
|
82
|
+
let result;
|
|
70
83
|
try {
|
|
71
|
-
|
|
72
|
-
if (result.code === 0)
|
|
73
|
-
return;
|
|
74
|
-
throw new Error(result.stderr.trim() || result.stdout.trim() || `graphify --help exited ${result.code}`);
|
|
84
|
+
result = await run('graphify', ['--version']);
|
|
75
85
|
}
|
|
76
86
|
catch (err) {
|
|
77
|
-
throw new Error(`graphify is required but was not found or did not run. Install it with ` +
|
|
78
|
-
|
|
79
|
-
`"graphify", needs Python 3.10+), then retry (${err.message}).`);
|
|
87
|
+
throw new Error(`graphify is required but was not found or did not run. Install it with ${GRAPHIFY_INSTALL}, ` +
|
|
88
|
+
`then retry (${err.message}).`);
|
|
80
89
|
}
|
|
90
|
+
// `graphify 0.9.58` on stdout. A release too old to answer `--version` at all
|
|
91
|
+
// (0.7.x says "unknown command") is older than the floor by definition, so it
|
|
92
|
+
// is refused with the same sentence rather than a different one.
|
|
93
|
+
const version = /\bgraphify\s+(\d+\.\d+\.\d+)/.exec(result.stdout)?.[1];
|
|
94
|
+
if (version && !olderThan(version, GRAPHIFY_MIN_VERSION))
|
|
95
|
+
return;
|
|
96
|
+
const found = version ?? (result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`);
|
|
97
|
+
throw new Error(`graphify ${found} is installed, and Unitbob needs ${GRAPHIFY_MIN_VERSION} or newer. Older releases ` +
|
|
98
|
+
`silently leave out any source file whose name ends in "token", "secret" or "password" — ` +
|
|
99
|
+
`the sign-in code, typically — so the map would be built without it and nobody would be told. ` +
|
|
100
|
+
`Upgrade with \`pip install --upgrade graphifyy\`, then retry.`);
|
|
101
|
+
}
|
|
102
|
+
function olderThan(version, floor) {
|
|
103
|
+
const a = version.split('.').map(Number);
|
|
104
|
+
const b = floor.split('.').map(Number);
|
|
105
|
+
for (let i = 0; i < 3; i += 1) {
|
|
106
|
+
if (a[i] !== b[i])
|
|
107
|
+
return a[i] < b[i];
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
81
110
|
}
|
|
82
111
|
// Paths that hold no business logic in the stacks unitbob supports — Rails,
|
|
83
112
|
// JS/TS, Python — and that graphify does not already skip (it drops
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -286,16 +286,27 @@ ${imports}
|
|
|
286
286
|
test('the modules our guardrails import all load', () => {});
|
|
287
287
|
`;
|
|
288
288
|
}
|
|
289
|
-
// Python imports modules, not files, so the probe does the translation itself
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
289
|
+
// Python imports modules, not files, so the probe does the translation itself —
|
|
290
|
+
// and then imports by name, exactly as a guardrail's `from app.models import
|
|
291
|
+
// User` will (spec 50). Not `spec_from_file_location` + `exec_module`, which is
|
|
292
|
+
// what stood here until 2026-09-11: that executes the file whether or not it is
|
|
293
|
+
// already loaded. On microblog the first target, `app/__init__.py`, does `from
|
|
294
|
+
// app import models`, so by the time the loop reached `app/models.py` the module
|
|
295
|
+
// was in `sys.modules` and got run a second time — SQLAlchemy answered "Table
|
|
296
|
+
// 'followers' is already defined for this MetaData instance", and a healthy
|
|
297
|
+
// Flask application lost its structural branch. `import_module` reads
|
|
298
|
+
// `sys.modules` first, names `app/__init__.py` `app` rather than `app.__init__`,
|
|
299
|
+
// and goes through the finders, so the probe asks the run's question and no
|
|
300
|
+
// stricter one (`docs/adr/0001`). Where it still differs: `sys.path.insert(0,
|
|
301
|
+
// ROOT)` puts the project root first, which `-m pytest` run from that root does
|
|
302
|
+
// on its own — every interpreter `locateRunner` returns is invoked that way — so
|
|
303
|
+
// the line is the run's assumption made visible, not an addition to it. A
|
|
304
|
+
// src-layout project, whose guardrails import `pkg.mod` with `src/` on the
|
|
305
|
+
// path, is named `src.pkg.mod` here and would be refused; no such project has
|
|
306
|
+
// reached the bench, and the spec records it as work not done.
|
|
296
307
|
function pytestProbeSource(sourceFiles) {
|
|
297
308
|
return `# Written by the unitbob connector before the boot check — do not edit.
|
|
298
|
-
import importlib
|
|
309
|
+
import importlib
|
|
299
310
|
import pathlib
|
|
300
311
|
import sys
|
|
301
312
|
|
|
@@ -313,12 +324,9 @@ def test_the_modules_our_guardrails_import_all_load():
|
|
|
313
324
|
if not rel.endswith(".py"):
|
|
314
325
|
continue
|
|
315
326
|
name = rel[:-3].replace("/", ".")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
module = importlib.util.module_from_spec(spec)
|
|
320
|
-
sys.modules[name] = module
|
|
321
|
-
spec.loader.exec_module(module)
|
|
327
|
+
if name.endswith(".__init__"):
|
|
328
|
+
name = name[: -len(".__init__")]
|
|
329
|
+
importlib.import_module(name)
|
|
322
330
|
`;
|
|
323
331
|
}
|
|
324
332
|
// Runs one command, turning "this binary is not on the machine" into null (the
|
package/dist/runner/provision.js
CHANGED
|
@@ -523,7 +523,23 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
523
523
|
// the exact shape of failure 35-1 closes. A project that does carry it keeps
|
|
524
524
|
// its own version, and now actually gets to: see the comment on the helper
|
|
525
525
|
// for what asking twice cost A2.Time.
|
|
526
|
-
gemLineUnlessTheProjectHasIt('webmock')
|
|
526
|
+
gemLineUnlessTheProjectHasIt('webmock') +
|
|
527
|
+
// The connector-owned World does not merely mention rspec — it requires
|
|
528
|
+
// `rspec/expectations` and `rspec/mocks` at load and runs a full mock
|
|
529
|
+
// lifecycle per scenario (`src/files/behavioral.ts`). Until spec 40 the
|
|
530
|
+
// sidecar never asked for either, so a Rails project on minitest got a World
|
|
531
|
+
// that could not load: noahsat-web died on `cannot load such file --
|
|
532
|
+
// rspec/expectations` and lost its behavioral branch entirely.
|
|
533
|
+
//
|
|
534
|
+
// Unpinned, and that is a safety condition rather than a taste. A project
|
|
535
|
+
// carrying `rspec-rails` does not declare `rspec-expectations` explicitly —
|
|
536
|
+
// it arrives transitively — so the guard above does not fire and our line is
|
|
537
|
+
// added. It resolves without conflict because the sidecar starts from a copy
|
|
538
|
+
// of the project's own lock (below) and `>= 0` constrains nothing, leaving
|
|
539
|
+
// the version rspec-rails already chose. A pin that missed that line would be
|
|
540
|
+
// a `Bundler::VersionConflict` at install instead.
|
|
541
|
+
gemLineUnlessTheProjectHasIt('rspec-expectations') +
|
|
542
|
+
gemLineUnlessTheProjectHasIt('rspec-mocks');
|
|
527
543
|
if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
|
|
528
544
|
writeFileSync(sidecarGemfile, sidecarContent);
|
|
529
545
|
}
|
package/dist/runner/pytest.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
3
|
+
import { GUARDRAILS_DIR, PYTEST_RESULT_NAME } from "../files/guardrails.js";
|
|
4
4
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
6
|
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
-
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
8
|
+
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, PYTEST_RESULT_NAME);
|
|
9
9
|
// A minimal runtime config, created or overwritten before each run and passed
|
|
10
10
|
// via `-c` so the project's own addopts (e.g. --cov, -n auto) cannot break the
|
|
11
11
|
// guardrail run or its JUnit output. Connector-owned: never stored in Rails,
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
2
|
+
import { GUARDRAILS_DIR, OPTIONS_FILE, RSPEC_RESULT_NAME } from "../files/guardrails.js";
|
|
3
3
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
4
4
|
import { locateRunner } from "./toolchain.js";
|
|
5
5
|
import { clearReport, readFreshReport } from "./types.js";
|
|
@@ -8,7 +8,7 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
8
8
|
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
9
9
|
// the project's random ordering.
|
|
10
10
|
export const RSPEC_SEED = '1';
|
|
11
|
-
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
11
|
+
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, RSPEC_RESULT_NAME);
|
|
12
12
|
// Run the materialised Unitbob guardrail suite (spec 26). Only these files run —
|
|
13
13
|
// never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
|
|
14
14
|
// --options points at the materialized empty file so the project's own .rspec
|
package/dist/runner/vitest.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
3
|
+
import { GUARDRAILS_DIR, VITEST_RESULT_NAME } from "../files/guardrails.js";
|
|
4
4
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
6
|
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
-
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
8
|
+
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, VITEST_RESULT_NAME);
|
|
9
9
|
// A connector-owned Vitest config, written next to .unitbob/ before a run when
|
|
10
10
|
// the project has its own config. Connector-owned: never stored in Rails, never
|
|
11
11
|
// part of the suite digest.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
1
|
+
import { namedBranches, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { placeProblem } from "../runner/place.js";
|
|
3
3
|
import { collectBuildProblems } from "./validateBuild.js";
|
|
4
4
|
import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
|
|
@@ -20,17 +20,40 @@ import { Wire } from "../wire.js";
|
|
|
20
20
|
//
|
|
21
21
|
// Returns the server's per-branch results so the caller can compose the first run
|
|
22
22
|
// on top of them (spec 32-4) without parsing the lines printed here.
|
|
23
|
-
export async function putSuiteBuild(config,
|
|
23
|
+
export async function putSuiteBuild(config, args = [], deps) {
|
|
24
24
|
// Spec 36, criterion 7. Publishing is followed immediately by a first run, so
|
|
25
25
|
// a place that cannot be used is not something to discover after the suite is
|
|
26
26
|
// stored on the server.
|
|
27
27
|
const unusable = placeProblem(config.projectRoot);
|
|
28
28
|
if (unusable)
|
|
29
29
|
throw new Error(`${unusable}\nNothing was uploaded.`);
|
|
30
|
-
|
|
30
|
+
// Spec 41, criterion 3. The one thing a caller may say about scope: publish
|
|
31
|
+
// these branches, and only these.
|
|
32
|
+
//
|
|
33
|
+
// a2time, 2026-09-05. A two-hour run was interrupted mid-repair and left
|
|
34
|
+
// nothing on the server, though a complete answer for both branches had been
|
|
35
|
+
// sitting on disk since before the first run. Uploading one branch was never
|
|
36
|
+
// the problem — a missing branch has always been named against itself and never
|
|
37
|
+
// sunk the batch. What was missing is a way to say the peer's absence is the
|
|
38
|
+
// plan: `collectBuildProblems` exists to catch a branch abandoned in silence,
|
|
39
|
+
// and on a branch-at-a-time publish it would cry wolf every run.
|
|
40
|
+
//
|
|
41
|
+
// The request is cut down once, here, so every later step answers the same
|
|
42
|
+
// question about the same list instead of each remembering to skip the peer.
|
|
43
|
+
const whole = readSuiteBuildRequest(config.projectRoot);
|
|
44
|
+
const only = namedBranches(whole, args);
|
|
45
|
+
const request = only.length > 0
|
|
46
|
+
? { ...whole, branches: whole.branches.filter((branch) => only.includes(branch.suite_kind)) }
|
|
47
|
+
: whole;
|
|
31
48
|
// Spec 32-6: read branch by branch, so one unreadable entry neither hides the
|
|
32
49
|
// next branch's problems nor sinks a peer that is finished and correct.
|
|
33
|
-
const
|
|
50
|
+
const answer = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
51
|
+
// The answer file is the whole run's, not this call's: when a branch is named,
|
|
52
|
+
// its peer's entry is somebody else's business — already published by an
|
|
53
|
+
// earlier call, or still being repaired — and reading it here would report the
|
|
54
|
+
// peer as unpublishable for the sole reason that this call was not about it.
|
|
55
|
+
const outputs = answer.outputs.filter((output) => only.length === 0 || only.includes(output.suite_kind));
|
|
56
|
+
const unreadable = answer.unreadable.filter((entry) => only.length === 0 || only.includes(entry.suite_kind));
|
|
34
57
|
const d = {
|
|
35
58
|
putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
|
|
36
59
|
stdout: process.stdout,
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
1
|
+
import { branchRunner, namedBranches, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { digestOf, failureSet, readRunState, rememberFailures, reportedFailures, } from "../runner/failureDigest.js";
|
|
3
3
|
import { placeProblem } from "../runner/place.js";
|
|
4
4
|
import { placeAdvice } from "../runner/placeAdvice.js";
|
|
@@ -76,26 +76,23 @@ function compareFailures(config, d, suiteKind, ran, before) {
|
|
|
76
76
|
rememberFailures(config.projectRoot, suiteKind, digest);
|
|
77
77
|
if (digest !== before)
|
|
78
78
|
return false;
|
|
79
|
-
|
|
79
|
+
// The number is the one the reader has just counted in the list above — every
|
|
80
|
+
// failed case — not the size of the compared set. That set is keyed on marker,
|
|
81
|
+
// file and first line, so seven Scenarios under one marker failing the same
|
|
82
|
+
// way are one entry in it; on soul, 2026-09-11, this line said "the same
|
|
83
|
+
// 1 case(s)" under a list of seven, and read as a counting error.
|
|
84
|
+
const reported = reportedFailures(ran.runner, ran.result.report)?.length ?? failures.length;
|
|
85
|
+
d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${reported} case(s) as the previous run, ` +
|
|
80
86
|
'down to the first line of every message. The edits since then changed nothing this run can see.\n' +
|
|
81
87
|
'Look at the failures yourself, replan the slice, or record the branch as a build_error. ' +
|
|
82
88
|
'Running it again unchanged prints this same line.\n');
|
|
83
89
|
return true;
|
|
84
90
|
}
|
|
85
|
-
// Which branches to run
|
|
86
|
-
//
|
|
87
|
-
// never teaches the habit the recipes forbid. A named branch is for the repair
|
|
88
|
-
// loop, where re-running the finished peer is pure cost.
|
|
91
|
+
// Which branches to run: the ones named, or every branch the request asked for.
|
|
92
|
+
// The parse and the error live in `namedBranches`, shared with the publish side.
|
|
89
93
|
function selectBranches(request, args) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
if (named.length === 0)
|
|
93
|
-
return all;
|
|
94
|
-
const unknown = named.filter((name) => !all.includes(name));
|
|
95
|
-
if (unknown.length > 0) {
|
|
96
|
-
throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
|
|
97
|
-
}
|
|
98
|
-
return named;
|
|
94
|
+
const named = namedBranches(request, args);
|
|
95
|
+
return named.length > 0 ? named : request.branches.map((branch) => branch.suite_kind);
|
|
99
96
|
}
|
|
100
97
|
// Non-null when the runner actually executed the branch. Everything else — no
|
|
101
98
|
// entry, a declared `build_error`, a stack that cannot run it — is a branch that
|
|
@@ -2,7 +2,7 @@ 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 { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
|
|
5
|
-
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
|
+
import { isNewBuild, movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
6
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
7
7
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
8
8
|
import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
|
|
@@ -100,6 +100,35 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
100
100
|
const replaced = alignRunnerEnvironmentWithPlace(config.projectRoot);
|
|
101
101
|
if (replaced)
|
|
102
102
|
actual.stdout.write(`${replaced}\n`);
|
|
103
|
+
// Spec 37-2, criterion 3, widened by spec 49. The papers a build leaves
|
|
104
|
+
// behind — plan, checkpoints, answer, review — outlive the `request.json`
|
|
105
|
+
// they were digested against, and every one of them is refused by its own
|
|
106
|
+
// gate from here on. The suite files of both branches outlive it too, and
|
|
107
|
+
// nothing refuses those: the behavioral runner loads its directory whole, so
|
|
108
|
+
// a dead `.feature` runs and a dead step file argues with a live one. Moved
|
|
109
|
+
// rather than removed — see `movePreviousRunAside`.
|
|
110
|
+
//
|
|
111
|
+
// Here, before the helper and the World are written and before the probe asks
|
|
112
|
+
// its question (criterion 4): a `conftest.py` from the last build would be
|
|
113
|
+
// loaded by pytest beside our probe, and a leftover `_setup.ts` would turn the
|
|
114
|
+
// probe's scouting into a sentence. Only on a new build (criterion 3): a
|
|
115
|
+
// repeat inside one is the coordinator's second question of the probe (spec
|
|
116
|
+
// 39), and the setup file written between the two runs has to survive it.
|
|
117
|
+
//
|
|
118
|
+
// Wrapped, because a read-only checkout or a permission the move does not
|
|
119
|
+
// have is a note, not a build that dies before it has asked the server
|
|
120
|
+
// anything. And nothing is lost if a later step refuses: `request.json` of
|
|
121
|
+
// the last build is still there, so the next run does not move again.
|
|
122
|
+
let displaced = { artifacts: [], branches: { structural: 0, behavioral: 0 } };
|
|
123
|
+
let displaceProblem = '';
|
|
124
|
+
if (isNewBuild(config.projectRoot)) {
|
|
125
|
+
try {
|
|
126
|
+
displaced = movePreviousRunAside(config.projectRoot, detectBddRunner(config.projectRoot));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
displaceProblem = err.message;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
103
132
|
// Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
|
|
104
133
|
// project it touched, so a Flask app and a NestJS app each came away with a
|
|
105
134
|
// Ruby file they never asked for and cannot run — the product leaving another
|
|
@@ -300,38 +329,22 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
300
329
|
// step of the loop, so a failure set remembered from the build before it would
|
|
301
330
|
// stop a branch that has not run once yet.
|
|
302
331
|
clearRunState(config.projectRoot);
|
|
303
|
-
// Spec 37-2, criterion 3. Same reasoning, applied to the papers that were left
|
|
304
|
-
// behind rather than cleared: a plan and its checkpoints outlive the
|
|
305
|
-
// `request.json` they were digested against, and every one of them is refused
|
|
306
|
-
// by its own gate from here on. Moved rather than removed — see
|
|
307
|
-
// `movePreviousRunAside`.
|
|
308
|
-
//
|
|
309
|
-
// Wrapped for the same reason `buildPackets` is: by this line the request is
|
|
310
|
-
// written and the build is real. A read-only checkout or a permission the move
|
|
311
|
-
// does not have is a note, not a build that dies after its own work landed
|
|
312
|
-
// and before it could say so.
|
|
313
|
-
let displaced = [];
|
|
314
|
-
let displaceProblem = '';
|
|
315
|
-
try {
|
|
316
|
-
displaced = movePreviousRunAside(config.projectRoot);
|
|
317
|
-
}
|
|
318
|
-
catch (err) {
|
|
319
|
-
displaceProblem = err.message;
|
|
320
|
-
}
|
|
321
332
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
322
333
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
323
334
|
? '`unitbob suite-review-prepare` before upload'
|
|
324
335
|
: '`unitbob put-suite-build`';
|
|
325
336
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
326
|
-
|
|
327
|
-
|
|
337
|
+
const moved = displacedList(displaced);
|
|
338
|
+
if (moved.length > 0) {
|
|
339
|
+
actual.stdout.write(`The previous run's ${moved.join(', ')} moved to ` +
|
|
328
340
|
`${request.project_root}/.unitbob/suite-build/previous/ — none of it is left where this build will ` +
|
|
329
341
|
'look, and none of it was deleted.\n');
|
|
330
342
|
}
|
|
331
343
|
if (displaceProblem) {
|
|
332
344
|
actual.stdout.write(`\nThe previous run's files could not be moved out of the way (${displaceProblem}). This build is fine, ` +
|
|
333
345
|
'but a plan or checkpoint left over from it will be refused by its own gate — the digests belong to ' +
|
|
334
|
-
'the request that was just replaced
|
|
346
|
+
'the request that was just replaced — and running a branch will include files from the previous build ' +
|
|
347
|
+
'still under .unitbob/structural/ and .unitbob/behavioral/.\n');
|
|
335
348
|
}
|
|
336
349
|
actual.stdout.write(packetNotice(request.project_root, sourcePackets));
|
|
337
350
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
@@ -403,6 +416,16 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
403
416
|
'\n');
|
|
404
417
|
}
|
|
405
418
|
}
|
|
419
|
+
// Everything that moved, in the words the line has always used: the artifacts
|
|
420
|
+
// by name, then each branch as a count (spec 49, criterion 5). By-products that
|
|
421
|
+
// were deleted are not listed — nobody lost them.
|
|
422
|
+
function displacedList(moved) {
|
|
423
|
+
const branchFiles = (branch) => {
|
|
424
|
+
const count = moved.branches[branch];
|
|
425
|
+
return count > 0 ? [`${count} ${branch} ${count === 1 ? 'file' : 'files'}`] : [];
|
|
426
|
+
};
|
|
427
|
+
return [...moved.artifacts, ...branchFiles('structural'), ...branchFiles('behavioral')];
|
|
428
|
+
}
|
|
406
429
|
// A checkout we cannot write packets into is a run without packets, not a
|
|
407
430
|
// failed build: the workers search the source themselves, exactly as they did
|
|
408
431
|
// before this spec. The same rule the route inventory follows for the same
|
|
@@ -93,6 +93,7 @@ function dryRunBatch(config, request, outputs) {
|
|
|
93
93
|
const items = [];
|
|
94
94
|
const unchecked = [];
|
|
95
95
|
const problems = [];
|
|
96
|
+
const withoutReview = new Set();
|
|
96
97
|
for (const output of outputs) {
|
|
97
98
|
if (output.build_error) {
|
|
98
99
|
items.push(uploadItem(request, output, undefined));
|
|
@@ -108,15 +109,33 @@ function dryRunBatch(config, request, outputs) {
|
|
|
108
109
|
problems.push({ branch: output.suite_kind, message: error.message });
|
|
109
110
|
continue;
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'
|
|
114
|
-
'
|
|
112
|
+
withoutReview.add(output.suite_kind);
|
|
113
|
+
unchecked.push(`${output.suite_kind}: the independent review has not been written yet, so the server judges ` +
|
|
114
|
+
'this branch up to the point where it would read the review. Everything before that — the ' +
|
|
115
|
+
'manifest, the markers, the addresses — is checked now; the review itself is answered later, ' +
|
|
116
|
+
'by `suite-review-prepare` and the reviewer. Run this command again afterwards for a verdict ' +
|
|
117
|
+
'on the whole branch.');
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
items.push(uploadItem(request, output, testMetadata));
|
|
118
121
|
}
|
|
119
|
-
return { items, unchecked, problems };
|
|
122
|
+
return { items, unchecked, problems, withoutReview };
|
|
123
|
+
}
|
|
124
|
+
// The one refusal a branch sent without its review is expected to get. The
|
|
125
|
+
// server checks the manifest, the markers and the surface arithmetic first and
|
|
126
|
+
// stops at the first rule broken, so reaching *this* rule means everything
|
|
127
|
+
// before it passed — which is the whole answer the pre-review run was after.
|
|
128
|
+
//
|
|
129
|
+
// Printed as a refusal, it was read as one: "must include a bdd_quality_review
|
|
130
|
+
// artifact", right under "Fix them, then run validate-build again", sent a
|
|
131
|
+
// reader looking for a defect in an answer that step 9 forbids from carrying
|
|
132
|
+
// that key in the first place. The text is matched, not reworded — the words on
|
|
133
|
+
// the line below are the server's, and if the server ever says it differently
|
|
134
|
+
// the line stops matching and the refusal is shown as before, which is the safe
|
|
135
|
+
// way for this to go stale.
|
|
136
|
+
const REVIEW_MISSING = /must include a bdd_quality_review artifact/;
|
|
137
|
+
function reachedTheReview(result, withoutReview) {
|
|
138
|
+
return withoutReview.has(result.suite_kind) && REVIEW_MISSING.test(result.error ?? '');
|
|
120
139
|
}
|
|
121
140
|
function describe(result) {
|
|
122
141
|
const tallies = result.counts
|
|
@@ -140,7 +159,7 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
140
159
|
};
|
|
141
160
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
142
161
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
143
|
-
const { items, unchecked, problems } = dryRunBatch(config, request, outputs);
|
|
162
|
+
const { items, unchecked, problems, withoutReview } = dryRunBatch(config, request, outputs);
|
|
144
163
|
const local = [
|
|
145
164
|
...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
|
|
146
165
|
...collectBuildProblems(request, outputs, unreadable),
|
|
@@ -178,7 +197,7 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
178
197
|
'instead of checking. Upgrade the server before running validate-build again — and note that this ' +
|
|
179
198
|
'branch is now live.\n');
|
|
180
199
|
}
|
|
181
|
-
const refused = results.filter((result) => result.status !== WOULD_PUBLISH && result.status !== 'build_error');
|
|
200
|
+
const refused = results.filter((result) => result.status !== WOULD_PUBLISH && result.status !== 'build_error' && !reachedTheReview(result, withoutReview));
|
|
182
201
|
if (refused.length > 0) {
|
|
183
202
|
throw new Error(`The Unitbob server would refuse this answer:\n${refused.map(rejection).join('\n')}\n` +
|
|
184
203
|
'Those are the server\'s own words. Fix them, then run `unitbob validate-build` again — this round ' +
|
|
@@ -188,9 +207,20 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
188
207
|
// answer whose every branch is a declared `build_error` is accepted and stores
|
|
189
208
|
// nothing, and reporting that as a suite about to go up would be the one
|
|
190
209
|
// sentence in this output that is not true of what happened.
|
|
210
|
+
//
|
|
211
|
+
// A branch that reached the review rule without a review is neither: the
|
|
212
|
+
// server checked everything before it and stopped where the review would be.
|
|
213
|
+
// It gets its own line, and the headline does not promise a publish for it.
|
|
191
214
|
const accepted = results.filter((result) => result.status === WOULD_PUBLISH);
|
|
215
|
+
const pending = results.filter((result) => reachedTheReview(result, withoutReview));
|
|
192
216
|
const headline = accepted.length === 0
|
|
193
|
-
?
|
|
217
|
+
? pending.length === 0
|
|
218
|
+
? 'The Unitbob server accepted this answer, and it publishes no suite:'
|
|
219
|
+
: 'The Unitbob server checked this answer as far as it can before the review:'
|
|
194
220
|
: 'The Unitbob server checked this answer and would publish it:';
|
|
195
|
-
|
|
221
|
+
const lines = results.map((result) => reachedTheReview(result, withoutReview)
|
|
222
|
+
? ` ${result.suite_kind}: checked up to the review — the manifest, the markers and the addresses passed; ` +
|
|
223
|
+
'the review is what is left'
|
|
224
|
+
: describe(result));
|
|
225
|
+
d.stdout.write(`${headline}\n${lines.join('\n')}\n${DRY_RUN_DOES_NOT}\n`);
|
|
196
226
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
|
|
2
|
+
import { assignedSurfaces, checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
|
|
3
3
|
export async function validateWorkerCheckpoints(config, _args = [], deps = { stdout: process.stdout }) {
|
|
4
4
|
const planErrors = validateWorkerPlanFiles(config.projectRoot);
|
|
5
5
|
if (planErrors.length > 0)
|
|
@@ -7,6 +7,7 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
|
|
|
7
7
|
const plan = readWorkerPlan(config.projectRoot);
|
|
8
8
|
const expectedRequestDigest = requestDigest(config.projectRoot);
|
|
9
9
|
const expectedPlanDigest = workerPlanDigest(config.projectRoot);
|
|
10
|
+
const assigned = assignedSurfaces(config.projectRoot);
|
|
10
11
|
const errors = [];
|
|
11
12
|
for (const item of plan.workers) {
|
|
12
13
|
const label = `${item.branch}:${item.worker_id}`;
|
|
@@ -53,6 +54,7 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
|
|
|
53
54
|
}
|
|
54
55
|
validateCompactFacts(checkpoint.facts, label, errors);
|
|
55
56
|
validateSurfaceCoverage(checkpoint.surface_coverage, item, label, errors);
|
|
57
|
+
validateUnreachableSurfaces(checkpoint.unreachable_surfaces, item, checkpoint.surface_coverage, assigned, label, errors);
|
|
56
58
|
stringArray(checkpoint.decisions, `${label}: decisions`, errors);
|
|
57
59
|
stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
|
|
58
60
|
}
|
|
@@ -139,6 +141,75 @@ function validateSurfaceCoverage(value, item, label, errors) {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
}
|
|
144
|
+
// Spec 41, criterion 1. Every assigned address ends in one of three places:
|
|
145
|
+
// driven by a Scenario, unreachable, or deferred. Only the first two are answers
|
|
146
|
+
// a worker can give — deferred is whatever is left, worked out where the upload
|
|
147
|
+
// is assembled, so a slice never has to enumerate what it did not do.
|
|
148
|
+
//
|
|
149
|
+
// a2time, 2026-09-05. Eight capabilities left 81 addresses in none of the three,
|
|
150
|
+
// and the server refused the publication after two hours. The workers were not
|
|
151
|
+
// careless: `deferred_surfaces` was only legal past the ceiling of twenty, six of
|
|
152
|
+
// those eight never came near it, and silence was the only move left. Widening
|
|
153
|
+
// the deferred bucket gave them a legal answer; computing it gave them a free
|
|
154
|
+
// one. What stays here is the pair the machine cannot work out on its own.
|
|
155
|
+
//
|
|
156
|
+
// Behavioral only, for the same reason as its neighbour: the structural branch
|
|
157
|
+
// has no addresses to account for.
|
|
158
|
+
function validateUnreachableSurfaces(value, item, coverage, assigned, label, errors) {
|
|
159
|
+
if (value === undefined && item.branch !== 'behavioral')
|
|
160
|
+
return;
|
|
161
|
+
if (!Array.isArray(value)) {
|
|
162
|
+
errors.push(`${label}: unreachable_surfaces must be an array of {surface, reason} entries, empty when the slice can drive everything it was given`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const driven = new Set(drivenSurfaces(coverage));
|
|
166
|
+
// Membership is measured against the addresses this slice's own capabilities
|
|
167
|
+
// were given, never the whole assignment: a neighbour's address is as foreign
|
|
168
|
+
// as an invented one.
|
|
169
|
+
const mine = item.capability_ids.flatMap((id) => assigned.get(id) ?? []);
|
|
170
|
+
const known = new Set(mine);
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
for (const [index, entry] of value.entries()) {
|
|
173
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
174
|
+
errors.push(`${label}: unreachable_surfaces[${index}] must be an object with surface and reason; got ${jsonType(entry)}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const record = entry;
|
|
178
|
+
const surface = record.surface;
|
|
179
|
+
if (typeof surface !== 'string' || !surface.trim()) {
|
|
180
|
+
errors.push(`${label}: unreachable_surfaces[${index}].surface must name one address`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// A reason per address, never one reason for a list. A sentence you cannot
|
|
184
|
+
// write about *this* address is the signal it is not really unreachable —
|
|
185
|
+
// which is the whole guard, and the reason this bucket stays narrow while
|
|
186
|
+
// its neighbour widened.
|
|
187
|
+
if (typeof record.reason !== 'string' || !record.reason.trim()) {
|
|
188
|
+
errors.push(`${label}: unreachable_surfaces[${index}].reason must say what has to happen elsewhere for ${surface} to be called`);
|
|
189
|
+
}
|
|
190
|
+
if (driven.has(surface)) {
|
|
191
|
+
errors.push(`${label}: ${surface} is driven by a Scenario and declared unreachable — it is one or the other`);
|
|
192
|
+
}
|
|
193
|
+
// Only when the assignment actually listed addresses for this capability.
|
|
194
|
+
// An assignment that says nothing cannot say a surface is foreign, and
|
|
195
|
+
// refusing there would refuse honest slices over an absence.
|
|
196
|
+
if (known.size > 0 && !known.has(surface)) {
|
|
197
|
+
errors.push(`${label}: ${surface} was not assigned to this slice`);
|
|
198
|
+
}
|
|
199
|
+
if (seen.has(surface))
|
|
200
|
+
errors.push(`${label}: unreachable_surfaces names ${surface} more than once`);
|
|
201
|
+
else
|
|
202
|
+
seen.add(surface);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function drivenSurfaces(coverage) {
|
|
206
|
+
if (!Array.isArray(coverage))
|
|
207
|
+
return [];
|
|
208
|
+
return coverage.flatMap((entry) => {
|
|
209
|
+
const surfaces = entry?.surfaces;
|
|
210
|
+
return Array.isArray(surfaces) ? surfaces.filter((surface) => typeof surface === 'string') : [];
|
|
211
|
+
});
|
|
212
|
+
}
|
|
142
213
|
function jsonType(value) {
|
|
143
214
|
if (value === null)
|
|
144
215
|
return 'null';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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.7 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
|
|
@@ -105,7 +105,15 @@ actually drives, and must equal that Scenario's `surface_coverage` in the
|
|
|
105
105
|
candidate's metadata. If the two disagree, that is a finding — say it in a
|
|
106
106
|
reservation or an objection rather than adjusting your list to match.
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
The `When`, and only the `When` — the same rule the worker wrote its list by.
|
|
109
|
+
What a `Given` does to arrive (sign in, create the table the Scenario needs) and
|
|
110
|
+
what an `After` does to leave are not the behaviour under test, so an address
|
|
111
|
+
they touch is not missing from `surface_coverage` and not a finding. On soul,
|
|
112
|
+
2026-09-11, every one of seven Scenarios got a reservation for its setup hitting
|
|
113
|
+
`POST /api/tables`, and seven reservations for one worker following its
|
|
114
|
+
instruction to the letter looked like a broken suite.
|
|
115
|
+
|
|
116
|
+
A Scenario whose `When` also drives an address belonging to another capability
|
|
109
117
|
goes in `reservation`, naming the address. There is no separate field for it and
|
|
110
118
|
none is coming; the text is free-form. One run had that observation, was right
|
|
111
119
|
about it, and withdrew it believing the format had nowhere to put it.
|
|
@@ -117,7 +125,7 @@ repair round opens — by the time you are reading, the repair rotation and the
|
|
|
117
125
|
final run are spent.
|
|
118
126
|
|
|
119
127
|
What it does do: a capability whose Scenarios were **all** objected to is stored
|
|
120
|
-
`unguarded` at publish — the amber "not yet
|
|
128
|
+
`unguarded` at publish — the amber "not guarded yet" lamp, never green — and
|
|
121
129
|
your objection becomes the sentence its owner reads in place of the headline.
|
|
122
130
|
Write it so it reads well there. One objection among sound siblings changes
|
|
123
131
|
nothing: the siblings guard the capability and its green lamp is earned. A
|
|
@@ -46,6 +46,22 @@ said about their work; the independent reviewer read the steps instead, six
|
|
|
46
46
|
Scenarios claimed addresses their steps never drove, and the server refused the
|
|
47
47
|
publication.
|
|
48
48
|
|
|
49
|
+
Your checkpoint also carries `unreachable_surfaces`, and it is usually empty. An
|
|
50
|
+
address goes there only when *nothing you can do* makes that request happen — a
|
|
51
|
+
third party's callback, a vendor's webhook, a redirect a real account has to
|
|
52
|
+
send. Each one needs its own sentence saying what has to happen elsewhere:
|
|
53
|
+
```json
|
|
54
|
+
{"surface":"GET /oauth2callback","reason":"The provider sends the user back here after they approve access, and no test can cause that."}
|
|
55
|
+
```
|
|
56
|
+
Hard is not unreachable. Authentication, a fixture that takes work, a background
|
|
57
|
+
job, a paid API with a sandbox — all drivable, so drive them.
|
|
58
|
+
|
|
59
|
+
You do **not** list the addresses you simply did not take. Whatever you neither
|
|
60
|
+
drove nor declared unreachable is the remainder, and the map shows it beside the
|
|
61
|
+
capability as *not taken this time* — "6 of 21 addresses guarded". So take the
|
|
62
|
+
ones that matter first: money, then authorization, then the addresses the rest of
|
|
63
|
+
the code points at most.
|
|
64
|
+
|
|
49
65
|
Write first, then find out. Start with the planned cases your seeded facts
|
|
50
66
|
already support and get them onto disk; go reading only for what you still lack
|
|
51
67
|
after that. The opposite order — survey the sources, then write — is what spent
|