unitbob 0.1.8 → 0.1.9

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/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  A living map of your app's business parts. Each important seam gets an automatic
4
4
  test — a "lamp" on the map. Green means fine, red means something broke.
5
5
 
6
+ Works with Ruby on Rails (RSpec), JavaScript/TypeScript (Vitest), and Python
7
+ (pytest) projects — the guardrail tests are generated in your project's own
8
+ language and run with its native test runner.
9
+
6
10
  You work through Claude Code. You need: Node 18+, Python 3.10+.
7
11
 
8
12
  ---
package/dist/config.js CHANGED
@@ -11,18 +11,29 @@ export const CONFIG_FILE = '.unitbob.json';
11
11
  // missing, unreadable, malformed JSON, or repo_id absent / 0 / non-integer
12
12
  // (the legacy init template wrote repo_id: 0). Callers re-link on null.
13
13
  export function readLocalRepoId(cwd) {
14
+ const repoId = readConfigField(cwd, 'repo_id');
15
+ return typeof repoId === 'number' && Number.isInteger(repoId) && repoId > 0 ? repoId : null;
16
+ }
17
+ // The server URL stored at `cwd`, or null when the file is missing, malformed,
18
+ // or carries no usable http(s) URL. A local file naming a server must win over
19
+ // the built-in default — otherwise a locally-linked project silently talks to
20
+ // (and registers itself on) the public brain.
21
+ export function readLocalServer(cwd) {
22
+ const server = readConfigField(cwd, 'server');
23
+ return typeof server === 'string' && /^https?:\/\//.test(server.trim()) ? server.trim() : null;
24
+ }
25
+ function readConfigField(cwd, field) {
14
26
  const path = join(cwd, CONFIG_FILE);
15
27
  if (!existsSync(path))
16
- return null;
28
+ return undefined;
17
29
  let parsed;
18
30
  try {
19
31
  parsed = JSON.parse(readFileSync(path, 'utf8'));
20
32
  }
21
33
  catch {
22
- return null;
34
+ return undefined;
23
35
  }
24
- const repoId = parsed && typeof parsed === 'object' ? parsed.repo_id : undefined;
25
- return typeof repoId === 'number' && Number.isInteger(repoId) && repoId > 0 ? repoId : null;
36
+ return parsed && typeof parsed === 'object' ? parsed[field] : undefined;
26
37
  }
27
38
  export function writeConfigFile(cwd, config) {
28
39
  writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
@@ -1,20 +1,29 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- export const GUARDRAILS_DIR = join('.unitbob', 'guardrails');
4
- export const SUITE_FILE = 'architecture_map_contracts_spec.rb';
2
+ import { dirname, isAbsolute, join } from 'node:path';
3
+ // A forward-slash literal, not path.join: suite paths always arrive over the
4
+ // wire with '/', while path.join would render this '.unitbob\guardrails' on
5
+ // Windows and make assertGuardrailPath reject every valid path there. node's
6
+ // path.join still accepts a '/'-joined segment as input on every platform, so
7
+ // filesystem builds below are unaffected.
8
+ export const GUARDRAILS_DIR = '.unitbob/guardrails';
5
9
  export const HELPER_FILE = 'unitbob_helper.rb';
6
10
  // An always-empty custom options file: pointing rspec's --options here keeps
7
11
  // the project's own .rspec (stray --require lines, extra stdout formatters)
8
12
  // out of guardrail runs, whose JSON output must stay parseable.
9
13
  export const OPTIONS_FILE = 'rspec.opts';
10
- // RSpec writes its JSON report here via `--out`, not to stdout: the app under
11
- // test can print to stdout during a run (Rails logging, deprecation notices, a
12
- // stray `puts`), which corrupts a stdout-parsed JSON document. A dedicated file
13
- // isolates the report from anything else the process emits.
14
- export const RESULT_FILE = 'rspec_result.json';
15
- // The boot file the generated suite requires (spec 29). Connector-owned and
14
+ // The one place that decides whether a host-provided suite path is safe to
15
+ // write: relative, anchored under .unitbob/guardrails/, no traversal. Anything
16
+ // else throws and nothing is written.
17
+ export function assertGuardrailPath(path) {
18
+ const prefix = `${GUARDRAILS_DIR}/`;
19
+ const unsafe = !path || isAbsolute(path) || !path.startsWith(prefix) || path.split('/').some((segment) => segment === '..');
20
+ if (unsafe) {
21
+ throw new Error(`suite_file.path must be a relative path under ${prefix} with no traversal (got "${path}").`);
22
+ }
23
+ }
24
+ // The boot file the generated Ruby suite requires (spec 29). Connector-owned and
16
25
  // versioned with it — never scaffolded into the project, never part of the
17
- // suite digest (that covers `spec_rb` bytes only). Delegates to the project's
26
+ // suite digest (that covers the suite_file only). Delegates to the project's
18
27
  // own RSpec setup when one exists; boots the Rails test environment directly
19
28
  // when none does. Both branches refuse a non-test environment — before boot
20
29
  // via ENV (nothing touched yet), after boot via Rails.env (config overrides).
@@ -39,16 +48,23 @@ else
39
48
  end
40
49
  abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
41
50
  `;
51
+ // Write the suite blob's guardrail file at its own (validated) relative path.
52
+ // The Ruby boot kit is materialized only for the rspec runner — Vitest and
53
+ // pytest runs need no connector-written support files here (the runtime
54
+ // pytest.ini lives outside this directory and is written by the pytest runner).
42
55
  export function materializeGuardrails(projectRoot, suite) {
56
+ assertGuardrailPath(suite.suite_file.path);
43
57
  const dir = join(projectRoot, GUARDRAILS_DIR);
44
58
  rmSync(dir, { recursive: true, force: true });
45
59
  mkdirSync(dir, { recursive: true });
46
- const suitePath = join(dir, SUITE_FILE);
47
- writeFileSync(suitePath, suite.spec_rb);
48
- materializeHelper(projectRoot);
60
+ const suitePath = join(projectRoot, suite.suite_file.path);
61
+ mkdirSync(dirname(suitePath), { recursive: true });
62
+ writeFileSync(suitePath, suite.suite_file.content);
63
+ if (suite.runner_manifest.runner === 'rspec')
64
+ materializeHelper(projectRoot);
49
65
  return { suitePath };
50
66
  }
51
- // Both flows boot the same way: the check flow writes the boot kit next to
67
+ // Both Ruby flows boot the same way: the check flow writes the boot kit next to
52
68
  // the suite here, the suite-build flow writes it right after the precheck.
53
69
  export function materializeHelper(projectRoot) {
54
70
  const dir = join(projectRoot, GUARDRAILS_DIR);
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname, isAbsolute, join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
+ import { assertGuardrailPath } from "./guardrails.js";
3
4
  export function requestPath(projectRoot) {
4
5
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
5
6
  }
@@ -30,34 +31,57 @@ export function readSuiteBuildRequest(projectRoot) {
30
31
  }
31
32
  return request;
32
33
  }
33
- // Read and parse the host's answer. The host wrote and ran the whole spec file;
34
- // the connector verifies it parses and carries `spec_rb` (inline or via
35
- // `spec_rb_path`) plus `test_metadata`, then relays it untouched. Anything
36
- // unparseable or incomplete means nothing is uploaded (all-or-nothing).
34
+ // Read and parse the host's answer. The host wrote and ran the whole guardrail
35
+ // file; the connector verifies the answer parses, carries `suite_file` (with a
36
+ // safe path under .unitbob/guardrails/), `runner_manifest`, and
37
+ // `test_metadata`, then relays it untouched. Anything unparseable or
38
+ // incomplete means nothing is uploaded (all-or-nothing).
37
39
  export function readHostSuiteOutput(path, projectRoot) {
38
40
  if (!existsSync(path)) {
39
41
  throw new Error(`${path} not found — the host suite builder did not write its output.`);
40
42
  }
41
43
  const parsed = parseJson(readFileSync(path, 'utf8'), path);
42
44
  if (!parsed || typeof parsed !== 'object') {
43
- throw new Error(`${path} is malformed: expected an object with spec_rb (or spec_rb_path) and test_metadata.`);
45
+ throw new Error(`${path} is malformed: expected an object with suite_file, runner_manifest, and test_metadata.`);
46
+ }
47
+ if ('spec_rb' in parsed || 'spec_rb_path' in parsed) {
48
+ throw new Error(`${path} uses the legacy spec_rb shape — emit suite_file { path, content } instead (spec 30).`);
44
49
  }
45
50
  if (!('test_metadata' in parsed)) {
46
51
  throw new Error(`${path} is malformed: missing test_metadata.`);
47
52
  }
48
- const specRb = resolveSpecRb(parsed, projectRoot, path);
49
- return { spec_rb: specRb, test_metadata: parsed.test_metadata };
53
+ const manifest = parsed.runner_manifest;
54
+ if (!manifest || typeof manifest !== 'object') {
55
+ throw new Error(`${path} is malformed: missing runner_manifest.`);
56
+ }
57
+ return {
58
+ suite_file: resolveSuiteFile(parsed, projectRoot, path),
59
+ runner_manifest: manifest,
60
+ test_metadata: parsed.test_metadata,
61
+ };
50
62
  }
51
- function resolveSpecRb(parsed, projectRoot, path) {
52
- if (typeof parsed.spec_rb === 'string' && parsed.spec_rb.trim())
53
- return parsed.spec_rb;
54
- if (typeof parsed.spec_rb_path === 'string' && parsed.spec_rb_path.trim()) {
55
- const specPath = isAbsolute(parsed.spec_rb_path) ? parsed.spec_rb_path : join(projectRoot, parsed.spec_rb_path);
56
- if (!existsSync(specPath))
57
- throw new Error(`${path}: spec_rb_path "${parsed.spec_rb_path}" does not exist.`);
58
- return readFileSync(specPath, 'utf8');
63
+ // The host may inline `content` or point at the file it already wrote at
64
+ // `suite_file.path` either way the path must be safe before anything is read.
65
+ function resolveSuiteFile(parsed, projectRoot, path) {
66
+ const file = parsed.suite_file;
67
+ if (!file || typeof file !== 'object') {
68
+ throw new Error(`${path} is malformed: expected suite_file { path, content }.`);
69
+ }
70
+ const suiteFile = file;
71
+ const suitePath = typeof suiteFile.path === 'string' ? suiteFile.path : '';
72
+ assertGuardrailPath(suitePath);
73
+ if (typeof suiteFile.content === 'string' && suiteFile.content.trim()) {
74
+ return { path: suitePath, content: suiteFile.content };
75
+ }
76
+ const onDisk = join(projectRoot, suitePath);
77
+ if (!existsSync(onDisk)) {
78
+ throw new Error(`${path}: suite_file has no content and "${suitePath}" does not exist in the project.`);
79
+ }
80
+ const content = readFileSync(onDisk, 'utf8');
81
+ if (!content.trim()) {
82
+ throw new Error(`${path}: suite_file content at "${suitePath}" is empty.`);
59
83
  }
60
- throw new Error(`${path} is malformed: expected a non-empty spec_rb or spec_rb_path.`);
84
+ return { path: suitePath, content };
61
85
  }
62
86
  function parseJson(raw, path) {
63
87
  try {
package/dist/link.js CHANGED
@@ -5,35 +5,29 @@
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
8
- import { CONFIG_FILE, readLocalRepoId, writeConfigFile } from "./config.js";
8
+ import { CONFIG_FILE, readLocalRepoId, readLocalServer, writeConfigFile } from "./config.js";
9
9
  import { registerRepo, WireError } from "./wire.js";
10
- // Public Unitbob brain used by default. Local development can still pass an
11
- // explicit server URL or edit `.unitbob.json`.
10
+ // Public Unitbob brain used by default. A `server` in `.unitbob.json` (or an
11
+ // explicit argument) overrides it see resolution order in ensureLinked.
12
12
  export const DEFAULT_SERVER = 'https://unitbob-73a4082838d3.herokuapp.com';
13
- // Make sure this project is linked, resolving the repo by name on every run
14
- // (cheap the server find-or-creates) and reconciling with the local file:
15
- // - no working link (file missing / repo_id 0 / non-int) → register, write
16
- // the file, announce in one calm line;
17
- // - the file's id matches the name's server id → proceed silently;
18
- // - mismatch → fail here, before any expensive work — never silently re-link,
19
- // the file may point at a real repo the user cares about.
20
- export async function ensureLinked(cwd = process.cwd(), server = DEFAULT_SERVER) {
13
+ export async function ensureLinked(cwd = process.cwd(), server, out = process.stdout) {
14
+ const resolvedServer = server ?? readLocalServer(cwd) ?? DEFAULT_SERVER;
21
15
  const fileId = readLocalRepoId(cwd); // only cwd's own file — no walk-up
22
16
  const name = projectName(cwd);
23
17
  // Refuse before touching the server, so a stray run can't mint a junk repo.
24
18
  if (fileId === null)
25
19
  assertProjectRoot(cwd);
26
- const authId = await registerRepo(server, name);
20
+ const authId = await registerRepo(resolvedServer, name);
27
21
  if (fileId === null) {
28
- writeConfigFile(cwd, { server, repo_id: authId });
29
- ensureGitignored(cwd);
30
- process.stdout.write(`Linked this project to Unitbob as ${name}.\n`);
22
+ writeConfigFile(cwd, { server: resolvedServer, repo_id: authId });
23
+ ensureGitignored(cwd, out);
24
+ out.write(`Linked this project to Unitbob as ${name}.\n`);
31
25
  }
32
26
  else if (fileId !== authId) {
33
27
  throw new WireError(`${CONFIG_FILE} points at repo ${fileId}, but "${name}" is repo ${authId} on the server. ` +
34
28
  `Fix or remove ${CONFIG_FILE} before continuing.`);
35
29
  }
36
- return { server, repoId: authId, projectRoot: cwd };
30
+ return { server: resolvedServer, repoId: authId, projectRoot: cwd };
37
31
  }
38
32
  // The linking name is the *project's* name, not the checkout's (spec 29). A
39
33
  // `.git` directory means cwd is the main checkout; a `.git` file is a worktree
@@ -113,7 +107,7 @@ function enclosingCheckout(cwd) {
113
107
  const ARTIFACT_DIR = '.unitbob/';
114
108
  const GITIGNORE = '.gitignore';
115
109
  // The config and the artifact dir are per-machine state, never committed.
116
- export function ensureGitignored(cwd) {
110
+ export function ensureGitignored(cwd, out = process.stdout) {
117
111
  const gitignorePath = join(cwd, GITIGNORE);
118
112
  let current = '';
119
113
  if (existsSync(gitignorePath)) {
@@ -125,5 +119,5 @@ export function ensureGitignored(cwd) {
125
119
  return;
126
120
  const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
127
121
  writeFileSync(gitignorePath, `${current}${prefix}${additions.join('\n')}\n`);
128
- process.stdout.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
122
+ out.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
129
123
  }
@@ -1,10 +1,44 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
- const SUPPORTED = 'Unitbob guardrails support Ruby on Rails projects using RSpec only. ' +
4
- 'This project does not look like Rails + RSpec.';
5
- export function runtimePrecheck(projectRoot) {
4
+ const defaultDeps = {
5
+ commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
6
+ };
7
+ const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
8
+ // The generation-time gate: at least one supported stack must be present.
9
+ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
10
+ const supported = ['rspec', 'vitest', 'pytest'].some((runner) => validateStack(projectRoot, runner, deps).ok);
11
+ if (supported)
12
+ return { ok: true };
13
+ return {
14
+ ok: false,
15
+ message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
16
+ };
17
+ }
18
+ // Confirm the host-selected runner against local markers. A mismatch fails
19
+ // closed: the caller writes no files and uploads nothing.
20
+ export function validateStack(projectRoot, runner, deps = defaultDeps) {
21
+ switch (runner) {
22
+ case 'rspec':
23
+ return rubyPrecheck(projectRoot);
24
+ case 'vitest':
25
+ return vitestPrecheck(projectRoot);
26
+ case 'pytest':
27
+ return pytestPrecheck(projectRoot, deps);
28
+ default:
29
+ return {
30
+ ok: false,
31
+ message: `Unsupported runner "${runner}" — Unitbob supports rspec, vitest, and pytest only.`,
32
+ };
33
+ }
34
+ }
35
+ function rubyPrecheck(projectRoot) {
6
36
  if (!hasGemfileWith(projectRoot, /\brails\b/)) {
7
- return { ok: false, message: `${SUPPORTED} (no \`rails\` gem found in Gemfile.)` };
37
+ return {
38
+ ok: false,
39
+ message: 'The Ruby stack was selected, but this project does not look like Rails ' +
40
+ '(no `rails` gem found in Gemfile).',
41
+ };
8
42
  }
9
43
  // Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
10
44
  // bare `rspec` gem passes nothing downstream — stop with the honest offer.
@@ -18,6 +52,53 @@ export function runtimePrecheck(projectRoot) {
18
52
  }
19
53
  return { ok: true };
20
54
  }
55
+ function vitestPrecheck(projectRoot) {
56
+ const packageJson = join(projectRoot, 'package.json');
57
+ if (!existsSync(packageJson)) {
58
+ return {
59
+ ok: false,
60
+ message: 'The JavaScript/TypeScript stack was selected, but this project has no package.json.',
61
+ };
62
+ }
63
+ const hasVitest = /"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
64
+ existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
65
+ if (!hasVitest) {
66
+ return {
67
+ ok: false,
68
+ message: 'JS/TS guardrails require Vitest (Jest is not supported in MVP v2), and vitest was not ' +
69
+ "found in this project's package.json or node_modules. Offer the user to add it " +
70
+ '(`npm i -D vitest`); change dependencies only with their consent, then retry.',
71
+ };
72
+ }
73
+ return { ok: true };
74
+ }
75
+ function pytestPrecheck(projectRoot, deps) {
76
+ const markers = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
77
+ const found = markers.some((name) => existsSync(join(projectRoot, name)));
78
+ if (!found) {
79
+ return {
80
+ ok: false,
81
+ message: 'The Python stack was selected, but this project has none of ' +
82
+ `${markers.join(', ')} — it does not look like a Python project.`,
83
+ };
84
+ }
85
+ // Spec 30 fails closed on runner availability: unlike marker files, pytest
86
+ // must actually be importable in the current interpreter, or every run would
87
+ // end as a "No module named pytest" suite error after files were written. We
88
+ // probe the same interpreters the pytest runner tries, in the same order, so
89
+ // the precheck and the run agree on whether pytest is runnable.
90
+ const available = ['python3', 'python'].some((python) => deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot));
91
+ if (!available) {
92
+ return {
93
+ ok: false,
94
+ message: 'The Python stack was selected, but pytest is not importable in the current Python ' +
95
+ 'environment. If your dependencies live in a virtualenv, activate it (e.g. ' +
96
+ '`source .venv/bin/activate`) before running Unitbob; otherwise install pytest ' +
97
+ '(`pip install pytest`), then retry.',
98
+ };
99
+ }
100
+ return { ok: true };
101
+ }
21
102
  function hasGemfileWith(projectRoot, pattern) {
22
103
  for (const name of ['Gemfile', 'gems.rb']) {
23
104
  const path = join(projectRoot, name);
@@ -0,0 +1,46 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runProcess } from "../proc.js";
4
+ import { GUARDRAILS_DIR } from "../files/guardrails.js";
5
+ import { readReport } from "./types.js";
6
+ export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
7
+ export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
8
+ // A minimal runtime config, created or overwritten before each run and passed
9
+ // via `-c` so the project's own addopts (e.g. --cov, -n auto) cannot break the
10
+ // guardrail run or its JUnit output. Connector-owned: never stored in Rails,
11
+ // never part of the suite digest.
12
+ export const PYTEST_INI_FILE = join('.unitbob', 'pytest.ini');
13
+ 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) {
20
+ 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}`];
23
+ const result = await runProcess(command, args, {
24
+ cwd: projectRoot,
25
+ timeoutMs: PYTEST_TIMEOUT_MS,
26
+ env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
27
+ });
28
+ return {
29
+ ...result,
30
+ command,
31
+ args,
32
+ resultPath: PYTEST_RESULT_FILE,
33
+ report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
34
+ };
35
+ }
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,23 +1,23 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
- import { GUARDRAILS_DIR, OPTIONS_FILE, RESULT_FILE, SUITE_FILE } from "../files/guardrails.js";
4
+ import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
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
7
8
  // green→red flip can never come from run-order nondeterminism. It does not inherit
8
9
  // the project's random ordering.
9
10
  export const RSPEC_SEED = '1';
11
+ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
10
12
  // Run the materialised Unitbob guardrail suite (spec 26). Only this file runs —
11
13
  // never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
12
14
  // --options points at the materialized empty file so the project's own .rspec
13
15
  // (a --require of a helper we replaced, an extra stdout formatter) can neither
14
16
  // break the boot nor corrupt the JSON output. The JSON report goes to `--out`
15
17
  // (a file), not stdout, so the app's own stdout writes during the run can never
16
- // corrupt it.
17
- export async function runRspecSuite(projectRoot) {
18
- const suitePath = join(GUARDRAILS_DIR, SUITE_FILE);
18
+ // corrupt it. `suitePath` is the suite blob's own project-relative path.
19
+ export async function runRspecSuite(projectRoot, suitePath) {
19
20
  const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
20
- const resultPath = join(GUARDRAILS_DIR, RESULT_FILE);
21
21
  const { result, command, args } = await invokeRspec(projectRoot, [
22
22
  suitePath,
23
23
  '--options',
@@ -29,20 +29,15 @@ export async function runRspecSuite(projectRoot) {
29
29
  '--format',
30
30
  'json',
31
31
  '--out',
32
- resultPath,
32
+ RSPEC_RESULT_FILE,
33
33
  ]);
34
- return { ...result, command, args, jsonReport: readReport(join(projectRoot, resultPath)) };
35
- }
36
- // Read the `--out` report file back verbatim. A missing or unreadable file is a
37
- // clean empty string, not a throw: the caller falls back to stdout/stderr and
38
- // reports a suite error, exactly as it would for unparseable output.
39
- function readReport(path) {
40
- try {
41
- return existsSync(path) ? readFileSync(path, 'utf8') : '';
42
- }
43
- catch {
44
- return '';
45
- }
34
+ return {
35
+ ...result,
36
+ command,
37
+ args,
38
+ resultPath: RSPEC_RESULT_FILE,
39
+ report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
40
+ };
46
41
  }
47
42
  // Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
48
43
  // run sets RAILS_ENV=test so guardrails execute against the Rails test
@@ -0,0 +1,11 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ // Read a report file back verbatim. A missing or unreadable file is a clean
3
+ // empty string, not a throw: the caller reports a structured suite error.
4
+ export function readReport(path) {
5
+ try {
6
+ return existsSync(path) ? readFileSync(path, 'utf8') : '';
7
+ }
8
+ catch {
9
+ return '';
10
+ }
11
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runProcess } from "../proc.js";
4
+ import { GUARDRAILS_DIR } from "../files/guardrails.js";
5
+ import { readReport } from "./types.js";
6
+ export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
7
+ export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
8
+ // A connector-owned Vitest config, written next to .unitbob/ before a run when
9
+ // the project has its own config. Connector-owned: never stored in Rails, never
10
+ // part of the suite digest.
11
+ export const VITEST_CONFIG_FILE = join('.unitbob', 'vitest.config.mjs');
12
+ // The project configs we inherit from, most specific first. Vitest reads a
13
+ // project's own config even when we pass `--config`, so we must merge ours with
14
+ // it rather than replace it (plugins, path aliases and resolve settings the
15
+ // guardrail file needs all live there).
16
+ const PROJECT_CONFIGS = [
17
+ 'vitest.config.ts',
18
+ 'vitest.config.mts',
19
+ 'vitest.config.cts',
20
+ 'vitest.config.js',
21
+ 'vitest.config.mjs',
22
+ 'vitest.config.cjs',
23
+ 'vite.config.ts',
24
+ 'vite.config.mts',
25
+ 'vite.config.cts',
26
+ 'vite.config.js',
27
+ 'vite.config.mjs',
28
+ 'vite.config.cjs',
29
+ ];
30
+ // 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
+ //
33
+ // A bare `vitest run <file>` treats the path as a filter that is intersected
34
+ // 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.
40
+ //
41
+ // The JSON report goes to --outputFile, not stdout, so app logging can never
42
+ // corrupt it. The command is connector-owned: the suite artifact never carries
43
+ // 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}`];
48
+ const result = await runProcess(command, args, {
49
+ cwd: projectRoot,
50
+ timeoutMs: VITEST_TIMEOUT_MS,
51
+ env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
52
+ });
53
+ return {
54
+ ...result,
55
+ command,
56
+ args,
57
+ resultPath: VITEST_RESULT_FILE,
58
+ report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
59
+ };
60
+ }
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) {
65
+ 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));
69
+ return ['--config', VITEST_CONFIG_FILE];
70
+ }
71
+ // 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';
78
+ import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
79
+
80
+ const base = typeof projectConfig === 'function'
81
+ ? await projectConfig({ command: 'serve', mode: 'test' })
82
+ : projectConfig;
83
+
84
+ export default mergeConfig(base, {
85
+ test: { include: [${JSON.stringify(suitePath)}] },
86
+ });
87
+ `;
88
+ }
@@ -1,20 +1,30 @@
1
1
  import { readHostSuiteOutput, readSuiteBuildRequest } from "../files/suiteBuild.js";
2
+ import { validateStack } from "../runner/precheck.js";
2
3
  import { Wire } from "../wire.js";
3
4
  // Read the task and the host's answer, verify the answer parses and carries the
4
- // whole spec file + test_metadata, then upload `{ map_digest, spec_rb,
5
- // test_metadata }`. `map_digest` comes from the task never the host's answer —
6
- // so the host cannot claim a different map than it was given. If the answer is
7
- // unparseable or incomplete, nothing is uploaded and the previous suite stands.
5
+ // whole suite artifact, confirm the host-selected stack against local project
6
+ // markers (fail closed a mismatch uploads nothing), then upload
7
+ // `{ map_digest, suite_file, runner_manifest, test_metadata }`. `map_digest`
8
+ // comes from the task never the host's answer — so the host cannot claim a
9
+ // different map than it was given. If the answer is unparseable or incomplete,
10
+ // nothing is uploaded and the previous suite stands.
8
11
  export async function putSuiteBuild(config, _args = [], deps) {
9
12
  const request = readSuiteBuildRequest(config.projectRoot);
10
13
  const output = readHostSuiteOutput(request.output_path, config.projectRoot);
11
14
  const actual = {
12
15
  putSuiteBuild: (payload) => new Wire(config).putSuiteBuild(payload),
16
+ validateStack,
13
17
  ...deps,
14
18
  };
19
+ const runner = String(output.runner_manifest.runner ?? '');
20
+ const check = actual.validateStack(config.projectRoot, runner);
21
+ if (!check.ok) {
22
+ throw new Error(check.message ?? `Local project does not match the selected runner "${runner}".`);
23
+ }
15
24
  const result = await actual.putSuiteBuild({
16
25
  map_digest: request.map_digest,
17
- spec_rb: output.spec_rb,
26
+ suite_file: output.suite_file,
27
+ runner_manifest: output.runner_manifest,
18
28
  test_metadata: output.test_metadata,
19
29
  });
20
30
  const tallies = Object.entries(result.counts)
package/dist/verbs/run.js CHANGED
@@ -1,60 +1,104 @@
1
1
  import { materializeGuardrails } from "../files/guardrails.js";
2
- import { runtimePrecheck } from "../runner/precheck.js";
2
+ import { validateStack } from "../runner/precheck.js";
3
3
  import { runRspecSuite } from "../runner/rspec.js";
4
+ import { runVitestSuite } from "../runner/vitest.js";
5
+ import { runPytestSuite } from "../runner/pytest.js";
4
6
  import { Wire } from "../wire.js";
5
7
  // Bound failure text for transport/storage hygiene (spec 26). This is a size
6
8
  // limit only, not a privacy filter — application values in failure messages are
7
9
  // accepted, not scrubbed.
8
- const MAX_FAILURE_CHARS = 4000;
10
+ const MAX_FAILURE_CHARS = 8000;
11
+ const OUTPUT_TAIL_CHARS = 2000;
12
+ // The check flow (spec 30): fetch the current suite blob, confirm the local
13
+ // project matches its runner, materialize the guardrail file, execute the
14
+ // connector-owned strategy named by `runner_manifest.runner`, and ship the raw
15
+ // machine-readable report (or a structured runner error) to Rails.
9
16
  export async function run(config, _args, deps) {
10
17
  const wire = new Wire(config);
11
18
  const d = {
12
19
  getSuite: () => wire.getSuite(),
13
20
  postRun: (payload) => wire.postRun(payload),
14
21
  materializeGuardrails,
15
- runRspecSuite,
16
- precheck: runtimePrecheck,
22
+ runSuite: runSuiteByRunner,
23
+ validateStack,
17
24
  stdout: process.stdout,
18
25
  ...deps,
19
26
  };
20
- const check = d.precheck(config.projectRoot);
21
- if (!check.ok)
22
- throw new Error(check.message ?? 'Unsupported runtime.');
23
27
  const suite = await d.getSuite();
24
28
  if (suite === null) {
25
29
  d.stdout.write('No Unitbob suite exists yet. Generate the test suite first, then run /unitbob check again.\n');
26
30
  return;
27
31
  }
32
+ // Fail closed before touching the working tree: a stack mismatch writes no files.
33
+ const runner = suite.runner_manifest.runner;
34
+ const check = d.validateStack(config.projectRoot, runner);
35
+ if (!check.ok)
36
+ throw new Error(check.message ?? `Local project does not match the suite runner "${runner}".`);
28
37
  d.materializeGuardrails(config.projectRoot, suite);
29
- const result = await d.runRspecSuite(config.projectRoot).catch((err) => ({
38
+ const result = await d.runSuite(config.projectRoot, runner, suite.suite_file.path).catch((err) => ({
30
39
  stdout: '',
31
40
  stderr: err.message,
32
41
  code: null,
33
- command: 'rspec',
42
+ command: runner,
34
43
  args: [],
35
- jsonReport: '',
44
+ resultPath: '',
45
+ report: '',
36
46
  }));
37
- const payload = rawRunPayload(suite.suite_digest, result);
47
+ const payload = rawRunPayload(suite.suite_digest, runner, result);
38
48
  const summary = await d.postRun(payload);
39
49
  d.stdout.write(`${summary.summary}\n`);
40
50
  if (summary.map_url)
41
51
  d.stdout.write(`${summary.map_url}\n`);
42
52
  }
43
- // The RSpec JSON report comes from the `--out` file (`jsonReport`), which the app
44
- // under test cannot pollute. Stdout is only a fallback for a runner double that
45
- // emits its report there; anything unparseable is a genuine suite error.
46
- function rawRunPayload(suiteDigest, result) {
47
- const parsed = parseJsonObject(result.jsonReport) ?? parseJsonObject(result.stdout);
48
- if (parsed)
49
- return { suite_digest: suiteDigest, rspec_json: boundFailures(parsed) };
53
+ // The connector-owned strategy table: `runner_manifest.runner` names one of
54
+ // these; only suite and result paths vary. Host-provided command strings are
55
+ // never executed.
56
+ function runSuiteByRunner(projectRoot, runner, suitePath) {
57
+ switch (runner) {
58
+ case 'rspec':
59
+ return runRspecSuite(projectRoot, suitePath);
60
+ case 'vitest':
61
+ return runVitestSuite(projectRoot, suitePath);
62
+ case 'pytest':
63
+ return runPytestSuite(projectRoot, suitePath);
64
+ default:
65
+ return Promise.reject(new Error(`Unsupported runner "${runner}" — rebuild the suite with /unitbob suite.`));
66
+ }
67
+ }
68
+ // The machine-readable report comes from the runner's own output file, which
69
+ // the app under test cannot pollute. A run that produced no report is a
70
+ // structured suite error — command, exit code, expected result path, output
71
+ // tail — which Rails records without repainting capabilities.
72
+ function rawRunPayload(suiteDigest, runner, result) {
73
+ const report = boundedReport(runner, result);
74
+ if (report !== null)
75
+ return { suite_digest: suiteDigest, run_result: report };
50
76
  return {
51
77
  suite_digest: suiteDigest,
52
- suite_error: suiteErrorMessage(result),
78
+ suite_error: {
79
+ command: [result.command, ...result.args].join(' '),
80
+ exit_code: result.code,
81
+ result_path: result.resultPath,
82
+ output_tail: outputTail(result),
83
+ },
53
84
  };
54
85
  }
86
+ // JSON reports (rspec, vitest) are parsed only to size-bound failure text, then
87
+ // re-serialized; the XML report is bounded per failure element the same way.
88
+ // `null` means "no usable report" — the suite-error path. Stdout is only a
89
+ // fallback for an rspec double that emits its report there.
90
+ function boundedReport(runner, result) {
91
+ if (runner === 'pytest')
92
+ return result.report.trim() ? boundJunitXml(result.report) : null;
93
+ const parsed = parseJsonObject(result.report) ?? (runner === 'rspec' ? parseJsonObject(result.stdout) : null);
94
+ if (!parsed)
95
+ return null;
96
+ const bounded = runner === 'rspec' ? boundRspecFailures(parsed) : boundVitestFailures(parsed);
97
+ return JSON.stringify(bounded);
98
+ }
55
99
  // Truncate long per-example failure message/backtrace before transport. Other
56
100
  // fields pass through untouched; Rails owns the capability join and status mapping.
57
- function boundFailures(rspecJson) {
101
+ function boundRspecFailures(rspecJson) {
58
102
  const examples = rspecJson.examples;
59
103
  if (!Array.isArray(examples))
60
104
  return rspecJson;
@@ -75,9 +119,53 @@ function boundFailures(rspecJson) {
75
119
  });
76
120
  return { ...rspecJson, examples: bounded };
77
121
  }
122
+ function boundVitestFailures(vitestJson) {
123
+ const testResults = vitestJson.testResults;
124
+ if (!Array.isArray(testResults))
125
+ return vitestJson;
126
+ const bounded = testResults.map((fileResult) => {
127
+ if (!fileResult || typeof fileResult !== 'object')
128
+ return fileResult;
129
+ const file = fileResult;
130
+ const assertions = file.assertionResults;
131
+ if (!Array.isArray(assertions))
132
+ return file;
133
+ const next = assertions.map((assertion) => {
134
+ if (!assertion || typeof assertion !== 'object')
135
+ return assertion;
136
+ const a = assertion;
137
+ if (!Array.isArray(a.failureMessages))
138
+ return a;
139
+ return { ...a, failureMessages: a.failureMessages.map((m) => (typeof m === 'string' ? truncate(m) : m)) };
140
+ });
141
+ return { ...file, assertionResults: next };
142
+ });
143
+ return { ...vitestJson, testResults: bounded };
144
+ }
78
145
  function truncate(text) {
79
146
  return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
80
147
  }
148
+ // pytest's JUnit XML has no size bound of its own: one failing assertion can
149
+ // carry a multi-megabyte diff plus captured stdout/stderr. Bound the two things
150
+ // Rails reads (the failure/error/skipped `message` attribute and the element
151
+ // body) plus the captured-output blocks, keeping the document well-formed so
152
+ // Rails' strict XML parse still succeeds.
153
+ function boundJunitXml(xml) {
154
+ const boundedBodies = xml.replace(/(<(failure|error|skipped|system-out|system-err)\b[^>]*>)([\s\S]*?)(<\/\2>)/g, (_match, open, _tag, body, close) => `${boundXmlMessageAttr(open)}${boundXmlText(body)}${close}`);
155
+ // Self-closing forms (e.g. <failure message="…"/>) carry everything in the attr.
156
+ return boundedBodies.replace(/<(failure|error|skipped)\b[^>]*\/>/g, (tag) => boundXmlMessageAttr(tag));
157
+ }
158
+ function boundXmlMessageAttr(tag) {
159
+ // Attribute values escape their own quotes, so matching to the next `"` is safe.
160
+ return tag.replace(/(\bmessage=")([\s\S]*?)(")/, (_m, pre, value, post) => value.length > MAX_FAILURE_CHARS ? `${pre}${boundXmlText(value)}… (truncated)${post}` : `${pre}${value}${post}`);
161
+ }
162
+ function boundXmlText(text) {
163
+ return text.length > MAX_FAILURE_CHARS ? `${truncateXml(text)}… (truncated)` : text;
164
+ }
165
+ // Slice without cutting inside an XML entity, which would break a strict parse.
166
+ function truncateXml(text) {
167
+ return text.slice(0, MAX_FAILURE_CHARS).replace(/&[^;]*$/, '');
168
+ }
81
169
  function parseJsonObject(text) {
82
170
  try {
83
171
  const parsed = JSON.parse(text);
@@ -87,13 +175,12 @@ function parseJsonObject(text) {
87
175
  return null;
88
176
  }
89
177
  }
90
- function suiteErrorMessage(result) {
178
+ function outputTail(result) {
91
179
  const bits = [];
92
- if (result.code !== 0 && result.code !== null)
93
- bits.push(`exit ${result.code}`);
94
180
  if (result.stderr.trim())
95
- bits.push(`stderr: ${truncate(result.stderr.trim())}`);
181
+ bits.push(result.stderr.trim());
96
182
  if (result.stdout.trim())
97
- bits.push(`stdout: ${result.stdout.trim().slice(0, 1000)}`);
98
- return bits.length > 0 ? bits.join(' | ') : 'RSpec output was not parseable JSON';
183
+ bits.push(result.stdout.trim());
184
+ const joined = bits.join('\n');
185
+ return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
99
186
  }
@@ -1,21 +1,22 @@
1
1
  import { materializeHelper } from "../files/guardrails.js";
2
2
  import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
3
- import { runtimePrecheck } from "../runner/precheck.js";
3
+ import { anyStackPrecheck } from "../runner/precheck.js";
4
4
  import { Wire } from "../wire.js";
5
- // Confirm the runtime is supported (Rails + RSpec), materialize the boot helper
6
- // the generated suite will require, then fetch the generate recipe and the
7
- // per-block capability assignment and write the host's task to
8
- // `.unitbob/suite-build/request.json`. No model is called and no source is
9
- // read here that is the host's job, framed by ai/agents/suite_builder.md. An
10
- // unsupported runtime stops with one actionable message and writes nothing; a
11
- // no-current-map error from the server surfaces (via WireError) with guidance to
12
- // run `/unitbob map` first.
5
+ // Confirm at least one supported stack is present (Rails+RSpec, Vitest, or
6
+ // pytest the host LLM picks the primary one during generation), materialize
7
+ // the Ruby boot helper a generated RSpec suite would require, then fetch the
8
+ // generate recipe and the per-block capability assignment and write the host's
9
+ // task to `.unitbob/suite-build/request.json`. No model is called and no
10
+ // source is read here that is the host's job, framed by
11
+ // ai/agents/suite_builder.md. An unsupported project stops with one actionable
12
+ // message and writes nothing; a no-current-map error from the server surfaces
13
+ // (via WireError) with guidance to run `/unitbob map` first.
13
14
  export async function suitePrepare(config, _args = [], deps) {
14
15
  const wire = new Wire(config);
15
16
  const actual = {
16
17
  getRecipe: (name) => wire.getRecipe(name),
17
18
  getSuitePackets: () => wire.getSuitePackets(),
18
- precheck: runtimePrecheck,
19
+ precheck: anyStackPrecheck,
19
20
  ...deps,
20
21
  };
21
22
  const check = actual.precheck(config.projectRoot);
package/dist/wire.js CHANGED
@@ -56,9 +56,9 @@ export class Wire {
56
56
  return (await res.json());
57
57
  }
58
58
  // PUT /repos/:id/suite_build — upload the host's complete, locally-validated
59
- // suite: the verbatim spec file plus the capability-keyed test_metadata. The
60
- // server validates the classification, stores the bytes verbatim, versions, and
61
- // returns the new suite's identity.
59
+ // suite artifact: the verbatim suite_file, the suite-level runner_manifest,
60
+ // and the capability-keyed test_metadata. The server validates the artifact,
61
+ // stores it verbatim, versions, and returns the new suite's identity.
62
62
  async putSuiteBuild(payload) {
63
63
  const res = await this.send('PUT', this.repoPath('suite_build'), payload);
64
64
  await this.ensureOk(res, `PUT ${this.repoPath('suite_build')}`);
@@ -118,9 +118,16 @@ export class Wire {
118
118
  throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload.`);
119
119
  }
120
120
  const suite = payload;
121
- if (typeof suite.suite_digest !== 'string' || typeof suite.spec_rb !== 'string') {
121
+ const file = suite.suite_file;
122
+ const manifest = suite.runner_manifest;
123
+ const wellFormed = typeof suite.suite_digest === 'string' &&
124
+ file != null && typeof file === 'object' &&
125
+ typeof file.path === 'string' && typeof file.content === 'string' &&
126
+ manifest != null && typeof manifest === 'object' &&
127
+ typeof manifest.runner === 'string';
128
+ if (!wellFormed) {
122
129
  throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload: ` +
123
- 'expected suite_digest and spec_rb.');
130
+ 'expected suite_digest, suite_file { path, content }, and runner_manifest.runner.');
124
131
  }
125
132
  return suite;
126
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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": {