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.
- package/dist/cli.js +4 -2
- package/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +20 -4
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +33 -19
- package/dist/proc.js +15 -0
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/failureDigest.js +238 -0
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +1 -1
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +69 -23
- package/dist/verbs/suitePrepare.js +49 -15
- package/dist/verbs/suiteReviewPrepare.js +0 -22
- package/dist/verbs/validateBuild.js +140 -450
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +18 -6
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +37 -20
- package/dist/files/budget.js +0 -74
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
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
|
|
102
|
+
return null;
|
|
69
103
|
}
|
|
70
104
|
let runner;
|
|
71
|
-
let
|
|
105
|
+
let suitePaths;
|
|
72
106
|
try {
|
|
73
107
|
runner = branchRunner(output);
|
|
74
|
-
|
|
108
|
+
suitePaths = artifactPathsOf(output);
|
|
75
109
|
}
|
|
76
110
|
catch (err) {
|
|
77
111
|
d.stdout.write(`Cannot run this branch: ${err.message}\n`);
|
|
78
|
-
return
|
|
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
|
|
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,
|
|
90
|
-
: await d.runStructural(config.projectRoot, runner,
|
|
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
|
|
128
|
+
return null;
|
|
95
129
|
}
|
|
96
130
|
d.stdout.write(report(result));
|
|
97
|
-
return
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
-
// (spec 34-
|
|
158
|
-
// step of the loop
|
|
159
|
-
// a
|
|
160
|
-
|
|
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
|
-
:
|
|
230
|
-
'
|
|
231
|
-
'`pip install -r requirements.txt`),
|
|
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)
|