unitbob 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,15 @@ import os
14
14
  _UNITBOB_REPORT = {"version": 1, "scenarios": []}
15
15
  _UNITBOB_CURRENT = {}
16
16
 
17
+ # Resolved once, at import, and never again. The connector passes this path
18
+ # relative to the project root — a relative path means the same thing whether the
19
+ # run happens on this machine or inside a container, and an absolute host path
20
+ # would name a directory the run cannot see. The working directory is the project
21
+ # root when the plugin loads, so a fixture that changes directory later cannot
22
+ # move the report out from under us.
23
+ _UNITBOB_OUT = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
24
+ _UNITBOB_OUT = os.path.abspath(_UNITBOB_OUT) if _UNITBOB_OUT else None
25
+
17
26
 
18
27
  def pytest_bdd_before_scenario(request, feature, scenario):
19
28
  _UNITBOB_CURRENT[id(scenario)] = {
@@ -55,8 +64,7 @@ def pytest_bdd_after_scenario(request, feature, scenario):
55
64
 
56
65
 
57
66
  def pytest_sessionfinish(session, exitstatus):
58
- out = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
59
- if out:
60
- with open(out, "w") as handle:
67
+ if _UNITBOB_OUT:
68
+ with open(_UNITBOB_OUT, "w") as handle:
61
69
  json.dump(_UNITBOB_REPORT, handle)
62
70
  `;
@@ -1,8 +1,8 @@
1
1
  import { join } from 'node:path';
2
- import { runProcess } from "../proc.js";
3
2
  import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
3
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
4
4
  import { locateRunner } from "./toolchain.js";
5
- import { readReport } from "./types.js";
5
+ import { clearReport, readFreshReport } from "./types.js";
6
6
  export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
7
7
  // Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
8
8
  // green→red flip can never come from run-order nondeterminism. It does not inherit
@@ -18,13 +18,15 @@ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
18
18
  // corrupt it.
19
19
  //
20
20
  // `suitePaths` is every file of the branch in the suite blob's own
21
- // project-relative form (spec 42, §6.5). Named one by one rather than as a
21
+ // project-relative form (spec 43, §6.5). Named one by one rather than as a
22
22
  // directory: the artifact already says exactly which files it is, while a
23
23
  // directory would also collect whatever else happens to be sitting under the
24
24
  // root.
25
25
  export async function runRspecSuite(projectRoot, suitePaths) {
26
26
  const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
27
- const { result, command, args } = await invokeRspec(projectRoot, [
27
+ const reportPath = join(projectRoot, RSPEC_RESULT_FILE);
28
+ const survivor = clearReport(reportPath);
29
+ const run = await invokeRspec(projectRoot, [
28
30
  ...suitePaths,
29
31
  '--options',
30
32
  optionsPath,
@@ -38,11 +40,9 @@ export async function runRspecSuite(projectRoot, suitePaths) {
38
40
  RSPEC_RESULT_FILE,
39
41
  ]);
40
42
  return {
41
- ...result,
42
- command,
43
- args,
43
+ ...run,
44
44
  resultPath: RSPEC_RESULT_FILE,
45
- report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
45
+ report: readFreshReport(reportPath, survivor),
46
46
  };
47
47
  }
48
48
  // Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
@@ -54,10 +54,12 @@ async function invokeRspec(projectRoot, rspecArgs) {
54
54
  const located = locateRunner(projectRoot, 'rspec');
55
55
  const command = located?.command ?? 'bundle';
56
56
  const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
57
- const result = await runProcess(command, args, {
58
- cwd: projectRoot,
57
+ return runInProject(projectRoot, command, args, {
59
58
  timeoutMs: RSPEC_TIMEOUT_MS,
60
- env: { ...process.env, ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
59
+ env: {
60
+ ...located?.env,
61
+ RAILS_ENV: 'test',
62
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
63
+ },
61
64
  });
62
- return { result, command, args };
63
65
  }
@@ -1,7 +1,7 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { isAbsolute, join } from 'node:path';
4
3
  import { executable } from "../proc.js";
4
+ import { commandSucceedsInProject } from "./place.js";
5
5
  // Where Unitbob keeps a test runner it had to install for itself, together with
6
6
  // whatever that runner needs to load the project.
7
7
  //
@@ -15,8 +15,42 @@ export const SIDECAR_DIR = '.unitbob/runners';
15
15
  export function sidecarPath(projectRoot, ...segments) {
16
16
  return join(projectRoot, SIDECAR_DIR, ...segments);
17
17
  }
18
+ // The stop that means "nothing here can start this project's test runner"
19
+ // (spec 36, §7.1).
20
+ //
21
+ // It carries no new wording — the four places that throw it say exactly what
22
+ // they said before. All it adds is a name, so that one place at the top can tell
23
+ // this stop apart from "the server did not answer" and "your token was refused",
24
+ // and offer the one piece of advice that only fits this one. Hanging that advice
25
+ // on the individual failure sites instead would have given it to Ruby alone: a
26
+ // pytest project in a container stops somewhere else, with different words, and
27
+ // a vitest one somewhere else again.
28
+ export class ToolchainUnavailableError extends Error {
29
+ projectRoot;
30
+ constructor(message, projectRoot) {
31
+ super(message);
32
+ this.name = 'ToolchainUnavailableError';
33
+ this.projectRoot = projectRoot;
34
+ }
35
+ }
36
+ // The file a command names, on the host's own filesystem.
37
+ //
38
+ // A command that names a file we own is written relative to the project root, so
39
+ // that it means the same thing wherever it is started (spec 36, §4.2). Asking
40
+ // whether that file exists is a different question and is always the host's:
41
+ // under the invariant the connector's files are on the host, and the place sees
42
+ // the very same ones. A command with no path in it — `bundle`, `python3` — is
43
+ // resolved by the place through its own PATH and is returned unchanged.
44
+ export function commandFileOnHost(projectRoot, command) {
45
+ if (isAbsolute(command) || !command.includes('/'))
46
+ return command;
47
+ return join(projectRoot, command);
48
+ }
49
+ // Asked of the place the run will happen in, never of this machine by default
50
+ // (spec 36, §3). `cwd` is the project root at every call site, which is what
51
+ // says which place that is.
18
52
  export const defaultToolDeps = {
19
- commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
53
+ commandSucceeds: (command, args, cwd) => commandSucceedsInProject(cwd, command, args),
20
54
  };
21
55
  // How to invoke `runner` in this project, or null when nothing here can.
22
56
  //
@@ -78,8 +112,9 @@ function locatePytest(projectRoot, deps) {
78
112
  // behind. Trusting the file made provisioning report success and the boot
79
113
  // check then say "No module named pytest" about an environment we had just
80
114
  // built. Found on a Flask project, 2026-08-12.
81
- const venvPython = sidecarPath(projectRoot, '.venv', 'bin', 'python');
82
- if (executable(venvPython) && deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
115
+ const venvPython = `${SIDECAR_DIR}/.venv/bin/python`;
116
+ if (executable(commandFileOnHost(projectRoot, venvPython)) &&
117
+ deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
83
118
  return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
84
119
  }
85
120
  for (const python of ['python3', 'python']) {
@@ -94,11 +129,11 @@ function locatePytest(projectRoot, deps) {
94
129
  // checks that must not install anything. The vitest runner keeps `npx` as its
95
130
  // own last resort, which is the behaviour it has always had.
96
131
  function locateVitest(projectRoot) {
97
- const sidecar = sidecarPath(projectRoot, 'node_modules', '.bin', 'vitest');
98
- if (executable(sidecar))
132
+ const sidecar = `${SIDECAR_DIR}/node_modules/.bin/vitest`;
133
+ if (executable(commandFileOnHost(projectRoot, sidecar)))
99
134
  return { command: sidecar, args: [], source: 'sidecar' };
100
- const project = join(projectRoot, 'node_modules', '.bin', 'vitest');
101
- if (executable(project))
135
+ const project = 'node_modules/.bin/vitest';
136
+ if (executable(commandFileOnHost(projectRoot, project)))
102
137
  return { command: project, args: [], source: 'project' };
103
138
  return null;
104
139
  }
@@ -115,8 +150,11 @@ function locateRspec(projectRoot) {
115
150
  source: 'sidecar',
116
151
  };
117
152
  }
118
- const binstub = join(projectRoot, 'bin', 'rspec');
119
- if (executable(binstub))
153
+ // Relative, and the slash is not decoration: `spawn` resolves a command
154
+ // against the working directory only when it has one, and a bare `rspec` would
155
+ // go looking on PATH — a different command altogether.
156
+ const binstub = 'bin/rspec';
157
+ if (executable(commandFileOnHost(projectRoot, binstub)))
120
158
  return { command: binstub, args: [], source: 'project' };
121
159
  return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
122
160
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
2
2
  // Read a report file back verbatim. A missing or unreadable file is a clean
3
3
  // empty string, not a throw: the caller reports a structured suite error.
4
4
  export function readReport(path) {
@@ -9,3 +9,47 @@ export function readReport(path) {
9
9
  return '';
10
10
  }
11
11
  }
12
+ // Clear the report before a run, and hand back whatever survived the attempt
13
+ // (spec 36, criterion 9).
14
+ //
15
+ // The report is written to a fixed path with no run marker on it, and — once the
16
+ // run happens in a container — into a folder both sides share. A run we gave up
17
+ // on can leave a process alive inside that container, and that process finishes
18
+ // and writes the report after we stopped waiting. The next run would then read a
19
+ // file belonging to a run nobody watched, and report a green result nobody
20
+ // earned. Having no result at all is the better of the two.
21
+ //
22
+ // What comes back is the modification time of a file that would not delete —
23
+ // a read-only mount, a permission we do not have — so the read below can tell
24
+ // "the same file, still there" from "a new one the run just wrote". Nothing is
25
+ // compared against this machine's clock: both stamps come from whoever wrote the
26
+ // file, so a container whose clock differs from the host's cannot make a good
27
+ // report look stale.
28
+ export function clearReport(path) {
29
+ try {
30
+ rmSync(path, { force: true });
31
+ }
32
+ catch {
33
+ // Could not remove it. That is exactly the case the stamp below covers.
34
+ }
35
+ try {
36
+ return statSync(path).mtimeMs;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ // The report of *this* run, or nothing. `survivor` is what `clearReport`
43
+ // returned before the run started.
44
+ export function readFreshReport(path, survivor) {
45
+ if (survivor !== null) {
46
+ try {
47
+ if (statSync(path).mtimeMs === survivor)
48
+ return '';
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ return readReport(path);
55
+ }
@@ -1,9 +1,9 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
3
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
4
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
5
  import { locateRunner } from "./toolchain.js";
6
- import { readReport } from "./types.js";
6
+ import { clearReport, readFreshReport } from "./types.js";
7
7
  export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
8
8
  export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
9
9
  // A connector-owned Vitest config, written next to .unitbob/ before a run when
@@ -37,7 +37,7 @@ const PROJECT_CONFIGS = [
37
37
  // file of the branch in `include`, and the positional filters keep the run to
38
38
  // exactly those files.
39
39
  //
40
- // Named files rather than a directory glob, since spec 42, §6.5 made a branch
40
+ // Named files rather than a directory glob, since spec 43, §6.5 made a branch
41
41
  // several files: the artifact already says which files it is, and a glob would
42
42
  // have to guess a naming convention nothing enforces. `include` is written even
43
43
  // when the project has no config of its own — Vitest's default include only
@@ -63,17 +63,16 @@ export async function runVitestSuite(projectRoot, suitePaths) {
63
63
  '--reporter=json',
64
64
  `--outputFile=${VITEST_RESULT_FILE}`,
65
65
  ];
66
- const result = await runProcess(command, args, {
67
- cwd: projectRoot,
66
+ const reportPath = join(projectRoot, VITEST_RESULT_FILE);
67
+ const survivor = clearReport(reportPath);
68
+ const run = await runInProject(projectRoot, command, args, {
68
69
  timeoutMs: VITEST_TIMEOUT_MS,
69
- env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
70
+ env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
70
71
  });
71
72
  return {
72
- ...result,
73
- command,
74
- args,
73
+ ...run,
75
74
  resultPath: VITEST_RESULT_FILE,
76
- report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
75
+ report: readFreshReport(reportPath, survivor),
77
76
  };
78
77
  }
79
78
  // Returns the `--config` args to add, writing the config first. Always written:
@@ -1,36 +1,35 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
4
3
  import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
4
+ import { BEHAVIORAL_GEMFILE } from "./bdd.js";
5
+ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
6
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
6
7
  const PROBE_ROOT = '.unitbob/suite-build/world-probe';
7
8
  export async function probeBehavioralWorld(projectRoot, deps = {
8
- runCmd: (command, args, options) => runProcess(command, args, {
9
- cwd: options.cwd,
10
- env: options.env,
11
- timeoutMs: PROVISION_TIMEOUT_MS,
12
- }),
9
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
13
10
  }) {
11
+ // Written on the host, named to the run relative to the project root — the
12
+ // same shape `bdd.ts` has always had, and the reason nothing here needs a path
13
+ // rewritten when the run happens somewhere else (spec 36, §4.2).
14
+ const feature = `${PROBE_ROOT}/world.feature`;
15
+ const steps = `${PROBE_ROOT}/world_steps.rb`;
14
16
  const probeRoot = join(projectRoot, PROBE_ROOT);
15
- const feature = join(probeRoot, 'world.feature');
16
- const steps = join(probeRoot, 'world_steps.rb');
17
17
  mkdirSync(probeRoot, { recursive: true });
18
- writeFileSync(feature, PROBE_FEATURE);
19
- writeFileSync(steps, PROBE_STEPS);
18
+ writeFileSync(join(projectRoot, feature), PROBE_FEATURE);
19
+ writeFileSync(join(projectRoot, steps), PROBE_STEPS);
20
20
  try {
21
21
  const result = await deps.runCmd('bundle', [
22
22
  'exec', 'cucumber', feature,
23
- '--require', join(projectRoot, BEHAVIORAL_WORLD_PATH),
23
+ '--require', BEHAVIORAL_WORLD_PATH,
24
24
  '--require', steps,
25
25
  '--format', 'progress',
26
26
  ], {
27
27
  cwd: projectRoot,
28
28
  env: {
29
- ...process.env,
30
29
  RAILS_ENV: 'test',
31
30
  CUCUMBER_PUBLISH_QUIET: 'true',
32
- UNITBOB_REPO_ROOT: projectRoot,
33
- BUNDLE_GEMFILE: join(projectRoot, '.unitbob', 'behavioral', 'Gemfile'),
31
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
32
+ BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
34
33
  },
35
34
  });
36
35
  if (result.code === 0)
@@ -52,6 +51,9 @@ const PROBE_FEATURE = `Feature: Unitbob World profile
52
51
 
53
52
  Scenario: supported state is clean at the next scenario boundary
54
53
  Then the second World probe scenario sees clean state and fresh mocks
54
+
55
+ Scenario: outgoing HTTP does not leave the machine
56
+ Then the World probe cannot reach the network
55
57
  `;
56
58
  const PROBE_STEPS = `PROBE_VERSION = "unitbob-world-probe-#{Process.pid}"
57
59
  PROBE_TIME_ZONE = Time.zone
@@ -84,6 +86,16 @@ Then('its integration assertion counter advances') do
84
86
  expect(unitbob_session.assertions).to be > 0
85
87
  end
86
88
 
89
+ # Spec 35-1, criterion 2. Checked here rather than trusted, because the failure
90
+ # it guards against is invisible from inside: a suite whose WebMock never came on
91
+ # passes exactly the same way, and only the other end of the wire ever finds out.
92
+ Then('the World probe cannot reach the network') do
93
+ require 'net/http'
94
+ expect {
95
+ Net::HTTP.get(URI('http://unitbob-world-probe.invalid/'))
96
+ }.to raise_error(WebMock::NetConnectNotAllowedError)
97
+ end
98
+
87
99
  Then('the second World probe scenario sees clean state and fresh mocks') do
88
100
  quoted = @unitbob_connection.quote(PROBE_VERSION)
89
101
  count = @unitbob_connection.select_value("SELECT COUNT(*) FROM schema_migrations WHERE version = #{quoted}").to_i
@@ -1,18 +1,20 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { runProcess } from "../proc.js";
3
+ import { executable } from "../proc.js";
4
4
  import { firstErrorLine } from "../runner/bootcheck.js";
5
+ import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
5
6
  import { detectStructuralRunner } from "../runner/precheck.js";
6
7
  import { graphPath } from "../files/mapBuild.js";
7
8
  // Reading a router means booting the application, which on a large Rails app is
8
9
  // tens of seconds. The same budget the other boot-shaped step uses.
9
10
  const ROUTES_TIMEOUT_MS = 120_000;
11
+ // The router is the application's own, so asking it is a project command and
12
+ // goes where the project's dependencies live (spec 36, §5). Unlike every other
13
+ // caller this one is allowed to fail: silence is already a normal answer here,
14
+ // so a project whose container is misconfigured loses its route inventory and
15
+ // nothing else.
10
16
  const defaultDeps = {
11
- runCmd: (command, args, options) => runProcess(command, args, {
12
- cwd: options.cwd,
13
- timeoutMs: ROUTES_TIMEOUT_MS,
14
- env: { ...process.env, ...options.env },
15
- }),
17
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: ROUTES_TIMEOUT_MS, env: options.env }),
16
18
  };
17
19
  export function routeInventoryPath(projectRoot) {
18
20
  return join(projectRoot, '.unitbob', 'map-build', 'route_inventory.json');
@@ -202,16 +204,16 @@ end`;
202
204
  // `default` means we leave RAILS_ENV alone and take whatever the project's own
203
205
  // setup chooses.
204
206
  async function askRouterOnce(projectRoot, deps, environment) {
205
- const local = join(projectRoot, 'bin', 'rails');
206
- const [command, args] = existsSync(local)
207
- ? [local, ['runner', ROUTES_SCRIPT]]
207
+ const [command, args] = executable(join(projectRoot, 'bin', 'rails'))
208
+ ? ['bin/rails', ['runner', ROUTES_SCRIPT]]
208
209
  : ['bundle', ['exec', 'rails', 'runner', ROUTES_SCRIPT]];
210
+ const repoRoot = projectRootAsSeenByThePlace(projectRoot);
209
211
  try {
210
212
  return await deps.runCmd(command, args, {
211
213
  cwd: projectRoot,
212
214
  env: environment === 'test'
213
- ? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot }
214
- : { UNITBOB_REPO_ROOT: projectRoot },
215
+ ? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: repoRoot }
216
+ : { UNITBOB_REPO_ROOT: repoRoot },
215
217
  });
216
218
  }
217
219
  catch {
@@ -415,7 +417,7 @@ export function inventoryProblems(inventory, surfaces) {
415
417
  // either printed by the router or copied out of the graph. A blank is not a gap
416
418
  // to be helpfully filled — an invented `source_file` is the same failure as an
417
419
  // invented address, one field over, and a `handler_symbol` swapped for another
418
- // node that happens to exist passes the host's check while sending spec 36's
420
+ // node that happens to exist passes the host's check while sending spec 38's
419
421
  // trace into the wrong code.
420
422
  const LINK_FIELDS = ['source_file', 'handler_symbol'];
421
423
  // Not a link and nothing downstream reads it — but spec 32-7 took the authoring
@@ -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 44, §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
  }
@@ -1,4 +1,4 @@
1
- import { ensureUnitbobIgnored, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
1
+ import { ensureUnitbobIgnored, ignoreExclusions, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
2
2
  import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
3
3
  import { describeRouteInventory, extractRouteInventory, } from "../surfaces/routeInventory.js";
4
4
  import { Wire } from "../wire.js";
@@ -10,9 +10,20 @@ export async function mapPrepare(config, _args = [], deps) {
10
10
  runGraphifyExtractKeyless,
11
11
  extractRouteInventory,
12
12
  getRecipe: (name) => wire.getRecipe(name),
13
+ stdout: process.stdout,
13
14
  ...deps,
14
15
  };
15
16
  actual.ensureUnitbobIgnored(config.projectRoot);
17
+ // Said before the graph is built, because after it there is nothing left to
18
+ // see: whatever these patterns matched never becomes a node, and a subsystem
19
+ // that is missing from the map looks exactly like a subsystem that was never
20
+ // written. One line per pattern that actually took something; a pattern that
21
+ // matched nothing is not news (spec 35-1, criterion 1).
22
+ const exclusions = ignoreExclusions(config.projectRoot);
23
+ if (exclusions.length > 0) {
24
+ actual.stdout.write('Kept out of the graph by .graphifyignore — nothing below can appear on the map:\n' +
25
+ exclusions.map((entry) => ` ${entry.pattern} — ${entry.files} file${entry.files === 1 ? '' : 's'}\n`).join(''));
26
+ }
16
27
  await actual.requireGraphify();
17
28
  // Keyless: refresh the one canonical graph in place. No inference secret and no
18
29
  // graph flags — semantic enrichment is host-LLM work, not a keyed LLM here.
@@ -31,7 +42,7 @@ export async function mapPrepare(config, _args = [], deps) {
31
42
  // Said out loud first, because asking a router means booting the application
32
43
  // and that can take a minute or two with nothing on the screen. Silence from
33
44
  // us there reads as a hang.
34
- process.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
45
+ actual.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
35
46
  const inventory = await actual.extractRouteInventory(config.projectRoot);
36
47
  const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
37
48
  actual.getRecipe('decompose'),
@@ -45,9 +56,9 @@ export async function mapPrepare(config, _args = [], deps) {
45
56
  extract_surfaces: extractSurfaces,
46
57
  decompose_surfaces: decomposeSurfaces,
47
58
  }, inventory.status === 'written' ? inventory.path : undefined);
48
- process.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
49
- process.stdout.write(`${describeRouteInventory(inventory)}\n`);
50
- process.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
59
+ actual.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
60
+ actual.stdout.write(`${describeRouteInventory(inventory)}\n`);
61
+ actual.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
51
62
  `${packet.output_path} (recipes.decompose, recipes.relate), and the surface map at ` +
52
63
  `${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
53
64
  'recipes.decompose_surfaces) — then run `unitbob put-map-build`.\n');
@@ -1,4 +1,5 @@
1
1
  import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { placeProblem } from "../runner/place.js";
2
3
  import { collectBuildProblems } from "./validateBuild.js";
3
4
  import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
4
5
  import { Wire } from "../wire.js";
@@ -20,6 +21,12 @@ import { Wire } from "../wire.js";
20
21
  // Returns the server's per-branch results so the caller can compose the first run
21
22
  // on top of them (spec 32-4) without parsing the lines printed here.
22
23
  export async function putSuiteBuild(config, _args = [], deps) {
24
+ // Spec 36, criterion 7. Publishing is followed immediately by a first run, so
25
+ // a place that cannot be used is not something to discover after the suite is
26
+ // stored on the server.
27
+ const unusable = placeProblem(config.projectRoot);
28
+ if (unusable)
29
+ throw new Error(`${unusable}\nNothing was uploaded.`);
23
30
  const request = readSuiteBuildRequest(config.projectRoot);
24
31
  // Spec 32-6: read branch by branch, so one unreadable entry neither hides the
25
32
  // next branch's problems nor sinks a peer that is finished and correct.
@@ -39,7 +46,7 @@ export async function putSuiteBuild(config, _args = [], deps) {
39
46
  // skipped by going straight to the upload — but reported the way every other
40
47
  // local failure here is reported: against the branch it belongs to.
41
48
  //
42
- // Since spec 42 that check is exactly one question, and it is about a branch
49
+ // Since spec 43 that check is exactly one question, and it is about a branch
43
50
  // the answer has *no* entry for: everything else it used to ask is now asked
44
51
  // of the server, by a dry run, before this command runs at all. So its
45
52
  // problems can never land on a branch this loop visits, and they are reported
@@ -122,7 +129,7 @@ function printResult(result) {
122
129
  const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
123
130
  return (`${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.` + printDowngrades(result));
124
131
  }
125
- // Spec 42, §7. A capability every one of whose Scenarios the review objected to
132
+ // Spec 43, §7. A capability every one of whose Scenarios the review objected to
126
133
  // is stored `unguarded` by the publish. The run is standing right here when that
127
134
  // is decided, so it is told here, in the server's own words — finding it on the
128
135
  // map afterwards is how a run finishes believing it published a guarantee it did
package/dist/verbs/run.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { materializeGuardrails } from "../files/guardrails.js";
2
2
  import { materializeBehavioral } from "../files/behavioral.js";
3
+ import { placeProblem } from "../runner/place.js";
4
+ import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
3
5
  import { validateStack } from "../runner/precheck.js";
4
6
  import { runRspecSuite } from "../runner/rspec.js";
5
7
  import { runVitestSuite } from "../runner/vitest.js";
@@ -28,7 +30,7 @@ function resolve(config, deps) {
28
30
  getSuites: () => wire.getSuites(),
29
31
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
30
32
  // The whole envelope, support files and all: a branch is a set of files
31
- // since spec 42, §6, and picking `path` and `content` out of it here was
33
+ // since spec 43, §6, and picking `path` and `content` out of it here was
32
34
  // where the rest of them used to be lost.
33
35
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
34
36
  suite_digest: item.suite_digest,
@@ -44,6 +46,12 @@ function resolve(config, deps) {
44
46
  };
45
47
  }
46
48
  async function execute(config, d, only) {
49
+ // Spec 36, criterion 7. Before the first suite is fetched: a run that cannot
50
+ // happen where this project's dependencies live has nothing honest to file,
51
+ // and the whole batch would go up as suite errors describing the wrong thing.
52
+ const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
53
+ if (unusable)
54
+ throw new Error(`${unusable}\nNothing was run and no results were filed.`);
47
55
  const suites = await d.getSuites();
48
56
  const ready = suites.filter((item) => item.status === 'ready');
49
57
  const selected = only === null ? ready : select(ready, only);
@@ -137,7 +145,7 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
137
145
  }
138
146
  }
139
147
  // Every file of the branch, in the order the envelope carries them. A structural
140
- // branch is one file per assignment since spec 42, §6, and running only the main
148
+ // branch is one file per assignment since spec 43, §6, and running only the main
141
149
  // one would execute a fraction of what the map says is guarded.
142
150
  function artifactPaths(file) {
143
151
  return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];