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.
@@ -0,0 +1,77 @@
1
+ import { materializeBehavioralUnion, materializeBehavioralWorld } from "../files/behavioral.js";
2
+ import { featureFeaturePath, featureStepsPath, knowledgePath, parseFeatureId, testsOutputPath, testsRequestPath, writeTestsRequest, } from "../files/features.js";
3
+ import { installedRunnerVersion, selectRunnerEnvelope } from "../runner/manifest.js";
4
+ import { placeProblem } from "../runner/place.js";
5
+ import { detectBddRunner } from "../runner/precheck.js";
6
+ import { ensureRunner } from "../runner/provision.js";
7
+ import { ToolchainUnavailableError } from "../runner/toolchain.js";
8
+ import { Wire } from "../wire.js";
9
+ import { assertKnowledgeUnchanged } from "./putTests.js";
10
+ // `tests-prepare <feature_id>` (spec 52-3, AC 3.4): the host's task for writing
11
+ // a feature's checks. The packet (a 409 before the talk is the server's own
12
+ // sentence, let through), then the knowledge file on disk held to the server's
13
+ // digest — the checks are written from that file, and a file edited after the
14
+ // talk would put text under seal the person never confirmed — then the recipe,
15
+ // the union of the main suite and every red feature's checks on disk (the
16
+ // shared steps to reuse, the base for rewriting the wiring at `red`), the
17
+ // runner provisioned as `suite-prepare` provisions it, the World in place, and
18
+ // `tests-request.json` beside the talk's request. No model is called, nothing
19
+ // is uploaded.
20
+ export async function testsPrepare(config, args = [], deps) {
21
+ const wire = new Wire(config);
22
+ const d = {
23
+ getTestsPacket: (id) => wire.getTestsPacket(id),
24
+ getRecipe: (name) => wire.getRecipe(name),
25
+ getSuiteIndex: () => wire.getSuiteIndex(),
26
+ detectRunner: detectBddRunner,
27
+ ensureRunner,
28
+ installedVersion: installedRunnerVersion,
29
+ stdout: process.stdout,
30
+ ...deps,
31
+ };
32
+ const featureId = parseFeatureId(args[0], 'tests-prepare');
33
+ const unusable = placeProblem(config.projectRoot);
34
+ if (unusable)
35
+ throw new Error(unusable);
36
+ const packet = await d.getTestsPacket(featureId);
37
+ assertKnowledgeUnchanged(config.projectRoot, featureId, packet.knowledge_digest);
38
+ const [recipe, index] = await Promise.all([d.getRecipe('feature_tests'), d.getSuiteIndex()]);
39
+ // The main suite's runner when it is built — the checks share its directory
40
+ // and its steps, so they cannot run on another — else the one this stack
41
+ // selects, as `suite-prepare` does.
42
+ const runner = packet.main_suite === 'not_built' ? d.detectRunner(config.projectRoot) : packet.main_suite.runner;
43
+ if (!runner) {
44
+ throw new Error('This project matches no BDD runner the connector can run — the checks cannot be written on it.');
45
+ }
46
+ const provisioned = await d.ensureRunner(config.projectRoot, runner);
47
+ if (provisioned.status === 'fixable') {
48
+ const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
49
+ throw new ToolchainUnavailableError(`The "${runner}" runner could not be installed under .unitbob/, and the checks cannot run without it: ` +
50
+ `${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written.`, config.projectRoot);
51
+ }
52
+ const selected = selectRunnerEnvelope(packet.runner_manifests, runner);
53
+ const version = d.installedVersion(runner, config.projectRoot);
54
+ if (!selected || !version) {
55
+ throw new Error(`The version of "${runner}" installed under .unitbob/behavioral/ could not be read, and the server requires it. ` +
56
+ 'Re-run `unitbob suite-prepare` so the runner is provisioned again.');
57
+ }
58
+ materializeBehavioralUnion(config.projectRoot, index, runner);
59
+ materializeBehavioralWorld(config.projectRoot, runner);
60
+ writeTestsRequest(config.projectRoot, featureId, {
61
+ project_root: config.projectRoot,
62
+ recipe,
63
+ feature: packet.feature,
64
+ feature_tag: packet.feature_tag,
65
+ assignment: packet.assignment,
66
+ scenarios: packet.scenarios,
67
+ knowledge_path: knowledgePath(config.projectRoot, featureId),
68
+ knowledge_digest: packet.knowledge_digest,
69
+ runner,
70
+ runner_manifest: { ...selected, runner_version: version },
71
+ main_suite: packet.main_suite,
72
+ feature_path: featureFeaturePath(featureId),
73
+ steps_path: featureStepsPath(featureId, runner),
74
+ output_path: testsOutputPath(config.projectRoot, featureId),
75
+ });
76
+ d.stdout.write(`Tests request written to ${testsRequestPath(config.projectRoot, featureId)}\n`);
77
+ }
@@ -0,0 +1,40 @@
1
+ import { knowledgePath, parseFeatureId, readTestsOutput, readTestsRequest, testsReviewOutputPath, testsReviewRequestPath, writeTestsReviewRequest, } from "../files/features.js";
2
+ import { suiteCandidateDigest } from "../files/suiteBuild.js";
3
+ import { Wire } from "../wire.js";
4
+ // `tests-review-prepare <feature_id>` (spec 52-4, AC 1.11): the independent
5
+ // reviewer's task for a feature's checks, once they all pass. The packet
6
+ // first — a 409 is the server's own sentence, at `intent` (nothing to review)
7
+ // and at `done` (the checks are guardrails now), let through as it is — then
8
+ // the files from disk exactly as `put-tests` will send them, bound by the same
9
+ // candidate digest the upload carries, and the request written in the shape
10
+ // of the main suite's `review-request.json` so the same role reads it: plus
11
+ // where `knowledge.md` is, because the promise each scenario protects is
12
+ // written there, and the scenarios as the server sealed them. No model is
13
+ // called, nothing is uploaded; `put-tests` publishes the review.
14
+ export async function testsReviewPrepare(config, args = [], deps) {
15
+ const d = {
16
+ getTestsPacket: (id) => new Wire(config).getTestsPacket(id),
17
+ stdout: process.stdout,
18
+ ...deps,
19
+ };
20
+ const featureId = parseFeatureId(args[0], 'tests-review-prepare');
21
+ // An existence guard only: the request is what `put-tests` will read after
22
+ // the review, and its absence names the verb that writes it — better said
23
+ // here than as a missing answer two steps later.
24
+ readTestsRequest(config.projectRoot, featureId);
25
+ const packet = await d.getTestsPacket(featureId);
26
+ const output = readTestsOutput(config.projectRoot, featureId);
27
+ writeTestsReviewRequest(config.projectRoot, featureId, {
28
+ candidate_digest: suiteCandidateDigest({
29
+ suite_kind: 'behavioral', suite_file: output.suite_file, runner_manifest: output.runner_manifest,
30
+ }),
31
+ suite_file: output.suite_file,
32
+ capabilities: output.test_metadata.capabilities,
33
+ knowledge_path: knowledgePath(config.projectRoot, featureId),
34
+ scenarios: packet.scenarios,
35
+ output_path: testsReviewOutputPath(config.projectRoot, featureId),
36
+ });
37
+ d.stdout.write(`Feature review request written to ${testsReviewRequestPath(config.projectRoot, featureId)}\n`);
38
+ d.stdout.write(`Next: have the independent reviewer write bdd_quality_review to ${testsReviewOutputPath(config.projectRoot, featureId)}, ` +
39
+ `then run \`unitbob put-tests ${featureId}\`.\n`);
40
+ }
package/dist/wire.js CHANGED
@@ -7,11 +7,17 @@ import { proxyHint } from "./proxyHint.js";
7
7
  // that reads an absence as a verdict either invents a rejection or invents an
8
8
  // approval. `validate-build` is the caller that needs the difference: with no
9
9
  // server it succeeds, and says out loud which questions went unasked.
10
+ //
11
+ // `status` is the HTTP status the server answered with, when there was one:
12
+ // for the one caller that treats a 404 — a server older than the route — as
13
+ // an absence rather than a verdict (`map-prepare`, spec 52-4).
10
14
  export class WireError extends Error {
11
15
  unreachable;
16
+ status;
12
17
  constructor(message, options = {}) {
13
18
  super(message);
14
19
  this.unreachable = options.unreachable ?? false;
20
+ this.status = options.status ?? null;
15
21
  }
16
22
  }
17
23
  // POST /repos/register — the linking bootstrap (spec 28). A standalone function
@@ -102,15 +108,19 @@ export class Wire {
102
108
  return body.results;
103
109
  }
104
110
  // GET /repos/:id/suites — both current suites (spec 32), exactly two peer
105
- // items. A `ready` item carries its blob; a `not_built` item is skipped.
106
- async getSuites() {
111
+ // items, and since spec 52-3 the checks of every red feature beside them. A
112
+ // `ready` item carries its blob; a `not_built` item is skipped.
113
+ async getSuiteIndex() {
107
114
  const res = await this.send('GET', this.repoPath('suites'));
108
115
  await this.ensureOk(res, `GET ${this.repoPath('suites')}`);
109
116
  const body = (await res.json());
110
117
  if (!Array.isArray(body.suites)) {
111
118
  throw new WireError(`GET ${this.repoPath('suites')} returned no suites array.`);
112
119
  }
113
- return body.suites;
120
+ return {
121
+ suites: body.suites,
122
+ feature_suites: Array.isArray(body.feature_suites) ? body.feature_suites : [],
123
+ };
114
124
  }
115
125
  // POST /repos/:id/runs/batch — ship each branch's raw report (or suite error)
116
126
  // in one batch; the server parses each against the exact stored version and
@@ -149,6 +159,15 @@ export class Wire {
149
159
  await this.ensureOk(res, `PUT ${this.repoPath('suite_build')}`);
150
160
  return (await res.json());
151
161
  }
162
+ // GET /repos/:id/red_lamps — the red list (spec 54-1). No parameters: the
163
+ // server names the current version of each map itself, which is the whole
164
+ // point — a fix no longer guesses a digest.
165
+ async getRedList() {
166
+ const url = this.repoPath('red_lamps');
167
+ const res = await this.send('GET', url);
168
+ await this.ensureOk(res, `GET ${url}`);
169
+ return (await res.json());
170
+ }
152
171
  // GET /repos/:id/fix_packet?interface_id= — the per-capability repair packet.
153
172
  // Relayed down for the host to fix code or accept the change; 422 (non-failed /
154
173
  // stale / no suite) surfaces as a WireError carrying the server's business reason.
@@ -158,6 +177,69 @@ export class Wire {
158
177
  await this.ensureOk(res, `GET ${url}`);
159
178
  return (await res.json());
160
179
  }
180
+ // POST /repos/:id/features — record a feature (spec 52-1). A 409 (no current
181
+ // map) and a 422 (an id not on the map, with both sides named in the body)
182
+ // surface as a WireError carrying the server's text, so the host reads what
183
+ // the map knows and corrects its file.
184
+ async postFeature(payload) {
185
+ const res = await this.send('POST', this.repoPath('features'), payload);
186
+ if (res.status === 422)
187
+ throw new WireError(await unknownCapabilitiesRefusal(res));
188
+ await this.ensureOk(res, `POST ${this.repoPath('features')}`);
189
+ return (await res.json());
190
+ }
191
+ // GET /repos/:id/features — every feature of the project, newest first, and
192
+ // the server's words for an empty list (spec 52-2, AC 1.2).
193
+ async listFeatures() {
194
+ const res = await this.send('GET', this.repoPath('features'));
195
+ await this.ensureOk(res, `GET ${this.repoPath('features')}`);
196
+ return (await res.json());
197
+ }
198
+ // GET /repos/:id/features/:feature_id/knowledge_packet (spec 52-2, AC 1.3).
199
+ async getKnowledgePacket(featureId) {
200
+ const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge_packet`);
201
+ const res = await this.send('GET', path);
202
+ await this.ensureOk(res, `GET ${path}`);
203
+ return (await res.json());
204
+ }
205
+ // PUT /repos/:id/features/:feature_id/knowledge (spec 52-2, AC 1.4). The
206
+ // server checks the file's shape; a 422 carries `problems`, and they are
207
+ // relaid whole, one line per problem with both sides — the host fixes the
208
+ // file from those lines, and cutting them at 500 characters would hide the
209
+ // ones at the end.
210
+ async putKnowledge(featureId, knowledge) {
211
+ const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge`);
212
+ const res = await this.send('PUT', path, { knowledge });
213
+ if (res.status === 422)
214
+ throw new WireError(await problemsRefusal(res, 'PUT knowledge failed: 422'));
215
+ await this.ensureOk(res, `PUT ${path}`);
216
+ return (await res.json());
217
+ }
218
+ // GET /repos/:id/features/:feature_id/tests_packet (spec 52-3, AC 2.1). A
219
+ // 409 is the server's own sentence (talk the feature through first) and is
220
+ // relaid as it is.
221
+ async getTestsPacket(featureId) {
222
+ const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/tests_packet`);
223
+ const res = await this.send('GET', path);
224
+ if (res.status === 409)
225
+ throw new WireError(await wordedRefusal(res, `GET tests_packet failed: 409`));
226
+ await this.ensureOk(res, `GET ${path}`);
227
+ return (await res.json());
228
+ }
229
+ // PUT /repos/:id/features/:feature_id/suite (spec 52-3, AC 2.2). Every
230
+ // refusal is worded by the server: a 409 in one sentence, a 422 in one
231
+ // sentence or, for a broken seal, with one problem per difference — relaid
232
+ // whole, both sides per line, like the knowledge file's.
233
+ async putFeatureSuite(featureId, upload) {
234
+ const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/suite`);
235
+ const res = await this.send('PUT', path, upload);
236
+ if (res.status === 409)
237
+ throw new WireError(await wordedRefusal(res, 'PUT suite failed: 409'));
238
+ if (res.status === 422)
239
+ throw new WireError(await problemsRefusal(res, 'PUT suite failed: 422'));
240
+ await this.ensureOk(res, `PUT ${path}`);
241
+ return (await res.json());
242
+ }
161
243
  // GET /recipes/:name — fetch a recipe at call time. Recipes live on Rails so
162
244
  // the connector and Skill carry no recipe text (spec 15, acceptance criteria).
163
245
  async getRecipe(name) {
@@ -244,7 +326,7 @@ export class Wire {
244
326
  if (res.status === 404) {
245
327
  throw new WireError(`This project is linked to a repository the server at ${this.config.server} does not have, ` +
246
328
  'or the token in .unitbob.json does not open it. Delete .unitbob.json to link again ' +
247
- '(the old project, along with its map and checks, stays where it is).');
329
+ '(the old project, along with its map and checks, stays where it is).', { status: 404 });
248
330
  }
249
331
  let detail = '';
250
332
  try {
@@ -253,8 +335,55 @@ export class Wire {
253
335
  catch {
254
336
  // ignore — the status alone is actionable enough
255
337
  }
256
- throw new WireError(statusRefusal(what, res, detail, this.config.server));
338
+ throw new WireError(statusRefusal(what, res, detail, this.config.server), { status: res.status });
339
+ }
340
+ }
341
+ // The 422 of POST /features carries two lists, and the host corrects its file by
342
+ // reading both (spec 52-1, AC 1.3). `ensureOk` keeps 500 characters of a body,
343
+ // which is a line of context for every other refusal and, on a map of twenty
344
+ // capabilities or more, cuts `known_ids` in half — the list the correction is
345
+ // made from, on exactly the projects that have the most ids to get wrong. So
346
+ // the two lists are relaid whole, one per line; any other 422 body keeps the
347
+ // ordinary shape.
348
+ async function unknownCapabilitiesRefusal(res) {
349
+ const { text, body } = await readBody(res);
350
+ if (!Array.isArray(body.unknown_ids) || !Array.isArray(body.known_ids)) {
351
+ return `POST features failed: 422 — ${text.slice(0, 500)}`;
352
+ }
353
+ return (`POST features failed: 422 — ${String(body.error ?? 'These capabilities are not on the current map.')}\n` +
354
+ `unknown_ids: ${JSON.stringify(body.unknown_ids)}\n` +
355
+ `known_ids: ${JSON.stringify(body.known_ids)}`);
356
+ }
357
+ // A refusal the server worded in one sentence: that sentence, whole.
358
+ async function wordedRefusal(res, prefix) {
359
+ const { text, body } = await readBody(res);
360
+ return `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
361
+ }
362
+ // A refusal that may carry `problems` — the knowledge file's shape (spec 52-2,
363
+ // AC 5.3) or a broken seal (spec 52-3): the sentence, then one problem per line
364
+ // with both sides, relaid whole because the host fixes the file from all of
365
+ // them; without problems, the sentence.
366
+ async function problemsRefusal(res, prefix) {
367
+ const { text, body } = await readBody(res);
368
+ const head = `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
369
+ if (!Array.isArray(body.problems))
370
+ return head;
371
+ const lines = body.problems.map((problem) => `expected: ${String(problem.expected)}\n got: ${String(problem.got)}`);
372
+ return [head, ...lines].join('\n');
373
+ }
374
+ // A refusal body as text and, when it is JSON, as an object; when it is not,
375
+ // the text itself is the detail.
376
+ async function readBody(res) {
377
+ let text = '';
378
+ let body = {};
379
+ try {
380
+ text = await res.text();
381
+ body = JSON.parse(text);
382
+ }
383
+ catch {
384
+ // not JSON — the text itself is the detail
257
385
  }
386
+ return { text, body };
258
387
  }
259
388
  // The two statuses that prove somebody else answered.
260
389
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.8",
3
+ "version": "0.7.13",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
21
21
  connector-owned harness, or another slice.
22
22
 
23
23
  After every owned edit, run
24
- `npx -y --loglevel=error unitbob@0.7.8 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.13 run-local <branch>` and inspect the machine
25
25
  report. Look only at examples or scenarios matching your owned paths or case
26
26
  markers. Do not require a green exit code from the whole branch: foreign failures
27
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded
@@ -160,6 +160,24 @@ genuinely holds a promise and holds less of it than its name suggests. Nothing
160
160
  mechanical can tell those two apart; what makes the difference is that your
161
161
  sentence is specific enough to act on.
162
162
 
163
+ ## A feature's checks
164
+
165
+ The same review, for a smaller candidate: the checks of one feature being
166
+ built (spec 52-4). Then the request is
167
+ `.unitbob/features/<id>/tests-review-request.json`, with the same keys as the
168
+ main suite's plus two — `knowledge_path` and `scenarios` — and the review goes
169
+ to the `output_path` it names, in the same form: `candidate_digest` at the top
170
+ level, `bdd_quality_review` with one entry per Scenario by `case_marker` and
171
+ name. There is no `known_defect_probe` and no `selection_review` here; write
172
+ neither.
173
+
174
+ When the request carries `knowledge_path`, the promise each Scenario protects
175
+ is written in that `knowledge.md`, in its `Scenarios` section — read it there,
176
+ not from the capability description, which for a feature is one line. The
177
+ Scenario text is the user's and sealed; the steps behind it are what you
178
+ judge, exactly as above. Do not read the feature's implementation: whether the
179
+ code is right is the run's question, and the run was made by the connector.
180
+
163
181
  ## What is not yours
164
182
 
165
183
  **Do not edit the suite.** Not the `.feature` files, not the step definitions,