unitbob 0.4.4 → 0.5.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.
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
4
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
5
+ import { locateRunner } from "./toolchain.js";
5
6
  import { readReport } from "./types.js";
6
7
  export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
7
8
  export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
@@ -11,19 +12,34 @@ export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
11
12
  // never part of the suite digest.
12
13
  export const PYTEST_INI_FILE = join('.unitbob', 'pytest.ini');
13
14
  export const PYTEST_INI = '[pytest]\naddopts =\n';
14
- // Run the materialised Unitbob guardrail suite with pytest in the current
15
- // Python environment (spec 30) — no guessing at Poetry/uv/virtualenv wrappers.
16
- // Only the guardrail file runs; the JUnit XML report goes to --junit-xml, not
17
- // stdout. The command is connector-owned: the suite artifact never carries a
18
- // command string.
19
- export async function runPytestSuite(projectRoot, suitePath) {
15
+ // Run the materialised Unitbob guardrail suite with pytest (spec 30) — no
16
+ // guessing at Poetry/uv/virtualenv wrappers. Only the guardrail files run; the
17
+ // JUnit XML report goes to --junit-xml, not stdout. The command is
18
+ // connector-owned: the suite artifact never carries a command string.
19
+ //
20
+ // Every file of the branch is named positionally (spec 42, §6.5) — a branch is
21
+ // one file per assignment now, and pytest takes as many paths as it is given.
22
+ //
23
+ // Which pytest is a single question answered in one place (`locateRunner`), so
24
+ // the precheck, the boot check and this run can never end up talking about
25
+ // different interpreters. `python` is the last resort when nothing was found:
26
+ // spawning it produces the honest "No module named pytest" rather than a silent
27
+ // no-op, and the checks upstream have already had their chance to say so first.
28
+ export async function runPytestSuite(projectRoot, suitePaths) {
20
29
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
21
- const command = await pickPython(projectRoot);
22
- const args = ['-m', 'pytest', '-c', PYTEST_INI_FILE, suitePath, `--junit-xml=${PYTEST_RESULT_FILE}`];
30
+ const located = locateRunner(projectRoot, 'pytest');
31
+ const command = located?.command ?? 'python';
32
+ const args = [
33
+ ...(located?.args ?? ['-m', 'pytest']),
34
+ '-c',
35
+ PYTEST_INI_FILE,
36
+ ...suitePaths,
37
+ `--junit-xml=${PYTEST_RESULT_FILE}`,
38
+ ];
23
39
  const result = await runProcess(command, args, {
24
40
  cwd: projectRoot,
25
41
  timeoutMs: PYTEST_TIMEOUT_MS,
26
- env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
42
+ env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
27
43
  });
28
44
  return {
29
45
  ...result,
@@ -33,14 +49,3 @@ export async function runPytestSuite(projectRoot, suitePath) {
33
49
  report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
34
50
  };
35
51
  }
36
- // The interpreter that can actually run pytest: `python3` when pytest imports
37
- // there (macOS/Linux ship no bare `python`), else `python`. Probing `-m pytest`
38
- // rather than just `--version` keeps this in step with pytestPrecheck, so the
39
- // run uses the same interpreter the precheck confirmed.
40
- async function pickPython(projectRoot) {
41
- const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
42
- cwd: projectRoot,
43
- timeoutMs: 10_000,
44
- }).catch(() => ({ stdout: '', stderr: '', code: 1 }));
45
- return probe.code === 0 ? 'python3' : 'python';
46
- }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
- import { executable, runProcess } from "../proc.js";
2
+ import { runProcess } from "../proc.js";
3
3
  import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
4
+ import { locateRunner } from "./toolchain.js";
4
5
  import { readReport } from "./types.js";
5
6
  export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
6
7
  // Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
@@ -8,17 +9,23 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
8
9
  // the project's random ordering.
9
10
  export const RSPEC_SEED = '1';
10
11
  export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
11
- // Run the materialised Unitbob guardrail suite (spec 26). Only this file runs
12
+ // Run the materialised Unitbob guardrail suite (spec 26). Only these files run
12
13
  // never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
13
14
  // --options points at the materialized empty file so the project's own .rspec
14
15
  // (a --require of a helper we replaced, an extra stdout formatter) can neither
15
16
  // break the boot nor corrupt the JSON output. The JSON report goes to `--out`
16
17
  // (a file), not stdout, so the app's own stdout writes during the run can never
17
- // corrupt it. `suitePath` is the suite blob's own project-relative path.
18
- export async function runRspecSuite(projectRoot, suitePath) {
18
+ // corrupt it.
19
+ //
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
22
+ // directory: the artifact already says exactly which files it is, while a
23
+ // directory would also collect whatever else happens to be sitting under the
24
+ // root.
25
+ export async function runRspecSuite(projectRoot, suitePaths) {
19
26
  const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
20
27
  const { result, command, args } = await invokeRspec(projectRoot, [
21
- suitePath,
28
+ ...suitePaths,
22
29
  '--options',
23
30
  optionsPath,
24
31
  '--order',
@@ -38,18 +45,19 @@ export async function runRspecSuite(projectRoot, suitePath) {
38
45
  report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
39
46
  };
40
47
  }
41
- // Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
48
+ // Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
49
+ // binstub, or `bundle exec rspec` — is decided once, in `locateRunner`, so this
50
+ // run and the checks that predicted it always mean the same installation. Every
42
51
  // run sets RAILS_ENV=test so guardrails execute against the Rails test
43
52
  // environment the project's `rails_helper` configures.
44
53
  async function invokeRspec(projectRoot, rspecArgs) {
45
- const localRspec = join(projectRoot, 'bin', 'rspec');
46
- const hasLocalRspec = executable(localRspec);
47
- const command = hasLocalRspec ? localRspec : 'bundle';
48
- const args = hasLocalRspec ? rspecArgs : ['exec', 'rspec', ...rspecArgs];
54
+ const located = locateRunner(projectRoot, 'rspec');
55
+ const command = located?.command ?? 'bundle';
56
+ const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
49
57
  const result = await runProcess(command, args, {
50
58
  cwd: projectRoot,
51
59
  timeoutMs: RSPEC_TIMEOUT_MS,
52
- env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
60
+ env: { ...process.env, ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
53
61
  });
54
62
  return { result, command, args };
55
63
  }
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { executable } from "../proc.js";
5
+ // Where Unitbob keeps a test runner it had to install for itself, together with
6
+ // whatever that runner needs to load the project.
7
+ //
8
+ // Deliberately not `.unitbob/structural/`: `materializeGuardrails` deletes that
9
+ // directory before every suite write, so an environment installed there would be
10
+ // rebuilt on every single run. Deliberately not the project's own dependency
11
+ // files either — the whole point is that a vibecoder's repository looks exactly
12
+ // the same after Unitbob has run as it did before. `.unitbob/` is already in
13
+ // their .gitignore (see `ensureUnitbobIgnored`).
14
+ export const SIDECAR_DIR = '.unitbob/runners';
15
+ export function sidecarPath(projectRoot, ...segments) {
16
+ return join(projectRoot, SIDECAR_DIR, ...segments);
17
+ }
18
+ export const defaultToolDeps = {
19
+ commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
20
+ };
21
+ // How to invoke `runner` in this project, or null when nothing here can.
22
+ //
23
+ // The sidecar wins when it exists, and that order is deliberate. A sidecar is
24
+ // only ever built because the project could not supply the runner itself, so
25
+ // preferring it keeps every later run on the same environment the build was
26
+ // prepared against. The alternative — asking the project first, every time —
27
+ // lets a stray `pip install pytest` between two runs move the suite to a
28
+ // different interpreter without anybody choosing that, and a guardrail suite
29
+ // whose meaning is "green means fine" cannot afford a silent environment flip.
30
+ export function locateRunner(projectRoot, runner, deps = defaultToolDeps) {
31
+ switch (runner) {
32
+ case 'pytest':
33
+ return locatePytest(projectRoot, deps);
34
+ case 'vitest':
35
+ return locateVitest(projectRoot);
36
+ case 'rspec':
37
+ return locateRspec(projectRoot);
38
+ default:
39
+ return null;
40
+ }
41
+ }
42
+ // Is a runner available at all — from the project or from a sidecar?
43
+ export function runnerAvailable(projectRoot, runner, deps = defaultToolDeps) {
44
+ return locateRunner(projectRoot, runner, deps) !== null;
45
+ }
46
+ // Does the project supply this runner on its own, with no help from us? This is
47
+ // the question provisioning asks before it builds anything: a project that is
48
+ // already set up is left completely alone.
49
+ //
50
+ // Ruby answers from the Gemfile rather than from `locateRspec`, which always has
51
+ // a `bundle exec rspec` to offer whether or not the gem behind it exists.
52
+ export function projectProvidesRunner(projectRoot, runner, deps = defaultToolDeps) {
53
+ if (runner === 'rspec') {
54
+ return hasGemfileWith(projectRoot, /\brails\b/) && hasGemfileWith(projectRoot, /\brspec-rails\b/);
55
+ }
56
+ return locateRunner(projectRoot, runner, deps)?.source === 'project';
57
+ }
58
+ // Does one of the project's Gemfiles mention this? Shared by the Ruby precheck
59
+ // and by the question above, so "is this a Rails project" is answered the same
60
+ // way wherever it is asked.
61
+ export function hasGemfileWith(projectRoot, pattern) {
62
+ for (const name of ['Gemfile', 'gems.rb']) {
63
+ const path = join(projectRoot, name);
64
+ if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ // Python: an interpreter that can actually import pytest. The sidecar venv is
70
+ // checked by file, the project's interpreters by asking them — `python3 -m
71
+ // pytest --version` is the same question the run itself asks, so the two can
72
+ // never disagree about which interpreter is usable.
73
+ function locatePytest(projectRoot, deps) {
74
+ // The sidecar interpreter is asked the same question as any other, rather
75
+ // than trusted because the file is there. A virtualenv can exist and still
76
+ // have no pytest in it — `uv venv` creates one with no pip at all, so an
77
+ // install into it can fail while leaving a perfectly good `bin/python`
78
+ // behind. Trusting the file made provisioning report success and the boot
79
+ // check then say "No module named pytest" about an environment we had just
80
+ // 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)) {
83
+ return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
84
+ }
85
+ for (const python of ['python3', 'python']) {
86
+ if (deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot)) {
87
+ return { command: python, args: ['-m', 'pytest'], source: 'project' };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ // JS/TS: a vitest binary we can spawn. `npx` is not offered here — it would
93
+ // install a package to answer a question, and this function's callers include
94
+ // checks that must not install anything. The vitest runner keeps `npx` as its
95
+ // own last resort, which is the behaviour it has always had.
96
+ function locateVitest(projectRoot) {
97
+ const sidecar = sidecarPath(projectRoot, 'node_modules', '.bin', 'vitest');
98
+ if (executable(sidecar))
99
+ return { command: sidecar, args: [], source: 'sidecar' };
100
+ const project = join(projectRoot, 'node_modules', '.bin', 'vitest');
101
+ if (executable(project))
102
+ return { command: project, args: [], source: 'project' };
103
+ return null;
104
+ }
105
+ // Ruby: bundler decides what "rspec" means, so the sidecar is selected by
106
+ // pointing BUNDLE_GEMFILE at the sidecar Gemfile rather than by a different
107
+ // binary. The project's own `bin/rspec` binstub is preferred over `bundle exec`
108
+ // when it is there, exactly as the rspec runner has always preferred it.
109
+ function locateRspec(projectRoot) {
110
+ if (existsSync(sidecarPath(projectRoot, 'Gemfile'))) {
111
+ return {
112
+ command: 'bundle',
113
+ args: ['exec', 'rspec'],
114
+ env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile` },
115
+ source: 'sidecar',
116
+ };
117
+ }
118
+ const binstub = join(projectRoot, 'bin', 'rspec');
119
+ if (executable(binstub))
120
+ return { command: binstub, args: [], source: 'project' };
121
+ return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
122
+ }
@@ -1,7 +1,8 @@
1
- import { existsSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
4
  import { GUARDRAILS_DIR } from "../files/guardrails.js";
5
+ import { locateRunner } from "./toolchain.js";
5
6
  import { readReport } from "./types.js";
6
7
  export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
7
8
  export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
@@ -28,27 +29,44 @@ const PROJECT_CONFIGS = [
28
29
  'vite.config.cjs',
29
30
  ];
30
31
  // Run the materialised Unitbob guardrail suite with the project's own Vitest
31
- // (spec 30). Only the guardrail file runs — the path argument filters the run.
32
+ // (spec 30). Only the guardrail files run — the path arguments filter the run.
32
33
  //
33
- // A bare `vitest run <file>` treats the path as a filter that is intersected
34
+ // A bare `vitest run <file>` treats each path as a filter that is intersected
34
35
  // with the project's `test.include`, so a project whose include does not cover
35
- // `.unitbob/` would collect no tests. When the project has its own config we
36
- // therefore write a tiny config that merges it and adds the guardrail file to
37
- // `include`; the positional filter still narrows the run to that one file. With
38
- // no project config, Vitest's default include already covers `.unitbob/`, so
39
- // the bare command is correct and we write nothing.
36
+ // `.unitbob/` would collect no tests. So the config Unitbob writes names every
37
+ // file of the branch in `include`, and the positional filters keep the run to
38
+ // exactly those files.
39
+ //
40
+ // Named files rather than a directory glob, since spec 42, §6.5 made a branch
41
+ // several files: the artifact already says which files it is, and a glob would
42
+ // have to guess a naming convention nothing enforces. `include` is written even
43
+ // when the project has no config of its own — Vitest's default include only
44
+ // covers `.unitbob/` for a file that happens to be named `*.test.ts`, which is a
45
+ // trap set for whoever names a slice after its capability.
40
46
  //
41
47
  // The JSON report goes to --outputFile, not stdout, so app logging can never
42
48
  // corrupt it. The command is connector-owned: the suite artifact never carries
43
49
  // a command string.
44
- export async function runVitestSuite(projectRoot, suitePath) {
45
- const configArgs = writeMergedConfig(projectRoot, suitePath);
46
- const command = 'npx';
47
- const args = ['vitest', 'run', suitePath, ...configArgs, '--reporter=json', `--outputFile=${VITEST_RESULT_FILE}`];
50
+ export async function runVitestSuite(projectRoot, suitePaths) {
51
+ const configArgs = writeMergedConfig(projectRoot, suitePaths);
52
+ // An installed vitest — the sidecar's, else the project's — is spawned by
53
+ // path. `npx` stays as the last resort it has always been: it is the only
54
+ // option that can conjure a runner out of nothing, which is right here at the
55
+ // end and wrong everywhere else (see `locateRunner`, which does not offer it).
56
+ const located = locateRunner(projectRoot, 'vitest');
57
+ const command = located?.command ?? 'npx';
58
+ const args = [
59
+ ...(located ? located.args : ['vitest']),
60
+ 'run',
61
+ ...suitePaths,
62
+ ...configArgs,
63
+ '--reporter=json',
64
+ `--outputFile=${VITEST_RESULT_FILE}`,
65
+ ];
48
66
  const result = await runProcess(command, args, {
49
67
  cwd: projectRoot,
50
68
  timeoutMs: VITEST_TIMEOUT_MS,
51
- env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
69
+ env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
52
70
  });
53
71
  return {
54
72
  ...result,
@@ -58,31 +76,47 @@ export async function runVitestSuite(projectRoot, suitePath) {
58
76
  report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
59
77
  };
60
78
  }
61
- // Returns the `--config` args to add, writing the merge config first. When the
62
- // project has no config of its own there is nothing to inherit and nothing to
63
- // override (defaults already cover .unitbob/), so we return no args.
64
- function writeMergedConfig(projectRoot, suitePath) {
79
+ // Returns the `--config` args to add, writing the config first. Always written:
80
+ // the branch's files have to be in `include` or nothing is collected, and the
81
+ // names a worker gives its slice are not something to bet a whole run on.
82
+ function writeMergedConfig(projectRoot, suitePaths) {
65
83
  const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
66
- if (!projectConfig)
67
- return [];
68
- writeFileSync(join(projectRoot, VITEST_CONFIG_FILE), mergedConfigSource(projectConfig, suitePath));
84
+ const path = join(projectRoot, VITEST_CONFIG_FILE);
85
+ mkdirSync(dirname(path), { recursive: true });
86
+ writeFileSync(path, configSource(projectConfig, suitePaths));
69
87
  return ['--config', VITEST_CONFIG_FILE];
70
88
  }
71
89
  // The .unitbob/ config sits one level below the project root, so the project
72
- // config is a `../` import. `mergeConfig` concatenates `include`, so the
73
- // guardrail file joins the project's patterns instead of replacing them; the
74
- // positional filter then isolates it. A function-form config is resolved first.
75
- function mergedConfigSource(projectConfig, suitePath) {
76
- return `// Written by the unitbob connector before each vitest run do not edit.
77
- import { mergeConfig } from 'vitest/config';
90
+ // config is a `../` import. A function-form config is resolved first, and
91
+ // everything the project set plugins, aliases, setup files, environment — is
92
+ // carried through; only `test.include` is replaced, with exactly this branch's
93
+ // files. Replacing rather than concatenating is the point: the run must be these
94
+ // files and no others, and the positional filters then say the same thing twice.
95
+ //
96
+ // Nothing is imported from `vitest` itself, and that is deliberate. Vite
97
+ // re-imports this generated file from a temporary module beside it, so every
98
+ // bare `import` here resolves by walking up from `.unitbob/` — while a project
99
+ // whose only vitest is the one Unitbob installed keeps it at
100
+ // `.unitbob/runners/node_modules`, which is not on that path. An
101
+ // `import { mergeConfig } from 'vitest/config'` there dies with
102
+ // ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
103
+ // projects the sidecar exists for. A spread does the same job with no import,
104
+ // and `defineConfig` is a typing helper that buys a generated file nothing.
105
+ function configSource(projectConfig, suitePaths) {
106
+ const include = `include: ${JSON.stringify(suitePaths)}`;
107
+ const header = '// Written by the unitbob connector before each vitest run — do not edit.';
108
+ if (!projectConfig) {
109
+ return `${header}
110
+ export default { test: { ${include} } };
111
+ `;
112
+ }
113
+ return `${header}
78
114
  import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
79
115
 
80
116
  const base = typeof projectConfig === 'function'
81
117
  ? await projectConfig({ command: 'serve', mode: 'test' })
82
118
  : projectConfig;
83
119
 
84
- export default mergeConfig(base, {
85
- test: { include: [${JSON.stringify(suitePath)}] },
86
- });
120
+ export default { ...base, test: { ...(base.test ?? {}), ${include} } };
87
121
  `;
88
122
  }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
2
2
  import { dirname, join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
4
  import { firstErrorLine } from "../runner/bootcheck.js";
5
+ import { detectStructuralRunner } from "../runner/precheck.js";
5
6
  import { graphPath } from "../files/mapBuild.js";
6
7
  // Reading a router means booting the application, which on a large Rails app is
7
8
  // tens of seconds. The same budget the other boot-shaped step uses.
@@ -20,7 +21,7 @@ export function routeInventoryPath(projectRoot) {
20
21
  // records why Django, FastAPI and Flask come next and why Express cannot follow
21
22
  // at all (its addresses are registered by arbitrary code at run time).
22
23
  export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
23
- if (!looksLikeRails(projectRoot))
24
+ if (!canAskTheRouter(projectRoot))
24
25
  return silent(projectRoot, 'unsupported_stack');
25
26
  const asked = await askTheRouter(projectRoot, deps);
26
27
  if ('reason' in asked)
@@ -150,8 +151,19 @@ function becauseOf(result) {
150
151
  function plural(count, one, many) {
151
152
  return count === 1 ? one : many;
152
153
  }
153
- function looksLikeRails(projectRoot) {
154
- return existsSync(join(projectRoot, 'config', 'routes.rb'));
154
+ // Which stack this is, is not this module's question — one answer to it already
155
+ // exists and this file now asks it. `config/routes.rb` stays as a second-level
156
+ // condition inside the Ruby branch, because a Rails application without a route
157
+ // file has no router to ask.
158
+ //
159
+ // It used to be the whole test, which made it a fourth independent way of
160
+ // deciding "is this Rails?" — and one that was wrong at both edges: a Rails app
161
+ // that keeps its routes elsewhere was refused, and any project that happens to
162
+ // carry a `config/routes.rb` was asked to boot Rails. There are still several
163
+ // stack detectors in this package; this removes the one that had a single caller
164
+ // and no excuse.
165
+ function canAskTheRouter(projectRoot) {
166
+ return (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb')));
155
167
  }
156
168
  // The question, asked of the router object rather than of the `rails routes`
157
169
  // command line. `--expanded` was the earlier reading, and it cost this project
@@ -2,7 +2,7 @@ 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'];
5
+ const AGENT_NAMES = ['suite-worker', 'suite-repair-worker', 'fact-finder', 'suite-reviewer'];
6
6
  const bundledAgentsDir = fileURLToPath(new URL('../../plugin/codex/agents/', import.meta.url));
7
7
  export function installCodexAgents(args, deps = { home: homedir(), stdout: process.stdout }) {
8
8
  if (args.length > 0)
@@ -1,5 +1,6 @@
1
- import { readBehavioralReview, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
- import { collectBuildProblems, formatBranchProblems } from "./validateBuild.js";
1
+ import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { collectBuildProblems } from "./validateBuild.js";
3
+ import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
3
4
  import { Wire } from "../wire.js";
4
5
  // Read the task and the host's answers, verify each branch parses and carries a
5
6
  // safe-path artifact envelope, then upload both peer branches in one batch
@@ -28,7 +29,6 @@ export async function putSuiteBuild(config, _args = [], deps) {
28
29
  stdout: process.stdout,
29
30
  ...deps,
30
31
  };
31
- const digestFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.source_digest]));
32
32
  const items = [];
33
33
  const blocked = unreadable.map((entry) => ({
34
34
  suite_kind: entry.suite_kind,
@@ -39,26 +39,19 @@ export async function putSuiteBuild(config, _args = [], deps) {
39
39
  // skipped by going straight to the upload — but reported the way every other
40
40
  // local failure here is reported: against the branch it belongs to.
41
41
  //
42
+ // Since spec 42 that check is exactly one question, and it is about a branch
43
+ // the answer has *no* entry for: everything else it used to ask is now asked
44
+ // of the server, by a dry run, before this command runs at all. So its
45
+ // problems can never land on a branch this loop visits, and they are reported
46
+ // below rather than inside it.
47
+ //
42
48
  // An earlier draft threw and stopped the command, which quietly undid spec
43
- // 32-5 Phase 4: one missing marker in the behavioral answer would have left a
44
- // finished structural suite unpublished. Every problem this check raises is
45
- // already named against a branch, so it blocks that branch and never the
46
- // batch. That also bounds what a false positive in a local check can cost —
47
- // one branch, with the peer still going up and the server still the authority.
48
- const problemsFor = new Map();
49
- for (const problem of collectBuildProblems(request, outputs, unreadable)) {
50
- problemsFor.set(problem.branch, [...(problemsFor.get(problem.branch) ?? []), problem.message]);
51
- }
49
+ // 32-5 Phase 4: one behavioral problem would have left a finished structural
50
+ // suite unpublished. Every problem here is named against a branch, so it
51
+ // blocks that branch and never the batch.
52
52
  for (const output of outputs) {
53
- const failed = problemsFor.get(output.suite_kind);
54
- problemsFor.delete(output.suite_kind);
55
- if (failed) {
56
- blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: formatBranchProblems(failed) });
57
- continue;
58
- }
59
- const sourceDigest = digestFor.get(output.suite_kind) ?? '';
60
53
  if (output.build_error) {
61
- items.push({ suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error });
54
+ items.push(uploadItem(request, output, undefined));
62
55
  continue;
63
56
  }
64
57
  let testMetadata = output.test_metadata;
@@ -71,23 +64,14 @@ export async function putSuiteBuild(config, _args = [], deps) {
71
64
  continue;
72
65
  }
73
66
  }
74
- items.push({
75
- suite_kind: output.suite_kind,
76
- source_digest: sourceDigest,
77
- artifacts: {
78
- suite_file: output.suite_file,
79
- runner_manifest: output.runner_manifest,
80
- test_metadata: testMetadata,
81
- },
82
- });
67
+ items.push(uploadItem(request, output, testMetadata));
83
68
  }
84
- // What is left in `problemsFor` belongs to a branch the loop above never
85
- // reached, because the answer has no entry for it at all. It has nothing to
86
- // upload and nothing to roll back, so it costs its peer nothing — but it is
69
+ // A branch the request asked for and the answer never mentions. It has nothing
70
+ // to upload and nothing to roll back, so it costs its peer nothing — but it is
87
71
  // exactly the branch that used to leave no trace anywhere, and the one line it
88
72
  // prints here is the whole point of noticing it (spec 32-6, a2time 2026-08-04).
89
- for (const [suiteKind, messages] of problemsFor) {
90
- blocked.push({ suite_kind: suiteKind, status: BLOCKED_STATUS, error: formatBranchProblems(messages) });
73
+ for (const problem of collectBuildProblems(request, outputs, unreadable)) {
74
+ blocked.push({ suite_kind: problem.branch, status: BLOCKED_STATUS, error: problem.message });
91
75
  }
92
76
  // Every branch is blocked, so there is nothing to upload. Asking the server to
93
77
  // publish an empty batch would turn a local, already-explained problem into a
@@ -103,46 +87,6 @@ export async function putSuiteBuild(config, _args = [], deps) {
103
87
  // malformed. Not a server status — it never reaches the server — but it travels
104
88
  // as one so a single rule decides what counts as published (see `PUBLISHED`).
105
89
  const BLOCKED_STATUS = 'not_ready';
106
- // The behavioral branch's uploaded metadata, with the independent review and the
107
- // connector's own run evidence folded in.
108
- //
109
- // Throws for anything that leaves this branch unpublishable — a missing review,
110
- // one bound to a different candidate, a defect the review called not_supplied.
111
- // The caller turns that into one unpublished branch rather than a failed
112
- // command: a blocked review is a fact about the behavioral suite, and the
113
- // structural peer next to it is finished and correct. Sinking the whole upload
114
- // with it forced the one workaround this contract exists to prevent — hand-editing
115
- // the answer down to a single branch, which loses the peer candidate for real.
116
- function withReview(config, request, output) {
117
- const review = readBehavioralReview(config.projectRoot, output);
118
- const probe = review.known_defect_probe;
119
- const qualityReview = review.bdd_quality_review;
120
- if (!qualityReview || typeof qualityReview !== 'object') {
121
- throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
122
- }
123
- if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
124
- throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
125
- }
126
- return {
127
- ...output.test_metadata,
128
- bdd_quality_review: {
129
- ...qualityReview,
130
- candidate_digest: review.candidate_digest,
131
- },
132
- ...(review.selection_review ? { selection_review: review.selection_review } : {}),
133
- known_defect_probe: review.known_defect_probe,
134
- known_defect_context: request.known_defect_context,
135
- candidate_run: review.candidate_run,
136
- ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
137
- };
138
- }
139
- // The three outcomes that leave a branch published and current: a new version, an
140
- // identical version already stored, or a reactivated one. Each returns the
141
- // identity to run. Everything else — a rejected branch, a branch the host could
142
- // not build, or a status this connector has never seen — fails closed and is
143
- // never run, so a newer server can never trick an older connector into running
144
- // something it does not understand.
145
- const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
146
90
  export function classifyPublication(results) {
147
91
  const split = { digests: [], unpublished: [] };
148
92
  for (const result of results) {
@@ -176,7 +120,20 @@ function printResult(result) {
176
120
  .join(', ')
177
121
  : '';
178
122
  const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
179
- return `${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.`;
123
+ return (`${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.` + printDowngrades(result));
124
+ }
125
+ // Spec 42, §7. A capability every one of whose Scenarios the review objected to
126
+ // is stored `unguarded` by the publish. The run is standing right here when that
127
+ // is decided, so it is told here, in the server's own words — finding it on the
128
+ // map afterwards is how a run finishes believing it published a guarantee it did
129
+ // not.
130
+ function printDowngrades(result) {
131
+ const downgraded = result.unguarded_by_review ?? [];
132
+ if (downgraded.length === 0)
133
+ return '';
134
+ return (`\n ${downgraded.length} capability(ies) published unguarded, because the review objected to every ` +
135
+ 'Scenario guarding them:\n' +
136
+ downgraded.map((entry) => ` - ${entry.capability_id}: ${entry.reason}`).join('\n'));
180
137
  }
181
138
  // The server's own words when it sent any; otherwise the best true thing that can
182
139
  // be said. A status this connector does not know is quoted rather than guessed
package/dist/verbs/run.js CHANGED
@@ -27,9 +27,12 @@ function resolve(config, deps) {
27
27
  return {
28
28
  getSuites: () => wire.getSuites(),
29
29
  postRunsBatch: (runs) => wire.postRunsBatch(runs),
30
+ // 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
32
+ // where the rest of them used to be lost.
30
33
  materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
31
34
  suite_digest: item.suite_digest,
32
- suite_file: { path: item.suite_file.path, content: item.suite_file.content },
35
+ suite_file: item.suite_file,
33
36
  runner_manifest: item.runner_manifest,
34
37
  }),
35
38
  materializeBehavioral: (projectRoot, item) => materializeBehavioral(projectRoot, item.suite_file, item.runner_manifest.runner).mainPath,
@@ -98,7 +101,7 @@ async function buildRunPayload(config, d, item) {
98
101
  }
99
102
  else {
100
103
  d.materializeStructural(config.projectRoot, item);
101
- result = await d.runStructural(config.projectRoot, runner, item.suite_file.path);
104
+ result = await d.runStructural(config.projectRoot, runner, artifactPaths(item.suite_file));
102
105
  }
103
106
  }
104
107
  catch (err) {
@@ -121,18 +124,24 @@ async function buildRunPayload(config, d, item) {
121
124
  // Exported for `run-local`, which runs these same strategies against the files
122
125
  // the host just wrote rather than against a published suite. One dispatch table,
123
126
  // so the command the loop iterates on is the command that runs after publishing.
124
- export function runStructuralByRunner(projectRoot, runner, suitePath) {
127
+ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
125
128
  switch (runner) {
126
129
  case 'rspec':
127
- return runRspecSuite(projectRoot, suitePath);
130
+ return runRspecSuite(projectRoot, suitePaths);
128
131
  case 'vitest':
129
- return runVitestSuite(projectRoot, suitePath);
132
+ return runVitestSuite(projectRoot, suitePaths);
130
133
  case 'pytest':
131
- return runPytestSuite(projectRoot, suitePath);
134
+ return runPytestSuite(projectRoot, suitePaths);
132
135
  default:
133
136
  return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite.`));
134
137
  }
135
138
  }
139
+ // 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
141
+ // one would execute a fraction of what the map says is guarded.
142
+ function artifactPaths(file) {
143
+ return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
144
+ }
136
145
  function suiteError(suiteDigest, message) {
137
146
  return {
138
147
  suite_digest: suiteDigest,