unitbob 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,15 +3,20 @@
3
3
  // trace. This module decides exit codes but never acts on them: importing it must
4
4
  // stay free of side effects so tests can drive `main` directly. `bin.ts` is the
5
5
  // executable that owns process startup and exit.
6
+ //
7
+ // One verb is more than a dispatch: `put-suite-build` composes two hands-verbs
8
+ // into a single user operation, so `publishAndRun` at the bottom of this file
9
+ // holds that sequence and the exit code it implies (spec 32-4). Every other verb
10
+ // keeps its whole flow in its own module under `verbs/`.
6
11
  import { ensureLinked } from "./link.js";
7
12
  import { recipe } from "./verbs/recipe.js";
8
13
  import { show } from "./verbs/show.js";
9
- import { run } from "./verbs/run.js";
14
+ import { run, runOnly } from "./verbs/run.js";
10
15
  import { init } from "./verbs/init.js";
11
16
  import { mapPrepare } from "./verbs/mapPrepare.js";
12
17
  import { putMapBuild } from "./verbs/putMapBuild.js";
13
18
  import { suitePrepare } from "./verbs/suitePrepare.js";
14
- import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
19
+ import { classifyPublication, putSuiteBuild } from "./verbs/putSuiteBuild.js";
15
20
  import { fixPrepare } from "./verbs/fixPrepare.js";
16
21
  import { contractPrompt } from "./verbs/contractPrompt.js";
17
22
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
@@ -27,7 +32,8 @@ Verbs:
27
32
  put-map-build Internal: upload the host-built map and graph.
28
33
  suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
29
34
  suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
30
- put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
35
+ put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
36
+ then run every branch it published and report the server's results.
31
37
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
32
38
  contract-prompt <digest> <test_id> [fix|accept]
33
39
  Internal: fetch the fix/accept brief for one red check on either map.
@@ -37,7 +43,8 @@ Verbs:
37
43
  Pipeline: map and suite are built on your machine. \`*-prepare\` writes a request
38
44
  packet (with the recipe and paths); you build the artifact at the packet's
39
45
  output_path from your local source; \`put-*\` uploads only the structured result.
40
- \`check\` runs the guardrails locally and reports results to the server.
46
+ \`put-suite-build\` then runs what it published, so a suite never sits with nothing
47
+ to show. \`check\` re-runs the guardrails locally and reports results to the server.
41
48
  Graph extraction is keyless: the connector needs no LLM API key. Inference is the
42
49
  host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify skill).
43
50
 
@@ -73,8 +80,7 @@ export async function main(argv, deps = { ensureLinked }) {
73
80
  await suiteReviewPrepare(await deps.ensureLinked(), args);
74
81
  return 0;
75
82
  case 'put-suite-build':
76
- await putSuiteBuild(await deps.ensureLinked(), args);
77
- return 0;
83
+ return await publishAndRun(await deps.ensureLinked(), args);
78
84
  case 'fix-prepare':
79
85
  await fixPrepare(await deps.ensureLinked(), args);
80
86
  return 0;
@@ -95,3 +101,48 @@ export async function main(argv, deps = { ensureLinked }) {
95
101
  return 1;
96
102
  }
97
103
  }
104
+ // Publishing a suite and running it the first time are one user operation
105
+ // (spec 32-4). They used to be two commands, and the second one was a step the
106
+ // host LLM could simply not take: the suite was stored, nothing had ever run it,
107
+ // and the user was told about results that did not exist. Now the connector owns
108
+ // the sequence, so no instruction-following can drop half of it.
109
+ //
110
+ // This is a composition, not a transaction. The two server requests stay separate:
111
+ // if the run cannot finish, the published suite and any earlier results for the
112
+ // same identity survive untouched, and a standalone `unitbob check` completes the
113
+ // loop. The two outputs also keep separate authorities — which branch published
114
+ // comes from the build response, and what the results are comes only from the
115
+ // server's answer to the run.
116
+ export async function publishAndRun(config, args, deps) {
117
+ const d = {
118
+ putSuiteBuild: (cfg, a) => putSuiteBuild(cfg, a),
119
+ runOnly: (cfg, digests) => runOnly(cfg, digests),
120
+ stdout: process.stdout,
121
+ stderr: process.stderr,
122
+ ...deps,
123
+ };
124
+ const { digests, unpublished } = classifyPublication(await d.putSuiteBuild(config, args));
125
+ // Nothing is current, so there is nothing honest to run. A branch that failed
126
+ // to publish must never fall back to the suite it was meant to replace.
127
+ if (digests.length === 0) {
128
+ d.stderr.write('No suite was published, so nothing was run. Fix the problems reported above and generate the Unitbob guardrails again.\n');
129
+ return 1;
130
+ }
131
+ // Said between the two halves, because it is a fact about publication and the
132
+ // run summaries have not been printed yet. Reading a peer's results onto a
133
+ // branch that was never published is the exact mistake this whole spec exists
134
+ // to stop, so the output names the gap instead of leaving it to be inferred.
135
+ if (unpublished.length > 0) {
136
+ d.stdout.write(`Partial success. No run summary below covers: ${unpublished.join(', ')}.\n`);
137
+ }
138
+ try {
139
+ await d.runOnly(config, digests);
140
+ }
141
+ catch (err) {
142
+ // The upload never happened, so no results were stored — partially or
143
+ // otherwise. Everything published above is still valid and still current.
144
+ d.stderr.write(`${err.message}\nThe suite is published. Run the Unitbob checks to finish.\n`);
145
+ return 1;
146
+ }
147
+ return 0;
148
+ }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { assertUnitbobPath } from "./artifactPath.js";
4
4
  // The behavioral suite lives under its own root: the main `.feature` plus its
@@ -34,6 +34,39 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
34
34
  }
35
35
  return { mainPath };
36
36
  }
37
+ // Everything under the behavioral root that the next materialization will
38
+ // delete: it wipes every top-level entry outside the runner environment and
39
+ // writes back only the files the answer listed, so a step file the answer forgot
40
+ // is gone and its steps come back undefined — a harness break reported far from
41
+ // its cause. One file per capability makes forgetting one much easier than a
42
+ // single file for the whole product ever did.
43
+ //
44
+ // The whole root is walked, not just the directories the answer happens to use:
45
+ // the file most likely to be forgotten is the one in a directory the answer
46
+ // never mentions — `features/support/env.rb` is exactly that shape.
47
+ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
48
+ const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
49
+ if (!existsSync(behavioralRoot))
50
+ return [];
51
+ const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
52
+ const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
53
+ return readdirSync(behavioralRoot)
54
+ .filter((entry) => !runnerEntries.has(entry))
55
+ .flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
56
+ .filter((path) => !listed.has(path))
57
+ .sort();
58
+ }
59
+ // The real files under `relative`. Symlinks are not followed: materialization
60
+ // removes the link, not what it points at, and naming the target here would read
61
+ // as a warning about a file that was never in danger.
62
+ function filesUnder(projectRoot, relative) {
63
+ const stats = lstatSync(join(projectRoot, relative));
64
+ if (stats.isFile())
65
+ return [relative];
66
+ if (!stats.isDirectory())
67
+ return [];
68
+ return readdirSync(join(projectRoot, relative)).flatMap((entry) => filesUnder(projectRoot, `${relative}/${entry}`));
69
+ }
37
70
  export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
38
71
  const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
39
72
  for (const entry of entries) {
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, sep } from 'node:path';
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
5
5
  export function requestPath(projectRoot) {
6
6
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
@@ -14,25 +14,40 @@ export function reviewOutputPath(projectRoot) {
14
14
  export function reviewRequestPath(projectRoot) {
15
15
  return join(projectRoot, '.unitbob', 'suite-build', 'review-request.json');
16
16
  }
17
+ export function candidateRunPath(projectRoot) {
18
+ return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
19
+ }
17
20
  export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
18
21
  const metadata = output.test_metadata;
19
22
  const candidateDigest = suiteCandidateDigest(output);
23
+ const evidence = {
24
+ candidate_digest: candidateDigest,
25
+ known_defect_context: knownDefectContext,
26
+ candidate_run: { candidate_digest: candidateDigest, ...candidateRun },
27
+ ...(fixedCandidateRun ? {
28
+ fixed_candidate_run: { candidate_digest: candidateDigest, ...fixedCandidateRun },
29
+ } : {}),
30
+ };
31
+ writeArtifact(candidateRunPath(projectRoot), evidence);
20
32
  const request = {
21
33
  candidate_digest: candidateDigest,
22
34
  suite_file: output.suite_file,
23
35
  capabilities: metadata?.capabilities,
24
36
  output_path: reviewOutputPath(projectRoot),
25
37
  known_defect_context: knownDefectContext,
26
- candidate_run: { candidate_digest: candidateDigest, ...candidateRun },
27
- ...(fixedCandidateRun ? {
28
- fixed_candidate_run: { candidate_digest: candidateDigest, ...fixedCandidateRun },
38
+ // Only a supplied defect gives the reviewer something to read a run for.
39
+ ...(knownDefectContext.status === 'supplied' ? {
40
+ candidate_run: evidence.candidate_run,
41
+ ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
29
42
  } : {}),
30
43
  };
31
- const path = reviewRequestPath(projectRoot);
32
- mkdirSync(dirname(path), { recursive: true });
33
- writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
44
+ writeArtifact(reviewRequestPath(projectRoot), request);
34
45
  return request;
35
46
  }
47
+ function writeArtifact(path, value) {
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
50
+ }
36
51
  // The connector-owned runner strategy this branch selected. Reading it out of
37
52
  // the envelope is transport, like every other `runner_manifest` access in this
38
53
  // module — callers only ever get back the strategy name they dispatch on.
@@ -77,36 +92,79 @@ export function readBehavioralReview(projectRoot, output) {
77
92
  }
78
93
  const review = parseJson(readFileSync(path, 'utf8'), path);
79
94
  if (!review || review.candidate_digest !== suiteCandidateDigest(output)) {
80
- throw new Error(`${path} does not review the current behavioral suite candidate.`);
95
+ throw new Error(`${path} ${staleReviewReason(projectRoot, review, output)}`);
81
96
  }
82
97
  if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
83
98
  throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
84
99
  }
85
- const request = readBehavioralReviewRequest(projectRoot, output);
100
+ const evidence = readCandidateRunEvidence(projectRoot, output);
86
101
  return {
87
102
  ...review,
88
- candidate_run: request.candidate_run,
89
- ...(request.fixed_candidate_run ? { fixed_candidate_run: request.fixed_candidate_run } : {}),
103
+ candidate_run: evidence.candidate_run,
104
+ ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
90
105
  };
91
106
  }
92
- function readBehavioralReviewRequest(projectRoot, output) {
93
- const path = reviewRequestPath(projectRoot);
94
- const request = parseJson(readFileSync(path, 'utf8'), path);
95
- const run = request?.candidate_run;
107
+ // Why a review does not bind — two different mistakes with one symptom.
108
+ //
109
+ // The likelier one, now that a branch may answer with a bare `{ path }`: the
110
+ // review was right when it was written, and the candidate changed afterwards.
111
+ // Editing one step file is enough, and nothing in the answer has to change for
112
+ // it — so the digest moves with no visible cause, and "this does not review the
113
+ // current candidate" sends the reader off to audit the reviewer instead of their
114
+ // own last edit.
115
+ //
116
+ // The connector's own evidence file settles which mistake it was: it carries the
117
+ // digest of the candidate that was actually run at review time. Agreeing with
118
+ // the review and disagreeing with what is on disk now means the candidate moved
119
+ // after the review. Otherwise the review really is about a different candidate.
120
+ function staleReviewReason(projectRoot, review, output) {
121
+ const evidencePath = candidateRunPath(projectRoot);
122
+ if (review && existsSync(evidencePath)) {
123
+ const evidence = parseJson(readFileSync(evidencePath, 'utf8'), evidencePath);
124
+ if (evidence && evidence.candidate_digest === review.candidate_digest) {
125
+ return 'reviewed this suite as it stood at review time, and it has changed since — re-run ' +
126
+ '`unitbob suite-review-prepare` and have the reviewer look at the changed suite.';
127
+ }
128
+ }
129
+ return 'does not review the current behavioral suite candidate.';
130
+ }
131
+ // The runs the connector made of this exact candidate, and the defect choice
132
+ // they were made under. One file, read on its own: the evidence the upload
133
+ // carries must not depend on what the reviewer was shown, and reaching back into
134
+ // the reviewer's request for the defect choice would have re-created exactly
135
+ // that dependency.
136
+ function readCandidateRunEvidence(projectRoot, output) {
137
+ const path = candidateRunPath(projectRoot);
138
+ if (!existsSync(path)) {
139
+ throw new Error(`${path} not found — run \`unitbob suite-review-prepare\` to record the candidate's run.`);
140
+ }
141
+ const file = parseJson(readFileSync(path, 'utf8'), path);
96
142
  const digest = suiteCandidateDigest(output);
97
- if (!request || request.candidate_digest !== digest || !run || run.candidate_digest !== digest ||
98
- typeof run.revision !== 'string' || !run.revision || typeof run.run_result !== 'string' || !run.run_result) {
143
+ if (!file || file.candidate_digest !== digest || !isRunEvidence(file.candidate_run, digest)) {
99
144
  throw new Error(`${path} has no connector runner evidence for the current behavioral candidate.`);
100
145
  }
101
- const context = request.known_defect_context;
102
- if (context?.status === 'supplied' && context.fixed_revision) {
103
- const fixed = request.fixed_candidate_run;
104
- if (!fixed || fixed.candidate_digest !== digest || fixed.revision !== context.fixed_revision ||
105
- typeof fixed.run_result !== 'string' || !fixed.run_result) {
146
+ // Demanded, not defaulted. `readKnownDefectContext` reads a missing field as
147
+ // `not_supplied`, which is right for a file someone else may have written and
148
+ // wrong here: the connector writes this one itself, always with the choice it
149
+ // was given. Missing means the file is damaged, and taking it as "no defect
150
+ // was supplied" would turn that into a silently skipped fixed-revision check.
151
+ if (file.known_defect_context === undefined) {
152
+ throw new Error(`${path} records no known_defect_context — run \`unitbob suite-review-prepare\` again.`);
153
+ }
154
+ const context = readKnownDefectContext(file.known_defect_context, path);
155
+ if (context.status === 'supplied' && context.fixed_revision) {
156
+ const fixed = file.fixed_candidate_run;
157
+ if (!isRunEvidence(fixed, digest) || fixed?.revision !== context.fixed_revision) {
106
158
  throw new Error(`${path} has no connector runner evidence for fixed revision ${context.fixed_revision}.`);
107
159
  }
108
160
  }
109
- return request;
161
+ return file;
162
+ }
163
+ function isRunEvidence(value, digest) {
164
+ const run = value;
165
+ return Boolean(run && run.candidate_digest === digest &&
166
+ typeof run.revision === 'string' && run.revision &&
167
+ typeof run.run_result === 'string' && run.run_result);
110
168
  }
111
169
  export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }) {
112
170
  const request = {
@@ -173,9 +231,9 @@ export function readHostSuiteOutputs(path, request) {
173
231
  throw new Error(`${path} is malformed: expected a branches array, one entry per contract system.`);
174
232
  }
175
233
  const rootFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root]));
176
- return branches.map((entry) => readBranch(entry, rootFor, path));
234
+ return branches.map((entry) => readBranch(entry, rootFor, path, request.project_root));
177
235
  }
178
- function readBranch(entry, rootFor, path) {
236
+ function readBranch(entry, rootFor, path, projectRoot) {
179
237
  if (!entry || typeof entry !== 'object') {
180
238
  throw new Error(`${path} is malformed: each branch must be an object.`);
181
239
  }
@@ -201,32 +259,64 @@ function readBranch(entry, rootFor, path) {
201
259
  }
202
260
  return {
203
261
  suite_kind: suiteKind,
204
- suite_file: resolveSuiteFile(branch.suite_file, root, path, suiteKind),
262
+ suite_file: resolveSuiteFile(branch.suite_file, root, path, suiteKind, projectRoot),
205
263
  runner_manifest: manifest,
206
264
  test_metadata: branch.test_metadata,
207
265
  };
208
266
  }
209
- // The host inlines every file's `content`; each path is checked safe under this
210
- // branch's root before anything is accepted.
211
- function resolveSuiteFile(file, root, path, suiteKind) {
267
+ // Each path is checked safe under this branch's root before anything is
268
+ // accepted. `content` may be inlined, but it does not have to be: the host wrote
269
+ // and ran these files on disk before answering, so a bare `{ path }` means "the
270
+ // file already under that path is the answer". Re-serializing a whole suite into
271
+ // this JSON on every rebuild was the single largest cost in the build loop, and
272
+ // the copy was never more trustworthy than the file that was actually executed.
273
+ function resolveSuiteFile(file, root, path, suiteKind, projectRoot) {
212
274
  if (!file || typeof file !== 'object') {
213
275
  throw new Error(`${path}: the ${suiteKind} branch is missing suite_file.`);
214
276
  }
215
277
  const envelope = file;
216
- const main = readOneFile(envelope, root, path, suiteKind, false);
278
+ const main = readOneFile(envelope, root, path, suiteKind, false, projectRoot);
217
279
  const support = Array.isArray(envelope.support_files)
218
- ? envelope.support_files.map((entry) => readOneFile(entry, root, path, suiteKind, true))
280
+ ? envelope.support_files.map((entry) => readOneFile(entry, root, path, suiteKind, true, projectRoot))
219
281
  : [];
220
282
  return support.length > 0 ? { ...main, support_files: support } : main;
221
283
  }
222
- function readOneFile(file, root, path, suiteKind, support) {
284
+ function readOneFile(file, root, path, suiteKind, support, projectRoot) {
223
285
  const filePath = typeof file.path === 'string' ? file.path : '';
224
286
  assertUnitbobPath(filePath, root);
225
287
  if (typeof file.content === 'string' && file.content.trim()) {
226
288
  return { path: filePath, content: file.content };
227
289
  }
228
- const label = support ? 'support file' : 'suite_file';
229
- throw new Error(`${path}: the ${suiteKind} ${label} at "${filePath}" has no inline content.`);
290
+ if (file.content !== undefined) {
291
+ throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" has empty content.`);
292
+ }
293
+ const onDisk = join(projectRoot, filePath);
294
+ if (!existsSync(onDisk)) {
295
+ throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} names "${filePath}", but no such file exists — write it before answering, or inline its content.`);
296
+ }
297
+ // `assertUnitbobPath` judges the path text; it cannot see that a safe-looking
298
+ // name is a link out of the suite root — nor that the directory holding it is.
299
+ // Inlined content could never reach outside the answer, so reading from disk
300
+ // is where that check has to be made: these bytes are uploaded.
301
+ //
302
+ // Only the project root is resolved, and the suite root is then joined onto it
303
+ // as text. Resolving the suite root too would let a linked `.unitbob/<kind>/`
304
+ // vouch for itself — every file under it resolves neatly under the link's own
305
+ // target. Resolving nothing at all would fail honest projects instead, because
306
+ // a macOS temp or home path is itself reached through a link.
307
+ // The wire's `path_root` carries a trailing slash; `join` keeps it.
308
+ const suiteRoot = join(realpathSync(projectRoot), root).replace(/[\\/]+$/, '');
309
+ if (!realpathSync(onDisk).startsWith(`${suiteRoot}${sep}`)) {
310
+ throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" resolves outside the suite root — suite files must be real files under it.`);
311
+ }
312
+ const content = readFileSync(onDisk, 'utf8');
313
+ if (!content.trim()) {
314
+ throw new Error(`${path}: the ${suiteKind} ${fileLabel(support)} at "${filePath}" is empty.`);
315
+ }
316
+ return { path: filePath, content };
317
+ }
318
+ function fileLabel(support) {
319
+ return support ? 'support file' : 'suite_file';
230
320
  }
231
321
  function parseJson(raw, path) {
232
322
  try {
@@ -6,6 +6,9 @@ import { Wire } from "../wire.js";
6
6
  // the host cannot claim a different map than each branch was given. A branch the
7
7
  // host could not build is uploaded as a `build_error`, which never rolls back the
8
8
  // peer branch. If a branch's answer is unparseable, nothing is uploaded.
9
+ //
10
+ // Returns the server's per-branch results so the caller can compose the first run
11
+ // on top of them (spec 32-4) without parsing the lines printed here.
9
12
  export async function putSuiteBuild(config, _args = [], deps) {
10
13
  const request = readSuiteBuildRequest(config.projectRoot);
11
14
  const outputs = readHostSuiteOutputs(request.output_path, request);
@@ -57,10 +60,37 @@ export async function putSuiteBuild(config, _args = [], deps) {
57
60
  for (const result of results) {
58
61
  d.stdout.write(`${printResult(result)}\n`);
59
62
  }
63
+ return results;
60
64
  }
65
+ // The three outcomes that leave a branch published and current: a new version, an
66
+ // identical version already stored, or a reactivated one. Each returns the
67
+ // identity to run. Everything else — a rejected branch, a branch the host could
68
+ // not build, or a status this connector has never seen — fails closed and is
69
+ // never run, so a newer server can never trick an older connector into running
70
+ // something it does not understand.
71
+ const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
72
+ export function classifyPublication(results) {
73
+ const split = { digests: [], unpublished: [] };
74
+ for (const result of results) {
75
+ if (!PUBLISHED.has(result.status)) {
76
+ split.unpublished.push(result.suite_kind);
77
+ continue;
78
+ }
79
+ if (!result.suite_digest) {
80
+ throw new Error(`The server accepted the ${result.suite_kind} suite as "${result.status}" but returned no identity ` +
81
+ 'to run it by. The suite is published; run the Unitbob checks to finish.');
82
+ }
83
+ split.digests.push(result.suite_digest);
84
+ }
85
+ return split;
86
+ }
87
+ // Asks `PUBLISHED` rather than naming the failing statuses again: this line and
88
+ // the run that follows it must agree about what "published" means, or a branch the
89
+ // command skipped gets a line that reads like a success — digest and all — right
90
+ // above "no suite was published".
61
91
  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'}.`;
92
+ if (!PUBLISHED.has(result.status)) {
93
+ return `${result.suite_kind}: not published — ${unpublishedReason(result)}.`;
64
94
  }
65
95
  const tallies = result.counts
66
96
  ? Object.entries(result.counts)
@@ -70,3 +100,13 @@ function printResult(result) {
70
100
  const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
71
101
  return `${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.`;
72
102
  }
103
+ // The server's own words when it sent any; otherwise the best true thing that can
104
+ // be said. A status this connector does not know is quoted rather than guessed
105
+ // at — claiming the host could not build it would invent a cause.
106
+ function unpublishedReason(result) {
107
+ if (result.error)
108
+ return result.error;
109
+ if (result.status === 'build_error')
110
+ return 'the host could not build this suite';
111
+ return `the server answered "${result.status}"`;
112
+ }
package/dist/verbs/run.js CHANGED
@@ -8,9 +8,22 @@ import { runBddSuite } from "../runner/bdd.js";
8
8
  import { boundReport } from "../runner/boundReport.js";
9
9
  import { Wire } from "../wire.js";
10
10
  const OUTPUT_TAIL_CHARS = 2000;
11
+ // `check`/`run`: execute every ready peer. This is the standalone flow the user
12
+ // asks for by name, and the recovery path after an interrupted first run.
11
13
  export async function run(config, _args, deps) {
14
+ return execute(config, resolve(config, deps), null);
15
+ }
16
+ // The first run that `put-suite-build` performs itself (spec 32-4): execute
17
+ // exactly the suite identities publication just returned, or none of them. Every
18
+ // requested identity must still be the current one — see `select`. Callers pass a
19
+ // non-empty list; "nothing was published" is decided and reported one level up,
20
+ // where the publication results that explain it are still in hand.
21
+ export async function runOnly(config, digests, deps) {
22
+ return execute(config, resolve(config, deps), digests);
23
+ }
24
+ function resolve(config, deps) {
12
25
  const wire = new Wire(config);
13
- const d = {
26
+ return {
14
27
  getSuites: () => wire.getSuites(),
15
28
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
16
29
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
@@ -25,14 +38,17 @@ export async function run(config, _args, deps) {
25
38
  stdout: process.stdout,
26
39
  ...deps,
27
40
  };
41
+ }
42
+ async function execute(config, d, only) {
28
43
  const suites = await d.getSuites();
29
44
  const ready = suites.filter((item) => item.status === 'ready');
30
- if (ready.length === 0) {
45
+ const selected = only === null ? ready : select(ready, only);
46
+ if (selected.length === 0) {
31
47
  d.stdout.write('No Unitbob suites exist yet. Generate them first, then run the Unitbob checks again.\n');
32
48
  return;
33
49
  }
34
50
  const runs = [];
35
- for (const item of ready) {
51
+ for (const item of selected) {
36
52
  runs.push(await buildRunPayload(config, d, item));
37
53
  }
38
54
  const { results, map_url } = await d.postRunsBatch(runs);
@@ -41,6 +57,21 @@ export async function run(config, _args, deps) {
41
57
  if (map_url)
42
58
  d.stdout.write(`${map_url}\n`);
43
59
  }
60
+ // All-or-nothing. Publication and this fetch are two requests, so another client
61
+ // can republish in between. Running whatever is current instead would file honest
62
+ // results against a version the user never asked about, and silently substituting
63
+ // an older suite for a branch that failed to publish would be worse still. So a
64
+ // requested identity that is no longer current stops the whole run.
65
+ function select(ready, wanted) {
66
+ const byDigest = new Map(ready.map((item) => [item.suite_digest ?? '', item]));
67
+ const missing = wanted.filter((digest) => !byDigest.has(digest));
68
+ if (missing.length > 0) {
69
+ throw new Error(`The suite this project just published (${missing.join(', ')}) is no longer the current one — ` +
70
+ 'something else replaced it while this command was running. Nothing was run, so no results were ' +
71
+ 'filed against the wrong version.');
72
+ }
73
+ return wanted.map((digest) => byDigest.get(digest));
74
+ }
44
75
  // One branch's run payload. A stack mismatch, a materialize failure, or a runner
45
76
  // that produced no report all become this branch's structured suite error — the
46
77
  // peer branch is unaffected. This connector never installs anything: a missing
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { copyBehavioralRunnerEnvironment, materializeBehavioral } from "../files/behavioral.js";
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
8
  import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
@@ -20,6 +20,13 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
20
20
  if (behavioral.build_error) {
21
21
  throw new Error(`The behavioral candidate could not be reviewed: ${behavioral.build_error.message}`);
22
22
  }
23
+ // Say this before the run, not after: the run materializes the answer, and
24
+ // that is where a forgotten file turns into undefined steps — by then it is
25
+ // already gone.
26
+ const lost = filesLostOnMaterialize(config.projectRoot, behavioral.suite_file, branchRunner(behavioral));
27
+ if (lost.length > 0) {
28
+ actual.stdout.write(`Warning: these files sit with the suite but are not in its answer, and running it will delete them: ${lost.join(', ')}.\n`);
29
+ }
23
30
  const candidateRun = await actual.runCandidate(config.projectRoot, behavioral);
24
31
  const fixedRevision = buildRequest.known_defect_context.status === 'supplied'
25
32
  ? buildRequest.known_defect_context.fixed_revision
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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": {