unitbob 0.2.3 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +11 -0
- package/dist/cli.js +20 -18
- package/dist/files/behavioral.js +30 -6
- package/dist/files/suiteBuild.js +137 -2
- package/dist/runner/bdd.js +14 -16
- package/dist/verbs/putSuiteBuild.js +25 -2
- package/dist/verbs/run.js +1 -1
- package/dist/verbs/suitePrepare.js +35 -5
- package/dist/verbs/suiteReviewPrepare.js +101 -0
- package/package.json +3 -3
package/dist/bin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The published executable (`package.json` bin). It exists only to start the
|
|
3
|
+
// process and end it: no logic lives here, so nothing about how the CLI was
|
|
4
|
+
// invoked can change what it does. npm installs a bin as a symlink, so this file
|
|
5
|
+
// runs under a path that is not its own — an entry point that tried to detect
|
|
6
|
+
// "am I the main module?" silently did nothing once installed.
|
|
7
|
+
import { main } from "./cli.js";
|
|
8
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
9
|
+
process.stderr.write(`${err.message}\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Verb dispatch. Parse `unitbob <verb> [args]`, dispatch to a hands-verb, and map
|
|
2
|
+
// any thrown error to an exit code with an actionable message — never a raw stack
|
|
3
|
+
// trace. This module decides exit codes but never acts on them: importing it must
|
|
4
|
+
// stay free of side effects so tests can drive `main` directly. `bin.ts` is the
|
|
5
|
+
// executable that owns process startup and exit.
|
|
5
6
|
import { ensureLinked } from "./link.js";
|
|
6
7
|
import { recipe } from "./verbs/recipe.js";
|
|
7
8
|
import { show } from "./verbs/show.js";
|
|
@@ -13,6 +14,7 @@ import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
|
13
14
|
import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
14
15
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
15
16
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
17
|
+
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
16
18
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
17
19
|
|
|
18
20
|
Usage: unitbob <verb> [args]
|
|
@@ -24,6 +26,7 @@ Verbs:
|
|
|
24
26
|
map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
|
|
25
27
|
put-map-build Internal: upload the host-built map and graph.
|
|
26
28
|
suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
|
|
29
|
+
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
27
30
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
|
|
28
31
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
29
32
|
contract-prompt <digest> <test_id> [fix|accept]
|
|
@@ -40,7 +43,7 @@ host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify sk
|
|
|
40
43
|
|
|
41
44
|
Config: .unitbob.json at your project root, created automatically: the first
|
|
42
45
|
run registers the project on the server by its folder name (spec 28).`;
|
|
43
|
-
async function main(argv) {
|
|
46
|
+
export async function main(argv, deps = { ensureLinked }) {
|
|
44
47
|
const [verb, ...args] = argv;
|
|
45
48
|
if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
|
|
46
49
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -52,32 +55,35 @@ async function main(argv) {
|
|
|
52
55
|
await init(args);
|
|
53
56
|
return 0;
|
|
54
57
|
case 'recipe':
|
|
55
|
-
await recipe(await ensureLinked(), args);
|
|
58
|
+
await recipe(await deps.ensureLinked(), args);
|
|
56
59
|
return 0;
|
|
57
60
|
case 'show':
|
|
58
|
-
await show(await ensureLinked());
|
|
61
|
+
await show(await deps.ensureLinked());
|
|
59
62
|
return 0;
|
|
60
63
|
case 'map-prepare':
|
|
61
|
-
await mapPrepare(await ensureLinked(), args);
|
|
64
|
+
await mapPrepare(await deps.ensureLinked(), args);
|
|
62
65
|
return 0;
|
|
63
66
|
case 'put-map-build':
|
|
64
|
-
await putMapBuild(await ensureLinked(), args);
|
|
67
|
+
await putMapBuild(await deps.ensureLinked(), args);
|
|
65
68
|
return 0;
|
|
66
69
|
case 'suite-prepare':
|
|
67
|
-
await suitePrepare(await ensureLinked(), args);
|
|
70
|
+
await suitePrepare(await deps.ensureLinked(), args);
|
|
71
|
+
return 0;
|
|
72
|
+
case 'suite-review-prepare':
|
|
73
|
+
await suiteReviewPrepare(await deps.ensureLinked(), args);
|
|
68
74
|
return 0;
|
|
69
75
|
case 'put-suite-build':
|
|
70
|
-
await putSuiteBuild(await ensureLinked(), args);
|
|
76
|
+
await putSuiteBuild(await deps.ensureLinked(), args);
|
|
71
77
|
return 0;
|
|
72
78
|
case 'fix-prepare':
|
|
73
|
-
await fixPrepare(await ensureLinked(), args);
|
|
79
|
+
await fixPrepare(await deps.ensureLinked(), args);
|
|
74
80
|
return 0;
|
|
75
81
|
case 'contract-prompt':
|
|
76
|
-
await contractPrompt(await ensureLinked(), args);
|
|
82
|
+
await contractPrompt(await deps.ensureLinked(), args);
|
|
77
83
|
return 0;
|
|
78
84
|
case 'run':
|
|
79
85
|
case 'check':
|
|
80
|
-
await run(await ensureLinked(), args);
|
|
86
|
+
await run(await deps.ensureLinked(), args);
|
|
81
87
|
return 0;
|
|
82
88
|
default:
|
|
83
89
|
process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
|
|
@@ -89,7 +95,3 @@ async function main(argv) {
|
|
|
89
95
|
return 1;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
|
-
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
93
|
-
process.stderr.write(`${err.message}\n`);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
});
|
package/dist/files/behavioral.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
4
|
// The behavioral suite lives under its own root: the main `.feature` plus its
|
|
@@ -9,14 +9,21 @@ import { assertUnitbobPath } from "./artifactPath.js";
|
|
|
9
9
|
// blob only so `check` can execute it locally.
|
|
10
10
|
export const BEHAVIORAL_DIR = '.unitbob/behavioral';
|
|
11
11
|
// Write a behavioral artifact envelope (main file + support files) under the
|
|
12
|
-
// behavioral root, after checking every path is safe.
|
|
13
|
-
//
|
|
14
|
-
// path of the materialized main file.
|
|
15
|
-
export function materializeBehavioral(projectRoot, artifact) {
|
|
12
|
+
// behavioral root, after checking every path is safe. Stale suite artifacts are
|
|
13
|
+
// removed while the separately provisioned runner environment is preserved.
|
|
14
|
+
// Returns the absolute path of the materialized main file.
|
|
15
|
+
export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
16
16
|
const files = [artifact, ...(artifact.support_files ?? [])];
|
|
17
17
|
for (const file of files)
|
|
18
18
|
assertUnitbobPath(file.path, BEHAVIORAL_DIR);
|
|
19
|
-
|
|
19
|
+
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
20
|
+
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
21
|
+
mkdirSync(behavioralRoot, { recursive: true });
|
|
22
|
+
for (const entry of readdirSync(behavioralRoot)) {
|
|
23
|
+
if (!runnerEntries.has(entry)) {
|
|
24
|
+
rmSync(join(behavioralRoot, entry), { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
20
27
|
let mainPath = '';
|
|
21
28
|
for (const file of files) {
|
|
22
29
|
const dest = join(projectRoot, file.path);
|
|
@@ -27,3 +34,20 @@ export function materializeBehavioral(projectRoot, artifact) {
|
|
|
27
34
|
}
|
|
28
35
|
return { mainPath };
|
|
29
36
|
}
|
|
37
|
+
export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
|
|
38
|
+
const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const source = join(sourceRoot, BEHAVIORAL_DIR, entry);
|
|
41
|
+
if (!existsSync(source))
|
|
42
|
+
continue;
|
|
43
|
+
const target = join(targetRoot, BEHAVIORAL_DIR, entry);
|
|
44
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
45
|
+
cpSync(source, target, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const EMPTY_ENTRIES = new Set();
|
|
49
|
+
const RUNNER_ENVIRONMENT_ENTRIES = {
|
|
50
|
+
cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
|
|
51
|
+
'cucumber-js': new Set(['node_modules', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']),
|
|
52
|
+
'pytest-bdd': new Set(['.venv']),
|
|
53
|
+
};
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
5
|
export function requestPath(projectRoot) {
|
|
@@ -7,11 +8,112 @@ export function requestPath(projectRoot) {
|
|
|
7
8
|
export function outputPath(projectRoot) {
|
|
8
9
|
return join(projectRoot, '.unitbob', 'suite-build', 'suite_output.json');
|
|
9
10
|
}
|
|
10
|
-
export function
|
|
11
|
+
export function reviewOutputPath(projectRoot) {
|
|
12
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'behavioral_review.json');
|
|
13
|
+
}
|
|
14
|
+
export function reviewRequestPath(projectRoot) {
|
|
15
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'review-request.json');
|
|
16
|
+
}
|
|
17
|
+
export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
|
|
18
|
+
const metadata = output.test_metadata;
|
|
19
|
+
const candidateDigest = suiteCandidateDigest(output);
|
|
20
|
+
const request = {
|
|
21
|
+
candidate_digest: candidateDigest,
|
|
22
|
+
suite_file: output.suite_file,
|
|
23
|
+
capabilities: metadata?.capabilities,
|
|
24
|
+
output_path: reviewOutputPath(projectRoot),
|
|
25
|
+
known_defect_context: knownDefectContext,
|
|
26
|
+
candidate_run: { candidate_digest: candidateDigest, ...candidateRun },
|
|
27
|
+
...(fixedCandidateRun ? {
|
|
28
|
+
fixed_candidate_run: { candidate_digest: candidateDigest, ...fixedCandidateRun },
|
|
29
|
+
} : {}),
|
|
30
|
+
};
|
|
31
|
+
const path = reviewRequestPath(projectRoot);
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
34
|
+
return request;
|
|
35
|
+
}
|
|
36
|
+
// The connector-owned runner strategy this branch selected. Reading it out of
|
|
37
|
+
// the envelope is transport, like every other `runner_manifest` access in this
|
|
38
|
+
// module — callers only ever get back the strategy name they dispatch on.
|
|
39
|
+
export function branchRunner(output) {
|
|
40
|
+
const manifest = output.runner_manifest;
|
|
41
|
+
const runner = manifest?.runner;
|
|
42
|
+
if (typeof runner !== 'string' || !runner) {
|
|
43
|
+
throw new Error('The behavioral candidate names no runner strategy to execute.');
|
|
44
|
+
}
|
|
45
|
+
return runner;
|
|
46
|
+
}
|
|
47
|
+
export function suiteCandidateDigest(output) {
|
|
48
|
+
return createHash('sha256')
|
|
49
|
+
.update(stableJson({
|
|
50
|
+
suite_file: output.suite_file,
|
|
51
|
+
runner_manifest: output.runner_manifest,
|
|
52
|
+
test_metadata: output.test_metadata,
|
|
53
|
+
}))
|
|
54
|
+
.digest('hex');
|
|
55
|
+
}
|
|
56
|
+
// Everything the server strips back out before it recomputes the candidate
|
|
57
|
+
// digest. The generator owns none of it, and if it writes any of these keys the
|
|
58
|
+
// two sides hash different objects — which surfaces as "this review is about a
|
|
59
|
+
// different candidate", blaming the reviewer for the generator's mistake. Refuse
|
|
60
|
+
// it here, where the real cause can still be named.
|
|
61
|
+
const POST_CANDIDATE_METADATA_KEYS = [
|
|
62
|
+
'bdd_quality_review',
|
|
63
|
+
'known_defect_probe',
|
|
64
|
+
'known_defect_context',
|
|
65
|
+
'candidate_run',
|
|
66
|
+
'fixed_candidate_run',
|
|
67
|
+
];
|
|
68
|
+
export function readBehavioralReview(projectRoot, output) {
|
|
69
|
+
const metadata = output.test_metadata;
|
|
70
|
+
const embedded = metadata ? POST_CANDIDATE_METADATA_KEYS.filter((key) => key in metadata) : [];
|
|
71
|
+
if (embedded.length > 0) {
|
|
72
|
+
throw new Error(`The behavioral suite generator must not embed its own review (found ${embedded.join(', ')} in test_metadata); run \`unitbob suite-review-prepare\` and provide the separate review artifact.`);
|
|
73
|
+
}
|
|
74
|
+
const path = reviewOutputPath(projectRoot);
|
|
75
|
+
if (!existsSync(path)) {
|
|
76
|
+
throw new Error(`${path} not found — run \`unitbob suite-review-prepare\` and have an independent reviewer write it.`);
|
|
77
|
+
}
|
|
78
|
+
const review = parseJson(readFileSync(path, 'utf8'), path);
|
|
79
|
+
if (!review || review.candidate_digest !== suiteCandidateDigest(output)) {
|
|
80
|
+
throw new Error(`${path} does not review the current behavioral suite candidate.`);
|
|
81
|
+
}
|
|
82
|
+
if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
|
|
83
|
+
throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
|
|
84
|
+
}
|
|
85
|
+
const request = readBehavioralReviewRequest(projectRoot, output);
|
|
86
|
+
return {
|
|
87
|
+
...review,
|
|
88
|
+
candidate_run: request.candidate_run,
|
|
89
|
+
...(request.fixed_candidate_run ? { fixed_candidate_run: request.fixed_candidate_run } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function readBehavioralReviewRequest(projectRoot, output) {
|
|
93
|
+
const path = reviewRequestPath(projectRoot);
|
|
94
|
+
const request = parseJson(readFileSync(path, 'utf8'), path);
|
|
95
|
+
const run = request?.candidate_run;
|
|
96
|
+
const digest = suiteCandidateDigest(output);
|
|
97
|
+
if (!request || request.candidate_digest !== digest || !run || run.candidate_digest !== digest ||
|
|
98
|
+
typeof run.revision !== 'string' || !run.revision || typeof run.run_result !== 'string' || !run.run_result) {
|
|
99
|
+
throw new Error(`${path} has no connector runner evidence for the current behavioral candidate.`);
|
|
100
|
+
}
|
|
101
|
+
const context = request.known_defect_context;
|
|
102
|
+
if (context?.status === 'supplied' && context.fixed_revision) {
|
|
103
|
+
const fixed = request.fixed_candidate_run;
|
|
104
|
+
if (!fixed || fixed.candidate_digest !== digest || fixed.revision !== context.fixed_revision ||
|
|
105
|
+
typeof fixed.run_result !== 'string' || !fixed.run_result) {
|
|
106
|
+
throw new Error(`${path} has no connector runner evidence for fixed revision ${context.fixed_revision}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return request;
|
|
110
|
+
}
|
|
111
|
+
export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }) {
|
|
11
112
|
const request = {
|
|
12
113
|
project_root: projectRoot,
|
|
13
114
|
output_path: outputPath(projectRoot),
|
|
14
115
|
branches,
|
|
116
|
+
known_defect_context: knownDefectContext,
|
|
15
117
|
};
|
|
16
118
|
const path = requestPath(projectRoot);
|
|
17
119
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -30,7 +132,31 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
30
132
|
!Array.isArray(request.branches)) {
|
|
31
133
|
throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
|
|
32
134
|
}
|
|
33
|
-
return
|
|
135
|
+
return {
|
|
136
|
+
...request,
|
|
137
|
+
known_defect_context: readKnownDefectContext(request.known_defect_context, path),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function readKnownDefectContext(value, path) {
|
|
141
|
+
if (value === undefined)
|
|
142
|
+
return { status: 'not_supplied' };
|
|
143
|
+
if (!value || typeof value !== 'object')
|
|
144
|
+
throw new Error(`${path}: known_defect_context must be an object.`);
|
|
145
|
+
const context = value;
|
|
146
|
+
if (context.status === 'not_supplied')
|
|
147
|
+
return { status: 'not_supplied' };
|
|
148
|
+
if (context.status !== 'supplied' || typeof context.defect !== 'string' || !context.defect.trim()) {
|
|
149
|
+
throw new Error(`${path}: supplied known_defect_context must name a defect.`);
|
|
150
|
+
}
|
|
151
|
+
if (context.fixed_revision !== undefined &&
|
|
152
|
+
(typeof context.fixed_revision !== 'string' || !context.fixed_revision.trim())) {
|
|
153
|
+
throw new Error(`${path}: fixed_revision must be a non-empty string.`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
status: 'supplied',
|
|
157
|
+
defect: context.defect,
|
|
158
|
+
...(context.fixed_revision ? { fixed_revision: context.fixed_revision } : {}),
|
|
159
|
+
};
|
|
34
160
|
}
|
|
35
161
|
// Read the host's answers, one per branch. The connector verifies each built
|
|
36
162
|
// branch parses, carries a safe-path artifact envelope under its own root, and
|
|
@@ -110,6 +236,15 @@ function parseJson(raw, path) {
|
|
|
110
236
|
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
111
237
|
}
|
|
112
238
|
}
|
|
239
|
+
function stableJson(value) {
|
|
240
|
+
if (Array.isArray(value))
|
|
241
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
242
|
+
if (value && typeof value === 'object') {
|
|
243
|
+
const object = value;
|
|
244
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
|
|
245
|
+
}
|
|
246
|
+
return JSON.stringify(value) ?? 'null';
|
|
247
|
+
}
|
|
113
248
|
// Turn a branch's assignment packet into its recipe name: structural uses the
|
|
114
249
|
// unit-guardrail recipe, behavioral the Gherkin one.
|
|
115
250
|
export function recipeNameFor(packet) {
|
package/dist/runner/bdd.js
CHANGED
|
@@ -34,20 +34,18 @@ export function runBddSuite(projectRoot, runner, mainPath) {
|
|
|
34
34
|
async function runCucumberRuby(projectRoot) {
|
|
35
35
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
36
36
|
const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
|
|
37
|
-
const localBin = join(projectRoot, 'bin', 'cucumber');
|
|
38
37
|
const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
38
|
+
if (!existsSync(sidecarGemfile)) {
|
|
39
|
+
throw missingRunner('Cucumber');
|
|
40
|
+
}
|
|
41
|
+
const command = 'bundle';
|
|
42
|
+
const args = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
|
|
43
43
|
const env = {
|
|
44
44
|
...process.env,
|
|
45
45
|
RAILS_ENV: 'test',
|
|
46
46
|
UNITBOB_REPO_ROOT: projectRoot,
|
|
47
47
|
};
|
|
48
|
-
|
|
49
|
-
env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
|
|
50
|
-
}
|
|
48
|
+
env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
|
|
51
49
|
const result = await runProcess(command, args, {
|
|
52
50
|
cwd: projectRoot,
|
|
53
51
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
@@ -55,16 +53,20 @@ async function runCucumberRuby(projectRoot) {
|
|
|
55
53
|
});
|
|
56
54
|
return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
|
|
57
55
|
}
|
|
56
|
+
function missingRunner(name) {
|
|
57
|
+
return new Error(`Behavioral runner missing (${name}). Run suite-prepare to provision it under ${BEHAVIORAL_ROOT}/, then run the checks again.`);
|
|
58
|
+
}
|
|
58
59
|
// JS/TS: `@cucumber/cucumber` (cucumber-js) with the message formatter written
|
|
59
60
|
// to a file.
|
|
60
61
|
async function runCucumberJs(projectRoot) {
|
|
61
62
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
62
63
|
const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
|
|
63
64
|
const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
if (!executable(sidecarBin)) {
|
|
66
|
+
throw missingRunner('Cucumber JS');
|
|
67
|
+
}
|
|
68
|
+
const command = sidecarBin;
|
|
66
69
|
const args = [
|
|
67
|
-
...baseArgs,
|
|
68
70
|
features,
|
|
69
71
|
'--require',
|
|
70
72
|
steps,
|
|
@@ -122,11 +124,7 @@ async function pickPython(projectRoot) {
|
|
|
122
124
|
if (executable(sidecarVenvPytest)) {
|
|
123
125
|
return sidecarVenvPytest;
|
|
124
126
|
}
|
|
125
|
-
|
|
126
|
-
cwd: projectRoot,
|
|
127
|
-
timeoutMs: 10_000,
|
|
128
|
-
}).catch(() => ({ stdout: '', stderr: '', code: 1 }));
|
|
129
|
-
return probe.code === 0 ? 'python3' : 'python';
|
|
127
|
+
throw missingRunner('pytest-bdd');
|
|
130
128
|
}
|
|
131
129
|
function executable(path) {
|
|
132
130
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readHostSuiteOutputs, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
1
|
+
import { readBehavioralReview, readHostSuiteOutputs, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { Wire } from "../wire.js";
|
|
3
3
|
// Read the task and the host's answers, verify each branch parses and carries a
|
|
4
4
|
// safe-path artifact envelope, then upload both peer branches in one batch
|
|
@@ -20,13 +20,36 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
20
20
|
if (output.build_error) {
|
|
21
21
|
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
22
22
|
}
|
|
23
|
+
let testMetadata = output.test_metadata;
|
|
24
|
+
if (output.suite_kind === 'behavioral') {
|
|
25
|
+
const review = readBehavioralReview(config.projectRoot, output);
|
|
26
|
+
const probe = review.known_defect_probe;
|
|
27
|
+
const qualityReview = review.bdd_quality_review;
|
|
28
|
+
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
29
|
+
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
30
|
+
}
|
|
31
|
+
if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
|
|
32
|
+
throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
|
|
33
|
+
}
|
|
34
|
+
testMetadata = {
|
|
35
|
+
...output.test_metadata,
|
|
36
|
+
bdd_quality_review: {
|
|
37
|
+
...qualityReview,
|
|
38
|
+
candidate_digest: review.candidate_digest,
|
|
39
|
+
},
|
|
40
|
+
known_defect_probe: review.known_defect_probe,
|
|
41
|
+
known_defect_context: request.known_defect_context,
|
|
42
|
+
candidate_run: review.candidate_run,
|
|
43
|
+
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
23
46
|
return {
|
|
24
47
|
suite_kind: output.suite_kind,
|
|
25
48
|
source_digest: sourceDigest,
|
|
26
49
|
artifacts: {
|
|
27
50
|
suite_file: output.suite_file,
|
|
28
51
|
runner_manifest: output.runner_manifest,
|
|
29
|
-
test_metadata:
|
|
52
|
+
test_metadata: testMetadata,
|
|
30
53
|
},
|
|
31
54
|
};
|
|
32
55
|
});
|
package/dist/verbs/run.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function run(config, _args, deps) {
|
|
|
18
18
|
suite_file: { path: item.suite_file.path, content: item.suite_file.content },
|
|
19
19
|
runner_manifest: item.runner_manifest,
|
|
20
20
|
}),
|
|
21
|
-
materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file).mainPath,
|
|
21
|
+
materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file, item.runner_manifest.runner).mainPath,
|
|
22
22
|
runStructural: runStructuralByRunner,
|
|
23
23
|
runBehavioral: runBddSuite,
|
|
24
24
|
validateStack,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { materializeHelper } from "../files/guardrails.js";
|
|
4
|
-
import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
4
|
+
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
5
|
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
6
6
|
import { ensureRunner } from "../runner/provision.js";
|
|
7
7
|
import { Wire } from "../wire.js";
|
|
@@ -13,7 +13,8 @@ import { Wire } from "../wire.js";
|
|
|
13
13
|
// unsupported project stops with one actionable message and writes nothing; a
|
|
14
14
|
// no-current-map error from the server surfaces (via WireError) with guidance to
|
|
15
15
|
// rebuild the map first.
|
|
16
|
-
export async function suitePrepare(config,
|
|
16
|
+
export async function suitePrepare(config, args = [], deps) {
|
|
17
|
+
const defectContext = knownDefectContext(args);
|
|
17
18
|
const wire = new Wire(config);
|
|
18
19
|
const actual = {
|
|
19
20
|
getRecipe: (name) => wire.getRecipe(name),
|
|
@@ -55,12 +56,15 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
55
56
|
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
56
57
|
assignment: packet.assignment,
|
|
57
58
|
})));
|
|
58
|
-
const request = writeSuiteBuildRequest(config.projectRoot, branches);
|
|
59
|
+
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
59
60
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
61
|
+
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
62
|
+
? '`unitbob suite-review-prepare` before upload'
|
|
63
|
+
: '`unitbob put-suite-build`';
|
|
60
64
|
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
61
65
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
62
|
-
`write your answer to ${request.output_path} as a branches array, run each locally
|
|
63
|
-
|
|
66
|
+
`write your answer to ${request.output_path} as a branches array, run each locally, repair broken harness steps while application failures remain red, ` +
|
|
67
|
+
`then run ${nextCommand}.\n`);
|
|
64
68
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
65
69
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
66
70
|
if (fixableNotices.length > 0) {
|
|
@@ -70,6 +74,32 @@ export async function suitePrepare(config, _args = [], deps) {
|
|
|
70
74
|
'\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
|
|
71
75
|
}
|
|
72
76
|
}
|
|
77
|
+
function knownDefectContext(args) {
|
|
78
|
+
const defect = option(args, '--known-defect=');
|
|
79
|
+
const fixedRevision = option(args, '--fixed-revision=');
|
|
80
|
+
const explicitlyAbsent = args.includes('--no-known-defect');
|
|
81
|
+
if ((defect && explicitlyAbsent) || (!defect && !explicitlyAbsent)) {
|
|
82
|
+
throw new Error('Choose exactly one of --known-defect or --no-known-defect (use --known-defect=<description>).');
|
|
83
|
+
}
|
|
84
|
+
if (fixedRevision && !defect)
|
|
85
|
+
throw new Error('--fixed-revision requires --known-defect.');
|
|
86
|
+
if (explicitlyAbsent)
|
|
87
|
+
return { status: 'not_supplied' };
|
|
88
|
+
if (!defect)
|
|
89
|
+
throw new Error('--known-defect requires a value.');
|
|
90
|
+
return {
|
|
91
|
+
status: 'supplied',
|
|
92
|
+
defect,
|
|
93
|
+
...(fixedRevision ? { fixed_revision: fixedRevision } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function option(args, prefix) {
|
|
97
|
+
const match = args.find((arg) => arg.startsWith(prefix));
|
|
98
|
+
const value = match?.slice(prefix.length).trim();
|
|
99
|
+
if (match && !value)
|
|
100
|
+
throw new Error(`${prefix.slice(0, -1)} requires a value.`);
|
|
101
|
+
return value || undefined;
|
|
102
|
+
}
|
|
73
103
|
function inferBddRunner(projectRoot) {
|
|
74
104
|
if (existsSync(join(projectRoot, 'package.json')))
|
|
75
105
|
return 'cucumber-js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { copyBehavioralRunnerEnvironment, materializeBehavioral } from "../files/behavioral.js";
|
|
6
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
7
|
+
import { boundReport } from "../runner/boundReport.js";
|
|
8
|
+
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
|
+
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
10
|
+
const actual = {
|
|
11
|
+
runCandidate: runCandidate,
|
|
12
|
+
stdout: process.stdout,
|
|
13
|
+
...deps,
|
|
14
|
+
};
|
|
15
|
+
const buildRequest = readSuiteBuildRequest(config.projectRoot);
|
|
16
|
+
const behavioral = readHostSuiteOutputs(buildRequest.output_path, buildRequest)
|
|
17
|
+
.find((branch) => branch.suite_kind === 'behavioral');
|
|
18
|
+
if (!behavioral)
|
|
19
|
+
throw new Error('The suite build has no behavioral candidate to review.');
|
|
20
|
+
if (behavioral.build_error) {
|
|
21
|
+
throw new Error(`The behavioral candidate could not be reviewed: ${behavioral.build_error.message}`);
|
|
22
|
+
}
|
|
23
|
+
const candidateRun = await actual.runCandidate(config.projectRoot, behavioral);
|
|
24
|
+
const fixedRevision = buildRequest.known_defect_context.status === 'supplied'
|
|
25
|
+
? buildRequest.known_defect_context.fixed_revision
|
|
26
|
+
: undefined;
|
|
27
|
+
const fixedCandidateRun = fixedRevision
|
|
28
|
+
? await actual.runCandidate(config.projectRoot, behavioral, fixedRevision)
|
|
29
|
+
: undefined;
|
|
30
|
+
const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
|
|
31
|
+
actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
|
|
32
|
+
actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
|
|
33
|
+
}
|
|
34
|
+
async function runCandidate(projectRoot, output, revision) {
|
|
35
|
+
if (revision)
|
|
36
|
+
return runCandidateAtRevision(projectRoot, output, revision);
|
|
37
|
+
return runCandidateInProject(projectRoot, output, gitRevision(projectRoot));
|
|
38
|
+
}
|
|
39
|
+
async function runCandidateInProject(projectRoot, output, revision) {
|
|
40
|
+
const runner = branchRunner(output);
|
|
41
|
+
const suiteFile = output.suite_file;
|
|
42
|
+
const mainPath = materializeBehavioral(projectRoot, suiteFile, runner).mainPath;
|
|
43
|
+
const result = await runBddSuite(projectRoot, runner, mainPath);
|
|
44
|
+
const report = boundReport(runner, result);
|
|
45
|
+
if (report === null)
|
|
46
|
+
throw new Error('The behavioral candidate produced no machine-readable runner report.');
|
|
47
|
+
return { revision, run_result: report };
|
|
48
|
+
}
|
|
49
|
+
async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
50
|
+
const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
|
|
51
|
+
cwd: projectRoot,
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
}).trim();
|
|
54
|
+
const worktree = mkdtempSync(join(tmpdir(), 'unitbob-fixed-review-'));
|
|
55
|
+
let added = false;
|
|
56
|
+
try {
|
|
57
|
+
execFileSync('git', ['worktree', 'add', '--detach', worktree, resolved], { cwd: projectRoot, stdio: 'pipe' });
|
|
58
|
+
added = true;
|
|
59
|
+
const runner = branchRunner(output);
|
|
60
|
+
materializeBehavioral(worktree, output.suite_file, runner);
|
|
61
|
+
copyBehavioralRunnerEnvironment(projectRoot, worktree, runner);
|
|
62
|
+
return await runCandidateInProject(worktree, output, revision);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (added) {
|
|
66
|
+
try {
|
|
67
|
+
execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: projectRoot, stdio: 'pipe' });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// The temporary directory cleanup below is still safe and bounded.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
74
|
+
if (added) {
|
|
75
|
+
try {
|
|
76
|
+
// `remove` can fail while the directory still goes away just above,
|
|
77
|
+
// which leaves a dangling entry under .git/worktrees in the user's own
|
|
78
|
+
// checkout. Reviewing a suite must not litter the repository it reads.
|
|
79
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: projectRoot, stdio: 'pipe' });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Housekeeping only — never fail a finished review run over it.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function gitRevision(projectRoot) {
|
|
88
|
+
try {
|
|
89
|
+
const options = {
|
|
90
|
+
cwd: projectRoot,
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
};
|
|
94
|
+
const head = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
|
|
95
|
+
const dirty = execFileSync('git', ['status', '--porcelain'], options).trim();
|
|
96
|
+
return dirty ? `${head}-dirty` : head;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 'working-tree';
|
|
100
|
+
}
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"unitbob": "dist/
|
|
7
|
+
"unitbob": "dist/bin.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=18"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/
|
|
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
19
|
},
|