unitbob 0.7.8 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js 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,28 @@ 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] Internal: run the suite you just wrote, before publishing it, with the same runner
66
- that will run it afterwards. No argument runs every branch the build asked for.
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.
67
76
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
68
- contract-prompt <digest> <test_id> [fix|accept]
69
- Internal: fetch the fix/accept brief for one red check on either map.
77
+ contract-prompt <digest>|feature:<id> <test_id> [fix|accept]
78
+ Internal: fetch the fix/accept brief for one red check on either map — or, with
79
+ feature:<id>, the fix brief for a feature's own failing checks.
80
+ feature-prepare Internal: fetch the recipe and the product capabilities, write the host
81
+ feature-start request — for naming what a change may touch, before it is made.
82
+ put-feature Internal: record the host's feature answer and print the link to its page.
83
+ knowledge-prepare Internal: without an id, list the features that can be talked through; with
84
+ one, fetch the recipe and the feature's packet and write the host request.
85
+ put-knowledge Internal: send the feature's knowledge.md and print the link to its page.
86
+ tests-prepare <id> Internal: fetch the feature's assignment and the recipe, put the suites on disk,
87
+ and write the host request for its checks.
88
+ put-tests <id> Internal: run the feature's checks and save the harness with that run as its
89
+ proof (all red at first); with a review file beside the answer — publishes the review.
90
+ Files the run under the saved version and prints the link to its page.
91
+ tests-review-prepare <id>
92
+ Internal: write the independent reviewer's request for the feature's checks, once
93
+ they all pass; put-tests then publishes the review it writes.
70
94
  check Run every Unitbob contract suite locally and report.
71
95
  run Alias for check.
72
96
 
@@ -138,6 +162,30 @@ export async function main(argv, deps = { ensureLinked }) {
138
162
  case 'contract-prompt':
139
163
  await contractPrompt(await linked(), args);
140
164
  return 0;
165
+ case 'feature-prepare':
166
+ await featurePrepare(await linked(), args);
167
+ return 0;
168
+ case 'put-feature':
169
+ await putFeature(await linked(), args);
170
+ return 0;
171
+ case 'knowledge-prepare':
172
+ await knowledgePrepare(await linked(), args);
173
+ return 0;
174
+ case 'put-knowledge':
175
+ await putKnowledge(await linked(), args);
176
+ return 0;
177
+ case 'tests-prepare':
178
+ await testsPrepare(await linked(), args);
179
+ return 0;
180
+ case 'put-tests':
181
+ // Non-zero when the runner could not start or produced no report:
182
+ // nothing was sent, and the host has to read why (spec 52-3, AC 3.5).
183
+ // And when a review lies beside a run that is not all green (spec
184
+ // 52-4, AC 1.10) — nothing was sent then either.
185
+ return await putTests(await linked(), args);
186
+ case 'tests-review-prepare':
187
+ await testsReviewPrepare(await linked(), args);
188
+ return 0;
141
189
  case 'run-local':
142
190
  // The one verb whose non-zero exit is not an error: a branch that failed
143
191
  // the same set of cases twice in a row (spec 34-6, criterion 3). Red
@@ -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
- export function materializeBehavioral(projectRoot, artifact, runner) {
294
- const files = [artifact, ...(artifact.support_files ?? [])];
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, ...(artifact.support_files ?? []).map((file) => file.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
+ }
@@ -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 });
@@ -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
- function resolveSuiteFile(file, root, path, suiteKind, projectRoot) {
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
  }
@@ -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 = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
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
- const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', '.'];
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');