unitbob 0.5.1 → 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,9 +1,9 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
3
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
4
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
5
  import { locateRunner } from "./toolchain.js";
6
- import { readReport } from "./types.js";
6
+ import { clearReport, readFreshReport } from "./types.js";
7
7
  export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
8
8
  export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
9
9
  // A connector-owned Vitest config, written next to .unitbob/ before a run when
@@ -37,7 +37,7 @@ const PROJECT_CONFIGS = [
37
37
  // file of the branch in `include`, and the positional filters keep the run to
38
38
  // exactly those files.
39
39
  //
40
- // Named files rather than a directory glob, since spec 42, §6.5 made a branch
40
+ // Named files rather than a directory glob, since spec 43, §6.5 made a branch
41
41
  // several files: the artifact already says which files it is, and a glob would
42
42
  // have to guess a naming convention nothing enforces. `include` is written even
43
43
  // when the project has no config of its own — Vitest's default include only
@@ -63,17 +63,16 @@ export async function runVitestSuite(projectRoot, suitePaths) {
63
63
  '--reporter=json',
64
64
  `--outputFile=${VITEST_RESULT_FILE}`,
65
65
  ];
66
- const result = await runProcess(command, args, {
67
- cwd: projectRoot,
66
+ const reportPath = join(projectRoot, VITEST_RESULT_FILE);
67
+ const survivor = clearReport(reportPath);
68
+ const run = await runInProject(projectRoot, command, args, {
68
69
  timeoutMs: VITEST_TIMEOUT_MS,
69
- env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
70
+ env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
70
71
  });
71
72
  return {
72
- ...result,
73
- command,
74
- args,
73
+ ...run,
75
74
  resultPath: VITEST_RESULT_FILE,
76
- report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
75
+ report: readFreshReport(reportPath, survivor),
77
76
  };
78
77
  }
79
78
  // Returns the `--config` args to add, writing the config first. Always written:
@@ -1,36 +1,35 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
3
  import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
4
+ import { BEHAVIORAL_GEMFILE } from "./bdd.js";
5
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
6
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
6
7
  const PROBE_ROOT = '.unitbob/suite-build/world-probe';
7
8
  export async function probeBehavioralWorld(projectRoot, deps = {
8
- runCmd: (command, args, options) => runProcess(command, args, {
9
- cwd: options.cwd,
10
- env: options.env,
11
- timeoutMs: PROVISION_TIMEOUT_MS,
12
- }),
9
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
13
10
  }) {
11
+ // Written on the host, named to the run relative to the project root — the
12
+ // same shape `bdd.ts` has always had, and the reason nothing here needs a path
13
+ // rewritten when the run happens somewhere else (spec 36, §4.2).
14
+ const feature = `${PROBE_ROOT}/world.feature`;
15
+ const steps = `${PROBE_ROOT}/world_steps.rb`;
14
16
  const probeRoot = join(projectRoot, PROBE_ROOT);
15
- const feature = join(probeRoot, 'world.feature');
16
- const steps = join(probeRoot, 'world_steps.rb');
17
17
  mkdirSync(probeRoot, { recursive: true });
18
- writeFileSync(feature, PROBE_FEATURE);
19
- writeFileSync(steps, PROBE_STEPS);
18
+ writeFileSync(join(projectRoot, feature), PROBE_FEATURE);
19
+ writeFileSync(join(projectRoot, steps), PROBE_STEPS);
20
20
  try {
21
21
  const result = await deps.runCmd('bundle', [
22
22
  'exec', 'cucumber', feature,
23
- '--require', join(projectRoot, BEHAVIORAL_WORLD_PATH),
23
+ '--require', BEHAVIORAL_WORLD_PATH,
24
24
  '--require', steps,
25
25
  '--format', 'progress',
26
26
  ], {
27
27
  cwd: projectRoot,
28
28
  env: {
29
- ...process.env,
30
29
  RAILS_ENV: 'test',
31
30
  CUCUMBER_PUBLISH_QUIET: 'true',
32
- UNITBOB_REPO_ROOT: projectRoot,
33
- BUNDLE_GEMFILE: join(projectRoot, '.unitbob', 'behavioral', 'Gemfile'),
31
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
32
+ BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
34
33
  },
35
34
  });
36
35
  if (result.code === 0)
@@ -52,6 +51,9 @@ const PROBE_FEATURE = `Feature: Unitbob World profile
52
51
 
53
52
  Scenario: supported state is clean at the next scenario boundary
54
53
  Then the second World probe scenario sees clean state and fresh mocks
54
+
55
+ Scenario: outgoing HTTP does not leave the machine
56
+ Then the World probe cannot reach the network
55
57
  `;
56
58
  const PROBE_STEPS = `PROBE_VERSION = "unitbob-world-probe-#{Process.pid}"
57
59
  PROBE_TIME_ZONE = Time.zone
@@ -84,6 +86,16 @@ Then('its integration assertion counter advances') do
84
86
  expect(unitbob_session.assertions).to be > 0
85
87
  end
86
88
 
89
+ # Spec 35-1, criterion 2. Checked here rather than trusted, because the failure
90
+ # it guards against is invisible from inside: a suite whose WebMock never came on
91
+ # passes exactly the same way, and only the other end of the wire ever finds out.
92
+ Then('the World probe cannot reach the network') do
93
+ require 'net/http'
94
+ expect {
95
+ Net::HTTP.get(URI('http://unitbob-world-probe.invalid/'))
96
+ }.to raise_error(WebMock::NetConnectNotAllowedError)
97
+ end
98
+
87
99
  Then('the second World probe scenario sees clean state and fresh mocks') do
88
100
  quoted = @unitbob_connection.quote(PROBE_VERSION)
89
101
  count = @unitbob_connection.select_value("SELECT COUNT(*) FROM schema_migrations WHERE version = #{quoted}").to_i
@@ -1,18 +1,20 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { runProcess } from "../proc.js";
3
+ import { executable } from "../proc.js";
4
4
  import { firstErrorLine } from "../runner/bootcheck.js";
5
+ import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
5
6
  import { detectStructuralRunner } from "../runner/precheck.js";
6
7
  import { graphPath } from "../files/mapBuild.js";
7
8
  // Reading a router means booting the application, which on a large Rails app is
8
9
  // tens of seconds. The same budget the other boot-shaped step uses.
9
10
  const ROUTES_TIMEOUT_MS = 120_000;
11
+ // The router is the application's own, so asking it is a project command and
12
+ // goes where the project's dependencies live (spec 36, §5). Unlike every other
13
+ // caller this one is allowed to fail: silence is already a normal answer here,
14
+ // so a project whose container is misconfigured loses its route inventory and
15
+ // nothing else.
10
16
  const defaultDeps = {
11
- runCmd: (command, args, options) => runProcess(command, args, {
12
- cwd: options.cwd,
13
- timeoutMs: ROUTES_TIMEOUT_MS,
14
- env: { ...process.env, ...options.env },
15
- }),
17
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: ROUTES_TIMEOUT_MS, env: options.env }),
16
18
  };
17
19
  export function routeInventoryPath(projectRoot) {
18
20
  return join(projectRoot, '.unitbob', 'map-build', 'route_inventory.json');
@@ -202,16 +204,16 @@ end`;
202
204
  // `default` means we leave RAILS_ENV alone and take whatever the project's own
203
205
  // setup chooses.
204
206
  async function askRouterOnce(projectRoot, deps, environment) {
205
- const local = join(projectRoot, 'bin', 'rails');
206
- const [command, args] = existsSync(local)
207
- ? [local, ['runner', ROUTES_SCRIPT]]
207
+ const [command, args] = executable(join(projectRoot, 'bin', 'rails'))
208
+ ? ['bin/rails', ['runner', ROUTES_SCRIPT]]
208
209
  : ['bundle', ['exec', 'rails', 'runner', ROUTES_SCRIPT]];
210
+ const repoRoot = projectRootAsSeenByThePlace(projectRoot);
209
211
  try {
210
212
  return await deps.runCmd(command, args, {
211
213
  cwd: projectRoot,
212
214
  env: environment === 'test'
213
- ? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot }
214
- : { UNITBOB_REPO_ROOT: projectRoot },
215
+ ? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: repoRoot }
216
+ : { UNITBOB_REPO_ROOT: repoRoot },
215
217
  });
216
218
  }
217
219
  catch {
@@ -415,7 +417,7 @@ export function inventoryProblems(inventory, surfaces) {
415
417
  // either printed by the router or copied out of the graph. A blank is not a gap
416
418
  // to be helpfully filled — an invented `source_file` is the same failure as an
417
419
  // invented address, one field over, and a `handler_symbol` swapped for another
418
- // node that happens to exist passes the host's check while sending spec 36's
420
+ // node that happens to exist passes the host's check while sending spec 38's
419
421
  // trace into the wrong code.
420
422
  const LINK_FIELDS = ['source_file', 'handler_symbol'];
421
423
  // Not a link and nothing downstream reads it — but spec 32-7 took the authoring
@@ -18,7 +18,7 @@ const LABEL = {
18
18
  // This used to refuse when an installed file differed from the bundled one, to
19
19
  // protect a definition the user had edited by hand. The case it actually met was
20
20
  // the ordinary one: an upgrade from an older release, where every file differs
21
- // and every install therefore failed. Spec 43, §1.6.
21
+ // and every install therefore failed. Spec 44, §1.6.
22
22
  //
23
23
  // The refusal became actively harmful once the workflows started asking a role
24
24
  // whether this session can see it. A stale role answers that question exactly
@@ -1,4 +1,4 @@
1
- import { ensureUnitbobIgnored, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
1
+ import { ensureUnitbobIgnored, ignoreExclusions, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
2
2
  import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
3
3
  import { describeRouteInventory, extractRouteInventory, } from "../surfaces/routeInventory.js";
4
4
  import { Wire } from "../wire.js";
@@ -10,9 +10,20 @@ export async function mapPrepare(config, _args = [], deps) {
10
10
  runGraphifyExtractKeyless,
11
11
  extractRouteInventory,
12
12
  getRecipe: (name) => wire.getRecipe(name),
13
+ stdout: process.stdout,
13
14
  ...deps,
14
15
  };
15
16
  actual.ensureUnitbobIgnored(config.projectRoot);
17
+ // Said before the graph is built, because after it there is nothing left to
18
+ // see: whatever these patterns matched never becomes a node, and a subsystem
19
+ // that is missing from the map looks exactly like a subsystem that was never
20
+ // written. One line per pattern that actually took something; a pattern that
21
+ // matched nothing is not news (spec 35-1, criterion 1).
22
+ const exclusions = ignoreExclusions(config.projectRoot);
23
+ if (exclusions.length > 0) {
24
+ actual.stdout.write('Kept out of the graph by .graphifyignore — nothing below can appear on the map:\n' +
25
+ exclusions.map((entry) => ` ${entry.pattern} — ${entry.files} file${entry.files === 1 ? '' : 's'}\n`).join(''));
26
+ }
16
27
  await actual.requireGraphify();
17
28
  // Keyless: refresh the one canonical graph in place. No inference secret and no
18
29
  // graph flags — semantic enrichment is host-LLM work, not a keyed LLM here.
@@ -31,7 +42,7 @@ export async function mapPrepare(config, _args = [], deps) {
31
42
  // Said out loud first, because asking a router means booting the application
32
43
  // and that can take a minute or two with nothing on the screen. Silence from
33
44
  // us there reads as a hang.
34
- process.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
45
+ actual.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
35
46
  const inventory = await actual.extractRouteInventory(config.projectRoot);
36
47
  const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
37
48
  actual.getRecipe('decompose'),
@@ -45,9 +56,9 @@ export async function mapPrepare(config, _args = [], deps) {
45
56
  extract_surfaces: extractSurfaces,
46
57
  decompose_surfaces: decomposeSurfaces,
47
58
  }, inventory.status === 'written' ? inventory.path : undefined);
48
- process.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
49
- process.stdout.write(`${describeRouteInventory(inventory)}\n`);
50
- process.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
59
+ actual.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
60
+ actual.stdout.write(`${describeRouteInventory(inventory)}\n`);
61
+ actual.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
51
62
  `${packet.output_path} (recipes.decompose, recipes.relate), and the surface map at ` +
52
63
  `${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
53
64
  'recipes.decompose_surfaces) — then run `unitbob put-map-build`.\n');
@@ -1,4 +1,5 @@
1
1
  import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { placeProblem } from "../runner/place.js";
2
3
  import { collectBuildProblems } from "./validateBuild.js";
3
4
  import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
4
5
  import { Wire } from "../wire.js";
@@ -20,6 +21,12 @@ import { Wire } from "../wire.js";
20
21
  // Returns the server's per-branch results so the caller can compose the first run
21
22
  // on top of them (spec 32-4) without parsing the lines printed here.
22
23
  export async function putSuiteBuild(config, _args = [], deps) {
24
+ // Spec 36, criterion 7. Publishing is followed immediately by a first run, so
25
+ // a place that cannot be used is not something to discover after the suite is
26
+ // stored on the server.
27
+ const unusable = placeProblem(config.projectRoot);
28
+ if (unusable)
29
+ throw new Error(`${unusable}\nNothing was uploaded.`);
23
30
  const request = readSuiteBuildRequest(config.projectRoot);
24
31
  // Spec 32-6: read branch by branch, so one unreadable entry neither hides the
25
32
  // next branch's problems nor sinks a peer that is finished and correct.
@@ -39,7 +46,7 @@ export async function putSuiteBuild(config, _args = [], deps) {
39
46
  // skipped by going straight to the upload — but reported the way every other
40
47
  // local failure here is reported: against the branch it belongs to.
41
48
  //
42
- // Since spec 42 that check is exactly one question, and it is about a branch
49
+ // Since spec 43 that check is exactly one question, and it is about a branch
43
50
  // the answer has *no* entry for: everything else it used to ask is now asked
44
51
  // of the server, by a dry run, before this command runs at all. So its
45
52
  // problems can never land on a branch this loop visits, and they are reported
@@ -122,7 +129,7 @@ function printResult(result) {
122
129
  const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
123
130
  return (`${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.` + printDowngrades(result));
124
131
  }
125
- // Spec 42, §7. A capability every one of whose Scenarios the review objected to
132
+ // Spec 43, §7. A capability every one of whose Scenarios the review objected to
126
133
  // is stored `unguarded` by the publish. The run is standing right here when that
127
134
  // is decided, so it is told here, in the server's own words — finding it on the
128
135
  // map afterwards is how a run finishes believing it published a guarantee it did
package/dist/verbs/run.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { materializeGuardrails } from "../files/guardrails.js";
2
2
  import { materializeBehavioral } from "../files/behavioral.js";
3
+ import { placeProblem } from "../runner/place.js";
4
+ import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
3
5
  import { validateStack } from "../runner/precheck.js";
4
6
  import { runRspecSuite } from "../runner/rspec.js";
5
7
  import { runVitestSuite } from "../runner/vitest.js";
@@ -28,7 +30,7 @@ function resolve(config, deps) {
28
30
  getSuites: () => wire.getSuites(),
29
31
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
30
32
  // The whole envelope, support files and all: a branch is a set of files
31
- // since spec 42, §6, and picking `path` and `content` out of it here was
33
+ // since spec 43, §6, and picking `path` and `content` out of it here was
32
34
  // where the rest of them used to be lost.
33
35
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
34
36
  suite_digest: item.suite_digest,
@@ -44,6 +46,12 @@ function resolve(config, deps) {
44
46
  };
45
47
  }
46
48
  async function execute(config, d, only) {
49
+ // Spec 36, criterion 7. Before the first suite is fetched: a run that cannot
50
+ // happen where this project's dependencies live has nothing honest to file,
51
+ // and the whole batch would go up as suite errors describing the wrong thing.
52
+ const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
53
+ if (unusable)
54
+ throw new Error(`${unusable}\nNothing was run and no results were filed.`);
47
55
  const suites = await d.getSuites();
48
56
  const ready = suites.filter((item) => item.status === 'ready');
49
57
  const selected = only === null ? ready : select(ready, only);
@@ -137,7 +145,7 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
137
145
  }
138
146
  }
139
147
  // Every file of the branch, in the order the envelope carries them. A structural
140
- // branch is one file per assignment since spec 42, §6, and running only the main
148
+ // branch is one file per assignment since spec 43, §6, and running only the main
141
149
  // one would execute a fraction of what the map says is guarded.
142
150
  function artifactPaths(file) {
143
151
  return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
@@ -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
  //
@@ -4,9 +4,12 @@ import { materializeBehavioralWorld } from "../files/behavioral.js";
4
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
5
  import { bddStepLoading } from "../runner/bdd.js";
6
6
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
7
- import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
7
+ import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
8
8
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
9
+ import { placeProblem } from "../runner/place.js";
10
+ import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
9
11
  import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
12
+ import { ToolchainUnavailableError } from "../runner/toolchain.js";
10
13
  import { probeBehavioralWorld } from "../runner/worldProbe.js";
11
14
  import { Wire } from "../wire.js";
12
15
  // The complete envelope for one branch, or null when this machine cannot
@@ -71,9 +74,30 @@ export async function suitePrepare(config, args = [], deps) {
71
74
  stdout: process.stdout,
72
75
  ...deps,
73
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.`);
74
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.
75
93
  if (!check.ok)
76
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`);
77
101
  // Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
78
102
  // project it touched, so a Flask app and a NestJS app each came away with a
79
103
  // Ruby file they never asked for and cannot run — the product leaving another
@@ -93,16 +117,17 @@ export async function suitePrepare(config, args = [], deps) {
93
117
  const provisioned = await actual.ensureStructuralRunner(config.projectRoot, check.runner);
94
118
  if (provisioned.status === 'fixable') {
95
119
  const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
96
- throw new Error(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
97
- `${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);
98
122
  }
99
123
  setupNotices.push(...(provisioned.checklist ?? []));
100
124
  // Confirm rather than assume. Provisioning reporting success and the runner
101
125
  // actually being startable are two different facts, and this is the cheap
102
126
  // one to check before a whole generation is built on it.
103
127
  const ready = actual.confirmRunner(config.projectRoot, check.runner);
104
- if (!ready.ok)
105
- 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
+ }
106
131
  }
107
132
  // Spec 32-6. Before anything is fetched or written, find out whether the suite
108
133
  // would get off the ground at all. It runs here, after the boot helper exists
@@ -118,8 +143,15 @@ export async function suitePrepare(config, args = [], deps) {
118
143
  // the same thing: on Python that would shell out to pytest all over again.
119
144
  const structuralRunner = check.runner ?? null;
120
145
  const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
121
- if (boot.status === 'broken')
122
- 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
+ }
123
155
  actual.stdout.write(bootFinding(boot, structuralRunner));
124
156
  const packets = await actual.getSuitePacketsBatch();
125
157
  // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
@@ -140,8 +172,14 @@ export async function suitePrepare(config, args = [], deps) {
140
172
  fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
141
173
  continue;
142
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);
143
182
  if (runner === 'cucumber') {
144
- materializeBehavioralWorld(config.projectRoot);
145
183
  const probe = await actual.worldProbe(config.projectRoot);
146
184
  if (probe.status === 'fixable') {
147
185
  fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
@@ -157,7 +195,7 @@ export async function suitePrepare(config, args = [], deps) {
157
195
  const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
158
196
  if (!manifest)
159
197
  return { packet, runner, branch: null };
160
- // Spec 43, §3.2. The rule for which step files this runner loads travels
198
+ // Spec 44, §3.2. The rule for which step files this runner loads travels
161
199
  // with the branch that will be written against it, so nobody has to read
162
200
  // the connector's own source to find it out — which is exactly what two
163
201
  // coordinators did.
@@ -213,6 +251,12 @@ export async function suitePrepare(config, args = [], deps) {
213
251
  for (const { packet, runner, branch } of prepared) {
214
252
  if (!branch || packet.suite_kind !== 'behavioral' || !runner)
215
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);
216
260
  actual.stdout.write(branch.step_loading
217
261
  ? stepLoadingNotice(runner, branch.step_loading)
218
262
  // Said rather than left blank. This connector has no strategy for that
@@ -245,7 +289,7 @@ export async function suitePrepare(config, args = [], deps) {
245
289
  }
246
290
  }
247
291
  // The runner's own rule for which step files it will load, in the words of the
248
- // side that loads them (spec 43, §3.2). The same object is in `request.json`, on
292
+ // side that loads them (spec 44, §3.2). The same object is in `request.json`, on
249
293
  // the behavioral branch; this is the copy the coordinator sees without opening a
250
294
  // file.
251
295
  //
@@ -291,7 +335,8 @@ function bootFinding(boot, runner) {
291
335
  // Not checked is not broken, and nothing downstream may treat it as such.
292
336
  // Conflating the two would block honest projects — the whole reason this
293
337
  // state is named for what happened rather than for what we know.
294
- 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`;
295
340
  }
296
341
  const headline = boot.cause === 'defect_in_code'
297
342
  ? 'Found a defect that stops your test suite from starting.'
@@ -348,6 +393,12 @@ const NOT_CHECKED_REASON = {
348
393
  'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
349
394
  timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
350
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.',
351
402
  };
352
403
  function knownDefectContext(args) {
353
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.1",
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.1 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