unitbob 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -4
- package/dist/files/guardrails.js +1 -1
- package/dist/files/packets.js +319 -0
- package/dist/files/suiteBuild.js +34 -2
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/files/workerPlan.js +79 -5
- package/dist/runner/failureDigest.js +98 -16
- package/dist/runner/provision.js +66 -2
- package/dist/runner/pytest.js +1 -1
- package/dist/runner/pytestBddPlugin.js +5 -0
- package/dist/runner/rspec.js +1 -1
- package/dist/runner/vitest.js +1 -1
- package/dist/surfaces/graph.js +33 -0
- package/dist/surfaces/routeInventory.js +2 -33
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putSuiteBuild.js +1 -1
- package/dist/verbs/run.js +2 -2
- package/dist/verbs/runLocal.js +66 -2
- package/dist/verbs/suitePrepare.js +78 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-worker.toml +13 -5
- package/dist/verbs/validateWorkerPlan.js +0 -9
package/dist/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ import { validateBuild } from "./verbs/validateBuild.js";
|
|
|
27
27
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
28
28
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
29
29
|
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
30
|
-
import {
|
|
30
|
+
import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
|
|
31
31
|
import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
|
|
32
32
|
import { installCodexAgents } from "./verbs/codexInstall.js";
|
|
33
33
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
@@ -53,7 +53,8 @@ Verbs:
|
|
|
53
53
|
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
54
54
|
validate-build Internal: check the host's suite answer against the request, locally, before
|
|
55
55
|
uploading. Reports every problem at once; put-suite-build runs it too.
|
|
56
|
-
|
|
56
|
+
accept-worker-plan Internal: check the exact request-bound worker plan and seed a checkpoint for
|
|
57
|
+
every slice it names, before fan-out.
|
|
57
58
|
validate-worker-checkpoints
|
|
58
59
|
Internal: validate every worker checkpoint before assembly or repair.
|
|
59
60
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
@@ -120,8 +121,8 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
120
121
|
case 'validate-build':
|
|
121
122
|
await validateBuild(await linked(), args);
|
|
122
123
|
return 0;
|
|
123
|
-
case '
|
|
124
|
-
await
|
|
124
|
+
case 'accept-worker-plan':
|
|
125
|
+
await acceptWorkerPlan(await linked(), args);
|
|
125
126
|
return 0;
|
|
126
127
|
case 'validate-worker-checkpoints':
|
|
127
128
|
await validateWorkerCheckpoints(await linked(), args);
|
package/dist/files/guardrails.js
CHANGED
|
@@ -67,7 +67,7 @@ abort 'unitbob_helper: refusing to run against a non-test environment' unless Ra
|
|
|
67
67
|
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
68
|
// lives outside this directory and is written by the pytest runner).
|
|
69
69
|
//
|
|
70
|
-
// Every file, not just the main one (spec
|
|
70
|
+
// Every file, not just the main one (spec one-place-per-rule, §6.4). The directory is wiped
|
|
71
71
|
// first and only the main file was written back, so a published suite of four
|
|
72
72
|
// files came back as one and the run that followed it silently protected a
|
|
73
73
|
// quarter of what the map claimed.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { graphNodes, methodNameOf } from "../surfaces/graph.js";
|
|
4
|
+
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
|
+
import { surfacesPath } from "./mapBuild.js";
|
|
6
|
+
// Spec 37-1. A worker used to receive names — `User#get_token`, `POST /api/tokens` —
|
|
7
|
+
// and spend its first two thirds finding the code behind them. On microblog,
|
|
8
|
+
// 2026-08-23, that was 474 of 674 worker turns and 63% of their input, once per
|
|
9
|
+
// worker, for an application whose entire source is 92 KB.
|
|
10
|
+
//
|
|
11
|
+
// Resolving a name to a file is a dictionary lookup against two artifacts that
|
|
12
|
+
// are already on this machine, so it costs no tokens and asks no model. What it
|
|
13
|
+
// finds is copied whole: the packet carries exactly what the worker would have
|
|
14
|
+
// opened anyway, which is why it cannot inflate a run.
|
|
15
|
+
//
|
|
16
|
+
// Nothing here goes up the wire. Spec 22 keeps the source on the vibecoder's
|
|
17
|
+
// machine, and a packet is a local file the worker opens by path.
|
|
18
|
+
export const PACKETS_DIR = '.unitbob/suite-build/packets';
|
|
19
|
+
// The fuse, not a setting. It exists for the five-thousand-line controller
|
|
20
|
+
// whose file is not "what a worker would have read anyway" — there the packet
|
|
21
|
+
// carries the path and says so, and the worker opens the part it needs. There
|
|
22
|
+
// is nowhere for this number to live except this source file: the size of a
|
|
23
|
+
// packet is not a fact about the project, it is a fact about what fits.
|
|
24
|
+
export const MAX_PACKET_BYTES = 200_000;
|
|
25
|
+
export function packetsDir(projectRoot) {
|
|
26
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'packets');
|
|
27
|
+
}
|
|
28
|
+
export function packetIndexPath(projectRoot) {
|
|
29
|
+
return join(packetsDir(projectRoot), 'index.json');
|
|
30
|
+
}
|
|
31
|
+
export function readPacketIndex(projectRoot) {
|
|
32
|
+
const path = packetIndexPath(projectRoot);
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (!Array.isArray(parsed?.targets))
|
|
38
|
+
return null;
|
|
39
|
+
// Entries, not just the array: a hand-edited index must not throw out of a
|
|
40
|
+
// verb whose whole policy is that a bad index costs the packets and nothing
|
|
41
|
+
// else.
|
|
42
|
+
return { targets: parsed.targets.filter((target) => !!asRecord(target) && asString(target?.id) !== undefined) };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A packet index we cannot read is the same as none: the worker searches,
|
|
46
|
+
// as it did before this spec. It is never a reason to fail a build.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Build every packet for this request. Called right after `request.json` is
|
|
51
|
+
// written, and before any plan exists: the entrypoints are known from the
|
|
52
|
+
// request, the workers are not, so a packet belongs to an entrypoint and two
|
|
53
|
+
// entrypoints in one file share one packet.
|
|
54
|
+
export function writeSuitePackets(projectRoot, request) {
|
|
55
|
+
const targets = targetsOf(request);
|
|
56
|
+
const lookup = buildLookup(projectRoot);
|
|
57
|
+
const notes = new Map();
|
|
58
|
+
// Regenerated whole, every run. A packet left over from a previous assignment
|
|
59
|
+
// sits under an authoritative name with nothing on it saying how old it is.
|
|
60
|
+
const dir = packetsDir(projectRoot);
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
const written = new Map();
|
|
64
|
+
const refused = new Map();
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
const sourceFile = lookup(target.entrypoint);
|
|
67
|
+
if (!sourceFile) {
|
|
68
|
+
target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
|
|
69
|
+
count(notes, 'did not resolve to one file');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The path travels even when the contents do not: a worker told which file
|
|
73
|
+
// to open has still been saved the search.
|
|
74
|
+
target.source_file = sourceFile;
|
|
75
|
+
// Two entrypoints in one file get one packet, written once, referenced twice.
|
|
76
|
+
if (!written.has(sourceFile) && !refused.has(sourceFile)) {
|
|
77
|
+
const copied = copyPacket(projectRoot, sourceFile);
|
|
78
|
+
if (typeof copied === 'number')
|
|
79
|
+
written.set(sourceFile, copied);
|
|
80
|
+
else
|
|
81
|
+
refused.set(sourceFile, copied);
|
|
82
|
+
}
|
|
83
|
+
const bytes = written.get(sourceFile);
|
|
84
|
+
if (bytes === undefined) {
|
|
85
|
+
const refusal = refused.get(sourceFile);
|
|
86
|
+
target.note = refusal.note;
|
|
87
|
+
count(notes, refusal.kind);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
target.packet = `${PACKETS_DIR}/${sourceFile}`;
|
|
91
|
+
target.bytes = bytes;
|
|
92
|
+
}
|
|
93
|
+
writeFileSync(packetIndexPath(projectRoot), `${JSON.stringify({ targets }, null, 2)}\n`);
|
|
94
|
+
return {
|
|
95
|
+
targets: targets.length,
|
|
96
|
+
located: targets.filter((target) => target.source_file).length,
|
|
97
|
+
resolved: targets.filter((target) => target.packet).length,
|
|
98
|
+
files: written.size,
|
|
99
|
+
bytes: [...written.values()].reduce((sum, bytes) => sum + bytes, 0),
|
|
100
|
+
notes: [...notes].map(([note, count]) => `${count} × ${note}`),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// The two names a request carries. Structural interfaces name methods
|
|
104
|
+
// (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
|
|
105
|
+
// Both are entrypoints, both resolve to a file, and both produce the same kind
|
|
106
|
+
// of packet — a worker on either branch opens the same thing the same way.
|
|
107
|
+
function targetsOf(request) {
|
|
108
|
+
const targets = [];
|
|
109
|
+
for (const branch of request.branches) {
|
|
110
|
+
const assignment = branch.assignment;
|
|
111
|
+
for (const block of asArray(assignment?.blocks)) {
|
|
112
|
+
for (const iface of asArray(asRecord(block)?.interfaces)) {
|
|
113
|
+
const record = asRecord(iface);
|
|
114
|
+
const id = asString(record?.interface_id);
|
|
115
|
+
if (!id)
|
|
116
|
+
continue;
|
|
117
|
+
for (const entrypoint of asArray(record?.entrypoints)) {
|
|
118
|
+
const name = asString(entrypoint);
|
|
119
|
+
if (name)
|
|
120
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const capability of asArray(assignment?.capabilities)) {
|
|
125
|
+
const record = asRecord(capability);
|
|
126
|
+
const id = asString(record?.capability_id);
|
|
127
|
+
if (!id)
|
|
128
|
+
continue;
|
|
129
|
+
for (const surface of asArray(record?.surfaces)) {
|
|
130
|
+
const name = asString(surface);
|
|
131
|
+
if (name)
|
|
132
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return targets;
|
|
137
|
+
}
|
|
138
|
+
// One name in, at most one file out. Two local artifacts answer, both of them
|
|
139
|
+
// built from the graph rather than from anybody's spelling rule:
|
|
140
|
+
//
|
|
141
|
+
// `surfaces.json` — the addresses the router declared, each already carrying
|
|
142
|
+
// the file that serves it. It answers a behavioral surface (`POST /api/tokens`)
|
|
143
|
+
// by its id and a structural entrypoint (`MainRoutes.export_posts`) by the
|
|
144
|
+
// handler label, which is the router's own wording, not a name we built.
|
|
145
|
+
//
|
|
146
|
+
// `graph.json` — every symbol graphify saw, matched by method name. A name
|
|
147
|
+
// alone is not enough: `User#get_token` and `ApiTokens.get_token` are two
|
|
148
|
+
// different entrypoints and graphify saw only one `get_token()`. So the owner
|
|
149
|
+
// the entrypoint named has to agree with the file before it is believed.
|
|
150
|
+
//
|
|
151
|
+
// Ambiguity ends in silence, never in a first match, and neither does a name
|
|
152
|
+
// that resolves to a file its owner has nothing to do with. A packet holding
|
|
153
|
+
// the wrong file is worse than no packet: the worker reads it, believes it, and
|
|
154
|
+
// writes a test about code that does not serve this entrypoint. On microblog,
|
|
155
|
+
// 2026-08-23, matching on the bare name alone sent both `ApiTokens` entrypoints
|
|
156
|
+
// to `app/models.py`, whose `get_token` belongs to `User`.
|
|
157
|
+
function buildLookup(projectRoot) {
|
|
158
|
+
const byAddress = new Map();
|
|
159
|
+
for (const surface of readSurfaces(projectRoot)) {
|
|
160
|
+
const record = asRecord(surface);
|
|
161
|
+
const file = normalisePath(asString(record?.source_file));
|
|
162
|
+
if (!file)
|
|
163
|
+
continue;
|
|
164
|
+
for (const key of [asString(record?.id), asString(record?.handler_label)]) {
|
|
165
|
+
if (key)
|
|
166
|
+
remember(byAddress, key, file);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const byMethod = new Map();
|
|
170
|
+
const byLabel = new Map();
|
|
171
|
+
for (const node of graphNodes(projectRoot)) {
|
|
172
|
+
const file = normalisePath(typeof node.source_file === 'string' ? node.source_file : undefined);
|
|
173
|
+
if (typeof node.label !== 'string' || !file)
|
|
174
|
+
continue;
|
|
175
|
+
remember(byMethod, methodNameOf(node.label), file);
|
|
176
|
+
remember(byLabel, node.label, file);
|
|
177
|
+
}
|
|
178
|
+
// The owner and the file agree if graphify put something of the owner's name
|
|
179
|
+
// in that file, or if the owner is the file — `ApiAuth` and `app/api/auth.py`
|
|
180
|
+
// are the same thing said twice, once in the map's words and once in the
|
|
181
|
+
// filesystem's. This confirms a file we already found; it never builds a path
|
|
182
|
+
// out of a name, which would be us maintaining somebody else's naming rule.
|
|
183
|
+
const agrees = (owner, file) => byLabel.get(owner)?.has(file) === true ||
|
|
184
|
+
byMethod.get(owner)?.has(file) === true ||
|
|
185
|
+
squash(file).includes(squash(owner));
|
|
186
|
+
return (entrypoint) => {
|
|
187
|
+
const declared = only(byAddress.get(entrypoint));
|
|
188
|
+
if (declared)
|
|
189
|
+
return declared;
|
|
190
|
+
// An address is the router's word, not a symbol, and must not fall through
|
|
191
|
+
// to symbol matching: `GET /users` would resolve by the name `users` to
|
|
192
|
+
// whatever single thing answers to it — a helper, a model, anything.
|
|
193
|
+
if (/\s/.test(entrypoint))
|
|
194
|
+
return undefined;
|
|
195
|
+
const named = byMethod.get(methodNameOf(entrypoint));
|
|
196
|
+
if (!named)
|
|
197
|
+
return undefined;
|
|
198
|
+
const owner = ownerNameOf(entrypoint);
|
|
199
|
+
if (!owner)
|
|
200
|
+
return only(named);
|
|
201
|
+
return only(new Set([...named].filter((file) => agrees(owner, file))));
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// The name the entrypoint hangs its method on: `User` in `User#get_token`,
|
|
205
|
+
// `Invoice` in `Billing::Invoice#total`. Split exactly as `methodNameOf` splits,
|
|
206
|
+
// so the two never disagree about where a name ends.
|
|
207
|
+
function ownerNameOf(entrypoint) {
|
|
208
|
+
const parts = entrypoint.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
209
|
+
return parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
210
|
+
}
|
|
211
|
+
function squash(value) {
|
|
212
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
213
|
+
}
|
|
214
|
+
// graphify writes a path the way its host does. `app\models.rb` and
|
|
215
|
+
// `./app/models.rb` are the same file as `app/models.rb`, and treating them as
|
|
216
|
+
// three keys makes one file look like three and one method look ambiguous.
|
|
217
|
+
function normalisePath(value) {
|
|
218
|
+
const normalised = value?.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
219
|
+
return normalised || undefined;
|
|
220
|
+
}
|
|
221
|
+
function remember(into, key, file) {
|
|
222
|
+
const seen = into.get(key) ?? new Set();
|
|
223
|
+
seen.add(file);
|
|
224
|
+
into.set(key, seen);
|
|
225
|
+
}
|
|
226
|
+
function count(notes, note) {
|
|
227
|
+
notes.set(note, (notes.get(note) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
function only(files) {
|
|
230
|
+
return files && files.size === 1 ? [...files][0] : undefined;
|
|
231
|
+
}
|
|
232
|
+
function readSurfaces(projectRoot) {
|
|
233
|
+
const path = surfacesPath(projectRoot);
|
|
234
|
+
if (!existsSync(path))
|
|
235
|
+
return [];
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
238
|
+
return asArray(parsed?.surfaces);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Copy one source file into the packets folder, keeping the path it has in the
|
|
245
|
+
// checkout so the worker recognises it. Returns the byte count, or why there is
|
|
246
|
+
// no copy to make. It never throws: one unreadable file out of a hundred is one
|
|
247
|
+
// worker searching, not a run without packets.
|
|
248
|
+
function copyPacket(projectRoot, sourceFile) {
|
|
249
|
+
const relative = `${PACKETS_DIR}/${sourceFile}`;
|
|
250
|
+
try {
|
|
251
|
+
assertUnitbobPath(relative, PACKETS_DIR);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return {
|
|
255
|
+
note: `graph.json names a path we will not write (${sourceFile}) — find the file yourself`,
|
|
256
|
+
kind: 'named a path we will not write',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
// The path came from graphify's output, not from us. A symlink or an
|
|
261
|
+
// absolute path there must not turn "copy a project file" into "copy
|
|
262
|
+
// anything on this machine", so the file is required to really live inside
|
|
263
|
+
// the checkout. `realpathSync` resolves every component, so a symlinked
|
|
264
|
+
// directory in the middle of the path is caught the same way.
|
|
265
|
+
const root = realpathSync(projectRoot);
|
|
266
|
+
let onDisk;
|
|
267
|
+
try {
|
|
268
|
+
onDisk = realpathSync(resolve(projectRoot, sourceFile));
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return {
|
|
272
|
+
note: `${sourceFile} is named in graph.json but is not on disk — find the file yourself`,
|
|
273
|
+
kind: 'named a file that is not on disk',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (onDisk !== root && !onDisk.startsWith(root + sep)) {
|
|
277
|
+
return {
|
|
278
|
+
note: `${sourceFile} resolves outside the project — find the file yourself`,
|
|
279
|
+
kind: 'resolved outside the project',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const stat = statSync(onDisk);
|
|
283
|
+
// A directory passes the size check comfortably and then throws EISDIR on
|
|
284
|
+
// read. Said here rather than caught below, so the words name the cause.
|
|
285
|
+
if (!stat.isFile()) {
|
|
286
|
+
return {
|
|
287
|
+
note: `${sourceFile} is not a file — find the code yourself`,
|
|
288
|
+
kind: 'named something that is not a file',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (stat.size > MAX_PACKET_BYTES) {
|
|
292
|
+
return {
|
|
293
|
+
note: `${sourceFile} is ${stat.size.toLocaleString('en-US')} bytes, over the ${MAX_PACKET_BYTES.toLocaleString('en-US')}-byte packet fuse — open it at that path instead`,
|
|
294
|
+
kind: 'over the packet fuse — the path travels instead',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// The bytes we actually wrote, not the size we saw a moment ago.
|
|
298
|
+
const body = readFileSync(onDisk);
|
|
299
|
+
const destination = join(projectRoot, relative);
|
|
300
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
301
|
+
writeFileSync(destination, body);
|
|
302
|
+
return body.length;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return {
|
|
306
|
+
note: `${sourceFile} could not be copied (${err.message}) — find the file yourself`,
|
|
307
|
+
kind: 'could not be copied',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function asArray(value) {
|
|
312
|
+
return Array.isArray(value) ? value : [];
|
|
313
|
+
}
|
|
314
|
+
function asRecord(value) {
|
|
315
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
316
|
+
}
|
|
317
|
+
function asString(value) {
|
|
318
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
319
|
+
}
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
@@ -18,6 +18,38 @@ export function reviewRequestPath(projectRoot) {
|
|
|
18
18
|
export function candidateRunPath(projectRoot) {
|
|
19
19
|
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
20
20
|
}
|
|
21
|
+
// Spec 37-2, criterion 3. What a new build leaves behind, in the order a reader
|
|
22
|
+
// meets it: the plan, the checkpoints written against that plan, and the answer
|
|
23
|
+
// assembled from them. All three are bound to the `request.json` this run is
|
|
24
|
+
// about to overwrite, so from the next line on they are a previous run's papers
|
|
25
|
+
// wearing this run's filenames — which is exactly the confusion the coordinator
|
|
26
|
+
// used to spend a turn untangling before fan-out.
|
|
27
|
+
const PREVIOUS_RUN_ARTIFACTS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
|
|
28
|
+
const PREVIOUS_DIR = 'previous';
|
|
29
|
+
// Moved, never removed. A run costs hours and real money, and one interrupt plus
|
|
30
|
+
// one restart must not be able to spend that twice — `previous/` is one line
|
|
31
|
+
// more than `rmSync` and it is the line that makes a restart survivable.
|
|
32
|
+
//
|
|
33
|
+
// Replaced one artifact at a time rather than by clearing `previous/` first. The
|
|
34
|
+
// tidier version — wipe, then move whatever exists — loses a complete previous
|
|
35
|
+
// run to a partial one: a build interrupted between writing the plan and seeding
|
|
36
|
+
// its checkpoints displaces only `worker-plan.json`, and clearing would take the
|
|
37
|
+
// finished checkpoints and answer of the run before it with no way back. A
|
|
38
|
+
// `previous/` holding pieces of two runs is worth strictly more than an empty
|
|
39
|
+
// one, and every piece in it is named by the file it kept.
|
|
40
|
+
export function movePreviousRunAside(projectRoot) {
|
|
41
|
+
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
42
|
+
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
43
|
+
if (found.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const previous = join(buildDir, PREVIOUS_DIR);
|
|
46
|
+
mkdirSync(previous, { recursive: true });
|
|
47
|
+
for (const name of found) {
|
|
48
|
+
rmSync(join(previous, name), { recursive: true, force: true });
|
|
49
|
+
renameSync(join(buildDir, name), join(previous, name));
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
21
53
|
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
22
54
|
const metadata = output.test_metadata;
|
|
23
55
|
const candidateDigest = suiteCandidateDigest(output);
|
|
@@ -83,7 +115,7 @@ export function branchRunner(output) {
|
|
|
83
115
|
return runner;
|
|
84
116
|
}
|
|
85
117
|
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
-
// them. Nothing else (spec
|
|
118
|
+
// them. Nothing else (spec one-place-per-rule, §4).
|
|
87
119
|
//
|
|
88
120
|
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
121
|
// stripped the review's own keys back out to match — two lists that had to stay
|
|
@@ -2,7 +2,7 @@ import { readBehavioralReview } from "./suiteBuild.js";
|
|
|
2
2
|
// What travels to the server, and what "published" means when it answers. One
|
|
3
3
|
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
4
4
|
// batch, and `validate-build` sends the same batch as a dry run so the server's
|
|
5
|
-
// verdict is about the exact bytes the publish will carry (spec
|
|
5
|
+
// verdict is about the exact bytes the publish will carry (spec one-place-per-rule, §3).
|
|
6
6
|
//
|
|
7
7
|
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
8
|
// dry run would then be checking something the publish does not send — which is
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
5
5
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
6
6
|
export function workerPlanPath(projectRoot) {
|
|
@@ -34,6 +34,75 @@ export function readWorkerPlan(projectRoot) {
|
|
|
34
34
|
}
|
|
35
35
|
return parsed;
|
|
36
36
|
}
|
|
37
|
+
export const SUPERSEDED_DIR = 'superseded';
|
|
38
|
+
export function seedWorkerCheckpoints(projectRoot) {
|
|
39
|
+
const plan = readWorkerPlan(projectRoot);
|
|
40
|
+
const request_digest = requestDigest(projectRoot);
|
|
41
|
+
const plan_digest = workerPlanDigest(projectRoot);
|
|
42
|
+
const written = [];
|
|
43
|
+
const kept = [];
|
|
44
|
+
const superseded = [];
|
|
45
|
+
for (const item of plan.workers) {
|
|
46
|
+
const path = checkpointPath(projectRoot, item);
|
|
47
|
+
const label = `${item.branch}:${item.worker_id}`;
|
|
48
|
+
// Left alone when it already belongs to this plan: the coordinator's facts
|
|
49
|
+
// and a worker's finished slice both live in this file, and a run costs
|
|
50
|
+
// hours.
|
|
51
|
+
if (belongsToPlan(path, plan_digest)) {
|
|
52
|
+
kept.push(label);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Everything else needs a fresh seed — but the file being replaced is not
|
|
56
|
+
// necessarily worthless. `plan_digest` is a digest of the whole
|
|
57
|
+
// `worker-plan.json`, so editing one slice invalidates every checkpoint at
|
|
58
|
+
// once, including finished ones; and replanning after the first slice comes
|
|
59
|
+
// back is a documented, ordinary move. Overwriting in place would have made
|
|
60
|
+
// this verb the one thing in the build that destroys hours of work, on the
|
|
61
|
+
// most ordinary path there is. Moved for the same reason `suite-prepare`
|
|
62
|
+
// moves the previous run rather than deleting it.
|
|
63
|
+
if (existsSync(path)) {
|
|
64
|
+
const aside = join(dirname(path), SUPERSEDED_DIR, `${item.branch}-${item.worker_id}.json`);
|
|
65
|
+
mkdirSync(dirname(aside), { recursive: true });
|
|
66
|
+
rmSync(aside, { force: true });
|
|
67
|
+
renameSync(path, aside);
|
|
68
|
+
superseded.push(label);
|
|
69
|
+
}
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
writeFileSync(path, `${JSON.stringify(seedFor(item, request_digest, plan_digest), null, 2)}\n`);
|
|
72
|
+
written.push(label);
|
|
73
|
+
}
|
|
74
|
+
return { written, kept, superseded };
|
|
75
|
+
}
|
|
76
|
+
function belongsToPlan(path, planDigest) {
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
81
|
+
return parsed?.plan_digest === planDigest;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function seedFor(item, request_digest, plan_digest) {
|
|
88
|
+
return {
|
|
89
|
+
request_digest,
|
|
90
|
+
plan_digest,
|
|
91
|
+
branch: item.branch,
|
|
92
|
+
worker_id: item.worker_id,
|
|
93
|
+
// Every promise starts unresolved: the slice has not been worked yet, and
|
|
94
|
+
// the gate wants each one accounted for exactly once.
|
|
95
|
+
unresolved_promises: [...item.promises],
|
|
96
|
+
completed_promises: [],
|
|
97
|
+
written_paths: [],
|
|
98
|
+
decisions: [],
|
|
99
|
+
known_problems: [],
|
|
100
|
+
// Behavioral only, and absent rather than empty elsewhere — it joins Gherkin
|
|
101
|
+
// Scenarios to addresses, and the structural branch has no Scenarios.
|
|
102
|
+
...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
|
|
103
|
+
facts: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
37
106
|
const RUBY_HARNESS = {
|
|
38
107
|
behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
|
|
39
108
|
structural: '.unitbob/structural/unitbob_helper.rb',
|
|
@@ -70,6 +139,14 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
70
139
|
const label = workerLabel(item, index);
|
|
71
140
|
if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
|
|
72
141
|
errors.push(`${label}: branch is not in the request`);
|
|
142
|
+
// Both halves of the checkpoint filename, held to the same rule. `worker_id`
|
|
143
|
+
// has always been checked; `branch` never was, because it only ever came
|
|
144
|
+
// from `request.json` and was only ever read. Since spec 37-2 the pair is
|
|
145
|
+
// also a path this connector *writes*, and `request.json` is a file on the
|
|
146
|
+
// vibecoder's disk — so a `suite_kind` of `../../..` would have put a
|
|
147
|
+
// seeded checkpoint outside the project.
|
|
148
|
+
else if (!/^[a-zA-Z0-9_-]+$/.test(item.branch))
|
|
149
|
+
errors.push(`${label}: branch must be a filename-safe name`);
|
|
73
150
|
if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
|
|
74
151
|
errors.push(`${label}: worker_id must be a stable filename-safe id`);
|
|
75
152
|
else if (seenWorkers.has(item.worker_id))
|
|
@@ -109,9 +186,6 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
109
186
|
if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
|
|
110
187
|
errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
|
|
111
188
|
}
|
|
112
|
-
if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
|
|
113
|
-
errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
|
|
114
|
-
}
|
|
115
189
|
for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
|
|
116
190
|
if (!isNonEmptyString(ownedPath)) {
|
|
117
191
|
errors.push(`${label}: owned path must be a non-empty string`);
|
|
@@ -12,10 +12,17 @@ const MARKER = /ubc_[0-9a-f]{12}(?![0-9a-f])/;
|
|
|
12
12
|
// the first test produces no set at all, and comparing against nothing would
|
|
13
13
|
// stop a branch over a harness problem the loop never even reached.
|
|
14
14
|
export function failureSet(runner, report) {
|
|
15
|
+
const found = reportedFailures(runner, report);
|
|
16
|
+
return found && canonical(found.map(({ marker, file, message }) => ({ marker, file, message })));
|
|
17
|
+
}
|
|
18
|
+
// The same failures, with everything a reader needs and the comparison does not.
|
|
19
|
+
// Reported in the order the runner reported them: this list is read by a person
|
|
20
|
+
// deciding what to repair, and a run's own order is the one that matches the
|
|
21
|
+
// console output next to it.
|
|
22
|
+
export function reportedFailures(runner, report) {
|
|
15
23
|
if (!report.trim())
|
|
16
24
|
return null;
|
|
17
|
-
|
|
18
|
-
return found && canonical(found);
|
|
25
|
+
return extract(runner, report);
|
|
19
26
|
}
|
|
20
27
|
// One hash for one set. Same set, same hash, on any machine and in any order.
|
|
21
28
|
export function digestOf(failures) {
|
|
@@ -99,7 +106,9 @@ function fromRspec(report) {
|
|
|
99
106
|
return [];
|
|
100
107
|
const name = `${text(example.description)} ${text(example.full_description)}`;
|
|
101
108
|
const exception = example.exception;
|
|
102
|
-
return [failure(name, text(example.file_path), text(exception?.message)
|
|
109
|
+
return [failure(name, text(example.file_path), text(exception?.message), {
|
|
110
|
+
name: text(example.full_description) || text(example.description),
|
|
111
|
+
})];
|
|
103
112
|
});
|
|
104
113
|
}
|
|
105
114
|
function fromVitest(report) {
|
|
@@ -113,7 +122,9 @@ function fromVitest(report) {
|
|
|
113
122
|
return [];
|
|
114
123
|
const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages : [];
|
|
115
124
|
const name = `${text(assertion.title)} ${text(assertion.fullName)}`;
|
|
116
|
-
return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n')
|
|
125
|
+
return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n'), {
|
|
126
|
+
name: text(assertion.fullName) || text(assertion.title),
|
|
127
|
+
})];
|
|
117
128
|
});
|
|
118
129
|
});
|
|
119
130
|
}
|
|
@@ -130,16 +141,55 @@ function fromVitest(report) {
|
|
|
130
141
|
function fromJunitXml(report) {
|
|
131
142
|
if (!/<testsuites?\b/.test(report))
|
|
132
143
|
return null;
|
|
133
|
-
|
|
144
|
+
// Self-closing first, and as its own alternative rather than a branch inside
|
|
145
|
+
// one `[^>]*`. Written the other way the engine backtracks out of `\/>` into
|
|
146
|
+
// `>[\s\S]*?<\/testcase>` and swallows the next element whole, so a passing
|
|
147
|
+
// self-closed case immediately before a failing one hands back the passing
|
|
148
|
+
// one's name and file. Harmless while this was only hashed; wrong the moment
|
|
149
|
+
// spec 37-2 started printing it to somebody deciding what to repair.
|
|
150
|
+
const cases = report.match(/<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g) ?? [];
|
|
134
151
|
return cases.flatMap((testcase) => {
|
|
135
|
-
const problem = testcase.match(/<(
|
|
152
|
+
const problem = testcase.match(/<(failure|error)\b[^>]*\/>|<(failure|error)\b[^>]*>([\s\S]*?)<\/(?:failure|error)>/);
|
|
136
153
|
if (!problem)
|
|
137
154
|
return [];
|
|
138
155
|
const name = attribute(testcase, 'name');
|
|
139
156
|
const file = attribute(testcase, 'file') || attribute(testcase, 'classname');
|
|
140
|
-
|
|
157
|
+
const message = attribute(problem[0], 'message');
|
|
158
|
+
// pytest puts the one-line summary in `message=` and the assertion with its
|
|
159
|
+
// traceback in the element's body. The body is what a person needs; the
|
|
160
|
+
// attribute is what the digest compares, and changing its bytes would make
|
|
161
|
+
// every branch look like it had moved once.
|
|
162
|
+
const body = unescapeXml(problem[3] ?? '').trim();
|
|
163
|
+
return [failure(name, file, message, {
|
|
164
|
+
name,
|
|
165
|
+
detail: [unescapeXml(message), body].filter(Boolean).join('\n'),
|
|
166
|
+
})];
|
|
141
167
|
});
|
|
142
168
|
}
|
|
169
|
+
// The five entities XML defines plus numeric references — pytest escapes its
|
|
170
|
+
// newlines as ` `, and a traceback rendered as one line of ` ` is not a
|
|
171
|
+
// traceback. This is pytest's own writer on the other end, not arbitrary markup.
|
|
172
|
+
// `&` last, so `&lt;` comes back as `<` rather than as `<`.
|
|
173
|
+
function unescapeXml(value) {
|
|
174
|
+
return value
|
|
175
|
+
.replace(/&#(\d+);/g, (whole, code) => codePoint(Number(code), whole))
|
|
176
|
+
.replace(/&#x([0-9a-f]+);/gi, (whole, code) => codePoint(parseInt(code, 16), whole))
|
|
177
|
+
.replace(/</g, '<')
|
|
178
|
+
.replace(/>/g, '>')
|
|
179
|
+
.replace(/"/g, '"')
|
|
180
|
+
.replace(/'/g, "'")
|
|
181
|
+
.replace(/&/g, '&');
|
|
182
|
+
}
|
|
183
|
+
// A reference outside Unicode is left as it was written. Nothing here is worth
|
|
184
|
+
// throwing over: this runs while somebody is reading why their suite is red.
|
|
185
|
+
function codePoint(value, whole) {
|
|
186
|
+
try {
|
|
187
|
+
return String.fromCodePoint(value);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return whole;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
143
193
|
// Cucumber Messages (NDJSON), both the Ruby and the JS emitter. One scenario is
|
|
144
194
|
// spread over several envelopes: the pickle holds its name, tags and file, the
|
|
145
195
|
// testCase maps its steps, and testStepFinished carries each step's result.
|
|
@@ -164,7 +214,13 @@ function fromCucumberMessages(report) {
|
|
|
164
214
|
continue;
|
|
165
215
|
const startedId = text(finished.testCaseStartedId);
|
|
166
216
|
const list = results.get(startedId) ?? [];
|
|
167
|
-
list.push(
|
|
217
|
+
list.push({
|
|
218
|
+
// The step's own id travels with its result, so the text of the step that
|
|
219
|
+
// failed can be recovered from the pickle it came from (spec 37-2,
|
|
220
|
+
// criterion 5). Nothing in the digest reads it.
|
|
221
|
+
testStepId: finished.testStepId,
|
|
222
|
+
...(finished.testStepResult ?? {}),
|
|
223
|
+
});
|
|
168
224
|
results.set(startedId, list);
|
|
169
225
|
}
|
|
170
226
|
return envelopes.flatMap((envelope) => {
|
|
@@ -180,12 +236,27 @@ function fromCucumberMessages(report) {
|
|
|
180
236
|
const tags = Array.isArray(pickle.tags) ? pickle.tags : [];
|
|
181
237
|
const tagText = rows(tags).map((tag) => text(tag.name)).join(' ');
|
|
182
238
|
const message = failed.map((step) => text(step.message)).find((line) => line.trim()) ?? '';
|
|
183
|
-
return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message
|
|
239
|
+
return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message, {
|
|
240
|
+
name: text(pickle.name),
|
|
241
|
+
step: cucumberStepText(failed[0], testCase, pickle),
|
|
242
|
+
})];
|
|
184
243
|
});
|
|
185
244
|
}
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
245
|
+
// Which step of that Scenario failed, in the words of the feature file. Three
|
|
246
|
+
// hops, because Cucumber Messages keeps the result, the mapping and the text in
|
|
247
|
+
// three different envelopes: result → testStep → pickleStep.
|
|
248
|
+
function cucumberStepText(failed, testCase, pickle) {
|
|
249
|
+
const testSteps = Array.isArray(testCase.testSteps) ? rows(testCase.testSteps) : [];
|
|
250
|
+
const testStep = testSteps.find((step) => text(step.id) === text(failed.testStepId));
|
|
251
|
+
if (!testStep)
|
|
252
|
+
return '';
|
|
253
|
+
const pickleSteps = Array.isArray(pickle.steps) ? rows(pickle.steps) : [];
|
|
254
|
+
return text(pickleSteps.find((step) => text(step.id) === text(testStep.pickleStepId))?.text);
|
|
255
|
+
}
|
|
256
|
+
// The connector's own pytest-bdd report (`runner/pytestBddPlugin.ts`). Its
|
|
257
|
+
// `file` is the `.feature` the Scenario came from, and it is empty on a report
|
|
258
|
+
// written by a connector older than spec 37-2 — which is why it is read
|
|
259
|
+
// defensively rather than assumed.
|
|
189
260
|
function fromPytestBdd(report) {
|
|
190
261
|
const data = parseObject(report);
|
|
191
262
|
if (!Array.isArray(data?.scenarios))
|
|
@@ -194,17 +265,28 @@ function fromPytestBdd(report) {
|
|
|
194
265
|
if (text(scenario.status) === 'passed')
|
|
195
266
|
return [];
|
|
196
267
|
const tags = Array.isArray(scenario.tags) ? scenario.tags.map((tag) => text(tag)).join(' ') : '';
|
|
197
|
-
|
|
268
|
+
// Our own plugin records one entry per step with its status, and marks the
|
|
269
|
+
// one it caught the exception in — so the step is read, never guessed.
|
|
270
|
+
const steps = Array.isArray(scenario.steps) ? rows(scenario.steps) : [];
|
|
271
|
+
const broke = steps.find((step) => text(step.status) === 'failed');
|
|
272
|
+
return [failure(`${tags} ${text(scenario.name)}`, text(scenario.file), text(scenario.failure), {
|
|
273
|
+
name: text(scenario.name),
|
|
274
|
+
step: broke ? `${text(broke.keyword)} ${text(broke.text)}`.trim() : '',
|
|
275
|
+
})];
|
|
198
276
|
});
|
|
199
277
|
}
|
|
200
|
-
//
|
|
278
|
+
// `message` is only the first line. Later lines are backtraces and diffs, which
|
|
201
279
|
// carry object ids and absolute paths that differ between two runs of the same
|
|
202
|
-
// unchanged failure — the very drift that would make
|
|
203
|
-
|
|
280
|
+
// unchanged failure — the very drift that would make the comparison useless.
|
|
281
|
+
// `detail` keeps all of it: nothing on that field is hashed.
|
|
282
|
+
function failure(name, file, message, extra = {}) {
|
|
204
283
|
return {
|
|
205
284
|
marker: name.match(MARKER)?.[0] ?? '',
|
|
206
285
|
file,
|
|
207
286
|
message: message.split('\n')[0]?.trim() ?? '',
|
|
287
|
+
name: (extra.name ?? name).trim(),
|
|
288
|
+
step: extra.step ?? '',
|
|
289
|
+
detail: (extra.detail ?? message).trim(),
|
|
208
290
|
};
|
|
209
291
|
}
|
|
210
292
|
function indexBy(envelopes, key) {
|
package/dist/runner/provision.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { executable } from "../proc.js";
|
|
4
4
|
import { BEHAVIORAL_DIR } from "../files/behavioral.js";
|
|
5
5
|
import { runInProject } from "./place.js";
|
|
6
|
-
import { commandFileOnHost, defaultToolDeps, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
|
|
6
|
+
import { commandFileOnHost, defaultToolDeps, hasGemfileWith, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, sidecarPath, } from "./toolchain.js";
|
|
7
7
|
// How long a local setup step may take before we stop waiting. Provisioning a
|
|
8
8
|
// runner and loading a cold Rails test environment sit in the same ballpark —
|
|
9
9
|
// tens of seconds on a large app — so `runner/bootcheck.ts` waits on this same
|
|
@@ -448,11 +448,75 @@ function copyLockIfPresent(projectRoot, destination) {
|
|
|
448
448
|
if (existsSync(projectLock))
|
|
449
449
|
writeFileSync(destination, readFileSync(projectLock, 'utf8'));
|
|
450
450
|
}
|
|
451
|
+
// The pin the sidecar asks for, and the oldest Ruby that pin will install on.
|
|
452
|
+
// Written next to each other because they are one fact: every cucumber in the
|
|
453
|
+
// 9.x line declares `required_ruby_version >= 2.7` (checked against rubygems for
|
|
454
|
+
// 9.0.0 through 9.2.1, 2026-08-24). Move the pin and this number moves with it.
|
|
455
|
+
const CUCUMBER_PIN = '~> 9.0';
|
|
456
|
+
const CUCUMBER_MIN_RUBY = { major: 2, minor: 7, text: '2.7' };
|
|
457
|
+
// Spec 37-2, criterion 4. One known refusal, made legible — not a parser for
|
|
458
|
+
// other people's error messages.
|
|
459
|
+
//
|
|
460
|
+
// a2time, 2026-08-17: the behavioral branch came back "Bundler failed to
|
|
461
|
+
// provision Cucumber sidecar gem", and the cause was a fact this process could
|
|
462
|
+
// have read in one command — the host's Ruby is 2.6.10, older than the cucumber
|
|
463
|
+
// this connector pins. It cost a round trip through the vibecoder to run
|
|
464
|
+
// `bundle install` by hand and read the same sentence back.
|
|
465
|
+
//
|
|
466
|
+
// Asked before the install rather than after: `bundle install` on a cold
|
|
467
|
+
// application takes minutes, and the answer is the same either way.
|
|
468
|
+
//
|
|
469
|
+
// Only when our pin is the one that applies. A project that declares cucumber
|
|
470
|
+
// itself keeps its own version — `gemLineUnlessTheProjectHasIt` drops our line
|
|
471
|
+
// whole — and refusing that project over a floor it never had to meet would
|
|
472
|
+
// stop a build that works.
|
|
473
|
+
//
|
|
474
|
+
// The Gemfile is read as text, while the line that drops the pin asks bundler's
|
|
475
|
+
// own resolved `dependencies`. The two can disagree, and only one direction of
|
|
476
|
+
// disagreement is expensive: a project whose cucumber arrives through `gemspec`
|
|
477
|
+
// or an `eval_gemfile` would be refused over a floor it never had to meet. So
|
|
478
|
+
// those two words count as "the project may name it", and the check stays quiet
|
|
479
|
+
// — back to bundler's own message, which is where this started and no worse.
|
|
480
|
+
const PROJECT_MAY_NAME_CUCUMBER = /^\s*gem\s+["']cucumber["']|^\s*gemspec\b|^\s*eval_gemfile\b/m;
|
|
481
|
+
async function rubyTooOldForCucumber(projectRoot, deps) {
|
|
482
|
+
if (hasGemfileWith(projectRoot, PROJECT_MAY_NAME_CUCUMBER))
|
|
483
|
+
return null;
|
|
484
|
+
const result = await deps
|
|
485
|
+
.runCmd('ruby', ['-v'], { cwd: projectRoot })
|
|
486
|
+
.catch(() => ({ code: 1, stdout: '', stderr: '' }));
|
|
487
|
+
if (result.code !== 0)
|
|
488
|
+
return null;
|
|
489
|
+
// A version we cannot read stops nothing. This check exists to replace one
|
|
490
|
+
// confusing message with one clear one, and guessing here would replace a
|
|
491
|
+
// clear failure with a wrong refusal.
|
|
492
|
+
const found = `${result.stdout}\n${result.stderr}`.match(/\bruby (\d+)\.(\d+)\.(\d+)/i);
|
|
493
|
+
if (!found)
|
|
494
|
+
return null;
|
|
495
|
+
const [, major, minor] = found;
|
|
496
|
+
const older = Number(major) < CUCUMBER_MIN_RUBY.major
|
|
497
|
+
|| (Number(major) === CUCUMBER_MIN_RUBY.major && Number(minor) < CUCUMBER_MIN_RUBY.minor);
|
|
498
|
+
return older ? `${major}.${minor}.${found[3]}` : null;
|
|
499
|
+
}
|
|
451
500
|
async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
501
|
+
const oldRuby = await rubyTooOldForCucumber(projectRoot, deps);
|
|
502
|
+
if (oldRuby) {
|
|
503
|
+
return {
|
|
504
|
+
status: 'fixable',
|
|
505
|
+
message: `Cucumber ${CUCUMBER_PIN} needs Ruby ${CUCUMBER_MIN_RUBY.text} or newer, and the Ruby answering here is ` +
|
|
506
|
+
`${oldRuby}. Bundler was not asked to install it.`,
|
|
507
|
+
checklist: [
|
|
508
|
+
`Run Unitbob where the application runs. The Ruby that answered is ${oldRuby}; if the app itself runs ` +
|
|
509
|
+
'on a newer one inside a container, name that container under `exec` in `.unitbob.json` and this ' +
|
|
510
|
+
'check runs there instead.',
|
|
511
|
+
`Or declare \`cucumber\` in the project's own Gemfile at a version that supports Ruby ${oldRuby} — ` +
|
|
512
|
+
'the sidecar drops its own pin whenever the project names the gem.',
|
|
513
|
+
],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
452
516
|
const sidecarGemfile = join(behavioralDir, 'Gemfile');
|
|
453
517
|
const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
|
|
454
518
|
'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
|
|
455
|
-
gemLineUnlessTheProjectHasIt('cucumber',
|
|
519
|
+
gemLineUnlessTheProjectHasIt('cucumber', CUCUMBER_PIN) +
|
|
456
520
|
// The connector-owned World blocks outgoing HTTP (spec 35-1), and it can only
|
|
457
521
|
// do that if webmock resolves here. A project that does not carry the gem
|
|
458
522
|
// would otherwise get a World promising a block it silently never performs —
|
package/dist/runner/pytest.js
CHANGED
|
@@ -17,7 +17,7 @@ export const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
|
17
17
|
// JUnit XML report goes to --junit-xml, not stdout. The command is
|
|
18
18
|
// connector-owned: the suite artifact never carries a command string.
|
|
19
19
|
//
|
|
20
|
-
// Every file of the branch is named positionally (spec
|
|
20
|
+
// Every file of the branch is named positionally (spec one-place-per-rule, §6.5) — a branch is
|
|
21
21
|
// one file per assignment now, and pytest takes as many paths as it is given.
|
|
22
22
|
//
|
|
23
23
|
// Which pytest is a single question answered in one place (`locateRunner`), so
|
|
@@ -27,6 +27,11 @@ _UNITBOB_OUT = os.path.abspath(_UNITBOB_OUT) if _UNITBOB_OUT else None
|
|
|
27
27
|
def pytest_bdd_before_scenario(request, feature, scenario):
|
|
28
28
|
_UNITBOB_CURRENT[id(scenario)] = {
|
|
29
29
|
"name": scenario.name,
|
|
30
|
+
# Which .feature file this Scenario came from. Every hook here is handed
|
|
31
|
+
# the feature and none of them recorded it, so a red run named the
|
|
32
|
+
# Scenario and left the reader to find the file (spec 37-2, criterion 5).
|
|
33
|
+
# Project-relative where pytest-bdd offers it.
|
|
34
|
+
"file": getattr(feature, "rel_filename", None) or getattr(feature, "filename", None) or "",
|
|
30
35
|
"tags": sorted(scenario.tags),
|
|
31
36
|
"status": "passed",
|
|
32
37
|
"failure": "",
|
package/dist/runner/rspec.js
CHANGED
|
@@ -18,7 +18,7 @@ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
|
|
|
18
18
|
// corrupt it.
|
|
19
19
|
//
|
|
20
20
|
// `suitePaths` is every file of the branch in the suite blob's own
|
|
21
|
-
// project-relative form (spec
|
|
21
|
+
// project-relative form (spec one-place-per-rule, §6.5). Named one by one rather than as a
|
|
22
22
|
// directory: the artifact already says exactly which files it is, while a
|
|
23
23
|
// directory would also collect whatever else happens to be sitting under the
|
|
24
24
|
// root.
|
package/dist/runner/vitest.js
CHANGED
|
@@ -37,7 +37,7 @@ const PROJECT_CONFIGS = [
|
|
|
37
37
|
// file of the branch in `include`, and the positional filters keep the run to
|
|
38
38
|
// exactly those files.
|
|
39
39
|
//
|
|
40
|
-
// Named files rather than a directory glob, since spec
|
|
40
|
+
// Named files rather than a directory glob, since spec one-place-per-rule, §6.5 made a branch
|
|
41
41
|
// several files: the artifact already says which files it is, and a glob would
|
|
42
42
|
// have to guess a naming convention nothing enforces. `include` is written even
|
|
43
43
|
// when the project has no config of its own — Vitest's default include only
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { graphPath } from "../files/mapBuild.js";
|
|
3
|
+
export function graphNodes(projectRoot) {
|
|
4
|
+
const path = graphPath(projectRoot);
|
|
5
|
+
if (!existsSync(path))
|
|
6
|
+
return [];
|
|
7
|
+
try {
|
|
8
|
+
const graph = JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
if (!Array.isArray(graph.nodes))
|
|
10
|
+
return [];
|
|
11
|
+
return graph.nodes.filter((node) => !!node && typeof node.id === 'string');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return []; // an unreadable graph costs us the links, not the addresses
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function pathsMatch(candidate, file) {
|
|
18
|
+
const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
19
|
+
const wanted = file.replace(/\\/g, '/');
|
|
20
|
+
if (normalised === wanted)
|
|
21
|
+
return 'exact';
|
|
22
|
+
return normalised.endsWith(`/${wanted}`) ? 'suffix' : 'no';
|
|
23
|
+
}
|
|
24
|
+
// Real graphify labels a Ruby method `.send_to_fsa()` and a JS one
|
|
25
|
+
// `initButtons()`; a qualified `CheckoutController#create` also turns up. All of
|
|
26
|
+
// them are read the same way — drop the call parentheses, then take the last
|
|
27
|
+
// name — so the match survives the decoration without depending on which form
|
|
28
|
+
// this release of graphify happens to use. The id itself is never rebuilt from
|
|
29
|
+
// any of this; it is copied.
|
|
30
|
+
export function methodNameOf(label) {
|
|
31
|
+
const parts = label.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
32
|
+
return parts[parts.length - 1] ?? '';
|
|
33
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { executable } from "../proc.js";
|
|
4
4
|
import { firstErrorLine } from "../runner/bootcheck.js";
|
|
5
5
|
import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
|
|
6
6
|
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
7
|
-
import {
|
|
7
|
+
import { graphNodes, methodNameOf, pathsMatch } from "./graph.js";
|
|
8
8
|
// Reading a router means booting the application, which on a large Rails app is
|
|
9
9
|
// tens of seconds. The same budget the other boot-shaped step uses.
|
|
10
10
|
const ROUTES_TIMEOUT_MS = 120_000;
|
|
@@ -266,20 +266,6 @@ function rowsFrom(record) {
|
|
|
266
266
|
action,
|
|
267
267
|
}));
|
|
268
268
|
}
|
|
269
|
-
function graphNodes(projectRoot) {
|
|
270
|
-
const path = graphPath(projectRoot);
|
|
271
|
-
if (!existsSync(path))
|
|
272
|
-
return [];
|
|
273
|
-
try {
|
|
274
|
-
const graph = JSON.parse(readFileSync(path, 'utf8'));
|
|
275
|
-
if (!Array.isArray(graph.nodes))
|
|
276
|
-
return [];
|
|
277
|
-
return graph.nodes.filter((node) => !!node && typeof node.id === 'string');
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return []; // an unreadable graph costs us the links, not the addresses
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
269
|
function toSurface(projectRoot, row, nodes) {
|
|
284
270
|
const surface = { kind: 'route', id: `${row.verb} ${row.path}` };
|
|
285
271
|
if (!row.controller || !row.action)
|
|
@@ -350,23 +336,6 @@ function findNode(nodes, file, action) {
|
|
|
350
336
|
// still ships, without a link.
|
|
351
337
|
return candidates.length === 1 ? candidates[0] : undefined;
|
|
352
338
|
}
|
|
353
|
-
function pathsMatch(candidate, file) {
|
|
354
|
-
const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
355
|
-
const wanted = file.replace(/\\/g, '/');
|
|
356
|
-
if (normalised === wanted)
|
|
357
|
-
return 'exact';
|
|
358
|
-
return normalised.endsWith(`/${wanted}`) ? 'suffix' : 'no';
|
|
359
|
-
}
|
|
360
|
-
// Real graphify labels a Ruby method `.send_to_fsa()` and a JS one
|
|
361
|
-
// `initButtons()`; a qualified `CheckoutController#create` also turns up. All of
|
|
362
|
-
// them are read the same way — drop the call parentheses, then take the last
|
|
363
|
-
// name — so the match survives the decoration without depending on which form
|
|
364
|
-
// this release of graphify happens to use. The id itself is never rebuilt from
|
|
365
|
-
// any of this; it is copied.
|
|
366
|
-
function methodNameOf(label) {
|
|
367
|
-
const parts = label.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
368
|
-
return parts[parts.length - 1] ?? '';
|
|
369
|
-
}
|
|
370
339
|
// What `surfaces.json` must contain for every address the router declared, and
|
|
371
340
|
// what it must not contain on top of them. Used before upload (`put-map-build`):
|
|
372
341
|
// the inventory removed the model's chance to invent an address, and this
|
|
Binary file
|
|
@@ -46,7 +46,7 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
46
46
|
// skipped by going straight to the upload — but reported the way every other
|
|
47
47
|
// local failure here is reported: against the branch it belongs to.
|
|
48
48
|
//
|
|
49
|
-
// Since spec
|
|
49
|
+
// Since spec one-place-per-rule that check is exactly one question, and it is about a branch
|
|
50
50
|
// the answer has *no* entry for: everything else it used to ask is now asked
|
|
51
51
|
// of the server, by a dry run, before this command runs at all. So its
|
|
52
52
|
// problems can never land on a branch this loop visits, and they are reported
|
package/dist/verbs/run.js
CHANGED
|
@@ -30,7 +30,7 @@ function resolve(config, deps) {
|
|
|
30
30
|
getSuites: () => wire.getSuites(),
|
|
31
31
|
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
32
32
|
// The whole envelope, support files and all: a branch is a set of files
|
|
33
|
-
// since spec
|
|
33
|
+
// since spec one-place-per-rule, §6, and picking `path` and `content` out of it here was
|
|
34
34
|
// where the rest of them used to be lost.
|
|
35
35
|
materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
|
|
36
36
|
suite_digest: item.suite_digest,
|
|
@@ -145,7 +145,7 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
// Every file of the branch, in the order the envelope carries them. A structural
|
|
148
|
-
// branch is one file per assignment since spec
|
|
148
|
+
// branch is one file per assignment since spec one-place-per-rule, §6, and running only the main
|
|
149
149
|
// one would execute a fraction of what the map says is guarded.
|
|
150
150
|
function artifactPaths(file) {
|
|
151
151
|
return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
-
import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
|
|
2
|
+
import { digestOf, failureSet, readRunState, rememberFailures, reportedFailures, } from "../runner/failureDigest.js";
|
|
3
3
|
import { placeProblem } from "../runner/place.js";
|
|
4
4
|
import { placeAdvice } from "../runner/placeAdvice.js";
|
|
5
5
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
@@ -143,8 +143,72 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
145
|
d.stdout.write(report(result));
|
|
146
|
+
d.stdout.write(failureLines(runner, result));
|
|
146
147
|
return { runner, result };
|
|
147
148
|
}
|
|
149
|
+
// How many lines of one failure's message are worth printing here. Enough for an
|
|
150
|
+
// assertion diff and the frame under it; not the whole backtrace, which is in
|
|
151
|
+
// the report file the line above names.
|
|
152
|
+
const DETAIL_LINES = 6;
|
|
153
|
+
const DETAIL_CHARS = 600;
|
|
154
|
+
// Spec 37-2, criterion 5. The report is read by the side that knows its format.
|
|
155
|
+
//
|
|
156
|
+
// Both behavioral report shapes and all three structural ones are already parsed
|
|
157
|
+
// in this connector, for the stall comparison — and the coordinator still opened
|
|
158
|
+
// `pytest_bdd_report.json` with an inline `node -e` and `pytest_result.xml` with
|
|
159
|
+
// an inline `python3`, because nothing printed what it found. That happened on
|
|
160
|
+
// the most expensive turns of the run: the repair stretch of the microblog bench
|
|
161
|
+
// cost 79 coordinator turns and 47% of its input, each turn re-reading a 300,000
|
|
162
|
+
// token conversation.
|
|
163
|
+
//
|
|
164
|
+
// Every failure, up to a budget on the whole block — this list is what the
|
|
165
|
+
// repair packets of step 12 are cut from, so a top-N would send the coordinator
|
|
166
|
+
// back to the file for the rest, which is the cost this removes. But the run
|
|
167
|
+
// this most exists for is the first red one, where the harness is not wired yet
|
|
168
|
+
// and every case fails: unbounded, that is half a megabyte into the very context
|
|
169
|
+
// this spec is trying to make cheaper. What does not fit says so, by count, and
|
|
170
|
+
// the report file is named on the line above.
|
|
171
|
+
const FAILURE_BLOCK_CHARS = 40_000;
|
|
172
|
+
function failureLines(runner, result) {
|
|
173
|
+
const failures = reportedFailures(runner, result.report);
|
|
174
|
+
if (!failures || failures.length === 0)
|
|
175
|
+
return '';
|
|
176
|
+
const head = `\n${failures.length} ${failures.length === 1 ? 'case' : 'cases'} failed, read out of ` +
|
|
177
|
+
`${result.resultPath}. Do not open that file to find this again:\n`;
|
|
178
|
+
const printed = [];
|
|
179
|
+
let spent = 0;
|
|
180
|
+
for (const [index, failure] of failures.entries()) {
|
|
181
|
+
const line = one(failure, index);
|
|
182
|
+
if (spent + line.length > FAILURE_BLOCK_CHARS && printed.length > 0)
|
|
183
|
+
break;
|
|
184
|
+
printed.push(line);
|
|
185
|
+
spent += line.length;
|
|
186
|
+
}
|
|
187
|
+
const left = failures.length - printed.length;
|
|
188
|
+
const tail = left === 0
|
|
189
|
+
? ''
|
|
190
|
+
: `\n …and ${left} more failed ${left === 1 ? 'case' : 'cases'}, not printed to keep this readable. ` +
|
|
191
|
+
`They are in ${result.resultPath}; a branch failing this widely is usually one harness problem, ` +
|
|
192
|
+
'not that many repairs.\n';
|
|
193
|
+
return head + printed.join('') + tail;
|
|
194
|
+
}
|
|
195
|
+
function one(failure, index) {
|
|
196
|
+
const where = failure.file ? ` — ${failure.file}` : '';
|
|
197
|
+
const lines = [`\n ${index + 1}. ${failure.name || failure.marker || '(the runner named no case)'}${where}`];
|
|
198
|
+
if (failure.marker && failure.name.includes(failure.marker) === false)
|
|
199
|
+
lines.push(` ${failure.marker}`);
|
|
200
|
+
if (failure.step)
|
|
201
|
+
lines.push(` step: ${failure.step}`);
|
|
202
|
+
for (const line of trimmed(failure.detail))
|
|
203
|
+
lines.push(` ${line}`);
|
|
204
|
+
return `${lines.join('\n')}\n`;
|
|
205
|
+
}
|
|
206
|
+
function trimmed(detail) {
|
|
207
|
+
const lines = detail.split('\n').slice(0, DETAIL_LINES);
|
|
208
|
+
const kept = lines.join('\n').slice(0, DETAIL_CHARS).split('\n');
|
|
209
|
+
const complete = kept.join('\n') === detail;
|
|
210
|
+
return complete ? kept : [...kept, '…'];
|
|
211
|
+
}
|
|
148
212
|
// The command first, and always — including on a green run. It is the answer to
|
|
149
213
|
// "how do I run just this one file again", which is the question the whole
|
|
150
214
|
// iteration loop is made of, and printing it only on failure would hide it at
|
|
@@ -178,7 +242,7 @@ function outputTail(result) {
|
|
|
178
242
|
}
|
|
179
243
|
// The suite blob's own project-relative paths, exactly as the runners expect
|
|
180
244
|
// them: the main file first, then every other file of the branch. The main file
|
|
181
|
-
// stopped being the whole suite in spec
|
|
245
|
+
// stopped being the whole suite in spec one-place-per-rule, §6 — a branch is one file per
|
|
182
246
|
// assignment now — and running it alone would exercise a fraction of what the
|
|
183
247
|
// answer claims to guard.
|
|
184
248
|
//
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { clearRunState } from "../runner/failureDigest.js";
|
|
2
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
3
3
|
import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
4
|
-
import {
|
|
4
|
+
import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
|
|
5
|
+
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
6
7
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
7
8
|
import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
|
|
@@ -230,16 +231,54 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
230
231
|
throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
|
|
231
232
|
}
|
|
232
233
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
234
|
+
// Spec 37-1. The assignment names entrypoints; the packets are the files
|
|
235
|
+
// behind them, resolved from this machine's own graph and copied where a
|
|
236
|
+
// worker can open them. Built here because the entrypoints are known from the
|
|
237
|
+
// request and the workers are not yet — a packet belongs to an entrypoint,
|
|
238
|
+
// not to whoever ends up guarding it.
|
|
239
|
+
//
|
|
240
|
+
// Not written into `request.json`: the coordinator reads that file whole and
|
|
241
|
+
// pays for it on every turn of the longest-lived context in the run.
|
|
242
|
+
const sourcePackets = buildPackets(config.projectRoot, request);
|
|
233
243
|
// A new request is a new build, and a new build has no previous run to be
|
|
234
244
|
// stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
|
|
235
245
|
// step of the loop, so a failure set remembered from the build before it would
|
|
236
246
|
// stop a branch that has not run once yet.
|
|
237
247
|
clearRunState(config.projectRoot);
|
|
248
|
+
// Spec 37-2, criterion 3. Same reasoning, applied to the papers that were left
|
|
249
|
+
// behind rather than cleared: a plan and its checkpoints outlive the
|
|
250
|
+
// `request.json` they were digested against, and every one of them is refused
|
|
251
|
+
// by its own gate from here on. Moved rather than removed — see
|
|
252
|
+
// `movePreviousRunAside`.
|
|
253
|
+
//
|
|
254
|
+
// Wrapped for the same reason `buildPackets` is: by this line the request is
|
|
255
|
+
// written and the build is real. A read-only checkout or a permission the move
|
|
256
|
+
// does not have is a note, not a build that dies after its own work landed
|
|
257
|
+
// and before it could say so.
|
|
258
|
+
let displaced = [];
|
|
259
|
+
let displaceProblem = '';
|
|
260
|
+
try {
|
|
261
|
+
displaced = movePreviousRunAside(config.projectRoot);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
displaceProblem = err.message;
|
|
265
|
+
}
|
|
238
266
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
239
267
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
240
268
|
? '`unitbob suite-review-prepare` before upload'
|
|
241
269
|
: '`unitbob put-suite-build`';
|
|
242
270
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
271
|
+
if (displaced.length > 0) {
|
|
272
|
+
actual.stdout.write(`The previous run's ${displaced.join(', ')} moved to ` +
|
|
273
|
+
`${request.project_root}/.unitbob/suite-build/previous/ — none of it is left where this build will ` +
|
|
274
|
+
'look, and none of it was deleted.\n');
|
|
275
|
+
}
|
|
276
|
+
if (displaceProblem) {
|
|
277
|
+
actual.stdout.write(`\nThe previous run's files could not be moved out of the way (${displaceProblem}). This build is fine, ` +
|
|
278
|
+
'but a plan or checkpoint left over from it will be refused by its own gate — the digests belong to ' +
|
|
279
|
+
'the request that was just replaced.\n');
|
|
280
|
+
}
|
|
281
|
+
actual.stdout.write(packetNotice(request.project_root, sourcePackets));
|
|
243
282
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
244
283
|
`write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
|
|
245
284
|
`finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
|
|
@@ -288,8 +327,45 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
288
327
|
'\n');
|
|
289
328
|
}
|
|
290
329
|
}
|
|
330
|
+
// A checkout we cannot write packets into is a run without packets, not a
|
|
331
|
+
// failed build: the workers search the source themselves, exactly as they did
|
|
332
|
+
// before this spec. The same rule the route inventory follows for the same
|
|
333
|
+
// reason — a read-only checkout or a full disk must not take a build down.
|
|
334
|
+
function buildPackets(projectRoot, request) {
|
|
335
|
+
try {
|
|
336
|
+
return writeSuitePackets(projectRoot, request);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
return err.message;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function packetNotice(projectRoot, packets) {
|
|
343
|
+
if (typeof packets === 'string') {
|
|
344
|
+
return (`\nNo packets were written this run (${packets}). Workers find their own source, as before.\n`);
|
|
345
|
+
}
|
|
346
|
+
if (packets.targets === 0)
|
|
347
|
+
return '';
|
|
348
|
+
const where = `${projectRoot}/${PACKETS_DIR}`;
|
|
349
|
+
const head = packets.files === 0
|
|
350
|
+
? `\nNo packet was written this run, so ${where} holds only its index.\n`
|
|
351
|
+
: `\n${packets.files} ${packets.files === 1 ? 'packet' : 'packets'} (${packets.bytes.toLocaleString('en-US')} bytes) ` +
|
|
352
|
+
`written to ${where}: the source behind ${packets.resolved} of ${packets.targets} entrypoints, resolved from this ` +
|
|
353
|
+
`machine's own graph and route inventory without asking a model. Hand each worker the paths of its packets, ` +
|
|
354
|
+
'never their contents — `unitbob accept-worker-plan` prints them per worker.\n';
|
|
355
|
+
// Two different outcomes, never merged: a file that was found and not carried
|
|
356
|
+
// still saves the worker the search, and a name nothing answered to does not.
|
|
357
|
+
const carried = packets.located - packets.resolved;
|
|
358
|
+
const unknown = packets.targets - packets.located;
|
|
359
|
+
if (carried === 0 && unknown === 0)
|
|
360
|
+
return head;
|
|
361
|
+
return (head +
|
|
362
|
+
`${carried + unknown} of ${packets.targets} entrypoints have no packet` +
|
|
363
|
+
(carried > 0 ? `; ${carried} name a file that was found but not carried` : '') +
|
|
364
|
+
` (${packets.notes.join('; ')}). ` +
|
|
365
|
+
`Each says why in ${where}/index.json.\n`);
|
|
366
|
+
}
|
|
291
367
|
// The runner's own rule for which step files it will load, in the words of the
|
|
292
|
-
// side that loads them (spec
|
|
368
|
+
// side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
|
|
293
369
|
// the behavioral branch; this is the copy the coordinator sees without opening a
|
|
294
370
|
// file.
|
|
295
371
|
//
|
package/dist/wire.js
CHANGED
|
@@ -87,7 +87,7 @@ export class Wire {
|
|
|
87
87
|
// carries one result per suite_kind.
|
|
88
88
|
//
|
|
89
89
|
// `dryRun` is the same route, the same body and the same server-side
|
|
90
|
-
// validation, stopped before the first write (spec
|
|
90
|
+
// validation, stopped before the first write (spec one-place-per-rule, §1). It answers
|
|
91
91
|
// `would_publish` instead of `created`, and it is deliberately not a route of
|
|
92
92
|
// its own: a second route would grow a second implementation, which is the
|
|
93
93
|
// defect this whole spec removes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.0 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
@@ -7,10 +7,10 @@ You receive exactly one worker-plan item and the request paths it references.
|
|
|
7
7
|
That item is your complete scope. Do not add capabilities, promises, examples,
|
|
8
8
|
or scenarios after fan-out.
|
|
9
9
|
|
|
10
|
-
Your checkpoint already exists:
|
|
11
|
-
path the workflow prescribes, with the exact request and plan digests, your
|
|
12
|
-
branch and worker id, your promises in `unresolved_promises
|
|
13
|
-
had already verified. Never initialize it again — not on the first incarnation,
|
|
10
|
+
Your checkpoint already exists: `accept-worker-plan` wrote it before fan-out, at
|
|
11
|
+
the path the workflow prescribes, with the exact request and plan digests, your
|
|
12
|
+
branch and worker id, and your promises in `unresolved_promises`; the
|
|
13
|
+
coordinator added the facts it had already verified. Never initialize it again — not on the first incarnation,
|
|
14
14
|
and not on an explicitly approved fresh incarnation after a native budget stop,
|
|
15
15
|
where you preserve the supplied checkpoint and completed files and continue only
|
|
16
16
|
its `unresolved_promises`. Update it after every completed promise.
|
|
@@ -62,7 +62,15 @@ workers got it as verified. One of them looked, disagreed, and kept its scenario
|
|
|
62
62
|
honest, which is the only reason that access hole came back red instead of green.
|
|
63
63
|
Nothing mechanical enforces any of this; it holds because you keep it.
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Your source packets are the starting point: do not go looking for what is
|
|
66
|
+
already in one. A source packet is the whole file behind one of your entrypoints,
|
|
67
|
+
found for you and put on disk, so opening it is a read and not a search. Your
|
|
68
|
+
task names the paths — sometimes a path to open in place, when the file was too
|
|
69
|
+
large to carry. If it names none, or the code you need is not in the ones it
|
|
70
|
+
names, then search as you would have anyway.
|
|
71
|
+
|
|
72
|
+
Read only the source packets, the `source_paths` and the dependencies your
|
|
73
|
+
finite planned cases need.
|
|
66
74
|
Ask closed questions with the files to look in. For a closed missing fact, use
|
|
67
75
|
the named `fact-finder` agent, as often as the work genuinely needs. A lookup
|
|
68
76
|
may confirm implementation facts but may not expand the plan.
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { validateWorkerPlanFiles, workerPlanDigest } from "../files/workerPlan.js";
|
|
2
|
-
export async function validateWorkerPlan(config, _args = [], deps = { stdout: process.stdout }) {
|
|
3
|
-
const errors = validateWorkerPlanFiles(config.projectRoot);
|
|
4
|
-
if (errors.length > 0)
|
|
5
|
-
throw new Error(`Worker plan is invalid:\n- ${errors.join('\n- ')}`);
|
|
6
|
-
const planDigest = workerPlanDigest(config.projectRoot);
|
|
7
|
-
deps.stdout.write(`Worker plan valid (${planDigest}). Fan-out may start.\n`);
|
|
8
|
-
return { plan_digest: planDigest };
|
|
9
|
-
}
|