unitbob 0.2.7 → 0.3.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,49 +1,76 @@
1
- import { readBehavioralReview, readHostSuiteOutputs, readSuiteBuildRequest, } from "../files/suiteBuild.js";
1
+ import { readBehavioralReview, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { collectBuildProblems, formatBranchProblems } from "./validateBuild.js";
2
3
  import { Wire } from "../wire.js";
3
4
  // Read the task and the host's answers, verify each branch parses and carries a
4
5
  // safe-path artifact envelope, then upload both peer branches in one batch
5
6
  // (spec 32). `source_digest` comes from the task — never the host's answer — so
6
7
  // the host cannot claim a different map than each branch was given. A branch the
7
8
  // host could not build is uploaded as a `build_error`, which never rolls back the
8
- // peer branch. If a branch's answer is unparseable, nothing is uploaded.
9
+ // peer branch. A branch whose local review will not bind is reported unpublished
10
+ // and its peer still goes up — one branch's problem is never the other's.
11
+ //
12
+ // The line between "skip this branch" and "upload nothing" is whose problem it
13
+ // is. The answer *file* is the whole answer: missing, unparseable, or carrying
14
+ // no branches array, it stops everything, because there is no second problem to
15
+ // find. Everything smaller belongs to one branch — a malformed entry, a review
16
+ // that will not bind, a marker the local check could not account for — and its
17
+ // peer still goes up.
18
+ //
19
+ // Returns the server's per-branch results so the caller can compose the first run
20
+ // on top of them (spec 32-4) without parsing the lines printed here.
9
21
  export async function putSuiteBuild(config, _args = [], deps) {
10
22
  const request = readSuiteBuildRequest(config.projectRoot);
11
- const outputs = readHostSuiteOutputs(request.output_path, request);
23
+ // Spec 32-6: read branch by branch, so one unreadable entry neither hides the
24
+ // next branch's problems nor sinks a peer that is finished and correct.
25
+ const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
12
26
  const d = {
13
27
  putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
14
28
  stdout: process.stdout,
15
29
  ...deps,
16
30
  };
17
31
  const digestFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.source_digest]));
18
- const items = outputs.map((output) => {
32
+ const items = [];
33
+ const blocked = unreadable.map((entry) => ({
34
+ suite_kind: entry.suite_kind,
35
+ status: BLOCKED_STATUS,
36
+ error: entry.message,
37
+ }));
38
+ // The same check `unitbob validate-build` runs, run here too so it cannot be
39
+ // skipped by going straight to the upload — but reported the way every other
40
+ // local failure here is reported: against the branch it belongs to.
41
+ //
42
+ // An earlier draft threw and stopped the command, which quietly undid spec
43
+ // 32-5 Phase 4: one missing marker in the behavioral answer would have left a
44
+ // finished structural suite unpublished. Every problem this check raises is
45
+ // already named against a branch, so it blocks that branch and never the
46
+ // batch. That also bounds what a false positive in a local check can cost —
47
+ // one branch, with the peer still going up and the server still the authority.
48
+ const problemsFor = new Map();
49
+ for (const problem of collectBuildProblems(request, outputs)) {
50
+ problemsFor.set(problem.branch, [...(problemsFor.get(problem.branch) ?? []), problem.message]);
51
+ }
52
+ for (const output of outputs) {
53
+ const failed = problemsFor.get(output.suite_kind);
54
+ if (failed) {
55
+ blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: formatBranchProblems(failed) });
56
+ continue;
57
+ }
19
58
  const sourceDigest = digestFor.get(output.suite_kind) ?? '';
20
59
  if (output.build_error) {
21
- return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
60
+ items.push({ suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error });
61
+ continue;
22
62
  }
23
63
  let testMetadata = output.test_metadata;
24
64
  if (output.suite_kind === 'behavioral') {
25
- const review = readBehavioralReview(config.projectRoot, output);
26
- const probe = review.known_defect_probe;
27
- const qualityReview = review.bdd_quality_review;
28
- if (!qualityReview || typeof qualityReview !== 'object') {
29
- throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
65
+ try {
66
+ testMetadata = withReview(config, request, output);
30
67
  }
31
- if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
32
- throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
68
+ catch (err) {
69
+ blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: err.message });
70
+ continue;
33
71
  }
34
- testMetadata = {
35
- ...output.test_metadata,
36
- bdd_quality_review: {
37
- ...qualityReview,
38
- candidate_digest: review.candidate_digest,
39
- },
40
- known_defect_probe: review.known_defect_probe,
41
- known_defect_context: request.known_defect_context,
42
- candidate_run: review.candidate_run,
43
- ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
44
- };
45
72
  }
46
- return {
73
+ items.push({
47
74
  suite_kind: output.suite_kind,
48
75
  source_digest: sourceDigest,
49
76
  artifacts: {
@@ -51,16 +78,87 @@ export async function putSuiteBuild(config, _args = [], deps) {
51
78
  runner_manifest: output.runner_manifest,
52
79
  test_metadata: testMetadata,
53
80
  },
54
- };
55
- });
56
- const results = await d.putSuiteBuilds(items);
57
- for (const result of results) {
81
+ });
82
+ }
83
+ // Every branch is blocked, so there is nothing to upload. Asking the server to
84
+ // publish an empty batch would turn a local, already-explained problem into a
85
+ // wire error with a worse message.
86
+ const results = items.length > 0 ? await d.putSuiteBuilds(items) : [];
87
+ const all = [...results, ...blocked];
88
+ for (const result of all) {
58
89
  d.stdout.write(`${printResult(result)}\n`);
59
90
  }
91
+ return all;
92
+ }
93
+ // A branch that cannot be assembled locally: its review is missing, stale, or
94
+ // malformed. Not a server status — it never reaches the server — but it travels
95
+ // as one so a single rule decides what counts as published (see `PUBLISHED`).
96
+ const BLOCKED_STATUS = 'not_ready';
97
+ // The behavioral branch's uploaded metadata, with the independent review and the
98
+ // connector's own run evidence folded in.
99
+ //
100
+ // Throws for anything that leaves this branch unpublishable — a missing review,
101
+ // one bound to a different candidate, a defect the review called not_supplied.
102
+ // The caller turns that into one unpublished branch rather than a failed
103
+ // command: a blocked review is a fact about the behavioral suite, and the
104
+ // structural peer next to it is finished and correct. Sinking the whole upload
105
+ // with it forced the one workaround this contract exists to prevent — hand-editing
106
+ // the answer down to a single branch, which loses the peer candidate for real.
107
+ function withReview(config, request, output) {
108
+ const review = readBehavioralReview(config.projectRoot, output);
109
+ const probe = review.known_defect_probe;
110
+ const qualityReview = review.bdd_quality_review;
111
+ if (!qualityReview || typeof qualityReview !== 'object') {
112
+ throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
113
+ }
114
+ if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
115
+ throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
116
+ }
117
+ return {
118
+ ...output.test_metadata,
119
+ bdd_quality_review: {
120
+ ...qualityReview,
121
+ candidate_digest: review.candidate_digest,
122
+ },
123
+ known_defect_probe: review.known_defect_probe,
124
+ known_defect_context: request.known_defect_context,
125
+ candidate_run: review.candidate_run,
126
+ ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
127
+ };
60
128
  }
129
+ // The three outcomes that leave a branch published and current: a new version, an
130
+ // identical version already stored, or a reactivated one. Each returns the
131
+ // identity to run. Everything else — a rejected branch, a branch the host could
132
+ // not build, or a status this connector has never seen — fails closed and is
133
+ // never run, so a newer server can never trick an older connector into running
134
+ // something it does not understand.
135
+ const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
136
+ export function classifyPublication(results) {
137
+ const split = { digests: [], unpublished: [] };
138
+ for (const result of results) {
139
+ if (!PUBLISHED.has(result.status)) {
140
+ split.unpublished.push(result.suite_kind);
141
+ continue;
142
+ }
143
+ if (!result.suite_digest) {
144
+ throw new Error(`The server accepted the ${result.suite_kind} suite as "${result.status}" but returned no identity ` +
145
+ 'to run it by. The suite is published; run the Unitbob checks to finish.');
146
+ }
147
+ split.digests.push(result.suite_digest);
148
+ }
149
+ return split;
150
+ }
151
+ // Asks `PUBLISHED` rather than naming the failing statuses again: this line and
152
+ // the run that follows it must agree about what "published" means, or a branch the
153
+ // command skipped gets a line that reads like a success — digest and all — right
154
+ // above "no suite was published".
61
155
  function printResult(result) {
62
- if (result.status === 'error' || result.status === 'build_error') {
63
- return `${result.suite_kind}: not published ${result.error ?? 'the host could not build this suite'}.`;
156
+ if (!PUBLISHED.has(result.status)) {
157
+ // The reason often ends in a sentence of its own a server message, or a
158
+ // list of this branch's problems — so the closing stop is added only when
159
+ // there is not one already.
160
+ const reason = unpublishedReason(result);
161
+ return `${result.suite_kind}: not published — ${reason}${/[.!?]$/.test(reason.trim()) ? '' : '.'}`;
64
162
  }
65
163
  const tallies = result.counts
66
164
  ? Object.entries(result.counts)
@@ -70,3 +168,13 @@ function printResult(result) {
70
168
  const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
71
169
  return `${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.`;
72
170
  }
171
+ // The server's own words when it sent any; otherwise the best true thing that can
172
+ // be said. A status this connector does not know is quoted rather than guessed
173
+ // at — claiming the host could not build it would invent a cause.
174
+ function unpublishedReason(result) {
175
+ if (result.error)
176
+ return result.error;
177
+ if (result.status === 'build_error')
178
+ return 'the host could not build this suite';
179
+ return `the server answered "${result.status}"`;
180
+ }
package/dist/verbs/run.js CHANGED
@@ -5,12 +5,26 @@ import { runRspecSuite } from "../runner/rspec.js";
5
5
  import { runVitestSuite } from "../runner/vitest.js";
6
6
  import { runPytestSuite } from "../runner/pytest.js";
7
7
  import { runBddSuite } from "../runner/bdd.js";
8
+ import { enterUrl } from "../links.js";
8
9
  import { boundReport } from "../runner/boundReport.js";
9
10
  import { Wire } from "../wire.js";
10
11
  const OUTPUT_TAIL_CHARS = 2000;
12
+ // `check`/`run`: execute every ready peer. This is the standalone flow the user
13
+ // asks for by name, and the recovery path after an interrupted first run.
11
14
  export async function run(config, _args, deps) {
15
+ return execute(config, resolve(config, deps), null);
16
+ }
17
+ // The first run that `put-suite-build` performs itself (spec 32-4): execute
18
+ // exactly the suite identities publication just returned, or none of them. Every
19
+ // requested identity must still be the current one — see `select`. Callers pass a
20
+ // non-empty list; "nothing was published" is decided and reported one level up,
21
+ // where the publication results that explain it are still in hand.
22
+ export async function runOnly(config, digests, deps) {
23
+ return execute(config, resolve(config, deps), digests);
24
+ }
25
+ function resolve(config, deps) {
12
26
  const wire = new Wire(config);
13
- const d = {
27
+ return {
14
28
  getSuites: () => wire.getSuites(),
15
29
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
16
30
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
@@ -25,21 +39,41 @@ export async function run(config, _args, deps) {
25
39
  stdout: process.stdout,
26
40
  ...deps,
27
41
  };
42
+ }
43
+ async function execute(config, d, only) {
28
44
  const suites = await d.getSuites();
29
45
  const ready = suites.filter((item) => item.status === 'ready');
30
- if (ready.length === 0) {
46
+ const selected = only === null ? ready : select(ready, only);
47
+ if (selected.length === 0) {
31
48
  d.stdout.write('No Unitbob suites exist yet. Generate them first, then run the Unitbob checks again.\n');
32
49
  return;
33
50
  }
34
51
  const runs = [];
35
- for (const item of ready) {
52
+ for (const item of selected) {
36
53
  runs.push(await buildRunPayload(config, d, item));
37
54
  }
38
55
  const { results, map_url } = await d.postRunsBatch(runs);
39
56
  for (const result of results)
40
57
  d.stdout.write(`${result.summary}\n`);
58
+ // The token joins the address here rather than on the server, so it stays out
59
+ // of response bodies and out of the brain's logs (spec 33).
41
60
  if (map_url)
42
- d.stdout.write(`${map_url}\n`);
61
+ d.stdout.write(`${enterUrl(config, map_url)}\n`);
62
+ }
63
+ // All-or-nothing. Publication and this fetch are two requests, so another client
64
+ // can republish in between. Running whatever is current instead would file honest
65
+ // results against a version the user never asked about, and silently substituting
66
+ // an older suite for a branch that failed to publish would be worse still. So a
67
+ // requested identity that is no longer current stops the whole run.
68
+ function select(ready, wanted) {
69
+ const byDigest = new Map(ready.map((item) => [item.suite_digest ?? '', item]));
70
+ const missing = wanted.filter((digest) => !byDigest.has(digest));
71
+ if (missing.length > 0) {
72
+ throw new Error(`The suite this project just published (${missing.join(', ')}) is no longer the current one — ` +
73
+ 'something else replaced it while this command was running. Nothing was run, so no results were ' +
74
+ 'filed against the wrong version.');
75
+ }
76
+ return wanted.map((digest) => byDigest.get(digest));
43
77
  }
44
78
  // One branch's run payload. A stack mismatch, a materialize failure, or a runner
45
79
  // that produced no report all become this branch's structured suite error — the
@@ -1,5 +1,6 @@
1
+ import { consoleUrl } from "../links.js";
1
2
  export function repoUrl(config) {
2
- return `${config.server}/repos/${config.repoId}#map`;
3
+ return consoleUrl(config);
3
4
  }
4
5
  export async function show(config) {
5
6
  process.stdout.write(`${repoUrl(config)}\n`);
@@ -1,10 +1,47 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
1
  import { materializeHelper } from "../files/guardrails.js";
4
2
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
- import { anyStackPrecheck } from "../runner/precheck.js";
3
+ import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
4
+ import { anyStackPrecheck, detectBddRunner, detectStructuralRunner } from "../runner/precheck.js";
5
+ import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
6
6
  import { ensureRunner } from "../runner/provision.js";
7
7
  import { Wire } from "../wire.js";
8
+ // The complete envelope for one branch, or null when this machine cannot
9
+ // produce one: the server offered no combination this stack matches, or the
10
+ // behavioral runner's installed version could not be read back.
11
+ //
12
+ // Null stops the branch rather than handing the host a shape to fill in. The
13
+ // host's own composition is what this path replaced — a single wrong field is
14
+ // rejected at upload, hours after the suite was written, run, and reviewed —
15
+ // and a half-filled envelope fails the same way. Better to say so now, in one
16
+ // line, than to be told by the server at the end.
17
+ // Why a branch got no envelope, in the vibecoder's terms. The two causes sit on
18
+ // opposite sides of the wire and have opposite fixes, so they are never merged
19
+ // into one vague line.
20
+ function envelopeBlockedReason(packet, runner) {
21
+ if (!Array.isArray(packet.runner_manifests) || packet.runner_manifests.length === 0) {
22
+ return 'the Unitbob server sent no runner combinations with this assignment — it is older than this connector. ' +
23
+ 'Update the server (or the connector) so the two agree, then retry.';
24
+ }
25
+ if (!runner) {
26
+ return `this project matches none of the runners the server offered for the ${packet.suite_kind} suite.`;
27
+ }
28
+ return `the version of "${runner}" installed under .unitbob/behavioral/ could not be read, and the server requires it. ` +
29
+ 'Re-run `unitbob suite-prepare` so the runner is provisioned again.';
30
+ }
31
+ function runnerEnvelopeFor(packet, runner, projectRoot) {
32
+ const selected = runner
33
+ ?? (packet.suite_kind === 'behavioral'
34
+ ? detectBddRunner(projectRoot) ?? undefined
35
+ : detectStructuralRunner(projectRoot) ?? undefined);
36
+ const envelope = selectRunnerEnvelope(packet.runner_manifests, selected);
37
+ if (!envelope || !selected)
38
+ return envelope;
39
+ // Only the behavioral runner is provisioned into an isolated environment, so
40
+ // it is the only one with an installed version to record.
41
+ return packet.suite_kind === 'behavioral'
42
+ ? withInstalledRunnerVersion(envelope, selected, projectRoot)
43
+ : envelope;
44
+ }
8
45
  // Confirm at least one supported stack is present, materialize the Ruby boot
9
46
  // helper a generated RSpec suite would require, then fetch both peer assignments
10
47
  // (spec 32) and each branch's recipe, and write the host's task to
@@ -20,7 +57,9 @@ export async function suitePrepare(config, args = [], deps) {
20
57
  getRecipe: (name) => wire.getRecipe(name),
21
58
  getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
22
59
  precheck: anyStackPrecheck,
60
+ bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
23
61
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
62
+ runnerEnvelope: runnerEnvelopeFor,
24
63
  stdout: process.stdout,
25
64
  ...deps,
26
65
  };
@@ -28,6 +67,23 @@ export async function suitePrepare(config, args = [], deps) {
28
67
  if (!check.ok)
29
68
  throw new Error(check.message ?? 'Unsupported runtime.');
30
69
  materializeHelper(config.projectRoot);
70
+ // Spec 32-6. Before anything is fetched or written, find out whether the suite
71
+ // would get off the ground at all. It runs here, after the boot helper exists
72
+ // and before the network, so a project whose suite cannot start costs one
73
+ // command instead of a full generation.
74
+ //
75
+ // There is no `--on-broken-boot` flag and no mode. The decision is not a
76
+ // policy we could reasonably let a user set — it follows from the fact: we
77
+ // tried to load the thing the suite starts with, it did not load, therefore
78
+ // not one test would reach its first assertion. Debugging generation against a
79
+ // knowingly dead project is our problem, not the vibecoder's.
80
+ // The stack the precheck just identified, rather than a second detection of
81
+ // the same thing: on Python that would shell out to pytest all over again.
82
+ const structuralRunner = check.runner ?? null;
83
+ const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
84
+ if (boot.status === 'broken')
85
+ throw new Error(bootFinding(boot, structuralRunner));
86
+ actual.stdout.write(bootFinding(boot, structuralRunner));
31
87
  const packets = await actual.getSuitePacketsBatch();
32
88
  // Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
33
89
  // A `fixable` outcome (no package manager available to install the runner) is an infrastructure
@@ -38,7 +94,8 @@ export async function suitePrepare(config, args = [], deps) {
38
94
  const fixableNotices = [];
39
95
  const buildable = [];
40
96
  for (const packet of packets) {
41
- const runner = packet.runner ?? (packet.suite_kind === 'behavioral' ? inferBddRunner(config.projectRoot) : undefined);
97
+ const runner = packet.runner
98
+ ?? (packet.suite_kind === 'behavioral' ? detectBddRunner(config.projectRoot) ?? undefined : undefined);
42
99
  if (runner && (packet.suite_kind === 'behavioral' || ['cucumber', 'cucumber-js', 'pytest-bdd'].includes(runner))) {
43
100
  const prov = await actual.ensureRunner(config.projectRoot, runner);
44
101
  if (prov.status === 'fixable') {
@@ -47,15 +104,42 @@ export async function suitePrepare(config, args = [], deps) {
47
104
  continue;
48
105
  }
49
106
  }
50
- buildable.push(packet);
107
+ buildable.push({ packet, runner });
108
+ }
109
+ const prepared = await Promise.all(buildable.map(async ({ packet, runner }) => {
110
+ // Read after provisioning, never before: the behavioral envelope carries
111
+ // the version that is now installed in the sidecar.
112
+ const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
113
+ if (!manifest)
114
+ return { packet, runner, branch: null };
115
+ return {
116
+ packet,
117
+ runner,
118
+ branch: {
119
+ suite_kind: packet.suite_kind,
120
+ source_digest: packet.source_digest,
121
+ path_root: packet.path_root,
122
+ recipe: await actual.getRecipe(recipeNameFor(packet)),
123
+ assignment: packet.assignment,
124
+ runner_manifest: manifest,
125
+ },
126
+ };
127
+ }));
128
+ // A branch without a complete envelope is not handed to the host at all. The
129
+ // host has nothing to compose it from — that composition is what this replaced
130
+ // — so writing the branch anyway only moves the same rejection to the end of
131
+ // the run, hours later.
132
+ const branches = [];
133
+ const blockedNotices = [];
134
+ for (const { packet, runner, branch } of prepared) {
135
+ if (branch)
136
+ branches.push(branch);
137
+ else
138
+ blockedNotices.push(` ${packet.suite_kind}: ${envelopeBlockedReason(packet, runner)}`);
139
+ }
140
+ if (branches.length === 0) {
141
+ throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
51
142
  }
52
- const branches = await Promise.all(buildable.map(async (packet) => ({
53
- suite_kind: packet.suite_kind,
54
- source_digest: packet.source_digest,
55
- path_root: packet.path_root,
56
- recipe: await actual.getRecipe(recipeNameFor(packet)),
57
- assignment: packet.assignment,
58
- })));
59
143
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
60
144
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
61
145
  const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
@@ -73,7 +157,102 @@ export async function suitePrepare(config, args = [], deps) {
73
157
  fixableNotices.join('\n') +
74
158
  '\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
75
159
  }
160
+ // Same shape, different cause: this branch has an assignment but no runner
161
+ // envelope to upload it with, so it is not offered to the host at all. Its
162
+ // peer above is unaffected.
163
+ if (blockedNotices.length > 0) {
164
+ actual.stdout.write('\nOne suite branch was left out of this run — it has no runner manifest, and the server ' +
165
+ 'accepts an upload only with one:\n' +
166
+ blockedNotices.join('\n') +
167
+ '\n');
168
+ }
76
169
  }
170
+ // What the boot check found, in the vibecoder's terms. Printed on every run,
171
+ // including the quiet ones: "we looked and it starts" and "we could not look"
172
+ // are both worth a line, and a check nobody hears about is a check nobody
173
+ // trusts.
174
+ //
175
+ // A stop here is a finding, not a refusal, and the wording has to carry that.
176
+ // "We found the defect that stops your suite from starting" and "we could not
177
+ // build your suite" describe the same event and leave the reader in completely
178
+ // different places.
179
+ function bootFinding(boot, runner) {
180
+ // Both halves of "what this answer is worth" travel together, on every
181
+ // outcome. Splitting them is how the stack caveat came to be missing from
182
+ // `broken`, and pinning `STRUCTURAL_ONLY` to `ok` alone would have repeated
183
+ // that in the same breath as the fix: on Rails the stack caveat reads
184
+ // "whatever stops one stops the other", which is an unscoped claim about a
185
+ // branch nobody asked — loudest exactly where the run stops for both.
186
+ // Empties are dropped rather than joined blindly, so a runner with no caveat
187
+ // of its own does not leave a blank line behind.
188
+ const caveat = [runner ? SIGNAL_STRENGTH[runner] : '', runner ? STRUCTURAL_ONLY : '']
189
+ .filter(Boolean)
190
+ .map((line) => `\n${line}`)
191
+ .join('');
192
+ if (boot.status === 'ok') {
193
+ return `Checked that the suite can start: it does.${caveat}\n`;
194
+ }
195
+ if (boot.status === 'not_checked') {
196
+ // Not checked is not broken, and nothing downstream may treat it as such.
197
+ // Conflating the two would block honest projects — the whole reason this
198
+ // state is named for what happened rather than for what we know.
199
+ return `${NOT_CHECKED_REASON[boot.reason]} Generation continues.${caveat}\n`;
200
+ }
201
+ const headline = boot.cause === 'defect_in_code'
202
+ ? 'Found a defect that stops your test suite from starting.'
203
+ : 'Your test suite cannot start yet — its environment is not ready.';
204
+ // The runner's own words. Everything else on screen is ours; this line is
205
+ // the one the vibecoder can paste into a search.
206
+ const next = boot.cause === 'defect_in_code'
207
+ ? 'Fix that, then run `unitbob suite-prepare` again.'
208
+ : "Unitbob does not install your project's own dependencies — that would rewrite your Gemfile.lock " +
209
+ 'or package-lock.json. Run the install your project needs (`bundle install`, `npm install`, ' +
210
+ '`pip install -r requirements.txt`), then run `unitbob suite-prepare` again.';
211
+ return (`${headline}\n\n` +
212
+ ` ${boot.message}\n\n` +
213
+ `${boot.detail}\n\n` +
214
+ 'No suite was written and nothing was uploaded — every test would have died on that line ' +
215
+ // The caveat belongs here most of all, and this was the one branch it did
216
+ // not reach — found on the fifth implementation review, 2026-08-03. On
217
+ // pytest and vitest the check collects the project's whole test tree, so
218
+ // the line above may come from a test of the project's own that the Unitbob
219
+ // suite would never have imported. Printing "found a defect" and keeping
220
+ // that back sends someone to fix a file this product was never going to
221
+ // touch, which is the same over-claim the spec accepted the wide check only
222
+ // on condition of disclosing.
223
+ `before reaching its first assertion. ${next}${caveat}\n`);
224
+ }
225
+ // Spec 32-6 says the boot rule is one rule for both branches; this check asks
226
+ // one of them. It is made against the *structural* runner, which is what
227
+ // `precheck` identified and what the materialized helper belongs to. The
228
+ // behavioral branch starts elsewhere — cucumber with its own `features/support`,
229
+ // cucumber-js with its own — and there is nothing of ours to load there yet:
230
+ // at this point in `suite-prepare` the behavioral suite has not been generated.
231
+ // Asking the question anyway would mean booting the project's own feature
232
+ // files, which is *wider* than the condition that stops the Unitbob run — the
233
+ // one thing this module's governing rule forbids ("the condition we test must
234
+ // equal the condition that makes a run impossible, never exceed it").
235
+ //
236
+ // So the boundary is stated instead of crossed. Recorded on the fifth
237
+ // implementation review, 2026-08-03, and written into the spec beside it.
238
+ const STRUCTURAL_ONLY = 'This says nothing about the product-behaviour branch: it starts with a runner of its own, ' +
239
+ 'which has nothing of ours to load until its suite exists, so it was not asked.';
240
+ const NOT_CHECKED_REASON = {
241
+ no_runner: 'Did not check whether the suite can start: no runner available to load it with.',
242
+ // Distinct from `no_runner` on purpose. The runner is installed and working;
243
+ // it is only too old to be asked this particular question, and "no runner
244
+ // available" would send someone to fix a thing that is not broken.
245
+ runner_too_old: 'Did not check whether the suite can start: the installed runner is too old to be asked. ' +
246
+ 'Nothing is wrong with it — this check simply has no way to pose the question to that version.',
247
+ // Distinct for the same reason, one step further along: the runner is there
248
+ // and current, it was reached, and it declined to answer — pytest exiting on
249
+ // a usage or internal error of its own. That says nothing about the project,
250
+ // and "no runner available" would again send someone after the wrong thing.
251
+ runner_could_not_answer: 'Did not check whether the suite can start: the runner could not answer the question — it ' +
252
+ 'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
253
+ timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
254
+ nothing_to_load: 'Did not check whether the suite can start: there was nothing to load yet.',
255
+ };
77
256
  function knownDefectContext(args) {
78
257
  const defect = option(args, '--known-defect=');
79
258
  const fixedRevision = option(args, '--fixed-revision=');
@@ -100,10 +279,3 @@ function option(args, prefix) {
100
279
  throw new Error(`${prefix.slice(0, -1)} requires a value.`);
101
280
  return value || undefined;
102
281
  }
103
- function inferBddRunner(projectRoot) {
104
- if (existsSync(join(projectRoot, 'package.json')))
105
- return 'cucumber-js';
106
- if (['pyproject.toml', 'requirements.txt', 'Pipfile'].some((f) => existsSync(join(projectRoot, f))))
107
- return 'pytest-bdd';
108
- return 'cucumber';
109
- }