unitbob 0.7.14 → 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.
@@ -1,13 +1,35 @@
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, NEXT_WORLD_BOOT_TIMEOUT_MS } 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 = {
9
- runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
10
- }) {
10
+ const defaultDeps = {
11
+ runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: options.timeoutMs ?? PROVISION_TIMEOUT_MS }),
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.";
19
+ // Which Worlds are probed by running them: the ones that open a door into the
20
+ // application — Ruby's, which integrates with Rails, and Next's, which starts
21
+ // the application (spec 56-1). A harness that only guards the network
22
+ // integrates with nothing on the project, and its one claim is executed in the
23
+ // connector's own suite instead. False means: nothing on this project to
24
+ // probe, and that is not a failure.
25
+ export function worldIsProbed(projectRoot, runner) {
26
+ return runner === 'cucumber' || (runner === 'cucumber-js' && looksLikeNext(projectRoot));
27
+ }
28
+ // The probe for a runner `worldIsProbed` said yes to.
29
+ export function probeWorld(projectRoot, runner, deps = defaultDeps) {
30
+ return runner === 'cucumber' ? probeBehavioralWorld(projectRoot, deps) : probeNextWorld(projectRoot, deps);
31
+ }
32
+ export async function probeBehavioralWorld(projectRoot, deps = defaultDeps) {
11
33
  // Written on the host, named to the run relative to the project root — the
12
34
  // same shape `bdd.ts` has always had, and the reason nothing here needs a path
13
35
  // rewritten when the run happens somewhere else (spec 36, §4.2).
@@ -33,7 +55,7 @@ export async function probeBehavioralWorld(projectRoot, deps = {
33
55
  },
34
56
  });
35
57
  if (result.code === 0)
36
- return { status: 'ok' };
58
+ return { status: 'ok', message: LEFT_TO_THE_RUN };
37
59
  const detail = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean).join('\n') || `exit ${result.code}`;
38
60
  return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
39
61
  }
@@ -44,6 +66,91 @@ export async function probeBehavioralWorld(projectRoot, deps = {
44
66
  rmSync(probeRoot, { recursive: true, force: true });
45
67
  }
46
68
  }
69
+ // Spec 56-1, §2. A door is probed by walking through it: one scenario, one
70
+ // request to `/`, and any HTTP status is an answer — the page may redirect,
71
+ // may 404, may 500; what the probe asks is whether the application came up
72
+ // and answered through Next. Red means the door did not open, and the first
73
+ // error line of what the run printed is the message — the way `next` itself
74
+ // says it: the module is not installed, the database guard refused, or a
75
+ // developer's `next dev` is holding Next 16's lock.
76
+ export async function probeNextWorld(projectRoot, deps = defaultDeps) {
77
+ const feature = `${NEXT_PROBE_ROOT}/world.feature`;
78
+ const steps = `${NEXT_PROBE_ROOT}/world_steps.js`;
79
+ const probeRoot = join(projectRoot, NEXT_PROBE_ROOT);
80
+ mkdirSync(probeRoot, { recursive: true });
81
+ writeFileSync(join(projectRoot, feature), NEXT_PROBE_FEATURE);
82
+ writeFileSync(join(projectRoot, steps), NEXT_PROBE_STEPS);
83
+ try {
84
+ const result = await deps.runCmd(`${BEHAVIORAL_DIR}/node_modules/.bin/cucumber-js`, [
85
+ feature,
86
+ '--require', BEHAVIORAL_WORLD_JS_PATH,
87
+ '--require', steps,
88
+ '--format', 'progress',
89
+ // Always: this probe starts a dev Next, and a dev Next never lets the
90
+ // process end on its own (see `cucumberJsExitArgs`).
91
+ '--exit',
92
+ ], {
93
+ cwd: projectRoot,
94
+ env: {
95
+ NODE_ENV: 'test',
96
+ CUCUMBER_PUBLISH_QUIET: 'true',
97
+ UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
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,
103
+ });
104
+ if (result.code === 0)
105
+ return { status: 'ok', message: LEFT_TO_THE_RUN };
106
+ // cucumber-js opens every run on a Node it has not been tested with by
107
+ // saying so; that line is not the error and must not be quoted as one.
108
+ const lines = [result.stdout, result.stderr]
109
+ .flatMap((text) => text.split('\n'))
110
+ .filter((line) => line.trim() && !CUCUMBER_NODE_NOTICE.test(line));
111
+ // The World's own refusal — the database guard — is printed under the
112
+ // connector's prefix and is the whole answer when it is there; cucumber-js
113
+ // would otherwise be quoted saying "a BeforeAll hook errored", with the
114
+ // reason folded under it.
115
+ const ours = lines.find((line) => line.trimStart().startsWith('[unitbob]'));
116
+ const detail = ours?.trim() ?? (lines.length > 0 ? firstErrorLine(lines.join('\n')) : `exit ${result.code}`);
117
+ return { status: 'fixable', message: `The connector-owned Next.js World probe failed: ${detail}` };
118
+ }
119
+ catch (error) {
120
+ return { status: 'fixable', message: `The connector-owned Next.js World probe could not run: ${String(error)}` };
121
+ }
122
+ finally {
123
+ rmSync(probeRoot, { recursive: true, force: true });
124
+ }
125
+ }
126
+ // Under the behavioral root, not beside the Ruby probe's files: a step file
127
+ // `require`s `@cucumber/cucumber`, and Node finds it by walking up from the
128
+ // file to the sidecar's `node_modules` — which is here and nowhere else. The
129
+ // same walk makes the file CommonJS: the sidecar's `package.json` is the
130
+ // nearest one, so a project that declares `"type": "module"` (umami does)
131
+ // does not turn the probe's `.js` into an ES module. Removed after the run;
132
+ // a leftover from a killed run is not in the runner environment, so the next
133
+ // materialization clears it.
134
+ const NEXT_PROBE_ROOT = `${BEHAVIORAL_DIR}/world-probe`;
135
+ const CUCUMBER_NODE_NOTICE = /^This Node\.js version \(v[\d.]+\) has not been tested with this version of Cucumber/;
136
+ const NEXT_PROBE_FEATURE = `Feature: Unitbob World profile
137
+ Scenario: the application answers through the door
138
+ When the World probe asks the application for "/"
139
+ Then the application answers with an HTTP status
140
+ `;
141
+ const NEXT_PROBE_STEPS = `const assert = require('node:assert');
142
+ const { Then, When } = require('@cucumber/cucumber');
143
+
144
+ // \`{string}\` rather than a literal \`"/"\`: in a Cucumber Expression \`/\` is
145
+ // the alternation character, and the literal never matched.
146
+ When('the World probe asks the application for {string}', async function (path) {
147
+ this.unitbobProbeResponse = await this.unitbobFetch(path);
148
+ });
149
+
150
+ Then('the application answers with an HTTP status', function () {
151
+ assert.ok(Number.isInteger(this.unitbobProbeResponse.status), 'no status came back');
152
+ });
153
+ `;
47
154
  const PROBE_FEATURE = `Feature: Unitbob World profile
48
155
  Scenario: request, assertion counter, mocks, and state mutation
49
156
  Given the first World probe scenario mutates supported state
@@ -0,0 +1,137 @@
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
+ export function nextAppRoutes(projectRoot) {
43
+ const appDir = nextAppDir(projectRoot);
44
+ const routing = { rows: [], unread: [] };
45
+ if (!appDir)
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;
50
+ }
51
+ function walk(dir, relative, segments, routing) {
52
+ const path = urlOfSegments(segments);
53
+ // A subtree with no URL has no addresses anywhere below it.
54
+ if (path === null)
55
+ return;
56
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
57
+ if (entry.isDirectory()) {
58
+ walk(join(dir, entry.name), `${relative}/${entry.name}`, [...segments, entry.name], routing);
59
+ continue;
60
+ }
61
+ if (!entry.isFile())
62
+ continue;
63
+ const file = `${relative}/${entry.name}`;
64
+ if (PAGE_FILE.test(entry.name)) {
65
+ routing.rows.push({ verb: 'GET', path, file, method: 'default' });
66
+ }
67
+ else if (ROUTE_FILE.test(entry.name)) {
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);
76
+ }
77
+ }
78
+ }
79
+ // The URL a folder path names, or null when Next gives it none.
80
+ //
81
+ // `(group)` organises files and is absent from the URL. `@slot` is a parallel
82
+ // route rendered inside its parent's page; `(.)`, `(..)`, `(...)` and
83
+ // `(..)(..)` intercept another route — neither has a URL of its own, and
84
+ // nothing below them does either. `[id]`, `[...slug]` and `[[...slug]]` are
85
+ // kept in Next's own spelling: the map already writes
86
+ // `GET /api/admin/projects/[id]/clients`, and Rails' inventory keeps `:id`
87
+ // the same way — the address as the framework declares it, not a pattern we
88
+ // rewrite.
89
+ export function urlOfSegments(segments) {
90
+ const kept = [];
91
+ for (const segment of segments) {
92
+ if (segment.startsWith('@') || /^\(\.{1,3}\)/.test(segment))
93
+ return null;
94
+ if (/^\(.*\)$/.test(segment))
95
+ continue;
96
+ kept.push(segment);
97
+ }
98
+ return `/${kept.join('/')}`;
99
+ }
100
+ // The seven methods a route file may export, in the order the inventory lists
101
+ // them for one path.
102
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
103
+ const METHOD = HTTP_METHODS.join('|');
104
+ // The five ways a route file declares a method, each read from the text:
105
+ // export async function GET() / export function GET()
106
+ // export const GET = …
107
+ // export { GET, POST } and export { GET, POST } from './x'
108
+ // export { handler as GET } … — the name after `as` is the export
109
+ // export const { GET, POST } = handlers (the Auth.js shape)
110
+ //
111
+ // Not read: `export * from './handlers'` — the file names no method, and
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;
117
+ const DECLARED = [
118
+ new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s+(${METHOD})\\b`, 'gm'),
119
+ new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+(${METHOD})\\s*[=:]`, 'gm'),
120
+ ];
121
+ const EXPORT_LIST = /^\s*export\s+(?:(?:const|let|var)\s+)?\{([^}]*)\}/gm;
122
+ export function exportedHttpMethods(text) {
123
+ const found = new Set();
124
+ for (const pattern of DECLARED) {
125
+ for (const match of text.matchAll(pattern))
126
+ found.add(match[1]);
127
+ }
128
+ for (const match of text.matchAll(EXPORT_LIST)) {
129
+ for (const item of match[1].split(',')) {
130
+ // `a as B` exports B; `A` exports A. Only the exported name is an address.
131
+ const exported = item.trim().split(/\s+as\s+/).pop()?.trim() ?? '';
132
+ if (HTTP_METHODS.includes(exported))
133
+ found.add(exported);
134
+ }
135
+ }
136
+ return HTTP_METHODS.filter((method) => found.has(method));
137
+ }
@@ -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,42 @@ 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, unread } = 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, {
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');
72
+ }
73
+ // The inventory, and only the inventory. `surfaces.json` is not written here,
74
+ // however tempting: the recipe turns this file into that one with a single
75
+ // command — no output spent retyping addresses, and the file still does not
76
+ // exist until the model has begun. That absence is a check we would otherwise
77
+ // throw away, because `put-map-build` refuses a build whose `surfaces.json` is
78
+ // missing, and a pre-written file full of routes looks finished enough for the
79
+ // job, table and external surfaces never to be looked for.
80
+ function writeInventory(projectRoot, document, source) {
46
81
  const path = routeInventoryPath(projectRoot);
47
82
  try {
48
83
  mkdirSync(dirname(path), { recursive: true });
49
- writeFileSync(path, `${JSON.stringify({ declared_by: 'rails router', environment: asked.environment, surfaces }, null, 2)}\n`);
84
+ writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`);
50
85
  }
51
86
  catch (err) {
52
87
  // A read-only checkout or a full disk is not a reason to take the whole map
@@ -57,9 +92,11 @@ export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
57
92
  return {
58
93
  status: 'written',
59
94
  path,
60
- routes: surfaces.length,
61
- linked: surfaces.filter((surface) => surface.handler_symbol).length,
62
- environment: asked.environment,
95
+ routes: document.surfaces.length,
96
+ linked: document.surfaces.filter((surface) => surface.handler_symbol).length,
97
+ environment: document.environment,
98
+ source,
99
+ ...(document.unread ? { unread: document.unread.length } : {}),
63
100
  };
64
101
  }
65
102
  // One question, up to two environments. Everything that can be decided from the
@@ -122,8 +159,11 @@ function silent(projectRoot, reason, detail) {
122
159
  export function describeRouteInventory(result) {
123
160
  if (result.status === 'written') {
124
161
  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 ` +
162
+ `from the ${result.source === 'files' ? 'file system' : 'router'}` +
163
+ `${result.source === 'router' && result.environment === 'default' ? ' (read in the default environment ' +
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 ` +
127
167
  'this file into surfaces.json with one command, then adds the job, table and external surfaces to it.');
128
168
  }
129
169
  return `No route inventory: ${becauseOf(result)}. The extract_surfaces recipe reads the source instead.`;
@@ -133,7 +173,7 @@ export function describeRouteInventory(result) {
133
173
  function becauseOf(result) {
134
174
  switch (result.reason) {
135
175
  case 'unsupported_stack':
136
- return 'this project has no router Unitbob can ask yet (Rails is the only one so far)';
176
+ return 'this project has no router Unitbob can ask yet (Rails and Next.js (app router) so far)';
137
177
  case 'app_did_not_load':
138
178
  return `the router could not be asked — ${result.detail}`;
139
179
  case 'did_not_finish':
@@ -164,8 +204,19 @@ function plural(count, one, many) {
164
204
  // carry a `config/routes.rb` was asked to boot Rails. There are still several
165
205
  // stack detectors in this package; this removes the one that had a single caller
166
206
  // and no excuse.
167
- function canAskTheRouter(projectRoot) {
168
- return (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb')));
207
+ //
208
+ // Next.js is the second source (spec 56-1), and it is a different kind: there
209
+ // is no router object to ask, the files are the declaration. `looksLikeNext` is
210
+ // the one answer to "is this Next?" in this package — the World that opens the
211
+ // door into the application (`files/behavioral.ts`) asks the same function, so
212
+ // the two cannot disagree.
213
+ function routeSourceOf(projectRoot) {
214
+ if (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb'))) {
215
+ return 'router';
216
+ }
217
+ if (looksLikeNext(projectRoot))
218
+ return 'files';
219
+ return null;
169
220
  }
170
221
  // The question, asked of the router object rather than of the `rails routes`
171
222
  // command line. `--expanded` was the earlier reading, and it cost this project
@@ -294,6 +345,21 @@ function toSurface(projectRoot, row, nodes) {
294
345
  surface.handler_label = `${row.controller}#${row.action}`;
295
346
  return surface;
296
347
  }
348
+ // The Next.js counterpart of `toSurface`. The file is the declaration, so it is
349
+ // always the `source_file`; the label is `<file>#<method>` — `#default` for a
350
+ // page — which is what the file itself says, the way `settings#index` is what
351
+ // the router said. The link is looked up by the same two facts as on Rails:
352
+ // this file, this method name. A page whose default export the graph did not
353
+ // record keeps its address with no link — that is the `AdminDashboard` page of
354
+ // the appartment run, and it is normal.
355
+ function toNextSurface(row, nodes) {
356
+ const surface = { kind: 'route', id: `${row.verb} ${row.path}`, source_file: row.file };
357
+ const node = findNode(nodes, row.file, row.method);
358
+ if (node)
359
+ surface.handler_symbol = node.id;
360
+ surface.handler_label = `${row.file}#${row.method}`;
361
+ return surface;
362
+ }
297
363
  // The link is *searched for*, never spelled. graphify names its node ids by an
298
364
  // internal rule, it is a third-party package (`pip install graphifyy`), and that
299
365
  // rule may change in any release. Rebuilding the rule here would make us
@@ -348,22 +414,29 @@ function findNode(nodes, file, action) {
348
414
  export function inventoryProblems(inventory, surfaces) {
349
415
  const declared = routeEntriesIn(inventory);
350
416
  if (declared === null) {
351
- 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'];
352
418
  }
353
419
  const written = routeEntriesIn(surfaces);
354
420
  if (written === null)
355
421
  return ['surfaces.json has no readable `surfaces` array'];
356
422
  const problems = [];
357
423
  const missing = [...declared.keys()].filter((id) => !written.has(id));
358
- 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));
359
429
  if (missing.length > 0) {
360
- 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)}. ` +
361
431
  'Copy every entry of the route inventory across unchanged.');
362
432
  }
363
433
  if (invented.length > 0) {
364
- problems.push(`surfaces.json carries ${invented.length} route the router never declared: ${list(invented)}. ` +
365
- 'The router is the authority on what exists; an address it does not know is either a typo ' +
366
- '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
+ : ''));
367
440
  }
368
441
  // Two sentences rather than one, because the two kinds of rewrite are not the
369
442
  // same mistake and the person reading has to fix the right thing: one sends a
@@ -377,7 +450,7 @@ export function inventoryProblems(inventory, surfaces) {
377
450
  const relabelled = alteredFields(declared, written, LABEL_FIELDS);
378
451
  if (relabelled.length > 0) {
379
452
  problems.push(`surfaces.json reworded ${relabelled.length} handler_label: ${list(relabelled)}. ` +
380
- '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 ' +
381
454
  'capability titles in the next step, not this field.');
382
455
  }
383
456
  return problems;
@@ -407,6 +480,12 @@ function alteredFields(declared, written, fields) {
407
480
  }
408
481
  return altered;
409
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
+ }
410
489
  function routeEntriesIn(document) {
411
490
  const surfaces = document?.surfaces;
412
491
  if (!Array.isArray(surfaces))
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 {
@@ -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