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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
// The revision a connector-made run was taken at, for the server's record:
|
|
3
|
+
// `git rev-parse HEAD`, with `-dirty` when the tree has changes, or a word
|
|
4
|
+
// when there is no git to ask. Read by the known-defect probe (the review)
|
|
5
|
+
// and by the proof of red of a feature's checks (spec 52-3), from one place.
|
|
6
|
+
export function gitRevision(projectRoot) {
|
|
7
|
+
try {
|
|
8
|
+
const options = {
|
|
9
|
+
cwd: projectRoot,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
12
|
+
};
|
|
13
|
+
const head = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
|
|
14
|
+
const dirty = execFileSync('git', ['status', '--porcelain'], options).trim();
|
|
15
|
+
return dirty ? `${head}-dirty` : head;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return 'working-tree';
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/runner/manifest.js
CHANGED
|
@@ -24,7 +24,9 @@ export function withInstalledRunnerVersion(envelope, runner, projectRoot) {
|
|
|
24
24
|
const BEHAVIORAL_DIR = '.unitbob/behavioral';
|
|
25
25
|
// Matches the server's own PINNED_VERSION: a digit, then version characters.
|
|
26
26
|
const PINNED_VERSION = /^[0-9][0-9A-Za-z.\-+]*$/;
|
|
27
|
-
|
|
27
|
+
// Exported for a feature's checks (spec 52-3), which take the runner from the
|
|
28
|
+
// main suite rather than from a selection and still owe the server its version.
|
|
29
|
+
export function installedRunnerVersion(runner, projectRoot) {
|
|
28
30
|
const root = join(projectRoot, BEHAVIORAL_DIR);
|
|
29
31
|
const found = readInstalledVersion(runner, root);
|
|
30
32
|
return found && PINNED_VERSION.test(found) ? found : null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The end of what a run printed, stderr first: enough to see why a runner
|
|
2
|
+
// died, not the whole log. One place for the three verbs that print it
|
|
3
|
+
// (`check`, `run-local`, `put-tests`); `run-local` asks for more because the
|
|
4
|
+
// person is reading it in a loop, `check` files it into a suite error.
|
|
5
|
+
export function outputTail(result, limit) {
|
|
6
|
+
const bits = [];
|
|
7
|
+
if (result.stderr.trim())
|
|
8
|
+
bits.push(result.stderr.trim());
|
|
9
|
+
if (result.stdout.trim())
|
|
10
|
+
bits.push(result.stdout.trim());
|
|
11
|
+
const joined = bits.join('\n');
|
|
12
|
+
return joined.length > limit ? joined.slice(-limit) : joined;
|
|
13
|
+
}
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
//
|
|
8
8
|
// It only records — it never reconciles markers or aggregates capabilities. It
|
|
9
9
|
// is connector-owned and never part of the LLM-generated step definitions.
|
|
10
|
+
//
|
|
11
|
+
// A step with no definition is recorded too (spec 52-3, AC 3.7): pytest-bdd
|
|
12
|
+
// calls `pytest_bdd_step_func_lookup_error` for it and never `after_step`, so
|
|
13
|
+
// without this hook the scenario went into the report as `passed` with no
|
|
14
|
+
// steps — and a feature's checks, which have to prove they are red, could
|
|
15
|
+
// have passed that proof on wiring nobody wrote. The server's parser refuses
|
|
16
|
+
// an `undefined` step as a broken suite, which is the honest answer.
|
|
10
17
|
export const PYTEST_BDD_PLUGIN = `# Written by the unitbob connector — do not edit.
|
|
11
18
|
import json
|
|
12
19
|
import os
|
|
@@ -62,6 +69,14 @@ def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func
|
|
|
62
69
|
_record_step(scenario, step, "failed")
|
|
63
70
|
|
|
64
71
|
|
|
72
|
+
def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception):
|
|
73
|
+
entry = _UNITBOB_CURRENT.get(id(scenario))
|
|
74
|
+
if entry is not None:
|
|
75
|
+
entry["status"] = "failed"
|
|
76
|
+
entry["failure"] = "{}: {}".format(type(exception).__name__, exception)
|
|
77
|
+
_record_step(scenario, step, "undefined")
|
|
78
|
+
|
|
79
|
+
|
|
65
80
|
def pytest_bdd_after_scenario(request, feature, scenario):
|
|
66
81
|
entry = _UNITBOB_CURRENT.pop(id(scenario), None)
|
|
67
82
|
if entry is not None:
|
|
@@ -5,21 +5,40 @@ import { Wire } from "../wire.js";
|
|
|
5
5
|
// The server composes the whole prompt; the connector prints its plain-language
|
|
6
6
|
// `message` and the copy-ready `prompt`. A 422 (not current / not failing /
|
|
7
7
|
// unknown intent) surfaces via WireError; nothing is fabricated.
|
|
8
|
+
//
|
|
9
|
+
// `feature:<id>` stands in for the digest (spec 52-4, AC 1.3): a feature's
|
|
10
|
+
// checks are not on the map to copy one from, so the id is resolved to
|
|
11
|
+
// the digest of the feature's current checks through the same index `check`
|
|
12
|
+
// runs from. The server words the brief itself, and answers 422 to `accept` —
|
|
13
|
+
// a feature's scenarios change through the talk, not through an accept.
|
|
8
14
|
export async function contractPrompt(config, args = [], deps) {
|
|
9
|
-
const
|
|
15
|
+
const selector = (args[0] ?? '').trim();
|
|
10
16
|
const testId = (args[1] ?? '').trim();
|
|
11
17
|
const intent = (args[2] ?? 'fix').trim();
|
|
12
|
-
if (!
|
|
13
|
-
throw new Error('Usage: unitbob contract-prompt <suite_digest> <test_id> [fix|accept]');
|
|
18
|
+
if (!selector || !testId) {
|
|
19
|
+
throw new Error('Usage: unitbob contract-prompt <suite_digest>|feature:<id> <test_id> [fix|accept]');
|
|
14
20
|
}
|
|
15
21
|
if (intent !== 'fix' && intent !== 'accept') {
|
|
16
22
|
throw new Error(`intent must be "fix" or "accept" (got "${intent}").`);
|
|
17
23
|
}
|
|
24
|
+
const wire = new Wire(config);
|
|
18
25
|
const d = {
|
|
19
|
-
getContractPrompt: (digest, id, action) =>
|
|
26
|
+
getContractPrompt: (digest, id, action) => wire.getContractPrompt(digest, id, action),
|
|
27
|
+
getSuiteIndex: () => wire.getSuiteIndex(),
|
|
20
28
|
stdout: process.stdout,
|
|
21
29
|
...deps,
|
|
22
30
|
};
|
|
31
|
+
const suiteDigest = selector.startsWith(FEATURE_SELECTOR) ? await featureDigest(d, selector.slice(FEATURE_SELECTOR.length)) : selector;
|
|
23
32
|
const packet = await d.getContractPrompt(suiteDigest, testId, intent);
|
|
24
33
|
d.stdout.write(`${packet.message}\n\n${packet.prompt}\n`);
|
|
25
34
|
}
|
|
35
|
+
const FEATURE_SELECTOR = 'feature:';
|
|
36
|
+
// The digest of the feature's current checks. Only a feature being built has
|
|
37
|
+
// them in the index: before the checks are written there is nothing to act on,
|
|
38
|
+
// and after Finish they are part of the main suite, on the map like the rest.
|
|
39
|
+
async function featureDigest(d, id) {
|
|
40
|
+
const item = (await d.getSuiteIndex()).feature_suites.find((entry) => String(entry.feature_id) === id);
|
|
41
|
+
if (!item)
|
|
42
|
+
throw new Error(`No feature ${id} has checks to act on — it is not being built, or it is finished.`);
|
|
43
|
+
return item.suite_digest;
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { requestPath, writeFeatureStartRequest } from "../files/featureStart.js";
|
|
2
|
+
import { Wire } from "../wire.js";
|
|
3
|
+
// Fetch the recipe and the behavioral assignment — the product capabilities of
|
|
4
|
+
// the current map, with what each promises and where in the checkout it lives —
|
|
5
|
+
// and write the host's task to `.unitbob/feature-start/request.json` (spec
|
|
6
|
+
// 52-1). One request for the list, the same one `suite-prepare` makes; today's
|
|
7
|
+
// check statuses are not asked for, because they belong on the feature's page
|
|
8
|
+
// and not in the judgement of what the work may touch. No model is called,
|
|
9
|
+
// no source is read, nothing is uploaded. A 409 (no current map) surfaces as a
|
|
10
|
+
// WireError with the server's guidance and nothing is written.
|
|
11
|
+
export async function featurePrepare(config, _args = [], deps) {
|
|
12
|
+
const wire = new Wire(config);
|
|
13
|
+
const d = {
|
|
14
|
+
getRecipe: (name) => wire.getRecipe(name),
|
|
15
|
+
getSuitePacketsBatch: () => wire.getSuitePacketsBatch(),
|
|
16
|
+
stdout: process.stdout,
|
|
17
|
+
...deps,
|
|
18
|
+
};
|
|
19
|
+
const [recipe, packets] = await Promise.all([d.getRecipe('feature_intent'), d.getSuitePacketsBatch()]);
|
|
20
|
+
const behavioral = packets.find((packet) => packet.suite_kind === 'behavioral');
|
|
21
|
+
if (!behavioral) {
|
|
22
|
+
throw new Error('The Unitbob server sent no behavioral assignment with the suite packets — it is older than this connector. ' +
|
|
23
|
+
'Update the server (or the connector) so the two agree, then retry.');
|
|
24
|
+
}
|
|
25
|
+
writeFeatureStartRequest(config.projectRoot, recipe, capabilitiesOf(behavioral));
|
|
26
|
+
d.stdout.write(`Feature request written to ${requestPath(config.projectRoot)}\n`);
|
|
27
|
+
}
|
|
28
|
+
// The six fields the recipe names, in the shape it names them. The contract
|
|
29
|
+
// identity (`contract_key`, `case_marker`) rides in the assignment for the
|
|
30
|
+
// suite recipe and is not copied: the host is naming what may be touched, not
|
|
31
|
+
// writing tests.
|
|
32
|
+
function capabilitiesOf(packet) {
|
|
33
|
+
const assignment = (packet.assignment ?? {});
|
|
34
|
+
const list = Array.isArray(assignment.capabilities) ? assignment.capabilities : [];
|
|
35
|
+
return list.map((entry) => {
|
|
36
|
+
const item = (typeof entry === 'object' && entry !== null ? entry : {});
|
|
37
|
+
return {
|
|
38
|
+
capability_id: String(item.capability_id ?? ''),
|
|
39
|
+
title: String(item.title ?? ''),
|
|
40
|
+
description: String(item.description ?? ''),
|
|
41
|
+
surfaces: strings(item.surfaces),
|
|
42
|
+
tables: strings(item.tables),
|
|
43
|
+
externals: strings(item.externals),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function strings(value) {
|
|
48
|
+
return Array.isArray(value) ? value.map((one) => String(one)) : [];
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { knowledgeRequestPath, parseFeatureId, writeKnowledgeRequest } from "../files/features.js";
|
|
2
|
+
import { Wire } from "../wire.js";
|
|
3
|
+
// The statuses a feature can be talked through in: not yet talked through,
|
|
4
|
+
// talked through and open to a second talk (spec 52-2, AC 2.1), or its checks
|
|
5
|
+
// written — a talk then withdraws them, out loud (spec 52-3, AC 2.9).
|
|
6
|
+
const TALKABLE = new Set(['intent', 'knowledge', 'red']);
|
|
7
|
+
// Two forms (spec 52-2, AC 2.1). Without an id, the list: one line per feature
|
|
8
|
+
// the talk can start on, so the host finds the one the person means by its
|
|
9
|
+
// words — and the server's own sentence when there is none; the connector
|
|
10
|
+
// words no state of its own. With an id, the host's task: the recipe and the
|
|
11
|
+
// packet the server built, written to `.unitbob/features/<id>/request.json`
|
|
12
|
+
// next to where `knowledge.md` will go. No model is called, nothing is
|
|
13
|
+
// uploaded.
|
|
14
|
+
export async function knowledgePrepare(config, args = [], deps) {
|
|
15
|
+
const wire = new Wire(config);
|
|
16
|
+
const d = {
|
|
17
|
+
listFeatures: () => wire.listFeatures(),
|
|
18
|
+
getRecipe: (name) => wire.getRecipe(name),
|
|
19
|
+
getKnowledgePacket: (id) => wire.getKnowledgePacket(id),
|
|
20
|
+
stdout: process.stdout,
|
|
21
|
+
...deps,
|
|
22
|
+
};
|
|
23
|
+
if (args.length === 0) {
|
|
24
|
+
const list = await d.listFeatures();
|
|
25
|
+
const talkable = list.features.filter((feature) => TALKABLE.has(feature.status));
|
|
26
|
+
if (talkable.length === 0) {
|
|
27
|
+
d.stdout.write(`${list.empty_text}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const feature of talkable) {
|
|
31
|
+
d.stdout.write(`${feature.feature_id} ${feature.title} (${feature.status})\n`);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const featureId = parseFeatureId(args[0], 'knowledge-prepare');
|
|
36
|
+
const [recipe, packet] = await Promise.all([d.getRecipe('feature_grill'), d.getKnowledgePacket(featureId)]);
|
|
37
|
+
writeKnowledgeRequest(config.projectRoot, featureId, recipe, packet);
|
|
38
|
+
d.stdout.write(`Knowledge request written to ${knowledgeRequestPath(config.projectRoot, featureId)}\n`);
|
|
39
|
+
}
|
package/dist/verbs/mapPrepare.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ensureUnitbobIgnored, ignoreExclusions, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
|
|
2
2
|
import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
|
|
3
3
|
import { describeRouteInventory, extractRouteInventory, } from "../surfaces/routeInventory.js";
|
|
4
|
-
import { Wire } from "../wire.js";
|
|
4
|
+
import { Wire, WireError } from "../wire.js";
|
|
5
5
|
export async function mapPrepare(config, _args = [], deps) {
|
|
6
6
|
const wire = new Wire(config);
|
|
7
7
|
const actual = {
|
|
@@ -10,7 +10,9 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
10
10
|
runGraphifyExtractKeyless,
|
|
11
11
|
extractRouteInventory,
|
|
12
12
|
getRecipe: (name) => wire.getRecipe(name),
|
|
13
|
+
listFeatures: () => wire.listFeatures(),
|
|
13
14
|
stdout: process.stdout,
|
|
15
|
+
stderr: process.stderr,
|
|
14
16
|
...deps,
|
|
15
17
|
};
|
|
16
18
|
actual.ensureUnitbobIgnored(config.projectRoot);
|
|
@@ -44,18 +46,19 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
44
46
|
// us there reads as a hang.
|
|
45
47
|
actual.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
|
|
46
48
|
const inventory = await actual.extractRouteInventory(config.projectRoot);
|
|
47
|
-
const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
|
|
49
|
+
const [decompose, relate, extractSurfaces, decomposeSurfaces, existing] = await Promise.all([
|
|
48
50
|
actual.getRecipe('decompose'),
|
|
49
51
|
actual.getRecipe('relate'),
|
|
50
52
|
actual.getRecipe('extract_surfaces'),
|
|
51
53
|
actual.getRecipe('decompose_surfaces'),
|
|
54
|
+
existingCapabilities(actual),
|
|
52
55
|
]);
|
|
53
56
|
const packet = writeMapBuildRequest(config.projectRoot, {
|
|
54
57
|
decompose,
|
|
55
58
|
relate,
|
|
56
59
|
extract_surfaces: extractSurfaces,
|
|
57
60
|
decompose_surfaces: decomposeSurfaces,
|
|
58
|
-
}, inventory.status === 'written' ? inventory.path : undefined);
|
|
61
|
+
}, inventory.status === 'written' ? inventory.path : undefined, existing);
|
|
59
62
|
actual.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
|
|
60
63
|
actual.stdout.write(`${describeRouteInventory(inventory)}\n`);
|
|
61
64
|
actual.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
|
|
@@ -63,3 +66,26 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
63
66
|
`${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
|
|
64
67
|
'recipes.decompose_surfaces) — then run `unitbob put-map-build`.\n');
|
|
65
68
|
}
|
|
69
|
+
// The capabilities finished features added to the map (spec 52-4, AC 5.1):
|
|
70
|
+
// by id, title and the intent as said — no addresses, the recipe matches by
|
|
71
|
+
// meaning. Only finished features: an open one is not on the map yet. A
|
|
72
|
+
// server older than the fields sends rows without a capability id, and one
|
|
73
|
+
// older than the route answers 404; both read as none, quietly, and the
|
|
74
|
+
// recipe skips its paragraph (AC 7.3). Any other failure of the list is not
|
|
75
|
+
// a reason to stop a map build either — but it is said, on stderr, because a
|
|
76
|
+
// map built without the finished features' ids is one that may redraw them.
|
|
77
|
+
async function existingCapabilities(d) {
|
|
78
|
+
let features;
|
|
79
|
+
try {
|
|
80
|
+
features = (await d.listFeatures()).features;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (!(err instanceof WireError && err.status === 404)) {
|
|
84
|
+
d.stderr.write(`Could not list finished features — the map is built without them: ${err.message}\n`);
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return features
|
|
89
|
+
.filter((feature) => feature.status === 'done' && typeof feature.capability_id === 'string')
|
|
90
|
+
.map((feature) => ({ id: feature.capability_id, title: feature.title, description: feature.intent ?? '' }));
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFeatureAnswer } from "../files/featureStart.js";
|
|
2
|
+
import { enterUrl } from "../links.js";
|
|
3
|
+
import { Wire } from "../wire.js";
|
|
4
|
+
// Read the host's answer and record the feature (spec 52-1). The server checks
|
|
5
|
+
// every id against the current map and words the sentence; this prints it and
|
|
6
|
+
// the link to the feature's page through the exchanger, like every link a
|
|
7
|
+
// person gets from the terminal (spec 33). A 422 — an id not on the map — is
|
|
8
|
+
// let through as a WireError with both id lists in its text, so the host
|
|
9
|
+
// corrects its file and runs this again. Nothing is created locally: the
|
|
10
|
+
// feature's folder, its knowledge file and its tests belong to later specs.
|
|
11
|
+
export async function putFeature(config, _args = [], deps) {
|
|
12
|
+
const d = {
|
|
13
|
+
postFeature: (payload) => new Wire(config).postFeature(payload),
|
|
14
|
+
stdout: process.stdout,
|
|
15
|
+
...deps,
|
|
16
|
+
};
|
|
17
|
+
const answer = readFeatureAnswer(config.projectRoot);
|
|
18
|
+
const recorded = await d.postFeature(answer);
|
|
19
|
+
d.stdout.write(`${recorded.message}\n`);
|
|
20
|
+
d.stdout.write(`${enterUrl(config, recorded.url)}\n`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseFeatureId, readKnowledge } from "../files/features.js";
|
|
2
|
+
import { enterUrl } from "../links.js";
|
|
3
|
+
import { Wire } from "../wire.js";
|
|
4
|
+
// Send `knowledge.md` as text (spec 52-2, AC 2.2) and print the server's
|
|
5
|
+
// sentence and the link to the feature's page through the exchanger, like
|
|
6
|
+
// every link a person gets from the terminal (spec 33). The shape is checked
|
|
7
|
+
// on the server only (Non-Goals); a 422 comes back through the wire as a
|
|
8
|
+
// WireError whose text already holds one line per problem, both sides, and
|
|
9
|
+
// this lets it through so the host fixes the file and runs this again.
|
|
10
|
+
export async function putKnowledge(config, args = [], deps) {
|
|
11
|
+
const d = {
|
|
12
|
+
putKnowledge: (id, knowledge) => new Wire(config).putKnowledge(id, knowledge),
|
|
13
|
+
stdout: process.stdout,
|
|
14
|
+
...deps,
|
|
15
|
+
};
|
|
16
|
+
const featureId = parseFeatureId(args[0], 'put-knowledge');
|
|
17
|
+
const text = readKnowledge(config.projectRoot, featureId);
|
|
18
|
+
const recorded = await d.putKnowledge(featureId, text);
|
|
19
|
+
d.stdout.write(`${recorded.message}\n`);
|
|
20
|
+
d.stdout.write(`${enterUrl(config, recorded.url)}\n`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { knowledgePath, parseFeatureId, readKnowledge, readTestsOutput, readTestsRequest, readTestsReviewOutput, testsReviewOutputPath, } from "../files/features.js";
|
|
3
|
+
import { suiteCandidateDigest } from "../files/suiteBuild.js";
|
|
4
|
+
import { enterUrl } from "../links.js";
|
|
5
|
+
import { runBddSuite } from "../runner/bdd.js";
|
|
6
|
+
import { boundReport } from "../runner/boundReport.js";
|
|
7
|
+
import { scenarioTally } from "../runner/failureDigest.js";
|
|
8
|
+
import { gitRevision } from "../runner/gitRevision.js";
|
|
9
|
+
import { placeAdvice } from "../runner/placeAdvice.js";
|
|
10
|
+
import { placeProblem } from "../runner/place.js";
|
|
11
|
+
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
12
|
+
import { outputTail } from "../runner/outputTail.js";
|
|
13
|
+
import { Wire } from "../wire.js";
|
|
14
|
+
const OUTPUT_TAIL_CHARS = 2000;
|
|
15
|
+
// The digest of `knowledge.md` on disk against the one the request was written
|
|
16
|
+
// from — both named when they differ, so the reader sees which side moved.
|
|
17
|
+
export function assertKnowledgeUnchanged(projectRoot, featureId, expected) {
|
|
18
|
+
const got = createHash('sha256').update(readKnowledge(projectRoot, featureId)).digest('hex');
|
|
19
|
+
if (got === expected)
|
|
20
|
+
return;
|
|
21
|
+
throw new Error(`${knowledgePath(projectRoot, featureId)}: knowledge.md on disk differs from what the server has — ` +
|
|
22
|
+
`run put-knowledge first, or restore the file, then run this again.\nexpected: ${expected}\n got: ${got}`);
|
|
23
|
+
}
|
|
24
|
+
// `put-tests <feature_id>` (spec 52-3, AC 3.5). The answer is read with its
|
|
25
|
+
// files from disk; the candidate digest is the one the review uses; then the
|
|
26
|
+
// connector runs the feature's tag itself — a report the host already has may
|
|
27
|
+
// be from earlier and over other files, and the proof has to be over these
|
|
28
|
+
// bytes. A runner that could not start or produced no report sends nothing:
|
|
29
|
+
// what it said is printed, and the exit code is non-zero. A 409 or 422 comes
|
|
30
|
+
// back as a WireError already worded by the server, one line per side, and is
|
|
31
|
+
// let through as it is.
|
|
32
|
+
//
|
|
33
|
+
// What the run proves decides what travels with it (spec 52-4, AC 1.10). All
|
|
34
|
+
// red: the run itself as `red_run`, revision and all, the way the known-defect
|
|
35
|
+
// probe sends its own. All green with the reviewer's file beside the answer:
|
|
36
|
+
// that file as `bdd_quality_review`, bound to this candidate. Anything else:
|
|
37
|
+
// no proof — the harness was saved mid-build. A review over a run that is not
|
|
38
|
+
// all green is not sent at all; a review of an older candidate is ignored out
|
|
39
|
+
// loud. And once the server has taken the version, the same run is filed
|
|
40
|
+
// under its digest, so the feature's page shows "N of M checks pass" on it at
|
|
41
|
+
// once rather than on the version before.
|
|
42
|
+
export async function putTests(config, args = [], deps) {
|
|
43
|
+
const wire = new Wire(config);
|
|
44
|
+
const d = {
|
|
45
|
+
runBehavioral: runBddSuite,
|
|
46
|
+
gitRevision,
|
|
47
|
+
putFeatureSuite: (id, upload) => wire.putFeatureSuite(id, upload),
|
|
48
|
+
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
49
|
+
stdout: process.stdout,
|
|
50
|
+
...deps,
|
|
51
|
+
};
|
|
52
|
+
const featureId = parseFeatureId(args[0], 'put-tests');
|
|
53
|
+
const request = readTestsRequest(config.projectRoot, featureId);
|
|
54
|
+
const output = readTestsOutput(config.projectRoot, featureId);
|
|
55
|
+
// The same check `tests-prepare` made, made again here: the file may have
|
|
56
|
+
// been edited in between, and the checks are sealed to the text the server
|
|
57
|
+
// has, not to the text on disk (spec 52-3, edge cases).
|
|
58
|
+
assertKnowledgeUnchanged(config.projectRoot, featureId, request.knowledge_digest);
|
|
59
|
+
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
60
|
+
if (unusable)
|
|
61
|
+
throw new Error(unusable);
|
|
62
|
+
// The review file, before the run: one the connector cannot read is a
|
|
63
|
+
// stop, said in one line, and a run before it would be a run for nothing.
|
|
64
|
+
const candidateDigest = suiteCandidateDigest({
|
|
65
|
+
suite_kind: 'behavioral', suite_file: output.suite_file, runner_manifest: output.runner_manifest,
|
|
66
|
+
});
|
|
67
|
+
let review;
|
|
68
|
+
try {
|
|
69
|
+
review = readTestsReviewOutput(config.projectRoot, featureId);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
d.stdout.write(`${err.message}\n`);
|
|
73
|
+
d.stdout.write('Nothing was sent.\n');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
if (review && review.candidate_digest !== candidateDigest) {
|
|
77
|
+
d.stdout.write(`${testsReviewOutputPath(config.projectRoot, featureId)} is for an older candidate — ignored.\n`);
|
|
78
|
+
review = null;
|
|
79
|
+
}
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = await d.runBehavioral(config.projectRoot, request.runner, request.feature_path, { only: request.feature_tag });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const advice = placeAdvice(config.projectRoot);
|
|
86
|
+
d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
87
|
+
d.stdout.write('Nothing was sent.\n');
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const report = boundReport(request.runner, result);
|
|
91
|
+
if (report === null) {
|
|
92
|
+
d.stdout.write(`The run produced no machine-readable report at ${result.resultPath} (exit code ${result.code}) — ` +
|
|
93
|
+
'it died before the first scenario rather than failing them.\n');
|
|
94
|
+
const tail = outputTail(result, OUTPUT_TAIL_CHARS);
|
|
95
|
+
if (tail)
|
|
96
|
+
d.stdout.write(`${tail}\n`);
|
|
97
|
+
d.stdout.write('Nothing was sent.\n');
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const tally = scenarioTally(request.runner, result.report);
|
|
101
|
+
const allRed = tally !== null && tally.passed === 0 && tally.failed > 0;
|
|
102
|
+
const allGreen = tally !== null && tally.failed === 0 && tally.passed > 0;
|
|
103
|
+
if (review && !allGreen) {
|
|
104
|
+
d.stdout.write(`Review the checks when they all pass — ${stillFailing(tally)}.\n`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const proof = review
|
|
108
|
+
? { bdd_quality_review: { ...review.bdd_quality_review, candidate_digest: candidateDigest } }
|
|
109
|
+
: allRed
|
|
110
|
+
? { red_run: { candidate_digest: candidateDigest, revision: d.gitRevision(config.projectRoot), run_result: report } }
|
|
111
|
+
: {};
|
|
112
|
+
const recorded = await d.putFeatureSuite(featureId, {
|
|
113
|
+
suite_file: output.suite_file,
|
|
114
|
+
runner_manifest: output.runner_manifest,
|
|
115
|
+
test_metadata: { ...output.test_metadata, ...proof },
|
|
116
|
+
knowledge_digest: request.knowledge_digest,
|
|
117
|
+
});
|
|
118
|
+
// Said before the run is filed: the upload is done whatever happens next,
|
|
119
|
+
// and a filing that fails leaves the run to the next `check`.
|
|
120
|
+
d.stdout.write(`${recorded.message}\n`);
|
|
121
|
+
const { results } = await d.postRunsBatch([{ suite_digest: recorded.suite_digest, run_result: report }]);
|
|
122
|
+
for (const item of results)
|
|
123
|
+
d.stdout.write(`${item.summary}\n`);
|
|
124
|
+
d.stdout.write(`${enterUrl(config, recorded.url)}\n`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
// "2 of 5 still fail" — or, for a report the connector could not count, only
|
|
128
|
+
// that it could not.
|
|
129
|
+
function stillFailing(tally) {
|
|
130
|
+
if (tally === null)
|
|
131
|
+
return 'the run’s report could not be read scenario by scenario';
|
|
132
|
+
return `${tally.failed} of ${tally.passed + tally.failed} still fail`;
|
|
133
|
+
}
|
package/dist/verbs/run.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
|
-
import {
|
|
2
|
+
import { FeatureFilesChangedError, materializeBehavioralUnion } from "../files/behavioral.js";
|
|
3
3
|
import { placeProblem } from "../runner/place.js";
|
|
4
4
|
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
5
5
|
import { validateStack } from "../runner/precheck.js";
|
|
@@ -7,6 +7,7 @@ import { runRspecSuite } from "../runner/rspec.js";
|
|
|
7
7
|
import { runVitestSuite, testPathsOf } from "../runner/vitest.js";
|
|
8
8
|
import { runPytestSuite } from "../runner/pytest.js";
|
|
9
9
|
import { runBddSuite } from "../runner/bdd.js";
|
|
10
|
+
import { outputTail } from "../runner/outputTail.js";
|
|
10
11
|
import { enterUrl } from "../links.js";
|
|
11
12
|
import { boundReport } from "../runner/boundReport.js";
|
|
12
13
|
import { Wire } from "../wire.js";
|
|
@@ -27,7 +28,7 @@ export async function runOnly(config, digests, deps) {
|
|
|
27
28
|
function resolve(config, deps) {
|
|
28
29
|
const wire = new Wire(config);
|
|
29
30
|
return {
|
|
30
|
-
|
|
31
|
+
getSuiteIndex: () => wire.getSuiteIndex(),
|
|
31
32
|
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
32
33
|
// The whole envelope, support files and all: a branch is a set of files
|
|
33
34
|
// since spec one-place-per-rule, §6, and picking `path` and `content` out of it here was
|
|
@@ -37,7 +38,9 @@ function resolve(config, deps) {
|
|
|
37
38
|
suite_file: item.suite_file,
|
|
38
39
|
runner_manifest: item.runner_manifest,
|
|
39
40
|
}),
|
|
40
|
-
|
|
41
|
+
// The union is never empty here: the caller only asks once the behavioral
|
|
42
|
+
// peer is ready or a feature has checks, so at least one envelope is in it.
|
|
43
|
+
materializeBehavioral: (projectRoot, index, runner) => materializeBehavioralUnion(projectRoot, index, runner),
|
|
41
44
|
runStructural: runStructuralByRunner,
|
|
42
45
|
runBehavioral: runBddSuite,
|
|
43
46
|
validateStack,
|
|
@@ -52,16 +55,24 @@ async function execute(config, d, only) {
|
|
|
52
55
|
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
53
56
|
if (unusable)
|
|
54
57
|
throw new Error(`${unusable}\nNothing was run and no results were filed.`);
|
|
55
|
-
const
|
|
56
|
-
const ready = suites.filter((item) => item.status === 'ready');
|
|
58
|
+
const index = await d.getSuiteIndex();
|
|
59
|
+
const ready = index.suites.filter((item) => item.status === 'ready');
|
|
57
60
|
const selected = only === null ? ready : select(ready, only);
|
|
58
|
-
|
|
61
|
+
// The features' checks ride with the behavioral branch: every run of `check`
|
|
62
|
+
// has them, and the first run after publishing has them when the behavioral
|
|
63
|
+
// branch is what was published — structural alone has no union to run on.
|
|
64
|
+
const features = only === null || selected.some((item) => item.suite_kind === 'behavioral') ? index.feature_suites : [];
|
|
65
|
+
if (selected.length === 0 && features.length === 0) {
|
|
59
66
|
d.stdout.write('No Unitbob suites exist yet. Generate them first, then run the Unitbob checks again.\n');
|
|
60
67
|
return;
|
|
61
68
|
}
|
|
69
|
+
const unionFor = unionOnce(config, d, index);
|
|
62
70
|
const runs = [];
|
|
63
71
|
for (const item of selected) {
|
|
64
|
-
runs.push(await buildRunPayload(config, d, item));
|
|
72
|
+
runs.push(await buildRunPayload(config, d, item, unionFor));
|
|
73
|
+
}
|
|
74
|
+
for (const item of features) {
|
|
75
|
+
runs.push(await buildFeatureRunPayload(config, d, item, unionFor));
|
|
65
76
|
}
|
|
66
77
|
const { results, map_url } = await d.postRunsBatch(runs);
|
|
67
78
|
for (const result of results)
|
|
@@ -86,48 +97,88 @@ function select(ready, wanted) {
|
|
|
86
97
|
}
|
|
87
98
|
return wanted.map((digest) => byDigest.get(digest));
|
|
88
99
|
}
|
|
100
|
+
function unionOnce(config, d, index) {
|
|
101
|
+
let union = null;
|
|
102
|
+
return (runner) => {
|
|
103
|
+
if (union)
|
|
104
|
+
return union;
|
|
105
|
+
try {
|
|
106
|
+
union = d.materializeBehavioral(config.projectRoot, index, runner);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// Not a branch's error to file and move past: the union refused to wipe
|
|
110
|
+
// a feature's rewired checks (spec 52-4, AC 1.8). Nothing was run,
|
|
111
|
+
// nothing is posted, and the sentence reaches the terminal through
|
|
112
|
+
// `cli.ts`.
|
|
113
|
+
if (err instanceof FeatureFilesChangedError)
|
|
114
|
+
throw err;
|
|
115
|
+
union = { error: err.message };
|
|
116
|
+
}
|
|
117
|
+
return union;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
89
120
|
// One branch's run payload. A stack mismatch, a materialize failure, or a runner
|
|
90
121
|
// that produced no report all become this branch's structured suite error — the
|
|
91
122
|
// peer branch is unaffected. This connector never installs anything: a missing
|
|
92
123
|
// or broken runner surfaces here as a suite error, not an install.
|
|
93
|
-
async function buildRunPayload(config, d, item) {
|
|
124
|
+
async function buildRunPayload(config, d, item, unionFor) {
|
|
94
125
|
const runner = item.runner_manifest.runner;
|
|
95
|
-
const
|
|
126
|
+
const digest = item.suite_digest;
|
|
96
127
|
// Confirm the local stack before touching the tree, for both contract systems.
|
|
97
128
|
// A mismatch is this branch's suite error — reported and left for the peer
|
|
98
129
|
// branch to run regardless. The behavioral check confirms only the base
|
|
99
130
|
// language; a missing BDD runner still surfaces from the run itself, since
|
|
100
131
|
// check installs nothing.
|
|
101
132
|
const check = d.validateStack(config.projectRoot, runner);
|
|
133
|
+
if (!check.ok)
|
|
134
|
+
return suiteError(digest, check.message ?? `Local project does not match "${runner}".`);
|
|
135
|
+
if (item.suite_kind !== 'behavioral') {
|
|
136
|
+
return filed(runner, digest, async () => {
|
|
137
|
+
d.materializeStructural(config.projectRoot, item);
|
|
138
|
+
return d.runStructural(config.projectRoot, runner, artifactPaths(item.suite_file));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const union = unionFor(runner);
|
|
142
|
+
if ('error' in union)
|
|
143
|
+
return suiteError(digest, union.error);
|
|
144
|
+
return filed(runner, digest, () => d.runBehavioral(config.projectRoot, runner, union.mainPath, { exclude: union.excludeTags }));
|
|
145
|
+
}
|
|
146
|
+
// One feature's run payload (spec 52-4, AC 1.1): the same checks a branch
|
|
147
|
+
// gets, the same union, only its own tag — and its own suite error when it
|
|
148
|
+
// cannot run, which stops nothing else.
|
|
149
|
+
async function buildFeatureRunPayload(config, d, item, unionFor) {
|
|
150
|
+
const runner = item.runner_manifest.runner;
|
|
151
|
+
const check = d.validateStack(config.projectRoot, runner);
|
|
102
152
|
if (!check.ok)
|
|
103
153
|
return suiteError(item.suite_digest, check.message ?? `Local project does not match "${runner}".`);
|
|
154
|
+
const union = unionFor(runner);
|
|
155
|
+
if ('error' in union)
|
|
156
|
+
return suiteError(item.suite_digest, union.error);
|
|
157
|
+
return filed(runner, item.suite_digest, () => d.runBehavioral(config.projectRoot, runner, union.mainPath, { only: item.feature_tag }));
|
|
158
|
+
}
|
|
159
|
+
// The run itself, filed as this branch's payload: a runner that could not
|
|
160
|
+
// start or a report that cannot be read is its structured suite error.
|
|
161
|
+
async function filed(runner, digest, execute) {
|
|
104
162
|
let result;
|
|
105
163
|
try {
|
|
106
|
-
|
|
107
|
-
const mainPath = d.materializeBehavioral(config.projectRoot, item);
|
|
108
|
-
result = await d.runBehavioral(config.projectRoot, runner, mainPath);
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
d.materializeStructural(config.projectRoot, item);
|
|
112
|
-
result = await d.runStructural(config.projectRoot, runner, artifactPaths(item.suite_file));
|
|
113
|
-
}
|
|
164
|
+
result = await execute();
|
|
114
165
|
}
|
|
115
166
|
catch (err) {
|
|
116
|
-
return suiteError(
|
|
167
|
+
return suiteError(digest, err.message);
|
|
117
168
|
}
|
|
118
169
|
const report = boundReport(runner, result);
|
|
119
170
|
if (report === null) {
|
|
120
171
|
return {
|
|
121
|
-
suite_digest:
|
|
172
|
+
suite_digest: digest,
|
|
122
173
|
suite_error: {
|
|
123
174
|
command: [result.command, ...result.args].join(' '),
|
|
124
175
|
exit_code: result.code,
|
|
125
176
|
result_path: result.resultPath,
|
|
126
|
-
output_tail: outputTail(result),
|
|
177
|
+
output_tail: outputTail(result, OUTPUT_TAIL_CHARS),
|
|
127
178
|
},
|
|
128
179
|
};
|
|
129
180
|
}
|
|
130
|
-
return { suite_digest:
|
|
181
|
+
return { suite_digest: digest, run_result: report };
|
|
131
182
|
}
|
|
132
183
|
// Exported for `run-local`, which runs these same strategies against the files
|
|
133
184
|
// the host just wrote rather than against a published suite. One dispatch table,
|
|
@@ -159,12 +210,3 @@ function suiteError(suiteDigest, message) {
|
|
|
159
210
|
suite_error: { command: '', exit_code: null, result_path: '', output_tail: message },
|
|
160
211
|
};
|
|
161
212
|
}
|
|
162
|
-
function outputTail(result) {
|
|
163
|
-
const bits = [];
|
|
164
|
-
if (result.stderr.trim())
|
|
165
|
-
bits.push(result.stderr.trim());
|
|
166
|
-
if (result.stdout.trim())
|
|
167
|
-
bits.push(result.stdout.trim());
|
|
168
|
-
const joined = bits.join('\n');
|
|
169
|
-
return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
|
|
170
|
-
}
|