unitbob 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -10
- package/dist/cli.js +84 -16
- package/dist/config.js +41 -3
- package/dist/files/behavioral.js +9 -1
- package/dist/files/mapBuild.js +2 -1
- package/dist/files/suiteBuild.js +37 -2
- package/dist/link.js +30 -17
- package/dist/links.js +24 -0
- package/dist/proc.js +15 -1
- package/dist/runner/bdd.js +21 -14
- package/dist/runner/bootcheck.js +389 -0
- package/dist/runner/manifest.js +87 -0
- package/dist/runner/precheck.js +31 -3
- package/dist/runner/provision.js +6 -1
- package/dist/runner/rspec.js +1 -10
- package/dist/surfaces/routeInventory.js +448 -0
- package/dist/verbs/extractSurfaces.js +17 -0
- package/dist/verbs/mapPrepare.js +15 -1
- package/dist/verbs/putMapBuild.js +69 -2
- package/dist/verbs/putSuiteBuild.js +98 -30
- package/dist/verbs/run.js +4 -1
- package/dist/verbs/show.js +2 -1
- package/dist/verbs/suitePrepare.js +191 -19
- package/dist/verbs/validateBuild.js +240 -0
- package/dist/wire.js +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ Works with Ruby on Rails (RSpec), JavaScript/TypeScript (Vitest), and Python
|
|
|
7
7
|
(pytest) projects — the guardrail tests are generated in your project's own
|
|
8
8
|
language and run with its native test runner.
|
|
9
9
|
|
|
10
|
-
You work through Claude Code. You need:
|
|
10
|
+
You work through a coding agent — Claude Code or Codex both work. You need:
|
|
11
|
+
Node 18+, Python 3.10+.
|
|
11
12
|
|
|
12
13
|
---
|
|
13
14
|
|
|
@@ -27,6 +28,8 @@ claude plugin install unitbob@unitbob
|
|
|
27
28
|
|
|
28
29
|
Restart the session so the commands load.
|
|
29
30
|
|
|
31
|
+
In Codex it is the same — the same install, and the same phrasings below.
|
|
32
|
+
|
|
30
33
|
---
|
|
31
34
|
|
|
32
35
|
## Full cycle
|
|
@@ -36,15 +39,21 @@ Just type it in the chat. There is nothing to memorise and no command to get rig
|
|
|
36
39
|
| Step | Say this |
|
|
37
40
|
|------|----------|
|
|
38
41
|
| 1. Build the map | `Build my Unitbob map` |
|
|
39
|
-
| 2. Generate tests | `Generate the guardrail tests` |
|
|
40
|
-
| 3.
|
|
41
|
-
| 4.
|
|
42
|
-
|
|
42
|
+
| 2. Generate the tests | `Generate the guardrail tests` |
|
|
43
|
+
| 3. Fix a red lamp | `Fix guardrail <id>` |
|
|
44
|
+
| 4. Open the map | `Open my Unitbob map` |
|
|
45
|
+
|
|
46
|
+
Step 2 lights the lamps by itself: generating the tests also runs them and sends
|
|
47
|
+
the results. You do not have to ask for a run to see the first result.
|
|
48
|
+
|
|
49
|
+
Say `Run the checks` later, whenever you want the lamps refreshed against your
|
|
50
|
+
current code — or to finish the job if a generation was interrupted after the
|
|
51
|
+
tests were saved but before they ran.
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
In Claude Code there are also `/unitbob:map`, `/unitbob:suite` and friends, but
|
|
54
|
+
they work only in a terminal session started after the plugin was installed — in
|
|
55
|
+
a browser or desktop window they are not recognised at all. The phrasings above
|
|
56
|
+
work everywhere, so they are the ones documented here.
|
|
48
57
|
|
|
49
58
|
---
|
|
50
59
|
|
|
@@ -52,5 +61,5 @@ phrasings above work everywhere, so they are the ones documented here.
|
|
|
52
61
|
|
|
53
62
|
- **Green lamp** — the behavior works.
|
|
54
63
|
- **Red lamp** — something the structure relied on broke. Copy its `id` and run
|
|
55
|
-
step
|
|
64
|
+
step 3.
|
|
56
65
|
- The project links itself by folder name — nothing to set up by hand.
|
package/dist/cli.js
CHANGED
|
@@ -8,30 +8,44 @@
|
|
|
8
8
|
// into a single user operation, so `publishAndRun` at the bottom of this file
|
|
9
9
|
// holds that sequence and the exit code it implies (spec 32-4). Every other verb
|
|
10
10
|
// keeps its whole flow in its own module under `verbs/`.
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
11
13
|
import { ensureLinked } from "./link.js";
|
|
12
14
|
import { recipe } from "./verbs/recipe.js";
|
|
13
15
|
import { show } from "./verbs/show.js";
|
|
14
16
|
import { run, runOnly } from "./verbs/run.js";
|
|
15
17
|
import { init } from "./verbs/init.js";
|
|
16
18
|
import { mapPrepare } from "./verbs/mapPrepare.js";
|
|
19
|
+
import { extractSurfaces } from "./verbs/extractSurfaces.js";
|
|
17
20
|
import { putMapBuild } from "./verbs/putMapBuild.js";
|
|
18
21
|
import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
19
22
|
import { classifyPublication, putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
23
|
+
import { validateBuild } from "./verbs/validateBuild.js";
|
|
20
24
|
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
21
25
|
import { contractPrompt } from "./verbs/contractPrompt.js";
|
|
22
26
|
import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
|
|
23
27
|
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
24
28
|
|
|
25
|
-
Usage: unitbob <verb> [args]
|
|
29
|
+
Usage: unitbob [--project-root <dir>] <verb> [args]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--project-root <dir> Run against this project instead of the current folder.
|
|
33
|
+
Without it, an already-linked project is found by walking
|
|
34
|
+
up from where you are, so a subfolder works too.
|
|
26
35
|
|
|
27
36
|
Verbs:
|
|
28
37
|
init Link this project to Unitbob (also happens automatically).
|
|
29
38
|
recipe <name> Fetch and print a recipe from the server.
|
|
30
39
|
show Print the link to this project's map.
|
|
31
40
|
map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
|
|
41
|
+
extract-surfaces Internal: write the addresses this project's own router declares, when the stack
|
|
42
|
+
can be asked for them. Says nothing when it cannot, which is a normal answer;
|
|
43
|
+
map-prepare runs it for you.
|
|
32
44
|
put-map-build Internal: upload the host-built map and graph.
|
|
33
45
|
suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
|
|
34
46
|
suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
|
|
47
|
+
validate-build Internal: check the host's suite answer against the request, locally, before
|
|
48
|
+
uploading. Reports every problem at once; put-suite-build runs it too.
|
|
35
49
|
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
|
|
36
50
|
then run every branch it published and report the server's results.
|
|
37
51
|
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
@@ -51,45 +65,57 @@ host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify sk
|
|
|
51
65
|
Config: .unitbob.json at your project root, created automatically: the first
|
|
52
66
|
run registers the project on the server by its folder name (spec 28).`;
|
|
53
67
|
export async function main(argv, deps = { ensureLinked }) {
|
|
54
|
-
const
|
|
68
|
+
const parsed = parseGlobalFlags(argv);
|
|
69
|
+
if (parsed.error) {
|
|
70
|
+
process.stderr.write(`${parsed.error}\n`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const [verb, ...args] = parsed.rest;
|
|
55
74
|
if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
|
|
56
75
|
process.stdout.write(`${USAGE}\n`);
|
|
57
76
|
return verb ? 0 : 1;
|
|
58
77
|
}
|
|
78
|
+
const linked = () => deps.ensureLinked(parsed.root);
|
|
59
79
|
try {
|
|
60
80
|
switch (verb) {
|
|
61
81
|
case 'init':
|
|
62
82
|
await init(args);
|
|
63
83
|
return 0;
|
|
64
84
|
case 'recipe':
|
|
65
|
-
await recipe(await
|
|
85
|
+
await recipe(await linked(), args);
|
|
66
86
|
return 0;
|
|
67
87
|
case 'show':
|
|
68
|
-
await show(await
|
|
88
|
+
await show(await linked());
|
|
69
89
|
return 0;
|
|
70
90
|
case 'map-prepare':
|
|
71
|
-
await mapPrepare(await
|
|
91
|
+
await mapPrepare(await linked(), args);
|
|
92
|
+
return 0;
|
|
93
|
+
case 'extract-surfaces':
|
|
94
|
+
await extractSurfaces(await linked(), args);
|
|
72
95
|
return 0;
|
|
73
96
|
case 'put-map-build':
|
|
74
|
-
await putMapBuild(await
|
|
97
|
+
await putMapBuild(await linked(), args);
|
|
75
98
|
return 0;
|
|
76
99
|
case 'suite-prepare':
|
|
77
|
-
await suitePrepare(await
|
|
100
|
+
await suitePrepare(await linked(), args);
|
|
78
101
|
return 0;
|
|
79
102
|
case 'suite-review-prepare':
|
|
80
|
-
await suiteReviewPrepare(await
|
|
103
|
+
await suiteReviewPrepare(await linked(), args);
|
|
104
|
+
return 0;
|
|
105
|
+
case 'validate-build':
|
|
106
|
+
await validateBuild(await linked(), args);
|
|
81
107
|
return 0;
|
|
82
108
|
case 'put-suite-build':
|
|
83
|
-
return await publishAndRun(await
|
|
109
|
+
return await publishAndRun(await linked(), args);
|
|
84
110
|
case 'fix-prepare':
|
|
85
|
-
await fixPrepare(await
|
|
111
|
+
await fixPrepare(await linked(), args);
|
|
86
112
|
return 0;
|
|
87
113
|
case 'contract-prompt':
|
|
88
|
-
await contractPrompt(await
|
|
114
|
+
await contractPrompt(await linked(), args);
|
|
89
115
|
return 0;
|
|
90
116
|
case 'run':
|
|
91
117
|
case 'check':
|
|
92
|
-
await run(await
|
|
118
|
+
await run(await linked(), args);
|
|
93
119
|
return 0;
|
|
94
120
|
default:
|
|
95
121
|
process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
|
|
@@ -101,6 +127,42 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
101
127
|
return 1;
|
|
102
128
|
}
|
|
103
129
|
}
|
|
130
|
+
// `--project-root` names the folder the verb runs in, for callers that cannot
|
|
131
|
+
// change directory — an agent driving the CLI from a scratch directory, a hook,
|
|
132
|
+
// a monorepo script. It is the explicit form of what `ensureLinked` already does
|
|
133
|
+
// by walking up, and it accepts the `project_root` a request packet prints, so
|
|
134
|
+
// the packet's own answer can be handed straight back.
|
|
135
|
+
//
|
|
136
|
+
// Parsed here rather than per verb: every verb resolves the same project, and a
|
|
137
|
+
// flag that means one thing for `check` and another for `suite-prepare` is worse
|
|
138
|
+
// than no flag.
|
|
139
|
+
function parseGlobalFlags(argv) {
|
|
140
|
+
const rest = [];
|
|
141
|
+
let root;
|
|
142
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
143
|
+
const arg = argv[index];
|
|
144
|
+
if (arg === PROJECT_ROOT_FLAG) {
|
|
145
|
+
root = argv[index + 1];
|
|
146
|
+
index += 1;
|
|
147
|
+
}
|
|
148
|
+
else if (arg.startsWith(`${PROJECT_ROOT_FLAG}=`)) {
|
|
149
|
+
root = arg.slice(PROJECT_ROOT_FLAG.length + 1);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
rest.push(arg);
|
|
153
|
+
}
|
|
154
|
+
if (root !== undefined && !root)
|
|
155
|
+
return { rest, error: `${PROJECT_ROOT_FLAG} requires a path.` };
|
|
156
|
+
}
|
|
157
|
+
if (root === undefined)
|
|
158
|
+
return { rest };
|
|
159
|
+
const resolved = resolve(root);
|
|
160
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
161
|
+
return { rest, error: `${PROJECT_ROOT_FLAG} ${root} is not a directory.` };
|
|
162
|
+
}
|
|
163
|
+
return { root: resolved, rest };
|
|
164
|
+
}
|
|
165
|
+
const PROJECT_ROOT_FLAG = '--project-root';
|
|
104
166
|
// Publishing a suite and running it the first time are one user operation
|
|
105
167
|
// (spec 32-4). They used to be two commands, and the second one was a step the
|
|
106
168
|
// host LLM could simply not take: the suite was stored, nothing had ever run it,
|
|
@@ -114,11 +176,17 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
114
176
|
// comes from the build response, and what the results are comes only from the
|
|
115
177
|
// server's answer to the run.
|
|
116
178
|
export async function publishAndRun(config, args, deps) {
|
|
179
|
+
// Resolved before the collaborators, so both halves of the command write to
|
|
180
|
+
// the same stream. Injecting `stdout` has to capture everything the command
|
|
181
|
+
// prints — publication lines and run summaries included — or a test can hold a
|
|
182
|
+
// fraction of the output and read it as the whole of it.
|
|
183
|
+
const stdout = deps?.stdout ?? process.stdout;
|
|
184
|
+
const stderr = deps?.stderr ?? process.stderr;
|
|
117
185
|
const d = {
|
|
118
|
-
putSuiteBuild: (cfg, a) => putSuiteBuild(cfg, a),
|
|
119
|
-
runOnly: (cfg, digests) => runOnly(cfg, digests),
|
|
120
|
-
stdout
|
|
121
|
-
stderr
|
|
186
|
+
putSuiteBuild: (cfg, a) => putSuiteBuild(cfg, a, { stdout }),
|
|
187
|
+
runOnly: (cfg, digests) => runOnly(cfg, digests, { stdout }),
|
|
188
|
+
stdout,
|
|
189
|
+
stderr,
|
|
122
190
|
...deps,
|
|
123
191
|
};
|
|
124
192
|
const { digests, unpublished } = classifyPublication(await d.putSuiteBuild(config, args));
|
package/dist/config.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
// Per-project config for the connector. Lives in `.unitbob.json` at the project
|
|
2
|
-
// root: { "server": "http://…", "repo_id": 3 }.
|
|
3
|
-
//
|
|
2
|
+
// root: { "server": "http://…", "repo_id": 3, "token": "…" }.
|
|
3
|
+
//
|
|
4
|
+
// The token is the project's key (spec 33): the brain mints it at register, every
|
|
5
|
+
// wire call carries it, and this file is the only place a person has it. It is
|
|
6
|
+
// gitignored — see ensureGitignored — because a committed token hands the project
|
|
7
|
+
// to whoever reads the repository. Lose the file and the project is gone: there
|
|
8
|
+
// is no recovery and no rotation, which is written down in the spec as accepted.
|
|
9
|
+
//
|
|
10
|
+
// Linking is automatic (spec 28): every verb goes
|
|
4
11
|
// through `ensureLinked` (src/link.ts), which registers the project by folder
|
|
5
12
|
// name when there is no working link. Only the project root's own file counts —
|
|
6
13
|
// never a parent directory's (no walk-up).
|
|
7
14
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import {
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
9
17
|
export const CONFIG_FILE = '.unitbob.json';
|
|
10
18
|
// The repo id stored at `cwd`, or null when there is no working link: file
|
|
11
19
|
// missing, unreadable, malformed JSON, or repo_id absent / 0 / non-integer
|
|
@@ -22,6 +30,13 @@ export function readLocalServer(cwd) {
|
|
|
22
30
|
const server = readConfigField(cwd, 'server');
|
|
23
31
|
return typeof server === 'string' && /^https?:\/\//.test(server.trim()) ? server.trim() : null;
|
|
24
32
|
}
|
|
33
|
+
// The token stored at `cwd`, or null when there is none. A project linked
|
|
34
|
+
// before spec 33 has an id and no token; its calls now 404, and the connector
|
|
35
|
+
// says so in words rather than showing a bare status.
|
|
36
|
+
export function readLocalToken(cwd) {
|
|
37
|
+
const token = readConfigField(cwd, 'token');
|
|
38
|
+
return typeof token === 'string' && token.length > 0 ? token : null;
|
|
39
|
+
}
|
|
25
40
|
function readConfigField(cwd, field) {
|
|
26
41
|
const path = join(cwd, CONFIG_FILE);
|
|
27
42
|
if (!existsSync(path))
|
|
@@ -35,6 +50,29 @@ function readConfigField(cwd, field) {
|
|
|
35
50
|
}
|
|
36
51
|
return parsed && typeof parsed === 'object' ? parsed[field] : undefined;
|
|
37
52
|
}
|
|
53
|
+
// The nearest directory at or above `cwd` that already carries a working link,
|
|
54
|
+
// or null when there is none.
|
|
55
|
+
//
|
|
56
|
+
// This does not weaken the no-walk-up rule above — it is what makes it usable.
|
|
57
|
+
// The rule exists so a parent's repo_id can never stand in for the directory
|
|
58
|
+
// the verb is running in; a sub-package would silently report as its monorepo.
|
|
59
|
+
// Relocating to the directory whose own file names the link keeps that intact:
|
|
60
|
+
// the config still belongs to the root it sits in, and the verb runs there.
|
|
61
|
+
// Without this, every command had to be run from exactly the right folder, and
|
|
62
|
+
// the request packet's own `project_root` could not be used as a working
|
|
63
|
+
// directory even though it names the answer.
|
|
64
|
+
//
|
|
65
|
+
// $HOME is the ceiling: a stray `.unitbob.json` in the home directory must not
|
|
66
|
+
// adopt every project underneath it.
|
|
67
|
+
export function locateLinkedRoot(cwd) {
|
|
68
|
+
const home = homedir();
|
|
69
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
70
|
+
if (readLocalRepoId(dir) !== null)
|
|
71
|
+
return dir;
|
|
72
|
+
if (dir === home || dirname(dir) === dir)
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
38
76
|
export function writeConfigFile(cwd, config) {
|
|
39
77
|
writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
40
78
|
}
|
package/dist/files/behavioral.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
4
|
+
import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
|
|
4
5
|
// The behavioral suite lives under its own root: the main `.feature` plus its
|
|
5
6
|
// step definitions and any helper files, all under `.unitbob/behavioral/`
|
|
6
7
|
// (spec 32). Nothing here is ever written into the project's own `spec/`,
|
|
@@ -51,7 +52,7 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
|
51
52
|
const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
|
|
52
53
|
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
53
54
|
return readdirSync(behavioralRoot)
|
|
54
|
-
.filter((entry) => !runnerEntries.has(entry))
|
|
55
|
+
.filter((entry) => !runnerEntries.has(entry) && !CONNECTOR_RUN_ARTIFACTS.has(entry))
|
|
55
56
|
.flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
|
|
56
57
|
.filter((path) => !listed.has(path))
|
|
57
58
|
.sort();
|
|
@@ -78,6 +79,13 @@ export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner)
|
|
|
78
79
|
cpSync(source, target, { recursive: true });
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
// Written into the behavioral root by the connector's own BDD run: the machine
|
|
83
|
+
// report and the pytest-bdd harness it drives the run with. They are regenerated
|
|
84
|
+
// by the next run, so materialization is right to clear them — but warning about
|
|
85
|
+
// them names the connector's own files as the user's loss. It made the warning
|
|
86
|
+
// fire on every single review, which is how a real forgotten step file learns to
|
|
87
|
+
// look like noise. Taken from the runner that writes them, never re-typed here.
|
|
88
|
+
const CONNECTOR_RUN_ARTIFACTS = new Set(BDD_RUN_ARTIFACTS);
|
|
81
89
|
const EMPTY_ENTRIES = new Set();
|
|
82
90
|
const RUNNER_ENVIRONMENT_ENTRIES = {
|
|
83
91
|
cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
|
package/dist/files/mapBuild.js
CHANGED
|
@@ -28,13 +28,14 @@ export function readFreshGraph(projectRoot) {
|
|
|
28
28
|
parseJson(rawGraphJson, path);
|
|
29
29
|
return rawGraphJson;
|
|
30
30
|
}
|
|
31
|
-
export function writeMapBuildRequest(projectRoot, recipes) {
|
|
31
|
+
export function writeMapBuildRequest(projectRoot, recipes, routeInventoryPath) {
|
|
32
32
|
const packet = {
|
|
33
33
|
project_root: projectRoot,
|
|
34
34
|
graph_path: graphPath(projectRoot),
|
|
35
35
|
output_path: outputPath(projectRoot),
|
|
36
36
|
surfaces_path: surfacesPath(projectRoot),
|
|
37
37
|
surface_output_path: surfaceOutputPath(projectRoot),
|
|
38
|
+
...(routeInventoryPath ? { route_inventory_path: routeInventoryPath } : {}),
|
|
38
39
|
recipes,
|
|
39
40
|
};
|
|
40
41
|
const path = requestPath(projectRoot);
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -222,6 +222,39 @@ function readKnownDefectContext(value, path) {
|
|
|
222
222
|
// build_error, which the connector relays. Anything unparseable throws and
|
|
223
223
|
// nothing is uploaded.
|
|
224
224
|
export function readHostSuiteOutputs(path, request) {
|
|
225
|
+
const { entries, rootFor } = openAnswer(path, request);
|
|
226
|
+
return entries.map((entry) => readBranch(entry, rootFor, path, request.project_root));
|
|
227
|
+
}
|
|
228
|
+
// The same read, but one branch's bad entry does not hide the next branch's.
|
|
229
|
+
//
|
|
230
|
+
// Spec 32-6, after review: the throwing form above stops at the first problem,
|
|
231
|
+
// so an unsafe path in one branch concealed every remaining problem in its peer
|
|
232
|
+
// — and the promise this validation was built on is that all problems are named
|
|
233
|
+
// in one pass. It also sank a finished peer branch, which is the exact rule spec
|
|
234
|
+
// 32-5 Phase 4 established against.
|
|
235
|
+
//
|
|
236
|
+
// What still throws is the answer as a whole: a file that is missing, is not
|
|
237
|
+
// JSON, or carries no branches array has no second problem to go and find,
|
|
238
|
+
// because there is no document left to read.
|
|
239
|
+
export function readHostSuiteOutputsPerBranch(path, request) {
|
|
240
|
+
const { entries, rootFor } = openAnswer(path, request);
|
|
241
|
+
const outputs = [];
|
|
242
|
+
const unreadable = [];
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
try {
|
|
245
|
+
outputs.push(readBranch(entry, rootFor, path, request.project_root));
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const kind = entry?.suite_kind;
|
|
249
|
+
unreadable.push({
|
|
250
|
+
suite_kind: typeof kind === 'string' && kind ? kind : 'unknown branch',
|
|
251
|
+
message: err.message,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { outputs, unreadable };
|
|
256
|
+
}
|
|
257
|
+
function openAnswer(path, request) {
|
|
225
258
|
if (!existsSync(path)) {
|
|
226
259
|
throw new Error(`${path} not found — the host suite builder did not write its output.`);
|
|
227
260
|
}
|
|
@@ -230,8 +263,10 @@ export function readHostSuiteOutputs(path, request) {
|
|
|
230
263
|
if (!branches) {
|
|
231
264
|
throw new Error(`${path} is malformed: expected a branches array, one entry per contract system.`);
|
|
232
265
|
}
|
|
233
|
-
|
|
234
|
-
|
|
266
|
+
return {
|
|
267
|
+
entries: branches,
|
|
268
|
+
rootFor: new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root])),
|
|
269
|
+
};
|
|
235
270
|
}
|
|
236
271
|
function readBranch(entry, rootFor, path, projectRoot) {
|
|
237
272
|
if (!entry || typeof entry !== 'object') {
|
package/dist/link.js
CHANGED
|
@@ -5,29 +5,42 @@
|
|
|
5
5
|
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
8
|
-
import { CONFIG_FILE, readLocalRepoId, readLocalServer, writeConfigFile } from "./config.js";
|
|
8
|
+
import { CONFIG_FILE, locateLinkedRoot, readLocalRepoId, readLocalServer, readLocalToken, writeConfigFile, } from "./config.js";
|
|
9
9
|
import { registerRepo, WireError } from "./wire.js";
|
|
10
10
|
// Public Unitbob brain used by default. A `server` in `.unitbob.json` (or an
|
|
11
11
|
// explicit argument) overrides it — see resolution order in ensureLinked.
|
|
12
12
|
export const DEFAULT_SERVER = 'https://unitbob-73a4082838d3.herokuapp.com';
|
|
13
13
|
export async function ensureLinked(cwd = process.cwd(), server, out = process.stdout) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
if (fileId
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
// An already-linked project answers from its own root, wherever the command
|
|
15
|
+
// was typed. Nothing is linked yet? Then `cwd` is the candidate root and every
|
|
16
|
+
// guard below applies to it unchanged.
|
|
17
|
+
const root = locateLinkedRoot(cwd) ?? cwd;
|
|
18
|
+
const resolvedServer = server ?? readLocalServer(root) ?? DEFAULT_SERVER;
|
|
19
|
+
const fileId = readLocalRepoId(root); // only the root's own file — no walk-up
|
|
20
|
+
const name = projectName(root);
|
|
21
|
+
if (fileId !== null) {
|
|
22
|
+
const token = readLocalToken(root);
|
|
23
|
+
if (token === null) {
|
|
24
|
+
// Linked before the project had a key of its own. There is no way to mint
|
|
25
|
+
// one for an existing project — that would be a door into it — so the only
|
|
26
|
+
// honest instruction is to link again.
|
|
27
|
+
throw new WireError(`${CONFIG_FILE} has no project token. It was written by an older Unitbob, and the ` +
|
|
28
|
+
`server now requires one. Delete ${CONFIG_FILE} to link this project again ` +
|
|
29
|
+
'(the old project, along with its map and checks, stays where it is).');
|
|
30
|
+
}
|
|
31
|
+
// Checked on every command, not only at linking: the file holds the only key
|
|
32
|
+
// the project has, and a `.gitignore` rewritten since is how that key ends
|
|
33
|
+
// up in a public repository. Costs nothing when the entry is already there.
|
|
34
|
+
ensureGitignored(root, out);
|
|
35
|
+
return { server: resolvedServer, repoId: fileId, token, projectRoot: root };
|
|
29
36
|
}
|
|
30
|
-
|
|
37
|
+
// Refuse before touching the server, so a stray run can't mint a junk repo.
|
|
38
|
+
assertProjectRoot(root);
|
|
39
|
+
const { id, token } = await registerRepo(resolvedServer, name);
|
|
40
|
+
writeConfigFile(root, { server: resolvedServer, repo_id: id, token });
|
|
41
|
+
ensureGitignored(root, out);
|
|
42
|
+
out.write(`Linked this project to Unitbob as ${name}.\n`);
|
|
43
|
+
return { server: resolvedServer, repoId: id, token, projectRoot: root };
|
|
31
44
|
}
|
|
32
45
|
// The linking name is the *project's* name, not the checkout's (spec 29). A
|
|
33
46
|
// `.git` directory means cwd is the main checkout; a `.git` file is a worktree
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// `destination` is where the person should end up: an absolute URL the server
|
|
2
|
+
// handed back (`map_url`), or a path. Only its path and query travel on; the
|
|
3
|
+
// server checks that it stays inside this project before honouring it.
|
|
4
|
+
export function enterUrl(config, destination) {
|
|
5
|
+
const next = pathOf(destination) ?? `/repos/${config.repoId}`;
|
|
6
|
+
const query = `?next=${encodeURIComponent(next)}`;
|
|
7
|
+
return `${config.server}/repos/${config.repoId}/enter${query}#t=${config.token}`;
|
|
8
|
+
}
|
|
9
|
+
// The console, in a given tab.
|
|
10
|
+
export function consoleUrl(config, tab) {
|
|
11
|
+
const base = `/repos/${config.repoId}`;
|
|
12
|
+
return enterUrl(config, tab ? `${base}?tab=${tab}` : base);
|
|
13
|
+
}
|
|
14
|
+
function pathOf(destination) {
|
|
15
|
+
if (destination.startsWith('/'))
|
|
16
|
+
return destination;
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(destination);
|
|
19
|
+
return `${url.pathname}${url.search}`;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/proc.js
CHANGED
|
@@ -2,8 +2,22 @@
|
|
|
2
2
|
// captures stdout/stderr/exit code and hands them back untouched — shaping or
|
|
3
3
|
// interpreting that output is the caller's (and ultimately Rails') job.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
// Can this path actually be spawned? A binstub that exists but has lost its
|
|
8
|
+
// executable bit is a real state — checkouts over a filesystem with no
|
|
9
|
+
// permission bits, an archive unpacked without them — and `spawn` answers it
|
|
10
|
+
// with an EACCES `error` event, which arrives as a thrown exception rather than
|
|
11
|
+
// an exit code. Callers that pick "the project's own binstub, else the global
|
|
12
|
+
// tool" have to ask this before choosing, or the fallback never gets its turn.
|
|
13
|
+
export function executable(path) {
|
|
14
|
+
try {
|
|
15
|
+
return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
export const GRAPHIFY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
22
|
export function runProcess(command, args = [], options = {}) {
|
|
9
23
|
return new Promise((resolve, reject) => {
|
package/dist/runner/bdd.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { runProcess } from "../proc.js";
|
|
3
|
+
import { executable, runProcess } from "../proc.js";
|
|
4
4
|
import { readReport } from "./types.js";
|
|
5
5
|
import { PYTEST_BDD_PLUGIN } from "./pytestBddPlugin.js";
|
|
6
6
|
export const BDD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
7
|
// The behavioral suite lives under one root; the report is written inside it so
|
|
8
8
|
// the app under test cannot pollute it and it travels with the suite.
|
|
9
9
|
const BEHAVIORAL_ROOT = '.unitbob/behavioral';
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
10
|
+
const CUCUMBER_REPORT_NAME = 'cucumber_messages.ndjson';
|
|
11
|
+
const PYTEST_BDD_REPORT_NAME = 'pytest_bdd_report.json';
|
|
12
|
+
const PYTEST_BDD_PLUGIN_NAME = 'unitbob_pytest_bdd_plugin.py';
|
|
13
|
+
const PYTEST_INI_NAME = 'pytest.ini';
|
|
14
|
+
// Everything a run writes into that root, listed once here — where it is
|
|
15
|
+
// written. The review's "these files will be lost" warning reads this list to
|
|
16
|
+
// stay quiet about them (see `files/behavioral.ts`). A second hand-kept copy
|
|
17
|
+
// drifts the moment a strategy gains a file, and the warning goes back to
|
|
18
|
+
// shouting about the connector's own output.
|
|
19
|
+
export const BDD_RUN_ARTIFACTS = [
|
|
20
|
+
CUCUMBER_REPORT_NAME,
|
|
21
|
+
PYTEST_BDD_REPORT_NAME,
|
|
22
|
+
PYTEST_BDD_PLUGIN_NAME,
|
|
23
|
+
PYTEST_INI_NAME,
|
|
24
|
+
];
|
|
25
|
+
const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
|
|
26
|
+
const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
|
|
27
|
+
const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
|
|
28
|
+
const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
|
|
14
29
|
const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
15
30
|
// The connector-owned BDD strategy table (spec 32): the `runner` enum names one
|
|
16
31
|
// of these; the connector never executes a host-provided command string. Each
|
|
@@ -126,11 +141,3 @@ async function pickPython(projectRoot) {
|
|
|
126
141
|
}
|
|
127
142
|
throw missingRunner('pytest-bdd');
|
|
128
143
|
}
|
|
129
|
-
function executable(path) {
|
|
130
|
-
try {
|
|
131
|
-
return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|