unitbob 0.1.5 → 0.1.7

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.
@@ -3,13 +3,25 @@ import { join } from 'node:path';
3
3
  export const GUARDRAILS_DIR = join('.unitbob', 'guardrails');
4
4
  export const SUITE_FILE = 'architecture_map_contracts_spec.rb';
5
5
  export const HELPER_FILE = 'unitbob_helper.rb';
6
+ // An always-empty custom options file: pointing rspec's --options here keeps
7
+ // the project's own .rspec (stray --require lines, extra stdout formatters)
8
+ // out of guardrail runs, whose JSON output must stay parseable.
9
+ 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';
6
15
  // The boot file the generated suite requires (spec 29). Connector-owned and
7
16
  // versioned with it — never scaffolded into the project, never part of the
8
17
  // suite digest (that covers `spec_rb` bytes only). Delegates to the project's
9
18
  // own RSpec setup when one exists; boots the Rails test environment directly
10
- // when none does.
19
+ // when none does. Both branches refuse a non-test environment — before boot
20
+ // via ENV (nothing touched yet), after boot via Rails.env (config overrides).
11
21
  export const UNITBOB_HELPER_RB = `# frozen_string_literal: true
12
22
  # Written by the unitbob connector on every materialization — do not edit.
23
+ ENV['RAILS_ENV'] ||= 'test'
24
+ abort 'unitbob_helper: refusing to run against a non-test environment' unless ENV['RAILS_ENV'] == 'test'
13
25
  root = File.expand_path('../..', __dir__)
14
26
  if File.exist?(File.join(root, 'spec', 'rails_helper.rb'))
15
27
  # The project has its own RSpec setup — respect it (factories, cleaners…).
@@ -17,9 +29,7 @@ if File.exist?(File.join(root, 'spec', 'rails_helper.rb'))
17
29
  require 'rails_helper'
18
30
  else
19
31
  # No RSpec scaffolding — boot the Rails test environment directly.
20
- ENV['RAILS_ENV'] ||= 'test'
21
32
  require File.join(root, 'config', 'environment')
22
- abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
23
33
  require 'rspec/rails'
24
34
  ActiveRecord::Migration.maintain_test_schema!
25
35
  RSpec.configure do |config|
@@ -27,6 +37,7 @@ else
27
37
  config.infer_spec_type_from_file_location!
28
38
  end
29
39
  end
40
+ abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
30
41
  `;
31
42
  export function materializeGuardrails(projectRoot, suite) {
32
43
  const dir = join(projectRoot, GUARDRAILS_DIR);
@@ -37,12 +48,13 @@ export function materializeGuardrails(projectRoot, suite) {
37
48
  materializeHelper(projectRoot);
38
49
  return { suitePath };
39
50
  }
40
- // Both flows boot the same way: the check flow writes the helper next to the
41
- // suite here, the suite-build flow writes it right after the precheck.
51
+ // Both flows boot the same way: the check flow writes the boot kit next to
52
+ // the suite here, the suite-build flow writes it right after the precheck.
42
53
  export function materializeHelper(projectRoot) {
43
54
  const dir = join(projectRoot, GUARDRAILS_DIR);
44
55
  mkdirSync(dir, { recursive: true });
45
56
  const helperPath = join(dir, HELPER_FILE);
46
57
  writeFileSync(helperPath, UNITBOB_HELPER_RB);
58
+ writeFileSync(join(dir, OPTIONS_FILE), '');
47
59
  return helperPath;
48
60
  }
package/dist/link.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // server key nobody is expected to know.
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
7
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
8
8
  import { CONFIG_FILE, readLocalRepoId, writeConfigFile } from "./config.js";
9
9
  import { registerRepo, WireError } from "./wire.js";
10
10
  // Grill decision #1: hosted deployment is deferred; the local brain is the only
@@ -50,17 +50,32 @@ export function projectName(cwd) {
50
50
  if (!pointer)
51
51
  return basename(cwd);
52
52
  const gitdir = isAbsolute(pointer[1]) ? pointer[1] : resolve(cwd, pointer[1]);
53
- const worktrees = dirname(gitdir); // <root>/.git/worktrees
54
- const commonGit = dirname(worktrees); // <root>/.git
55
- if (basename(worktrees) === 'worktrees' && basename(commonGit) === '.git') {
53
+ const commonGit = commonGitDir(gitdir);
54
+ if (commonGit === null)
55
+ return basename(cwd); // submodule (`.git/modules/…`) or exotic layout
56
+ if (basename(commonGit) === '.git')
56
57
  return basename(dirname(commonGit));
57
- }
58
- return basename(cwd); // submodule (`.git/modules/…`) or exotic layout
58
+ return basename(commonGit).replace(/\.git$/, '') || basename(cwd); // bare / separate git dir
59
59
  }
60
60
  catch {
61
61
  return basename(cwd); // no .git at all — git-less project
62
62
  }
63
63
  }
64
+ // The main checkout's git dir for a worktree: git's own `commondir` pointer
65
+ // inside the worktree admin dir works for every layout (bare main repo,
66
+ // --separate-git-dir); the literal `<root>/.git/worktrees/<slug>` shape is
67
+ // the fallback. null means "not a worktree we understand" — the caller falls
68
+ // back to basename(cwd), so name resolution never fails linking.
69
+ function commonGitDir(gitdir) {
70
+ const commondir = join(gitdir, 'commondir');
71
+ if (existsSync(commondir))
72
+ return resolve(gitdir, readFileSync(commondir, 'utf8').trim());
73
+ const worktrees = dirname(gitdir);
74
+ if (basename(worktrees) === 'worktrees' && basename(dirname(worktrees)) === '.git') {
75
+ return dirname(worktrees);
76
+ }
77
+ return null;
78
+ }
64
79
  const PROJECT_MARKERS = ['Gemfile', 'gems.rb', 'package.json'];
65
80
  // Linking writes a file and creates a server row — only do that at a real
66
81
  // project root: never $HOME or the filesystem root, and the folder must be
@@ -68,12 +83,32 @@ const PROJECT_MARKERS = ['Gemfile', 'gems.rb', 'package.json'];
68
83
  // version control at all; the marker still keeps junk folders off the brain).
69
84
  export function assertProjectRoot(cwd) {
70
85
  const isFsRoot = dirname(cwd) === cwd;
71
- const anchored = existsSync(join(cwd, '.git')) ||
72
- PROJECT_MARKERS.some((marker) => existsSync(join(cwd, marker)));
86
+ const hasGit = existsSync(join(cwd, '.git'));
87
+ const anchored = hasGit || PROJECT_MARKERS.some((marker) => existsSync(join(cwd, marker)));
73
88
  if (isFsRoot || cwd === homedir() || !anchored) {
74
89
  throw new WireError(`${cwd} does not look like a project root (no .git and no project files ` +
75
90
  "(Gemfile/package.json) here) — run this from your project's root folder.");
76
91
  }
92
+ // A marker without .git can also mean a folder *inside* a bigger project —
93
+ // a monorepo sub-package, a Rails app's frontend/, node_modules. Those must
94
+ // never mint a brain repo: inside a checkout, the root is where .git is.
95
+ if (!hasGit) {
96
+ const enclosing = enclosingCheckout(cwd);
97
+ if (enclosing !== null || cwd.split(sep).includes('node_modules')) {
98
+ const hint = enclosing === null ? 'a dependency folder' : `the project at ${enclosing}`;
99
+ throw new WireError(`${cwd} looks like a folder inside ${hint} — run this from your project's root folder.`);
100
+ }
101
+ }
102
+ }
103
+ // Nearest ancestor carrying a .git, walking up to (but not including) $HOME —
104
+ // a dotfiles repo in $HOME must not swallow every git-less project under it.
105
+ function enclosingCheckout(cwd) {
106
+ const home = homedir();
107
+ for (let dir = dirname(cwd); dir !== home && dirname(dir) !== dir; dir = dirname(dir)) {
108
+ if (existsSync(join(dir, '.git')))
109
+ return dir;
110
+ }
111
+ return null;
77
112
  }
78
113
  const ARTIFACT_DIR = '.unitbob/';
79
114
  const GITIGNORE = '.gitignore';
@@ -6,7 +6,9 @@ export function runtimePrecheck(projectRoot) {
6
6
  if (!hasGemfileWith(projectRoot, /\brails\b/)) {
7
7
  return { ok: false, message: `${SUPPORTED} (no \`rails\` gem found in Gemfile.)` };
8
8
  }
9
- if (!hasGemfileWith(projectRoot, /\brspec(-rails)?\b/)) {
9
+ // Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
10
+ // bare `rspec` gem passes nothing downstream — stop with the honest offer.
11
+ if (!hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
10
12
  return {
11
13
  ok: false,
12
14
  message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
@@ -1,7 +1,7 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { runProcess } from "../proc.js";
4
- import { GUARDRAILS_DIR, SUITE_FILE } from "../files/guardrails.js";
4
+ import { GUARDRAILS_DIR, OPTIONS_FILE, RESULT_FILE, SUITE_FILE } from "../files/guardrails.js";
5
5
  export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
6
6
  // Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
7
7
  // green→red flip can never come from run-order nondeterminism. It does not inherit
@@ -9,9 +9,40 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
9
9
  export const RSPEC_SEED = '1';
10
10
  // Run the materialised Unitbob guardrail suite (spec 26). Only this file runs —
11
11
  // never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
12
+ // --options points at the materialized empty file so the project's own .rspec
13
+ // (a --require of a helper we replaced, an extra stdout formatter) can neither
14
+ // break the boot nor corrupt the JSON output. The JSON report goes to `--out`
15
+ // (a file), not stdout, so the app's own stdout writes during the run can never
16
+ // corrupt it.
12
17
  export async function runRspecSuite(projectRoot) {
13
18
  const suitePath = join(GUARDRAILS_DIR, SUITE_FILE);
14
- return invokeRspec(projectRoot, [suitePath, '--order', 'defined', '--seed', RSPEC_SEED, '--format', 'json']);
19
+ const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
20
+ const resultPath = join(GUARDRAILS_DIR, RESULT_FILE);
21
+ const { result, command, args } = await invokeRspec(projectRoot, [
22
+ suitePath,
23
+ '--options',
24
+ optionsPath,
25
+ '--order',
26
+ 'defined',
27
+ '--seed',
28
+ RSPEC_SEED,
29
+ '--format',
30
+ 'json',
31
+ '--out',
32
+ resultPath,
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
+ }
15
46
  }
16
47
  // Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
17
48
  // run sets RAILS_ENV=test so guardrails execute against the Rails test
@@ -26,7 +57,7 @@ async function invokeRspec(projectRoot, rspecArgs) {
26
57
  timeoutMs: RSPEC_TIMEOUT_MS,
27
58
  env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
28
59
  });
29
- return { ...result, command, args };
60
+ return { result, command, args };
30
61
  }
31
62
  function executable(path) {
32
63
  try {
package/dist/verbs/run.js CHANGED
@@ -32,6 +32,7 @@ export async function run(config, _args, deps) {
32
32
  code: null,
33
33
  command: 'rspec',
34
34
  args: [],
35
+ jsonReport: '',
35
36
  }));
36
37
  const payload = rawRunPayload(suite.suite_digest, result);
37
38
  const summary = await d.postRun(payload);
@@ -39,8 +40,11 @@ export async function run(config, _args, deps) {
39
40
  if (summary.map_url)
40
41
  d.stdout.write(`${summary.map_url}\n`);
41
42
  }
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.
42
46
  function rawRunPayload(suiteDigest, result) {
43
- const parsed = parseJsonObject(result.stdout);
47
+ const parsed = parseJsonObject(result.jsonReport) ?? parseJsonObject(result.stdout);
44
48
  if (parsed)
45
49
  return { suite_digest: suiteDigest, rspec_json: boundFailures(parsed) };
46
50
  return {
@@ -1,5 +1,5 @@
1
1
  export function repoUrl(config) {
2
- return `${config.server}/repos/${config.repoId}`;
2
+ return `${config.server}/repos/${config.repoId}#map`;
3
3
  }
4
4
  export async function show(config) {
5
5
  process.stdout.write(`${repoUrl(config)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {