unitbob 0.7.13 → 0.7.15

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.
@@ -1,13 +1,29 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
3
+ import { BEHAVIORAL_DIR, BEHAVIORAL_WORLD_JS_PATH, BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
4
4
  import { BEHAVIORAL_GEMFILE } from "./bdd.js";
5
+ import { firstErrorLine } from "./bootcheck.js";
5
6
  import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
6
7
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
8
+ import { looksLikeNext } from "../surfaces/nextRoutes.js";
7
9
  const PROBE_ROOT = '.unitbob/suite-build/world-probe';
8
- export async function probeBehavioralWorld(projectRoot, deps = {
10
+ const defaultDeps = {
9
11
  runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
10
- }) {
12
+ };
13
+ // Which Worlds are probed by running them: the ones that open a door into the
14
+ // application — Ruby's, which integrates with Rails, and Next's, which starts
15
+ // the application (spec 56-1). A harness that only guards the network
16
+ // integrates with nothing on the project, and its one claim is executed in the
17
+ // connector's own suite instead. False means: nothing on this project to
18
+ // probe, and that is not a failure.
19
+ export function worldIsProbed(projectRoot, runner) {
20
+ return runner === 'cucumber' || (runner === 'cucumber-js' && looksLikeNext(projectRoot));
21
+ }
22
+ // The probe for a runner `worldIsProbed` said yes to.
23
+ export function probeWorld(projectRoot, runner, deps = defaultDeps) {
24
+ return runner === 'cucumber' ? probeBehavioralWorld(projectRoot, deps) : probeNextWorld(projectRoot, deps);
25
+ }
26
+ export async function probeBehavioralWorld(projectRoot, deps = defaultDeps) {
11
27
  // Written on the host, named to the run relative to the project root — the
12
28
  // same shape `bdd.ts` has always had, and the reason nothing here needs a path
13
29
  // rewritten when the run happens somewhere else (spec 36, §4.2).
@@ -44,6 +60,87 @@ export async function probeBehavioralWorld(projectRoot, deps = {
44
60
  rmSync(probeRoot, { recursive: true, force: true });
45
61
  }
46
62
  }
63
+ // Spec 56-1, §2. A door is probed by walking through it: one scenario, one
64
+ // request to `/`, and any HTTP status is an answer — the page may redirect,
65
+ // may 404, may 500; what the probe asks is whether the application came up
66
+ // and answered through Next. Red means the door did not open, and the first
67
+ // error line of what the run printed is the message — the way `next` itself
68
+ // says it: the module is not installed, the database guard refused, or a
69
+ // developer's `next dev` is holding Next 16's lock.
70
+ export async function probeNextWorld(projectRoot, deps = defaultDeps) {
71
+ const feature = `${NEXT_PROBE_ROOT}/world.feature`;
72
+ const steps = `${NEXT_PROBE_ROOT}/world_steps.js`;
73
+ const probeRoot = join(projectRoot, NEXT_PROBE_ROOT);
74
+ mkdirSync(probeRoot, { recursive: true });
75
+ writeFileSync(join(projectRoot, feature), NEXT_PROBE_FEATURE);
76
+ writeFileSync(join(projectRoot, steps), NEXT_PROBE_STEPS);
77
+ try {
78
+ const result = await deps.runCmd(`${BEHAVIORAL_DIR}/node_modules/.bin/cucumber-js`, [
79
+ feature,
80
+ '--require', BEHAVIORAL_WORLD_JS_PATH,
81
+ '--require', steps,
82
+ '--format', 'progress',
83
+ // Always: this probe starts a dev Next, and a dev Next never lets the
84
+ // process end on its own (see `cucumberJsExitArgs`).
85
+ '--exit',
86
+ ], {
87
+ cwd: projectRoot,
88
+ env: {
89
+ NODE_ENV: 'test',
90
+ CUCUMBER_PUBLISH_QUIET: 'true',
91
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
92
+ },
93
+ });
94
+ if (result.code === 0)
95
+ return { status: 'ok' };
96
+ // cucumber-js opens every run on a Node it has not been tested with by
97
+ // saying so; that line is not the error and must not be quoted as one.
98
+ const lines = [result.stdout, result.stderr]
99
+ .flatMap((text) => text.split('\n'))
100
+ .filter((line) => line.trim() && !CUCUMBER_NODE_NOTICE.test(line));
101
+ // The World's own refusal — the database guard — is printed under the
102
+ // connector's prefix and is the whole answer when it is there; cucumber-js
103
+ // would otherwise be quoted saying "a BeforeAll hook errored", with the
104
+ // reason folded under it.
105
+ const ours = lines.find((line) => line.trimStart().startsWith('[unitbob]'));
106
+ const detail = ours?.trim() ?? (lines.length > 0 ? firstErrorLine(lines.join('\n')) : `exit ${result.code}`);
107
+ return { status: 'fixable', message: `The connector-owned Next.js World probe failed: ${detail}` };
108
+ }
109
+ catch (error) {
110
+ return { status: 'fixable', message: `The connector-owned Next.js World probe could not run: ${String(error)}` };
111
+ }
112
+ finally {
113
+ rmSync(probeRoot, { recursive: true, force: true });
114
+ }
115
+ }
116
+ // Under the behavioral root, not beside the Ruby probe's files: a step file
117
+ // `require`s `@cucumber/cucumber`, and Node finds it by walking up from the
118
+ // file to the sidecar's `node_modules` — which is here and nowhere else. The
119
+ // same walk makes the file CommonJS: the sidecar's `package.json` is the
120
+ // nearest one, so a project that declares `"type": "module"` (umami does)
121
+ // does not turn the probe's `.js` into an ES module. Removed after the run;
122
+ // a leftover from a killed run is not in the runner environment, so the next
123
+ // materialization clears it.
124
+ const NEXT_PROBE_ROOT = `${BEHAVIORAL_DIR}/world-probe`;
125
+ const CUCUMBER_NODE_NOTICE = /^This Node\.js version \(v[\d.]+\) has not been tested with this version of Cucumber/;
126
+ const NEXT_PROBE_FEATURE = `Feature: Unitbob World profile
127
+ Scenario: the application answers through the door
128
+ When the World probe asks the application for "/"
129
+ Then the application answers with an HTTP status
130
+ `;
131
+ const NEXT_PROBE_STEPS = `const assert = require('node:assert');
132
+ const { Then, When } = require('@cucumber/cucumber');
133
+
134
+ // \`{string}\` rather than a literal \`"/"\`: in a Cucumber Expression \`/\` is
135
+ // the alternation character, and the literal never matched.
136
+ When('the World probe asks the application for {string}', async function (path) {
137
+ this.unitbobProbeResponse = await this.unitbobFetch(path);
138
+ });
139
+
140
+ Then('the application answers with an HTTP status', function () {
141
+ assert.ok(Number.isInteger(this.unitbobProbeResponse.status), 'no status came back');
142
+ });
143
+ `;
47
144
  const PROBE_FEATURE = `Feature: Unitbob World profile
48
145
  Scenario: request, assertion counter, mocks, and state mutation
49
146
  Given the first World probe scenario mutates supported state
@@ -0,0 +1,133 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // `next` among the dependencies *and* an app directory. Both, so that the
4
+ // inventory and the World cannot disagree on "is this Next?": a project that
5
+ // installs `next` for one tool and routes with Express is not the app router,
6
+ // and an `app/` folder in a project without `next` is somebody else's `app/`.
7
+ export function looksLikeNext(projectRoot) {
8
+ return dependsOnNext(projectRoot) && nextAppDir(projectRoot) !== undefined;
9
+ }
10
+ function dependsOnNext(projectRoot) {
11
+ const packageJson = join(projectRoot, 'package.json');
12
+ if (!existsSync(packageJson))
13
+ return false;
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(packageJson, 'utf8'));
16
+ return 'next' in (parsed.dependencies ?? {}) || 'next' in (parsed.devDependencies ?? {});
17
+ }
18
+ catch {
19
+ return false; // an unreadable package.json names no dependency
20
+ }
21
+ }
22
+ // `app/` first, as Next itself chooses when both exist. Relative to the project
23
+ // root, with the platform's separator, so it joins onto the root like any path.
24
+ export function nextAppDir(projectRoot) {
25
+ return [join('app'), join('src', 'app')].find((dir) => existsSync(join(projectRoot, dir)));
26
+ }
27
+ // The gate every address goes through before its handler — `proxy.ts` from
28
+ // Next 16, `middleware.ts` before it. Named in the inventory so the recipe
29
+ // can say it exists; never listed as an address, because it has none.
30
+ const GATE_FILES = ['proxy.ts', 'proxy.js', 'middleware.ts', 'middleware.js'];
31
+ export function nextGate(projectRoot) {
32
+ for (const dir of ['', 'src']) {
33
+ for (const name of GATE_FILES) {
34
+ if (existsSync(join(projectRoot, dir, name)))
35
+ return dir ? `${dir}/${name}` : name;
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+ const PAGE_FILE = /^page\.(tsx|jsx|js|ts)$/;
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
+ export function nextAppRoutes(projectRoot) {
47
+ const appDir = nextAppDir(projectRoot);
48
+ 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));
53
+ }
54
+ function walk(dir, relative, segments, rows) {
55
+ const path = urlOfSegments(segments);
56
+ // A subtree with no URL has no addresses anywhere below it.
57
+ if (path === null)
58
+ return;
59
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
60
+ if (entry.isDirectory()) {
61
+ walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name], rows);
62
+ continue;
63
+ }
64
+ if (!entry.isFile())
65
+ continue;
66
+ const file = `${relative}/${entry.name}`;
67
+ if (PAGE_FILE.test(entry.name)) {
68
+ rows.push({ verb: 'GET', path, file, method: 'default' });
69
+ }
70
+ 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
+ }
74
+ }
75
+ }
76
+ }
77
+ // The URL a folder path names, or null when Next gives it none.
78
+ //
79
+ // `(group)` organises files and is absent from the URL. `@slot` is a parallel
80
+ // route rendered inside its parent's page; `(.)`, `(..)`, `(...)` and
81
+ // `(..)(..)` intercept another route — neither has a URL of its own, and
82
+ // nothing below them does either. `[id]`, `[...slug]` and `[[...slug]]` are
83
+ // kept in Next's own spelling: the map already writes
84
+ // `GET /api/admin/projects/[id]/clients`, and Rails' inventory keeps `:id`
85
+ // the same way — the address as the framework declares it, not a pattern we
86
+ // rewrite.
87
+ export function urlOfSegments(segments) {
88
+ const kept = [];
89
+ for (const segment of segments) {
90
+ if (segment.startsWith('@') || /^\(\.{1,3}\)/.test(segment))
91
+ return null;
92
+ if (/^\(.*\)$/.test(segment))
93
+ continue;
94
+ kept.push(segment);
95
+ }
96
+ return `/${kept.join('/')}`;
97
+ }
98
+ // The seven methods a route file may export, in the order the inventory lists
99
+ // them for one path.
100
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
101
+ const METHOD = HTTP_METHODS.join('|');
102
+ // The five ways a route file declares a method, each read from the text:
103
+ // export async function GET() / export function GET()
104
+ // export const GET = …
105
+ // export { GET, POST } and export { GET, POST } from './x'
106
+ // export { handler as GET } … — the name after `as` is the export
107
+ // export const { GET, POST } = handlers (the Auth.js shape)
108
+ //
109
+ // 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.
113
+ const DECLARED = [
114
+ new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s+(${METHOD})\\b`, 'gm'),
115
+ new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+(${METHOD})\\s*[=:]`, 'gm'),
116
+ ];
117
+ const EXPORT_LIST = /^\s*export\s+(?:(?:const|let|var)\s+)?\{([^}]*)\}/gm;
118
+ export function exportedHttpMethods(text) {
119
+ const found = new Set();
120
+ for (const pattern of DECLARED) {
121
+ for (const match of text.matchAll(pattern))
122
+ found.add(match[1]);
123
+ }
124
+ for (const match of text.matchAll(EXPORT_LIST)) {
125
+ for (const item of match[1].split(',')) {
126
+ // `a as B` exports B; `A` exports A. Only the exported name is an address.
127
+ const exported = item.trim().split(/\s+as\s+/).pop()?.trim() ?? '';
128
+ if (HTTP_METHODS.includes(exported))
129
+ found.add(exported);
130
+ }
131
+ }
132
+ return HTTP_METHODS.filter((method) => found.has(method));
133
+ }
@@ -5,6 +5,7 @@ import { firstErrorLine } from "../runner/bootcheck.js";
5
5
  import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
6
6
  import { detectStructuralRunner } from "../runner/precheck.js";
7
7
  import { graphNodes, methodNameOf, pathsMatch } from "./graph.js";
8
+ import { looksLikeNext, nextAppRoutes, nextGate } from "./nextRoutes.js";
8
9
  // Reading a router means booting the application, which on a large Rails app is
9
10
  // tens of seconds. The same budget the other boot-shaped step uses.
10
11
  const ROUTES_TIMEOUT_MS = 120_000;
@@ -19,12 +20,21 @@ const defaultDeps = {
19
20
  export function routeInventoryPath(projectRoot) {
20
21
  return join(projectRoot, '.unitbob', 'map-build', 'route_inventory.json');
21
22
  }
22
- // Ask this project's router, and write what it says. Rails today; the design
23
- // records why Django, FastAPI and Flask come next and why Express cannot follow
24
- // at all (its addresses are registered by arbitrary code at run time).
23
+ // Ask this project's router, and write what it says. Rails and Next.js (app
24
+ // router) today; the design records why Django, FastAPI and Flask come next and
25
+ // why Express cannot follow at all (its addresses are registered by arbitrary
26
+ // code at run time).
25
27
  export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
26
- if (!canAskTheRouter(projectRoot))
27
- return silent(projectRoot, 'unsupported_stack');
28
+ switch (routeSourceOf(projectRoot)) {
29
+ case 'router':
30
+ return askRailsRouter(projectRoot, deps);
31
+ case 'files':
32
+ return readNextFiles(projectRoot);
33
+ default:
34
+ return silent(projectRoot, 'unsupported_stack');
35
+ }
36
+ }
37
+ async function askRailsRouter(projectRoot, deps) {
28
38
  const asked = await askTheRouter(projectRoot, deps);
29
39
  if ('reason' in asked)
30
40
  return silent(projectRoot, asked.reason, asked.detail);
@@ -36,17 +46,34 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
36
46
  return silent(projectRoot, 'no_routes');
37
47
  const nodes = graphNodes(projectRoot);
38
48
  const surfaces = rows.map((row) => toSurface(projectRoot, row, nodes));
39
- // The inventory, and only the inventory. `surfaces.json` is not written here,
40
- // however tempting: the recipe turns this file into that one with a single
41
- // command no output spent retyping addresses, and the file still does not
42
- // exist until the model has begun. That absence is a check we would otherwise
43
- // throw away, because `put-map-build` refuses a build whose `surfaces.json` is
44
- // missing, and a pre-written file full of routes looks finished enough for the
45
- // job, table and external surfaces never to be looked for.
49
+ return writeInventory(projectRoot, { declared_by: 'rails router', environment: asked.environment, surfaces }, 'router');
50
+ }
51
+ // Spec 56-1. No environment to choose and nothing to boot: the files are the
52
+ // declaration. `gate` names the `proxy.ts`/`middleware.ts` every address goes
53
+ // through not an address itself, and the recipe says so.
54
+ function readNextFiles(projectRoot) {
55
+ const rows = nextAppRoutes(projectRoot);
56
+ // An `app/` with no `page.*` or `route.*` declares nothing. As on Rails, we do
57
+ // not claim "no addresses" for a reading that may simply have missed them.
58
+ if (rows.length === 0)
59
+ return silent(projectRoot, 'no_routes');
60
+ const nodes = graphNodes(projectRoot);
61
+ const surfaces = rows.map((row) => toNextSurface(row, nodes));
62
+ const gate = nextGate(projectRoot);
63
+ return writeInventory(projectRoot, { declared_by: 'next app router', environment: 'default', ...(gate ? { gate } : {}), surfaces }, 'files');
64
+ }
65
+ // The inventory, and only the inventory. `surfaces.json` is not written here,
66
+ // however tempting: the recipe turns this file into that one with a single
67
+ // command — no output spent retyping addresses, and the file still does not
68
+ // exist until the model has begun. That absence is a check we would otherwise
69
+ // throw away, because `put-map-build` refuses a build whose `surfaces.json` is
70
+ // missing, and a pre-written file full of routes looks finished enough for the
71
+ // job, table and external surfaces never to be looked for.
72
+ function writeInventory(projectRoot, document, source) {
46
73
  const path = routeInventoryPath(projectRoot);
47
74
  try {
48
75
  mkdirSync(dirname(path), { recursive: true });
49
- writeFileSync(path, `${JSON.stringify({ declared_by: 'rails router', environment: asked.environment, surfaces }, null, 2)}\n`);
76
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
50
77
  }
51
78
  catch (err) {
52
79
  // A read-only checkout or a full disk is not a reason to take the whole map
@@ -57,9 +84,10 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
57
84
  return {
58
85
  status: 'written',
59
86
  path,
60
- routes: surfaces.length,
61
- linked: surfaces.filter((surface) => surface.handler_symbol).length,
62
- environment: asked.environment,
87
+ routes: document.surfaces.length,
88
+ linked: document.surfaces.filter((surface) => surface.handler_symbol).length,
89
+ environment: document.environment,
90
+ source,
63
91
  };
64
92
  }
65
93
  // One question, up to two environments. Everything that can be decided from the
@@ -122,8 +150,9 @@ function silent(projectRoot, reason, detail) {
122
150
  export function describeRouteInventory(result) {
123
151
  if (result.status === 'written') {
124
152
  return (`Route inventory written to ${result.path}: ${result.routes} ${plural(result.routes, 'address', 'addresses')} ` +
125
- `from the router${result.environment === 'default' ? ' (read in the default environment — the test one ' +
126
- 'would not load)' : ''}, ${result.linked} tied to a graph node. The extract_surfaces recipe turns ` +
153
+ `from the ${result.source === 'files' ? 'file system' : 'router'}` +
154
+ `${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 ` +
127
156
  'this file into surfaces.json with one command, then adds the job, table and external surfaces to it.');
128
157
  }
129
158
  return `No route inventory: ${becauseOf(result)}. The extract_surfaces recipe reads the source instead.`;
@@ -133,7 +162,7 @@ export function describeRouteInventory(result) {
133
162
  function becauseOf(result) {
134
163
  switch (result.reason) {
135
164
  case 'unsupported_stack':
136
- return 'this project has no router Unitbob can ask yet (Rails is the only one so far)';
165
+ return 'this project has no router Unitbob can ask yet (Rails and the Next.js app router so far)';
137
166
  case 'app_did_not_load':
138
167
  return `the router could not be asked — ${result.detail}`;
139
168
  case 'did_not_finish':
@@ -164,8 +193,19 @@ function plural(count, one, many) {
164
193
  // carry a `config/routes.rb` was asked to boot Rails. There are still several
165
194
  // stack detectors in this package; this removes the one that had a single caller
166
195
  // and no excuse.
167
- function canAskTheRouter(projectRoot) {
168
- return (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb')));
196
+ //
197
+ // Next.js is the second source (spec 56-1), and it is a different kind: there
198
+ // is no router object to ask, the files are the declaration. `looksLikeNext` is
199
+ // the one answer to "is this Next?" in this package — the World that opens the
200
+ // door into the application (`files/behavioral.ts`) asks the same function, so
201
+ // the two cannot disagree.
202
+ function routeSourceOf(projectRoot) {
203
+ if (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb'))) {
204
+ return 'router';
205
+ }
206
+ if (looksLikeNext(projectRoot))
207
+ return 'files';
208
+ return null;
169
209
  }
170
210
  // The question, asked of the router object rather than of the `rails routes`
171
211
  // command line. `--expanded` was the earlier reading, and it cost this project
@@ -294,6 +334,21 @@ function toSurface(projectRoot, row, nodes) {
294
334
  surface.handler_label = `${row.controller}#${row.action}`;
295
335
  return surface;
296
336
  }
337
+ // The Next.js counterpart of `toSurface`. The file is the declaration, so it is
338
+ // always the `source_file`; the label is `<file>#<method>` — `#default` for a
339
+ // page — which is what the file itself says, the way `settings#index` is what
340
+ // the router said. The link is looked up by the same two facts as on Rails:
341
+ // this file, this method name. A page whose default export the graph did not
342
+ // record keeps its address with no link — that is the `AdminDashboard` page of
343
+ // the appartment run, and it is normal.
344
+ function toNextSurface(row, nodes) {
345
+ const surface = { kind: 'route', id: `${row.verb} ${row.path}`, source_file: row.file };
346
+ const node = findNode(nodes, row.file, row.method);
347
+ if (node)
348
+ surface.handler_symbol = node.id;
349
+ surface.handler_label = `${row.file}#${row.method}`;
350
+ return surface;
351
+ }
297
352
  // The link is *searched for*, never spelled. graphify names its node ids by an
298
353
  // internal rule, it is a third-party package (`pip install graphifyy`), and that
299
354
  // rule may change in any release. Rebuilding the rule here would make us
Binary file
@@ -47,7 +47,7 @@ function refuseAlteredAddresses(inventoryPath, surfaces) {
47
47
  return; // no router was asked; there is nothing to hold the answer to
48
48
  if (!existsSync(inventoryPath)) {
49
49
  throw new Error(`${inventoryPath} is named by the build request but is not there. Run \`unitbob map-prepare\` ` +
50
- 'again so the addresses come from the router, then rebuild the surface map.');
50
+ 'again so the addresses come from the router (or, on Next.js, the file routing), then rebuild the surface map.');
51
51
  }
52
52
  let inventory = null;
53
53
  try {
@@ -12,7 +12,7 @@ import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
12
12
  import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
13
13
  import { ToolchainUnavailableError } from "../runner/toolchain.js";
14
14
  import { canPrepareBeforeImports, setupFileOf, STRUCTURAL_SETUP_FILE } from "../runner/vitest.js";
15
- import { probeBehavioralWorld } from "../runner/worldProbe.js";
15
+ import { probeWorld, worldIsProbed } from "../runner/worldProbe.js";
16
16
  import { Wire } from "../wire.js";
17
17
  // The complete envelope for one branch, or null when this machine cannot
18
18
  // produce one: the server offered no combination this stack matches, or the
@@ -72,7 +72,7 @@ export async function suitePrepare(config, args = [], deps) {
72
72
  bootCheck: (projectRoot, runner, sourceFiles) => bootCheck(projectRoot, runner, sourceFiles),
73
73
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
74
74
  ensureStructuralRunner: deps?.ensureStructuralRunner ?? ensureStructuralRunner,
75
- worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
75
+ worldProbe: deps?.worldProbe ?? probeWorld,
76
76
  runnerEnvelope: runnerEnvelopeFor,
77
77
  stdout: process.stdout,
78
78
  ...deps,
@@ -185,14 +185,16 @@ export async function suitePrepare(config, args = [], deps) {
185
185
  continue;
186
186
  }
187
187
  // Every BDD runner with a connector-owned harness gets it here, before its
188
- // branch is offered to the host (spec 35-1). Only Ruby is probed by
189
- // running it: the Ruby World integrates deeply with Rails, and the probe
190
- // needs an application to integrate with. The JS and Python harnesses do
191
- // one thingrefuse connections that leave the machine and their
192
- // guards are executed for real in the connector's own suite.
188
+ // branch is offered to the host (spec 35-1). A World that opens a door
189
+ // into the application is probed by running it Ruby's, which
190
+ // integrates deeply with Rails, and Next's, which starts the application
191
+ // (spec 56-1)because the probe needs the application to answer. A
192
+ // harness that only refuses connections leaving the machine (JS
193
+ // elsewhere, Python) has nothing on the project to probe; its guard is
194
+ // executed for real in the connector's own suite.
193
195
  materializeBehavioralWorld(config.projectRoot, runner);
194
- if (runner === 'cucumber') {
195
- const probe = await actual.worldProbe(config.projectRoot);
196
+ if (worldIsProbed(config.projectRoot, runner)) {
197
+ const probe = await actual.worldProbe(config.projectRoot, runner);
196
198
  if (probe.status === 'fixable') {
197
199
  fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
198
200
  continue;
@@ -293,7 +295,15 @@ export async function suitePrepare(config, args = [], deps) {
293
295
  const bootAdvisories = [];
294
296
  const structuralIndex = branches.findIndex((branch) => branch.suite_kind === 'structural');
295
297
  if (structuralIndex !== -1) {
296
- const boot = await actual.bootCheck(config.projectRoot, structuralRunner, structuralSourceFiles(config.projectRoot, { branches }));
298
+ const sources = structuralSourceFiles(config.projectRoot, { branches }, structuralRunner);
299
+ // Spec 55-1, §1. Said before the probe's answer and before "Next:", in the
300
+ // vibecoder's terms: these files are in the map and not in this suite.
301
+ if (sources.leftOut.length > 0) {
302
+ const count = sources.leftOut.length;
303
+ actual.stdout.write(`${count} ${count === 1 ? 'file' : 'files'} the map names ${count === 1 ? 'is' : 'are'} not ${structuralRunner} ` +
304
+ `and ${count === 1 ? 'was' : 'were'} left out of the code-structure suite: ${sources.leftOut.join(', ')}\n`);
305
+ }
306
+ const boot = await actual.bootCheck(config.projectRoot, structuralRunner, sources.files);
297
307
  if (boot.status === 'broken') {
298
308
  if (!canPrepareBeforeImports(structuralRunner) || setupFileOf(config.projectRoot)) {
299
309
  branches.splice(structuralIndex, 1);
@@ -325,7 +335,7 @@ export async function suitePrepare(config, args = [], deps) {
325
335
  //
326
336
  // Not written into `request.json`: the coordinator reads that file whole and
327
337
  // pays for it on every turn of the longest-lived context in the run.
328
- const sourcePackets = buildPackets(config.projectRoot, request);
338
+ const sourcePackets = buildPackets(config.projectRoot, request, structuralRunner);
329
339
  // A new request is a new build, and a new build has no previous run to be
330
340
  // stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
331
341
  // step of the loop, so a failure set remembered from the build before it would
@@ -432,9 +442,9 @@ function displacedList(moved) {
432
442
  // failed build: the workers search the source themselves, exactly as they did
433
443
  // before this spec. The same rule the route inventory follows for the same
434
444
  // reason — a read-only checkout or a full disk must not take a build down.
435
- function buildPackets(projectRoot, request) {
445
+ function buildPackets(projectRoot, request, runner) {
436
446
  try {
437
- return writeSuitePackets(projectRoot, request);
447
+ return writeSuitePackets(projectRoot, request, runner);
438
448
  }
439
449
  catch (err) {
440
450
  return err.message;
@@ -535,18 +545,24 @@ function bootAdvisory(boot, runner) {
535
545
  // is not something a setup file can prepare its way around, and sending someone
536
546
  // to write one would waste the round it costs.
537
547
  function bootReport(boot, runner, prepared) {
538
- const next = boot.cause !== 'defect_in_code'
539
- ? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` ' +
540
- 'it never writes to your project. Something outside that file is still missing here. Run the ' +
541
- 'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
542
- 'then run `unitbob suite-prepare` again.'
543
- : prepared
544
- ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
545
- : `This is what these files do with nothing in front of them, and the run will have ` +
546
- `${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
547
- 'first import the environment variables the modules read, a loader registration, and only as a ' +
548
- 'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
549
- 'and this same question will be asked through it.';
548
+ // Spec 55-1, §1. The third cause is ours: vite stopped on a file our own list
549
+ // handed it. Not a repair and not an install the sentence repo 139 got
550
+ // twice, over a `.py` the map had named.
551
+ const next = boot.cause === 'harness'
552
+ ? 'This is a hole in the list of files Unitbob resolved for the map, not in your code — ' +
553
+ `${boot.file}. Report it; nothing to repair here.`
554
+ : boot.cause !== 'defect_in_code'
555
+ ? 'Unitbob installs the runner, and your declared dependencies with it, into `.unitbob/runners/` ' +
556
+ 'it never writes to your project. Something outside that file is still missing here. Run the ' +
557
+ 'install your project needs (`bundle install`, `npm install`, `pip install -r requirements.txt`), ' +
558
+ 'then run `unitbob suite-prepare` again.'
559
+ : prepared
560
+ ? 'Repair it and run `unitbob suite-prepare` again to build this branch.'
561
+ : `This is what these files do with nothing in front of them, and the run will have ` +
562
+ `${STRUCTURAL_SETUP_FILE} in front of them. Put into that file whatever has to happen before the ` +
563
+ 'first import — the environment variables the modules read, a loader registration, and only as a ' +
564
+ 'last resort an entry through the project\'s root module — then run `unitbob suite-prepare` again ' +
565
+ 'and this same question will be asked through it.';
550
566
  // `boot.message` is the runner's own words, indented but never paraphrased:
551
567
  // this is the line the vibecoder can paste into a search.
552
568
  return ` ${boot.message}\n\n${boot.detail}\n\n ${next}${caveatFor(runner, ' ')}\n`;