unitbob 0.4.5 → 0.5.0

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,13 +1,23 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
+ import { defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
4
5
  // How long a local setup step may take before we stop waiting. Provisioning a
5
6
  // runner and loading a cold Rails test environment sit in the same ballpark —
6
7
  // tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
7
8
  // number rather than inventing a second one to keep in sync.
8
9
  export const PROVISION_TIMEOUT_MS = 120_000;
10
+ // Installing an application's own dependency tree is a different order of work
11
+ // from adding one runner gem: hundreds of packages, compiled extensions, a cold
12
+ // package index. Two minutes is a normal figure for it, so it gets its own
13
+ // budget instead of borrowing one sized for a single install.
14
+ export const DEPENDENCY_INSTALL_TIMEOUT_MS = 15 * 60 * 1000;
9
15
  const defaultDeps = {
10
- runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: PROVISION_TIMEOUT_MS, env: { ...process.env, ...options.env } }),
16
+ runCmd: (command, args, options) => runProcess(command, args, {
17
+ cwd: options.cwd,
18
+ timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS,
19
+ env: { ...process.env, ...options.env },
20
+ }),
11
21
  };
12
22
  export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
13
23
  const behavioralDir = join(projectRoot, '.unitbob', 'behavioral');
@@ -23,6 +33,297 @@ export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
23
33
  return { status: 'fixable', message: `Unsupported BDD runner "${runner}".` };
24
34
  }
25
35
  }
36
+ // Make the structural runner runnable without touching the project.
37
+ //
38
+ // Nothing is built when the project already supplies the runner itself: a
39
+ // developer with a working setup should not find a second copy of their
40
+ // toolchain appear under `.unitbob/` because they tried Unitbob once.
41
+ //
42
+ // When the project does not supply it, everything the runner needs is installed
43
+ // under `.unitbob/runners/` instead — the runner and, on the two stacks where it
44
+ // is possible, the application's own dependencies with it. The project's
45
+ // Gemfile, requirements.txt and package.json are read and never written.
46
+ //
47
+ // Python and Ruby get a complete environment this way. JavaScript deliberately
48
+ // does not: node resolves an import by walking up from the importing file, so a
49
+ // suite sitting in `.unitbob/structural/` finds the project's `node_modules` and
50
+ // can never be made to find a sidecar copy instead. Vitest itself is installed
51
+ // here because we spawn that binary by path; the project's dependencies stay the
52
+ // project's, and a missing `node_modules` comes back as a fixable notice naming
53
+ // the one command that fixes it.
54
+ export async function ensureStructuralRunner(projectRoot, runner, deps = defaultDeps) {
55
+ const tools = deps.tools ?? defaultToolDeps;
56
+ if (projectProvidesRunner(projectRoot, runner, tools))
57
+ return { status: 'provisioned' };
58
+ const dir = join(projectRoot, SIDECAR_DIR);
59
+ mkdirSync(dir, { recursive: true });
60
+ switch (runner) {
61
+ case 'pytest':
62
+ return provisionPytest(projectRoot, deps);
63
+ case 'vitest':
64
+ return provisionVitest(projectRoot, deps);
65
+ case 'rspec':
66
+ return provisionRspec(projectRoot, deps);
67
+ default:
68
+ return { status: 'fixable', message: `Unsupported structural runner "${runner}".` };
69
+ }
70
+ }
71
+ // The builders we can make an environment with, in the order we try them.
72
+ //
73
+ // `python3 -m venv` before `uv` even though uv is faster: the standard-library
74
+ // builder always puts pip in the environment it makes, and `uv venv`
75
+ // deliberately does not. An environment with no pip is one nothing can be
76
+ // installed into afterwards.
77
+ //
78
+ // And no `--system-site-packages`. Borrowing the machine's own packages looks
79
+ // like a saving — the application's dependencies may already be installed
80
+ // globally — but it makes the environment a different one on every machine,
81
+ // which is the one thing a sidecar exists to prevent. It also lets pytest pick
82
+ // up plugins nobody asked for: measured on a Flask app where an unrelated
83
+ // globally-installed langsmith plugin was loaded into the run and died on a
84
+ // pydantic/typing_extensions mismatch, so a project that imports perfectly well
85
+ // could not be collected. This environment holds the requirements file and
86
+ // pytest, and nothing else. Found 2026-08-12.
87
+ const VENV_BUILDERS = [
88
+ { command: 'python3', args: (venvDir) => ['-m', 'venv', venvDir] },
89
+ { command: 'python', args: (venvDir) => ['-m', 'venv', venvDir] },
90
+ { command: 'uv', args: (venvDir) => ['venv', venvDir] },
91
+ ];
92
+ // A virtual environment under `.unitbob/runners/.venv` holding pytest and, when
93
+ // the project declares them in a requirements file, the application's own
94
+ // packages — and deliberately nothing else. See `VENV_BUILDERS`.
95
+ async function provisionPytest(projectRoot, deps) {
96
+ const venvDir = sidecarPath(projectRoot, '.venv');
97
+ const venvPython = join(venvDir, 'bin', 'python');
98
+ const built = await buildPythonEnvironment(projectRoot, venvDir, deps);
99
+ if (!built.created) {
100
+ return {
101
+ status: 'fixable',
102
+ message: `Failed to create a virtual environment under ${SIDECAR_DIR}/.venv.`,
103
+ checklist: ['Install python3-venv or uv: `python3 -m venv --help`, or `pip install uv`.'],
104
+ };
105
+ }
106
+ const pytest = await pipInstall(deps, projectRoot, venvPython, ['pytest']);
107
+ const notes = built.requirementsNote ? [built.requirementsNote] : [];
108
+ if (pytest.ok || runnerAvailable(projectRoot, 'pytest', deps.tools ?? defaultToolDeps)) {
109
+ return notes.length > 0 ? { status: 'provisioned', checklist: notes } : { status: 'provisioned' };
110
+ }
111
+ return {
112
+ status: 'fixable',
113
+ message: `Failed to install pytest into ${SIDECAR_DIR}/.venv.`,
114
+ checklist: [`Run \`${venvPython} -m pip install pytest\` manually to provision the runner.`, ...notes],
115
+ };
116
+ }
117
+ // Build a Python environment holding the application's declared dependencies,
118
+ // and say so honestly when they would not go in. Both branches call it: the
119
+ // structural suite imports the application and the behavioral suite drives it,
120
+ // so neither is any use in an environment the application is not installed in.
121
+ //
122
+ // An interpreter that is merely present is not one the project can run on. A
123
+ // machine can easily carry a Python newer than everything the project pins —
124
+ // measured on a Flask app whose psycopg2, greenlet and multidict have no wheels
125
+ // for 3.14 and do not compile against it, while the 3.11 standing beside it
126
+ // installs all three from wheels in seconds. So when the requirements will not
127
+ // go in, the environment is rebuilt with the next builder rather than handed
128
+ // over half-empty. Found 2026-08-12.
129
+ async function buildPythonEnvironment(projectRoot, venvDir, deps) {
130
+ const venvPython = join(venvDir, 'bin', 'python');
131
+ // The project's own statement of what it needs. It is also the only test of
132
+ // whether an environment is any use: one the application's packages will not
133
+ // install into is the wrong environment, however well it was built.
134
+ const requirements = ['requirements.txt', 'requirements/base.txt', 'requirements-dev.txt']
135
+ .find((name) => existsSync(join(projectRoot, name)));
136
+ let created = existsSync(venvPython);
137
+ let requirementsOk = requirements === undefined;
138
+ let failure;
139
+ for (const [index, builder] of VENV_BUILDERS.entries()) {
140
+ if (!created) {
141
+ const result = await deps
142
+ .runCmd(builder.command, builder.args(venvDir), { cwd: projectRoot })
143
+ .catch(() => ({ code: 1, stdout: '', stderr: '' }));
144
+ if (result.code !== 0)
145
+ continue;
146
+ created = true;
147
+ }
148
+ if (requirements === undefined)
149
+ break;
150
+ const installed = await pipInstall(deps, projectRoot, venvPython, ['-r', requirements]);
151
+ if (installed.ok) {
152
+ requirementsOk = true;
153
+ break;
154
+ }
155
+ // Keep the first complaint: it comes from the interpreter the project would
156
+ // have been given by default, and it is the one worth reporting if no
157
+ // interpreter here works.
158
+ failure ??= installed.detail ?? '';
159
+ // Discard the environment only while there is another builder to try. The
160
+ // last one is kept even though the requirements did not go in: the runner
161
+ // still installs into it, and a suite that runs and cannot import the
162
+ // application says far more than no suite at all.
163
+ if (index === VENV_BUILDERS.length - 1)
164
+ break;
165
+ rmSync(venvDir, { recursive: true, force: true });
166
+ created = false;
167
+ }
168
+ if (!created)
169
+ return { created };
170
+ if (requirements === undefined) {
171
+ return { created, requirementsNote: noDependencySourceNote(projectRoot, venvDir) };
172
+ }
173
+ if (requirementsOk)
174
+ return { created };
175
+ // The reason travels with the notice. Without it the reader is told that
176
+ // something did not install and has to re-run the install by hand to find out
177
+ // what — and the answer is usually one line ("no wheel for this Python",
178
+ // "pg_config not found") that decides what they do next.
179
+ return {
180
+ created,
181
+ requirementsNote: `installing ${requirements} into ${relativeVenv(projectRoot, venvDir)} did not finish on any Python ` +
182
+ `available here — the suite may not be able to import the application.` +
183
+ (failure ? ` The install said: ${failure}` : ''),
184
+ };
185
+ }
186
+ // A Python project that states its dependencies somewhere other than a
187
+ // requirements file was the quietest failure in here: nothing found to install
188
+ // was read as nothing to install, the environment was declared a success, and
189
+ // the suite then met the application it could not import. Detection accepts
190
+ // `pyproject.toml` and `Pipfile` (see `precheck.ts`) while this function only
191
+ // ever read `requirements*.txt`, so the two disagreed about what a Python
192
+ // project is.
193
+ //
194
+ // Installing from those two sources is not attempted here yet. Saying so is not
195
+ // optional: "we did not install your application's packages, and here is why"
196
+ // is a sentence the reader can act on, and an empty environment reported as
197
+ // built is not.
198
+ function noDependencySourceNote(projectRoot, venvDir) {
199
+ const declared = ['pyproject.toml', 'Pipfile'].filter((name) => existsSync(join(projectRoot, name)));
200
+ const where = declared.length
201
+ ? `this project declares its dependencies in ${declared.join(' and ')}, which Unitbob does not install from yet`
202
+ : 'no requirements.txt, pyproject.toml or Pipfile was found';
203
+ return (`the application's own packages are not installed into ${relativeVenv(projectRoot, venvDir)} — ${where}. ` +
204
+ 'The suite can start, but it may not be able to import the application.');
205
+ }
206
+ // Install into the sidecar environment, whichever tool built it.
207
+ //
208
+ // `python -m pip` rather than the `bin/pip` script: the script is missing from a
209
+ // uv-built environment, and calling a path that is not there throws ENOENT,
210
+ // which reads as "the install failed" when nothing was ever attempted. `uv pip`
211
+ // is the second attempt for exactly that environment.
212
+ async function pipInstall(deps, projectRoot, venvPython, packages) {
213
+ let last;
214
+ for (const candidate of [
215
+ { command: venvPython, args: ['-m', 'pip', 'install', ...packages] },
216
+ { command: 'uv', args: ['pip', 'install', '--python', venvPython, ...packages] },
217
+ ]) {
218
+ last = await deps
219
+ .runCmd(candidate.command, candidate.args, { cwd: projectRoot, timeoutMs: DEPENDENCY_INSTALL_TIMEOUT_MS })
220
+ .catch(() => ({ code: 1, stdout: '', stderr: '' }));
221
+ if (last.code === 0)
222
+ return { ok: true };
223
+ }
224
+ return { ok: false, detail: last ? installerComplaint(last) : undefined };
225
+ }
226
+ // The one line of an installer's output that says what went wrong. pip prints
227
+ // hundreds of lines of compiler noise and, among the lines that do look like
228
+ // errors, one is a verbatim dump of the compiler command — three hundred
229
+ // characters of flags whose only readable part is the word "clang". That line
230
+ // is dropped along with anything else too long to be a summary, which leaves
231
+ // pip's own verdict ("Failed building wheel for psycopg2-binary").
232
+ function installerComplaint(result) {
233
+ const complaints = `${result.stdout}\n${result.stderr}`
234
+ .split('\n')
235
+ .map((line) => line.trim())
236
+ .filter((line) => /^(error|ERROR|×|note: This error|Failed to build)/.test(line))
237
+ .filter((line) => line.length <= 160 && !line.includes("Command '["));
238
+ return complaints[complaints.length - 1];
239
+ }
240
+ // Vitest under `.unitbob/runners/node_modules`, spawned by path. See the note on
241
+ // `ensureStructuralRunner` for why the application's own packages are not
242
+ // installed here.
243
+ async function provisionVitest(projectRoot, deps) {
244
+ writeIfChanged(sidecarPath(projectRoot, 'package.json'), JSON.stringify({ name: 'unitbob-structural-sidecar', private: true, devDependencies: { vitest: '^3.0.0' } }, null, 2) + '\n');
245
+ if (!runnerAvailable(projectRoot, 'vitest')) {
246
+ const installed = await firstSuccess(deps, projectRoot, [
247
+ { command: 'npm', args: ['install', '--prefix', SIDECAR_DIR] },
248
+ { command: 'pnpm', args: ['install', '--prefix', SIDECAR_DIR] },
249
+ { command: 'yarn', args: ['install', '--cwd', SIDECAR_DIR] },
250
+ ], DEPENDENCY_INSTALL_TIMEOUT_MS);
251
+ if (!installed && !runnerAvailable(projectRoot, 'vitest')) {
252
+ return {
253
+ status: 'fixable',
254
+ message: `Failed to install vitest into ${SIDECAR_DIR}.`,
255
+ checklist: [`Install it manually: \`npm install --prefix ${SIDECAR_DIR}\`.`],
256
+ };
257
+ }
258
+ }
259
+ // The suite imports the application, and on this stack that resolves through
260
+ // the project's own node_modules — the one thing a sidecar cannot stand in for.
261
+ if (existsSync(join(projectRoot, 'package.json')) && !existsSync(join(projectRoot, 'node_modules'))) {
262
+ return {
263
+ status: 'provisioned',
264
+ checklist: [
265
+ "This project's own dependencies are not installed (`node_modules` is missing), and on this stack " +
266
+ 'they cannot be installed under `.unitbob/` — node resolves imports from the project itself. ' +
267
+ 'Run `npm install` in the project before generating, or the suite will not be able to import it.',
268
+ ],
269
+ };
270
+ }
271
+ return { status: 'provisioned' };
272
+ }
273
+ // A sidecar Gemfile that inherits the project's own, plus rspec-rails. Bundler
274
+ // resolves the two together, so the application's gems come with it — the same
275
+ // arrangement the Cucumber sidecar has used since spec 32-1, and the reason the
276
+ // project's Gemfile is read rather than edited.
277
+ async function provisionRspec(projectRoot, deps) {
278
+ const sidecarGemfile = sidecarPath(projectRoot, 'Gemfile');
279
+ writeIfChanged(sidecarGemfile, '# Sidecar Gemfile written by the unitbob connector — do not edit.\n' +
280
+ 'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
281
+ 'gem "rspec-rails", require: false\n');
282
+ // Start from the project's own resolution for the reason spelled out on the
283
+ // Cucumber sidecar below: without it bundler re-resolves the whole graph and
284
+ // hands the sidecar versions the project does not run.
285
+ copyLockIfPresent(projectRoot, sidecarPath(projectRoot, 'Gemfile.lock'));
286
+ const localBundle = join(projectRoot, 'bin', 'bundle');
287
+ const command = existsSync(localBundle) ? localBundle : 'bundle';
288
+ const result = await deps
289
+ .runCmd(command, ['install'], {
290
+ cwd: projectRoot,
291
+ env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile` },
292
+ timeoutMs: DEPENDENCY_INSTALL_TIMEOUT_MS,
293
+ })
294
+ .catch((err) => ({ code: 1, stdout: '', stderr: String(err) }));
295
+ if (result.code === 0)
296
+ return { status: 'provisioned' };
297
+ return {
298
+ status: 'fixable',
299
+ message: `Bundler failed to provision rspec-rails under ${SIDECAR_DIR}.`,
300
+ checklist: [
301
+ 'Ensure bundler is installed (`gem install bundler`), then run ' +
302
+ `\`BUNDLE_GEMFILE=${SIDECAR_DIR}/Gemfile bundle install\` from the project root.`,
303
+ ],
304
+ };
305
+ }
306
+ // Run each candidate until one exits zero. Used for the "uv, else venv" and
307
+ // "npm, else pnpm, else yarn" ladders, which are the same shape.
308
+ async function firstSuccess(deps, cwd, candidates, timeoutMs) {
309
+ for (const candidate of candidates) {
310
+ const result = await deps
311
+ .runCmd(candidate.command, candidate.args, { cwd, timeoutMs })
312
+ .catch(() => ({ code: 1, stdout: '', stderr: '' }));
313
+ if (result.code === 0)
314
+ return true;
315
+ }
316
+ return false;
317
+ }
318
+ function writeIfChanged(path, content) {
319
+ if (!existsSync(path) || readFileSync(path, 'utf8') !== content)
320
+ writeFileSync(path, content);
321
+ }
322
+ function copyLockIfPresent(projectRoot, destination) {
323
+ const projectLock = join(projectRoot, 'Gemfile.lock');
324
+ if (existsSync(projectLock))
325
+ writeFileSync(destination, readFileSync(projectLock, 'utf8'));
326
+ }
26
327
  async function provisionRuby(projectRoot, behavioralDir, deps) {
27
328
  const sidecarGemfile = join(behavioralDir, 'Gemfile');
28
329
  const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
@@ -71,37 +372,36 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
71
372
  }
72
373
  async function provisionPython(projectRoot, behavioralDir, deps) {
73
374
  const venvDir = join(behavioralDir, '.venv');
74
- const venvPip = join(venvDir, 'bin', 'pip');
375
+ const venvPython = join(venvDir, 'bin', 'python');
75
376
  const venvPytest = join(venvDir, 'bin', 'pytest');
76
- if (existsSync(venvPytest)) {
77
- return { status: 'provisioned' };
78
- }
79
- // Ladder: uv -> python3 -m venv --system-site-packages
80
- const uvResult = await deps.runCmd('uv', ['venv', venvDir, '--system-site-packages'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
81
- let venvCreated = uvResult.code === 0;
82
- if (!venvCreated) {
83
- const venvResult = await deps
84
- .runCmd('python3', ['-m', 'venv', '--system-site-packages', venvDir], { cwd: projectRoot })
85
- .catch(() => ({ code: 1 }));
86
- venvCreated = venvResult.code === 0;
87
- }
88
- if (!venvCreated) {
377
+ // The behavioral suite drives the application, so its environment needs the
378
+ // application in it — the same requirement, and now the same treatment, as the
379
+ // structural peer. It used to be built with `--system-site-packages` and given
380
+ // nothing but pytest-bdd, on the assumption that the machine already had the
381
+ // project's packages. On a machine that did not, every scenario failed on
382
+ // `No module named flask` in an environment Unitbob had just built for it.
383
+ const built = await buildPythonEnvironment(projectRoot, venvDir, deps);
384
+ if (!built.created) {
89
385
  return {
90
386
  status: 'fixable',
91
- message: 'Failed to create virtual environment under .unitbob/behavioral/.venv.',
387
+ message: `Failed to create virtual environment under ${relativeVenv(projectRoot, venvDir)}.`,
92
388
  checklist: ['Install python3-venv or uv: `python3 -m venv --help` or `pip install uv`.'],
93
389
  };
94
390
  }
95
- // Install pytest-bdd into the sidecar venv
96
- const pipResult = await deps.runCmd(venvPip, ['install', 'pytest-bdd'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
97
- if (pipResult.code === 0 || existsSync(venvPytest)) {
98
- return { status: 'provisioned' };
391
+ const installed = existsSync(venvPytest) || (await pipInstall(deps, projectRoot, venvPython, ['pytest-bdd'])).ok;
392
+ if (!installed && !existsSync(venvPytest)) {
393
+ return {
394
+ status: 'fixable',
395
+ message: `Failed to install pytest-bdd into ${relativeVenv(projectRoot, venvDir)}.`,
396
+ checklist: [`Run \`${venvPython} -m pip install pytest-bdd\` manually to provision the runner.`],
397
+ };
99
398
  }
100
- return {
101
- status: 'fixable',
102
- message: 'Failed to install pytest-bdd into .unitbob/behavioral/.venv.',
103
- checklist: [`Run \`${venvPip} install pytest-bdd\` manually to provision the runner.`],
104
- };
399
+ return built.requirementsNote
400
+ ? { status: 'provisioned', checklist: [built.requirementsNote] }
401
+ : { status: 'provisioned' };
402
+ }
403
+ function relativeVenv(projectRoot, venvDir) {
404
+ return venvDir.startsWith(projectRoot) ? venvDir.slice(projectRoot.length + 1) : venvDir;
105
405
  }
106
406
  async function provisionJs(projectRoot, behavioralDir, deps) {
107
407
  const sidecarPkg = join(behavioralDir, 'package.json');
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
4
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
5
+ import { locateRunner } from "./toolchain.js";
5
6
  import { readReport } from "./types.js";
6
7
  export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
7
8
  export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
@@ -11,19 +12,34 @@ export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
11
12
  // never part of the suite digest.
12
13
  export const PYTEST_INI_FILE = join('.unitbob', 'pytest.ini');
13
14
  export const PYTEST_INI = '[pytest]\naddopts =\n';
14
- // Run the materialised Unitbob guardrail suite with pytest in the current
15
- // Python environment (spec 30) — no guessing at Poetry/uv/virtualenv wrappers.
16
- // Only the guardrail file runs; the JUnit XML report goes to --junit-xml, not
17
- // stdout. The command is connector-owned: the suite artifact never carries a
18
- // command string.
19
- export async function runPytestSuite(projectRoot, suitePath) {
15
+ // Run the materialised Unitbob guardrail suite with pytest (spec 30) — no
16
+ // guessing at Poetry/uv/virtualenv wrappers. Only the guardrail files run; the
17
+ // JUnit XML report goes to --junit-xml, not stdout. The command is
18
+ // connector-owned: the suite artifact never carries a command string.
19
+ //
20
+ // Every file of the branch is named positionally (spec 42, §6.5) — a branch is
21
+ // one file per assignment now, and pytest takes as many paths as it is given.
22
+ //
23
+ // Which pytest is a single question answered in one place (`locateRunner`), so
24
+ // the precheck, the boot check and this run can never end up talking about
25
+ // different interpreters. `python` is the last resort when nothing was found:
26
+ // spawning it produces the honest "No module named pytest" rather than a silent
27
+ // no-op, and the checks upstream have already had their chance to say so first.
28
+ export async function runPytestSuite(projectRoot, suitePaths) {
20
29
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
21
- const command = await pickPython(projectRoot);
22
- const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, suitePath, `--junit-xml=${PYTEST_RESULT_FILE}`];
30
+ const located = locateRunner(projectRoot, 'pytest');
31
+ const command = located?.command ?? 'python';
32
+ const args = [
33
+ ...(located?.args ?? ['-m', 'pytest']),
34
+ '-c',
35
+ PYTEST_INI_FILE,
36
+ ...suitePaths,
37
+ `--junit-xml=${PYTEST_RESULT_FILE}`,
38
+ ];
23
39
  const result = await runProcess(command, args, {
24
40
  cwd: projectRoot,
25
41
  timeoutMs: PYTEST_TIMEOUT_MS,
26
- env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
42
+ env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
27
43
  });
28
44
  return {
29
45
  ...result,
@@ -33,14 +49,3 @@ export async function runPytestSuite(projectRoot, suitePath) {
33
49
  report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
34
50
  };
35
51
  }
36
- // The interpreter that can actually run pytest: `python3` when pytest imports
37
- // there (macOS/Linux ship no bare `python`), else `python`. Probing `-m pytest`
38
- // rather than just `--version` keeps this in step with pytestPrecheck, so the
39
- // run uses the same interpreter the precheck confirmed.
40
- async function pickPython(projectRoot) {
41
- const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
42
- cwd: projectRoot,
43
- timeoutMs: 10_000,
44
- }).catch(() => ({ stdout: '', stderr: '', code: 1 }));
45
- return probe.code === 0 ? 'python3' : 'python';
46
- }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
- import { executable, runProcess } from "../proc.js";
2
+ import { runProcess } from "../proc.js";
3
3
  import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
4
+ import { locateRunner } from "./toolchain.js";
4
5
  import { readReport } from "./types.js";
5
6
  export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
6
7
  // Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
@@ -8,17 +9,23 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
8
9
  // the project's random ordering.
9
10
  export const RSPEC_SEED = '1';
10
11
  export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
11
- // Run the materialised Unitbob guardrail suite (spec 26). Only this file runs
12
+ // Run the materialised Unitbob guardrail suite (spec 26). Only these files run
12
13
  // never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
13
14
  // --options points at the materialized empty file so the project's own .rspec
14
15
  // (a --require of a helper we replaced, an extra stdout formatter) can neither
15
16
  // break the boot nor corrupt the JSON output. The JSON report goes to `--out`
16
17
  // (a file), not stdout, so the app's own stdout writes during the run can never
17
- // corrupt it. `suitePath` is the suite blob's own project-relative path.
18
- export async function runRspecSuite(projectRoot, suitePath) {
18
+ // corrupt it.
19
+ //
20
+ // `suitePaths` is every file of the branch in the suite blob's own
21
+ // project-relative form (spec 42, §6.5). Named one by one rather than as a
22
+ // directory: the artifact already says exactly which files it is, while a
23
+ // directory would also collect whatever else happens to be sitting under the
24
+ // root.
25
+ export async function runRspecSuite(projectRoot, suitePaths) {
19
26
  const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
20
27
  const { result, command, args } = await invokeRspec(projectRoot, [
21
- suitePath,
28
+ ...suitePaths,
22
29
  '--options',
23
30
  optionsPath,
24
31
  '--order',
@@ -38,18 +45,19 @@ export async function runRspecSuite(projectRoot, suitePath) {
38
45
  report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
39
46
  };
40
47
  }
41
- // Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
48
+ // Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
49
+ // binstub, or `bundle exec rspec` — is decided once, in `locateRunner`, so this
50
+ // run and the checks that predicted it always mean the same installation. Every
42
51
  // run sets RAILS_ENV=test so guardrails execute against the Rails test
43
52
  // environment the project's `rails_helper` configures.
44
53
  async function invokeRspec(projectRoot, rspecArgs) {
45
- const localRspec = join(projectRoot, 'bin', 'rspec');
46
- const hasLocalRspec = executable(localRspec);
47
- const command = hasLocalRspec ? localRspec : 'bundle';
48
- const args = hasLocalRspec ? rspecArgs : ['exec', 'rspec', ...rspecArgs];
54
+ const located = locateRunner(projectRoot, 'rspec');
55
+ const command = located?.command ?? 'bundle';
56
+ const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
49
57
  const result = await runProcess(command, args, {
50
58
  cwd: projectRoot,
51
59
  timeoutMs: RSPEC_TIMEOUT_MS,
52
- env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
60
+ env: { ...process.env, ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
53
61
  });
54
62
  return { result, command, args };
55
63
  }
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { executable } from "../proc.js";
5
+ // Where Unitbob keeps a test runner it had to install for itself, together with
6
+ // whatever that runner needs to load the project.
7
+ //
8
+ // Deliberately not `.unitbob/structural/`: `materializeGuardrails` deletes that
9
+ // directory before every suite write, so an environment installed there would be
10
+ // rebuilt on every single run. Deliberately not the project's own dependency
11
+ // files either — the whole point is that a vibecoder's repository looks exactly
12
+ // the same after Unitbob has run as it did before. `.unitbob/` is already in
13
+ // their .gitignore (see `ensureUnitbobIgnored`).
14
+ export const SIDECAR_DIR = '.unitbob/runners';
15
+ export function sidecarPath(projectRoot, ...segments) {
16
+ return join(projectRoot, SIDECAR_DIR, ...segments);
17
+ }
18
+ export const defaultToolDeps = {
19
+ commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
20
+ };
21
+ // How to invoke `runner` in this project, or null when nothing here can.
22
+ //
23
+ // The sidecar wins when it exists, and that order is deliberate. A sidecar is
24
+ // only ever built because the project could not supply the runner itself, so
25
+ // preferring it keeps every later run on the same environment the build was
26
+ // prepared against. The alternative — asking the project first, every time —
27
+ // lets a stray `pip install pytest` between two runs move the suite to a
28
+ // different interpreter without anybody choosing that, and a guardrail suite
29
+ // whose meaning is "green means fine" cannot afford a silent environment flip.
30
+ export function locateRunner(projectRoot, runner, deps = defaultToolDeps) {
31
+ switch (runner) {
32
+ case 'pytest':
33
+ return locatePytest(projectRoot, deps);
34
+ case 'vitest':
35
+ return locateVitest(projectRoot);
36
+ case 'rspec':
37
+ return locateRspec(projectRoot);
38
+ default:
39
+ return null;
40
+ }
41
+ }
42
+ // Is a runner available at all — from the project or from a sidecar?
43
+ export function runnerAvailable(projectRoot, runner, deps = defaultToolDeps) {
44
+ return locateRunner(projectRoot, runner, deps) !== null;
45
+ }
46
+ // Does the project supply this runner on its own, with no help from us? This is
47
+ // the question provisioning asks before it builds anything: a project that is
48
+ // already set up is left completely alone.
49
+ //
50
+ // Ruby answers from the Gemfile rather than from `locateRspec`, which always has
51
+ // a `bundle exec rspec` to offer whether or not the gem behind it exists.
52
+ export function projectProvidesRunner(projectRoot, runner, deps = defaultToolDeps) {
53
+ if (runner === 'rspec') {
54
+ return hasGemfileWith(projectRoot, /\brails\b/) && hasGemfileWith(projectRoot, /\brspec-rails\b/);
55
+ }
56
+ return locateRunner(projectRoot, runner, deps)?.source === 'project';
57
+ }
58
+ // Does one of the project's Gemfiles mention this? Shared by the Ruby precheck
59
+ // and by the question above, so "is this a Rails project" is answered the same
60
+ // way wherever it is asked.
61
+ export function hasGemfileWith(projectRoot, pattern) {
62
+ for (const name of ['Gemfile', 'gems.rb']) {
63
+ const path = join(projectRoot, name);
64
+ if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ // Python: an interpreter that can actually import pytest. The sidecar venv is
70
+ // checked by file, the project's interpreters by asking them — `python3 -m
71
+ // pytest --version` is the same question the run itself asks, so the two can
72
+ // never disagree about which interpreter is usable.
73
+ function locatePytest(projectRoot, deps) {
74
+ // The sidecar interpreter is asked the same question as any other, rather
75
+ // than trusted because the file is there. A virtualenv can exist and still
76
+ // have no pytest in it — `uv venv` creates one with no pip at all, so an
77
+ // install into it can fail while leaving a perfectly good `bin/python`
78
+ // behind. Trusting the file made provisioning report success and the boot
79
+ // check then say "No module named pytest" about an environment we had just
80
+ // built. Found on a Flask project, 2026-08-12.
81
+ const venvPython = sidecarPath(projectRoot, '.venv', 'bin', 'python');
82
+ if (executable(venvPython) && deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
83
+ return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
84
+ }
85
+ for (const python of ['python3', 'python']) {
86
+ if (deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot)) {
87
+ return { command: python, args: ['-m', 'pytest'], source: 'project' };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ // JS/TS: a vitest binary we can spawn. `npx` is not offered here — it would
93
+ // install a package to answer a question, and this function's callers include
94
+ // checks that must not install anything. The vitest runner keeps `npx` as its
95
+ // own last resort, which is the behaviour it has always had.
96
+ function locateVitest(projectRoot) {
97
+ const sidecar = sidecarPath(projectRoot, 'node_modules', '.bin', 'vitest');
98
+ if (executable(sidecar))
99
+ return { command: sidecar, args: [], source: 'sidecar' };
100
+ const project = join(projectRoot, 'node_modules', '.bin', 'vitest');
101
+ if (executable(project))
102
+ return { command: project, args: [], source: 'project' };
103
+ return null;
104
+ }
105
+ // Ruby: bundler decides what "rspec" means, so the sidecar is selected by
106
+ // pointing BUNDLE_GEMFILE at the sidecar Gemfile rather than by a different
107
+ // binary. The project's own `bin/rspec` binstub is preferred over `bundle exec`
108
+ // when it is there, exactly as the rspec runner has always preferred it.
109
+ function locateRspec(projectRoot) {
110
+ if (existsSync(sidecarPath(projectRoot, 'Gemfile'))) {
111
+ return {
112
+ command: 'bundle',
113
+ args: ['exec', 'rspec'],
114
+ env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile` },
115
+ source: 'sidecar',
116
+ };
117
+ }
118
+ const binstub = join(projectRoot, 'bin', 'rspec');
119
+ if (executable(binstub))
120
+ return { command: binstub, args: [], source: 'project' };
121
+ return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
122
+ }