unitbob 0.5.0 → 0.6.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.
@@ -0,0 +1,181 @@
1
+ // Where this project's own processes start (spec 36).
2
+ //
3
+ // A mature team's test environment does not live on the machine the connector
4
+ // was called on: the code is on the host, and the interpreter, the packages and
5
+ // the database are inside a container. So "run it here" stops being a fact and
6
+ // becomes a question, and this module is the one place that answers it.
7
+ //
8
+ // The invariant the whole spec is built on:
9
+ //
10
+ // Files stay on the host. Processes run where the dependencies live.
11
+ //
12
+ // Everything the connector reads and writes — request packets, suite files, run
13
+ // reports — it reads and writes on the host with ordinary filesystem calls. Only
14
+ // the processes that need the project's own toolchain travel.
15
+ //
16
+ // Which processes those are is not decided here and is deliberately not decided
17
+ // by a mode, a flag, or a global. It is decided at each call site by which
18
+ // function it calls: `runInProject` for a command that needs the project's
19
+ // dependencies, `runProcess` for a tool the connector brought with it (graphify
20
+ // is installed on the vibecoder's machine and is nowhere to be found inside
21
+ // somebody else's image). The rule is visible in the call itself, so the next
22
+ // reader sees the boundary without reading this file.
23
+ //
24
+ // The place itself is a property of the *project*, not of the run: it is written
25
+ // in `.unitbob.json` at the project root. So this module works it out from the
26
+ // project root it is handed, and no caller sets it, passes it, or can forget to.
27
+ import { spawnSync } from 'node:child_process';
28
+ import { readLocalExecContainer } from "../config.js";
29
+ import { runProcess } from "../proc.js";
30
+ import { containerProjectRoot, dockerExec, dockerOwnFailure } from "./docker.js";
31
+ // The place this project's processes run in.
32
+ //
33
+ // Read from the project on every call rather than resolved once into a module
34
+ // variable. A setter would be cheap and would introduce exactly the failure this
35
+ // spec exists to remove: a new verb forgets to call it, the connector quietly
36
+ // runs on the host, and nobody finds out.
37
+ export function placeOf(projectRoot) {
38
+ const container = readLocalExecContainer(projectRoot);
39
+ return container ? { kind: 'docker', container } : { kind: 'local' };
40
+ }
41
+ // How this place is written down in `.unitbob/.place`, so a runner environment
42
+ // built by one place is never mistaken for one built by another.
43
+ const DOCKER_MARK = 'docker:';
44
+ export function placeId(place) {
45
+ return place.kind === 'docker' ? `${DOCKER_MARK}${place.container}` : 'local';
46
+ }
47
+ // The same mark in words, for the one message that has to name both the place an
48
+ // environment was built in and the place this run happens in. It lives beside
49
+ // `placeId` because a format written in one file and taken apart in another is a
50
+ // format that drifts.
51
+ export function describePlaceId(id) {
52
+ return id.startsWith(DOCKER_MARK) ? `the container \`${id.slice(DOCKER_MARK.length)}\`` : 'this machine';
53
+ }
54
+ // The project root as the place sees it.
55
+ //
56
+ // Exactly one value in the system has to know where the root is —
57
+ // `UNITBOB_REPO_ROOT`, which the generated Ruby joins `config/environment` onto.
58
+ // It is built from the place rather than rewritten from a host path, so on the
59
+ // local place it is byte for byte the path it has always been.
60
+ export function projectRootAsSeenByThePlace(projectRoot) {
61
+ const place = placeOf(projectRoot);
62
+ if (place.kind === 'local')
63
+ return projectRoot;
64
+ const lookup = containerProjectRoot(place.container, projectRoot);
65
+ if (lookup.status === 'ok')
66
+ return lookup.projectRoot;
67
+ // Every command that must not run blind has been through `placeProblem`
68
+ // first. The one that has not is `map-prepare`, which asks the router and is
69
+ // allowed to fail — it degrades to one line and the map is built from source
70
+ // as it always was. So this returns something rather than throwing out of what
71
+ // reads like a getter: the command it belongs to is not going to run anyway.
72
+ return projectRoot;
73
+ }
74
+ // Run one of the project's own commands, in the project's own root.
75
+ //
76
+ // The working directory is always the project root, and that is the discipline
77
+ // that keeps host paths out of commands rather than a rule that rewrites them:
78
+ // with the root as the working directory, every argument a command needs can be
79
+ // relative, and a relative argument means the same thing in every place.
80
+ export async function runInProject(projectRoot, command, args, options = {}) {
81
+ const place = placeOf(projectRoot);
82
+ if (place.kind === 'local') {
83
+ const result = await runProcess(command, args, {
84
+ cwd: projectRoot,
85
+ timeoutMs: options.timeoutMs,
86
+ env: { ...process.env, ...options.env },
87
+ });
88
+ return { ...result, command, args };
89
+ }
90
+ const shaped = shapeForContainer(place.container, projectRoot, command, args, options.env ?? {});
91
+ const result = await runProcess(shaped.command, shaped.args, {
92
+ cwd: projectRoot,
93
+ timeoutMs: options.timeoutMs,
94
+ // The environment of the `docker` client, not of the command: everything the
95
+ // command is meant to see was listed with `-e` above. This machine's own
96
+ // PATH and HOME never travel — a container has a PATH of its own, and
97
+ // overwriting it hides the very `bundle` the image was built with.
98
+ env: process.env,
99
+ });
100
+ const failure = dockerOwnFailure(result);
101
+ return { ...result, ...(failure ? { placeFailure: failure } : {}), command: shaped.command, args: shaped.args };
102
+ }
103
+ function shapeForContainer(container, projectRoot, command, args, env) {
104
+ const lookup = containerProjectRoot(container, projectRoot);
105
+ // `/` is not a working directory anything would succeed in, and that is the
106
+ // point: the lookup has already failed, every caller of consequence has been
107
+ // through `ensurePlaceIsUsable`, and docker's own refusal is a better answer
108
+ // than a path this connector made up.
109
+ const workingDirectory = lookup.status === 'ok' ? lookup.projectRoot : '/';
110
+ return dockerExec(container, workingDirectory, env, command, args);
111
+ }
112
+ // Can work happen here at all? Asked before anything is written and before
113
+ // anything is uploaded (spec 36, criterion 7), because the alternative is a
114
+ // green run whose evidence disappears with the container.
115
+ //
116
+ // Returns the sentence to stop with, or null when the place is usable. Not a
117
+ // throw: two callers want to stop, one — the map — wants to degrade quietly, and
118
+ // the difference belongs to them.
119
+ export function placeProblem(projectRoot) {
120
+ const place = placeOf(projectRoot);
121
+ if (place.kind === 'local')
122
+ return null;
123
+ return explain(place.container, containerProjectRoot(place.container, projectRoot));
124
+ }
125
+ function explain(container, lookup) {
126
+ switch (lookup.status) {
127
+ case 'ok':
128
+ return null;
129
+ case 'no_docker':
130
+ return (`This project is set to run its tests inside the container \`${container}\`, but \`docker\` is not ` +
131
+ 'available on this machine — either it is not installed or its daemon is not running. Start Docker, ' +
132
+ `or remove \`"exec"\` from ${CONFIG_HINT} to run everything here instead.`);
133
+ case 'no_container':
134
+ return (`This project is set to run its tests inside the container \`${container}\`, and no container of that ` +
135
+ 'name exists here. Check the name with `docker ps --format "{{.Names}}"` and correct it in ' +
136
+ `${CONFIG_HINT}.`);
137
+ case 'not_running':
138
+ return (`The container \`${container}\` exists but is not running, and this project's tests are set to run ` +
139
+ `inside it. Start it (\`docker start ${container}\`, or \`docker compose up -d\`), then run this again.`);
140
+ case 'not_mounted':
141
+ return (`The container \`${container}\` is running, but this project's folder is not mounted into it — the ` +
142
+ 'code inside it was copied in when the image was built. Unitbob cannot work that way: it writes the ' +
143
+ 'suite here and the run has to read it there, and a report written inside a container that is not ' +
144
+ 'sharing this folder disappears with the container. Mount the project into the container (a `volumes:` ' +
145
+ 'entry in your compose file) and run this again. Nothing was written and nothing was uploaded.');
146
+ case 'unreadable':
147
+ return (`Could not ask docker about the container \`${container}\`, which is where this project's tests are ` +
148
+ `set to run: ${lookup.detail}`);
149
+ }
150
+ }
151
+ const CONFIG_HINT = '`.unitbob.json`';
152
+ // "Can this be started at all?" — asked of the same place that will start it.
153
+ //
154
+ // Kept next to `runInProject` rather than left at its old call site, because a
155
+ // check that asks this machine while the run happens somewhere else is a check
156
+ // that predicts the wrong thing. It is the same reason `runnerAvailable` already
157
+ // asks the same interpreters, in the same order, that the run itself asks.
158
+ // One known limitation, written down rather than hidden: the answer is a
159
+ // boolean, so a container that died between the preflight and this question
160
+ // comes back as "no, that cannot be started" — the same conflation `place_failed`
161
+ // was added to the boot check to stop making. It stays a boolean because every
162
+ // caller of `locateRunner` is synchronous and takes yes or no; what keeps it from
163
+ // being the ordinary case is `placeProblem`, which runs first for every command
164
+ // that would act on the answer.
165
+ export function commandSucceedsInProject(projectRoot, command, args) {
166
+ const place = placeOf(projectRoot);
167
+ const shaped = place.kind === 'local'
168
+ ? { command, args }
169
+ : shapeForContainer(place.container, projectRoot, command, args, {});
170
+ const result = spawnSync(shaped.command, shaped.args, {
171
+ cwd: projectRoot,
172
+ // A round trip through the docker daemon is not the same wait as starting a
173
+ // local binary: the client has to reach the daemon, and the daemon has to
174
+ // start a process in a container that may be busy running the application.
175
+ // Ten seconds is right for `python3 -m pytest --version` here and is the
176
+ // kind of budget that turns a slow machine into "no runner available".
177
+ timeout: place.kind === 'local' ? 10_000 : 60_000,
178
+ env: { ...process.env },
179
+ });
180
+ return result.status === 0;
181
+ }
@@ -0,0 +1,52 @@
1
+ // What to say when this project's test toolchain cannot be started (spec 36, §7).
2
+ //
3
+ // Half the value of the whole spec is here rather than in the adapter. A field
4
+ // nobody discovers is a field nobody sets: the vibecoder whose gems live only
5
+ // inside a container reads "Bundler failed to provision rspec-rails under
6
+ // .unitbob/runners." — a sentence about a symptom, which sounds like "your
7
+ // project is broken" and sends them off inventing workarounds.
8
+ //
9
+ // Two situations, one question, so they live in one function: the place is not
10
+ // configured and something on this machine looks like the answer, or the place
11
+ // is configured and the advice above just told somebody to run a command in the
12
+ // wrong place.
13
+ import { containersHolding } from "./docker.js";
14
+ import { placeOf } from "./place.js";
15
+ // The sentence to add to a message that has already decided to stop, or null
16
+ // when there is nothing worth saying.
17
+ //
18
+ // Only ever called on the failure path. On a successful run `docker` is not
19
+ // asked anything, so nobody without Docker pays for this and nobody with Docker
20
+ // waits for it.
21
+ export function placeAdvice(projectRoot) {
22
+ const place = placeOf(projectRoot);
23
+ // Already running in a container. Then the problem is not "which place" — it
24
+ // is that every manual command above (`bundle install`, `npm install --prefix
25
+ // .unitbob/runners`, `gem install bundler`, "activate your virtualenv") reads
26
+ // as "do this in your project folder", and doing it here would change nothing.
27
+ // One sentence covers all of them, and none of them has to be rewritten.
28
+ if (place.kind === 'docker') {
29
+ return (`This project's tests run inside the container \`${place.container}\`, so any command suggested above ` +
30
+ `has to be run in there, not here: \`docker exec -it ${place.container} <command>\`.\n` +
31
+ // The two edge cases the spec asks to be named rather than solved. Both
32
+ // look like an ordinary install failure and neither is: nothing in the
33
+ // output says "the mount is read-only" or "these files belong to someone
34
+ // else", so the reader is left staring at a permission error with no
35
+ // reason to suspect the container is why.
36
+ 'If it failed on permissions rather than on a missing package, two things are worth checking: that ' +
37
+ 'the project is not mounted read-only, and — on a Linux host — that the files under `.unitbob/` are ' +
38
+ 'still yours, since a container writing there creates them as `root`.');
39
+ }
40
+ const candidates = containersHolding(projectRoot);
41
+ if (candidates.length === 0)
42
+ return null;
43
+ const lines = candidates.map((found) => ` \`${found.name}\` — it sees this project as \`${found.projectRoot}\`\n` +
44
+ ` "exec": {"docker": {"container": ${JSON.stringify(found.name)}}}`);
45
+ // Several candidates are listed and none is picked. Guessing between `web` and
46
+ // `worker` is wrong on the first project that has both, and a wrong guess here
47
+ // is silent: the suite runs somewhere nobody meant it to.
48
+ return ("This project's tests do not run on this machine, and they may not be meant to. " +
49
+ `${candidates.length === 1 ? 'A running container already has this project mounted' : 'These running containers already have this project mounted'}` +
50
+ `:\n\n${lines.join('\n')}\n\n` +
51
+ `Add the line under the container you run your tests in to \`.unitbob.json\`, then run this again.`);
52
+ }
@@ -0,0 +1,111 @@
1
+ // An installed environment belongs to the place that installed it (spec 36, §6).
2
+ //
3
+ // The invariant "files on the host, processes where the dependencies live" is
4
+ // true of text and false of installed packages. `.unitbob/runners/.venv` and
5
+ // `.unitbob/behavioral/node_modules` sit under the project, which is to say
6
+ // inside the mounted folder, which is to say both sides can see them — and they
7
+ // were built for one operating system. A virtualenv built on macOS is not a
8
+ // virtualenv inside a Linux container, however plainly it is there.
9
+ //
10
+ // And the connector cannot tell by looking: readiness is decided by a file being
11
+ // on disk (`provision.ts`, `existsSync(venvPython)`), never by starting it. So
12
+ // the main scenario of this whole spec ends badly without the mark below —
13
+ // somebody hits the wall on this machine (a `.venv` gets created), reads the
14
+ // hint, adds `exec`, runs again, and inside the container the macOS interpreter
15
+ // is taken for a finished environment. What follows is `pip` failing to start,
16
+ // its stderr discarded, and the advice to run a command that cannot run either.
17
+ //
18
+ // The cure is one mark, not a directory scheme per place: cheap to write, and
19
+ // nothing else in the tree has to know about it.
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { BEHAVIORAL_DIR, RUNNER_ENVIRONMENT_ENTRIES } from "../files/behavioral.js";
23
+ import { describePlaceId, placeId, placeOf } from "./place.js";
24
+ import { SIDECAR_DIR } from "./toolchain.js";
25
+ const PLACE_FILE = '.unitbob/.place';
26
+ // Throw away any runner environment that was built somewhere else, and record
27
+ // where this one is being built. Returns a line worth printing when something
28
+ // was actually removed, and null when nothing was.
29
+ //
30
+ // Called where an environment can be built again — `suite-prepare` — and
31
+ // nowhere else. Clearing it anywhere else would leave a run with no runner and
32
+ // no way to get one.
33
+ export function alignRunnerEnvironmentWithPlace(projectRoot) {
34
+ const current = placeId(placeOf(projectRoot));
35
+ const marked = readMark(projectRoot);
36
+ if (marked === current)
37
+ return null;
38
+ // A project that has never been marked was provisioned before this existed,
39
+ // which means it was provisioned on this machine. Saying so out loud is what
40
+ // makes "the mark is missing" a fact rather than a mystery.
41
+ const previous = marked ?? 'local';
42
+ const removed = previous === current ? [] : removeRunnerEnvironment(projectRoot);
43
+ writeMark(projectRoot, current);
44
+ if (removed.length === 0)
45
+ return null;
46
+ return (`The test runner installed under \`.unitbob/\` was built for ${describePlaceId(previous)}, and this run ` +
47
+ `happens in ${describePlaceId(current)}. An installed package is not portable between the two, so it was ` +
48
+ `removed and will be installed again here (${removed.join(', ')}). Your generated suite was not touched.`);
49
+ }
50
+ // The same question, asked by a command that cannot install anything — `check`
51
+ // and `run-local`. It refuses instead of clearing: throwing the environment away
52
+ // here would leave the run with no runner and no way to get one, which is a
53
+ // worse answer than naming the one command that fixes it.
54
+ //
55
+ // Criterion 6 is about not *accepting* a foreign environment as ready, and this
56
+ // is the half of it that lives outside `suite-prepare`. Without it, adding
57
+ // `exec` to a project whose suite already exists and running `check` starts a
58
+ // macOS interpreter inside a Linux container and reports whatever comes out.
59
+ export function runnerEnvironmentPlaceProblem(projectRoot) {
60
+ const current = placeId(placeOf(projectRoot));
61
+ const previous = readMark(projectRoot) ?? 'local';
62
+ if (previous === current)
63
+ return null;
64
+ // Nothing installed, nothing stale. A project whose runner has never been
65
+ // built has an honest reason to be here — `check` on a fresh checkout — and
66
+ // the missing runner is reported by the runner, in its own words.
67
+ const installed = installedRunnerEnvironment(projectRoot);
68
+ if (installed.length === 0)
69
+ return null;
70
+ return (`The test runner installed under \`.unitbob/\` was built for ${describePlaceId(previous)}, and this run ` +
71
+ `happens in ${describePlaceId(current)} (${installed.join(', ')}). An installed package is not portable ` +
72
+ 'between the two, and this command installs nothing. Run `unitbob suite-prepare` to build the runner ' +
73
+ 'where the run now happens, then try this again.');
74
+ }
75
+ function readMark(projectRoot) {
76
+ try {
77
+ const mark = readFileSync(join(projectRoot, PLACE_FILE), 'utf8').trim();
78
+ return mark.length > 0 ? mark : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ function writeMark(projectRoot, id) {
85
+ mkdirSync(join(projectRoot, '.unitbob'), { recursive: true });
86
+ writeFileSync(join(projectRoot, PLACE_FILE), `${id}\n`);
87
+ }
88
+ // The structural sidecar whole, and from the behavioral root only the entries
89
+ // that are an installed environment. That list already exists and already means
90
+ // exactly "this was installed, it is not generated text", so the suite the host
91
+ // wrote survives.
92
+ //
93
+ // One list, read by both the command that clears and the command that only
94
+ // complains, so the two can never disagree about what an installed environment
95
+ // is.
96
+ function installedRunnerEnvironment(projectRoot) {
97
+ const found = [];
98
+ if (existsSync(join(projectRoot, SIDECAR_DIR)))
99
+ found.push(`${SIDECAR_DIR}/`);
100
+ for (const entry of new Set(Object.values(RUNNER_ENVIRONMENT_ENTRIES).flatMap((set) => [...set]))) {
101
+ if (existsSync(join(projectRoot, BEHAVIORAL_DIR, entry)))
102
+ found.push(`${BEHAVIORAL_DIR}/${entry}`);
103
+ }
104
+ return found.sort();
105
+ }
106
+ function removeRunnerEnvironment(projectRoot) {
107
+ const installed = installedRunnerEnvironment(projectRoot);
108
+ for (const entry of installed)
109
+ rmSync(join(projectRoot, entry), { recursive: true, force: true });
110
+ return installed;
111
+ }
@@ -157,6 +157,41 @@ function rubyBehavioralPrecheck(projectRoot) {
157
157
  'like Rails (no `rails` gem found in Gemfile).',
158
158
  };
159
159
  }
160
+ // What the behavioral branch's environment is, before a single step is written
161
+ // (spec 35-1, criterion 2). None of the three BDD runners reads the project's own
162
+ // test bootstrap, so on every stack nothing a project's test setup switches on is
163
+ // on here — and a worker who assumes otherwise writes a test that quietly goes
164
+ // out to the real network.
165
+ //
166
+ // One sentence per runner, in that runner's own terms and stating only what is
167
+ // true of it. A shared sentence would have to be vague enough to fit all three,
168
+ // and vague is how the fact went unsaid in the first place.
169
+ const BEHAVIORAL_HARNESS_NOTICE = {
170
+ cucumber: 'Cucumber loads neither `spec/rails_helper.rb` nor `spec/support/**` — whatever your RSpec ' +
171
+ 'setup switches on is off here. The connector-owned World file turns on the part that is the ' +
172
+ 'same in every Rails app: WebMock is on and outgoing HTTP is blocked (localhost still ' +
173
+ 'reachable), Sidekiq is in fake mode, ActiveJob is on the test adapter, and the default URL ' +
174
+ 'host is fixed. Everything that depends on this application — signing in, factories, reading ' +
175
+ 'props, stubbing a provider — is yours to write in shared steps.',
176
+ 'cucumber-js': 'cucumber-js loads none of this project\'s test bootstrap — not your Vitest setup files, and ' +
177
+ 'not `features/support/`, which the connector\'s explicit `--require` switches off. Whatever ' +
178
+ 'your test setup switches on is off here. The connector-owned World file settles the one ' +
179
+ 'thing that is the same in every JavaScript project: a connection that would leave this ' +
180
+ 'machine is refused, localhost included in neither direction — it stays reachable. There is ' +
181
+ 'no project-wide job runner or default host to fix, so nothing else is assumed. Signing in, ' +
182
+ 'fixtures and seeding are yours to write in shared steps.',
183
+ 'pytest-bdd': 'pytest runs here against the connector\'s own config (`-c`), so this project\'s pytest ' +
184
+ 'settings — addopts, markers, plugin configuration — do not apply, and the only conftest.py ' +
185
+ 'files loaded are those from the repository root down to `step_definitions/`: your ' +
186
+ '`tests/conftest.py` fixtures are not available. The connector-owned `conftest.py` one level ' +
187
+ 'above `step_definitions/` settles the one thing that is the same in every Python project: a ' +
188
+ 'connection that would leave this machine is refused, and localhost stays reachable. Your own ' +
189
+ '`step_definitions/conftest.py` is untouched and is where your shared fixtures belong.',
190
+ };
191
+ export function behavioralHarnessNotice(runner) {
192
+ const notice = BEHAVIORAL_HARNESS_NOTICE[runner];
193
+ return notice ? `\n${notice}\n` : null;
194
+ }
160
195
  function jsBehavioralPrecheck(projectRoot) {
161
196
  if (existsSync(join(projectRoot, 'package.json')))
162
197
  return { ok: true };
@@ -1,7 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
- import { defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
3
+ import { executable } from "../proc.js";
4
+ import { BEHAVIORAL_DIR } from "../files/behavioral.js";
5
+ import { runInProject } from "./place.js";
6
+ import { commandFileOnHost, defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
5
7
  // How long a local setup step may take before we stop waiting. Provisioning a
6
8
  // runner and loading a cold Rails test environment sit in the same ballpark —
7
9
  // tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
@@ -12,11 +14,14 @@ export const PROVISION_TIMEOUT_MS = 120_000;
12
14
  // package index. Two minutes is a normal figure for it, so it gets its own
13
15
  // budget instead of borrowing one sized for a single install.
14
16
  export const DEPENDENCY_INSTALL_TIMEOUT_MS = 15 * 60 * 1000;
17
+ // `cwd` is the project root at every call site, and that is what decides where
18
+ // the install happens: a sidecar built by this machine is no use inside a
19
+ // container, and one built inside a container is no use here (see
20
+ // `runner/placeEnvironment.ts`).
15
21
  const defaultDeps = {
16
- runCmd: (command, args, options) => runProcess(command, args, {
17
- cwd: options.cwd,
22
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, {
18
23
  timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS,
19
- env: { ...process.env, ...options.env },
24
+ env: options.env,
20
25
  }),
21
26
  };
22
27
  export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
@@ -93,8 +98,8 @@ const VENV_BUILDERS = [
93
98
  // the project declares them in a requirements file, the application's own
94
99
  // packages — and deliberately nothing else. See `VENV_BUILDERS`.
95
100
  async function provisionPytest(projectRoot, deps) {
96
- const venvDir = sidecarPath(projectRoot, '.venv');
97
- const venvPython = join(venvDir, 'bin', 'python');
101
+ const venvDir = `${SIDECAR_DIR}/.venv`;
102
+ const venvPython = `${venvDir}/bin/python`;
98
103
  const built = await buildPythonEnvironment(projectRoot, venvDir, deps);
99
104
  if (!built.created) {
100
105
  return {
@@ -126,14 +131,17 @@ async function provisionPytest(projectRoot, deps) {
126
131
  // installs all three from wheels in seconds. So when the requirements will not
127
132
  // go in, the environment is rebuilt with the next builder rather than handed
128
133
  // over half-empty. Found 2026-08-12.
134
+ // `venvDir` is relative to the project root, like every other path that reaches
135
+ // a command: the interpreter is built and then started by the place, and only
136
+ // the existence checks below are the host's (spec 36, §4.2).
129
137
  async function buildPythonEnvironment(projectRoot, venvDir, deps) {
130
- const venvPython = join(venvDir, 'bin', 'python');
138
+ const venvPython = `${venvDir}/bin/python`;
131
139
  // The project's own statement of what it needs. It is also the only test of
132
140
  // whether an environment is any use: one the application's packages will not
133
141
  // install into is the wrong environment, however well it was built.
134
142
  const requirements = ['requirements.txt', 'requirements/base.txt', 'requirements-dev.txt']
135
143
  .find((name) => existsSync(join(projectRoot, name)));
136
- let created = existsSync(venvPython);
144
+ let created = existsSync(join(projectRoot, venvPython));
137
145
  let requirementsOk = requirements === undefined;
138
146
  let failure;
139
147
  for (const [index, builder] of VENV_BUILDERS.entries()) {
@@ -162,7 +170,7 @@ async function buildPythonEnvironment(projectRoot, venvDir, deps) {
162
170
  // application says far more than no suite at all.
163
171
  if (index === VENV_BUILDERS.length - 1)
164
172
  break;
165
- rmSync(venvDir, { recursive: true, force: true });
173
+ rmSync(join(projectRoot, venvDir), { recursive: true, force: true });
166
174
  created = false;
167
175
  }
168
176
  if (!created)
@@ -178,7 +186,7 @@ async function buildPythonEnvironment(projectRoot, venvDir, deps) {
178
186
  // "pg_config not found") that decides what they do next.
179
187
  return {
180
188
  created,
181
- requirementsNote: `installing ${requirements} into ${relativeVenv(projectRoot, venvDir)} did not finish on any Python ` +
189
+ requirementsNote: `installing ${requirements} into ${venvDir} did not finish on any Python ` +
182
190
  `available here — the suite may not be able to import the application.` +
183
191
  (failure ? ` The install said: ${failure}` : ''),
184
192
  };
@@ -200,7 +208,7 @@ function noDependencySourceNote(projectRoot, venvDir) {
200
208
  const where = declared.length
201
209
  ? `this project declares its dependencies in ${declared.join(' and ')}, which Unitbob does not install from yet`
202
210
  : 'no requirements.txt, pyproject.toml or Pipfile was found';
203
- return (`the application's own packages are not installed into ${relativeVenv(projectRoot, venvDir)} — ${where}. ` +
211
+ return (`the application's own packages are not installed into ${venvDir} — ${where}. ` +
204
212
  'The suite can start, but it may not be able to import the application.');
205
213
  }
206
214
  // Install into the sidecar environment, whichever tool built it.
@@ -270,6 +278,23 @@ async function provisionVitest(projectRoot, deps) {
270
278
  }
271
279
  return { status: 'provisioned' };
272
280
  }
281
+ // Frozen bundler, turned off for our own Gemfile and for nothing else
282
+ // (spec 36, task 2.6).
283
+ //
284
+ // The sidecar Gemfile adds a gem the project's lockfile has never heard of —
285
+ // that is its entire job — and under `frozen` or `deployment` bundler refuses
286
+ // exactly that: "the dependencies in your gemfile changed, but the lockfile
287
+ // can't be updated because frozen mode is set". Nothing installs, and the
288
+ // vibecoder is told to commit a file the connector wrote.
289
+ //
290
+ // Measured, not reasoned about (2026-08-17, `ruby:3.3-slim`). The project's own
291
+ // `.bundle/config` does *not* reach here: bundler reads app config relative to
292
+ // the Gemfile it was given, which is `.unitbob/runners/`. The environment does,
293
+ // and a dev or production image setting `BUNDLE_DEPLOYMENT=1` is ordinary. So
294
+ // the override is scoped to the one install whose Gemfile we wrote; the
295
+ // project's own bundler settings are never touched, and no other bundler
296
+ // invocation carries this.
297
+ const UNFROZEN_SIDECAR = { BUNDLE_FROZEN: 'false', BUNDLE_DEPLOYMENT: 'false' };
273
298
  // A sidecar Gemfile that inherits the project's own, plus rspec-rails. Bundler
274
299
  // resolves the two together, so the application's gems come with it — the same
275
300
  // arrangement the Cucumber sidecar has used since spec 32-1, and the reason the
@@ -283,12 +308,11 @@ async function provisionRspec(projectRoot, deps) {
283
308
  // Cucumber sidecar below: without it bundler re-resolves the whole graph and
284
309
  // hands the sidecar versions the project does not run.
285
310
  copyLockIfPresent(projectRoot, sidecarPath(projectRoot, 'Gemfile.lock'));
286
- const localBundle = join(projectRoot, 'bin', 'bundle');
287
- const command = existsSync(localBundle) ? localBundle : 'bundle';
311
+ const command = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
288
312
  const result = await deps
289
313
  .runCmd(command, ['install'], {
290
314
  cwd: projectRoot,
291
- env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile` },
315
+ env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile`, ...UNFROZEN_SIDECAR },
292
316
  timeoutMs: DEPENDENCY_INSTALL_TIMEOUT_MS,
293
317
  })
294
318
  .catch((err) => ({ code: 1, stdout: '', stderr: String(err) }));
@@ -328,7 +352,13 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
328
352
  const sidecarGemfile = join(behavioralDir, 'Gemfile');
329
353
  const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
330
354
  'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
331
- 'gem "cucumber", "~> 9.0", require: false\n';
355
+ 'gem "cucumber", "~> 9.0", require: false\n' +
356
+ // The connector-owned World blocks outgoing HTTP (spec 35-1), and it can only
357
+ // do that if webmock resolves here. A project that already carries the gem
358
+ // keeps its own version, because bundler starts from the project's own
359
+ // resolution. A project that does not would otherwise get a World promising a
360
+ // block it silently never performs — the exact shape of failure 35-1 closes.
361
+ 'gem "webmock", require: false\n';
332
362
  if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
333
363
  writeFileSync(sidecarGemfile, sidecarContent);
334
364
  }
@@ -351,11 +381,14 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
351
381
  if (existsSync(projectLock)) {
352
382
  writeFileSync(join(behavioralDir, 'Gemfile.lock'), readFileSync(projectLock, 'utf8'));
353
383
  }
354
- const gemfileRel = '.unitbob/behavioral/Gemfile';
355
- const env = { BUNDLE_GEMFILE: gemfileRel };
356
- // Try project local bin/bundle, then bundle
357
- const localBundle = join(projectRoot, 'bin', 'bundle');
358
- const cmd = existsSync(localBundle) ? localBundle : 'bundle';
384
+ // The Cucumber sidecar has the same shape and the same problem as the rspec
385
+ // one: it adds gems the project's lockfile does not carry. See UNFROZEN_SIDECAR.
386
+ const gemfileRel = `${BEHAVIORAL_DIR}/Gemfile`;
387
+ const env = { BUNDLE_GEMFILE: gemfileRel, ...UNFROZEN_SIDECAR };
388
+ // Try project local bin/bundle, then bundle. Relative with a slash: the
389
+ // working directory is the project root, and a bare name would be looked up on
390
+ // PATH instead.
391
+ const cmd = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
359
392
  const result = await deps.runCmd(cmd, ['install'], { cwd: projectRoot, env }).catch((err) => ({
360
393
  code: 1,
361
394
  stdout: '',
@@ -371,9 +404,13 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
371
404
  };
372
405
  }
373
406
  async function provisionPython(projectRoot, behavioralDir, deps) {
374
- const venvDir = join(behavioralDir, '.venv');
375
- const venvPython = join(venvDir, 'bin', 'python');
376
- const venvPytest = join(venvDir, 'bin', 'pytest');
407
+ // Two forms of one path, and the split is the rule of spec 36, §4.2: what a
408
+ // command names is relative, because the command runs where the dependencies
409
+ // live; what we test for existence is the host's, because that is where the
410
+ // files are.
411
+ const venvDir = `${BEHAVIORAL_DIR}/.venv`;
412
+ const venvPython = `${venvDir}/bin/python`;
413
+ const venvPytest = commandFileOnHost(projectRoot, `${venvDir}/bin/pytest`);
377
414
  // The behavioral suite drives the application, so its environment needs the
378
415
  // application in it — the same requirement, and now the same treatment, as the
379
416
  // structural peer. It used to be built with `--system-site-packages` and given
@@ -384,7 +421,7 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
384
421
  if (!built.created) {
385
422
  return {
386
423
  status: 'fixable',
387
- message: `Failed to create virtual environment under ${relativeVenv(projectRoot, venvDir)}.`,
424
+ message: `Failed to create virtual environment under ${venvDir}.`,
388
425
  checklist: ['Install python3-venv or uv: `python3 -m venv --help` or `pip install uv`.'],
389
426
  };
390
427
  }
@@ -392,7 +429,7 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
392
429
  if (!installed && !existsSync(venvPytest)) {
393
430
  return {
394
431
  status: 'fixable',
395
- message: `Failed to install pytest-bdd into ${relativeVenv(projectRoot, venvDir)}.`,
432
+ message: `Failed to install pytest-bdd into ${venvDir}.`,
396
433
  checklist: [`Run \`${venvPython} -m pip install pytest-bdd\` manually to provision the runner.`],
397
434
  };
398
435
  }
@@ -400,9 +437,6 @@ async function provisionPython(projectRoot, behavioralDir, deps) {
400
437
  ? { status: 'provisioned', checklist: [built.requirementsNote] }
401
438
  : { status: 'provisioned' };
402
439
  }
403
- function relativeVenv(projectRoot, venvDir) {
404
- return venvDir.startsWith(projectRoot) ? venvDir.slice(projectRoot.length + 1) : venvDir;
405
- }
406
440
  async function provisionJs(projectRoot, behavioralDir, deps) {
407
441
  const sidecarPkg = join(behavioralDir, 'package.json');
408
442
  const sidecarContent = JSON.stringify({
@@ -1,9 +1,9 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
3
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
4
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
5
  import { locateRunner } from "./toolchain.js";
6
- import { readReport } from "./types.js";
6
+ import { clearReport, readFreshReport } from "./types.js";
7
7
  export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
8
8
  export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
9
9
  // A minimal runtime config, created or overwritten before each run and passed
@@ -17,7 +17,7 @@ export const PYTEST_INI = '[pytest]\naddopts =\n';
17
17
  // JUnit XML report goes to --junit-xml, not stdout. The command is
18
18
  // connector-owned: the suite artifact never carries a command string.
19
19
  //
20
- // Every file of the branch is named positionally (spec 42, §6.5) — a branch is
20
+ // Every file of the branch is named positionally (spec 43, §6.5) — a branch is
21
21
  // one file per assignment now, and pytest takes as many paths as it is given.
22
22
  //
23
23
  // Which pytest is a single question answered in one place (`locateRunner`), so
@@ -36,16 +36,15 @@ export async function runPytestSuite(projectRoot, suitePaths) {
36
36
  ...suitePaths,
37
37
  `--junit-xml=${PYTEST_RESULT_FILE}`,
38
38
  ];
39
- const result = await runProcess(command, args, {
40
- cwd: projectRoot,
39
+ const reportPath = join(projectRoot, PYTEST_RESULT_FILE);
40
+ const survivor = clearReport(reportPath);
41
+ const run = await runInProject(projectRoot, command, args, {
41
42
  timeoutMs: PYTEST_TIMEOUT_MS,
42
- env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
43
+ env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
43
44
  });
44
45
  return {
45
- ...result,
46
- command,
47
- args,
46
+ ...run,
48
47
  resultPath: PYTEST_RESULT_FILE,
49
- report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
48
+ report: readFreshReport(reportPath, survivor),
50
49
  };
51
50
  }