unitbob 0.1.11 → 0.2.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 +7 -1
- package/dist/files/artifactPath.js +19 -0
- package/dist/files/behavioral.js +29 -0
- package/dist/files/guardrails.js +6 -4
- package/dist/files/suiteBuild.js +65 -57
- package/dist/runner/bdd.js +120 -0
- package/dist/runner/boundReport.js +107 -0
- package/dist/runner/precheck.js +60 -1
- package/dist/runner/pytestBddPlugin.js +62 -0
- package/dist/verbs/contractPrompt.js +25 -0
- package/dist/verbs/putSuiteBuild.js +42 -28
- package/dist/verbs/run.js +71 -144
- package/dist/verbs/suitePrepare.js +25 -20
- package/dist/wire.js +58 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { putMapBuild } from "./verbs/putMapBuild.js";
|
|
|
12
12
|
import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
13
13
|
import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
14
14
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
15
|
+
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
15
16
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
16
17
|
|
|
17
18
|
Usage: unitbob <verb> [args]
|
|
@@ -25,7 +26,9 @@ Verbs:
|
|
|
25
26
|
suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
|
|
26
27
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
|
|
27
28
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
28
|
-
|
|
29
|
+
contract-prompt <digest> <test_id> [fix|accept]
|
|
30
|
+
Internal: fetch the fix/accept brief for one red check on either map.
|
|
31
|
+
check Run every Unitbob contract suite locally and report.
|
|
29
32
|
run Alias for check.
|
|
30
33
|
|
|
31
34
|
Pipeline: map and suite are built on your machine. \`*-prepare\` writes a request
|
|
@@ -69,6 +72,9 @@ async function main(argv) {
|
|
|
69
72
|
case 'fix-prepare':
|
|
70
73
|
await fixPrepare(await ensureLinked(), args);
|
|
71
74
|
return 0;
|
|
75
|
+
case 'contract-prompt':
|
|
76
|
+
await contractPrompt(await ensureLinked(), args);
|
|
77
|
+
return 0;
|
|
72
78
|
case 'run':
|
|
73
79
|
case 'check':
|
|
74
80
|
await run(await ensureLinked(), args);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
// The one rule for whether a host-provided suite file path is safe to write:
|
|
3
|
+
// relative, anchored under the given `.unitbob/<kind>/` root, no traversal.
|
|
4
|
+
// Both contract systems (spec 32) share it — structural under
|
|
5
|
+
// `.unitbob/structural/`, behavioral under `.unitbob/behavioral/`. It mirrors
|
|
6
|
+
// the Rails-side check exactly (any `.`/`..` segment is refused outright, not
|
|
7
|
+
// resolved), so a path the brain would refuse can never materialize here.
|
|
8
|
+
export function assertUnitbobPath(path, root) {
|
|
9
|
+
// The root may arrive with or without a trailing slash (the wire's path_root
|
|
10
|
+
// carries one, the local constants do not) — normalize to exactly one.
|
|
11
|
+
const prefix = `${root.replace(/\/+$/, '')}/`;
|
|
12
|
+
const unsafe = !path ||
|
|
13
|
+
isAbsolute(path) ||
|
|
14
|
+
!path.startsWith(prefix) ||
|
|
15
|
+
path.split('/').some((segment) => segment === '.' || segment === '..');
|
|
16
|
+
if (unsafe) {
|
|
17
|
+
throw new Error(`suite file path must be a relative path under ${prefix} with no traversal (got "${path}").`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
|
+
// The behavioral suite lives under its own root: the main `.feature` plus its
|
|
5
|
+
// step definitions and any helper files, all under `.unitbob/behavioral/`
|
|
6
|
+
// (spec 32). Nothing here is ever written into the project's own `spec/`,
|
|
7
|
+
// `features/`, or `tests/`. Like the structural flow, the host LLM writes and
|
|
8
|
+
// runs the suite to green before upload; the connector materializes the stored
|
|
9
|
+
// blob only so `check` can execute it locally.
|
|
10
|
+
export const BEHAVIORAL_DIR = '.unitbob/behavioral';
|
|
11
|
+
// Write a behavioral artifact envelope (main file + support files) under the
|
|
12
|
+
// behavioral root, after checking every path is safe. The root is wiped first
|
|
13
|
+
// so a stale file from a previous version never lingers. Returns the absolute
|
|
14
|
+
// path of the materialized main file.
|
|
15
|
+
export function materializeBehavioral(projectRoot, artifact) {
|
|
16
|
+
const files = [artifact, ...(artifact.support_files ?? [])];
|
|
17
|
+
for (const file of files)
|
|
18
|
+
assertUnitbobPath(file.path, BEHAVIORAL_DIR);
|
|
19
|
+
rmSync(join(projectRoot, BEHAVIORAL_DIR), { recursive: true, force: true });
|
|
20
|
+
let mainPath = '';
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const dest = join(projectRoot, file.path);
|
|
23
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
24
|
+
writeFileSync(dest, file.content);
|
|
25
|
+
if (file === artifact)
|
|
26
|
+
mainPath = dest;
|
|
27
|
+
}
|
|
28
|
+
return { mainPath };
|
|
29
|
+
}
|
package/dist/files/guardrails.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The structural suite lives under its own root now that behavioral is its peer
|
|
4
|
+
// (spec 32) — `.unitbob/structural/`, matching the Rails StructuralSuiteVersion
|
|
5
|
+
// path root. A forward-slash literal, not path.join: suite paths always arrive
|
|
6
|
+
// over the wire with '/', while path.join would render this '.unitbob\structural'
|
|
7
|
+
// on Windows and make assertGuardrailPath reject every valid path there. node's
|
|
6
8
|
// path.join still accepts a '/'-joined segment as input on every platform, so
|
|
7
9
|
// filesystem builds below are unaffected.
|
|
8
|
-
export const GUARDRAILS_DIR = '.unitbob/
|
|
10
|
+
export const GUARDRAILS_DIR = '.unitbob/structural';
|
|
9
11
|
export const HELPER_FILE = 'unitbob_helper.rb';
|
|
10
12
|
// An always-empty custom options file: pointing rspec's --options here keeps
|
|
11
13
|
// the project's own .rspec (stray --require lines, extra stdout formatters)
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
4
|
export function requestPath(projectRoot) {
|
|
5
5
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
6
6
|
}
|
|
7
7
|
export function outputPath(projectRoot) {
|
|
8
8
|
return join(projectRoot, '.unitbob', 'suite-build', 'suite_output.json');
|
|
9
9
|
}
|
|
10
|
-
export function writeSuiteBuildRequest(projectRoot,
|
|
10
|
+
export function writeSuiteBuildRequest(projectRoot, branches) {
|
|
11
11
|
const request = {
|
|
12
12
|
project_root: projectRoot,
|
|
13
|
-
map_digest: task.map_digest,
|
|
14
13
|
output_path: outputPath(projectRoot),
|
|
15
|
-
|
|
16
|
-
blocks: task.blocks,
|
|
14
|
+
branches,
|
|
17
15
|
};
|
|
18
16
|
const path = requestPath(projectRoot);
|
|
19
17
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -26,62 +24,83 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
26
24
|
throw new Error(`${path} not found — run \`npx unitbob suite-prepare\` first.`);
|
|
27
25
|
}
|
|
28
26
|
const request = parseJson(readFileSync(path, 'utf8'), path);
|
|
29
|
-
if (!
|
|
30
|
-
|
|
27
|
+
if (!request ||
|
|
28
|
+
typeof request.project_root !== 'string' ||
|
|
29
|
+
typeof request.output_path !== 'string' ||
|
|
30
|
+
!Array.isArray(request.branches)) {
|
|
31
|
+
throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
|
|
31
32
|
}
|
|
32
33
|
return request;
|
|
33
34
|
}
|
|
34
|
-
// Read
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
export function
|
|
35
|
+
// Read the host's answers, one per branch. The connector verifies each built
|
|
36
|
+
// branch parses, carries a safe-path artifact envelope under its own root, and
|
|
37
|
+
// has a runner_manifest and test_metadata. A branch may instead carry a
|
|
38
|
+
// build_error, which the connector relays. Anything unparseable throws and
|
|
39
|
+
// nothing is uploaded.
|
|
40
|
+
export function readHostSuiteOutputs(path, request) {
|
|
40
41
|
if (!existsSync(path)) {
|
|
41
42
|
throw new Error(`${path} not found — the host suite builder did not write its output.`);
|
|
42
43
|
}
|
|
43
44
|
const parsed = parseJson(readFileSync(path, 'utf8'), path);
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
const branches = parsed && Array.isArray(parsed.branches) ? parsed.branches : null;
|
|
46
|
+
if (!branches) {
|
|
47
|
+
throw new Error(`${path} is malformed: expected a branches array, one entry per contract system.`);
|
|
46
48
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
const rootFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root]));
|
|
50
|
+
return branches.map((entry) => readBranch(entry, rootFor, path));
|
|
51
|
+
}
|
|
52
|
+
function readBranch(entry, rootFor, path) {
|
|
53
|
+
if (!entry || typeof entry !== 'object') {
|
|
54
|
+
throw new Error(`${path} is malformed: each branch must be an object.`);
|
|
55
|
+
}
|
|
56
|
+
const branch = entry;
|
|
57
|
+
const suiteKind = String(branch.suite_kind ?? '');
|
|
58
|
+
const root = rootFor.get(suiteKind);
|
|
59
|
+
if (!root) {
|
|
60
|
+
throw new Error(`${path}: unknown suite_kind "${suiteKind}" — it was not in the build request.`);
|
|
61
|
+
}
|
|
62
|
+
if ('spec_rb' in branch || 'spec_rb_path' in branch) {
|
|
63
|
+
throw new Error(`${path}: the ${suiteKind} branch uses the legacy spec_rb shape — emit suite_file instead.`);
|
|
64
|
+
}
|
|
65
|
+
if (branch.build_error && typeof branch.build_error === 'object') {
|
|
66
|
+
const message = String(branch.build_error.message ?? 'the host could not build this suite');
|
|
67
|
+
return { suite_kind: suiteKind, build_error: { message } };
|
|
49
68
|
}
|
|
50
|
-
if (!('test_metadata' in
|
|
51
|
-
throw new Error(`${path} is
|
|
69
|
+
if (!('test_metadata' in branch)) {
|
|
70
|
+
throw new Error(`${path}: the ${suiteKind} branch is missing test_metadata.`);
|
|
52
71
|
}
|
|
53
|
-
const manifest =
|
|
72
|
+
const manifest = branch.runner_manifest;
|
|
54
73
|
if (!manifest || typeof manifest !== 'object') {
|
|
55
|
-
throw new Error(`${path} is
|
|
74
|
+
throw new Error(`${path}: the ${suiteKind} branch is missing runner_manifest.`);
|
|
56
75
|
}
|
|
57
76
|
return {
|
|
58
|
-
|
|
77
|
+
suite_kind: suiteKind,
|
|
78
|
+
suite_file: resolveSuiteFile(branch.suite_file, root, path, suiteKind),
|
|
59
79
|
runner_manifest: manifest,
|
|
60
|
-
test_metadata:
|
|
80
|
+
test_metadata: branch.test_metadata,
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
|
-
// The host
|
|
64
|
-
//
|
|
65
|
-
function resolveSuiteFile(
|
|
66
|
-
const file = parsed.suite_file;
|
|
83
|
+
// The host inlines every file's `content`; each path is checked safe under this
|
|
84
|
+
// branch's root before anything is accepted.
|
|
85
|
+
function resolveSuiteFile(file, root, path, suiteKind) {
|
|
67
86
|
if (!file || typeof file !== 'object') {
|
|
68
|
-
throw new Error(`${path}
|
|
87
|
+
throw new Error(`${path}: the ${suiteKind} branch is missing suite_file.`);
|
|
69
88
|
}
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
throw new Error(`${path}: suite_file content at "${suitePath}" is empty.`);
|
|
89
|
+
const envelope = file;
|
|
90
|
+
const main = readOneFile(envelope, root, path, suiteKind, false);
|
|
91
|
+
const support = Array.isArray(envelope.support_files)
|
|
92
|
+
? envelope.support_files.map((entry) => readOneFile(entry, root, path, suiteKind, true))
|
|
93
|
+
: [];
|
|
94
|
+
return support.length > 0 ? { ...main, support_files: support } : main;
|
|
95
|
+
}
|
|
96
|
+
function readOneFile(file, root, path, suiteKind, support) {
|
|
97
|
+
const filePath = typeof file.path === 'string' ? file.path : '';
|
|
98
|
+
assertUnitbobPath(filePath, root);
|
|
99
|
+
if (typeof file.content === 'string' && file.content.trim()) {
|
|
100
|
+
return { path: filePath, content: file.content };
|
|
83
101
|
}
|
|
84
|
-
|
|
102
|
+
const label = support ? 'support file' : 'suite_file';
|
|
103
|
+
throw new Error(`${path}: the ${suiteKind} ${label} at "${filePath}" has no inline content.`);
|
|
85
104
|
}
|
|
86
105
|
function parseJson(raw, path) {
|
|
87
106
|
try {
|
|
@@ -91,19 +110,8 @@ function parseJson(raw, path) {
|
|
|
91
110
|
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return (typeof request.project_root === 'string' &&
|
|
99
|
-
typeof request.map_digest === 'string' &&
|
|
100
|
-
typeof request.output_path === 'string' &&
|
|
101
|
-
isRecipe(request.recipe) &&
|
|
102
|
-
Array.isArray(request.blocks));
|
|
103
|
-
}
|
|
104
|
-
function isRecipe(value) {
|
|
105
|
-
if (!value || typeof value !== 'object')
|
|
106
|
-
return false;
|
|
107
|
-
const recipe = value;
|
|
108
|
-
return typeof recipe.name === 'string' && typeof recipe.version === 'string' && typeof recipe.text === 'string';
|
|
113
|
+
// Turn a branch's assignment packet into its recipe name: structural uses the
|
|
114
|
+
// unit-guardrail recipe, behavioral the Gherkin one.
|
|
115
|
+
export function recipeNameFor(packet) {
|
|
116
|
+
return packet.suite_kind === 'behavioral' ? 'generate_behavioral' : 'generate';
|
|
109
117
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
import { readReport } from "./types.js";
|
|
5
|
+
import { PYTEST_BDD_PLUGIN } from "./pytestBddPlugin.js";
|
|
6
|
+
export const BDD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
+
// The behavioral suite lives under one root; the report is written inside it so
|
|
8
|
+
// the app under test cannot pollute it and it travels with the suite.
|
|
9
|
+
const BEHAVIORAL_ROOT = '.unitbob/behavioral';
|
|
10
|
+
const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, 'cucumber_messages.ndjson');
|
|
11
|
+
const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, 'pytest_bdd_report.json');
|
|
12
|
+
const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, 'unitbob_pytest_bdd_plugin.py');
|
|
13
|
+
const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, 'pytest.ini');
|
|
14
|
+
const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
15
|
+
// The connector-owned BDD strategy table (spec 32): the `runner` enum names one
|
|
16
|
+
// of these; the connector never executes a host-provided command string. Each
|
|
17
|
+
// strategy runs the whole behavioral bundle and returns the raw machine-readable
|
|
18
|
+
// report verbatim — the connector does no marker join and no aggregation.
|
|
19
|
+
export function runBddSuite(projectRoot, runner, mainPath) {
|
|
20
|
+
switch (runner) {
|
|
21
|
+
case 'cucumber':
|
|
22
|
+
return runCucumberRuby(projectRoot);
|
|
23
|
+
case 'cucumber-js':
|
|
24
|
+
return runCucumberJs(projectRoot);
|
|
25
|
+
case 'pytest-bdd':
|
|
26
|
+
return runPytestBdd(projectRoot, mainPath);
|
|
27
|
+
default:
|
|
28
|
+
return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Ruby: `cucumber` with the built-in message formatter. The features and step
|
|
32
|
+
// definitions both live under the behavioral root; --require points at the step
|
|
33
|
+
// definitions so only the Unitbob bundle loads.
|
|
34
|
+
async function runCucumberRuby(projectRoot) {
|
|
35
|
+
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
36
|
+
const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
|
|
37
|
+
const localBin = join(projectRoot, 'bin', 'cucumber');
|
|
38
|
+
const command = executable(localBin) ? localBin : 'bundle';
|
|
39
|
+
const base = executable(localBin) ? [] : ['exec', 'cucumber'];
|
|
40
|
+
const args = [...base, features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
|
|
41
|
+
const result = await runProcess(command, args, {
|
|
42
|
+
cwd: projectRoot,
|
|
43
|
+
timeoutMs: BDD_TIMEOUT_MS,
|
|
44
|
+
env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
45
|
+
});
|
|
46
|
+
return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
|
|
47
|
+
}
|
|
48
|
+
// JS/TS: `@cucumber/cucumber` (cucumber-js) with the message formatter written
|
|
49
|
+
// to a file.
|
|
50
|
+
async function runCucumberJs(projectRoot) {
|
|
51
|
+
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
52
|
+
const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
|
|
53
|
+
const command = 'npx';
|
|
54
|
+
const args = [
|
|
55
|
+
'cucumber-js',
|
|
56
|
+
features,
|
|
57
|
+
'--require',
|
|
58
|
+
steps,
|
|
59
|
+
'--format',
|
|
60
|
+
`message:${CUCUMBER_REPORT}`,
|
|
61
|
+
];
|
|
62
|
+
const result = await runProcess(command, args, {
|
|
63
|
+
cwd: projectRoot,
|
|
64
|
+
timeoutMs: BDD_TIMEOUT_MS,
|
|
65
|
+
env: { ...process.env, NODE_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
66
|
+
});
|
|
67
|
+
return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
|
|
68
|
+
}
|
|
69
|
+
// Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
|
|
70
|
+
// plugin writes the JSON report; `-c` isolates the run from the project's own
|
|
71
|
+
// addopts. The runner command is connector-owned.
|
|
72
|
+
async function runPytestBdd(projectRoot, mainPath) {
|
|
73
|
+
mkdirSync(join(projectRoot, BEHAVIORAL_ROOT), { recursive: true });
|
|
74
|
+
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
75
|
+
writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
|
|
76
|
+
const command = await pickPython(projectRoot);
|
|
77
|
+
const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
|
|
78
|
+
const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p',
|
|
79
|
+
pluginModule(), stepsDir, '--rootdir', projectRoot];
|
|
80
|
+
const result = await runProcess(command, args, {
|
|
81
|
+
cwd: projectRoot,
|
|
82
|
+
timeoutMs: BDD_TIMEOUT_MS,
|
|
83
|
+
env: {
|
|
84
|
+
...process.env,
|
|
85
|
+
UNITBOB_REPO_ROOT: projectRoot,
|
|
86
|
+
UNITBOB_PYTEST_BDD_REPORT: join(projectRoot, PYTEST_BDD_REPORT),
|
|
87
|
+
PYTHONPATH: [join(projectRoot, BEHAVIORAL_ROOT), process.env.PYTHONPATH ?? ''].filter(Boolean).join(':'),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
return finalize(result, command, args, projectRoot, PYTEST_BDD_REPORT);
|
|
91
|
+
// mainPath is accepted for symmetry with the structural runners; pytest-bdd
|
|
92
|
+
// discovers scenarios from the step-definition modules, not the .feature path.
|
|
93
|
+
}
|
|
94
|
+
function pluginModule() {
|
|
95
|
+
return 'unitbob_pytest_bdd_plugin';
|
|
96
|
+
}
|
|
97
|
+
function finalize(result, command, args, projectRoot, reportRel) {
|
|
98
|
+
return {
|
|
99
|
+
...result,
|
|
100
|
+
command,
|
|
101
|
+
args,
|
|
102
|
+
resultPath: reportRel,
|
|
103
|
+
report: readReport(join(projectRoot, reportRel)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function pickPython(projectRoot) {
|
|
107
|
+
const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
|
|
108
|
+
cwd: projectRoot,
|
|
109
|
+
timeoutMs: 10_000,
|
|
110
|
+
}).catch(() => ({ stdout: '', stderr: '', code: 1 }));
|
|
111
|
+
return probe.code === 0 ? 'python3' : 'python';
|
|
112
|
+
}
|
|
113
|
+
function executable(path) {
|
|
114
|
+
try {
|
|
115
|
+
return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Size-bound the raw machine-readable report before transport (spec 26/30/32).
|
|
2
|
+
// This is a size limit only, not a privacy filter: application values in failure
|
|
3
|
+
// messages are accepted, not scrubbed. `null` means "no usable report" — the
|
|
4
|
+
// caller takes the structured suite-error path.
|
|
5
|
+
//
|
|
6
|
+
// Structural JSON/XML reports are parsed only to bound per-failure text, then
|
|
7
|
+
// re-serialized. Behavioral reports (Cucumber Messages NDJSON, pytest-bdd JSON)
|
|
8
|
+
// are bounded by whole-document size and shipped verbatim — Rails owns every bit
|
|
9
|
+
// of their interpretation.
|
|
10
|
+
const MAX_FAILURE_CHARS = 8000;
|
|
11
|
+
const MAX_REPORT_CHARS = 4_000_000;
|
|
12
|
+
export function boundReport(runner, result) {
|
|
13
|
+
switch (runner) {
|
|
14
|
+
case 'pytest':
|
|
15
|
+
return result.report.trim() ? boundJunitXml(result.report) : null;
|
|
16
|
+
case 'rspec':
|
|
17
|
+
case 'vitest': {
|
|
18
|
+
const parsed = parseJsonObject(result.report) ?? (runner === 'rspec' ? parseJsonObject(result.stdout) : null);
|
|
19
|
+
if (!parsed)
|
|
20
|
+
return null;
|
|
21
|
+
const bounded = runner === 'rspec' ? boundRspecFailures(parsed) : boundVitestFailures(parsed);
|
|
22
|
+
return JSON.stringify(bounded);
|
|
23
|
+
}
|
|
24
|
+
case 'cucumber':
|
|
25
|
+
case 'cucumber-js':
|
|
26
|
+
case 'pytest-bdd':
|
|
27
|
+
return boundWhole(result.report);
|
|
28
|
+
default:
|
|
29
|
+
return boundWhole(result.report);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// A behavioral report is shipped whole. An empty one is "no usable report" so
|
|
33
|
+
// the caller reports a suite error rather than an empty green.
|
|
34
|
+
function boundWhole(report) {
|
|
35
|
+
if (!report.trim())
|
|
36
|
+
return null;
|
|
37
|
+
return report.length > MAX_REPORT_CHARS ? report.slice(0, MAX_REPORT_CHARS) : report;
|
|
38
|
+
}
|
|
39
|
+
function boundRspecFailures(rspecJson) {
|
|
40
|
+
const examples = rspecJson.examples;
|
|
41
|
+
if (!Array.isArray(examples))
|
|
42
|
+
return rspecJson;
|
|
43
|
+
const bounded = examples.map((example) => {
|
|
44
|
+
if (!example || typeof example !== 'object')
|
|
45
|
+
return example;
|
|
46
|
+
const ex = example;
|
|
47
|
+
const exception = ex.exception;
|
|
48
|
+
if (!exception || typeof exception !== 'object')
|
|
49
|
+
return ex;
|
|
50
|
+
const exc = exception;
|
|
51
|
+
const next = { ...exc };
|
|
52
|
+
if (typeof exc.message === 'string')
|
|
53
|
+
next.message = truncate(exc.message);
|
|
54
|
+
if (Array.isArray(exc.backtrace))
|
|
55
|
+
next.backtrace = exc.backtrace.slice(0, 20);
|
|
56
|
+
return { ...ex, exception: next };
|
|
57
|
+
});
|
|
58
|
+
return { ...rspecJson, examples: bounded };
|
|
59
|
+
}
|
|
60
|
+
function boundVitestFailures(vitestJson) {
|
|
61
|
+
const testResults = vitestJson.testResults;
|
|
62
|
+
if (!Array.isArray(testResults))
|
|
63
|
+
return vitestJson;
|
|
64
|
+
const bounded = testResults.map((fileResult) => {
|
|
65
|
+
if (!fileResult || typeof fileResult !== 'object')
|
|
66
|
+
return fileResult;
|
|
67
|
+
const file = fileResult;
|
|
68
|
+
const assertions = file.assertionResults;
|
|
69
|
+
if (!Array.isArray(assertions))
|
|
70
|
+
return file;
|
|
71
|
+
const next = assertions.map((assertion) => {
|
|
72
|
+
if (!assertion || typeof assertion !== 'object')
|
|
73
|
+
return assertion;
|
|
74
|
+
const a = assertion;
|
|
75
|
+
if (!Array.isArray(a.failureMessages))
|
|
76
|
+
return a;
|
|
77
|
+
return { ...a, failureMessages: a.failureMessages.map((m) => (typeof m === 'string' ? truncate(m) : m)) };
|
|
78
|
+
});
|
|
79
|
+
return { ...file, assertionResults: next };
|
|
80
|
+
});
|
|
81
|
+
return { ...vitestJson, testResults: bounded };
|
|
82
|
+
}
|
|
83
|
+
function truncate(text) {
|
|
84
|
+
return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
|
|
85
|
+
}
|
|
86
|
+
function boundJunitXml(xml) {
|
|
87
|
+
const boundedBodies = xml.replace(/(<(failure|error|skipped|system-out|system-err)\b[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, open, _tag, body, close) => `${boundXmlMessageAttr(open)}${boundXmlText(body)}${close}`);
|
|
88
|
+
return boundedBodies.replace(/<(failure|error|skipped)\b[^>]*\/>/g, (tag) => boundXmlMessageAttr(tag));
|
|
89
|
+
}
|
|
90
|
+
function boundXmlMessageAttr(tag) {
|
|
91
|
+
return tag.replace(/(\bmessage=")([\s\S]*?)(")/, (_m, pre, value, post) => value.length > MAX_FAILURE_CHARS ? `${pre}${boundXmlText(value)}… (truncated)${post}` : `${pre}${value}${post}`);
|
|
92
|
+
}
|
|
93
|
+
function boundXmlText(text) {
|
|
94
|
+
return text.length > MAX_FAILURE_CHARS ? `${truncateXml(text)}… (truncated)` : text;
|
|
95
|
+
}
|
|
96
|
+
function truncateXml(text) {
|
|
97
|
+
return text.slice(0, MAX_FAILURE_CHARS).replace(/&[^;]*$/, '');
|
|
98
|
+
}
|
|
99
|
+
function parseJsonObject(text) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(text);
|
|
102
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/runner/precheck.js
CHANGED
|
@@ -17,6 +17,14 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
|
17
17
|
}
|
|
18
18
|
// Confirm the host-selected runner against local markers. A mismatch fails
|
|
19
19
|
// closed: the caller writes no files and uploads nothing.
|
|
20
|
+
//
|
|
21
|
+
// Both contract systems route through here (spec 32). The structural runners
|
|
22
|
+
// (rspec/vitest/pytest) confirm the exact test framework is present, because the
|
|
23
|
+
// host writes and runs against it. The behavioral BDD runners
|
|
24
|
+
// (cucumber/cucumber-js/pytest-bdd) confirm only the base language: `check` never
|
|
25
|
+
// installs anything, so a missing BDD runner is left to surface as a suite error
|
|
26
|
+
// from the actual run — the precheck just replaces "bundle: command not found"
|
|
27
|
+
// with an early, clear "this project isn't Ruby".
|
|
20
28
|
export function validateStack(projectRoot, runner, deps = defaultDeps) {
|
|
21
29
|
switch (runner) {
|
|
22
30
|
case 'rspec':
|
|
@@ -25,13 +33,64 @@ export function validateStack(projectRoot, runner, deps = defaultDeps) {
|
|
|
25
33
|
return vitestPrecheck(projectRoot);
|
|
26
34
|
case 'pytest':
|
|
27
35
|
return pytestPrecheck(projectRoot, deps);
|
|
36
|
+
case 'cucumber':
|
|
37
|
+
return rubyBehavioralPrecheck(projectRoot);
|
|
38
|
+
case 'cucumber-js':
|
|
39
|
+
return jsBehavioralPrecheck(projectRoot);
|
|
40
|
+
case 'pytest-bdd':
|
|
41
|
+
return pythonBehavioralPrecheck(projectRoot, deps);
|
|
28
42
|
default:
|
|
29
43
|
return {
|
|
30
44
|
ok: false,
|
|
31
|
-
message: `Unsupported runner "${runner}" — Unitbob supports
|
|
45
|
+
message: `Unsupported runner "${runner}" — Unitbob supports ` +
|
|
46
|
+
'rspec, vitest, pytest, cucumber, cucumber-js, and pytest-bdd only.',
|
|
32
47
|
};
|
|
33
48
|
}
|
|
34
49
|
}
|
|
50
|
+
// The behavioral suite boots the app in its test environment, so a Ruby project
|
|
51
|
+
// is the marker — but not rspec-rails (the behavioral runner is cucumber, not
|
|
52
|
+
// rspec), and not the cucumber gem itself (check never installs; a missing
|
|
53
|
+
// runner surfaces as a suite error from the run).
|
|
54
|
+
function rubyBehavioralPrecheck(projectRoot) {
|
|
55
|
+
if (hasGemfileWith(projectRoot, /\brails\b/))
|
|
56
|
+
return { ok: true };
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
message: 'The behavioral (Gherkin) suite selected the Ruby stack, but this project does not look ' +
|
|
60
|
+
'like Rails (no `rails` gem found in Gemfile).',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function jsBehavioralPrecheck(projectRoot) {
|
|
64
|
+
if (existsSync(join(projectRoot, 'package.json')))
|
|
65
|
+
return { ok: true };
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
message: 'The behavioral (Gherkin) suite selected the JavaScript/TypeScript stack, but this project has no package.json.',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// pytest-bdd runs under pytest, so the harness must be importable — same probe
|
|
72
|
+
// and message shape as the structural pytest precheck; pytest-bdd itself, if
|
|
73
|
+
// missing, surfaces as a suite error from the run.
|
|
74
|
+
function pythonBehavioralPrecheck(projectRoot, deps) {
|
|
75
|
+
const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
|
|
76
|
+
if (!markers.some((name) => existsSync(join(projectRoot, name)))) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
message: 'The behavioral (Gherkin) suite selected the Python stack, but this project has none of ' +
|
|
80
|
+
`${markers.join(', ')} — it does not look like a Python project.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
|
|
84
|
+
if (!available) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
message: 'The behavioral (Gherkin) suite selected the Python stack, but pytest is not importable in ' +
|
|
88
|
+
'the current Python environment. If your dependencies live in a virtualenv, activate it ' +
|
|
89
|
+
'(e.g. `source .venv/bin/activate`) before running Unitbob, then retry.',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
35
94
|
function rubyPrecheck(projectRoot) {
|
|
36
95
|
if (!hasGemfileWith(projectRoot, /\brails\b/)) {
|
|
37
96
|
return {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The connector-owned pytest-bdd reporter (spec 32). Python has no Cucumber
|
|
2
|
+
// Messages emitter, so the connector ships this small deterministic plugin. It
|
|
3
|
+
// hangs off pytest-bdd's public scenario/step hooks and writes one JSON document
|
|
4
|
+
// the Rails PytestBddJson parser reads:
|
|
5
|
+
//
|
|
6
|
+
// { "version": 1, "scenarios": [ { name, tags, status, failure, steps: [...] } ] }
|
|
7
|
+
//
|
|
8
|
+
// It only records — it never reconciles markers or aggregates capabilities. It
|
|
9
|
+
// is connector-owned and never part of the LLM-generated step definitions.
|
|
10
|
+
export const PYTEST_BDD_PLUGIN = `# Written by the unitbob connector — do not edit.
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
_UNITBOB_REPORT = {"version": 1, "scenarios": []}
|
|
15
|
+
_UNITBOB_CURRENT = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pytest_bdd_before_scenario(request, feature, scenario):
|
|
19
|
+
_UNITBOB_CURRENT[id(scenario)] = {
|
|
20
|
+
"name": scenario.name,
|
|
21
|
+
"tags": sorted(scenario.tags),
|
|
22
|
+
"status": "passed",
|
|
23
|
+
"failure": "",
|
|
24
|
+
"steps": [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _record_step(scenario, step, status):
|
|
29
|
+
entry = _UNITBOB_CURRENT.get(id(scenario))
|
|
30
|
+
if entry is None:
|
|
31
|
+
return
|
|
32
|
+
entry["steps"].append({
|
|
33
|
+
"keyword": getattr(step, "keyword", "").strip(),
|
|
34
|
+
"text": step.name,
|
|
35
|
+
"status": status,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pytest_bdd_after_step(request, feature, scenario, step, step_func):
|
|
40
|
+
_record_step(scenario, step, "passed")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception):
|
|
44
|
+
entry = _UNITBOB_CURRENT.get(id(scenario))
|
|
45
|
+
if entry is not None:
|
|
46
|
+
entry["status"] = "failed"
|
|
47
|
+
entry["failure"] = "{}: {}".format(type(exception).__name__, exception)
|
|
48
|
+
_record_step(scenario, step, "failed")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pytest_bdd_after_scenario(request, feature, scenario):
|
|
52
|
+
entry = _UNITBOB_CURRENT.pop(id(scenario), None)
|
|
53
|
+
if entry is not None:
|
|
54
|
+
_UNITBOB_REPORT["scenarios"].append(entry)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
58
|
+
out = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
|
|
59
|
+
if out:
|
|
60
|
+
with open(out, "w") as handle:
|
|
61
|
+
json.dump(_UNITBOB_REPORT, handle)
|
|
62
|
+
`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Wire } from "../wire.js";
|
|
2
|
+
// Fetch the contract action brief for one red check (spec 32). One operation for
|
|
3
|
+
// both maps and both intents: the digest names the exact current version (and so
|
|
4
|
+
// its contract system), `test_id` is that kind's own id, `intent` is fix|accept.
|
|
5
|
+
// The server composes the whole prompt; the connector prints its plain-language
|
|
6
|
+
// `message` and the copy-ready `prompt`. A 422 (not current / not failing /
|
|
7
|
+
// unknown intent) surfaces via WireError; nothing is fabricated.
|
|
8
|
+
export async function contractPrompt(config, args = [], deps) {
|
|
9
|
+
const suiteDigest = (args[0] ?? '').trim();
|
|
10
|
+
const testId = (args[1] ?? '').trim();
|
|
11
|
+
const intent = (args[2] ?? 'fix').trim();
|
|
12
|
+
if (!suiteDigest || !testId) {
|
|
13
|
+
throw new Error('Usage: unitbob contract-prompt <suite_digest> <test_id> [fix|accept]');
|
|
14
|
+
}
|
|
15
|
+
if (intent !== 'fix' && intent !== 'accept') {
|
|
16
|
+
throw new Error(`intent must be "fix" or "accept" (got "${intent}").`);
|
|
17
|
+
}
|
|
18
|
+
const d = {
|
|
19
|
+
getContractPrompt: (digest, id, action) => new Wire(config).getContractPrompt(digest, id, action),
|
|
20
|
+
stdout: process.stdout,
|
|
21
|
+
...deps,
|
|
22
|
+
};
|
|
23
|
+
const packet = await d.getContractPrompt(suiteDigest, testId, intent);
|
|
24
|
+
d.stdout.write(`${packet.message}\n\n${packet.prompt}\n`);
|
|
25
|
+
}
|
|
@@ -1,35 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { validateStack } from "../runner/precheck.js";
|
|
1
|
+
import { readHostSuiteOutputs, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
2
|
import { Wire } from "../wire.js";
|
|
4
|
-
// Read the task and the host's
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// nothing is uploaded and the previous suite stands.
|
|
3
|
+
// Read the task and the host's answers, verify each branch parses and carries a
|
|
4
|
+
// safe-path artifact envelope, then upload both peer branches in one batch
|
|
5
|
+
// (spec 32). `source_digest` comes from the task — never the host's answer — so
|
|
6
|
+
// the host cannot claim a different map than each branch was given. A branch the
|
|
7
|
+
// host could not build is uploaded as a `build_error`, which never rolls back the
|
|
8
|
+
// peer branch. If a branch's answer is unparseable, nothing is uploaded.
|
|
11
9
|
export async function putSuiteBuild(config, _args = [], deps) {
|
|
12
10
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
const outputs = readHostSuiteOutputs(request.output_path, request);
|
|
12
|
+
const d = {
|
|
13
|
+
putSuiteBuilds: (items) => new Wire(config).putSuiteBuilds(items),
|
|
14
|
+
stdout: process.stdout,
|
|
17
15
|
...deps,
|
|
18
16
|
};
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
const digestFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.source_digest]));
|
|
18
|
+
const items = outputs.map((output) => {
|
|
19
|
+
const sourceDigest = digestFor.get(output.suite_kind) ?? '';
|
|
20
|
+
if (output.build_error) {
|
|
21
|
+
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
suite_kind: output.suite_kind,
|
|
25
|
+
source_digest: sourceDigest,
|
|
26
|
+
artifacts: {
|
|
27
|
+
suite_file: output.suite_file,
|
|
28
|
+
runner_manifest: output.runner_manifest,
|
|
29
|
+
test_metadata: output.test_metadata,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
29
32
|
});
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const results = await d.putSuiteBuilds(items);
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
d.stdout.write(`${printResult(result)}\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function printResult(result) {
|
|
39
|
+
if (result.status === 'error' || result.status === 'build_error') {
|
|
40
|
+
return `${result.suite_kind}: not published — ${result.error ?? 'the host could not build this suite'}.`;
|
|
41
|
+
}
|
|
42
|
+
const tallies = result.counts
|
|
43
|
+
? Object.entries(result.counts)
|
|
44
|
+
.map(([name, value]) => `${value} ${name}`)
|
|
45
|
+
.join(', ')
|
|
46
|
+
: '';
|
|
47
|
+
const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
|
|
48
|
+
return `${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.`;
|
|
35
49
|
}
|
package/dist/verbs/run.js
CHANGED
|
@@ -1,59 +1,90 @@
|
|
|
1
1
|
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
|
+
import { materializeBehavioral } from "../files/behavioral.js";
|
|
2
3
|
import { validateStack } from "../runner/precheck.js";
|
|
3
4
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
4
5
|
import { runVitestSuite } from "../runner/vitest.js";
|
|
5
6
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
7
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { boundReport } from "../runner/boundReport.js";
|
|
6
9
|
import { Wire } from "../wire.js";
|
|
7
|
-
// Bound failure text for transport/storage hygiene (spec 26). This is a size
|
|
8
|
-
// limit only, not a privacy filter — application values in failure messages are
|
|
9
|
-
// accepted, not scrubbed.
|
|
10
|
-
const MAX_FAILURE_CHARS = 8000;
|
|
11
10
|
const OUTPUT_TAIL_CHARS = 2000;
|
|
12
|
-
// The check flow (spec 30): fetch the current suite blob, confirm the local
|
|
13
|
-
// project matches its runner, materialize the guardrail file, execute the
|
|
14
|
-
// connector-owned strategy named by `runner_manifest.runner`, and ship the raw
|
|
15
|
-
// machine-readable report (or a structured runner error) to Rails.
|
|
16
11
|
export async function run(config, _args, deps) {
|
|
17
12
|
const wire = new Wire(config);
|
|
18
13
|
const d = {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
materializeGuardrails,
|
|
22
|
-
|
|
14
|
+
getSuites: () => wire.getSuites(),
|
|
15
|
+
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
16
|
+
materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
|
|
17
|
+
suite_digest: item.suite_digest,
|
|
18
|
+
suite_file: { path: item.suite_file.path, content: item.suite_file.content },
|
|
19
|
+
runner_manifest: item.runner_manifest,
|
|
20
|
+
}),
|
|
21
|
+
materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file).mainPath,
|
|
22
|
+
runStructural: runStructuralByRunner,
|
|
23
|
+
runBehavioral: runBddSuite,
|
|
23
24
|
validateStack,
|
|
24
25
|
stdout: process.stdout,
|
|
25
26
|
...deps,
|
|
26
27
|
};
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const suites = await d.getSuites();
|
|
29
|
+
const ready = suites.filter((item) => item.status === 'ready');
|
|
30
|
+
if (ready.length === 0) {
|
|
31
|
+
d.stdout.write('No Unitbob suites exist yet. Generate them first, then run the Unitbob checks again.\n');
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
+
const runs = [];
|
|
35
|
+
for (const item of ready) {
|
|
36
|
+
runs.push(await buildRunPayload(config, d, item));
|
|
37
|
+
}
|
|
38
|
+
const { results, map_url } = await d.postRunsBatch(runs);
|
|
39
|
+
for (const result of results)
|
|
40
|
+
d.stdout.write(`${result.summary}\n`);
|
|
41
|
+
if (map_url)
|
|
42
|
+
d.stdout.write(`${map_url}\n`);
|
|
43
|
+
}
|
|
44
|
+
// One branch's run payload. A stack mismatch, a materialize failure, or a runner
|
|
45
|
+
// that produced no report all become this branch's structured suite error — the
|
|
46
|
+
// peer branch is unaffected. This connector never installs anything: a missing
|
|
47
|
+
// or broken runner surfaces here as a suite error, not an install.
|
|
48
|
+
async function buildRunPayload(config, d, item) {
|
|
49
|
+
const runner = item.runner_manifest.runner;
|
|
50
|
+
const behavioral = item.suite_kind === 'behavioral';
|
|
51
|
+
// Confirm the local stack before touching the tree, for both contract systems.
|
|
52
|
+
// A mismatch is this branch's suite error — reported and left for the peer
|
|
53
|
+
// branch to run regardless. The behavioral check confirms only the base
|
|
54
|
+
// language; a missing BDD runner still surfaces from the run itself, since
|
|
55
|
+
// check installs nothing.
|
|
34
56
|
const check = d.validateStack(config.projectRoot, runner);
|
|
35
57
|
if (!check.ok)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
return suiteError(item.suite_digest, check.message ?? `Local project does not match "${runner}".`);
|
|
59
|
+
let result;
|
|
60
|
+
try {
|
|
61
|
+
if (behavioral) {
|
|
62
|
+
const mainPath = d.materializeBehavioral(config.projectRoot, item);
|
|
63
|
+
result = await d.runBehavioral(config.projectRoot, runner, mainPath);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
d.materializeStructural(config.projectRoot, item);
|
|
67
|
+
result = await d.runStructural(config.projectRoot, runner, item.suite_file.path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return suiteError(item.suite_digest, err.message);
|
|
72
|
+
}
|
|
73
|
+
const report = boundReport(runner, result);
|
|
74
|
+
if (report === null) {
|
|
75
|
+
return {
|
|
76
|
+
suite_digest: item.suite_digest,
|
|
77
|
+
suite_error: {
|
|
78
|
+
command: [result.command, ...result.args].join(' '),
|
|
79
|
+
exit_code: result.code,
|
|
80
|
+
result_path: result.resultPath,
|
|
81
|
+
output_tail: outputTail(result),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { suite_digest: item.suite_digest, run_result: report };
|
|
52
86
|
}
|
|
53
|
-
|
|
54
|
-
// these; only suite and result paths vary. Host-provided command strings are
|
|
55
|
-
// never executed.
|
|
56
|
-
function runSuiteByRunner(projectRoot, runner, suitePath) {
|
|
87
|
+
function runStructuralByRunner(projectRoot, runner, suitePath) {
|
|
57
88
|
switch (runner) {
|
|
58
89
|
case 'rspec':
|
|
59
90
|
return runRspecSuite(projectRoot, suitePath);
|
|
@@ -62,119 +93,15 @@ function runSuiteByRunner(projectRoot, runner, suitePath) {
|
|
|
62
93
|
case 'pytest':
|
|
63
94
|
return runPytestSuite(projectRoot, suitePath);
|
|
64
95
|
default:
|
|
65
|
-
return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite
|
|
96
|
+
return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite.`));
|
|
66
97
|
}
|
|
67
98
|
}
|
|
68
|
-
|
|
69
|
-
// the app under test cannot pollute. A run that produced no report is a
|
|
70
|
-
// structured suite error — command, exit code, expected result path, output
|
|
71
|
-
// tail — which Rails records without repainting capabilities.
|
|
72
|
-
function rawRunPayload(suiteDigest, runner, result) {
|
|
73
|
-
const report = boundedReport(runner, result);
|
|
74
|
-
if (report !== null)
|
|
75
|
-
return { suite_digest: suiteDigest, run_result: report };
|
|
99
|
+
function suiteError(suiteDigest, message) {
|
|
76
100
|
return {
|
|
77
101
|
suite_digest: suiteDigest,
|
|
78
|
-
suite_error: {
|
|
79
|
-
command: [result.command, ...result.args].join(' '),
|
|
80
|
-
exit_code: result.code,
|
|
81
|
-
result_path: result.resultPath,
|
|
82
|
-
output_tail: outputTail(result),
|
|
83
|
-
},
|
|
102
|
+
suite_error: { command: '', exit_code: null, result_path: '', output_tail: message },
|
|
84
103
|
};
|
|
85
104
|
}
|
|
86
|
-
// JSON reports (rspec, vitest) are parsed only to size-bound failure text, then
|
|
87
|
-
// re-serialized; the XML report is bounded per failure element the same way.
|
|
88
|
-
// `null` means "no usable report" — the suite-error path. Stdout is only a
|
|
89
|
-
// fallback for an rspec double that emits its report there.
|
|
90
|
-
function boundedReport(runner, result) {
|
|
91
|
-
if (runner === 'pytest')
|
|
92
|
-
return result.report.trim() ? boundJunitXml(result.report) : null;
|
|
93
|
-
const parsed = parseJsonObject(result.report) ?? (runner === 'rspec' ? parseJsonObject(result.stdout) : null);
|
|
94
|
-
if (!parsed)
|
|
95
|
-
return null;
|
|
96
|
-
const bounded = runner === 'rspec' ? boundRspecFailures(parsed) : boundVitestFailures(parsed);
|
|
97
|
-
return JSON.stringify(bounded);
|
|
98
|
-
}
|
|
99
|
-
// Truncate long per-example failure message/backtrace before transport. Other
|
|
100
|
-
// fields pass through untouched; Rails owns the capability join and status mapping.
|
|
101
|
-
function boundRspecFailures(rspecJson) {
|
|
102
|
-
const examples = rspecJson.examples;
|
|
103
|
-
if (!Array.isArray(examples))
|
|
104
|
-
return rspecJson;
|
|
105
|
-
const bounded = examples.map((example) => {
|
|
106
|
-
if (!example || typeof example !== 'object')
|
|
107
|
-
return example;
|
|
108
|
-
const ex = example;
|
|
109
|
-
const exception = ex.exception;
|
|
110
|
-
if (!exception || typeof exception !== 'object')
|
|
111
|
-
return ex;
|
|
112
|
-
const exc = exception;
|
|
113
|
-
const next = { ...exc };
|
|
114
|
-
if (typeof exc.message === 'string')
|
|
115
|
-
next.message = truncate(exc.message);
|
|
116
|
-
if (Array.isArray(exc.backtrace))
|
|
117
|
-
next.backtrace = exc.backtrace.slice(0, 20);
|
|
118
|
-
return { ...ex, exception: next };
|
|
119
|
-
});
|
|
120
|
-
return { ...rspecJson, examples: bounded };
|
|
121
|
-
}
|
|
122
|
-
function boundVitestFailures(vitestJson) {
|
|
123
|
-
const testResults = vitestJson.testResults;
|
|
124
|
-
if (!Array.isArray(testResults))
|
|
125
|
-
return vitestJson;
|
|
126
|
-
const bounded = testResults.map((fileResult) => {
|
|
127
|
-
if (!fileResult || typeof fileResult !== 'object')
|
|
128
|
-
return fileResult;
|
|
129
|
-
const file = fileResult;
|
|
130
|
-
const assertions = file.assertionResults;
|
|
131
|
-
if (!Array.isArray(assertions))
|
|
132
|
-
return file;
|
|
133
|
-
const next = assertions.map((assertion) => {
|
|
134
|
-
if (!assertion || typeof assertion !== 'object')
|
|
135
|
-
return assertion;
|
|
136
|
-
const a = assertion;
|
|
137
|
-
if (!Array.isArray(a.failureMessages))
|
|
138
|
-
return a;
|
|
139
|
-
return { ...a, failureMessages: a.failureMessages.map((m) => (typeof m === 'string' ? truncate(m) : m)) };
|
|
140
|
-
});
|
|
141
|
-
return { ...file, assertionResults: next };
|
|
142
|
-
});
|
|
143
|
-
return { ...vitestJson, testResults: bounded };
|
|
144
|
-
}
|
|
145
|
-
function truncate(text) {
|
|
146
|
-
return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
|
|
147
|
-
}
|
|
148
|
-
// pytest's JUnit XML has no size bound of its own: one failing assertion can
|
|
149
|
-
// carry a multi-megabyte diff plus captured stdout/stderr. Bound the two things
|
|
150
|
-
// Rails reads (the failure/error/skipped `message` attribute and the element
|
|
151
|
-
// body) plus the captured-output blocks, keeping the document well-formed so
|
|
152
|
-
// Rails' strict XML parse still succeeds.
|
|
153
|
-
function boundJunitXml(xml) {
|
|
154
|
-
const boundedBodies = xml.replace(/(<(failure|error|skipped|system-out|system-err)\b[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, open, _tag, body, close) => `${boundXmlMessageAttr(open)}${boundXmlText(body)}${close}`);
|
|
155
|
-
// Self-closing forms (e.g. <failure message="…"/>) carry everything in the attr.
|
|
156
|
-
return boundedBodies.replace(/<(failure|error|skipped)\b[^>]*\/>/g, (tag) => boundXmlMessageAttr(tag));
|
|
157
|
-
}
|
|
158
|
-
function boundXmlMessageAttr(tag) {
|
|
159
|
-
// Attribute values escape their own quotes, so matching to the next `"` is safe.
|
|
160
|
-
return tag.replace(/(\bmessage=")([\s\S]*?)(")/, (_m, pre, value, post) => value.length > MAX_FAILURE_CHARS ? `${pre}${boundXmlText(value)}… (truncated)${post}` : `${pre}${value}${post}`);
|
|
161
|
-
}
|
|
162
|
-
function boundXmlText(text) {
|
|
163
|
-
return text.length > MAX_FAILURE_CHARS ? `${truncateXml(text)}… (truncated)` : text;
|
|
164
|
-
}
|
|
165
|
-
// Slice without cutting inside an XML entity, which would break a strict parse.
|
|
166
|
-
function truncateXml(text) {
|
|
167
|
-
return text.slice(0, MAX_FAILURE_CHARS).replace(/&[^;]*$/, '');
|
|
168
|
-
}
|
|
169
|
-
function parseJsonObject(text) {
|
|
170
|
-
try {
|
|
171
|
-
const parsed = JSON.parse(text);
|
|
172
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
105
|
function outputTail(result) {
|
|
179
106
|
const bits = [];
|
|
180
107
|
if (result.stderr.trim())
|
|
@@ -1,35 +1,40 @@
|
|
|
1
1
|
import { materializeHelper } from "../files/guardrails.js";
|
|
2
|
-
import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
2
|
+
import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
3
3
|
import { anyStackPrecheck } from "../runner/precheck.js";
|
|
4
4
|
import { Wire } from "../wire.js";
|
|
5
|
-
// Confirm at least one supported stack is present
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// (via WireError) with guidance to run `/unitbob map` first.
|
|
5
|
+
// Confirm at least one supported stack is present, materialize the Ruby boot
|
|
6
|
+
// helper a generated RSpec suite would require, then fetch both peer assignments
|
|
7
|
+
// (spec 32) and each branch's recipe, and write the host's task to
|
|
8
|
+
// `.unitbob/suite-build/request.json`. No model is called and no source is read
|
|
9
|
+
// here — that is the host's job, framed by the two generation recipes. An
|
|
10
|
+
// unsupported project stops with one actionable message and writes nothing; a
|
|
11
|
+
// no-current-map error from the server surfaces (via WireError) with guidance to
|
|
12
|
+
// rebuild the map first.
|
|
14
13
|
export async function suitePrepare(config, _args = [], deps) {
|
|
15
14
|
const wire = new Wire(config);
|
|
16
15
|
const actual = {
|
|
17
16
|
getRecipe: (name) => wire.getRecipe(name),
|
|
18
|
-
|
|
17
|
+
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
19
18
|
precheck: anyStackPrecheck,
|
|
19
|
+
stdout: process.stdout,
|
|
20
20
|
...deps,
|
|
21
21
|
};
|
|
22
22
|
const check = actual.precheck(config.projectRoot);
|
|
23
23
|
if (!check.ok)
|
|
24
24
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
25
25
|
materializeHelper(config.projectRoot);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
const packets = await actual.getSuitePacketsBatch();
|
|
27
|
+
const branches = await Promise.all(packets.map(async (packet) => ({
|
|
28
|
+
suite_kind: packet.suite_kind,
|
|
29
|
+
source_digest: packet.source_digest,
|
|
30
|
+
path_root: packet.path_root,
|
|
31
|
+
recipe: await actual.getRecipe(recipeNameFor(packet)),
|
|
32
|
+
assignment: packet.assignment,
|
|
33
|
+
})));
|
|
34
|
+
const request = writeSuiteBuildRequest(config.projectRoot, branches);
|
|
35
|
+
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
36
|
+
actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
37
|
+
actual.stdout.write(`Next: build both peer suites (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
38
|
+
`write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
|
|
39
|
+
'then run `unitbob put-suite-build`.\n');
|
|
35
40
|
}
|
package/dist/wire.js
CHANGED
|
@@ -49,9 +49,64 @@ export class Wire {
|
|
|
49
49
|
await this.ensureOk(res, `PUT ${this.repoPath('map_build')}`);
|
|
50
50
|
return (await res.json());
|
|
51
51
|
}
|
|
52
|
-
// GET /repos/:id/suite_packets — the
|
|
53
|
-
//
|
|
54
|
-
// the server's "run
|
|
52
|
+
// GET /repos/:id/suite_packets — the two peer assignments (spec 32), exactly
|
|
53
|
+
// one per contract system. Relayed opaque; 409 (no current map) surfaces as a
|
|
54
|
+
// WireError carrying the server's "run the Unitbob map first" guidance.
|
|
55
|
+
async getSuitePacketsBatch() {
|
|
56
|
+
const res = await this.send('GET', this.repoPath('suite_packets'));
|
|
57
|
+
await this.ensureOk(res, `GET ${this.repoPath('suite_packets')}`);
|
|
58
|
+
const body = (await res.json());
|
|
59
|
+
if (!Array.isArray(body.suite_packets)) {
|
|
60
|
+
throw new WireError(`GET ${this.repoPath('suite_packets')} returned no suite_packets array.`);
|
|
61
|
+
}
|
|
62
|
+
return body.suite_packets;
|
|
63
|
+
}
|
|
64
|
+
// PUT /repos/:id/suite_builds — upload both peer branches in one batch (spec
|
|
65
|
+
// 32). Each item is validated and published independently; the response
|
|
66
|
+
// carries one result per suite_kind.
|
|
67
|
+
async putSuiteBuilds(items) {
|
|
68
|
+
const res = await this.send('PUT', this.repoPath('suite_builds'), { suite_builds: items });
|
|
69
|
+
await this.ensureOk(res, `PUT ${this.repoPath('suite_builds')}`);
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
if (!Array.isArray(body.results)) {
|
|
72
|
+
throw new WireError(`PUT ${this.repoPath('suite_builds')} returned no results array.`);
|
|
73
|
+
}
|
|
74
|
+
return body.results;
|
|
75
|
+
}
|
|
76
|
+
// GET /repos/:id/suites — both current suites (spec 32), exactly two peer
|
|
77
|
+
// items. A `ready` item carries its blob; a `not_built` item is skipped.
|
|
78
|
+
async getSuites() {
|
|
79
|
+
const res = await this.send('GET', this.repoPath('suites'));
|
|
80
|
+
await this.ensureOk(res, `GET ${this.repoPath('suites')}`);
|
|
81
|
+
const body = (await res.json());
|
|
82
|
+
if (!Array.isArray(body.suites)) {
|
|
83
|
+
throw new WireError(`GET ${this.repoPath('suites')} returned no suites array.`);
|
|
84
|
+
}
|
|
85
|
+
return body.suites;
|
|
86
|
+
}
|
|
87
|
+
// POST /repos/:id/runs/batch — ship each branch's raw report (or suite error)
|
|
88
|
+
// in one batch; the server parses each against the exact stored version and
|
|
89
|
+
// returns one summary per branch plus one shared map URL.
|
|
90
|
+
async postRunsBatch(runs) {
|
|
91
|
+
const res = await this.send('POST', this.repoPath('runs/batch'), { runs });
|
|
92
|
+
await this.ensureOk(res, `POST ${this.repoPath('runs/batch')}`);
|
|
93
|
+
const body = (await res.json());
|
|
94
|
+
if (!Array.isArray(body.results)) {
|
|
95
|
+
throw new WireError(`POST ${this.repoPath('runs/batch')} returned no results array.`);
|
|
96
|
+
}
|
|
97
|
+
return { results: body.results, map_url: String(body.map_url ?? '') };
|
|
98
|
+
}
|
|
99
|
+
// GET /repos/:id/contract_prompt?suite_digest=&test_id=&intent= — the one
|
|
100
|
+
// contract action operation for both maps and both intents (spec 32).
|
|
101
|
+
async getContractPrompt(suiteDigest, testId, intent) {
|
|
102
|
+
const query = new URLSearchParams({ suite_digest: suiteDigest, test_id: testId, intent });
|
|
103
|
+
const url = `${this.repoPath('contract_prompt')}?${query}`;
|
|
104
|
+
const res = await this.send('GET', url);
|
|
105
|
+
await this.ensureOk(res, `GET ${url}`);
|
|
106
|
+
return (await res.json());
|
|
107
|
+
}
|
|
108
|
+
// GET /repos/:id/suite_packets — the legacy singular assignment (spec 26),
|
|
109
|
+
// kept for the structural-only flow. Relayed opaque.
|
|
55
110
|
async getSuitePackets() {
|
|
56
111
|
const res = await this.send('GET', this.repoPath('suite_packets'));
|
|
57
112
|
await this.ensureOk(res, `GET ${this.repoPath('suite_packets')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": {
|