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.
@@ -62,21 +62,31 @@ else
62
62
  end
63
63
  abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
64
64
  `;
65
- // Write the suite blob's guardrail file at its own (validated) relative path.
66
- // The Ruby boot kit is materialized only for the rspec runner — Vitest and
67
- // pytest runs need no connector-written support files here (the runtime
68
- // pytest.ini lives outside this directory and is written by the pytest runner).
65
+ // Write every file of the suite blob at its own (validated) relative path. The
66
+ // Ruby boot kit is materialized only for the rspec runner — Vitest and pytest
67
+ // runs need no connector-written support files here (the runtime pytest.ini
68
+ // lives outside this directory and is written by the pytest runner).
69
+ //
70
+ // Every file, not just the main one (spec 42, §6.4). The directory is wiped
71
+ // first and only the main file was written back, so a published suite of four
72
+ // files came back as one and the run that followed it silently protected a
73
+ // quarter of what the map claimed.
69
74
  export function materializeGuardrails(projectRoot, suite) {
70
- assertGuardrailPath(suite.suite_file.path);
75
+ const files = [suite.suite_file, ...(suite.suite_file.support_files ?? [])];
76
+ for (const file of files)
77
+ assertGuardrailPath(file.path);
71
78
  const dir = join(projectRoot, GUARDRAILS_DIR);
72
79
  rmSync(dir, { recursive: true, force: true });
73
80
  mkdirSync(dir, { recursive: true });
74
- const suitePath = join(projectRoot, suite.suite_file.path);
75
- mkdirSync(dirname(suitePath), { recursive: true });
76
- writeFileSync(suitePath, suite.suite_file.content);
81
+ const written = files.map((file) => {
82
+ const path = join(projectRoot, file.path);
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ writeFileSync(path, file.content);
85
+ return path;
86
+ });
77
87
  if (suite.runner_manifest.runner === 'rspec')
78
88
  materializeHelper(projectRoot);
79
- return { suitePath };
89
+ return { suitePath: written[0], supportPaths: written.slice(1) };
80
90
  }
81
91
  // Both Ruby flows boot the same way: the check flow writes the boot kit next to
82
92
  // the suite here, the suite-build flow writes it right after the precheck.
@@ -82,12 +82,27 @@ export function branchRunner(output) {
82
82
  }
83
83
  return runner;
84
84
  }
85
+ // What the reviewer actually read: the suite files, and the manifest that runs
86
+ // them. Nothing else (spec 42, §4).
87
+ //
88
+ // `test_metadata` used to be in here, and the server's copy of this formula
89
+ // stripped the review's own keys back out to match — two lists that had to stay
90
+ // identical for ever or every upload would break. The real cost was elsewhere,
91
+ // though: editing metadata declared the review stale. On noahsat-web,
92
+ // 2026-08-12 the reviewer was right that the steps drive only `PATCH`, the fix
93
+ // moved three `PUT` aliases into a deferred list, not one byte of the suite
94
+ // moved — and the run still paid for re-binding the candidate and a second
95
+ // reviewer pass, the most expensive step of the whole recipe, to satisfy the
96
+ // reviewer's own finding.
97
+ //
98
+ // `stableJson` sorts object keys and does nothing else. The server's
99
+ // `canonical_json` does the same, which is the only reason the two sides agree;
100
+ // a normalization added on one side alone would break every upload.
85
101
  export function suiteCandidateDigest(output) {
86
102
  return createHash('sha256')
87
103
  .update(stableJson({
88
104
  suite_file: output.suite_file,
89
105
  runner_manifest: output.runner_manifest,
90
- test_metadata: output.test_metadata,
91
106
  }))
92
107
  .digest('hex');
93
108
  }
@@ -0,0 +1,71 @@
1
+ import { readBehavioralReview } from "./suiteBuild.js";
2
+ // What travels to the server, and what "published" means when it answers. One
3
+ // module, because two commands ask those questions: `put-suite-build` sends the
4
+ // batch, and `validate-build` sends the same batch as a dry run so the server's
5
+ // verdict is about the exact bytes the publish will carry (spec 42, §3).
6
+ //
7
+ // A second assembly would be a second answer to "what are we uploading", and the
8
+ // dry run would then be checking something the publish does not send — which is
9
+ // worth less than not checking at all, because it reads as a verdict.
10
+ // The three outcomes that leave a branch published and current: a new version, an
11
+ // identical version already stored, or a reactivated one. Each returns the
12
+ // identity to run. Everything else — a rejected branch, a branch the host could
13
+ // not build, or a status this connector has never seen — fails closed and is
14
+ // never run, so a newer server can never trick an older connector into running
15
+ // something it does not understand.
16
+ export const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
17
+ // The answer a dry run gives to a branch it would accept. A server that does not
18
+ // know `dry_run` answers one of `PUBLISHED` instead — which means it published —
19
+ // and `validate-build` says so rather than reporting a check that passed.
20
+ export const WOULD_PUBLISH = 'would_publish';
21
+ // One branch, as the upload sends it. `source_digest` comes from the request,
22
+ // never from the host's answer, so the host cannot claim a different map than
23
+ // the branch was given.
24
+ export function uploadItem(request, output, testMetadata) {
25
+ const sourceDigest = request.branches.find((branch) => branch.suite_kind === output.suite_kind)?.source_digest ?? '';
26
+ if (output.build_error) {
27
+ return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
28
+ }
29
+ return {
30
+ suite_kind: output.suite_kind,
31
+ source_digest: sourceDigest,
32
+ artifacts: {
33
+ suite_file: output.suite_file,
34
+ runner_manifest: output.runner_manifest,
35
+ test_metadata: testMetadata,
36
+ },
37
+ };
38
+ }
39
+ // The behavioral branch's uploaded metadata, with the independent review and the
40
+ // connector's own run evidence folded in.
41
+ //
42
+ // Throws for anything that leaves this branch unpublishable — a missing review,
43
+ // one bound to a different candidate, a defect the review called not_supplied.
44
+ // The caller turns that into one unpublished branch rather than a failed
45
+ // command: a blocked review is a fact about the behavioral suite, and the
46
+ // structural peer next to it is finished and correct. Sinking the whole upload
47
+ // with it forced the one workaround this contract exists to prevent — hand-editing
48
+ // the answer down to a single branch, which loses the peer candidate for real.
49
+ export function withReview(config, request, output) {
50
+ const review = readBehavioralReview(config.projectRoot, output);
51
+ const probe = review.known_defect_probe;
52
+ const qualityReview = review.bdd_quality_review;
53
+ if (!qualityReview || typeof qualityReview !== 'object') {
54
+ throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
55
+ }
56
+ if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
57
+ throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
58
+ }
59
+ return {
60
+ ...output.test_metadata,
61
+ bdd_quality_review: {
62
+ ...qualityReview,
63
+ candidate_digest: review.candidate_digest,
64
+ },
65
+ ...(review.selection_review ? { selection_review: review.selection_review } : {}),
66
+ known_defect_probe: review.known_defect_probe,
67
+ known_defect_context: request.known_defect_context,
68
+ candidate_run: review.candidate_run,
69
+ ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
70
+ };
71
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { detectStructuralRunner } from "../runner/precheck.js";
4
5
  import { assertUnitbobPath } from "./artifactPath.js";
5
6
  export function workerPlanPath(projectRoot) {
6
7
  return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
@@ -33,8 +34,13 @@ export function readWorkerPlan(projectRoot) {
33
34
  }
34
35
  return parsed;
35
36
  }
37
+ const RUBY_HARNESS = {
38
+ behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
39
+ structural: '.unitbob/structural/unitbob_helper.rb',
40
+ };
36
41
  export function validateWorkerPlanFiles(projectRoot) {
37
42
  const errors = [];
43
+ const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
38
44
  const plan = readWorkerPlan(projectRoot);
39
45
  let request;
40
46
  try {
@@ -86,12 +92,23 @@ export function validateWorkerPlanFiles(projectRoot) {
86
92
  errors.push(`${label}: done_when must be non-empty`);
87
93
  if (!isNonEmptyString(item?.harness_path))
88
94
  errors.push(`${label}: harness_path must be non-empty`);
89
- const expectedHarness = item?.branch === 'behavioral'
90
- ? '.unitbob/behavioral/step_definitions/00_unitbob_world.rb'
91
- : item?.branch === 'structural' ? '.unitbob/structural/unitbob_helper.rb' : null;
95
+ // Both connector-owned harness files are Ruby, and only a Ruby project has
96
+ // them: `unitbob_helper.rb` boots Rails for RSpec, and the behavioral World
97
+ // is materialized for cucumber alone. Demanding them everywhere refused
98
+ // every Python and JS plan over a file that does not exist and would mean
99
+ // nothing if it did — the same mistake as the stack gate that reported a
100
+ // Python project as no stack at all. Found 2026-08-12.
101
+ //
102
+ // What is required of the other stacks is what the rule was ever about: the
103
+ // harness is connector territory, under `.unitbob/`, not a file in the
104
+ // project.
105
+ const expectedHarness = rubyProject ? RUBY_HARNESS[item?.branch] ?? null : null;
92
106
  if (expectedHarness && item.harness_path !== expectedHarness) {
93
107
  errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
94
108
  }
109
+ if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
110
+ errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
111
+ }
95
112
  if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
96
113
  errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
97
114
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { executable, runProcess } from "../proc.js";
4
+ import { locateRunner } from "./toolchain.js";
4
5
  import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
5
6
  import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
6
7
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
@@ -86,9 +87,14 @@ async function loadRubyHelper(projectRoot, helper, deps) {
86
87
  // and the global `bundle` was standing right there the whole time.
87
88
  const localBundle = join(projectRoot, 'bin', 'bundle');
88
89
  const command = executable(localBundle) ? localBundle : 'bundle';
90
+ // When Unitbob installed rspec-rails for itself, the gems this helper needs
91
+ // are resolved by the sidecar Gemfile, not the project's. Asking bundler
92
+ // without that variable would load a different set of gems than the run does,
93
+ // which is exactly the way a check ends up predicting the wrong thing.
94
+ const located = locateRunner(projectRoot, 'rspec');
89
95
  return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
90
96
  cwd: projectRoot,
91
- env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
97
+ env: { ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
92
98
  }),
93
99
  // A clean load says nothing on stdout and exits 0. Anything else is the
94
100
  // suite failing to start.
@@ -110,10 +116,25 @@ async function pytestBootCheck(projectRoot, deps) {
110
116
  // first: this check must not fail because a different step was skipped.
111
117
  mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
112
118
  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
- });
119
+ // The same pytest the run will use, resolved once in `locateRunner` — the
120
+ // sidecar under `.unitbob/` when Unitbob installed one, else the machine's own
121
+ // interpreter. Asking a different interpreter than the run uses is how a check
122
+ // ends up answering about something nobody is going to execute.
123
+ //
124
+ // When it resolves nothing we still try the two interpreters by name rather
125
+ // than reporting `no_runner` from a lookup. The lookup is a prediction; the
126
+ // spawn is the fact, and a check that stops at its own prediction can be
127
+ // wrong in the one direction that costs the most — refusing a project that
128
+ // would have answered perfectly well.
129
+ const located = locateRunner(projectRoot, 'pytest');
130
+ const candidates = located
131
+ ? [located]
132
+ : [
133
+ { command: 'python3', args: ['-m', 'pytest'], env: undefined },
134
+ { command: 'python', args: ['-m', 'pytest'], env: undefined },
135
+ ];
136
+ for (const candidate of candidates) {
137
+ const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--collect-only', '-q'], { cwd: projectRoot, env: candidate.env });
117
138
  if (result === null)
118
139
  continue; // this interpreter is not on the machine
119
140
  return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
@@ -148,7 +169,10 @@ function pytestVerdict(code) {
148
169
  // turn away the majority. A file that is genuinely unparseable is caught here
149
170
  // anyway, since `vitest list` has to parse it.
150
171
  async function vitestBootCheck(projectRoot, deps) {
151
- const local = join(projectRoot, 'node_modules', '.bin', 'vitest');
172
+ // A sidecar vitest counts as installed: it is ours, it is on disk, and it is
173
+ // the one the run will spawn. What stays out is `npx`, for the reason below.
174
+ const local = locateRunner(projectRoot, 'vitest')?.command
175
+ ?? join(projectRoot, 'node_modules', '.bin', 'vitest');
152
176
  // Only a vitest already installed in the project is used. Reaching for `npx`
153
177
  // would install a package to answer a question, and installing into the
154
178
  // user's project is not this check's business.
@@ -275,15 +299,17 @@ function hasProjectFrame(output, projectRoot, runner) {
275
299
  // asked of that file. Judging the whole output at once let `.venv/lib/...`
276
300
  // answer yes on the strength of its `lib/`, which is how a `TypeError` deep
277
301
  // 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('|')})/`);
302
+ const ownDirs = CONVENTIONAL_SOURCE_DIRS[runner] ?? [];
303
+ const conventional = ownDirs.length
304
+ ? new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`)
305
+ : null;
280
306
  for (const line of output.split('\n')) {
281
307
  // Wherever a dependency is installed, it is not this project's code — and
282
308
  // that has to be decided before anything below gets a chance to say yes.
283
309
  if (INSTALLED_DEPENDENCY.test(line))
284
310
  continue;
285
311
  // The conventional homes of business code, relative or absolute.
286
- if (conventional.test(line))
312
+ if (conventional?.test(line))
287
313
  return true;
288
314
  if (line.includes(projectRoot))
289
315
  return true;
@@ -303,6 +329,18 @@ function hasProjectFrame(output, projectRoot, runner) {
303
329
  }
304
330
  return false;
305
331
  }
332
+ // Where each stack conventionally keeps its business code. Only a stack that
333
+ // really has such a convention gets an entry: `app/` and `lib/` are Rails, and
334
+ // they used to be the fallback for everything that was not vitest, which meant a
335
+ // Python project got Rails's layout applied to its stack traces. Python names no
336
+ // fixed layout at all, so it is deliberately absent — the repository-file test
337
+ // below is the answer there, and it is the more reliable one anyway.
338
+ const CONVENTIONAL_SOURCE_DIRS = {
339
+ rspec: ['app', 'lib'],
340
+ cucumber: ['app', 'lib'],
341
+ vitest: ['src'],
342
+ 'cucumber-js': ['src'],
343
+ };
306
344
  // Where a dependency lives once installed — never the project's own code, in
307
345
  // any of the three languages. The last two are the languages' own installed
308
346
  // libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would
@@ -1,18 +1,36 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { existsSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
- const defaultDeps = {
5
- commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
6
- };
3
+ import { defaultToolDeps, hasGemfileWith, locateRunner, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, } from "./toolchain.js";
4
+ const defaultDeps = defaultToolDeps;
7
5
  const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
8
6
  // Tried in this order, so a project carrying markers for more than one stack
9
7
  // resolves to the same runner on every run.
10
8
  const STRUCTURAL_RUNNERS = ['rspec', 'vitest', 'pytest'];
9
+ // The file that says "this project is written in this language". Nothing here
10
+ // asks whether the runner is installed — that is a separate question with a
11
+ // separate answer, and merging the two is what made this gate lie.
12
+ //
13
+ // A project whose language is obvious but whose runner is missing used to fail
14
+ // detection, and the caller then reported the only thing it had left: "this
15
+ // project matches none of those stacks". That sentence was false — the project
16
+ // was Python, it simply had no pytest — and it sent people to look for a problem
17
+ // with their project instead of at the one command that fixes it. The runner is
18
+ // now provisioned under `.unitbob/` (see `ensureStructuralRunner`), so the
19
+ // question this gate answers is the one it can answer honestly: which language.
20
+ const PYTHON_MARKERS = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
21
+ function looksLikePython(projectRoot) {
22
+ return PYTHON_MARKERS.some((name) => existsSync(join(projectRoot, name)));
23
+ }
24
+ const STACK_MARKERS = {
25
+ rspec: (projectRoot) => hasGemfileWith(projectRoot, /\brails\b/),
26
+ vitest: (projectRoot) => existsSync(join(projectRoot, 'package.json')),
27
+ pytest: looksLikePython,
28
+ };
11
29
  // Which structural runner this project's markers select, or null when none do.
12
30
  // The gate below walks the same list: "is any stack present" and "which one is
13
31
  // it" must never be able to disagree.
14
- export function detectStructuralRunner(projectRoot, deps = defaultDeps) {
15
- return STRUCTURAL_RUNNERS.find((runner) => validateStack(projectRoot, runner, deps).ok) ?? null;
32
+ export function detectStructuralRunner(projectRoot, _deps = defaultDeps) {
33
+ return STRUCTURAL_RUNNERS.find((runner) => STACK_MARKERS[runner]?.(projectRoot)) ?? null;
16
34
  }
17
35
  // The BDD runner for a structural stack. One project, one language: the
18
36
  // behavioral peer follows the stack already detected instead of probing the
@@ -33,7 +51,10 @@ export function detectBddRunner(projectRoot, deps = defaultDeps) {
33
51
  const structural = detectStructuralRunner(projectRoot, deps);
34
52
  return structural ? BDD_RUNNER_FOR_STACK[structural] ?? null : null;
35
53
  }
36
- // The generation-time gate: at least one supported stack must be present.
54
+ // The generation-time gate: at least one supported stack must be present. It
55
+ // says nothing about whether the runner is installed, because by the time that
56
+ // matters the runner has been provisioned; `runnerReadyPrecheck` is the check
57
+ // for that, and it runs straight after provisioning.
37
58
  export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
38
59
  const runner = detectStructuralRunner(projectRoot, deps);
39
60
  if (runner !== null)
@@ -43,6 +64,54 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
43
64
  message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
44
65
  };
45
66
  }
67
+ // Is the runner startable now, after provisioning has had its turn?
68
+ //
69
+ // Not the same question as `validateStack`, and the difference is the sidecar.
70
+ // `validateStack` asks whether the *project* is set up for a stack — the right
71
+ // question when a host has chosen one and nothing has been installed yet. This
72
+ // asks whether anything on this machine can start the runner, which includes
73
+ // the environment Unitbob just built under `.unitbob/`.
74
+ //
75
+ // Asking the first question in the second's place refuses a project we have
76
+ // only just finished preparing: a JS project with no vitest of its own was
77
+ // told to `npm i -D vitest` seconds after a working vitest was installed for
78
+ // it. Found on the connector's own repository, 2026-08-12.
79
+ export function runnerReadyPrecheck(projectRoot, runner, deps = defaultDeps) {
80
+ // Ruby is the one stack whose lookup can never come back empty — `bundle exec
81
+ // rspec` is always a command one could type — so readiness is the gem being
82
+ // resolvable, from the sidecar Gemfile or from the project's own.
83
+ const ready = runner === 'rspec'
84
+ ? locateRunner(projectRoot, 'rspec')?.source === 'sidecar' || projectProvidesRunner(projectRoot, 'rspec', deps)
85
+ : runnerAvailable(projectRoot, runner, deps);
86
+ if (ready)
87
+ return { ok: true };
88
+ return {
89
+ ok: false,
90
+ message: `The ${runner} runner is not available: it is not installed in this project, and Unitbob could ` +
91
+ `not install one for itself under ${SIDECAR_DIR}/. Nothing was written and nothing was uploaded.`,
92
+ };
93
+ }
94
+ // Has Unitbob built this runner for this project already? One question, asked
95
+ // the same way by every precheck that would otherwise advise the user to install
96
+ // something they now have.
97
+ //
98
+ // Ruby needs the second half. Its sidecar is a Gemfile, and `provisionRspec`
99
+ // writes that file *before* it runs bundler and leaves it in place when bundler
100
+ // fails — so its mere existence says "we tried", not "it is there", and taking
101
+ // it as proof would silence the rspec-rails advice exactly when the install
102
+ // failed and the advice is what the user needs. Bundler rewrites the sidecar
103
+ // lock on success, and the project's own lock cannot name a gem its Gemfile
104
+ // lacks, so the gem appearing there is the first artefact that means it
105
+ // resolves. The vitest side needs nothing extra: `locateVitest` tests the actual
106
+ // `.bin/vitest` executable.
107
+ function sidecarProvides(projectRoot, runner, deps = defaultDeps) {
108
+ if (locateRunner(projectRoot, runner, deps)?.source !== 'sidecar')
109
+ return false;
110
+ if (runner !== 'rspec')
111
+ return true;
112
+ const lock = join(projectRoot, SIDECAR_DIR, 'Gemfile.lock');
113
+ return existsSync(lock) && /\brspec-rails\s+\(/.test(readFileSync(lock, 'utf8'));
114
+ }
46
115
  // Confirm the host-selected runner against local markers. A mismatch fails
47
116
  // closed: the caller writes no files and uploads nothing.
48
117
  //
@@ -56,9 +125,9 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
56
125
  export function validateStack(projectRoot, runner, deps = defaultDeps) {
57
126
  switch (runner) {
58
127
  case 'rspec':
59
- return rubyPrecheck(projectRoot);
128
+ return rubyPrecheck(projectRoot, deps);
60
129
  case 'vitest':
61
- return vitestPrecheck(projectRoot);
130
+ return vitestPrecheck(projectRoot, deps);
62
131
  case 'pytest':
63
132
  return pytestPrecheck(projectRoot, deps);
64
133
  case 'cucumber':
@@ -100,16 +169,14 @@ function jsBehavioralPrecheck(projectRoot) {
100
169
  // and message shape as the structural pytest precheck; pytest-bdd itself, if
101
170
  // missing, surfaces as a suite error from the run.
102
171
  function pythonBehavioralPrecheck(projectRoot, deps) {
103
- const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
104
- if (!markers.some((name) => existsSync(join(projectRoot, name)))) {
172
+ if (!looksLikePython(projectRoot)) {
105
173
  return {
106
174
  ok: false,
107
175
  message: 'The behavioral (Gherkin) suite selected the Python stack, but this project has none of ' +
108
- `${markers.join(', ')} — it does not look like a Python project.`,
176
+ `${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
109
177
  };
110
178
  }
111
- const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
112
- if (!available) {
179
+ if (!runnerAvailable(projectRoot, 'pytest', deps)) {
113
180
  return {
114
181
  ok: false,
115
182
  message: 'The behavioral (Gherkin) suite selected the Python stack, but pytest is not importable in ' +
@@ -119,7 +186,7 @@ function pythonBehavioralPrecheck(projectRoot, deps) {
119
186
  }
120
187
  return { ok: true };
121
188
  }
122
- function rubyPrecheck(projectRoot) {
189
+ function rubyPrecheck(projectRoot, deps) {
123
190
  if (!hasGemfileWith(projectRoot, /\brails\b/)) {
124
191
  return {
125
192
  ok: false,
@@ -129,7 +196,13 @@ function rubyPrecheck(projectRoot) {
129
196
  }
130
197
  // Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
131
198
  // bare `rspec` gem passes nothing downstream — stop with the honest offer.
132
- if (!hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
199
+ //
200
+ // Unless Unitbob already installed one for itself. The advice below asks the
201
+ // user to change their Gemfile, and that is the wrong sentence seconds after a
202
+ // working runner was provisioned under `.unitbob/`. `runnerReadyPrecheck`
203
+ // already knows this; this function used to send the reader back to the old
204
+ // answer regardless.
205
+ if (!sidecarProvides(projectRoot, 'rspec', deps) && !hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
133
206
  return {
134
207
  ok: false,
135
208
  message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
@@ -139,7 +212,7 @@ function rubyPrecheck(projectRoot) {
139
212
  }
140
213
  return { ok: true };
141
214
  }
142
- function vitestPrecheck(projectRoot) {
215
+ function vitestPrecheck(projectRoot, deps) {
143
216
  const packageJson = join(projectRoot, 'package.json');
144
217
  if (!existsSync(packageJson)) {
145
218
  return {
@@ -147,7 +220,12 @@ function vitestPrecheck(projectRoot) {
147
220
  message: 'The JavaScript/TypeScript stack was selected, but this project has no package.json.',
148
221
  };
149
222
  }
150
- const hasVitest = /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
223
+ // Same rule as the Ruby precheck above: a runner Unitbob installed for this
224
+ // project is a runner this project has. Without this the connector's own
225
+ // repository was told to `npm i -D vitest` seconds after a working vitest had
226
+ // been put under `.unitbob/` for it.
227
+ const hasVitest = sidecarProvides(projectRoot, 'vitest', deps) ||
228
+ /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
151
229
  existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
152
230
  if (!hasVitest) {
153
231
  return {
@@ -160,22 +238,20 @@ function vitestPrecheck(projectRoot) {
160
238
  return { ok: true };
161
239
  }
162
240
  function pytestPrecheck(projectRoot, deps) {
163
- const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
164
- const found = markers.some((name) => existsSync(join(projectRoot, name)));
165
- if (!found) {
241
+ if (!looksLikePython(projectRoot)) {
166
242
  return {
167
243
  ok: false,
168
244
  message: 'The Python stack was selected, but this project has none of ' +
169
- `${markers.join(', ')} — it does not look like a Python project.`,
245
+ `${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
170
246
  };
171
247
  }
172
248
  // Spec 30 fails closed on runner availability: unlike marker files, pytest
173
- // must actually be importable in the current interpreter, or every run would
174
- // end as a "No module named pytest" suite error after files were written. We
175
- // probe the same interpreters the pytest runner tries, in the same order, so
176
- // the precheck and the run agree on whether pytest is runnable.
177
- const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
178
- if (!available) {
249
+ // must actually be importable, or every run would end as a "No module named
250
+ // pytest" suite error after files were written. `runnerAvailable` asks the
251
+ // same question the run asks, of the same environments in the same order
252
+ // the sidecar under `.unitbob/` first, then the machine's own interpreters
253
+ // so the check and the run can never disagree about what is runnable.
254
+ if (!runnerAvailable(projectRoot, 'pytest', deps)) {
179
255
  return {
180
256
  ok: false,
181
257
  message: 'The Python stack was selected, but pytest is not importable in the current Python ' +
@@ -186,11 +262,3 @@ function pytestPrecheck(projectRoot, deps) {
186
262
  }
187
263
  return { ok: true };
188
264
  }
189
- function hasGemfileWith(projectRoot, pattern) {
190
- for (const name of ['Gemfile', 'gems.rb']) {
191
- const path = join(projectRoot, name);
192
- if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
193
- return true;
194
- }
195
- return false;
196
- }