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 +5 -2
- package/dist/files/suiteBuild.js +21 -0
- package/dist/files/workerPlan.js +52 -10
- package/dist/runner/provision.js +17 -1
- package/dist/verbs/putSuiteBuild.js +27 -4
- package/dist/verbs/runLocal.js +5 -14
- package/dist/verbs/validateWorkerCheckpoints.js +72 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-worker.toml +16 -0
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
|
|
61
|
-
|
|
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).
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -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)) {
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -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 —
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
-
|
|
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))
|
package/dist/runner/provision.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -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
|
|
86
|
-
//
|
|
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
|
|
91
|
-
|
|
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.
|
|
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.
|
|
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
|