unitbob 0.1.3 → 0.1.5

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,47 @@ 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
+ // The boot file the generated suite requires (spec 29). Connector-owned and
7
+ // versioned with it — never scaffolded into the project, never part of the
8
+ // suite digest (that covers `spec_rb` bytes only). Delegates to the project's
9
+ // own RSpec setup when one exists; boots the Rails test environment directly
10
+ // when none does.
11
+ export const UNITBOB_HELPER_RB = `# frozen_string_literal: true
12
+ # Written by the unitbob connector on every materialization — do not edit.
13
+ root = File.expand_path('../..', __dir__)
14
+ if File.exist?(File.join(root, 'spec', 'rails_helper.rb'))
15
+ # The project has its own RSpec setup — respect it (factories, cleaners…).
16
+ $LOAD_PATH.unshift File.join(root, 'spec')
17
+ require 'rails_helper'
18
+ else
19
+ # No RSpec scaffolding — boot the Rails test environment directly.
20
+ ENV['RAILS_ENV'] ||= 'test'
21
+ require File.join(root, 'config', 'environment')
22
+ abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
23
+ require 'rspec/rails'
24
+ ActiveRecord::Migration.maintain_test_schema!
25
+ RSpec.configure do |config|
26
+ config.use_transactional_fixtures = true
27
+ config.infer_spec_type_from_file_location!
28
+ end
29
+ end
30
+ `;
5
31
  export function materializeGuardrails(projectRoot, suite) {
6
32
  const dir = join(projectRoot, GUARDRAILS_DIR);
7
33
  rmSync(dir, { recursive: true, force: true });
8
34
  mkdirSync(dir, { recursive: true });
9
35
  const suitePath = join(dir, SUITE_FILE);
10
36
  writeFileSync(suitePath, suite.spec_rb);
37
+ materializeHelper(projectRoot);
11
38
  return { suitePath };
12
39
  }
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.
42
+ export function materializeHelper(projectRoot) {
43
+ const dir = join(projectRoot, GUARDRAILS_DIR);
44
+ mkdirSync(dir, { recursive: true });
45
+ const helperPath = join(dir, HELPER_FILE);
46
+ writeFileSync(helperPath, UNITBOB_HELPER_RB);
47
+ return helperPath;
48
+ }
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 } 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,13 +35,44 @@ 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 worktrees = dirname(gitdir); // <root>/.git/worktrees
54
+ const commonGit = dirname(worktrees); // <root>/.git
55
+ if (basename(worktrees) === 'worktrees' && basename(commonGit) === '.git') {
56
+ return basename(dirname(commonGit));
57
+ }
58
+ return basename(cwd); // submodule (`.git/modules/…`) or exotic layout
59
+ }
60
+ catch {
61
+ return basename(cwd); // no .git at all — git-less project
62
+ }
63
+ }
64
+ const PROJECT_MARKERS = ['Gemfile', 'gems.rb', 'package.json'];
38
65
  // 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.
66
+ // project root: never $HOME or the filesystem root, and the folder must be
67
+ // anchored by `.git` or a recognizable project marker (a vibecoder may not use
68
+ // version control at all; the marker still keeps junk folders off the brain).
40
69
  export function assertProjectRoot(cwd) {
41
70
  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.");
71
+ const anchored = existsSync(join(cwd, '.git')) ||
72
+ PROJECT_MARKERS.some((marker) => existsSync(join(cwd, marker)));
73
+ if (isFsRoot || cwd === homedir() || !anchored) {
74
+ throw new WireError(`${cwd} does not look like a project root (no .git and no project files ` +
75
+ "(Gemfile/package.json) here) — run this from your project's root folder.");
45
76
  }
46
77
  }
47
78
  const ARTIFACT_DIR = '.unitbob/';
@@ -7,13 +7,11 @@ export function runtimePrecheck(projectRoot) {
7
7
  return { ok: false, message: `${SUPPORTED} (no \`rails\` gem found in Gemfile.)` };
8
8
  }
9
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'))) {
13
10
  return {
14
11
  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.',
12
+ message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
13
+ 'Gemfile. Offer the user to add it and run `bundle install`; change the Gemfile ' +
14
+ 'only with their consent, then retry.',
17
15
  };
18
16
  }
19
17
  return { ok: true };
@@ -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.3",
3
+ "version": "0.1.5",
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": {