unitbob 0.6.3 → 0.7.1
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/fanOut.js +148 -0
- package/dist/files/guardrails.js +1 -1
- package/dist/files/packets.js +325 -0
- package/dist/files/suiteBuild.js +34 -2
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/files/workerPlan.js +138 -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 +96 -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/files/workerPlan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
|
+
import { branchWidth } from "./fanOut.js";
|
|
6
7
|
export function workerPlanPath(projectRoot) {
|
|
7
8
|
return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
|
|
8
9
|
}
|
|
@@ -34,6 +35,75 @@ export function readWorkerPlan(projectRoot) {
|
|
|
34
35
|
}
|
|
35
36
|
return parsed;
|
|
36
37
|
}
|
|
38
|
+
export const SUPERSEDED_DIR = 'superseded';
|
|
39
|
+
export function seedWorkerCheckpoints(projectRoot) {
|
|
40
|
+
const plan = readWorkerPlan(projectRoot);
|
|
41
|
+
const request_digest = requestDigest(projectRoot);
|
|
42
|
+
const plan_digest = workerPlanDigest(projectRoot);
|
|
43
|
+
const written = [];
|
|
44
|
+
const kept = [];
|
|
45
|
+
const superseded = [];
|
|
46
|
+
for (const item of plan.workers) {
|
|
47
|
+
const path = checkpointPath(projectRoot, item);
|
|
48
|
+
const label = `${item.branch}:${item.worker_id}`;
|
|
49
|
+
// Left alone when it already belongs to this plan: the coordinator's facts
|
|
50
|
+
// and a worker's finished slice both live in this file, and a run costs
|
|
51
|
+
// hours.
|
|
52
|
+
if (belongsToPlan(path, plan_digest)) {
|
|
53
|
+
kept.push(label);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Everything else needs a fresh seed — but the file being replaced is not
|
|
57
|
+
// necessarily worthless. `plan_digest` is a digest of the whole
|
|
58
|
+
// `worker-plan.json`, so editing one slice invalidates every checkpoint at
|
|
59
|
+
// once, including finished ones; and replanning after the first slice comes
|
|
60
|
+
// back is a documented, ordinary move. Overwriting in place would have made
|
|
61
|
+
// this verb the one thing in the build that destroys hours of work, on the
|
|
62
|
+
// most ordinary path there is. Moved for the same reason `suite-prepare`
|
|
63
|
+
// moves the previous run rather than deleting it.
|
|
64
|
+
if (existsSync(path)) {
|
|
65
|
+
const aside = join(dirname(path), SUPERSEDED_DIR, `${item.branch}-${item.worker_id}.json`);
|
|
66
|
+
mkdirSync(dirname(aside), { recursive: true });
|
|
67
|
+
rmSync(aside, { force: true });
|
|
68
|
+
renameSync(path, aside);
|
|
69
|
+
superseded.push(label);
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, `${JSON.stringify(seedFor(item, request_digest, plan_digest), null, 2)}\n`);
|
|
73
|
+
written.push(label);
|
|
74
|
+
}
|
|
75
|
+
return { written, kept, superseded };
|
|
76
|
+
}
|
|
77
|
+
function belongsToPlan(path, planDigest) {
|
|
78
|
+
if (!existsSync(path))
|
|
79
|
+
return false;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
return parsed?.plan_digest === planDigest;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function seedFor(item, request_digest, plan_digest) {
|
|
89
|
+
return {
|
|
90
|
+
request_digest,
|
|
91
|
+
plan_digest,
|
|
92
|
+
branch: item.branch,
|
|
93
|
+
worker_id: item.worker_id,
|
|
94
|
+
// Every promise starts unresolved: the slice has not been worked yet, and
|
|
95
|
+
// the gate wants each one accounted for exactly once.
|
|
96
|
+
unresolved_promises: [...item.promises],
|
|
97
|
+
completed_promises: [],
|
|
98
|
+
written_paths: [],
|
|
99
|
+
decisions: [],
|
|
100
|
+
known_problems: [],
|
|
101
|
+
// Behavioral only, and absent rather than empty elsewhere — it joins Gherkin
|
|
102
|
+
// Scenarios to addresses, and the structural branch has no Scenarios.
|
|
103
|
+
...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
|
|
104
|
+
facts: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
37
107
|
const RUBY_HARNESS = {
|
|
38
108
|
behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
|
|
39
109
|
structural: '.unitbob/structural/unitbob_helper.rb',
|
|
@@ -70,6 +140,14 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
70
140
|
const label = workerLabel(item, index);
|
|
71
141
|
if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
|
|
72
142
|
errors.push(`${label}: branch is not in the request`);
|
|
143
|
+
// Both halves of the checkpoint filename, held to the same rule. `worker_id`
|
|
144
|
+
// has always been checked; `branch` never was, because it only ever came
|
|
145
|
+
// from `request.json` and was only ever read. Since spec 37-2 the pair is
|
|
146
|
+
// also a path this connector *writes*, and `request.json` is a file on the
|
|
147
|
+
// vibecoder's disk — so a `suite_kind` of `../../..` would have put a
|
|
148
|
+
// seeded checkpoint outside the project.
|
|
149
|
+
else if (!/^[a-zA-Z0-9_-]+$/.test(item.branch))
|
|
150
|
+
errors.push(`${label}: branch must be a filename-safe name`);
|
|
73
151
|
if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
|
|
74
152
|
errors.push(`${label}: worker_id must be a stable filename-safe id`);
|
|
75
153
|
else if (seenWorkers.has(item.worker_id))
|
|
@@ -109,9 +187,6 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
109
187
|
if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
|
|
110
188
|
errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
|
|
111
189
|
}
|
|
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
190
|
for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
|
|
116
191
|
if (!isNonEmptyString(ownedPath)) {
|
|
117
192
|
errors.push(`${label}: owned path must be a non-empty string`);
|
|
@@ -141,6 +216,11 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
141
216
|
// The neighbours stay for the opposite reason: naming a capability the request
|
|
142
217
|
// never assigned, or naming one twice, are ways a plan is wrong rather than
|
|
143
218
|
// ways it is narrow. So is an empty plan for a branch that was given work.
|
|
219
|
+
// Measured over the ids this plan actually took, never over the whole
|
|
220
|
+
// assignment. The packets are built before anybody chooses a scope, and since
|
|
221
|
+
// spec 37-3 criterion 2 both branches may be narrowed — so weighing the whole
|
|
222
|
+
// assignment against the width of a narrowed plan would compare two different
|
|
223
|
+
// jobs and refuse the narrow one for being narrow.
|
|
144
224
|
for (const [branch, expected] of expectedByBranch) {
|
|
145
225
|
const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
|
|
146
226
|
if (expected.length > 0 && items.length === 0)
|
|
@@ -153,9 +233,62 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
153
233
|
}
|
|
154
234
|
for (const id of assigned.filter((id) => !expected.includes(id)))
|
|
155
235
|
errors.push(`${branch}: capability ${id} was not assigned by the request`);
|
|
236
|
+
const cases = items.reduce((sum, item) => sum + (Array.isArray(item.planned_cases) ? item.planned_cases.length : 0), 0);
|
|
237
|
+
errors.push(...fanOutErrors(branch, items.length, cases));
|
|
156
238
|
}
|
|
157
239
|
return errors;
|
|
158
240
|
}
|
|
241
|
+
// Which assigned ids each branch of a plan actually took. Spec 37-3 weighs a
|
|
242
|
+
// plan against the work it took on, never against the whole assignment: the
|
|
243
|
+
// packets are built before anybody chooses a scope, and since criterion 2 both
|
|
244
|
+
// branches may be narrowed, so the two are different jobs.
|
|
245
|
+
export function takenIds(plan) {
|
|
246
|
+
const taken = new Map();
|
|
247
|
+
for (const item of Array.isArray(plan?.workers) ? plan.workers : []) {
|
|
248
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) || !isNonEmptyString(item.branch))
|
|
249
|
+
continue;
|
|
250
|
+
const ids = taken.get(item.branch) ?? new Set();
|
|
251
|
+
for (const id of Array.isArray(item.capability_ids) ? item.capability_ids : []) {
|
|
252
|
+
if (isNonEmptyString(id))
|
|
253
|
+
ids.add(id);
|
|
254
|
+
}
|
|
255
|
+
taken.set(item.branch, ids);
|
|
256
|
+
}
|
|
257
|
+
return taken;
|
|
258
|
+
}
|
|
259
|
+
// Spec 37-3, criterion 1. Two things are checked, and only when the packets
|
|
260
|
+
// exist to measure against: that the plan says what it divided, and that it did
|
|
261
|
+
// not divide work that already fits in one worker.
|
|
262
|
+
//
|
|
263
|
+
// A run without packets has no measured work, and a rule with no measurement
|
|
264
|
+
// behind it refuses nobody — the same policy the packets themselves follow.
|
|
265
|
+
function fanOutErrors(branch, planned, cases) {
|
|
266
|
+
const width = branchWidth(branch, cases);
|
|
267
|
+
if (!width || planned === 0)
|
|
268
|
+
return [];
|
|
269
|
+
// A band, not a ceiling. Both ends are expensive and neither is safe: on the
|
|
270
|
+
// 2026-08-24 bench fifteen workers cost 28% more than the cheapest width, and
|
|
271
|
+
// one worker cost 85% more — and a single worker on the behavioral branch
|
|
272
|
+
// would have run 216 turns into a 150-turn fuse. Anywhere inside the band is
|
|
273
|
+
// within about a tenth of the cheapest, so this refuses only what costs.
|
|
274
|
+
//
|
|
275
|
+
// Nothing is restated in the plan to prove the coordinator did this division.
|
|
276
|
+
// Both halves are already in the file — the cases in `planned_cases`, the
|
|
277
|
+
// width as the length of the branch's slice list — so a `fan_out` record would
|
|
278
|
+
// be the same two numbers copied by hand, which is what spec 37-1 refused for
|
|
279
|
+
// `packet_paths`. The gate is the guarantee; `accept-worker-plan` prints the
|
|
280
|
+
// derivation next to it.
|
|
281
|
+
if (planned >= width.fewest && planned <= width.most)
|
|
282
|
+
return [];
|
|
283
|
+
const way = planned > width.most ? 'wide' : 'narrow';
|
|
284
|
+
return [
|
|
285
|
+
`${branch}: ${planned} slices for ${cases} planned cases is too ${way} — ` +
|
|
286
|
+
`${width.fewest}-${width.most} is the band, ${width.workers} the cheapest. ` +
|
|
287
|
+
`Each slice costs a whole opening context (26,065 tokens on that bench, re-read every turn), ` +
|
|
288
|
+
`and each slice fewer makes one conversation longer, which costs with the square of its ` +
|
|
289
|
+
`length. At ${width.workers} a worker of this branch runs about ${width.turns_each} turns.`,
|
|
290
|
+
];
|
|
291
|
+
}
|
|
159
292
|
function assignmentIds(value) {
|
|
160
293
|
const assignment = value;
|
|
161
294
|
if (Array.isArray(assignment?.capabilities)) {
|
|
@@ -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)];
|