unitbob 0.1.0
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/cli.js +88 -0
- package/dist/config.js +53 -0
- package/dist/files/fix.js +20 -0
- package/dist/files/guardrails.js +12 -0
- package/dist/files/mapBuild.js +77 -0
- package/dist/files/suiteBuild.js +85 -0
- package/dist/proc.js +79 -0
- package/dist/runner/precheck.js +28 -0
- package/dist/runner/rspec.js +38 -0
- package/dist/verbs/fixPrepare.js +21 -0
- package/dist/verbs/init.js +35 -0
- package/dist/verbs/mapPrepare.js +28 -0
- package/dist/verbs/putMapBuild.js +16 -0
- package/dist/verbs/putSuiteBuild.js +25 -0
- package/dist/verbs/recipe.js +9 -0
- package/dist/verbs/run.js +95 -0
- package/dist/verbs/show.js +6 -0
- package/dist/verbs/suitePrepare.js +31 -0
- package/dist/wire.js +120 -0
- package/package.json +27 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The single entry point. Parse `unitbob <verb> [args]`, dispatch to a hands-verb,
|
|
3
|
+
// and map any thrown error to a non-zero exit with an actionable message — never
|
|
4
|
+
// a raw stack trace. This is the only place that decides process exit codes.
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { recipe } from "./verbs/recipe.js";
|
|
7
|
+
import { show } from "./verbs/show.js";
|
|
8
|
+
import { run } from "./verbs/run.js";
|
|
9
|
+
import { init } from "./verbs/init.js";
|
|
10
|
+
import { mapPrepare } from "./verbs/mapPrepare.js";
|
|
11
|
+
import { putMapBuild } from "./verbs/putMapBuild.js";
|
|
12
|
+
import { suitePrepare } from "./verbs/suitePrepare.js";
|
|
13
|
+
import { putSuiteBuild } from "./verbs/putSuiteBuild.js";
|
|
14
|
+
import { fixPrepare } from "./verbs/fixPrepare.js";
|
|
15
|
+
const USAGE = `unitbob — thin local hands for the Unitbob server.
|
|
16
|
+
|
|
17
|
+
Usage: unitbob <verb> [args]
|
|
18
|
+
|
|
19
|
+
Verbs:
|
|
20
|
+
init Write a .unitbob.json template and git-ignore it.
|
|
21
|
+
recipe <name> Fetch and print a recipe from the server.
|
|
22
|
+
show Print the link to this project's map.
|
|
23
|
+
map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
|
|
24
|
+
put-map-build Internal: upload the host-built map and graph.
|
|
25
|
+
suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
|
|
26
|
+
put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata).
|
|
27
|
+
fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
|
|
28
|
+
check Run the guardrail suite locally and report.
|
|
29
|
+
run Alias for check.
|
|
30
|
+
|
|
31
|
+
Pipeline: map and suite are built on your machine. \`*-prepare\` writes a request
|
|
32
|
+
packet (with the recipe and paths); you build the artifact at the packet's
|
|
33
|
+
output_path from your local source; \`put-*\` uploads only the structured result.
|
|
34
|
+
\`check\` runs the guardrails locally and reports results to the server.
|
|
35
|
+
Graph extraction is keyless: the connector needs no LLM API key. Inference is the
|
|
36
|
+
host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify skill).
|
|
37
|
+
|
|
38
|
+
Config: .unitbob.json at your project root — { "server": "...", "repo_id": <number> }.`;
|
|
39
|
+
async function main(argv) {
|
|
40
|
+
const [verb, ...args] = argv;
|
|
41
|
+
if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
|
|
42
|
+
process.stdout.write(`${USAGE}\n`);
|
|
43
|
+
return verb ? 0 : 1;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
switch (verb) {
|
|
47
|
+
case 'init':
|
|
48
|
+
await init(args);
|
|
49
|
+
return 0;
|
|
50
|
+
case 'recipe':
|
|
51
|
+
await recipe(loadConfig(), args);
|
|
52
|
+
return 0;
|
|
53
|
+
case 'show':
|
|
54
|
+
await show(loadConfig());
|
|
55
|
+
return 0;
|
|
56
|
+
case 'map-prepare':
|
|
57
|
+
await mapPrepare(loadConfig(), args);
|
|
58
|
+
return 0;
|
|
59
|
+
case 'put-map-build':
|
|
60
|
+
await putMapBuild(loadConfig(), args);
|
|
61
|
+
return 0;
|
|
62
|
+
case 'suite-prepare':
|
|
63
|
+
await suitePrepare(loadConfig(), args);
|
|
64
|
+
return 0;
|
|
65
|
+
case 'put-suite-build':
|
|
66
|
+
await putSuiteBuild(loadConfig(), args);
|
|
67
|
+
return 0;
|
|
68
|
+
case 'fix-prepare':
|
|
69
|
+
await fixPrepare(loadConfig(), args);
|
|
70
|
+
return 0;
|
|
71
|
+
case 'run':
|
|
72
|
+
case 'check':
|
|
73
|
+
await run(loadConfig(), args);
|
|
74
|
+
return 0;
|
|
75
|
+
default:
|
|
76
|
+
process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
process.stderr.write(`${err.message}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
86
|
+
process.stderr.write(`${err.message}\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Per-project config for the connector. Lives in `.unitbob.json` at the project
|
|
2
|
+
// root: { "server": "https://…", "repo_id": 3 }. No secret — auth is deferred
|
|
3
|
+
// (spec 15, decision #3). Every verb reads it. A missing or malformed file must
|
|
4
|
+
// fail with an actionable setup message, never a raw stack trace.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
const CONFIG_FILE = '.unitbob.json';
|
|
8
|
+
const SETUP_HINT = 'run `unitbob init`, or create a `.unitbob.json` at your project root with ' +
|
|
9
|
+
'{ "server": "https://your-unitbob-host", "repo_id": <number> }';
|
|
10
|
+
// Walk up from `startDir` to the filesystem root looking for `.unitbob.json`.
|
|
11
|
+
export function findConfigPath(startDir = process.cwd()) {
|
|
12
|
+
let dir = startDir;
|
|
13
|
+
for (;;) {
|
|
14
|
+
const candidate = join(dir, CONFIG_FILE);
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir)
|
|
19
|
+
return null;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(startDir = process.cwd()) {
|
|
24
|
+
const path = findConfigPath(startDir);
|
|
25
|
+
if (!path) {
|
|
26
|
+
throw new Error(`No ${CONFIG_FILE} found — ${SETUP_HINT}`);
|
|
27
|
+
}
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = readFileSync(path, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new Error(`Could not read ${path} (${err.message}) — ${SETUP_HINT}`);
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`${path} is not valid JSON — ${SETUP_HINT}`);
|
|
41
|
+
}
|
|
42
|
+
const obj = (parsed && typeof parsed === 'object' ? parsed : {});
|
|
43
|
+
const server = obj.server;
|
|
44
|
+
const repoId = obj.repo_id;
|
|
45
|
+
if (typeof server !== 'string' || server.trim() === '') {
|
|
46
|
+
throw new Error(`${path} is missing a "server" string — ${SETUP_HINT}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof repoId !== 'number' || !Number.isInteger(repoId)) {
|
|
49
|
+
throw new Error(`${path} is missing an integer "repo_id" — ${SETUP_HINT}`);
|
|
50
|
+
}
|
|
51
|
+
// Trim a trailing slash so callers can join paths without doubling up.
|
|
52
|
+
return { server: server.trim().replace(/\/+$/, ''), repoId, projectRoot: dirname(path) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function requestPath(projectRoot) {
|
|
4
|
+
return join(projectRoot, '.unitbob', 'fix', 'request.json');
|
|
5
|
+
}
|
|
6
|
+
export function writeFixRequest(projectRoot, interfaceId, packet) {
|
|
7
|
+
const request = {
|
|
8
|
+
project_root: projectRoot,
|
|
9
|
+
interface_id: interfaceId,
|
|
10
|
+
headline: packet.headline,
|
|
11
|
+
failure_message: packet.failure_message,
|
|
12
|
+
anchor: packet.anchor,
|
|
13
|
+
prompt: packet.prompt,
|
|
14
|
+
};
|
|
15
|
+
const path = requestPath(projectRoot);
|
|
16
|
+
if (!existsSync(dirname(path)))
|
|
17
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
18
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
19
|
+
return request;
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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';
|
|
5
|
+
export function materializeGuardrails(projectRoot, suite) {
|
|
6
|
+
const dir = join(projectRoot, GUARDRAILS_DIR);
|
|
7
|
+
rmSync(dir, { recursive: true, force: true });
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
const suitePath = join(dir, SUITE_FILE);
|
|
10
|
+
writeFileSync(suitePath, suite.spec_rb);
|
|
11
|
+
return { suitePath };
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function graphPath(projectRoot) {
|
|
4
|
+
// The one canonical local graph, shared with Graphify and any host-LLM
|
|
5
|
+
// enrichment. The connector keeps no second `.unitbob` copy.
|
|
6
|
+
return join(projectRoot, 'graphify-out', 'graph.json');
|
|
7
|
+
}
|
|
8
|
+
export function requestPath(projectRoot) {
|
|
9
|
+
return join(projectRoot, '.unitbob', 'map-build', 'request.json');
|
|
10
|
+
}
|
|
11
|
+
export function outputPath(projectRoot) {
|
|
12
|
+
return join(projectRoot, '.unitbob', 'map-build', 'map_document.json');
|
|
13
|
+
}
|
|
14
|
+
export function readFreshGraph(projectRoot) {
|
|
15
|
+
const path = graphPath(projectRoot);
|
|
16
|
+
if (!existsSync(path)) {
|
|
17
|
+
throw new Error(`graphify update did not write ${path}`);
|
|
18
|
+
}
|
|
19
|
+
const rawGraphJson = readFileSync(path, 'utf8');
|
|
20
|
+
parseJson(rawGraphJson, path);
|
|
21
|
+
return rawGraphJson;
|
|
22
|
+
}
|
|
23
|
+
export function writeMapBuildRequest(projectRoot, recipes) {
|
|
24
|
+
const packet = {
|
|
25
|
+
project_root: projectRoot,
|
|
26
|
+
graph_path: graphPath(projectRoot),
|
|
27
|
+
output_path: outputPath(projectRoot),
|
|
28
|
+
recipes,
|
|
29
|
+
};
|
|
30
|
+
const path = requestPath(projectRoot);
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
writeFileSync(path, `${JSON.stringify(packet, null, 2)}\n`);
|
|
33
|
+
return packet;
|
|
34
|
+
}
|
|
35
|
+
export function readMapBuildRequest(projectRoot) {
|
|
36
|
+
const path = requestPath(projectRoot);
|
|
37
|
+
if (!existsSync(path)) {
|
|
38
|
+
throw new Error(`${path} not found — run \`npx unitbob map-prepare\` first.`);
|
|
39
|
+
}
|
|
40
|
+
const packet = parseJson(readFileSync(path, 'utf8'), path);
|
|
41
|
+
if (!isMapBuildRequest(packet)) {
|
|
42
|
+
throw new Error(`${path} is malformed: expected project_root, graph_path, output_path, and recipes.`);
|
|
43
|
+
}
|
|
44
|
+
return packet;
|
|
45
|
+
}
|
|
46
|
+
export function readHostMapOutput(path) {
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
throw new Error(`${path} not found — the host map builder did not write a map document.`);
|
|
49
|
+
}
|
|
50
|
+
return parseJson(readFileSync(path, 'utf8'), path);
|
|
51
|
+
}
|
|
52
|
+
function parseJson(raw, path) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isMapBuildRequest(value) {
|
|
61
|
+
if (!value || typeof value !== 'object')
|
|
62
|
+
return false;
|
|
63
|
+
const packet = value;
|
|
64
|
+
const recipes = packet.recipes;
|
|
65
|
+
return (typeof packet.project_root === 'string' &&
|
|
66
|
+
typeof packet.graph_path === 'string' &&
|
|
67
|
+
typeof packet.output_path === 'string' &&
|
|
68
|
+
!!recipes &&
|
|
69
|
+
isRecipe(recipes.decompose) &&
|
|
70
|
+
isRecipe(recipes.relate));
|
|
71
|
+
}
|
|
72
|
+
function isRecipe(value) {
|
|
73
|
+
if (!value || typeof value !== 'object')
|
|
74
|
+
return false;
|
|
75
|
+
const recipe = value;
|
|
76
|
+
return typeof recipe.name === 'string' && typeof recipe.version === 'string' && typeof recipe.text === 'string';
|
|
77
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
export function requestPath(projectRoot) {
|
|
4
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
5
|
+
}
|
|
6
|
+
export function outputPath(projectRoot) {
|
|
7
|
+
return join(projectRoot, '.unitbob', 'suite-build', 'suite_output.json');
|
|
8
|
+
}
|
|
9
|
+
export function writeSuiteBuildRequest(projectRoot, task) {
|
|
10
|
+
const request = {
|
|
11
|
+
project_root: projectRoot,
|
|
12
|
+
map_digest: task.map_digest,
|
|
13
|
+
output_path: outputPath(projectRoot),
|
|
14
|
+
recipe: task.recipe,
|
|
15
|
+
blocks: task.blocks,
|
|
16
|
+
};
|
|
17
|
+
const path = requestPath(projectRoot);
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
|
|
20
|
+
return request;
|
|
21
|
+
}
|
|
22
|
+
export function readSuiteBuildRequest(projectRoot) {
|
|
23
|
+
const path = requestPath(projectRoot);
|
|
24
|
+
if (!existsSync(path)) {
|
|
25
|
+
throw new Error(`${path} not found — run \`npx unitbob suite-prepare\` first.`);
|
|
26
|
+
}
|
|
27
|
+
const request = parseJson(readFileSync(path, 'utf8'), path);
|
|
28
|
+
if (!isSuiteBuildRequest(request)) {
|
|
29
|
+
throw new Error(`${path} is malformed: expected project_root, map_digest, output_path, recipe, and blocks.`);
|
|
30
|
+
}
|
|
31
|
+
return request;
|
|
32
|
+
}
|
|
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).
|
|
37
|
+
export function readHostSuiteOutput(path, projectRoot) {
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
throw new Error(`${path} not found — the host suite builder did not write its output.`);
|
|
40
|
+
}
|
|
41
|
+
const parsed = parseJson(readFileSync(path, 'utf8'), path);
|
|
42
|
+
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.`);
|
|
44
|
+
}
|
|
45
|
+
if (!('test_metadata' in parsed)) {
|
|
46
|
+
throw new Error(`${path} is malformed: missing test_metadata.`);
|
|
47
|
+
}
|
|
48
|
+
const specRb = resolveSpecRb(parsed, projectRoot, path);
|
|
49
|
+
return { spec_rb: specRb, test_metadata: parsed.test_metadata };
|
|
50
|
+
}
|
|
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');
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`${path} is malformed: expected a non-empty spec_rb or spec_rb_path.`);
|
|
61
|
+
}
|
|
62
|
+
function parseJson(raw, path) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new Error(`${path} is not valid JSON (${err.message})`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function isSuiteBuildRequest(value) {
|
|
71
|
+
if (!value || typeof value !== 'object')
|
|
72
|
+
return false;
|
|
73
|
+
const request = value;
|
|
74
|
+
return (typeof request.project_root === 'string' &&
|
|
75
|
+
typeof request.map_digest === 'string' &&
|
|
76
|
+
typeof request.output_path === 'string' &&
|
|
77
|
+
isRecipe(request.recipe) &&
|
|
78
|
+
Array.isArray(request.blocks));
|
|
79
|
+
}
|
|
80
|
+
function isRecipe(value) {
|
|
81
|
+
if (!value || typeof value !== 'object')
|
|
82
|
+
return false;
|
|
83
|
+
const recipe = value;
|
|
84
|
+
return typeof recipe.name === 'string' && typeof recipe.version === 'string' && typeof recipe.text === 'string';
|
|
85
|
+
}
|
package/dist/proc.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Spawn helper for the local tools the connector drives (graphify, rspec). It
|
|
2
|
+
// captures stdout/stderr/exit code and hands them back untouched — shaping or
|
|
3
|
+
// interpreting that output is the caller's (and ultimately Rails') job.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const GRAPHIFY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
+
export function runProcess(command, args = [], options = {}) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env });
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let stderr = '';
|
|
13
|
+
let finished = false;
|
|
14
|
+
let timedOut = false;
|
|
15
|
+
let forceKillTimer = null;
|
|
16
|
+
const finish = (result) => {
|
|
17
|
+
if (finished)
|
|
18
|
+
return;
|
|
19
|
+
finished = true;
|
|
20
|
+
if (timer)
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
if (forceKillTimer)
|
|
23
|
+
clearTimeout(forceKillTimer);
|
|
24
|
+
resolve(result);
|
|
25
|
+
};
|
|
26
|
+
const timer = options.timeoutMs
|
|
27
|
+
? setTimeout(() => {
|
|
28
|
+
timedOut = true;
|
|
29
|
+
stderr += `${stderr.endsWith('\n') || stderr.length === 0 ? '' : '\n'}${command} timed out after ${options.timeoutMs}ms`;
|
|
30
|
+
child.kill('SIGTERM');
|
|
31
|
+
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
32
|
+
}, options.timeoutMs)
|
|
33
|
+
: null;
|
|
34
|
+
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
|
35
|
+
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
|
36
|
+
child.on('error', (err) => {
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (forceKillTimer)
|
|
40
|
+
clearTimeout(forceKillTimer);
|
|
41
|
+
reject(err);
|
|
42
|
+
});
|
|
43
|
+
child.on('close', (code) => finish({ stdout, stderr, code: timedOut ? null : code }));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export async function requireGraphify() {
|
|
47
|
+
try {
|
|
48
|
+
const result = await runProcess('graphify', ['--help']);
|
|
49
|
+
if (result.code === 0)
|
|
50
|
+
return;
|
|
51
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || `graphify --help exited ${result.code}`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error(`graphify is required but was not found or did not run. Install graphify and make sure ` +
|
|
55
|
+
`it is available in PATH (${err.message}).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function ensureUnitbobIgnored(projectRoot) {
|
|
59
|
+
ensureLine(join(projectRoot, '.gitignore'), '.unitbob/');
|
|
60
|
+
ensureLine(join(projectRoot, '.gitignore'), 'graphify-out/');
|
|
61
|
+
ensureLine(join(projectRoot, '.graphifyignore'), '.unitbob/');
|
|
62
|
+
}
|
|
63
|
+
function ensureLine(path, line) {
|
|
64
|
+
let current = '';
|
|
65
|
+
if (existsSync(path)) {
|
|
66
|
+
current = readFileSync(path, 'utf8');
|
|
67
|
+
if (current.split('\n').some((existing) => existing.trim() === line))
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
71
|
+
writeFileSync(path, `${current}${prefix}${line}\n`);
|
|
72
|
+
}
|
|
73
|
+
export async function runGraphifyExtractKeyless(projectRoot) {
|
|
74
|
+
// Deterministic AST-only graph; no LLM, no API key. `update --force` re-extracts
|
|
75
|
+
// the code and refreshes <root>/graphify-out/graph.json in place, replacing
|
|
76
|
+
// removed nodes. Semantic enrichment, if wanted, is host-LLM work (the
|
|
77
|
+
// /graphify skill on the client), never a keyed LLM here.
|
|
78
|
+
return await runProcess('graphify', ['update', projectRoot, '--force'], { cwd: projectRoot, timeoutMs: GRAPHIFY_TIMEOUT_MS });
|
|
79
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
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) {
|
|
6
|
+
if (!hasGemfileWith(projectRoot, /\brails\b/)) {
|
|
7
|
+
return { ok: false, message: `${SUPPORTED} (no \`rails\` gem found in Gemfile.)` };
|
|
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'))) {
|
|
13
|
+
return {
|
|
14
|
+
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.',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { ok: true };
|
|
20
|
+
}
|
|
21
|
+
function hasGemfileWith(projectRoot, pattern) {
|
|
22
|
+
for (const name of ['Gemfile', 'gems.rb']) {
|
|
23
|
+
const path = join(projectRoot, name);
|
|
24
|
+
if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
import { GUARDRAILS_DIR, SUITE_FILE } from "../files/guardrails.js";
|
|
5
|
+
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
|
+
// Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
|
|
7
|
+
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
8
|
+
// the project's random ordering.
|
|
9
|
+
export const RSPEC_SEED = '1';
|
|
10
|
+
// Run the materialised Unitbob guardrail suite (spec 26). Only this file runs —
|
|
11
|
+
// never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
|
|
12
|
+
export async function runRspecSuite(projectRoot) {
|
|
13
|
+
const suitePath = join(GUARDRAILS_DIR, SUITE_FILE);
|
|
14
|
+
return invokeRspec(projectRoot, [suitePath, '--order', 'defined', '--seed', RSPEC_SEED, '--format', 'json']);
|
|
15
|
+
}
|
|
16
|
+
// Prefer the project's own `bin/rspec`; fall back to `bundle exec rspec`. Every
|
|
17
|
+
// run sets RAILS_ENV=test so guardrails execute against the Rails test
|
|
18
|
+
// environment the project's `rails_helper` configures.
|
|
19
|
+
async function invokeRspec(projectRoot, rspecArgs) {
|
|
20
|
+
const localRspec = join(projectRoot, 'bin', 'rspec');
|
|
21
|
+
const hasLocalRspec = executable(localRspec);
|
|
22
|
+
const command = hasLocalRspec ? localRspec : 'bundle';
|
|
23
|
+
const args = hasLocalRspec ? rspecArgs : ['exec', 'rspec', ...rspecArgs];
|
|
24
|
+
const result = await runProcess(command, args, {
|
|
25
|
+
cwd: projectRoot,
|
|
26
|
+
timeoutMs: RSPEC_TIMEOUT_MS,
|
|
27
|
+
env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
28
|
+
});
|
|
29
|
+
return { ...result, command, args };
|
|
30
|
+
}
|
|
31
|
+
function executable(path) {
|
|
32
|
+
try {
|
|
33
|
+
return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeFixRequest } from "../files/fix.js";
|
|
2
|
+
import { Wire } from "../wire.js";
|
|
3
|
+
// Fetch the per-capability repair packet and write the host's task to
|
|
4
|
+
// `.unitbob/fix/request.json`. No recipe fetch, no upload — the host reads its own
|
|
5
|
+
// source and the local spec file, then either fixes code (next `/unitbob check`
|
|
6
|
+
// shows the result) or accepts the change and republishes the suite (spec 26). A
|
|
7
|
+
// 422 from the server (non-failed / stale / no suite) surfaces via WireError;
|
|
8
|
+
// nothing is written.
|
|
9
|
+
export async function fixPrepare(config, args = [], deps) {
|
|
10
|
+
const interfaceId = (args[0] ?? '').trim();
|
|
11
|
+
if (!interfaceId)
|
|
12
|
+
throw new Error('Usage: unitbob fix-prepare <interface_id>');
|
|
13
|
+
const d = {
|
|
14
|
+
getFixPacket: (id) => new Wire(config).getFixPacket(id),
|
|
15
|
+
stdout: process.stdout,
|
|
16
|
+
...deps,
|
|
17
|
+
};
|
|
18
|
+
const packet = await d.getFixPacket(interfaceId);
|
|
19
|
+
writeFixRequest(config.projectRoot, interfaceId, packet);
|
|
20
|
+
d.stdout.write(`${packet.message}\n`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// `unitbob init` — write a `.unitbob.json` template at the current directory and
|
|
2
|
+
// make sure it is git-ignored. This is the one verb that does not require an
|
|
3
|
+
// existing config, since its job is to create one.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
const CONFIG_FILE = '.unitbob.json';
|
|
7
|
+
const ARTIFACT_DIR = '.unitbob/';
|
|
8
|
+
const GITIGNORE = '.gitignore';
|
|
9
|
+
export async function init(_args) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const configPath = join(cwd, CONFIG_FILE);
|
|
12
|
+
if (existsSync(configPath)) {
|
|
13
|
+
process.stdout.write(`${CONFIG_FILE} already exists at ${configPath} — leaving it untouched.\n`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const template = { server: 'http://localhost:3000', repo_id: 0 };
|
|
17
|
+
writeFileSync(configPath, `${JSON.stringify(template, null, 2)}\n`);
|
|
18
|
+
process.stdout.write(`Wrote ${configPath}. Edit "server" and "repo_id" to point at your Unitbob host.\n`);
|
|
19
|
+
}
|
|
20
|
+
ensureGitignored(cwd);
|
|
21
|
+
}
|
|
22
|
+
function ensureGitignored(cwd) {
|
|
23
|
+
const gitignorePath = join(cwd, GITIGNORE);
|
|
24
|
+
let current = '';
|
|
25
|
+
if (existsSync(gitignorePath)) {
|
|
26
|
+
current = readFileSync(gitignorePath, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
const lines = current.split('\n').map((line) => line.trim());
|
|
29
|
+
const additions = [CONFIG_FILE, ARTIFACT_DIR].filter((line) => !lines.includes(line));
|
|
30
|
+
if (additions.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
33
|
+
writeFileSync(gitignorePath, `${current}${prefix}${additions.join('\n')}\n`);
|
|
34
|
+
process.stdout.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ensureUnitbobIgnored, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
|
|
2
|
+
import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
|
|
3
|
+
import { Wire } from "../wire.js";
|
|
4
|
+
export async function mapPrepare(config, _args = [], deps) {
|
|
5
|
+
const wire = new Wire(config);
|
|
6
|
+
const actual = {
|
|
7
|
+
requireGraphify,
|
|
8
|
+
ensureUnitbobIgnored,
|
|
9
|
+
runGraphifyExtractKeyless,
|
|
10
|
+
getRecipe: (name) => wire.getRecipe(name),
|
|
11
|
+
...deps,
|
|
12
|
+
};
|
|
13
|
+
actual.ensureUnitbobIgnored(config.projectRoot);
|
|
14
|
+
await actual.requireGraphify();
|
|
15
|
+
// Keyless: refresh the one canonical graph in place. No inference secret and no
|
|
16
|
+
// graph flags — semantic enrichment is host-LLM work, not a keyed LLM here.
|
|
17
|
+
const result = await actual.runGraphifyExtractKeyless(config.projectRoot);
|
|
18
|
+
if (result.code !== 0) {
|
|
19
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `graphify exited ${result.code}`;
|
|
20
|
+
throw new Error(`graphify update failed: ${detail}`);
|
|
21
|
+
}
|
|
22
|
+
readFreshGraph(config.projectRoot);
|
|
23
|
+
const [decompose, relate] = await Promise.all([actual.getRecipe('decompose'), actual.getRecipe('relate')]);
|
|
24
|
+
const packet = writeMapBuildRequest(config.projectRoot, { decompose, relate });
|
|
25
|
+
process.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
|
|
26
|
+
process.stdout.write(`Next: build the Map Document at ${packet.output_path} following recipes.decompose and ` +
|
|
27
|
+
'recipes.relate inside that request, then run `unitbob put-map-build`.\n');
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readHostMapOutput, readMapBuildRequest } from "../files/mapBuild.js";
|
|
3
|
+
import { Wire } from "../wire.js";
|
|
4
|
+
export async function putMapBuild(config, _args = [], deps) {
|
|
5
|
+
const packet = readMapBuildRequest(config.projectRoot);
|
|
6
|
+
const graph = JSON.parse(readFileSync(packet.graph_path, 'utf8'));
|
|
7
|
+
const mapDocument = readHostMapOutput(packet.output_path);
|
|
8
|
+
const actual = {
|
|
9
|
+
putMapBuild: (payload) => new Wire(config).putMapBuild(payload),
|
|
10
|
+
...deps,
|
|
11
|
+
};
|
|
12
|
+
const result = await actual.putMapBuild({ graph, map_document: mapDocument });
|
|
13
|
+
process.stdout.write(`Map uploaded (${result.map_digest}, graph ${result.graph_digest}) ` +
|
|
14
|
+
`${result.reused ? 'reused' : 'created'} version ${result.map_version_id}.\n` +
|
|
15
|
+
`${result.map_url}\n`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readHostSuiteOutput, readSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
2
|
+
import { Wire } from "../wire.js";
|
|
3
|
+
// 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.
|
|
8
|
+
export async function putSuiteBuild(config, _args = [], deps) {
|
|
9
|
+
const request = readSuiteBuildRequest(config.projectRoot);
|
|
10
|
+
const output = readHostSuiteOutput(request.output_path, config.projectRoot);
|
|
11
|
+
const actual = {
|
|
12
|
+
putSuiteBuild: (payload) => new Wire(config).putSuiteBuild(payload),
|
|
13
|
+
...deps,
|
|
14
|
+
};
|
|
15
|
+
const result = await actual.putSuiteBuild({
|
|
16
|
+
map_digest: request.map_digest,
|
|
17
|
+
spec_rb: output.spec_rb,
|
|
18
|
+
test_metadata: output.test_metadata,
|
|
19
|
+
});
|
|
20
|
+
const tallies = Object.entries(result.counts)
|
|
21
|
+
.map(([name, value]) => `${value} ${name}`)
|
|
22
|
+
.join(', ');
|
|
23
|
+
process.stdout.write(`Suite uploaded (${result.suite_digest}) as version ${result.suite_version_id}` +
|
|
24
|
+
`${tallies ? ` — ${tallies}` : ''}.\n${result.map_url}\n`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Wire } from "../wire.js";
|
|
2
|
+
export async function recipe(config, args) {
|
|
3
|
+
const name = args[0];
|
|
4
|
+
if (!name) {
|
|
5
|
+
throw new Error('usage: unitbob recipe <name> (e.g. decompose, relate, generate)');
|
|
6
|
+
}
|
|
7
|
+
const { text } = await new Wire(config).getRecipe(name);
|
|
8
|
+
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
|
+
import { runtimePrecheck } from "../runner/precheck.js";
|
|
3
|
+
import { runRspecSuite } from "../runner/rspec.js";
|
|
4
|
+
import { Wire } from "../wire.js";
|
|
5
|
+
// Bound failure text for transport/storage hygiene (spec 26). This is a size
|
|
6
|
+
// limit only, not a privacy filter — application values in failure messages are
|
|
7
|
+
// accepted, not scrubbed.
|
|
8
|
+
const MAX_FAILURE_CHARS = 4000;
|
|
9
|
+
export async function run(config, _args, deps) {
|
|
10
|
+
const wire = new Wire(config);
|
|
11
|
+
const d = {
|
|
12
|
+
getSuite: () => wire.getSuite(),
|
|
13
|
+
postRun: (payload) => wire.postRun(payload),
|
|
14
|
+
materializeGuardrails,
|
|
15
|
+
runRspecSuite,
|
|
16
|
+
precheck: runtimePrecheck,
|
|
17
|
+
stdout: process.stdout,
|
|
18
|
+
...deps,
|
|
19
|
+
};
|
|
20
|
+
const check = d.precheck(config.projectRoot);
|
|
21
|
+
if (!check.ok)
|
|
22
|
+
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
23
|
+
const suite = await d.getSuite();
|
|
24
|
+
if (suite === null) {
|
|
25
|
+
d.stdout.write('No Unitbob suite exists yet. Generate the test suite first, then run /unitbob check again.\n');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
d.materializeGuardrails(config.projectRoot, suite);
|
|
29
|
+
const result = await d.runRspecSuite(config.projectRoot).catch((err) => ({
|
|
30
|
+
stdout: '',
|
|
31
|
+
stderr: err.message,
|
|
32
|
+
code: null,
|
|
33
|
+
command: 'rspec',
|
|
34
|
+
args: [],
|
|
35
|
+
}));
|
|
36
|
+
const payload = rawRunPayload(suite.suite_digest, result);
|
|
37
|
+
const summary = await d.postRun(payload);
|
|
38
|
+
d.stdout.write(`${summary.summary}\n`);
|
|
39
|
+
if (summary.map_url)
|
|
40
|
+
d.stdout.write(`${summary.map_url}\n`);
|
|
41
|
+
}
|
|
42
|
+
function rawRunPayload(suiteDigest, result) {
|
|
43
|
+
const parsed = parseJsonObject(result.stdout);
|
|
44
|
+
if (parsed)
|
|
45
|
+
return { suite_digest: suiteDigest, rspec_json: boundFailures(parsed) };
|
|
46
|
+
return {
|
|
47
|
+
suite_digest: suiteDigest,
|
|
48
|
+
suite_error: suiteErrorMessage(result),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Truncate long per-example failure message/backtrace before transport. Other
|
|
52
|
+
// fields pass through untouched; Rails owns the capability join and status mapping.
|
|
53
|
+
function boundFailures(rspecJson) {
|
|
54
|
+
const examples = rspecJson.examples;
|
|
55
|
+
if (!Array.isArray(examples))
|
|
56
|
+
return rspecJson;
|
|
57
|
+
const bounded = examples.map((example) => {
|
|
58
|
+
if (!example || typeof example !== 'object')
|
|
59
|
+
return example;
|
|
60
|
+
const ex = example;
|
|
61
|
+
const exception = ex.exception;
|
|
62
|
+
if (!exception || typeof exception !== 'object')
|
|
63
|
+
return ex;
|
|
64
|
+
const exc = exception;
|
|
65
|
+
const next = { ...exc };
|
|
66
|
+
if (typeof exc.message === 'string')
|
|
67
|
+
next.message = truncate(exc.message);
|
|
68
|
+
if (Array.isArray(exc.backtrace))
|
|
69
|
+
next.backtrace = exc.backtrace.slice(0, 20);
|
|
70
|
+
return { ...ex, exception: next };
|
|
71
|
+
});
|
|
72
|
+
return { ...rspecJson, examples: bounded };
|
|
73
|
+
}
|
|
74
|
+
function truncate(text) {
|
|
75
|
+
return text.length > MAX_FAILURE_CHARS ? `${text.slice(0, MAX_FAILURE_CHARS)}… (truncated)` : text;
|
|
76
|
+
}
|
|
77
|
+
function parseJsonObject(text) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(text);
|
|
80
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function suiteErrorMessage(result) {
|
|
87
|
+
const bits = [];
|
|
88
|
+
if (result.code !== 0 && result.code !== null)
|
|
89
|
+
bits.push(`exit ${result.code}`);
|
|
90
|
+
if (result.stderr.trim())
|
|
91
|
+
bits.push(`stderr: ${truncate(result.stderr.trim())}`);
|
|
92
|
+
if (result.stdout.trim())
|
|
93
|
+
bits.push(`stdout: ${result.stdout.trim().slice(0, 1000)}`);
|
|
94
|
+
return bits.length > 0 ? bits.join(' | ') : 'RSpec output was not parseable JSON';
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { writeSuiteBuildRequest } from "../files/suiteBuild.js";
|
|
2
|
+
import { runtimePrecheck } from "../runner/precheck.js";
|
|
3
|
+
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
|
|
7
|
+
// read here — that is the host's job, framed by ai/agents/suite_builder.md. An
|
|
8
|
+
// unsupported runtime stops with one actionable message and writes nothing; a
|
|
9
|
+
// no-current-map error from the server surfaces (via WireError) with guidance to
|
|
10
|
+
// run `/unitbob map` first.
|
|
11
|
+
export async function suitePrepare(config, _args = [], deps) {
|
|
12
|
+
const wire = new Wire(config);
|
|
13
|
+
const actual = {
|
|
14
|
+
getRecipe: (name) => wire.getRecipe(name),
|
|
15
|
+
getSuitePackets: () => wire.getSuitePackets(),
|
|
16
|
+
precheck: runtimePrecheck,
|
|
17
|
+
...deps,
|
|
18
|
+
};
|
|
19
|
+
const check = actual.precheck(config.projectRoot);
|
|
20
|
+
if (!check.ok)
|
|
21
|
+
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
22
|
+
const [recipe, packets] = await Promise.all([actual.getRecipe('generate'), actual.getSuitePackets()]);
|
|
23
|
+
const request = writeSuiteBuildRequest(config.projectRoot, {
|
|
24
|
+
map_digest: packets.map_digest,
|
|
25
|
+
recipe,
|
|
26
|
+
blocks: packets.blocks,
|
|
27
|
+
});
|
|
28
|
+
process.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
|
|
29
|
+
process.stdout.write(`Next: write the complete guardrail spec and ${request.output_path} following \`recipe\` and the ` +
|
|
30
|
+
'per-block capability `blocks` inside that request, run it locally to green, then run `unitbob put-suite-build`.\n');
|
|
31
|
+
}
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Raised when the server cannot be reached or answers with an error status.
|
|
2
|
+
// Verbs surface its message and exit non-zero; they never fabricate a result.
|
|
3
|
+
export class WireError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export class Wire {
|
|
6
|
+
config;
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
// PUT /repos/:id/map_build — upload the fresh graph and host-built map as one
|
|
11
|
+
// atomic blob. Rails validates, versions, and computes all digests.
|
|
12
|
+
async putMapBuild(payload) {
|
|
13
|
+
const res = await this.send('PUT', this.repoPath('map_build'), payload);
|
|
14
|
+
await this.ensureOk(res, `PUT ${this.repoPath('map_build')}`);
|
|
15
|
+
return (await res.json());
|
|
16
|
+
}
|
|
17
|
+
// GET /repos/:id/suite_packets — the host's assignment (the capabilities to
|
|
18
|
+
// guard). Relayed opaque; 409 (no current map) surfaces as a WireError carrying
|
|
19
|
+
// the server's "run /unitbob map first" guidance.
|
|
20
|
+
async getSuitePackets() {
|
|
21
|
+
const res = await this.send('GET', this.repoPath('suite_packets'));
|
|
22
|
+
await this.ensureOk(res, `GET ${this.repoPath('suite_packets')}`);
|
|
23
|
+
return (await res.json());
|
|
24
|
+
}
|
|
25
|
+
// PUT /repos/:id/suite_build — upload the host's complete, locally-validated
|
|
26
|
+
// suite: the verbatim spec file plus the capability-keyed test_metadata. The
|
|
27
|
+
// server validates the classification, stores the bytes verbatim, versions, and
|
|
28
|
+
// returns the new suite's identity.
|
|
29
|
+
async putSuiteBuild(payload) {
|
|
30
|
+
const res = await this.send('PUT', this.repoPath('suite_build'), payload);
|
|
31
|
+
await this.ensureOk(res, `PUT ${this.repoPath('suite_build')}`);
|
|
32
|
+
return (await res.json());
|
|
33
|
+
}
|
|
34
|
+
// GET /repos/:id/fix_packet?interface_id= — the per-capability repair packet.
|
|
35
|
+
// Relayed down for the host to fix code or accept the change; 422 (non-failed /
|
|
36
|
+
// stale / no suite) surfaces as a WireError carrying the server's business reason.
|
|
37
|
+
async getFixPacket(interfaceId) {
|
|
38
|
+
const url = this.repoQueryPath('fix_packet', 'interface_id', interfaceId);
|
|
39
|
+
const res = await this.send('GET', url);
|
|
40
|
+
await this.ensureOk(res, `GET ${url}`);
|
|
41
|
+
return (await res.json());
|
|
42
|
+
}
|
|
43
|
+
// GET /recipes/:name — fetch a recipe at call time. Recipes live on Rails so
|
|
44
|
+
// the connector and Skill carry no recipe text (spec 15, acceptance criteria).
|
|
45
|
+
async getRecipe(name) {
|
|
46
|
+
const url = `${this.config.server}/recipes/${encodeURIComponent(name)}`;
|
|
47
|
+
const res = await this.send('GET', url);
|
|
48
|
+
if (res.status === 404) {
|
|
49
|
+
throw new WireError(`Unknown recipe "${name}" (server returned 404).`);
|
|
50
|
+
}
|
|
51
|
+
await this.ensureOk(res, `GET ${url}`);
|
|
52
|
+
return (await res.json());
|
|
53
|
+
}
|
|
54
|
+
// GET /repos/:id/suite — the current suite blob, or null when none exists yet
|
|
55
|
+
// (the server answers 204). Returned opaque; materializing it is spec 18.
|
|
56
|
+
async getSuite() {
|
|
57
|
+
const res = await this.send('GET', this.repoPath('suite'));
|
|
58
|
+
if (res.status === 204)
|
|
59
|
+
return null;
|
|
60
|
+
await this.ensureOk(res, `GET ${this.repoPath('suite')}`);
|
|
61
|
+
return this.decodeSuiteBlob(await res.json());
|
|
62
|
+
}
|
|
63
|
+
// POST /repos/:id/runs — ship the raw runner output; the server parses it and
|
|
64
|
+
// returns the run summary. The connector neither builds nor reads the payload
|
|
65
|
+
// body beyond passing it along.
|
|
66
|
+
async postRun(payload) {
|
|
67
|
+
const res = await this.send('POST', this.repoPath('runs'), payload);
|
|
68
|
+
await this.ensureOk(res, `POST ${this.repoPath('runs')}`);
|
|
69
|
+
return (await res.json());
|
|
70
|
+
}
|
|
71
|
+
// GET /repos/:id/lamps — the server's current run summary, printed verbatim.
|
|
72
|
+
async getLamps() {
|
|
73
|
+
const res = await this.send('GET', this.repoPath('lamps'));
|
|
74
|
+
await this.ensureOk(res, `GET ${this.repoPath('lamps')}`);
|
|
75
|
+
return await res.json();
|
|
76
|
+
}
|
|
77
|
+
repoPath(suffix) {
|
|
78
|
+
return `${this.config.server}/repos/${this.config.repoId}/${suffix}`;
|
|
79
|
+
}
|
|
80
|
+
repoQueryPath(suffix, key, value) {
|
|
81
|
+
return `${this.repoPath(suffix)}?${key}=${encodeURIComponent(value)}`;
|
|
82
|
+
}
|
|
83
|
+
decodeSuiteBlob(payload) {
|
|
84
|
+
if (!payload || typeof payload !== 'object') {
|
|
85
|
+
throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload.`);
|
|
86
|
+
}
|
|
87
|
+
const suite = payload;
|
|
88
|
+
if (typeof suite.suite_digest !== 'string' || typeof suite.spec_rb !== 'string') {
|
|
89
|
+
throw new WireError(`GET ${this.repoPath('suite')} returned a malformed suite payload: ` +
|
|
90
|
+
'expected suite_digest and spec_rb.');
|
|
91
|
+
}
|
|
92
|
+
return suite;
|
|
93
|
+
}
|
|
94
|
+
async send(method, url, body) {
|
|
95
|
+
try {
|
|
96
|
+
return await fetch(url, {
|
|
97
|
+
method,
|
|
98
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
99
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw new WireError(`Cannot reach the Unitbob server at ${this.config.server} ` +
|
|
104
|
+
`(${err.message}). Check that the server is running and that ` +
|
|
105
|
+
`"server" in .unitbob.json is correct.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async ensureOk(res, what) {
|
|
109
|
+
if (res.ok)
|
|
110
|
+
return;
|
|
111
|
+
let detail = '';
|
|
112
|
+
try {
|
|
113
|
+
detail = (await res.text()).slice(0, 500);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// ignore — the status alone is actionable enough
|
|
117
|
+
}
|
|
118
|
+
throw new WireError(`${what} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unitbob",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"unitbob": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"test": "node --test test/*.test.ts"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
}
|
|
27
|
+
}
|