unitbob 0.2.8 → 0.3.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,389 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { executable, runProcess } from "../proc.js";
4
+ import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
5
+ import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
6
+ import { PROVISION_TIMEOUT_MS } from "./provision.js";
7
+ const defaultDeps = {
8
+ runCmd: (command, args, options) => runProcess(command, args, {
9
+ cwd: options.cwd,
10
+ timeoutMs: PROVISION_TIMEOUT_MS,
11
+ env: { ...process.env, ...options.env },
12
+ }),
13
+ };
14
+ // How much of a runner's output rides along in `detail`. Enough to see the
15
+ // stack that mattered, short enough that a stop message stays readable.
16
+ const DETAIL_LIMIT = 4_000;
17
+ // What each stack can and cannot tell us. Printed with the finding rather than
18
+ // kept quiet: promising all three stacks the same guarantee is exactly the kind
19
+ // of claim spec 32-5 had to go back and delete.
20
+ export const SIGNAL_STRENGTH = {
21
+ rspec: 'Full signal on this stack: the check loads the very file the suite starts from, ' +
22
+ 'so whatever stops one stops the other.',
23
+ pytest: 'Partial signal on this stack: collection only imports what the tests import, so code no ' +
24
+ 'test reaches was not checked — and it imports the whole test tree, so a failure may belong ' +
25
+ "to one of the project's own tests rather than to the suite that was about to be written.",
26
+ vitest: 'Weak signal on this stack: only test files are parsed and imported, and all of them — so a ' +
27
+ "failure may belong to one of the project's own tests. Type errors are not checked at all " +
28
+ '(vite and esbuild strip types without checking them), and a project with no tests yields ' +
29
+ 'no signal.',
30
+ };
31
+ // Does the suite for this stack get off the ground? One attempt, one answer.
32
+ export async function bootCheck(projectRoot, runner, deps = defaultDeps) {
33
+ switch (runner) {
34
+ case 'rspec':
35
+ return rubyBootCheck(projectRoot, deps);
36
+ case 'pytest':
37
+ return pytestBootCheck(projectRoot, deps);
38
+ case 'vitest':
39
+ return vitestBootCheck(projectRoot, deps);
40
+ default:
41
+ return { status: 'not_checked', reason: 'no_runner' };
42
+ }
43
+ }
44
+ // Ruby: load `.unitbob/structural/unitbob_helper.rb` under RAILS_ENV=test. That
45
+ // is not a stand-in for the suite's boot — it *is* the suite's boot. The
46
+ // generated spec's first line requires this exact file, and the helper hands off
47
+ // to the project's own `spec/rails_helper.rb` when there is one, so factories,
48
+ // `spec/support` and the project's own configuration all come along for free,
49
+ // without this module knowing anything about them.
50
+ async function rubyBootCheck(projectRoot, deps) {
51
+ const helper = join(projectRoot, GUARDRAILS_DIR, HELPER_FILE);
52
+ if (!existsSync(helper))
53
+ return { status: 'not_checked', reason: 'nothing_to_load' };
54
+ const first = await loadRubyHelper(projectRoot, helper, deps);
55
+ if (first.status !== 'broken')
56
+ return first;
57
+ // Repair what we are allowed to repair, then ask once more. A vibecoder has
58
+ // no test database — demanding one would turn away nearly every user we have.
59
+ // Never in a loop: one attempt, one retry, then the answer stands.
60
+ //
61
+ // Only when the failure is about the database, though. Preparing it is not
62
+ // free — it drops and reloads the schema, tens of seconds on a large app —
63
+ // and doing that in response to a syntax error is a side effect nobody asked
64
+ // for and a wait that buys nothing.
65
+ if (!looksLikeDatabase(first.detail))
66
+ return first;
67
+ if (!(await prepareTestDatabase(projectRoot, deps)))
68
+ return first;
69
+ return loadRubyHelper(projectRoot, helper, deps);
70
+ }
71
+ // Is this failure about the database at all? Kept as one broad signal rather
72
+ // than a list of adapter error classes — the same reason the cause heuristics
73
+ // stay generic. Being wrong here is cheap in one direction (a preparation we
74
+ // did not need) and cheap in the other (we return the failure we already have,
75
+ // which is honest either way).
76
+ function looksLikeDatabase(detail) {
77
+ return /database|migration|schema|ActiveRecord::(NoDatabase|StatementInvalid|PendingMigration)/i.test(detail);
78
+ }
79
+ async function loadRubyHelper(projectRoot, helper, deps) {
80
+ // `executable`, not `existsSync` — the same test `runRspecSuite` makes of
81
+ // `bin/rspec`, and for the same reason. A binstub that is present but not
82
+ // executable makes `spawn` throw, `attempt` return null, and the answer come
83
+ // back `no_runner`: "no runner available to load your suite with", said to
84
+ // someone whose bundler is installed and working. That is the mistake
85
+ // `runner_too_old` and `runner_could_not_answer` were added to stop making,
86
+ // and the global `bundle` was standing right there the whole time.
87
+ const localBundle = join(projectRoot, 'bin', 'bundle');
88
+ const command = executable(localBundle) ? localBundle : 'bundle';
89
+ return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
90
+ cwd: projectRoot,
91
+ env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
92
+ }),
93
+ // A clean load says nothing on stdout and exits 0. Anything else is the
94
+ // suite failing to start.
95
+ (result) => (result.code === 0 ? 'ok' : 'broken'));
96
+ }
97
+ // Python: `pytest --collect-only` imports every test module, which is what the
98
+ // run would do first. It sees import errors in code the tests reach, and only
99
+ // there — recorded as a limitation rather than dressed up as completeness.
100
+ //
101
+ // `-c` with an empty-addopts config, exactly as `runPytestSuite` does and for
102
+ // exactly its reason: the project's own `addopts` (`--cov`, `-n auto`) must not
103
+ // decide the answer. Without it a project whose `pytest.ini` asks for a plugin
104
+ // that is not installed came back `broken` — a healthy application refused over
105
+ // a `--cov` flag. The run path had already solved this; the check had not
106
+ // inherited the solution, which also made it *stricter* than the thing it
107
+ // predicts, the one rule this whole check is built on.
108
+ async function pytestBootCheck(projectRoot, deps) {
109
+ // Its own directory, rather than relying on `materializeHelper` having run
110
+ // first: this check must not fail because a different step was skipped.
111
+ mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
112
+ writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
113
+ for (const python of ['python3', 'python']) {
114
+ const result = await attempt(deps, python, ['-m', 'pytest', '-c', PYTEST_INI_FILE, '--collect-only', '-q'], {
115
+ cwd: projectRoot,
116
+ });
117
+ if (result === null)
118
+ continue; // this interpreter is not on the machine
119
+ return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
120
+ }
121
+ return { status: 'not_checked', reason: 'no_runner' };
122
+ }
123
+ // pytest's own exit vocabulary, used rather than "zero or not". The distinction
124
+ // that matters is between "your code did not load" and "pytest itself could not
125
+ // be asked", and only the first is an answer about the project.
126
+ function pytestVerdict(code) {
127
+ // `classify` turns a timeout into `timed_out` before asking, so this is
128
+ // unreachable — but "no exit code" can only ever mean "we learned nothing".
129
+ if (code === null)
130
+ return 'runner_could_not_answer';
131
+ // 5 — collected nothing. A project that came to Unitbob *for* tests is the
132
+ // typical customer, not a defect.
133
+ if (code === 5)
134
+ return 'nothing_to_load';
135
+ // 3 — internal error, 4 — bad usage. Both are about the invocation, not the
136
+ // project, so neither may read as "your suite cannot start".
137
+ if (code === 3 || code === 4)
138
+ return 'runner_could_not_answer';
139
+ return code === 0 ? 'ok' : 'broken';
140
+ }
141
+ // JS/TS: `vitest list` parses and imports the test files, which is the closest
142
+ // thing this stack has to a boot.
143
+ //
144
+ // `tsc --noEmit` is deliberately not used. It answers a different question —
145
+ // are the types sound — and a project with a hundred type errors runs perfectly
146
+ // well, because vite, esbuild and tsx strip types without checking them. Type
147
+ // errors accumulate for years in healthy codebases; calling that "broken" would
148
+ // turn away the majority. A file that is genuinely unparseable is caught here
149
+ // anyway, since `vitest list` has to parse it.
150
+ async function vitestBootCheck(projectRoot, deps) {
151
+ const local = join(projectRoot, 'node_modules', '.bin', 'vitest');
152
+ // Only a vitest already installed in the project is used. Reaching for `npx`
153
+ // would install a package to answer a question, and installing into the
154
+ // user's project is not this check's business.
155
+ //
156
+ // `executable`, not `existsSync`, and there is no fallback to go to: an
157
+ // unrunnable binary sends `spawn` into EACCES, `supportsList` reads that as
158
+ // "cannot be asked", and the answer came back `runner_too_old` — a positive
159
+ // falsehood about a version nobody looked at. `no_runner` is the honest one
160
+ // here: there is no vitest this check can invoke.
161
+ if (!executable(local))
162
+ return { status: 'not_checked', reason: 'no_runner' };
163
+ // `list` is a subcommand only from Vitest 2.1. Older versions read it as a
164
+ // *filename filter* and go on to run whatever it matches, which was measured
165
+ // doing two unhelpful things: reporting "No test files found" on a project
166
+ // that plainly has tests, and — when a file happened to match — starting watch
167
+ // mode and hanging until the timeout killed it, two minutes for nothing.
168
+ //
169
+ // Neither is `broken`, so no healthy project was ever refused. But a check
170
+ // that quietly answers about the wrong thing is worse than one that says it
171
+ // did not run, so ask the version first and decline outright when the
172
+ // subcommand does not exist.
173
+ // Not `no_runner`: vitest is installed and works, it simply cannot be asked
174
+ // this question. Telling someone "no runner available" while it sits in their
175
+ // node_modules sends them to fix the wrong thing.
176
+ if (!(await supportsList(projectRoot, local, deps))) {
177
+ return { status: 'not_checked', reason: 'runner_too_old' };
178
+ }
179
+ const result = await attempt(deps, local, ['list'], { cwd: projectRoot });
180
+ return classify(projectRoot, 'vitest', result, (proc) => {
181
+ if (proc.code === 0)
182
+ return 'ok';
183
+ if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
184
+ return 'nothing_to_load';
185
+ return 'broken';
186
+ });
187
+ }
188
+ // The first Vitest that has a `list` subcommand. Below this it is a filter.
189
+ const VITEST_LIST_FROM = { major: 2, minor: 1 };
190
+ // `vitest --version` prints e.g. `vitest/1.6.0 darwin-arm64 node-v25.2.1`. A
191
+ // version we cannot read is treated as unsupported: guessing wrong here costs
192
+ // either a silent non-answer or a two-minute hang, and both are worse than
193
+ // saying plainly that the check did not run.
194
+ async function supportsList(projectRoot, binary, deps) {
195
+ const result = await attempt(deps, binary, ['--version'], { cwd: projectRoot });
196
+ if (!result || result.code !== 0)
197
+ return false;
198
+ const found = /vitest\/(\d+)\.(\d+)/i.exec(`${result.stdout}\n${result.stderr}`);
199
+ if (!found)
200
+ return false;
201
+ const [major, minor] = [Number(found[1]), Number(found[2])];
202
+ return major > VITEST_LIST_FROM.major || (major === VITEST_LIST_FROM.major && minor >= VITEST_LIST_FROM.minor);
203
+ }
204
+ // Runs one command, turning "this binary is not on the machine" into null (the
205
+ // caller decides whether that means `no_runner` or "try the next interpreter")
206
+ // and a timeout into a ProcResult with a null code, as runProcess already does.
207
+ async function attempt(deps, command, args, options) {
208
+ try {
209
+ return await deps.runCmd(command, args, options);
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function classify(projectRoot, runner, result, verdict) {
216
+ if (result === null)
217
+ return { status: 'not_checked', reason: 'no_runner' };
218
+ // runProcess reports a timeout as a null exit code. Waiting too long tells us
219
+ // nothing about the code, so it must not read as a defect.
220
+ if (result.code === null)
221
+ return { status: 'not_checked', reason: 'timed_out' };
222
+ const outcome = verdict(result);
223
+ if (outcome === 'ok')
224
+ return { status: 'ok' };
225
+ if (outcome === 'nothing_to_load')
226
+ return { status: 'not_checked', reason: 'nothing_to_load' };
227
+ // Nothing was learned about the project, and saying otherwise would be the
228
+ // lie this check exists to remove.
229
+ if (outcome === 'runner_could_not_answer')
230
+ return { status: 'not_checked', reason: 'runner_could_not_answer' };
231
+ const output = `${result.stdout}\n${result.stderr}`.trim();
232
+ return {
233
+ status: 'broken',
234
+ cause: causeOf(output, projectRoot, runner),
235
+ message: firstErrorLine(output),
236
+ detail: output.length > DETAIL_LIMIT ? `${output.slice(0, DETAIL_LIMIT)}\n…` : output,
237
+ };
238
+ }
239
+ // One concept, not a catalogue of known errors — the lesson spec 32-2 drew from
240
+ // the over-fitted regex scanner of 32-3. "A name that does not resolve to a file
241
+ // inside this project" is the single class of failure that, in all three
242
+ // languages, reaches project code while still being a setup step rather than a
243
+ // defect.
244
+ const DEPENDENCY_MISSING = [
245
+ /\bLoadError\b/,
246
+ /Bundler::GemNotFound/,
247
+ /\bModuleNotFoundError\b/,
248
+ /Cannot find module\b/,
249
+ /Failed to resolve import\b/,
250
+ ];
251
+ // Which of the two sentences the vibecoder reads. It changes wording only —
252
+ // both outcomes stop the run, because in both cases every test would die before
253
+ // asserting anything. That is why getting this wrong is cheap: the first draft
254
+ // of this spec let the same distinction decide whether to stop at all, and
255
+ // there a misclassification would have refused a healthy project.
256
+ function causeOf(output, projectRoot, runner) {
257
+ if (DEPENDENCY_MISSING.some((pattern) => pattern.test(output)))
258
+ return 'environment_not_ready';
259
+ return hasProjectFrame(output, projectRoot, runner) ? 'defect_in_code' : 'environment_not_ready';
260
+ }
261
+ // Both halves are needed, and reasoning from Rails alone hides that. Ruby
262
+ // resolves gems through `bundler/setup` before the first application file, so a
263
+ // missing gem there reliably produces a trace with no project frame in it.
264
+ // Python and JS have no such gate — the import is resolved from inside a project
265
+ // file:
266
+ //
267
+ // tests/test_foo.py:3: in <module>
268
+ // import requests
269
+ // E ModuleNotFoundError: No module named 'requests'
270
+ //
271
+ // There is a project frame, and it is an un-run `pip install`. Hence the second
272
+ // condition above.
273
+ function hasProjectFrame(output, projectRoot, runner) {
274
+ // Line by line, because a stack frame names one file and the question is
275
+ // asked of that file. Judging the whole output at once let `.venv/lib/...`
276
+ // answer yes on the strength of its `lib/`, which is how a `TypeError` deep
277
+ // inside a dependency came back as a defect in the user's code.
278
+ const ownDirs = runner === 'vitest' ? ['src'] : ['app', 'lib'];
279
+ const conventional = new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`);
280
+ for (const line of output.split('\n')) {
281
+ // Wherever a dependency is installed, it is not this project's code — and
282
+ // that has to be decided before anything below gets a chance to say yes.
283
+ if (INSTALLED_DEPENDENCY.test(line))
284
+ continue;
285
+ // The conventional homes of business code, relative or absolute.
286
+ if (conventional.test(line))
287
+ return true;
288
+ if (line.includes(projectRoot))
289
+ return true;
290
+ // Python names no fixed layout the way Rails does, and pytest prints frames
291
+ // relative to the working directory — `mypackage/billing.py:12`, matching
292
+ // neither of the two rules above. So fall back to the only thing that
293
+ // generalises: the file in this frame is a file this repository has.
294
+ //
295
+ // Data-driven, so it needs no list of package names and holds for
296
+ // src-layout, flat-layout and anything else. Without it a genuine
297
+ // `NameError` in the user's own module was reported as "your environment is
298
+ // not ready", sending them to run `pip install` over a typo.
299
+ for (const [, candidate] of line.matchAll(SOURCE_FRAME)) {
300
+ if (existsSync(join(projectRoot, candidate)))
301
+ return true;
302
+ }
303
+ }
304
+ return false;
305
+ }
306
+ // Where a dependency lives once installed — never the project's own code, in
307
+ // any of the three languages. The last two are the languages' own installed
308
+ // libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would
309
+ // otherwise read as this project's business code.
310
+ const INSTALLED_DEPENDENCY = /site-packages|dist-packages|node_modules|[/.]venv[/\\]|\/gems\/|[/\\]lib[/\\]ruby[/\\]|[/\\]lib[/\\]python\d/;
311
+ // A `path/to/file.ext:LINE` frame, as every one of these runners prints them.
312
+ const SOURCE_FRAME = /([\w.\-/\\]+\.(?:py|rb|ts|tsx|js|jsx|mjs|cjs)):\d+/g;
313
+ // The runner's own words, never a paraphrase. A vibecoder can search for this
314
+ // string; a summary of it they cannot. File and line come along when the runner
315
+ // put them on the same line, and are not manufactured when it did not.
316
+ //
317
+ // Exported because spec 32-7 asks a second tool to load the application (the
318
+ // router), and one rule for quoting a failed load is better than two.
319
+ export function firstErrorLine(output) {
320
+ const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
321
+ const looksLikeError = /(^|[^A-Za-z])(error|exception|traceback)|:\d+:in |^E\s|\(.*Error\)/i;
322
+ return lines.find((line) => looksLikeError.test(line)) ?? lines[0] ?? 'the runner exited without output';
323
+ }
324
+ // The one repair this check performs. A test database is not a repository file
325
+ // and is disposable by nature, which puts it under the same rule that already
326
+ // lets `provision.ts` write its own Gemfile and venv: everything inside our own
327
+ // sandbox, nothing of the project's touched.
328
+ //
329
+ // The project's own dependencies (`bundle install`, `npm install`,
330
+ // `pip install`) stay off-limits for exactly that reason — they rewrite
331
+ // `Gemfile.lock` and `package-lock.json`, which are the user's files. Those are
332
+ // named to the human instead.
333
+ //
334
+ // Returns whether the repair ran, so the caller knows whether a retry is worth
335
+ // anything.
336
+ async function prepareTestDatabase(projectRoot, deps) {
337
+ if (!testDatabaseIsSeparate(projectRoot))
338
+ return false;
339
+ const rails = join(projectRoot, 'bin', 'rails');
340
+ const useBinstub = executable(rails);
341
+ const command = useBinstub ? rails : 'bundle';
342
+ const args = useBinstub ? ['db:test:prepare'] : ['exec', 'rails', 'db:test:prepare'];
343
+ const result = await attempt(deps, command, args, {
344
+ cwd: projectRoot,
345
+ env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
346
+ });
347
+ return result !== null && result.code === 0;
348
+ }
349
+ // Refuse to prepare unless we can positively read two different database names.
350
+ // Getting this wrong destroys a person's working data, which is not recoverable
351
+ // by re-running anything, so every uncertainty resolves to "don't": no
352
+ // database.yml, no `test:` block, a name we cannot read through ERB, or two
353
+ // names that match — all of them stop the step.
354
+ export function testDatabaseIsSeparate(projectRoot) {
355
+ const path = join(projectRoot, 'config', 'database.yml');
356
+ if (!existsSync(path))
357
+ return false;
358
+ let text;
359
+ try {
360
+ text = readFileSync(path, 'utf8');
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ const test = databaseNameIn(text, 'test');
366
+ const development = databaseNameIn(text, 'development');
367
+ if (!test || !development)
368
+ return false;
369
+ return test !== development;
370
+ }
371
+ // The `database:` of one top-level environment block. Deliberately textual: the
372
+ // connector carries no YAML parser, database.yml is routinely full of ERB, and
373
+ // a name inherited through a `<<: *default` anchor is one we cannot resolve —
374
+ // all of which come back as undefined and stop the step, which is the safe
375
+ // direction to be wrong in.
376
+ function databaseNameIn(text, environment) {
377
+ const lines = text.split('\n');
378
+ const start = lines.findIndex((line) => new RegExp(`^${environment}:\\s*(#.*)?$`).test(line));
379
+ if (start === -1)
380
+ return null;
381
+ for (const line of lines.slice(start + 1)) {
382
+ if (/^\S/.test(line))
383
+ break; // the next top-level block began
384
+ const match = /^\s+database:\s*(.+?)\s*$/.exec(line);
385
+ if (match)
386
+ return match[1];
387
+ }
388
+ return null;
389
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function selectRunnerEnvelope(candidates, runner) {
4
+ if (!runner || !Array.isArray(candidates))
5
+ return null;
6
+ const match = candidates.find((entry) => entry && typeof entry === 'object' && entry.runner === runner);
7
+ return match ? { ...match } : null;
8
+ }
9
+ // The behavioral envelope also carries the pinned dependency actually installed
10
+ // into the isolated runner environment. Only the machine that provisioned the
11
+ // sidecar can know it, so it is read back from that environment rather than
12
+ // guessed.
13
+ //
14
+ // Unreadable means no envelope at all — never a partial one, and never an
15
+ // invented version. The server requires this field on every behavioral upload,
16
+ // so an envelope missing it is rejected at the last step, after the suite has
17
+ // been written, run, and reviewed: precisely the failure this whole path exists
18
+ // to remove. An envelope is either complete and copied verbatim, or absent and
19
+ // the branch does not get built.
20
+ export function withInstalledRunnerVersion(envelope, runner, projectRoot) {
21
+ const version = installedRunnerVersion(runner, projectRoot);
22
+ return version ? { ...envelope, runner_version: version } : null;
23
+ }
24
+ const BEHAVIORAL_DIR = '.unitbob/behavioral';
25
+ // Matches the server's own PINNED_VERSION: a digit, then version characters.
26
+ const PINNED_VERSION = /^[0-9][0-9A-Za-z.\-+]*$/;
27
+ function installedRunnerVersion(runner, projectRoot) {
28
+ const root = join(projectRoot, BEHAVIORAL_DIR);
29
+ const found = readInstalledVersion(runner, root);
30
+ return found && PINNED_VERSION.test(found) ? found : null;
31
+ }
32
+ function readInstalledVersion(runner, root) {
33
+ switch (runner) {
34
+ case 'cucumber':
35
+ return gemfileLockVersion(join(root, 'Gemfile.lock'), 'cucumber');
36
+ case 'cucumber-js':
37
+ return packageJsonVersion(join(root, 'node_modules', '@cucumber', 'cucumber', 'package.json'));
38
+ case 'pytest-bdd':
39
+ return sitePackagesVersion(join(root, '.venv'), 'pytest_bdd');
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ // ` cucumber (9.2.0)` under GEM/specs. The indented, parenthesised form is
45
+ // the resolved version; the Gemfile's own `~> 9.0` constraint is not.
46
+ function gemfileLockVersion(path, gem) {
47
+ if (!existsSync(path))
48
+ return null;
49
+ try {
50
+ const match = readFileSync(path, 'utf8').match(new RegExp(`^\\s{4}${gem} \\(([^)]+)\\)$`, 'm'));
51
+ return match ? match[1] : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function packageJsonVersion(path) {
58
+ if (!existsSync(path))
59
+ return null;
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
62
+ return typeof parsed.version === 'string' ? parsed.version : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ // Installed distributions record their version in the `<name>-<version>.dist-info`
69
+ // directory name, which needs no interpreter to read.
70
+ function sitePackagesVersion(venv, distribution) {
71
+ try {
72
+ for (const lib of readdirSync(join(venv, 'lib'))) {
73
+ const packages = join(venv, 'lib', lib, 'site-packages');
74
+ if (!existsSync(packages))
75
+ continue;
76
+ for (const entry of readdirSync(packages)) {
77
+ const match = entry.match(new RegExp(`^${distribution}-(.+)\\.dist-info$`));
78
+ if (match)
79
+ return match[1];
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ return null;
87
+ }
@@ -5,11 +5,39 @@ const defaultDeps = {
5
5
  commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
6
6
  };
7
7
  const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
8
+ // Tried in this order, so a project carrying markers for more than one stack
9
+ // resolves to the same runner on every run.
10
+ const STRUCTURAL_RUNNERS = ['rspec', 'vitest', 'pytest'];
11
+ // Which structural runner this project's markers select, or null when none do.
12
+ // The gate below walks the same list: "is any stack present" and "which one is
13
+ // it" must never be able to disagree.
14
+ export function detectStructuralRunner(projectRoot, deps = defaultDeps) {
15
+ return STRUCTURAL_RUNNERS.find((runner) => validateStack(projectRoot, runner, deps).ok) ?? null;
16
+ }
17
+ // The BDD runner for a structural stack. One project, one language: the
18
+ // behavioral peer follows the stack already detected instead of probing the
19
+ // filesystem a second time.
20
+ //
21
+ // A second probe used to answer first on `package.json` alone, so a Rails app
22
+ // with any front-end build — the common case — got an rspec structural branch
23
+ // and a cucumber-js behavioral one. Step definitions in JavaScript cannot boot
24
+ // Rails, use its test helpers, or reach its test database, so that branch was
25
+ // dead before it was written, and the generation recipe allows exactly one stack
26
+ // per project anyway.
27
+ const BDD_RUNNER_FOR_STACK = {
28
+ rspec: 'cucumber',
29
+ vitest: 'cucumber-js',
30
+ pytest: 'pytest-bdd',
31
+ };
32
+ export function detectBddRunner(projectRoot, deps = defaultDeps) {
33
+ const structural = detectStructuralRunner(projectRoot, deps);
34
+ return structural ? BDD_RUNNER_FOR_STACK[structural] ?? null : null;
35
+ }
8
36
  // The generation-time gate: at least one supported stack must be present.
9
37
  export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
10
- const supported = ['rspec', 'vitest', 'pytest'].some((runner) => validateStack(projectRoot, runner, deps).ok);
11
- if (supported)
12
- return { ok: true };
38
+ const runner = detectStructuralRunner(projectRoot, deps);
39
+ if (runner !== null)
40
+ return { ok: true, runner };
13
41
  return {
14
42
  ok: false,
15
43
  message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
@@ -1,8 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
+ // How long a local setup step may take before we stop waiting. Provisioning a
5
+ // runner and loading a cold Rails test environment sit in the same ballpark —
6
+ // tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
7
+ // number rather than inventing a second one to keep in sync.
8
+ export const PROVISION_TIMEOUT_MS = 120_000;
4
9
  const defaultDeps = {
5
- runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: 120_000, env: { ...process.env, ...options.env } }),
10
+ runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: PROVISION_TIMEOUT_MS, env: { ...process.env, ...options.env } }),
6
11
  };
7
12
  export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
8
13
  const behavioralDir = join(projectRoot, '.unitbob', 'behavioral');
@@ -1,6 +1,5 @@
1
- import { existsSync, statSync } from 'node:fs';
2
1
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
2
+ import { executable, runProcess } from "../proc.js";
4
3
  import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
5
4
  import { readReport } from "./types.js";
6
5
  export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
@@ -54,11 +53,3 @@ async function invokeRspec(projectRoot, rspecArgs) {
54
53
  });
55
54
  return { result, command, args };
56
55
  }
57
- function executable(path) {
58
- try {
59
- return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
60
- }
61
- catch {
62
- return false;
63
- }
64
- }