unitbob 0.7.2 → 0.7.4
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/runner/bootcheck.js +162 -71
- package/dist/runner/precheck.js +10 -3
- package/dist/runner/vitest.js +116 -7
- package/dist/verbs/run.js +5 -2
- package/dist/verbs/runLocal.js +6 -1
- package/dist/verbs/suitePrepare.js +176 -79
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
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/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,62 @@ 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');
|
|
19
|
+
// The structural branch's one shared setup file (spec 39). The coordinator
|
|
20
|
+
// writes it before fan-out and owns it afterwards: it holds whatever has to
|
|
21
|
+
// happen before the first import of any file of the branch — environment
|
|
22
|
+
// variables, a TypeScript loader registration, at the very last resort an entry
|
|
23
|
+
// through the project's root module.
|
|
24
|
+
//
|
|
25
|
+
// A fixed name rather than a new protocol field, because the file has to survive
|
|
26
|
+
// the round trip: `materializeGuardrails` wipes this directory and lays the
|
|
27
|
+
// artifact out again, so the file must travel as an ordinary `support_files`
|
|
28
|
+
// entry — and every such entry is otherwise handed to the runner as a test path.
|
|
29
|
+
// One constant here, read by the config builder, by both collectors of test
|
|
30
|
+
// paths, and by `suite-prepare`, is the whole of the agreement. A field would
|
|
31
|
+
// have had to be agreed with the server and both of its version models.
|
|
32
|
+
//
|
|
33
|
+
// One name, one extension. A second one buys nothing: vitest puts every setup
|
|
34
|
+
// file through vite, so `require` is undefined in a `.js` setup file just as it
|
|
35
|
+
// is in a `.ts` one.
|
|
36
|
+
export const STRUCTURAL_SETUP_FILE = '.unitbob/structural/_setup.ts';
|
|
37
|
+
// The project-relative path of that file, or `undefined` when it has not been
|
|
38
|
+
// written yet. Two callers, one question: the config builder asks it to decide
|
|
39
|
+
// whether to name the file in `setupFiles`, and `suite-prepare` asks it to
|
|
40
|
+
// decide whether the boot probe is a scout or a sentry (spec 39, criterion 4).
|
|
41
|
+
// Deliberately the same observable fact for both, rather than a flag — 32-6
|
|
42
|
+
// forbids a mode, and a mode would let the two sides disagree about which run
|
|
43
|
+
// they are in.
|
|
44
|
+
export function setupFileOf(projectRoot) {
|
|
45
|
+
return existsSync(join(projectRoot, STRUCTURAL_SETUP_FILE)) ? STRUCTURAL_SETUP_FILE : undefined;
|
|
46
|
+
}
|
|
47
|
+
// Whether anything of ours can run before this stack's branch imports its first
|
|
48
|
+
// module. Only vitest, because `setupFiles` is a vitest notion and the builder
|
|
49
|
+
// below is the single place that inserts our file. Kept here, beside the
|
|
50
|
+
// insertion it describes, rather than in the verb that asks: the verb would be
|
|
51
|
+
// stating a fact about a module it does not own, and the two could then drift.
|
|
52
|
+
export function canPrepareBeforeImports(runner) {
|
|
53
|
+
return runner === 'vitest';
|
|
54
|
+
}
|
|
55
|
+
// The files of an artifact that a runner may be pointed at, which is all of them
|
|
56
|
+
// except the shared setup file. It travels as an ordinary `support_files` entry
|
|
57
|
+
// so that materialization writes it back rather than wiping it, and it holds no
|
|
58
|
+
// cases at all: a runner given it as a test path opens it, finds nothing, and
|
|
59
|
+
// reports that nothing as a suite.
|
|
60
|
+
//
|
|
61
|
+
// One filter for both collectors — the check flow's and run-local's — because
|
|
62
|
+
// the two must never disagree about which file this is. A leading `./` is
|
|
63
|
+
// tolerated: the answer that names the file is hand-written, and two characters
|
|
64
|
+
// must not be what decides whether the branch's preparation is executed or
|
|
65
|
+
// collected.
|
|
66
|
+
export function testPathsOf(paths) {
|
|
67
|
+
return paths.filter((path) => path.replace(/^\.\//, '') !== STRUCTURAL_SETUP_FILE);
|
|
68
|
+
}
|
|
13
69
|
// The project configs we inherit from, most specific first. Vitest reads a
|
|
14
70
|
// project's own config even when we pass `--config`, so we must merge ours with
|
|
15
71
|
// it rather than replace it (plugins, path aliases and resolve settings the
|
|
@@ -79,12 +135,26 @@ export async function runVitestSuite(projectRoot, suitePaths) {
|
|
|
79
135
|
// the branch's files have to be in `include` or nothing is collected, and the
|
|
80
136
|
// names a worker gives its slice are not something to bet a whole run on.
|
|
81
137
|
function writeMergedConfig(projectRoot, suitePaths) {
|
|
82
|
-
const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
83
138
|
const path = join(projectRoot, VITEST_CONFIG_FILE);
|
|
84
139
|
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
-
writeFileSync(path, configSource(
|
|
140
|
+
writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run', setupFileOf(projectRoot)));
|
|
86
141
|
return ['--config', VITEST_CONFIG_FILE];
|
|
87
142
|
}
|
|
143
|
+
// The same config, shaped for the boot check's probe (spec 38). Returned as text
|
|
144
|
+
// rather than written, because the boot check owns the lifetime of every file it
|
|
145
|
+
// puts in the project: it writes them together and removes them together, and a
|
|
146
|
+
// writer here would take half of that away from the one place that can see it.
|
|
147
|
+
export function vitestBootConfigSource(projectRoot, probePath) {
|
|
148
|
+
// The same preparation the run will get, which is the point of asking twice
|
|
149
|
+
// (spec 39, criterion 4). On the first `suite-prepare` there is no setup file
|
|
150
|
+
// and the probe answers "these files do not load on their own"; on the second,
|
|
151
|
+
// after the coordinator wrote one, it answers the question the run will
|
|
152
|
+
// actually put — and only then is a red answer worth a branch.
|
|
153
|
+
return configSource(projectConfigOf(projectRoot), [probePath], 'boot', setupFileOf(projectRoot));
|
|
154
|
+
}
|
|
155
|
+
function projectConfigOf(projectRoot) {
|
|
156
|
+
return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
157
|
+
}
|
|
88
158
|
// The .unitbob/ config sits one level below the project root, so the project
|
|
89
159
|
// config is a `../` import. A function-form config is resolved first, and
|
|
90
160
|
// everything the project set — plugins, aliases, setup files, environment — is
|
|
@@ -101,21 +171,60 @@ function writeMergedConfig(projectRoot, suitePaths) {
|
|
|
101
171
|
// ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
|
|
102
172
|
// projects the sidecar exists for. A spread does the same job with no import,
|
|
103
173
|
// and `defineConfig` is a typing helper that buys a generated file nothing.
|
|
104
|
-
|
|
105
|
-
|
|
174
|
+
// `mode` is the boot check's two differences from the run, and they go together:
|
|
175
|
+
// globals on, and a workspace refused. See `vitestBootConfigSource`.
|
|
176
|
+
//
|
|
177
|
+
// `setupFiles` is the one field that is added to rather than replaced (spec 39,
|
|
178
|
+
// criterion 2). The project's own entry is what raises its test database and
|
|
179
|
+
// reads its environment — epic-stack's `tests/setup/setup-test-env.ts` is why
|
|
180
|
+
// its 50 structural checks have a database to read — and replacing it would
|
|
181
|
+
// take that away from the only structural suite on the bench that works. Ours
|
|
182
|
+
// goes first: it exists to set things up before the first import of anything,
|
|
183
|
+
// and the project's own setup file opens with imports of the application.
|
|
184
|
+
function configSource(projectConfig, suitePaths, mode, setupFile) {
|
|
185
|
+
// After the project's own `test`, never before it: ours has to win.
|
|
186
|
+
const settings = `${mode === 'boot' ? 'globals: true, ' : ''}${setupFile ? 'setupFiles, ' : ''}include: ${JSON.stringify(suitePaths)}`;
|
|
106
187
|
const header = '// Written by the unitbob connector before each vitest run — do not edit.';
|
|
107
188
|
if (!projectConfig) {
|
|
189
|
+
// Nothing to inherit, so nothing to concatenate — but the shared file is
|
|
190
|
+
// still the branch's, and a project with no config of its own is exactly
|
|
191
|
+
// the kind that needs it.
|
|
192
|
+
const alone = setupFile ? `const setupFiles = ${JSON.stringify([setupFile])};\n` : '';
|
|
108
193
|
return `${header}
|
|
109
|
-
export default { test: { ${
|
|
194
|
+
${alone}export default { test: { ${settings} } };
|
|
110
195
|
`;
|
|
111
196
|
}
|
|
197
|
+
// Globals, because the probe lives in `.unitbob/structural/`: an
|
|
198
|
+
// `import { test } from 'vitest'` there resolves by walking up from that
|
|
199
|
+
// directory, which never reaches `.unitbob/runners/node_modules` — where the
|
|
200
|
+
// vitest we installed for a project that had none is kept. We write this file,
|
|
201
|
+
// so we turn the globals on and the probe needs no import at all.
|
|
202
|
+
//
|
|
203
|
+
// A workspace is dropped for a harder reason. With `test.projects` (or the
|
|
204
|
+
// older `test.workspace`) set, the root `include` stops deciding anything and
|
|
205
|
+
// vitest runs each sub-project's own test files — which would open the
|
|
206
|
+
// project's tests again through the back door, the one thing spec 38 removes.
|
|
207
|
+
const narrow = mode === 'boot'
|
|
208
|
+
? `
|
|
209
|
+
const { projects, workspace, ...test } = base.test ?? {};
|
|
210
|
+
`
|
|
211
|
+
: `
|
|
212
|
+
const test = base.test ?? {};
|
|
213
|
+
`;
|
|
214
|
+
// `[].concat(x)` on purpose: vitest accepts `setupFiles` as an array or as a
|
|
215
|
+
// bare string, and most projects set neither. All three forms arrive here and
|
|
216
|
+
// all three have to come out an array, because a config that throws while it
|
|
217
|
+
// is being read takes the whole run with it.
|
|
218
|
+
const merge = setupFile
|
|
219
|
+
? `const setupFiles = ${JSON.stringify([setupFile])}.concat(test.setupFiles ?? []);\n`
|
|
220
|
+
: '';
|
|
112
221
|
return `${header}
|
|
113
222
|
import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
|
|
114
223
|
|
|
115
224
|
const base = typeof projectConfig === 'function'
|
|
116
225
|
? await projectConfig({ command: 'serve', mode: 'test' })
|
|
117
226
|
: projectConfig;
|
|
118
|
-
|
|
119
|
-
export default { ...base, test: { ...
|
|
227
|
+
${narrow}${merge}
|
|
228
|
+
export default { ...base, test: { ...test, ${settings} } };
|
|
120
229
|
`;
|
|
121
230
|
}
|
package/dist/verbs/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import { placeProblem } from "../runner/place.js";
|
|
|
4
4
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
5
5
|
import { validateStack } from "../runner/precheck.js";
|
|
6
6
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
7
|
-
import { runVitestSuite } from "../runner/vitest.js";
|
|
7
|
+
import { runVitestSuite, testPathsOf } from "../runner/vitest.js";
|
|
8
8
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
9
9
|
import { runBddSuite } from "../runner/bdd.js";
|
|
10
10
|
import { enterUrl } from "../links.js";
|
|
@@ -147,8 +147,11 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
|
|
|
147
147
|
// Every file of the branch, in the order the envelope carries them. A structural
|
|
148
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
|
+
//
|
|
151
|
+
// Every file except the branch's shared setup file, which `testPathsOf` drops
|
|
152
|
+
// (spec 39): it is named in `setupFiles` instead of being collected from.
|
|
150
153
|
function artifactPaths(file) {
|
|
151
|
-
return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
|
|
154
|
+
return testPathsOf([file.path, ...(file.support_files ?? []).map((entry) => entry.path)]);
|
|
152
155
|
}
|
|
153
156
|
function suiteError(suiteDigest, message) {
|
|
154
157
|
return {
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -5,6 +5,7 @@ import { placeAdvice } from "../runner/placeAdvice.js";
|
|
|
5
5
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
6
6
|
import { validateStack } from "../runner/precheck.js";
|
|
7
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { testPathsOf } from "../runner/vitest.js";
|
|
8
9
|
import { runStructuralByRunner } from "./run.js";
|
|
9
10
|
const OUTPUT_TAIL_CHARS = 4000;
|
|
10
11
|
export async function runLocal(config, args = [], deps) {
|
|
@@ -259,7 +260,11 @@ function artifactPathsOf(output) {
|
|
|
259
260
|
const rest = support
|
|
260
261
|
.map((entry) => entry?.path)
|
|
261
262
|
.filter((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
262
|
-
|
|
263
|
+
// Everything but the branch's shared setup file (spec 39): it is a support
|
|
264
|
+
// file so that it survives materialization, and it is named in `setupFiles`
|
|
265
|
+
// rather than handed over as a path to collect tests from. Literally the same
|
|
266
|
+
// filter the check flow uses, because both sides have to mean the one file.
|
|
267
|
+
return testPathsOf([path, ...rest]);
|
|
263
268
|
}
|
|
264
269
|
function branchRoot(config, suiteKind) {
|
|
265
270
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
@@ -1,7 +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 { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
|
|
4
|
+
import { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
|
|
5
5
|
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
6
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
7
7
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
@@ -11,6 +11,7 @@ import { placeProblem } from "../runner/place.js";
|
|
|
11
11
|
import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
|
|
12
12
|
import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
|
|
13
13
|
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
14
|
+
import { canPrepareBeforeImports, setupFileOf, STRUCTURAL_SETUP_FILE } from "../runner/vitest.js";
|
|
14
15
|
import { probeBehavioralWorld } from "../runner/worldProbe.js";
|
|
15
16
|
import { Wire } from "../wire.js";
|
|
16
17
|
// The complete envelope for one branch, or null when this machine cannot
|
|
@@ -67,7 +68,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
67
68
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
68
69
|
precheck: anyStackPrecheck,
|
|
69
70
|
confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
|
|
70
|
-
bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
|
|
71
|
+
bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
|
|
71
72
|
ensureRunner: deps?.ensureRunner ?? ensureRunner,
|
|
72
73
|
ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
|
|
73
74
|
worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
|
|
@@ -130,30 +131,10 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
130
131
|
throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
// Spec 32-6. Before anything is fetched or written, find out whether the suite
|
|
134
|
-
// would get off the ground at all. It runs here, after the boot helper exists
|
|
135
|
-
// and before the network, so a project whose suite cannot start costs one
|
|
136
|
-
// command instead of a full generation.
|
|
137
|
-
//
|
|
138
|
-
// There is no `--on-broken-boot` flag and no mode. The decision is not a
|
|
139
|
-
// policy we could reasonably let a user set — it follows from the fact: we
|
|
140
|
-
// tried to load the thing the suite starts with, it did not load, therefore
|
|
141
|
-
// not one test would reach its first assertion. Debugging generation against a
|
|
142
|
-
// knowingly dead project is our problem, not the vibecoder's.
|
|
143
134
|
// The stack the precheck just identified, rather than a second detection of
|
|
144
135
|
// the same thing: on Python that would shell out to pytest all over again.
|
|
136
|
+
// Used further down, where the boot check now runs.
|
|
145
137
|
const structuralRunner = check.runner ?? null;
|
|
146
|
-
const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
|
|
147
|
-
if (boot.status === 'broken') {
|
|
148
|
-
// "Your environment is not ready" is the one of the two that a container can
|
|
149
|
-
// answer — the toolchain is missing here and may be sitting in one. A defect
|
|
150
|
-
// found in the code is a defect wherever it runs, and offering a container
|
|
151
|
-
// for it would be the noise this spec is trying to remove.
|
|
152
|
-
throw boot.cause === 'environment_not_ready'
|
|
153
|
-
? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
|
|
154
|
-
: new Error(bootFinding(boot, structuralRunner));
|
|
155
|
-
}
|
|
156
|
-
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
157
138
|
const packets = await actual.getSuitePacketsBatch();
|
|
158
139
|
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
159
140
|
// A `fixable` outcome (no package manager available to install the runner) is an infrastructure
|
|
@@ -227,8 +208,82 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
227
208
|
else
|
|
228
209
|
blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
|
|
229
210
|
}
|
|
211
|
+
// Spec 32-6, moved down by spec 38. Before anything is written and before a
|
|
212
|
+
// single token is spent, find out whether the code this branch's guardrails
|
|
213
|
+
// will import actually loads.
|
|
214
|
+
//
|
|
215
|
+
// Here rather than at the top of the verb, because here the list of those
|
|
216
|
+
// files exists: it is resolved from the branches that were just assembled,
|
|
217
|
+
// out of this machine's own graph and route inventory. Nothing is lost by
|
|
218
|
+
// waiting — `getSuitePacketsBatch` is a GET that writes nothing and costs
|
|
219
|
+
// nothing, and the generator, which is where the money starts, does not run
|
|
220
|
+
// until `request.json` is written below.
|
|
221
|
+
//
|
|
222
|
+
// What is gained is that a failure here takes one branch and not the run. The
|
|
223
|
+
// behavioral peer has a runner of its own, touches none of these files, and
|
|
224
|
+
// used to die of a verdict that was never about it.
|
|
225
|
+
// Only when there is a branch to check. Ruby's helper ignores the file list
|
|
226
|
+
// and boots the application for itself, so asking with no structural branch in
|
|
227
|
+
// the run would start Rails to answer a question nobody put — and a `broken`
|
|
228
|
+
// answer would then report a branch this build never had.
|
|
229
|
+
//
|
|
230
|
+
// Spec 39 kept the sentry and moved the sentence. The probe asks its question
|
|
231
|
+
// twice: once before anything has been prepared for these files, and once
|
|
232
|
+
// after the coordinator has written the branch's shared setup file. Only the
|
|
233
|
+
// second answer costs a branch, because only the second one asks what the run
|
|
234
|
+
// will ask. Before the setup file exists the probe's question — "do these
|
|
235
|
+
// files load with nothing in front of them" — is stricter than the condition
|
|
236
|
+
// it stands in for, and `docs/adr/0001` forbids exactly that: the condition
|
|
237
|
+
// tested must equal the condition that makes a run impossible, never exceed
|
|
238
|
+
// it. Two bench projects were refused on that excess while their behavioral
|
|
239
|
+
// peers built and stayed green.
|
|
240
|
+
//
|
|
241
|
+
// The switch is the file on disk and nothing else. No flag and no mode: 32-6
|
|
242
|
+
// forbade them, and the connector has to ask this same question anyway to
|
|
243
|
+
// decide whether to name the file in `setupFiles`, so a second signal could
|
|
244
|
+
// only ever disagree with the first.
|
|
245
|
+
//
|
|
246
|
+
// And only on the stack where a preparation can actually be put in front of
|
|
247
|
+
// the imports — `canPrepareBeforeImports`, which is vitest and only vitest. On
|
|
248
|
+
// rspec and pytest nothing of ours runs before the branch's imports, so there
|
|
249
|
+
// the probe's question already *is* the run's question: no excess to correct,
|
|
250
|
+
// and a red answer costs the branch on the first run exactly as it has since
|
|
251
|
+
// 32-6. Waiting for a file those stacks will never have would have quietly
|
|
252
|
+
// reopened the funnel that spec closed.
|
|
253
|
+
//
|
|
254
|
+
// One thing this sign cannot see: a setup file left over from an earlier
|
|
255
|
+
// build. It comes back with the artifact and is materialized like any other
|
|
256
|
+
// file of the branch, so on a re-generation the probe sentences on its first
|
|
257
|
+
// ask instead of scouting. That is the honest reading — a preparation does
|
|
258
|
+
// exist and the probe went through it — and the way out is the same one the
|
|
259
|
+
// verdict already names: fix that file, run `suite-prepare` again. Telling the
|
|
260
|
+
// two apart would need a memory of which build wrote it, which is a mode by
|
|
261
|
+
// another name, and 32-6 forbade those.
|
|
262
|
+
const bootNotices = [];
|
|
263
|
+
const bootAdvisories = [];
|
|
264
|
+
const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
|
|
265
|
+
if (structuralIndex !== -1) {
|
|
266
|
+
const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
|
|
267
|
+
if (boot.status === 'broken') {
|
|
268
|
+
if (!canPrepareBeforeImports(structuralRunner) || setupFileOf(config.projectRoot)) {
|
|
269
|
+
branches.splice(structuralIndex, 1);
|
|
270
|
+
bootNotices.push(bootStop(boot, structuralRunner));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
bootAdvisories.push(bootAdvisory(boot, structuralRunner));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Every reason a branch is not here, in one place. A run can lose its last
|
|
281
|
+
// branch to any of the three and the reader needs the one that applies to
|
|
282
|
+
// them — printing only the envelope reasons left a jest project reading an
|
|
283
|
+
// empty list under "no suite branch can be built".
|
|
230
284
|
if (branches.length === 0) {
|
|
231
|
-
|
|
285
|
+
const why = [...bootNotices, ...blockedNotices, ...fixableNotices].join('\n');
|
|
286
|
+
throw new Error(`No suite branch can be built this run:\n${why}\nNothing was written and nothing was uploaded.`);
|
|
232
287
|
}
|
|
233
288
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
234
289
|
// Spec 37-1. The assignment names entrypoints; the packets are the files
|
|
@@ -306,6 +361,27 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
306
361
|
'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
|
|
307
362
|
'them, and this connector will not be able to run the branch either.\n');
|
|
308
363
|
}
|
|
364
|
+
// Same shape as the two notices below it, and the same rule: the branch that
|
|
365
|
+
// could not be prepared drops out, its peer is untouched, and the reason is
|
|
366
|
+
// printed rather than swallowed.
|
|
367
|
+
if (bootNotices.length > 0) {
|
|
368
|
+
actual.stdout.write('\nThe code-structure suite was left out of this run — the source files its guardrails would ' +
|
|
369
|
+
'import did not load. None of your own tests were opened; only the files the map named:\n' +
|
|
370
|
+
bootNotices.join('\n') +
|
|
371
|
+
'\n');
|
|
372
|
+
}
|
|
373
|
+
// The other half of the probe's answer (spec 39), and it needs a heading of
|
|
374
|
+
// its own: "was left out of this run" is true only of the verdict, and this
|
|
375
|
+
// branch was not left out of anything. It is being built, and what follows is
|
|
376
|
+
// the opening fact for whoever writes its preparation.
|
|
377
|
+
if (bootAdvisories.length > 0) {
|
|
378
|
+
actual.stdout.write('\nThe code-structure suite is still being built, and here is what its source files did on their own: ' +
|
|
379
|
+
`they did not load. Nothing has been put in front of them yet — that is what ${STRUCTURAL_SETUP_FILE} ` +
|
|
380
|
+
'is for, and writing it comes before the fan-out. None of your own tests were opened; only the files ' +
|
|
381
|
+
'the map named:\n' +
|
|
382
|
+
bootAdvisories.join('\n') +
|
|
383
|
+
'\n');
|
|
384
|
+
}
|
|
309
385
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
310
386
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
311
387
|
if (setupNotices.length > 0) {
|
|
@@ -382,62 +458,88 @@ function stepLoadingNotice(runner, loading) {
|
|
|
382
458
|
loading.requirements.join('\n - ') +
|
|
383
459
|
'\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
|
|
384
460
|
}
|
|
385
|
-
// What the boot check found, in the vibecoder's
|
|
386
|
-
// including the quiet ones: "we looked and it
|
|
387
|
-
// are both worth a line, and a check nobody
|
|
388
|
-
// trusts.
|
|
461
|
+
// What the boot check found when it found nothing wrong, in the vibecoder's
|
|
462
|
+
// terms. Printed on every such run, including the quiet ones: "we looked and it
|
|
463
|
+
// starts" and "we could not look" are both worth a line, and a check nobody
|
|
464
|
+
// hears about is a check nobody trusts.
|
|
389
465
|
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// different places.
|
|
466
|
+
// The third answer, `broken`, is the only one that changes what gets built, so
|
|
467
|
+
// it goes to `bootStop` and is printed where every other missing branch is
|
|
468
|
+
// explained.
|
|
394
469
|
function bootFinding(boot, runner) {
|
|
395
|
-
|
|
396
|
-
// outcome. Splitting them is how the stack caveat came to be missing from
|
|
397
|
-
// `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
|
|
398
|
-
// that in the same breath as the fix: on Rails the stack caveat reads
|
|
399
|
-
// "whatever stops one stops the other", which is an unscoped claim about a
|
|
400
|
-
// branch nobody asked — loudest exactly where the run stops for both.
|
|
401
|
-
// Empties are dropped rather than joined blindly, so a runner with no caveat
|
|
402
|
-
// of its own does not leave a blank line behind.
|
|
403
|
-
const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
|
|
404
|
-
.filter(Boolean)
|
|
405
|
-
.map((line) => `\n${line}`)
|
|
406
|
-
.join('');
|
|
470
|
+
const caveat = caveatFor(runner);
|
|
407
471
|
if (boot.status === 'ok') {
|
|
408
472
|
return `Checked that the suite can start: it does.${caveat}\n`;
|
|
409
473
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
474
|
+
// Not checked is not broken, and nothing downstream may treat it as such.
|
|
475
|
+
// Conflating the two would block honest projects — the whole reason this
|
|
476
|
+
// state is named for what happened rather than for what we know.
|
|
477
|
+
//
|
|
478
|
+
// `broken` cannot arrive here: it is the one answer that changes what gets
|
|
479
|
+
// built, so it goes to `bootStop` and is printed as the reason a branch is
|
|
480
|
+
// missing.
|
|
481
|
+
const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
|
|
482
|
+
return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
|
|
483
|
+
}
|
|
484
|
+
// Why the code-structure branch is not in this run. One indented block, the same
|
|
485
|
+
// shape as every other missing-branch reason, because that is now what this is:
|
|
486
|
+
// its peer carries on, `request.json` is written, and the vibecoder comes away
|
|
487
|
+
// with the guardrails that branch can still give rather than with nothing.
|
|
488
|
+
//
|
|
489
|
+
// Reached only once the branch's shared setup file exists (spec 39). By then the
|
|
490
|
+
// probe has been asked through that preparation, so a red answer is the run's
|
|
491
|
+
// own answer and taking the branch is honest.
|
|
492
|
+
function bootStop(boot, runner) {
|
|
493
|
+
return bootReport(boot, runner, true);
|
|
494
|
+
}
|
|
495
|
+
// The same red answer, read before anything was prepared for these files (spec
|
|
496
|
+
// 39). Same facts, same words from the runner, same caveat — what differs is
|
|
497
|
+
// the step that follows, and that is the whole difference between a scout and a
|
|
498
|
+
// sentry. Nothing here is worded as a verdict on somebody's code, because on
|
|
499
|
+
// this run it is not one: the files were asked to load with nothing in front of
|
|
500
|
+
// them, and putting something in front of them is Unitbob's own work.
|
|
501
|
+
function bootAdvisory(boot, runner) {
|
|
502
|
+
return bootReport(boot, runner, false);
|
|
503
|
+
}
|
|
504
|
+
// The two causes keep their separate next steps. An un-run `pip install` is not
|
|
505
|
+
// somebody's bug and must not be worded as one; a module of theirs that raises
|
|
506
|
+
// on import, asked with the preparation already in place, is theirs to fix and
|
|
507
|
+
// pointing at an install would waste their time.
|
|
508
|
+
//
|
|
509
|
+
// The environment cause keeps the same next step in both reports: a missing gem
|
|
510
|
+
// is not something a setup file can prepare its way around, and sending someone
|
|
511
|
+
// to write one would waste the round it costs.
|
|
512
|
+
function bootReport(boot, runner, prepared) {
|
|
513
|
+
const next = boot.cause !== 'defect_in_code'
|
|
514
|
+
? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
|
|
425
515
|
'it never writes to your project. Something outside that file is still missing here. Run the ' +
|
|
426
516
|
'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
|
|
427
|
-
'then run `unitbob suite-prepare` again.'
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
517
|
+
'then run `unitbob suite-prepare` again.'
|
|
518
|
+
: prepared
|
|
519
|
+
? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
|
|
520
|
+
: `This is what these files do with nothing in front of them, and the run will have ` +
|
|
521
|
+
`${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
|
|
522
|
+
'first import — the environment variables the modules read, a loader registration, and only as a ' +
|
|
523
|
+
'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
|
|
524
|
+
'and this same question will be asked through it.';
|
|
525
|
+
// `boot.message` is the runner's own words, indented but never paraphrased:
|
|
526
|
+
// this is the line the vibecoder can paste into a search.
|
|
527
|
+
return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
|
|
528
|
+
}
|
|
529
|
+
// What this answer is worth, in two halves that always travel together: how much
|
|
530
|
+
// this stack's check can see, and the fact that it speaks for one branch only.
|
|
531
|
+
// Splitting them is how the stack caveat came to be missing from the outcome
|
|
532
|
+
// that costs a branch — found on the fifth implementation review, 2026-08-03 —
|
|
533
|
+
// so there is one builder and every caller goes through it. `indent` sets how
|
|
534
|
+
// the lines sit, and is the only thing a caller may vary.
|
|
535
|
+
//
|
|
536
|
+
// Empties are dropped rather than joined blindly, so a runner with no caveat of
|
|
537
|
+
// its own does not leave a blank line behind.
|
|
538
|
+
function caveatFor(runner, indent = '') {
|
|
539
|
+
return [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
|
|
540
|
+
.filter(Boolean)
|
|
541
|
+
.map((line) => `\n${indent}${line}`)
|
|
542
|
+
.join('');
|
|
441
543
|
}
|
|
442
544
|
// Spec 32-6 says the boot rule is one rule for both branches; this check asks
|
|
443
545
|
// one of them. It is made against the *structural* runner, which is what
|
|
@@ -456,12 +558,7 @@ const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: i
|
|
|
456
558
|
'which has nothing of ours to load until its suite exists, so it was not asked.';
|
|
457
559
|
const NOT_CHECKED_REASON = {
|
|
458
560
|
no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
|
|
459
|
-
// Distinct from `no_runner` on purpose
|
|
460
|
-
// it is only too old to be asked this particular question, and "no runner
|
|
461
|
-
// available" would send someone to fix a thing that is not broken.
|
|
462
|
-
runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
|
|
463
|
-
'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
|
|
464
|
-
// Distinct for the same reason, one step further along: the runner is there
|
|
561
|
+
// Distinct from `no_runner` on purpose: the runner is there
|
|
465
562
|
// and current, it was reached, and it declined to answer — pytest exiting on
|
|
466
563
|
// a usage or internal error of its own. That says nothing about the project,
|
|
467
564
|
// 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.4",
|
|
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.4 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
|