unitbob 0.2.3 → 0.2.6

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/bin.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // The published executable (`package.json` bin). It exists only to start the
3
+ // process and end it: no logic lives here, so nothing about how the CLI was
4
+ // invoked can change what it does. npm installs a bin as a symlink, so this file
5
+ // runs under a path that is not its own — an entry point that tried to detect
6
+ // "am I the main module?" silently did nothing once installed.
7
+ import { main } from "./cli.js";
8
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
9
+ process.stderr.write(`${err.message}\n`);
10
+ process.exit(1);
11
+ });
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
- #!/usr/bin/env node
2
- // The single entry point. Parse `unitbob <verb> [args]`, dispatch to a hands-verb,
3
- // and map any thrown error to a non-zero exit with an actionable message — never
4
- // a raw stack trace. This is the only place that decides process exit codes.
1
+ // Verb dispatch. Parse `unitbob <verb> [args]`, dispatch to a hands-verb, and map
2
+ // any thrown error to an exit code with an actionable message — never a raw stack
3
+ // trace. This module decides exit codes but never acts on them: importing it must
4
+ // stay free of side effects so tests can drive `main` directly. `bin.ts` is the
5
+ // executable that owns process startup and exit.
5
6
  import { ensureLinked } from "./link.js";
6
7
  import { recipe } from "./verbs/recipe.js";
7
8
  import { show } from "./verbs/show.js";
@@ -13,6 +14,7 @@ import { suitePrepare } from "./verbs/suitePrepare.js";
13
14
  import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
14
15
  import { fixPrepare } from "./verbs/fixPrepare.js";
15
16
  import { contractPrompt } from "./verbs/contractPrompt.js";
17
+ import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
16
18
  const USAGE = `unitbob — thin local hands for the Unitbob server.
17
19
 
18
20
  Usage: unitbob <verb> [args]
@@ -24,6 +26,7 @@ Verbs:
24
26
  map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
25
27
  put-map-build Internal: upload the host-built map and graph.
26
28
  suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
29
+ suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
27
30
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
28
31
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
29
32
  contract-prompt <digest> <test_id> [fix|accept]
@@ -40,7 +43,7 @@ host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify sk
40
43
 
41
44
  Config: .unitbob.json at your project root, created automatically: the first
42
45
  run registers the project on the server by its folder name (spec 28).`;
43
- async function main(argv) {
46
+ export async function main(argv, deps = { ensureLinked }) {
44
47
  const [verb, ...args] = argv;
45
48
  if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
46
49
  process.stdout.write(`${USAGE}\n`);
@@ -52,32 +55,35 @@ async function main(argv) {
52
55
  await init(args);
53
56
  return 0;
54
57
  case 'recipe':
55
- await recipe(await ensureLinked(), args);
58
+ await recipe(await deps.ensureLinked(), args);
56
59
  return 0;
57
60
  case 'show':
58
- await show(await ensureLinked());
61
+ await show(await deps.ensureLinked());
59
62
  return 0;
60
63
  case 'map-prepare':
61
- await mapPrepare(await ensureLinked(), args);
64
+ await mapPrepare(await deps.ensureLinked(), args);
62
65
  return 0;
63
66
  case 'put-map-build':
64
- await putMapBuild(await ensureLinked(), args);
67
+ await putMapBuild(await deps.ensureLinked(), args);
65
68
  return 0;
66
69
  case 'suite-prepare':
67
- await suitePrepare(await ensureLinked(), args);
70
+ await suitePrepare(await deps.ensureLinked(), args);
71
+ return 0;
72
+ case 'suite-review-prepare':
73
+ await suiteReviewPrepare(await deps.ensureLinked(), args);
68
74
  return 0;
69
75
  case 'put-suite-build':
70
- await putSuiteBuild(await ensureLinked(), args);
76
+ await putSuiteBuild(await deps.ensureLinked(), args);
71
77
  return 0;
72
78
  case 'fix-prepare':
73
- await fixPrepare(await ensureLinked(), args);
79
+ await fixPrepare(await deps.ensureLinked(), args);
74
80
  return 0;
75
81
  case 'contract-prompt':
76
- await contractPrompt(await ensureLinked(), args);
82
+ await contractPrompt(await deps.ensureLinked(), args);
77
83
  return 0;
78
84
  case 'run':
79
85
  case 'check':
80
- await run(await ensureLinked(), args);
86
+ await run(await deps.ensureLinked(), args);
81
87
  return 0;
82
88
  default:
83
89
  process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
@@ -89,7 +95,3 @@ async function main(argv) {
89
95
  return 1;
90
96
  }
91
97
  }
92
- main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
93
- process.stderr.write(`${err.message}\n`);
94
- process.exit(1);
95
- });
@@ -1,4 +1,4 @@
1
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { assertUnitbobPath } from "./artifactPath.js";
4
4
  // The behavioral suite lives under its own root: the main `.feature` plus its
@@ -9,14 +9,21 @@ import { assertUnitbobPath } from "./artifactPath.js";
9
9
  // blob only so `check` can execute it locally.
10
10
  export const BEHAVIORAL_DIR = '.unitbob/behavioral';
11
11
  // Write a behavioral artifact envelope (main file + support files) under the
12
- // behavioral root, after checking every path is safe. The root is wiped first
13
- // so a stale file from a previous version never lingers. Returns the absolute
14
- // path of the materialized main file.
15
- export function materializeBehavioral(projectRoot, artifact) {
12
+ // behavioral root, after checking every path is safe. Stale suite artifacts are
13
+ // removed while the separately provisioned runner environment is preserved.
14
+ // Returns the absolute path of the materialized main file.
15
+ export function materializeBehavioral(projectRoot, artifact, runner) {
16
16
  const files = [artifact, ...(artifact.support_files ?? [])];
17
17
  for (const file of files)
18
18
  assertUnitbobPath(file.path, BEHAVIORAL_DIR);
19
- rmSync(join(projectRoot, BEHAVIORAL_DIR), { recursive: true, force: true });
19
+ const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
20
+ const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
21
+ mkdirSync(behavioralRoot, { recursive: true });
22
+ for (const entry of readdirSync(behavioralRoot)) {
23
+ if (!runnerEntries.has(entry)) {
24
+ rmSync(join(behavioralRoot, entry), { recursive: true, force: true });
25
+ }
26
+ }
20
27
  let mainPath = '';
21
28
  for (const file of files) {
22
29
  const dest = join(projectRoot, file.path);
@@ -27,3 +34,20 @@ export function materializeBehavioral(projectRoot, artifact) {
27
34
  }
28
35
  return { mainPath };
29
36
  }
37
+ export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner) {
38
+ const entries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
39
+ for (const entry of entries) {
40
+ const source = join(sourceRoot, BEHAVIORAL_DIR, entry);
41
+ if (!existsSync(source))
42
+ continue;
43
+ const target = join(targetRoot, BEHAVIORAL_DIR, entry);
44
+ mkdirSync(dirname(target), { recursive: true });
45
+ cpSync(source, target, { recursive: true });
46
+ }
47
+ }
48
+ const EMPTY_ENTRIES = new Set();
49
+ const RUNNER_ENVIRONMENT_ENTRIES = {
50
+ cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
51
+ 'cucumber-js': new Set(['node_modules', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']),
52
+ 'pytest-bdd': new Set(['.venv']),
53
+ };
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { assertUnitbobPath } from "./artifactPath.js";
4
5
  export function requestPath(projectRoot) {
@@ -7,11 +8,112 @@ export function requestPath(projectRoot) {
7
8
  export function outputPath(projectRoot) {
8
9
  return join(projectRoot, '.unitbob', 'suite-build', 'suite_output.json');
9
10
  }
10
- export function writeSuiteBuildRequest(projectRoot, branches) {
11
+ export function reviewOutputPath(projectRoot) {
12
+ return join(projectRoot, '.unitbob', 'suite-build', 'behavioral_review.json');
13
+ }
14
+ export function reviewRequestPath(projectRoot) {
15
+ return join(projectRoot, '.unitbob', 'suite-build', 'review-request.json');
16
+ }
17
+ export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
18
+ const metadata = output.test_metadata;
19
+ const candidateDigest = suiteCandidateDigest(output);
20
+ const request = {
21
+ candidate_digest: candidateDigest,
22
+ suite_file: output.suite_file,
23
+ capabilities: metadata?.capabilities,
24
+ output_path: reviewOutputPath(projectRoot),
25
+ known_defect_context: knownDefectContext,
26
+ candidate_run: { candidate_digest: candidateDigest, ...candidateRun },
27
+ ...(fixedCandidateRun ? {
28
+ fixed_candidate_run: { candidate_digest: candidateDigest, ...fixedCandidateRun },
29
+ } : {}),
30
+ };
31
+ const path = reviewRequestPath(projectRoot);
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
34
+ return request;
35
+ }
36
+ // The connector-owned runner strategy this branch selected. Reading it out of
37
+ // the envelope is transport, like every other `runner_manifest` access in this
38
+ // module — callers only ever get back the strategy name they dispatch on.
39
+ export function branchRunner(output) {
40
+ const manifest = output.runner_manifest;
41
+ const runner = manifest?.runner;
42
+ if (typeof runner !== 'string' || !runner) {
43
+ throw new Error('The behavioral candidate names no runner strategy to execute.');
44
+ }
45
+ return runner;
46
+ }
47
+ export function suiteCandidateDigest(output) {
48
+ return createHash('sha256')
49
+ .update(stableJson({
50
+ suite_file: output.suite_file,
51
+ runner_manifest: output.runner_manifest,
52
+ test_metadata: output.test_metadata,
53
+ }))
54
+ .digest('hex');
55
+ }
56
+ // Everything the server strips back out before it recomputes the candidate
57
+ // digest. The generator owns none of it, and if it writes any of these keys the
58
+ // two sides hash different objects — which surfaces as "this review is about a
59
+ // different candidate", blaming the reviewer for the generator's mistake. Refuse
60
+ // it here, where the real cause can still be named.
61
+ const POST_CANDIDATE_METADATA_KEYS = [
62
+ 'bdd_quality_review',
63
+ 'known_defect_probe',
64
+ 'known_defect_context',
65
+ 'candidate_run',
66
+ 'fixed_candidate_run',
67
+ ];
68
+ export function readBehavioralReview(projectRoot, output) {
69
+ const metadata = output.test_metadata;
70
+ const embedded = metadata ? POST_CANDIDATE_METADATA_KEYS.filter((key) => key in metadata) : [];
71
+ if (embedded.length > 0) {
72
+ throw new Error(`The behavioral suite generator must not embed its own review (found ${embedded.join(', ')} in test_metadata); run \`unitbob suite-review-prepare\` and provide the separate review artifact.`);
73
+ }
74
+ const path = reviewOutputPath(projectRoot);
75
+ if (!existsSync(path)) {
76
+ throw new Error(`${path} not found — run \`unitbob suite-review-prepare\` and have an independent reviewer write it.`);
77
+ }
78
+ const review = parseJson(readFileSync(path, 'utf8'), path);
79
+ if (!review || review.candidate_digest !== suiteCandidateDigest(output)) {
80
+ throw new Error(`${path} does not review the current behavioral suite candidate.`);
81
+ }
82
+ if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
83
+ throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
84
+ }
85
+ const request = readBehavioralReviewRequest(projectRoot, output);
86
+ return {
87
+ ...review,
88
+ candidate_run: request.candidate_run,
89
+ ...(request.fixed_candidate_run ? { fixed_candidate_run: request.fixed_candidate_run } : {}),
90
+ };
91
+ }
92
+ function readBehavioralReviewRequest(projectRoot, output) {
93
+ const path = reviewRequestPath(projectRoot);
94
+ const request = parseJson(readFileSync(path, 'utf8'), path);
95
+ const run = request?.candidate_run;
96
+ const digest = suiteCandidateDigest(output);
97
+ if (!request || request.candidate_digest !== digest || !run || run.candidate_digest !== digest ||
98
+ typeof run.revision !== 'string' || !run.revision || typeof run.run_result !== 'string' || !run.run_result) {
99
+ throw new Error(`${path} has no connector runner evidence for the current behavioral candidate.`);
100
+ }
101
+ const context = request.known_defect_context;
102
+ if (context?.status === 'supplied' && context.fixed_revision) {
103
+ const fixed = request.fixed_candidate_run;
104
+ if (!fixed || fixed.candidate_digest !== digest || fixed.revision !== context.fixed_revision ||
105
+ typeof fixed.run_result !== 'string' || !fixed.run_result) {
106
+ throw new Error(`${path} has no connector runner evidence for fixed revision ${context.fixed_revision}.`);
107
+ }
108
+ }
109
+ return request;
110
+ }
111
+ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext = { status: 'not_supplied' }) {
11
112
  const request = {
12
113
  project_root: projectRoot,
13
114
  output_path: outputPath(projectRoot),
14
115
  branches,
116
+ known_defect_context: knownDefectContext,
15
117
  };
16
118
  const path = requestPath(projectRoot);
17
119
  mkdirSync(dirname(path), { recursive: true });
@@ -30,7 +132,31 @@ export function readSuiteBuildRequest(projectRoot) {
30
132
  !Array.isArray(request.branches)) {
31
133
  throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
32
134
  }
33
- return request;
135
+ return {
136
+ ...request,
137
+ known_defect_context: readKnownDefectContext(request.known_defect_context, path),
138
+ };
139
+ }
140
+ function readKnownDefectContext(value, path) {
141
+ if (value === undefined)
142
+ return { status: 'not_supplied' };
143
+ if (!value || typeof value !== 'object')
144
+ throw new Error(`${path}: known_defect_context must be an object.`);
145
+ const context = value;
146
+ if (context.status === 'not_supplied')
147
+ return { status: 'not_supplied' };
148
+ if (context.status !== 'supplied' || typeof context.defect !== 'string' || !context.defect.trim()) {
149
+ throw new Error(`${path}: supplied known_defect_context must name a defect.`);
150
+ }
151
+ if (context.fixed_revision !== undefined &&
152
+ (typeof context.fixed_revision !== 'string' || !context.fixed_revision.trim())) {
153
+ throw new Error(`${path}: fixed_revision must be a non-empty string.`);
154
+ }
155
+ return {
156
+ status: 'supplied',
157
+ defect: context.defect,
158
+ ...(context.fixed_revision ? { fixed_revision: context.fixed_revision } : {}),
159
+ };
34
160
  }
35
161
  // Read the host's answers, one per branch. The connector verifies each built
36
162
  // branch parses, carries a safe-path artifact envelope under its own root, and
@@ -110,6 +236,15 @@ function parseJson(raw, path) {
110
236
  throw new Error(`${path} is not valid JSON (${err.message})`);
111
237
  }
112
238
  }
239
+ function stableJson(value) {
240
+ if (Array.isArray(value))
241
+ return `[${value.map(stableJson).join(',')}]`;
242
+ if (value && typeof value === 'object') {
243
+ const object = value;
244
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
245
+ }
246
+ return JSON.stringify(value) ?? 'null';
247
+ }
113
248
  // Turn a branch's assignment packet into its recipe name: structural uses the
114
249
  // unit-guardrail recipe, behavioral the Gherkin one.
115
250
  export function recipeNameFor(packet) {
@@ -34,20 +34,18 @@ export function runBddSuite(projectRoot, runner, mainPath) {
34
34
  async function runCucumberRuby(projectRoot) {
35
35
  const features = join(BEHAVIORAL_ROOT, 'features');
36
36
  const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
37
- const localBin = join(projectRoot, 'bin', 'cucumber');
38
37
  const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
39
- const hasSidecarGemfile = existsSync(sidecarGemfile);
40
- const command = executable(localBin) ? localBin : 'bundle';
41
- const base = executable(localBin) ? [] : ['exec', 'cucumber'];
42
- const args = [...base, features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
38
+ if (!existsSync(sidecarGemfile)) {
39
+ throw missingRunner('Cucumber');
40
+ }
41
+ const command = 'bundle';
42
+ const args = ['exec', 'cucumber', features, '--require', steps, '--format', 'message', '--out', CUCUMBER_REPORT];
43
43
  const env = {
44
44
  ...process.env,
45
45
  RAILS_ENV: 'test',
46
46
  UNITBOB_REPO_ROOT: projectRoot,
47
47
  };
48
- if (hasSidecarGemfile) {
49
- env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
50
- }
48
+ env.BUNDLE_GEMFILE = join(BEHAVIORAL_ROOT, 'Gemfile');
51
49
  const result = await runProcess(command, args, {
52
50
  cwd: projectRoot,
53
51
  timeoutMs: BDD_TIMEOUT_MS,
@@ -55,16 +53,20 @@ async function runCucumberRuby(projectRoot) {
55
53
  });
56
54
  return finalize(result, command, args, projectRoot, CUCUMBER_REPORT);
57
55
  }
56
+ function missingRunner(name) {
57
+ return new Error(`Behavioral runner missing (${name}). Run suite-prepare to provision it under ${BEHAVIORAL_ROOT}/, then run the checks again.`);
58
+ }
58
59
  // JS/TS: `@cucumber/cucumber` (cucumber-js) with the message formatter written
59
60
  // to a file.
60
61
  async function runCucumberJs(projectRoot) {
61
62
  const features = join(BEHAVIORAL_ROOT, 'features');
62
63
  const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
63
64
  const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
64
- const command = executable(sidecarBin) ? sidecarBin : 'npx';
65
- const baseArgs = executable(sidecarBin) ? [] : ['cucumber-js'];
65
+ if (!executable(sidecarBin)) {
66
+ throw missingRunner('Cucumber JS');
67
+ }
68
+ const command = sidecarBin;
66
69
  const args = [
67
- ...baseArgs,
68
70
  features,
69
71
  '--require',
70
72
  steps,
@@ -122,11 +124,7 @@ async function pickPython(projectRoot) {
122
124
  if (executable(sidecarVenvPytest)) {
123
125
  return sidecarVenvPytest;
124
126
  }
125
- const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
126
- cwd: projectRoot,
127
- timeoutMs: 10_000,
128
- }).catch(() => ({ stdout: '', stderr: '', code: 1 }));
129
- return probe.code === 0 ? 'python3' : 'python';
127
+ throw missingRunner('pytest-bdd');
130
128
  }
131
129
  function executable(path) {
132
130
  try {
@@ -1,4 +1,4 @@
1
- import { readHostSuiteOutputs, readSuiteBuildRequest } from "../files/suiteBuild.js";
1
+ import { readBehavioralReview, readHostSuiteOutputs, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
2
  import { Wire } from "../wire.js";
3
3
  // Read the task and the host's answers, verify each branch parses and carries a
4
4
  // safe-path artifact envelope, then upload both peer branches in one batch
@@ -20,13 +20,36 @@ export async function putSuiteBuild(config, _args = [], deps) {
20
20
  if (output.build_error) {
21
21
  return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
22
22
  }
23
+ let testMetadata = output.test_metadata;
24
+ if (output.suite_kind === 'behavioral') {
25
+ const review = readBehavioralReview(config.projectRoot, output);
26
+ const probe = review.known_defect_probe;
27
+ const qualityReview = review.bdd_quality_review;
28
+ if (!qualityReview || typeof qualityReview !== 'object') {
29
+ throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
30
+ }
31
+ if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
32
+ throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
33
+ }
34
+ testMetadata = {
35
+ ...output.test_metadata,
36
+ bdd_quality_review: {
37
+ ...qualityReview,
38
+ candidate_digest: review.candidate_digest,
39
+ },
40
+ known_defect_probe: review.known_defect_probe,
41
+ known_defect_context: request.known_defect_context,
42
+ candidate_run: review.candidate_run,
43
+ ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
44
+ };
45
+ }
23
46
  return {
24
47
  suite_kind: output.suite_kind,
25
48
  source_digest: sourceDigest,
26
49
  artifacts: {
27
50
  suite_file: output.suite_file,
28
51
  runner_manifest: output.runner_manifest,
29
- test_metadata: output.test_metadata,
52
+ test_metadata: testMetadata,
30
53
  },
31
54
  };
32
55
  });
package/dist/verbs/run.js CHANGED
@@ -18,7 +18,7 @@ export async function run(config, _args, deps) {
18
18
  suite_file: { path: item.suite_file.path, content: item.suite_file.content },
19
19
  runner_manifest: item.runner_manifest,
20
20
  }),
21
- materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file).mainPath,
21
+ materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file, item.runner_manifest.runner).mainPath,
22
22
  runStructural: runStructuralByRunner,
23
23
  runBehavioral: runBddSuite,
24
24
  validateStack,
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { materializeHelper } from "../files/guardrails.js";
4
- import { recipeNameFor, writeSuiteBuildRequest } from "../files/suiteBuild.js";
4
+ import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
5
  import { anyStackPrecheck } from "../runner/precheck.js";
6
6
  import { ensureRunner } from "../runner/provision.js";
7
7
  import { Wire } from "../wire.js";
@@ -13,7 +13,8 @@ import { Wire } from "../wire.js";
13
13
  // unsupported project stops with one actionable message and writes nothing; a
14
14
  // no-current-map error from the server surfaces (via WireError) with guidance to
15
15
  // rebuild the map first.
16
- export async function suitePrepare(config, _args = [], deps) {
16
+ export async function suitePrepare(config, args = [], deps) {
17
+ const defectContext = knownDefectContext(args);
17
18
  const wire = new Wire(config);
18
19
  const actual = {
19
20
  getRecipe: (name) => wire.getRecipe(name),
@@ -55,12 +56,15 @@ export async function suitePrepare(config, _args = [], deps) {
55
56
  recipe: await actual.getRecipe(recipeNameFor(packet)),
56
57
  assignment: packet.assignment,
57
58
  })));
58
- const request = writeSuiteBuildRequest(config.projectRoot, branches);
59
+ const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
59
60
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
61
+ const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
62
+ ? '`unitbob suite-review-prepare` before upload'
63
+ : '`unitbob put-suite-build`';
60
64
  actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
61
65
  actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
62
- `write your answer to ${request.output_path} as a branches array, run each locally to green, ` +
63
- 'then run `unitbob put-suite-build`.\n');
66
+ `write your answer to ${request.output_path} as a branches array, run each locally, repair broken harness steps while application failures remain red, ` +
67
+ `then run ${nextCommand}.\n`);
64
68
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
65
69
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
66
70
  if (fixableNotices.length > 0) {
@@ -70,6 +74,32 @@ export async function suitePrepare(config, _args = [], deps) {
70
74
  '\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
71
75
  }
72
76
  }
77
+ function knownDefectContext(args) {
78
+ const defect = option(args, '--known-defect=');
79
+ const fixedRevision = option(args, '--fixed-revision=');
80
+ const explicitlyAbsent = args.includes('--no-known-defect');
81
+ if ((defect && explicitlyAbsent) || (!defect && !explicitlyAbsent)) {
82
+ throw new Error('Choose exactly one of --known-defect or --no-known-defect (use --known-defect=<description>).');
83
+ }
84
+ if (fixedRevision && !defect)
85
+ throw new Error('--fixed-revision requires --known-defect.');
86
+ if (explicitlyAbsent)
87
+ return { status: 'not_supplied' };
88
+ if (!defect)
89
+ throw new Error('--known-defect requires a value.');
90
+ return {
91
+ status: 'supplied',
92
+ defect,
93
+ ...(fixedRevision ? { fixed_revision: fixedRevision } : {}),
94
+ };
95
+ }
96
+ function option(args, prefix) {
97
+ const match = args.find((arg) => arg.startsWith(prefix));
98
+ const value = match?.slice(prefix.length).trim();
99
+ if (match && !value)
100
+ throw new Error(`${prefix.slice(0, -1)} requires a value.`);
101
+ return value || undefined;
102
+ }
73
103
  function inferBddRunner(projectRoot) {
74
104
  if (existsSync(join(projectRoot, 'package.json')))
75
105
  return 'cucumber-js';
@@ -0,0 +1,101 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { copyBehavioralRunnerEnvironment, materializeBehavioral } from "../files/behavioral.js";
6
+ import { runBddSuite } from "../runner/bdd.js";
7
+ import { boundReport } from "../runner/boundReport.js";
8
+ import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
9
+ export async function suiteReviewPrepare(config, _args = [], deps) {
10
+ const actual = {
11
+ runCandidate: runCandidate,
12
+ stdout: process.stdout,
13
+ ...deps,
14
+ };
15
+ const buildRequest = readSuiteBuildRequest(config.projectRoot);
16
+ const behavioral = readHostSuiteOutputs(buildRequest.output_path, buildRequest)
17
+ .find((branch) => branch.suite_kind === 'behavioral');
18
+ if (!behavioral)
19
+ throw new Error('The suite build has no behavioral candidate to review.');
20
+ if (behavioral.build_error) {
21
+ throw new Error(`The behavioral candidate could not be reviewed: ${behavioral.build_error.message}`);
22
+ }
23
+ const candidateRun = await actual.runCandidate(config.projectRoot, behavioral);
24
+ const fixedRevision = buildRequest.known_defect_context.status === 'supplied'
25
+ ? buildRequest.known_defect_context.fixed_revision
26
+ : undefined;
27
+ const fixedCandidateRun = fixedRevision
28
+ ? await actual.runCandidate(config.projectRoot, behavioral, fixedRevision)
29
+ : undefined;
30
+ const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
31
+ actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
32
+ actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
33
+ }
34
+ async function runCandidate(projectRoot, output, revision) {
35
+ if (revision)
36
+ return runCandidateAtRevision(projectRoot, output, revision);
37
+ return runCandidateInProject(projectRoot, output, gitRevision(projectRoot));
38
+ }
39
+ async function runCandidateInProject(projectRoot, output, revision) {
40
+ const runner = branchRunner(output);
41
+ const suiteFile = output.suite_file;
42
+ const mainPath = materializeBehavioral(projectRoot, suiteFile, runner).mainPath;
43
+ const result = await runBddSuite(projectRoot, runner, mainPath);
44
+ const report = boundReport(runner, result);
45
+ if (report === null)
46
+ throw new Error('The behavioral candidate produced no machine-readable runner report.');
47
+ return { revision, run_result: report };
48
+ }
49
+ async function runCandidateAtRevision(projectRoot, output, revision) {
50
+ const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
51
+ cwd: projectRoot,
52
+ encoding: 'utf8',
53
+ }).trim();
54
+ const worktree = mkdtempSync(join(tmpdir(), 'unitbob-fixed-review-'));
55
+ let added = false;
56
+ try {
57
+ execFileSync('git', ['worktree', 'add', '--detach', worktree, resolved], { cwd: projectRoot, stdio: 'pipe' });
58
+ added = true;
59
+ const runner = branchRunner(output);
60
+ materializeBehavioral(worktree, output.suite_file, runner);
61
+ copyBehavioralRunnerEnvironment(projectRoot, worktree, runner);
62
+ return await runCandidateInProject(worktree, output, revision);
63
+ }
64
+ finally {
65
+ if (added) {
66
+ try {
67
+ execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: projectRoot, stdio: 'pipe' });
68
+ }
69
+ catch {
70
+ // The temporary directory cleanup below is still safe and bounded.
71
+ }
72
+ }
73
+ rmSync(worktree, { recursive: true, force: true });
74
+ if (added) {
75
+ try {
76
+ // `remove` can fail while the directory still goes away just above,
77
+ // which leaves a dangling entry under .git/worktrees in the user's own
78
+ // checkout. Reviewing a suite must not litter the repository it reads.
79
+ execFileSync('git', ['worktree', 'prune'], { cwd: projectRoot, stdio: 'pipe' });
80
+ }
81
+ catch {
82
+ // Housekeeping only — never fail a finished review run over it.
83
+ }
84
+ }
85
+ }
86
+ }
87
+ function gitRevision(projectRoot) {
88
+ try {
89
+ const options = {
90
+ cwd: projectRoot,
91
+ encoding: 'utf8',
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ };
94
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
95
+ const dirty = execFileSync('git', ['status', '--porcelain'], options).trim();
96
+ return dirty ? `${head}-dirty` : head;
97
+ }
98
+ catch {
99
+ return 'working-tree';
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
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": {
7
- "unitbob": "dist/cli.js"
7
+ "unitbob": "dist/bin.js"
8
8
  },
9
9
  "files": [
10
10
  "dist"
@@ -13,7 +13,7 @@
13
13
  "node": ">=18"
14
14
  },
15
15
  "scripts": {
16
- "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/cli.js",
16
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
17
17
  "prepublishOnly": "npm run build",
18
18
  "test": "node --test test/*.test.ts"
19
19
  },