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.
@@ -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
+ }
@@ -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
- `${result.map_url}\n`);
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
  }