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.
- package/dist/files/guardrails.js +17 -5
- package/dist/link.js +43 -8
- package/dist/runner/precheck.js +3 -1
- package/dist/runner/rspec.js +35 -4
- package/dist/verbs/run.js +5 -1
- package/dist/verbs/show.js +1 -1
- package/package.json +1 -1
package/dist/files/guardrails.js
CHANGED
|
@@ -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
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
72
|
-
|
|
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';
|
package/dist/runner/precheck.js
CHANGED
|
@@ -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
|
-
|
|
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 " +
|
package/dist/runner/rspec.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
package/dist/verbs/show.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|