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/README.md
CHANGED
|
@@ -1,134 +1,142 @@
|
|
|
1
1
|
# Unitbob
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Unitbob draws a map of what your app does for people — signing in, paying,
|
|
4
|
+
sending messages — and puts a lamp on each part. Behind every lamp is an
|
|
5
|
+
automatic check. Green means that part still works. Red means something you
|
|
6
|
+
changed broke it.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
You do not write the checks. Your coding assistant — Claude Code or Codex —
|
|
9
|
+
does, and Unitbob keeps them honest. Your code stays on your computer; only
|
|
10
|
+
the checks and their results go to Unitbob.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
Node 18
|
|
12
|
+
Works with apps written in Ruby on Rails, JavaScript/TypeScript and Python.
|
|
13
|
+
You need Node 18 or newer and Python 3.10 or newer installed.
|
|
12
14
|
|
|
13
15
|
---
|
|
14
16
|
|
|
15
17
|
## Install (once)
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
Tell your assistant:
|
|
20
|
+
|
|
18
21
|
```
|
|
19
22
|
Add the Unitbob plugin marketplace: sergeygershun/unitbob-connector
|
|
20
23
|
Install the unitbob plugin
|
|
21
24
|
```
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
Or, if you prefer the terminal — Claude Code:
|
|
27
|
+
|
|
24
28
|
```
|
|
25
29
|
claude plugin marketplace add sergeygershun/unitbob-connector
|
|
26
30
|
claude plugin install unitbob@unitbob
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
Codex:
|
|
34
|
+
|
|
30
35
|
```
|
|
31
36
|
codex plugin marketplace add sergeygershun/unitbob-connector
|
|
32
37
|
codex plugin add unitbob@unitbob
|
|
33
|
-
npx -y unitbob@0.
|
|
38
|
+
npx -y unitbob@0.7.13 codex-install
|
|
34
39
|
```
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
hosts.
|
|
41
|
+
Then start a new chat. A chat opened before the install does not know about
|
|
42
|
+
Unitbob yet. Everything below works the same in Claude Code and Codex.
|
|
39
43
|
|
|
40
|
-
Codex
|
|
41
|
-
|
|
42
|
-
than enforced separately for each named agent. No Codex version is currently
|
|
43
|
-
qualified by Unitbob for a native per-agent ceiling. Before the first bounded
|
|
44
|
-
role, Unitbob therefore asks whether to continue this invocation without that
|
|
45
|
-
mechanical ceiling; approval is never persisted. The definitions keep the native
|
|
46
|
-
budget values so a future Codex release can be qualified without introducing a
|
|
47
|
-
Unitbob supervisor.
|
|
44
|
+
On Codex, the assistant may ask once whether it can continue without a spending
|
|
45
|
+
limit for its helpers. Answer yes; Codex cannot enforce that limit yet.
|
|
48
46
|
|
|
49
47
|
---
|
|
50
48
|
|
|
51
|
-
##
|
|
49
|
+
## Protect what your app already does
|
|
52
50
|
|
|
53
|
-
Just
|
|
51
|
+
Just say it in the chat. Nothing to memorise, no command to get right.
|
|
54
52
|
|
|
55
53
|
| Step | Say this |
|
|
56
54
|
|------|----------|
|
|
57
|
-
| 1.
|
|
58
|
-
| 2.
|
|
59
|
-
| 3.
|
|
60
|
-
| 4.
|
|
55
|
+
| 1. Draw the map | `Build my Unitbob map` |
|
|
56
|
+
| 2. Write the checks | `Generate the guardrail tests` |
|
|
57
|
+
| 3. Look at the map | `Open my Unitbob map` |
|
|
58
|
+
| 4. Fix a red lamp | `Fix guardrail <id>` — the id is written on the lamp |
|
|
61
59
|
|
|
62
|
-
Step 2
|
|
63
|
-
|
|
60
|
+
Step 2 also runs the checks, so the lamps light up right away. Later, whenever
|
|
61
|
+
you want to know whether your latest changes broke anything, say `Run the
|
|
62
|
+
checks`.
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
tests were saved but before they ran.
|
|
64
|
+
Your project connects itself to Unitbob the first time. There is nothing to
|
|
65
|
+
set up by hand.
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Build something new
|
|
70
|
+
|
|
71
|
+
The steps above protect what your app does today. When you want to add
|
|
72
|
+
something new, Unitbob can protect the new part too — before it exists. Then
|
|
73
|
+
"done" is not a feeling; it is the checks turning green.
|
|
74
|
+
|
|
75
|
+
Do steps 1 and 2 above first. Then, in the chat, in this order:
|
|
76
|
+
|
|
77
|
+
| Step | Say this | What happens |
|
|
78
|
+
|------|----------|--------------|
|
|
79
|
+
| 1. Name it | `I want to add <feature>` | Unitbob writes down what you want and which parts of the map it may touch. You get a link to the feature's page. |
|
|
80
|
+
| 2. Talk it through | `Let's talk through <feature>` | The assistant asks a few numbered questions about how the feature should behave — in plain words, with options and a recommendation. Answer with a letter, a word, or `do as you recommend`. Then it shows you the list of things that will be true when the feature is done and asks: is this it? Say `yes`. |
|
|
81
|
+
| 3. Write the checks | `Write the checks for <feature>` | Each item from that list becomes a check. They are all red now — on purpose, the feature is not built yet. |
|
|
82
|
+
| 4. Build it | `Build <feature>` | Ordinary work in the same chat. Say `Run the checks` at any time to see how many of the feature's checks already pass, and whether anything else broke. |
|
|
83
|
+
| 5. Wrap up | `I'm done with <feature>` | The checks run once more, a second assistant reads them over, and you get the link to the feature's page. |
|
|
84
|
+
| 6. Finish | Press **Finish this feature** on that page | Only you can press it, and only when everything is green. The feature gets its own lamp on the map, already green. |
|
|
85
|
+
|
|
86
|
+
Good to know:
|
|
87
|
+
|
|
88
|
+
- **If the button is grey, the page says why** — a check of the feature still
|
|
89
|
+
fails, the checks have not been read over yet, or something you said must
|
|
90
|
+
keep working is red.
|
|
91
|
+
- **A red lamp you expected is fine.** If in step 2 you said a part would
|
|
92
|
+
change, its row on the feature page is amber, not red, with a button
|
|
93
|
+
`Accept the new behaviour`. Press it and it is green again. A red lamp you
|
|
94
|
+
did not expect is shown too, with the usual `Fix this`.
|
|
95
|
+
- **The checks say exactly what you agreed to in step 2.** Nobody can quietly
|
|
96
|
+
bend them to fit the code. If you change your mind about the feature, say
|
|
97
|
+
`Let's talk through <feature>` again and the checks are rewritten.
|
|
98
|
+
- **Work in your project's main folder.** Unitbob keeps the checks in a folder
|
|
99
|
+
called `.unitbob` next to your code. A copy of the project in another folder
|
|
100
|
+
does not have it.
|
|
101
|
+
- **After Finish, nothing more to do.** From now on the feature's checks run
|
|
102
|
+
with all the others.
|
|
73
103
|
|
|
74
104
|
### If the assistant says it cannot find the Unitbob instructions
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
clean fix. If that is inconvenient, the instructions are ordinary files on disk
|
|
79
|
-
and the assistant can read them directly — tell it:
|
|
106
|
+
The chat was started before the plugin was installed. Start a new chat. If you
|
|
107
|
+
would rather not, tell the assistant:
|
|
80
108
|
|
|
81
109
|
```
|
|
82
110
|
Read ~/.claude/plugins/cache/unitbob/unitbob/<version>/skills/unitbob/SKILL.md
|
|
83
111
|
and follow the workflow it names for this job.
|
|
84
112
|
```
|
|
85
113
|
|
|
86
|
-
`<version>` is
|
|
87
|
-
workflow files sit next to it under `workflows/`, one per job, and each is
|
|
88
|
-
self-contained — that is what they are designed for.
|
|
89
|
-
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## How to read it
|
|
93
|
-
|
|
94
|
-
- **Green lamp** — the behavior works.
|
|
95
|
-
- **Red lamp** — something the structure relied on broke. Copy its `id` and run
|
|
96
|
-
step 3.
|
|
97
|
-
- The project links itself by folder name — nothing to set up by hand.
|
|
114
|
+
`<version>` is the number that `claude plugin list` shows.
|
|
98
115
|
|
|
99
116
|
---
|
|
100
117
|
|
|
101
|
-
## If your
|
|
118
|
+
## If your app only runs inside Docker
|
|
102
119
|
|
|
103
|
-
Some projects keep the code on
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
there:
|
|
120
|
+
Some projects keep the code on your computer but run it inside a Docker
|
|
121
|
+
container. Tell Unitbob the container's name in the file `.unitbob.json` in
|
|
122
|
+
your project folder:
|
|
107
123
|
|
|
108
124
|
```json
|
|
109
125
|
{ "server": "…", "repo_id": 3, "token": "…",
|
|
110
126
|
"exec": { "docker": { "container": "myapp-web-1" } } }
|
|
111
127
|
```
|
|
112
128
|
|
|
113
|
-
|
|
114
|
-
the container
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
- **On a Linux host, files the container writes belong to `root`.** Unitbob does
|
|
128
|
-
not map users: guessing there breaks images that installed their packages as a
|
|
129
|
-
user of their own.
|
|
130
|
-
- **Only a container that is already running.** A project whose tests go through
|
|
131
|
-
`docker compose run --rm` is not supported yet.
|
|
132
|
-
- **Review at a fixed revision is not supported with a container.** It works in
|
|
133
|
-
a git worktree under the system's temporary directory, which is outside the
|
|
134
|
-
mount. Ordinary review works in the project itself and is unaffected.
|
|
129
|
+
Keep the other three values as they are; Unitbob wrote them. Now the checks
|
|
130
|
+
run inside the container. Without that line everything runs on your computer,
|
|
131
|
+
as before.
|
|
132
|
+
|
|
133
|
+
This works only when the project folder is shared with the container — which
|
|
134
|
+
it already is if editing a file on your computer changes the running app. If
|
|
135
|
+
it is not, Unitbob tells you and stops.
|
|
136
|
+
|
|
137
|
+
A few things Unitbob does not do in a container yet:
|
|
138
|
+
|
|
139
|
+
- The container has to be running already; Unitbob does not start it.
|
|
140
|
+
- On Linux, files written from inside the container belong to `root`.
|
|
141
|
+
- If a check takes too long and is stopped, the container may keep running it
|
|
142
|
+
in the background for a while.
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,13 @@ import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
|
25
25
|
import { classifyPublication, putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
26
26
|
import { validateBuild } from "./verbs/validateBuild.js";
|
|
27
27
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
28
|
+
import { featurePrepare } from "./verbs/featurePrepare.js";
|
|
29
|
+
import { knowledgePrepare } from "./verbs/knowledgePrepare.js";
|
|
30
|
+
import { putKnowledge } from "./verbs/putKnowledge.js";
|
|
31
|
+
import { testsPrepare } from "./verbs/testsPrepare.js";
|
|
32
|
+
import { putTests } from "./verbs/putTests.js";
|
|
33
|
+
import { testsReviewPrepare } from "./verbs/testsReviewPrepare.js";
|
|
34
|
+
import { putFeature } from "./verbs/putFeature.js";
|
|
28
35
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
29
36
|
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
30
37
|
import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
|
|
@@ -62,11 +69,30 @@ Verbs:
|
|
|
62
69
|
then run every branch it published and report the server's results. Name a branch
|
|
63
70
|
to publish that one alone, as soon as it is finished; with no argument both are
|
|
64
71
|
expected, and one the answer never mentions is reported.
|
|
65
|
-
run-local [branch]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
run-local [branch] | --feature <id>
|
|
73
|
+
Internal: run the suite you just wrote, before publishing it, with the same runner
|
|
74
|
+
that will run it afterwards. No argument runs every branch the build asked for;
|
|
75
|
+
--feature runs one feature's checks alone, by their tag.
|
|
76
|
+
fix-prepare [<id>] Without an id: every red guard on both maps, with its suite digest and history — the
|
|
77
|
+
list a fix starts from. With an id: the per-capability repair packet for one
|
|
78
|
+
structural guard (spec 26).
|
|
79
|
+
contract-prompt <digest>|feature:<id> <test_id> [fix|accept]
|
|
80
|
+
Internal: fetch the fix/accept brief for one red check on either map — or, with
|
|
81
|
+
feature:<id>, the fix brief for a feature's own failing checks.
|
|
82
|
+
feature-prepare Internal: fetch the recipe and the product capabilities, write the host
|
|
83
|
+
feature-start request — for naming what a change may touch, before it is made.
|
|
84
|
+
put-feature Internal: record the host's feature answer and print the link to its page.
|
|
85
|
+
knowledge-prepare Internal: without an id, list the features that can be talked through; with
|
|
86
|
+
one, fetch the recipe and the feature's packet and write the host request.
|
|
87
|
+
put-knowledge Internal: send the feature's knowledge.md and print the link to its page.
|
|
88
|
+
tests-prepare <id> Internal: fetch the feature's assignment and the recipe, put the suites on disk,
|
|
89
|
+
and write the host request for its checks.
|
|
90
|
+
put-tests <id> Internal: run the feature's checks and save the harness with that run as its
|
|
91
|
+
proof (all red at first); with a review file beside the answer — publishes the review.
|
|
92
|
+
Files the run under the saved version and prints the link to its page.
|
|
93
|
+
tests-review-prepare <id>
|
|
94
|
+
Internal: write the independent reviewer's request for the feature's checks, once
|
|
95
|
+
they all pass; put-tests then publishes the review it writes.
|
|
70
96
|
check Run every Unitbob contract suite locally and report.
|
|
71
97
|
run Alias for check.
|
|
72
98
|
|
|
@@ -138,6 +164,30 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
138
164
|
case 'contract-prompt':
|
|
139
165
|
await contractPrompt(await linked(), args);
|
|
140
166
|
return 0;
|
|
167
|
+
case 'feature-prepare':
|
|
168
|
+
await featurePrepare(await linked(), args);
|
|
169
|
+
return 0;
|
|
170
|
+
case 'put-feature':
|
|
171
|
+
await putFeature(await linked(), args);
|
|
172
|
+
return 0;
|
|
173
|
+
case 'knowledge-prepare':
|
|
174
|
+
await knowledgePrepare(await linked(), args);
|
|
175
|
+
return 0;
|
|
176
|
+
case 'put-knowledge':
|
|
177
|
+
await putKnowledge(await linked(), args);
|
|
178
|
+
return 0;
|
|
179
|
+
case 'tests-prepare':
|
|
180
|
+
await testsPrepare(await linked(), args);
|
|
181
|
+
return 0;
|
|
182
|
+
case 'put-tests':
|
|
183
|
+
// Non-zero when the runner could not start or produced no report:
|
|
184
|
+
// nothing was sent, and the host has to read why (spec 52-3, AC 3.5).
|
|
185
|
+
// And when a review lies beside a run that is not all green (spec
|
|
186
|
+
// 52-4, AC 1.10) — nothing was sent then either.
|
|
187
|
+
return await putTests(await linked(), args);
|
|
188
|
+
case 'tests-review-prepare':
|
|
189
|
+
await testsReviewPrepare(await linked(), args);
|
|
190
|
+
return 0;
|
|
141
191
|
case 'run-local':
|
|
142
192
|
// The one verb whose non-zero exit is not an error: a branch that failed
|
|
143
193
|
// the same set of cases twice in a row (spec 34-6, criterion 3). Red
|
package/dist/files/behavioral.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
4
|
import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
|
|
@@ -290,14 +290,27 @@ export function materializeBehavioralWorld(projectRoot, runner = 'cucumber') {
|
|
|
290
290
|
// behavioral root, after checking every path is safe. Stale suite artifacts are
|
|
291
291
|
// removed while the separately provisioned runner environment is preserved.
|
|
292
292
|
// Returns the absolute path of the materialized main file.
|
|
293
|
-
|
|
294
|
-
|
|
293
|
+
//
|
|
294
|
+
// `extras` are further envelopes written in the same pass (spec 52-3, AC 3.1):
|
|
295
|
+
// the checks of every red feature, beside the main suite. One clearing, then
|
|
296
|
+
// every file of every envelope; a path two envelopes both hold is refused by
|
|
297
|
+
// name before anything is written — the server does not issue such a union,
|
|
298
|
+
// and the disk could not carry it if it did.
|
|
299
|
+
export function materializeBehavioral(projectRoot, artifact, runner, extras = []) {
|
|
300
|
+
const files = [artifact, ...extras].flatMap((envelope) => [envelope, ...(envelope.support_files ?? [])]);
|
|
295
301
|
for (const file of files)
|
|
296
302
|
assertUnitbobPath(file.path, BEHAVIORAL_DIR);
|
|
297
303
|
const world = behavioralWorldFor(runner);
|
|
298
304
|
if (world && files.some((file) => file.path === world.path)) {
|
|
299
305
|
throw new Error(`${world.path} is the connector-owned World and cannot be supplied by the host artifact.`);
|
|
300
306
|
}
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
if (seen.has(file.path)) {
|
|
310
|
+
throw new Error(`${file.path} is listed twice across the suites to materialize — one path, one file.`);
|
|
311
|
+
}
|
|
312
|
+
seen.add(file.path);
|
|
313
|
+
}
|
|
301
314
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
302
315
|
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
303
316
|
mkdirSync(behavioralRoot, { recursive: true });
|
|
@@ -317,6 +330,60 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
|
317
330
|
materializeBehavioralWorld(projectRoot, runner);
|
|
318
331
|
return { mainPath };
|
|
319
332
|
}
|
|
333
|
+
// The main suite and the checks of every red feature, as one union on disk
|
|
334
|
+
// (spec 52-3, AC 3.1), and the tags the ordinary run leaves out (3.2). The
|
|
335
|
+
// main file is the main suite's when it is built, else the first feature's —
|
|
336
|
+
// a union of checks alone, on a machine where the main suite is not built, is
|
|
337
|
+
// legal. Null when there is nothing at all to write.
|
|
338
|
+
//
|
|
339
|
+
// Before anything is cleared, the feature files on disk are compared with the
|
|
340
|
+
// ones the server holds (spec 52-4, AC 1.8): while a feature is being built
|
|
341
|
+
// the host rewires its steps against the real code, and a union that wrote
|
|
342
|
+
// the saved version over that work would lose it without a word. A difference
|
|
343
|
+
// stops here, disk untouched, with the one command that saves it.
|
|
344
|
+
export function materializeBehavioralUnion(projectRoot, index, runner) {
|
|
345
|
+
const changed = changedFeatureFiles(projectRoot, index.feature_suites);
|
|
346
|
+
if (changed.length > 0)
|
|
347
|
+
throw new FeatureFilesChangedError(changed[0].feature_id, changed[0].title);
|
|
348
|
+
const main = index.suites.find((item) => item.suite_kind === 'behavioral' && item.status === 'ready' && item.suite_file);
|
|
349
|
+
const envelopes = [...(main?.suite_file ? [main.suite_file] : []), ...index.feature_suites.map((item) => item.suite_file)];
|
|
350
|
+
if (envelopes.length === 0)
|
|
351
|
+
return null;
|
|
352
|
+
const [first, ...rest] = envelopes;
|
|
353
|
+
const { mainPath } = materializeBehavioral(projectRoot, first, runner, rest);
|
|
354
|
+
return { mainPath, excludeTags: index.feature_suites.map((item) => item.feature_tag) };
|
|
355
|
+
}
|
|
356
|
+
// The features whose checks on disk differ from the saved ones, with the
|
|
357
|
+
// paths that differ: every file of the feature's envelope that is on disk,
|
|
358
|
+
// compared by content. A file that is not on disk is not a change — the union
|
|
359
|
+
// writes it, as it always has — and a feature with nothing on disk is not
|
|
360
|
+
// listed at all.
|
|
361
|
+
export function changedFeatureFiles(projectRoot, items) {
|
|
362
|
+
return items.flatMap((item) => {
|
|
363
|
+
const paths = [item.suite_file, ...(item.suite_file.support_files ?? [])]
|
|
364
|
+
.filter((file) => {
|
|
365
|
+
const onDisk = join(projectRoot, file.path);
|
|
366
|
+
return existsSync(onDisk) && readFileSync(onDisk, 'utf8') !== file.content;
|
|
367
|
+
})
|
|
368
|
+
.map((file) => file.path);
|
|
369
|
+
return paths.length > 0 ? [{ feature_id: item.feature_id, title: featureTitle(item), paths }] : [];
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// The one sentence of AC 1.8, worded here and printed wherever the union is
|
|
373
|
+
// materialised — `check`, `tests-prepare` — by the shared catch in `cli.ts`.
|
|
374
|
+
export class FeatureFilesChangedError extends Error {
|
|
375
|
+
featureId;
|
|
376
|
+
constructor(featureId, title) {
|
|
377
|
+
super(`The checks for “${title}” changed on disk since they were saved. ` +
|
|
378
|
+
`Run \`npx unitbob put-tests ${featureId}\` to save them, then try again.`);
|
|
379
|
+
this.featureId = featureId;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// A server from spec 52-3 sends the item without a title; the id still names
|
|
383
|
+
// the feature the command below takes.
|
|
384
|
+
function featureTitle(item) {
|
|
385
|
+
return item.title ?? `feature ${item.feature_id}`;
|
|
386
|
+
}
|
|
320
387
|
// Everything under the behavioral root that the next materialization will
|
|
321
388
|
// delete: it wipes every top-level entry outside the runner environment and
|
|
322
389
|
// writes back only the files the answer listed, so a step file the answer forgot
|
|
@@ -327,11 +394,11 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
|
327
394
|
// The whole root is walked, not just the directories the answer happens to use:
|
|
328
395
|
// the file most likely to be forgotten is the one in a directory the answer
|
|
329
396
|
// never mentions — `features/support/env.rb` is exactly that shape.
|
|
330
|
-
export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
397
|
+
export function filesLostOnMaterialize(projectRoot, artifact, runner, extras = []) {
|
|
331
398
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
332
399
|
if (!existsSync(behavioralRoot))
|
|
333
400
|
return [];
|
|
334
|
-
const listed = new Set([artifact.path, ...(
|
|
401
|
+
const listed = new Set([artifact, ...extras].flatMap((envelope) => [envelope.path, ...(envelope.support_files ?? []).map((file) => file.path)]));
|
|
335
402
|
const kept = behavioralKeptByConnector(runner);
|
|
336
403
|
return readdirSync(behavioralRoot)
|
|
337
404
|
.filter((entry) => !kept.has(entry))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function requestPath(projectRoot) {
|
|
4
|
+
return join(projectRoot, '.unitbob', 'feature-start', 'request.json');
|
|
5
|
+
}
|
|
6
|
+
export function outputPath(projectRoot) {
|
|
7
|
+
return join(projectRoot, '.unitbob', 'feature-start', 'feature.json');
|
|
8
|
+
}
|
|
9
|
+
export function writeFeatureStartRequest(projectRoot, recipe, capabilities) {
|
|
10
|
+
const request = {
|
|
11
|
+
project_root: projectRoot,
|
|
12
|
+
recipe,
|
|
13
|
+
capabilities,
|
|
14
|
+
behavioral_suite_path: presentDir(join(projectRoot, '.unitbob', 'behavioral')),
|
|
15
|
+
map_documents_path: presentDir(join(projectRoot, '.unitbob', 'map-build')),
|
|
16
|
+
output_path: outputPath(projectRoot),
|
|
17
|
+
};
|
|
18
|
+
const path = requestPath(projectRoot);
|
|
19
|
+
if (!existsSync(dirname(path)))
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
21
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
22
|
+
return request;
|
|
23
|
+
}
|
|
24
|
+
// The host's answer in the wire shape, and nothing else: a field it added is
|
|
25
|
+
// dropped here rather than sent on. Each check names the field it failed, so
|
|
26
|
+
// the host corrects the file instead of guessing what was wrong with it.
|
|
27
|
+
export function readFeatureAnswer(projectRoot) {
|
|
28
|
+
const path = outputPath(projectRoot);
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
throw new Error(`No feature answer at ${path}. Write it as the recipe describes, then run \`unitbob put-feature\`.`);
|
|
31
|
+
}
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new Error(`${path} is not valid JSON: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
40
|
+
throw new Error(`${path} must hold a JSON object with "title", "intent" and "affected".`);
|
|
41
|
+
}
|
|
42
|
+
const body = raw;
|
|
43
|
+
const title = nonEmptyString(body.title, 'title', path);
|
|
44
|
+
const intent = nonEmptyString(body.intent, 'intent', path);
|
|
45
|
+
if (!Array.isArray(body.affected)) {
|
|
46
|
+
throw new Error(`${path}: "affected" must be an array of { id, why } (it may be empty).`);
|
|
47
|
+
}
|
|
48
|
+
const affected = body.affected.map((entry, index) => {
|
|
49
|
+
const item = (typeof entry === 'object' && entry !== null ? entry : {});
|
|
50
|
+
return {
|
|
51
|
+
id: nonEmptyString(item.id, `affected[${index}].id`, path),
|
|
52
|
+
why: typeof item.why === 'string' ? item.why : '',
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
return { title, intent, affected };
|
|
56
|
+
}
|
|
57
|
+
function nonEmptyString(value, field, path) {
|
|
58
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
59
|
+
throw new Error(`${path}: "${field}" must be a non-empty string.`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function presentDir(path) {
|
|
64
|
+
return existsSync(path) ? path : null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { resolveSuiteFile } from "./suiteBuild.js";
|
|
4
|
+
// A feature's folder on disk (spec 52-2): `.unitbob/features/<id>/`. This is
|
|
5
|
+
// the first spec that gives a feature a folder; the talk's request and the
|
|
6
|
+
// file it folds into live here, and spec 52-3 puts its own request beside them.
|
|
7
|
+
export function featureDir(projectRoot, featureId) {
|
|
8
|
+
return join(projectRoot, '.unitbob', 'features', String(featureId));
|
|
9
|
+
}
|
|
10
|
+
// The id as the list printed it and the folder is named: a number. A word
|
|
11
|
+
// ("refunds") is the person's, not the server's, and the verb says so.
|
|
12
|
+
export function parseFeatureId(raw, verb) {
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
throw new Error(`Usage: unitbob ${verb} <feature_id>`);
|
|
15
|
+
if (!/^\d+$/.test(raw))
|
|
16
|
+
throw new Error(`unitbob ${verb}: the feature id must be a number, got "${raw}".`);
|
|
17
|
+
return Number(raw);
|
|
18
|
+
}
|
|
19
|
+
export function knowledgeRequestPath(projectRoot, featureId) {
|
|
20
|
+
return join(featureDir(projectRoot, featureId), 'request.json');
|
|
21
|
+
}
|
|
22
|
+
export function knowledgePath(projectRoot, featureId) {
|
|
23
|
+
return join(featureDir(projectRoot, featureId), 'knowledge.md');
|
|
24
|
+
}
|
|
25
|
+
export function writeKnowledgeRequest(projectRoot, featureId, recipe, packet) {
|
|
26
|
+
const request = {
|
|
27
|
+
project_root: projectRoot,
|
|
28
|
+
recipe,
|
|
29
|
+
feature: packet.feature,
|
|
30
|
+
affected: packet.affected,
|
|
31
|
+
knowledge: packet.knowledge,
|
|
32
|
+
behavioral_suite_path: presentDir(join(projectRoot, '.unitbob', 'behavioral')),
|
|
33
|
+
map_documents_path: presentDir(join(projectRoot, '.unitbob', 'map-build')),
|
|
34
|
+
output_path: knowledgePath(projectRoot, featureId),
|
|
35
|
+
};
|
|
36
|
+
const path = knowledgeRequestPath(projectRoot, featureId);
|
|
37
|
+
if (!existsSync(dirname(path)))
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
40
|
+
return request;
|
|
41
|
+
}
|
|
42
|
+
// The file as text, and nothing else: the server is the one place that checks
|
|
43
|
+
// its shape (spec 52-2, Non-Goals), so the connector only refuses to send
|
|
44
|
+
// nothing.
|
|
45
|
+
export function readKnowledge(projectRoot, featureId) {
|
|
46
|
+
const path = knowledgePath(projectRoot, featureId);
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
throw new Error(`No knowledge file at ${path}. Write it as the recipe describes, then run \`unitbob put-knowledge\`.`);
|
|
49
|
+
}
|
|
50
|
+
const text = readFileSync(path, 'utf8');
|
|
51
|
+
if (text.trim() === '')
|
|
52
|
+
throw new Error(`${path} is empty. Write it as the recipe describes, then run \`unitbob put-knowledge\`.`);
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
function presentDir(path) {
|
|
56
|
+
return existsSync(path) ? path : null;
|
|
57
|
+
}
|
|
58
|
+
// --- the checks (spec 52-3) -------------------------------------------------
|
|
59
|
+
export function testsRequestPath(projectRoot, featureId) {
|
|
60
|
+
return join(featureDir(projectRoot, featureId), 'tests-request.json');
|
|
61
|
+
}
|
|
62
|
+
export function testsOutputPath(projectRoot, featureId) {
|
|
63
|
+
return join(featureDir(projectRoot, featureId), 'tests-output.json');
|
|
64
|
+
}
|
|
65
|
+
// Where the checks go, beside the main suite (spec 52-3, AC 3.4): one
|
|
66
|
+
// `.feature` and one step file, both named after the feature so that they can
|
|
67
|
+
// never take a path of the main suite's.
|
|
68
|
+
export function featureFeaturePath(featureId) {
|
|
69
|
+
return `.unitbob/behavioral/features/feature_${featureId}.feature`;
|
|
70
|
+
}
|
|
71
|
+
export function featureStepsPath(featureId, runner) {
|
|
72
|
+
return `.unitbob/behavioral/step_definitions/${stepsFileName(featureId, runner)}`;
|
|
73
|
+
}
|
|
74
|
+
// Named as the runner collects it: pytest only picks up `test_*.py`, and a
|
|
75
|
+
// file named otherwise loads nothing (`bdd.ts` says so at length).
|
|
76
|
+
function stepsFileName(featureId, runner) {
|
|
77
|
+
switch (runner) {
|
|
78
|
+
case 'pytest-bdd': return `test_feature_${featureId}_steps.py`;
|
|
79
|
+
case 'cucumber-js': return `feature_${featureId}_steps.js`;
|
|
80
|
+
default: return `feature_${featureId}_steps.rb`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function writeTestsRequest(projectRoot, featureId, request) {
|
|
84
|
+
const path = testsRequestPath(projectRoot, featureId);
|
|
85
|
+
if (!existsSync(dirname(path)))
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
88
|
+
return request;
|
|
89
|
+
}
|
|
90
|
+
export function readTestsRequest(projectRoot, featureId) {
|
|
91
|
+
const path = testsRequestPath(projectRoot, featureId);
|
|
92
|
+
if (!existsSync(path)) {
|
|
93
|
+
throw new Error(`${path} not found — run \`npx unitbob tests-prepare ${featureId}\` first.`);
|
|
94
|
+
}
|
|
95
|
+
const request = JSON.parse(readFileSync(path, 'utf8'));
|
|
96
|
+
if (!request || typeof request.feature_tag !== 'string' || typeof request.runner !== 'string' ||
|
|
97
|
+
typeof request.feature_path !== 'string' || typeof request.output_path !== 'string') {
|
|
98
|
+
throw new Error(`${path} is malformed — run \`npx unitbob tests-prepare ${featureId}\` again.`);
|
|
99
|
+
}
|
|
100
|
+
return request;
|
|
101
|
+
}
|
|
102
|
+
export function readTestsOutput(projectRoot, featureId) {
|
|
103
|
+
const path = testsOutputPath(projectRoot, featureId);
|
|
104
|
+
if (!existsSync(path)) {
|
|
105
|
+
throw new Error(`${path} not found — write the answer as the recipe describes, then run \`unitbob put-tests\`.`);
|
|
106
|
+
}
|
|
107
|
+
const answer = JSON.parse(readFileSync(path, 'utf8'));
|
|
108
|
+
if (!answer || typeof answer !== 'object')
|
|
109
|
+
throw new Error(`${path} is malformed: expected an object.`);
|
|
110
|
+
const manifest = answer.runner_manifest;
|
|
111
|
+
if (!manifest || typeof manifest !== 'object' || typeof manifest.runner !== 'string') {
|
|
112
|
+
throw new Error(`${path} is missing runner_manifest.`);
|
|
113
|
+
}
|
|
114
|
+
if (!answer.test_metadata || typeof answer.test_metadata !== 'object') {
|
|
115
|
+
throw new Error(`${path} is missing test_metadata.`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
suite_file: resolveSuiteFile(answer.suite_file, '.unitbob/behavioral/', path, 'feature', projectRoot),
|
|
119
|
+
runner_manifest: manifest,
|
|
120
|
+
test_metadata: answer.test_metadata,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// --- the review of the checks (spec 52-4, AC 1.11) ---------------------------
|
|
124
|
+
export function testsReviewRequestPath(projectRoot, featureId) {
|
|
125
|
+
return join(featureDir(projectRoot, featureId), 'tests-review-request.json');
|
|
126
|
+
}
|
|
127
|
+
export function testsReviewOutputPath(projectRoot, featureId) {
|
|
128
|
+
return join(featureDir(projectRoot, featureId), 'tests-review-output.json');
|
|
129
|
+
}
|
|
130
|
+
export function writeTestsReviewRequest(projectRoot, featureId, request) {
|
|
131
|
+
const path = testsReviewRequestPath(projectRoot, featureId);
|
|
132
|
+
if (!existsSync(dirname(path)))
|
|
133
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
134
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
135
|
+
return request;
|
|
136
|
+
}
|
|
137
|
+
export function readTestsReviewOutput(projectRoot, featureId) {
|
|
138
|
+
const path = testsReviewOutputPath(projectRoot, featureId);
|
|
139
|
+
if (!existsSync(path))
|
|
140
|
+
return null;
|
|
141
|
+
let review;
|
|
142
|
+
try {
|
|
143
|
+
review = JSON.parse(readFileSync(path, 'utf8'));
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
147
|
+
}
|
|
148
|
+
const record = review;
|
|
149
|
+
if (!record || typeof record !== 'object' || typeof record.candidate_digest !== 'string') {
|
|
150
|
+
throw new Error(`${path} must carry candidate_digest at the top level, copied from tests-review-request.json.`);
|
|
151
|
+
}
|
|
152
|
+
if (!record.bdd_quality_review || typeof record.bdd_quality_review !== 'object') {
|
|
153
|
+
throw new Error(`${path} must contain a bdd_quality_review object.`);
|
|
154
|
+
}
|
|
155
|
+
return { candidate_digest: record.candidate_digest, bdd_quality_review: record.bdd_quality_review };
|
|
156
|
+
}
|