unitbob 0.7.14 → 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.
- package/README.md +1 -1
- package/dist/cli.js +7 -3
- package/dist/files/behavioral.js +188 -4
- package/dist/files/packets.js +39 -5
- package/dist/files/workerPlan.js +81 -14
- package/dist/runner/bdd.js +19 -0
- package/dist/runner/bootcheck.js +34 -8
- package/dist/runner/worldProbe.js +100 -3
- package/dist/surfaces/nextRoutes.js +133 -0
- package/dist/surfaces/routeInventory.js +76 -21
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putMapBuild.js +1 -1
- package/dist/verbs/suitePrepare.js +41 -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,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
|
-
|
|
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
|
|
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,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
|
-
|
|
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 = 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(
|
|
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:
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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 {
|
|
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 ??
|
|
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).
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
'
|
|
548
|
-
'
|
|
549
|
-
|
|
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`;
|