unitbob 0.3.6 → 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
@@ -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.6",
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": {