unitbob 0.7.15 → 0.7.16

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
@@ -35,7 +35,7 @@ Codex:
35
35
  ```
36
36
  codex plugin marketplace add sergeygershun/unitbob-connector
37
37
  codex plugin add unitbob@unitbob
38
- npx -y unitbob@0.7.15 codex-install
38
+ npx -y unitbob@0.7.16 codex-install
39
39
  ```
40
40
 
41
41
  Then start a new chat. A chat opened before the install does not know about
@@ -217,6 +217,10 @@ net.Socket.prototype.connect = function unitbobGuardedConnect(...args) {
217
217
  // database. Those stay in host-owned shared steps, as on every other stack.
218
218
  // The one thing this file says about the database is "no": a `DATABASE_URL`
219
219
  // whose database is not named like a test one does not get a door.
220
+ // How long the World gives Next to come up in `BeforeAll`: a cold `.next` on
221
+ // a large application compiles for a while. One constant, read by the probe
222
+ // too, so the probe's clock can never be the shorter one (ADR 0001).
223
+ export const NEXT_WORLD_BOOT_TIMEOUT_MS = 180_000;
220
224
  const NEXT_DOOR_JS = `
221
225
  // ---------------------------------------------------------------------------
222
226
  // The door into this Next.js application (spec 56-1).
@@ -276,7 +280,7 @@ function unitbobDatabaseName(url) {
276
280
  }
277
281
  }
278
282
 
279
- BeforeAll({ timeout: 180000 }, async function () {
283
+ BeforeAll({ timeout: ${NEXT_WORLD_BOOT_TIMEOUT_MS} }, async function () {
280
284
  // The runner already sets NODE_ENV=test; kept here so Next reads .env.test
281
285
  // even when this file is loaded some other way.
282
286
  process.env.NODE_ENV = process.env.NODE_ENV || 'test';
@@ -73,9 +73,11 @@ export function assignedSurfaces(projectRoot) {
73
73
  }
74
74
  // Spec 55-1, §4. The marker the request minted for each capability and each
75
75
  // structural interface, by id — the `@ubc_…` tag a Scenario must carry and the
76
- // `[ubc_…]` a structural test names. This is the one reader of markers in
77
- // `src/`, and it answers one question: which markers were this slice's. What a
78
- // marker *means* on the map is the server's, and nothing here goes near it.
76
+ // `[ubc_…]` a structural test names. This reads them off the request and
77
+ // answers one question: which markers were this slice's (the verb then finds
78
+ // them in the slice's files, `runner/failureDigest.ts` in a run report — both
79
+ // by the same 12-hex shape). What a marker *means* on the map is the server's,
80
+ // and nothing here goes near it.
79
81
  export function assignedMarkers(projectRoot) {
80
82
  const request = readRequest(projectRoot);
81
83
  const byId = new Map();
@@ -380,10 +380,12 @@ function classify(projectRoot, runner, result, verdict) {
380
380
  return { status: 'broken', cause: causeOf(output, projectRoot, runner), message, detail };
381
381
  }
382
382
  // Spec 55-1, §1. Vite's import-analysis stopped on a file this runner has no
383
- // reading of — a `.py` the map named, say. That file got to vite through our
384
- // list and nowhere else, so the answer is about the list. Only vite says this;
385
- // the other runners never see such a file (their probes skip it) and their
386
- // parse errors mean what they say.
383
+ // reading of — a `.py` the map named, say. The probe imports only runner files
384
+ // (`vitestProbeSource`), so such a file reaches vite either through the list
385
+ // before that filter existed or through the project's own import of it; in
386
+ // both cases the answer names the file, and the second is a defect the message
387
+ // still points at. Only vite says this; the other runners never see such a
388
+ // file (their probes skip it) and their parse errors mean what they say.
387
389
  const FOREIGN_PARSE = /vite:import-analysis|Failed to parse source for import analysis/;
388
390
  const REPORTED_FILE = /^\s*File:\s*(\S+?)(?::\d+)*\s*$/m;
389
391
  function foreignFileParsed(output, projectRoot, runner) {
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { BEHAVIORAL_DIR, BEHAVIORAL_WORLD_JS_PATH, BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
3
+ import { BEHAVIORAL_DIR, BEHAVIORAL_WORLD_JS_PATH, BEHAVIORAL_WORLD_PATH, NEXT_WORLD_BOOT_TIMEOUT_MS } from "../files/behavioral.js";
4
4
  import { BEHAVIORAL_GEMFILE } from "./bdd.js";
5
5
  import { firstErrorLine } from "./bootcheck.js";
6
6
  import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
@@ -8,8 +8,14 @@ import { PROVISION_TIMEOUT_MS } from "./provision.js";
8
8
  import { looksLikeNext } from "../surfaces/nextRoutes.js";
9
9
  const PROBE_ROOT = '.unitbob/suite-build/world-probe';
10
10
  const defaultDeps = {
11
- runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
11
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS }),
12
12
  };
13
+ // What both probes leave to the run itself, said with every `ok`. The probe
14
+ // loads the connector's World and its own two or three steps; the project's
15
+ // `step_definitions/` — and any `Before` hook a coordinator writes there — are
16
+ // first loaded by the suite. So a green probe means the door opens, not that
17
+ // every hook behind it is sound.
18
+ const LEFT_TO_THE_RUN = "It ran the connector's own steps only; your step_definitions/ and the hooks in them are first loaded by the suite itself.";
13
19
  // Which Worlds are probed by running them: the ones that open a door into the
14
20
  // application — Ruby's, which integrates with Rails, and Next's, which starts
15
21
  // the application (spec 56-1). A harness that only guards the network
@@ -49,7 +55,7 @@ export async function probeBehavioralWorld(projectRoot, deps = defaultDeps) {
49
55
  },
50
56
  });
51
57
  if (result.code === 0)
52
- return { status: 'ok' };
58
+ return { status: 'ok', message: LEFT_TO_THE_RUN };
53
59
  const detail = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean).join('\n') || `exit ${result.code}`;
54
60
  return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
55
61
  }
@@ -90,9 +96,13 @@ export async function probeNextWorld(projectRoot, deps = defaultDeps) {
90
96
  CUCUMBER_PUBLISH_QUIET: 'true',
91
97
  UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
92
98
  },
99
+ // The run gives `BeforeAll` this long to start Next (ADR 0001: the probe
100
+ // may be looser than the run, never stricter), plus one request through
101
+ // the door once it is open.
102
+ timeoutMs: NEXT_WORLD_BOOT_TIMEOUT_MS + PROVISION_TIMEOUT_MS,
93
103
  });
94
104
  if (result.code === 0)
95
- return { status: 'ok' };
105
+ return { status: 'ok', message: LEFT_TO_THE_RUN };
96
106
  // cucumber-js opens every run on a Node it has not been tested with by
97
107
  // saying so; that line is not the error and must not be quoted as one.
98
108
  const lines = [result.stdout, result.stderr]
@@ -39,38 +39,40 @@ export function nextGate(projectRoot) {
39
39
  }
40
40
  const PAGE_FILE = /^page\.(tsx|jsx|js|ts)$/;
41
41
  const ROUTE_FILE = /^route\.(tsx|jsx|js|ts)$/;
42
- // Every address under the app directory, sorted by path so the inventory reads
43
- // like a route table rather than like a directory listing. Files are named
44
- // relative to the project root with forward slashes, which is how the graph
45
- // names them too.
46
42
  export function nextAppRoutes(projectRoot) {
47
43
  const appDir = nextAppDir(projectRoot);
44
+ const routing = { rows: [], unread: [] };
48
45
  if (!appDir)
49
- return [];
50
- const rows = [];
51
- walk(join(projectRoot, appDir), appDir.split(/[\\/]/).join('/'), [], rows);
52
- return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
46
+ return routing;
47
+ walk(join(projectRoot, appDir), appDir.split(/[\\/]/).join('/'), [], routing);
48
+ routing.rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
49
+ return routing;
53
50
  }
54
- function walk(dir, relative, segments, rows) {
51
+ function walk(dir, relative, segments, routing) {
55
52
  const path = urlOfSegments(segments);
56
53
  // A subtree with no URL has no addresses anywhere below it.
57
54
  if (path === null)
58
55
  return;
59
56
  for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
60
57
  if (entry.isDirectory()) {
61
- walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name], rows);
58
+ walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name], routing);
62
59
  continue;
63
60
  }
64
61
  if (!entry.isFile())
65
62
  continue;
66
63
  const file = `${relative}/${entry.name}`;
67
64
  if (PAGE_FILE.test(entry.name)) {
68
- rows.push({ verb: 'GET', path, file, method: 'default' });
65
+ routing.rows.push({ verb: 'GET', path, file, method: 'default' });
69
66
  }
70
67
  else if (ROUTE_FILE.test(entry.name)) {
71
- for (const verb of exportedHttpMethods(readFileSync(join(dir, entry.name), 'utf8'))) {
72
- rows.push({ verb, path, file, method: verb });
73
- }
68
+ const text = readFileSync(join(dir, entry.name), 'utf8');
69
+ const verbs = exportedHttpMethods(text);
70
+ for (const verb of verbs)
71
+ routing.rows.push({ verb, path, file, method: verb });
72
+ // Whatever it declares itself, a file that re-exports has addresses this
73
+ // reading cannot see; the model finds them in the graph.
74
+ if (REEXPORTS_ALL.test(text))
75
+ routing.unread.push(file);
74
76
  }
75
77
  }
76
78
  }
@@ -107,9 +109,11 @@ const METHOD = HTTP_METHODS.join('|');
107
109
  // export const { GET, POST } = handlers (the Auth.js shape)
108
110
  //
109
111
  // Not read: `export * from './handlers'` — the file names no method, and
110
- // following the import would be a second module resolver. Such an address is
111
- // simply not in the inventory; the recipe says the model may still find it in
112
- // the graph, as it finds everything on a stack with no inventory at all.
112
+ // following the import would be a second module resolver. Such a file goes to
113
+ // `NextRouting.unread` instead, and the recipe tells the model to find its
114
+ // addresses in the graph and name that file as their `source_file`; that is
115
+ // the one address the inventory check accepts beyond the inventory.
116
+ const REEXPORTS_ALL = /^\s*export\s+\*/m;
113
117
  const DECLARED = [
114
118
  new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s+(${METHOD})\\b`, 'gm'),
115
119
  new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+(${METHOD})\\s*[=:]`, 'gm'),
@@ -52,7 +52,7 @@ async function askRailsRouter(projectRoot, deps) {
52
52
  // declaration. `gate` names the `proxy.ts`/`middleware.ts` every address goes
53
53
  // through — not an address itself, and the recipe says so.
54
54
  function readNextFiles(projectRoot) {
55
- const rows = nextAppRoutes(projectRoot);
55
+ const { rows, unread } = nextAppRoutes(projectRoot);
56
56
  // An `app/` with no `page.*` or `route.*` declares nothing. As on Rails, we do
57
57
  // not claim "no addresses" for a reading that may simply have missed them.
58
58
  if (rows.length === 0)
@@ -60,7 +60,15 @@ function readNextFiles(projectRoot) {
60
60
  const nodes = graphNodes(projectRoot);
61
61
  const surfaces = rows.map((row) => toNextSurface(row, nodes));
62
62
  const gate = nextGate(projectRoot);
63
- return writeInventory(projectRoot, { declared_by: 'next app router', environment: 'default', ...(gate ? { gate } : {}), surfaces }, 'files');
63
+ return writeInventory(projectRoot, {
64
+ declared_by: 'next app router',
65
+ environment: 'default',
66
+ ...(gate ? { gate } : {}),
67
+ // The files this reading could not see into (ADR 0001): named here so
68
+ // the sentence below and `inventoryProblems` both know the gap.
69
+ ...(unread.length > 0 ? { unread } : {}),
70
+ surfaces,
71
+ }, 'files');
64
72
  }
65
73
  // The inventory, and only the inventory. `surfaces.json` is not written here,
66
74
  // however tempting: the recipe turns this file into that one with a single
@@ -88,6 +96,7 @@ function writeInventory(projectRoot, document, source) {
88
96
  linked: document.surfaces.filter((surface) => surface.handler_symbol).length,
89
97
  environment: document.environment,
90
98
  source,
99
+ ...(document.unread ? { unread: document.unread.length } : {}),
91
100
  };
92
101
  }
93
102
  // One question, up to two environments. Everything that can be decided from the
@@ -152,7 +161,9 @@ export function describeRouteInventory(result) {
152
161
  return (`Route inventory written to ${result.path}: ${result.routes} ${plural(result.routes, 'address', 'addresses')} ` +
153
162
  `from the ${result.source === 'files' ? 'file system' : 'router'}` +
154
163
  `${result.source === 'router' && result.environment === 'default' ? ' (read in the default environment — ' +
155
- 'the test one would not load)' : ''}, ${result.linked} tied to a graph node. The extract_surfaces recipe turns ` +
164
+ 'the test one would not load)' : ''}, ${result.linked} tied to a graph node` +
165
+ `${result.unread ? `; ${result.unread} route ${plural(result.unread, 'file re-exports its handlers and is', 'files re-export their handlers and are')} ` +
166
+ 'listed under `unread` — their addresses are not in the inventory' : ''}. The extract_surfaces recipe turns ` +
156
167
  'this file into surfaces.json with one command, then adds the job, table and external surfaces to it.');
157
168
  }
158
169
  return `No route inventory: ${becauseOf(result)}. The extract_surfaces recipe reads the source instead.`;
@@ -162,7 +173,7 @@ export function describeRouteInventory(result) {
162
173
  function becauseOf(result) {
163
174
  switch (result.reason) {
164
175
  case 'unsupported_stack':
165
- return 'this project has no router Unitbob can ask yet (Rails and the Next.js app router so far)';
176
+ return 'this project has no router Unitbob can ask yet (Rails and Next.js (app router) so far)';
166
177
  case 'app_did_not_load':
167
178
  return `the router could not be asked — ${result.detail}`;
168
179
  case 'did_not_finish':
@@ -403,22 +414,29 @@ function findNode(nodes, file, action) {
403
414
  export function inventoryProblems(inventory, surfaces) {
404
415
  const declared = routeEntriesIn(inventory);
405
416
  if (declared === null) {
406
- return ['the route inventory is not readable, so what the router declared cannot be confirmed'];
417
+ return ['the route inventory is not readable, so what the application declared cannot be confirmed'];
407
418
  }
408
419
  const written = routeEntriesIn(surfaces);
409
420
  if (written === null)
410
421
  return ['surfaces.json has no readable `surfaces` array'];
411
422
  const problems = [];
412
423
  const missing = [...declared.keys()].filter((id) => !written.has(id));
413
- const invented = [...written.keys()].filter((id) => !declared.has(id));
424
+ // ADR 0001: the check is no stricter than the inventory's own claim. A file
425
+ // the inventory lists under `unread` is one it could not see into, so an
426
+ // address served by that file is the model's to add — and only that one.
427
+ const unread = unreadFilesIn(inventory);
428
+ const invented = [...written.keys()].filter((id) => !declared.has(id) && !unread.has(written.get(id)?.source_file));
414
429
  if (missing.length > 0) {
415
- problems.push(`surfaces.json is missing ${missing.length} address the router declared: ${list(missing)}. ` +
430
+ problems.push(`surfaces.json is missing ${missing.length} address the inventory declared: ${list(missing)}. ` +
416
431
  'Copy every entry of the route inventory across unchanged.');
417
432
  }
418
433
  if (invented.length > 0) {
419
- problems.push(`surfaces.json carries ${invented.length} route the router never declared: ${list(invented)}. ` +
420
- 'The router is the authority on what exists; an address it does not know is either a typo ' +
421
- 'in a copied id or invented.');
434
+ problems.push(`surfaces.json carries ${invented.length} route the inventory does not declare: ${list(invented)}. ` +
435
+ 'The inventory is what the application declared about itself its router\'s answer, or its route ' +
436
+ 'files; an address it does not know is either a typo in a copied id or invented.' +
437
+ (unread.size > 0
438
+ ? ' The one exception is an address served by a route file listed under `unread`, with that file as its `source_file`.'
439
+ : ''));
422
440
  }
423
441
  // Two sentences rather than one, because the two kinds of rewrite are not the
424
442
  // same mistake and the person reading has to fix the right thing: one sends a
@@ -432,7 +450,7 @@ export function inventoryProblems(inventory, surfaces) {
432
450
  const relabelled = alteredFields(declared, written, LABEL_FIELDS);
433
451
  if (relabelled.length > 0) {
434
452
  problems.push(`surfaces.json reworded ${relabelled.length} handler_label: ${list(relabelled)}. ` +
435
- 'That name is what the router itself printed; the human names for the map are the ' +
453
+ 'That name is what the inventory itself printed; the human names for the map are the ' +
436
454
  'capability titles in the next step, not this field.');
437
455
  }
438
456
  return problems;
@@ -462,6 +480,12 @@ function alteredFields(declared, written, fields) {
462
480
  }
463
481
  return altered;
464
482
  }
483
+ // The `unread` list of a Next.js inventory, as a set; empty on a router's
484
+ // inventory, which has no such list because a router declares all it serves.
485
+ function unreadFilesIn(inventory) {
486
+ const unread = inventory?.unread;
487
+ return new Set(Array.isArray(unread) ? unread.filter((file) => typeof file === 'string') : []);
488
+ }
465
489
  function routeEntriesIn(document) {
466
490
  const surfaces = document?.surfaces;
467
491
  if (!Array.isArray(surfaces))
@@ -59,7 +59,7 @@ function refuseAlteredAddresses(inventoryPath, surfaces) {
59
59
  const problems = inventoryProblems(inventory, surfaces);
60
60
  if (problems.length === 0)
61
61
  return;
62
- throw new Error(`The routes in surfaces.json do not match the ones this project's router declared, so nothing ` +
62
+ throw new Error(`The routes in surfaces.json do not match the ones this project declared (its router's answer, or its route files), so nothing ` +
63
63
  `was uploaded and the previous map stays current:\n- ${problems.join('\n- ')}`);
64
64
  }
65
65
  // Spec 32-7, Task 1.11. The addresses are now a command's answer rather than the
@@ -199,6 +199,9 @@ export async function suitePrepare(config, args = [], deps) {
199
199
  fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
200
200
  continue;
201
201
  }
202
+ // ADR 0001: the probe's account of what it did not cover is part of
203
+ // the answer, printed with the good news rather than kept in a comment.
204
+ actual.stdout.write(`Checked that the connector-owned World profile works on this project: it does.${probe.message ? ` ${probe.message}` : ''}\n`);
202
205
  }
203
206
  }
204
207
  buildable.push({ packet, runner });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
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": {
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
21
21
  connector-owned harness, or another slice.
22
22
 
23
23
  After every owned edit, run
24
- `npx -y --loglevel=error unitbob@0.7.15 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.7.16 run-local <branch>` and inspect the machine
25
25
  report. Look only at examples or scenarios matching your owned paths or case
26
26
  markers. Do not require a green exit code from the whole branch: foreign failures
27
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded