unitbob 0.7.3 → 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/runner/vitest.js +80 -6
- package/dist/verbs/putSuiteBuild.js +27 -4
- package/dist/verbs/run.js +5 -2
- package/dist/verbs/runLocal.js +11 -15
- package/dist/verbs/suitePrepare.js +86 -8
- 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
|
}
|
package/dist/runner/vitest.js
CHANGED
|
@@ -16,6 +16,56 @@ export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
|
|
|
16
16
|
// and is rewritten immediately before every run, so a boot check that reused it
|
|
17
17
|
// would either be overwritten or leave the run pointing at a probe.
|
|
18
18
|
export const VITEST_BOOT_CONFIG_FILE = join('.unitbob', 'vitest.boot.config.mjs');
|
|
19
|
+
// The structural branch's one shared setup file (spec 39). The coordinator
|
|
20
|
+
// writes it before fan-out and owns it afterwards: it holds whatever has to
|
|
21
|
+
// happen before the first import of any file of the branch — environment
|
|
22
|
+
// variables, a TypeScript loader registration, at the very last resort an entry
|
|
23
|
+
// through the project's root module.
|
|
24
|
+
//
|
|
25
|
+
// A fixed name rather than a new protocol field, because the file has to survive
|
|
26
|
+
// the round trip: `materializeGuardrails` wipes this directory and lays the
|
|
27
|
+
// artifact out again, so the file must travel as an ordinary `support_files`
|
|
28
|
+
// entry — and every such entry is otherwise handed to the runner as a test path.
|
|
29
|
+
// One constant here, read by the config builder, by both collectors of test
|
|
30
|
+
// paths, and by `suite-prepare`, is the whole of the agreement. A field would
|
|
31
|
+
// have had to be agreed with the server and both of its version models.
|
|
32
|
+
//
|
|
33
|
+
// One name, one extension. A second one buys nothing: vitest puts every setup
|
|
34
|
+
// file through vite, so `require` is undefined in a `.js` setup file just as it
|
|
35
|
+
// is in a `.ts` one.
|
|
36
|
+
export const STRUCTURAL_SETUP_FILE = '.unitbob/structural/_setup.ts';
|
|
37
|
+
// The project-relative path of that file, or `undefined` when it has not been
|
|
38
|
+
// written yet. Two callers, one question: the config builder asks it to decide
|
|
39
|
+
// whether to name the file in `setupFiles`, and `suite-prepare` asks it to
|
|
40
|
+
// decide whether the boot probe is a scout or a sentry (spec 39, criterion 4).
|
|
41
|
+
// Deliberately the same observable fact for both, rather than a flag — 32-6
|
|
42
|
+
// forbids a mode, and a mode would let the two sides disagree about which run
|
|
43
|
+
// they are in.
|
|
44
|
+
export function setupFileOf(projectRoot) {
|
|
45
|
+
return existsSync(join(projectRoot, STRUCTURAL_SETUP_FILE)) ? STRUCTURAL_SETUP_FILE : undefined;
|
|
46
|
+
}
|
|
47
|
+
// Whether anything of ours can run before this stack's branch imports its first
|
|
48
|
+
// module. Only vitest, because `setupFiles` is a vitest notion and the builder
|
|
49
|
+
// below is the single place that inserts our file. Kept here, beside the
|
|
50
|
+
// insertion it describes, rather than in the verb that asks: the verb would be
|
|
51
|
+
// stating a fact about a module it does not own, and the two could then drift.
|
|
52
|
+
export function canPrepareBeforeImports(runner) {
|
|
53
|
+
return runner === 'vitest';
|
|
54
|
+
}
|
|
55
|
+
// The files of an artifact that a runner may be pointed at, which is all of them
|
|
56
|
+
// except the shared setup file. It travels as an ordinary `support_files` entry
|
|
57
|
+
// so that materialization writes it back rather than wiping it, and it holds no
|
|
58
|
+
// cases at all: a runner given it as a test path opens it, finds nothing, and
|
|
59
|
+
// reports that nothing as a suite.
|
|
60
|
+
//
|
|
61
|
+
// One filter for both collectors — the check flow's and run-local's — because
|
|
62
|
+
// the two must never disagree about which file this is. A leading `./` is
|
|
63
|
+
// tolerated: the answer that names the file is hand-written, and two characters
|
|
64
|
+
// must not be what decides whether the branch's preparation is executed or
|
|
65
|
+
// collected.
|
|
66
|
+
export function testPathsOf(paths) {
|
|
67
|
+
return paths.filter((path) => path.replace(/^\.\//, '') !== STRUCTURAL_SETUP_FILE);
|
|
68
|
+
}
|
|
19
69
|
// The project configs we inherit from, most specific first. Vitest reads a
|
|
20
70
|
// project's own config even when we pass `--config`, so we must merge ours with
|
|
21
71
|
// it rather than replace it (plugins, path aliases and resolve settings the
|
|
@@ -87,7 +137,7 @@ export async function runVitestSuite(projectRoot, suitePaths) {
|
|
|
87
137
|
function writeMergedConfig(projectRoot, suitePaths) {
|
|
88
138
|
const path = join(projectRoot, VITEST_CONFIG_FILE);
|
|
89
139
|
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
-
writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run'));
|
|
140
|
+
writeFileSync(path, configSource(projectConfigOf(projectRoot), suitePaths, 'run', setupFileOf(projectRoot)));
|
|
91
141
|
return ['--config', VITEST_CONFIG_FILE];
|
|
92
142
|
}
|
|
93
143
|
// The same config, shaped for the boot check's probe (spec 38). Returned as text
|
|
@@ -95,7 +145,12 @@ function writeMergedConfig(projectRoot, suitePaths) {
|
|
|
95
145
|
// puts in the project: it writes them together and removes them together, and a
|
|
96
146
|
// writer here would take half of that away from the one place that can see it.
|
|
97
147
|
export function vitestBootConfigSource(projectRoot, probePath) {
|
|
98
|
-
|
|
148
|
+
// The same preparation the run will get, which is the point of asking twice
|
|
149
|
+
// (spec 39, criterion 4). On the first `suite-prepare` there is no setup file
|
|
150
|
+
// and the probe answers "these files do not load on their own"; on the second,
|
|
151
|
+
// after the coordinator wrote one, it answers the question the run will
|
|
152
|
+
// actually put — and only then is a red answer worth a branch.
|
|
153
|
+
return configSource(projectConfigOf(projectRoot), [probePath], 'boot', setupFileOf(projectRoot));
|
|
99
154
|
}
|
|
100
155
|
function projectConfigOf(projectRoot) {
|
|
101
156
|
return PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
@@ -118,13 +173,25 @@ function projectConfigOf(projectRoot) {
|
|
|
118
173
|
// and `defineConfig` is a typing helper that buys a generated file nothing.
|
|
119
174
|
// `mode` is the boot check's two differences from the run, and they go together:
|
|
120
175
|
// globals on, and a workspace refused. See `vitestBootConfigSource`.
|
|
121
|
-
|
|
176
|
+
//
|
|
177
|
+
// `setupFiles` is the one field that is added to rather than replaced (spec 39,
|
|
178
|
+
// criterion 2). The project's own entry is what raises its test database and
|
|
179
|
+
// reads its environment — epic-stack's `tests/setup/setup-test-env.ts` is why
|
|
180
|
+
// its 50 structural checks have a database to read — and replacing it would
|
|
181
|
+
// take that away from the only structural suite on the bench that works. Ours
|
|
182
|
+
// goes first: it exists to set things up before the first import of anything,
|
|
183
|
+
// and the project's own setup file opens with imports of the application.
|
|
184
|
+
function configSource(projectConfig, suitePaths, mode, setupFile) {
|
|
122
185
|
// After the project's own `test`, never before it: ours has to win.
|
|
123
|
-
const settings = `${mode === 'boot' ? 'globals: true, ' : ''}include: ${JSON.stringify(suitePaths)}`;
|
|
186
|
+
const settings = `${mode === 'boot' ? 'globals: true, ' : ''}${setupFile ? 'setupFiles, ' : ''}include: ${JSON.stringify(suitePaths)}`;
|
|
124
187
|
const header = '// Written by the unitbob connector before each vitest run — do not edit.';
|
|
125
188
|
if (!projectConfig) {
|
|
189
|
+
// Nothing to inherit, so nothing to concatenate — but the shared file is
|
|
190
|
+
// still the branch's, and a project with no config of its own is exactly
|
|
191
|
+
// the kind that needs it.
|
|
192
|
+
const alone = setupFile ? `const setupFiles = ${JSON.stringify([setupFile])};\n` : '';
|
|
126
193
|
return `${header}
|
|
127
|
-
export default { test: { ${settings} } };
|
|
194
|
+
${alone}export default { test: { ${settings} } };
|
|
128
195
|
`;
|
|
129
196
|
}
|
|
130
197
|
// Globals, because the probe lives in `.unitbob/structural/`: an
|
|
@@ -144,13 +211,20 @@ const { projects, workspace, ...test } = base.test ?? {};
|
|
|
144
211
|
: `
|
|
145
212
|
const test = base.test ?? {};
|
|
146
213
|
`;
|
|
214
|
+
// `[].concat(x)` on purpose: vitest accepts `setupFiles` as an array or as a
|
|
215
|
+
// bare string, and most projects set neither. All three forms arrive here and
|
|
216
|
+
// all three have to come out an array, because a config that throws while it
|
|
217
|
+
// is being read takes the whole run with it.
|
|
218
|
+
const merge = setupFile
|
|
219
|
+
? `const setupFiles = ${JSON.stringify([setupFile])}.concat(test.setupFiles ?? []);\n`
|
|
220
|
+
: '';
|
|
147
221
|
return `${header}
|
|
148
222
|
import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
|
|
149
223
|
|
|
150
224
|
const base = typeof projectConfig === 'function'
|
|
151
225
|
? await projectConfig({ command: 'serve', mode: 'test' })
|
|
152
226
|
: projectConfig;
|
|
153
|
-
${narrow}
|
|
227
|
+
${narrow}${merge}
|
|
154
228
|
export default { ...base, test: { ...test, ${settings} } };
|
|
155
229
|
`;
|
|
156
230
|
}
|
|
@@ -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/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import { placeProblem } from "../runner/place.js";
|
|
|
4
4
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
5
5
|
import { validateStack } from "../runner/precheck.js";
|
|
6
6
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
7
|
-
import { runVitestSuite } from "../runner/vitest.js";
|
|
7
|
+
import { runVitestSuite, testPathsOf } from "../runner/vitest.js";
|
|
8
8
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
9
9
|
import { runBddSuite } from "../runner/bdd.js";
|
|
10
10
|
import { enterUrl } from "../links.js";
|
|
@@ -147,8 +147,11 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
|
|
|
147
147
|
// Every file of the branch, in the order the envelope carries them. A structural
|
|
148
148
|
// branch is one file per assignment since spec one-place-per-rule, §6, and running only the main
|
|
149
149
|
// one would execute a fraction of what the map says is guarded.
|
|
150
|
+
//
|
|
151
|
+
// Every file except the branch's shared setup file, which `testPathsOf` drops
|
|
152
|
+
// (spec 39): it is named in `setupFiles` instead of being collected from.
|
|
150
153
|
function artifactPaths(file) {
|
|
151
|
-
return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
|
|
154
|
+
return testPathsOf([file.path, ...(file.support_files ?? []).map((entry) => entry.path)]);
|
|
152
155
|
}
|
|
153
156
|
function suiteError(suiteDigest, message) {
|
|
154
157
|
return {
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
5
5
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
6
6
|
import { validateStack } from "../runner/precheck.js";
|
|
7
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { testPathsOf } from "../runner/vitest.js";
|
|
8
9
|
import { runStructuralByRunner } from "./run.js";
|
|
9
10
|
const OUTPUT_TAIL_CHARS = 4000;
|
|
10
11
|
export async function runLocal(config, args = [], deps) {
|
|
@@ -81,20 +82,11 @@ function compareFailures(config, d, suiteKind, ran, before) {
|
|
|
81
82
|
'Running it again unchanged prints this same line.\n');
|
|
82
83
|
return true;
|
|
83
84
|
}
|
|
84
|
-
// Which branches to run
|
|
85
|
-
//
|
|
86
|
-
// never teaches the habit the recipes forbid. A named branch is for the repair
|
|
87
|
-
// 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.
|
|
88
87
|
function selectBranches(request, args) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
if (named.length === 0)
|
|
92
|
-
return all;
|
|
93
|
-
const unknown = named.filter((name) => !all.includes(name));
|
|
94
|
-
if (unknown.length > 0) {
|
|
95
|
-
throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
|
|
96
|
-
}
|
|
97
|
-
return named;
|
|
88
|
+
const named = namedBranches(request, args);
|
|
89
|
+
return named.length > 0 ? named : request.branches.map((branch) => branch.suite_kind);
|
|
98
90
|
}
|
|
99
91
|
// Non-null when the runner actually executed the branch. Everything else — no
|
|
100
92
|
// entry, a declared `build_error`, a stack that cannot run it — is a branch that
|
|
@@ -259,7 +251,11 @@ function artifactPathsOf(output) {
|
|
|
259
251
|
const rest = support
|
|
260
252
|
.map((entry) => entry?.path)
|
|
261
253
|
.filter((candidate) => typeof candidate === 'string' && candidate.length > 0);
|
|
262
|
-
|
|
254
|
+
// Everything but the branch's shared setup file (spec 39): it is a support
|
|
255
|
+
// file so that it survives materialization, and it is named in `setupFiles`
|
|
256
|
+
// rather than handed over as a path to collect tests from. Literally the same
|
|
257
|
+
// filter the check flow uses, because both sides have to mean the one file.
|
|
258
|
+
return testPathsOf([path, ...rest]);
|
|
263
259
|
}
|
|
264
260
|
function branchRoot(config, suiteKind) {
|
|
265
261
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
@@ -11,6 +11,7 @@ import { placeProblem } from "../runner/place.js";
|
|
|
11
11
|
import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
|
|
12
12
|
import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
|
|
13
13
|
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
14
|
+
import { canPrepareBeforeImports, setupFileOf, STRUCTURAL_SETUP_FILE } from "../runner/vitest.js";
|
|
14
15
|
import { probeBehavioralWorld } from "../runner/worldProbe.js";
|
|
15
16
|
import { Wire } from "../wire.js";
|
|
16
17
|
// The complete envelope for one branch, or null when this machine cannot
|
|
@@ -225,13 +226,52 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
225
226
|
// and boots the application for itself, so asking with no structural branch in
|
|
226
227
|
// the run would start Rails to answer a question nobody put — and a `broken`
|
|
227
228
|
// answer would then report a branch this build never had.
|
|
229
|
+
//
|
|
230
|
+
// Spec 39 kept the sentry and moved the sentence. The probe asks its question
|
|
231
|
+
// twice: once before anything has been prepared for these files, and once
|
|
232
|
+
// after the coordinator has written the branch's shared setup file. Only the
|
|
233
|
+
// second answer costs a branch, because only the second one asks what the run
|
|
234
|
+
// will ask. Before the setup file exists the probe's question — "do these
|
|
235
|
+
// files load with nothing in front of them" — is stricter than the condition
|
|
236
|
+
// it stands in for, and `docs/adr/0001` forbids exactly that: the condition
|
|
237
|
+
// tested must equal the condition that makes a run impossible, never exceed
|
|
238
|
+
// it. Two bench projects were refused on that excess while their behavioral
|
|
239
|
+
// peers built and stayed green.
|
|
240
|
+
//
|
|
241
|
+
// The switch is the file on disk and nothing else. No flag and no mode: 32-6
|
|
242
|
+
// forbade them, and the connector has to ask this same question anyway to
|
|
243
|
+
// decide whether to name the file in `setupFiles`, so a second signal could
|
|
244
|
+
// only ever disagree with the first.
|
|
245
|
+
//
|
|
246
|
+
// And only on the stack where a preparation can actually be put in front of
|
|
247
|
+
// the imports — `canPrepareBeforeImports`, which is vitest and only vitest. On
|
|
248
|
+
// rspec and pytest nothing of ours runs before the branch's imports, so there
|
|
249
|
+
// the probe's question already *is* the run's question: no excess to correct,
|
|
250
|
+
// and a red answer costs the branch on the first run exactly as it has since
|
|
251
|
+
// 32-6. Waiting for a file those stacks will never have would have quietly
|
|
252
|
+
// reopened the funnel that spec closed.
|
|
253
|
+
//
|
|
254
|
+
// One thing this sign cannot see: a setup file left over from an earlier
|
|
255
|
+
// build. It comes back with the artifact and is materialized like any other
|
|
256
|
+
// file of the branch, so on a re-generation the probe sentences on its first
|
|
257
|
+
// ask instead of scouting. That is the honest reading — a preparation does
|
|
258
|
+
// exist and the probe went through it — and the way out is the same one the
|
|
259
|
+
// verdict already names: fix that file, run `suite-prepare` again. Telling the
|
|
260
|
+
// two apart would need a memory of which build wrote it, which is a mode by
|
|
261
|
+
// another name, and 32-6 forbade those.
|
|
228
262
|
const bootNotices = [];
|
|
263
|
+
const bootAdvisories = [];
|
|
229
264
|
const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
|
|
230
265
|
if (structuralIndex !== -1) {
|
|
231
266
|
const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
|
|
232
267
|
if (boot.status === 'broken') {
|
|
233
|
-
|
|
234
|
-
|
|
268
|
+
if (!canPrepareBeforeImports(structuralRunner) || setupFileOf(config.projectRoot)) {
|
|
269
|
+
branches.splice(structuralIndex, 1);
|
|
270
|
+
bootNotices.push(bootStop(boot, structuralRunner));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
bootAdvisories.push(bootAdvisory(boot, structuralRunner));
|
|
274
|
+
}
|
|
235
275
|
}
|
|
236
276
|
else {
|
|
237
277
|
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
@@ -330,6 +370,18 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
330
370
|
bootNotices.join('\n') +
|
|
331
371
|
'\n');
|
|
332
372
|
}
|
|
373
|
+
// The other half of the probe's answer (spec 39), and it needs a heading of
|
|
374
|
+
// its own: "was left out of this run" is true only of the verdict, and this
|
|
375
|
+
// branch was not left out of anything. It is being built, and what follows is
|
|
376
|
+
// the opening fact for whoever writes its preparation.
|
|
377
|
+
if (bootAdvisories.length > 0) {
|
|
378
|
+
actual.stdout.write('\nThe code-structure suite is still being built, and here is what its source files did on their own: ' +
|
|
379
|
+
`they did not load. Nothing has been put in front of them yet — that is what ${STRUCTURAL_SETUP_FILE} ` +
|
|
380
|
+
'is for, and writing it comes before the fan-out. None of your own tests were opened; only the files ' +
|
|
381
|
+
'the map named:\n' +
|
|
382
|
+
bootAdvisories.join('\n') +
|
|
383
|
+
'\n');
|
|
384
|
+
}
|
|
333
385
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
334
386
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
335
387
|
if (setupNotices.length > 0) {
|
|
@@ -434,16 +486,42 @@ function bootFinding(boot, runner) {
|
|
|
434
486
|
// its peer carries on, `request.json` is written, and the vibecoder comes away
|
|
435
487
|
// with the guardrails that branch can still give rather than with nothing.
|
|
436
488
|
//
|
|
489
|
+
// Reached only once the branch's shared setup file exists (spec 39). By then the
|
|
490
|
+
// probe has been asked through that preparation, so a red answer is the run's
|
|
491
|
+
// own answer and taking the branch is honest.
|
|
492
|
+
function bootStop(boot, runner) {
|
|
493
|
+
return bootReport(boot, runner, true);
|
|
494
|
+
}
|
|
495
|
+
// The same red answer, read before anything was prepared for these files (spec
|
|
496
|
+
// 39). Same facts, same words from the runner, same caveat — what differs is
|
|
497
|
+
// the step that follows, and that is the whole difference between a scout and a
|
|
498
|
+
// sentry. Nothing here is worded as a verdict on somebody's code, because on
|
|
499
|
+
// this run it is not one: the files were asked to load with nothing in front of
|
|
500
|
+
// them, and putting something in front of them is Unitbob's own work.
|
|
501
|
+
function bootAdvisory(boot, runner) {
|
|
502
|
+
return bootReport(boot, runner, false);
|
|
503
|
+
}
|
|
437
504
|
// The two causes keep their separate next steps. An un-run `pip install` is not
|
|
438
505
|
// somebody's bug and must not be worded as one; a module of theirs that raises
|
|
439
|
-
// on import
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
506
|
+
// on import, asked with the preparation already in place, is theirs to fix and
|
|
507
|
+
// pointing at an install would waste their time.
|
|
508
|
+
//
|
|
509
|
+
// The environment cause keeps the same next step in both reports: a missing gem
|
|
510
|
+
// is not something a setup file can prepare its way around, and sending someone
|
|
511
|
+
// to write one would waste the round it costs.
|
|
512
|
+
function bootReport(boot, runner, prepared) {
|
|
513
|
+
const next = boot.cause !== 'defect_in_code'
|
|
514
|
+
? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` — ' +
|
|
444
515
|
'it never writes to your project. Something outside that file is still missing here. Run the ' +
|
|
445
516
|
'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
|
|
446
|
-
'then run `unitbob suite-prepare` again.'
|
|
517
|
+
'then run `unitbob suite-prepare` again.'
|
|
518
|
+
: prepared
|
|
519
|
+
? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
|
|
520
|
+
: `This is what these files do with nothing in front of them, and the run will have ` +
|
|
521
|
+
`${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
|
|
522
|
+
'first import — the environment variables the modules read, a loader registration, and only as a ' +
|
|
523
|
+
'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
|
|
524
|
+
'and this same question will be asked through it.';
|
|
447
525
|
// `boot.message` is the runner's own words, indented but never paraphrased:
|
|
448
526
|
// this is the line the vibecoder can paste into a search.
|
|
449
527
|
return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;
|
|
@@ -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
|