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.
@@ -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 { probeBehavioralWorld } from "../runner/worldProbe.js";
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 ?? probeBehavioralWorld,
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). Only Ruby is probed by
189
- // running it: the Ruby World integrates deeply with Rails, and the probe
190
- // needs an application to integrate with. The JS and Python harnesses do
191
- // one thingrefuse connections that leave the machine and their
192
- // guards are executed for real in the connector's own suite.
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 === 'cucumber') {
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 boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
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
- const next = boot.cause !== 'defect_in_code'
539
- ? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` ' +
540
- 'it never writes to your project. Something outside that file is still missing here. Run the ' +
541
- 'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
542
- 'then run `unitbob suite-prepare` again.'
543
- : prepared
544
- ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
545
- : `This is what these files do with nothing in front of them, and the run will have ` +
546
- `${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
547
- 'first import the environment variables the modules read, a loader registration, and only as a ' +
548
- 'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
549
- 'and this same question will be asked through it.';
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 { assignedSurfaces, checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
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 errors = [];
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 path = checkpointPath(config.projectRoot, item);
15
- if (!existsSync(path)) {
16
- errors.push(`${label}: checkpoint is missing at ${path}`);
17
- continue;
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
- let checkpoint;
20
- try {
21
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
22
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
23
- errors.push(`${label}: checkpoint must be an object`);
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
- catch (error) {
29
- errors.push(`${label}: checkpoint is not valid JSON: ${error.message}`);
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
- if (checkpoint.request_digest !== expectedRequestDigest)
33
- errors.push(`${label}: request_digest is stale`);
34
- if (checkpoint.plan_digest !== expectedPlanDigest)
35
- errors.push(`${label}: plan_digest is stale`);
36
- if (checkpoint.branch !== item.branch)
37
- errors.push(`${label}: branch does not match its plan item`);
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, label, errors) {
219
+ function validateCompactFacts(value, errors) {
85
220
  if (!Array.isArray(value)) {
86
- errors.push(`${label}: facts must be an array`);
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(`${label}: $.facts[${index}] must be an object with fact, source_refs and established_by; got ${jsonType(entry)}`);
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(`${label}: facts[${index}].fact must be non-empty`);
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(`${label}: facts[${index}].source_refs must be compact source references`);
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(`${label}: facts[${index}].established_by must be "read" or "ran: <command>"`);
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(`${label}: facts[${index}] may not embed source, transcript, or suite copies`);
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
- function validateSurfaceCoverage(value, item, label, errors) {
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(`${label}: surface_coverage must be an array of {capability_id, scenario, surfaces} entries, one per Scenario written`);
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(`${label}: surface_coverage[${index}] must be an object with capability_id, scenario and surfaces; got ${jsonType(entry)}`);
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
- if (typeof record.capability_id !== 'string' || !item.capability_ids.includes(record.capability_id)) {
133
- errors.push(`${label}: surface_coverage[${index}].capability_id ${String(record.capability_id)} is not in this plan item`);
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(`${label}: surface_coverage[${index}].scenario must name the exact Scenario it covers`);
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
- if (!Array.isArray(record.surfaces) || record.surfaces.length === 0
139
- || record.surfaces.some((surface) => typeof surface !== 'string' || !surface.trim())) {
140
- errors.push(`${label}: surface_coverage[${index}].surfaces must name at least one surface the Scenario drives`);
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, label, errors) {
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(`${label}: unreachable_surfaces must be an array of {surface, reason} entries, empty when the slice can drive everything it was given`);
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(`${label}: unreachable_surfaces[${index}] must be an object with surface and reason; got ${jsonType(entry)}`);
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(`${label}: unreachable_surfaces[${index}].surface must name one address`);
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(`${label}: unreachable_surfaces[${index}].reason must say what has to happen elsewhere for ${surface} to be called`);
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(`${label}: ${surface} is driven by a Scenario and declared unreachable — it is one or the other`);
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(`${label}: ${surface} was not assigned to this slice`);
353
+ errors.push(`${surface} was not assigned to this slice`);
198
354
  }
199
355
  if (seen.has(surface))
200
- errors.push(`${label}: unreachable_surfaces names ${surface} more than once`);
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.14",
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.14 run-local <branch>` and inspect the machine
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. On
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 and jobs the Scenario's `When` really reaches — not
42
- the ones its capability was assigned, and not the ones you meant to reach. Only
43
- you can know this: the coordinator publishes this join and never reopens your step
44
- files. On a2time, 2026-08-17, it had to reconstruct the join from what the workers
45
- said about their work; the independent reviewer read the steps instead, six
46
- Scenarios claimed addresses their steps never drove, and the server refused the
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 every `facts` entry
102
- is an object in the normative shape above and correct the checkpoint if it is
103
- not. Do not create temporary self-validation scripts or
104
- loop over repeated rereads.
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