unitbob 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -57,8 +57,11 @@ Verbs:
57
57
  every slice it names, before fan-out.
58
58
  validate-worker-checkpoints
59
59
  Internal: validate every worker checkpoint before assembly or repair.
60
- put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
61
- then run every branch it published and report the server's results.
60
+ put-suite-build [branch]
61
+ Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
62
+ then run every branch it published and report the server's results. Name a branch
63
+ to publish that one alone, as soon as it is finished; with no argument both are
64
+ expected, and one the answer never mentions is reported.
62
65
  run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
63
66
  that will run it afterwards. No argument runs every branch the build asked for.
64
67
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
@@ -284,6 +284,27 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
284
284
  writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
285
285
  return request;
286
286
  }
287
+ // Which branches a command was told to work on. No name means all of them — the
288
+ // "one suite, one run" shape both recipes insist on, so the default never teaches
289
+ // the habit the recipes forbid. A name narrows: `run-local` uses it for the
290
+ // repair loop, where re-running the finished peer is pure cost, and
291
+ // `put-suite-build` for publishing a branch the moment it is done (spec 41,
292
+ // criterion 3).
293
+ //
294
+ // One parse and one sentence for both, because it is one rule. They had a copy
295
+ // each and worded the same user error two different ways, which makes a person
296
+ // who has met one of them read the other as a different problem.
297
+ export function namedBranches(request, args) {
298
+ const all = request.branches.map((branch) => branch.suite_kind);
299
+ const named = args.filter((arg) => !arg.startsWith('-'));
300
+ if (named.length === 0)
301
+ return [];
302
+ const unknown = named.filter((name) => !all.includes(name));
303
+ if (unknown.length > 0) {
304
+ throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
305
+ }
306
+ return named;
307
+ }
287
308
  export function readSuiteBuildRequest(projectRoot) {
288
309
  const path = requestPath(projectRoot);
289
310
  if (!existsSync(path)) {
@@ -21,6 +21,48 @@ export function requestDigest(projectRoot) {
21
21
  export function workerPlanDigest(projectRoot) {
22
22
  return exactFileDigest(workerPlanPath(projectRoot));
23
23
  }
24
+ // The addresses the request handed to each capability, indexed by id. The
25
+ // checkpoint gate needs them for one question only: was this address given to
26
+ // this slice at all (spec 41, criterion 1). What a slice *left* is not worked out
27
+ // anywhere in this repo — answering that means reading how much of a capability
28
+ // is guarded, which is Rails' to say and, as the architecture guard notes, not a
29
+ // sentence `src/` is even allowed to write.
30
+ //
31
+ // A capability whose assignment lists no surfaces is absent from the map rather
32
+ // than present with an empty list: "this assignment does not say" and "this
33
+ // capability has no addresses" are different, and only the first must leave
34
+ // membership unchecked.
35
+ export function assignedSurfaces(projectRoot) {
36
+ const request = readRequest(projectRoot);
37
+ const byId = new Map();
38
+ for (const branch of Array.isArray(request.branches) ? request.branches : []) {
39
+ const assignment = branch.assignment;
40
+ for (const entry of Array.isArray(assignment?.capabilities) ? assignment.capabilities : []) {
41
+ const capability = entry;
42
+ const id = capability?.capability_id;
43
+ const surfaces = capability?.surfaces;
44
+ if (!isNonEmptyString(id) || !Array.isArray(surfaces) || surfaces.length === 0)
45
+ continue;
46
+ byId.set(id, surfaces.filter(isNonEmptyString));
47
+ }
48
+ }
49
+ return byId;
50
+ }
51
+ // The task, read as loosely typed JSON.
52
+ //
53
+ // `readSuiteBuildRequest` in `suiteBuild.ts` returns the same file typed, and is
54
+ // the obvious thing to call — but that module imports this one, so calling it
55
+ // back would close an import cycle. This is the price, written down so the next
56
+ // reader does not spend the same minutes finding out why.
57
+ function readRequest(projectRoot) {
58
+ const path = requestPath(projectRoot);
59
+ try {
60
+ return JSON.parse(readFileSync(path, 'utf8'));
61
+ }
62
+ catch (error) {
63
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
64
+ }
65
+ }
24
66
  export function readWorkerPlan(projectRoot) {
25
67
  const path = workerPlanPath(projectRoot);
26
68
  if (!existsSync(path))
@@ -97,9 +139,15 @@ function seedFor(item, request_digest, plan_digest) {
97
139
  written_paths: [],
98
140
  decisions: [],
99
141
  known_problems: [],
100
- // Behavioral only, and absent rather than empty elsewhere — it joins Gherkin
101
- // Scenarios to addresses, and the structural branch has no Scenarios.
102
- ...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
142
+ // Behavioral only, and absent rather than empty elsewhere — they are about
143
+ // addresses, and the structural branch has none.
144
+ //
145
+ // `unreachable_surfaces` is seeded empty because empty is the honest common
146
+ // answer and the gate wants the key present either way. Its peer bucket,
147
+ // `deferred_surfaces`, is deliberately not here: what a slice did not take is
148
+ // the remainder of what it did, and it is worked out where the upload is
149
+ // assembled. Two places to answer one question is how the two drift.
150
+ ...(item.branch === 'behavioral' ? { surface_coverage: [], unreachable_surfaces: [] } : {}),
103
151
  facts: [],
104
152
  };
105
153
  }
@@ -111,13 +159,7 @@ export function validateWorkerPlanFiles(projectRoot) {
111
159
  const errors = [];
112
160
  const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
113
161
  const plan = readWorkerPlan(projectRoot);
114
- let request;
115
- try {
116
- request = JSON.parse(readFileSync(requestPath(projectRoot), 'utf8'));
117
- }
118
- catch (error) {
119
- throw new Error(`${requestPath(projectRoot)} is not valid JSON: ${error.message}`);
120
- }
162
+ const request = readRequest(projectRoot);
121
163
  if (!plan || typeof plan !== 'object')
122
164
  return ['worker plan must be an object'];
123
165
  if (plan.request_digest !== requestDigest(projectRoot))
@@ -523,7 +523,23 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
523
523
  // the exact shape of failure 35-1 closes. A project that does carry it keeps
524
524
  // its own version, and now actually gets to: see the comment on the helper
525
525
  // for what asking twice cost A2.Time.
526
- gemLineUnlessTheProjectHasIt('webmock');
526
+ gemLineUnlessTheProjectHasIt('webmock') +
527
+ // The connector-owned World does not merely mention rspec — it requires
528
+ // `rspec/expectations` and `rspec/mocks` at load and runs a full mock
529
+ // lifecycle per scenario (`src/files/behavioral.ts`). Until spec 40 the
530
+ // sidecar never asked for either, so a Rails project on minitest got a World
531
+ // that could not load: noahsat-web died on `cannot load such file --
532
+ // rspec/expectations` and lost its behavioral branch entirely.
533
+ //
534
+ // Unpinned, and that is a safety condition rather than a taste. A project
535
+ // carrying `rspec-rails` does not declare `rspec-expectations` explicitly —
536
+ // it arrives transitively — so the guard above does not fire and our line is
537
+ // added. It resolves without conflict because the sidecar starts from a copy
538
+ // of the project's own lock (below) and `>= 0` constrains nothing, leaving
539
+ // the version rspec-rails already chose. A pin that missed that line would be
540
+ // a `Bundler::VersionConflict` at install instead.
541
+ gemLineUnlessTheProjectHasIt('rspec-expectations') +
542
+ gemLineUnlessTheProjectHasIt('rspec-mocks');
527
543
  if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
528
544
  writeFileSync(sidecarGemfile, sidecarContent);
529
545
  }
@@ -1,4 +1,4 @@
1
- import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
1
+ import { namedBranches, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
2
  import { placeProblem } from "../runner/place.js";
3
3
  import { collectBuildProblems } from "./validateBuild.js";
4
4
  import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
@@ -20,17 +20,40 @@ import { Wire } from "../wire.js";
20
20
  //
21
21
  // Returns the server's per-branch results so the caller can compose the first run
22
22
  // on top of them (spec 32-4) without parsing the lines printed here.
23
- export async function putSuiteBuild(config, _args = [], deps) {
23
+ export async function putSuiteBuild(config, args = [], deps) {
24
24
  // Spec 36, criterion 7. Publishing is followed immediately by a first run, so
25
25
  // a place that cannot be used is not something to discover after the suite is
26
26
  // stored on the server.
27
27
  const unusable = placeProblem(config.projectRoot);
28
28
  if (unusable)
29
29
  throw new Error(`${unusable}\nNothing was uploaded.`);
30
- const request = readSuiteBuildRequest(config.projectRoot);
30
+ // Spec 41, criterion 3. The one thing a caller may say about scope: publish
31
+ // these branches, and only these.
32
+ //
33
+ // a2time, 2026-09-05. A two-hour run was interrupted mid-repair and left
34
+ // nothing on the server, though a complete answer for both branches had been
35
+ // sitting on disk since before the first run. Uploading one branch was never
36
+ // the problem — a missing branch has always been named against itself and never
37
+ // sunk the batch. What was missing is a way to say the peer's absence is the
38
+ // plan: `collectBuildProblems` exists to catch a branch abandoned in silence,
39
+ // and on a branch-at-a-time publish it would cry wolf every run.
40
+ //
41
+ // The request is cut down once, here, so every later step answers the same
42
+ // question about the same list instead of each remembering to skip the peer.
43
+ const whole = readSuiteBuildRequest(config.projectRoot);
44
+ const only = namedBranches(whole, args);
45
+ const request = only.length > 0
46
+ ? { ...whole, branches: whole.branches.filter((branch) => only.includes(branch.suite_kind)) }
47
+ : whole;
31
48
  // Spec 32-6: read branch by branch, so one unreadable entry neither hides the
32
49
  // next branch's problems nor sinks a peer that is finished and correct.
33
- const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
50
+ const answer = readHostSuiteOutputsPerBranch(request.output_path, request);
51
+ // The answer file is the whole run's, not this call's: when a branch is named,
52
+ // its peer's entry is somebody else's business — already published by an
53
+ // earlier call, or still being repaired — and reading it here would report the
54
+ // peer as unpublishable for the sole reason that this call was not about it.
55
+ const outputs = answer.outputs.filter((output) => only.length === 0 || only.includes(output.suite_kind));
56
+ const unreadable = answer.unreadable.filter((entry) => only.length === 0 || only.includes(entry.suite_kind));
34
57
  const d = {
35
58
  putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
36
59
  stdout: process.stdout,
@@ -1,4 +1,4 @@
1
- import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
1
+ import { branchRunner, namedBranches, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
2
  import { digestOf, failureSet, readRunState, rememberFailures, reportedFailures, } from "../runner/failureDigest.js";
3
3
  import { placeProblem } from "../runner/place.js";
4
4
  import { placeAdvice } from "../runner/placeAdvice.js";
@@ -82,20 +82,11 @@ function compareFailures(config, d, suiteKind, ran, before) {
82
82
  'Running it again unchanged prints this same line.\n');
83
83
  return true;
84
84
  }
85
- // Which branches to run. No argument runs every branch the request asked for
86
- // the same "one suite, one run" shape both recipes insist on, so the default
87
- // never teaches the habit the recipes forbid. A named branch is for the repair
88
- // loop, where re-running the finished peer is pure cost.
85
+ // Which branches to run: the ones named, or every branch the request asked for.
86
+ // The parse and the error live in `namedBranches`, shared with the publish side.
89
87
  function selectBranches(request, args) {
90
- const all = request.branches.map((branch) => branch.suite_kind);
91
- const named = args.filter((arg) => !arg.startsWith('-'));
92
- if (named.length === 0)
93
- return all;
94
- const unknown = named.filter((name) => !all.includes(name));
95
- if (unknown.length > 0) {
96
- throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
97
- }
98
- return named;
88
+ const named = namedBranches(request, args);
89
+ return named.length > 0 ? named : request.branches.map((branch) => branch.suite_kind);
99
90
  }
100
91
  // Non-null when the runner actually executed the branch. Everything else — no
101
92
  // entry, a declared `build_error`, a stack that cannot run it — is a branch that
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
2
+ import { assignedSurfaces, checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
3
3
  export async function validateWorkerCheckpoints(config, _args = [], deps = { stdout: process.stdout }) {
4
4
  const planErrors = validateWorkerPlanFiles(config.projectRoot);
5
5
  if (planErrors.length > 0)
@@ -7,6 +7,7 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
7
7
  const plan = readWorkerPlan(config.projectRoot);
8
8
  const expectedRequestDigest = requestDigest(config.projectRoot);
9
9
  const expectedPlanDigest = workerPlanDigest(config.projectRoot);
10
+ const assigned = assignedSurfaces(config.projectRoot);
10
11
  const errors = [];
11
12
  for (const item of plan.workers) {
12
13
  const label = `${item.branch}:${item.worker_id}`;
@@ -53,6 +54,7 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
53
54
  }
54
55
  validateCompactFacts(checkpoint.facts, label, errors);
55
56
  validateSurfaceCoverage(checkpoint.surface_coverage, item, label, errors);
57
+ validateUnreachableSurfaces(checkpoint.unreachable_surfaces, item, checkpoint.surface_coverage, assigned, label, errors);
56
58
  stringArray(checkpoint.decisions, `${label}: decisions`, errors);
57
59
  stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
58
60
  }
@@ -139,6 +141,75 @@ function validateSurfaceCoverage(value, item, label, errors) {
139
141
  }
140
142
  }
141
143
  }
144
+ // Spec 41, criterion 1. Every assigned address ends in one of three places:
145
+ // driven by a Scenario, unreachable, or deferred. Only the first two are answers
146
+ // a worker can give — deferred is whatever is left, worked out where the upload
147
+ // is assembled, so a slice never has to enumerate what it did not do.
148
+ //
149
+ // a2time, 2026-09-05. Eight capabilities left 81 addresses in none of the three,
150
+ // and the server refused the publication after two hours. The workers were not
151
+ // careless: `deferred_surfaces` was only legal past the ceiling of twenty, six of
152
+ // those eight never came near it, and silence was the only move left. Widening
153
+ // the deferred bucket gave them a legal answer; computing it gave them a free
154
+ // one. What stays here is the pair the machine cannot work out on its own.
155
+ //
156
+ // Behavioral only, for the same reason as its neighbour: the structural branch
157
+ // has no addresses to account for.
158
+ function validateUnreachableSurfaces(value, item, coverage, assigned, label, errors) {
159
+ if (value === undefined && item.branch !== 'behavioral')
160
+ return;
161
+ 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`);
163
+ return;
164
+ }
165
+ const driven = new Set(drivenSurfaces(coverage));
166
+ // Membership is measured against the addresses this slice's own capabilities
167
+ // were given, never the whole assignment: a neighbour's address is as foreign
168
+ // as an invented one.
169
+ const mine = item.capability_ids.flatMap((id) => assigned.get(id) ?? []);
170
+ const known = new Set(mine);
171
+ const seen = new Set();
172
+ for (const [index, entry] of value.entries()) {
173
+ 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)}`);
175
+ continue;
176
+ }
177
+ const record = entry;
178
+ const surface = record.surface;
179
+ if (typeof surface !== 'string' || !surface.trim()) {
180
+ errors.push(`${label}: unreachable_surfaces[${index}].surface must name one address`);
181
+ continue;
182
+ }
183
+ // A reason per address, never one reason for a list. A sentence you cannot
184
+ // write about *this* address is the signal it is not really unreachable —
185
+ // which is the whole guard, and the reason this bucket stays narrow while
186
+ // its neighbour widened.
187
+ 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`);
189
+ }
190
+ if (driven.has(surface)) {
191
+ errors.push(`${label}: ${surface} is driven by a Scenario and declared unreachable — it is one or the other`);
192
+ }
193
+ // Only when the assignment actually listed addresses for this capability.
194
+ // An assignment that says nothing cannot say a surface is foreign, and
195
+ // refusing there would refuse honest slices over an absence.
196
+ if (known.size > 0 && !known.has(surface)) {
197
+ errors.push(`${label}: ${surface} was not assigned to this slice`);
198
+ }
199
+ if (seen.has(surface))
200
+ errors.push(`${label}: unreachable_surfaces names ${surface} more than once`);
201
+ else
202
+ seen.add(surface);
203
+ }
204
+ }
205
+ function drivenSurfaces(coverage) {
206
+ if (!Array.isArray(coverage))
207
+ return [];
208
+ return coverage.flatMap((entry) => {
209
+ const surfaces = entry?.surfaces;
210
+ return Array.isArray(surfaces) ? surfaces.filter((surface) => typeof surface === 'string') : [];
211
+ });
212
+ }
142
213
  function jsonType(value) {
143
214
  if (value === null)
144
215
  return 'null';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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.4 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.6 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
@@ -46,6 +46,22 @@ said about their work; the independent reviewer read the steps instead, six
46
46
  Scenarios claimed addresses their steps never drove, and the server refused the
47
47
  publication.
48
48
 
49
+ Your checkpoint also carries `unreachable_surfaces`, and it is usually empty. An
50
+ address goes there only when *nothing you can do* makes that request happen — a
51
+ third party's callback, a vendor's webhook, a redirect a real account has to
52
+ send. Each one needs its own sentence saying what has to happen elsewhere:
53
+ ```json
54
+ {"surface":"GET /oauth2callback","reason":"The provider sends the user back here after they approve access, and no test can cause that."}
55
+ ```
56
+ Hard is not unreachable. Authentication, a fixture that takes work, a background
57
+ job, a paid API with a sandbox — all drivable, so drive them.
58
+
59
+ You do **not** list the addresses you simply did not take. Whatever you neither
60
+ drove nor declared unreachable is the remainder, and the map shows it beside the
61
+ capability as *not taken this time* — "6 of 21 addresses guarded". So take the
62
+ ones that matter first: money, then authorization, then the addresses the rest of
63
+ the code points at most.
64
+
49
65
  Write first, then find out. Start with the planned cases your seeded facts
50
66
  already support and get them onto disk; go reading only for what you still lack
51
67
  after that. The opposite order — survey the sources, then write — is what spent