unitbob 0.7.13 → 0.7.15
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 +3 -3
- package/dist/cli.js +7 -3
- package/dist/files/behavioral.js +188 -4
- package/dist/files/packets.js +39 -5
- package/dist/files/workerPlan.js +81 -14
- package/dist/runner/bdd.js +19 -0
- package/dist/runner/bootcheck.js +34 -8
- package/dist/runner/worldProbe.js +100 -3
- package/dist/surfaces/nextRoutes.js +133 -0
- package/dist/surfaces/routeInventory.js +76 -21
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putMapBuild.js +1 -1
- package/dist/verbs/suitePrepare.js +41 -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
|
@@ -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.15",
|
|
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.15 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
|