unitbob 0.7.14 → 0.7.16
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/README.md +1 -1
- package/dist/cli.js +7 -3
- package/dist/files/behavioral.js +192 -4
- package/dist/files/packets.js +39 -5
- package/dist/files/workerPlan.js +83 -14
- package/dist/runner/bdd.js +19 -0
- package/dist/runner/bootcheck.js +36 -8
- package/dist/runner/worldProbe.js +112 -5
- package/dist/surfaces/nextRoutes.js +137 -0
- package/dist/surfaces/routeInventory.js +107 -28
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putMapBuild.js +2 -2
- package/dist/verbs/suitePrepare.js +44 -25
- package/dist/verbs/validateWorkerCheckpoints.js +228 -72
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +5 -2
- package/plugin/codex/agents/suite-reviewer.toml +4 -0
- package/plugin/codex/agents/suite-worker.toml +21 -10
|
@@ -12,7 +12,7 @@ import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
|
|
|
12
12
|
import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
|
|
13
13
|
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
14
14
|
import { canPrepareBeforeImports, setupFileOf, STRUCTURAL_SETUP_FILE } from "../runner/vitest.js";
|
|
15
|
-
import {
|
|
15
|
+
import { probeWorld, worldIsProbed } from "../runner/worldProbe.js";
|
|
16
16
|
import { Wire } from "../wire.js";
|
|
17
17
|
// The complete envelope for one branch, or null when this machine cannot
|
|
18
18
|
// produce one: the server offered no combination this stack matches, or the
|
|
@@ -72,7 +72,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
72
72
|
bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
|
|
73
73
|
ensureRunner: deps?.ensureRunner ?? ensureRunner,
|
|
74
74
|
ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
|
|
75
|
-
worldProbe: deps?.worldProbe ??
|
|
75
|
+
worldProbe: deps?.worldProbe ?? probeWorld,
|
|
76
76
|
runnerEnvelope: runnerEnvelopeFor,
|
|
77
77
|
stdout: process.stdout,
|
|
78
78
|
...deps,
|
|
@@ -185,18 +185,23 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
185
185
|
continue;
|
|
186
186
|
}
|
|
187
187
|
// Every BDD runner with a connector-owned harness gets it here, before its
|
|
188
|
-
// branch is offered to the host (spec 35-1).
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
188
|
+
// branch is offered to the host (spec 35-1). A World that opens a door
|
|
189
|
+
// into the application is probed by running it — Ruby's, which
|
|
190
|
+
// integrates deeply with Rails, and Next's, which starts the application
|
|
191
|
+
// (spec 56-1) — because the probe needs the application to answer. A
|
|
192
|
+
// harness that only refuses connections leaving the machine (JS
|
|
193
|
+
// elsewhere, Python) has nothing on the project to probe; its guard is
|
|
194
|
+
// executed for real in the connector's own suite.
|
|
193
195
|
materializeBehavioralWorld(config.projectRoot, runner);
|
|
194
|
-
if (runner
|
|
195
|
-
const probe = await actual.worldProbe(config.projectRoot);
|
|
196
|
+
if (worldIsProbed(config.projectRoot, runner)) {
|
|
197
|
+
const probe = await actual.worldProbe(config.projectRoot, runner);
|
|
196
198
|
if (probe.status === 'fixable') {
|
|
197
199
|
fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
|
|
198
200
|
continue;
|
|
199
201
|
}
|
|
202
|
+
// ADR 0001: the probe's account of what it did not cover is part of
|
|
203
|
+
// the answer, printed with the good news rather than kept in a comment.
|
|
204
|
+
actual.stdout.write(`Checked that the connector-owned World profile works on this project: it does.${probe.message ? ` ${probe.message}` : ''}\n`);
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
buildable.push({ packet, runner });
|
|
@@ -293,7 +298,15 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
293
298
|
const bootAdvisories = [];
|
|
294
299
|
const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
|
|
295
300
|
if (structuralIndex !== -1) {
|
|
296
|
-
const
|
|
301
|
+
const sources = structuralSourceFiles(config.projectRoot, { branches }, structuralRunner);
|
|
302
|
+
// Spec 55-1, §1. Said before the probe's answer and before "Next:", in the
|
|
303
|
+
// vibecoder's terms: these files are in the map and not in this suite.
|
|
304
|
+
if (sources.leftOut.length > 0) {
|
|
305
|
+
const count = sources.leftOut.length;
|
|
306
|
+
actual.stdout.write(`${count} ${count === 1 ? 'file' : 'files'} the map names ${count === 1 ? 'is' : 'are'} not ${structuralRunner} ` +
|
|
307
|
+
`and ${count === 1 ? 'was' : 'were'} left out of the code-structure suite: ${sources.leftOut.join(', ')}\n`);
|
|
308
|
+
}
|
|
309
|
+
const boot = await actual.bootCheck(config.projectRoot, structuralRunner, sources.files);
|
|
297
310
|
if (boot.status === 'broken') {
|
|
298
311
|
if (!canPrepareBeforeImports(structuralRunner) || setupFileOf(config.projectRoot)) {
|
|
299
312
|
branches.splice(structuralIndex, 1);
|
|
@@ -325,7 +338,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
325
338
|
//
|
|
326
339
|
// Not written into `request.json`: the coordinator reads that file whole and
|
|
327
340
|
// pays for it on every turn of the longest-lived context in the run.
|
|
328
|
-
const sourcePackets = buildPackets(config.projectRoot, request);
|
|
341
|
+
const sourcePackets = buildPackets(config.projectRoot, request, structuralRunner);
|
|
329
342
|
// A new request is a new build, and a new build has no previous run to be
|
|
330
343
|
// stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
|
|
331
344
|
// step of the loop, so a failure set remembered from the build before it would
|
|
@@ -432,9 +445,9 @@ function displacedList(moved) {
|
|
|
432
445
|
// failed build: the workers search the source themselves, exactly as they did
|
|
433
446
|
// before this spec. The same rule the route inventory follows for the same
|
|
434
447
|
// reason — a read-only checkout or a full disk must not take a build down.
|
|
435
|
-
function buildPackets(projectRoot, request) {
|
|
448
|
+
function buildPackets(projectRoot, request, runner) {
|
|
436
449
|
try {
|
|
437
|
-
return writeSuitePackets(projectRoot, request);
|
|
450
|
+
return writeSuitePackets(projectRoot, request, runner);
|
|
438
451
|
}
|
|
439
452
|
catch (err) {
|
|
440
453
|
return err.message;
|
|
@@ -535,18 +548,24 @@ function bootAdvisory(boot, runner) {
|
|
|
535
548
|
// is not something a setup file can prepare its way around, and sending someone
|
|
536
549
|
// to write one would waste the round it costs.
|
|
537
550
|
function bootReport(boot, runner, prepared) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
'
|
|
548
|
-
'
|
|
549
|
-
|
|
551
|
+
// Spec 55-1, §1. The third cause is ours: vite stopped on a file our own list
|
|
552
|
+
// handed it. Not a repair and not an install — the sentence repo 139 got
|
|
553
|
+
// twice, over a `.py` the map had named.
|
|
554
|
+
const next = boot.cause === 'harness'
|
|
555
|
+
? 'This is a hole in the list of files Unitbob resolved for the map, not in your code — ' +
|
|
556
|
+
`${boot.file}. Report it; nothing to repair here.`
|
|
557
|
+
: boot.cause !== 'defect_in_code'
|
|
558
|
+
? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
|
|
559
|
+
'it never writes to your project. Something outside that file is still missing here. Run the ' +
|
|
560
|
+
'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
|
|
561
|
+
'then run `unitbob suite-prepare` again.'
|
|
562
|
+
: prepared
|
|
563
|
+
? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
|
|
564
|
+
: `This is what these files do with nothing in front of them, and the run will have ` +
|
|
565
|
+
`${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
|
|
566
|
+
'first import — the environment variables the modules read, a loader registration, and only as a ' +
|
|
567
|
+
'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
|
|
568
|
+
'and this same question will be asked through it.';
|
|
550
569
|
// `boot.message` is the runner's own words, indented but never paraphrased:
|
|
551
570
|
// this is the line the vibecoder can paste into a search.
|
|
552
571
|
return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { assignedMarkers, assignedSurfaces, checkpointPath, readWorkerPlan, requestDigest, sliceDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
|
|
4
|
+
// Spec 55-1, §3. The verdict is per slice. One list of every slice's errors,
|
|
5
|
+
// thrown as one exception, made one bad checkpoint out of 43 a refusal of the
|
|
6
|
+
// whole branch — and the workflow turned that into a `build_error` for 37
|
|
7
|
+
// capabilities on repo 139. A slice is the unit of work here: the ones that
|
|
8
|
+
// pass go on to the build, the ones that do not go back to their worker with
|
|
9
|
+
// exactly the lines printed under their name. The exception stays for the one
|
|
10
|
+
// thing that is not a slice's fault: a plan that does not validate.
|
|
3
11
|
export async function validateWorkerCheckpoints(config, _args = [], deps = { stdout: process.stdout }) {
|
|
4
12
|
const planErrors = validateWorkerPlanFiles(config.projectRoot);
|
|
5
13
|
if (planErrors.length > 0)
|
|
@@ -8,61 +16,188 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
|
|
|
8
16
|
const expectedRequestDigest = requestDigest(config.projectRoot);
|
|
9
17
|
const expectedPlanDigest = workerPlanDigest(config.projectRoot);
|
|
10
18
|
const assigned = assignedSurfaces(config.projectRoot);
|
|
11
|
-
const
|
|
19
|
+
const markers = assignedMarkers(config.projectRoot);
|
|
20
|
+
const valid_workers = [];
|
|
21
|
+
const invalid_workers = [];
|
|
12
22
|
for (const item of plan.workers) {
|
|
13
23
|
const label = `${item.branch}:${item.worker_id}`;
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
const errors = sliceErrors(config.projectRoot, item, expectedRequestDigest, expectedPlanDigest, assigned, markers);
|
|
25
|
+
if (errors.length === 0)
|
|
26
|
+
valid_workers.push(label);
|
|
27
|
+
else
|
|
28
|
+
invalid_workers.push({ worker: label, errors });
|
|
29
|
+
}
|
|
30
|
+
if (valid_workers.length > 0)
|
|
31
|
+
deps.stdout.write(`Worker checkpoints valid for ${valid_workers.join(', ')}.\n`);
|
|
32
|
+
for (const { worker, errors } of invalid_workers) {
|
|
33
|
+
deps.stdout.write(`\n${worker}:\n${errors.map((line) => ` ${line}\n`).join('')}`);
|
|
34
|
+
}
|
|
35
|
+
if (invalid_workers.length > 0) {
|
|
36
|
+
deps.stdout.write(`\n${invalid_workers.length} of ${plan.workers.length} checkpoints invalid — ` +
|
|
37
|
+
'relaunch those workers once with the lines above.\n');
|
|
38
|
+
}
|
|
39
|
+
return { valid_workers, invalid_workers };
|
|
40
|
+
}
|
|
41
|
+
// Everything one slice can be wrong about, without the slice's label: the
|
|
42
|
+
// caller prints the errors under it.
|
|
43
|
+
function sliceErrors(projectRoot, item, expectedRequestDigest, expectedPlanDigest, assigned, markers) {
|
|
44
|
+
const errors = [];
|
|
45
|
+
const path = checkpointPath(projectRoot, item);
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return [`checkpoint is missing at ${path}`];
|
|
48
|
+
let checkpoint;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
52
|
+
return ['checkpoint must be an object'];
|
|
53
|
+
checkpoint = parsed;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return [`checkpoint is not valid JSON: ${error.message}`];
|
|
57
|
+
}
|
|
58
|
+
if (checkpoint.request_digest !== expectedRequestDigest)
|
|
59
|
+
errors.push('request_digest is stale');
|
|
60
|
+
if (checkpoint.plan_digest !== expectedPlanDigest)
|
|
61
|
+
errors.push('plan_digest is stale');
|
|
62
|
+
// Fresh by construction after a carry-over, which is why the next line exists:
|
|
63
|
+
// `plan_digest` says "the plan on disk", `slice_digest` says "this slice of it".
|
|
64
|
+
if (checkpoint.slice_digest !== sliceDigest(item))
|
|
65
|
+
errors.push('slice_digest does not match its plan item');
|
|
66
|
+
if (checkpoint.branch !== item.branch)
|
|
67
|
+
errors.push('branch does not match its plan item');
|
|
68
|
+
if (checkpoint.worker_id !== item.worker_id)
|
|
69
|
+
errors.push('worker_id does not match its plan item');
|
|
70
|
+
const completed = stringArray(checkpoint.completed_promises, 'completed_promises', errors);
|
|
71
|
+
const unresolved = stringArray(checkpoint.unresolved_promises, 'unresolved_promises', errors);
|
|
72
|
+
const accounted = [...completed, ...unresolved];
|
|
73
|
+
for (const promise of item.promises) {
|
|
74
|
+
const count = accounted.filter((candidate) => candidate === promise).length;
|
|
75
|
+
if (count !== 1)
|
|
76
|
+
errors.push(`promise ${promise} must appear exactly once across completed/unresolved promises`);
|
|
77
|
+
}
|
|
78
|
+
// With the plan's own text next to it: a promise a worker annotated — "(done)",
|
|
79
|
+
// a trailing note — is the common way this fails, and the exact string it
|
|
80
|
+
// should have carried is the whole fix. When no one promise stands out, all
|
|
81
|
+
// of them are listed.
|
|
82
|
+
for (const promise of accounted.filter((candidate) => !item.promises.includes(candidate))) {
|
|
83
|
+
const expected = closestPromise(promise, item.promises);
|
|
84
|
+
const hint = expected
|
|
85
|
+
? `expected exactly: ${expected}`
|
|
86
|
+
: `the plan item's promises are: ${item.promises.map((text) => JSON.stringify(text)).join(', ')}`;
|
|
87
|
+
errors.push(`checkpoint names promise ${promise} outside its plan item — ${hint}`);
|
|
88
|
+
}
|
|
89
|
+
const writtenPaths = stringArray(checkpoint.written_paths, 'written_paths', errors);
|
|
90
|
+
for (const pathValue of writtenPaths.filter((candidate) => !item.owned_paths.includes(candidate))) {
|
|
91
|
+
errors.push(`written path ${pathValue} is not an owned path`);
|
|
92
|
+
}
|
|
93
|
+
for (const pathValue of writtenPaths.filter((candidate) => item.owned_paths.includes(candidate) && !existsSync(join(projectRoot, candidate)))) {
|
|
94
|
+
errors.push(`written path ${pathValue} is not on disk`);
|
|
95
|
+
}
|
|
96
|
+
validateCompactFacts(checkpoint.facts, errors);
|
|
97
|
+
validateMarkers(projectRoot, item, writtenPaths, markers, errors);
|
|
98
|
+
validateSurfaceCoverage(checkpoint.surface_coverage, item, assigned, errors);
|
|
99
|
+
validateUnreachableSurfaces(checkpoint.unreachable_surfaces, item, checkpoint.surface_coverage, assigned, errors);
|
|
100
|
+
stringArray(checkpoint.decisions, 'decisions', errors);
|
|
101
|
+
stringArray(checkpoint.known_problems, 'known_problems', errors);
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
// The plan promise a rewritten one most likely started as: the one it contains
|
|
105
|
+
// or is contained by, else the one sharing the longest prefix. A hint, never a
|
|
106
|
+
// judgement — the error is the same either way.
|
|
107
|
+
//
|
|
108
|
+
// A shared prefix shorter than `PREFIX_TO_MEAN_IT` is what any two English
|
|
109
|
+
// sentences share ("A signed-in", "The user "); below it the guess would be
|
|
110
|
+
// noise, and the caller lists every promise instead.
|
|
111
|
+
const PREFIX_TO_MEAN_IT = 8;
|
|
112
|
+
function closestPromise(candidate, promises) {
|
|
113
|
+
const contained = promises.find((promise) => candidate.includes(promise) || promise.includes(candidate));
|
|
114
|
+
if (contained)
|
|
115
|
+
return contained;
|
|
116
|
+
let best = null;
|
|
117
|
+
let bestPrefix = 0;
|
|
118
|
+
for (const promise of promises) {
|
|
119
|
+
let shared = 0;
|
|
120
|
+
while (shared < promise.length && shared < candidate.length && promise[shared] === candidate[shared])
|
|
121
|
+
shared += 1;
|
|
122
|
+
if (shared > bestPrefix) {
|
|
123
|
+
best = promise;
|
|
124
|
+
bestPrefix = shared;
|
|
18
125
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
126
|
+
}
|
|
127
|
+
if (bestPrefix >= PREFIX_TO_MEAN_IT)
|
|
128
|
+
return best;
|
|
129
|
+
return promises.length === 1 ? promises[0] : null;
|
|
130
|
+
}
|
|
131
|
+
// Spec 55-1, §4. The files this slice wrote, read for the one thing the server
|
|
132
|
+
// will refuse them for and nothing checked before the build: the marker. A
|
|
133
|
+
// behavioral Scenario carries exactly one `@ubc_…` tag; a structural file names
|
|
134
|
+
// a marker at least once, anywhere — the server counts occurrences per file,
|
|
135
|
+
// and this gate counts no finer. On repo 139, 31 of 37 feature files reached
|
|
136
|
+
// `validate-build` with no tag at all, after the workers were gone.
|
|
137
|
+
//
|
|
138
|
+
// Where this gate is narrower than the server, on purpose: the marker must be
|
|
139
|
+
// one of *this slice's*. The server accepts any marker of the assignment,
|
|
140
|
+
// because it has no idea slices exist; a Scenario a worker tagged with its
|
|
141
|
+
// neighbour's marker would pass there and count for the wrong capability. The
|
|
142
|
+
// same narrowing `unreachable_surfaces` already has for addresses — see
|
|
143
|
+
// `test/architecture.test.ts`, which records both.
|
|
144
|
+
//
|
|
145
|
+
// A file that is not on disk is its neighbour's error, reported once, above.
|
|
146
|
+
function validateMarkers(projectRoot, item, writtenPaths, markers, errors) {
|
|
147
|
+
const mine = item.capability_ids.flatMap((id) => markers.get(id) ?? []);
|
|
148
|
+
if (mine.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
const oneOf = `(one of ${mine.map((marker) => item.branch === 'behavioral' ? `@${marker}` : marker).join(', ')})`;
|
|
151
|
+
for (const relative of writtenPaths) {
|
|
152
|
+
if (!item.owned_paths.includes(relative))
|
|
153
|
+
continue;
|
|
154
|
+
const path = join(projectRoot, relative);
|
|
155
|
+
if (!existsSync(path))
|
|
156
|
+
continue;
|
|
157
|
+
const text = readFileSync(path, 'utf8');
|
|
158
|
+
if (item.branch === 'behavioral') {
|
|
159
|
+
if (!relative.endsWith('.feature'))
|
|
24
160
|
continue;
|
|
161
|
+
for (const scenario of scenarios(text)) {
|
|
162
|
+
const tags = scenario.tags.filter((tag) => MARKER_TAG.test(tag));
|
|
163
|
+
const own = tags.filter((tag) => mine.includes(tag.slice(1)));
|
|
164
|
+
if (tags.length === 1 && own.length === 1)
|
|
165
|
+
continue;
|
|
166
|
+
const has = tags.length === 0
|
|
167
|
+
? 'none'
|
|
168
|
+
: tags.length > 1 ? `${tags.length}` : `${tags[0]}, which is not`;
|
|
169
|
+
errors.push(`${relative}:${scenario.line}: ${scenario.keyword} "${scenario.name}" needs exactly one @ubc_… tag of this slice ${oneOf}; it has ${has}`);
|
|
25
170
|
}
|
|
26
|
-
checkpoint = parsed;
|
|
27
171
|
}
|
|
28
|
-
|
|
29
|
-
errors.push(`${
|
|
172
|
+
else if (!mine.some((marker) => text.includes(marker))) {
|
|
173
|
+
errors.push(`${relative}: no ubc_… marker of this slice ${oneOf} appears in the file`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const MARKER_TAG = /^@ubc_[0-9a-f]{12}$/;
|
|
178
|
+
const SCENARIO_LINE = /^\s*(Scenario Outline|Scenario Template|Scenario|Example):\s*(.*)$/;
|
|
179
|
+
// Gherkin, read only as far as tags go: the tag lines immediately above a
|
|
180
|
+
// Scenario, across blank lines and comments. `Feature:` and `Examples:` reset
|
|
181
|
+
// the pile so a feature-level tag or one over Examples never counts as the
|
|
182
|
+
// Scenario's. Anything else — steps, docstrings, tables — clears it too, so a
|
|
183
|
+
// tag can only ever belong to the container right below it.
|
|
184
|
+
function scenarios(text) {
|
|
185
|
+
const found = [];
|
|
186
|
+
let pending = [];
|
|
187
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
188
|
+
const line = raw.trim();
|
|
189
|
+
if (line === '' || line.startsWith('#'))
|
|
190
|
+
continue;
|
|
191
|
+
if (line.startsWith('@')) {
|
|
192
|
+
pending.push(...line.split(/\s+/).filter((tag) => tag.startsWith('@')));
|
|
30
193
|
continue;
|
|
31
194
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (checkpoint.worker_id !== item.worker_id)
|
|
39
|
-
errors.push(`${label}: worker_id does not match its plan item`);
|
|
40
|
-
const completed = stringArray(checkpoint.completed_promises, `${label}: completed_promises`, errors);
|
|
41
|
-
const unresolved = stringArray(checkpoint.unresolved_promises, `${label}: unresolved_promises`, errors);
|
|
42
|
-
const accounted = [...completed, ...unresolved];
|
|
43
|
-
for (const promise of item.promises) {
|
|
44
|
-
const count = accounted.filter((candidate) => candidate === promise).length;
|
|
45
|
-
if (count !== 1)
|
|
46
|
-
errors.push(`${label}: promise ${promise} must appear exactly once across completed/unresolved promises`);
|
|
47
|
-
}
|
|
48
|
-
for (const promise of accounted.filter((candidate) => !item.promises.includes(candidate))) {
|
|
49
|
-
errors.push(`${label}: checkpoint names promise ${promise} outside its plan item`);
|
|
50
|
-
}
|
|
51
|
-
const writtenPaths = stringArray(checkpoint.written_paths, `${label}: written_paths`, errors);
|
|
52
|
-
for (const pathValue of writtenPaths.filter((candidate) => !item.owned_paths.includes(candidate))) {
|
|
53
|
-
errors.push(`${label}: written path ${pathValue} is not an owned path`);
|
|
54
|
-
}
|
|
55
|
-
validateCompactFacts(checkpoint.facts, label, errors);
|
|
56
|
-
validateSurfaceCoverage(checkpoint.surface_coverage, item, label, errors);
|
|
57
|
-
validateUnreachableSurfaces(checkpoint.unreachable_surfaces, item, checkpoint.surface_coverage, assigned, label, errors);
|
|
58
|
-
stringArray(checkpoint.decisions, `${label}: decisions`, errors);
|
|
59
|
-
stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
|
|
60
|
-
}
|
|
61
|
-
if (errors.length > 0)
|
|
62
|
-
throw new Error(`Worker checkpoints are invalid:\n- ${errors.join('\n- ')}`);
|
|
63
|
-
const validWorkers = plan.workers.map((item) => `${item.branch}:${item.worker_id}`);
|
|
64
|
-
deps.stdout.write(`Worker checkpoints valid for ${validWorkers.join(', ')}.\n`);
|
|
65
|
-
return { valid_workers: validWorkers };
|
|
195
|
+
const match = SCENARIO_LINE.exec(line);
|
|
196
|
+
if (match)
|
|
197
|
+
found.push({ line: index + 1, keyword: match[1], name: match[2].trim(), tags: pending });
|
|
198
|
+
pending = [];
|
|
199
|
+
}
|
|
200
|
+
return found;
|
|
66
201
|
}
|
|
67
202
|
function stringArray(value, label, errors) {
|
|
68
203
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
|
@@ -81,27 +216,27 @@ function stringArray(value, label, errors) {
|
|
|
81
216
|
// by running the application held; the one that was not, did not — and nothing in
|
|
82
217
|
// the checkpoint told the two apart, so no reader could weigh them differently.
|
|
83
218
|
const ESTABLISHED_BY = /^(read|ran: \S.*)$/;
|
|
84
|
-
function validateCompactFacts(value,
|
|
219
|
+
function validateCompactFacts(value, errors) {
|
|
85
220
|
if (!Array.isArray(value)) {
|
|
86
|
-
errors.push(
|
|
221
|
+
errors.push('facts must be an array');
|
|
87
222
|
return;
|
|
88
223
|
}
|
|
89
224
|
for (const [index, entry] of value.entries()) {
|
|
90
225
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
91
|
-
errors.push(
|
|
226
|
+
errors.push(`$.facts[${index}] must be an object with fact, source_refs and established_by; got ${jsonType(entry)}`);
|
|
92
227
|
continue;
|
|
93
228
|
}
|
|
94
229
|
const fact = entry;
|
|
95
230
|
if (typeof fact.fact !== 'string' || !fact.fact.trim())
|
|
96
|
-
errors.push(
|
|
231
|
+
errors.push(`facts[${index}].fact must be non-empty`);
|
|
97
232
|
if (!Array.isArray(fact.source_refs) || fact.source_refs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
|
|
98
|
-
errors.push(
|
|
233
|
+
errors.push(`facts[${index}].source_refs must be compact source references`);
|
|
99
234
|
}
|
|
100
235
|
if (typeof fact.established_by !== 'string' || !ESTABLISHED_BY.test(fact.established_by)) {
|
|
101
|
-
errors.push(
|
|
236
|
+
errors.push(`facts[${index}].established_by must be "read" or "ran: <command>"`);
|
|
102
237
|
}
|
|
103
238
|
if ('source' in fact || 'transcript' in fact || 'suite' in fact) {
|
|
104
|
-
errors.push(
|
|
239
|
+
errors.push(`facts[${index}] may not embed source, transcript, or suite copies`);
|
|
105
240
|
}
|
|
106
241
|
}
|
|
107
242
|
}
|
|
@@ -116,28 +251,49 @@ function validateCompactFacts(value, label, errors) {
|
|
|
116
251
|
// Behavioral only: this is a join between Gherkin Scenarios and surfaces, and the
|
|
117
252
|
// structural branch has neither. Requiring the key there would refuse honest
|
|
118
253
|
// slices over a field that would mean nothing if they filled it in.
|
|
119
|
-
|
|
254
|
+
//
|
|
255
|
+
// Spec 55-1, §3 and §4. Two rules here are the server's, taken as they are.
|
|
256
|
+
// An empty `surfaces` is refused only when the capability was given addresses
|
|
257
|
+
// at all (`assigned_any` in behavioral_suite_metadata_validator.rb) — a
|
|
258
|
+
// Scenario over a capability with no addresses has nothing to name, and
|
|
259
|
+
// refusing it here while the server accepts it made the connector stricter than
|
|
260
|
+
// the side that decides. And every address named must be one this capability
|
|
261
|
+
// was assigned — the mirror of the membership check `unreachable_surfaces`
|
|
262
|
+
// already had, and the check that would have caught the neighbours' routes and
|
|
263
|
+
// server actions repo 139's workers wrote down on their role's own advice.
|
|
264
|
+
function validateSurfaceCoverage(value, item, assigned, errors) {
|
|
120
265
|
if (value === undefined && item.branch !== 'behavioral')
|
|
121
266
|
return;
|
|
122
267
|
if (!Array.isArray(value)) {
|
|
123
|
-
errors.push(
|
|
268
|
+
errors.push('surface_coverage must be an array of {capability_id, scenario, surfaces} entries, one per Scenario written');
|
|
124
269
|
return;
|
|
125
270
|
}
|
|
126
271
|
for (const [index, entry] of value.entries()) {
|
|
127
272
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
128
|
-
errors.push(
|
|
273
|
+
errors.push(`surface_coverage[${index}] must be an object with capability_id, scenario and surfaces; got ${jsonType(entry)}`);
|
|
129
274
|
continue;
|
|
130
275
|
}
|
|
131
276
|
const record = entry;
|
|
132
|
-
|
|
133
|
-
|
|
277
|
+
const capabilityId = typeof record.capability_id === 'string' ? record.capability_id : null;
|
|
278
|
+
if (capabilityId === null || !item.capability_ids.includes(capabilityId)) {
|
|
279
|
+
errors.push(`surface_coverage[${index}].capability_id ${String(record.capability_id)} is not in this plan item`);
|
|
134
280
|
}
|
|
135
281
|
if (typeof record.scenario !== 'string' || !record.scenario.trim()) {
|
|
136
|
-
errors.push(
|
|
282
|
+
errors.push(`surface_coverage[${index}].scenario must name the exact Scenario it covers`);
|
|
283
|
+
}
|
|
284
|
+
const known = capabilityId === null ? [] : assigned.get(capabilityId) ?? [];
|
|
285
|
+
if (!Array.isArray(record.surfaces) || record.surfaces.some((surface) => typeof surface !== 'string' || !surface.trim())) {
|
|
286
|
+
errors.push(`surface_coverage[${index}].surfaces must be an array of the addresses the Scenario drives`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (known.length === 0)
|
|
290
|
+
continue;
|
|
291
|
+
if (record.surfaces.length === 0) {
|
|
292
|
+
errors.push(`surface_coverage[${index}].surfaces must name at least one surface the Scenario drives`);
|
|
137
293
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
294
|
+
for (const surface of record.surfaces) {
|
|
295
|
+
if (!known.includes(surface))
|
|
296
|
+
errors.push(`${surface} was not assigned to ${capabilityId}`);
|
|
141
297
|
}
|
|
142
298
|
}
|
|
143
299
|
}
|
|
@@ -155,11 +311,11 @@ function validateSurfaceCoverage(value, item, label, errors) {
|
|
|
155
311
|
//
|
|
156
312
|
// Behavioral only, for the same reason as its neighbour: the structural branch
|
|
157
313
|
// has no addresses to account for.
|
|
158
|
-
function validateUnreachableSurfaces(value, item, coverage, assigned,
|
|
314
|
+
function validateUnreachableSurfaces(value, item, coverage, assigned, errors) {
|
|
159
315
|
if (value === undefined && item.branch !== 'behavioral')
|
|
160
316
|
return;
|
|
161
317
|
if (!Array.isArray(value)) {
|
|
162
|
-
errors.push(
|
|
318
|
+
errors.push('unreachable_surfaces must be an array of {surface, reason} entries, empty when the slice can drive everything it was given');
|
|
163
319
|
return;
|
|
164
320
|
}
|
|
165
321
|
const driven = new Set(drivenSurfaces(coverage));
|
|
@@ -171,13 +327,13 @@ function validateUnreachableSurfaces(value, item, coverage, assigned, label, err
|
|
|
171
327
|
const seen = new Set();
|
|
172
328
|
for (const [index, entry] of value.entries()) {
|
|
173
329
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
174
|
-
errors.push(
|
|
330
|
+
errors.push(`unreachable_surfaces[${index}] must be an object with surface and reason; got ${jsonType(entry)}`);
|
|
175
331
|
continue;
|
|
176
332
|
}
|
|
177
333
|
const record = entry;
|
|
178
334
|
const surface = record.surface;
|
|
179
335
|
if (typeof surface !== 'string' || !surface.trim()) {
|
|
180
|
-
errors.push(
|
|
336
|
+
errors.push(`unreachable_surfaces[${index}].surface must name one address`);
|
|
181
337
|
continue;
|
|
182
338
|
}
|
|
183
339
|
// A reason per address, never one reason for a list. A sentence you cannot
|
|
@@ -185,19 +341,19 @@ function validateUnreachableSurfaces(value, item, coverage, assigned, label, err
|
|
|
185
341
|
// which is the whole guard, and the reason this bucket stays narrow while
|
|
186
342
|
// its neighbour widened.
|
|
187
343
|
if (typeof record.reason !== 'string' || !record.reason.trim()) {
|
|
188
|
-
errors.push(
|
|
344
|
+
errors.push(`unreachable_surfaces[${index}].reason must say what has to happen elsewhere for ${surface} to be called`);
|
|
189
345
|
}
|
|
190
346
|
if (driven.has(surface)) {
|
|
191
|
-
errors.push(`${
|
|
347
|
+
errors.push(`${surface} is driven by a Scenario and declared unreachable — it is one or the other`);
|
|
192
348
|
}
|
|
193
349
|
// Only when the assignment actually listed addresses for this capability.
|
|
194
350
|
// An assignment that says nothing cannot say a surface is foreign, and
|
|
195
351
|
// refusing there would refuse honest slices over an absence.
|
|
196
352
|
if (known.size > 0 && !known.has(surface)) {
|
|
197
|
-
errors.push(`${
|
|
353
|
+
errors.push(`${surface} was not assigned to this slice`);
|
|
198
354
|
}
|
|
199
355
|
if (seen.has(surface))
|
|
200
|
-
errors.push(
|
|
356
|
+
errors.push(`unreachable_surfaces names ${surface} more than once`);
|
|
201
357
|
else
|
|
202
358
|
seen.add(surface);
|
|
203
359
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.16 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
@@ -51,7 +51,10 @@ source-referenced. The normative JSON shape of one facts entry is:
|
|
|
51
51
|
{"fact":"The route creates an order.","source_refs":["app/orders.rb:12"],"established_by":"read"}
|
|
52
52
|
```
|
|
53
53
|
Every facts entry is an object in that shape, never a string; `established_by` is
|
|
54
|
-
`read` or `ran: <command>`, and a failure you reproduced is the second kind.
|
|
54
|
+
`read` or `ran: <command>`, and a failure you reproduced is the second kind.
|
|
55
|
+
`known_problems` and `decisions` are arrays of plain strings. A promise moves
|
|
56
|
+
between `unresolved_promises` and `completed_promises` by its exact plan text —
|
|
57
|
+
never rewritten, never annotated. On
|
|
55
58
|
the behavioral branch, when you rename a Scenario or change what its steps drive,
|
|
56
59
|
update that Scenario's `surface_coverage` entry in the same breath — the
|
|
57
60
|
coordinator publishes those entries and does not reread your steps. Before handoff,
|
|
@@ -188,6 +188,10 @@ A reviewer who improves the thing under review has reviewed its own work.
|
|
|
188
188
|
by the connector and travels with the upload; a run started here lands on the
|
|
189
189
|
shared test database and proves nothing about the candidate that was bound.
|
|
190
190
|
|
|
191
|
+
**Do not delegate.** Read every Scenario yourself, in this context, and write the
|
|
192
|
+
one file. A review split across helpers comes back as their notifications to
|
|
193
|
+
whoever launched you, and the file you owe is assembled by nobody.
|
|
194
|
+
|
|
191
195
|
**Do not rewrite anybody's verdict, including on a second pass.** If you find
|
|
192
196
|
yourself weighing whether an objection is worth the trouble, the answer is that
|
|
193
197
|
it costs this run nothing at all. A second pass that arrives with a
|
|
@@ -38,14 +38,22 @@ entry per Scenario you write, recorded as you write it.
|
|
|
38
38
|
```json
|
|
39
39
|
{"capability_id":"<one of your plan item's ids>","scenario":"<exact Scenario name>","surfaces":["POST /orders"]}
|
|
40
40
|
```
|
|
41
|
-
`surfaces` names the addresses
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
`surfaces` names the **assigned** addresses the Scenario's `When` really reaches —
|
|
42
|
+
not the ones you meant to reach. An address of another capability, a server
|
|
43
|
+
action, a private function — none of these is a surface: say it in `decisions`
|
|
44
|
+
and leave it out of `surfaces`. Only you can know what the `When` reaches: the
|
|
45
|
+
coordinator publishes this join and never reopens your step files. On a2time,
|
|
46
|
+
2026-08-17, it had to reconstruct the join from what the workers said about
|
|
47
|
+
their work; the independent reviewer read the steps instead, six Scenarios
|
|
48
|
+
claimed addresses their steps never drove, and the server refused the
|
|
47
49
|
publication.
|
|
48
50
|
|
|
51
|
+
A Scenario that reaches none of the addresses assigned to its capability — dead
|
|
52
|
+
code, a private function, a job nothing on the map calls — is not a Scenario of
|
|
53
|
+
this suite. Delete it, or fold what it checks into a Scenario that does reach
|
|
54
|
+
one, and write the reason in `decisions`. There is no honest
|
|
55
|
+
`surface_coverage` row for it, and the server has no place for it either.
|
|
56
|
+
|
|
49
57
|
Your checkpoint also carries `unreachable_surfaces`, and it is usually empty. An
|
|
50
58
|
address goes there only when *nothing you can do* makes that request happen — a
|
|
51
59
|
third party's callback, a vendor's webhook, a redirect a real account has to
|
|
@@ -98,10 +106,13 @@ copy it into an owned file.
|
|
|
98
106
|
|
|
99
107
|
Never run the suite, boot the application, or perform branch-global duplicate,
|
|
100
108
|
marker, metadata, or surface validation. You may make one final read of your
|
|
101
|
-
owned files before handoff. During that final read, confirm
|
|
102
|
-
is
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
owned files before handoff. During that final read, confirm three things and
|
|
110
|
+
correct what is not so: every `facts` entry is an object in the normative shape
|
|
111
|
+
above; every `Scenario` and `Scenario Outline` carries exactly one `@ubc_…` tag,
|
|
112
|
+
the `case_marker` of its capability (on the structural branch: every file you
|
|
113
|
+
wrote names the marker of its interface at least once); every address in
|
|
114
|
+
`surface_coverage` is one assigned to that capability in the request. Do not
|
|
115
|
+
create temporary self-validation scripts or loop over repeated rereads.
|
|
105
116
|
|
|
106
117
|
Your ceiling is an emergency fuse, not a budget to spend. It sits far above the
|
|
107
118
|
work one plan item takes, so reaching it means this run is broken rather than
|