unitbob 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -31,13 +31,20 @@ Restart the session so the commands load.
31
31
 
32
32
  ## Full cycle
33
33
 
34
- | Step | With a prompt (just type in chat) | With a command |
35
- |------|-----------------------------------|----------------|
36
- | 1. Build the map | `Build my Unitbob map` | `/unitbob:map` |
37
- | 2. Generate tests | `Generate the guardrail tests` | `/unitbob:suite` |
38
- | 3. Run the checks | `Run the checks` | `/unitbob:check` |
39
- | 4. Fix a red lamp | `Fix guardrail <id>` | `/unitbob:fix <id>` |
40
- | 5. Open the map | `Open my Unitbob map` | `/unitbob:show` |
34
+ Just type it in the chat. There is nothing to memorise and no command to get right.
35
+
36
+ | Step | Say this |
37
+ |------|----------|
38
+ | 1. Build the map | `Build my Unitbob map` |
39
+ | 2. Generate tests | `Generate the guardrail tests` |
40
+ | 3. Run the checks | `Run the checks` |
41
+ | 4. Fix a red lamp | `Fix guardrail <id>` |
42
+ | 5. Open the map | `Open my Unitbob map` |
43
+
44
+ There are also `/unitbob:map`, `/unitbob:suite` and friends, but they work only
45
+ inside a Claude Code terminal, and only in a session started after the plugin was
46
+ installed — in a browser or desktop window they are not recognised at all. The
47
+ phrasings above work everywhere, so they are the ones documented here.
41
48
 
42
49
  ---
43
50
 
@@ -11,6 +11,14 @@ export function requestPath(projectRoot) {
11
11
  export function outputPath(projectRoot) {
12
12
  return join(projectRoot, '.unitbob', 'map-build', 'map_document.json');
13
13
  }
14
+ // The surface inventory (spec 31, step 1) and the grouped surface document
15
+ // (step 2) are the two extra local artifacts of one atomic map build.
16
+ export function surfacesPath(projectRoot) {
17
+ return join(projectRoot, '.unitbob', 'map-build', 'surfaces.json');
18
+ }
19
+ export function surfaceOutputPath(projectRoot) {
20
+ return join(projectRoot, '.unitbob', 'map-build', 'surface_document.json');
21
+ }
14
22
  export function readFreshGraph(projectRoot) {
15
23
  const path = graphPath(projectRoot);
16
24
  if (!existsSync(path)) {
@@ -25,6 +33,8 @@ export function writeMapBuildRequest(projectRoot, recipes) {
25
33
  project_root: projectRoot,
26
34
  graph_path: graphPath(projectRoot),
27
35
  output_path: outputPath(projectRoot),
36
+ surfaces_path: surfacesPath(projectRoot),
37
+ surface_output_path: surfaceOutputPath(projectRoot),
28
38
  recipes,
29
39
  };
30
40
  const path = requestPath(projectRoot);
@@ -39,7 +49,8 @@ export function readMapBuildRequest(projectRoot) {
39
49
  }
40
50
  const packet = parseJson(readFileSync(path, 'utf8'), path);
41
51
  if (!isMapBuildRequest(packet)) {
42
- throw new Error(`${path} is malformed: expected project_root, graph_path, output_path, and recipes.`);
52
+ throw new Error(`${path} is malformed: expected project_root, graph_path, output_path, surfaces_path, ` +
53
+ 'surface_output_path, and recipes.');
43
54
  }
44
55
  return packet;
45
56
  }
@@ -49,6 +60,20 @@ export function readHostMapOutput(path) {
49
60
  }
50
61
  return parseJson(readFileSync(path, 'utf8'), path);
51
62
  }
63
+ // The two surface artifacts are both required before upload: no partial bundle
64
+ // carrying only one lens is ever sent (spec 31, atomic bundle).
65
+ export function readSurfacesInventory(path) {
66
+ if (!existsSync(path)) {
67
+ throw new Error(`${path} not found — the extract_surfaces step did not write a surfaces inventory.`);
68
+ }
69
+ return parseJson(readFileSync(path, 'utf8'), path);
70
+ }
71
+ export function readSurfaceDocument(path) {
72
+ if (!existsSync(path)) {
73
+ throw new Error(`${path} not found — the decompose_surfaces step did not write a surface document.`);
74
+ }
75
+ return parseJson(readFileSync(path, 'utf8'), path);
76
+ }
52
77
  function parseJson(raw, path) {
53
78
  try {
54
79
  return JSON.parse(raw);
@@ -65,9 +90,13 @@ function isMapBuildRequest(value) {
65
90
  return (typeof packet.project_root === 'string' &&
66
91
  typeof packet.graph_path === 'string' &&
67
92
  typeof packet.output_path === 'string' &&
93
+ typeof packet.surfaces_path === 'string' &&
94
+ typeof packet.surface_output_path === 'string' &&
68
95
  !!recipes &&
69
96
  isRecipe(recipes.decompose) &&
70
- isRecipe(recipes.relate));
97
+ isRecipe(recipes.relate) &&
98
+ isRecipe(recipes.extract_surfaces) &&
99
+ isRecipe(recipes.decompose_surfaces));
71
100
  }
72
101
  function isRecipe(value) {
73
102
  if (!value || typeof value !== 'object')
package/dist/proc.js CHANGED
@@ -56,20 +56,70 @@ export async function requireGraphify() {
56
56
  `"graphify", needs Python 3.10+), then retry (${err.message}).`);
57
57
  }
58
58
  }
59
+ // Paths that hold no business logic in the stacks unitbob supports — Rails,
60
+ // JS/TS, Python — and that graphify does not already skip (it drops
61
+ // node_modules, venv, dist, build, target, out, __pycache__, and the framework
62
+ // cache and report dirs on its own).
63
+ //
64
+ // Every entry earns its place, because an over-broad pattern fails silently: a
65
+ // subsystem simply never appears on the map and nobody learns why. Measured on
66
+ // one real Rails app (2943 nodes), vendor/ was 1337 of them, app/assets/ 414,
67
+ // db/migrate/ 314 — 70% of the graph, and the busiest nodes in it were `_()`
68
+ // and `$()`. Everything else in that project was already clean: bin/, tmp/,
69
+ // log/ and storage/ produced zero nodes, so they are deliberately not listed.
70
+ //
71
+ // The list stays language-neutral: one project is often Rails and Python at
72
+ // once. Gitignore syntax; graphify merges it with .gitignore, and it can only
73
+ // ever exclude more, never re-include.
74
+ export const GRAPH_NOISE_PATTERNS = [
75
+ '# unitbob: keep the graph about your own business code',
76
+ '.unitbob/',
77
+ // Third-party code committed into the repo. In Rails, `vendor/` is the
78
+ // convention for it, and `app/assets/javascripts/` is where sprockets-era
79
+ // apps dumped libraries — on the measured app that folder was moment.js,
80
+ // datatables.js and jquery.inputmask, against a single node of own code. A
81
+ // modern Rails app keeps its own JS in `app/javascript/`, which stays.
82
+ 'vendor/',
83
+ 'app/assets/javascripts/',
84
+ 'app/assets/builds/',
85
+ 'app/assets/config/',
86
+ 'public/assets/',
87
+ 'public/packs/',
88
+ '*.min.js',
89
+ '*.min.css',
90
+ '*.bundle.js',
91
+ // Generated code: schema history and codegen output. Describes the shape of
92
+ // data, never the behaviour a guardrail could protect.
93
+ 'db/migrate/',
94
+ 'db/schema.rb',
95
+ 'db/structure.sql',
96
+ 'migrations/',
97
+ '__generated__/',
98
+ '*_pb2.py',
99
+ '*_pb2_grpc.py',
100
+ '*_pb.js',
101
+ // Type declarations — a contract for a compiler, with no runtime behaviour.
102
+ '*.d.ts',
103
+ '*.pyi',
104
+ ];
59
105
  export function ensureUnitbobIgnored(projectRoot) {
60
- ensureLine(join(projectRoot, '.gitignore'), '.unitbob/');
61
- ensureLine(join(projectRoot, '.gitignore'), 'graphify-out/');
62
- ensureLine(join(projectRoot, '.graphifyignore'), '.unitbob/');
106
+ // `.graphifyignore` is unitbob's own bookkeeping, like the other two entries —
107
+ // the user never edits it, so it stays out of their commits.
108
+ ensureLines(join(projectRoot, '.gitignore'), ['.unitbob/', 'graphify-out/', '.graphifyignore']);
109
+ ensureLines(join(projectRoot, '.graphifyignore'), GRAPH_NOISE_PATTERNS);
63
110
  }
64
- function ensureLine(path, line) {
111
+ // Appends whichever lines are missing, in one write, leaving the user's own
112
+ // entries (and their order) untouched. Idempotent: a second run adds nothing.
113
+ function ensureLines(path, lines) {
65
114
  let current = '';
66
- if (existsSync(path)) {
115
+ if (existsSync(path))
67
116
  current = readFileSync(path, 'utf8');
68
- if (current.split('\n').some((existing) => existing.trim() === line))
69
- return;
70
- }
117
+ const present = new Set(current.split('\n').map((line) => line.trim()));
118
+ const missing = lines.filter((line) => !present.has(line));
119
+ if (missing.length === 0)
120
+ return;
71
121
  const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
72
- writeFileSync(path, `${current}${prefix}${line}\n`);
122
+ writeFileSync(path, `${current}${prefix}${missing.join('\n')}\n`);
73
123
  }
74
124
  export async function runGraphifyExtractKeyless(projectRoot) {
75
125
  // Deterministic AST-only graph; no LLM, no API key. `update --force` re-extracts
@@ -20,9 +20,21 @@ export async function mapPrepare(config, _args = [], deps) {
20
20
  throw new Error(`graphify update failed: ${detail}`);
21
21
  }
22
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 });
23
+ const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
24
+ actual.getRecipe('decompose'),
25
+ actual.getRecipe('relate'),
26
+ actual.getRecipe('extract_surfaces'),
27
+ actual.getRecipe('decompose_surfaces'),
28
+ ]);
29
+ const packet = writeMapBuildRequest(config.projectRoot, {
30
+ decompose,
31
+ relate,
32
+ extract_surfaces: extractSurfaces,
33
+ decompose_surfaces: decomposeSurfaces,
34
+ });
25
35
  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');
36
+ process.stdout.write(`Next: build BOTH lenses following the recipes in that request the decompose map at ` +
37
+ `${packet.output_path} (recipes.decompose, recipes.relate), and the surface map at ` +
38
+ `${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
39
+ 'recipes.decompose_surfaces) — then run `unitbob put-map-build`.\n');
28
40
  }
@@ -1,16 +1,26 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { readHostMapOutput, readMapBuildRequest } from "../files/mapBuild.js";
2
+ import { readHostMapOutput, readMapBuildRequest, readSurfaceDocument, readSurfacesInventory, } from "../files/mapBuild.js";
3
3
  import { Wire } from "../wire.js";
4
4
  export async function putMapBuild(config, _args = [], deps) {
5
5
  const packet = readMapBuildRequest(config.projectRoot);
6
6
  const graph = JSON.parse(readFileSync(packet.graph_path, 'utf8'));
7
+ // Both lenses must be present locally before anything is sent — the host stores
8
+ // the bundle atomically or not at all (spec 31). A missing artifact here throws
9
+ // and no partial upload happens.
7
10
  const mapDocument = readHostMapOutput(packet.output_path);
11
+ const surfaces = readSurfacesInventory(packet.surfaces_path);
12
+ const surfaceDocument = readSurfaceDocument(packet.surface_output_path);
8
13
  const actual = {
9
14
  putMapBuild: (payload) => new Wire(config).putMapBuild(payload),
10
15
  ...deps,
11
16
  };
12
- const result = await actual.putMapBuild({ graph, map_document: mapDocument });
13
- process.stdout.write(`Map uploaded (${result.map_digest}, graph ${result.graph_digest}) ` +
17
+ const result = await actual.putMapBuild({
18
+ graph,
19
+ map_document: mapDocument,
20
+ surfaces,
21
+ surface_document: surfaceDocument,
22
+ });
23
+ process.stdout.write(`Map uploaded (map ${result.map_digest}, surface ${result.surface_digest}, graph ${result.graph_digest}) ` +
14
24
  `${result.reused ? 'reused' : 'created'} version ${result.map_version_id}.\n` +
15
25
  `${result.map_url}\n`);
16
26
  }
package/dist/wire.js CHANGED
@@ -40,8 +40,10 @@ export class Wire {
40
40
  constructor(config) {
41
41
  this.config = config;
42
42
  }
43
- // PUT /repos/:id/map_build — upload the fresh graph and host-built map as one
44
- // atomic blob. Rails validates, versions, and computes all digests.
43
+ // PUT /repos/:id/map_build — upload the fresh graph and both host-built lenses
44
+ // (decompose map_document + surfaces inventory + grouped surface_document) as
45
+ // one atomic bundle. Rails validates both documents, versions, and computes all
46
+ // digests; either lens failing rejects the whole bundle (spec 31).
45
47
  async putMapBuild(payload) {
46
48
  const res = await this.send('PUT', this.repoPath('map_build'), payload);
47
49
  await this.ensureOk(res, `PUT ${this.repoPath('map_build')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": {