unitbob 0.4.4 → 0.5.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.
@@ -1,55 +1,22 @@
1
- import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
- // The assignment, reduced to what a local check can compare against. Ids are
3
- // recovered from `contract_key`, which the server derives as `contract:<id>` and
4
- // both sides copy verbatim so nothing here has to know whether this branch's
5
- // ids are called `interface_id` or `capability_id`.
6
- const CONTRACT_PREFIX = 'contract:';
1
+ import { existsSync } from 'node:fs';
2
+ import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, reviewOutputPath, } from "../files/suiteBuild.js";
3
+ import { PUBLISHED, uploadItem, withReview, WOULD_PUBLISH } from "../files/suiteBuildUpload.js";
4
+ import { Wire, WireError } from "../wire.js";
5
+ // The three things a dry run does not do. The list is closed and none of them
6
+ // can reject an artifact: deduplication needs to know whether this digest is
7
+ // already stored, `make_current!` moves the pointer, `parent_digest` records
8
+ // lineage. ADR 0001 asks for them to be named out loud, and one fixed line is
9
+ // how — a field in the protocol that always carries the same sentence tells a
10
+ // reader nothing and stays in the shape for ever.
11
+ const DRY_RUN_DOES_NOT = 'A dry run skips exactly three things the publish does: deduplication by digest, moving the current ' +
12
+ 'pointer, and recording the parent digest. None of the three can reject an artifact.';
7
13
  export function collectBuildProblems(request, outputs, unreadable = []) {
8
- const problems = [];
9
- const branchFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch]));
10
- for (const output of outputs) {
11
- // The host said plainly that it could not build this one. That is an answer,
12
- // not a malformed answer, and the server records it as such.
13
- if (output.build_error)
14
- continue;
15
- const branch = branchFor.get(output.suite_kind);
16
- if (!branch)
17
- continue; // reading the answer already refused this one
18
- const add = (message) => { problems.push({ branch: output.suite_kind, message }); };
19
- checkRunnerManifest(branch, output, add);
20
- checkAssignment(branch, output, add);
21
- if (output.suite_kind === 'behavioral')
22
- checkDuplicateStepExpressions(output, add);
23
- }
24
- problems.push(...unansweredBranches(request, outputs, unreadable));
25
- return problems;
26
- }
27
- function checkDuplicateStepExpressions(output, add) {
28
- const suite = output.suite_file;
29
- const definitions = new Map();
30
- for (const file of Array.isArray(suite?.support_files) ? suite.support_files : []) {
31
- if (typeof file.path !== 'string' || typeof file.content !== 'string')
32
- continue;
33
- for (const line of file.content.split('\n')) {
34
- const call = line.match(/^\s*(?:Given|When|Then|And|But)\s*\(\s*(['"`])(.*?)\1/);
35
- const decorator = line.match(/^\s*@(given|when|then)\s*\(\s*(['"])(.*?)\2/i);
36
- const expression = call?.[2] ?? decorator?.[3];
37
- if (!expression)
38
- continue;
39
- definitions.set(expression, [...(definitions.get(expression) ?? []), file.path]);
40
- }
41
- }
42
- for (const [expression, paths] of definitions) {
43
- const uniquePaths = [...new Set(paths)];
44
- if (paths.length > 1) {
45
- add(`duplicate step expression "${expression}" appears in ${uniquePaths.join(', ')} — step expressions share one branch-global namespace.`);
46
- }
47
- }
14
+ return unansweredBranches(request, outputs, unreadable);
48
15
  }
49
- // The branch that is not there at all. Every check above reads the answer and
50
- // asks whether it is well-formed; none of them can see a branch the answer never
51
- // mentions, because there is no entry to walk. So this one walks the request
52
- // instead — the only list that knows what was asked for.
16
+ // The branch that is not there at all. Reading the answer tells you whether what
17
+ // arrived is well-formed; it cannot see a branch the answer never mentions,
18
+ // because there is no entry to walk. So this walks the request instead — the
19
+ // only list that knows what was asked for.
53
20
  //
54
21
  // The a2time run of 2026-08-04 is the whole reason. Its behavioral branch was
55
22
  // prepared, half-built and abandoned for budget; the answer went up carrying the
@@ -58,10 +25,8 @@ function checkDuplicateStepExpressions(output, add) {
58
25
  // second branch had ever been asked for, so the cost of the work already done on
59
26
  // it was not merely wasted, it was invisible.
60
27
  //
61
- // ADR 1 names this shape: a pre-check must not be *narrower* than the thing it
62
- // predicts. The server checks each branch it receives; what it cannot check is a
63
- // branch nobody sent it. That gap belongs here, where the request is still in
64
- // hand.
28
+ // The server cannot close this gap: it checks each branch it receives, and this
29
+ // is about a branch nobody sent it.
65
30
  //
66
31
  // `build_error` is the answer for a branch that could not be built, and it is
67
32
  // deliberately cheap to give — one line, no suite, never blocks the peer. This
@@ -88,387 +53,10 @@ function unansweredBranches(request, outputs, unreadable) {
88
53
  'for, so the work already spent on it disappears without a trace.',
89
54
  }));
90
55
  }
91
- // After spec 32-5 the envelope comes down from the server inside the request, so
92
- // there is nothing here to derive only to confirm the host copied it. This is
93
- // the field most likely to be rejected after all the work is done, which is
94
- // exactly why it is worth a second of checking beforehand.
95
- function checkRunnerManifest(branch, output, add) {
96
- if (branch.runner_manifest === undefined)
97
- return;
98
- if (!sameJson(branch.runner_manifest, output.runner_manifest)) {
99
- add('runner_manifest does not match the one the request issued. Copy it verbatim — the server ' +
100
- 'accepts only the exact combinations it named.\n' +
101
- ` issued: ${stableJson(branch.runner_manifest)}\n` +
102
- ` answered: ${stableJson(output.runner_manifest)}`);
103
- }
104
- }
105
- // Every assigned id accounted for exactly once, and every marker the one the
106
- // server minted. A marker the host invented or edited severs the only join
107
- // between a runner's output and the map, so it cannot be allowed to travel.
108
- function checkAssignment(branch, output, add) {
109
- const assigned = assignedCases(branch.assignment);
110
- if (assigned.length === 0) {
111
- // An assignment with no cases in it is normal — a map with nothing to guard
112
- // yet. An assignment that has content this walker could not read is not: the
113
- // check would pass everything from then on and never say why. Fail open, but
114
- // never fail open quietly.
115
- if (hasContent(branch.assignment)) {
116
- add('this branch\'s assignment could not be read, so its coverage was not checked here. ' +
117
- 'The server still checks it; if this persists the connector is older than the assignment format.');
118
- }
119
- return;
120
- }
121
- const metadata = output.test_metadata;
122
- const entries = Array.isArray(metadata?.capabilities) ? metadata.capabilities : null;
123
- if (!entries) {
124
- add('test_metadata must carry a capabilities array, one entry per assigned id.');
125
- return;
126
- }
127
- const byId = new Map(assigned.map((entry) => [entry.id, entry]));
128
- const idKey = idKeyOf(branch.assignment, assigned);
129
- const seen = new Map();
130
- const suiteText = suiteBytes(output);
131
- for (const entry of entries) {
132
- const row = (entry ?? {});
133
- const id = String(idKey ? row[idKey] ?? '' : '');
134
- const expected = byId.get(id);
135
- if (!expected) {
136
- add(`test_metadata names "${id || '(no id)'}", which is not in this branch's assignment.`);
137
- continue;
138
- }
139
- seen.set(id, (seen.get(id) ?? 0) + 1);
140
- checkOneCase(row, id, expected, suiteText, add);
141
- }
142
- for (const [id, count] of seen) {
143
- if (count > 1)
144
- add(`${id} is answered ${count} times — every assigned id is answered exactly once.`);
145
- }
146
- const missing = assigned.filter((entry) => !seen.has(entry.id)).map((entry) => entry.id).sort();
147
- if (missing.length > 0) {
148
- add(`no answer for ${missing.length} assigned id(s): ${missing.join(', ')}.`);
149
- }
150
- }
151
- function checkOneCase(row, id, expected, suiteText, add) {
152
- const status = String(row.status ?? '');
153
- if (status === 'unguarded') {
154
- if (!String(row.reason ?? '').trim()) {
155
- add(`${id} is unguarded but gives no business reason for it.`);
156
- }
157
- return;
158
- }
159
- if (status !== 'covered') {
160
- add(`${id} must be answered "covered" or "unguarded" (got ${JSON.stringify(status)}).`);
161
- return;
162
- }
163
- if (String(row.contract_key ?? '') !== expected.contract_key) {
164
- add(`${id} carries contract_key ${JSON.stringify(row.contract_key)} — it must be copied verbatim as "${expected.contract_key}".`);
165
- }
166
- if (String(row.case_marker ?? '') !== expected.case_marker) {
167
- add(`${id} carries case_marker ${JSON.stringify(row.case_marker)} — it must be copied verbatim as "${expected.case_marker}". Markers are never minted or edited locally.`);
168
- return;
169
- }
170
- // Declared covered, but the marker never made it into a test name or a
171
- // Gherkin tag. The server refuses this, and rightly: without the marker in the
172
- // suite there is nothing to join a result to, so the capability would report
173
- // as a mismatch rather than as the green it claims.
174
- if (suiteText && !suiteText.includes(expected.case_marker)) {
175
- add(`${id} is answered "covered", but its marker ${expected.case_marker} appears nowhere in the suite files.`);
176
- return;
177
- }
178
- checkSurfaceCoverage(row, id, expected, suiteText, add);
179
- }
180
- // Which scenario reached which address. The a2time run of 2026-08-04 published
181
- // 97 coverage rows against 99 Scenarios: one Scenario had no row, another had a
182
- // row naming no address. Both mean the same thing — a Scenario that ran and
183
- // whose result reaches nothing on the map — and both were found by the
184
- // independent reviewer, hours later, doing a different job. This check was the
185
- // cheap place to find them and it was not looking.
186
- //
187
- // Only asked when the answer is already speaking this language: an answer with
188
- // no `surface_coverage` anywhere is an older map's shape, and refusing it here
189
- // would refuse what the server accepts. Within a branch that does declare it,
190
- // the rules below are the server's own, in the server's own order.
191
- function checkSurfaceCoverage(row, id, expected, suiteText, add) {
192
- if (expected.surfaces.length === 0)
193
- return;
194
- const coverage = row.surface_coverage;
195
- if (coverage === undefined)
196
- return; // not this map's shape — the server decides
197
- if (!Array.isArray(coverage)) {
198
- add(`${id} is answered "covered", so its surface_coverage must be an array of {scenario, surfaces}.`);
199
- return;
200
- }
201
- const named = new Set();
202
- const reached = new Set();
203
- for (const [index, item] of coverage.entries()) {
204
- const entry = (item ?? {});
205
- const scenario = String(entry.scenario ?? '').trim();
206
- const surfaces = entry.surfaces;
207
- if (!scenario || !Array.isArray(surfaces)) {
208
- add(`${id} surface_coverage[${index}] must name a scenario and its surfaces.`);
209
- continue;
210
- }
211
- if (surfaces.length === 0) {
212
- add(`${id} surface_coverage names no surface for "${scenario}" — that scenario's result reaches nothing on the map.`);
213
- }
214
- if (suiteText && !suiteText.includes(scenario)) {
215
- add(`${id} surface_coverage names "${scenario}", which appears nowhere in the suite files.`);
216
- }
217
- named.add(scenario);
218
- surfaces.filter((s) => typeof s === 'string').forEach((s) => reached.add(s));
219
- }
220
- // Spec 34, decision 15: an address the suite genuinely cannot drive — a
221
- // third-party OAuth callback, a vendor webhook — is declared rather than
222
- // faked, and satisfies coverage without being claimed as reached. Mirrored
223
- // here in the server's own shape; refusing it locally would refuse an answer
224
- // the server takes, which is the one direction of drift that costs a branch.
225
- const declaredUnreachable = collectUnreachable(row, id, reached, add);
226
- const deferred = collectDeferred(row, id, reached, declaredUnreachable, add);
227
- // Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
228
- // one of the answers the server rejects, and finding it after the suite has
229
- // been written, run and reviewed costs the whole cycle.
230
- const budget = expected.surfaceBudget;
231
- if (budget !== undefined && reached.size > budget) {
232
- add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
233
- ' — guard the most important ones up to that number and list the rest in deferred_surfaces.');
234
- }
235
- const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
236
- if (missed.length > 0) {
237
- add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
238
- ' — drive it, declare it unreachable with a business reason, or defer it under the surface budget.');
239
- }
240
- // No check here for "declares everything unreachable and drives nothing": the
241
- // caller already returned when the capability's marker appears in no suite
242
- // file, so a capability with no Scenario never reaches this function at all.
243
- // The server refuses that state for the same reason, one rule earlier.
244
- const foreign = [...reached, ...declaredUnreachable, ...deferred].filter((surface) => !expected.surfaces.includes(surface));
245
- if (foreign.length > 0) {
246
- add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
247
- }
248
- // The other direction, and the one that found nothing on a2time because
249
- // nobody asked it: a Scenario that carries the marker but appears in no row.
250
- //
251
- // Read off the file rather than parsed: a tag line carrying this marker, then
252
- // the next line that has a colon in it, whose name is whatever follows the
253
- // first colon. That holds for any Gherkin dialect, because only the keyword is
254
- // translated and the colon is not. When the shape is not recognised the answer
255
- // is silence — the server does parse this properly, and a guess here that says
256
- // "you forgot a Scenario" about a Scenario that does not exist would cost the
257
- // branch its publication.
258
- const unlisted = scenarioNamesTagged(suiteText, expected.case_marker).filter((name) => !named.has(name));
259
- if (unlisted.length > 0) {
260
- add(`${id} surface_coverage does not account for ${unlisted.map((n) => `"${n}"`).join(', ')}.`);
261
- }
262
- }
263
- // The addresses this capability says it cannot drive, each with its own reason.
264
- // A blanket reason covering a list is exactly the boilerplate the rule exists to
265
- // stop, so the reason is per address and its absence is the whole complaint.
266
- function collectUnreachable(row, id, reached, add) {
267
- const declared = row.unreachable_surfaces;
268
- if (declared === undefined)
269
- return new Set();
270
- if (!Array.isArray(declared) || declared.length === 0) {
271
- add(`${id} unreachable_surfaces must be a non-empty array of {surface, reason} when it is present.`);
272
- return new Set();
273
- }
274
- const surfaces = new Set();
275
- for (const [index, item] of declared.entries()) {
276
- const entry = (item ?? {});
277
- const surface = String(entry.surface ?? '').trim();
278
- if (!surface) {
279
- add(`${id} unreachable_surfaces[${index}] names no surface.`);
280
- continue;
281
- }
282
- if (!String(entry.reason ?? '').trim()) {
283
- add(`${id} declares ${surface} unreachable but gives no business reason for it.`);
284
- continue;
285
- }
286
- if (reached.has(surface)) {
287
- add(`${id} both drives ${surface} in a scenario and declares it unreachable — it is one or the other.`);
288
- continue;
289
- }
290
- if (surfaces.has(surface)) {
291
- add(`${id} declares ${surface} unreachable more than once.`);
292
- continue;
293
- }
294
- surfaces.add(surface);
295
- }
296
- return surfaces;
297
- }
298
- // Spec 34-3, criterion 6. The addresses this run did not take, because the
299
- // capability carried more than `surface_budget` of them. Mirrored here for the
300
- // same reason the unreachable list is, and more urgently: refusing this answer
301
- // locally does not merely disagree with the server, it hands the host an error
302
- // message pointing at `unreachable_surfaces` — the one place these must never
303
- // go, because "nothing can cause this request" and "there were better ones" are
304
- // different sentences and only one of them is true.
305
- //
306
- // Plain surface ids, with no reason each. That asymmetry with the unreachable
307
- // list is deliberate: there the sentence is the guard, because an address you
308
- // cannot write a sentence about is not really unreachable. Here the reason is
309
- // the same for every entry and already known — the ceiling.
310
- function collectDeferred(row, id, reached, unreachable, add) {
311
- const declared = row.deferred_surfaces;
312
- if (declared === undefined)
313
- return new Set();
314
- if (!Array.isArray(declared) || declared.length === 0) {
315
- add(`${id} deferred_surfaces must be a non-empty array of surface ids when it is present.`);
316
- return new Set();
317
- }
318
- const surfaces = new Set();
319
- for (const [index, item] of declared.entries()) {
320
- const surface = typeof item === 'string' ? item.trim() : '';
321
- if (!surface) {
322
- add(`${id} deferred_surfaces[${index}] names no surface.`);
323
- continue;
324
- }
325
- if (reached.has(surface)) {
326
- add(`${id} both drives ${surface} in a scenario and defers it — it is one or the other.`);
327
- continue;
328
- }
329
- if (unreachable.has(surface)) {
330
- add(`${id} declares ${surface} both unreachable and deferred — cannot be reached and was not taken this time are different answers.`);
331
- continue;
332
- }
333
- if (surfaces.has(surface)) {
334
- add(`${id} defers ${surface} more than once.`);
335
- continue;
336
- }
337
- surfaces.add(surface);
338
- }
339
- return surfaces;
340
- }
341
- // Scenario names carrying one marker, by shape rather than by grammar. See the
342
- // caller for why this stays deliberately timid.
343
- function scenarioNamesTagged(suiteText, marker) {
344
- if (!suiteText)
345
- return [];
346
- const lines = suiteText.split('\n');
347
- const names = [];
348
- for (const [index, line] of lines.entries()) {
349
- const trimmed = line.trim();
350
- if (!trimmed.startsWith('@') || !trimmed.split(/\s+/).includes(`@${marker}`))
351
- continue;
352
- const next = lines.slice(index + 1).find((candidate) => candidate.trim().length > 0) ?? '';
353
- const colon = next.indexOf(':');
354
- if (colon === -1)
355
- continue;
356
- const name = next.slice(colon + 1).trim();
357
- if (name)
358
- names.push(name);
359
- }
360
- return names;
361
- }
362
- // Which field names the id. Read off the *assignment*, where the answer is
363
- // exact: the id is already known (it is `contract_key` minus its prefix), so the
364
- // field holding it can be identified rather than guessed.
365
- //
366
- // An earlier version searched the host's answer for any string field whose value
367
- // happened to be an assigned id. That usually landed on the right key and could
368
- // just as well have landed on a `headline` that echoed the id. Neither branch's
369
- // key name is written down here either way — `interface_id` and `capability_id`
370
- // stay the server's business.
371
- function idKeyOf(assignment, cases) {
372
- const ids = new Set(cases.map((entry) => entry.id));
373
- let found = null;
374
- const walk = (value) => {
375
- if (found)
376
- return;
377
- if (Array.isArray(value)) {
378
- value.forEach(walk);
379
- return;
380
- }
381
- if (!value || typeof value !== 'object')
382
- return;
383
- const row = value;
384
- if (typeof row.contract_key === 'string') {
385
- const id = row.contract_key.slice(CONTRACT_PREFIX.length);
386
- for (const [key, candidate] of Object.entries(row)) {
387
- if (key !== 'contract_key' && candidate === id && ids.has(id)) {
388
- found = key;
389
- return;
390
- }
391
- }
392
- }
393
- Object.values(row).forEach(walk);
394
- };
395
- walk(assignment);
396
- return found;
397
- }
398
- // Does the assignment carry anything at all? Distinguishes "nothing to guard"
399
- // from "we could not read what was there".
400
- function hasContent(assignment) {
401
- if (Array.isArray(assignment))
402
- return assignment.length > 0;
403
- if (!assignment || typeof assignment !== 'object')
404
- return false;
405
- return Object.values(assignment).some(hasContent);
406
- }
407
- // The assignment is an opaque body the server composed, so it is walked rather
408
- // than destructured: every object carrying a `contract_key` is one assigned
409
- // case, wherever the shape happens to nest it.
410
- function assignedCases(assignment) {
411
- const found = [];
412
- let surfaceBudget;
413
- const walk = (value) => {
414
- if (Array.isArray(value)) {
415
- value.forEach(walk);
416
- return;
417
- }
418
- if (!value || typeof value !== 'object')
419
- return;
420
- const row = value;
421
- // Found by the same walk rather than by knowing where the server put it, for
422
- // the same reason the cases are: the assignment body is opaque here.
423
- if (typeof row.surface_budget === 'number' && Number.isFinite(row.surface_budget)) {
424
- surfaceBudget = row.surface_budget;
425
- }
426
- const key = row.contract_key;
427
- const marker = row.case_marker;
428
- if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
429
- found.push({
430
- id: key.slice(CONTRACT_PREFIX.length),
431
- contract_key: key,
432
- case_marker: marker,
433
- // Only the behavioral assignment carries addresses. Its absence is what
434
- // tells the coverage check below there is nothing of that kind here.
435
- surfaces: Array.isArray(row.surfaces) ? row.surfaces.filter((s) => typeof s === 'string') : [],
436
- });
437
- }
438
- Object.values(row).forEach(walk);
439
- };
440
- walk(assignment);
441
- // Stamped after the walk, never during it: nothing promises the ceiling is
442
- // visited before the cases that answer to it.
443
- return found.map((entry) => ({ ...entry, surfaceBudget }));
444
- }
445
- // Every byte of the branch's suite, main file and support files together, for
446
- // the "is the marker actually in there" check.
447
- function suiteBytes(output) {
448
- const file = output.suite_file;
449
- if (!file)
450
- return '';
451
- return [file.content, ...(Array.isArray(file.support_files) ? file.support_files.map((f) => f.content) : [])]
452
- .filter((content) => typeof content === 'string')
453
- .join('\n');
454
- }
455
- function sameJson(a, b) {
456
- return stableJson(a) === stableJson(b);
457
- }
458
- function stableJson(value) {
459
- if (Array.isArray(value))
460
- return `[${value.map(stableJson).join(',')}]`;
461
- if (value && typeof value === 'object') {
462
- const object = value;
463
- return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
464
- }
465
- return JSON.stringify(value) ?? 'null';
466
- }
467
- // Reads the task and the answer and reports every problem it can see. Reading
468
- // the answer is itself a check — safe paths, files that exist, a parseable
469
- // envelope — and it is done branch by branch, so a bad entry in one contributes
470
- // its problem and the other is still examined. Only the answer file as a whole
471
- // can stop the pass, because then there is no document left to read.
56
+ // Reads the task and the answer and reports every local problem it can see.
57
+ // Reading the answer is itself a checksafe paths, files that exist, a
58
+ // parseable envelope and it is done branch by branch, so a bad entry in one
59
+ // contributes its problem and the other is still examined.
472
60
  export function validateBuildProblems(config) {
473
61
  const request = readSuiteBuildRequest(config.projectRoot);
474
62
  const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
@@ -477,13 +65,6 @@ export function validateBuildProblems(config) {
477
65
  ...collectBuildProblems(request, outputs, unreadable),
478
66
  ];
479
67
  }
480
- // One branch's problems, for the line that reports it unpublished alongside its
481
- // peer. `put-suite-build` blocks per branch, so its message is per branch too.
482
- export function formatBranchProblems(messages) {
483
- if (messages.length === 1)
484
- return messages[0];
485
- return `${messages.length} problems in this branch's answer:\n${messages.map((m) => ` - ${m}`).join('\n')}`;
486
- }
487
68
  // One report, not a queue of one-at-a-time discoveries. Fixing one thing to be
488
69
  // told the next costs a full round trip each time, and the round trip is the
489
70
  // expensive part.
@@ -491,16 +72,125 @@ export function formatProblems(problems) {
491
72
  const lines = problems.map((problem) => ` ${problem.branch}: ${problem.message}`);
492
73
  return (`Your suite answer has ${problems.length} problem${problems.length === 1 ? '' : 's'}:\n` +
493
74
  `${lines.join('\n')}\n` +
494
- 'Fix all of them, then answer again. The Unitbob server has the last word on ' +
495
- 'what it accepts; this check just finds the common problems in seconds instead ' +
496
- 'of after the whole build.\n');
75
+ 'Fix all of them, then answer again. These are the checks the server cannot make — it has ' +
76
+ 'neither your files nor the request it issued.\n');
77
+ }
78
+ // The exact batch `put-suite-build` would send, plus the two things that can go
79
+ // wrong on the way there.
80
+ //
81
+ // `validate-build` runs before the run and before the review, so the behavioral
82
+ // review usually does not exist yet. That is not a fault in the answer — it is a
83
+ // question this check cannot ask yet — so the branch still goes to the server
84
+ // without it and the gap is named out loud (ADR 0001).
85
+ //
86
+ // A review that *does* exist and will not bind is the opposite: a review of a
87
+ // different candidate, a missing `bdd_quality_review`, a `selection_review` that
88
+ // does not match the plan. `put-suite-build` refuses the branch for each of
89
+ // those, so calling any of them "not written yet" would hand back a green
90
+ // verdict for a branch that is about to be blocked — and the second run of this
91
+ // command, the one after the review, is precisely where that must not happen.
92
+ function dryRunBatch(config, request, outputs) {
93
+ const items = [];
94
+ const unchecked = [];
95
+ const problems = [];
96
+ for (const output of outputs) {
97
+ if (output.build_error) {
98
+ items.push(uploadItem(request, output, undefined));
99
+ continue;
100
+ }
101
+ let testMetadata = output.test_metadata;
102
+ if (output.suite_kind === 'behavioral') {
103
+ try {
104
+ testMetadata = withReview(config, request, output);
105
+ }
106
+ catch (error) {
107
+ if (existsSync(reviewOutputPath(config.projectRoot))) {
108
+ problems.push({ branch: output.suite_kind, message: error.message });
109
+ continue;
110
+ }
111
+ unchecked.push(`${output.suite_kind}: the independent review has not been written yet, so the server judged ` +
112
+ 'this branch without it. Anything it says about bdd_quality_review, known_defect_probe or ' +
113
+ 'candidate_run is answered later, by `suite-review-prepare` and the reviewer — run this ' +
114
+ 'command again afterwards for a verdict on the whole branch.');
115
+ }
116
+ }
117
+ items.push(uploadItem(request, output, testMetadata));
118
+ }
119
+ return { items, unchecked, problems };
120
+ }
121
+ function describe(result) {
122
+ const tallies = result.counts
123
+ ? Object.entries(result.counts)
124
+ .map(([name, value]) => `${value} ${name}`)
125
+ .join(', ')
126
+ : '';
127
+ return ` ${result.suite_kind}: ${result.status}${tallies ? ` — ${tallies}` : ''}`;
128
+ }
129
+ // The server's own words, never a paraphrase. Rewording a rejection here is how
130
+ // a third implementation of a rule starts: the reader then acts on this file's
131
+ // idea of what the server meant, and the two drift the moment either changes.
132
+ function rejection(result) {
133
+ return ` ${result.suite_kind}: ${result.error ?? `the server answered "${result.status}"`}`;
497
134
  }
498
135
  export async function validateBuild(config, _args = [], deps) {
499
- const stdout = deps?.stdout ?? process.stdout;
500
- const problems = validateBuildProblems(config);
501
- if (problems.length === 0) {
502
- stdout.write('Your suite answer looks well-formed. Run `unitbob put-suite-build` to publish it.\n');
136
+ const d = {
137
+ dryRun: (items) => new Wire(config).putSuiteBuilds(items, { dryRun: true }),
138
+ stdout: process.stdout,
139
+ ...deps,
140
+ };
141
+ const request = readSuiteBuildRequest(config.projectRoot);
142
+ const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
143
+ const { items, unchecked, problems } = dryRunBatch(config, request, outputs);
144
+ const local = [
145
+ ...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
146
+ ...collectBuildProblems(request, outputs, unreadable),
147
+ ...problems,
148
+ ];
149
+ if (local.length > 0)
150
+ throw new Error(formatProblems(local));
151
+ for (const line of unchecked)
152
+ d.stdout.write(`Not checked — ${line}\n`);
153
+ if (items.length === 0) {
154
+ d.stdout.write('There is nothing to check with the server: the answer builds no branch.\n');
503
155
  return;
504
156
  }
505
- throw new Error(formatProblems(problems));
157
+ let results;
158
+ try {
159
+ results = await d.dryRun(items);
160
+ }
161
+ catch (error) {
162
+ if (error instanceof WireError && error.unreachable) {
163
+ d.stdout.write(`The Unitbob server was not asked for a verdict: ${error.message}\n` +
164
+ 'Unchecked, therefore: how the assignment was answered, case markers, surface arithmetic and the ' +
165
+ 'surface ceiling, the runner manifest, and the review binding — every rule the server owns. What ' +
166
+ 'passed here is only that the files exist, sit under .unitbob/, and that every branch the request ' +
167
+ 'asked for has an entry.\n');
168
+ return;
169
+ }
170
+ throw error;
171
+ }
172
+ // A server older than `dry_run` ignores the flag and publishes. Saying "the
173
+ // check passed" then would be the worst possible answer: the suite is live and
174
+ // the one publication the recipe allows has been spent.
175
+ const published = results.filter((result) => PUBLISHED.has(result.status));
176
+ if (published.length > 0) {
177
+ throw new Error(`This Unitbob server does not know dry runs: it published ${published.map((r) => r.suite_kind).join(', ')} ` +
178
+ 'instead of checking. Upgrade the server before running validate-build again — and note that this ' +
179
+ 'branch is now live.\n');
180
+ }
181
+ const refused = results.filter((result) => result.status !== WOULD_PUBLISH && result.status !== 'build_error');
182
+ if (refused.length > 0) {
183
+ throw new Error(`The Unitbob server would refuse this answer:\n${refused.map(rejection).join('\n')}\n` +
184
+ 'Those are the server\'s own words. Fix them, then run `unitbob validate-build` again — this round ' +
185
+ 'costs one request, not another run and review.\n');
186
+ }
187
+ // "Would publish it" is only true of the branches it would actually publish. An
188
+ // answer whose every branch is a declared `build_error` is accepted and stores
189
+ // nothing, and reporting that as a suite about to go up would be the one
190
+ // sentence in this output that is not true of what happened.
191
+ const accepted = results.filter((result) => result.status === WOULD_PUBLISH);
192
+ const headline = accepted.length === 0
193
+ ? 'The Unitbob server accepted this answer, and it publishes no suite:'
194
+ : 'The Unitbob server checked this answer and would publish it:';
195
+ d.stdout.write(`${headline}\n${results.map(describe).join('\n')}\n${DRY_RUN_DOES_NOT}\n`);
506
196
  }