unitbob 0.7.3 → 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.
@@ -16,6 +16,56 @@ export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
16
16
  // and is rewritten immediately before every run, so a boot check that reused it
17
17
  // would either be overwritten or leave the run pointing at a probe.
18
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
+ }
19
69
  // The project configs we inherit from, most specific first. Vitest reads a
20
70
  // project's own config even when we pass `--config`, so we must merge ours with
21
71
  // it rather than replace it (plugins, path aliases and resolve settings the
@@ -87,7 +137,7 @@ export async function runVitestSuite(projectRoot, suitePaths) {
87
137
  function writeMergedConfig(projectRoot, suitePaths) {
88
138
  const path = join(projectRoot, VITEST_CONFIG_FILE);
89
139
  mkdirSync(dirname(path), { recursive: true });
90
- writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run'));
140
+ writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run', setupFileOf(projectRoot)));
91
141
  return ['--config', VITEST_CONFIG_FILE];
92
142
  }
93
143
  // The same config, shaped for the boot check's probe (spec 38). Returned as text
@@ -95,7 +145,12 @@ function writeMergedConfig(projectRoot, suitePaths) {
95
145
  // puts in the project: it writes them together and removes them together, and a
96
146
  // writer here would take half of that away from the one place that can see it.
97
147
  export function vitestBootConfigSource(projectRoot, probePath) {
98
- return configSource(projectConfigOf(projectRoot), [probePath], 'boot');
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));
99
154
  }
100
155
  function projectConfigOf(projectRoot) {
101
156
  return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
@@ -118,13 +173,25 @@ function projectConfigOf(projectRoot) {
118
173
  // and `defineConfig` is a typing helper that buys a generated file nothing.
119
174
  // `mode` is the boot check's two differences from the run, and they go together:
120
175
  // globals on, and a workspace refused. See `vitestBootConfigSource`.
121
- function configSource(projectConfig, suitePaths, mode) {
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) {
122
185
  // After the project's own `test`, never before it: ours has to win.
123
- const settings = `${mode === 'boot' ? 'globals: true, ' : ''}include: ${JSON.stringify(suitePaths)}`;
186
+ const settings = `${mode === 'boot' ? 'globals: true, ' : ''}${setupFile ? 'setupFiles, ' : ''}include: ${JSON.stringify(suitePaths)}`;
124
187
  const header = '// Written by the unitbob connector before each vitest run — do not edit.';
125
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` : '';
126
193
  return `${header}
127
- export default { test: { ${settings} } };
194
+ ${alone}export default { test: { ${settings} } };
128
195
  `;
129
196
  }
130
197
  // Globals, because the probe lives in `.unitbob/structural/`: an
@@ -144,13 +211,20 @@ const { projects, workspace, ...test } = base.test ?? {};
144
211
  : `
145
212
  const test = base.test ?? {};
146
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
+ : '';
147
221
  return `${header}
148
222
  import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
149
223
 
150
224
  const base = typeof projectConfig === 'function'
151
225
  ? await projectConfig({ command: 'serve', mode: 'test' })
152
226
  : projectConfig;
153
- ${narrow}
227
+ ${narrow}${merge}
154
228
  export default { ...base, test: { ...test, ${settings} } };
155
229
  `;
156
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 {
@@ -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
- return [path, ...rest];
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);
@@ -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
@@ -225,13 +226,52 @@ export async function suitePrepare(config, args = [], deps) {
225
226
  // and boots the application for itself, so asking with no structural branch in
226
227
  // the run would start Rails to answer a question nobody put — and a `broken`
227
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.
228
262
  const bootNotices = [];
263
+ const bootAdvisories = [];
229
264
  const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
230
265
  if (structuralIndex !== -1) {
231
266
  const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
232
267
  if (boot.status === 'broken') {
233
- branches.splice(structuralIndex, 1);
234
- bootNotices.push(bootStop(boot, structuralRunner));
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
+ }
235
275
  }
236
276
  else {
237
277
  actual.stdout.write(bootFinding(boot, structuralRunner));
@@ -330,6 +370,18 @@ export async function suitePrepare(config, args = [], deps) {
330
370
  bootNotices.join('\n') +
331
371
  '\n');
332
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
+ }
333
385
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
334
386
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
335
387
  if (setupNotices.length > 0) {
@@ -434,16 +486,42 @@ function bootFinding(boot, runner) {
434
486
  // its peer carries on, `request.json` is written, and the vibecoder comes away
435
487
  // with the guardrails that branch can still give rather than with nothing.
436
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
+ }
437
504
  // The two causes keep their separate next steps. An un-run `pip install` is not
438
505
  // 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) {
441
- const next = boot.cause === 'defect_in_code'
442
- ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
443
- : 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
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/` — ' +
444
515
  'it never writes to your project. Something outside that file is still missing here. Run the ' +
445
516
  'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
446
- 'then run `unitbob suite-prepare` again.';
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.';
447
525
  // `boot.message` is the runner's own words, indented but never paraphrased:
448
526
  // this is the line the vibecoder can paste into a search.
449
527
  return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.3",
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.3 run-local <branch>` and inspect the machine
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