unitbob 0.5.0 → 0.5.1

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.
@@ -22,33 +22,96 @@ export const BDD_RUN_ARTIFACTS = [
22
22
  PYTEST_BDD_PLUGIN_NAME,
23
23
  PYTEST_INI_NAME,
24
24
  ];
25
+ // One name for the directory every strategy points its loader at, so the
26
+ // descriptors below and the commands below them cannot come to mean different
27
+ // directories.
28
+ const STEP_DEFINITIONS = 'step_definitions';
25
29
  const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
26
30
  const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
27
31
  const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
28
32
  const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
29
33
  const PYTEST_INI = '[pytest]\naddopts =\n';
34
+ // Load order is a fact about both Cucumbers and about neither pytest — pytest
35
+ // picks `conftest.py` up itself, so there is no trap to work around there.
36
+ const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
37
+ 'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
38
+ 'than trusting the alphabet.';
30
39
  // The connector-owned BDD strategy table (spec 32): the `runner` enum names one
31
40
  // of these; the connector never executes a host-provided command string. Each
32
41
  // strategy runs the whole behavioral bundle and returns the raw machine-readable
33
42
  // report verbatim — the connector does no marker join and no aggregation.
43
+ //
44
+ // A strategy is its command *and* its loading rule. They are one entry so that a
45
+ // fourth runner cannot be added with only half of itself stated.
46
+ const BDD_STRATEGIES = {
47
+ cucumber: {
48
+ run: (projectRoot) => runCucumberRuby(projectRoot),
49
+ loading: {
50
+ step_files: '*.rb',
51
+ requirements: [
52
+ 'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
53
+ 'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
54
+ 'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
55
+ CUCUMBER_LOAD_ORDER,
56
+ ],
57
+ },
58
+ },
59
+ 'cucumber-js': {
60
+ run: (projectRoot) => runCucumberJs(projectRoot),
61
+ loading: {
62
+ step_files: '*.js',
63
+ requirements: [
64
+ 'Keep `step_definitions/` to CommonJS JavaScript and nothing else. The connector passes the whole ' +
65
+ 'directory to `--require`, and cucumber-js `require()`s every file it matches whatever the ' +
66
+ 'extension — a stray `.ts`, `.json` or `.md` left there is executed as JavaScript and aborts the ' +
67
+ 'entire run with a parse error, before a single scenario.',
68
+ 'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
69
+ 'one, register the compiler from the file that sorts first — and remember the file registering it ' +
70
+ 'is itself loaded as plain JavaScript.',
71
+ CUCUMBER_LOAD_ORDER,
72
+ ],
73
+ },
74
+ },
75
+ 'pytest-bdd': {
76
+ run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
77
+ loading: {
78
+ step_files: 'test_*.py',
79
+ requirements: [
80
+ 'pytest collects `step_definitions/` under its own default, which is `test_*.py` and `*_test.py`. ' +
81
+ 'The connector writes no `python_files` setting and will not: a file named outside those two — ' +
82
+ '`<capability>_steps.py`, say — is simply not collected. No error, no scenarios, a green run ' +
83
+ 'over nothing.',
84
+ '`conftest.py` is picked up by pytest itself whatever else sits beside it, so shared fixtures ' +
85
+ 'belong there and there is no load-order trap to work around.',
86
+ ],
87
+ },
88
+ },
89
+ };
34
90
  export function runBddSuite(projectRoot, runner, mainPath) {
35
- switch (runner) {
36
- case 'cucumber':
37
- return runCucumberRuby(projectRoot);
38
- case 'cucumber-js':
39
- return runCucumberJs(projectRoot);
40
- case 'pytest-bdd':
41
- return runPytestBdd(projectRoot, mainPath);
42
- default:
43
- return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
91
+ const strategy = strategyFor(runner);
92
+ if (!strategy) {
93
+ return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
44
94
  }
95
+ return strategy.run(projectRoot, mainPath);
96
+ }
97
+ // How this runner loads step files, for whoever has to write one. Null for a
98
+ // runner this connector does not run, which is the same answer `runBddSuite`
99
+ // gives it.
100
+ export function bddStepLoading(runner) {
101
+ return strategyFor(runner)?.loading ?? null;
102
+ }
103
+ // `Object.hasOwn` rather than a bare index: the runner name arrives over the
104
+ // wire, and `constructor` would otherwise come back as a truthy strategy with no
105
+ // `run` on it.
106
+ function strategyFor(runner) {
107
+ return Object.hasOwn(BDD_STRATEGIES, runner) ? BDD_STRATEGIES[runner] : null;
45
108
  }
46
109
  // Ruby: `cucumber` with the built-in message formatter. The features and step
47
110
  // definitions both live under the behavioral root; --require points at the step
48
111
  // definitions so only the Unitbob bundle loads.
49
112
  async function runCucumberRuby(projectRoot) {
50
113
  const features = join(BEHAVIORAL_ROOT, 'features');
51
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
114
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
52
115
  const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
53
116
  if (!existsSync(sidecarGemfile)) {
54
117
  throw missingRunner('Cucumber');
@@ -75,7 +138,7 @@ function missingRunner(name) {
75
138
  // to a file.
76
139
  async function runCucumberJs(projectRoot) {
77
140
  const features = join(BEHAVIORAL_ROOT, 'features');
78
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
141
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
79
142
  const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
80
143
  if (!executable(sidecarBin)) {
81
144
  throw missingRunner('Cucumber JS');
@@ -103,7 +166,7 @@ async function runPytestBdd(projectRoot, mainPath) {
103
166
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
104
167
  writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
105
168
  const command = await pickPython(projectRoot);
106
- const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
169
+ const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
107
170
  const isVenvPytest = command.endsWith('/pytest');
108
171
  const args = isVenvPytest
109
172
  ? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
@@ -2,27 +2,62 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- const AGENT_NAMES = ['suite-worker', 'suite-repair-worker', 'fact-finder', 'suite-reviewer'];
5
+ // The four named roles, in one place. Everything that counts them counts this
6
+ // list — see the message at the bottom, which used to carry the number as a
7
+ // literal and spent a release saying "Installed 3" beside four names.
8
+ export const AGENT_NAMES = ['suite-worker', 'suite-repair-worker', 'fact-finder', 'suite-reviewer'];
6
9
  const bundledAgentsDir = fileURLToPath(new URL('../../plugin/codex/agents/', import.meta.url));
10
+ const LABEL = {
11
+ created: 'created: ',
12
+ updated: 'updated: ',
13
+ current: 'already current:',
14
+ };
15
+ // Install (or refresh) the bounded Codex role definitions in the user's agent
16
+ // directory.
17
+ //
18
+ // This used to refuse when an installed file differed from the bundled one, to
19
+ // protect a definition the user had edited by hand. The case it actually met was
20
+ // the ordinary one: an upgrade from an older release, where every file differs
21
+ // and every install therefore failed. Spec 43, §1.6.
22
+ //
23
+ // The refusal became actively harmful once the workflows started asking a role
24
+ // whether this session can see it. A stale role answers that question exactly
25
+ // like a current one, so an update nobody could apply reads as "fully equipped"
26
+ // — the check would pass and the run would proceed on last release's
27
+ // instructions. So the file is overwritten, and what changed is said out loud
28
+ // rather than left for the user to discover.
7
29
  export function installCodexAgents(args, deps = { home: homedir(), stdout: process.stdout }) {
8
30
  if (args.length > 0)
9
31
  throw new Error('codex-install accepts no arguments.');
10
32
  const targetDir = join(deps.home, '.codex', 'agents');
11
- const files = AGENT_NAMES.map((name) => ({
12
- source: join(bundledAgentsDir, `${name}.toml`),
13
- target: join(targetDir, `${name}.toml`),
14
- }));
15
- for (const file of files) {
16
- if (!existsSync(file.target))
17
- continue;
18
- if (readFileSync(file.target, 'utf8') === readFileSync(file.source, 'utf8'))
19
- continue;
20
- throw new Error(`Refusing to overwrite existing Codex agent definition: ${file.target}`);
21
- }
22
33
  mkdirSync(targetDir, { recursive: true });
23
- for (const file of files) {
24
- if (!existsSync(file.target))
25
- copyFileSync(file.source, file.target);
34
+ const byOutcome = { created: [], updated: [], current: [] };
35
+ for (const name of AGENT_NAMES) {
36
+ const source = join(bundledAgentsDir, `${name}.toml`);
37
+ const target = join(targetDir, `${name}.toml`);
38
+ const outcome = outcomeFor(source, target);
39
+ if (outcome !== 'current')
40
+ copyFileSync(source, target);
41
+ byOutcome[outcome].push(`${name}.toml`);
26
42
  }
27
- deps.stdout.write(`Installed 3 Unitbob Codex agent definitions in ${targetDir}. Start a new Codex thread before running Unitbob.\n`);
43
+ const changes = ['created', 'updated', 'current']
44
+ .filter((outcome) => byOutcome[outcome].length > 0)
45
+ .map((outcome) => ` ${LABEL[outcome]} ${byOutcome[outcome].join(', ')}`);
46
+ // The reason is attached only when something actually changed, and it is
47
+ // worded to be true of both ways it can change. "A thread open before these
48
+ // files existed" is true of a first install and false of an upgrade, where the
49
+ // files did exist — and the upgrade is the case that matters most, because a
50
+ // thread holding last release's definition answers a readiness check exactly
51
+ // like a current one.
52
+ const changed = byOutcome.created.length + byOutcome.updated.length > 0;
53
+ const why = changed
54
+ ? ' — a thread already open is running the definitions it read when it started, not these'
55
+ : '';
56
+ deps.stdout.write(`${AGENT_NAMES.length} Unitbob Codex agent definitions in ${targetDir}:\n${changes.join('\n')}\n` +
57
+ `Start a new Codex thread before running Unitbob${why}.\n`);
58
+ }
59
+ function outcomeFor(source, target) {
60
+ if (!existsSync(target))
61
+ return 'created';
62
+ return readFileSync(target, 'utf8') === readFileSync(source, 'utf8') ? 'current' : 'updated';
28
63
  }
@@ -2,6 +2,7 @@ import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
5
+ import { bddStepLoading } from "../runner/bdd.js";
5
6
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
6
7
  import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
7
8
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
@@ -156,6 +157,11 @@ export async function suitePrepare(config, args = [], deps) {
156
157
  const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
157
158
  if (!manifest)
158
159
  return { packet, runner, branch: null };
160
+ // Spec 43, §3.2. The rule for which step files this runner loads travels
161
+ // with the branch that will be written against it, so nobody has to read
162
+ // the connector's own source to find it out — which is exactly what two
163
+ // coordinators did.
164
+ const stepLoading = packet.suite_kind === 'behavioral' && runner ? bddStepLoading(runner) : null;
159
165
  return {
160
166
  packet,
161
167
  runner,
@@ -166,6 +172,7 @@ export async function suitePrepare(config, args = [], deps) {
166
172
  recipe: await actual.getRecipe(recipeNameFor(packet)),
167
173
  assignment: packet.assignment,
168
174
  runner_manifest: manifest,
175
+ ...(stepLoading ? { step_loading: stepLoading } : {}),
169
176
  },
170
177
  };
171
178
  }));
@@ -200,6 +207,22 @@ export async function suitePrepare(config, args = [], deps) {
200
207
  `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
201
208
  `runner that runs after publishing, so you never have to guess the command), repair broken harness steps while application failures remain red, ` +
202
209
  `then run ${nextCommand}.\n`);
210
+ // Printed as well as written, because a rule nobody reads is a rule nobody
211
+ // follows — and this one is silent when broken: a step file the runner does
212
+ // not collect produces no error, only a green run over no scenarios.
213
+ for (const { packet, runner, branch } of prepared) {
214
+ if (!branch || packet.suite_kind !== 'behavioral' || !runner)
215
+ continue;
216
+ actual.stdout.write(branch.step_loading
217
+ ? stepLoadingNotice(runner, branch.step_loading)
218
+ // Said rather than left blank. This connector has no strategy for that
219
+ // runner, so it does not know which files it loads — and silence here
220
+ // reads as "any name will do", which is the failure this whole notice
221
+ // exists to prevent.
222
+ : `\nBehavioral steps run under "${runner}", and this connector does not know how that runner ` +
223
+ 'finds its step files — it has no strategy of that name. Nothing here tells you what to call ' +
224
+ 'them, and this connector will not be able to run the branch either.\n');
225
+ }
203
226
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
204
227
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
205
228
  if (setupNotices.length > 0) {
@@ -221,6 +244,24 @@ export async function suitePrepare(config, args = [], deps) {
221
244
  '\n');
222
245
  }
223
246
  }
247
+ // The runner's own rule for which step files it will load, in the words of the
248
+ // side that loads them (spec 43, §3.2). The same object is in `request.json`, on
249
+ // the behavioral branch; this is the copy the coordinator sees without opening a
250
+ // file.
251
+ //
252
+ // A null pattern is printed as a null pattern. A runner whose rule is not one
253
+ // pattern says what it does know and admits the rest — inventing a pattern here
254
+ // would recreate, in the connector this time, exactly the retelling this
255
+ // replaced.
256
+ function stepLoadingNotice(runner, loading) {
257
+ const rule = loading.step_files
258
+ ? `it loads \`${loading.step_files}\` from \`.unitbob/behavioral/step_definitions/\` — put the capability id ` +
259
+ 'where the `*` is, and a file named anything else is not loaded at all'
260
+ : 'its rule for which files it loads is not one pattern, and this connector will not state one for it';
261
+ return (`\nBehavioral steps run under "${runner}", and ${rule}. What else has to be true of a step file there:\n - ` +
262
+ loading.requirements.join('\n - ') +
263
+ '\nThis is also in `request.json`, on the behavioral branch, as `step_loading`.\n');
264
+ }
224
265
  // What the boot check found, in the vibecoder's terms. Printed on every run,
225
266
  // including the quiet ones: "we looked and it starts" and "we could not look"
226
267
  // are both worth a line, and a check nobody hears about is a check nobody
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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.5.0 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.5.1 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