unitbob 0.7.3 → 0.7.6

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).
@@ -284,6 +284,27 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
284
284
  writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
285
285
  return request;
286
286
  }
287
+ // Which branches a command was told to work on. No name means all of them — the
288
+ // "one suite, one run" shape both recipes insist on, so the default never teaches
289
+ // the habit the recipes forbid. A name narrows: `run-local` uses it for the
290
+ // repair loop, where re-running the finished peer is pure cost, and
291
+ // `put-suite-build` for publishing a branch the moment it is done (spec 41,
292
+ // criterion 3).
293
+ //
294
+ // One parse and one sentence for both, because it is one rule. They had a copy
295
+ // each and worded the same user error two different ways, which makes a person
296
+ // who has met one of them read the other as a different problem.
297
+ export function namedBranches(request, args) {
298
+ const all = request.branches.map((branch) => branch.suite_kind);
299
+ const named = args.filter((arg) => !arg.startsWith('-'));
300
+ if (named.length === 0)
301
+ return [];
302
+ const unknown = named.filter((name) => !all.includes(name));
303
+ if (unknown.length > 0) {
304
+ throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
305
+ }
306
+ return named;
307
+ }
287
308
  export function readSuiteBuildRequest(projectRoot) {
288
309
  const path = requestPath(projectRoot);
289
310
  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))
@@ -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
  }
@@ -16,6 +16,56 @@ export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
16
16
  // and is rewritten immediately before every run, so a boot check that reused it
17
17
  // would either be overwritten or leave the run pointing at a probe.
18
18
  export const VITEST_BOOT_CONFIG_FILE = join('.unitbob', 'vitest.boot.config.mjs');
19
+ // The structural branch's one shared setup file (spec 39). The coordinator
20
+ // writes it before fan-out and owns it afterwards: it holds whatever has to
21
+ // happen before the first import of any file of the branch — environment
22
+ // variables, a TypeScript loader registration, at the very last resort an entry
23
+ // through the project's root module.
24
+ //
25
+ // A fixed name rather than a new protocol field, because the file has to survive
26
+ // the round trip: `materializeGuardrails` wipes this directory and lays the
27
+ // artifact out again, so the file must travel as an ordinary `support_files`
28
+ // entry — and every such entry is otherwise handed to the runner as a test path.
29
+ // One constant here, read by the config builder, by both collectors of test
30
+ // paths, and by `suite-prepare`, is the whole of the agreement. A field would
31
+ // have had to be agreed with the server and both of its version models.
32
+ //
33
+ // One name, one extension. A second one buys nothing: vitest puts every setup
34
+ // file through vite, so `require` is undefined in a `.js` setup file just as it
35
+ // is in a `.ts` one.
36
+ export const STRUCTURAL_SETUP_FILE = '.unitbob/structural/_setup.ts';
37
+ // The project-relative path of that file, or `undefined` when it has not been
38
+ // written yet. Two callers, one question: the config builder asks it to decide
39
+ // whether to name the file in `setupFiles`, and `suite-prepare` asks it to
40
+ // decide whether the boot probe is a scout or a sentry (spec 39, criterion 4).
41
+ // Deliberately the same observable fact for both, rather than a flag — 32-6
42
+ // forbids a mode, and a mode would let the two sides disagree about which run
43
+ // they are in.
44
+ export function setupFileOf(projectRoot) {
45
+ return existsSync(join(projectRoot, STRUCTURAL_SETUP_FILE)) ? STRUCTURAL_SETUP_FILE : undefined;
46
+ }
47
+ // Whether anything of ours can run before this stack's branch imports its first
48
+ // module. Only vitest, because `setupFiles` is a vitest notion and the builder
49
+ // below is the single place that inserts our file. Kept here, beside the
50
+ // insertion it describes, rather than in the verb that asks: the verb would be
51
+ // stating a fact about a module it does not own, and the two could then drift.
52
+ export function canPrepareBeforeImports(runner) {
53
+ return runner === 'vitest';
54
+ }
55
+ // The files of an artifact that a runner may be pointed at, which is all of them
56
+ // except the shared setup file. It travels as an ordinary `support_files` entry
57
+ // so that materialization writes it back rather than wiping it, and it holds no
58
+ // cases at all: a runner given it as a test path opens it, finds nothing, and
59
+ // reports that nothing as a suite.
60
+ //
61
+ // One filter for both collectors — the check flow's and run-local's — because
62
+ // the two must never disagree about which file this is. A leading `./` is
63
+ // tolerated: the answer that names the file is hand-written, and two characters
64
+ // must not be what decides whether the branch's preparation is executed or
65
+ // collected.
66
+ export function testPathsOf(paths) {
67
+ return paths.filter((path) => path.replace(/^\.\//, '') !== STRUCTURAL_SETUP_FILE);
68
+ }
19
69
  // The project configs we inherit from, most specific first. Vitest reads a
20
70
  // project's own config even when we pass `--config`, so we must merge ours with
21
71
  // it rather than replace it (plugins, path aliases and resolve settings the
@@ -87,7 +137,7 @@ export async function runVitestSuite(projectRoot, suitePaths) {
87
137
  function writeMergedConfig(projectRoot, suitePaths) {
88
138
  const path = join(projectRoot, VITEST_CONFIG_FILE);
89
139
  mkdirSync(dirname(path), { recursive: true });
90
- writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run'));
140
+ writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run', setupFileOf(projectRoot)));
91
141
  return ['--config', VITEST_CONFIG_FILE];
92
142
  }
93
143
  // The same config, shaped for the boot check's probe (spec 38). Returned as text
@@ -95,7 +145,12 @@ function writeMergedConfig(projectRoot, suitePaths) {
95
145
  // puts in the project: it writes them together and removes them together, and a
96
146
  // writer here would take half of that away from the one place that can see it.
97
147
  export function vitestBootConfigSource(projectRoot, probePath) {
98
- return configSource(projectConfigOf(projectRoot), [probePath], 'boot');
148
+ // The same preparation the run will get, which is the point of asking twice
149
+ // (spec 39, criterion 4). On the first `suite-prepare` there is no setup file
150
+ // and the probe answers "these files do not load on their own"; on the second,
151
+ // after the coordinator wrote one, it answers the question the run will
152
+ // actually put — and only then is a red answer worth a branch.
153
+ return configSource(projectConfigOf(projectRoot), [probePath], 'boot', setupFileOf(projectRoot));
99
154
  }
100
155
  function projectConfigOf(projectRoot) {
101
156
  return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
@@ -118,13 +173,25 @@ function projectConfigOf(projectRoot) {
118
173
  // and `defineConfig` is a typing helper that buys a generated file nothing.
119
174
  // `mode` is the boot check's two differences from the run, and they go together:
120
175
  // globals on, and a workspace refused. See `vitestBootConfigSource`.
121
- function configSource(projectConfig, suitePaths, mode) {
176
+ //
177
+ // `setupFiles` is the one field that is added to rather than replaced (spec 39,
178
+ // criterion 2). The project's own entry is what raises its test database and
179
+ // reads its environment — epic-stack's `tests/setup/setup-test-env.ts` is why
180
+ // its 50 structural checks have a database to read — and replacing it would
181
+ // take that away from the only structural suite on the bench that works. Ours
182
+ // goes first: it exists to set things up before the first import of anything,
183
+ // and the project's own setup file opens with imports of the application.
184
+ function configSource(projectConfig, suitePaths, mode, setupFile) {
122
185
  // After the project's own `test`, never before it: ours has to win.
123
- const settings = `${mode === 'boot' ? 'globals: true, ' : ''}include: ${JSON.stringify(suitePaths)}`;
186
+ const settings = `${mode === 'boot' ? 'globals: true, ' : ''}${setupFile ? 'setupFiles, ' : ''}include: ${JSON.stringify(suitePaths)}`;
124
187
  const header = '// Written by the unitbob connector before each vitest run — do not edit.';
125
188
  if (!projectConfig) {
189
+ // Nothing to inherit, so nothing to concatenate — but the shared file is
190
+ // still the branch's, and a project with no config of its own is exactly
191
+ // the kind that needs it.
192
+ const alone = setupFile ? `const setupFiles = ${JSON.stringify([setupFile])};\n` : '';
126
193
  return `${header}
127
- export default { test: { ${settings} } };
194
+ ${alone}export default { test: { ${settings} } };
128
195
  `;
129
196
  }
130
197
  // Globals, because the probe lives in `.unitbob/structural/`: an
@@ -144,13 +211,20 @@ const { projects, workspace, ...test } = base.test ?? {};
144
211
  : `
145
212
  const test = base.test ?? {};
146
213
  `;
214
+ // `[].concat(x)` on purpose: vitest accepts `setupFiles` as an array or as a
215
+ // bare string, and most projects set neither. All three forms arrive here and
216
+ // all three have to come out an array, because a config that throws while it
217
+ // is being read takes the whole run with it.
218
+ const merge = setupFile
219
+ ? `const setupFiles = ${JSON.stringify([setupFile])}.concat(test.setupFiles ?? []);\n`
220
+ : '';
147
221
  return `${header}
148
222
  import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
149
223
 
150
224
  const base = typeof projectConfig === 'function'
151
225
  ? await projectConfig({ command: 'serve', mode: 'test' })
152
226
  : projectConfig;
153
- ${narrow}
227
+ ${narrow}${merge}
154
228
  export default { ...base, test: { ...test, ${settings} } };
155
229
  `;
156
230
  }
@@ -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,
package/dist/verbs/run.js CHANGED
@@ -4,7 +4,7 @@ import { placeProblem } from "../runner/place.js";
4
4
  import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
5
5
  import { validateStack } from "../runner/precheck.js";
6
6
  import { runRspecSuite } from "../runner/rspec.js";
7
- import { runVitestSuite } from "../runner/vitest.js";
7
+ import { runVitestSuite, testPathsOf } from "../runner/vitest.js";
8
8
  import { runPytestSuite } from "../runner/pytest.js";
9
9
  import { runBddSuite } from "../runner/bdd.js";
10
10
  import { enterUrl } from "../links.js";
@@ -147,8 +147,11 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
147
147
  // Every file of the branch, in the order the envelope carries them. A structural
148
148
  // branch is one file per assignment since spec one-place-per-rule, §6, and running only the main
149
149
  // one would execute a fraction of what the map says is guarded.
150
+ //
151
+ // Every file except the branch's shared setup file, which `testPathsOf` drops
152
+ // (spec 39): it is named in `setupFiles` instead of being collected from.
150
153
  function artifactPaths(file) {
151
- return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
154
+ return testPathsOf([file.path, ...(file.support_files ?? []).map((entry) => entry.path)]);
152
155
  }
153
156
  function suiteError(suiteDigest, message) {
154
157
  return {
@@ -1,10 +1,11 @@
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";
5
5
  import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
6
6
  import { validateStack } from "../runner/precheck.js";
7
7
  import { runBddSuite } from "../runner/bdd.js";
8
+ import { testPathsOf } from "../runner/vitest.js";
8
9
  import { runStructuralByRunner } from "./run.js";
9
10
  const OUTPUT_TAIL_CHARS = 4000;
10
11
  export async function runLocal(config, args = [], deps) {
@@ -81,20 +82,11 @@ function compareFailures(config, d, suiteKind, ran, before) {
81
82
  'Running it again unchanged prints this same line.\n');
82
83
  return true;
83
84
  }
84
- // Which branches to run. No argument runs every branch the request asked for
85
- // the same "one suite, one run" shape both recipes insist on, so the default
86
- // never teaches the habit the recipes forbid. A named branch is for the repair
87
- // loop, where re-running the finished peer is pure cost.
85
+ // Which branches to run: the ones named, or every branch the request asked for.
86
+ // The parse and the error live in `namedBranches`, shared with the publish side.
88
87
  function selectBranches(request, args) {
89
- const all = request.branches.map((branch) => branch.suite_kind);
90
- const named = args.filter((arg) => !arg.startsWith('-'));
91
- if (named.length === 0)
92
- return all;
93
- const unknown = named.filter((name) => !all.includes(name));
94
- if (unknown.length > 0) {
95
- throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
96
- }
97
- return named;
88
+ const named = namedBranches(request, args);
89
+ return named.length > 0 ? named : request.branches.map((branch) => branch.suite_kind);
98
90
  }
99
91
  // Non-null when the runner actually executed the branch. Everything else — no
100
92
  // entry, a declared `build_error`, a stack that cannot run it — is a branch that
@@ -259,7 +251,11 @@ function artifactPathsOf(output) {
259
251
  const rest = support
260
252
  .map((entry) => entry?.path)
261
253
  .filter((candidate) => typeof candidate === 'string' && candidate.length > 0);
262
- return [path, ...rest];
254
+ // Everything but the branch's shared setup file (spec 39): it is a support
255
+ // file so that it survives materialization, and it is named in `setupFiles`
256
+ // rather than handed over as a path to collect tests from. Literally the same
257
+ // filter the check flow uses, because both sides have to mean the one file.
258
+ return testPathsOf([path, ...rest]);
263
259
  }
264
260
  function branchRoot(config, suiteKind) {
265
261
  const request = readSuiteBuildRequest(config.projectRoot);
@@ -11,6 +11,7 @@ import { placeProblem } from "../runner/place.js";
11
11
  import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
12
12
  import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
13
13
  import { ToolchainUnavailableError } from "../runner/toolchain.js";
14
+ import { canPrepareBeforeImports, setupFileOf, STRUCTURAL_SETUP_FILE } from "../runner/vitest.js";
14
15
  import { probeBehavioralWorld } from "../runner/worldProbe.js";
15
16
  import { Wire } from "../wire.js";
16
17
  // The complete envelope for one branch, or null when this machine cannot
@@ -225,13 +226,52 @@ export async function suitePrepare(config, args = [], deps) {
225
226
  // and boots the application for itself, so asking with no structural branch in
226
227
  // the run would start Rails to answer a question nobody put — and a `broken`
227
228
  // answer would then report a branch this build never had.
229
+ //
230
+ // Spec 39 kept the sentry and moved the sentence. The probe asks its question
231
+ // twice: once before anything has been prepared for these files, and once
232
+ // after the coordinator has written the branch's shared setup file. Only the
233
+ // second answer costs a branch, because only the second one asks what the run
234
+ // will ask. Before the setup file exists the probe's question — "do these
235
+ // files load with nothing in front of them" — is stricter than the condition
236
+ // it stands in for, and `docs/adr/0001` forbids exactly that: the condition
237
+ // tested must equal the condition that makes a run impossible, never exceed
238
+ // it. Two bench projects were refused on that excess while their behavioral
239
+ // peers built and stayed green.
240
+ //
241
+ // The switch is the file on disk and nothing else. No flag and no mode: 32-6
242
+ // forbade them, and the connector has to ask this same question anyway to
243
+ // decide whether to name the file in `setupFiles`, so a second signal could
244
+ // only ever disagree with the first.
245
+ //
246
+ // And only on the stack where a preparation can actually be put in front of
247
+ // the imports — `canPrepareBeforeImports`, which is vitest and only vitest. On
248
+ // rspec and pytest nothing of ours runs before the branch's imports, so there
249
+ // the probe's question already *is* the run's question: no excess to correct,
250
+ // and a red answer costs the branch on the first run exactly as it has since
251
+ // 32-6. Waiting for a file those stacks will never have would have quietly
252
+ // reopened the funnel that spec closed.
253
+ //
254
+ // One thing this sign cannot see: a setup file left over from an earlier
255
+ // build. It comes back with the artifact and is materialized like any other
256
+ // file of the branch, so on a re-generation the probe sentences on its first
257
+ // ask instead of scouting. That is the honest reading — a preparation does
258
+ // exist and the probe went through it — and the way out is the same one the
259
+ // verdict already names: fix that file, run `suite-prepare` again. Telling the
260
+ // two apart would need a memory of which build wrote it, which is a mode by
261
+ // another name, and 32-6 forbade those.
228
262
  const bootNotices = [];
263
+ const bootAdvisories = [];
229
264
  const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
230
265
  if (structuralIndex !== -1) {
231
266
  const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
232
267
  if (boot.status === 'broken') {
233
- branches.splice(structuralIndex, 1);
234
- bootNotices.push(bootStop(boot, structuralRunner));
268
+ if (!canPrepareBeforeImports(structuralRunner) || setupFileOf(config.projectRoot)) {
269
+ branches.splice(structuralIndex, 1);
270
+ bootNotices.push(bootStop(boot, structuralRunner));
271
+ }
272
+ else {
273
+ bootAdvisories.push(bootAdvisory(boot, structuralRunner));
274
+ }
235
275
  }
236
276
  else {
237
277
  actual.stdout.write(bootFinding(boot, structuralRunner));
@@ -330,6 +370,18 @@ export async function suitePrepare(config, args = [], deps) {
330
370
  bootNotices.join('\n') +
331
371
  '\n');
332
372
  }
373
+ // The other half of the probe's answer (spec 39), and it needs a heading of
374
+ // its own: "was left out of this run" is true only of the verdict, and this
375
+ // branch was not left out of anything. It is being built, and what follows is
376
+ // the opening fact for whoever writes its preparation.
377
+ if (bootAdvisories.length > 0) {
378
+ actual.stdout.write('\nThe code-structure suite is still being built, and here is what its source files did on their own: ' +
379
+ `they did not load. Nothing has been put in front of them yet — that is what ${STRUCTURAL_SETUP_FILE} ` +
380
+ 'is for, and writing it comes before the fan-out. None of your own tests were opened; only the files ' +
381
+ 'the map named:\n' +
382
+ bootAdvisories.join('\n') +
383
+ '\n');
384
+ }
333
385
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
334
386
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
335
387
  if (setupNotices.length > 0) {
@@ -434,16 +486,42 @@ function bootFinding(boot, runner) {
434
486
  // its peer carries on, `request.json` is written, and the vibecoder comes away
435
487
  // with the guardrails that branch can still give rather than with nothing.
436
488
  //
489
+ // Reached only once the branch's shared setup file exists (spec 39). By then the
490
+ // probe has been asked through that preparation, so a red answer is the run's
491
+ // own answer and taking the branch is honest.
492
+ function bootStop(boot, runner) {
493
+ return bootReport(boot, runner, true);
494
+ }
495
+ // The same red answer, read before anything was prepared for these files (spec
496
+ // 39). Same facts, same words from the runner, same caveat — what differs is
497
+ // the step that follows, and that is the whole difference between a scout and a
498
+ // sentry. Nothing here is worded as a verdict on somebody's code, because on
499
+ // this run it is not one: the files were asked to load with nothing in front of
500
+ // them, and putting something in front of them is Unitbob's own work.
501
+ function bootAdvisory(boot, runner) {
502
+ return bootReport(boot, runner, false);
503
+ }
437
504
  // The two causes keep their separate next steps. An un-run `pip install` is not
438
505
  // somebody's bug and must not be worded as one; a module of theirs that raises
439
- // on import is theirs to fix and pointing at an install would waste their time.
440
- function bootStop(boot, runner) {
441
- const next = boot.cause === 'defect_in_code'
442
- ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
443
- : 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
506
+ // on import, asked with the preparation already in place, is theirs to fix and
507
+ // pointing at an install would waste their time.
508
+ //
509
+ // The environment cause keeps the same next step in both reports: a missing gem
510
+ // is not something a setup file can prepare its way around, and sending someone
511
+ // to write one would waste the round it costs.
512
+ function bootReport(boot, runner, prepared) {
513
+ const next = boot.cause !== 'defect_in_code'
514
+ ? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
444
515
  'it never writes to your project. Something outside that file is still missing here. Run the ' +
445
516
  'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
446
- 'then run `unitbob suite-prepare` again.';
517
+ 'then run `unitbob suite-prepare` again.'
518
+ : prepared
519
+ ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
520
+ : `This is what these files do with nothing in front of them, and the run will have ` +
521
+ `${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
522
+ 'first import — the environment variables the modules read, a loader registration, and only as a ' +
523
+ 'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
524
+ 'and this same question will be asked through it.';
447
525
  // `boot.message` is the runner's own words, indented but never paraphrased:
448
526
  // this is the line the vibecoder can paste into a search.
449
527
  return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
@@ -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",
3
+ "version": "0.7.6",
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.3 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.6 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
@@ -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