unitbob 0.2.0 → 0.2.2

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.
@@ -35,13 +35,23 @@ async function runCucumberRuby(projectRoot) {
35
35
  const features = join(BEHAVIORAL_ROOT, 'features');
36
36
  const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
37
37
  const localBin = join(projectRoot, 'bin', 'cucumber');
38
+ const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
39
+ const hasSidecarGemfile = existsSync(sidecarGemfile);
38
40
  const command = executable(localBin) ? localBin : 'bundle';
39
41
  const base = executable(localBin) ? [] : ['exec', 'cucumber'];
40
42
  const args = [...base, features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
43
+ const env = {
44
+ ...process.env,
45
+ RAILS_ENV: 'test',
46
+ UNITBOB_REPO_ROOT: projectRoot,
47
+ };
48
+ if (hasSidecarGemfile) {
49
+ env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
50
+ }
41
51
  const result = await runProcess(command, args, {
42
52
  cwd: projectRoot,
43
53
  timeoutMs: BDD_TIMEOUT_MS,
44
- env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
54
+ env,
45
55
  });
46
56
  return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
47
57
  }
@@ -50,9 +60,11 @@ async function runCucumberRuby(projectRoot) {
50
60
  async function runCucumberJs(projectRoot) {
51
61
  const features = join(BEHAVIORAL_ROOT, 'features');
52
62
  const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
53
- const command = 'npx';
63
+ const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
64
+ const command = executable(sidecarBin) ? sidecarBin : 'npx';
65
+ const baseArgs = executable(sidecarBin) ? [] : ['cucumber-js'];
54
66
  const args = [
55
- 'cucumber-js',
67
+ ...baseArgs,
56
68
  features,
57
69
  '--require',
58
70
  steps,
@@ -75,8 +87,10 @@ async function runPytestBdd(projectRoot, mainPath) {
75
87
  writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
76
88
  const command = await pickPython(projectRoot);
77
89
  const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
78
- const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p',
79
- pluginModule(), stepsDir, '--rootdir', projectRoot];
90
+ const isVenvPytest = command.endsWith('/pytest');
91
+ const args = isVenvPytest
92
+ ? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
93
+ : ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot];
80
94
  const result = await runProcess(command, args, {
81
95
  cwd: projectRoot,
82
96
  timeoutMs: BDD_TIMEOUT_MS,
@@ -104,6 +118,10 @@ function finalize(result, command, args, projectRoot, reportRel) {
104
118
  };
105
119
  }
106
120
  async function pickPython(projectRoot) {
121
+ const sidecarVenvPytest = join(projectRoot, BEHAVIORAL_ROOT, '.venv', 'bin', 'pytest');
122
+ if (executable(sidecarVenvPytest)) {
123
+ return sidecarVenvPytest;
124
+ }
107
125
  const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
108
126
  cwd: projectRoot,
109
127
  timeoutMs: 10_000,
@@ -0,0 +1,116 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runProcess } from "../proc.js";
4
+ const defaultDeps = {
5
+ runCmd: (command, args, options) => runProcess(command, args, { cwd: options.cwd, timeoutMs: 120_000, env: { ...process.env, ...options.env } }),
6
+ };
7
+ export async function ensureRunner(projectRoot, runner, deps = defaultDeps) {
8
+ const behavioralDir = join(projectRoot, '.unitbob', 'behavioral');
9
+ mkdirSync(behavioralDir, { recursive: true });
10
+ switch (runner) {
11
+ case 'cucumber':
12
+ return provisionRuby(projectRoot, behavioralDir, deps);
13
+ case 'cucumber-js':
14
+ return provisionJs(projectRoot, behavioralDir, deps);
15
+ case 'pytest-bdd':
16
+ return provisionPython(projectRoot, behavioralDir, deps);
17
+ default:
18
+ return { status: 'fixable', message: `Unsupported BDD runner "${runner}".` };
19
+ }
20
+ }
21
+ async function provisionRuby(projectRoot, behavioralDir, deps) {
22
+ const sidecarGemfile = join(behavioralDir, 'Gemfile');
23
+ const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
24
+ 'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
25
+ 'gem "cucumber", "~> 9.0", require: false\n';
26
+ if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
27
+ writeFileSync(sidecarGemfile, sidecarContent);
28
+ }
29
+ const gemfileRel = '.unitbob/behavioral/Gemfile';
30
+ const env = { BUNDLE_GEMFILE: gemfileRel };
31
+ // Try project local bin/bundle, then bundle
32
+ const localBundle = join(projectRoot, 'bin', 'bundle');
33
+ const cmd = existsSync(localBundle) ? localBundle : 'bundle';
34
+ const result = await deps.runCmd(cmd, ['install'], { cwd: projectRoot, env }).catch((err) => ({
35
+ code: 1,
36
+ stdout: '',
37
+ stderr: String(err),
38
+ }));
39
+ if (result.code === 0) {
40
+ return { status: 'provisioned' };
41
+ }
42
+ return {
43
+ status: 'fixable',
44
+ message: 'Bundler failed to provision Cucumber sidecar gem.',
45
+ checklist: ['Ensure bundler is installed (`gem install bundler`) and run `bundle install` manually inside `.unitbob/behavioral/`.'],
46
+ };
47
+ }
48
+ async function provisionPython(projectRoot, behavioralDir, deps) {
49
+ const venvDir = join(behavioralDir, '.venv');
50
+ const venvPip = join(venvDir, 'bin', 'pip');
51
+ const venvPytest = join(venvDir, 'bin', 'pytest');
52
+ if (existsSync(venvPytest)) {
53
+ return { status: 'provisioned' };
54
+ }
55
+ // Ladder: uv -> python3 -m venv --system-site-packages
56
+ const uvResult = await deps.runCmd('uv', ['venv', venvDir, '--system-site-packages'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
57
+ let venvCreated = uvResult.code === 0;
58
+ if (!venvCreated) {
59
+ const venvResult = await deps
60
+ .runCmd('python3', ['-m', 'venv', '--system-site-packages', venvDir], { cwd: projectRoot })
61
+ .catch(() => ({ code: 1 }));
62
+ venvCreated = venvResult.code === 0;
63
+ }
64
+ if (!venvCreated) {
65
+ return {
66
+ status: 'fixable',
67
+ message: 'Failed to create virtual environment under .unitbob/behavioral/.venv.',
68
+ checklist: ['Install python3-venv or uv: `python3 -m venv --help` or `pip install uv`.'],
69
+ };
70
+ }
71
+ // Install pytest-bdd into the sidecar venv
72
+ const pipResult = await deps.runCmd(venvPip, ['install', 'pytest-bdd'], { cwd: projectRoot }).catch(() => ({ code: 1 }));
73
+ if (pipResult.code === 0 || existsSync(venvPytest)) {
74
+ return { status: 'provisioned' };
75
+ }
76
+ return {
77
+ status: 'fixable',
78
+ message: 'Failed to install pytest-bdd into .unitbob/behavioral/.venv.',
79
+ checklist: [`Run \`${venvPip} install pytest-bdd\` manually to provision the runner.`],
80
+ };
81
+ }
82
+ async function provisionJs(projectRoot, behavioralDir, deps) {
83
+ const sidecarPkg = join(behavioralDir, 'package.json');
84
+ const sidecarContent = JSON.stringify({
85
+ name: 'unitbob-behavioral-sidecar',
86
+ private: true,
87
+ devDependencies: {
88
+ '@cucumber/cucumber': '^10.0.0',
89
+ 'ts-node': '^10.9.0',
90
+ },
91
+ }, null, 2) + '\n';
92
+ if (!existsSync(sidecarPkg) || readFileSync(sidecarPkg, 'utf8') !== sidecarContent) {
93
+ writeFileSync(sidecarPkg, sidecarContent);
94
+ }
95
+ const cucumberBin = join(behavioralDir, 'node_modules', '.bin', 'cucumber-js');
96
+ if (existsSync(cucumberBin)) {
97
+ return { status: 'provisioned' };
98
+ }
99
+ // Fallback ladder: npm -> pnpm -> yarn
100
+ const managers = [
101
+ { cmd: 'npm', args: ['install', '--prefix', '.unitbob/behavioral'] },
102
+ { cmd: 'pnpm', args: ['install', '--prefix', '.unitbob/behavioral'] },
103
+ { cmd: 'yarn', args: ['install', '--cwd', '.unitbob/behavioral'] },
104
+ ];
105
+ for (const mgr of managers) {
106
+ const res = await deps.runCmd(mgr.cmd, mgr.args, { cwd: projectRoot }).catch(() => ({ code: 1 }));
107
+ if (res.code === 0 || existsSync(cucumberBin)) {
108
+ return { status: 'provisioned' };
109
+ }
110
+ }
111
+ return {
112
+ status: 'fixable',
113
+ message: 'Failed to install @cucumber/cucumber sidecar dependency.',
114
+ checklist: ['Install dependencies manually: `npm install --prefix .unitbob/behavioral`.'],
115
+ };
116
+ }
@@ -1,6 +1,9 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { materializeHelper } from "../files/guardrails.js";
2
4
  import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
3
5
  import { anyStackPrecheck } from "../runner/precheck.js";
6
+ import { ensureRunner } from "../runner/provision.js";
4
7
  import { Wire } from "../wire.js";
5
8
  // Confirm at least one supported stack is present, materialize the Ruby boot
6
9
  // helper a generated RSpec suite would require, then fetch both peer assignments
@@ -16,6 +19,7 @@ export async function suitePrepare(config, _args = [], deps) {
16
19
  getRecipe: (name) => wire.getRecipe(name),
17
20
  getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
18
21
  precheck: anyStackPrecheck,
22
+ ensureRunner: deps?.ensureRunner ?? ensureRunner,
19
23
  stdout: process.stdout,
20
24
  ...deps,
21
25
  };
@@ -24,7 +28,27 @@ export async function suitePrepare(config, _args = [], deps) {
24
28
  throw new Error(check.message ?? 'Unsupported runtime.');
25
29
  materializeHelper(config.projectRoot);
26
30
  const packets = await actual.getSuitePacketsBatch();
27
- const branches = await Promise.all(packets.map(async (packet) => ({
31
+ // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
32
+ // A `fixable` outcome (no package manager available to install the runner) is an infrastructure
33
+ // blocker the vibecoder clears with one command. Per the spec it must NOT surface as a
34
+ // build_error dead-end, must NOT abort the build, and must NOT block the structural peer. So we
35
+ // drop only the unprovisioned behavioral branch from this run, keep everything else buildable,
36
+ // and print a fixable checklist after the request is written.
37
+ const fixableNotices = [];
38
+ const buildable = [];
39
+ for (const packet of packets) {
40
+ const runner = packet.runner ?? (packet.suite_kind === 'behavioral' ? inferBddRunner(config.projectRoot) : undefined);
41
+ if (runner && (packet.suite_kind === 'behavioral' || ['cucumber', 'cucumber-js', 'pytest-bdd'].includes(runner))) {
42
+ const prov = await actual.ensureRunner(config.projectRoot, runner);
43
+ if (prov.status === 'fixable') {
44
+ const steps = prov.checklist?.length ? `\n - ${prov.checklist.join('\n - ')}` : '';
45
+ fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
46
+ continue;
47
+ }
48
+ }
49
+ buildable.push(packet);
50
+ }
51
+ const branches = await Promise.all(buildable.map(async (packet) => ({
28
52
  suite_kind: packet.suite_kind,
29
53
  source_digest: packet.source_digest,
30
54
  path_root: packet.path_root,
@@ -34,7 +58,22 @@ export async function suitePrepare(config, _args = [], deps) {
34
58
  const request = writeSuiteBuildRequest(config.projectRoot, branches);
35
59
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
36
60
  actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
37
- actual.stdout.write(`Next: build both peer suites (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
61
+ actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
38
62
  `write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
39
63
  'then run `unitbob put-suite-build`.\n');
64
+ // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
65
+ // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
66
+ if (fixableNotices.length > 0) {
67
+ actual.stdout.write('\nBehavioral suite skipped this run — its BDD runner is not installed yet. ' +
68
+ 'This is a fixable setup step, not a build failure, and it does not affect the structural suite:\n' +
69
+ fixableNotices.join('\n') +
70
+ '\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
71
+ }
72
+ }
73
+ function inferBddRunner(projectRoot) {
74
+ if (existsSync(join(projectRoot, 'package.json')))
75
+ return 'cucumber-js';
76
+ if (['pyproject.toml', 'requirements.txt', 'Pipfile'].some((f) => existsSync(join(projectRoot, f))))
77
+ return 'pytest-bdd';
78
+ return 'cucumber';
40
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {