unitbob 0.6.3 → 0.7.1
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 -4
- package/dist/files/fanOut.js +148 -0
- package/dist/files/guardrails.js +1 -1
- package/dist/files/packets.js +325 -0
- package/dist/files/suiteBuild.js +34 -2
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/files/workerPlan.js +138 -5
- package/dist/runner/failureDigest.js +98 -16
- package/dist/runner/provision.js +66 -2
- package/dist/runner/pytest.js +1 -1
- package/dist/runner/pytestBddPlugin.js +5 -0
- package/dist/runner/rspec.js +1 -1
- package/dist/runner/vitest.js +1 -1
- package/dist/surfaces/graph.js +33 -0
- package/dist/surfaces/routeInventory.js +2 -33
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putSuiteBuild.js +1 -1
- package/dist/verbs/run.js +2 -2
- package/dist/verbs/runLocal.js +66 -2
- package/dist/verbs/suitePrepare.js +96 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-worker.toml +13 -5
- package/dist/verbs/validateWorkerPlan.js +0 -9
package/dist/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ import { validateBuild } from "./verbs/validateBuild.js";
|
|
|
27
27
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
28
28
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
29
29
|
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
30
|
-
import {
|
|
30
|
+
import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
|
|
31
31
|
import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
|
|
32
32
|
import { installCodexAgents } from "./verbs/codexInstall.js";
|
|
33
33
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
@@ -53,7 +53,8 @@ Verbs:
|
|
|
53
53
|
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
54
54
|
validate-build Internal: check the host's suite answer against the request, locally, before
|
|
55
55
|
uploading. Reports every problem at once; put-suite-build runs it too.
|
|
56
|
-
|
|
56
|
+
accept-worker-plan Internal: check the exact request-bound worker plan and seed a checkpoint for
|
|
57
|
+
every slice it names, before fan-out.
|
|
57
58
|
validate-worker-checkpoints
|
|
58
59
|
Internal: validate every worker checkpoint before assembly or repair.
|
|
59
60
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
@@ -120,8 +121,8 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
120
121
|
case 'validate-build':
|
|
121
122
|
await validateBuild(await linked(), args);
|
|
122
123
|
return 0;
|
|
123
|
-
case '
|
|
124
|
-
await
|
|
124
|
+
case 'accept-worker-plan':
|
|
125
|
+
await acceptWorkerPlan(await linked(), args);
|
|
125
126
|
return 0;
|
|
126
127
|
case 'validate-worker-checkpoints':
|
|
127
128
|
await validateWorkerCheckpoints(await linked(), args);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readPacketIndex } from "./packets.js";
|
|
2
|
+
// Spec 37-3, criterion 1. How wide a branch's fan-out should be.
|
|
3
|
+
//
|
|
4
|
+
// The rule this replaces said there was no ceiling at all: "an agent re-reads
|
|
5
|
+
// its context every turn, so splitting the work never costs more than keeping it
|
|
6
|
+
// together." Half right, and the wrong half was load-bearing. An agent's cost is
|
|
7
|
+
// the sum of its context over its turns, so splitting pulls in two directions:
|
|
8
|
+
//
|
|
9
|
+
// - the opening context is bought once per worker and re-read every turn, so
|
|
10
|
+
// it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
|
|
11
|
+
// the same to within ±370 across fifteen workers.
|
|
12
|
+
// - each worker's conversation is shorter, and a conversation's cost grows
|
|
13
|
+
// with the square of its length, so this falls with the width.
|
|
14
|
+
//
|
|
15
|
+
// There is therefore a minimum, and it is neither end. Measured on that bench,
|
|
16
|
+
// against what the fifteen workers actually cost:
|
|
17
|
+
//
|
|
18
|
+
// workers 1 2 3 5 8 15 20
|
|
19
|
+
// input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
|
|
20
|
+
//
|
|
21
|
+
// Fifteen was 28% over the cheapest width. One worker — which is what "the work
|
|
22
|
+
// fits in one context" would have said, and what the first draft of this rule
|
|
23
|
+
// enforced — is 85% over it, and would have run a 216-turn worker into a
|
|
24
|
+
// 150-turn fuse. The floor is as expensive a mistake as the ceiling.
|
|
25
|
+
//
|
|
26
|
+
// See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
|
|
27
|
+
// What the optimum is not a function of. Productive turns per 1,000 tokens of
|
|
28
|
+
// source were 7.8 on the behavioral branch and 1.4 on the structural one of the
|
|
29
|
+
// same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
|
|
30
|
+
// there is to read, which turns out not to be what a worker spends its turns on.
|
|
31
|
+
// They stay here for the printout and for the record in `fan_out`; they do not
|
|
32
|
+
// set the width.
|
|
33
|
+
const BYTES_PER_TOKEN = 4;
|
|
34
|
+
const WRITTEN_PER_READ = 3;
|
|
35
|
+
// What it is a function of. A planned case is one intent the worker has to turn
|
|
36
|
+
// into a written example or Scenario, and its cost in turns is a property of the
|
|
37
|
+
// branch, not of the project: a Gherkin Scenario needs the World, a session, a
|
|
38
|
+
// fixture and an assertion; a structural example calls a method.
|
|
39
|
+
//
|
|
40
|
+
// Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
|
|
41
|
+
// structural cases over 65.
|
|
42
|
+
const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
|
|
43
|
+
// The optimum width is the branch's productive turns over this. It comes out of
|
|
44
|
+
// setting the derivative of the cost above to zero, which gives
|
|
45
|
+
// `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
|
|
46
|
+
// that run and 41.7 on the structural one, near enough to each other that one
|
|
47
|
+
// number carries both and the flat bottom of the curve absorbs the difference.
|
|
48
|
+
const TURNS_PER_WORKER = 36;
|
|
49
|
+
// What a worker spends before it writes anything — reading its packets, its plan
|
|
50
|
+
// item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
|
|
51
|
+
// behavioral workers, 202 over seven structural ones. It is per worker and does
|
|
52
|
+
// not divide, which is half of why width costs; it is added back here so that
|
|
53
|
+
// the turns this prints are the whole conversation, the thing that meets the
|
|
54
|
+
// 150-turn fuse.
|
|
55
|
+
const WARMUP_TURNS = { behavioral: 22, structural: 28 };
|
|
56
|
+
// Every case ends up at the same handful of widths, so the rule has to be a band
|
|
57
|
+
// rather than a number: anywhere from three to eight workers cost within 10% of
|
|
58
|
+
// the cheapest on the measured run. What the band excludes is what actually
|
|
59
|
+
// costs — fifteen at one end, one at the other.
|
|
60
|
+
const NARROWEST = 0.5;
|
|
61
|
+
const WIDEST = 1.5;
|
|
62
|
+
// How wide a branch should be, from the cases its plan intends to write.
|
|
63
|
+
// Returns nothing for a branch this connector has no measured cost for: a rule
|
|
64
|
+
// with no measurement behind it must not refuse anybody's plan.
|
|
65
|
+
export function branchWidth(branch, plannedCases) {
|
|
66
|
+
const perCase = TURNS_PER_CASE[branch];
|
|
67
|
+
if (perCase === undefined || plannedCases <= 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
const turns = plannedCases * perCase;
|
|
70
|
+
const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
|
|
71
|
+
return {
|
|
72
|
+
branch,
|
|
73
|
+
planned_cases: plannedCases,
|
|
74
|
+
turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
|
|
75
|
+
workers,
|
|
76
|
+
fewest: Math.max(1, Math.round(workers * NARROWEST)),
|
|
77
|
+
most: Math.max(1, Math.ceil(workers * WIDEST)),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// What each branch's source weighs. Kept because it is the honest answer to "how
|
|
81
|
+
// much is there", printed before the plan exists and recorded in `fan_out` — but
|
|
82
|
+
// it is not what decides the width. See TURNS_PER_CASE above.
|
|
83
|
+
//
|
|
84
|
+
// `taken` narrows the count to the ids a plan actually took, which matters since
|
|
85
|
+
// criterion 2 let the structural branch be narrowed too: the packets are built
|
|
86
|
+
// from the whole assignment, before anybody chose a scope.
|
|
87
|
+
export function branchWorkloads(projectRoot, taken) {
|
|
88
|
+
const index = readPacketIndex(projectRoot);
|
|
89
|
+
if (!index || index.targets.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const measured = new Map();
|
|
92
|
+
const unmeasured = new Map();
|
|
93
|
+
for (const target of index.targets) {
|
|
94
|
+
const ids = taken?.get(target.branch);
|
|
95
|
+
if (taken && !ids?.has(target.id))
|
|
96
|
+
continue;
|
|
97
|
+
const size = sizeOf(target);
|
|
98
|
+
// A file too large to copy still has a path and a size, and it is the
|
|
99
|
+
// heaviest reading on the branch — counting it as nothing would let the
|
|
100
|
+
// biggest sources look like the smallest.
|
|
101
|
+
const file = target.packet ?? target.source_file;
|
|
102
|
+
if (file !== undefined && size !== undefined) {
|
|
103
|
+
const files = measured.get(target.branch) ?? new Map();
|
|
104
|
+
files.set(file, size);
|
|
105
|
+
measured.set(target.branch, files);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
|
|
112
|
+
return [...branches].sort().flatMap((branch) => {
|
|
113
|
+
const files = measured.get(branch);
|
|
114
|
+
if (!files || files.size === 0)
|
|
115
|
+
return [];
|
|
116
|
+
const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
|
|
117
|
+
const missing = unmeasured.get(branch) ?? 0;
|
|
118
|
+
const withMissing = bytes + Math.round((bytes / files.size) * missing);
|
|
119
|
+
const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
|
|
120
|
+
return [{
|
|
121
|
+
branch, files: files.size, bytes, unmeasured: missing,
|
|
122
|
+
read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
|
|
123
|
+
}];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// The index is a file on the vibecoder's disk, so a size that is not a real byte
|
|
127
|
+
// count is treated as a size we do not have rather than as zero. Zero would
|
|
128
|
+
// quietly shrink the branch's average.
|
|
129
|
+
function sizeOf(target) {
|
|
130
|
+
const { bytes } = target;
|
|
131
|
+
return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
132
|
+
}
|
|
133
|
+
export function widthLine(width) {
|
|
134
|
+
return (` ${width.branch} — ${width.planned_cases} planned ` +
|
|
135
|
+
`${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
|
|
136
|
+
`${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
|
|
137
|
+
`(${width.fewest}–${width.most} accepted).\n`);
|
|
138
|
+
}
|
|
139
|
+
export function workloadLine(load) {
|
|
140
|
+
const missing = load.unmeasured === 0
|
|
141
|
+
? ''
|
|
142
|
+
: ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
|
|
143
|
+
'priced at what the others average';
|
|
144
|
+
return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
|
|
145
|
+
`${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
|
|
146
|
+
`${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
|
|
147
|
+
`${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
|
|
148
|
+
}
|
package/dist/files/guardrails.js
CHANGED
|
@@ -67,7 +67,7 @@ abort 'unitbob_helper: refusing to run against a non-test environment' unless Ra
|
|
|
67
67
|
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
68
|
// lives outside this directory and is written by the pytest runner).
|
|
69
69
|
//
|
|
70
|
-
// Every file, not just the main one (spec
|
|
70
|
+
// Every file, not just the main one (spec one-place-per-rule, §6.4). The directory is wiped
|
|
71
71
|
// first and only the main file was written back, so a published suite of four
|
|
72
72
|
// files came back as one and the run that followed it silently protected a
|
|
73
73
|
// quarter of what the map claimed.
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { graphNodes, methodNameOf } from "../surfaces/graph.js";
|
|
4
|
+
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
|
+
import { surfacesPath } from "./mapBuild.js";
|
|
6
|
+
// Spec 37-1. A worker used to receive names — `User#get_token`, `POST /api/tokens` —
|
|
7
|
+
// and spend its first two thirds finding the code behind them. On microblog,
|
|
8
|
+
// 2026-08-23, that was 474 of 674 worker turns and 63% of their input, once per
|
|
9
|
+
// worker, for an application whose entire source is 92 KB.
|
|
10
|
+
//
|
|
11
|
+
// Resolving a name to a file is a dictionary lookup against two artifacts that
|
|
12
|
+
// are already on this machine, so it costs no tokens and asks no model. What it
|
|
13
|
+
// finds is copied whole: the packet carries exactly what the worker would have
|
|
14
|
+
// opened anyway, which is why it cannot inflate a run.
|
|
15
|
+
//
|
|
16
|
+
// Nothing here goes up the wire. Spec 22 keeps the source on the vibecoder's
|
|
17
|
+
// machine, and a packet is a local file the worker opens by path.
|
|
18
|
+
export const PACKETS_DIR = '.unitbob/suite-build/packets';
|
|
19
|
+
// The fuse, not a setting. It exists for the five-thousand-line controller
|
|
20
|
+
// whose file is not "what a worker would have read anyway" — there the packet
|
|
21
|
+
// carries the path and says so, and the worker opens the part it needs. There
|
|
22
|
+
// is nowhere for this number to live except this source file: the size of a
|
|
23
|
+
// packet is not a fact about the project, it is a fact about what fits.
|
|
24
|
+
export const MAX_PACKET_BYTES = 200_000;
|
|
25
|
+
export function packetsDir(projectRoot) {
|
|
26
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'packets');
|
|
27
|
+
}
|
|
28
|
+
export function packetIndexPath(projectRoot) {
|
|
29
|
+
return join(packetsDir(projectRoot), 'index.json');
|
|
30
|
+
}
|
|
31
|
+
export function readPacketIndex(projectRoot) {
|
|
32
|
+
const path = packetIndexPath(projectRoot);
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
if (!Array.isArray(parsed?.targets))
|
|
38
|
+
return null;
|
|
39
|
+
// Entries, not just the array: a hand-edited index must not throw out of a
|
|
40
|
+
// verb whose whole policy is that a bad index costs the packets and nothing
|
|
41
|
+
// else.
|
|
42
|
+
return { targets: parsed.targets.filter((target) => !!asRecord(target) && asString(target?.id) !== undefined) };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A packet index we cannot read is the same as none: the worker searches,
|
|
46
|
+
// as it did before this spec. It is never a reason to fail a build.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Build every packet for this request. Called right after `request.json` is
|
|
51
|
+
// written, and before any plan exists: the entrypoints are known from the
|
|
52
|
+
// request, the workers are not, so a packet belongs to an entrypoint and two
|
|
53
|
+
// entrypoints in one file share one packet.
|
|
54
|
+
export function writeSuitePackets(projectRoot, request) {
|
|
55
|
+
const targets = targetsOf(request);
|
|
56
|
+
const lookup = buildLookup(projectRoot);
|
|
57
|
+
const notes = new Map();
|
|
58
|
+
// Regenerated whole, every run. A packet left over from a previous assignment
|
|
59
|
+
// sits under an authoritative name with nothing on it saying how old it is.
|
|
60
|
+
const dir = packetsDir(projectRoot);
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
const written = new Map();
|
|
64
|
+
const refused = new Map();
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
const sourceFile = lookup(target.entrypoint);
|
|
67
|
+
if (!sourceFile) {
|
|
68
|
+
target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
|
|
69
|
+
count(notes, 'did not resolve to one file');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// The path travels even when the contents do not: a worker told which file
|
|
73
|
+
// to open has still been saved the search.
|
|
74
|
+
target.source_file = sourceFile;
|
|
75
|
+
// Two entrypoints in one file get one packet, written once, referenced twice.
|
|
76
|
+
if (!written.has(sourceFile) && !refused.has(sourceFile)) {
|
|
77
|
+
const copied = copyPacket(projectRoot, sourceFile);
|
|
78
|
+
if (typeof copied === 'number')
|
|
79
|
+
written.set(sourceFile, copied);
|
|
80
|
+
else
|
|
81
|
+
refused.set(sourceFile, copied);
|
|
82
|
+
}
|
|
83
|
+
const bytes = written.get(sourceFile);
|
|
84
|
+
if (bytes === undefined) {
|
|
85
|
+
const refusal = refused.get(sourceFile);
|
|
86
|
+
target.note = refusal.note;
|
|
87
|
+
// The size travels even when the contents do not, so spec 37-3 can weigh
|
|
88
|
+
// this entrypoint. `packet` stays unset: there is still nothing to open
|
|
89
|
+
// under the packets folder, and every reader keys off that, not off size.
|
|
90
|
+
if (refusal.bytes !== undefined)
|
|
91
|
+
target.bytes = refusal.bytes;
|
|
92
|
+
count(notes, refusal.kind);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
target.packet = `${PACKETS_DIR}/${sourceFile}`;
|
|
96
|
+
target.bytes = bytes;
|
|
97
|
+
}
|
|
98
|
+
writeFileSync(packetIndexPath(projectRoot), `${JSON.stringify({ targets }, null, 2)}\n`);
|
|
99
|
+
return {
|
|
100
|
+
targets: targets.length,
|
|
101
|
+
located: targets.filter((target) => target.source_file).length,
|
|
102
|
+
resolved: targets.filter((target) => target.packet).length,
|
|
103
|
+
files: written.size,
|
|
104
|
+
bytes: [...written.values()].reduce((sum, bytes) => sum + bytes, 0),
|
|
105
|
+
notes: [...notes].map(([note, count]) => `${count} × ${note}`),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// The two names a request carries. Structural interfaces name methods
|
|
109
|
+
// (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
|
|
110
|
+
// Both are entrypoints, both resolve to a file, and both produce the same kind
|
|
111
|
+
// of packet — a worker on either branch opens the same thing the same way.
|
|
112
|
+
function targetsOf(request) {
|
|
113
|
+
const targets = [];
|
|
114
|
+
for (const branch of request.branches) {
|
|
115
|
+
const assignment = branch.assignment;
|
|
116
|
+
for (const block of asArray(assignment?.blocks)) {
|
|
117
|
+
for (const iface of asArray(asRecord(block)?.interfaces)) {
|
|
118
|
+
const record = asRecord(iface);
|
|
119
|
+
const id = asString(record?.interface_id);
|
|
120
|
+
if (!id)
|
|
121
|
+
continue;
|
|
122
|
+
for (const entrypoint of asArray(record?.entrypoints)) {
|
|
123
|
+
const name = asString(entrypoint);
|
|
124
|
+
if (name)
|
|
125
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const capability of asArray(assignment?.capabilities)) {
|
|
130
|
+
const record = asRecord(capability);
|
|
131
|
+
const id = asString(record?.capability_id);
|
|
132
|
+
if (!id)
|
|
133
|
+
continue;
|
|
134
|
+
for (const surface of asArray(record?.surfaces)) {
|
|
135
|
+
const name = asString(surface);
|
|
136
|
+
if (name)
|
|
137
|
+
targets.push({ branch: branch.suite_kind, id, entrypoint: name });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return targets;
|
|
142
|
+
}
|
|
143
|
+
// One name in, at most one file out. Two local artifacts answer, both of them
|
|
144
|
+
// built from the graph rather than from anybody's spelling rule:
|
|
145
|
+
//
|
|
146
|
+
// `surfaces.json` — the addresses the router declared, each already carrying
|
|
147
|
+
// the file that serves it. It answers a behavioral surface (`POST /api/tokens`)
|
|
148
|
+
// by its id and a structural entrypoint (`MainRoutes.export_posts`) by the
|
|
149
|
+
// handler label, which is the router's own wording, not a name we built.
|
|
150
|
+
//
|
|
151
|
+
// `graph.json` — every symbol graphify saw, matched by method name. A name
|
|
152
|
+
// alone is not enough: `User#get_token` and `ApiTokens.get_token` are two
|
|
153
|
+
// different entrypoints and graphify saw only one `get_token()`. So the owner
|
|
154
|
+
// the entrypoint named has to agree with the file before it is believed.
|
|
155
|
+
//
|
|
156
|
+
// Ambiguity ends in silence, never in a first match, and neither does a name
|
|
157
|
+
// that resolves to a file its owner has nothing to do with. A packet holding
|
|
158
|
+
// the wrong file is worse than no packet: the worker reads it, believes it, and
|
|
159
|
+
// writes a test about code that does not serve this entrypoint. On microblog,
|
|
160
|
+
// 2026-08-23, matching on the bare name alone sent both `ApiTokens` entrypoints
|
|
161
|
+
// to `app/models.py`, whose `get_token` belongs to `User`.
|
|
162
|
+
function buildLookup(projectRoot) {
|
|
163
|
+
const byAddress = new Map();
|
|
164
|
+
for (const surface of readSurfaces(projectRoot)) {
|
|
165
|
+
const record = asRecord(surface);
|
|
166
|
+
const file = normalisePath(asString(record?.source_file));
|
|
167
|
+
if (!file)
|
|
168
|
+
continue;
|
|
169
|
+
for (const key of [asString(record?.id), asString(record?.handler_label)]) {
|
|
170
|
+
if (key)
|
|
171
|
+
remember(byAddress, key, file);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const byMethod = new Map();
|
|
175
|
+
const byLabel = new Map();
|
|
176
|
+
for (const node of graphNodes(projectRoot)) {
|
|
177
|
+
const file = normalisePath(typeof node.source_file === 'string' ? node.source_file : undefined);
|
|
178
|
+
if (typeof node.label !== 'string' || !file)
|
|
179
|
+
continue;
|
|
180
|
+
remember(byMethod, methodNameOf(node.label), file);
|
|
181
|
+
remember(byLabel, node.label, file);
|
|
182
|
+
}
|
|
183
|
+
// The owner and the file agree if graphify put something of the owner's name
|
|
184
|
+
// in that file, or if the owner is the file — `ApiAuth` and `app/api/auth.py`
|
|
185
|
+
// are the same thing said twice, once in the map's words and once in the
|
|
186
|
+
// filesystem's. This confirms a file we already found; it never builds a path
|
|
187
|
+
// out of a name, which would be us maintaining somebody else's naming rule.
|
|
188
|
+
const agrees = (owner, file) => byLabel.get(owner)?.has(file) === true ||
|
|
189
|
+
byMethod.get(owner)?.has(file) === true ||
|
|
190
|
+
squash(file).includes(squash(owner));
|
|
191
|
+
return (entrypoint) => {
|
|
192
|
+
const declared = only(byAddress.get(entrypoint));
|
|
193
|
+
if (declared)
|
|
194
|
+
return declared;
|
|
195
|
+
// An address is the router's word, not a symbol, and must not fall through
|
|
196
|
+
// to symbol matching: `GET /users` would resolve by the name `users` to
|
|
197
|
+
// whatever single thing answers to it — a helper, a model, anything.
|
|
198
|
+
if (/\s/.test(entrypoint))
|
|
199
|
+
return undefined;
|
|
200
|
+
const named = byMethod.get(methodNameOf(entrypoint));
|
|
201
|
+
if (!named)
|
|
202
|
+
return undefined;
|
|
203
|
+
const owner = ownerNameOf(entrypoint);
|
|
204
|
+
if (!owner)
|
|
205
|
+
return only(named);
|
|
206
|
+
return only(new Set([...named].filter((file) => agrees(owner, file))));
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// The name the entrypoint hangs its method on: `User` in `User#get_token`,
|
|
210
|
+
// `Invoice` in `Billing::Invoice#total`. Split exactly as `methodNameOf` splits,
|
|
211
|
+
// so the two never disagree about where a name ends.
|
|
212
|
+
function ownerNameOf(entrypoint) {
|
|
213
|
+
const parts = entrypoint.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
214
|
+
return parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
215
|
+
}
|
|
216
|
+
function squash(value) {
|
|
217
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
218
|
+
}
|
|
219
|
+
// graphify writes a path the way its host does. `app\models.rb` and
|
|
220
|
+
// `./app/models.rb` are the same file as `app/models.rb`, and treating them as
|
|
221
|
+
// three keys makes one file look like three and one method look ambiguous.
|
|
222
|
+
function normalisePath(value) {
|
|
223
|
+
const normalised = value?.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
224
|
+
return normalised || undefined;
|
|
225
|
+
}
|
|
226
|
+
function remember(into, key, file) {
|
|
227
|
+
const seen = into.get(key) ?? new Set();
|
|
228
|
+
seen.add(file);
|
|
229
|
+
into.set(key, seen);
|
|
230
|
+
}
|
|
231
|
+
function count(notes, note) {
|
|
232
|
+
notes.set(note, (notes.get(note) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
function only(files) {
|
|
235
|
+
return files && files.size === 1 ? [...files][0] : undefined;
|
|
236
|
+
}
|
|
237
|
+
function readSurfaces(projectRoot) {
|
|
238
|
+
const path = surfacesPath(projectRoot);
|
|
239
|
+
if (!existsSync(path))
|
|
240
|
+
return [];
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
243
|
+
return asArray(parsed?.surfaces);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Copy one source file into the packets folder, keeping the path it has in the
|
|
250
|
+
// checkout so the worker recognises it. Returns the byte count, or why there is
|
|
251
|
+
// no copy to make. It never throws: one unreadable file out of a hundred is one
|
|
252
|
+
// worker searching, not a run without packets.
|
|
253
|
+
function copyPacket(projectRoot, sourceFile) {
|
|
254
|
+
const relative = `${PACKETS_DIR}/${sourceFile}`;
|
|
255
|
+
try {
|
|
256
|
+
assertUnitbobPath(relative, PACKETS_DIR);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return {
|
|
260
|
+
note: `graph.json names a path we will not write (${sourceFile}) — find the file yourself`,
|
|
261
|
+
kind: 'named a path we will not write',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
// The path came from graphify's output, not from us. A symlink or an
|
|
266
|
+
// absolute path there must not turn "copy a project file" into "copy
|
|
267
|
+
// anything on this machine", so the file is required to really live inside
|
|
268
|
+
// the checkout. `realpathSync` resolves every component, so a symlinked
|
|
269
|
+
// directory in the middle of the path is caught the same way.
|
|
270
|
+
const root = realpathSync(projectRoot);
|
|
271
|
+
let onDisk;
|
|
272
|
+
try {
|
|
273
|
+
onDisk = realpathSync(resolve(projectRoot, sourceFile));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return {
|
|
277
|
+
note: `${sourceFile} is named in graph.json but is not on disk — find the file yourself`,
|
|
278
|
+
kind: 'named a file that is not on disk',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (onDisk !== root && !onDisk.startsWith(root + sep)) {
|
|
282
|
+
return {
|
|
283
|
+
note: `${sourceFile} resolves outside the project — find the file yourself`,
|
|
284
|
+
kind: 'resolved outside the project',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const stat = statSync(onDisk);
|
|
288
|
+
// A directory passes the size check comfortably and then throws EISDIR on
|
|
289
|
+
// read. Said here rather than caught below, so the words name the cause.
|
|
290
|
+
if (!stat.isFile()) {
|
|
291
|
+
return {
|
|
292
|
+
note: `${sourceFile} is not a file — find the code yourself`,
|
|
293
|
+
kind: 'named something that is not a file',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (stat.size > MAX_PACKET_BYTES) {
|
|
297
|
+
return {
|
|
298
|
+
note: `${sourceFile} is ${stat.size.toLocaleString('en-US')} bytes, over the ${MAX_PACKET_BYTES.toLocaleString('en-US')}-byte packet fuse — open it at that path instead`,
|
|
299
|
+
kind: 'over the packet fuse — the path travels instead',
|
|
300
|
+
bytes: stat.size,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// The bytes we actually wrote, not the size we saw a moment ago.
|
|
304
|
+
const body = readFileSync(onDisk);
|
|
305
|
+
const destination = join(projectRoot, relative);
|
|
306
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
307
|
+
writeFileSync(destination, body);
|
|
308
|
+
return body.length;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
return {
|
|
312
|
+
note: `${sourceFile} could not be copied (${err.message}) — find the file yourself`,
|
|
313
|
+
kind: 'could not be copied',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function asArray(value) {
|
|
318
|
+
return Array.isArray(value) ? value : [];
|
|
319
|
+
}
|
|
320
|
+
function asRecord(value) {
|
|
321
|
+
return value && typeof value === 'object' ? value : undefined;
|
|
322
|
+
}
|
|
323
|
+
function asString(value) {
|
|
324
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
325
|
+
}
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
@@ -18,6 +18,38 @@ export function reviewRequestPath(projectRoot) {
|
|
|
18
18
|
export function candidateRunPath(projectRoot) {
|
|
19
19
|
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
20
20
|
}
|
|
21
|
+
// Spec 37-2, criterion 3. What a new build leaves behind, in the order a reader
|
|
22
|
+
// meets it: the plan, the checkpoints written against that plan, and the answer
|
|
23
|
+
// assembled from them. All three are bound to the `request.json` this run is
|
|
24
|
+
// about to overwrite, so from the next line on they are a previous run's papers
|
|
25
|
+
// wearing this run's filenames — which is exactly the confusion the coordinator
|
|
26
|
+
// used to spend a turn untangling before fan-out.
|
|
27
|
+
const PREVIOUS_RUN_ARTIFACTS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
|
|
28
|
+
const PREVIOUS_DIR = 'previous';
|
|
29
|
+
// Moved, never removed. A run costs hours and real money, and one interrupt plus
|
|
30
|
+
// one restart must not be able to spend that twice — `previous/` is one line
|
|
31
|
+
// more than `rmSync` and it is the line that makes a restart survivable.
|
|
32
|
+
//
|
|
33
|
+
// Replaced one artifact at a time rather than by clearing `previous/` first. The
|
|
34
|
+
// tidier version — wipe, then move whatever exists — loses a complete previous
|
|
35
|
+
// run to a partial one: a build interrupted between writing the plan and seeding
|
|
36
|
+
// its checkpoints displaces only `worker-plan.json`, and clearing would take the
|
|
37
|
+
// finished checkpoints and answer of the run before it with no way back. A
|
|
38
|
+
// `previous/` holding pieces of two runs is worth strictly more than an empty
|
|
39
|
+
// one, and every piece in it is named by the file it kept.
|
|
40
|
+
export function movePreviousRunAside(projectRoot) {
|
|
41
|
+
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
42
|
+
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
43
|
+
if (found.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const previous = join(buildDir, PREVIOUS_DIR);
|
|
46
|
+
mkdirSync(previous, { recursive: true });
|
|
47
|
+
for (const name of found) {
|
|
48
|
+
rmSync(join(previous, name), { recursive: true, force: true });
|
|
49
|
+
renameSync(join(buildDir, name), join(previous, name));
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
21
53
|
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
22
54
|
const metadata = output.test_metadata;
|
|
23
55
|
const candidateDigest = suiteCandidateDigest(output);
|
|
@@ -83,7 +115,7 @@ export function branchRunner(output) {
|
|
|
83
115
|
return runner;
|
|
84
116
|
}
|
|
85
117
|
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
-
// them. Nothing else (spec
|
|
118
|
+
// them. Nothing else (spec one-place-per-rule, §4).
|
|
87
119
|
//
|
|
88
120
|
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
121
|
// stripped the review's own keys back out to match — two lists that had to stay
|
|
@@ -2,7 +2,7 @@ import { readBehavioralReview } from "./suiteBuild.js";
|
|
|
2
2
|
// What travels to the server, and what "published" means when it answers. One
|
|
3
3
|
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
4
4
|
// batch, and `validate-build` sends the same batch as a dry run so the server's
|
|
5
|
-
// verdict is about the exact bytes the publish will carry (spec
|
|
5
|
+
// verdict is about the exact bytes the publish will carry (spec one-place-per-rule, §3).
|
|
6
6
|
//
|
|
7
7
|
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
8
|
// dry run would then be checking something the publish does not send — which is
|