unitbob 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
- import { spend } from "../files/budget.js";
2
+ import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
3
3
  import { validateStack } from "../runner/precheck.js";
4
4
  import { runBddSuite } from "../runner/bdd.js";
5
5
  import { runStructuralByRunner } from "./run.js";
@@ -15,6 +15,8 @@ export async function runLocal(config, args = [], deps) {
15
15
  const request = readSuiteBuildRequest(config.projectRoot);
16
16
  const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
17
17
  const wanted = selectBranches(request, args);
18
+ const previous = readRunState(config.projectRoot);
19
+ let stuck = false;
18
20
  for (const suiteKind of wanted) {
19
21
  d.stdout.write(`\n── ${suiteKind} ──\n`);
20
22
  // An entry that exists but will not parse is a different problem from an
@@ -27,16 +29,47 @@ export async function runLocal(config, args = [], deps) {
27
29
  continue;
28
30
  }
29
31
  const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
30
- // Only a run that happened spends a repair round. A branch with no entry
31
- // written yet, or one the stack cannot execute, produced nothing to repair
32
- // against charging it would exhaust the budget on rounds that never
33
- // examined the suite.
32
+ // A branch with no entry written yet, or one the stack cannot execute,
33
+ // produced nothing to compare: it is the ordinary state halfway through a
34
+ // build, not a repair loop going nowhere.
34
35
  if (!ran)
35
36
  continue;
36
- // Kept as compatibility diagnostics only. The bounded repair role owns the
37
- // mechanical ceiling; this counter never stops execution or classifies reds.
38
- spend(config.projectRoot, `run-local:${suiteKind}`);
37
+ if (compareFailures(config, d, suiteKind, ran, previous[suiteKind]))
38
+ stuck = true;
39
39
  }
40
+ return stuck ? 1 : 0;
41
+ }
42
+ // Spec 34-6, criterion 3. The whole stop condition, and it stops the branch
43
+ // rather than the worker: the set of failures belongs to the branch, and a
44
+ // repair worker looking only at its own slice cannot see that the branch as a
45
+ // whole has stopped moving.
46
+ //
47
+ // Returns true when this branch is the one that has stopped moving.
48
+ function compareFailures(config, d, suiteKind, ran, before) {
49
+ const failures = failureSet(ran.runner, ran.result.report);
50
+ // No comparable set: the run produced no readable report, which is a harness
51
+ // problem the loop never reached. Forget the branch so the next run that does
52
+ // produce one is a first run again, rather than a match against a set from
53
+ // before the harness broke.
54
+ if (!failures) {
55
+ rememberFailures(config.projectRoot, suiteKind, undefined);
56
+ return false;
57
+ }
58
+ // Green. Nothing to be stuck on, and remembering an empty set would stop a
59
+ // branch that passes twice in a row.
60
+ if (failures.length === 0) {
61
+ rememberFailures(config.projectRoot, suiteKind, undefined);
62
+ return false;
63
+ }
64
+ const digest = digestOf(failures);
65
+ rememberFailures(config.projectRoot, suiteKind, digest);
66
+ if (digest !== before)
67
+ return false;
68
+ d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${failures.length} case(s) as the previous run, ` +
69
+ 'down to the first line of every message. The edits since then changed nothing this run can see.\n' +
70
+ 'Look at the failures yourself, replan the slice, or record the branch as a build_error. ' +
71
+ 'Running it again unchanged prints this same line.\n');
72
+ return true;
40
73
  }
41
74
  // Which branches to run. No argument runs every branch the request asked for —
42
75
  // the same "one suite, one run" shape both recipes insist on, so the default
@@ -53,48 +86,49 @@ function selectBranches(request, args) {
53
86
  }
54
87
  return named;
55
88
  }
56
- // True when the runner actually executed the branch which is what a repair
57
- // round is, and the only thing the caller charges the budget for.
89
+ // Non-null when the runner actually executed the branch. Everything else no
90
+ // entry, a declared `build_error`, a stack that cannot run it — is a branch that
91
+ // produced no result to compare against.
58
92
  async function runOneBranch(config, d, suiteKind, output) {
59
93
  // Nothing written for this branch yet. That is the ordinary state halfway
60
94
  // through a build, not an error — say what is missing and move to the peer.
61
95
  if (!output) {
62
96
  d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
63
97
  `${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
64
- return false;
98
+ return null;
65
99
  }
66
100
  if (output.build_error) {
67
101
  d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
68
- return false;
102
+ return null;
69
103
  }
70
104
  let runner;
71
- let suitePath;
105
+ let suitePaths;
72
106
  try {
73
107
  runner = branchRunner(output);
74
- suitePath = mainPathOf(output);
108
+ suitePaths = artifactPathsOf(output);
75
109
  }
76
110
  catch (err) {
77
111
  d.stdout.write(`Cannot run this branch: ${err.message}\n`);
78
- return false;
112
+ return null;
79
113
  }
80
114
  const check = d.validateStack(config.projectRoot, runner);
81
115
  if (!check.ok) {
82
116
  d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
83
- return false;
117
+ return null;
84
118
  }
85
119
  let result;
86
120
  try {
87
121
  result =
88
122
  suiteKind === 'behavioral'
89
- ? await d.runBehavioral(config.projectRoot, runner, suitePath)
90
- : await d.runStructural(config.projectRoot, runner, suitePath);
123
+ ? await d.runBehavioral(config.projectRoot, runner, suitePaths[0])
124
+ : await d.runStructural(config.projectRoot, runner, suitePaths);
91
125
  }
92
126
  catch (err) {
93
127
  d.stdout.write(`The runner could not start: ${err.message}\n`);
94
- return false;
128
+ return null;
95
129
  }
96
130
  d.stdout.write(report(result));
97
- return true;
131
+ return { runner, result };
98
132
  }
99
133
  // The command first, and always — including on a green run. It is the answer to
100
134
  // "how do I run just this one file again", which is the question the whole
@@ -127,14 +161,26 @@ function outputTail(result) {
127
161
  const joined = bits.join('\n');
128
162
  return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
129
163
  }
130
- // The suite blob's own project-relative path, exactly as the runners expect it.
131
- function mainPathOf(output) {
164
+ // The suite blob's own project-relative paths, exactly as the runners expect
165
+ // 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
167
+ // assignment now — and running it alone would exercise a fraction of what the
168
+ // answer claims to guard.
169
+ //
170
+ // The behavioral runners are given the main `.feature` and find the rest
171
+ // themselves: all three are pointed at the directory (`bdd.ts`), which is why a
172
+ // multi-file behavioral branch already worked before this spec.
173
+ function artifactPathsOf(output) {
132
174
  const file = output.suite_file;
133
175
  const path = file?.path;
134
176
  if (typeof path !== 'string' || !path) {
135
177
  throw new Error('this branch names no suite file to run.');
136
178
  }
137
- return path;
179
+ const support = Array.isArray(file?.support_files) ? file.support_files : [];
180
+ const rest = support
181
+ .map((entry) => entry?.path)
182
+ .filter((candidate) => typeof candidate === 'string' && candidate.length > 0);
183
+ return [path, ...rest];
138
184
  }
139
185
  function branchRoot(config, suiteKind) {
140
186
  const request = readSuiteBuildRequest(config.projectRoot);
@@ -1,11 +1,11 @@
1
- import { clearSpending } from "../files/budget.js";
1
+ import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
5
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
6
- import { anyStackPrecheck, detectBddRunner, detectStructuralRunner } from "../runner/precheck.js";
6
+ import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
7
7
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
8
- import { ensureRunner } from "../runner/provision.js";
8
+ import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
9
9
  import { probeBehavioralWorld } from "../runner/worldProbe.js";
10
10
  import { Wire } from "../wire.js";
11
11
  // The complete envelope for one branch, or null when this machine cannot
@@ -46,7 +46,8 @@ function runnerEnvelopeFor(packet, runner, projectRoot) {
46
46
  : envelope;
47
47
  }
48
48
  // Confirm at least one supported stack is present, materialize the Ruby boot
49
- // helper a generated RSpec suite would require, then fetch both peer assignments
49
+ // helper an RSpec suite would need — only in a Ruby project — then fetch both
50
+ // peer assignments
50
51
  // (spec 32) and each branch's recipe, and write the host's task to
51
52
  // `.unitbob/suite-build/request.json`. No model is called and no source is read
52
53
  // here — that is the host's job, framed by the two generation recipes. An
@@ -60,8 +61,10 @@ export async function suitePrepare(config, args = [], deps) {
60
61
  getRecipe: (name) => wire.getRecipe(name),
61
62
  getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
62
63
  precheck: anyStackPrecheck,
64
+ confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
63
65
  bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
64
66
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
67
+ ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
65
68
  worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
66
69
  runnerEnvelope: runnerEnvelopeFor,
67
70
  stdout: process.stdout,
@@ -70,7 +73,36 @@ export async function suitePrepare(config, args = [], deps) {
70
73
  const check = actual.precheck(config.projectRoot);
71
74
  if (!check.ok)
72
75
  throw new Error(check.message ?? 'Unsupported runtime.');
73
- materializeHelper(config.projectRoot);
76
+ // Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
77
+ // project it touched, so a Flask app and a NestJS app each came away with a
78
+ // Ruby file they never asked for and cannot run — the product leaving another
79
+ // stack's litter in someone's repository.
80
+ if (detectStructuralRunner(config.projectRoot) === 'rspec')
81
+ materializeHelper(config.projectRoot);
82
+ // The stack is known; now make it runnable. A vibecoder who has never
83
+ // installed a test runner is the ordinary customer, not an edge case, so the
84
+ // runner (and, where the language allows it, the application's own
85
+ // dependencies) is installed under `.unitbob/` rather than reported as a
86
+ // reason they cannot use the product. Nothing in their project is written to.
87
+ //
88
+ // A project that already has its runner is left completely alone — see
89
+ // `ensureStructuralRunner` — so this costs nothing on a set-up machine.
90
+ const setupNotices = [];
91
+ if (check.runner) {
92
+ const provisioned = await actual.ensureStructuralRunner(config.projectRoot, check.runner);
93
+ if (provisioned.status === 'fixable') {
94
+ const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
95
+ throw new Error(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
96
+ `${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded.`);
97
+ }
98
+ setupNotices.push(...(provisioned.checklist ?? []));
99
+ // Confirm rather than assume. Provisioning reporting success and the runner
100
+ // actually being startable are two different facts, and this is the cheap
101
+ // one to check before a whole generation is built on it.
102
+ const ready = actual.confirmRunner(config.projectRoot, check.runner);
103
+ if (!ready.ok)
104
+ throw new Error(ready.message ?? `The ${check.runner} runner is not available.`);
105
+ }
74
106
  // Spec 32-6. Before anything is fetched or written, find out whether the suite
75
107
  // would get off the ground at all. It runs here, after the boot helper exists
76
108
  // and before the network, so a project whose suite cannot start costs one
@@ -153,13 +185,11 @@ export async function suitePrepare(config, args = [], deps) {
153
185
  throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
154
186
  }
155
187
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
156
- // A new request is a new build, and a new build starts on the whole budget
157
- // (spec 34-2). Said out loud, because re-running this verb is a documented
158
- // step of the loop the fixable-runner path below ends by asking for it — so
159
- // a reset that happened silently would be a ceiling that quietly is not one.
160
- if (clearSpending(config.projectRoot)) {
161
- actual.stdout.write('Starting a fresh build: the review and run counts from the previous one are cleared.\n');
162
- }
188
+ // A new request is a new build, and a new build has no previous run to be
189
+ // stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
190
+ // step of the loop, so a failure set remembered from the build before it would
191
+ // stop a branch that has not run once yet.
192
+ clearRunState(config.projectRoot);
163
193
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
164
194
  const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
165
195
  ? '`unitbob suite-review-prepare` before upload'
@@ -172,6 +202,9 @@ export async function suitePrepare(config, args = [], deps) {
172
202
  `then run ${nextCommand}.\n`);
173
203
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
174
204
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
205
+ if (setupNotices.length > 0) {
206
+ actual.stdout.write('\nOne setup step is worth knowing about before you generate:\n - ' + setupNotices.join('\n - ') + '\n');
207
+ }
175
208
  if (fixableNotices.length > 0) {
176
209
  actual.stdout.write('\nBehavioral suite skipped this run — its runner or connector-owned World profile is not ready. ' +
177
210
  'This is a fixable setup step, not a build failure, and it does not affect the structural suite:\n' +
@@ -226,9 +259,10 @@ function bootFinding(boot, runner) {
226
259
  // the one the vibecoder can paste into a search.
227
260
  const next = boot.cause === 'defect_in_code'
228
261
  ? 'Fix that, then run `unitbob suite-prepare` again.'
229
- : "Unitbob does not install your project's own dependencies that would rewrite your Gemfile.lock " +
230
- 'or package-lock.json. Run the install your project needs (`bundle install`, `npm install`, ' +
231
- '`pip install -r requirements.txt`), then run `unitbob suite-prepare` again.';
262
+ : 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` ' +
263
+ 'it never writes to your project. Something outside that file is still missing here. Run the ' +
264
+ 'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
265
+ 'then run `unitbob suite-prepare` again.';
232
266
  return (`${headline}\n\n` +
233
267
  ` ${boot.message}\n\n` +
234
268
  `${boot.detail}\n\n` +
@@ -6,7 +6,6 @@ import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBeh
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";
9
- import { spend } from "../files/budget.js";
10
9
  export async function suiteReviewPrepare(config, _args = [], deps) {
11
10
  const actual = {
12
11
  runCandidate: runCandidate,
@@ -38,27 +37,6 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
38
37
  const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
39
38
  actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
40
39
  actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
41
- // Counted after the request is written, never before it. Refusing the round
42
- // would rebuild the autobrella deadlock on a different number: the branch
43
- // could not publish, so the work would be lost again for the sake of the
44
- // ceiling meant to protect it. What the ceiling buys is a sentence.
45
- const round = spend(config.projectRoot, 'review_rounds');
46
- const budget = buildRequest.budget;
47
- if (budget && round > budget.review_rounds) {
48
- actual.stdout.write(lastRoundNotice(round, budget.review_rounds));
49
- }
50
- }
51
- // Says what to do, not that something is forbidden. This is only sayable because
52
- // spec 34-2 already made an imperfect suite publishable: before it, "publish what
53
- // you have" was not available to the reviewer at all, and the only way to record
54
- // a bad Scenario was to take the branch down.
55
- function lastRoundNotice(round, allowed) {
56
- return (`\nThis is review round ${round}; the budget for this build was ${allowed}. Treat it as the last round ` +
57
- 'and publish what you have.\n' +
58
- 'A Scenario the reviewer still objects to does not hold the branch back: it is recorded with ' +
59
- '`verdict: "does_not_pass"` and a `reviewer_objection_text` saying what is wrong, the branch publishes, ' +
60
- 'and the objection is read afterwards by the operator. Another round of rewriting buys less than ' +
61
- 'publishing the objection does.\n');
62
40
  }
63
41
  async function runCandidate(projectRoot, output, revision) {
64
42
  if (revision)