unitbob 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/files/packets.js +60 -8
- package/dist/files/workerPlan.js +0 -54
- package/dist/runner/bootcheck.js +162 -71
- package/dist/runner/precheck.js +10 -3
- package/dist/runner/vitest.js +42 -7
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/suitePrepare.js +95 -94
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/dist/files/fanOut.js +0 -148
package/dist/files/packets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { graphNodes, methodNameOf } from "../surfaces/graph.js";
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
5
|
import { surfacesPath } from "./mapBuild.js";
|
|
@@ -52,8 +52,7 @@ export function readPacketIndex(projectRoot) {
|
|
|
52
52
|
// request, the workers are not, so a packet belongs to an entrypoint and two
|
|
53
53
|
// entrypoints in one file share one packet.
|
|
54
54
|
export function writeSuitePackets(projectRoot, request) {
|
|
55
|
-
const targets =
|
|
56
|
-
const lookup = buildLookup(projectRoot);
|
|
55
|
+
const targets = resolveTargets(projectRoot, request);
|
|
57
56
|
const notes = new Map();
|
|
58
57
|
// Regenerated whole, every run. A packet left over from a previous assignment
|
|
59
58
|
// sits under an authoritative name with nothing on it saying how old it is.
|
|
@@ -63,15 +62,11 @@ export function writeSuitePackets(projectRoot, request) {
|
|
|
63
62
|
const written = new Map();
|
|
64
63
|
const refused = new Map();
|
|
65
64
|
for (const target of targets) {
|
|
66
|
-
const sourceFile =
|
|
65
|
+
const sourceFile = target.source_file;
|
|
67
66
|
if (!sourceFile) {
|
|
68
|
-
target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
|
|
69
67
|
count(notes, 'did not resolve to one file');
|
|
70
68
|
continue;
|
|
71
69
|
}
|
|
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
70
|
// Two entrypoints in one file get one packet, written once, referenced twice.
|
|
76
71
|
if (!written.has(sourceFile) && !refused.has(sourceFile)) {
|
|
77
72
|
const copied = copyPacket(projectRoot, sourceFile);
|
|
@@ -105,6 +100,63 @@ export function writeSuitePackets(projectRoot, request) {
|
|
|
105
100
|
notes: [...notes].map(([note, count]) => `${count} × ${note}`),
|
|
106
101
|
};
|
|
107
102
|
}
|
|
103
|
+
// Every entrypoint the request names, each with the file behind it when one file
|
|
104
|
+
// answers. The one place a name is turned into a path: `writeSuitePackets` copies
|
|
105
|
+
// what comes out of here, and `structuralSourceFiles` only reads it. Two
|
|
106
|
+
// resolvers would be two chances to disagree about which file serves a name, and
|
|
107
|
+
// the boot check and the packets have to be talking about the same files.
|
|
108
|
+
//
|
|
109
|
+
// The words for a name nothing answered to are set here rather than by the
|
|
110
|
+
// caller, because they are part of the answer.
|
|
111
|
+
function resolveTargets(projectRoot, request) {
|
|
112
|
+
const targets = targetsOf(request);
|
|
113
|
+
const lookup = buildLookup(projectRoot);
|
|
114
|
+
for (const target of targets) {
|
|
115
|
+
const sourceFile = lookup(target.entrypoint);
|
|
116
|
+
// The path travels even when the contents do not: a worker told which file
|
|
117
|
+
// to open has still been saved the search.
|
|
118
|
+
if (sourceFile)
|
|
119
|
+
target.source_file = sourceFile;
|
|
120
|
+
else
|
|
121
|
+
target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
|
|
122
|
+
}
|
|
123
|
+
return targets;
|
|
124
|
+
}
|
|
125
|
+
// Spec 38, criterion 2. The source files the structural guardrails of this
|
|
126
|
+
// request will import — the list the boot check loads instead of the project's
|
|
127
|
+
// own tests. Computed from two artifacts already on this machine, so it costs no
|
|
128
|
+
// token and no network call.
|
|
129
|
+
//
|
|
130
|
+
// A name nothing answered to is simply absent. That gap is already a visible
|
|
131
|
+
// number — "the source behind N of M entrypoints" — and turning it into a
|
|
132
|
+
// refusal would stop a run over a hole in the graph (see В3 of the requirements).
|
|
133
|
+
export function structuralSourceFiles(projectRoot, request) {
|
|
134
|
+
const files = [];
|
|
135
|
+
for (const target of resolveTargets(projectRoot, request)) {
|
|
136
|
+
if (target.branch !== 'structural' || !target.source_file)
|
|
137
|
+
continue;
|
|
138
|
+
// Only a path that is plainly inside this checkout, and only a file that is
|
|
139
|
+
// really there. graph.json is written by a tool, not by us, and what comes
|
|
140
|
+
// out of here becomes an `import` statement — so an absolute path or a `..`
|
|
141
|
+
// would load something this project does not contain, and a name the graph
|
|
142
|
+
// kept after the file moved would fail to resolve.
|
|
143
|
+
//
|
|
144
|
+
// That last one is the whole reason this check exists. A stale graph entry
|
|
145
|
+
// reaches the runner as "Failed to resolve import" or "FileNotFoundError",
|
|
146
|
+
// which reads as a dependency that is not installed or a module that will
|
|
147
|
+
// not load — and the vibecoder would be sent to repair their code over a
|
|
148
|
+
// hole in *our* map. `writeSuitePackets` already refuses all three, in
|
|
149
|
+
// words, one file further down; here silence is right, because nothing reads
|
|
150
|
+
// this list but the probe.
|
|
151
|
+
if (isAbsolute(target.source_file) || target.source_file.split('/').includes('..'))
|
|
152
|
+
continue;
|
|
153
|
+
if (!existsSync(join(projectRoot, target.source_file)))
|
|
154
|
+
continue;
|
|
155
|
+
if (!files.includes(target.source_file))
|
|
156
|
+
files.push(target.source_file);
|
|
157
|
+
}
|
|
158
|
+
return files;
|
|
159
|
+
}
|
|
108
160
|
// The two names a request carries. Structural interfaces name methods
|
|
109
161
|
// (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
|
|
110
162
|
// Both are entrypoints, both resolve to a file, and both produce the same kind
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
3
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";
|
|
7
6
|
export function workerPlanPath(projectRoot) {
|
|
8
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
|
|
9
8
|
}
|
|
@@ -233,62 +232,9 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
233
232
|
}
|
|
234
233
|
for (const id of assigned.filter((id) => !expected.includes(id)))
|
|
235
234
|
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));
|
|
238
235
|
}
|
|
239
236
|
return errors;
|
|
240
237
|
}
|
|
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
|
-
}
|
|
292
238
|
function assignmentIds(value) {
|
|
293
239
|
const assignment = value;
|
|
294
240
|
if (Array.isArray(assignment?.capabilities)) {
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { executable } from "../proc.js";
|
|
4
4
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
@@ -6,6 +6,7 @@ import { commandFileOnHost, locateRunner } from "./toolchain.js";
|
|
|
6
6
|
import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
|
|
7
7
|
import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
|
|
8
8
|
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
9
|
+
import { VITEST_BOOT_CONFIG_FILE, vitestBootConfigSource } from "./vitest.js";
|
|
9
10
|
const defaultDeps = {
|
|
10
11
|
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: PROVISION_TIMEOUT_MS, env: options.env }),
|
|
11
12
|
};
|
|
@@ -18,23 +19,29 @@ const DETAIL_LIMIT = 4_000;
|
|
|
18
19
|
export const SIGNAL_STRENGTH = {
|
|
19
20
|
rspec: 'Full signal on this stack: the check loads the very file the suite starts from, ' +
|
|
20
21
|
'so whatever stops one stops the other.',
|
|
21
|
-
pytest: '
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
vitest: '
|
|
25
|
-
"
|
|
26
|
-
'
|
|
27
|
-
'no
|
|
22
|
+
pytest: 'Full signal on the files it names: the check imports the very modules the guardrails of this ' +
|
|
23
|
+
"branch will import, and nothing else — none of this project's own tests are opened. Code that " +
|
|
24
|
+
'no guardrail imports was not looked at.',
|
|
25
|
+
vitest: 'Full signal on the files it names: the check imports the very modules the guardrails of this ' +
|
|
26
|
+
"branch will import, through this project's own vite configuration, and nothing else — none of " +
|
|
27
|
+
"the project's own tests are opened. Types are not checked (vite strips them without checking " +
|
|
28
|
+
'them), and code no guardrail imports was not looked at.',
|
|
28
29
|
};
|
|
29
30
|
// Does the suite for this stack get off the ground? One attempt, one answer.
|
|
30
|
-
|
|
31
|
+
//
|
|
32
|
+
// `sourceFiles` are the project's own source files this branch's guardrails will
|
|
33
|
+
// import, resolved from the map (spec 38). They are what gets loaded on the two
|
|
34
|
+
// stacks that have no boot file of their own — never the project's test tree.
|
|
35
|
+
// Ruby ignores the list: its helper is the suite's real first line and already
|
|
36
|
+
// pulls the application up behind it.
|
|
37
|
+
export async function bootCheck(projectRoot, runner, sourceFiles, deps = defaultDeps) {
|
|
31
38
|
switch (runner) {
|
|
32
39
|
case 'rspec':
|
|
33
40
|
return rubyBootCheck(projectRoot, deps);
|
|
34
41
|
case 'pytest':
|
|
35
|
-
return pytestBootCheck(projectRoot, deps);
|
|
42
|
+
return pytestBootCheck(projectRoot, sourceFiles, deps);
|
|
36
43
|
case 'vitest':
|
|
37
|
-
return vitestBootCheck(projectRoot, deps);
|
|
44
|
+
return vitestBootCheck(projectRoot, sourceFiles, deps);
|
|
38
45
|
default:
|
|
39
46
|
return { status: 'not_checked', reason: 'no_runner' };
|
|
40
47
|
}
|
|
@@ -80,7 +87,7 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
80
87
|
// executable makes `spawn` throw, `attempt` return null, and the answer come
|
|
81
88
|
// back `no_runner`: "no runner available to load your suite with", said to
|
|
82
89
|
// someone whose bundler is installed and working. That is the mistake
|
|
83
|
-
// `
|
|
90
|
+
// `runner_could_not_answer` was added to stop making,
|
|
84
91
|
// and the global `bundle` was standing right there the whole time.
|
|
85
92
|
const command = executable(join(projectRoot, 'bin', 'bundle')) ? 'bin/bundle' : 'bundle';
|
|
86
93
|
// When Unitbob installed rspec-rails for itself, the gems this helper needs
|
|
@@ -106,9 +113,11 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
106
113
|
// suite failing to start.
|
|
107
114
|
(result) => (result.code === 0 ? 'ok' : 'broken'));
|
|
108
115
|
}
|
|
109
|
-
// Python:
|
|
110
|
-
//
|
|
111
|
-
//
|
|
116
|
+
// Python: run one test file of our own, which imports the modules the
|
|
117
|
+
// guardrails will import. Not `--collect-only`, and not the project's test tree:
|
|
118
|
+
// what the run would do first is import *our* suite's targets, and asking pytest
|
|
119
|
+
// to collect everything it can find asks about files this product never touches
|
|
120
|
+
// (spec 38, criterion 1).
|
|
112
121
|
//
|
|
113
122
|
// `-c` with an empty-addopts config, exactly as `runPytestSuite` does and for
|
|
114
123
|
// exactly its reason: the project's own `addopts` (`--cov`, `-n auto`) must not
|
|
@@ -117,7 +126,9 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
117
126
|
// a `--cov` flag. The run path had already solved this; the check had not
|
|
118
127
|
// inherited the solution, which also made it *stricter* than the thing it
|
|
119
128
|
// predicts, the one rule this whole check is built on.
|
|
120
|
-
async function pytestBootCheck(projectRoot, deps) {
|
|
129
|
+
async function pytestBootCheck(projectRoot, sourceFiles, deps) {
|
|
130
|
+
if (sourceFiles.length === 0)
|
|
131
|
+
return { status: 'not_checked', reason: 'nothing_to_load' };
|
|
121
132
|
// Its own directory, rather than relying on `materializeHelper` having run
|
|
122
133
|
// first: this check must not fail because a different step was skipped.
|
|
123
134
|
mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
|
|
@@ -139,13 +150,19 @@ async function pytestBootCheck(projectRoot, deps) {
|
|
|
139
150
|
{ command: 'python3', args: ['-m', 'pytest'], env: undefined },
|
|
140
151
|
{ command: 'python', args: ['-m', 'pytest'], env: undefined },
|
|
141
152
|
];
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
return withProbe(projectRoot, { [PYTEST_PROBE_FILE]: pytestProbeSource(sourceFiles) }, async () => {
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
// The probe's path is passed explicitly, and it has to be: `.unitbob` is a
|
|
156
|
+
// hidden directory, which pytest's default `norecursedirs` skips. Without
|
|
157
|
+
// the path it would collect the project's tests and not ours — the exact
|
|
158
|
+
// mistake this spec removes.
|
|
159
|
+
const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--rootdir', '.', PYTEST_PROBE_FILE, '-q'], { cwd: projectRoot, env: candidate.env });
|
|
160
|
+
if (result === null)
|
|
161
|
+
continue; // this interpreter is not on the machine
|
|
162
|
+
return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
|
|
163
|
+
}
|
|
164
|
+
return { status: 'not_checked', reason: 'no_runner' };
|
|
165
|
+
});
|
|
149
166
|
}
|
|
150
167
|
// pytest's own exit vocabulary, used rather than "zero or not". The distinction
|
|
151
168
|
// that matters is between "your code did not load" and "pytest itself could not
|
|
@@ -155,8 +172,9 @@ function pytestVerdict(code) {
|
|
|
155
172
|
// unreachable — but "no exit code" can only ever mean "we learned nothing".
|
|
156
173
|
if (code === null)
|
|
157
174
|
return 'runner_could_not_answer';
|
|
158
|
-
// 5 — collected nothing.
|
|
159
|
-
//
|
|
175
|
+
// 5 — collected nothing. Since spec 38 the only file offered for collection is
|
|
176
|
+
// our own probe, so this now means "our probe was not collected", not "this
|
|
177
|
+
// project has no tests". Still not a verdict on the code either way.
|
|
160
178
|
if (code === 5)
|
|
161
179
|
return 'nothing_to_load';
|
|
162
180
|
// 3 — internal error, 4 — bad usage. Both are about the invocation, not the
|
|
@@ -165,16 +183,34 @@ function pytestVerdict(code) {
|
|
|
165
183
|
return 'runner_could_not_answer';
|
|
166
184
|
return code === 0 ? 'ok' : 'broken';
|
|
167
185
|
}
|
|
168
|
-
// JS/TS:
|
|
169
|
-
//
|
|
186
|
+
// JS/TS: run one test file of our own, which imports the source files the
|
|
187
|
+
// guardrails will import.
|
|
188
|
+
//
|
|
189
|
+
// It used to be `vitest list`, which parses and imports *every* test file the
|
|
190
|
+
// project has. That answered about somebody else's tests: a jest project came
|
|
191
|
+
// back "found a defect that stops your test suite from starting" because its own
|
|
192
|
+
// suites, green under jest, do not define `describe` under vitest. We do not
|
|
193
|
+
// need the project's tests — we write our own — so we stopped opening them
|
|
194
|
+
// (spec 38, criterion 1).
|
|
195
|
+
//
|
|
196
|
+
// Why a test file rather than a plain import: a test runner has no "load this
|
|
197
|
+
// module and tell me if it exploded" command, and a bare `node` cannot stand in,
|
|
198
|
+
// because TypeScript, JSX, path aliases and bundler plugins are exactly what
|
|
199
|
+
// vite resolves from the project's own config. So the probe is a legal vitest
|
|
200
|
+
// test that asserts nothing and imports everything named. Ruby has done the same
|
|
201
|
+
// since spec 29, through `unitbob_helper.rb`.
|
|
170
202
|
//
|
|
171
203
|
// `tsc --noEmit` is deliberately not used. It answers a different question —
|
|
172
204
|
// are the types sound — and a project with a hundred type errors runs perfectly
|
|
173
205
|
// well, because vite, esbuild and tsx strip types without checking them. Type
|
|
174
206
|
// errors accumulate for years in healthy codebases; calling that "broken" would
|
|
175
207
|
// turn away the majority. A file that is genuinely unparseable is caught here
|
|
176
|
-
// anyway, since
|
|
177
|
-
async function vitestBootCheck(projectRoot, deps) {
|
|
208
|
+
// anyway, since the probe's import has to parse it.
|
|
209
|
+
async function vitestBootCheck(projectRoot, sourceFiles, deps) {
|
|
210
|
+
// Nothing the map resolved to a file, so there is nothing to import. That is a
|
|
211
|
+
// hole in the graph, not a broken application, and the run carries on.
|
|
212
|
+
if (sourceFiles.length === 0)
|
|
213
|
+
return { status: 'not_checked', reason: 'nothing_to_load' };
|
|
178
214
|
// A sidecar vitest counts as installed: it is ours, it is on disk, and it is
|
|
179
215
|
// the one the run will spawn. What stays out is `npx`, for the reason below.
|
|
180
216
|
const local = locateRunner(projectRoot, 'vitest')?.command ?? 'node_modules/.bin/vitest';
|
|
@@ -183,52 +219,107 @@ async function vitestBootCheck(projectRoot, deps) {
|
|
|
183
219
|
// user's project is not this check's business.
|
|
184
220
|
//
|
|
185
221
|
// `executable`, not `existsSync`, and there is no fallback to go to: an
|
|
186
|
-
// unrunnable binary sends `spawn` into EACCES
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// here: there is no vitest this check can invoke.
|
|
222
|
+
// unrunnable binary sends `spawn` into EACCES and there is nothing left to
|
|
223
|
+
// ask. `no_runner` is the honest answer: there is no vitest this check can
|
|
224
|
+
// invoke.
|
|
190
225
|
if (!executable(commandFileOnHost(projectRoot, local)))
|
|
191
226
|
return { status: 'not_checked', reason: 'no_runner' };
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (proc.code === 0)
|
|
211
|
-
return 'ok';
|
|
212
|
-
if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
|
|
213
|
-
return 'nothing_to_load';
|
|
214
|
-
return 'broken';
|
|
227
|
+
return withProbe(projectRoot, {
|
|
228
|
+
[VITEST_PROBE_FILE]: vitestProbeSource(sourceFiles),
|
|
229
|
+
[VITEST_BOOT_CONFIG_FILE]: vitestBootConfigSource(projectRoot, VITEST_PROBE_FILE),
|
|
230
|
+
}, async () => {
|
|
231
|
+
// `run`, which every version of vitest has. The version probe that used to
|
|
232
|
+
// stand here existed only for the `list` subcommand, which arrived in 2.1 —
|
|
233
|
+
// with it goes the `runner_too_old` answer, and with that the case where a
|
|
234
|
+
// perfectly good runner was declined for its age.
|
|
235
|
+
const result = await attempt(deps, local, ['run', '--config', VITEST_BOOT_CONFIG_FILE], { cwd: projectRoot });
|
|
236
|
+
return classify(projectRoot, 'vitest', result, (proc) => {
|
|
237
|
+
if (proc.code === 0)
|
|
238
|
+
return 'ok';
|
|
239
|
+
// Our own probe was not collected. It says nothing about the project's
|
|
240
|
+
// code, so it must not read as a verdict on it.
|
|
241
|
+
if (/no test files found/i.test(`${proc.stdout}\n${proc.stderr}`))
|
|
242
|
+
return 'nothing_to_load';
|
|
243
|
+
return 'broken';
|
|
244
|
+
});
|
|
215
245
|
});
|
|
216
246
|
}
|
|
217
|
-
// The
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
// The probe: one test that asserts nothing and imports everything the map named.
|
|
248
|
+
//
|
|
249
|
+
// The vitest probe lives beside the generated suite because that is where the
|
|
250
|
+
// suite's own imports will resolve from; `materializeGuardrails` wipes that
|
|
251
|
+
// directory before writing a suite, and by then the probe is long gone.
|
|
252
|
+
const VITEST_PROBE_FILE = `${GUARDRAILS_DIR}/__unitbob_boot.test.mjs`;
|
|
253
|
+
// `test_` first, on purpose: pytest collects a file by that prefix whatever the
|
|
254
|
+
// project's own `python_files` setting says.
|
|
255
|
+
const PYTEST_PROBE_FILE = `${GUARDRAILS_DIR}/test_unitbob_boot.py`;
|
|
256
|
+
// Every file this check puts in somebody's project, written together and removed
|
|
257
|
+
// together whatever happens — the way `worldProbe` treats its own. One of them
|
|
258
|
+
// left behind would be collected by the project's next test run, and changing
|
|
259
|
+
// what that run does is not ours to do.
|
|
260
|
+
//
|
|
261
|
+
// Both stacks go through here, so the write and the removal cannot drift apart
|
|
262
|
+
// on one of them.
|
|
263
|
+
async function withProbe(projectRoot, files, ask) {
|
|
264
|
+
for (const [relativePath, source] of Object.entries(files)) {
|
|
265
|
+
const path = join(projectRoot, relativePath);
|
|
266
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
267
|
+
writeFileSync(path, source);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
return await ask();
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
for (const relativePath of Object.keys(files))
|
|
274
|
+
rmSync(join(projectRoot, relativePath), { force: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Relative from the probe, which sits two levels down (`.unitbob/structural/`).
|
|
278
|
+
// The empty test is not decoration: vitest fails a file that declares none, and
|
|
279
|
+
// `globals: true` in the config we write is what lets it be named without an
|
|
280
|
+
// import (see `writeVitestBootConfig`).
|
|
281
|
+
function vitestProbeSource(sourceFiles) {
|
|
282
|
+
const imports = sourceFiles.map((file) => `import ${JSON.stringify(`../../${file}`)};`).join('\n');
|
|
283
|
+
return `// Written by the unitbob connector before the boot check — do not edit.
|
|
284
|
+
${imports}
|
|
285
|
+
|
|
286
|
+
test('the modules our guardrails import all load', () => {});
|
|
287
|
+
`;
|
|
288
|
+
}
|
|
289
|
+
// Python imports modules, not files, so the probe does the translation itself.
|
|
290
|
+
// The module is registered in `sys.modules` before it is executed, because a
|
|
291
|
+
// module that is not there yet cannot be the target of its own relative
|
|
292
|
+
// imports.
|
|
293
|
+
//
|
|
294
|
+
// This is the one part of spec 38 that no live project has exercised: there was
|
|
295
|
+
// no Python project on the bench. Worth watching on the first Python run.
|
|
296
|
+
function pytestProbeSource(sourceFiles) {
|
|
297
|
+
return `# Written by the unitbob connector before the boot check — do not edit.
|
|
298
|
+
import importlib.util
|
|
299
|
+
import pathlib
|
|
300
|
+
import sys
|
|
301
|
+
|
|
302
|
+
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
303
|
+
sys.path.insert(0, str(ROOT))
|
|
304
|
+
TARGETS = ${JSON.stringify(sourceFiles)}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_the_modules_our_guardrails_import_all_load():
|
|
308
|
+
for rel in TARGETS:
|
|
309
|
+
# A path we cannot build a module out of is our resolution falling
|
|
310
|
+
# short, never this project's code, so it is passed over in silence.
|
|
311
|
+
# Blaming somebody's application for a name our own map handed us is
|
|
312
|
+
# the whole mistake this check was rewritten to stop making.
|
|
313
|
+
if not rel.endswith(".py"):
|
|
314
|
+
continue
|
|
315
|
+
name = rel[:-3].replace("/", ".")
|
|
316
|
+
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
|
317
|
+
if spec is None or spec.loader is None:
|
|
318
|
+
continue
|
|
319
|
+
module = importlib.util.module_from_spec(spec)
|
|
320
|
+
sys.modules[name] = module
|
|
321
|
+
spec.loader.exec_module(module)
|
|
322
|
+
`;
|
|
232
323
|
}
|
|
233
324
|
// Runs one command, turning "this binary is not on the machine" into null (the
|
|
234
325
|
// caller decides whether that means `no_runner` or "try the next interpreter")
|
package/dist/runner/precheck.js
CHANGED
|
@@ -262,12 +262,19 @@ function vitestPrecheck(projectRoot, deps) {
|
|
|
262
262
|
const hasVitest = sidecarProvides(projectRoot, 'vitest', deps) ||
|
|
263
263
|
/"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
|
|
264
264
|
existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
|
|
265
|
+
// What the project runs its own tests with is not asked here and is not a
|
|
266
|
+
// reason to stop (spec 38): we never open those tests, so jest, mocha or
|
|
267
|
+
// nothing at all is none of our business. The only question is whether there
|
|
268
|
+
// is a vitest to run *our* guardrails with — and suite-prepare installs one
|
|
269
|
+
// under `.unitbob/` when there is not, which is why this sentence is not
|
|
270
|
+
// reached on the build path at all. It stands for `run` and `run-local`,
|
|
271
|
+
// where nothing has been provisioned yet.
|
|
265
272
|
if (!hasVitest) {
|
|
266
273
|
return {
|
|
267
274
|
ok: false,
|
|
268
|
-
message: '
|
|
269
|
-
|
|
270
|
-
'(`npm i -D vitest`); change dependencies only with their consent, then retry.',
|
|
275
|
+
message: 'Unitbob runs its own guardrails with Vitest, and no vitest was found in this project\'s ' +
|
|
276
|
+
'package.json or node_modules — nor one installed by Unitbob under `.unitbob/`. Offer the ' +
|
|
277
|
+
'user to add it (`npm i -D vitest`); change dependencies only with their consent, then retry.',
|
|
271
278
|
};
|
|
272
279
|
}
|
|
273
280
|
return { ok: true };
|
package/dist/runner/vitest.js
CHANGED
|
@@ -10,6 +10,12 @@ export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
|
|
|
10
10
|
// the project has its own config. Connector-owned: never stored in Rails, never
|
|
11
11
|
// part of the suite digest.
|
|
12
12
|
export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
|
|
13
|
+
// The boot check's own config (spec 38), kept in a separate file from the one
|
|
14
|
+
// above rather than shared with it. The two answer different questions and are
|
|
15
|
+
// alive at different moments — the run's config names the branch's suite files
|
|
16
|
+
// and is rewritten immediately before every run, so a boot check that reused it
|
|
17
|
+
// would either be overwritten or leave the run pointing at a probe.
|
|
18
|
+
export const VITEST_BOOT_CONFIG_FILE = join('.unitbob', 'vitest.boot.config.mjs');
|
|
13
19
|
// The project configs we inherit from, most specific first. Vitest reads a
|
|
14
20
|
// project's own config even when we pass `--config`, so we must merge ours with
|
|
15
21
|
// it rather than replace it (plugins, path aliases and resolve settings the
|
|
@@ -79,12 +85,21 @@ export async function runVitestSuite(projectRoot, suitePaths) {
|
|
|
79
85
|
// the branch's files have to be in `include` or nothing is collected, and the
|
|
80
86
|
// names a worker gives its slice are not something to bet a whole run on.
|
|
81
87
|
function writeMergedConfig(projectRoot, suitePaths) {
|
|
82
|
-
const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
83
88
|
const path = join(projectRoot, VITEST_CONFIG_FILE);
|
|
84
89
|
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
-
writeFileSync(path, configSource(
|
|
90
|
+
writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run'));
|
|
86
91
|
return ['--config', VITEST_CONFIG_FILE];
|
|
87
92
|
}
|
|
93
|
+
// The same config, shaped for the boot check's probe (spec 38). Returned as text
|
|
94
|
+
// rather than written, because the boot check owns the lifetime of every file it
|
|
95
|
+
// puts in the project: it writes them together and removes them together, and a
|
|
96
|
+
// writer here would take half of that away from the one place that can see it.
|
|
97
|
+
export function vitestBootConfigSource(projectRoot, probePath) {
|
|
98
|
+
return configSource(projectConfigOf(projectRoot), [probePath], 'boot');
|
|
99
|
+
}
|
|
100
|
+
function projectConfigOf(projectRoot) {
|
|
101
|
+
return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
102
|
+
}
|
|
88
103
|
// The .unitbob/ config sits one level below the project root, so the project
|
|
89
104
|
// config is a `../` import. A function-form config is resolved first, and
|
|
90
105
|
// everything the project set — plugins, aliases, setup files, environment — is
|
|
@@ -101,21 +116,41 @@ function writeMergedConfig(projectRoot, suitePaths) {
|
|
|
101
116
|
// ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
|
|
102
117
|
// projects the sidecar exists for. A spread does the same job with no import,
|
|
103
118
|
// and `defineConfig` is a typing helper that buys a generated file nothing.
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
// `mode` is the boot check's two differences from the run, and they go together:
|
|
120
|
+
// globals on, and a workspace refused. See `vitestBootConfigSource`.
|
|
121
|
+
function configSource(projectConfig, suitePaths, mode) {
|
|
122
|
+
// After the project's own `test`, never before it: ours has to win.
|
|
123
|
+
const settings = `${mode === 'boot' ? 'globals: true, ' : ''}include: ${JSON.stringify(suitePaths)}`;
|
|
106
124
|
const header = '// Written by the unitbob connector before each vitest run — do not edit.';
|
|
107
125
|
if (!projectConfig) {
|
|
108
126
|
return `${header}
|
|
109
|
-
export default { test: { ${
|
|
127
|
+
export default { test: { ${settings} } };
|
|
110
128
|
`;
|
|
111
129
|
}
|
|
130
|
+
// Globals, because the probe lives in `.unitbob/structural/`: an
|
|
131
|
+
// `import { test } from 'vitest'` there resolves by walking up from that
|
|
132
|
+
// directory, which never reaches `.unitbob/runners/node_modules` — where the
|
|
133
|
+
// vitest we installed for a project that had none is kept. We write this file,
|
|
134
|
+
// so we turn the globals on and the probe needs no import at all.
|
|
135
|
+
//
|
|
136
|
+
// A workspace is dropped for a harder reason. With `test.projects` (or the
|
|
137
|
+
// older `test.workspace`) set, the root `include` stops deciding anything and
|
|
138
|
+
// vitest runs each sub-project's own test files — which would open the
|
|
139
|
+
// project's tests again through the back door, the one thing spec 38 removes.
|
|
140
|
+
const narrow = mode === 'boot'
|
|
141
|
+
? `
|
|
142
|
+
const { projects, workspace, ...test } = base.test ?? {};
|
|
143
|
+
`
|
|
144
|
+
: `
|
|
145
|
+
const test = base.test ?? {};
|
|
146
|
+
`;
|
|
112
147
|
return `${header}
|
|
113
148
|
import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
|
|
114
149
|
|
|
115
150
|
const base = typeof projectConfig === 'function'
|
|
116
151
|
? await projectConfig({ command: 'serve', mode: 'test' })
|
|
117
152
|
: projectConfig;
|
|
118
|
-
|
|
119
|
-
export default { ...base, test: { ...
|
|
153
|
+
${narrow}
|
|
154
|
+
export default { ...base, test: { ...test, ${settings} } };
|
|
120
155
|
`;
|
|
121
156
|
}
|
|
Binary file
|
|
@@ -1,8 +1,7 @@
|
|
|
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 {
|
|
5
|
-
import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
|
|
4
|
+
import { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
|
|
6
5
|
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
7
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
8
7
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
@@ -68,7 +67,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
68
67
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
69
68
|
precheck: anyStackPrecheck,
|
|
70
69
|
confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
|
|
71
|
-
bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
|
|
70
|
+
bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
|
|
72
71
|
ensureRunner: deps?.ensureRunner ?? ensureRunner,
|
|
73
72
|
ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
|
|
74
73
|
worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
|
|
@@ -131,30 +130,10 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
131
130
|
throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
|
|
132
131
|
}
|
|
133
132
|
}
|
|
134
|
-
// Spec 32-6. Before anything is fetched or written, find out whether the suite
|
|
135
|
-
// would get off the ground at all. It runs here, after the boot helper exists
|
|
136
|
-
// and before the network, so a project whose suite cannot start costs one
|
|
137
|
-
// command instead of a full generation.
|
|
138
|
-
//
|
|
139
|
-
// There is no `--on-broken-boot` flag and no mode. The decision is not a
|
|
140
|
-
// policy we could reasonably let a user set — it follows from the fact: we
|
|
141
|
-
// tried to load the thing the suite starts with, it did not load, therefore
|
|
142
|
-
// not one test would reach its first assertion. Debugging generation against a
|
|
143
|
-
// knowingly dead project is our problem, not the vibecoder's.
|
|
144
133
|
// The stack the precheck just identified, rather than a second detection of
|
|
145
134
|
// the same thing: on Python that would shell out to pytest all over again.
|
|
135
|
+
// Used further down, where the boot check now runs.
|
|
146
136
|
const structuralRunner = check.runner ?? null;
|
|
147
|
-
const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
|
|
148
|
-
if (boot.status === 'broken') {
|
|
149
|
-
// "Your environment is not ready" is the one of the two that a container can
|
|
150
|
-
// answer — the toolchain is missing here and may be sitting in one. A defect
|
|
151
|
-
// found in the code is a defect wherever it runs, and offering a container
|
|
152
|
-
// for it would be the noise this spec is trying to remove.
|
|
153
|
-
throw boot.cause === 'environment_not_ready'
|
|
154
|
-
? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
|
|
155
|
-
: new Error(bootFinding(boot, structuralRunner));
|
|
156
|
-
}
|
|
157
|
-
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
158
137
|
const packets = await actual.getSuitePacketsBatch();
|
|
159
138
|
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
160
139
|
// A `fixable` outcome (no package manager available to install the runner) is an infrastructure
|
|
@@ -228,8 +207,43 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
228
207
|
else
|
|
229
208
|
blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
|
|
230
209
|
}
|
|
210
|
+
// Spec 32-6, moved down by spec 38. Before anything is written and before a
|
|
211
|
+
// single token is spent, find out whether the code this branch's guardrails
|
|
212
|
+
// will import actually loads.
|
|
213
|
+
//
|
|
214
|
+
// Here rather than at the top of the verb, because here the list of those
|
|
215
|
+
// files exists: it is resolved from the branches that were just assembled,
|
|
216
|
+
// out of this machine's own graph and route inventory. Nothing is lost by
|
|
217
|
+
// waiting — `getSuitePacketsBatch` is a GET that writes nothing and costs
|
|
218
|
+
// nothing, and the generator, which is where the money starts, does not run
|
|
219
|
+
// until `request.json` is written below.
|
|
220
|
+
//
|
|
221
|
+
// What is gained is that a failure here takes one branch and not the run. The
|
|
222
|
+
// behavioral peer has a runner of its own, touches none of these files, and
|
|
223
|
+
// used to die of a verdict that was never about it.
|
|
224
|
+
// Only when there is a branch to check. Ruby's helper ignores the file list
|
|
225
|
+
// and boots the application for itself, so asking with no structural branch in
|
|
226
|
+
// the run would start Rails to answer a question nobody put — and a `broken`
|
|
227
|
+
// answer would then report a branch this build never had.
|
|
228
|
+
const bootNotices = [];
|
|
229
|
+
const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
|
|
230
|
+
if (structuralIndex !== -1) {
|
|
231
|
+
const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
|
|
232
|
+
if (boot.status === 'broken') {
|
|
233
|
+
branches.splice(structuralIndex, 1);
|
|
234
|
+
bootNotices.push(bootStop(boot, structuralRunner));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Every reason a branch is not here, in one place. A run can lose its last
|
|
241
|
+
// branch to any of the three and the reader needs the one that applies to
|
|
242
|
+
// them — printing only the envelope reasons left a jest project reading an
|
|
243
|
+
// empty list under "no suite branch can be built".
|
|
231
244
|
if (branches.length === 0) {
|
|
232
|
-
|
|
245
|
+
const why = [...bootNotices, ...blockedNotices, ...fixableNotices].join('\n');
|
|
246
|
+
throw new Error(`No suite branch can be built this run:\n${why}\nNothing was written and nothing was uploaded.`);
|
|
233
247
|
}
|
|
234
248
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
235
249
|
// Spec 37-1. The assignment names entrypoints; the packets are the files
|
|
@@ -280,7 +294,6 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
280
294
|
'the request that was just replaced.\n');
|
|
281
295
|
}
|
|
282
296
|
actual.stdout.write(packetNotice(request.project_root, sourcePackets));
|
|
283
|
-
actual.stdout.write(workloadNotice(request.project_root));
|
|
284
297
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
285
298
|
`write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
|
|
286
299
|
`finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
|
|
@@ -308,6 +321,15 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
308
321
|
'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
|
|
309
322
|
'them, and this connector will not be able to run the branch either.\n');
|
|
310
323
|
}
|
|
324
|
+
// Same shape as the two notices below it, and the same rule: the branch that
|
|
325
|
+
// could not be prepared drops out, its peer is untouched, and the reason is
|
|
326
|
+
// printed rather than swallowed.
|
|
327
|
+
if (bootNotices.length > 0) {
|
|
328
|
+
actual.stdout.write('\nThe code-structure suite was left out of this run — the source files its guardrails would ' +
|
|
329
|
+
'import did not load. None of your own tests were opened; only the files the map named:\n' +
|
|
330
|
+
bootNotices.join('\n') +
|
|
331
|
+
'\n');
|
|
332
|
+
}
|
|
311
333
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
312
334
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
313
335
|
if (setupNotices.length > 0) {
|
|
@@ -366,22 +388,6 @@ function packetNotice(projectRoot, packets) {
|
|
|
366
388
|
` (${packets.notes.join('; ')}). ` +
|
|
367
389
|
`Each says why in ${where}/index.json.\n`);
|
|
368
390
|
}
|
|
369
|
-
// Spec 37-3, criterion 1. The size of the work, printed before the plan exists,
|
|
370
|
-
// because that is the only moment it can decide anything: the packets are built
|
|
371
|
-
// from the request's entrypoints, and the entrypoints are known before the
|
|
372
|
-
// workers are. A number that arrives after the plan is a number the plan was
|
|
373
|
-
// not made from.
|
|
374
|
-
function workloadNotice(projectRoot) {
|
|
375
|
-
const loads = branchWorkloads(projectRoot);
|
|
376
|
-
if (loads.length === 0)
|
|
377
|
-
return '';
|
|
378
|
-
return ('\nHow much each branch has to read, over the whole assignment and before you narrow it:\n' +
|
|
379
|
-
loads.map(workloadLine).join('') +
|
|
380
|
-
'This does not set how many workers a branch gets, and that is worth knowing before you plan: ' +
|
|
381
|
-
'on the bench of 2026-08-24 the branch with three times the source spent half the turns. What ' +
|
|
382
|
-
'sets the width is how many cases you intend to write, so it is decided by the plan and ' +
|
|
383
|
-
'checked by `accept-worker-plan`, which prints the band it accepted.\n');
|
|
384
|
-
}
|
|
385
391
|
// The runner's own rule for which step files it will load, in the words of the
|
|
386
392
|
// side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
|
|
387
393
|
// the behavioral branch; this is the copy the coordinator sees without opening a
|
|
@@ -400,62 +406,62 @@ function stepLoadingNotice(runner, loading) {
|
|
|
400
406
|
loading.requirements.join('\n - ') +
|
|
401
407
|
'\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
|
|
402
408
|
}
|
|
403
|
-
// What the boot check found, in the vibecoder's
|
|
404
|
-
// including the quiet ones: "we looked and it
|
|
405
|
-
// are both worth a line, and a check nobody
|
|
406
|
-
// trusts.
|
|
409
|
+
// What the boot check found when it found nothing wrong, in the vibecoder's
|
|
410
|
+
// terms. Printed on every such run, including the quiet ones: "we looked and it
|
|
411
|
+
// starts" and "we could not look" are both worth a line, and a check nobody
|
|
412
|
+
// hears about is a check nobody trusts.
|
|
407
413
|
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// different places.
|
|
414
|
+
// The third answer, `broken`, is the only one that changes what gets built, so
|
|
415
|
+
// it goes to `bootStop` and is printed where every other missing branch is
|
|
416
|
+
// explained.
|
|
412
417
|
function bootFinding(boot, runner) {
|
|
413
|
-
|
|
414
|
-
// outcome. Splitting them is how the stack caveat came to be missing from
|
|
415
|
-
// `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
|
|
416
|
-
// that in the same breath as the fix: on Rails the stack caveat reads
|
|
417
|
-
// "whatever stops one stops the other", which is an unscoped claim about a
|
|
418
|
-
// branch nobody asked — loudest exactly where the run stops for both.
|
|
419
|
-
// Empties are dropped rather than joined blindly, so a runner with no caveat
|
|
420
|
-
// of its own does not leave a blank line behind.
|
|
421
|
-
const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
|
|
422
|
-
.filter(Boolean)
|
|
423
|
-
.map((line) => `\n${line}`)
|
|
424
|
-
.join('');
|
|
418
|
+
const caveat = caveatFor(runner);
|
|
425
419
|
if (boot.status === 'ok') {
|
|
426
420
|
return `Checked that the suite can start: it does.${caveat}\n`;
|
|
427
421
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
422
|
+
// Not checked is not broken, and nothing downstream may treat it as such.
|
|
423
|
+
// Conflating the two would block honest projects — the whole reason this
|
|
424
|
+
// state is named for what happened rather than for what we know.
|
|
425
|
+
//
|
|
426
|
+
// `broken` cannot arrive here: it is the one answer that changes what gets
|
|
427
|
+
// built, so it goes to `bootStop` and is printed as the reason a branch is
|
|
428
|
+
// missing.
|
|
429
|
+
const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
|
|
430
|
+
return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
|
|
431
|
+
}
|
|
432
|
+
// Why the code-structure branch is not in this run. One indented block, the same
|
|
433
|
+
// shape as every other missing-branch reason, because that is now what this is:
|
|
434
|
+
// its peer carries on, `request.json` is written, and the vibecoder comes away
|
|
435
|
+
// with the guardrails that branch can still give rather than with nothing.
|
|
436
|
+
//
|
|
437
|
+
// The two causes keep their separate next steps. An un-run `pip install` is not
|
|
438
|
+
// somebody's bug and must not be worded as one; a module of theirs that raises
|
|
439
|
+
// on import is theirs to fix and pointing at an install would waste their time.
|
|
440
|
+
function bootStop(boot, runner) {
|
|
440
441
|
const next = boot.cause === 'defect_in_code'
|
|
441
|
-
? '
|
|
442
|
+
? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
|
|
442
443
|
: 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
|
|
443
444
|
'it never writes to your project. Something outside that file is still missing here. Run the ' +
|
|
444
445
|
'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
|
|
445
446
|
'then run `unitbob suite-prepare` again.';
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
447
|
+
// `boot.message` is the runner's own words, indented but never paraphrased:
|
|
448
|
+
// this is the line the vibecoder can paste into a search.
|
|
449
|
+
return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
|
|
450
|
+
}
|
|
451
|
+
// What this answer is worth, in two halves that always travel together: how much
|
|
452
|
+
// this stack's check can see, and the fact that it speaks for one branch only.
|
|
453
|
+
// Splitting them is how the stack caveat came to be missing from the outcome
|
|
454
|
+
// that costs a branch — found on the fifth implementation review, 2026-08-03 —
|
|
455
|
+
// so there is one builder and every caller goes through it. `indent` sets how
|
|
456
|
+
// the lines sit, and is the only thing a caller may vary.
|
|
457
|
+
//
|
|
458
|
+
// Empties are dropped rather than joined blindly, so a runner with no caveat of
|
|
459
|
+
// its own does not leave a blank line behind.
|
|
460
|
+
function caveatFor(runner, indent = '') {
|
|
461
|
+
return [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.map((line) => `\n${indent}${line}`)
|
|
464
|
+
.join('');
|
|
459
465
|
}
|
|
460
466
|
// Spec 32-6 says the boot rule is one rule for both branches; this check asks
|
|
461
467
|
// one of them. It is made against the *structural* runner, which is what
|
|
@@ -474,12 +480,7 @@ const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: i
|
|
|
474
480
|
'which has nothing of ours to load until its suite exists, so it was not asked.';
|
|
475
481
|
const NOT_CHECKED_REASON = {
|
|
476
482
|
no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
|
|
477
|
-
// Distinct from `no_runner` on purpose
|
|
478
|
-
// it is only too old to be asked this particular question, and "no runner
|
|
479
|
-
// available" would send someone to fix a thing that is not broken.
|
|
480
|
-
runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
|
|
481
|
-
'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
|
|
482
|
-
// Distinct for the same reason, one step further along: the runner is there
|
|
483
|
+
// Distinct from `no_runner` on purpose: the runner is there
|
|
483
484
|
// and current, it was reached, and it declined to answer — pytest exiting on
|
|
484
485
|
// a usage or internal error of its own. That says nothing about the project,
|
|
485
486
|
// and "no runner available" would again send someone after the wrong thing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.3 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
|
package/dist/files/fanOut.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { readPacketIndex } from "./packets.js";
|
|
2
|
-
// Spec 37-3, criterion 1. How wide a branch's fan-out should be.
|
|
3
|
-
//
|
|
4
|
-
// The rule this replaces said there was no ceiling at all: "an agent re-reads
|
|
5
|
-
// its context every turn, so splitting the work never costs more than keeping it
|
|
6
|
-
// together." Half right, and the wrong half was load-bearing. An agent's cost is
|
|
7
|
-
// the sum of its context over its turns, so splitting pulls in two directions:
|
|
8
|
-
//
|
|
9
|
-
// - the opening context is bought once per worker and re-read every turn, so
|
|
10
|
-
// it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
|
|
11
|
-
// the same to within ±370 across fifteen workers.
|
|
12
|
-
// - each worker's conversation is shorter, and a conversation's cost grows
|
|
13
|
-
// with the square of its length, so this falls with the width.
|
|
14
|
-
//
|
|
15
|
-
// There is therefore a minimum, and it is neither end. Measured on that bench,
|
|
16
|
-
// against what the fifteen workers actually cost:
|
|
17
|
-
//
|
|
18
|
-
// workers 1 2 3 5 8 15 20
|
|
19
|
-
// input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
|
|
20
|
-
//
|
|
21
|
-
// Fifteen was 28% over the cheapest width. One worker — which is what "the work
|
|
22
|
-
// fits in one context" would have said, and what the first draft of this rule
|
|
23
|
-
// enforced — is 85% over it, and would have run a 216-turn worker into a
|
|
24
|
-
// 150-turn fuse. The floor is as expensive a mistake as the ceiling.
|
|
25
|
-
//
|
|
26
|
-
// See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
|
|
27
|
-
// What the optimum is not a function of. Productive turns per 1,000 tokens of
|
|
28
|
-
// source were 7.8 on the behavioral branch and 1.4 on the structural one of the
|
|
29
|
-
// same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
|
|
30
|
-
// there is to read, which turns out not to be what a worker spends its turns on.
|
|
31
|
-
// They stay here for the printout and for the record in `fan_out`; they do not
|
|
32
|
-
// set the width.
|
|
33
|
-
const BYTES_PER_TOKEN = 4;
|
|
34
|
-
const WRITTEN_PER_READ = 3;
|
|
35
|
-
// What it is a function of. A planned case is one intent the worker has to turn
|
|
36
|
-
// into a written example or Scenario, and its cost in turns is a property of the
|
|
37
|
-
// branch, not of the project: a Gherkin Scenario needs the World, a session, a
|
|
38
|
-
// fixture and an assertion; a structural example calls a method.
|
|
39
|
-
//
|
|
40
|
-
// Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
|
|
41
|
-
// structural cases over 65.
|
|
42
|
-
const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
|
|
43
|
-
// The optimum width is the branch's productive turns over this. It comes out of
|
|
44
|
-
// setting the derivative of the cost above to zero, which gives
|
|
45
|
-
// `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
|
|
46
|
-
// that run and 41.7 on the structural one, near enough to each other that one
|
|
47
|
-
// number carries both and the flat bottom of the curve absorbs the difference.
|
|
48
|
-
const TURNS_PER_WORKER = 36;
|
|
49
|
-
// What a worker spends before it writes anything — reading its packets, its plan
|
|
50
|
-
// item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
|
|
51
|
-
// behavioral workers, 202 over seven structural ones. It is per worker and does
|
|
52
|
-
// not divide, which is half of why width costs; it is added back here so that
|
|
53
|
-
// the turns this prints are the whole conversation, the thing that meets the
|
|
54
|
-
// 150-turn fuse.
|
|
55
|
-
const WARMUP_TURNS = { behavioral: 22, structural: 28 };
|
|
56
|
-
// Every case ends up at the same handful of widths, so the rule has to be a band
|
|
57
|
-
// rather than a number: anywhere from three to eight workers cost within 10% of
|
|
58
|
-
// the cheapest on the measured run. What the band excludes is what actually
|
|
59
|
-
// costs — fifteen at one end, one at the other.
|
|
60
|
-
const NARROWEST = 0.5;
|
|
61
|
-
const WIDEST = 1.5;
|
|
62
|
-
// How wide a branch should be, from the cases its plan intends to write.
|
|
63
|
-
// Returns nothing for a branch this connector has no measured cost for: a rule
|
|
64
|
-
// with no measurement behind it must not refuse anybody's plan.
|
|
65
|
-
export function branchWidth(branch, plannedCases) {
|
|
66
|
-
const perCase = TURNS_PER_CASE[branch];
|
|
67
|
-
if (perCase === undefined || plannedCases <= 0)
|
|
68
|
-
return undefined;
|
|
69
|
-
const turns = plannedCases * perCase;
|
|
70
|
-
const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
|
|
71
|
-
return {
|
|
72
|
-
branch,
|
|
73
|
-
planned_cases: plannedCases,
|
|
74
|
-
turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
|
|
75
|
-
workers,
|
|
76
|
-
fewest: Math.max(1, Math.round(workers * NARROWEST)),
|
|
77
|
-
most: Math.max(1, Math.ceil(workers * WIDEST)),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
// What each branch's source weighs. Kept because it is the honest answer to "how
|
|
81
|
-
// much is there", printed before the plan exists and recorded in `fan_out` — but
|
|
82
|
-
// it is not what decides the width. See TURNS_PER_CASE above.
|
|
83
|
-
//
|
|
84
|
-
// `taken` narrows the count to the ids a plan actually took, which matters since
|
|
85
|
-
// criterion 2 let the structural branch be narrowed too: the packets are built
|
|
86
|
-
// from the whole assignment, before anybody chose a scope.
|
|
87
|
-
export function branchWorkloads(projectRoot, taken) {
|
|
88
|
-
const index = readPacketIndex(projectRoot);
|
|
89
|
-
if (!index || index.targets.length === 0)
|
|
90
|
-
return [];
|
|
91
|
-
const measured = new Map();
|
|
92
|
-
const unmeasured = new Map();
|
|
93
|
-
for (const target of index.targets) {
|
|
94
|
-
const ids = taken?.get(target.branch);
|
|
95
|
-
if (taken && !ids?.has(target.id))
|
|
96
|
-
continue;
|
|
97
|
-
const size = sizeOf(target);
|
|
98
|
-
// A file too large to copy still has a path and a size, and it is the
|
|
99
|
-
// heaviest reading on the branch — counting it as nothing would let the
|
|
100
|
-
// biggest sources look like the smallest.
|
|
101
|
-
const file = target.packet ?? target.source_file;
|
|
102
|
-
if (file !== undefined && size !== undefined) {
|
|
103
|
-
const files = measured.get(target.branch) ?? new Map();
|
|
104
|
-
files.set(file, size);
|
|
105
|
-
measured.set(target.branch, files);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
|
|
112
|
-
return [...branches].sort().flatMap((branch) => {
|
|
113
|
-
const files = measured.get(branch);
|
|
114
|
-
if (!files || files.size === 0)
|
|
115
|
-
return [];
|
|
116
|
-
const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
|
|
117
|
-
const missing = unmeasured.get(branch) ?? 0;
|
|
118
|
-
const withMissing = bytes + Math.round((bytes / files.size) * missing);
|
|
119
|
-
const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
|
|
120
|
-
return [{
|
|
121
|
-
branch, files: files.size, bytes, unmeasured: missing,
|
|
122
|
-
read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
|
|
123
|
-
}];
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
// The index is a file on the vibecoder's disk, so a size that is not a real byte
|
|
127
|
-
// count is treated as a size we do not have rather than as zero. Zero would
|
|
128
|
-
// quietly shrink the branch's average.
|
|
129
|
-
function sizeOf(target) {
|
|
130
|
-
const { bytes } = target;
|
|
131
|
-
return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
132
|
-
}
|
|
133
|
-
export function widthLine(width) {
|
|
134
|
-
return (` ${width.branch} — ${width.planned_cases} planned ` +
|
|
135
|
-
`${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
|
|
136
|
-
`${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
|
|
137
|
-
`(${width.fewest}–${width.most} accepted).\n`);
|
|
138
|
-
}
|
|
139
|
-
export function workloadLine(load) {
|
|
140
|
-
const missing = load.unmeasured === 0
|
|
141
|
-
? ''
|
|
142
|
-
: ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
|
|
143
|
-
'priced at what the others average';
|
|
144
|
-
return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
|
|
145
|
-
`${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
|
|
146
|
-
`${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
|
|
147
|
-
`${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
|
|
148
|
-
}
|