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.
- package/README.md +1 -1
- package/dist/cli.js +7 -3
- package/dist/files/behavioral.js +192 -4
- package/dist/files/packets.js +39 -5
- package/dist/files/workerPlan.js +83 -14
- package/dist/runner/bdd.js +19 -0
- package/dist/runner/bootcheck.js +36 -8
- package/dist/runner/worldProbe.js +112 -5
- package/dist/surfaces/nextRoutes.js +137 -0
- package/dist/surfaces/routeInventory.js +107 -28
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putMapBuild.js +2 -2
- package/dist/verbs/suitePrepare.js +44 -25
- package/dist/verbs/validateWorkerCheckpoints.js +228 -72
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +5 -2
- package/plugin/codex/agents/suite-reviewer.toml +4 -0
- package/plugin/codex/agents/suite-worker.toml +21 -10
|
@@ -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
|
-
|
|
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
|
|
23
|
-
// records why Django, FastAPI and Flask come next and
|
|
24
|
-
// at all (its addresses are registered by arbitrary
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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:
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
365
|
-
'The
|
|
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
|
|
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
|
|
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
|