unitbob 0.6.2 → 0.6.3

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.
@@ -295,6 +295,92 @@ async function provisionVitest(projectRoot, deps) {
295
295
  // project's own bundler settings are never touched, and no other bundler
296
296
  // invocation carries this.
297
297
  const UNFROZEN_SIDECAR = { BUNDLE_FROZEN: 'false', BUNDLE_DEPLOYMENT: 'false' };
298
+ // One gem line for the sidecar, asked for only if the project has not asked for
299
+ // it already.
300
+ //
301
+ // `eval_gemfile` runs the project's own Gemfile inside *this* Dsl object — that
302
+ // is the whole point of it, and it is also the trap. Every `gem` line we add
303
+ // afterwards lands in the same dependency list the project just filled, so a gem
304
+ // the project already names is declared twice, and bundler's rules for that are
305
+ // strict: identical requirements warn, differing ones raise `GemfileError` while
306
+ // the Gemfile is still being parsed.
307
+ //
308
+ // Measured on bundler 2.4.22 and 4.0.1 after A2.Time (Rails 5.0, Ruby 2.7.8)
309
+ // could not generate a behavioral suite at all, 2026-08-20. It pins
310
+ // `webmock "~> 3.23"`; we asked for `webmock (>= 0)`:
311
+ //
312
+ // You cannot specify the same gem twice with different version requirements.
313
+ // You specified: webmock (~> 3.23) and webmock (>= 0). Bundler cannot continue.
314
+ //
315
+ // Parsing fails before resolution begins, so there is no lock and no versions to
316
+ // negotiate — and `suite-prepare` rewrote the same conflicting file on every
317
+ // retry, which left the vibecoder with nothing to patch either. The comment that
318
+ // used to sit on the webmock line had this exactly backwards: it promised the
319
+ // project's own version would win "because bundler starts from the project's own
320
+ // resolution". True of resolution. Parsing never reached it.
321
+ //
322
+ // `dependencies` is Bundler::Dsl's own reader and the Gemfile is instance_eval'd
323
+ // on the Dsl, so the list is in scope and already holds everything the project
324
+ // declared. Checked in the dsl.rb of 2.1.4, 2.2.33, 2.3.27, 2.4.22 and 4.0.1 —
325
+ // 2.1.4 because it is what Ruby 2.7.8 ships, and Ruby 2.7.8 is what the
326
+ // application that found this bug runs.
327
+ //
328
+ // What we would have added is dropped rather than merged, version and all: a
329
+ // project pinning `cucumber "~> 8.0"` gets a sidecar on cucumber 8 instead of a
330
+ // hard failure. The suite then runs on the version that project already trusts,
331
+ // which is the bargain the rest of this sidecar strikes anyway — it inherits the
332
+ // project's Gemfile precisely so the two cannot drift apart.
333
+ //
334
+ // One thing this does give up, measured on 2.1.4 and 4.0.1 rather than assumed.
335
+ // Where the requirements happened to match, bundler used to keep *both*
336
+ // declarations — the project's and ours — so a gem the project had confined to
337
+ // `group :test` also arrived ungrouped through us, and no `BUNDLE_WITHOUT` could
338
+ // drop it. Skipping our line leaves only the project's, groups and all. That is
339
+ // the honest arrangement, and it is not silent: the World probe of spec 35-1
340
+ // asserts against a live `WebMock::NetConnectNotAllowedError`, so a webmock that
341
+ // did not come along stops `suite-prepare` with a fixable probe failure instead
342
+ // of letting a suite run with the block it advertises quietly missing.
343
+ function gemLineUnlessTheProjectHasIt(name, requirement) {
344
+ const pin = requirement ? `, "${requirement}"` : '';
345
+ return `gem "${name}"${pin}, require: false unless dependencies.any? { |d| d.name == "${name}" }\n`;
346
+ }
347
+ const BUNDLER_OUTPUT_LINES = 20;
348
+ const BUNDLER_OUTPUT_CHARS = 2000;
349
+ // What bundler said, kept instead of thrown away. Reads as the sentence after
350
+ // "Bundler failed to ...", whichever of its three shapes it takes.
351
+ //
352
+ // Both Ruby sidecars used to capture `result` and then return a fixed line, so a
353
+ // provisioning failure reached the vibecoder as "Bundler failed to provision ..."
354
+ // and nothing else. On A2.Time that hid the `GemfileError` above completely: the
355
+ // run reported a blocked behavioral branch, the reason was already in this
356
+ // process's memory, and it still took a round trip through the user — run bundler
357
+ // by hand, paste the output — to find out what it was. An error we have been told
358
+ // is not one to make somebody fetch again.
359
+ //
360
+ // The tail, because bundler puts the reason last on failures long enough to
361
+ // scroll (a resolution conflict prints its whole search first), and a Gemfile
362
+ // that will not parse is short enough that the tail is all of it. Not
363
+ // `installerComplaint`, which is next door and does the opposite on purpose: it
364
+ // picks the single line pip labelled an error out of hundreds of lines of
365
+ // compiler noise. Bundler's verdict carries no such label — the one that matters
366
+ // here opens with `[!]` and runs over three lines — so filtering by line would
367
+ // drop exactly the sentence worth keeping.
368
+ function whatBundlerSaid(result) {
369
+ const text = [result.stdout, result.stderr].map((part) => part.trim()).filter(Boolean).join('\n');
370
+ // A null code is a process this connector killed, not one that decided
371
+ // anything. No number is named with it: the two callers run under different
372
+ // budgets — `provisionRspec` asks for `DEPENDENCY_INSTALL_TIMEOUT_MS`,
373
+ // `provisionRuby` takes the `PROVISION_TIMEOUT_MS` default — and a message
374
+ // that states the wrong one is worse than a message that states none.
375
+ if (!text) {
376
+ return result.code === null
377
+ ? 'It said nothing: it was stopped before it could, having run past its timeout or lost the place it was running in.'
378
+ : `It said nothing, and exited ${result.code}.`;
379
+ }
380
+ const lines = text.split('\n');
381
+ const tail = lines.slice(-BUNDLER_OUTPUT_LINES).join('\n').slice(-BUNDLER_OUTPUT_CHARS);
382
+ return `It said:\n${tail.length < text.length ? `...\n${tail}` : tail}`;
383
+ }
298
384
  // A sidecar Gemfile that inherits the project's own, plus rspec-rails. Bundler
299
385
  // resolves the two together, so the application's gems come with it — the same
300
386
  // arrangement the Cucumber sidecar has used since spec 32-1, and the reason the
@@ -303,6 +389,20 @@ async function provisionRspec(projectRoot, deps) {
303
389
  const sidecarGemfile = sidecarPath(projectRoot, 'Gemfile');
304
390
  writeIfChanged(sidecarGemfile, '# Sidecar Gemfile written by the unitbob connector — do not edit.\n' +
305
391
  'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
392
+ // Deliberately *not* guarded the way the Cucumber sidecar below is.
393
+ // `ensureStructuralRunner` only reaches here when the project does not
394
+ // supply rspec itself, so the duplicate that stopped A2.Time has almost no
395
+ // way in — and the guard would cost something real where it does. Measured
396
+ // 2026-08-20 on a gem project that carries rspec-rails as a gemspec
397
+ // development dependency: bundler replaces a `:development` dependency with
398
+ // ours rather than refusing it, so today the runner lands in `:default` and
399
+ // is always installed. Guarded, we would skip our line and leave it in
400
+ // `:development`, where a `BUNDLE_WITHOUT=development` would take the
401
+ // structural runner away from a project that had it working.
402
+ //
403
+ // That leaves one narrow hole: rspec-rails reached through an eval'd
404
+ // sub-Gemfile does raise the duplicate error here. It is now a legible one
405
+ // — see the message below, which no longer swallows what bundler said.
306
406
  'gem "rspec-rails", require: false\n');
307
407
  // Start from the project's own resolution for the reason spelled out on the
308
408
  // Cucumber sidecar below: without it bundler re-resolves the whole graph and
@@ -320,7 +420,7 @@ async function provisionRspec(projectRoot, deps) {
320
420
  return { status: 'provisioned' };
321
421
  return {
322
422
  status: 'fixable',
323
- message: `Bundler failed to provision rspec-rails under ${SIDECAR_DIR}.`,
423
+ message: `Bundler failed to provision rspec-rails under ${SIDECAR_DIR}. ${whatBundlerSaid(result)}`,
324
424
  checklist: [
325
425
  'Ensure bundler is installed (`gem install bundler`), then run ' +
326
426
  `\`BUNDLE_GEMFILE=${SIDECAR_DIR}/Gemfile bundle install\` from the project root.`,
@@ -352,13 +452,14 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
352
452
  const sidecarGemfile = join(behavioralDir, 'Gemfile');
353
453
  const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
354
454
  'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
355
- 'gem "cucumber", "~> 9.0", require: false\n' +
455
+ gemLineUnlessTheProjectHasIt('cucumber', '~> 9.0') +
356
456
  // 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';
457
+ // do that if webmock resolves here. A project that does not carry the gem
458
+ // would otherwise get a World promising a block it silently never performs —
459
+ // the exact shape of failure 35-1 closes. A project that does carry it keeps
460
+ // its own version, and now actually gets to: see the comment on the helper
461
+ // for what asking twice cost A2.Time.
462
+ gemLineUnlessTheProjectHasIt('webmock');
362
463
  if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
363
464
  writeFileSync(sidecarGemfile, sidecarContent);
364
465
  }
@@ -399,7 +500,7 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
399
500
  }
400
501
  return {
401
502
  status: 'fixable',
402
- message: 'Bundler failed to provision Cucumber sidecar gem.',
503
+ message: `Bundler failed to provision Cucumber sidecar gem. ${whatBundlerSaid(result)}`,
403
504
  checklist: ['Ensure bundler is installed (`gem install bundler`) and run `bundle install` manually inside `.unitbob/behavioral/`.'],
404
505
  };
405
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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": {
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
21
21
  connector-owned harness, or another slice.
22
22
 
23
23
  After every owned edit, run
24
- `npx -y --loglevel=error unitbob@0.6.2 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.6.3 run-local <branch>` and inspect the machine
25
25
  report. Look only at examples or scenarios matching your owned paths or case
26
26
  markers. Do not require a green exit code from the whole branch: foreign failures
27
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded