unitbob 0.7.6 → 0.7.7
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/files/behavioral.js +44 -9
- package/dist/files/guardrails.js +21 -0
- package/dist/files/suiteBuild.js +106 -9
- package/dist/proc.js +37 -8
- package/dist/runner/bootcheck.js +22 -14
- package/dist/runner/pytest.js +2 -2
- package/dist/runner/rspec.js +2 -2
- package/dist/runner/vitest.js +2 -2
- package/dist/verbs/runLocal.js +7 -1
- package/dist/verbs/suitePrepare.js +45 -22
- package/dist/verbs/validateBuild.js +39 -9
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +10 -2
package/dist/files/behavioral.js
CHANGED
|
@@ -331,19 +331,33 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
|
331
331
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
332
332
|
if (!existsSync(behavioralRoot))
|
|
333
333
|
return [];
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
artifact.path,
|
|
337
|
-
...(artifact.support_files ?? []).map((file) => file.path),
|
|
338
|
-
...(connectorWorld ? [connectorWorld.path] : []),
|
|
339
|
-
]);
|
|
340
|
-
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
334
|
+
const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
|
|
335
|
+
const kept = behavioralKeptByConnector(runner);
|
|
341
336
|
return readdirSync(behavioralRoot)
|
|
342
|
-
.filter((entry) => !
|
|
337
|
+
.filter((entry) => !kept.has(entry))
|
|
343
338
|
.flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
|
|
344
|
-
.filter((path) => !listed.has(path))
|
|
339
|
+
.filter((path) => !listed.has(path) && !kept.has(path.slice(BEHAVIORAL_DIR.length + 1)) && !isRuntimeByProduct(path))
|
|
345
340
|
.sort();
|
|
346
341
|
}
|
|
342
|
+
// What under the behavioral root is the connector's or the runner's rather than
|
|
343
|
+
// the build's, as paths relative to that root: the installed environment, the
|
|
344
|
+
// connector-owned World, and the report files the run itself writes. One set,
|
|
345
|
+
// read by two callers that must agree — the review warning above, which stays
|
|
346
|
+
// quiet about these, and `movePreviousRunAside` (spec 49), which leaves them in
|
|
347
|
+
// place while everything else the last build wrote goes to `previous/`. A
|
|
348
|
+
// second hand-kept list would let the warning and the move disagree about
|
|
349
|
+
// whose file something is.
|
|
350
|
+
//
|
|
351
|
+
// A runner this connector has no table for keeps nothing but the run's own
|
|
352
|
+
// artifacts, which is the same answer `materializeBehavioral` gives it.
|
|
353
|
+
export function behavioralKeptByConnector(runner) {
|
|
354
|
+
const world = runner ? behavioralWorldFor(runner) : undefined;
|
|
355
|
+
return new Set([
|
|
356
|
+
...(runner ? RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES : EMPTY_ENTRIES),
|
|
357
|
+
...(world ? [world.path.slice(BEHAVIORAL_DIR.length + 1)] : []),
|
|
358
|
+
...CONNECTOR_RUN_ARTIFACTS,
|
|
359
|
+
]);
|
|
360
|
+
}
|
|
347
361
|
// The real files under `relative`. Symlinks are not followed: materialization
|
|
348
362
|
// removes the link, not what it points at, and naming the target here would read
|
|
349
363
|
// as a warning about a file that was never in danger.
|
|
@@ -355,6 +369,27 @@ function filesUnder(projectRoot, relative) {
|
|
|
355
369
|
return [];
|
|
356
370
|
return readdirSync(join(projectRoot, relative)).flatMap((entry) => filesUnder(projectRoot, `${relative}/${entry}`));
|
|
357
371
|
}
|
|
372
|
+
// What the branch's own run leaves behind while it runs: Python's byte-code
|
|
373
|
+
// cache beside every step file, the SQLite database the World opens at start
|
|
374
|
+
// and its journal. Nobody wrote them, the next run makes them again, and the
|
|
375
|
+
// answer could not list them if it tried. On the bench, 2026-09-11, the review
|
|
376
|
+
// warning named fourteen `.pyc` files on microblog and the World's own database
|
|
377
|
+
// on soul as things "running it will delete" — the same way the connector's
|
|
378
|
+
// report files used to be named, and for the same cost: a warning that fires on
|
|
379
|
+
// every review is a warning nobody reads when a step file really is forgotten.
|
|
380
|
+
//
|
|
381
|
+
// Matched anywhere in the path, not only at the top: `__pycache__` sits inside
|
|
382
|
+
// `step_definitions/`, where the top-level filter above never looks.
|
|
383
|
+
//
|
|
384
|
+
// Exported for `movePreviousRunAside` (spec 49), which deletes exactly these
|
|
385
|
+
// rather than carrying them to `previous/` — the same list, so what the warning
|
|
386
|
+
// does not name is what the move does not keep.
|
|
387
|
+
const RUNTIME_BY_PRODUCT_DIRS = new Set(['__pycache__', '.pytest_cache']);
|
|
388
|
+
const RUNTIME_BY_PRODUCT_FILE = /\.(pyc|sqlite|sqlite3|db|db-shm|db-wal|db-journal)$/;
|
|
389
|
+
export function isRuntimeByProduct(relativePath) {
|
|
390
|
+
const parts = relativePath.split('/');
|
|
391
|
+
return parts.some((part) => RUNTIME_BY_PRODUCT_DIRS.has(part)) || RUNTIME_BY_PRODUCT_FILE.test(parts[parts.length - 1]);
|
|
392
|
+
}
|
|
358
393
|
export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
|
|
359
394
|
const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
360
395
|
for (const entry of entries) {
|
package/dist/files/guardrails.js
CHANGED
|
@@ -13,6 +13,27 @@ export const HELPER_FILE = 'unitbob_helper.rb';
|
|
|
13
13
|
// the project's own .rspec (stray --require lines, extra stdout formatters)
|
|
14
14
|
// out of guardrail runs, whose JSON output must stay parseable.
|
|
15
15
|
export const OPTIONS_FILE = 'rspec.opts';
|
|
16
|
+
// The report each structural runner writes beside the suite. Named here rather
|
|
17
|
+
// than in `runner/{rspec,vitest,pytest}.ts`, which build their full paths from
|
|
18
|
+
// these: those modules import this one, and the names are needed below — the
|
|
19
|
+
// same reason `BDD_RUN_ARTIFACTS` is listed where it is written.
|
|
20
|
+
export const RSPEC_RESULT_NAME = 'rspec_result.json';
|
|
21
|
+
export const VITEST_RESULT_NAME = 'vitest_result.json';
|
|
22
|
+
export const PYTEST_RESULT_NAME = 'pytest_result.xml';
|
|
23
|
+
// What under the structural root is the connector's rather than the build's,
|
|
24
|
+
// as paths relative to that root (spec 49): the Ruby boot kit and the runners'
|
|
25
|
+
// reports. `movePreviousRunAside` leaves these in place and moves everything
|
|
26
|
+
// else — including `_setup.ts` and a `conftest.py`, which are the *last*
|
|
27
|
+
// build's preparation, written against its files. The same set whatever the
|
|
28
|
+
// runner: a Ruby helper in a Python project is litter `suite-prepare` no longer
|
|
29
|
+
// writes, and moving it to `previous/` would only move the litter.
|
|
30
|
+
export const STRUCTURAL_KEPT_BY_CONNECTOR = new Set([
|
|
31
|
+
HELPER_FILE,
|
|
32
|
+
OPTIONS_FILE,
|
|
33
|
+
RSPEC_RESULT_NAME,
|
|
34
|
+
VITEST_RESULT_NAME,
|
|
35
|
+
PYTEST_RESULT_NAME,
|
|
36
|
+
]);
|
|
16
37
|
// The one place that decides whether a host-provided suite path is safe to
|
|
17
38
|
// write: relative, anchored under .unitbob/guardrails/, no traversal. Anything
|
|
18
39
|
// else throws and nothing is written.
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, 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";
|
|
5
|
+
import { BEHAVIORAL_DIR, behavioralKeptByConnector, isRuntimeByProduct } from "./behavioral.js";
|
|
6
|
+
import { GUARDRAILS_DIR, STRUCTURAL_KEPT_BY_CONNECTOR } from "./guardrails.js";
|
|
5
7
|
import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
|
|
6
8
|
export function requestPath(projectRoot) {
|
|
7
9
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
@@ -18,14 +20,29 @@ export function reviewRequestPath(projectRoot) {
|
|
|
18
20
|
export function candidateRunPath(projectRoot) {
|
|
19
21
|
return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
|
|
20
22
|
}
|
|
21
|
-
// Spec 37-2, criterion 3. What a
|
|
23
|
+
// Spec 37-2, criterion 3. What a build leaves behind, in the order a reader
|
|
22
24
|
// meets it: the plan, the checkpoints written against that plan, and the answer
|
|
23
25
|
// assembled from them. All three are bound to the `request.json` this run is
|
|
24
26
|
// about to overwrite, so from the next line on they are a previous run's papers
|
|
25
27
|
// wearing this run's filenames — which is exactly the confusion the coordinator
|
|
26
28
|
// used to spend a turn untangling before fan-out.
|
|
27
|
-
const
|
|
29
|
+
const BUILD_PAPERS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
|
|
30
|
+
// Spec 49. And the three the review writes, bound to the candidate that build
|
|
31
|
+
// ran: left behind, a stale `behavioral_review.json` fails `validate-build` with
|
|
32
|
+
// a sentence about the reviewer.
|
|
33
|
+
const PREVIOUS_RUN_ARTIFACTS = [...BUILD_PAPERS, 'behavioral_review.json', 'candidate-run.json', 'review-request.json'];
|
|
28
34
|
const PREVIOUS_DIR = 'previous';
|
|
35
|
+
// Is the `suite-prepare` about to run the first step of a new build, or a
|
|
36
|
+
// repeat inside one? Spec 49, criterion 3. A new build is one with a plan, a
|
|
37
|
+
// checkpoint set or an answer on disk from the build before — or no
|
|
38
|
+
// `request.json` at all, which is a machine that has never built here (and may
|
|
39
|
+
// hold a suite that `check` materialized from the published one). A request
|
|
40
|
+
// with no plan yet is the coordinator's second question of the probe (spec 39):
|
|
41
|
+
// nothing moves then, so the `_setup.ts` written between the two runs survives.
|
|
42
|
+
export function isNewBuild(projectRoot) {
|
|
43
|
+
const buildDir = join(projectRoot, '.unitbob', 'suite-build');
|
|
44
|
+
return !existsSync(requestPath(projectRoot)) || BUILD_PAPERS.some((name) => existsSync(join(buildDir, name)));
|
|
45
|
+
}
|
|
29
46
|
// Moved, never removed. A run costs hours and real money, and one interrupt plus
|
|
30
47
|
// one restart must not be able to spend that twice — `previous/` is one line
|
|
31
48
|
// more than `rmSync` and it is the line that makes a restart survivable.
|
|
@@ -37,18 +54,98 @@ const PREVIOUS_DIR = 'previous';
|
|
|
37
54
|
// finished checkpoints and answer of the run before it with no way back. A
|
|
38
55
|
// `previous/` holding pieces of two runs is worth strictly more than an empty
|
|
39
56
|
// one, and every piece in it is named by the file it kept.
|
|
40
|
-
|
|
57
|
+
//
|
|
58
|
+
// Since spec 49 the same rule covers the branches' own directories. The
|
|
59
|
+
// behavioral runner loads its directory whole, so a `.feature` the last build
|
|
60
|
+
// wrote runs under this build's markers and a dead step file argues with a live
|
|
61
|
+
// one over the same step text; on the bench (2026-09-11) two workers reworded
|
|
62
|
+
// their steps to dodge files nobody had written this build. Every file the last
|
|
63
|
+
// build wrote under `.unitbob/structural/` and `.unitbob/behavioral/` goes to
|
|
64
|
+
// `previous/<branch>/` under the same relative path — `_setup.ts` and a
|
|
65
|
+
// `conftest.py` included, since they are the last build's preparation, written
|
|
66
|
+
// against its files. What stays is what the connector and the runner own
|
|
67
|
+
// (`STRUCTURAL_KEPT_BY_CONNECTOR`, `behavioralKeptByConnector`); what the run
|
|
68
|
+
// itself dropped — byte-code caches, the World's database — is deleted, because
|
|
69
|
+
// nobody wrote it and the next run makes it again.
|
|
70
|
+
//
|
|
71
|
+
// `behavioralRunner` is the runner that branch is under, so the move knows
|
|
72
|
+
// which installed environment and which World are that runner's; null when
|
|
73
|
+
// none is known, and then only what every runner shares is kept. The structural
|
|
74
|
+
// side needs no runner: its kept set is the same for all three.
|
|
75
|
+
export function movePreviousRunAside(projectRoot, behavioralRunner) {
|
|
41
76
|
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
77
|
const previous = join(buildDir, PREVIOUS_DIR);
|
|
46
|
-
|
|
78
|
+
const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
|
|
79
|
+
if (found.length > 0)
|
|
80
|
+
mkdirSync(previous, { recursive: true });
|
|
47
81
|
for (const name of found) {
|
|
48
82
|
rmSync(join(previous, name), { recursive: true, force: true });
|
|
49
83
|
renameSync(join(buildDir, name), join(previous, name));
|
|
50
84
|
}
|
|
51
|
-
return
|
|
85
|
+
return {
|
|
86
|
+
artifacts: found,
|
|
87
|
+
branches: {
|
|
88
|
+
structural: moveBranchAside(join(projectRoot, GUARDRAILS_DIR), join(previous, 'structural'), STRUCTURAL_KEPT_BY_CONNECTOR),
|
|
89
|
+
behavioral: moveBranchAside(join(projectRoot, BEHAVIORAL_DIR), join(previous, 'behavioral'), behavioralKeptByConnector(behavioralRunner)),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// One branch directory: the build's files to `target` under the same relative
|
|
94
|
+
// paths, `kept` left where it is, by-products deleted. Returns how many files
|
|
95
|
+
// moved.
|
|
96
|
+
//
|
|
97
|
+
// Collected first, moved second, so that a branch with nothing of the build's
|
|
98
|
+
// in it leaves the `previous/<branch>/` of the build before alone — the
|
|
99
|
+
// per-artifact rule above, applied to a directory. When there is something to
|
|
100
|
+
// move, the target is replaced whole: `previous/` holds one previous build, not
|
|
101
|
+
// an archive. The clearing happens before the first rename, so a rename that
|
|
102
|
+
// fails halfway (a full disk, a permission) leaves `previous/<branch>/` holding
|
|
103
|
+
// only what moved so far; the files that did not move are still where they
|
|
104
|
+
// were, and nothing is lost, which is the promise this function makes.
|
|
105
|
+
//
|
|
106
|
+
// The directories the files leave behind are removed too, so the branch root
|
|
107
|
+
// reads as what it is — empty of the last build — rather than as its skeleton.
|
|
108
|
+
// Deepest first, and only when empty: a directory holding a kept file (the
|
|
109
|
+
// World inside `step_definitions/`) keeps its file and stays.
|
|
110
|
+
function moveBranchAside(root, target, kept) {
|
|
111
|
+
if (!existsSync(root))
|
|
112
|
+
return 0;
|
|
113
|
+
const moving = [];
|
|
114
|
+
collectBuildFiles(root, '', kept, moving);
|
|
115
|
+
if (moving.length === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
rmSync(target, { recursive: true, force: true });
|
|
118
|
+
for (const relative of moving) {
|
|
119
|
+
const destination = join(target, relative);
|
|
120
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
121
|
+
renameSync(join(root, relative), destination);
|
|
122
|
+
}
|
|
123
|
+
const emptied = new Set(moving.map((relative) => dirname(relative)).filter((dir) => dir !== '.'));
|
|
124
|
+
for (const dir of [...emptied].sort((a, b) => b.length - a.length)) {
|
|
125
|
+
for (let ancestor = dir; ancestor !== '.'; ancestor = dirname(ancestor)) {
|
|
126
|
+
if (readdirSync(join(root, ancestor)).length > 0)
|
|
127
|
+
break;
|
|
128
|
+
rmSync(join(root, ancestor), { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return moving.length;
|
|
132
|
+
}
|
|
133
|
+
// A symlink is a file here: it moves as a link, and what it points at is never
|
|
134
|
+
// touched — the same reading `filesUnder` gives the review warning.
|
|
135
|
+
function collectBuildFiles(root, relative, kept, into) {
|
|
136
|
+
for (const entry of readdirSync(join(root, relative))) {
|
|
137
|
+
const path = relative ? `${relative}/${entry}` : entry;
|
|
138
|
+
if (kept.has(path))
|
|
139
|
+
continue;
|
|
140
|
+
if (isRuntimeByProduct(path)) {
|
|
141
|
+
rmSync(join(root, path), { recursive: true, force: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (lstatSync(join(root, path)).isDirectory())
|
|
145
|
+
collectBuildFiles(root, path, kept, into);
|
|
146
|
+
else
|
|
147
|
+
into.push(path);
|
|
148
|
+
}
|
|
52
149
|
}
|
|
53
150
|
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
54
151
|
const metadata = output.test_metadata;
|
package/dist/proc.js
CHANGED
|
@@ -66,18 +66,47 @@ export function runProcess(command, args = [], options = {}) {
|
|
|
66
66
|
child.on('close', (code) => finish({ stdout, stderr, code: timedOut ? null : code }));
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
// The oldest graphify the map may be built with. Below it, `detect.py` drops
|
|
70
|
+
// any file whose name ends in `token`, `secret`, `password` or `credential` as
|
|
71
|
+
// a probable secret store — by the name alone, before the file is parsed, and
|
|
72
|
+
// without a word on stdout: `graphify update` never prints the list it keeps.
|
|
73
|
+
// On the bench, 2026-09-11, that took `app/api/tokens.py` out of microblog and
|
|
74
|
+
// `src/controllers/auth/token.js` out of soul, so both maps were built without
|
|
75
|
+
// the sign-in code and nothing said so. `.graphifyignore` cannot re-include a
|
|
76
|
+
// file graphify has decided is sensitive, so the only cure is the release that
|
|
77
|
+
// exempts real source files (`.py`, `.js`, `.ts`, `.rb`) from that rule, and
|
|
78
|
+
// 0.9.18 is the first one that does — checked release by release.
|
|
79
|
+
export const GRAPHIFY_MIN_VERSION = '0.9.18';
|
|
80
|
+
const GRAPHIFY_INSTALL = '`pip install graphifyy && graphify install` (PyPI package "graphifyy", command "graphify", needs Python 3.10+)';
|
|
81
|
+
export async function requireGraphify(run = runProcess) {
|
|
82
|
+
let result;
|
|
70
83
|
try {
|
|
71
|
-
|
|
72
|
-
if (result.code === 0)
|
|
73
|
-
return;
|
|
74
|
-
throw new Error(result.stderr.trim() || result.stdout.trim() || `graphify --help exited ${result.code}`);
|
|
84
|
+
result = await run('graphify', ['--version']);
|
|
75
85
|
}
|
|
76
86
|
catch (err) {
|
|
77
|
-
throw new Error(`graphify is required but was not found or did not run. Install it with ` +
|
|
78
|
-
|
|
79
|
-
`"graphify", needs Python 3.10+), then retry (${err.message}).`);
|
|
87
|
+
throw new Error(`graphify is required but was not found or did not run. Install it with ${GRAPHIFY_INSTALL}, ` +
|
|
88
|
+
`then retry (${err.message}).`);
|
|
80
89
|
}
|
|
90
|
+
// `graphify 0.9.58` on stdout. A release too old to answer `--version` at all
|
|
91
|
+
// (0.7.x says "unknown command") is older than the floor by definition, so it
|
|
92
|
+
// is refused with the same sentence rather than a different one.
|
|
93
|
+
const version = /\bgraphify\s+(\d+\.\d+\.\d+)/.exec(result.stdout)?.[1];
|
|
94
|
+
if (version && !olderThan(version, GRAPHIFY_MIN_VERSION))
|
|
95
|
+
return;
|
|
96
|
+
const found = version ?? (result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`);
|
|
97
|
+
throw new Error(`graphify ${found} is installed, and Unitbob needs ${GRAPHIFY_MIN_VERSION} or newer. Older releases ` +
|
|
98
|
+
`silently leave out any source file whose name ends in "token", "secret" or "password" — ` +
|
|
99
|
+
`the sign-in code, typically — so the map would be built without it and nobody would be told. ` +
|
|
100
|
+
`Upgrade with \`pip install --upgrade graphifyy\`, then retry.`);
|
|
101
|
+
}
|
|
102
|
+
function olderThan(version, floor) {
|
|
103
|
+
const a = version.split('.').map(Number);
|
|
104
|
+
const b = floor.split('.').map(Number);
|
|
105
|
+
for (let i = 0; i < 3; i += 1) {
|
|
106
|
+
if (a[i] !== b[i])
|
|
107
|
+
return a[i] < b[i];
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
81
110
|
}
|
|
82
111
|
// Paths that hold no business logic in the stacks unitbob supports — Rails,
|
|
83
112
|
// JS/TS, Python — and that graphify does not already skip (it drops
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -286,16 +286,27 @@ ${imports}
|
|
|
286
286
|
test('the modules our guardrails import all load', () => {});
|
|
287
287
|
`;
|
|
288
288
|
}
|
|
289
|
-
// Python imports modules, not files, so the probe does the translation itself
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
289
|
+
// Python imports modules, not files, so the probe does the translation itself —
|
|
290
|
+
// and then imports by name, exactly as a guardrail's `from app.models import
|
|
291
|
+
// User` will (spec 50). Not `spec_from_file_location` + `exec_module`, which is
|
|
292
|
+
// what stood here until 2026-09-11: that executes the file whether or not it is
|
|
293
|
+
// already loaded. On microblog the first target, `app/__init__.py`, does `from
|
|
294
|
+
// app import models`, so by the time the loop reached `app/models.py` the module
|
|
295
|
+
// was in `sys.modules` and got run a second time — SQLAlchemy answered "Table
|
|
296
|
+
// 'followers' is already defined for this MetaData instance", and a healthy
|
|
297
|
+
// Flask application lost its structural branch. `import_module` reads
|
|
298
|
+
// `sys.modules` first, names `app/__init__.py` `app` rather than `app.__init__`,
|
|
299
|
+
// and goes through the finders, so the probe asks the run's question and no
|
|
300
|
+
// stricter one (`docs/adr/0001`). Where it still differs: `sys.path.insert(0,
|
|
301
|
+
// ROOT)` puts the project root first, which `-m pytest` run from that root does
|
|
302
|
+
// on its own — every interpreter `locateRunner` returns is invoked that way — so
|
|
303
|
+
// the line is the run's assumption made visible, not an addition to it. A
|
|
304
|
+
// src-layout project, whose guardrails import `pkg.mod` with `src/` on the
|
|
305
|
+
// path, is named `src.pkg.mod` here and would be refused; no such project has
|
|
306
|
+
// reached the bench, and the spec records it as work not done.
|
|
296
307
|
function pytestProbeSource(sourceFiles) {
|
|
297
308
|
return `# Written by the unitbob connector before the boot check — do not edit.
|
|
298
|
-
import importlib
|
|
309
|
+
import importlib
|
|
299
310
|
import pathlib
|
|
300
311
|
import sys
|
|
301
312
|
|
|
@@ -313,12 +324,9 @@ def test_the_modules_our_guardrails_import_all_load():
|
|
|
313
324
|
if not rel.endswith(".py"):
|
|
314
325
|
continue
|
|
315
326
|
name = rel[:-3].replace("/", ".")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
module = importlib.util.module_from_spec(spec)
|
|
320
|
-
sys.modules[name] = module
|
|
321
|
-
spec.loader.exec_module(module)
|
|
327
|
+
if name.endswith(".__init__"):
|
|
328
|
+
name = name[: -len(".__init__")]
|
|
329
|
+
importlib.import_module(name)
|
|
322
330
|
`;
|
|
323
331
|
}
|
|
324
332
|
// Runs one command, turning "this binary is not on the machine" into null (the
|
package/dist/runner/pytest.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
3
|
+
import { GUARDRAILS_DIR, PYTEST_RESULT_NAME } from "../files/guardrails.js";
|
|
4
4
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
6
|
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
-
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
8
|
+
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, PYTEST_RESULT_NAME);
|
|
9
9
|
// A minimal runtime config, created or overwritten before each run and passed
|
|
10
10
|
// via `-c` so the project's own addopts (e.g. --cov, -n auto) cannot break the
|
|
11
11
|
// guardrail run or its JUnit output. Connector-owned: never stored in Rails,
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
2
|
+
import { GUARDRAILS_DIR, OPTIONS_FILE, RSPEC_RESULT_NAME } from "../files/guardrails.js";
|
|
3
3
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
4
4
|
import { locateRunner } from "./toolchain.js";
|
|
5
5
|
import { clearReport, readFreshReport } from "./types.js";
|
|
@@ -8,7 +8,7 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
8
8
|
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
9
9
|
// the project's random ordering.
|
|
10
10
|
export const RSPEC_SEED = '1';
|
|
11
|
-
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
11
|
+
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, RSPEC_RESULT_NAME);
|
|
12
12
|
// Run the materialised Unitbob guardrail suite (spec 26). Only these files run —
|
|
13
13
|
// never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
|
|
14
14
|
// --options points at the materialized empty file so the project's own .rspec
|
package/dist/runner/vitest.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
3
|
+
import { GUARDRAILS_DIR, VITEST_RESULT_NAME } from "../files/guardrails.js";
|
|
4
4
|
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
6
|
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
-
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR,
|
|
8
|
+
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, VITEST_RESULT_NAME);
|
|
9
9
|
// A connector-owned Vitest config, written next to .unitbob/ before a run when
|
|
10
10
|
// the project has its own config. Connector-owned: never stored in Rails, never
|
|
11
11
|
// part of the suite digest.
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -76,7 +76,13 @@ function compareFailures(config, d, suiteKind, ran, before) {
|
|
|
76
76
|
rememberFailures(config.projectRoot, suiteKind, digest);
|
|
77
77
|
if (digest !== before)
|
|
78
78
|
return false;
|
|
79
|
-
|
|
79
|
+
// The number is the one the reader has just counted in the list above — every
|
|
80
|
+
// failed case — not the size of the compared set. That set is keyed on marker,
|
|
81
|
+
// file and first line, so seven Scenarios under one marker failing the same
|
|
82
|
+
// way are one entry in it; on soul, 2026-09-11, this line said "the same
|
|
83
|
+
// 1 case(s)" under a list of seven, and read as a counting error.
|
|
84
|
+
const reported = reportedFailures(ran.runner, ran.result.report)?.length ?? failures.length;
|
|
85
|
+
d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${reported} case(s) as the previous run, ` +
|
|
80
86
|
'down to the first line of every message. The edits since then changed nothing this run can see.\n' +
|
|
81
87
|
'Look at the failures yourself, replan the slice, or record the branch as a build_error. ' +
|
|
82
88
|
'Running it again unchanged prints this same line.\n');
|
|
@@ -2,7 +2,7 @@ import { clearRunState } from "../runner/failureDigest.js";
|
|
|
2
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
3
3
|
import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
4
4
|
import { PACKETS_DIR, structuralSourceFiles, writeSuitePackets, } from "../files/packets.js";
|
|
5
|
-
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
|
+
import { isNewBuild, movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
6
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
7
7
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
8
8
|
import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
|
|
@@ -100,6 +100,35 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
100
100
|
const replaced = alignRunnerEnvironmentWithPlace(config.projectRoot);
|
|
101
101
|
if (replaced)
|
|
102
102
|
actual.stdout.write(`${replaced}\n`);
|
|
103
|
+
// Spec 37-2, criterion 3, widened by spec 49. The papers a build leaves
|
|
104
|
+
// behind — plan, checkpoints, answer, review — outlive the `request.json`
|
|
105
|
+
// they were digested against, and every one of them is refused by its own
|
|
106
|
+
// gate from here on. The suite files of both branches outlive it too, and
|
|
107
|
+
// nothing refuses those: the behavioral runner loads its directory whole, so
|
|
108
|
+
// a dead `.feature` runs and a dead step file argues with a live one. Moved
|
|
109
|
+
// rather than removed — see `movePreviousRunAside`.
|
|
110
|
+
//
|
|
111
|
+
// Here, before the helper and the World are written and before the probe asks
|
|
112
|
+
// its question (criterion 4): a `conftest.py` from the last build would be
|
|
113
|
+
// loaded by pytest beside our probe, and a leftover `_setup.ts` would turn the
|
|
114
|
+
// probe's scouting into a sentence. Only on a new build (criterion 3): a
|
|
115
|
+
// repeat inside one is the coordinator's second question of the probe (spec
|
|
116
|
+
// 39), and the setup file written between the two runs has to survive it.
|
|
117
|
+
//
|
|
118
|
+
// Wrapped, because a read-only checkout or a permission the move does not
|
|
119
|
+
// have is a note, not a build that dies before it has asked the server
|
|
120
|
+
// anything. And nothing is lost if a later step refuses: `request.json` of
|
|
121
|
+
// the last build is still there, so the next run does not move again.
|
|
122
|
+
let displaced = { artifacts: [], branches: { structural: 0, behavioral: 0 } };
|
|
123
|
+
let displaceProblem = '';
|
|
124
|
+
if (isNewBuild(config.projectRoot)) {
|
|
125
|
+
try {
|
|
126
|
+
displaced = movePreviousRunAside(config.projectRoot, detectBddRunner(config.projectRoot));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
displaceProblem = err.message;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
103
132
|
// Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
|
|
104
133
|
// project it touched, so a Flask app and a NestJS app each came away with a
|
|
105
134
|
// Ruby file they never asked for and cannot run — the product leaving another
|
|
@@ -300,38 +329,22 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
300
329
|
// step of the loop, so a failure set remembered from the build before it would
|
|
301
330
|
// stop a branch that has not run once yet.
|
|
302
331
|
clearRunState(config.projectRoot);
|
|
303
|
-
// Spec 37-2, criterion 3. Same reasoning, applied to the papers that were left
|
|
304
|
-
// behind rather than cleared: a plan and its checkpoints outlive the
|
|
305
|
-
// `request.json` they were digested against, and every one of them is refused
|
|
306
|
-
// by its own gate from here on. Moved rather than removed — see
|
|
307
|
-
// `movePreviousRunAside`.
|
|
308
|
-
//
|
|
309
|
-
// Wrapped for the same reason `buildPackets` is: by this line the request is
|
|
310
|
-
// written and the build is real. A read-only checkout or a permission the move
|
|
311
|
-
// does not have is a note, not a build that dies after its own work landed
|
|
312
|
-
// and before it could say so.
|
|
313
|
-
let displaced = [];
|
|
314
|
-
let displaceProblem = '';
|
|
315
|
-
try {
|
|
316
|
-
displaced = movePreviousRunAside(config.projectRoot);
|
|
317
|
-
}
|
|
318
|
-
catch (err) {
|
|
319
|
-
displaceProblem = err.message;
|
|
320
|
-
}
|
|
321
332
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
322
333
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
323
334
|
? '`unitbob suite-review-prepare` before upload'
|
|
324
335
|
: '`unitbob put-suite-build`';
|
|
325
336
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
326
|
-
|
|
327
|
-
|
|
337
|
+
const moved = displacedList(displaced);
|
|
338
|
+
if (moved.length > 0) {
|
|
339
|
+
actual.stdout.write(`The previous run's ${moved.join(', ')} moved to ` +
|
|
328
340
|
`${request.project_root}/.unitbob/suite-build/previous/ — none of it is left where this build will ` +
|
|
329
341
|
'look, and none of it was deleted.\n');
|
|
330
342
|
}
|
|
331
343
|
if (displaceProblem) {
|
|
332
344
|
actual.stdout.write(`\nThe previous run's files could not be moved out of the way (${displaceProblem}). This build is fine, ` +
|
|
333
345
|
'but a plan or checkpoint left over from it will be refused by its own gate — the digests belong to ' +
|
|
334
|
-
'the request that was just replaced
|
|
346
|
+
'the request that was just replaced — and running a branch will include files from the previous build ' +
|
|
347
|
+
'still under .unitbob/structural/ and .unitbob/behavioral/.\n');
|
|
335
348
|
}
|
|
336
349
|
actual.stdout.write(packetNotice(request.project_root, sourcePackets));
|
|
337
350
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
@@ -403,6 +416,16 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
403
416
|
'\n');
|
|
404
417
|
}
|
|
405
418
|
}
|
|
419
|
+
// Everything that moved, in the words the line has always used: the artifacts
|
|
420
|
+
// by name, then each branch as a count (spec 49, criterion 5). By-products that
|
|
421
|
+
// were deleted are not listed — nobody lost them.
|
|
422
|
+
function displacedList(moved) {
|
|
423
|
+
const branchFiles = (branch) => {
|
|
424
|
+
const count = moved.branches[branch];
|
|
425
|
+
return count > 0 ? [`${count} ${branch} ${count === 1 ? 'file' : 'files'}`] : [];
|
|
426
|
+
};
|
|
427
|
+
return [...moved.artifacts, ...branchFiles('structural'), ...branchFiles('behavioral')];
|
|
428
|
+
}
|
|
406
429
|
// A checkout we cannot write packets into is a run without packets, not a
|
|
407
430
|
// failed build: the workers search the source themselves, exactly as they did
|
|
408
431
|
// before this spec. The same rule the route inventory follows for the same
|
|
@@ -93,6 +93,7 @@ function dryRunBatch(config, request, outputs) {
|
|
|
93
93
|
const items = [];
|
|
94
94
|
const unchecked = [];
|
|
95
95
|
const problems = [];
|
|
96
|
+
const withoutReview = new Set();
|
|
96
97
|
for (const output of outputs) {
|
|
97
98
|
if (output.build_error) {
|
|
98
99
|
items.push(uploadItem(request, output, undefined));
|
|
@@ -108,15 +109,33 @@ function dryRunBatch(config, request, outputs) {
|
|
|
108
109
|
problems.push({ branch: output.suite_kind, message: error.message });
|
|
109
110
|
continue;
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'
|
|
114
|
-
'
|
|
112
|
+
withoutReview.add(output.suite_kind);
|
|
113
|
+
unchecked.push(`${output.suite_kind}: the independent review has not been written yet, so the server judges ` +
|
|
114
|
+
'this branch up to the point where it would read the review. Everything before that — the ' +
|
|
115
|
+
'manifest, the markers, the addresses — is checked now; the review itself is answered later, ' +
|
|
116
|
+
'by `suite-review-prepare` and the reviewer. Run this command again afterwards for a verdict ' +
|
|
117
|
+
'on the whole branch.');
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
items.push(uploadItem(request, output, testMetadata));
|
|
118
121
|
}
|
|
119
|
-
return { items, unchecked, problems };
|
|
122
|
+
return { items, unchecked, problems, withoutReview };
|
|
123
|
+
}
|
|
124
|
+
// The one refusal a branch sent without its review is expected to get. The
|
|
125
|
+
// server checks the manifest, the markers and the surface arithmetic first and
|
|
126
|
+
// stops at the first rule broken, so reaching *this* rule means everything
|
|
127
|
+
// before it passed — which is the whole answer the pre-review run was after.
|
|
128
|
+
//
|
|
129
|
+
// Printed as a refusal, it was read as one: "must include a bdd_quality_review
|
|
130
|
+
// artifact", right under "Fix them, then run validate-build again", sent a
|
|
131
|
+
// reader looking for a defect in an answer that step 9 forbids from carrying
|
|
132
|
+
// that key in the first place. The text is matched, not reworded — the words on
|
|
133
|
+
// the line below are the server's, and if the server ever says it differently
|
|
134
|
+
// the line stops matching and the refusal is shown as before, which is the safe
|
|
135
|
+
// way for this to go stale.
|
|
136
|
+
const REVIEW_MISSING = /must include a bdd_quality_review artifact/;
|
|
137
|
+
function reachedTheReview(result, withoutReview) {
|
|
138
|
+
return withoutReview.has(result.suite_kind) && REVIEW_MISSING.test(result.error ?? '');
|
|
120
139
|
}
|
|
121
140
|
function describe(result) {
|
|
122
141
|
const tallies = result.counts
|
|
@@ -140,7 +159,7 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
140
159
|
};
|
|
141
160
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
142
161
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
143
|
-
const { items, unchecked, problems } = dryRunBatch(config, request, outputs);
|
|
162
|
+
const { items, unchecked, problems, withoutReview } = dryRunBatch(config, request, outputs);
|
|
144
163
|
const local = [
|
|
145
164
|
...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
|
|
146
165
|
...collectBuildProblems(request, outputs, unreadable),
|
|
@@ -178,7 +197,7 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
178
197
|
'instead of checking. Upgrade the server before running validate-build again — and note that this ' +
|
|
179
198
|
'branch is now live.\n');
|
|
180
199
|
}
|
|
181
|
-
const refused = results.filter((result) => result.status !== WOULD_PUBLISH && result.status !== 'build_error');
|
|
200
|
+
const refused = results.filter((result) => result.status !== WOULD_PUBLISH && result.status !== 'build_error' && !reachedTheReview(result, withoutReview));
|
|
182
201
|
if (refused.length > 0) {
|
|
183
202
|
throw new Error(`The Unitbob server would refuse this answer:\n${refused.map(rejection).join('\n')}\n` +
|
|
184
203
|
'Those are the server\'s own words. Fix them, then run `unitbob validate-build` again — this round ' +
|
|
@@ -188,9 +207,20 @@ export async function validateBuild(config, _args = [], deps) {
|
|
|
188
207
|
// answer whose every branch is a declared `build_error` is accepted and stores
|
|
189
208
|
// nothing, and reporting that as a suite about to go up would be the one
|
|
190
209
|
// sentence in this output that is not true of what happened.
|
|
210
|
+
//
|
|
211
|
+
// A branch that reached the review rule without a review is neither: the
|
|
212
|
+
// server checked everything before it and stopped where the review would be.
|
|
213
|
+
// It gets its own line, and the headline does not promise a publish for it.
|
|
191
214
|
const accepted = results.filter((result) => result.status === WOULD_PUBLISH);
|
|
215
|
+
const pending = results.filter((result) => reachedTheReview(result, withoutReview));
|
|
192
216
|
const headline = accepted.length === 0
|
|
193
|
-
?
|
|
217
|
+
? pending.length === 0
|
|
218
|
+
? 'The Unitbob server accepted this answer, and it publishes no suite:'
|
|
219
|
+
: 'The Unitbob server checked this answer as far as it can before the review:'
|
|
194
220
|
: 'The Unitbob server checked this answer and would publish it:';
|
|
195
|
-
|
|
221
|
+
const lines = results.map((result) => reachedTheReview(result, withoutReview)
|
|
222
|
+
? ` ${result.suite_kind}: checked up to the review — the manifest, the markers and the addresses passed; ` +
|
|
223
|
+
'the review is what is left'
|
|
224
|
+
: describe(result));
|
|
225
|
+
d.stdout.write(`${headline}\n${lines.join('\n')}\n${DRY_RUN_DOES_NOT}\n`);
|
|
196
226
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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.7 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
|
|
@@ -105,7 +105,15 @@ actually drives, and must equal that Scenario's `surface_coverage` in the
|
|
|
105
105
|
candidate's metadata. If the two disagree, that is a finding — say it in a
|
|
106
106
|
reservation or an objection rather than adjusting your list to match.
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
The `When`, and only the `When` — the same rule the worker wrote its list by.
|
|
109
|
+
What a `Given` does to arrive (sign in, create the table the Scenario needs) and
|
|
110
|
+
what an `After` does to leave are not the behaviour under test, so an address
|
|
111
|
+
they touch is not missing from `surface_coverage` and not a finding. On soul,
|
|
112
|
+
2026-09-11, every one of seven Scenarios got a reservation for its setup hitting
|
|
113
|
+
`POST /api/tables`, and seven reservations for one worker following its
|
|
114
|
+
instruction to the letter looked like a broken suite.
|
|
115
|
+
|
|
116
|
+
A Scenario whose `When` also drives an address belonging to another capability
|
|
109
117
|
goes in `reservation`, naming the address. There is no separate field for it and
|
|
110
118
|
none is coming; the text is free-form. One run had that observation, was right
|
|
111
119
|
about it, and withdrew it believing the format had nowhere to put it.
|
|
@@ -117,7 +125,7 @@ repair round opens — by the time you are reading, the repair rotation and the
|
|
|
117
125
|
final run are spent.
|
|
118
126
|
|
|
119
127
|
What it does do: a capability whose Scenarios were **all** objected to is stored
|
|
120
|
-
`unguarded` at publish — the amber "not yet
|
|
128
|
+
`unguarded` at publish — the amber "not guarded yet" lamp, never green — and
|
|
121
129
|
your objection becomes the sentence its owner reads in place of the headline.
|
|
122
130
|
Write it so it reads well there. One objection among sound siblings changes
|
|
123
131
|
nothing: the siblings guard the capability and its green lamp is earned. A
|