unitbob 0.4.4 → 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,18 +1,36 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { existsSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
- const defaultDeps = {
5
- commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
6
- };
3
+ import { defaultToolDeps, hasGemfileWith, locateRunner, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, } from "./toolchain.js";
4
+ const defaultDeps = defaultToolDeps;
7
5
  const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
8
6
  // Tried in this order, so a project carrying markers for more than one stack
9
7
  // resolves to the same runner on every run.
10
8
  const STRUCTURAL_RUNNERS = ['rspec', 'vitest', 'pytest'];
9
+ // The file that says "this project is written in this language". Nothing here
10
+ // asks whether the runner is installed — that is a separate question with a
11
+ // separate answer, and merging the two is what made this gate lie.
12
+ //
13
+ // A project whose language is obvious but whose runner is missing used to fail
14
+ // detection, and the caller then reported the only thing it had left: "this
15
+ // project matches none of those stacks". That sentence was false — the project
16
+ // was Python, it simply had no pytest — and it sent people to look for a problem
17
+ // with their project instead of at the one command that fixes it. The runner is
18
+ // now provisioned under `.unitbob/` (see `ensureStructuralRunner`), so the
19
+ // question this gate answers is the one it can answer honestly: which language.
20
+ const PYTHON_MARKERS = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
21
+ function looksLikePython(projectRoot) {
22
+ return PYTHON_MARKERS.some((name) => existsSync(join(projectRoot, name)));
23
+ }
24
+ const STACK_MARKERS = {
25
+ rspec: (projectRoot) => hasGemfileWith(projectRoot, /\brails\b/),
26
+ vitest: (projectRoot) => existsSync(join(projectRoot, 'package.json')),
27
+ pytest: looksLikePython,
28
+ };
11
29
  // Which structural runner this project's markers select, or null when none do.
12
30
  // The gate below walks the same list: "is any stack present" and "which one is
13
31
  // it" must never be able to disagree.
14
- export function detectStructuralRunner(projectRoot, deps = defaultDeps) {
15
- return STRUCTURAL_RUNNERS.find((runner) => validateStack(projectRoot, runner, deps).ok) ?? null;
32
+ export function detectStructuralRunner(projectRoot, _deps = defaultDeps) {
33
+ return STRUCTURAL_RUNNERS.find((runner) => STACK_MARKERS[runner]?.(projectRoot)) ?? null;
16
34
  }
17
35
  // The BDD runner for a structural stack. One project, one language: the
18
36
  // behavioral peer follows the stack already detected instead of probing the
@@ -33,7 +51,10 @@ export function detectBddRunner(projectRoot, deps = defaultDeps) {
33
51
  const structural = detectStructuralRunner(projectRoot, deps);
34
52
  return structural ? BDD_RUNNER_FOR_STACK[structural] ?? null : null;
35
53
  }
36
- // The generation-time gate: at least one supported stack must be present.
54
+ // The generation-time gate: at least one supported stack must be present. It
55
+ // says nothing about whether the runner is installed, because by the time that
56
+ // matters the runner has been provisioned; `runnerReadyPrecheck` is the check
57
+ // for that, and it runs straight after provisioning.
37
58
  export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
38
59
  const runner = detectStructuralRunner(projectRoot, deps);
39
60
  if (runner !== null)
@@ -43,6 +64,54 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
43
64
  message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
44
65
  };
45
66
  }
67
+ // Is the runner startable now, after provisioning has had its turn?
68
+ //
69
+ // Not the same question as `validateStack`, and the difference is the sidecar.
70
+ // `validateStack` asks whether the *project* is set up for a stack — the right
71
+ // question when a host has chosen one and nothing has been installed yet. This
72
+ // asks whether anything on this machine can start the runner, which includes
73
+ // the environment Unitbob just built under `.unitbob/`.
74
+ //
75
+ // Asking the first question in the second's place refuses a project we have
76
+ // only just finished preparing: a JS project with no vitest of its own was
77
+ // told to `npm i -D vitest` seconds after a working vitest was installed for
78
+ // it. Found on the connector's own repository, 2026-08-12.
79
+ export function runnerReadyPrecheck(projectRoot, runner, deps = defaultDeps) {
80
+ // Ruby is the one stack whose lookup can never come back empty — `bundle exec
81
+ // rspec` is always a command one could type — so readiness is the gem being
82
+ // resolvable, from the sidecar Gemfile or from the project's own.
83
+ const ready = runner === 'rspec'
84
+ ? locateRunner(projectRoot, 'rspec')?.source === 'sidecar' || projectProvidesRunner(projectRoot, 'rspec', deps)
85
+ : runnerAvailable(projectRoot, runner, deps);
86
+ if (ready)
87
+ return { ok: true };
88
+ return {
89
+ ok: false,
90
+ message: `The ${runner} runner is not available: it is not installed in this project, and Unitbob could ` +
91
+ `not install one for itself under ${SIDECAR_DIR}/. Nothing was written and nothing was uploaded.`,
92
+ };
93
+ }
94
+ // Has Unitbob built this runner for this project already? One question, asked
95
+ // the same way by every precheck that would otherwise advise the user to install
96
+ // something they now have.
97
+ //
98
+ // Ruby needs the second half. Its sidecar is a Gemfile, and `provisionRspec`
99
+ // writes that file *before* it runs bundler and leaves it in place when bundler
100
+ // fails — so its mere existence says "we tried", not "it is there", and taking
101
+ // it as proof would silence the rspec-rails advice exactly when the install
102
+ // failed and the advice is what the user needs. Bundler rewrites the sidecar
103
+ // lock on success, and the project's own lock cannot name a gem its Gemfile
104
+ // lacks, so the gem appearing there is the first artefact that means it
105
+ // resolves. The vitest side needs nothing extra: `locateVitest` tests the actual
106
+ // `.bin/vitest` executable.
107
+ function sidecarProvides(projectRoot, runner, deps = defaultDeps) {
108
+ if (locateRunner(projectRoot, runner, deps)?.source !== 'sidecar')
109
+ return false;
110
+ if (runner !== 'rspec')
111
+ return true;
112
+ const lock = join(projectRoot, SIDECAR_DIR, 'Gemfile.lock');
113
+ return existsSync(lock) && /\brspec-rails\s+\(/.test(readFileSync(lock, 'utf8'));
114
+ }
46
115
  // Confirm the host-selected runner against local markers. A mismatch fails
47
116
  // closed: the caller writes no files and uploads nothing.
48
117
  //
@@ -56,9 +125,9 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
56
125
  export function validateStack(projectRoot, runner, deps = defaultDeps) {
57
126
  switch (runner) {
58
127
  case 'rspec':
59
- return rubyPrecheck(projectRoot);
128
+ return rubyPrecheck(projectRoot, deps);
60
129
  case 'vitest':
61
- return vitestPrecheck(projectRoot);
130
+ return vitestPrecheck(projectRoot, deps);
62
131
  case 'pytest':
63
132
  return pytestPrecheck(projectRoot, deps);
64
133
  case 'cucumber':
@@ -100,16 +169,14 @@ function jsBehavioralPrecheck(projectRoot) {
100
169
  // and message shape as the structural pytest precheck; pytest-bdd itself, if
101
170
  // missing, surfaces as a suite error from the run.
102
171
  function pythonBehavioralPrecheck(projectRoot, deps) {
103
- const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
104
- if (!markers.some((name) => existsSync(join(projectRoot, name)))) {
172
+ if (!looksLikePython(projectRoot)) {
105
173
  return {
106
174
  ok: false,
107
175
  message: 'The behavioral (Gherkin) suite selected the Python stack, but this project has none of ' +
108
- `${markers.join(', ')} — it does not look like a Python project.`,
176
+ `${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
109
177
  };
110
178
  }
111
- const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
112
- if (!available) {
179
+ if (!runnerAvailable(projectRoot, 'pytest', deps)) {
113
180
  return {
114
181
  ok: false,
115
182
  message: 'The behavioral (Gherkin) suite selected the Python stack, but pytest is not importable in ' +
@@ -119,7 +186,7 @@ function pythonBehavioralPrecheck(projectRoot, deps) {
119
186
  }
120
187
  return { ok: true };
121
188
  }
122
- function rubyPrecheck(projectRoot) {
189
+ function rubyPrecheck(projectRoot, deps) {
123
190
  if (!hasGemfileWith(projectRoot, /\brails\b/)) {
124
191
  return {
125
192
  ok: false,
@@ -129,7 +196,13 @@ function rubyPrecheck(projectRoot) {
129
196
  }
130
197
  // Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
131
198
  // bare `rspec` gem passes nothing downstream — stop with the honest offer.
132
- if (!hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
199
+ //
200
+ // Unless Unitbob already installed one for itself. The advice below asks the
201
+ // user to change their Gemfile, and that is the wrong sentence seconds after a
202
+ // working runner was provisioned under `.unitbob/`. `runnerReadyPrecheck`
203
+ // already knows this; this function used to send the reader back to the old
204
+ // answer regardless.
205
+ if (!sidecarProvides(projectRoot, 'rspec', deps) && !hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
133
206
  return {
134
207
  ok: false,
135
208
  message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
@@ -139,7 +212,7 @@ function rubyPrecheck(projectRoot) {
139
212
  }
140
213
  return { ok: true };
141
214
  }
142
- function vitestPrecheck(projectRoot) {
215
+ function vitestPrecheck(projectRoot, deps) {
143
216
  const packageJson = join(projectRoot, 'package.json');
144
217
  if (!existsSync(packageJson)) {
145
218
  return {
@@ -147,7 +220,12 @@ function vitestPrecheck(projectRoot) {
147
220
  message: 'The JavaScript/TypeScript stack was selected, but this project has no package.json.',
148
221
  };
149
222
  }
150
- const hasVitest = /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
223
+ // Same rule as the Ruby precheck above: a runner Unitbob installed for this
224
+ // project is a runner this project has. Without this the connector's own
225
+ // repository was told to `npm i -D vitest` seconds after a working vitest had
226
+ // been put under `.unitbob/` for it.
227
+ const hasVitest = sidecarProvides(projectRoot, 'vitest', deps) ||
228
+ /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
151
229
  existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
152
230
  if (!hasVitest) {
153
231
  return {
@@ -160,22 +238,20 @@ function vitestPrecheck(projectRoot) {
160
238
  return { ok: true };
161
239
  }
162
240
  function pytestPrecheck(projectRoot, deps) {
163
- const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
164
- const found = markers.some((name) => existsSync(join(projectRoot, name)));
165
- if (!found) {
241
+ if (!looksLikePython(projectRoot)) {
166
242
  return {
167
243
  ok: false,
168
244
  message: 'The Python stack was selected, but this project has none of ' +
169
- `${markers.join(', ')} — it does not look like a Python project.`,
245
+ `${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
170
246
  };
171
247
  }
172
248
  // Spec 30 fails closed on runner availability: unlike marker files, pytest
173
- // must actually be importable in the current interpreter, or every run would
174
- // end as a "No module named pytest" suite error after files were written. We
175
- // probe the same interpreters the pytest runner tries, in the same order, so
176
- // the precheck and the run agree on whether pytest is runnable.
177
- const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
178
- if (!available) {
249
+ // must actually be importable, or every run would end as a "No module named
250
+ // pytest" suite error after files were written. `runnerAvailable` asks the
251
+ // same question the run asks, of the same environments in the same order
252
+ // the sidecar under `.unitbob/` first, then the machine's own interpreters
253
+ // so the check and the run can never disagree about what is runnable.
254
+ if (!runnerAvailable(projectRoot, 'pytest', deps)) {
179
255
  return {
180
256
  ok: false,
181
257
  message: 'The Python stack was selected, but pytest is not importable in the current Python ' +
@@ -186,11 +262,3 @@ function pytestPrecheck(projectRoot, deps) {
186
262
  }
187
263
  return { ok: true };
188
264
  }
189
- function hasGemfileWith(projectRoot, pattern) {
190
- for (const name of ['Gemfile', 'gems.rb']) {
191
- const path = join(projectRoot, name);
192
- if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
193
- return true;
194
- }
195
- return false;
196
- }
@@ -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');