unitbob 0.7.1 → 0.7.3

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.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, resolve, sep } from 'node:path';
2
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
3
  import { graphNodes, methodNameOf } from "../surfaces/graph.js";
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
5
5
  import { surfacesPath } from "./mapBuild.js";
@@ -52,8 +52,7 @@ export function readPacketIndex(projectRoot) {
52
52
  // request, the workers are not, so a packet belongs to an entrypoint and two
53
53
  // entrypoints in one file share one packet.
54
54
  export function writeSuitePackets(projectRoot, request) {
55
- const targets = targetsOf(request);
56
- const lookup = buildLookup(projectRoot);
55
+ const targets = resolveTargets(projectRoot, request);
57
56
  const notes = new Map();
58
57
  // Regenerated whole, every run. A packet left over from a previous assignment
59
58
  // sits under an authoritative name with nothing on it saying how old it is.
@@ -63,15 +62,11 @@ export function writeSuitePackets(projectRoot, request) {
63
62
  const written = new Map();
64
63
  const refused = new Map();
65
64
  for (const target of targets) {
66
- const sourceFile = lookup(target.entrypoint);
65
+ const sourceFile = target.source_file;
67
66
  if (!sourceFile) {
68
- target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
69
67
  count(notes, 'did not resolve to one file');
70
68
  continue;
71
69
  }
72
- // The path travels even when the contents do not: a worker told which file
73
- // to open has still been saved the search.
74
- target.source_file = sourceFile;
75
70
  // Two entrypoints in one file get one packet, written once, referenced twice.
76
71
  if (!written.has(sourceFile) && !refused.has(sourceFile)) {
77
72
  const copied = copyPacket(projectRoot, sourceFile);
@@ -105,6 +100,63 @@ export function writeSuitePackets(projectRoot, request) {
105
100
  notes: [...notes].map(([note, count]) => `${count} × ${note}`),
106
101
  };
107
102
  }
103
+ // Every entrypoint the request names, each with the file behind it when one file
104
+ // answers. The one place a name is turned into a path: `writeSuitePackets` copies
105
+ // what comes out of here, and `structuralSourceFiles` only reads it. Two
106
+ // resolvers would be two chances to disagree about which file serves a name, and
107
+ // the boot check and the packets have to be talking about the same files.
108
+ //
109
+ // The words for a name nothing answered to are set here rather than by the
110
+ // caller, because they are part of the answer.
111
+ function resolveTargets(projectRoot, request) {
112
+ const targets = targetsOf(request);
113
+ const lookup = buildLookup(projectRoot);
114
+ for (const target of targets) {
115
+ const sourceFile = lookup(target.entrypoint);
116
+ // The path travels even when the contents do not: a worker told which file
117
+ // to open has still been saved the search.
118
+ if (sourceFile)
119
+ target.source_file = sourceFile;
120
+ else
121
+ target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
122
+ }
123
+ return targets;
124
+ }
125
+ // Spec 38, criterion 2. The source files the structural guardrails of this
126
+ // request will import — the list the boot check loads instead of the project's
127
+ // own tests. Computed from two artifacts already on this machine, so it costs no
128
+ // token and no network call.
129
+ //
130
+ // A name nothing answered to is simply absent. That gap is already a visible
131
+ // number — "the source behind N of M entrypoints" — and turning it into a
132
+ // refusal would stop a run over a hole in the graph (see В3 of the requirements).
133
+ export function structuralSourceFiles(projectRoot, request) {
134
+ const files = [];
135
+ for (const target of resolveTargets(projectRoot, request)) {
136
+ if (target.branch !== 'structural' || !target.source_file)
137
+ continue;
138
+ // Only a path that is plainly inside this checkout, and only a file that is
139
+ // really there. graph.json is written by a tool, not by us, and what comes
140
+ // out of here becomes an `import` statement — so an absolute path or a `..`
141
+ // would load something this project does not contain, and a name the graph
142
+ // kept after the file moved would fail to resolve.
143
+ //
144
+ // That last one is the whole reason this check exists. A stale graph entry
145
+ // reaches the runner as "Failed to resolve import" or "FileNotFoundError",
146
+ // which reads as a dependency that is not installed or a module that will
147
+ // not load — and the vibecoder would be sent to repair their code over a
148
+ // hole in *our* map. `writeSuitePackets` already refuses all three, in
149
+ // words, one file further down; here silence is right, because nothing reads
150
+ // this list but the probe.
151
+ if (isAbsolute(target.source_file) || target.source_file.split('/').includes('..'))
152
+ continue;
153
+ if (!existsSync(join(projectRoot, target.source_file)))
154
+ continue;
155
+ if (!files.includes(target.source_file))
156
+ files.push(target.source_file);
157
+ }
158
+ return files;
159
+ }
108
160
  // The two names a request carries. Structural interfaces name methods
109
161
  // (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
110
162
  // Both are entrypoints, both resolve to a file, and both produce the same kind
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
3
3
  import { dirname, join } from 'node:path';
4
4
  import { detectStructuralRunner } from "../runner/precheck.js";
5
5
  import { assertUnitbobPath } from "./artifactPath.js";
6
- import { branchWidth } from "./fanOut.js";
7
6
  export function workerPlanPath(projectRoot) {
8
7
  return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
9
8
  }
@@ -233,62 +232,9 @@ export function validateWorkerPlanFiles(projectRoot) {
233
232
  }
234
233
  for (const id of assigned.filter((id) => !expected.includes(id)))
235
234
  errors.push(`${branch}: capability ${id} was not assigned by the request`);
236
- const cases = items.reduce((sum, item) => sum + (Array.isArray(item.planned_cases) ? item.planned_cases.length : 0), 0);
237
- errors.push(...fanOutErrors(branch, items.length, cases));
238
235
  }
239
236
  return errors;
240
237
  }
241
- // Which assigned ids each branch of a plan actually took. Spec 37-3 weighs a
242
- // plan against the work it took on, never against the whole assignment: the
243
- // packets are built before anybody chooses a scope, and since criterion 2 both
244
- // branches may be narrowed, so the two are different jobs.
245
- export function takenIds(plan) {
246
- const taken = new Map();
247
- for (const item of Array.isArray(plan?.workers) ? plan.workers : []) {
248
- if (!item || typeof item !== 'object' || Array.isArray(item) || !isNonEmptyString(item.branch))
249
- continue;
250
- const ids = taken.get(item.branch) ?? new Set();
251
- for (const id of Array.isArray(item.capability_ids) ? item.capability_ids : []) {
252
- if (isNonEmptyString(id))
253
- ids.add(id);
254
- }
255
- taken.set(item.branch, ids);
256
- }
257
- return taken;
258
- }
259
- // Spec 37-3, criterion 1. Two things are checked, and only when the packets
260
- // exist to measure against: that the plan says what it divided, and that it did
261
- // not divide work that already fits in one worker.
262
- //
263
- // A run without packets has no measured work, and a rule with no measurement
264
- // behind it refuses nobody — the same policy the packets themselves follow.
265
- function fanOutErrors(branch, planned, cases) {
266
- const width = branchWidth(branch, cases);
267
- if (!width || planned === 0)
268
- return [];
269
- // A band, not a ceiling. Both ends are expensive and neither is safe: on the
270
- // 2026-08-24 bench fifteen workers cost 28% more than the cheapest width, and
271
- // one worker cost 85% more — and a single worker on the behavioral branch
272
- // would have run 216 turns into a 150-turn fuse. Anywhere inside the band is
273
- // within about a tenth of the cheapest, so this refuses only what costs.
274
- //
275
- // Nothing is restated in the plan to prove the coordinator did this division.
276
- // Both halves are already in the file — the cases in `planned_cases`, the
277
- // width as the length of the branch's slice list — so a `fan_out` record would
278
- // be the same two numbers copied by hand, which is what spec 37-1 refused for
279
- // `packet_paths`. The gate is the guarantee; `accept-worker-plan` prints the
280
- // derivation next to it.
281
- if (planned >= width.fewest && planned <= width.most)
282
- return [];
283
- const way = planned > width.most ? 'wide' : 'narrow';
284
- return [
285
- `${branch}: ${planned} slices for ${cases} planned cases is too ${way} — ` +
286
- `${width.fewest}-${width.most} is the band, ${width.workers} the cheapest. ` +
287
- `Each slice costs a whole opening context (26,065 tokens on that bench, re-read every turn), ` +
288
- `and each slice fewer makes one conversation longer, which costs with the square of its ` +
289
- `length. At ${width.workers} a worker of this branch runs about ${width.turns_each} turns.`,
290
- ];
291
- }
292
238
  function assignmentIds(value) {
293
239
  const assignment = value;
294
240
  if (Array.isArray(assignment?.capabilities)) {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { executable } from "../proc.js";
4
4
  import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
@@ -6,6 +6,7 @@ import { commandFileOnHost, locateRunner } from "./toolchain.js";
6
6
  import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
7
7
  import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
8
8
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
9
+ import { VITEST_BOOT_CONFIG_FILE, vitestBootConfigSource } from "./vitest.js";
9
10
  const defaultDeps = {
10
11
  runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: PROVISION_TIMEOUT_MS, env: options.env }),
11
12
  };
@@ -18,23 +19,29 @@ const DETAIL_LIMIT = 4_000;
18
19
  export const SIGNAL_STRENGTH = {
19
20
  rspec: 'Full signal on this stack: the check loads the very file the suite starts from, ' +
20
21
  'so whatever stops one stops the other.',
21
- pytest: 'Partial signal on this stack: collection only imports what the tests import, so code no ' +
22
- 'test reaches was not checkedand it imports the whole test tree, so a failure may belong ' +
23
- "to one of the project's own tests rather than to the suite that was about to be written.",
24
- vitest: 'Weak signal on this stack: only test files are parsed and imported, and all of them — so a ' +
25
- "failure may belong to one of the project's own tests. Type errors are not checked at all " +
26
- '(vite and esbuild strip types without checking them), and a project with no tests yields ' +
27
- 'no signal.',
22
+ pytest: 'Full signal on the files it names: the check imports the very modules the guardrails of this ' +
23
+ "branch will import, and nothing else none of this project's own tests are opened. Code that " +
24
+ 'no guardrail imports was not looked at.',
25
+ vitest: 'Full signal on the files it names: the check imports the very modules the guardrails of this ' +
26
+ "branch will import, through this project's own vite configuration, and nothing else none of " +
27
+ "the project's own tests are opened. Types are not checked (vite strips them without checking " +
28
+ 'them), and code no guardrail imports was not looked at.',
28
29
  };
29
30
  // Does the suite for this stack get off the ground? One attempt, one answer.
30
- export async function bootCheck(projectRoot, runner, deps = defaultDeps) {
31
+ //
32
+ // `sourceFiles` are the project's own source files this branch's guardrails will
33
+ // import, resolved from the map (spec 38). They are what gets loaded on the two
34
+ // stacks that have no boot file of their own — never the project's test tree.
35
+ // Ruby ignores the list: its helper is the suite's real first line and already
36
+ // pulls the application up behind it.
37
+ export async function bootCheck(projectRoot, runner, sourceFiles, deps = defaultDeps) {
31
38
  switch (runner) {
32
39
  case 'rspec':
33
40
  return rubyBootCheck(projectRoot, deps);
34
41
  case 'pytest':
35
- return pytestBootCheck(projectRoot, deps);
42
+ return pytestBootCheck(projectRoot, sourceFiles, deps);
36
43
  case 'vitest':
37
- return vitestBootCheck(projectRoot, deps);
44
+ return vitestBootCheck(projectRoot, sourceFiles, deps);
38
45
  default:
39
46
  return { status: 'not_checked', reason: 'no_runner' };
40
47
  }
@@ -80,7 +87,7 @@ async function loadRubyHelper(projectRoot, helper, deps) {
80
87
  // executable makes `spawn` throw, `attempt` return null, and the answer come
81
88
  // back `no_runner`: "no runner available to load your suite with", said to
82
89
  // someone whose bundler is installed and working. That is the mistake
83
- // `runner_too_old` and `runner_could_not_answer` were added to stop making,
90
+ // `runner_could_not_answer` was added to stop making,
84
91
  // and the global `bundle` was standing right there the whole time.
85
92
  const command = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
86
93
  // When Unitbob installed rspec-rails for itself, the gems this helper needs
@@ -106,9 +113,11 @@ async function loadRubyHelper(projectRoot, helper, deps) {
106
113
  // suite failing to start.
107
114
  (result) => (result.code === 0 ? 'ok' : 'broken'));
108
115
  }
109
- // Python: `pytest --collect-only` imports every test module, which is what the
110
- // run would do first. It sees import errors in code the tests reach, and only
111
- // there recorded as a limitation rather than dressed up as completeness.
116
+ // Python: run one test file of our own, which imports the modules the
117
+ // guardrails will import. Not `--collect-only`, and not the project's test tree:
118
+ // what the run would do first is import *our* suite's targets, and asking pytest
119
+ // to collect everything it can find asks about files this product never touches
120
+ // (spec 38, criterion 1).
112
121
  //
113
122
  // `-c` with an empty-addopts config, exactly as `runPytestSuite` does and for
114
123
  // exactly its reason: the project's own `addopts` (`--cov`, `-n auto`) must not
@@ -117,7 +126,9 @@ async function loadRubyHelper(projectRoot, helper, deps) {
117
126
  // a `--cov` flag. The run path had already solved this; the check had not
118
127
  // inherited the solution, which also made it *stricter* than the thing it
119
128
  // predicts, the one rule this whole check is built on.
120
- async function pytestBootCheck(projectRoot, deps) {
129
+ async function pytestBootCheck(projectRoot, sourceFiles, deps) {
130
+ if (sourceFiles.length === 0)
131
+ return { status: 'not_checked', reason: 'nothing_to_load' };
121
132
  // Its own directory, rather than relying on `materializeHelper` having run
122
133
  // first: this check must not fail because a different step was skipped.
123
134
  mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
@@ -139,13 +150,19 @@ async function pytestBootCheck(projectRoot, deps) {
139
150
  { command: 'python3', args: ['-m', 'pytest'], env: undefined },
140
151
  { command: 'python', args: ['-m', 'pytest'], env: undefined },
141
152
  ];
142
- for (const candidate of candidates) {
143
- const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--collect-only', '-q'], { cwd: projectRoot, env: candidate.env });
144
- if (result === null)
145
- continue; // this interpreter is not on the machine
146
- return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
147
- }
148
- return { status: 'not_checked', reason: 'no_runner' };
153
+ return withProbe(projectRoot, { [PYTEST_PROBE_FILE]: pytestProbeSource(sourceFiles) }, async () => {
154
+ for (const candidate of candidates) {
155
+ // The probe's path is passed explicitly, and it has to be: `.unitbob` is a
156
+ // hidden directory, which pytest's default `norecursedirs` skips. Without
157
+ // the path it would collect the project's tests and not ours — the exact
158
+ // mistake this spec removes.
159
+ const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--rootdir', '.', PYTEST_PROBE_FILE, '-q'], { cwd: projectRoot, env: candidate.env });
160
+ if (result === null)
161
+ continue; // this interpreter is not on the machine
162
+ return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
163
+ }
164
+ return { status: 'not_checked', reason: 'no_runner' };
165
+ });
149
166
  }
150
167
  // pytest's own exit vocabulary, used rather than "zero or not". The distinction
151
168
  // that matters is between "your code did not load" and "pytest itself could not
@@ -155,8 +172,9 @@ function pytestVerdict(code) {
155
172
  // unreachable — but "no exit code" can only ever mean "we learned nothing".
156
173
  if (code === null)
157
174
  return 'runner_could_not_answer';
158
- // 5 — collected nothing. A project that came to Unitbob *for* tests is the
159
- // typical customer, not a defect.
175
+ // 5 — collected nothing. Since spec 38 the only file offered for collection is
176
+ // our own probe, so this now means "our probe was not collected", not "this
177
+ // project has no tests". Still not a verdict on the code either way.
160
178
  if (code === 5)
161
179
  return 'nothing_to_load';
162
180
  // 3 — internal error, 4 — bad usage. Both are about the invocation, not the
@@ -165,16 +183,34 @@ function pytestVerdict(code) {
165
183
  return 'runner_could_not_answer';
166
184
  return code === 0 ? 'ok' : 'broken';
167
185
  }
168
- // JS/TS: `vitest list` parses and imports the test files, which is the closest
169
- // thing this stack has to a boot.
186
+ // JS/TS: run one test file of our own, which imports the source files the
187
+ // guardrails will import.
188
+ //
189
+ // It used to be `vitest list`, which parses and imports *every* test file the
190
+ // project has. That answered about somebody else's tests: a jest project came
191
+ // back "found a defect that stops your test suite from starting" because its own
192
+ // suites, green under jest, do not define `describe` under vitest. We do not
193
+ // need the project's tests — we write our own — so we stopped opening them
194
+ // (spec 38, criterion 1).
195
+ //
196
+ // Why a test file rather than a plain import: a test runner has no "load this
197
+ // module and tell me if it exploded" command, and a bare `node` cannot stand in,
198
+ // because TypeScript, JSX, path aliases and bundler plugins are exactly what
199
+ // vite resolves from the project's own config. So the probe is a legal vitest
200
+ // test that asserts nothing and imports everything named. Ruby has done the same
201
+ // since spec 29, through `unitbob_helper.rb`.
170
202
  //
171
203
  // `tsc --noEmit` is deliberately not used. It answers a different question —
172
204
  // are the types sound — and a project with a hundred type errors runs perfectly
173
205
  // well, because vite, esbuild and tsx strip types without checking them. Type
174
206
  // errors accumulate for years in healthy codebases; calling that "broken" would
175
207
  // turn away the majority. A file that is genuinely unparseable is caught here
176
- // anyway, since `vitest list` has to parse it.
177
- async function vitestBootCheck(projectRoot, deps) {
208
+ // anyway, since the probe's import has to parse it.
209
+ async function vitestBootCheck(projectRoot, sourceFiles, deps) {
210
+ // Nothing the map resolved to a file, so there is nothing to import. That is a
211
+ // hole in the graph, not a broken application, and the run carries on.
212
+ if (sourceFiles.length === 0)
213
+ return { status: 'not_checked', reason: 'nothing_to_load' };
178
214
  // A sidecar vitest counts as installed: it is ours, it is on disk, and it is
179
215
  // the one the run will spawn. What stays out is `npx`, for the reason below.
180
216
  const local = locateRunner(projectRoot, 'vitest')?.command ?? 'node_modules/.bin/vitest';
@@ -183,52 +219,107 @@ async function vitestBootCheck(projectRoot, deps) {
183
219
  // user's project is not this check's business.
184
220
  //
185
221
  // `executable`, not `existsSync`, and there is no fallback to go to: an
186
- // unrunnable binary sends `spawn` into EACCES, `supportsList` reads that as
187
- // "cannot be asked", and the answer came back `runner_too_old` a positive
188
- // falsehood about a version nobody looked at. `no_runner` is the honest one
189
- // here: there is no vitest this check can invoke.
222
+ // unrunnable binary sends `spawn` into EACCES and there is nothing left to
223
+ // ask. `no_runner` is the honest answer: there is no vitest this check can
224
+ // invoke.
190
225
  if (!executable(commandFileOnHost(projectRoot, local)))
191
226
  return { status: 'not_checked', reason: 'no_runner' };
192
- // `list` is a subcommand only from Vitest 2.1. Older versions read it as a
193
- // *filename filter* and go on to run whatever it matches, which was measured
194
- // doing two unhelpful things: reporting "No test files found" on a project
195
- // that plainly has tests, and when a file happened to match — starting watch
196
- // mode and hanging until the timeout killed it, two minutes for nothing.
197
- //
198
- // Neither is `broken`, so no healthy project was ever refused. But a check
199
- // that quietly answers about the wrong thing is worse than one that says it
200
- // did not run, so ask the version first and decline outright when the
201
- // subcommand does not exist.
202
- // Not `no_runner`: vitest is installed and works, it simply cannot be asked
203
- // this question. Telling someone "no runner available" while it sits in their
204
- // node_modules sends them to fix the wrong thing.
205
- if (!(await supportsList(projectRoot, local, deps))) {
206
- return { status: 'not_checked', reason: 'runner_too_old' };
207
- }
208
- const result = await attempt(deps, local, ['list'], { cwd: projectRoot });
209
- return classify(projectRoot, 'vitest', result, (proc) => {
210
- if (proc.code === 0)
211
- return 'ok';
212
- if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
213
- return 'nothing_to_load';
214
- return 'broken';
227
+ return withProbe(projectRoot, {
228
+ [VITEST_PROBE_FILE]: vitestProbeSource(sourceFiles),
229
+ [VITEST_BOOT_CONFIG_FILE]: vitestBootConfigSource(projectRoot, VITEST_PROBE_FILE),
230
+ }, async () => {
231
+ // `run`, which every version of vitest has. The version probe that used to
232
+ // stand here existed only for the `list` subcommand, which arrived in 2.1 —
233
+ // with it goes the `runner_too_old` answer, and with that the case where a
234
+ // perfectly good runner was declined for its age.
235
+ const result = await attempt(deps, local, ['run', '--config', VITEST_BOOT_CONFIG_FILE], { cwd: projectRoot });
236
+ return classify(projectRoot, 'vitest', result, (proc) => {
237
+ if (proc.code === 0)
238
+ return 'ok';
239
+ // Our own probe was not collected. It says nothing about the project's
240
+ // code, so it must not read as a verdict on it.
241
+ if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
242
+ return 'nothing_to_load';
243
+ return 'broken';
244
+ });
215
245
  });
216
246
  }
217
- // The first Vitest that has a `list` subcommand. Below this it is a filter.
218
- const VITEST_LIST_FROM = { major: 2, minor: 1 };
219
- // `vitest --version` prints e.g. `vitest/1.6.0 darwin-arm64 node-v25.2.1`. A
220
- // version we cannot read is treated as unsupported: guessing wrong here costs
221
- // either a silent non-answer or a two-minute hang, and both are worse than
222
- // saying plainly that the check did not run.
223
- async function supportsList(projectRoot, binary, deps) {
224
- const result = await attempt(deps, binary, ['--version'], { cwd: projectRoot });
225
- if (!result || result.code !== 0)
226
- return false;
227
- const found = /vitest\/(\d+)\.(\d+)/i.exec(`${result.stdout}\n${result.stderr}`);
228
- if (!found)
229
- return false;
230
- const [major, minor] = [Number(found[1]), Number(found[2])];
231
- return major > VITEST_LIST_FROM.major || (major === VITEST_LIST_FROM.major && minor >= VITEST_LIST_FROM.minor);
247
+ // The probe: one test that asserts nothing and imports everything the map named.
248
+ //
249
+ // The vitest probe lives beside the generated suite because that is where the
250
+ // suite's own imports will resolve from; `materializeGuardrails` wipes that
251
+ // directory before writing a suite, and by then the probe is long gone.
252
+ const VITEST_PROBE_FILE = `${GUARDRAILS_DIR}/__unitbob_boot.test.mjs`;
253
+ // `test_` first, on purpose: pytest collects a file by that prefix whatever the
254
+ // project's own `python_files` setting says.
255
+ const PYTEST_PROBE_FILE = `${GUARDRAILS_DIR}/test_unitbob_boot.py`;
256
+ // Every file this check puts in somebody's project, written together and removed
257
+ // together whatever happens — the way `worldProbe` treats its own. One of them
258
+ // left behind would be collected by the project's next test run, and changing
259
+ // what that run does is not ours to do.
260
+ //
261
+ // Both stacks go through here, so the write and the removal cannot drift apart
262
+ // on one of them.
263
+ async function withProbe(projectRoot, files, ask) {
264
+ for (const [relativePath, source] of Object.entries(files)) {
265
+ const path = join(projectRoot, relativePath);
266
+ mkdirSync(dirname(path), { recursive: true });
267
+ writeFileSync(path, source);
268
+ }
269
+ try {
270
+ return await ask();
271
+ }
272
+ finally {
273
+ for (const relativePath of Object.keys(files))
274
+ rmSync(join(projectRoot, relativePath), { force: true });
275
+ }
276
+ }
277
+ // Relative from the probe, which sits two levels down (`.unitbob/structural/`).
278
+ // The empty test is not decoration: vitest fails a file that declares none, and
279
+ // `globals: true` in the config we write is what lets it be named without an
280
+ // import (see `writeVitestBootConfig`).
281
+ function vitestProbeSource(sourceFiles) {
282
+ const imports = sourceFiles.map((file) => `import ${JSON.stringify(`../../${file}`)};`).join('\n');
283
+ return `// Written by the unitbob connector before the boot check — do not edit.
284
+ ${imports}
285
+
286
+ test('the modules our guardrails import all load', () => {});
287
+ `;
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.
296
+ function pytestProbeSource(sourceFiles) {
297
+ return `# Written by the unitbob connector before the boot check — do not edit.
298
+ import importlib.util
299
+ import pathlib
300
+ import sys
301
+
302
+ ROOT = pathlib.Path(__file__).resolve().parents[2]
303
+ sys.path.insert(0, str(ROOT))
304
+ TARGETS = ${JSON.stringify(sourceFiles)}
305
+
306
+
307
+ def test_the_modules_our_guardrails_import_all_load():
308
+ for rel in TARGETS:
309
+ # A path we cannot build a module out of is our resolution falling
310
+ # short, never this project's code, so it is passed over in silence.
311
+ # Blaming somebody's application for a name our own map handed us is
312
+ # the whole mistake this check was rewritten to stop making.
313
+ if not rel.endswith(".py"):
314
+ continue
315
+ 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)
322
+ `;
232
323
  }
233
324
  // Runs one command, turning "this binary is not on the machine" into null (the
234
325
  // caller decides whether that means `no_runner` or "try the next interpreter")
@@ -262,12 +262,19 @@ function vitestPrecheck(projectRoot, deps) {
262
262
  const hasVitest = sidecarProvides(projectRoot, 'vitest', deps) ||
263
263
  /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
264
264
  existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
265
+ // What the project runs its own tests with is not asked here and is not a
266
+ // reason to stop (spec 38): we never open those tests, so jest, mocha or
267
+ // nothing at all is none of our business. The only question is whether there
268
+ // is a vitest to run *our* guardrails with — and suite-prepare installs one
269
+ // under `.unitbob/` when there is not, which is why this sentence is not
270
+ // reached on the build path at all. It stands for `run` and `run-local`,
271
+ // where nothing has been provisioned yet.
265
272
  if (!hasVitest) {
266
273
  return {
267
274
  ok: false,
268
- message: 'JS/TS guardrails require Vitest (Jest is not supported in MVP v2), and vitest was not ' +
269
- "found in this project's package.json or node_modules. Offer the user to add it " +
270
- '(`npm i -D vitest`); change dependencies only with their consent, then retry.',
275
+ message: 'Unitbob runs its own guardrails with Vitest, and no vitest was found in this project\'s ' +
276
+ 'package.json or node_modules nor one installed by Unitbob under `.unitbob/`. Offer the ' +
277
+ 'user to add it (`npm i -D vitest`); change dependencies only with their consent, then retry.',
271
278
  };
272
279
  }
273
280
  return { ok: true };
@@ -10,6 +10,12 @@ export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
10
10
  // the project has its own config. Connector-owned: never stored in Rails, never
11
11
  // part of the suite digest.
12
12
  export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
13
+ // The boot check's own config (spec 38), kept in a separate file from the one
14
+ // above rather than shared with it. The two answer different questions and are
15
+ // alive at different moments — the run's config names the branch's suite files
16
+ // and is rewritten immediately before every run, so a boot check that reused it
17
+ // would either be overwritten or leave the run pointing at a probe.
18
+ export const VITEST_BOOT_CONFIG_FILE = join('.unitbob', 'vitest.boot.config.mjs');
13
19
  // The project configs we inherit from, most specific first. Vitest reads a
14
20
  // project's own config even when we pass `--config`, so we must merge ours with
15
21
  // it rather than replace it (plugins, path aliases and resolve settings the
@@ -79,12 +85,21 @@ export async function runVitestSuite(projectRoot, suitePaths) {
79
85
  // the branch's files have to be in `include` or nothing is collected, and the
80
86
  // names a worker gives its slice are not something to bet a whole run on.
81
87
  function writeMergedConfig(projectRoot, suitePaths) {
82
- const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
83
88
  const path = join(projectRoot, VITEST_CONFIG_FILE);
84
89
  mkdirSync(dirname(path), { recursive: true });
85
- writeFileSync(path, configSource(projectConfig, suitePaths));
90
+ writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run'));
86
91
  return ['--config', VITEST_CONFIG_FILE];
87
92
  }
93
+ // The same config, shaped for the boot check's probe (spec 38). Returned as text
94
+ // rather than written, because the boot check owns the lifetime of every file it
95
+ // puts in the project: it writes them together and removes them together, and a
96
+ // writer here would take half of that away from the one place that can see it.
97
+ export function vitestBootConfigSource(projectRoot, probePath) {
98
+ return configSource(projectConfigOf(projectRoot), [probePath], 'boot');
99
+ }
100
+ function projectConfigOf(projectRoot) {
101
+ return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
102
+ }
88
103
  // The .unitbob/ config sits one level below the project root, so the project
89
104
  // config is a `../` import. A function-form config is resolved first, and
90
105
  // everything the project set — plugins, aliases, setup files, environment — is
@@ -101,21 +116,41 @@ function writeMergedConfig(projectRoot, suitePaths) {
101
116
  // ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
102
117
  // projects the sidecar exists for. A spread does the same job with no import,
103
118
  // and `defineConfig` is a typing helper that buys a generated file nothing.
104
- function configSource(projectConfig, suitePaths) {
105
- const include = `include: ${JSON.stringify(suitePaths)}`;
119
+ // `mode` is the boot check's two differences from the run, and they go together:
120
+ // globals on, and a workspace refused. See `vitestBootConfigSource`.
121
+ function configSource(projectConfig, suitePaths, mode) {
122
+ // After the project's own `test`, never before it: ours has to win.
123
+ const settings = `${mode === 'boot' ? 'globals: true, ' : ''}include: ${JSON.stringify(suitePaths)}`;
106
124
  const header = '// Written by the unitbob connector before each vitest run — do not edit.';
107
125
  if (!projectConfig) {
108
126
  return `${header}
109
- export default { test: { ${include} } };
127
+ export default { test: { ${settings} } };
110
128
  `;
111
129
  }
130
+ // Globals, because the probe lives in `.unitbob/structural/`: an
131
+ // `import { test } from 'vitest'` there resolves by walking up from that
132
+ // directory, which never reaches `.unitbob/runners/node_modules` — where the
133
+ // vitest we installed for a project that had none is kept. We write this file,
134
+ // so we turn the globals on and the probe needs no import at all.
135
+ //
136
+ // A workspace is dropped for a harder reason. With `test.projects` (or the
137
+ // older `test.workspace`) set, the root `include` stops deciding anything and
138
+ // vitest runs each sub-project's own test files — which would open the
139
+ // project's tests again through the back door, the one thing spec 38 removes.
140
+ const narrow = mode === 'boot'
141
+ ? `
142
+ const { projects, workspace, ...test } = base.test ?? {};
143
+ `
144
+ : `
145
+ const test = base.test ?? {};
146
+ `;
112
147
  return `${header}
113
148
  import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
114
149
 
115
150
  const base = typeof projectConfig === 'function'
116
151
  ? await projectConfig({ command: 'serve', mode: 'test' })
117
152
  : projectConfig;
118
-
119
- export default { ...base, test: { ...(base.test ?? {}), ${include} } };
153
+ ${narrow}
154
+ export default { ...base, test: { ...test, ${settings} } };
120
155
  `;
121
156
  }
Binary file
@@ -1,8 +1,7 @@
1
1
  import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
- import { branchWorkloads, workloadLine } from "../files/fanOut.js";
5
- import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
4
+ import { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
6
5
  import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
7
6
  import { bddStepLoading } from "../runner/bdd.js";
8
7
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
@@ -68,7 +67,7 @@ export async function suitePrepare(config, args = [], deps) {
68
67
  getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
69
68
  precheck: anyStackPrecheck,
70
69
  confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
71
- bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
70
+ bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
72
71
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
73
72
  ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
74
73
  worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
@@ -131,30 +130,10 @@ export async function suitePrepare(config, args = [], deps) {
131
130
  throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
132
131
  }
133
132
  }
134
- // Spec 32-6. Before anything is fetched or written, find out whether the suite
135
- // would get off the ground at all. It runs here, after the boot helper exists
136
- // and before the network, so a project whose suite cannot start costs one
137
- // command instead of a full generation.
138
- //
139
- // There is no `--on-broken-boot` flag and no mode. The decision is not a
140
- // policy we could reasonably let a user set — it follows from the fact: we
141
- // tried to load the thing the suite starts with, it did not load, therefore
142
- // not one test would reach its first assertion. Debugging generation against a
143
- // knowingly dead project is our problem, not the vibecoder's.
144
133
  // The stack the precheck just identified, rather than a second detection of
145
134
  // the same thing: on Python that would shell out to pytest all over again.
135
+ // Used further down, where the boot check now runs.
146
136
  const structuralRunner = check.runner ?? null;
147
- const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
148
- if (boot.status === 'broken') {
149
- // "Your environment is not ready" is the one of the two that a container can
150
- // answer — the toolchain is missing here and may be sitting in one. A defect
151
- // found in the code is a defect wherever it runs, and offering a container
152
- // for it would be the noise this spec is trying to remove.
153
- throw boot.cause === 'environment_not_ready'
154
- ? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
155
- : new Error(bootFinding(boot, structuralRunner));
156
- }
157
- actual.stdout.write(bootFinding(boot, structuralRunner));
158
137
  const packets = await actual.getSuitePacketsBatch();
159
138
  // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
160
139
  // A `fixable` outcome (no package manager available to install the runner) is an infrastructure
@@ -228,8 +207,43 @@ export async function suitePrepare(config, args = [], deps) {
228
207
  else
229
208
  blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
230
209
  }
210
+ // Spec 32-6, moved down by spec 38. Before anything is written and before a
211
+ // single token is spent, find out whether the code this branch's guardrails
212
+ // will import actually loads.
213
+ //
214
+ // Here rather than at the top of the verb, because here the list of those
215
+ // files exists: it is resolved from the branches that were just assembled,
216
+ // out of this machine's own graph and route inventory. Nothing is lost by
217
+ // waiting — `getSuitePacketsBatch` is a GET that writes nothing and costs
218
+ // nothing, and the generator, which is where the money starts, does not run
219
+ // until `request.json` is written below.
220
+ //
221
+ // What is gained is that a failure here takes one branch and not the run. The
222
+ // behavioral peer has a runner of its own, touches none of these files, and
223
+ // used to die of a verdict that was never about it.
224
+ // Only when there is a branch to check. Ruby's helper ignores the file list
225
+ // and boots the application for itself, so asking with no structural branch in
226
+ // the run would start Rails to answer a question nobody put — and a `broken`
227
+ // answer would then report a branch this build never had.
228
+ const bootNotices = [];
229
+ const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
230
+ if (structuralIndex !== -1) {
231
+ const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
232
+ if (boot.status === 'broken') {
233
+ branches.splice(structuralIndex, 1);
234
+ bootNotices.push(bootStop(boot, structuralRunner));
235
+ }
236
+ else {
237
+ actual.stdout.write(bootFinding(boot, structuralRunner));
238
+ }
239
+ }
240
+ // Every reason a branch is not here, in one place. A run can lose its last
241
+ // branch to any of the three and the reader needs the one that applies to
242
+ // them — printing only the envelope reasons left a jest project reading an
243
+ // empty list under "no suite branch can be built".
231
244
  if (branches.length === 0) {
232
- throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
245
+ const why = [...bootNotices, ...blockedNotices, ...fixableNotices].join('\n');
246
+ throw new Error(`No suite branch can be built this run:\n${why}\nNothing was written and nothing was uploaded.`);
233
247
  }
234
248
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
235
249
  // Spec 37-1. The assignment names entrypoints; the packets are the files
@@ -280,7 +294,6 @@ export async function suitePrepare(config, args = [], deps) {
280
294
  'the request that was just replaced.\n');
281
295
  }
282
296
  actual.stdout.write(packetNotice(request.project_root, sourcePackets));
283
- actual.stdout.write(workloadNotice(request.project_root));
284
297
  actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
285
298
  `write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
286
299
  `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
@@ -308,6 +321,15 @@ export async function suitePrepare(config, args = [], deps) {
308
321
  'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
309
322
  'them, and this connector will not be able to run the branch either.\n');
310
323
  }
324
+ // Same shape as the two notices below it, and the same rule: the branch that
325
+ // could not be prepared drops out, its peer is untouched, and the reason is
326
+ // printed rather than swallowed.
327
+ if (bootNotices.length > 0) {
328
+ actual.stdout.write('\nThe code-structure suite was left out of this run — the source files its guardrails would ' +
329
+ 'import did not load. None of your own tests were opened; only the files the map named:\n' +
330
+ bootNotices.join('\n') +
331
+ '\n');
332
+ }
311
333
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
312
334
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
313
335
  if (setupNotices.length > 0) {
@@ -366,22 +388,6 @@ function packetNotice(projectRoot, packets) {
366
388
  ` (${packets.notes.join('; ')}). ` +
367
389
  `Each says why in ${where}/index.json.\n`);
368
390
  }
369
- // Spec 37-3, criterion 1. The size of the work, printed before the plan exists,
370
- // because that is the only moment it can decide anything: the packets are built
371
- // from the request's entrypoints, and the entrypoints are known before the
372
- // workers are. A number that arrives after the plan is a number the plan was
373
- // not made from.
374
- function workloadNotice(projectRoot) {
375
- const loads = branchWorkloads(projectRoot);
376
- if (loads.length === 0)
377
- return '';
378
- return ('\nHow much each branch has to read, over the whole assignment and before you narrow it:\n' +
379
- loads.map(workloadLine).join('') +
380
- 'This does not set how many workers a branch gets, and that is worth knowing before you plan: ' +
381
- 'on the bench of 2026-08-24 the branch with three times the source spent half the turns. What ' +
382
- 'sets the width is how many cases you intend to write, so it is decided by the plan and ' +
383
- 'checked by `accept-worker-plan`, which prints the band it accepted.\n');
384
- }
385
391
  // The runner's own rule for which step files it will load, in the words of the
386
392
  // side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
387
393
  // the behavioral branch; this is the copy the coordinator sees without opening a
@@ -400,62 +406,62 @@ function stepLoadingNotice(runner, loading) {
400
406
  loading.requirements.join('\n - ') +
401
407
  '\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
402
408
  }
403
- // What the boot check found, in the vibecoder's terms. Printed on every run,
404
- // including the quiet ones: "we looked and it starts" and "we could not look"
405
- // are both worth a line, and a check nobody hears about is a check nobody
406
- // trusts.
409
+ // What the boot check found when it found nothing wrong, in the vibecoder's
410
+ // terms. Printed on every such run, including the quiet ones: "we looked and it
411
+ // starts" and "we could not look" are both worth a line, and a check nobody
412
+ // hears about is a check nobody trusts.
407
413
  //
408
- // A stop here is a finding, not a refusal, and the wording has to carry that.
409
- // "We found the defect that stops your suite from starting" and "we could not
410
- // build your suite" describe the same event and leave the reader in completely
411
- // different places.
414
+ // The third answer, `broken`, is the only one that changes what gets built, so
415
+ // it goes to `bootStop` and is printed where every other missing branch is
416
+ // explained.
412
417
  function bootFinding(boot, runner) {
413
- // Both halves of "what this answer is worth" travel together, on every
414
- // outcome. Splitting them is how the stack caveat came to be missing from
415
- // `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
416
- // that in the same breath as the fix: on Rails the stack caveat reads
417
- // "whatever stops one stops the other", which is an unscoped claim about a
418
- // branch nobody asked — loudest exactly where the run stops for both.
419
- // Empties are dropped rather than joined blindly, so a runner with no caveat
420
- // of its own does not leave a blank line behind.
421
- const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
422
- .filter(Boolean)
423
- .map((line) => `\n${line}`)
424
- .join('');
418
+ const caveat = caveatFor(runner);
425
419
  if (boot.status === 'ok') {
426
420
  return `Checked that the suite can start: it does.${caveat}\n`;
427
421
  }
428
- if (boot.status === 'not_checked') {
429
- // Not checked is not broken, and nothing downstream may treat it as such.
430
- // Conflating the two would block honest projects the whole reason this
431
- // state is named for what happened rather than for what we know.
432
- const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
433
- return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
434
- }
435
- const headline = boot.cause === 'defect_in_code'
436
- ? 'Found a defect that stops your test suite from starting.'
437
- : 'Your test suite cannot start yet — its environment is not ready.';
438
- // The runner's own words. Everything else on screen is ours; this line is
439
- // the one the vibecoder can paste into a search.
422
+ // Not checked is not broken, and nothing downstream may treat it as such.
423
+ // Conflating the two would block honest projects the whole reason this
424
+ // state is named for what happened rather than for what we know.
425
+ //
426
+ // `broken` cannot arrive here: it is the one answer that changes what gets
427
+ // built, so it goes to `bootStop` and is printed as the reason a branch is
428
+ // missing.
429
+ const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
430
+ return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
431
+ }
432
+ // Why the code-structure branch is not in this run. One indented block, the same
433
+ // shape as every other missing-branch reason, because that is now what this is:
434
+ // its peer carries on, `request.json` is written, and the vibecoder comes away
435
+ // with the guardrails that branch can still give rather than with nothing.
436
+ //
437
+ // The two causes keep their separate next steps. An un-run `pip install` is not
438
+ // 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) {
440
441
  const next = boot.cause === 'defect_in_code'
441
- ? 'Fix that, then run `unitbob suite-prepare` again.'
442
+ ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
442
443
  : 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
443
444
  'it never writes to your project. Something outside that file is still missing here. Run the ' +
444
445
  'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
445
446
  'then run `unitbob suite-prepare` again.';
446
- return (`${headline}\n\n` +
447
- ` ${boot.message}\n\n` +
448
- `${boot.detail}\n\n` +
449
- 'No suite was written and nothing was uploaded — every test would have died on that line ' +
450
- // The caveat belongs here most of all, and this was the one branch it did
451
- // not reach found on the fifth implementation review, 2026-08-03. On
452
- // pytest and vitest the check collects the project's whole test tree, so
453
- // the line above may come from a test of the project's own that the Unitbob
454
- // suite would never have imported. Printing "found a defect" and keeping
455
- // that back sends someone to fix a file this product was never going to
456
- // touch, which is the same over-claim the spec accepted the wide check only
457
- // on condition of disclosing.
458
- `before reaching its first assertion. ${next}${caveat}\n`);
447
+ // `boot.message` is the runner's own words, indented but never paraphrased:
448
+ // this is the line the vibecoder can paste into a search.
449
+ return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
450
+ }
451
+ // What this answer is worth, in two halves that always travel together: how much
452
+ // this stack's check can see, and the fact that it speaks for one branch only.
453
+ // Splitting them is how the stack caveat came to be missing from the outcome
454
+ // that costs a branch found on the fifth implementation review, 2026-08-03
455
+ // so there is one builder and every caller goes through it. `indent` sets how
456
+ // the lines sit, and is the only thing a caller may vary.
457
+ //
458
+ // Empties are dropped rather than joined blindly, so a runner with no caveat of
459
+ // its own does not leave a blank line behind.
460
+ function caveatFor(runner, indent = '') {
461
+ return [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
462
+ .filter(Boolean)
463
+ .map((line) => `\n${indent}${line}`)
464
+ .join('');
459
465
  }
460
466
  // Spec 32-6 says the boot rule is one rule for both branches; this check asks
461
467
  // one of them. It is made against the *structural* runner, which is what
@@ -474,12 +480,7 @@ const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: i
474
480
  'which has nothing of ours to load until its suite exists, so it was not asked.';
475
481
  const NOT_CHECKED_REASON = {
476
482
  no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
477
- // Distinct from `no_runner` on purpose. The runner is installed and working;
478
- // it is only too old to be asked this particular question, and "no runner
479
- // available" would send someone to fix a thing that is not broken.
480
- runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
481
- 'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
482
- // Distinct for the same reason, one step further along: the runner is there
483
+ // Distinct from `no_runner` on purpose: the runner is there
483
484
  // and current, it was reached, and it declined to answer — pytest exiting on
484
485
  // a usage or internal error of its own. That says nothing about the project,
485
486
  // and "no runner available" would again send someone after the wrong thing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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.1 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.3 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
@@ -1,148 +0,0 @@
1
- import { readPacketIndex } from "./packets.js";
2
- // Spec 37-3, criterion 1. How wide a branch's fan-out should be.
3
- //
4
- // The rule this replaces said there was no ceiling at all: "an agent re-reads
5
- // its context every turn, so splitting the work never costs more than keeping it
6
- // together." Half right, and the wrong half was load-bearing. An agent's cost is
7
- // the sum of its context over its turns, so splitting pulls in two directions:
8
- //
9
- // - the opening context is bought once per worker and re-read every turn, so
10
- // it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
11
- // the same to within ±370 across fifteen workers.
12
- // - each worker's conversation is shorter, and a conversation's cost grows
13
- // with the square of its length, so this falls with the width.
14
- //
15
- // There is therefore a minimum, and it is neither end. Measured on that bench,
16
- // against what the fifteen workers actually cost:
17
- //
18
- // workers 1 2 3 5 8 15 20
19
- // input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
20
- //
21
- // Fifteen was 28% over the cheapest width. One worker — which is what "the work
22
- // fits in one context" would have said, and what the first draft of this rule
23
- // enforced — is 85% over it, and would have run a 216-turn worker into a
24
- // 150-turn fuse. The floor is as expensive a mistake as the ceiling.
25
- //
26
- // See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
27
- // What the optimum is not a function of. Productive turns per 1,000 tokens of
28
- // source were 7.8 on the behavioral branch and 1.4 on the structural one of the
29
- // same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
30
- // there is to read, which turns out not to be what a worker spends its turns on.
31
- // They stay here for the printout and for the record in `fan_out`; they do not
32
- // set the width.
33
- const BYTES_PER_TOKEN = 4;
34
- const WRITTEN_PER_READ = 3;
35
- // What it is a function of. A planned case is one intent the worker has to turn
36
- // into a written example or Scenario, and its cost in turns is a property of the
37
- // branch, not of the project: a Gherkin Scenario needs the World, a session, a
38
- // fixture and an assertion; a structural example calls a method.
39
- //
40
- // Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
41
- // structural cases over 65.
42
- const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
43
- // The optimum width is the branch's productive turns over this. It comes out of
44
- // setting the derivative of the cost above to zero, which gives
45
- // `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
46
- // that run and 41.7 on the structural one, near enough to each other that one
47
- // number carries both and the flat bottom of the curve absorbs the difference.
48
- const TURNS_PER_WORKER = 36;
49
- // What a worker spends before it writes anything — reading its packets, its plan
50
- // item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
51
- // behavioral workers, 202 over seven structural ones. It is per worker and does
52
- // not divide, which is half of why width costs; it is added back here so that
53
- // the turns this prints are the whole conversation, the thing that meets the
54
- // 150-turn fuse.
55
- const WARMUP_TURNS = { behavioral: 22, structural: 28 };
56
- // Every case ends up at the same handful of widths, so the rule has to be a band
57
- // rather than a number: anywhere from three to eight workers cost within 10% of
58
- // the cheapest on the measured run. What the band excludes is what actually
59
- // costs — fifteen at one end, one at the other.
60
- const NARROWEST = 0.5;
61
- const WIDEST = 1.5;
62
- // How wide a branch should be, from the cases its plan intends to write.
63
- // Returns nothing for a branch this connector has no measured cost for: a rule
64
- // with no measurement behind it must not refuse anybody's plan.
65
- export function branchWidth(branch, plannedCases) {
66
- const perCase = TURNS_PER_CASE[branch];
67
- if (perCase === undefined || plannedCases <= 0)
68
- return undefined;
69
- const turns = plannedCases * perCase;
70
- const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
71
- return {
72
- branch,
73
- planned_cases: plannedCases,
74
- turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
75
- workers,
76
- fewest: Math.max(1, Math.round(workers * NARROWEST)),
77
- most: Math.max(1, Math.ceil(workers * WIDEST)),
78
- };
79
- }
80
- // What each branch's source weighs. Kept because it is the honest answer to "how
81
- // much is there", printed before the plan exists and recorded in `fan_out` — but
82
- // it is not what decides the width. See TURNS_PER_CASE above.
83
- //
84
- // `taken` narrows the count to the ids a plan actually took, which matters since
85
- // criterion 2 let the structural branch be narrowed too: the packets are built
86
- // from the whole assignment, before anybody chose a scope.
87
- export function branchWorkloads(projectRoot, taken) {
88
- const index = readPacketIndex(projectRoot);
89
- if (!index || index.targets.length === 0)
90
- return [];
91
- const measured = new Map();
92
- const unmeasured = new Map();
93
- for (const target of index.targets) {
94
- const ids = taken?.get(target.branch);
95
- if (taken && !ids?.has(target.id))
96
- continue;
97
- const size = sizeOf(target);
98
- // A file too large to copy still has a path and a size, and it is the
99
- // heaviest reading on the branch — counting it as nothing would let the
100
- // biggest sources look like the smallest.
101
- const file = target.packet ?? target.source_file;
102
- if (file !== undefined && size !== undefined) {
103
- const files = measured.get(target.branch) ?? new Map();
104
- files.set(file, size);
105
- measured.set(target.branch, files);
106
- }
107
- else {
108
- unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
109
- }
110
- }
111
- const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
112
- return [...branches].sort().flatMap((branch) => {
113
- const files = measured.get(branch);
114
- if (!files || files.size === 0)
115
- return [];
116
- const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
117
- const missing = unmeasured.get(branch) ?? 0;
118
- const withMissing = bytes + Math.round((bytes / files.size) * missing);
119
- const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
120
- return [{
121
- branch, files: files.size, bytes, unmeasured: missing,
122
- read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
123
- }];
124
- });
125
- }
126
- // The index is a file on the vibecoder's disk, so a size that is not a real byte
127
- // count is treated as a size we do not have rather than as zero. Zero would
128
- // quietly shrink the branch's average.
129
- function sizeOf(target) {
130
- const { bytes } = target;
131
- return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
132
- }
133
- export function widthLine(width) {
134
- return (` ${width.branch} — ${width.planned_cases} planned ` +
135
- `${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
136
- `${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
137
- `(${width.fewest}–${width.most} accepted).\n`);
138
- }
139
- export function workloadLine(load) {
140
- const missing = load.unmeasured === 0
141
- ? ''
142
- : ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
143
- 'priced at what the others average';
144
- return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
145
- `${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
146
- `${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
147
- `${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
148
- }