unitbob 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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) {
@@ -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)
@@ -199,16 +199,25 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
199
199
  // here in the server's own shape; refusing it locally would refuse an answer
200
200
  // the server takes, which is the one direction of drift that costs a branch.
201
201
  const declaredUnreachable = collectUnreachable(row, id, reached, add);
202
- const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface));
202
+ const deferred = collectDeferred(row, id, reached, declaredUnreachable, add);
203
+ // Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
204
+ // one of the answers the server rejects, and finding it after the suite has
205
+ // been written, run and reviewed costs the whole cycle.
206
+ const budget = expected.surfaceBudget;
207
+ if (budget !== undefined && reached.size > budget) {
208
+ add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
209
+ ' — guard the most important ones up to that number and list the rest in deferred_surfaces.');
210
+ }
211
+ const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
203
212
  if (missed.length > 0) {
204
213
  add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
205
- ' — drive it, or declare it unreachable with a business reason.');
214
+ ' — drive it, declare it unreachable with a business reason, or defer it under the surface budget.');
206
215
  }
207
216
  // No check here for "declares everything unreachable and drives nothing": the
208
217
  // caller already returned when the capability's marker appears in no suite
209
218
  // file, so a capability with no Scenario never reaches this function at all.
210
219
  // The server refuses that state for the same reason, one rule earlier.
211
- const foreign = [...reached, ...declaredUnreachable].filter((surface) => !expected.surfaces.includes(surface));
220
+ const foreign = [...reached, ...declaredUnreachable, ...deferred].filter((surface) => !expected.surfaces.includes(surface));
212
221
  if (foreign.length > 0) {
213
222
  add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
214
223
  }
@@ -262,6 +271,49 @@ function collectUnreachable(row, id, reached, add) {
262
271
  }
263
272
  return surfaces;
264
273
  }
274
+ // Spec 34-3, criterion 6. The addresses this run did not take, because the
275
+ // capability carried more than `surface_budget` of them. Mirrored here for the
276
+ // same reason the unreachable list is, and more urgently: refusing this answer
277
+ // locally does not merely disagree with the server, it hands the host an error
278
+ // message pointing at `unreachable_surfaces` — the one place these must never
279
+ // go, because "nothing can cause this request" and "there were better ones" are
280
+ // different sentences and only one of them is true.
281
+ //
282
+ // Plain surface ids, with no reason each. That asymmetry with the unreachable
283
+ // list is deliberate: there the sentence is the guard, because an address you
284
+ // cannot write a sentence about is not really unreachable. Here the reason is
285
+ // the same for every entry and already known — the ceiling.
286
+ function collectDeferred(row, id, reached, unreachable, add) {
287
+ const declared = row.deferred_surfaces;
288
+ if (declared === undefined)
289
+ return new Set();
290
+ if (!Array.isArray(declared) || declared.length === 0) {
291
+ add(`${id} deferred_surfaces must be a non-empty array of surface ids when it is present.`);
292
+ return new Set();
293
+ }
294
+ const surfaces = new Set();
295
+ for (const [index, item] of declared.entries()) {
296
+ const surface = typeof item === 'string' ? item.trim() : '';
297
+ if (!surface) {
298
+ add(`${id} deferred_surfaces[${index}] names no surface.`);
299
+ continue;
300
+ }
301
+ if (reached.has(surface)) {
302
+ add(`${id} both drives ${surface} in a scenario and defers it — it is one or the other.`);
303
+ continue;
304
+ }
305
+ if (unreachable.has(surface)) {
306
+ add(`${id} declares ${surface} both unreachable and deferred — cannot be reached and was not taken this time are different answers.`);
307
+ continue;
308
+ }
309
+ if (surfaces.has(surface)) {
310
+ add(`${id} defers ${surface} more than once.`);
311
+ continue;
312
+ }
313
+ surfaces.add(surface);
314
+ }
315
+ return surfaces;
316
+ }
265
317
  // Scenario names carrying one marker, by shape rather than by grammar. See the
266
318
  // caller for why this stays deliberately timid.
267
319
  function scenarioNamesTagged(suiteText, marker) {
@@ -333,6 +385,7 @@ function hasContent(assignment) {
333
385
  // case, wherever the shape happens to nest it.
334
386
  function assignedCases(assignment) {
335
387
  const found = [];
388
+ let surfaceBudget;
336
389
  const walk = (value) => {
337
390
  if (Array.isArray(value)) {
338
391
  value.forEach(walk);
@@ -341,6 +394,11 @@ function assignedCases(assignment) {
341
394
  if (!value || typeof value !== 'object')
342
395
  return;
343
396
  const row = value;
397
+ // Found by the same walk rather than by knowing where the server put it, for
398
+ // the same reason the cases are: the assignment body is opaque here.
399
+ if (typeof row.surface_budget === 'number' && Number.isFinite(row.surface_budget)) {
400
+ surfaceBudget = row.surface_budget;
401
+ }
344
402
  const key = row.contract_key;
345
403
  const marker = row.case_marker;
346
404
  if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
@@ -356,7 +414,9 @@ function assignedCases(assignment) {
356
414
  Object.values(row).forEach(walk);
357
415
  };
358
416
  walk(assignment);
359
- return found;
417
+ // Stamped after the walk, never during it: nothing promises the ceiling is
418
+ // visited before the cases that answer to it.
419
+ return found.map((entry) => ({ ...entry, surfaceBudget }));
360
420
  }
361
421
  // Every byte of the branch's suite, main file and support files together, for
362
422
  // the "is the marker actually in there" check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"