unitbob 0.1.4 → 0.1.6

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,11 +2,54 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  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
+ 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
+ // The boot file the generated suite requires (spec 29). Connector-owned and
11
+ // versioned with it — never scaffolded into the project, never part of the
12
+ // suite digest (that covers `spec_rb` bytes only). Delegates to the project's
13
+ // own RSpec setup when one exists; boots the Rails test environment directly
14
+ // when none does. Both branches refuse a non-test environment — before boot
15
+ // via ENV (nothing touched yet), after boot via Rails.env (config overrides).
16
+ export const UNITBOB_HELPER_RB = `# frozen_string_literal: true
17
+ # Written by the unitbob connector on every materialization — do not edit.
18
+ ENV['RAILS_ENV'] ||= 'test'
19
+ abort 'unitbob_helper: refusing to run against a non-test environment' unless ENV['RAILS_ENV'] == 'test'
20
+ root = File.expand_path('../..', __dir__)
21
+ if File.exist?(File.join(root, 'spec', 'rails_helper.rb'))
22
+ # The project has its own RSpec setup — respect it (factories, cleaners…).
23
+ $LOAD_PATH.unshift File.join(root, 'spec')
24
+ require 'rails_helper'
25
+ else
26
+ # No RSpec scaffolding — boot the Rails test environment directly.
27
+ require File.join(root, 'config', 'environment')
28
+ require 'rspec/rails'
29
+ ActiveRecord::Migration.maintain_test_schema!
30
+ RSpec.configure do |config|
31
+ config.use_transactional_fixtures = true
32
+ config.infer_spec_type_from_file_location!
33
+ end
34
+ end
35
+ abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
36
+ `;
5
37
  export function materializeGuardrails(projectRoot, suite) {
6
38
  const dir = join(projectRoot, GUARDRAILS_DIR);
7
39
  rmSync(dir, { recursive: true, force: true });
8
40
  mkdirSync(dir, { recursive: true });
9
41
  const suitePath = join(dir, SUITE_FILE);
10
42
  writeFileSync(suitePath, suite.spec_rb);
43
+ materializeHelper(projectRoot);
11
44
  return { suitePath };
12
45
  }
46
+ // Both flows boot the same way: the check flow writes the boot kit next to
47
+ // the suite here, the suite-build flow writes it right after the precheck.
48
+ export function materializeHelper(projectRoot) {
49
+ const dir = join(projectRoot, GUARDRAILS_DIR);
50
+ mkdirSync(dir, { recursive: true });
51
+ const helperPath = join(dir, HELPER_FILE);
52
+ writeFileSync(helperPath, UNITBOB_HELPER_RB);
53
+ writeFileSync(join(dir, OPTIONS_FILE), '');
54
+ return helperPath;
55
+ }
package/dist/link.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // Zero-touch linking (spec 28): every verb starts here. A project is identified
2
- // by its folder name basename(cwd), nothing else — and resolved on the server
2
+ // by its main checkout's folder name (spec 29) and resolved on the server
3
3
  // idempotently each run. The user never supplies a repo_id; it is an internal
4
4
  // server key nobody is expected to know.
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
- import { basename, dirname, join } 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
@@ -19,7 +19,7 @@ export const DEFAULT_SERVER = 'http://localhost:3000';
19
19
  // the file may point at a real repo the user cares about.
20
20
  export async function ensureLinked(cwd = process.cwd(), server = DEFAULT_SERVER) {
21
21
  const fileId = readLocalRepoId(cwd); // only cwd's own file — no walk-up
22
- const name = basename(cwd);
22
+ const name = projectName(cwd);
23
23
  // Refuse before touching the server, so a stray run can't mint a junk repo.
24
24
  if (fileId === null)
25
25
  assertProjectRoot(cwd);
@@ -35,14 +35,80 @@ export async function ensureLinked(cwd = process.cwd(), server = DEFAULT_SERVER)
35
35
  }
36
36
  return { server, repoId: authId, projectRoot: cwd };
37
37
  }
38
+ // The linking name is the *project's* name, not the checkout's (spec 29). A
39
+ // `.git` directory means cwd is the main checkout; a `.git` file is a worktree
40
+ // pointer whose `gitdir:` leads back to the main checkout
41
+ // (`<root>/.git/worktrees/<slug>`). No subprocess — the file is parsed
42
+ // directly, and anything unexpected (no `.git`, submodules, exotic layouts)
43
+ // falls back to basename(cwd): name resolution never fails linking.
44
+ export function projectName(cwd) {
45
+ const gitPath = join(cwd, '.git');
46
+ try {
47
+ if (!statSync(gitPath).isFile())
48
+ return basename(cwd); // .git directory — main checkout
49
+ const pointer = readFileSync(gitPath, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
50
+ if (!pointer)
51
+ return basename(cwd);
52
+ const gitdir = isAbsolute(pointer[1]) ? pointer[1] : resolve(cwd, pointer[1]);
53
+ const commonGit = commonGitDir(gitdir);
54
+ if (commonGit === null)
55
+ return basename(cwd); // submodule (`.git/modules/…`) or exotic layout
56
+ if (basename(commonGit) === '.git')
57
+ return basename(dirname(commonGit));
58
+ return basename(commonGit).replace(/\.git$/, '') || basename(cwd); // bare / separate git dir
59
+ }
60
+ catch {
61
+ return basename(cwd); // no .git at all — git-less project
62
+ }
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
+ }
79
+ const PROJECT_MARKERS = ['Gemfile', 'gems.rb', 'package.json'];
38
80
  // Linking writes a file and creates a server row — only do that at a real
39
- // project root: a `.git` present, and never at $HOME or the filesystem root.
81
+ // project root: never $HOME or the filesystem root, and the folder must be
82
+ // anchored by `.git` or a recognizable project marker (a vibecoder may not use
83
+ // version control at all; the marker still keeps junk folders off the brain).
40
84
  export function assertProjectRoot(cwd) {
41
85
  const isFsRoot = dirname(cwd) === cwd;
42
- if (isFsRoot || cwd === homedir() || !existsSync(join(cwd, '.git'))) {
43
- throw new WireError(`${cwd} does not look like a project root (no .git here) — ` +
44
- "run this from your project's root folder.");
86
+ const hasGit = existsSync(join(cwd, '.git'));
87
+ const anchored = hasGit || PROJECT_MARKERS.some((marker) => existsSync(join(cwd, marker)));
88
+ if (isFsRoot || cwd === homedir() || !anchored) {
89
+ throw new WireError(`${cwd} does not look like a project root (no .git and no project files ` +
90
+ "(Gemfile/package.json) here) — run this from your project's root folder.");
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;
45
110
  }
111
+ return null;
46
112
  }
47
113
  const ARTIFACT_DIR = '.unitbob/';
48
114
  const GITIGNORE = '.gitignore';
@@ -6,14 +6,14 @@ 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/)) {
10
- return { ok: false, message: `${SUPPORTED} (no \`rspec\`/\`rspec-rails\` gem found in Gemfile.)` };
11
- }
12
- if (!existsSync(join(projectRoot, 'spec', 'rails_helper.rb'))) {
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/)) {
13
12
  return {
14
13
  ok: false,
15
- message: 'Unitbob needs spec/rails_helper.rb to run Rails guardrails, but it was not found. ' +
16
- 'Set up RSpec for this Rails project first, then try again.',
14
+ message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
15
+ 'Gemfile. Offer the user to add it and run `bundle install`; change the Gemfile ' +
16
+ 'only with their consent, then retry.',
17
17
  };
18
18
  }
19
19
  return { ok: true };
@@ -1,7 +1,7 @@
1
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, SUITE_FILE } from "../files/guardrails.js";
4
+ import { GUARDRAILS_DIR, OPTIONS_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,23 @@ 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.
12
15
  export async function runRspecSuite(projectRoot) {
13
16
  const suitePath = join(GUARDRAILS_DIR, SUITE_FILE);
14
- return invokeRspec(projectRoot, [suitePath, '--order', 'defined', '--seed', RSPEC_SEED, '--format', 'json']);
17
+ const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
18
+ return invokeRspec(projectRoot, [
19
+ suitePath,
20
+ '--options',
21
+ optionsPath,
22
+ '--order',
23
+ 'defined',
24
+ '--seed',
25
+ RSPEC_SEED,
26
+ '--format',
27
+ 'json',
28
+ ]);
15
29
  }
16
30
  // Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
17
31
  // run sets RAILS_ENV=test so guardrails execute against the Rails test
@@ -1,9 +1,11 @@
1
+ import { materializeHelper } from "../files/guardrails.js";
1
2
  import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
2
3
  import { runtimePrecheck } from "../runner/precheck.js";
3
4
  import { Wire } from "../wire.js";
4
- // Confirm the runtime is supported (Rails + RSpec + rails_helper), then fetch the
5
- // generate recipe and the per-block capability assignment and write the host's
6
- // task to `.unitbob/suite-build/request.json`. No model is called and no source is
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
7
9
  // read here — that is the host's job, framed by ai/agents/suite_builder.md. An
8
10
  // unsupported runtime stops with one actionable message and writes nothing; a
9
11
  // no-current-map error from the server surfaces (via WireError) with guidance to
@@ -19,6 +21,7 @@ export async function suitePrepare(config, _args = [], deps) {
19
21
  const check = actual.precheck(config.projectRoot);
20
22
  if (!check.ok)
21
23
  throw new Error(check.message ?? 'Unsupported runtime.');
24
+ materializeHelper(config.projectRoot);
22
25
  const [recipe, packets] = await Promise.all([actual.getRecipe('generate'), actual.getSuitePackets()]);
23
26
  const request = writeSuiteBuildRequest(config.projectRoot, {
24
27
  map_digest: packets.map_digest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {