unitbob 0.7.8 → 0.7.13
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/README.md +88 -80
- package/dist/cli.js +55 -5
- 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/fixPrepare.js +21 -9
- 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 +134 -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/files/mapBuild.js
CHANGED
|
@@ -28,7 +28,7 @@ export function readFreshGraph(projectRoot) {
|
|
|
28
28
|
parseJson(rawGraphJson, path);
|
|
29
29
|
return rawGraphJson;
|
|
30
30
|
}
|
|
31
|
-
export function writeMapBuildRequest(projectRoot, recipes, routeInventoryPath) {
|
|
31
|
+
export function writeMapBuildRequest(projectRoot, recipes, routeInventoryPath, existingCapabilities = []) {
|
|
32
32
|
const packet = {
|
|
33
33
|
project_root: projectRoot,
|
|
34
34
|
graph_path: graphPath(projectRoot),
|
|
@@ -37,6 +37,7 @@ export function writeMapBuildRequest(projectRoot, recipes, routeInventoryPath) {
|
|
|
37
37
|
surface_output_path: surfaceOutputPath(projectRoot),
|
|
38
38
|
...(routeInventoryPath ? { route_inventory_path: routeInventoryPath } : {}),
|
|
39
39
|
recipes,
|
|
40
|
+
existing_capabilities: existingCapabilities,
|
|
40
41
|
};
|
|
41
42
|
const path = requestPath(projectRoot);
|
|
42
43
|
mkdirSync(dirname(path), { recursive: true });
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -369,12 +369,13 @@ function isRunEvidence(value, digest) {
|
|
|
369
369
|
typeof run.revision === 'string' && run.revision &&
|
|
370
370
|
typeof run.run_result === 'string' && run.run_result);
|
|
371
371
|
}
|
|
372
|
-
export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }) {
|
|
372
|
+
export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }, excludeFeatureTags = []) {
|
|
373
373
|
const request = {
|
|
374
374
|
project_root: projectRoot,
|
|
375
375
|
output_path: outputPath(projectRoot),
|
|
376
376
|
branches,
|
|
377
377
|
known_defect_context: knownDefectContext,
|
|
378
|
+
exclude_feature_tags: excludeFeatureTags,
|
|
378
379
|
};
|
|
379
380
|
const path = requestPath(projectRoot);
|
|
380
381
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -421,6 +422,9 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
421
422
|
return {
|
|
422
423
|
...request,
|
|
423
424
|
known_defect_context: readKnownDefectContext(request.known_defect_context, path),
|
|
425
|
+
exclude_feature_tags: Array.isArray(request.exclude_feature_tags)
|
|
426
|
+
? request.exclude_feature_tags.filter((tag) => typeof tag === 'string')
|
|
427
|
+
: [],
|
|
424
428
|
};
|
|
425
429
|
}
|
|
426
430
|
function readKnownDefectContext(value, path) {
|
|
@@ -533,7 +537,10 @@ function readBranch(entry, rootFor, path, projectRoot) {
|
|
|
533
537
|
// file already under that path is the answer". Re-serializing a whole suite into
|
|
534
538
|
// this JSON on every rebuild was the single largest cost in the build loop, and
|
|
535
539
|
// the copy was never more trustworthy than the file that was actually executed.
|
|
536
|
-
|
|
540
|
+
//
|
|
541
|
+
// Exported for a feature's answer (spec 52-3), which is one branch of the same
|
|
542
|
+
// shape read from its own file.
|
|
543
|
+
export function resolveSuiteFile(file, root, path, suiteKind, projectRoot) {
|
|
537
544
|
if (!file || typeof file !== 'object') {
|
|
538
545
|
throw new Error(`${path}: the ${suiteKind} branch is missing suite_file.`);
|
|
539
546
|
}
|
package/dist/runner/bdd.js
CHANGED
|
@@ -56,7 +56,7 @@ const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file i
|
|
|
56
56
|
// fourth runner cannot be added with only half of itself stated.
|
|
57
57
|
const BDD_STRATEGIES = {
|
|
58
58
|
cucumber: {
|
|
59
|
-
run: (projectRoot) => runCucumberRuby(projectRoot),
|
|
59
|
+
run: (projectRoot, _mainPath, filter) => runCucumberRuby(projectRoot, filter),
|
|
60
60
|
loading: {
|
|
61
61
|
step_files: '*.rb',
|
|
62
62
|
requirements: [
|
|
@@ -68,7 +68,7 @@ const BDD_STRATEGIES = {
|
|
|
68
68
|
},
|
|
69
69
|
},
|
|
70
70
|
'cucumber-js': {
|
|
71
|
-
run: (projectRoot) => runCucumberJs(projectRoot),
|
|
71
|
+
run: (projectRoot, _mainPath, filter) => runCucumberJs(projectRoot, filter),
|
|
72
72
|
loading: {
|
|
73
73
|
step_files: '*.js',
|
|
74
74
|
requirements: [
|
|
@@ -84,7 +84,7 @@ const BDD_STRATEGIES = {
|
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
86
|
'pytest-bdd': {
|
|
87
|
-
run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
|
|
87
|
+
run: (projectRoot, mainPath, filter) => runPytestBdd(projectRoot, mainPath, filter),
|
|
88
88
|
loading: {
|
|
89
89
|
step_files: 'test_*.py',
|
|
90
90
|
requirements: [
|
|
@@ -100,12 +100,26 @@ const BDD_STRATEGIES = {
|
|
|
100
100
|
},
|
|
101
101
|
},
|
|
102
102
|
};
|
|
103
|
-
export function runBddSuite(projectRoot, runner, mainPath) {
|
|
103
|
+
export function runBddSuite(projectRoot, runner, mainPath, filter) {
|
|
104
104
|
const strategy = strategyFor(runner);
|
|
105
105
|
if (!strategy) {
|
|
106
106
|
return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
|
|
107
107
|
}
|
|
108
|
-
return strategy.run(projectRoot, mainPath);
|
|
108
|
+
return strategy.run(projectRoot, mainPath, filter);
|
|
109
|
+
}
|
|
110
|
+
// The filter as a tag expression. Both Cucumbers read `@tag`; pytest-bdd turns
|
|
111
|
+
// a tag into a marker of the same name and reads `-m`, where the `@` would be
|
|
112
|
+
// a syntax error. Empty for no filter, so the caller adds no flag at all.
|
|
113
|
+
function tagExpression(filter, prefix) {
|
|
114
|
+
if (!filter)
|
|
115
|
+
return '';
|
|
116
|
+
if ('only' in filter)
|
|
117
|
+
return `${prefix}${filter.only}`;
|
|
118
|
+
return filter.exclude.map((tag) => `not ${prefix}${tag}`).join(' and ');
|
|
119
|
+
}
|
|
120
|
+
function tagArgs(filter, flag, prefix) {
|
|
121
|
+
const expression = tagExpression(filter, prefix);
|
|
122
|
+
return expression ? [flag, expression] : [];
|
|
109
123
|
}
|
|
110
124
|
// How this runner loads step files, for whoever has to write one. Null for a
|
|
111
125
|
// runner this connector does not run, which is the same answer `runBddSuite`
|
|
@@ -122,7 +136,7 @@ function strategyFor(runner) {
|
|
|
122
136
|
// Ruby: `cucumber` with the built-in message formatter. The features and step
|
|
123
137
|
// definitions both live under the behavioral root; --require points at the step
|
|
124
138
|
// definitions so only the Unitbob bundle loads.
|
|
125
|
-
async function runCucumberRuby(projectRoot) {
|
|
139
|
+
async function runCucumberRuby(projectRoot, filter) {
|
|
126
140
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
127
141
|
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
|
|
128
142
|
const sidecarGemfile = join(projectRoot, BEHAVIORAL_GEMFILE);
|
|
@@ -130,7 +144,10 @@ async function runCucumberRuby(projectRoot) {
|
|
|
130
144
|
throw missingRunner('Cucumber');
|
|
131
145
|
}
|
|
132
146
|
const command = 'bundle';
|
|
133
|
-
const args = [
|
|
147
|
+
const args = [
|
|
148
|
+
'exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT,
|
|
149
|
+
...tagArgs(filter, '--tags', '@'),
|
|
150
|
+
];
|
|
134
151
|
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
135
152
|
const run = await runInProject(projectRoot, command, args, {
|
|
136
153
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
@@ -147,7 +164,7 @@ function missingRunner(name) {
|
|
|
147
164
|
}
|
|
148
165
|
// JS/TS: `@cucumber/cucumber` (cucumber-js) with the message formatter written
|
|
149
166
|
// to a file.
|
|
150
|
-
async function runCucumberJs(projectRoot) {
|
|
167
|
+
async function runCucumberJs(projectRoot, filter) {
|
|
151
168
|
const features = join(BEHAVIORAL_ROOT, 'features');
|
|
152
169
|
const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
|
|
153
170
|
const command = `${BEHAVIORAL_ROOT}/node_modules/.bin/cucumber-js`;
|
|
@@ -160,6 +177,7 @@ async function runCucumberJs(projectRoot) {
|
|
|
160
177
|
steps,
|
|
161
178
|
'--format',
|
|
162
179
|
`message:${CUCUMBER_REPORT}`,
|
|
180
|
+
...tagArgs(filter, '--tags', '@'),
|
|
163
181
|
];
|
|
164
182
|
const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
|
|
165
183
|
const run = await runInProject(projectRoot, command, args, {
|
|
@@ -171,7 +189,7 @@ async function runCucumberJs(projectRoot) {
|
|
|
171
189
|
// Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
|
|
172
190
|
// plugin writes the JSON report; `-c` isolates the run from the project's own
|
|
173
191
|
// addopts. The runner command is connector-owned.
|
|
174
|
-
async function runPytestBdd(projectRoot, mainPath) {
|
|
192
|
+
async function runPytestBdd(projectRoot, mainPath, filter) {
|
|
175
193
|
mkdirSync(join(projectRoot, BEHAVIORAL_ROOT), { recursive: true });
|
|
176
194
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
177
195
|
writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
|
|
@@ -186,7 +204,15 @@ async function runPytestBdd(projectRoot, mainPath) {
|
|
|
186
204
|
// `--rootdir .`, not the absolute root: the working directory is the project
|
|
187
205
|
// root in every place, and an absolute host path would name a directory that
|
|
188
206
|
// does not exist wherever the run actually happens.
|
|
189
|
-
|
|
207
|
+
// The marker filter goes after `--rootdir`, so the two arguments the harness
|
|
208
|
+
// loading depends on keep their place at the end of the unfiltered command.
|
|
209
|
+
// pytest-bdd hangs a marker named after each tag on the scenario; the
|
|
210
|
+
// connector's ini registers none, and an unregistered marker is a warning,
|
|
211
|
+
// not an error (spec 52-3, non-goal).
|
|
212
|
+
const args = [
|
|
213
|
+
'-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', '.',
|
|
214
|
+
...tagArgs(filter, '-m', ''),
|
|
215
|
+
];
|
|
190
216
|
const survivor = clearReport(join(projectRoot, PYTEST_BDD_REPORT));
|
|
191
217
|
const run = await runInProject(projectRoot, command, args, {
|
|
192
218
|
timeoutMs: BDD_TIMEOUT_MS,
|
|
@@ -24,6 +24,65 @@ export function reportedFailures(runner, report) {
|
|
|
24
24
|
return null;
|
|
25
25
|
return extract(runner, report);
|
|
26
26
|
}
|
|
27
|
+
// Spec 52-4, AC 1.10. The same parse, read for a third question: how many of
|
|
28
|
+
// a feature's scenarios passed and how many did not. `put-tests` decides from
|
|
29
|
+
// it which proof to attach — all red is the red run, all green with a review
|
|
30
|
+
// is the review, anything else is no proof — and whether a review may be sent
|
|
31
|
+
// at all. The number reaches a sentence on this machine and the choice of a
|
|
32
|
+
// field; the server checks whatever proof it is sent against the report
|
|
33
|
+
// itself. Only the two behavioral shapes have scenarios to count; a report
|
|
34
|
+
// that cannot be read is no tally, never a green one.
|
|
35
|
+
export function scenarioTally(runner, report) {
|
|
36
|
+
if (!report.trim())
|
|
37
|
+
return null;
|
|
38
|
+
const outcomes = scenarioOutcomes(runner, report);
|
|
39
|
+
if (outcomes === null)
|
|
40
|
+
return null;
|
|
41
|
+
const failed = outcomes.filter((passed) => !passed).length;
|
|
42
|
+
return { passed: outcomes.length - failed, failed };
|
|
43
|
+
}
|
|
44
|
+
// One entry per scenario, true when it passed. Cucumber emits one
|
|
45
|
+
// `testCaseStarted` per attempt when a scenario is retried, so the rows are
|
|
46
|
+
// keyed by the test case and the last attempt is the one that counts — the
|
|
47
|
+
// one the run ended on.
|
|
48
|
+
function scenarioOutcomes(runner, report) {
|
|
49
|
+
switch (runner) {
|
|
50
|
+
case 'cucumber':
|
|
51
|
+
case 'cucumber-js': {
|
|
52
|
+
const envelopes = [];
|
|
53
|
+
for (const line of report.split('\n')) {
|
|
54
|
+
if (!line.trim())
|
|
55
|
+
continue;
|
|
56
|
+
const parsed = parseObject(line);
|
|
57
|
+
if (!parsed)
|
|
58
|
+
return null;
|
|
59
|
+
envelopes.push(parsed);
|
|
60
|
+
}
|
|
61
|
+
const failedRuns = new Set();
|
|
62
|
+
for (const envelope of envelopes) {
|
|
63
|
+
const finished = envelope.testStepFinished;
|
|
64
|
+
const status = text(finished?.testStepResult?.status);
|
|
65
|
+
if (finished && status !== 'PASSED' && status !== 'SKIPPED')
|
|
66
|
+
failedRuns.add(text(finished.testCaseStartedId));
|
|
67
|
+
}
|
|
68
|
+
const lastAttempt = new Map();
|
|
69
|
+
for (const envelope of envelopes) {
|
|
70
|
+
const started = envelope.testCaseStarted;
|
|
71
|
+
if (started)
|
|
72
|
+
lastAttempt.set(text(started.testCaseId), text(started.id));
|
|
73
|
+
}
|
|
74
|
+
return [...lastAttempt.values()].map((startedId) => !failedRuns.has(startedId));
|
|
75
|
+
}
|
|
76
|
+
case 'pytest-bdd': {
|
|
77
|
+
const data = parseObject(report);
|
|
78
|
+
if (!Array.isArray(data?.scenarios))
|
|
79
|
+
return null;
|
|
80
|
+
return rows(data.scenarios).map((scenario) => text(scenario.status) === 'passed');
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
27
86
|
// One hash for one set. Same set, same hash, on any machine and in any order.
|
|
28
87
|
export function digestOf(failures) {
|
|
29
88
|
return createHash('sha256').update(JSON.stringify(failures)).digest('hex');
|
|
@@ -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
|
+
}
|
package/dist/verbs/fixPrepare.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import { writeFixRequest } from "../files/fix.js";
|
|
2
2
|
import { Wire } from "../wire.js";
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Two forms, one verb.
|
|
4
|
+
//
|
|
5
|
+
// Without an id (spec 54-1): print the list a fix starts from — every red guard
|
|
6
|
+
// of both maps under the digest it is red on, with the line that says whether
|
|
7
|
+
// it ever passed on that suite. The server words it; the connector prints it
|
|
8
|
+
// as it is. Nothing is written: the list is read, the brief comes next from
|
|
9
|
+
// `contract-prompt` with the digest copied off this list.
|
|
10
|
+
//
|
|
11
|
+
// With an id (spec 26): fetch the per-capability repair packet and write the
|
|
12
|
+
// host's task to `.unitbob/fix/request.json`. No recipe fetch, no upload — the
|
|
13
|
+
// host reads its own source and the local spec file, then either fixes code
|
|
14
|
+
// (next `/unitbob check` shows the result) or accepts the change and
|
|
15
|
+
// republishes the suite. A 422 from the server (non-failed / stale / no suite)
|
|
16
|
+
// surfaces via WireError; nothing is written.
|
|
9
17
|
export async function fixPrepare(config, args = [], deps) {
|
|
10
|
-
const interfaceId = (args[0] ?? '').trim();
|
|
11
|
-
if (!interfaceId)
|
|
12
|
-
throw new Error('Usage: unitbob fix-prepare <interface_id>');
|
|
13
18
|
const d = {
|
|
19
|
+
getRedList: () => new Wire(config).getRedList(),
|
|
14
20
|
getFixPacket: (id) => new Wire(config).getFixPacket(id),
|
|
15
21
|
stdout: process.stdout,
|
|
16
22
|
...deps,
|
|
17
23
|
};
|
|
24
|
+
const interfaceId = (args[0] ?? '').trim();
|
|
25
|
+
if (!interfaceId) {
|
|
26
|
+
const list = await d.getRedList();
|
|
27
|
+
d.stdout.write(`${list.message}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
18
30
|
const packet = await d.getFixPacket(interfaceId);
|
|
19
31
|
writeFixRequest(config.projectRoot, interfaceId, packet);
|
|
20
32
|
d.stdout.write(`${packet.message}\n`);
|
|
@@ -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
|
+
}
|