unitbob 0.3.1 → 0.3.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.
- package/README.md +16 -0
- package/dist/cli.js +6 -0
- package/dist/files/guardrails.js +12 -0
- package/dist/runner/provision.js +19 -0
- package/dist/surfaces/routeInventory.js +69 -71
- package/dist/verbs/putSuiteBuild.js +10 -1
- package/dist/verbs/run.js +4 -1
- package/dist/verbs/runLocal.js +129 -0
- package/dist/verbs/suitePrepare.js +3 -1
- package/dist/verbs/validateBuild.js +185 -3
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -55,6 +55,22 @@ they work only in a terminal session started after the plugin was installed —
|
|
|
55
55
|
a browser or desktop window they are not recognised at all. The phrasings above
|
|
56
56
|
work everywhere, so they are the ones documented here.
|
|
57
57
|
|
|
58
|
+
### If the assistant says it cannot find the Unitbob instructions
|
|
59
|
+
|
|
60
|
+
A session that started *before* the plugin was installed does not pick up the
|
|
61
|
+
skill, so the assistant has nothing to follow. Restarting the session is the
|
|
62
|
+
clean fix. If that is inconvenient, the instructions are ordinary files on disk
|
|
63
|
+
and the assistant can read them directly — tell it:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Read ~/.claude/plugins/cache/unitbob/unitbob/<version>/skills/unitbob/SKILL.md
|
|
67
|
+
and follow the workflow it names for this job.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`<version>` is whatever `claude plugin list` reports (for example `0.3.2`). The
|
|
71
|
+
workflow files sit next to it under `workflows/`, one per job, and each is
|
|
72
|
+
self-contained — that is what they are designed for.
|
|
73
|
+
|
|
58
74
|
---
|
|
59
75
|
|
|
60
76
|
## How to read it
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ensureLinked } from "./link.js";
|
|
|
14
14
|
import { recipe } from "./verbs/recipe.js";
|
|
15
15
|
import { show } from "./verbs/show.js";
|
|
16
16
|
import { run, runOnly } from "./verbs/run.js";
|
|
17
|
+
import { runLocal } from "./verbs/runLocal.js";
|
|
17
18
|
import { init } from "./verbs/init.js";
|
|
18
19
|
import { mapPrepare } from "./verbs/mapPrepare.js";
|
|
19
20
|
import { extractSurfaces } from "./verbs/extractSurfaces.js";
|
|
@@ -48,6 +49,8 @@ Verbs:
|
|
|
48
49
|
uploading. Reports every problem at once; put-suite-build runs it too.
|
|
49
50
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
50
51
|
then run every branch it published and report the server's results.
|
|
52
|
+
run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
|
|
53
|
+
that will run it afterwards. No argument runs every branch the build asked for.
|
|
51
54
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
52
55
|
contract-prompt <digest> <test_id> [fix|accept]
|
|
53
56
|
Internal: fetch the fix/accept brief for one red check on either map.
|
|
@@ -113,6 +116,9 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
113
116
|
case 'contract-prompt':
|
|
114
117
|
await contractPrompt(await linked(), args);
|
|
115
118
|
return 0;
|
|
119
|
+
case 'run-local':
|
|
120
|
+
await runLocal(await linked(), args);
|
|
121
|
+
return 0;
|
|
116
122
|
case 'run':
|
|
117
123
|
case 'check':
|
|
118
124
|
await run(await linked(), args);
|
package/dist/files/guardrails.js
CHANGED
|
@@ -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…).
|
package/dist/runner/provision.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 '
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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
|
|
192
|
+
async function askRouterOnce(projectRoot, deps, environment) {
|
|
174
193
|
const local = join(projectRoot, 'bin', 'rails');
|
|
175
194
|
const [command, args] = existsSync(local)
|
|
176
|
-
? [local, ['
|
|
177
|
-
: ['bundle', ['exec', 'rails', '
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
241
|
-
const verb = record.
|
|
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
|
|
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]`
|
|
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
|
|
254
|
-
action
|
|
251
|
+
controller,
|
|
252
|
+
action,
|
|
255
253
|
}));
|
|
256
254
|
}
|
|
257
255
|
function graphNodes(projectRoot) {
|
|
@@ -405,7 +403,7 @@ export function inventoryProblems(inventory, surfaces) {
|
|
|
405
403
|
// either printed by the router or copied out of the graph. A blank is not a gap
|
|
406
404
|
// to be helpfully filled — an invented `source_file` is the same failure as an
|
|
407
405
|
// invented address, one field over, and a `handler_symbol` swapped for another
|
|
408
|
-
// node that happens to exist passes the host's check while sending spec
|
|
406
|
+
// node that happens to exist passes the host's check while sending spec 36's
|
|
409
407
|
// trace into the wrong code.
|
|
410
408
|
const LINK_FIELDS = ['source_file', 'handler_symbol'];
|
|
411
409
|
// Not a link and nothing downstream reads it — but spec 32-7 took the authoring
|
|
@@ -46,11 +46,12 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
46
46
|
// batch. That also bounds what a false positive in a local check can cost —
|
|
47
47
|
// one branch, with the peer still going up and the server still the authority.
|
|
48
48
|
const problemsFor = new Map();
|
|
49
|
-
for (const problem of collectBuildProblems(request, outputs)) {
|
|
49
|
+
for (const problem of collectBuildProblems(request, outputs, unreadable)) {
|
|
50
50
|
problemsFor.set(problem.branch, [...(problemsFor.get(problem.branch) ?? []), problem.message]);
|
|
51
51
|
}
|
|
52
52
|
for (const output of outputs) {
|
|
53
53
|
const failed = problemsFor.get(output.suite_kind);
|
|
54
|
+
problemsFor.delete(output.suite_kind);
|
|
54
55
|
if (failed) {
|
|
55
56
|
blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: formatBranchProblems(failed) });
|
|
56
57
|
continue;
|
|
@@ -80,6 +81,14 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
80
81
|
},
|
|
81
82
|
});
|
|
82
83
|
}
|
|
84
|
+
// What is left in `problemsFor` belongs to a branch the loop above never
|
|
85
|
+
// reached, because the answer has no entry for it at all. It has nothing to
|
|
86
|
+
// upload and nothing to roll back, so it costs its peer nothing — but it is
|
|
87
|
+
// exactly the branch that used to leave no trace anywhere, and the one line it
|
|
88
|
+
// prints here is the whole point of noticing it (spec 32-6, a2time 2026-08-04).
|
|
89
|
+
for (const [suiteKind, messages] of problemsFor) {
|
|
90
|
+
blocked.push({ suite_kind: suiteKind, status: BLOCKED_STATUS, error: formatBranchProblems(messages) });
|
|
91
|
+
}
|
|
83
92
|
// Every branch is blocked, so there is nothing to upload. Asking the server to
|
|
84
93
|
// publish an empty batch would turn a local, already-explained problem into a
|
|
85
94
|
// wire error with a worse message.
|
package/dist/verbs/run.js
CHANGED
|
@@ -118,7 +118,10 @@ async function buildRunPayload(config, d, item) {
|
|
|
118
118
|
}
|
|
119
119
|
return { suite_digest: item.suite_digest, run_result: report };
|
|
120
120
|
}
|
|
121
|
-
|
|
121
|
+
// Exported for `run-local`, which runs these same strategies against the files
|
|
122
|
+
// the host just wrote rather than against a published suite. One dispatch table,
|
|
123
|
+
// so the command the loop iterates on is the command that runs after publishing.
|
|
124
|
+
export function runStructuralByRunner(projectRoot, runner, suitePath) {
|
|
122
125
|
switch (runner) {
|
|
123
126
|
case 'rspec':
|
|
124
127
|
return runRspecSuite(projectRoot, suitePath);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
+
import { validateStack } from "../runner/precheck.js";
|
|
3
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
4
|
+
import { runStructuralByRunner } from "./run.js";
|
|
5
|
+
const OUTPUT_TAIL_CHARS = 4000;
|
|
6
|
+
export async function runLocal(config, args = [], deps) {
|
|
7
|
+
const d = {
|
|
8
|
+
runStructural: runStructuralByRunner,
|
|
9
|
+
runBehavioral: runBddSuite,
|
|
10
|
+
validateStack,
|
|
11
|
+
stdout: process.stdout,
|
|
12
|
+
...deps,
|
|
13
|
+
};
|
|
14
|
+
const request = readSuiteBuildRequest(config.projectRoot);
|
|
15
|
+
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
16
|
+
const wanted = selectBranches(request, args);
|
|
17
|
+
for (const suiteKind of wanted) {
|
|
18
|
+
d.stdout.write(`\n── ${suiteKind} ──\n`);
|
|
19
|
+
// An entry that exists but will not parse is a different problem from an
|
|
20
|
+
// entry that is not there, and it is the one worth catching early: the file
|
|
21
|
+
// it names is usually missing from disk, which the runner would otherwise
|
|
22
|
+
// discover as a confusing "no tests" halfway through the loop.
|
|
23
|
+
const broken = unreadable.find((entry) => entry.suite_kind === suiteKind);
|
|
24
|
+
if (broken) {
|
|
25
|
+
d.stdout.write(`Cannot run this branch — its entry in your answer could not be read: ${broken.message}\n`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Which branches to run. No argument runs every branch the request asked for —
|
|
32
|
+
// the same "one suite, one run" shape both recipes insist on, so the default
|
|
33
|
+
// never teaches the habit the recipes forbid. A named branch is for the repair
|
|
34
|
+
// loop, where re-running the finished peer is pure cost.
|
|
35
|
+
function selectBranches(request, args) {
|
|
36
|
+
const all = request.branches.map((branch) => branch.suite_kind);
|
|
37
|
+
const named = args.filter((arg) => !arg.startsWith('-'));
|
|
38
|
+
if (named.length === 0)
|
|
39
|
+
return all;
|
|
40
|
+
const unknown = named.filter((name) => !all.includes(name));
|
|
41
|
+
if (unknown.length > 0) {
|
|
42
|
+
throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
|
|
43
|
+
}
|
|
44
|
+
return named;
|
|
45
|
+
}
|
|
46
|
+
async function runOneBranch(config, d, suiteKind, output) {
|
|
47
|
+
// Nothing written for this branch yet. That is the ordinary state halfway
|
|
48
|
+
// through a build, not an error — say what is missing and move to the peer.
|
|
49
|
+
if (!output) {
|
|
50
|
+
d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
|
|
51
|
+
`${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (output.build_error) {
|
|
55
|
+
d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let runner;
|
|
59
|
+
let suitePath;
|
|
60
|
+
try {
|
|
61
|
+
runner = branchRunner(output);
|
|
62
|
+
suitePath = mainPathOf(output);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
d.stdout.write(`Cannot run this branch: ${err.message}\n`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const check = d.validateStack(config.projectRoot, runner);
|
|
69
|
+
if (!check.ok) {
|
|
70
|
+
d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result =
|
|
76
|
+
suiteKind === 'behavioral'
|
|
77
|
+
? await d.runBehavioral(config.projectRoot, runner, suitePath)
|
|
78
|
+
: await d.runStructural(config.projectRoot, runner, suitePath);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
d.stdout.write(`The runner could not start: ${err.message}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
d.stdout.write(report(result));
|
|
85
|
+
}
|
|
86
|
+
// The command first, and always — including on a green run. It is the answer to
|
|
87
|
+
// "how do I run just this one file again", which is the question the whole
|
|
88
|
+
// iteration loop is made of, and printing it only on failure would hide it at
|
|
89
|
+
// exactly the moment someone starts trusting the loop.
|
|
90
|
+
function report(result) {
|
|
91
|
+
const lines = [
|
|
92
|
+
`ran: ${[result.command, ...result.args].join(' ')}`,
|
|
93
|
+
`exit code: ${result.code}`,
|
|
94
|
+
];
|
|
95
|
+
if (result.report) {
|
|
96
|
+
lines.push(`machine-readable report: ${result.resultPath}` +
|
|
97
|
+
' — the whole run in one file, if the console output is too long to read.');
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(`no report at ${result.resultPath} — the run produced none, which usually means it died before` +
|
|
101
|
+
' the first test rather than that the tests failed.');
|
|
102
|
+
}
|
|
103
|
+
const tail = outputTail(result);
|
|
104
|
+
if (tail)
|
|
105
|
+
lines.push('', tail);
|
|
106
|
+
return `${lines.join('\n')}\n`;
|
|
107
|
+
}
|
|
108
|
+
function outputTail(result) {
|
|
109
|
+
const bits = [];
|
|
110
|
+
if (result.stderr.trim())
|
|
111
|
+
bits.push(result.stderr.trim());
|
|
112
|
+
if (result.stdout.trim())
|
|
113
|
+
bits.push(result.stdout.trim());
|
|
114
|
+
const joined = bits.join('\n');
|
|
115
|
+
return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
|
|
116
|
+
}
|
|
117
|
+
// The suite blob's own project-relative path, exactly as the runners expect it.
|
|
118
|
+
function mainPathOf(output) {
|
|
119
|
+
const file = output.suite_file;
|
|
120
|
+
const path = file?.path;
|
|
121
|
+
if (typeof path !== 'string' || !path) {
|
|
122
|
+
throw new Error('this branch names no suite file to run.');
|
|
123
|
+
}
|
|
124
|
+
return path;
|
|
125
|
+
}
|
|
126
|
+
function branchRoot(config, suiteKind) {
|
|
127
|
+
const request = readSuiteBuildRequest(config.projectRoot);
|
|
128
|
+
return request.branches.find((branch) => branch.suite_kind === suiteKind)?.path_root ?? `.unitbob/${suiteKind}/`;
|
|
129
|
+
}
|
|
@@ -147,7 +147,9 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
147
147
|
: '`unitbob put-suite-build`';
|
|
148
148
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
149
149
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
150
|
-
`write your answer to ${request.output_path} as a branches array
|
|
150
|
+
`write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
|
|
151
|
+
`finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
|
|
152
|
+
`runner that runs after publishing, so you never have to guess the command), repair broken harness steps while application failures remain red, ` +
|
|
151
153
|
`then run ${nextCommand}.\n`);
|
|
152
154
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
153
155
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
@@ -4,7 +4,7 @@ import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/
|
|
|
4
4
|
// both sides copy verbatim — so nothing here has to know whether this branch's
|
|
5
5
|
// ids are called `interface_id` or `capability_id`.
|
|
6
6
|
const CONTRACT_PREFIX = 'contract:';
|
|
7
|
-
export function collectBuildProblems(request, outputs) {
|
|
7
|
+
export function collectBuildProblems(request, outputs, unreadable = []) {
|
|
8
8
|
const problems = [];
|
|
9
9
|
const branchFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch]));
|
|
10
10
|
for (const output of outputs) {
|
|
@@ -19,8 +19,51 @@ export function collectBuildProblems(request, outputs) {
|
|
|
19
19
|
checkRunnerManifest(branch, output, add);
|
|
20
20
|
checkAssignment(branch, output, add);
|
|
21
21
|
}
|
|
22
|
+
problems.push(...unansweredBranches(request, outputs, unreadable));
|
|
22
23
|
return problems;
|
|
23
24
|
}
|
|
25
|
+
// The branch that is not there at all. Every check above reads the answer and
|
|
26
|
+
// asks whether it is well-formed; none of them can see a branch the answer never
|
|
27
|
+
// mentions, because there is no entry to walk. So this one walks the request
|
|
28
|
+
// instead — the only list that knows what was asked for.
|
|
29
|
+
//
|
|
30
|
+
// The a2time run of 2026-08-04 is the whole reason. Its behavioral branch was
|
|
31
|
+
// prepared, half-built and abandoned for budget; the answer went up carrying the
|
|
32
|
+
// structural branch alone; this check said "well-formed"; the upload published
|
|
33
|
+
// one branch and said nothing about the other. Nothing anywhere recorded that a
|
|
34
|
+
// second branch had ever been asked for, so the cost of the work already done on
|
|
35
|
+
// it was not merely wasted, it was invisible.
|
|
36
|
+
//
|
|
37
|
+
// ADR 1 names this shape: a pre-check must not be *narrower* than the thing it
|
|
38
|
+
// predicts. The server checks each branch it receives; what it cannot check is a
|
|
39
|
+
// branch nobody sent it. That gap belongs here, where the request is still in
|
|
40
|
+
// hand.
|
|
41
|
+
//
|
|
42
|
+
// `build_error` is the answer for a branch that could not be built, and it is
|
|
43
|
+
// deliberately cheap to give — one line, no suite, never blocks the peer. This
|
|
44
|
+
// does not demand the branch be built. It demands only that its absence be
|
|
45
|
+
// stated rather than left as a silence that reads like success.
|
|
46
|
+
//
|
|
47
|
+
// A branch whose entry existed but could not be parsed is already reported as
|
|
48
|
+
// unreadable by the caller; naming it "missing" too would be two complaints
|
|
49
|
+
// about one mistake, and the second would send the reader looking for a second
|
|
50
|
+
// problem that is not there.
|
|
51
|
+
function unansweredBranches(request, outputs, unreadable) {
|
|
52
|
+
const accounted = new Set([
|
|
53
|
+
...outputs.map((output) => output.suite_kind),
|
|
54
|
+
...unreadable.map((entry) => entry.suite_kind),
|
|
55
|
+
]);
|
|
56
|
+
return request.branches
|
|
57
|
+
.filter((branch) => !accounted.has(branch.suite_kind))
|
|
58
|
+
.map((branch) => ({
|
|
59
|
+
branch: branch.suite_kind,
|
|
60
|
+
message: 'the request asked for this branch and the answer has no entry for it — it is neither built nor ' +
|
|
61
|
+
'declared unbuildable. Every branch in the request gets one entry: the suite you built, or ' +
|
|
62
|
+
`{ "suite_kind": "${branch.suite_kind}", "build_error": { "message": "why not" } }. ` +
|
|
63
|
+
'Leaving it out is not the same as declining it: nothing records that this branch was ever asked ' +
|
|
64
|
+
'for, so the work already spent on it disappears without a trace.',
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
24
67
|
// After spec 32-5 the envelope comes down from the server inside the request, so
|
|
25
68
|
// there is nothing here to derive — only to confirm the host copied it. This is
|
|
26
69
|
// the field most likely to be rejected after all the work is done, which is
|
|
@@ -106,7 +149,139 @@ function checkOneCase(row, id, expected, suiteText, add) {
|
|
|
106
149
|
// as a mismatch rather than as the green it claims.
|
|
107
150
|
if (suiteText && !suiteText.includes(expected.case_marker)) {
|
|
108
151
|
add(`${id} is answered "covered", but its marker ${expected.case_marker} appears nowhere in the suite files.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
checkSurfaceCoverage(row, id, expected, suiteText, add);
|
|
155
|
+
}
|
|
156
|
+
// Which scenario reached which address. The a2time run of 2026-08-04 published
|
|
157
|
+
// 97 coverage rows against 99 Scenarios: one Scenario had no row, another had a
|
|
158
|
+
// row naming no address. Both mean the same thing — a Scenario that ran and
|
|
159
|
+
// whose result reaches nothing on the map — and both were found by the
|
|
160
|
+
// independent reviewer, hours later, doing a different job. This check was the
|
|
161
|
+
// cheap place to find them and it was not looking.
|
|
162
|
+
//
|
|
163
|
+
// Only asked when the answer is already speaking this language: an answer with
|
|
164
|
+
// no `surface_coverage` anywhere is an older map's shape, and refusing it here
|
|
165
|
+
// would refuse what the server accepts. Within a branch that does declare it,
|
|
166
|
+
// the rules below are the server's own, in the server's own order.
|
|
167
|
+
function checkSurfaceCoverage(row, id, expected, suiteText, add) {
|
|
168
|
+
if (expected.surfaces.length === 0)
|
|
169
|
+
return;
|
|
170
|
+
const coverage = row.surface_coverage;
|
|
171
|
+
if (coverage === undefined)
|
|
172
|
+
return; // not this map's shape — the server decides
|
|
173
|
+
if (!Array.isArray(coverage)) {
|
|
174
|
+
add(`${id} is answered "covered", so its surface_coverage must be an array of {scenario, surfaces}.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const named = new Set();
|
|
178
|
+
const reached = new Set();
|
|
179
|
+
for (const [index, item] of coverage.entries()) {
|
|
180
|
+
const entry = (item ?? {});
|
|
181
|
+
const scenario = String(entry.scenario ?? '').trim();
|
|
182
|
+
const surfaces = entry.surfaces;
|
|
183
|
+
if (!scenario || !Array.isArray(surfaces)) {
|
|
184
|
+
add(`${id} surface_coverage[${index}] must name a scenario and its surfaces.`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (surfaces.length === 0) {
|
|
188
|
+
add(`${id} surface_coverage names no surface for "${scenario}" — that scenario's result reaches nothing on the map.`);
|
|
189
|
+
}
|
|
190
|
+
if (suiteText && !suiteText.includes(scenario)) {
|
|
191
|
+
add(`${id} surface_coverage names "${scenario}", which appears nowhere in the suite files.`);
|
|
192
|
+
}
|
|
193
|
+
named.add(scenario);
|
|
194
|
+
surfaces.filter((s) => typeof s === 'string').forEach((s) => reached.add(s));
|
|
195
|
+
}
|
|
196
|
+
// Spec 34, decision 15: an address the suite genuinely cannot drive — a
|
|
197
|
+
// third-party OAuth callback, a vendor webhook — is declared rather than
|
|
198
|
+
// faked, and satisfies coverage without being claimed as reached. Mirrored
|
|
199
|
+
// here in the server's own shape; refusing it locally would refuse an answer
|
|
200
|
+
// the server takes, which is the one direction of drift that costs a branch.
|
|
201
|
+
const declaredUnreachable = collectUnreachable(row, id, reached, add);
|
|
202
|
+
const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface));
|
|
203
|
+
if (missed.length > 0) {
|
|
204
|
+
add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
|
|
205
|
+
' — drive it, or declare it unreachable with a business reason.');
|
|
206
|
+
}
|
|
207
|
+
// No check here for "declares everything unreachable and drives nothing": the
|
|
208
|
+
// caller already returned when the capability's marker appears in no suite
|
|
209
|
+
// file, so a capability with no Scenario never reaches this function at all.
|
|
210
|
+
// The server refuses that state for the same reason, one rule earlier.
|
|
211
|
+
const foreign = [...reached, ...declaredUnreachable].filter((surface) => !expected.surfaces.includes(surface));
|
|
212
|
+
if (foreign.length > 0) {
|
|
213
|
+
add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
|
|
214
|
+
}
|
|
215
|
+
// The other direction, and the one that found nothing on a2time because
|
|
216
|
+
// nobody asked it: a Scenario that carries the marker but appears in no row.
|
|
217
|
+
//
|
|
218
|
+
// Read off the file rather than parsed: a tag line carrying this marker, then
|
|
219
|
+
// the next line that has a colon in it, whose name is whatever follows the
|
|
220
|
+
// first colon. That holds for any Gherkin dialect, because only the keyword is
|
|
221
|
+
// translated and the colon is not. When the shape is not recognised the answer
|
|
222
|
+
// is silence — the server does parse this properly, and a guess here that says
|
|
223
|
+
// "you forgot a Scenario" about a Scenario that does not exist would cost the
|
|
224
|
+
// branch its publication.
|
|
225
|
+
const unlisted = scenarioNamesTagged(suiteText, expected.case_marker).filter((name) => !named.has(name));
|
|
226
|
+
if (unlisted.length > 0) {
|
|
227
|
+
add(`${id} surface_coverage does not account for ${unlisted.map((n) => `"${n}"`).join(', ')}.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// The addresses this capability says it cannot drive, each with its own reason.
|
|
231
|
+
// A blanket reason covering a list is exactly the boilerplate the rule exists to
|
|
232
|
+
// stop, so the reason is per address and its absence is the whole complaint.
|
|
233
|
+
function collectUnreachable(row, id, reached, add) {
|
|
234
|
+
const declared = row.unreachable_surfaces;
|
|
235
|
+
if (declared === undefined)
|
|
236
|
+
return new Set();
|
|
237
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
238
|
+
add(`${id} unreachable_surfaces must be a non-empty array of {surface, reason} when it is present.`);
|
|
239
|
+
return new Set();
|
|
240
|
+
}
|
|
241
|
+
const surfaces = new Set();
|
|
242
|
+
for (const [index, item] of declared.entries()) {
|
|
243
|
+
const entry = (item ?? {});
|
|
244
|
+
const surface = String(entry.surface ?? '').trim();
|
|
245
|
+
if (!surface) {
|
|
246
|
+
add(`${id} unreachable_surfaces[${index}] names no surface.`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!String(entry.reason ?? '').trim()) {
|
|
250
|
+
add(`${id} declares ${surface} unreachable but gives no business reason for it.`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (reached.has(surface)) {
|
|
254
|
+
add(`${id} both drives ${surface} in a scenario and declares it unreachable — it is one or the other.`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (surfaces.has(surface)) {
|
|
258
|
+
add(`${id} declares ${surface} unreachable more than once.`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
surfaces.add(surface);
|
|
262
|
+
}
|
|
263
|
+
return surfaces;
|
|
264
|
+
}
|
|
265
|
+
// Scenario names carrying one marker, by shape rather than by grammar. See the
|
|
266
|
+
// caller for why this stays deliberately timid.
|
|
267
|
+
function scenarioNamesTagged(suiteText, marker) {
|
|
268
|
+
if (!suiteText)
|
|
269
|
+
return [];
|
|
270
|
+
const lines = suiteText.split('\n');
|
|
271
|
+
const names = [];
|
|
272
|
+
for (const [index, line] of lines.entries()) {
|
|
273
|
+
const trimmed = line.trim();
|
|
274
|
+
if (!trimmed.startsWith('@') || !trimmed.split(/\s+/).includes(`@${marker}`))
|
|
275
|
+
continue;
|
|
276
|
+
const next = lines.slice(index + 1).find((candidate) => candidate.trim().length > 0) ?? '';
|
|
277
|
+
const colon = next.indexOf(':');
|
|
278
|
+
if (colon === -1)
|
|
279
|
+
continue;
|
|
280
|
+
const name = next.slice(colon + 1).trim();
|
|
281
|
+
if (name)
|
|
282
|
+
names.push(name);
|
|
109
283
|
}
|
|
284
|
+
return names;
|
|
110
285
|
}
|
|
111
286
|
// Which field names the id. Read off the *assignment*, where the answer is
|
|
112
287
|
// exact: the id is already known (it is `contract_key` minus its prefix), so the
|
|
@@ -169,7 +344,14 @@ function assignedCases(assignment) {
|
|
|
169
344
|
const key = row.contract_key;
|
|
170
345
|
const marker = row.case_marker;
|
|
171
346
|
if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
|
|
172
|
-
found.push({
|
|
347
|
+
found.push({
|
|
348
|
+
id: key.slice(CONTRACT_PREFIX.length),
|
|
349
|
+
contract_key: key,
|
|
350
|
+
case_marker: marker,
|
|
351
|
+
// Only the behavioral assignment carries addresses. Its absence is what
|
|
352
|
+
// tells the coverage check below there is nothing of that kind here.
|
|
353
|
+
surfaces: Array.isArray(row.surfaces) ? row.surfaces.filter((s) => typeof s === 'string') : [],
|
|
354
|
+
});
|
|
173
355
|
}
|
|
174
356
|
Object.values(row).forEach(walk);
|
|
175
357
|
};
|
|
@@ -208,7 +390,7 @@ export function validateBuildProblems(config) {
|
|
|
208
390
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
209
391
|
return [
|
|
210
392
|
...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
|
|
211
|
-
...collectBuildProblems(request, outputs),
|
|
393
|
+
...collectBuildProblems(request, outputs, unreadable),
|
|
212
394
|
];
|
|
213
395
|
}
|
|
214
396
|
// One branch's problems, for the line that reports it unpublished alongside its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -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"
|