unitbob 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
2
  import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
3
+ import { placeProblem } from "../runner/place.js";
4
+ import { placeAdvice } from "../runner/placeAdvice.js";
5
+ import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
3
6
  import { validateStack } from "../runner/precheck.js";
4
7
  import { runBddSuite } from "../runner/bdd.js";
5
8
  import { runStructuralByRunner } from "./run.js";
@@ -12,6 +15,13 @@ export async function runLocal(config, args = [], deps) {
12
15
  stdout: process.stdout,
13
16
  ...deps,
14
17
  };
18
+ // Spec 36, criteria 7 and 6. A run that cannot reach the place its
19
+ // dependencies live in has nothing to report but noise — and neither has one
20
+ // whose runner was installed somewhere else, which looks ready because
21
+ // readiness here is a file existing.
22
+ const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
23
+ if (unusable)
24
+ throw new Error(unusable);
15
25
  const request = readSuiteBuildRequest(config.projectRoot);
16
26
  const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
17
27
  const wanted = selectBranches(request, args);
@@ -124,7 +134,12 @@ async function runOneBranch(config, d, suiteKind, output) {
124
134
  : await d.runStructural(config.projectRoot, runner, suitePaths);
125
135
  }
126
136
  catch (err) {
127
- d.stdout.write(`The runner could not start: ${err.message}\n`);
137
+ // The second and last dead end (spec 36, §7.1). This one does not throw
138
+ // it prints and returns zero, so it never reaches the one `catch` that adds
139
+ // this advice everywhere else. And it is the case that matters most: the
140
+ // suite exists by now, and the person is trying to run it.
141
+ const advice = placeAdvice(config.projectRoot);
142
+ d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
128
143
  return null;
129
144
  }
130
145
  d.stdout.write(report(result));
@@ -163,7 +178,7 @@ function outputTail(result) {
163
178
  }
164
179
  // The suite blob's own project-relative paths, exactly as the runners expect
165
180
  // them: the main file first, then every other file of the branch. The main file
166
- // stopped being the whole suite in spec 42, §6 — a branch is one file per
181
+ // stopped being the whole suite in spec 43, §6 — a branch is one file per
167
182
  // assignment now — and running it alone would exercise a fraction of what the
168
183
  // answer claims to guard.
169
184
  //
@@ -2,10 +2,14 @@ import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
+ import { bddStepLoading } from "../runner/bdd.js";
5
6
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
6
- import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
7
+ import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
7
8
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
9
+ import { placeProblem } from "../runner/place.js";
10
+ import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
8
11
  import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
12
+ import { ToolchainUnavailableError } from "../runner/toolchain.js";
9
13
  import { probeBehavioralWorld } from "../runner/worldProbe.js";
10
14
  import { Wire } from "../wire.js";
11
15
  // The complete envelope for one branch, or null when this machine cannot
@@ -70,9 +74,30 @@ export async function suitePrepare(config, args = [], deps) {
70
74
  stdout: process.stdout,
71
75
  ...deps,
72
76
  };
77
+ // Spec 36, criterion 7. Before the first byte is written and long before the
78
+ // first call to the server: a place that cannot be used is a fact we can learn
79
+ // now, and learning it after a suite has been generated and run means the
80
+ // evidence disappeared with the container.
81
+ //
82
+ // Not a `ToolchainUnavailableError`: the place is named in the config and the
83
+ // message below already says what to do about it. Suggesting a container to
84
+ // somebody whose container is the problem is noise.
85
+ const unusable = placeProblem(config.projectRoot);
86
+ if (unusable)
87
+ throw new Error(`${unusable}\nNothing was written and nothing was uploaded.`);
73
88
  const check = actual.precheck(config.projectRoot);
89
+ // Deliberately a plain stop. This one says "none of the three stacks is here",
90
+ // which is read off files — a Gemfile, a package.json, a requirements.txt —
91
+ // and those are on this machine whatever place the run happens in. A container
92
+ // is never the answer to it.
74
93
  if (!check.ok)
75
94
  throw new Error(check.message ?? 'Unsupported runtime.');
95
+ // An environment installed somewhere else is not an environment (spec 36, §6).
96
+ // Here, where it can be built again, and before anything asks whether a runner
97
+ // is ready.
98
+ const replaced = alignRunnerEnvironmentWithPlace(config.projectRoot);
99
+ if (replaced)
100
+ actual.stdout.write(`${replaced}\n`);
76
101
  // Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
77
102
  // project it touched, so a Flask app and a NestJS app each came away with a
78
103
  // Ruby file they never asked for and cannot run — the product leaving another
@@ -92,16 +117,17 @@ export async function suitePrepare(config, args = [], deps) {
92
117
  const provisioned = await actual.ensureStructuralRunner(config.projectRoot, check.runner);
93
118
  if (provisioned.status === 'fixable') {
94
119
  const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
95
- throw new Error(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
96
- `${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded.`);
120
+ throw new ToolchainUnavailableError(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
121
+ `${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded.`, config.projectRoot);
97
122
  }
98
123
  setupNotices.push(...(provisioned.checklist ?? []));
99
124
  // Confirm rather than assume. Provisioning reporting success and the runner
100
125
  // actually being startable are two different facts, and this is the cheap
101
126
  // one to check before a whole generation is built on it.
102
127
  const ready = actual.confirmRunner(config.projectRoot, check.runner);
103
- if (!ready.ok)
104
- throw new Error(ready.message ?? `The ${check.runner} runner is not available.`);
128
+ if (!ready.ok) {
129
+ throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
130
+ }
105
131
  }
106
132
  // Spec 32-6. Before anything is fetched or written, find out whether the suite
107
133
  // would get off the ground at all. It runs here, after the boot helper exists
@@ -117,8 +143,15 @@ export async function suitePrepare(config, args = [], deps) {
117
143
  // the same thing: on Python that would shell out to pytest all over again.
118
144
  const structuralRunner = check.runner ?? null;
119
145
  const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
120
- if (boot.status === 'broken')
121
- throw new Error(bootFinding(boot, structuralRunner));
146
+ if (boot.status === 'broken') {
147
+ // "Your environment is not ready" is the one of the two that a container can
148
+ // answer — the toolchain is missing here and may be sitting in one. A defect
149
+ // found in the code is a defect wherever it runs, and offering a container
150
+ // for it would be the noise this spec is trying to remove.
151
+ throw boot.cause === 'environment_not_ready'
152
+ ? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
153
+ : new Error(bootFinding(boot, structuralRunner));
154
+ }
122
155
  actual.stdout.write(bootFinding(boot, structuralRunner));
123
156
  const packets = await actual.getSuitePacketsBatch();
124
157
  // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
@@ -139,8 +172,14 @@ export async function suitePrepare(config, args = [], deps) {
139
172
  fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
140
173
  continue;
141
174
  }
175
+ // Every BDD runner with a connector-owned harness gets it here, before its
176
+ // branch is offered to the host (spec 35-1). Only Ruby is probed by
177
+ // running it: the Ruby World integrates deeply with Rails, and the probe
178
+ // needs an application to integrate with. The JS and Python harnesses do
179
+ // one thing — refuse connections that leave the machine — and their
180
+ // guards are executed for real in the connector's own suite.
181
+ materializeBehavioralWorld(config.projectRoot, runner);
142
182
  if (runner === 'cucumber') {
143
- materializeBehavioralWorld(config.projectRoot);
144
183
  const probe = await actual.worldProbe(config.projectRoot);
145
184
  if (probe.status === 'fixable') {
146
185
  fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
@@ -156,6 +195,11 @@ export async function suitePrepare(config, args = [], deps) {
156
195
  const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
157
196
  if (!manifest)
158
197
  return { packet, runner, branch: null };
198
+ // Spec 44, §3.2. The rule for which step files this runner loads travels
199
+ // with the branch that will be written against it, so nobody has to read
200
+ // the connector's own source to find it out — which is exactly what two
201
+ // coordinators did.
202
+ const stepLoading = packet.suite_kind === 'behavioral' && runner ? bddStepLoading(runner) : null;
159
203
  return {
160
204
  packet,
161
205
  runner,
@@ -166,6 +210,7 @@ export async function suitePrepare(config, args = [], deps) {
166
210
  recipe: await actual.getRecipe(recipeNameFor(packet)),
167
211
  assignment: packet.assignment,
168
212
  runner_manifest: manifest,
213
+ ...(stepLoading ? { step_loading: stepLoading } : {}),
169
214
  },
170
215
  };
171
216
  }));
@@ -200,6 +245,28 @@ export async function suitePrepare(config, args = [], deps) {
200
245
  `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
201
246
  `runner that runs after publishing, so you never have to guess the command), repair broken harness steps while application failures remain red, ` +
202
247
  `then run ${nextCommand}.\n`);
248
+ // Printed as well as written, because a rule nobody reads is a rule nobody
249
+ // follows — and this one is silent when broken: a step file the runner does
250
+ // not collect produces no error, only a green run over no scenarios.
251
+ for (const { packet, runner, branch } of prepared) {
252
+ if (!branch || packet.suite_kind !== 'behavioral' || !runner)
253
+ continue;
254
+ // Before the steps are written, not after they misbehave: what this runner
255
+ // does and does not load is the fact a worker needs while deciding what a
256
+ // step may assume (spec 35-1, criterion 2).
257
+ const harness = behavioralHarnessNotice(runner);
258
+ if (harness)
259
+ actual.stdout.write(harness);
260
+ actual.stdout.write(branch.step_loading
261
+ ? stepLoadingNotice(runner, branch.step_loading)
262
+ // Said rather than left blank. This connector has no strategy for that
263
+ // runner, so it does not know which files it loads — and silence here
264
+ // reads as "any name will do", which is the failure this whole notice
265
+ // exists to prevent.
266
+ : `\nBehavioral steps run under "${runner}", and this connector does not know how that runner ` +
267
+ 'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
268
+ 'them, and this connector will not be able to run the branch either.\n');
269
+ }
203
270
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
204
271
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
205
272
  if (setupNotices.length > 0) {
@@ -221,6 +288,24 @@ export async function suitePrepare(config, args = [], deps) {
221
288
  '\n');
222
289
  }
223
290
  }
291
+ // The runner's own rule for which step files it will load, in the words of the
292
+ // side that loads them (spec 44, §3.2). The same object is in `request.json`, on
293
+ // the behavioral branch; this is the copy the coordinator sees without opening a
294
+ // file.
295
+ //
296
+ // A null pattern is printed as a null pattern. A runner whose rule is not one
297
+ // pattern says what it does know and admits the rest — inventing a pattern here
298
+ // would recreate, in the connector this time, exactly the retelling this
299
+ // replaced.
300
+ function stepLoadingNotice(runner, loading) {
301
+ const rule = loading.step_files
302
+ ? `it loads \`${loading.step_files}\` from \`.unitbob/behavioral/step_definitions/\` — put the capability id ` +
303
+ 'where the `*` is, and a file named anything else is not loaded at all'
304
+ : 'its rule for which files it loads is not one pattern, and this connector will not state one for it';
305
+ return (`\nBehavioral steps run under "${runner}", and ${rule}. What else has to be true of a step file there:\n - ` +
306
+ loading.requirements.join('\n - ') +
307
+ '\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
308
+ }
224
309
  // What the boot check found, in the vibecoder's terms. Printed on every run,
225
310
  // including the quiet ones: "we looked and it starts" and "we could not look"
226
311
  // are both worth a line, and a check nobody hears about is a check nobody
@@ -250,7 +335,8 @@ function bootFinding(boot, runner) {
250
335
  // Not checked is not broken, and nothing downstream may treat it as such.
251
336
  // Conflating the two would block honest projects — the whole reason this
252
337
  // state is named for what happened rather than for what we know.
253
- return `${NOT_CHECKED_REASON[boot.reason]} Generation continues.${caveat}\n`;
338
+ const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
339
+ return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
254
340
  }
255
341
  const headline = boot.cause === 'defect_in_code'
256
342
  ? 'Found a defect that stops your test suite from starting.'
@@ -307,6 +393,12 @@ const NOT_CHECKED_REASON = {
307
393
  'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
308
394
  timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
309
395
  nothing_to_load: 'Did not check whether the suite can start: there was nothing to load yet.',
396
+ // Spec 36, criterion 8. Docker refused, or the container went away between the
397
+ // check and the spawn. Nothing here says anything about the project, and the
398
+ // one thing this must never turn into is "we found a defect in your code".
399
+ place_failed: 'Did not check whether the suite can start: the place this project runs in did not carry the command ' +
400
+ 'out. That is a fault of the container or the docker daemon, not of your code, and nothing was learned ' +
401
+ 'about your code either way.',
310
402
  };
311
403
  function knownDefectContext(args) {
312
404
  const defect = option(args, '--known-defect=');
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBehavioral, } from "../files/behavioral.js";
6
6
  import { runBddSuite } from "../runner/bdd.js";
7
7
  import { boundReport } from "../runner/boundReport.js";
8
+ import { placeOf } from "../runner/place.js";
8
9
  import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
9
10
  export async function suiteReviewPrepare(config, _args = [], deps) {
10
11
  const actual = {
@@ -54,6 +55,19 @@ async function runCandidateInProject(projectRoot, output, revision) {
54
55
  return { revision, run_result: report };
55
56
  }
56
57
  async function runCandidateAtRevision(projectRoot, output, revision) {
58
+ // Spec 36, Non-Goals. The worktree below is created under the system's
59
+ // temporary directory — outside anything a container has mounted, so in there
60
+ // it does not exist at all. Said plainly rather than run into. The obvious
61
+ // repair, moving the worktree under `.unitbob/`, puts a whole second copy of
62
+ // the application inside the tree graphify scans, and a copy left behind by a
63
+ // failure builds the next map out of two applications.
64
+ const place = placeOf(projectRoot);
65
+ if (place.kind === 'docker') {
66
+ throw new Error("Reviewing at a fixed revision is not supported while this project's tests run inside a container " +
67
+ `(\`${place.container}\`): the review needs a git worktree outside the project, which the container ` +
68
+ 'cannot see. Review against the working tree instead (drop the fixed revision), or run this project ' +
69
+ 'on this machine.');
70
+ }
57
71
  const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
58
72
  cwd: projectRoot,
59
73
  encoding: 'utf8',
package/dist/wire.js CHANGED
@@ -86,7 +86,7 @@ export class Wire {
86
86
  // carries one result per suite_kind.
87
87
  //
88
88
  // `dryRun` is the same route, the same body and the same server-side
89
- // validation, stopped before the first write (spec 42, §1). It answers
89
+ // validation, stopped before the first write (spec 43, §1). It answers
90
90
  // `would_publish` instead of `created`, and it is deliberately not a route of
91
91
  // its own: a second route would grow a second implementation, which is the
92
92
  // defect this whole spec removes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
21
21
  connector-owned harness, or another slice.
22
22
 
23
23
  After every owned edit, run
24
- `npx -y --loglevel=error unitbob@0.5.0 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.6.0 run-local <branch>` and inspect the machine
25
25
  report. Look only at examples or scenarios matching your owned paths or case
26
26
  markers. Do not require a green exit code from the whole branch: foreign failures
27
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded