unitbob 0.3.5 → 0.4.0

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 CHANGED
@@ -25,6 +25,8 @@ import { validateBuild } from "./verbs/validateBuild.js";
25
25
  import { fixPrepare } from "./verbs/fixPrepare.js";
26
26
  import { contractPrompt } from "./verbs/contractPrompt.js";
27
27
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
28
+ import { validateWorkerPlan } from "./verbs/validateWorkerPlan.js";
29
+ import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
28
30
  const USAGE = `unitbob — thin local hands for the Unitbob server.
29
31
 
30
32
  Usage: unitbob [--project-root <dir>] <verb> [args]
@@ -47,6 +49,9 @@ Verbs:
47
49
  suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
48
50
  validate-build Internal: check the host's suite answer against the request, locally, before
49
51
  uploading. Reports every problem at once; put-suite-build runs it too.
52
+ validate-worker-plan Internal: validate the exact request-bound worker plan before fan-out.
53
+ validate-worker-checkpoints
54
+ Internal: validate every worker checkpoint before assembly or repair.
50
55
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
51
56
  then run every branch it published and report the server's results.
52
57
  run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
@@ -108,6 +113,12 @@ export async function main(argv, deps = { ensureLinked }) {
108
113
  case 'validate-build':
109
114
  await validateBuild(await linked(), args);
110
115
  return 0;
116
+ case 'validate-worker-plan':
117
+ await validateWorkerPlan(await linked(), args);
118
+ return 0;
119
+ case 'validate-worker-checkpoints':
120
+ await validateWorkerCheckpoints(await linked(), args);
121
+ return 0;
111
122
  case 'put-suite-build':
112
123
  return await publishAndRun(await linked(), args);
113
124
  case 'fix-prepare':
@@ -9,6 +9,87 @@ import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
9
9
  // runs the suite to green before upload; the connector materializes the stored
10
10
  // blob only so `check` can execute it locally.
11
11
  export const BEHAVIORAL_DIR = '.unitbob/behavioral';
12
+ export const BEHAVIORAL_WORLD_PATH = `${BEHAVIORAL_DIR}/step_definitions/00_unitbob_world.rb`;
13
+ // The Ruby/Cucumber harness is connector-owned. Host agents own business steps;
14
+ // this file owns only the stable Rails integration seam they build on.
15
+ export const BEHAVIORAL_WORLD = `# Generated by Unitbob. DO NOT EDIT: suite materialization restores this file.
16
+ repo_root = ENV.fetch('UNITBOB_REPO_ROOT')
17
+ ENV['RAILS_ENV'] ||= 'test'
18
+ require File.join(repo_root, 'config', 'environment')
19
+ require 'action_dispatch/testing/integration'
20
+ require 'rspec/expectations'
21
+ require 'rspec/mocks'
22
+
23
+ module UnitbobWorld
24
+ include RSpec::Matchers
25
+ include RSpec::Mocks::ExampleMethods
26
+
27
+ attr_reader :unitbob_session
28
+
29
+ def unitbob_get(path, **options)
30
+ unitbob_session.get(path, **options)
31
+ end
32
+
33
+ def unitbob_post(path, **options)
34
+ unitbob_session.post(path, **options)
35
+ end
36
+
37
+ def unitbob_patch(path, **options)
38
+ unitbob_session.patch(path, **options)
39
+ end
40
+
41
+ def unitbob_put(path, **options)
42
+ unitbob_session.put(path, **options)
43
+ end
44
+
45
+ def unitbob_delete(path, **options)
46
+ unitbob_session.delete(path, **options)
47
+ end
48
+
49
+ def unitbob_expect_status(status)
50
+ unitbob_session.assert_response(status)
51
+ end
52
+
53
+ def unitbob_expect_redirect_to(location)
54
+ unitbob_session.assert_redirected_to(location)
55
+ end
56
+ end
57
+
58
+ World(UnitbobWorld)
59
+
60
+ Before do
61
+ @unitbob_time_zone = Time.zone
62
+ @unitbob_locale = I18n.locale
63
+ @unitbob_connection = ActiveRecord::Base.connection
64
+ @unitbob_connection.begin_transaction(joinable: false)
65
+ RSpec::Mocks.setup
66
+
67
+ @unitbob_session = ActionDispatch::Integration::Session.new(Rails.application)
68
+ @unitbob_session.singleton_class.class_eval { attr_accessor :assertions }
69
+ @unitbob_session.assertions = 0
70
+ end
71
+
72
+ After do
73
+ mock_error = nil
74
+ begin
75
+ RSpec::Mocks.verify
76
+ rescue Exception => error # rubocop:disable Lint/RescueException
77
+ mock_error = error
78
+ ensure
79
+ RSpec::Mocks.teardown
80
+ @unitbob_connection.rollback_transaction if @unitbob_connection&.transaction_open?
81
+ Time.zone = @unitbob_time_zone
82
+ I18n.locale = @unitbob_locale
83
+ end
84
+ raise mock_error if mock_error
85
+ end
86
+ `;
87
+ export function materializeBehavioralWorld(projectRoot) {
88
+ const worldPath = join(projectRoot, BEHAVIORAL_WORLD_PATH);
89
+ mkdirSync(dirname(worldPath), { recursive: true });
90
+ writeFileSync(worldPath, BEHAVIORAL_WORLD);
91
+ return worldPath;
92
+ }
12
93
  // Write a behavioral artifact envelope (main file + support files) under the
13
94
  // behavioral root, after checking every path is safe. Stale suite artifacts are
14
95
  // removed while the separately provisioned runner environment is preserved.
@@ -17,6 +98,9 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
17
98
  const files = [artifact, ...(artifact.support_files ?? [])];
18
99
  for (const file of files)
19
100
  assertUnitbobPath(file.path, BEHAVIORAL_DIR);
101
+ if (files.some((file) => file.path === BEHAVIORAL_WORLD_PATH)) {
102
+ throw new Error(`${BEHAVIORAL_WORLD_PATH} is the connector-owned World and cannot be supplied by the host artifact.`);
103
+ }
20
104
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
21
105
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
22
106
  mkdirSync(behavioralRoot, { recursive: true });
@@ -33,6 +117,9 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
33
117
  if (file === artifact)
34
118
  mainPath = dest;
35
119
  }
120
+ if (runner === 'cucumber') {
121
+ materializeBehavioralWorld(projectRoot);
122
+ }
36
123
  return { mainPath };
37
124
  }
38
125
  // Everything under the behavioral root that the next materialization will
@@ -49,7 +136,11 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
49
136
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
50
137
  if (!existsSync(behavioralRoot))
51
138
  return [];
52
- const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
139
+ const listed = new Set([
140
+ artifact.path,
141
+ ...(artifact.support_files ?? []).map((file) => file.path),
142
+ ...(runner === 'cucumber' ? [BEHAVIORAL_WORLD_PATH] : []),
143
+ ]);
53
144
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
54
145
  return readdirSync(behavioralRoot)
55
146
  .filter((entry) => !runnerEntries.has(entry) && !CONNECTOR_RUN_ARTIFACTS.has(entry))
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
5
5
  import { readBudget, RUN_BUDGET } from "./budget.js";
6
+ import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
6
7
  export function requestPath(projectRoot) {
7
8
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
8
9
  }
@@ -42,6 +43,28 @@ export function writeBehavioralReviewRequest(projectRoot, output, candidateRun,
42
43
  ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
43
44
  } : {}),
44
45
  };
46
+ const workerPlanDigestInMetadata = metadata?.worker_plan_digest;
47
+ if (workerPlanDigestInMetadata === undefined && existsSync(workerPlanPath(projectRoot))) {
48
+ throw new Error('behavioral test_metadata.worker_plan_digest is required when a local worker-plan.json exists.');
49
+ }
50
+ if (workerPlanDigestInMetadata !== undefined) {
51
+ if (typeof workerPlanDigestInMetadata !== 'string' || !/^[a-f0-9]{64}$/.test(workerPlanDigestInMetadata)) {
52
+ throw new Error('behavioral test_metadata.worker_plan_digest must be a SHA-256 string.');
53
+ }
54
+ const planErrors = validateWorkerPlanFiles(projectRoot);
55
+ if (planErrors.length > 0)
56
+ throw new Error(`Cannot review an invalid worker plan:\n- ${planErrors.join('\n- ')}`);
57
+ const digest = workerPlanDigest(projectRoot);
58
+ if (workerPlanDigestInMetadata !== digest) {
59
+ throw new Error('behavioral test_metadata.worker_plan_digest does not match the exact local worker-plan.json bytes.');
60
+ }
61
+ const buildRequest = readSuiteBuildRequest(projectRoot);
62
+ const assignment = buildRequest.branches.find((branch) => branch.suite_kind === 'behavioral')?.assignment;
63
+ const items = readWorkerPlan(projectRoot).workers.filter((item) => item.branch === 'behavioral');
64
+ request.behavioral_assignment = assignment;
65
+ request.worker_plan = items;
66
+ request.plan_digest = digest;
67
+ }
45
68
  writeArtifact(reviewRequestPath(projectRoot), request);
46
69
  return request;
47
70
  }
@@ -80,6 +103,7 @@ const POST_CANDIDATE_METADATA_KEYS = [
80
103
  'known_defect_context',
81
104
  'candidate_run',
82
105
  'fixed_candidate_run',
106
+ 'selection_review',
83
107
  ];
84
108
  export function readBehavioralReview(projectRoot, output) {
85
109
  const metadata = output.test_metadata;
@@ -98,6 +122,7 @@ export function readBehavioralReview(projectRoot, output) {
98
122
  if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
99
123
  throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
100
124
  }
125
+ validateSelectionReview(review, metadata);
101
126
  const evidence = readCandidateRunEvidence(projectRoot, output);
102
127
  return {
103
128
  ...review,
@@ -105,6 +130,40 @@ export function readBehavioralReview(projectRoot, output) {
105
130
  ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
106
131
  };
107
132
  }
133
+ function validateSelectionReview(review, metadata) {
134
+ const digest = metadata?.worker_plan_digest;
135
+ if (digest === undefined)
136
+ return;
137
+ const selection = review.selection_review;
138
+ if (!selection || selection.plan_digest !== digest || !Array.isArray(selection.capability_reviews)) {
139
+ throw new Error('The behavioral review must contain selection_review bound to worker_plan_digest.');
140
+ }
141
+ const expected = Array.isArray(metadata?.capabilities)
142
+ ? metadata.capabilities.flatMap((entry) => {
143
+ const id = entry?.capability_id;
144
+ return typeof id === 'string' && id ? [id] : [];
145
+ })
146
+ : [];
147
+ const entries = selection.capability_reviews;
148
+ const actual = entries.map((entry) => entry?.capability_id);
149
+ for (const id of expected) {
150
+ if (actual.filter((candidate) => candidate === id).length !== 1) {
151
+ throw new Error(`selection_review must contain exactly one verdict for capability ${id}.`);
152
+ }
153
+ }
154
+ for (const [index, entry] of entries.entries()) {
155
+ if (!expected.includes(entry?.capability_id)) {
156
+ throw new Error(`selection_review.capability_reviews[${index}] names an unassigned capability.`);
157
+ }
158
+ if (!['pass', 'does_not_pass'].includes(entry?.verdict)) {
159
+ throw new Error(`selection_review.capability_reviews[${index}].verdict must be pass or does_not_pass.`);
160
+ }
161
+ if (entry.verdict === 'does_not_pass' &&
162
+ (typeof entry.reviewer_objection_text !== 'string' || !entry.reviewer_objection_text.trim())) {
163
+ throw new Error(`selection_review.capability_reviews[${index}] must include reviewer_objection_text.`);
164
+ }
165
+ }
166
+ }
108
167
  // Why a review does not bind — two different mistakes with one symptom.
109
168
  //
110
169
  // The likelier one, now that a branch may answer with a bare `{ path }`: the
@@ -0,0 +1,170 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { assertUnitbobPath } from "./artifactPath.js";
5
+ export function workerPlanPath(projectRoot) {
6
+ return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
7
+ }
8
+ function requestPath(projectRoot) {
9
+ return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
10
+ }
11
+ export function checkpointPath(projectRoot, item) {
12
+ return join(projectRoot, '.unitbob', 'suite-build', 'checkpoints', `${item.branch}-${item.worker_id}.json`);
13
+ }
14
+ export function exactFileDigest(path) {
15
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
16
+ }
17
+ export function requestDigest(projectRoot) {
18
+ return exactFileDigest(requestPath(projectRoot));
19
+ }
20
+ export function workerPlanDigest(projectRoot) {
21
+ return exactFileDigest(workerPlanPath(projectRoot));
22
+ }
23
+ export function readWorkerPlan(projectRoot) {
24
+ const path = workerPlanPath(projectRoot);
25
+ if (!existsSync(path))
26
+ throw new Error(`${path} not found — write the complete worker plan before fan-out.`);
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
30
+ }
31
+ catch (error) {
32
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
33
+ }
34
+ return parsed;
35
+ }
36
+ export function validateWorkerPlanFiles(projectRoot) {
37
+ const errors = [];
38
+ const plan = readWorkerPlan(projectRoot);
39
+ let request;
40
+ try {
41
+ request = JSON.parse(readFileSync(requestPath(projectRoot), 'utf8'));
42
+ }
43
+ catch (error) {
44
+ throw new Error(`${requestPath(projectRoot)} is not valid JSON: ${error.message}`);
45
+ }
46
+ if (!plan || typeof plan !== 'object')
47
+ return ['worker plan must be an object'];
48
+ if (plan.request_digest !== requestDigest(projectRoot))
49
+ errors.push('request_digest does not match the exact request.json bytes');
50
+ if (!Array.isArray(plan.workers) || plan.workers.length === 0)
51
+ return [...errors, 'workers must be a non-empty array'];
52
+ const branches = Array.isArray(request.branches) ? request.branches : [];
53
+ const expectedByBranch = new Map(branches.map((branch) => [
54
+ branch.suite_kind,
55
+ assignmentIds(branch.assignment),
56
+ ]));
57
+ const budget = request.budget;
58
+ const workerCeiling = typeof budget?.workers === 'number' ? budget.workers : Number.POSITIVE_INFINITY;
59
+ const seenWorkers = new Set();
60
+ const seenPaths = new Map();
61
+ for (const [index, item] of plan.workers.entries()) {
62
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
63
+ errors.push(`workers[${index}]: worker plan item must be an object`);
64
+ continue;
65
+ }
66
+ const label = workerLabel(item, index);
67
+ if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
68
+ errors.push(`${label}: branch is not in the request`);
69
+ if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
70
+ errors.push(`${label}: worker_id must be a stable filename-safe id`);
71
+ else if (seenWorkers.has(item.worker_id))
72
+ errors.push(`${label}: worker_id ${item.worker_id} appears more than once`);
73
+ else
74
+ seenWorkers.add(item.worker_id);
75
+ for (const field of ['capability_ids', 'promises', 'planned_cases', 'source_paths', 'owned_paths']) {
76
+ if (!Array.isArray(item?.[field]) || item[field].length === 0)
77
+ errors.push(`${label}: ${field} must be non-empty`);
78
+ }
79
+ for (const field of ['capability_ids', 'promises', 'source_paths', 'owned_paths']) {
80
+ if (Array.isArray(item?.[field]) && item[field].some((value) => !isNonEmptyString(value))) {
81
+ errors.push(`${label}: ${field} must contain non-empty strings`);
82
+ }
83
+ }
84
+ if (Array.isArray(item?.planned_cases) && item.planned_cases.some((value) => !(isNonEmptyString(value) || (value !== null && typeof value === 'object')))) {
85
+ errors.push(`${label}: planned_cases must contain non-empty intents`);
86
+ }
87
+ if (!isNonEmptyString(item?.done_when))
88
+ errors.push(`${label}: done_when must be non-empty`);
89
+ if (!isNonEmptyString(item?.harness_path))
90
+ errors.push(`${label}: harness_path must be non-empty`);
91
+ const expectedHarness = item?.branch === 'behavioral'
92
+ ? '.unitbob/behavioral/step_definitions/00_unitbob_world.rb'
93
+ : item?.branch === 'structural' ? '.unitbob/structural/unitbob_helper.rb' : null;
94
+ if (expectedHarness && item.harness_path !== expectedHarness) {
95
+ errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
96
+ }
97
+ if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
98
+ errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
99
+ }
100
+ if (!Number.isInteger(item?.limits?.fact_finder_lookups) || item.limits.fact_finder_lookups < 0 || item.limits.fact_finder_lookups > 8) {
101
+ errors.push(`${label}: limits.fact_finder_lookups must be an integer from 0 to 8`);
102
+ }
103
+ for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
104
+ if (!isNonEmptyString(ownedPath)) {
105
+ errors.push(`${label}: owned path must be a non-empty string`);
106
+ continue;
107
+ }
108
+ try {
109
+ assertUnitbobPath(ownedPath, `.unitbob/${item.branch}`);
110
+ }
111
+ catch (error) {
112
+ errors.push(`${label}: ${error.message}`);
113
+ }
114
+ const owner = seenPaths.get(ownedPath);
115
+ if (owner)
116
+ errors.push(`${label}: owned path ${ownedPath} is also owned by ${owner}`);
117
+ else
118
+ seenPaths.set(ownedPath, label);
119
+ }
120
+ }
121
+ for (const [branch, expected] of expectedByBranch) {
122
+ const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
123
+ if (expected.length > 0 && items.length === 0)
124
+ errors.push(`${branch}: no worker slice was planned`);
125
+ if (items.length > Math.min(workerCeiling, expected.length)) {
126
+ errors.push(`${branch}: worker count ${items.length} exceeds min(budget.workers, capability count)`);
127
+ }
128
+ const assigned = items.flatMap((item) => Array.isArray(item.capability_ids) ? item.capability_ids : []);
129
+ for (const id of expected) {
130
+ const count = assigned.filter((candidate) => candidate === id).length;
131
+ if (count === 0)
132
+ errors.push(`${branch}: assigned capability ${id} is missing from the plan`);
133
+ if (count > 1)
134
+ errors.push(`${branch}: assigned capability ${id} appears more than once`);
135
+ }
136
+ for (const id of assigned.filter((id) => !expected.includes(id)))
137
+ errors.push(`${branch}: capability ${id} was not assigned by the request`);
138
+ const sizes = items.map((item) => Array.isArray(item.planned_cases) ? item.planned_cases.length : 0).filter((size) => size > 0);
139
+ if (sizes.length > 1 && Math.max(...sizes) / Math.min(...sizes) > 1.5) {
140
+ errors.push(`${branch}: planned case slice ratio exceeds 1.5`);
141
+ }
142
+ }
143
+ return errors;
144
+ }
145
+ function assignmentIds(value) {
146
+ const assignment = value;
147
+ if (Array.isArray(assignment?.capabilities)) {
148
+ return assignment.capabilities.flatMap((entry) => idFrom(entry, 'capability_id'));
149
+ }
150
+ if (!Array.isArray(assignment?.blocks))
151
+ return [];
152
+ return assignment.blocks.flatMap((block) => {
153
+ const record = block;
154
+ return Array.isArray(record.interfaces)
155
+ ? record.interfaces.flatMap((entry) => idFrom(entry, 'interface_id'))
156
+ : [];
157
+ });
158
+ }
159
+ function idFrom(value, field) {
160
+ const id = value?.[field];
161
+ return isNonEmptyString(id) ? [id] : [];
162
+ }
163
+ function workerLabel(item, index) {
164
+ return item && isNonEmptyString(item.branch) && isNonEmptyString(item.worker_id)
165
+ ? `${item.branch}:${item.worker_id}`
166
+ : `workers[${index}]`;
167
+ }
168
+ function isNonEmptyString(value) {
169
+ return typeof value === 'string' && value.trim().length > 0;
170
+ }
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runProcess } from "../proc.js";
4
+ import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
5
+ import { PROVISION_TIMEOUT_MS } from "./provision.js";
6
+ const PROBE_ROOT = '.unitbob/suite-build/world-probe';
7
+ export async function probeBehavioralWorld(projectRoot, deps = {
8
+ runCmd: (command, args, options) => runProcess(command, args, {
9
+ cwd: options.cwd,
10
+ env: options.env,
11
+ timeoutMs: PROVISION_TIMEOUT_MS,
12
+ }),
13
+ }) {
14
+ const probeRoot = join(projectRoot, PROBE_ROOT);
15
+ const feature = join(probeRoot, 'world.feature');
16
+ const steps = join(probeRoot, 'world_steps.rb');
17
+ mkdirSync(probeRoot, { recursive: true });
18
+ writeFileSync(feature, PROBE_FEATURE);
19
+ writeFileSync(steps, PROBE_STEPS);
20
+ try {
21
+ const result = await deps.runCmd('bundle', [
22
+ 'exec', 'cucumber', feature,
23
+ '--require', join(projectRoot, BEHAVIORAL_WORLD_PATH),
24
+ '--require', steps,
25
+ '--format', 'progress',
26
+ ], {
27
+ cwd: projectRoot,
28
+ env: {
29
+ ...process.env,
30
+ RAILS_ENV: 'test',
31
+ UNITBOB_REPO_ROOT: projectRoot,
32
+ BUNDLE_GEMFILE: join(projectRoot, '.unitbob', 'behavioral', 'Gemfile'),
33
+ },
34
+ });
35
+ if (result.code === 0)
36
+ return { status: 'ok' };
37
+ const detail = [result.stderr, result.stdout].find((text) => text.trim())?.trim() ?? `exit ${result.code}`;
38
+ return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
39
+ }
40
+ catch (error) {
41
+ return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe could not run: ${String(error)}` };
42
+ }
43
+ finally {
44
+ rmSync(probeRoot, { recursive: true, force: true });
45
+ }
46
+ }
47
+ const PROBE_FEATURE = `Feature: Unitbob World profile
48
+ Scenario: request, assertion counter, mocks, and state mutation
49
+ Given the first World probe scenario mutates supported state
50
+ Then its integration assertion counter advances
51
+
52
+ Scenario: supported state is clean at the next scenario boundary
53
+ Then the second World probe scenario sees clean state and fresh mocks
54
+ `;
55
+ const PROBE_STEPS = `PROBE_VERSION = "unitbob-world-probe-#{Process.pid}"
56
+ PROBE_TIME_ZONE = Time.zone
57
+ PROBE_OTHER_TIME_ZONE = PROBE_TIME_ZONE&.name == 'UTC' ? 'Hawaii' : 'UTC'
58
+ PROBE_LOCALE = I18n.locale
59
+ PROBE_RECEIVER = Object.new
60
+
61
+ Given('the first World probe scenario mutates supported state') do
62
+ @unitbob_connection.execute("INSERT INTO schema_migrations (version) VALUES ('#{PROBE_VERSION}')")
63
+ Time.zone = PROBE_OTHER_TIME_ZONE
64
+ alternate_locale = (I18n.available_locales - [PROBE_LOCALE]).first
65
+ I18n.locale = alternate_locale if alternate_locale
66
+ expect(PROBE_RECEIVER).to receive(:unitbob_probe).once.and_return(:mocked)
67
+ expect(PROBE_RECEIVER.unitbob_probe).to eq(:mocked)
68
+ unitbob_get('/__unitbob_world_probe__')
69
+ unitbob_expect_status(:success)
70
+ unitbob_get('/__unitbob_world_probe_redirect__')
71
+ unitbob_expect_redirect_to('/__unitbob_world_probe_target__')
72
+ end
73
+
74
+ Then('its integration assertion counter advances') do
75
+ expect(unitbob_session.assertions).to be > 0
76
+ end
77
+
78
+ Then('the second World probe scenario sees clean state and fresh mocks') do
79
+ quoted = @unitbob_connection.quote(PROBE_VERSION)
80
+ count = @unitbob_connection.select_value("SELECT COUNT(*) FROM schema_migrations WHERE version = #{quoted}").to_i
81
+ expect(count).to eq(0)
82
+ expect(Time.zone).to eq(PROBE_TIME_ZONE)
83
+ expect(I18n.locale).to eq(PROBE_LOCALE)
84
+ expect(PROBE_RECEIVER).not_to respond_to(:unitbob_probe)
85
+ probe = double('fresh-world-probe')
86
+ expect(probe).to receive(:call).once
87
+ probe.call
88
+ end
89
+ `;
@@ -129,6 +129,7 @@ function withReview(config, request, output) {
129
129
  ...qualityReview,
130
130
  candidate_digest: review.candidate_digest,
131
131
  },
132
+ ...(review.selection_review ? { selection_review: review.selection_review } : {}),
132
133
  known_defect_probe: review.known_defect_probe,
133
134
  known_defect_context: request.known_defect_context,
134
135
  candidate_run: review.candidate_run,
@@ -1,10 +1,12 @@
1
1
  import { clearSpending } from "../files/budget.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
+ import { materializeBehavioralWorld } from "../files/behavioral.js";
3
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
4
5
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
5
6
  import { anyStackPrecheck, detectBddRunner, detectStructuralRunner } from "../runner/precheck.js";
6
7
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
7
8
  import { ensureRunner } from "../runner/provision.js";
9
+ import { probeBehavioralWorld } from "../runner/worldProbe.js";
8
10
  import { Wire } from "../wire.js";
9
11
  // The complete envelope for one branch, or null when this machine cannot
10
12
  // produce one: the server offered no combination this stack matches, or the
@@ -60,6 +62,7 @@ export async function suitePrepare(config, args = [], deps) {
60
62
  precheck: anyStackPrecheck,
61
63
  bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
62
64
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
65
+ worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
63
66
  runnerEnvelope: runnerEnvelopeFor,
64
67
  stdout: process.stdout,
65
68
  ...deps,
@@ -104,6 +107,14 @@ export async function suitePrepare(config, args = [], deps) {
104
107
  fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
105
108
  continue;
106
109
  }
110
+ if (runner === 'cucumber') {
111
+ materializeBehavioralWorld(config.projectRoot);
112
+ const probe = await actual.worldProbe(config.projectRoot);
113
+ if (probe.status === 'fixable') {
114
+ fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
115
+ continue;
116
+ }
117
+ }
107
118
  }
108
119
  buildable.push({ packet, runner });
109
120
  }
@@ -18,10 +18,34 @@ export function collectBuildProblems(request, outputs, unreadable = []) {
18
18
  const add = (message) => { problems.push({ branch: output.suite_kind, message }); };
19
19
  checkRunnerManifest(branch, output, add);
20
20
  checkAssignment(branch, output, add);
21
+ if (output.suite_kind === 'behavioral')
22
+ checkDuplicateStepExpressions(output, add);
21
23
  }
22
24
  problems.push(...unansweredBranches(request, outputs, unreadable));
23
25
  return problems;
24
26
  }
27
+ function checkDuplicateStepExpressions(output, add) {
28
+ const suite = output.suite_file;
29
+ const definitions = new Map();
30
+ for (const file of Array.isArray(suite?.support_files) ? suite.support_files : []) {
31
+ if (typeof file.path !== 'string' || typeof file.content !== 'string')
32
+ continue;
33
+ for (const line of file.content.split('\n')) {
34
+ const call = line.match(/^\s*(?:Given|When|Then|And|But)\s*\(\s*(['"`])(.*?)\1/);
35
+ const decorator = line.match(/^\s*@(given|when|then)\s*\(\s*(['"])(.*?)\2/i);
36
+ const expression = call?.[2] ?? decorator?.[3];
37
+ if (!expression)
38
+ continue;
39
+ definitions.set(expression, [...(definitions.get(expression) ?? []), file.path]);
40
+ }
41
+ }
42
+ for (const [expression, paths] of definitions) {
43
+ const uniquePaths = [...new Set(paths)];
44
+ if (paths.length > 1) {
45
+ add(`duplicate step expression "${expression}" appears in ${uniquePaths.join(', ')} — step expressions share one branch-global namespace.`);
46
+ }
47
+ }
48
+ }
25
49
  // The branch that is not there at all. Every check above reads the answer and
26
50
  // asks whether it is well-formed; none of them can see a branch the answer never
27
51
  // mentions, because there is no entry to walk. So this one walks the request
@@ -199,16 +223,25 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
199
223
  // here in the server's own shape; refusing it locally would refuse an answer
200
224
  // the server takes, which is the one direction of drift that costs a branch.
201
225
  const declaredUnreachable = collectUnreachable(row, id, reached, add);
202
- const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface));
226
+ const deferred = collectDeferred(row, id, reached, declaredUnreachable, add);
227
+ // Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
228
+ // one of the answers the server rejects, and finding it after the suite has
229
+ // been written, run and reviewed costs the whole cycle.
230
+ const budget = expected.surfaceBudget;
231
+ if (budget !== undefined && reached.size > budget) {
232
+ add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
233
+ ' — guard the most important ones up to that number and list the rest in deferred_surfaces.');
234
+ }
235
+ const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
203
236
  if (missed.length > 0) {
204
237
  add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
205
- ' — drive it, or declare it unreachable with a business reason.');
238
+ ' — drive it, declare it unreachable with a business reason, or defer it under the surface budget.');
206
239
  }
207
240
  // No check here for "declares everything unreachable and drives nothing": the
208
241
  // caller already returned when the capability's marker appears in no suite
209
242
  // file, so a capability with no Scenario never reaches this function at all.
210
243
  // The server refuses that state for the same reason, one rule earlier.
211
- const foreign = [...reached, ...declaredUnreachable].filter((surface) => !expected.surfaces.includes(surface));
244
+ const foreign = [...reached, ...declaredUnreachable, ...deferred].filter((surface) => !expected.surfaces.includes(surface));
212
245
  if (foreign.length > 0) {
213
246
  add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
214
247
  }
@@ -262,6 +295,49 @@ function collectUnreachable(row, id, reached, add) {
262
295
  }
263
296
  return surfaces;
264
297
  }
298
+ // Spec 34-3, criterion 6. The addresses this run did not take, because the
299
+ // capability carried more than `surface_budget` of them. Mirrored here for the
300
+ // same reason the unreachable list is, and more urgently: refusing this answer
301
+ // locally does not merely disagree with the server, it hands the host an error
302
+ // message pointing at `unreachable_surfaces` — the one place these must never
303
+ // go, because "nothing can cause this request" and "there were better ones" are
304
+ // different sentences and only one of them is true.
305
+ //
306
+ // Plain surface ids, with no reason each. That asymmetry with the unreachable
307
+ // list is deliberate: there the sentence is the guard, because an address you
308
+ // cannot write a sentence about is not really unreachable. Here the reason is
309
+ // the same for every entry and already known — the ceiling.
310
+ function collectDeferred(row, id, reached, unreachable, add) {
311
+ const declared = row.deferred_surfaces;
312
+ if (declared === undefined)
313
+ return new Set();
314
+ if (!Array.isArray(declared) || declared.length === 0) {
315
+ add(`${id} deferred_surfaces must be a non-empty array of surface ids when it is present.`);
316
+ return new Set();
317
+ }
318
+ const surfaces = new Set();
319
+ for (const [index, item] of declared.entries()) {
320
+ const surface = typeof item === 'string' ? item.trim() : '';
321
+ if (!surface) {
322
+ add(`${id} deferred_surfaces[${index}] names no surface.`);
323
+ continue;
324
+ }
325
+ if (reached.has(surface)) {
326
+ add(`${id} both drives ${surface} in a scenario and defers it — it is one or the other.`);
327
+ continue;
328
+ }
329
+ if (unreachable.has(surface)) {
330
+ add(`${id} declares ${surface} both unreachable and deferred — cannot be reached and was not taken this time are different answers.`);
331
+ continue;
332
+ }
333
+ if (surfaces.has(surface)) {
334
+ add(`${id} defers ${surface} more than once.`);
335
+ continue;
336
+ }
337
+ surfaces.add(surface);
338
+ }
339
+ return surfaces;
340
+ }
265
341
  // Scenario names carrying one marker, by shape rather than by grammar. See the
266
342
  // caller for why this stays deliberately timid.
267
343
  function scenarioNamesTagged(suiteText, marker) {
@@ -333,6 +409,7 @@ function hasContent(assignment) {
333
409
  // case, wherever the shape happens to nest it.
334
410
  function assignedCases(assignment) {
335
411
  const found = [];
412
+ let surfaceBudget;
336
413
  const walk = (value) => {
337
414
  if (Array.isArray(value)) {
338
415
  value.forEach(walk);
@@ -341,6 +418,11 @@ function assignedCases(assignment) {
341
418
  if (!value || typeof value !== 'object')
342
419
  return;
343
420
  const row = value;
421
+ // Found by the same walk rather than by knowing where the server put it, for
422
+ // the same reason the cases are: the assignment body is opaque here.
423
+ if (typeof row.surface_budget === 'number' && Number.isFinite(row.surface_budget)) {
424
+ surfaceBudget = row.surface_budget;
425
+ }
344
426
  const key = row.contract_key;
345
427
  const marker = row.case_marker;
346
428
  if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
@@ -356,7 +438,9 @@ function assignedCases(assignment) {
356
438
  Object.values(row).forEach(walk);
357
439
  };
358
440
  walk(assignment);
359
- return found;
441
+ // Stamped after the walk, never during it: nothing promises the ceiling is
442
+ // visited before the cases that answer to it.
443
+ return found.map((entry) => ({ ...entry, surfaceBudget }));
360
444
  }
361
445
  // Every byte of the branch's suite, main file and support files together, for
362
446
  // the "is the marker actually in there" check.
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
3
+ export async function validateWorkerCheckpoints(config, _args = [], deps = { stdout: process.stdout }) {
4
+ const planErrors = validateWorkerPlanFiles(config.projectRoot);
5
+ if (planErrors.length > 0)
6
+ throw new Error(`Cannot validate checkpoints for an invalid worker plan:\n- ${planErrors.join('\n- ')}`);
7
+ const plan = readWorkerPlan(config.projectRoot);
8
+ const expectedRequestDigest = requestDigest(config.projectRoot);
9
+ const expectedPlanDigest = workerPlanDigest(config.projectRoot);
10
+ const errors = [];
11
+ for (const item of plan.workers) {
12
+ const label = `${item.branch}:${item.worker_id}`;
13
+ const path = checkpointPath(config.projectRoot, item);
14
+ if (!existsSync(path)) {
15
+ errors.push(`${label}: checkpoint is missing at ${path}`);
16
+ continue;
17
+ }
18
+ let checkpoint;
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
22
+ errors.push(`${label}: checkpoint must be an object`);
23
+ continue;
24
+ }
25
+ checkpoint = parsed;
26
+ }
27
+ catch (error) {
28
+ errors.push(`${label}: checkpoint is not valid JSON: ${error.message}`);
29
+ continue;
30
+ }
31
+ if (checkpoint.request_digest !== expectedRequestDigest)
32
+ errors.push(`${label}: request_digest is stale`);
33
+ if (checkpoint.plan_digest !== expectedPlanDigest)
34
+ errors.push(`${label}: plan_digest is stale`);
35
+ if (checkpoint.branch !== item.branch)
36
+ errors.push(`${label}: branch does not match its plan item`);
37
+ if (checkpoint.worker_id !== item.worker_id)
38
+ errors.push(`${label}: worker_id does not match its plan item`);
39
+ const completed = stringArray(checkpoint.completed_promises, `${label}: completed_promises`, errors);
40
+ const unresolved = stringArray(checkpoint.unresolved_promises, `${label}: unresolved_promises`, errors);
41
+ const accounted = [...completed, ...unresolved];
42
+ for (const promise of item.promises) {
43
+ const count = accounted.filter((candidate) => candidate === promise).length;
44
+ if (count !== 1)
45
+ errors.push(`${label}: promise ${promise} must appear exactly once across completed/unresolved promises`);
46
+ }
47
+ for (const promise of accounted.filter((candidate) => !item.promises.includes(candidate))) {
48
+ errors.push(`${label}: checkpoint names promise ${promise} outside its plan item`);
49
+ }
50
+ const writtenPaths = stringArray(checkpoint.written_paths, `${label}: written_paths`, errors);
51
+ for (const pathValue of writtenPaths.filter((candidate) => !item.owned_paths.includes(candidate))) {
52
+ errors.push(`${label}: written path ${pathValue} is not an owned path`);
53
+ }
54
+ validateCompactFacts(checkpoint.facts, label, errors);
55
+ stringArray(checkpoint.decisions, `${label}: decisions`, errors);
56
+ stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
57
+ }
58
+ if (errors.length > 0)
59
+ throw new Error(`Worker checkpoints are invalid:\n- ${errors.join('\n- ')}`);
60
+ const validWorkers = plan.workers.map((item) => `${item.branch}:${item.worker_id}`);
61
+ deps.stdout.write(`Worker checkpoints valid for ${validWorkers.join(', ')}.\n`);
62
+ return { valid_workers: validWorkers };
63
+ }
64
+ function stringArray(value, label, errors) {
65
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
66
+ errors.push(`${label} must be an array of non-empty strings`);
67
+ return [];
68
+ }
69
+ return value;
70
+ }
71
+ function validateCompactFacts(value, label, errors) {
72
+ if (!Array.isArray(value)) {
73
+ errors.push(`${label}: facts must be an array`);
74
+ return;
75
+ }
76
+ for (const [index, entry] of value.entries()) {
77
+ const fact = entry;
78
+ if (!fact || typeof fact.fact !== 'string' || !fact.fact.trim())
79
+ errors.push(`${label}: facts[${index}].fact must be non-empty`);
80
+ if (!Array.isArray(fact?.source_refs) || fact.source_refs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
81
+ errors.push(`${label}: facts[${index}].source_refs must be compact source references`);
82
+ }
83
+ if ('source' in (fact ?? {}) || 'transcript' in (fact ?? {}) || 'suite' in (fact ?? {})) {
84
+ errors.push(`${label}: facts[${index}] may not embed source, transcript, or suite copies`);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,9 @@
1
+ import { validateWorkerPlanFiles, workerPlanDigest } from "../files/workerPlan.js";
2
+ export async function validateWorkerPlan(config, _args = [], deps = { stdout: process.stdout }) {
3
+ const errors = validateWorkerPlanFiles(config.projectRoot);
4
+ if (errors.length > 0)
5
+ throw new Error(`Worker plan is invalid:\n- ${errors.join('\n- ')}`);
6
+ const planDigest = workerPlanDigest(config.projectRoot);
7
+ deps.stdout.write(`Worker plan valid (${planDigest}). Fan-out may start.\n`);
8
+ return { plan_digest: planDigest };
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
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,6 +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
+ "test:world-profiles": "node scripts/test-world-profiles.mjs",
19
20
  "check:release": "node scripts/check-release.mjs"
20
21
  },
21
22
  "publishConfig": {