unitbob 0.7.2 → 0.7.4

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
@@ -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,62 @@ 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');
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
+ }
13
69
  // The project configs we inherit from, most specific first. Vitest reads a
14
70
  // project's own config even when we pass `--config`, so we must merge ours with
15
71
  // it rather than replace it (plugins, path aliases and resolve settings the
@@ -79,12 +135,26 @@ export async function runVitestSuite(projectRoot, suitePaths) {
79
135
  // the branch's files have to be in `include` or nothing is collected, and the
80
136
  // names a worker gives its slice are not something to bet a whole run on.
81
137
  function writeMergedConfig(projectRoot, suitePaths) {
82
- const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
83
138
  const path = join(projectRoot, VITEST_CONFIG_FILE);
84
139
  mkdirSync(dirname(path), { recursive: true });
85
- writeFileSync(path, configSource(projectConfig, suitePaths));
140
+ writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run', setupFileOf(projectRoot)));
86
141
  return ['--config', VITEST_CONFIG_FILE];
87
142
  }
143
+ // The same config, shaped for the boot check's probe (spec 38). Returned as text
144
+ // rather than written, because the boot check owns the lifetime of every file it
145
+ // puts in the project: it writes them together and removes them together, and a
146
+ // writer here would take half of that away from the one place that can see it.
147
+ export function vitestBootConfigSource(projectRoot, probePath) {
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));
154
+ }
155
+ function projectConfigOf(projectRoot) {
156
+ return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
157
+ }
88
158
  // The .unitbob/ config sits one level below the project root, so the project
89
159
  // config is a `../` import. A function-form config is resolved first, and
90
160
  // everything the project set — plugins, aliases, setup files, environment — is
@@ -101,21 +171,60 @@ function writeMergedConfig(projectRoot, suitePaths) {
101
171
  // ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
102
172
  // projects the sidecar exists for. A spread does the same job with no import,
103
173
  // and `defineConfig` is a typing helper that buys a generated file nothing.
104
- function configSource(projectConfig, suitePaths) {
105
- const include = `include: ${JSON.stringify(suitePaths)}`;
174
+ // `mode` is the boot check's two differences from the run, and they go together:
175
+ // globals on, and a workspace refused. See `vitestBootConfigSource`.
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) {
185
+ // After the project's own `test`, never before it: ours has to win.
186
+ const settings = `${mode === 'boot' ? 'globals: true, ' : ''}${setupFile ? 'setupFiles, ' : ''}include: ${JSON.stringify(suitePaths)}`;
106
187
  const header = '// Written by the unitbob connector before each vitest run — do not edit.';
107
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` : '';
108
193
  return `${header}
109
- export default { test: { ${include} } };
194
+ ${alone}export default { test: { ${settings} } };
110
195
  `;
111
196
  }
197
+ // Globals, because the probe lives in `.unitbob/structural/`: an
198
+ // `import { test } from 'vitest'` there resolves by walking up from that
199
+ // directory, which never reaches `.unitbob/runners/node_modules` — where the
200
+ // vitest we installed for a project that had none is kept. We write this file,
201
+ // so we turn the globals on and the probe needs no import at all.
202
+ //
203
+ // A workspace is dropped for a harder reason. With `test.projects` (or the
204
+ // older `test.workspace`) set, the root `include` stops deciding anything and
205
+ // vitest runs each sub-project's own test files — which would open the
206
+ // project's tests again through the back door, the one thing spec 38 removes.
207
+ const narrow = mode === 'boot'
208
+ ? `
209
+ const { projects, workspace, ...test } = base.test ?? {};
210
+ `
211
+ : `
212
+ const test = base.test ?? {};
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
+ : '';
112
221
  return `${header}
113
222
  import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
114
223
 
115
224
  const base = typeof projectConfig === 'function'
116
225
  ? await projectConfig({ command: 'serve', mode: 'test' })
117
226
  : projectConfig;
118
-
119
- export default { ...base, test: { ...(base.test ?? {}), ${include} } };
227
+ ${narrow}${merge}
228
+ export default { ...base, test: { ...test, ${settings} } };
120
229
  `;
121
230
  }
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 {
@@ -5,6 +5,7 @@ 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) {
@@ -259,7 +260,11 @@ function artifactPathsOf(output) {
259
260
  const rest = support
260
261
  .map((entry) => entry?.path)
261
262
  .filter((candidate) => typeof candidate === 'string' && candidate.length > 0);
262
- return [path, ...rest];
263
+ // Everything but the branch's shared setup file (spec 39): it is a support
264
+ // file so that it survives materialization, and it is named in `setupFiles`
265
+ // rather than handed over as a path to collect tests from. Literally the same
266
+ // filter the check flow uses, because both sides have to mean the one file.
267
+ return testPathsOf([path, ...rest]);
263
268
  }
264
269
  function branchRoot(config, suiteKind) {
265
270
  const request = readSuiteBuildRequest(config.projectRoot);
@@ -1,7 +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 { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
4
+ import { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
5
5
  import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
6
6
  import { bddStepLoading } from "../runner/bdd.js";
7
7
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
@@ -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
@@ -67,7 +68,7 @@ export async function suitePrepare(config, args = [], deps) {
67
68
  getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
68
69
  precheck: anyStackPrecheck,
69
70
  confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
70
- bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
71
+ bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
71
72
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
72
73
  ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
73
74
  worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
@@ -130,30 +131,10 @@ export async function suitePrepare(config, args = [], deps) {
130
131
  throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
131
132
  }
132
133
  }
133
- // Spec 32-6. Before anything is fetched or written, find out whether the suite
134
- // would get off the ground at all. It runs here, after the boot helper exists
135
- // and before the network, so a project whose suite cannot start costs one
136
- // command instead of a full generation.
137
- //
138
- // There is no `--on-broken-boot` flag and no mode. The decision is not a
139
- // policy we could reasonably let a user set — it follows from the fact: we
140
- // tried to load the thing the suite starts with, it did not load, therefore
141
- // not one test would reach its first assertion. Debugging generation against a
142
- // knowingly dead project is our problem, not the vibecoder's.
143
134
  // The stack the precheck just identified, rather than a second detection of
144
135
  // the same thing: on Python that would shell out to pytest all over again.
136
+ // Used further down, where the boot check now runs.
145
137
  const structuralRunner = check.runner ?? null;
146
- const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
147
- if (boot.status === 'broken') {
148
- // "Your environment is not ready" is the one of the two that a container can
149
- // answer — the toolchain is missing here and may be sitting in one. A defect
150
- // found in the code is a defect wherever it runs, and offering a container
151
- // for it would be the noise this spec is trying to remove.
152
- throw boot.cause === 'environment_not_ready'
153
- ? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
154
- : new Error(bootFinding(boot, structuralRunner));
155
- }
156
- actual.stdout.write(bootFinding(boot, structuralRunner));
157
138
  const packets = await actual.getSuitePacketsBatch();
158
139
  // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
159
140
  // A `fixable` outcome (no package manager available to install the runner) is an infrastructure
@@ -227,8 +208,82 @@ export async function suitePrepare(config, args = [], deps) {
227
208
  else
228
209
  blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
229
210
  }
211
+ // Spec 32-6, moved down by spec 38. Before anything is written and before a
212
+ // single token is spent, find out whether the code this branch's guardrails
213
+ // will import actually loads.
214
+ //
215
+ // Here rather than at the top of the verb, because here the list of those
216
+ // files exists: it is resolved from the branches that were just assembled,
217
+ // out of this machine's own graph and route inventory. Nothing is lost by
218
+ // waiting — `getSuitePacketsBatch` is a GET that writes nothing and costs
219
+ // nothing, and the generator, which is where the money starts, does not run
220
+ // until `request.json` is written below.
221
+ //
222
+ // What is gained is that a failure here takes one branch and not the run. The
223
+ // behavioral peer has a runner of its own, touches none of these files, and
224
+ // used to die of a verdict that was never about it.
225
+ // Only when there is a branch to check. Ruby's helper ignores the file list
226
+ // and boots the application for itself, so asking with no structural branch in
227
+ // the run would start Rails to answer a question nobody put — and a `broken`
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.
262
+ const bootNotices = [];
263
+ const bootAdvisories = [];
264
+ const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
265
+ if (structuralIndex !== -1) {
266
+ const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
267
+ if (boot.status === 'broken') {
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
+ }
275
+ }
276
+ else {
277
+ actual.stdout.write(bootFinding(boot, structuralRunner));
278
+ }
279
+ }
280
+ // Every reason a branch is not here, in one place. A run can lose its last
281
+ // branch to any of the three and the reader needs the one that applies to
282
+ // them — printing only the envelope reasons left a jest project reading an
283
+ // empty list under "no suite branch can be built".
230
284
  if (branches.length === 0) {
231
- throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
285
+ const why = [...bootNotices, ...blockedNotices, ...fixableNotices].join('\n');
286
+ throw new Error(`No suite branch can be built this run:\n${why}\nNothing was written and nothing was uploaded.`);
232
287
  }
233
288
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
234
289
  // Spec 37-1. The assignment names entrypoints; the packets are the files
@@ -306,6 +361,27 @@ export async function suitePrepare(config, args = [], deps) {
306
361
  'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
307
362
  'them, and this connector will not be able to run the branch either.\n');
308
363
  }
364
+ // Same shape as the two notices below it, and the same rule: the branch that
365
+ // could not be prepared drops out, its peer is untouched, and the reason is
366
+ // printed rather than swallowed.
367
+ if (bootNotices.length > 0) {
368
+ actual.stdout.write('\nThe code-structure suite was left out of this run — the source files its guardrails would ' +
369
+ 'import did not load. None of your own tests were opened; only the files the map named:\n' +
370
+ bootNotices.join('\n') +
371
+ '\n');
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
+ }
309
385
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
310
386
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
311
387
  if (setupNotices.length > 0) {
@@ -382,62 +458,88 @@ function stepLoadingNotice(runner, loading) {
382
458
  loading.requirements.join('\n - ') +
383
459
  '\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
384
460
  }
385
- // What the boot check found, in the vibecoder's terms. Printed on every run,
386
- // including the quiet ones: "we looked and it starts" and "we could not look"
387
- // are both worth a line, and a check nobody hears about is a check nobody
388
- // trusts.
461
+ // What the boot check found when it found nothing wrong, in the vibecoder's
462
+ // terms. Printed on every such run, including the quiet ones: "we looked and it
463
+ // starts" and "we could not look" are both worth a line, and a check nobody
464
+ // hears about is a check nobody trusts.
389
465
  //
390
- // A stop here is a finding, not a refusal, and the wording has to carry that.
391
- // "We found the defect that stops your suite from starting" and "we could not
392
- // build your suite" describe the same event and leave the reader in completely
393
- // different places.
466
+ // The third answer, `broken`, is the only one that changes what gets built, so
467
+ // it goes to `bootStop` and is printed where every other missing branch is
468
+ // explained.
394
469
  function bootFinding(boot, runner) {
395
- // Both halves of "what this answer is worth" travel together, on every
396
- // outcome. Splitting them is how the stack caveat came to be missing from
397
- // `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
398
- // that in the same breath as the fix: on Rails the stack caveat reads
399
- // "whatever stops one stops the other", which is an unscoped claim about a
400
- // branch nobody asked — loudest exactly where the run stops for both.
401
- // Empties are dropped rather than joined blindly, so a runner with no caveat
402
- // of its own does not leave a blank line behind.
403
- const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
404
- .filter(Boolean)
405
- .map((line) => `\n${line}`)
406
- .join('');
470
+ const caveat = caveatFor(runner);
407
471
  if (boot.status === 'ok') {
408
472
  return `Checked that the suite can start: it does.${caveat}\n`;
409
473
  }
410
- if (boot.status === 'not_checked') {
411
- // Not checked is not broken, and nothing downstream may treat it as such.
412
- // Conflating the two would block honest projects the whole reason this
413
- // state is named for what happened rather than for what we know.
414
- const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
415
- return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
416
- }
417
- const headline = boot.cause === 'defect_in_code'
418
- ? 'Found a defect that stops your test suite from starting.'
419
- : 'Your test suite cannot start yet — its environment is not ready.';
420
- // The runner's own words. Everything else on screen is ours; this line is
421
- // the one the vibecoder can paste into a search.
422
- const next = boot.cause === 'defect_in_code'
423
- ? 'Fix that, then run `unitbob suite-prepare` again.'
424
- : 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
474
+ // Not checked is not broken, and nothing downstream may treat it as such.
475
+ // Conflating the two would block honest projects the whole reason this
476
+ // state is named for what happened rather than for what we know.
477
+ //
478
+ // `broken` cannot arrive here: it is the one answer that changes what gets
479
+ // built, so it goes to `bootStop` and is printed as the reason a branch is
480
+ // missing.
481
+ const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
482
+ return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
483
+ }
484
+ // Why the code-structure branch is not in this run. One indented block, the same
485
+ // shape as every other missing-branch reason, because that is now what this is:
486
+ // its peer carries on, `request.json` is written, and the vibecoder comes away
487
+ // with the guardrails that branch can still give rather than with nothing.
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
+ }
504
+ // The two causes keep their separate next steps. An un-run `pip install` is not
505
+ // somebody's bug and must not be worded as one; a module of theirs that raises
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/` — ' +
425
515
  'it never writes to your project. Something outside that file is still missing here. Run the ' +
426
516
  'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
427
- 'then run `unitbob suite-prepare` again.';
428
- return (`${headline}\n\n` +
429
- ` ${boot.message}\n\n` +
430
- `${boot.detail}\n\n` +
431
- 'No suite was written and nothing was uploaded every test would have died on that line ' +
432
- // The caveat belongs here most of all, and this was the one branch it did
433
- // not reach found on the fifth implementation review, 2026-08-03. On
434
- // pytest and vitest the check collects the project's whole test tree, so
435
- // the line above may come from a test of the project's own that the Unitbob
436
- // suite would never have imported. Printing "found a defect" and keeping
437
- // that back sends someone to fix a file this product was never going to
438
- // touch, which is the same over-claim the spec accepted the wide check only
439
- // on condition of disclosing.
440
- `before reaching its first assertion. ${next}${caveat}\n`);
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.';
525
+ // `boot.message` is the runner's own words, indented but never paraphrased:
526
+ // this is the line the vibecoder can paste into a search.
527
+ return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
528
+ }
529
+ // What this answer is worth, in two halves that always travel together: how much
530
+ // this stack's check can see, and the fact that it speaks for one branch only.
531
+ // Splitting them is how the stack caveat came to be missing from the outcome
532
+ // that costs a branch — found on the fifth implementation review, 2026-08-03 —
533
+ // so there is one builder and every caller goes through it. `indent` sets how
534
+ // the lines sit, and is the only thing a caller may vary.
535
+ //
536
+ // Empties are dropped rather than joined blindly, so a runner with no caveat of
537
+ // its own does not leave a blank line behind.
538
+ function caveatFor(runner, indent = '') {
539
+ return [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
540
+ .filter(Boolean)
541
+ .map((line) => `\n${indent}${line}`)
542
+ .join('');
441
543
  }
442
544
  // Spec 32-6 says the boot rule is one rule for both branches; this check asks
443
545
  // one of them. It is made against the *structural* runner, which is what
@@ -456,12 +558,7 @@ const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: i
456
558
  'which has nothing of ours to load until its suite exists, so it was not asked.';
457
559
  const NOT_CHECKED_REASON = {
458
560
  no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
459
- // Distinct from `no_runner` on purpose. The runner is installed and working;
460
- // it is only too old to be asked this particular question, and "no runner
461
- // available" would send someone to fix a thing that is not broken.
462
- runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
463
- 'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
464
- // Distinct for the same reason, one step further along: the runner is there
561
+ // Distinct from `no_runner` on purpose: the runner is there
465
562
  // and current, it was reached, and it declined to answer — pytest exiting on
466
563
  // a usage or internal error of its own. That says nothing about the project,
467
564
  // 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.2",
3
+ "version": "0.7.4",
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.2 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.4 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