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.
- package/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +16 -1
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +20 -3
- package/dist/runner/bootcheck.js +47 -9
- 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 +19 -7
- package/dist/verbs/suitePrepare.js +43 -7
- package/dist/verbs/validateBuild.js +140 -456
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +9 -3
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');
|
package/dist/runner/pytest.js
CHANGED
|
@@ -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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
|
22
|
-
const
|
|
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
|
-
}
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
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
|
|
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.
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const
|
|
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
|
+
}
|