unitbob 0.2.7 → 0.3.0
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 +19 -10
- package/dist/cli.js +136 -17
- package/dist/config.js +41 -3
- package/dist/files/behavioral.js +9 -1
- package/dist/files/mapBuild.js +2 -1
- package/dist/files/suiteBuild.js +37 -2
- package/dist/link.js +30 -17
- package/dist/links.js +24 -0
- package/dist/proc.js +15 -1
- package/dist/runner/bdd.js +21 -14
- package/dist/runner/bootcheck.js +389 -0
- package/dist/runner/manifest.js +87 -0
- package/dist/runner/precheck.js +31 -3
- package/dist/runner/provision.js +6 -1
- package/dist/runner/rspec.js +1 -10
- package/dist/surfaces/routeInventory.js +448 -0
- package/dist/verbs/extractSurfaces.js +17 -0
- package/dist/verbs/mapPrepare.js +15 -1
- package/dist/verbs/putMapBuild.js +69 -2
- package/dist/verbs/putSuiteBuild.js +138 -30
- package/dist/verbs/run.js +38 -4
- package/dist/verbs/show.js +2 -1
- package/dist/verbs/suitePrepare.js +191 -19
- package/dist/verbs/validateBuild.js +240 -0
- package/dist/wire.js +25 -3
- package/package.json +1 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { runProcess } from "../proc.js";
|
|
4
|
+
import { firstErrorLine } from "../runner/bootcheck.js";
|
|
5
|
+
import { graphPath } from "../files/mapBuild.js";
|
|
6
|
+
// Reading a router means booting the application, which on a large Rails app is
|
|
7
|
+
// tens of seconds. The same budget the other boot-shaped step uses.
|
|
8
|
+
const ROUTES_TIMEOUT_MS = 120_000;
|
|
9
|
+
const defaultDeps = {
|
|
10
|
+
runCmd: (command, args, options) => runProcess(command, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
timeoutMs: ROUTES_TIMEOUT_MS,
|
|
13
|
+
env: { ...process.env, ...options.env },
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
export function routeInventoryPath(projectRoot) {
|
|
17
|
+
return join(projectRoot, '.unitbob', 'map-build', 'route_inventory.json');
|
|
18
|
+
}
|
|
19
|
+
// Ask this project's router, and write what it says. Rails today; the design
|
|
20
|
+
// records why Django, FastAPI and Flask come next and why Express cannot follow
|
|
21
|
+
// at all (its addresses are registered by arbitrary code at run time).
|
|
22
|
+
export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
|
|
23
|
+
if (!looksLikeRails(projectRoot))
|
|
24
|
+
return silent(projectRoot, 'unsupported_stack');
|
|
25
|
+
const asked = await askTheRouter(projectRoot, deps);
|
|
26
|
+
if ('reason' in asked)
|
|
27
|
+
return silent(projectRoot, asked.reason, asked.detail);
|
|
28
|
+
const rows = parseExpandedRoutes(asked.result.stdout);
|
|
29
|
+
// Zero rows is far likelier to mean "this output is not what we know how to
|
|
30
|
+
// read" than "this application has no addresses". Claiming the second would
|
|
31
|
+
// hand the map a confident, empty answer, so we claim neither.
|
|
32
|
+
if (rows.length === 0)
|
|
33
|
+
return silent(projectRoot, 'no_routes');
|
|
34
|
+
const nodes = graphNodes(projectRoot);
|
|
35
|
+
const surfaces = rows.map((row) => toSurface(projectRoot, row, nodes));
|
|
36
|
+
// The inventory, and only the inventory. `surfaces.json` is not written here,
|
|
37
|
+
// however tempting: the recipe turns this file into that one with a single
|
|
38
|
+
// command — no output spent retyping addresses, and the file still does not
|
|
39
|
+
// exist until the model has begun. That absence is a check we would otherwise
|
|
40
|
+
// throw away, because `put-map-build` refuses a build whose `surfaces.json` is
|
|
41
|
+
// missing, and a pre-written file full of routes looks finished enough for the
|
|
42
|
+
// job, table and external surfaces never to be looked for.
|
|
43
|
+
const path = routeInventoryPath(projectRoot);
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
writeFileSync(path, `${JSON.stringify({ declared_by: 'rails routes', environment: asked.environment, surfaces }, null, 2)}\n`);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
// A read-only checkout or a full disk is not a reason to take the whole map
|
|
50
|
+
// build down: without an inventory the recipe reads the source, exactly as
|
|
51
|
+
// on every stack we cannot ask.
|
|
52
|
+
return silent(projectRoot, 'could_not_write', err.message);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
status: 'written',
|
|
56
|
+
path,
|
|
57
|
+
routes: surfaces.length,
|
|
58
|
+
linked: surfaces.filter((surface) => surface.handler_symbol).length,
|
|
59
|
+
environment: asked.environment,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// One question, up to two environments. Everything that can be decided from the
|
|
63
|
+
// first answer is decided there; only "this environment refused to load" is
|
|
64
|
+
// worth asking again, because that is the one failure an environment can own.
|
|
65
|
+
async function askTheRouter(projectRoot, deps) {
|
|
66
|
+
const first = await runRailsRoutes(projectRoot, deps, 'test');
|
|
67
|
+
if (first === null) {
|
|
68
|
+
// The command is not on this machine. A second environment cannot conjure it.
|
|
69
|
+
return { reason: 'app_did_not_load', detail: 'the command did not run on this machine' };
|
|
70
|
+
}
|
|
71
|
+
if (first.code === 0)
|
|
72
|
+
return { result: first, environment: 'test' };
|
|
73
|
+
// Rails learned `--expanded` in 5.1. Older ones reject the flag before they
|
|
74
|
+
// load anything, and saying "your application did not load" there sends
|
|
75
|
+
// somebody to debug an application that is perfectly fine — the same reason
|
|
76
|
+
// the boot check grew `runner_too_old` rather than calling vitest missing.
|
|
77
|
+
// The flag is refused the same way in every environment, so there is nothing
|
|
78
|
+
// to retry.
|
|
79
|
+
if (rejectedTheFlag(first))
|
|
80
|
+
return { reason: 'router_too_old' };
|
|
81
|
+
// No exit code at all means the process was stopped rather than finished —
|
|
82
|
+
// our own timeout, or a signal from outside. An application too large to
|
|
83
|
+
// enumerate its routes in the time we allow has told us nothing about whether
|
|
84
|
+
// it is healthy, and "it did not load" would be us inventing the answer.
|
|
85
|
+
// Retrying would spend the same budget twice for the same silence.
|
|
86
|
+
if (first.code === null)
|
|
87
|
+
return { reason: 'did_not_finish' };
|
|
88
|
+
const second = await runRailsRoutes(projectRoot, deps, 'default');
|
|
89
|
+
if (second !== null && second.code === 0)
|
|
90
|
+
return { result: second, environment: 'default' };
|
|
91
|
+
if (second !== null && second.code === null)
|
|
92
|
+
return { reason: 'did_not_finish' };
|
|
93
|
+
// `rails routes` loads the application, so running it *is* the check: an app
|
|
94
|
+
// that will not boot cannot print its routes. We do not ask the boot check
|
|
95
|
+
// from spec 32-6 first — that would boot the app twice to learn the same
|
|
96
|
+
// thing — but we do answer in its currency, quoting the runner's own first
|
|
97
|
+
// error line rather than a paraphrase of it. The default environment's words
|
|
98
|
+
// are the ones quoted: if even that one will not load, the application does
|
|
99
|
+
// not load, and the test database is not what is missing.
|
|
100
|
+
const output = `${second?.stdout ?? first.stdout}\n${second?.stderr ?? first.stderr}`.trim();
|
|
101
|
+
return {
|
|
102
|
+
reason: 'app_did_not_load',
|
|
103
|
+
detail: output ? firstErrorLine(output) : 'the command did not run on this machine',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Every way of saying nothing goes through here, and every one of them takes
|
|
107
|
+
// yesterday's inventory with it. Silence must leave no answer behind: a file
|
|
108
|
+
// full of the addresses of a previous run sits in the folder the map build
|
|
109
|
+
// reads, under an authoritative name, and nothing on it says how old it is.
|
|
110
|
+
// `request.json` would not point at it, but a stale answer that looks current
|
|
111
|
+
// is the same failure this spec exists to remove — only quieter, because it was
|
|
112
|
+
// true once.
|
|
113
|
+
function silent(projectRoot, reason, detail) {
|
|
114
|
+
try {
|
|
115
|
+
rmSync(routeInventoryPath(projectRoot), { force: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Nothing to clear, or a path we cannot touch. Either way the answer below
|
|
119
|
+
// stands: this run declares no addresses.
|
|
120
|
+
}
|
|
121
|
+
return detail === undefined ? { status: 'silent', reason } : { status: 'silent', reason, detail };
|
|
122
|
+
}
|
|
123
|
+
// The one sentence a vibecoder reads about all this: how many addresses came
|
|
124
|
+
// back, or why none did. It lives beside the closed list of reasons rather than
|
|
125
|
+
// in the verb, because both the verb and `map-prepare` print it and neither owns
|
|
126
|
+
// the vocabulary.
|
|
127
|
+
export function describeRouteInventory(result) {
|
|
128
|
+
if (result.status === 'written') {
|
|
129
|
+
return (`Route inventory written to ${result.path}: ${result.routes} ${plural(result.routes, 'address', 'addresses')} ` +
|
|
130
|
+
`from the router${result.environment === 'default' ? ' (read in the default environment — the test one ' +
|
|
131
|
+
'would not load)' : ''}, ${result.linked} tied to a graph node. The extract_surfaces recipe turns ` +
|
|
132
|
+
'this file into surfaces.json with one command, then adds the job, table and external surfaces to it.');
|
|
133
|
+
}
|
|
134
|
+
return `No route inventory: ${becauseOf(result)}. The extract_surfaces recipe reads the source instead.`;
|
|
135
|
+
}
|
|
136
|
+
// Why we stayed quiet, in the vibecoder's words. A closed list, so the sentence
|
|
137
|
+
// is the same from run to run and a test can pin it.
|
|
138
|
+
function becauseOf(result) {
|
|
139
|
+
switch (result.reason) {
|
|
140
|
+
case 'unsupported_stack':
|
|
141
|
+
return 'this project has no router Unitbob can ask yet (Rails is the only one so far)';
|
|
142
|
+
case 'router_too_old':
|
|
143
|
+
return '`rails routes --expanded` needs Rails 5.1 or newer, and this Rails refused the flag ' +
|
|
144
|
+
'(nothing is wrong with the application)';
|
|
145
|
+
case 'app_did_not_load':
|
|
146
|
+
return `\`rails routes\` did not get through — ${result.detail}`;
|
|
147
|
+
case 'did_not_finish':
|
|
148
|
+
// No exit code means stopped, and we do not know by whom: our own
|
|
149
|
+
// ${minutes}-minute limit, or something outside this process. Naming only
|
|
150
|
+
// the timeout would put a duration on a run that may have been killed in
|
|
151
|
+
// ten seconds — a claim we cannot make.
|
|
152
|
+
return `\`rails routes\` was stopped before it finished — either it ran past the ` +
|
|
153
|
+
`${ROUTES_TIMEOUT_MS / 60_000}-minute limit or something else killed it (nothing here says the ` +
|
|
154
|
+
'application is unhealthy)';
|
|
155
|
+
case 'no_routes':
|
|
156
|
+
return '`rails routes` printed nothing this version knows how to read';
|
|
157
|
+
case 'could_not_write':
|
|
158
|
+
return `the inventory could not be written — ${result.detail}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function plural(count, one, many) {
|
|
162
|
+
return count === 1 ? one : many;
|
|
163
|
+
}
|
|
164
|
+
function looksLikeRails(projectRoot) {
|
|
165
|
+
return existsSync(join(projectRoot, 'config', 'routes.rb'));
|
|
166
|
+
}
|
|
167
|
+
// `--expanded` rather than the default table: one field per line, so nothing
|
|
168
|
+
// depends on guessing column widths, and a long URI pattern cannot run into its
|
|
169
|
+
// neighbour. `test` is asked first because that is the environment the guardrail
|
|
170
|
+
// suite boots in, so the routes read there are the routes the suite would see;
|
|
171
|
+
// `default` means we leave RAILS_ENV alone and take whatever the project's own
|
|
172
|
+
// setup chooses.
|
|
173
|
+
async function runRailsRoutes(projectRoot, deps, environment) {
|
|
174
|
+
const local = join(projectRoot, 'bin', 'rails');
|
|
175
|
+
const [command, args] = existsSync(local)
|
|
176
|
+
? [local, ['routes', '--expanded']]
|
|
177
|
+
: ['bundle', ['exec', 'rails', 'routes', '--expanded']];
|
|
178
|
+
try {
|
|
179
|
+
return await deps.runCmd(command, args, {
|
|
180
|
+
cwd: projectRoot,
|
|
181
|
+
env: environment === 'test'
|
|
182
|
+
? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot }
|
|
183
|
+
: { UNITBOB_REPO_ROOT: projectRoot },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null; // the command is not on this machine
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Ruby's own OptionParser wording, and Thor's — whichever layer of the `rails`
|
|
191
|
+
// command line sees the flag first. Both name the flag they refused, and the
|
|
192
|
+
// name has to be part of the match: "invalid option" is ordinary Ruby wording
|
|
193
|
+
// that also turns up inside a failing initializer, and reading that as an old
|
|
194
|
+
// Rails would tell somebody their application is fine while it is broken —
|
|
195
|
+
// exactly the wrong-errand this reason exists to prevent, pointing the other way.
|
|
196
|
+
function rejectedTheFlag(result) {
|
|
197
|
+
return /(?:invalid option|unknown switches?|unrecognized option)[^\n]*expanded/i.test(`${result.stdout}\n${result.stderr}`);
|
|
198
|
+
}
|
|
199
|
+
// The `--expanded` record:
|
|
200
|
+
//
|
|
201
|
+
// --[ Route 1 ]------------------------------
|
|
202
|
+
// Prefix | settings
|
|
203
|
+
// Verb | GET
|
|
204
|
+
// URI | /settings(.:format)
|
|
205
|
+
// Controller#Action | settings#index
|
|
206
|
+
//
|
|
207
|
+
// The path field is `URI` on Rails 7 and `URI Pattern` on older versions — both
|
|
208
|
+
// are read, because which one we get is decided by the user's Gemfile. Fields we
|
|
209
|
+
// do not use (Prefix, Source Location) are ignored rather than rejected, so a
|
|
210
|
+
// newer Rails printing more of them still reads.
|
|
211
|
+
export function parseExpandedRoutes(stdout) {
|
|
212
|
+
const rows = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
let record = {};
|
|
215
|
+
const flush = () => {
|
|
216
|
+
for (const row of rowsFrom(record)) {
|
|
217
|
+
const key = `${row.verb} ${row.path}`;
|
|
218
|
+
// The same address can be drawn under several prefixes. It is one address.
|
|
219
|
+
if (seen.has(key))
|
|
220
|
+
continue;
|
|
221
|
+
seen.add(key);
|
|
222
|
+
rows.push(row);
|
|
223
|
+
}
|
|
224
|
+
record = {};
|
|
225
|
+
};
|
|
226
|
+
for (const line of stdout.split('\n')) {
|
|
227
|
+
if (/^--\[ Route /.test(line)) {
|
|
228
|
+
flush();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const separator = line.indexOf('|');
|
|
232
|
+
if (separator === -1)
|
|
233
|
+
continue;
|
|
234
|
+
record[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
235
|
+
}
|
|
236
|
+
flush();
|
|
237
|
+
return rows;
|
|
238
|
+
}
|
|
239
|
+
function rowsFrom(record) {
|
|
240
|
+
const pattern = record.URI ?? record['URI Pattern'];
|
|
241
|
+
const verb = record.Verb ?? '';
|
|
242
|
+
// No verb means a mounted Rack application (`mount Sidekiq::Web at: '/sidekiq'`),
|
|
243
|
+
// not an address of this application. Its own routes live in its own router.
|
|
244
|
+
if (!pattern || !verb)
|
|
245
|
+
return [];
|
|
246
|
+
const handler = /^([A-Za-z0-9_/]+)#([A-Za-z0-9_]+)$/.exec(record['Controller#Action'] ?? '');
|
|
247
|
+
const path = pattern.replace(/\(\.:format\)$/, '');
|
|
248
|
+
// `match via: [:get, :post]` prints one record with both verbs. They are two
|
|
249
|
+
// addresses, and the map should say so.
|
|
250
|
+
return verb.split('|').map((one) => ({
|
|
251
|
+
verb: one.trim(),
|
|
252
|
+
path,
|
|
253
|
+
controller: handler?.[1],
|
|
254
|
+
action: handler?.[2],
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
function graphNodes(projectRoot) {
|
|
258
|
+
const path = graphPath(projectRoot);
|
|
259
|
+
if (!existsSync(path))
|
|
260
|
+
return [];
|
|
261
|
+
try {
|
|
262
|
+
const graph = JSON.parse(readFileSync(path, 'utf8'));
|
|
263
|
+
if (!Array.isArray(graph.nodes))
|
|
264
|
+
return [];
|
|
265
|
+
return graph.nodes.filter((node) => !!node && typeof node.id === 'string');
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return []; // an unreadable graph costs us the links, not the addresses
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function toSurface(projectRoot, row, nodes) {
|
|
272
|
+
const surface = { kind: 'route', id: `${row.verb} ${row.path}` };
|
|
273
|
+
if (!row.controller || !row.action)
|
|
274
|
+
return surface;
|
|
275
|
+
const file = join('app', 'controllers', `${row.controller}_controller.rb`);
|
|
276
|
+
const node = findNode(nodes, file, row.action);
|
|
277
|
+
// The node's own path when we found it; otherwise the file the convention
|
|
278
|
+
// names, and only if it is really there — a route from a gem has neither.
|
|
279
|
+
//
|
|
280
|
+
// This second branch is the one place the module answers with something the
|
|
281
|
+
// router did not print, so it is worth being plain about what it claims and
|
|
282
|
+
// what it does not. It claims: Rails resolves `settings#index` to
|
|
283
|
+
// `app/controllers/settings_controller.rb`, and that file exists here. It does
|
|
284
|
+
// not claim the method lives in that file — an inherited action's controller
|
|
285
|
+
// is on disk while the code that serves it is in a parent class elsewhere.
|
|
286
|
+
// That is why the same branch leaves `handler_symbol` out: the file is where
|
|
287
|
+
// Rails looks, the link is where the code is, and only the second one is
|
|
288
|
+
// allowed to be a guess-free fact.
|
|
289
|
+
if (node?.source_file)
|
|
290
|
+
surface.source_file = node.source_file;
|
|
291
|
+
else if (existsSync(join(projectRoot, file)))
|
|
292
|
+
surface.source_file = file;
|
|
293
|
+
if (node)
|
|
294
|
+
surface.handler_symbol = node.id;
|
|
295
|
+
// What the router printed, not a class name we built from it.
|
|
296
|
+
surface.handler_label = `${row.controller}#${row.action}`;
|
|
297
|
+
return surface;
|
|
298
|
+
}
|
|
299
|
+
// The link is *searched for*, never spelled. graphify names its node ids by an
|
|
300
|
+
// internal rule, it is a third-party package (`pip install graphifyy`), and that
|
|
301
|
+
// rule may change in any release. Rebuilding the rule here would make us
|
|
302
|
+
// responsible for somebody else's private detail and would break in a way we
|
|
303
|
+
// could not see. So we look the node up by the two things the router already
|
|
304
|
+
// knows — which file, which method — and copy the id it carries, whole.
|
|
305
|
+
//
|
|
306
|
+
// Not finding one is ordinary: the action may be drawn by a template with no
|
|
307
|
+
// method behind it, inherited from a parent class in another file, or come from
|
|
308
|
+
// a gem graphify never saw. Then the address keeps its place with no link. The
|
|
309
|
+
// router said it exists, so it exists; an empty field says "we do not know
|
|
310
|
+
// where this is served", while dropping the address would say it is not there
|
|
311
|
+
// at all.
|
|
312
|
+
// A wrong link is worse than no link — the address would send a later trace
|
|
313
|
+
// into somebody else's code, and nothing downstream could tell: the node it
|
|
314
|
+
// names really does exist, so the host's check passes. Hence the ranking below
|
|
315
|
+
// rather than "first node that fits".
|
|
316
|
+
function findNode(nodes, file, action) {
|
|
317
|
+
const candidates = nodes.filter((node) => typeof node.source_file === 'string' &&
|
|
318
|
+
typeof node.label === 'string' &&
|
|
319
|
+
methodNameOf(node.label) === action &&
|
|
320
|
+
pathsMatch(node.source_file, file) !== 'no');
|
|
321
|
+
// The path the graph gives may be relative to the project or absolute, so an
|
|
322
|
+
// exact match cannot be the only rule. But when one node names exactly the
|
|
323
|
+
// file the router pointed at, it wins outright: an engine, a second app in
|
|
324
|
+
// the monorepo or a vendored copy can end with the same
|
|
325
|
+
// `app/controllers/users_controller.rb` and would otherwise be picked by
|
|
326
|
+
// nothing better than graph order.
|
|
327
|
+
//
|
|
328
|
+
// One, though — not "the first of several". Two nodes can name the same file
|
|
329
|
+
// and the same method: two classes in one file, or a graph that lists a
|
|
330
|
+
// definition more than once. Taking the earlier one there is the very rule
|
|
331
|
+
// this branch was written to replace, one level down, so the same answer
|
|
332
|
+
// applies as below: we do not know, and we say so by staying quiet.
|
|
333
|
+
const exact = candidates.filter((node) => pathsMatch(node.source_file, file) === 'exact');
|
|
334
|
+
if (exact.length > 0)
|
|
335
|
+
return exact.length === 1 ? exact[0] : undefined;
|
|
336
|
+
// Ends with the right path and is the only one that does. Two or more and we
|
|
337
|
+
// cannot tell which file the router meant, so we say nothing — the address
|
|
338
|
+
// still ships, without a link.
|
|
339
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
340
|
+
}
|
|
341
|
+
function pathsMatch(candidate, file) {
|
|
342
|
+
const normalised = candidate.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
343
|
+
const wanted = file.replace(/\\/g, '/');
|
|
344
|
+
if (normalised === wanted)
|
|
345
|
+
return 'exact';
|
|
346
|
+
return normalised.endsWith(`/${wanted}`) ? 'suffix' : 'no';
|
|
347
|
+
}
|
|
348
|
+
// Real graphify labels a Ruby method `.send_to_fsa()` and a JS one
|
|
349
|
+
// `initButtons()`; a qualified `CheckoutController#create` also turns up. All of
|
|
350
|
+
// them are read the same way — drop the call parentheses, then take the last
|
|
351
|
+
// name — so the match survives the decoration without depending on which form
|
|
352
|
+
// this release of graphify happens to use. The id itself is never rebuilt from
|
|
353
|
+
// any of this; it is copied.
|
|
354
|
+
function methodNameOf(label) {
|
|
355
|
+
const parts = label.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
|
|
356
|
+
return parts[parts.length - 1] ?? '';
|
|
357
|
+
}
|
|
358
|
+
// What `surfaces.json` must contain for every address the router declared, and
|
|
359
|
+
// what it must not contain on top of them. Used before upload (`put-map-build`):
|
|
360
|
+
// the inventory removed the model's chance to invent an address, and this
|
|
361
|
+
// removes its chance to lose or rename one on the way to the map. The same move
|
|
362
|
+
// spec 32-6 made for suites — check the answer against the request locally,
|
|
363
|
+
// while both files are still on this machine.
|
|
364
|
+
//
|
|
365
|
+
// Routes only. Whether the grouping is sensible, whether the human names are
|
|
366
|
+
// good, what the jobs and tables are — none of that is this function's business.
|
|
367
|
+
export function inventoryProblems(inventory, surfaces) {
|
|
368
|
+
const declared = routeEntriesIn(inventory);
|
|
369
|
+
if (declared === null) {
|
|
370
|
+
return ['the route inventory is not readable, so what the router declared cannot be confirmed'];
|
|
371
|
+
}
|
|
372
|
+
const written = routeEntriesIn(surfaces);
|
|
373
|
+
if (written === null)
|
|
374
|
+
return ['surfaces.json has no readable `surfaces` array'];
|
|
375
|
+
const problems = [];
|
|
376
|
+
const missing = [...declared.keys()].filter((id) => !written.has(id));
|
|
377
|
+
const invented = [...written.keys()].filter((id) => !declared.has(id));
|
|
378
|
+
if (missing.length > 0) {
|
|
379
|
+
problems.push(`surfaces.json is missing ${missing.length} address the router declared: ${list(missing)}. ` +
|
|
380
|
+
'Copy every entry of the route inventory across unchanged.');
|
|
381
|
+
}
|
|
382
|
+
if (invented.length > 0) {
|
|
383
|
+
problems.push(`surfaces.json carries ${invented.length} route the router never declared: ${list(invented)}. ` +
|
|
384
|
+
'The router is the authority on what exists; an address it does not know is either a typo ' +
|
|
385
|
+
'in a copied id or invented.');
|
|
386
|
+
}
|
|
387
|
+
// Two sentences rather than one, because the two kinds of rewrite are not the
|
|
388
|
+
// same mistake and the person reading has to fix the right thing: one sends a
|
|
389
|
+
// later trace into the wrong code, the other only renames what is shown.
|
|
390
|
+
const relinked = alteredFields(declared, written, LINK_FIELDS);
|
|
391
|
+
if (relinked.length > 0) {
|
|
392
|
+
problems.push(`surfaces.json rewrote ${relinked.length} field that points at code: ${list(relinked)}. ` +
|
|
393
|
+
'Copy each entry as it stands — a field the inventory leaves out is an answer too, ' +
|
|
394
|
+
'"we do not know where this is served", and filling it in makes one up.');
|
|
395
|
+
}
|
|
396
|
+
const relabelled = alteredFields(declared, written, LABEL_FIELDS);
|
|
397
|
+
if (relabelled.length > 0) {
|
|
398
|
+
problems.push(`surfaces.json reworded ${relabelled.length} handler_label: ${list(relabelled)}. ` +
|
|
399
|
+
'That name is what the router itself printed; the human names for the map are the ' +
|
|
400
|
+
'capability titles in the next step, not this field.');
|
|
401
|
+
}
|
|
402
|
+
return problems;
|
|
403
|
+
}
|
|
404
|
+
// The id says the address exists; these say where it is served, and both were
|
|
405
|
+
// either printed by the router or copied out of the graph. A blank is not a gap
|
|
406
|
+
// to be helpfully filled — an invented `source_file` is the same failure as an
|
|
407
|
+
// invented address, one field over, and a `handler_symbol` swapped for another
|
|
408
|
+
// node that happens to exist passes the host's check while sending spec 35's
|
|
409
|
+
// trace into the wrong code.
|
|
410
|
+
const LINK_FIELDS = ['source_file', 'handler_symbol'];
|
|
411
|
+
// Not a link and nothing downstream reads it — but spec 32-7 took the authoring
|
|
412
|
+
// of names away from the model on purpose, and `settings#index` as the router
|
|
413
|
+
// printed it is the whole of what we claim. A prettier `Api::V1::UsersController`
|
|
414
|
+
// is simply wrong on a project with its own inflections.
|
|
415
|
+
const LABEL_FIELDS = ['handler_label'];
|
|
416
|
+
function alteredFields(declared, written, fields) {
|
|
417
|
+
const altered = [];
|
|
418
|
+
for (const [id, entry] of declared) {
|
|
419
|
+
const copy = written.get(id);
|
|
420
|
+
if (!copy)
|
|
421
|
+
continue; // already reported as missing
|
|
422
|
+
for (const field of fields) {
|
|
423
|
+
if (entry[field] !== copy[field])
|
|
424
|
+
altered.push(`${id} → ${field}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return altered;
|
|
428
|
+
}
|
|
429
|
+
function routeEntriesIn(document) {
|
|
430
|
+
const surfaces = document?.surfaces;
|
|
431
|
+
if (!Array.isArray(surfaces))
|
|
432
|
+
return null;
|
|
433
|
+
const entries = new Map();
|
|
434
|
+
for (const surface of surfaces) {
|
|
435
|
+
if (!surface || surface.kind !== 'route')
|
|
436
|
+
continue;
|
|
437
|
+
const id = surface.id;
|
|
438
|
+
if (typeof id !== 'string' || entries.has(id))
|
|
439
|
+
continue;
|
|
440
|
+
entries.set(id, surface);
|
|
441
|
+
}
|
|
442
|
+
return entries;
|
|
443
|
+
}
|
|
444
|
+
// Enough ids to recognise the mistake, not so many that the message scrolls off.
|
|
445
|
+
function list(ids) {
|
|
446
|
+
const shown = ids.slice(0, 10);
|
|
447
|
+
return ids.length > shown.length ? `${shown.join(', ')}, … (+${ids.length - shown.length})` : shown.join(', ');
|
|
448
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ensureUnitbobIgnored } from "../proc.js";
|
|
2
|
+
import { describeRouteInventory, extractRouteInventory } from "../surfaces/routeInventory.js";
|
|
3
|
+
// Spec 32-7. `unitbob extract-surfaces` asks the framework's own router for the
|
|
4
|
+
// application's addresses and writes them down. It has the right to say nothing,
|
|
5
|
+
// and says nothing far more often than it speaks: only Rails can be asked today,
|
|
6
|
+
// and only when the application loads.
|
|
7
|
+
//
|
|
8
|
+
// Silence exits 0. Nothing is wrong when a stack has no router to ask — the
|
|
9
|
+
// map is still built, by the path that has always built it.
|
|
10
|
+
export async function extractSurfaces(config, _args = []) {
|
|
11
|
+
// Run on its own, this verb is the first thing to write into `.unitbob/`, so
|
|
12
|
+
// it owes the project the same courtesy `map-prepare` does: the folder is our
|
|
13
|
+
// bookkeeping and must not turn up in the vibecoder's commit.
|
|
14
|
+
ensureUnitbobIgnored(config.projectRoot);
|
|
15
|
+
const result = await extractRouteInventory(config.projectRoot);
|
|
16
|
+
process.stdout.write(`${describeRouteInventory(result)}\n`);
|
|
17
|
+
}
|
package/dist/verbs/mapPrepare.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ensureUnitbobIgnored, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
|
|
2
2
|
import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
|
|
3
|
+
import { describeRouteInventory, extractRouteInventory, } from "../surfaces/routeInventory.js";
|
|
3
4
|
import { Wire } from "../wire.js";
|
|
4
5
|
export async function mapPrepare(config, _args = [], deps) {
|
|
5
6
|
const wire = new Wire(config);
|
|
@@ -7,6 +8,7 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
7
8
|
requireGraphify,
|
|
8
9
|
ensureUnitbobIgnored,
|
|
9
10
|
runGraphifyExtractKeyless,
|
|
11
|
+
extractRouteInventory,
|
|
10
12
|
getRecipe: (name) => wire.getRecipe(name),
|
|
11
13
|
...deps,
|
|
12
14
|
};
|
|
@@ -20,6 +22,17 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
20
22
|
throw new Error(`graphify update failed: ${detail}`);
|
|
21
23
|
}
|
|
22
24
|
readFreshGraph(config.projectRoot);
|
|
25
|
+
// Asked here rather than left to the workflow as a step of its own (spec
|
|
26
|
+
// 32-7). The graph has just been refreshed, which is what the addresses are
|
|
27
|
+
// linked against, and a step an instruction can skip is a step that gets
|
|
28
|
+
// skipped — the lesson spec 32-4 paid for. Silence costs nothing: the request
|
|
29
|
+
// simply carries no inventory and the recipe reads the source.
|
|
30
|
+
//
|
|
31
|
+
// Said out loud first, because asking a router means booting the application
|
|
32
|
+
// and that can take a minute or two with nothing on the screen. Silence from
|
|
33
|
+
// us there reads as a hang.
|
|
34
|
+
process.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
|
|
35
|
+
const inventory = await actual.extractRouteInventory(config.projectRoot);
|
|
23
36
|
const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
|
|
24
37
|
actual.getRecipe('decompose'),
|
|
25
38
|
actual.getRecipe('relate'),
|
|
@@ -31,8 +44,9 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
31
44
|
relate,
|
|
32
45
|
extract_surfaces: extractSurfaces,
|
|
33
46
|
decompose_surfaces: decomposeSurfaces,
|
|
34
|
-
});
|
|
47
|
+
}, inventory.status === 'written' ? inventory.path : undefined);
|
|
35
48
|
process.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
|
|
49
|
+
process.stdout.write(`${describeRouteInventory(inventory)}\n`);
|
|
36
50
|
process.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
|
|
37
51
|
`${packet.output_path} (recipes.decompose, recipes.relate), and the surface map at ` +
|
|
38
52
|
`${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { readHostMapOutput, readMapBuildRequest, readSurfaceDocument, readSurfacesInventory, } from "../files/mapBuild.js";
|
|
4
|
+
import { enterUrl } from "../links.js";
|
|
5
|
+
import { inventoryProblems } from "../surfaces/routeInventory.js";
|
|
3
6
|
import { Wire } from "../wire.js";
|
|
4
7
|
export async function putMapBuild(config, _args = [], deps) {
|
|
5
8
|
const packet = readMapBuildRequest(config.projectRoot);
|
|
@@ -10,6 +13,8 @@ export async function putMapBuild(config, _args = [], deps) {
|
|
|
10
13
|
const mapDocument = readHostMapOutput(packet.output_path);
|
|
11
14
|
const surfaces = readSurfacesInventory(packet.surfaces_path);
|
|
12
15
|
const surfaceDocument = readSurfaceDocument(packet.surface_output_path);
|
|
16
|
+
refuseAlteredAddresses(packet.route_inventory_path, surfaces);
|
|
17
|
+
refuseSurfacesWithoutStorage(config.projectRoot, surfaces);
|
|
13
18
|
const actual = {
|
|
14
19
|
putMapBuild: (payload) => new Wire(config).putMapBuild(payload),
|
|
15
20
|
...deps,
|
|
@@ -22,5 +27,67 @@ export async function putMapBuild(config, _args = [], deps) {
|
|
|
22
27
|
});
|
|
23
28
|
process.stdout.write(`Map uploaded (map ${result.map_digest}, surface ${result.surface_digest}, graph ${result.graph_digest}) ` +
|
|
24
29
|
`${result.reused ? 'reused' : 'created'} version ${result.map_version_id}.\n` +
|
|
25
|
-
|
|
30
|
+
// The server hands back a clean address; the token is added here, at the
|
|
31
|
+
// moment of printing, so it never rides in a response body or a server log.
|
|
32
|
+
`${enterUrl(config, result.map_url)}\n`);
|
|
33
|
+
}
|
|
34
|
+
// Spec 32-7. Where the router could be asked, the addresses are a fact, and the
|
|
35
|
+
// only thing standing between that fact and the map was an instruction in a
|
|
36
|
+
// prompt — the same kind of instruction that already said "never spell an id
|
|
37
|
+
// yourself" while a2time's map grew an address no router had. So the two files
|
|
38
|
+
// are compared here, where both are still on this machine, and a mismatch stops
|
|
39
|
+
// the upload with the ids named.
|
|
40
|
+
//
|
|
41
|
+
// This decides nothing about the map: it does not group, rank or judge a single
|
|
42
|
+
// surface. It compares the answer with the request, which is the line spec 32-6
|
|
43
|
+
// drew for `validate-build`, and refuses to send an answer that contradicts a
|
|
44
|
+
// fact we already hold.
|
|
45
|
+
function refuseAlteredAddresses(inventoryPath, surfaces) {
|
|
46
|
+
if (!inventoryPath)
|
|
47
|
+
return; // no router was asked; there is nothing to hold the answer to
|
|
48
|
+
if (!existsSync(inventoryPath)) {
|
|
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.');
|
|
51
|
+
}
|
|
52
|
+
let inventory = null;
|
|
53
|
+
try {
|
|
54
|
+
inventory = JSON.parse(readFileSync(inventoryPath, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
inventory = null; // unreadable is itself a problem, and reads as one below
|
|
58
|
+
}
|
|
59
|
+
const problems = inventoryProblems(inventory, surfaces);
|
|
60
|
+
if (problems.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
throw new Error(`The routes in surfaces.json do not match the ones this project's router declared, so nothing ` +
|
|
63
|
+
`was uploaded and the previous map stays current:\n- ${problems.join('\n- ')}`);
|
|
64
|
+
}
|
|
65
|
+
// Spec 32-7, Task 1.11. The addresses are now a command's answer rather than the
|
|
66
|
+
// model's, and the recipe turns that answer into `surfaces.json` with one line
|
|
67
|
+
// of `node -e`. Ten seconds in, the file is full of routes and looks like a
|
|
68
|
+
// finished inventory — and the surfaces no router declares (`table`, `job`,
|
|
69
|
+
// `external`) are found only if the model keeps reading after that point.
|
|
70
|
+
//
|
|
71
|
+
// The spec spent five rounds establishing that "the prompt says so" is not a
|
|
72
|
+
// guarantee, then left exactly this one resting on it. So the cheapest half is
|
|
73
|
+
// checked here: a project that keeps a schema on disk and reports not a single
|
|
74
|
+
// table did not finish the file. Jobs and externals a project may genuinely not
|
|
75
|
+
// have, and no honest check exists for them; tables it either stores or does not.
|
|
76
|
+
//
|
|
77
|
+
// A refusal, not a warning — the same standing as an altered address above, and
|
|
78
|
+
// for the same reason: an inventory missing every table sails through the rest
|
|
79
|
+
// of the pipeline agreeing with itself.
|
|
80
|
+
function refuseSurfacesWithoutStorage(projectRoot, surfaces) {
|
|
81
|
+
const schema = ['db/schema.rb', 'db/structure.sql'].find((file) => existsSync(join(projectRoot, ...file.split('/'))));
|
|
82
|
+
if (!schema)
|
|
83
|
+
return; // nothing on disk says this project stores anything
|
|
84
|
+
const list = surfaces?.surfaces;
|
|
85
|
+
if (!Array.isArray(list))
|
|
86
|
+
return; // shape is the host's complaint to make, not ours
|
|
87
|
+
if (list.some((surface) => surface?.kind === 'table'))
|
|
88
|
+
return;
|
|
89
|
+
throw new Error(`surfaces.json declares no \`table\` surface, but this project has ${schema}, so nothing was ` +
|
|
90
|
+
'uploaded and the previous map stays current. The routes are only one of the four kinds: read ' +
|
|
91
|
+
'the schema for `table` surfaces, and the source for `job` and `external` ones, then run ' +
|
|
92
|
+
'`unitbob put-map-build` again.');
|
|
26
93
|
}
|