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.
- package/dist/cli.js +4 -2
- package/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +20 -4
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +33 -19
- package/dist/proc.js +15 -0
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/failureDigest.js +238 -0
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +1 -1
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +69 -23
- package/dist/verbs/suitePrepare.js +49 -15
- package/dist/verbs/suiteReviewPrepare.js +0 -22
- package/dist/verbs/validateBuild.js +140 -450
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +18 -6
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +37 -20
- package/dist/files/budget.js +0 -74
|
@@ -1,55 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
-
|
|
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.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
//
|
|
62
|
-
//
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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 check — safe 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.
|
|
495
|
-
'
|
|
496
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
}
|