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.
- package/dist/cli.js +4 -2
- package/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +20 -4
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +33 -19
- package/dist/proc.js +15 -0
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/failureDigest.js +238 -0
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +1 -1
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +69 -23
- package/dist/verbs/suitePrepare.js +49 -15
- package/dist/verbs/suiteReviewPrepare.js +0 -22
- package/dist/verbs/validateBuild.js +140 -450
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +18 -6
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +37 -20
- package/dist/files/budget.js +0 -74
package/dist/runner/precheck.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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,
|
|
15
|
-
return STRUCTURAL_RUNNERS.find((runner) =>
|
|
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
|
-
|
|
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
|
-
`${
|
|
176
|
+
`${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
|
|
109
177
|
};
|
|
110
178
|
}
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// the
|
|
177
|
-
|
|
178
|
-
if (!
|
|
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
|
-
}
|
package/dist/runner/provision.js
CHANGED
|
@@ -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, {
|
|
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
|
|
375
|
+
const venvPython = join(venvDir, 'bin', 'python');
|
|
75
376
|
const venvPytest = join(venvDir, 'bin', 'pytest');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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: '
|
|
102
|
-
|
|
103
|
-
|
|
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');
|