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 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 Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
61
- then run every branch it published and report the server's results.
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).
@@ -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 connectorWorld = behavioralWorldFor(runner);
335
- const listed = new Set([
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) => !runnerEntries.has(entry) && !CONNECTOR_RUN_ARTIFACTS.has(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) {
@@ -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.
@@ -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 new build leaves behind, in the order a reader
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 PREVIOUS_RUN_ARTIFACTS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
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
- export function movePreviousRunAside(projectRoot) {
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
- mkdirSync(previous, { recursive: true });
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 found;
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)) {
@@ -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 — it joins Gherkin
101
- // Scenarios to addresses, and the structural branch has no Scenarios.
102
- ...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
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
- let request;
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
- export async function requireGraphify() {
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
- const result = await runProcess('graphify', ['--help']);
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
- `\`pip install graphifyy && graphify install\` (PyPI package "graphifyy", command ` +
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
@@ -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
- // The module is registered in `sys.modules` before it is executed, because a
291
- // module that is not there yet cannot be the target of its own relative
292
- // imports.
293
- //
294
- // This is the one part of spec 38 that no live project has exercised: there was
295
- // no Python project on the bench. Worth watching on the first Python run.
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.util
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
- spec = importlib.util.spec_from_file_location(name, ROOT / rel)
317
- if spec is None or spec.loader is None:
318
- continue
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
@@ -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
  }
@@ -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, 'pytest_result.xml');
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,
@@ -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, 'rspec_result.json');
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
@@ -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, 'vitest_result.json');
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, _args = [], deps) {
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
- const request = readSuiteBuildRequest(config.projectRoot);
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 { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
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,
@@ -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
- d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${failures.length} case(s) as the previous run, ` +
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. No argument runs every branch the request asked for
86
- // the same "one suite, one run" shape both recipes insist on, so the default
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 all = request.branches.map((branch) => branch.suite_kind);
91
- const named = args.filter((arg) => !arg.startsWith('-'));
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
- if (displaced.length > 0) {
327
- actual.stdout.write(`The previous run's ${displaced.join(', ')} moved to ` +
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.\n');
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
- unchecked.push(`${output.suite_kind}: the independent review has not been written yet, so the server judged ` +
112
- 'this branch without it. Anything it says about bdd_quality_review, known_defect_probe or ' +
113
- 'candidate_run is answered later, by `suite-review-prepare` and the reviewerrun this ' +
114
- 'command again afterwards for a verdict on the whole branch.');
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
- ? 'The Unitbob server accepted this answer, and it publishes no suite:'
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
- d.stdout.write(`${headline}\n${results.map(describe).join('\n')}\n${DRY_RUN_DOES_NOT}\n`);
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.4",
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.4 run-local <branch>` and inspect the machine
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
- A Scenario that also happens to drive an address belonging to another capability
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 testable" lamp, never green — and
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