unitbob 0.7.8 → 0.7.12
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 +52 -4
- package/dist/files/behavioral.js +72 -5
- package/dist/files/featureStart.js +65 -0
- package/dist/files/features.js +156 -0
- package/dist/files/mapBuild.js +2 -1
- package/dist/files/suiteBuild.js +9 -2
- package/dist/runner/bdd.js +36 -10
- package/dist/runner/failureDigest.js +59 -0
- package/dist/runner/gitRevision.js +20 -0
- package/dist/runner/manifest.js +3 -1
- package/dist/runner/outputTail.js +13 -0
- package/dist/runner/pytestBddPlugin.js +15 -0
- package/dist/verbs/contractPrompt.js +23 -4
- package/dist/verbs/featurePrepare.js +49 -0
- package/dist/verbs/knowledgePrepare.js +39 -0
- package/dist/verbs/mapPrepare.js +29 -3
- package/dist/verbs/putFeature.js +21 -0
- package/dist/verbs/putKnowledge.js +21 -0
- package/dist/verbs/putTests.js +133 -0
- package/dist/verbs/run.js +72 -30
- package/dist/verbs/runLocal.js +56 -14
- package/dist/verbs/suitePrepare.js +3 -1
- package/dist/verbs/suiteReviewPrepare.js +45 -29
- package/dist/verbs/testsPrepare.js +77 -0
- package/dist/verbs/testsReviewPrepare.js +40 -0
- package/dist/wire.js +125 -5
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +18 -0
package/dist/verbs/runLocal.js
CHANGED
|
@@ -5,8 +5,10 @@ import { placeAdvice } from "../runner/placeAdvice.js";
|
|
|
5
5
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
6
6
|
import { validateStack } from "../runner/precheck.js";
|
|
7
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
8
|
+
import { parseFeatureId, readTestsRequest } from "../files/features.js";
|
|
8
9
|
import { testPathsOf } from "../runner/vitest.js";
|
|
9
10
|
import { runStructuralByRunner } from "./run.js";
|
|
11
|
+
import { outputTail } from "../runner/outputTail.js";
|
|
10
12
|
const OUTPUT_TAIL_CHARS = 4000;
|
|
11
13
|
export async function runLocal(config, args = [], deps) {
|
|
12
14
|
const d = {
|
|
@@ -23,6 +25,10 @@ export async function runLocal(config, args = [], deps) {
|
|
|
23
25
|
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
24
26
|
if (unusable)
|
|
25
27
|
throw new Error(unusable);
|
|
28
|
+
const featureFlag = args.indexOf('--feature');
|
|
29
|
+
if (featureFlag !== -1) {
|
|
30
|
+
return runFeature(config, d, parseFeatureId(args[featureFlag + 1], 'run-local --feature'));
|
|
31
|
+
}
|
|
26
32
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
27
33
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
28
34
|
const wanted = selectBranches(request, args);
|
|
@@ -39,7 +45,7 @@ export async function runLocal(config, args = [], deps) {
|
|
|
39
45
|
d.stdout.write(`Cannot run this branch — its entry in your answer could not be read: ${broken.message}\n`);
|
|
40
46
|
continue;
|
|
41
47
|
}
|
|
42
|
-
const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
|
|
48
|
+
const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind), { exclude: request.exclude_feature_tags });
|
|
43
49
|
// A branch with no entry written yet, or one the stack cannot execute,
|
|
44
50
|
// produced nothing to compare: it is the ordinary state halfway through a
|
|
45
51
|
// build, not a repair loop going nowhere.
|
|
@@ -47,9 +53,54 @@ export async function runLocal(config, args = [], deps) {
|
|
|
47
53
|
continue;
|
|
48
54
|
if (compareFailures(config, d, suiteKind, ran, previous[suiteKind]))
|
|
49
55
|
stuck = true;
|
|
56
|
+
// The features' checks, each by its tag, on the same files. No stall
|
|
57
|
+
// comparison for them: their loop is the feature's own, in `--feature`.
|
|
58
|
+
if (suiteKind === 'behavioral') {
|
|
59
|
+
for (const tag of request.exclude_feature_tags)
|
|
60
|
+
await runTag(config, d, ran.runner, ran.mainPath, tag);
|
|
61
|
+
}
|
|
50
62
|
}
|
|
51
63
|
return stuck ? 1 : 0;
|
|
52
64
|
}
|
|
65
|
+
async function runTag(config, d, runner, mainPath, tag) {
|
|
66
|
+
d.stdout.write(`\n── ${tag} ──\n`);
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await d.runBehavioral(config.projectRoot, runner, mainPath, { only: tag });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const advice = placeAdvice(config.projectRoot);
|
|
73
|
+
d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
d.stdout.write(report(result));
|
|
77
|
+
d.stdout.write(failureLines(runner, result));
|
|
78
|
+
}
|
|
79
|
+
// One feature's checks, by their tag (spec 52-3, AC 3.3). No stall comparison:
|
|
80
|
+
// the loop here ends on "every scenario red", which the person reads off the
|
|
81
|
+
// failures printed, and the server's proof of red is the connector's own run
|
|
82
|
+
// in `put-tests`, not this one.
|
|
83
|
+
async function runFeature(config, d, featureId) {
|
|
84
|
+
const request = readTestsRequest(config.projectRoot, featureId);
|
|
85
|
+
d.stdout.write(`\n── feature ${featureId} ──\n`);
|
|
86
|
+
const check = d.validateStack(config.projectRoot, request.runner);
|
|
87
|
+
if (!check.ok) {
|
|
88
|
+
d.stdout.write(`Cannot run the checks: ${check.message ?? `this project does not match "${request.runner}".`}\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
let result;
|
|
92
|
+
try {
|
|
93
|
+
result = await d.runBehavioral(config.projectRoot, request.runner, request.feature_path, { only: request.feature_tag });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const advice = placeAdvice(config.projectRoot);
|
|
97
|
+
d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
d.stdout.write(report(result));
|
|
101
|
+
d.stdout.write(failureLines(request.runner, result));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
53
104
|
// Spec 34-6, criterion 3. The whole stop condition, and it stops the branch
|
|
54
105
|
// rather than the worker: the set of failures belongs to the branch, and a
|
|
55
106
|
// repair worker looking only at its own slice cannot see that the branch as a
|
|
@@ -97,7 +148,7 @@ function selectBranches(request, args) {
|
|
|
97
148
|
// Non-null when the runner actually executed the branch. Everything else — no
|
|
98
149
|
// entry, a declared `build_error`, a stack that cannot run it — is a branch that
|
|
99
150
|
// produced no result to compare against.
|
|
100
|
-
async function runOneBranch(config, d, suiteKind, output) {
|
|
151
|
+
async function runOneBranch(config, d, suiteKind, output, filter) {
|
|
101
152
|
// Nothing written for this branch yet. That is the ordinary state halfway
|
|
102
153
|
// through a build, not an error — say what is missing and move to the peer.
|
|
103
154
|
if (!output) {
|
|
@@ -128,7 +179,7 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
128
179
|
try {
|
|
129
180
|
result =
|
|
130
181
|
suiteKind === 'behavioral'
|
|
131
|
-
? await d.runBehavioral(config.projectRoot, runner, suitePaths[0])
|
|
182
|
+
? await d.runBehavioral(config.projectRoot, runner, suitePaths[0], filter)
|
|
132
183
|
: await d.runStructural(config.projectRoot, runner, suitePaths);
|
|
133
184
|
}
|
|
134
185
|
catch (err) {
|
|
@@ -142,7 +193,7 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
142
193
|
}
|
|
143
194
|
d.stdout.write(report(result));
|
|
144
195
|
d.stdout.write(failureLines(runner, result));
|
|
145
|
-
return { runner, result };
|
|
196
|
+
return { runner, mainPath: suitePaths[0], result };
|
|
146
197
|
}
|
|
147
198
|
// How many lines of one failure's message are worth printing here. Enough for an
|
|
148
199
|
// assertion diff and the frame under it; not the whole backtrace, which is in
|
|
@@ -224,20 +275,11 @@ function report(result) {
|
|
|
224
275
|
lines.push(`no report at ${result.resultPath} — the run produced none, which usually means it died before` +
|
|
225
276
|
' the first test rather than that the tests failed.');
|
|
226
277
|
}
|
|
227
|
-
const tail = outputTail(result);
|
|
278
|
+
const tail = outputTail(result, OUTPUT_TAIL_CHARS);
|
|
228
279
|
if (tail)
|
|
229
280
|
lines.push('', tail);
|
|
230
281
|
return `${lines.join('\n')}\n`;
|
|
231
282
|
}
|
|
232
|
-
function outputTail(result) {
|
|
233
|
-
const bits = [];
|
|
234
|
-
if (result.stderr.trim())
|
|
235
|
-
bits.push(result.stderr.trim());
|
|
236
|
-
if (result.stdout.trim())
|
|
237
|
-
bits.push(result.stdout.trim());
|
|
238
|
-
const joined = bits.join('\n');
|
|
239
|
-
return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
|
|
240
|
-
}
|
|
241
283
|
// The suite blob's own project-relative paths, exactly as the runners expect
|
|
242
284
|
// them: the main file first, then every other file of the branch. The main file
|
|
243
285
|
// stopped being the whole suite in spec one-place-per-rule, §6 — a branch is one file per
|
|
@@ -66,6 +66,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
66
66
|
const actual = {
|
|
67
67
|
getRecipe: (name) => wire.getRecipe(name),
|
|
68
68
|
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
69
|
+
getSuiteIndex: () => wire.getSuiteIndex(),
|
|
69
70
|
precheck: anyStackPrecheck,
|
|
70
71
|
confirmRunner: (projectRoot, runner) => runnerReadyPrecheck(projectRoot, runner),
|
|
71
72
|
bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
|
|
@@ -314,7 +315,8 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
314
315
|
const why = [...bootNotices, ...blockedNotices, ...fixableNotices].join('\n');
|
|
315
316
|
throw new Error(`No suite branch can be built this run:\n${why}\nNothing was written and nothing was uploaded.`);
|
|
316
317
|
}
|
|
317
|
-
const
|
|
318
|
+
const excludeFeatureTags = (await actual.getSuiteIndex()).feature_suites.map((item) => item.feature_tag);
|
|
319
|
+
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext, excludeFeatureTags);
|
|
318
320
|
// Spec 37-1. The assignment names entrypoints; the packets are the files
|
|
319
321
|
// behind them, resolved from this machine's own graph and copied where a
|
|
320
322
|
// worker can open them. Built here because the entrypoints are known from the
|
|
@@ -2,13 +2,21 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize,
|
|
5
|
+
import { changedFeatureFiles, copyBehavioralRunnerEnvironment, FeatureFilesChangedError, filesLostOnMaterialize, materializeBehavioralUnion, } from "../files/behavioral.js";
|
|
6
6
|
import { runBddSuite } from "../runner/bdd.js";
|
|
7
7
|
import { boundReport } from "../runner/boundReport.js";
|
|
8
8
|
import { placeOf } from "../runner/place.js";
|
|
9
|
+
import { gitRevision } from "../runner/gitRevision.js";
|
|
10
|
+
import { Wire } from "../wire.js";
|
|
9
11
|
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
12
|
+
// The candidate is run on disk as the same union `check` writes (spec 52-4,
|
|
13
|
+
// AC 1.8): the candidate plus the checks of every red feature the server
|
|
14
|
+
// holds, with their tags left out of the run. Written as the candidate alone,
|
|
15
|
+
// the review wiped those checks from the disk — and with them any steps the
|
|
16
|
+
// host had rewired against the real code and not yet saved.
|
|
10
17
|
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
11
18
|
const actual = {
|
|
19
|
+
getSuiteIndex: () => new Wire(config).getSuiteIndex(),
|
|
12
20
|
runCandidate: runCandidate,
|
|
13
21
|
stdout: process.stdout,
|
|
14
22
|
...deps,
|
|
@@ -21,40 +29,63 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
|
21
29
|
if (behavioral.build_error) {
|
|
22
30
|
throw new Error(`The behavioral candidate could not be reviewed: ${behavioral.build_error.message}`);
|
|
23
31
|
}
|
|
32
|
+
// Before anything else is said or run: a feature's checks rewired on disk
|
|
33
|
+
// and not saved stop the review here, disk untouched, with the command that
|
|
34
|
+
// saves them — the same stop `check` makes.
|
|
35
|
+
const features = (await actual.getSuiteIndex()).feature_suites;
|
|
36
|
+
const changed = changedFeatureFiles(config.projectRoot, features);
|
|
37
|
+
if (changed.length > 0)
|
|
38
|
+
throw new FeatureFilesChangedError(changed[0].feature_id, changed[0].title);
|
|
24
39
|
// Say this before the run, not after: the run materializes the answer, and
|
|
25
40
|
// that is where a forgotten file turns into undefined steps — by then it is
|
|
26
41
|
// already gone.
|
|
27
|
-
const lost = filesLostOnMaterialize(config.projectRoot, behavioral.suite_file, branchRunner(behavioral));
|
|
42
|
+
const lost = filesLostOnMaterialize(config.projectRoot, behavioral.suite_file, branchRunner(behavioral), features.map((item) => item.suite_file));
|
|
28
43
|
if (lost.length > 0) {
|
|
29
44
|
actual.stdout.write(`Warning: these files sit with the suite but are not in its answer, and running it will delete them: ${lost.join(', ')}.\n`);
|
|
30
45
|
}
|
|
31
|
-
const candidateRun = await actual.runCandidate(config.projectRoot, behavioral);
|
|
46
|
+
const candidateRun = await actual.runCandidate(config.projectRoot, behavioral, features);
|
|
32
47
|
const fixedRevision = buildRequest.known_defect_context.status === 'supplied'
|
|
33
48
|
? buildRequest.known_defect_context.fixed_revision
|
|
34
49
|
: undefined;
|
|
35
50
|
const fixedCandidateRun = fixedRevision
|
|
36
|
-
? await actual.runCandidate(config.projectRoot, behavioral, fixedRevision)
|
|
51
|
+
? await actual.runCandidate(config.projectRoot, behavioral, features, fixedRevision)
|
|
37
52
|
: undefined;
|
|
38
53
|
const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
|
|
39
54
|
actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
|
|
40
55
|
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`);
|
|
41
56
|
}
|
|
42
|
-
async function runCandidate(projectRoot, output, revision) {
|
|
57
|
+
async function runCandidate(projectRoot, output, features, revision) {
|
|
43
58
|
if (revision)
|
|
44
|
-
return runCandidateAtRevision(projectRoot, output, revision);
|
|
45
|
-
return runCandidateInProject(projectRoot, output, gitRevision(projectRoot));
|
|
59
|
+
return runCandidateAtRevision(projectRoot, output, features, revision);
|
|
60
|
+
return runCandidateInProject(projectRoot, output, features, gitRevision(projectRoot));
|
|
46
61
|
}
|
|
47
|
-
async function runCandidateInProject(projectRoot, output, revision) {
|
|
62
|
+
async function runCandidateInProject(projectRoot, output, features, revision) {
|
|
48
63
|
const runner = branchRunner(output);
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const result = await runBddSuite(projectRoot, runner, mainPath);
|
|
64
|
+
const { mainPath, excludeTags } = candidateUnion(projectRoot, output, features);
|
|
65
|
+
const result = await runBddSuite(projectRoot, runner, mainPath, { exclude: excludeTags });
|
|
52
66
|
const report = boundReport(runner, result);
|
|
53
67
|
if (report === null)
|
|
54
68
|
throw new Error('The behavioral candidate produced no machine-readable runner report.');
|
|
55
69
|
return { revision, run_result: report };
|
|
56
70
|
}
|
|
57
|
-
|
|
71
|
+
// The candidate on disk as `check` would write it: the union of the candidate
|
|
72
|
+
// and every feature's checks, one clearing, the feature tags to leave out.
|
|
73
|
+
// Through `materializeBehavioralUnion` rather than beside it, so the stop on
|
|
74
|
+
// a changed feature file is the same one, made before the disk is touched.
|
|
75
|
+
export function candidateUnion(projectRoot, output, features) {
|
|
76
|
+
const index = {
|
|
77
|
+
suites: [{
|
|
78
|
+
suite_kind: 'behavioral',
|
|
79
|
+
status: 'ready',
|
|
80
|
+
suite_file: output.suite_file,
|
|
81
|
+
runner_manifest: output.runner_manifest,
|
|
82
|
+
}],
|
|
83
|
+
feature_suites: [...features],
|
|
84
|
+
};
|
|
85
|
+
// Never null: the candidate itself is in the union.
|
|
86
|
+
return materializeBehavioralUnion(projectRoot, index, branchRunner(output));
|
|
87
|
+
}
|
|
88
|
+
async function runCandidateAtRevision(projectRoot, output, features, revision) {
|
|
58
89
|
// Spec 36, Non-Goals. The worktree below is created under the system's
|
|
59
90
|
// temporary directory — outside anything a container has mounted, so in there
|
|
60
91
|
// it does not exist at all. Said plainly rather than run into. The obvious
|
|
@@ -78,9 +109,9 @@ async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
|
78
109
|
execFileSync('git', ['worktree', 'add', '--detach', worktree, resolved], { cwd: projectRoot, stdio: 'pipe' });
|
|
79
110
|
added = true;
|
|
80
111
|
const runner = branchRunner(output);
|
|
81
|
-
|
|
112
|
+
candidateUnion(worktree, output, features);
|
|
82
113
|
copyBehavioralRunnerEnvironment(projectRoot, worktree, runner);
|
|
83
|
-
return await runCandidateInProject(worktree, output, revision);
|
|
114
|
+
return await runCandidateInProject(worktree, output, features, revision);
|
|
84
115
|
}
|
|
85
116
|
finally {
|
|
86
117
|
if (added) {
|
|
@@ -105,18 +136,3 @@ async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
|
105
136
|
}
|
|
106
137
|
}
|
|
107
138
|
}
|
|
108
|
-
function gitRevision(projectRoot) {
|
|
109
|
-
try {
|
|
110
|
-
const options = {
|
|
111
|
-
cwd: projectRoot,
|
|
112
|
-
encoding: 'utf8',
|
|
113
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
114
|
-
};
|
|
115
|
-
const head = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
|
|
116
|
-
const dirty = execFileSync('git', ['status', '--porcelain'], options).trim();
|
|
117
|
-
return dirty ? `${head}-dirty` : head;
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
return 'working-tree';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { materializeBehavioralUnion, materializeBehavioralWorld } from "../files/behavioral.js";
|
|
2
|
+
import { featureFeaturePath, featureStepsPath, knowledgePath, parseFeatureId, testsOutputPath, testsRequestPath, writeTestsRequest, } from "../files/features.js";
|
|
3
|
+
import { installedRunnerVersion, selectRunnerEnvelope } from "../runner/manifest.js";
|
|
4
|
+
import { placeProblem } from "../runner/place.js";
|
|
5
|
+
import { detectBddRunner } from "../runner/precheck.js";
|
|
6
|
+
import { ensureRunner } from "../runner/provision.js";
|
|
7
|
+
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
8
|
+
import { Wire } from "../wire.js";
|
|
9
|
+
import { assertKnowledgeUnchanged } from "./putTests.js";
|
|
10
|
+
// `tests-prepare <feature_id>` (spec 52-3, AC 3.4): the host's task for writing
|
|
11
|
+
// a feature's checks. The packet (a 409 before the talk is the server's own
|
|
12
|
+
// sentence, let through), then the knowledge file on disk held to the server's
|
|
13
|
+
// digest — the checks are written from that file, and a file edited after the
|
|
14
|
+
// talk would put text under seal the person never confirmed — then the recipe,
|
|
15
|
+
// the union of the main suite and every red feature's checks on disk (the
|
|
16
|
+
// shared steps to reuse, the base for rewriting the wiring at `red`), the
|
|
17
|
+
// runner provisioned as `suite-prepare` provisions it, the World in place, and
|
|
18
|
+
// `tests-request.json` beside the talk's request. No model is called, nothing
|
|
19
|
+
// is uploaded.
|
|
20
|
+
export async function testsPrepare(config, args = [], deps) {
|
|
21
|
+
const wire = new Wire(config);
|
|
22
|
+
const d = {
|
|
23
|
+
getTestsPacket: (id) => wire.getTestsPacket(id),
|
|
24
|
+
getRecipe: (name) => wire.getRecipe(name),
|
|
25
|
+
getSuiteIndex: () => wire.getSuiteIndex(),
|
|
26
|
+
detectRunner: detectBddRunner,
|
|
27
|
+
ensureRunner,
|
|
28
|
+
installedVersion: installedRunnerVersion,
|
|
29
|
+
stdout: process.stdout,
|
|
30
|
+
...deps,
|
|
31
|
+
};
|
|
32
|
+
const featureId = parseFeatureId(args[0], 'tests-prepare');
|
|
33
|
+
const unusable = placeProblem(config.projectRoot);
|
|
34
|
+
if (unusable)
|
|
35
|
+
throw new Error(unusable);
|
|
36
|
+
const packet = await d.getTestsPacket(featureId);
|
|
37
|
+
assertKnowledgeUnchanged(config.projectRoot, featureId, packet.knowledge_digest);
|
|
38
|
+
const [recipe, index] = await Promise.all([d.getRecipe('feature_tests'), d.getSuiteIndex()]);
|
|
39
|
+
// The main suite's runner when it is built — the checks share its directory
|
|
40
|
+
// and its steps, so they cannot run on another — else the one this stack
|
|
41
|
+
// selects, as `suite-prepare` does.
|
|
42
|
+
const runner = packet.main_suite === 'not_built' ? d.detectRunner(config.projectRoot) : packet.main_suite.runner;
|
|
43
|
+
if (!runner) {
|
|
44
|
+
throw new Error('This project matches no BDD runner the connector can run — the checks cannot be written on it.');
|
|
45
|
+
}
|
|
46
|
+
const provisioned = await d.ensureRunner(config.projectRoot, runner);
|
|
47
|
+
if (provisioned.status === 'fixable') {
|
|
48
|
+
const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
|
|
49
|
+
throw new ToolchainUnavailableError(`The "${runner}" runner could not be installed under .unitbob/, and the checks cannot run without it: ` +
|
|
50
|
+
`${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written.`, config.projectRoot);
|
|
51
|
+
}
|
|
52
|
+
const selected = selectRunnerEnvelope(packet.runner_manifests, runner);
|
|
53
|
+
const version = d.installedVersion(runner, config.projectRoot);
|
|
54
|
+
if (!selected || !version) {
|
|
55
|
+
throw new Error(`The version of "${runner}" installed under .unitbob/behavioral/ could not be read, and the server requires it. ` +
|
|
56
|
+
'Re-run `unitbob suite-prepare` so the runner is provisioned again.');
|
|
57
|
+
}
|
|
58
|
+
materializeBehavioralUnion(config.projectRoot, index, runner);
|
|
59
|
+
materializeBehavioralWorld(config.projectRoot, runner);
|
|
60
|
+
writeTestsRequest(config.projectRoot, featureId, {
|
|
61
|
+
project_root: config.projectRoot,
|
|
62
|
+
recipe,
|
|
63
|
+
feature: packet.feature,
|
|
64
|
+
feature_tag: packet.feature_tag,
|
|
65
|
+
assignment: packet.assignment,
|
|
66
|
+
scenarios: packet.scenarios,
|
|
67
|
+
knowledge_path: knowledgePath(config.projectRoot, featureId),
|
|
68
|
+
knowledge_digest: packet.knowledge_digest,
|
|
69
|
+
runner,
|
|
70
|
+
runner_manifest: { ...selected, runner_version: version },
|
|
71
|
+
main_suite: packet.main_suite,
|
|
72
|
+
feature_path: featureFeaturePath(featureId),
|
|
73
|
+
steps_path: featureStepsPath(featureId, runner),
|
|
74
|
+
output_path: testsOutputPath(config.projectRoot, featureId),
|
|
75
|
+
});
|
|
76
|
+
d.stdout.write(`Tests request written to ${testsRequestPath(config.projectRoot, featureId)}\n`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { knowledgePath, parseFeatureId, readTestsOutput, readTestsRequest, testsReviewOutputPath, testsReviewRequestPath, writeTestsReviewRequest, } from "../files/features.js";
|
|
2
|
+
import { suiteCandidateDigest } from "../files/suiteBuild.js";
|
|
3
|
+
import { Wire } from "../wire.js";
|
|
4
|
+
// `tests-review-prepare <feature_id>` (spec 52-4, AC 1.11): the independent
|
|
5
|
+
// reviewer's task for a feature's checks, once they all pass. The packet
|
|
6
|
+
// first — a 409 is the server's own sentence, at `intent` (nothing to review)
|
|
7
|
+
// and at `done` (the checks are guardrails now), let through as it is — then
|
|
8
|
+
// the files from disk exactly as `put-tests` will send them, bound by the same
|
|
9
|
+
// candidate digest the upload carries, and the request written in the shape
|
|
10
|
+
// of the main suite's `review-request.json` so the same role reads it: plus
|
|
11
|
+
// where `knowledge.md` is, because the promise each scenario protects is
|
|
12
|
+
// written there, and the scenarios as the server sealed them. No model is
|
|
13
|
+
// called, nothing is uploaded; `put-tests` publishes the review.
|
|
14
|
+
export async function testsReviewPrepare(config, args = [], deps) {
|
|
15
|
+
const d = {
|
|
16
|
+
getTestsPacket: (id) => new Wire(config).getTestsPacket(id),
|
|
17
|
+
stdout: process.stdout,
|
|
18
|
+
...deps,
|
|
19
|
+
};
|
|
20
|
+
const featureId = parseFeatureId(args[0], 'tests-review-prepare');
|
|
21
|
+
// An existence guard only: the request is what `put-tests` will read after
|
|
22
|
+
// the review, and its absence names the verb that writes it — better said
|
|
23
|
+
// here than as a missing answer two steps later.
|
|
24
|
+
readTestsRequest(config.projectRoot, featureId);
|
|
25
|
+
const packet = await d.getTestsPacket(featureId);
|
|
26
|
+
const output = readTestsOutput(config.projectRoot, featureId);
|
|
27
|
+
writeTestsReviewRequest(config.projectRoot, featureId, {
|
|
28
|
+
candidate_digest: suiteCandidateDigest({
|
|
29
|
+
suite_kind: 'behavioral', suite_file: output.suite_file, runner_manifest: output.runner_manifest,
|
|
30
|
+
}),
|
|
31
|
+
suite_file: output.suite_file,
|
|
32
|
+
capabilities: output.test_metadata.capabilities,
|
|
33
|
+
knowledge_path: knowledgePath(config.projectRoot, featureId),
|
|
34
|
+
scenarios: packet.scenarios,
|
|
35
|
+
output_path: testsReviewOutputPath(config.projectRoot, featureId),
|
|
36
|
+
});
|
|
37
|
+
d.stdout.write(`Feature review request written to ${testsReviewRequestPath(config.projectRoot, featureId)}\n`);
|
|
38
|
+
d.stdout.write(`Next: have the independent reviewer write bdd_quality_review to ${testsReviewOutputPath(config.projectRoot, featureId)}, ` +
|
|
39
|
+
`then run \`unitbob put-tests ${featureId}\`.\n`);
|
|
40
|
+
}
|
package/dist/wire.js
CHANGED
|
@@ -7,11 +7,17 @@ import { proxyHint } from "./proxyHint.js";
|
|
|
7
7
|
// that reads an absence as a verdict either invents a rejection or invents an
|
|
8
8
|
// approval. `validate-build` is the caller that needs the difference: with no
|
|
9
9
|
// server it succeeds, and says out loud which questions went unasked.
|
|
10
|
+
//
|
|
11
|
+
// `status` is the HTTP status the server answered with, when there was one:
|
|
12
|
+
// for the one caller that treats a 404 — a server older than the route — as
|
|
13
|
+
// an absence rather than a verdict (`map-prepare`, spec 52-4).
|
|
10
14
|
export class WireError extends Error {
|
|
11
15
|
unreachable;
|
|
16
|
+
status;
|
|
12
17
|
constructor(message, options = {}) {
|
|
13
18
|
super(message);
|
|
14
19
|
this.unreachable = options.unreachable ?? false;
|
|
20
|
+
this.status = options.status ?? null;
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
23
|
// POST /repos/register — the linking bootstrap (spec 28). A standalone function
|
|
@@ -102,15 +108,19 @@ export class Wire {
|
|
|
102
108
|
return body.results;
|
|
103
109
|
}
|
|
104
110
|
// GET /repos/:id/suites — both current suites (spec 32), exactly two peer
|
|
105
|
-
// items
|
|
106
|
-
|
|
111
|
+
// items, and since spec 52-3 the checks of every red feature beside them. A
|
|
112
|
+
// `ready` item carries its blob; a `not_built` item is skipped.
|
|
113
|
+
async getSuiteIndex() {
|
|
107
114
|
const res = await this.send('GET', this.repoPath('suites'));
|
|
108
115
|
await this.ensureOk(res, `GET ${this.repoPath('suites')}`);
|
|
109
116
|
const body = (await res.json());
|
|
110
117
|
if (!Array.isArray(body.suites)) {
|
|
111
118
|
throw new WireError(`GET ${this.repoPath('suites')} returned no suites array.`);
|
|
112
119
|
}
|
|
113
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
suites: body.suites,
|
|
122
|
+
feature_suites: Array.isArray(body.feature_suites) ? body.feature_suites : [],
|
|
123
|
+
};
|
|
114
124
|
}
|
|
115
125
|
// POST /repos/:id/runs/batch — ship each branch's raw report (or suite error)
|
|
116
126
|
// in one batch; the server parses each against the exact stored version and
|
|
@@ -158,6 +168,69 @@ export class Wire {
|
|
|
158
168
|
await this.ensureOk(res, `GET ${url}`);
|
|
159
169
|
return (await res.json());
|
|
160
170
|
}
|
|
171
|
+
// POST /repos/:id/features — record a feature (spec 52-1). A 409 (no current
|
|
172
|
+
// map) and a 422 (an id not on the map, with both sides named in the body)
|
|
173
|
+
// surface as a WireError carrying the server's text, so the host reads what
|
|
174
|
+
// the map knows and corrects its file.
|
|
175
|
+
async postFeature(payload) {
|
|
176
|
+
const res = await this.send('POST', this.repoPath('features'), payload);
|
|
177
|
+
if (res.status === 422)
|
|
178
|
+
throw new WireError(await unknownCapabilitiesRefusal(res));
|
|
179
|
+
await this.ensureOk(res, `POST ${this.repoPath('features')}`);
|
|
180
|
+
return (await res.json());
|
|
181
|
+
}
|
|
182
|
+
// GET /repos/:id/features — every feature of the project, newest first, and
|
|
183
|
+
// the server's words for an empty list (spec 52-2, AC 1.2).
|
|
184
|
+
async listFeatures() {
|
|
185
|
+
const res = await this.send('GET', this.repoPath('features'));
|
|
186
|
+
await this.ensureOk(res, `GET ${this.repoPath('features')}`);
|
|
187
|
+
return (await res.json());
|
|
188
|
+
}
|
|
189
|
+
// GET /repos/:id/features/:feature_id/knowledge_packet (spec 52-2, AC 1.3).
|
|
190
|
+
async getKnowledgePacket(featureId) {
|
|
191
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge_packet`);
|
|
192
|
+
const res = await this.send('GET', path);
|
|
193
|
+
await this.ensureOk(res, `GET ${path}`);
|
|
194
|
+
return (await res.json());
|
|
195
|
+
}
|
|
196
|
+
// PUT /repos/:id/features/:feature_id/knowledge (spec 52-2, AC 1.4). The
|
|
197
|
+
// server checks the file's shape; a 422 carries `problems`, and they are
|
|
198
|
+
// relaid whole, one line per problem with both sides — the host fixes the
|
|
199
|
+
// file from those lines, and cutting them at 500 characters would hide the
|
|
200
|
+
// ones at the end.
|
|
201
|
+
async putKnowledge(featureId, knowledge) {
|
|
202
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge`);
|
|
203
|
+
const res = await this.send('PUT', path, { knowledge });
|
|
204
|
+
if (res.status === 422)
|
|
205
|
+
throw new WireError(await problemsRefusal(res, 'PUT knowledge failed: 422'));
|
|
206
|
+
await this.ensureOk(res, `PUT ${path}`);
|
|
207
|
+
return (await res.json());
|
|
208
|
+
}
|
|
209
|
+
// GET /repos/:id/features/:feature_id/tests_packet (spec 52-3, AC 2.1). A
|
|
210
|
+
// 409 is the server's own sentence (talk the feature through first) and is
|
|
211
|
+
// relaid as it is.
|
|
212
|
+
async getTestsPacket(featureId) {
|
|
213
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/tests_packet`);
|
|
214
|
+
const res = await this.send('GET', path);
|
|
215
|
+
if (res.status === 409)
|
|
216
|
+
throw new WireError(await wordedRefusal(res, `GET tests_packet failed: 409`));
|
|
217
|
+
await this.ensureOk(res, `GET ${path}`);
|
|
218
|
+
return (await res.json());
|
|
219
|
+
}
|
|
220
|
+
// PUT /repos/:id/features/:feature_id/suite (spec 52-3, AC 2.2). Every
|
|
221
|
+
// refusal is worded by the server: a 409 in one sentence, a 422 in one
|
|
222
|
+
// sentence or, for a broken seal, with one problem per difference — relaid
|
|
223
|
+
// whole, both sides per line, like the knowledge file's.
|
|
224
|
+
async putFeatureSuite(featureId, upload) {
|
|
225
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/suite`);
|
|
226
|
+
const res = await this.send('PUT', path, upload);
|
|
227
|
+
if (res.status === 409)
|
|
228
|
+
throw new WireError(await wordedRefusal(res, 'PUT suite failed: 409'));
|
|
229
|
+
if (res.status === 422)
|
|
230
|
+
throw new WireError(await problemsRefusal(res, 'PUT suite failed: 422'));
|
|
231
|
+
await this.ensureOk(res, `PUT ${path}`);
|
|
232
|
+
return (await res.json());
|
|
233
|
+
}
|
|
161
234
|
// GET /recipes/:name — fetch a recipe at call time. Recipes live on Rails so
|
|
162
235
|
// the connector and Skill carry no recipe text (spec 15, acceptance criteria).
|
|
163
236
|
async getRecipe(name) {
|
|
@@ -244,7 +317,7 @@ export class Wire {
|
|
|
244
317
|
if (res.status === 404) {
|
|
245
318
|
throw new WireError(`This project is linked to a repository the server at ${this.config.server} does not have, ` +
|
|
246
319
|
'or the token in .unitbob.json does not open it. Delete .unitbob.json to link again ' +
|
|
247
|
-
'(the old project, along with its map and checks, stays where it is).');
|
|
320
|
+
'(the old project, along with its map and checks, stays where it is).', { status: 404 });
|
|
248
321
|
}
|
|
249
322
|
let detail = '';
|
|
250
323
|
try {
|
|
@@ -253,8 +326,55 @@ export class Wire {
|
|
|
253
326
|
catch {
|
|
254
327
|
// ignore — the status alone is actionable enough
|
|
255
328
|
}
|
|
256
|
-
throw new WireError(statusRefusal(what, res, detail, this.config.server));
|
|
329
|
+
throw new WireError(statusRefusal(what, res, detail, this.config.server), { status: res.status });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// The 422 of POST /features carries two lists, and the host corrects its file by
|
|
333
|
+
// reading both (spec 52-1, AC 1.3). `ensureOk` keeps 500 characters of a body,
|
|
334
|
+
// which is a line of context for every other refusal and, on a map of twenty
|
|
335
|
+
// capabilities or more, cuts `known_ids` in half — the list the correction is
|
|
336
|
+
// made from, on exactly the projects that have the most ids to get wrong. So
|
|
337
|
+
// the two lists are relaid whole, one per line; any other 422 body keeps the
|
|
338
|
+
// ordinary shape.
|
|
339
|
+
async function unknownCapabilitiesRefusal(res) {
|
|
340
|
+
const { text, body } = await readBody(res);
|
|
341
|
+
if (!Array.isArray(body.unknown_ids) || !Array.isArray(body.known_ids)) {
|
|
342
|
+
return `POST features failed: 422 — ${text.slice(0, 500)}`;
|
|
343
|
+
}
|
|
344
|
+
return (`POST features failed: 422 — ${String(body.error ?? 'These capabilities are not on the current map.')}\n` +
|
|
345
|
+
`unknown_ids: ${JSON.stringify(body.unknown_ids)}\n` +
|
|
346
|
+
`known_ids: ${JSON.stringify(body.known_ids)}`);
|
|
347
|
+
}
|
|
348
|
+
// A refusal the server worded in one sentence: that sentence, whole.
|
|
349
|
+
async function wordedRefusal(res, prefix) {
|
|
350
|
+
const { text, body } = await readBody(res);
|
|
351
|
+
return `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
|
|
352
|
+
}
|
|
353
|
+
// A refusal that may carry `problems` — the knowledge file's shape (spec 52-2,
|
|
354
|
+
// AC 5.3) or a broken seal (spec 52-3): the sentence, then one problem per line
|
|
355
|
+
// with both sides, relaid whole because the host fixes the file from all of
|
|
356
|
+
// them; without problems, the sentence.
|
|
357
|
+
async function problemsRefusal(res, prefix) {
|
|
358
|
+
const { text, body } = await readBody(res);
|
|
359
|
+
const head = `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
|
|
360
|
+
if (!Array.isArray(body.problems))
|
|
361
|
+
return head;
|
|
362
|
+
const lines = body.problems.map((problem) => `expected: ${String(problem.expected)}\n got: ${String(problem.got)}`);
|
|
363
|
+
return [head, ...lines].join('\n');
|
|
364
|
+
}
|
|
365
|
+
// A refusal body as text and, when it is JSON, as an object; when it is not,
|
|
366
|
+
// the text itself is the detail.
|
|
367
|
+
async function readBody(res) {
|
|
368
|
+
let text = '';
|
|
369
|
+
let body = {};
|
|
370
|
+
try {
|
|
371
|
+
text = await res.text();
|
|
372
|
+
body = JSON.parse(text);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// not JSON — the text itself is the detail
|
|
257
376
|
}
|
|
377
|
+
return { text, body };
|
|
258
378
|
}
|
|
259
379
|
// The two statuses that prove somebody else answered.
|
|
260
380
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
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": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.12 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
@@ -160,6 +160,24 @@ genuinely holds a promise and holds less of it than its name suggests. Nothing
|
|
|
160
160
|
mechanical can tell those two apart; what makes the difference is that your
|
|
161
161
|
sentence is specific enough to act on.
|
|
162
162
|
|
|
163
|
+
## A feature's checks
|
|
164
|
+
|
|
165
|
+
The same review, for a smaller candidate: the checks of one feature being
|
|
166
|
+
built (spec 52-4). Then the request is
|
|
167
|
+
`.unitbob/features/<id>/tests-review-request.json`, with the same keys as the
|
|
168
|
+
main suite's plus two — `knowledge_path` and `scenarios` — and the review goes
|
|
169
|
+
to the `output_path` it names, in the same form: `candidate_digest` at the top
|
|
170
|
+
level, `bdd_quality_review` with one entry per Scenario by `case_marker` and
|
|
171
|
+
name. There is no `known_defect_probe` and no `selection_review` here; write
|
|
172
|
+
neither.
|
|
173
|
+
|
|
174
|
+
When the request carries `knowledge_path`, the promise each Scenario protects
|
|
175
|
+
is written in that `knowledge.md`, in its `Scenarios` section — read it there,
|
|
176
|
+
not from the capability description, which for a feature is one line. The
|
|
177
|
+
Scenario text is the user's and sealed; the steps behind it are what you
|
|
178
|
+
judge, exactly as above. Do not read the feature's implementation: whether the
|
|
179
|
+
code is right is the run's question, and the run was made by the connector.
|
|
180
|
+
|
|
163
181
|
## What is not yours
|
|
164
182
|
|
|
165
183
|
**Do not edit the suite.** Not the `.feature` files, not the step definitions,
|