unitbob 0.3.4 → 0.3.5
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.
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export const RUN_BUDGET = { workers: 4, review_rounds: 2, repair_rounds: 8 };
|
|
4
|
+
// Only two of the three have a counter behind them. The connector cannot see
|
|
5
|
+
// the host launch a subagent, so `workers` is a number it states and cannot
|
|
6
|
+
// check. It travels in the same block anyway, and the recipe is told not to sort
|
|
7
|
+
// the fields into checked and unchecked: an agent that knows which half is
|
|
8
|
+
// watched has been handed a reason to treat the other half as advice.
|
|
9
|
+
const SPENT_FILE = 'budget-spent.json';
|
|
10
|
+
function spentPath(projectRoot) {
|
|
11
|
+
return join(projectRoot, '.unitbob', 'suite-build', SPENT_FILE);
|
|
12
|
+
}
|
|
13
|
+
// On disk, not in memory, because the loop these bound spans separate processes:
|
|
14
|
+
// every `run-local` and every `suite-review-prepare` is a fresh `npx`. A count
|
|
15
|
+
// held in a running command would reset on each one and bound nothing.
|
|
16
|
+
export function spend(projectRoot, key) {
|
|
17
|
+
const path = spentPath(projectRoot);
|
|
18
|
+
const spent = readSpent(path);
|
|
19
|
+
const count = (spent[key] ?? 0) + 1;
|
|
20
|
+
spent[key] = count;
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
// Written whole and moved into place, never written in place. A plain write
|
|
23
|
+
// that is interrupted leaves truncated JSON, which `readSpent` then reads as
|
|
24
|
+
// nothing spent — so the count would reset exactly when a run is being killed
|
|
25
|
+
// and restarted, which is the loop this exists to bound.
|
|
26
|
+
const staging = `${path}.tmp`;
|
|
27
|
+
writeFileSync(staging, `${JSON.stringify(spent, null, 2)}\n`);
|
|
28
|
+
renameSync(staging, path);
|
|
29
|
+
return count;
|
|
30
|
+
}
|
|
31
|
+
// A new build request starts on a fresh budget. Without this a project Unitbob
|
|
32
|
+
// ran a month ago opens today already over its ceiling, and every command
|
|
33
|
+
// announces the last round — noise, and advice that is wrong besides.
|
|
34
|
+
//
|
|
35
|
+
// True when there was something to clear, so the caller can say so out loud.
|
|
36
|
+
// Re-running `suite-prepare` is a documented step of the loop, and it resets
|
|
37
|
+
// both counters; a reset nobody is told about is a ceiling that quietly is not
|
|
38
|
+
// one.
|
|
39
|
+
export function clearSpending(projectRoot) {
|
|
40
|
+
const path = spentPath(projectRoot);
|
|
41
|
+
const had = existsSync(path);
|
|
42
|
+
rmSync(path, { force: true });
|
|
43
|
+
return had;
|
|
44
|
+
}
|
|
45
|
+
// A damaged or hand-edited file counts as nothing spent. The alternative is
|
|
46
|
+
// refusing to run over a bookkeeping file, which would make a counter that
|
|
47
|
+
// deliberately never blocks into the one thing that does.
|
|
48
|
+
function readSpent(path) {
|
|
49
|
+
if (!existsSync(path))
|
|
50
|
+
return {};
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
54
|
+
return {};
|
|
55
|
+
return Object.fromEntries(Object.entries(parsed)
|
|
56
|
+
.filter(([, value]) => typeof value === 'number' && Number.isFinite(value)));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A request written by an older connector carries no budget, and that is not an
|
|
63
|
+
// error: there is no ceiling to enforce, so every counter goes quiet and the run
|
|
64
|
+
// works as it did before (spec 34-2, edge cases).
|
|
65
|
+
export function readBudget(value) {
|
|
66
|
+
if (!value || typeof value !== 'object')
|
|
67
|
+
return undefined;
|
|
68
|
+
const block = value;
|
|
69
|
+
const fields = ['workers', 'review_rounds', 'repair_rounds']
|
|
70
|
+
.map((field) => [field, block[field]]);
|
|
71
|
+
if (fields.some(([, number]) => typeof number !== 'number' || !Number.isFinite(number)))
|
|
72
|
+
return undefined;
|
|
73
|
+
return Object.fromEntries(fields);
|
|
74
|
+
}
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
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 { readBudget, RUN_BUDGET } from "./budget.js";
|
|
5
6
|
export function requestPath(projectRoot) {
|
|
6
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
7
8
|
}
|
|
@@ -172,6 +173,7 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
|
|
|
172
173
|
output_path: outputPath(projectRoot),
|
|
173
174
|
branches,
|
|
174
175
|
known_defect_context: knownDefectContext,
|
|
176
|
+
budget: RUN_BUDGET,
|
|
175
177
|
};
|
|
176
178
|
const path = requestPath(projectRoot);
|
|
177
179
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -193,6 +195,7 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
193
195
|
return {
|
|
194
196
|
...request,
|
|
195
197
|
known_defect_context: readKnownDefectContext(request.known_defect_context, path),
|
|
198
|
+
budget: readBudget(request.budget),
|
|
196
199
|
};
|
|
197
200
|
}
|
|
198
201
|
function readKnownDefectContext(value, path) {
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
+
import { spend } from "../files/budget.js";
|
|
2
3
|
import { validateStack } from "../runner/precheck.js";
|
|
3
4
|
import { runBddSuite } from "../runner/bdd.js";
|
|
4
5
|
import { runStructuralByRunner } from "./run.js";
|
|
@@ -25,9 +26,31 @@ export async function runLocal(config, args = [], deps) {
|
|
|
25
26
|
d.stdout.write(`Cannot run this branch — its entry in your answer could not be read: ${broken.message}\n`);
|
|
26
27
|
continue;
|
|
27
28
|
}
|
|
28
|
-
await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
|
|
29
|
+
const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
|
|
30
|
+
// Only a run that happened spends a repair round. A branch with no entry
|
|
31
|
+
// written yet, or one the stack cannot execute, produced nothing to repair
|
|
32
|
+
// against — charging it would exhaust the budget on rounds that never
|
|
33
|
+
// examined the suite.
|
|
34
|
+
if (!ran)
|
|
35
|
+
continue;
|
|
36
|
+
const spent = spend(config.projectRoot, `run-local:${suiteKind}`);
|
|
37
|
+
if (request.budget && spent > request.budget.repair_rounds) {
|
|
38
|
+
d.stdout.write(polishedEnoughNotice(suiteKind, spent, request.budget.repair_rounds));
|
|
39
|
+
}
|
|
29
40
|
}
|
|
30
41
|
}
|
|
42
|
+
// Not a refusal, and deliberately not about the budget either. After eight
|
|
43
|
+
// rounds of repair the interesting fact is not that a number ran out — it is
|
|
44
|
+
// what the remaining reds most likely are. Both real logs show 3-5 runs of a
|
|
45
|
+
// branch as ordinary work, so a branch on its ninth has already been repaired
|
|
46
|
+
// past the point where the harness is the usual explanation.
|
|
47
|
+
function polishedEnoughNotice(suiteKind, spent, allowed) {
|
|
48
|
+
return (`\nThat was run ${spent} of the ${suiteKind} branch; this build budgeted ${allowed}. ` +
|
|
49
|
+
'Reds that survive this many rounds of repair are far more likely to be defects of your product ' +
|
|
50
|
+
'than of the harness around it.\n' +
|
|
51
|
+
'Publish the suite as it stands rather than keep polishing. A first suite that comes out red is ' +
|
|
52
|
+
'a finding, not a failure — finding those reds is what it was written to do.\n');
|
|
53
|
+
}
|
|
31
54
|
// Which branches to run. No argument runs every branch the request asked for —
|
|
32
55
|
// the same "one suite, one run" shape both recipes insist on, so the default
|
|
33
56
|
// never teaches the habit the recipes forbid. A named branch is for the repair
|
|
@@ -43,17 +66,19 @@ function selectBranches(request, args) {
|
|
|
43
66
|
}
|
|
44
67
|
return named;
|
|
45
68
|
}
|
|
69
|
+
// True when the runner actually executed the branch — which is what a repair
|
|
70
|
+
// round is, and the only thing the caller charges the budget for.
|
|
46
71
|
async function runOneBranch(config, d, suiteKind, output) {
|
|
47
72
|
// Nothing written for this branch yet. That is the ordinary state halfway
|
|
48
73
|
// through a build, not an error — say what is missing and move to the peer.
|
|
49
74
|
if (!output) {
|
|
50
75
|
d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
|
|
51
76
|
`${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
|
|
52
|
-
return;
|
|
77
|
+
return false;
|
|
53
78
|
}
|
|
54
79
|
if (output.build_error) {
|
|
55
80
|
d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
|
|
56
|
-
return;
|
|
81
|
+
return false;
|
|
57
82
|
}
|
|
58
83
|
let runner;
|
|
59
84
|
let suitePath;
|
|
@@ -63,12 +88,12 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
63
88
|
}
|
|
64
89
|
catch (err) {
|
|
65
90
|
d.stdout.write(`Cannot run this branch: ${err.message}\n`);
|
|
66
|
-
return;
|
|
91
|
+
return false;
|
|
67
92
|
}
|
|
68
93
|
const check = d.validateStack(config.projectRoot, runner);
|
|
69
94
|
if (!check.ok) {
|
|
70
95
|
d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
|
|
71
|
-
return;
|
|
96
|
+
return false;
|
|
72
97
|
}
|
|
73
98
|
let result;
|
|
74
99
|
try {
|
|
@@ -79,9 +104,10 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
79
104
|
}
|
|
80
105
|
catch (err) {
|
|
81
106
|
d.stdout.write(`The runner could not start: ${err.message}\n`);
|
|
82
|
-
return;
|
|
107
|
+
return false;
|
|
83
108
|
}
|
|
84
109
|
d.stdout.write(report(result));
|
|
110
|
+
return true;
|
|
85
111
|
}
|
|
86
112
|
// The command first, and always — including on a green run. It is the answer to
|
|
87
113
|
// "how do I run just this one file again", which is the question the whole
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearSpending } from "../files/budget.js";
|
|
1
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
2
3
|
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
3
4
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
@@ -141,6 +142,13 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
141
142
|
throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
|
|
142
143
|
}
|
|
143
144
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
145
|
+
// A new request is a new build, and a new build starts on the whole budget
|
|
146
|
+
// (spec 34-2). Said out loud, because re-running this verb is a documented
|
|
147
|
+
// step of the loop — the fixable-runner path below ends by asking for it — so
|
|
148
|
+
// a reset that happened silently would be a ceiling that quietly is not one.
|
|
149
|
+
if (clearSpending(config.projectRoot)) {
|
|
150
|
+
actual.stdout.write('Starting a fresh build: the review and run counts from the previous one are cleared.\n');
|
|
151
|
+
}
|
|
144
152
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
145
153
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
146
154
|
? '`unitbob suite-review-prepare` before upload'
|
|
@@ -6,6 +6,7 @@ import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBeh
|
|
|
6
6
|
import { runBddSuite } from "../runner/bdd.js";
|
|
7
7
|
import { boundReport } from "../runner/boundReport.js";
|
|
8
8
|
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
|
+
import { spend } from "../files/budget.js";
|
|
9
10
|
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
10
11
|
const actual = {
|
|
11
12
|
runCandidate: runCandidate,
|
|
@@ -37,6 +38,27 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
|
37
38
|
const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
|
|
38
39
|
actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
|
|
39
40
|
actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
|
|
41
|
+
// Counted after the request is written, never before it. Refusing the round
|
|
42
|
+
// would rebuild the autobrella deadlock on a different number: the branch
|
|
43
|
+
// could not publish, so the work would be lost again for the sake of the
|
|
44
|
+
// ceiling meant to protect it. What the ceiling buys is a sentence.
|
|
45
|
+
const round = spend(config.projectRoot, 'review_rounds');
|
|
46
|
+
const budget = buildRequest.budget;
|
|
47
|
+
if (budget && round > budget.review_rounds) {
|
|
48
|
+
actual.stdout.write(lastRoundNotice(round, budget.review_rounds));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Says what to do, not that something is forbidden. This is only sayable because
|
|
52
|
+
// spec 34-2 already made an imperfect suite publishable: before it, "publish what
|
|
53
|
+
// you have" was not available to the reviewer at all, and the only way to record
|
|
54
|
+
// a bad Scenario was to take the branch down.
|
|
55
|
+
function lastRoundNotice(round, allowed) {
|
|
56
|
+
return (`\nThis is review round ${round}; the budget for this build was ${allowed}. Treat it as the last round ` +
|
|
57
|
+
'and publish what you have.\n' +
|
|
58
|
+
'A Scenario the reviewer still objects to does not hold the branch back: it is recorded with ' +
|
|
59
|
+
'`verdict: "does_not_pass"` and a `reviewer_objection_text` saying what is wrong, the branch publishes, ' +
|
|
60
|
+
'and the objection is read afterwards by the operator. Another round of rewriting buys less than ' +
|
|
61
|
+
'publishing the objection does.\n');
|
|
40
62
|
}
|
|
41
63
|
async function runCandidate(projectRoot, output, revision) {
|
|
42
64
|
if (revision)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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": {
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
|
|
17
17
|
"prepublishOnly": "npm run build",
|
|
18
18
|
"test": "node --test test/*.test.ts",
|
|
19
|
-
"check:release": "node scripts/check-release.mjs"
|
|
20
|
-
"hooks:install": "git config core.hooksPath hooks"
|
|
19
|
+
"check:release": "node scripts/check-release.mjs"
|
|
21
20
|
},
|
|
22
21
|
"publishConfig": {
|
|
23
22
|
"access": "public"
|