unitbob 0.6.2 → 0.7.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.
@@ -12,10 +12,17 @@ const MARKER = /ubc_[0-9a-f]{12}(?![0-9a-f])/;
12
12
  // the first test produces no set at all, and comparing against nothing would
13
13
  // stop a branch over a harness problem the loop never even reached.
14
14
  export function failureSet(runner, report) {
15
+ const found = reportedFailures(runner, report);
16
+ return found && canonical(found.map(({ marker, file, message }) => ({ marker, file, message })));
17
+ }
18
+ // The same failures, with everything a reader needs and the comparison does not.
19
+ // Reported in the order the runner reported them: this list is read by a person
20
+ // deciding what to repair, and a run's own order is the one that matches the
21
+ // console output next to it.
22
+ export function reportedFailures(runner, report) {
15
23
  if (!report.trim())
16
24
  return null;
17
- const found = extract(runner, report);
18
- return found && canonical(found);
25
+ return extract(runner, report);
19
26
  }
20
27
  // One hash for one set. Same set, same hash, on any machine and in any order.
21
28
  export function digestOf(failures) {
@@ -99,7 +106,9 @@ function fromRspec(report) {
99
106
  return [];
100
107
  const name = `${text(example.description)} ${text(example.full_description)}`;
101
108
  const exception = example.exception;
102
- return [failure(name, text(example.file_path), text(exception?.message))];
109
+ return [failure(name, text(example.file_path), text(exception?.message), {
110
+ name: text(example.full_description) || text(example.description),
111
+ })];
103
112
  });
104
113
  }
105
114
  function fromVitest(report) {
@@ -113,7 +122,9 @@ function fromVitest(report) {
113
122
  return [];
114
123
  const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages : [];
115
124
  const name = `${text(assertion.title)} ${text(assertion.fullName)}`;
116
- return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n'))];
125
+ return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n'), {
126
+ name: text(assertion.fullName) || text(assertion.title),
127
+ })];
117
128
  });
118
129
  });
119
130
  }
@@ -130,16 +141,55 @@ function fromVitest(report) {
130
141
  function fromJunitXml(report) {
131
142
  if (!/<testsuites?\b/.test(report))
132
143
  return null;
133
- const cases = report.match(/<testcase\b[^>]*(?:\/>|>[\s\S]*?<\/testcase>)/g) ?? [];
144
+ // Self-closing first, and as its own alternative rather than a branch inside
145
+ // one `[^>]*`. Written the other way the engine backtracks out of `\/>` into
146
+ // `>[\s\S]*?<\/testcase>` and swallows the next element whole, so a passing
147
+ // self-closed case immediately before a failing one hands back the passing
148
+ // one's name and file. Harmless while this was only hashed; wrong the moment
149
+ // spec 37-2 started printing it to somebody deciding what to repair.
150
+ const cases = report.match(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g) ?? [];
134
151
  return cases.flatMap((testcase) => {
135
- const problem = testcase.match(/<(?:failure|error)\b[^>]*(?:\/>|>[\s\S]*?<\/(?:failure|error)>)/);
152
+ const problem = testcase.match(/<(failure|error)\b[^>]*\/>|<(failure|error)\b[^>]*>([\s\S]*?)<\/(?:failure|error)>/);
136
153
  if (!problem)
137
154
  return [];
138
155
  const name = attribute(testcase, 'name');
139
156
  const file = attribute(testcase, 'file') || attribute(testcase, 'classname');
140
- return [failure(name, file, attribute(problem[0], 'message'))];
157
+ const message = attribute(problem[0], 'message');
158
+ // pytest puts the one-line summary in `message=` and the assertion with its
159
+ // traceback in the element's body. The body is what a person needs; the
160
+ // attribute is what the digest compares, and changing its bytes would make
161
+ // every branch look like it had moved once.
162
+ const body = unescapeXml(problem[3] ?? '').trim();
163
+ return [failure(name, file, message, {
164
+ name,
165
+ detail: [unescapeXml(message), body].filter(Boolean).join('\n'),
166
+ })];
141
167
  });
142
168
  }
169
+ // The five entities XML defines plus numeric references — pytest escapes its
170
+ // newlines as `&#10;`, and a traceback rendered as one line of `&#10;` is not a
171
+ // traceback. This is pytest's own writer on the other end, not arbitrary markup.
172
+ // `&amp;` last, so `&amp;lt;` comes back as `&lt;` rather than as `<`.
173
+ function unescapeXml(value) {
174
+ return value
175
+ .replace(/&#(\d+);/g, (whole, code) => codePoint(Number(code), whole))
176
+ .replace(/&#x([0-9a-f]+);/gi, (whole, code) => codePoint(parseInt(code, 16), whole))
177
+ .replace(/&lt;/g, '<')
178
+ .replace(/&gt;/g, '>')
179
+ .replace(/&quot;/g, '"')
180
+ .replace(/&apos;/g, "'")
181
+ .replace(/&amp;/g, '&');
182
+ }
183
+ // A reference outside Unicode is left as it was written. Nothing here is worth
184
+ // throwing over: this runs while somebody is reading why their suite is red.
185
+ function codePoint(value, whole) {
186
+ try {
187
+ return String.fromCodePoint(value);
188
+ }
189
+ catch {
190
+ return whole;
191
+ }
192
+ }
143
193
  // Cucumber Messages (NDJSON), both the Ruby and the JS emitter. One scenario is
144
194
  // spread over several envelopes: the pickle holds its name, tags and file, the
145
195
  // testCase maps its steps, and testStepFinished carries each step's result.
@@ -164,7 +214,13 @@ function fromCucumberMessages(report) {
164
214
  continue;
165
215
  const startedId = text(finished.testCaseStartedId);
166
216
  const list = results.get(startedId) ?? [];
167
- list.push(finished.testStepResult ?? {});
217
+ list.push({
218
+ // The step's own id travels with its result, so the text of the step that
219
+ // failed can be recovered from the pickle it came from (spec 37-2,
220
+ // criterion 5). Nothing in the digest reads it.
221
+ testStepId: finished.testStepId,
222
+ ...(finished.testStepResult ?? {}),
223
+ });
168
224
  results.set(startedId, list);
169
225
  }
170
226
  return envelopes.flatMap((envelope) => {
@@ -180,12 +236,27 @@ function fromCucumberMessages(report) {
180
236
  const tags = Array.isArray(pickle.tags) ? pickle.tags : [];
181
237
  const tagText = rows(tags).map((tag) => text(tag.name)).join(' ');
182
238
  const message = failed.map((step) => text(step.message)).find((line) => line.trim()) ?? '';
183
- return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message)];
239
+ return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message, {
240
+ name: text(pickle.name),
241
+ step: cucumberStepText(failed[0], testCase, pickle),
242
+ })];
184
243
  });
185
244
  }
186
- // The connector's own pytest-bdd report (`runner/pytestBddPlugin.ts`). It names
187
- // no file the whole behavioral bundle is one run — so the scenario's marker
188
- // and message carry the identity alone.
245
+ // Which step of that Scenario failed, in the words of the feature file. Three
246
+ // hops, because Cucumber Messages keeps the result, the mapping and the text in
247
+ // three different envelopes: result testStep → pickleStep.
248
+ function cucumberStepText(failed, testCase, pickle) {
249
+ const testSteps = Array.isArray(testCase.testSteps) ? rows(testCase.testSteps) : [];
250
+ const testStep = testSteps.find((step) => text(step.id) === text(failed.testStepId));
251
+ if (!testStep)
252
+ return '';
253
+ const pickleSteps = Array.isArray(pickle.steps) ? rows(pickle.steps) : [];
254
+ return text(pickleSteps.find((step) => text(step.id) === text(testStep.pickleStepId))?.text);
255
+ }
256
+ // The connector's own pytest-bdd report (`runner/pytestBddPlugin.ts`). Its
257
+ // `file` is the `.feature` the Scenario came from, and it is empty on a report
258
+ // written by a connector older than spec 37-2 — which is why it is read
259
+ // defensively rather than assumed.
189
260
  function fromPytestBdd(report) {
190
261
  const data = parseObject(report);
191
262
  if (!Array.isArray(data?.scenarios))
@@ -194,17 +265,28 @@ function fromPytestBdd(report) {
194
265
  if (text(scenario.status) === 'passed')
195
266
  return [];
196
267
  const tags = Array.isArray(scenario.tags) ? scenario.tags.map((tag) => text(tag)).join(' ') : '';
197
- return [failure(`${tags} ${text(scenario.name)}`, '', text(scenario.failure))];
268
+ // Our own plugin records one entry per step with its status, and marks the
269
+ // one it caught the exception in — so the step is read, never guessed.
270
+ const steps = Array.isArray(scenario.steps) ? rows(scenario.steps) : [];
271
+ const broke = steps.find((step) => text(step.status) === 'failed');
272
+ return [failure(`${tags} ${text(scenario.name)}`, text(scenario.file), text(scenario.failure), {
273
+ name: text(scenario.name),
274
+ step: broke ? `${text(broke.keyword)} ${text(broke.text)}`.trim() : '',
275
+ })];
198
276
  });
199
277
  }
200
- // Only the first line of a message. Later lines are backtraces and diffs, which
278
+ // `message` is only the first line. Later lines are backtraces and diffs, which
201
279
  // carry object ids and absolute paths that differ between two runs of the same
202
- // unchanged failure — the very drift that would make this comparison useless.
203
- function failure(name, file, message) {
280
+ // unchanged failure — the very drift that would make the comparison useless.
281
+ // `detail` keeps all of it: nothing on that field is hashed.
282
+ function failure(name, file, message, extra = {}) {
204
283
  return {
205
284
  marker: name.match(MARKER)?.[0] ?? '',
206
285
  file,
207
286
  message: message.split('\n')[0]?.trim() ?? '',
287
+ name: (extra.name ?? name).trim(),
288
+ step: extra.step ?? '',
289
+ detail: (extra.detail ?? message).trim(),
208
290
  };
209
291
  }
210
292
  function indexBy(envelopes, key) {
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { executable } from "../proc.js";
4
4
  import { BEHAVIORAL_DIR } from "../files/behavioral.js";
5
5
  import { runInProject } from "./place.js";
6
- import { commandFileOnHost, defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
6
+ import { commandFileOnHost, defaultToolDeps, hasGemfileWith, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
7
7
  // How long a local setup step may take before we stop waiting. Provisioning a
8
8
  // runner and loading a cold Rails test environment sit in the same ballpark —
9
9
  // tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
@@ -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.`,
@@ -348,17 +448,82 @@ function copyLockIfPresent(projectRoot, destination) {
348
448
  if (existsSync(projectLock))
349
449
  writeFileSync(destination, readFileSync(projectLock, 'utf8'));
350
450
  }
451
+ // The pin the sidecar asks for, and the oldest Ruby that pin will install on.
452
+ // Written next to each other because they are one fact: every cucumber in the
453
+ // 9.x line declares `required_ruby_version >= 2.7` (checked against rubygems for
454
+ // 9.0.0 through 9.2.1, 2026-08-24). Move the pin and this number moves with it.
455
+ const CUCUMBER_PIN = '~> 9.0';
456
+ const CUCUMBER_MIN_RUBY = { major: 2, minor: 7, text: '2.7' };
457
+ // Spec 37-2, criterion 4. One known refusal, made legible — not a parser for
458
+ // other people's error messages.
459
+ //
460
+ // a2time, 2026-08-17: the behavioral branch came back "Bundler failed to
461
+ // provision Cucumber sidecar gem", and the cause was a fact this process could
462
+ // have read in one command — the host's Ruby is 2.6.10, older than the cucumber
463
+ // this connector pins. It cost a round trip through the vibecoder to run
464
+ // `bundle install` by hand and read the same sentence back.
465
+ //
466
+ // Asked before the install rather than after: `bundle install` on a cold
467
+ // application takes minutes, and the answer is the same either way.
468
+ //
469
+ // Only when our pin is the one that applies. A project that declares cucumber
470
+ // itself keeps its own version — `gemLineUnlessTheProjectHasIt` drops our line
471
+ // whole — and refusing that project over a floor it never had to meet would
472
+ // stop a build that works.
473
+ //
474
+ // The Gemfile is read as text, while the line that drops the pin asks bundler's
475
+ // own resolved `dependencies`. The two can disagree, and only one direction of
476
+ // disagreement is expensive: a project whose cucumber arrives through `gemspec`
477
+ // or an `eval_gemfile` would be refused over a floor it never had to meet. So
478
+ // those two words count as "the project may name it", and the check stays quiet
479
+ // — back to bundler's own message, which is where this started and no worse.
480
+ const PROJECT_MAY_NAME_CUCUMBER = /^\s*gem\s+["']cucumber["']|^\s*gemspec\b|^\s*eval_gemfile\b/m;
481
+ async function rubyTooOldForCucumber(projectRoot, deps) {
482
+ if (hasGemfileWith(projectRoot, PROJECT_MAY_NAME_CUCUMBER))
483
+ return null;
484
+ const result = await deps
485
+ .runCmd('ruby', ['-v'], { cwd: projectRoot })
486
+ .catch(() => ({ code: 1, stdout: '', stderr: '' }));
487
+ if (result.code !== 0)
488
+ return null;
489
+ // A version we cannot read stops nothing. This check exists to replace one
490
+ // confusing message with one clear one, and guessing here would replace a
491
+ // clear failure with a wrong refusal.
492
+ const found = `${result.stdout}\n${result.stderr}`.match(/\bruby (\d+)\.(\d+)\.(\d+)/i);
493
+ if (!found)
494
+ return null;
495
+ const [, major, minor] = found;
496
+ const older = Number(major) < CUCUMBER_MIN_RUBY.major
497
+ || (Number(major) === CUCUMBER_MIN_RUBY.major && Number(minor) < CUCUMBER_MIN_RUBY.minor);
498
+ return older ? `${major}.${minor}.${found[3]}` : null;
499
+ }
351
500
  async function provisionRuby(projectRoot, behavioralDir, deps) {
501
+ const oldRuby = await rubyTooOldForCucumber(projectRoot, deps);
502
+ if (oldRuby) {
503
+ return {
504
+ status: 'fixable',
505
+ message: `Cucumber ${CUCUMBER_PIN} needs Ruby ${CUCUMBER_MIN_RUBY.text} or newer, and the Ruby answering here is ` +
506
+ `${oldRuby}. Bundler was not asked to install it.`,
507
+ checklist: [
508
+ `Run Unitbob where the application runs. The Ruby that answered is ${oldRuby}; if the app itself runs ` +
509
+ 'on a newer one inside a container, name that container under `exec` in `.unitbob.json` and this ' +
510
+ 'check runs there instead.',
511
+ `Or declare \`cucumber\` in the project's own Gemfile at a version that supports Ruby ${oldRuby} — ` +
512
+ 'the sidecar drops its own pin whenever the project names the gem.',
513
+ ],
514
+ };
515
+ }
352
516
  const sidecarGemfile = join(behavioralDir, 'Gemfile');
353
517
  const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
354
518
  'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
355
- 'gem "cucumber", "~> 9.0", require: false\n' +
519
+ gemLineUnlessTheProjectHasIt('cucumber', CUCUMBER_PIN) +
356
520
  // 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';
521
+ // do that if webmock resolves here. A project that does not carry the gem
522
+ // would otherwise get a World promising a block it silently never performs —
523
+ // the exact shape of failure 35-1 closes. A project that does carry it keeps
524
+ // its own version, and now actually gets to: see the comment on the helper
525
+ // for what asking twice cost A2.Time.
526
+ gemLineUnlessTheProjectHasIt('webmock');
362
527
  if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
363
528
  writeFileSync(sidecarGemfile, sidecarContent);
364
529
  }
@@ -399,7 +564,7 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
399
564
  }
400
565
  return {
401
566
  status: 'fixable',
402
- message: 'Bundler failed to provision Cucumber sidecar gem.',
567
+ message: `Bundler failed to provision Cucumber sidecar gem. ${whatBundlerSaid(result)}`,
403
568
  checklist: ['Ensure bundler is installed (`gem install bundler`) and run `bundle install` manually inside `.unitbob/behavioral/`.'],
404
569
  };
405
570
  }
@@ -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 43, §6.5) — a branch is
20
+ // Every file of the branch is named positionally (spec one-place-per-rule, §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
@@ -27,6 +27,11 @@ _UNITBOB_OUT = os.path.abspath(_UNITBOB_OUT) if _UNITBOB_OUT else None
27
27
  def pytest_bdd_before_scenario(request, feature, scenario):
28
28
  _UNITBOB_CURRENT[id(scenario)] = {
29
29
  "name": scenario.name,
30
+ # Which .feature file this Scenario came from. Every hook here is handed
31
+ # the feature and none of them recorded it, so a red run named the
32
+ # Scenario and left the reader to find the file (spec 37-2, criterion 5).
33
+ # Project-relative where pytest-bdd offers it.
34
+ "file": getattr(feature, "rel_filename", None) or getattr(feature, "filename", None) or "",
30
35
  "tags": sorted(scenario.tags),
31
36
  "status": "passed",
32
37
  "failure": "",
@@ -18,7 +18,7 @@ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
18
18
  // corrupt it.
19
19
  //
20
20
  // `suitePaths` is every file of the branch in the suite blob's own
21
- // project-relative form (spec 43, §6.5). Named one by one rather than as a
21
+ // project-relative form (spec one-place-per-rule, §6.5). Named one by one rather than as a
22
22
  // directory: the artifact already says exactly which files it is, while a
23
23
  // directory would also collect whatever else happens to be sitting under the
24
24
  // root.
@@ -37,7 +37,7 @@ const PROJECT_CONFIGS = [
37
37
  // file of the branch in `include`, and the positional filters keep the run to
38
38
  // exactly those files.
39
39
  //
40
- // Named files rather than a directory glob, since spec 43, §6.5 made a branch
40
+ // Named files rather than a directory glob, since spec one-place-per-rule, §6.5 made a branch
41
41
  // several files: the artifact already says which files it is, and a glob would
42
42
  // have to guess a naming convention nothing enforces. `include` is written even
43
43
  // when the project has no config of its own — Vitest's default include only
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { graphPath } from "../files/mapBuild.js";
3
+ export function graphNodes(projectRoot) {
4
+ const path = graphPath(projectRoot);
5
+ if (!existsSync(path))
6
+ return [];
7
+ try {
8
+ const graph = JSON.parse(readFileSync(path, 'utf8'));
9
+ if (!Array.isArray(graph.nodes))
10
+ return [];
11
+ return graph.nodes.filter((node) => !!node && typeof node.id === 'string');
12
+ }
13
+ catch {
14
+ return []; // an unreadable graph costs us the links, not the addresses
15
+ }
16
+ }
17
+ export function pathsMatch(candidate, file) {
18
+ const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
19
+ const wanted = file.replace(/\\/g, '/');
20
+ if (normalised === wanted)
21
+ return 'exact';
22
+ return normalised.endsWith(`/${wanted}`) ? 'suffix' : 'no';
23
+ }
24
+ // Real graphify labels a Ruby method `.send_to_fsa()` and a JS one
25
+ // `initButtons()`; a qualified `CheckoutController#create` also turns up. All of
26
+ // them are read the same way — drop the call parentheses, then take the last
27
+ // name — so the match survives the decoration without depending on which form
28
+ // this release of graphify happens to use. The id itself is never rebuilt from
29
+ // any of this; it is copied.
30
+ export function methodNameOf(label) {
31
+ const parts = label.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
32
+ return parts[parts.length - 1] ?? '';
33
+ }
@@ -1,10 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { executable } from "../proc.js";
4
4
  import { firstErrorLine } from "../runner/bootcheck.js";
5
5
  import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
6
6
  import { detectStructuralRunner } from "../runner/precheck.js";
7
- import { graphPath } from "../files/mapBuild.js";
7
+ import { graphNodes, methodNameOf, pathsMatch } from "./graph.js";
8
8
  // Reading a router means booting the application, which on a large Rails app is
9
9
  // tens of seconds. The same budget the other boot-shaped step uses.
10
10
  const ROUTES_TIMEOUT_MS = 120_000;
@@ -266,20 +266,6 @@ function rowsFrom(record) {
266
266
  action,
267
267
  }));
268
268
  }
269
- function graphNodes(projectRoot) {
270
- const path = graphPath(projectRoot);
271
- if (!existsSync(path))
272
- return [];
273
- try {
274
- const graph = JSON.parse(readFileSync(path, 'utf8'));
275
- if (!Array.isArray(graph.nodes))
276
- return [];
277
- return graph.nodes.filter((node) => !!node && typeof node.id === 'string');
278
- }
279
- catch {
280
- return []; // an unreadable graph costs us the links, not the addresses
281
- }
282
- }
283
269
  function toSurface(projectRoot, row, nodes) {
284
270
  const surface = { kind: 'route', id: `${row.verb} ${row.path}` };
285
271
  if (!row.controller || !row.action)
@@ -350,23 +336,6 @@ function findNode(nodes, file, action) {
350
336
  // still ships, without a link.
351
337
  return candidates.length === 1 ? candidates[0] : undefined;
352
338
  }
353
- function pathsMatch(candidate, file) {
354
- const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
355
- const wanted = file.replace(/\\/g, '/');
356
- if (normalised === wanted)
357
- return 'exact';
358
- return normalised.endsWith(`/${wanted}`) ? 'suffix' : 'no';
359
- }
360
- // Real graphify labels a Ruby method `.send_to_fsa()` and a JS one
361
- // `initButtons()`; a qualified `CheckoutController#create` also turns up. All of
362
- // them are read the same way — drop the call parentheses, then take the last
363
- // name — so the match survives the decoration without depending on which form
364
- // this release of graphify happens to use. The id itself is never rebuilt from
365
- // any of this; it is copied.
366
- function methodNameOf(label) {
367
- const parts = label.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
368
- return parts[parts.length - 1] ?? '';
369
- }
370
339
  // What `surfaces.json` must contain for every address the router declared, and
371
340
  // what it must not contain on top of them. Used before upload (`put-map-build`):
372
341
  // the inventory removed the model's chance to invent an address, and this
Binary file
@@ -46,7 +46,7 @@ export async function putSuiteBuild(config, _args = [], deps) {
46
46
  // skipped by going straight to the upload — but reported the way every other
47
47
  // local failure here is reported: against the branch it belongs to.
48
48
  //
49
- // Since spec 43 that check is exactly one question, and it is about a branch
49
+ // Since spec one-place-per-rule that check is exactly one question, and it is about a branch
50
50
  // the answer has *no* entry for: everything else it used to ask is now asked
51
51
  // of the server, by a dry run, before this command runs at all. So its
52
52
  // problems can never land on a branch this loop visits, and they are reported
package/dist/verbs/run.js CHANGED
@@ -30,7 +30,7 @@ function resolve(config, deps) {
30
30
  getSuites: () => wire.getSuites(),
31
31
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
32
32
  // The whole envelope, support files and all: a branch is a set of files
33
- // since spec 43, §6, and picking `path` and `content` out of it here was
33
+ // since spec one-place-per-rule, §6, and picking `path` and `content` out of it here was
34
34
  // where the rest of them used to be lost.
35
35
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
36
36
  suite_digest: item.suite_digest,
@@ -145,7 +145,7 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
145
145
  }
146
146
  }
147
147
  // Every file of the branch, in the order the envelope carries them. A structural
148
- // branch is one file per assignment since spec 43, §6, and running only the main
148
+ // branch is one file per assignment since spec one-place-per-rule, §6, and running only the main
149
149
  // one would execute a fraction of what the map says is guarded.
150
150
  function artifactPaths(file) {
151
151
  return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];