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 +11 -0
- package/dist/files/behavioral.js +92 -1
- package/dist/files/suiteBuild.js +59 -0
- package/dist/files/workerPlan.js +170 -0
- package/dist/runner/worldProbe.js +89 -0
- package/dist/verbs/putSuiteBuild.js +1 -0
- package/dist/verbs/suitePrepare.js +11 -0
- package/dist/verbs/validateBuild.js +24 -0
- package/dist/verbs/validateWorkerCheckpoints.js +87 -0
- package/dist/verbs/validateWorkerPlan.js +9 -0
- package/package.json +2 -1
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':
|
package/dist/files/behavioral.js
CHANGED
|
@@ -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([
|
|
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))
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -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
|
+
"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": {
|