unitbob 0.3.1 → 0.3.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.
@@ -29,10 +29,22 @@ export function assertGuardrailPath(path) {
29
29
  // own RSpec setup when one exists; boots the Rails test environment directly
30
30
  // when none does. Both branches refuse a non-test environment — before boot
31
31
  // via ENV (nothing touched yet), after boot via Rails.env (config overrides).
32
+ //
33
+ // `rspec/core` is required first because this file has two callers and only one
34
+ // of them is the `rspec` binary. The run loads it through that binary, which has
35
+ // already required rspec-core by the time it reads a spec file; the boot check
36
+ // of spec 32-6 loads it through a bare `ruby -e`, which has not. A Rails-
37
+ // generated `spec/rails_helper.rb` calls `RSpec.configure` (inside `spec_helper`)
38
+ // before it requires `rspec/rails`, so under the bare interpreter it died on
39
+ // `uninitialized constant RSpec` — a healthy application declared broken, and its
40
+ // owner told to edit a tracked file the recipes forbid touching. That made the
41
+ // check stricter than the run it predicts, which is the one rule 32-6 is built
42
+ // on. Under the binary this line is a no-op. Found on the a2time run 2026-08-04.
32
43
  export const UNITBOB_HELPER_RB = `# frozen_string_literal: true
33
44
  # Written by the unitbob connector on every materialization — do not edit.
34
45
  ENV['RAILS_ENV'] ||= 'test'
35
46
  abort 'unitbob_helper: refusing to run against a non-test environment' unless ENV['RAILS_ENV'] == 'test'
47
+ require 'rspec/core'
36
48
  root = File.expand_path('../..', __dir__)
37
49
  if File.exist?(File.join(root, 'spec', 'rails_helper.rb'))
38
50
  # The project has its own RSpec setup — respect it (factories, cleaners…).
@@ -31,6 +31,25 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
31
31
  if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
32
32
  writeFileSync(sidecarGemfile, sidecarContent);
33
33
  }
34
+ // Start the sidecar from the project's own resolution, so bundler adds
35
+ // cucumber and leaves everything else on the versions the project already
36
+ // runs. Without a starting lock it resolves the whole graph from scratch: on
37
+ // a2time that moved 285 gems, handed the sidecar carrierwave 2.2.0 where the
38
+ // project pins 2.2.6, and Rails then would not load at all (`cannot load such
39
+ // file -- mimemagic/overlay`). The behavioral branch could not start, and the
40
+ // boot check of 32-6 had said `ok` — truthfully, because it only ever asks the
41
+ // structural runner. Found on the a2time run 2026-08-04.
42
+ //
43
+ // Copied on every provision rather than only when missing. A lock seeded once
44
+ // goes stale the moment the project upgrades a gem — the sidecar Gemfile
45
+ // inherits the project's *Gemfile* through `eval_gemfile`, never its lock, so
46
+ // nothing would pull the new version through and the drift this exists to
47
+ // prevent comes back slowly instead of at once. `bundle install` already runs
48
+ // on every provision, so re-adding cucumber to a fresh copy costs nothing new.
49
+ const projectLock = join(projectRoot, 'Gemfile.lock');
50
+ if (existsSync(projectLock)) {
51
+ writeFileSync(join(behavioralDir, 'Gemfile.lock'), readFileSync(projectLock, 'utf8'));
52
+ }
34
53
  const gemfileRel = '.unitbob/behavioral/Gemfile';
35
54
  const env = { BUNDLE_GEMFILE: gemfileRel };
36
55
  // Try project local bin/bundle, then bundle
@@ -25,7 +25,7 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
25
25
  const asked = await askTheRouter(projectRoot, deps);
26
26
  if ('reason' in asked)
27
27
  return silent(projectRoot, asked.reason, asked.detail);
28
- const rows = parseExpandedRoutes(asked.result.stdout);
28
+ const rows = parseRouterAnswer(asked.result.stdout);
29
29
  // Zero rows is far likelier to mean "this output is not what we know how to
30
30
  // read" than "this application has no addresses". Claiming the second would
31
31
  // hand the map a confident, empty answer, so we claim neither.
@@ -43,7 +43,7 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
43
43
  const path = routeInventoryPath(projectRoot);
44
44
  try {
45
45
  mkdirSync(dirname(path), { recursive: true });
46
- writeFileSync(path, `${JSON.stringify({ declared_by: 'rails routes', environment: asked.environment, surfaces }, null, 2)}\n`);
46
+ writeFileSync(path, `${JSON.stringify({ declared_by: 'rails router', environment: asked.environment, surfaces }, null, 2)}\n`);
47
47
  }
48
48
  catch (err) {
49
49
  // A read-only checkout or a full disk is not a reason to take the whole map
@@ -63,21 +63,13 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
63
63
  // first answer is decided there; only "this environment refused to load" is
64
64
  // worth asking again, because that is the one failure an environment can own.
65
65
  async function askTheRouter(projectRoot, deps) {
66
- const first = await runRailsRoutes(projectRoot, deps, 'test');
66
+ const first = await askRouterOnce(projectRoot, deps, 'test');
67
67
  if (first === null) {
68
68
  // The command is not on this machine. A second environment cannot conjure it.
69
69
  return { reason: 'app_did_not_load', detail: 'the command did not run on this machine' };
70
70
  }
71
71
  if (first.code === 0)
72
72
  return { result: first, environment: 'test' };
73
- // Rails learned `--expanded` in 5.1. Older ones reject the flag before they
74
- // load anything, and saying "your application did not load" there sends
75
- // somebody to debug an application that is perfectly fine — the same reason
76
- // the boot check grew `runner_too_old` rather than calling vitest missing.
77
- // The flag is refused the same way in every environment, so there is nothing
78
- // to retry.
79
- if (rejectedTheFlag(first))
80
- return { reason: 'router_too_old' };
81
73
  // No exit code at all means the process was stopped rather than finished —
82
74
  // our own timeout, or a signal from outside. An application too large to
83
75
  // enumerate its routes in the time we allow has told us nothing about whether
@@ -85,12 +77,12 @@ async function askTheRouter(projectRoot, deps) {
85
77
  // Retrying would spend the same budget twice for the same silence.
86
78
  if (first.code === null)
87
79
  return { reason: 'did_not_finish' };
88
- const second = await runRailsRoutes(projectRoot, deps, 'default');
80
+ const second = await askRouterOnce(projectRoot, deps, 'default');
89
81
  if (second !== null && second.code === 0)
90
82
  return { result: second, environment: 'default' };
91
83
  if (second !== null && second.code === null)
92
84
  return { reason: 'did_not_finish' };
93
- // `rails routes` loads the application, so running it *is* the check: an app
85
+ // Asking the router loads the application, so asking it *is* the check: an app
94
86
  // that will not boot cannot print its routes. We do not ask the boot check
95
87
  // from spec 32-6 first — that would boot the app twice to learn the same
96
88
  // thing — but we do answer in its currency, quoting the runner's own first
@@ -139,21 +131,18 @@ function becauseOf(result) {
139
131
  switch (result.reason) {
140
132
  case 'unsupported_stack':
141
133
  return 'this project has no router Unitbob can ask yet (Rails is the only one so far)';
142
- case 'router_too_old':
143
- return '`rails routes --expanded` needs Rails 5.1 or newer, and this Rails refused the flag ' +
144
- '(nothing is wrong with the application)';
145
134
  case 'app_did_not_load':
146
- return `\`rails routes\` did not get through — ${result.detail}`;
135
+ return `the router could not be asked — ${result.detail}`;
147
136
  case 'did_not_finish':
148
137
  // No exit code means stopped, and we do not know by whom: our own
149
138
  // ${minutes}-minute limit, or something outside this process. Naming only
150
139
  // the timeout would put a duration on a run that may have been killed in
151
140
  // ten seconds — a claim we cannot make.
152
- return `\`rails routes\` was stopped before it finished — either it ran past the ` +
141
+ return `the router was not done answering — either it ran past the ` +
153
142
  `${ROUTES_TIMEOUT_MS / 60_000}-minute limit or something else killed it (nothing here says the ` +
154
143
  'application is unhealthy)';
155
144
  case 'no_routes':
156
- return '`rails routes` printed nothing this version knows how to read';
145
+ return 'the router answered in a shape this connector could not read';
157
146
  case 'could_not_write':
158
147
  return `the inventory could not be written — ${result.detail}`;
159
148
  }
@@ -164,17 +153,47 @@ function plural(count, one, many) {
164
153
  function looksLikeRails(projectRoot) {
165
154
  return existsSync(join(projectRoot, 'config', 'routes.rb'));
166
155
  }
167
- // `--expanded` rather than the default table: one field per line, so nothing
168
- // depends on guessing column widths, and a long URI pattern cannot run into its
169
- // neighbour. `test` is asked first because that is the environment the guardrail
170
- // suite boots in, so the routes read there are the routes the suite would see;
156
+ // The question, asked of the router object rather than of the `rails routes`
157
+ // command line. `--expanded` was the earlier reading, and it cost this project
158
+ // its whole inventory on the a2time run: the flag arrived in Rails 5.1, that
159
+ // application runs 5.0, and 194 addresses went back to being typed out by hand
160
+ // the exact work this module exists to remove. `routes` also prints for people,
161
+ // so its field names move between versions (`URI` in Rails 7, `URI Pattern`
162
+ // before it) and the reader had to know both.
163
+ //
164
+ // `Rails.application.routes.routes` has been the same object since Rails 3 and
165
+ // answers in a shape we choose. Nothing here is version-specific except the one
166
+ // thing that genuinely changed: `verb` was a Regexp before Rails 5 and is a
167
+ // String after, so both are reduced to the same letters.
168
+ //
169
+ // The answer is printed behind a sentinel because an application is free to
170
+ // write to stdout while it boots — an initializer with a banner, a deprecation
171
+ // notice — and none of that is ours to parse.
172
+ const ROUTES_SENTINEL = 'UNITBOB_ROUTES ';
173
+ // One address per line rather than one array holding all of them: a project with
174
+ // five hundred routes should not be a single line, and a line cut in half loses
175
+ // one address instead of every address. That cannot lose addresses quietly —
176
+ // output is only ever cut short when the process was killed, and a run that did
177
+ // not exit cleanly is refused before anyone reads what it printed.
178
+ const ROUTES_SCRIPT = `require 'json'
179
+ Rails.application.routes.routes.each do |r|
180
+ next if r.respond_to?(:internal) && r.internal
181
+ STDOUT.print("${ROUTES_SENTINEL}" + JSON.generate(
182
+ 'verb' => r.verb.is_a?(String) ? r.verb : r.verb.to_s.gsub(%r{[$^/]}, ''),
183
+ 'path' => r.path.spec.to_s,
184
+ 'controller' => r.defaults[:controller],
185
+ 'action' => r.defaults[:action]
186
+ ) + "\\n")
187
+ end`;
188
+ // `test` is asked first because that is the environment the guardrail suite
189
+ // boots in, so the routes read there are the routes the suite would see;
171
190
  // `default` means we leave RAILS_ENV alone and take whatever the project's own
172
191
  // setup chooses.
173
- async function runRailsRoutes(projectRoot, deps, environment) {
192
+ async function askRouterOnce(projectRoot, deps, environment) {
174
193
  const local = join(projectRoot, 'bin', 'rails');
175
194
  const [command, args] = existsSync(local)
176
- ? [local, ['routes', '--expanded']]
177
- : ['bundle', ['exec', 'rails', 'routes', '--expanded']];
195
+ ? [local, ['runner', ROUTES_SCRIPT]]
196
+ : ['bundle', ['exec', 'rails', 'runner', ROUTES_SCRIPT]];
178
197
  try {
179
198
  return await deps.runCmd(command, args, {
180
199
  cwd: projectRoot,
@@ -187,33 +206,23 @@ async function runRailsRoutes(projectRoot, deps, environment) {
187
206
  return null; // the command is not on this machine
188
207
  }
189
208
  }
190
- // Ruby's own OptionParser wording, and Thor's whichever layer of the `rails`
191
- // command line sees the flag first. Both name the flag they refused, and the
192
- // name has to be part of the match: "invalid option" is ordinary Ruby wording
193
- // that also turns up inside a failing initializer, and reading that as an old
194
- // Rails would tell somebody their application is fine while it is broken —
195
- // exactly the wrong-errand this reason exists to prevent, pointing the other way.
196
- function rejectedTheFlag(result) {
197
- return /(?:invalid option|unknown switches?|unrecognized option)[^\n]*expanded/i.test(`${result.stdout}\n${result.stderr}`);
198
- }
199
- // The `--expanded` record:
200
- //
201
- // --[ Route 1 ]------------------------------
202
- // Prefix | settings
203
- // Verb | GET
204
- // URI | /settings(.:format)
205
- // Controller#Action | settings#index
206
- //
207
- // The path field is `URI` on Rails 7 and `URI Pattern` on older versions — both
208
- // are read, because which one we get is decided by the user's Gemfile. Fields we
209
- // do not use (Prefix, Source Location) are ignored rather than rejected, so a
210
- // newer Rails printing more of them still reads.
211
- export function parseExpandedRoutes(stdout) {
209
+ // The router's answer: the sentinel lines, in among whatever the application
210
+ // printed while booting. Lines are picked out by their prefix rather than by
211
+ // position, so a banner or a deprecation notice costs nothing.
212
+ export function parseRouterAnswer(stdout) {
212
213
  const rows = [];
213
214
  const seen = new Set();
214
- let record = {};
215
- const flush = () => {
216
- for (const row of rowsFrom(record)) {
215
+ for (const line of stdout.split('\n')) {
216
+ if (!line.startsWith(ROUTES_SENTINEL))
217
+ continue;
218
+ let declared;
219
+ try {
220
+ declared = JSON.parse(line.slice(ROUTES_SENTINEL.length));
221
+ }
222
+ catch {
223
+ continue; // not the shape we know how to read
224
+ }
225
+ for (const row of rowsFrom((declared ?? {}))) {
217
226
  const key = `${row.verb} ${row.path}`;
218
227
  // The same address can be drawn under several prefixes. It is one address.
219
228
  if (seen.has(key))
@@ -221,37 +230,26 @@ export function parseExpandedRoutes(stdout) {
221
230
  seen.add(key);
222
231
  rows.push(row);
223
232
  }
224
- record = {};
225
- };
226
- for (const line of stdout.split('\n')) {
227
- if (/^--\[ Route /.test(line)) {
228
- flush();
229
- continue;
230
- }
231
- const separator = line.indexOf('|');
232
- if (separator === -1)
233
- continue;
234
- record[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
235
233
  }
236
- flush();
237
234
  return rows;
238
235
  }
239
236
  function rowsFrom(record) {
240
- const pattern = record.URI ?? record['URI Pattern'];
241
- const verb = record.Verb ?? '';
237
+ const pattern = typeof record.path === 'string' ? record.path : '';
238
+ const verb = typeof record.verb === 'string' ? record.verb : '';
242
239
  // No verb means a mounted Rack application (`mount Sidekiq::Web at: '/sidekiq'`),
243
240
  // not an address of this application. Its own routes live in its own router.
244
241
  if (!pattern || !verb)
245
242
  return [];
246
- const handler = /^([A-Za-z0-9_/]+)#([A-Za-z0-9_]+)$/.exec(record['Controller#Action'] ?? '');
243
+ const controller = typeof record.controller === 'string' ? record.controller : undefined;
244
+ const action = typeof record.action === 'string' ? record.action : undefined;
247
245
  const path = pattern.replace(/\(\.:format\)$/, '');
248
- // `match via: [:get, :post]` prints one record with both verbs. They are two
249
- // addresses, and the map should say so.
246
+ // `match via: [:get, :post]` is one route object carrying both verbs. They are
247
+ // two addresses, and the map should say so.
250
248
  return verb.split('|').map((one) => ({
251
249
  verb: one.trim(),
252
250
  path,
253
- controller: handler?.[1],
254
- action: handler?.[2],
251
+ controller,
252
+ action,
255
253
  }));
256
254
  }
257
255
  function graphNodes(projectRoot) {
@@ -106,7 +106,93 @@ function checkOneCase(row, id, expected, suiteText, add) {
106
106
  // as a mismatch rather than as the green it claims.
107
107
  if (suiteText && !suiteText.includes(expected.case_marker)) {
108
108
  add(`${id} is answered "covered", but its marker ${expected.case_marker} appears nowhere in the suite files.`);
109
+ return;
110
+ }
111
+ checkSurfaceCoverage(row, id, expected, suiteText, add);
112
+ }
113
+ // Which scenario reached which address. The a2time run of 2026-08-04 published
114
+ // 97 coverage rows against 99 Scenarios: one Scenario had no row, another had a
115
+ // row naming no address. Both mean the same thing — a Scenario that ran and
116
+ // whose result reaches nothing on the map — and both were found by the
117
+ // independent reviewer, hours later, doing a different job. This check was the
118
+ // cheap place to find them and it was not looking.
119
+ //
120
+ // Only asked when the answer is already speaking this language: an answer with
121
+ // no `surface_coverage` anywhere is an older map's shape, and refusing it here
122
+ // would refuse what the server accepts. Within a branch that does declare it,
123
+ // the rules below are the server's own, in the server's own order.
124
+ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
125
+ if (expected.surfaces.length === 0)
126
+ return;
127
+ const coverage = row.surface_coverage;
128
+ if (coverage === undefined)
129
+ return; // not this map's shape — the server decides
130
+ if (!Array.isArray(coverage)) {
131
+ add(`${id} is answered "covered", so its surface_coverage must be an array of {scenario, surfaces}.`);
132
+ return;
133
+ }
134
+ const named = new Set();
135
+ const reached = new Set();
136
+ for (const [index, item] of coverage.entries()) {
137
+ const entry = (item ?? {});
138
+ const scenario = String(entry.scenario ?? '').trim();
139
+ const surfaces = entry.surfaces;
140
+ if (!scenario || !Array.isArray(surfaces)) {
141
+ add(`${id} surface_coverage[${index}] must name a scenario and its surfaces.`);
142
+ continue;
143
+ }
144
+ if (surfaces.length === 0) {
145
+ add(`${id} surface_coverage names no surface for "${scenario}" — that scenario's result reaches nothing on the map.`);
146
+ }
147
+ if (suiteText && !suiteText.includes(scenario)) {
148
+ add(`${id} surface_coverage names "${scenario}", which appears nowhere in the suite files.`);
149
+ }
150
+ named.add(scenario);
151
+ surfaces.filter((s) => typeof s === 'string').forEach((s) => reached.add(s));
152
+ }
153
+ const missed = expected.surfaces.filter((surface) => !reached.has(surface));
154
+ if (missed.length > 0) {
155
+ add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}.`);
156
+ }
157
+ const foreign = [...reached].filter((surface) => !expected.surfaces.includes(surface));
158
+ if (foreign.length > 0) {
159
+ add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
160
+ }
161
+ // The other direction, and the one that found nothing on a2time because
162
+ // nobody asked it: a Scenario that carries the marker but appears in no row.
163
+ //
164
+ // Read off the file rather than parsed: a tag line carrying this marker, then
165
+ // the next line that has a colon in it, whose name is whatever follows the
166
+ // first colon. That holds for any Gherkin dialect, because only the keyword is
167
+ // translated and the colon is not. When the shape is not recognised the answer
168
+ // is silence — the server does parse this properly, and a guess here that says
169
+ // "you forgot a Scenario" about a Scenario that does not exist would cost the
170
+ // branch its publication.
171
+ const unlisted = scenarioNamesTagged(suiteText, expected.case_marker).filter((name) => !named.has(name));
172
+ if (unlisted.length > 0) {
173
+ add(`${id} surface_coverage does not account for ${unlisted.map((n) => `"${n}"`).join(', ')}.`);
174
+ }
175
+ }
176
+ // Scenario names carrying one marker, by shape rather than by grammar. See the
177
+ // caller for why this stays deliberately timid.
178
+ function scenarioNamesTagged(suiteText, marker) {
179
+ if (!suiteText)
180
+ return [];
181
+ const lines = suiteText.split('\n');
182
+ const names = [];
183
+ for (const [index, line] of lines.entries()) {
184
+ const trimmed = line.trim();
185
+ if (!trimmed.startsWith('@') || !trimmed.split(/\s+/).includes(`@${marker}`))
186
+ continue;
187
+ const next = lines.slice(index + 1).find((candidate) => candidate.trim().length > 0) ?? '';
188
+ const colon = next.indexOf(':');
189
+ if (colon === -1)
190
+ continue;
191
+ const name = next.slice(colon + 1).trim();
192
+ if (name)
193
+ names.push(name);
109
194
  }
195
+ return names;
110
196
  }
111
197
  // Which field names the id. Read off the *assignment*, where the answer is
112
198
  // exact: the id is already known (it is `contract_key` minus its prefix), so the
@@ -169,7 +255,14 @@ function assignedCases(assignment) {
169
255
  const key = row.contract_key;
170
256
  const marker = row.case_marker;
171
257
  if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
172
- found.push({ id: key.slice(CONTRACT_PREFIX.length), contract_key: key, case_marker: marker });
258
+ found.push({
259
+ id: key.slice(CONTRACT_PREFIX.length),
260
+ contract_key: key,
261
+ case_marker: marker,
262
+ // Only the behavioral assignment carries addresses. Its absence is what
263
+ // tells the coverage check below there is nothing of that kind here.
264
+ surfaces: Array.isArray(row.surfaces) ? row.surfaces.filter((s) => typeof s === 'string') : [],
265
+ });
173
266
  }
174
267
  Object.values(row).forEach(walk);
175
268
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.1",
3
+ "version": "0.3.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": {
@@ -15,7 +15,9 @@
15
15
  "scripts": {
16
16
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
17
17
  "prepublishOnly": "npm run build",
18
- "test": "node --test test/*.test.ts"
18
+ "test": "node --test test/*.test.ts",
19
+ "check:release": "node scripts/check-release.mjs",
20
+ "hooks:install": "git config core.hooksPath hooks"
19
21
  },
20
22
  "publishConfig": {
21
23
  "access": "public"