@amulet-laboratories/astrolabe 0.4.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,541 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import { Graph } from '../../core/graph.mjs'
5
+ import { normalizeTag, parseMdcComponents, parseScript, parseSfc } from '../../core/sfc.mjs'
6
+ import { sliceBalanced, topLevelKeys } from '../../core/syntax.mjs'
7
+ import {
8
+ annotateChurn,
9
+ countLoc,
10
+ eachFile,
11
+ isTest,
12
+ linkTags,
13
+ readEach,
14
+ rel,
15
+ routeFromFile,
16
+ shortenPkgPath,
17
+ walk,
18
+ } from '../shared.mjs'
19
+ import { detect, loadProject, readGeneratedComponents, readGeneratedImports } from './project.mjs'
20
+
21
+ // Composables from these packages are framework plumbing (useRoute, useHead,
22
+ // useAsyncData). Their data-access is already drawn as typed edges, so drawing
23
+ // them as nodes too would bury the app's own code in noise.
24
+ const FRAMEWORK = /^(nuxt|vue|vue-router|h3|nitropack|@nuxt\/|@unhead|@vue\/|#)/
25
+
26
+ /** This adapter handles any directory with a nuxt.config. */
27
+ export const name = 'nuxt'
28
+ export { detect }
29
+
30
+ /**
31
+ * Read a Nuxt app into a canonical graph. Returns the raw graph plus its
32
+ * project metadata and warnings; the shared finalize pipeline (prune, group,
33
+ * findings, stats) runs in core, identically for every adapter.
34
+ */
35
+ export async function scan(dir) {
36
+ const project = loadProject(dir)
37
+ const g = new Graph()
38
+ const warnings = []
39
+
40
+ const generatedComponents = readGeneratedComponents(project)
41
+ const generatedImports = readGeneratedImports(project)
42
+ if (!generatedComponents) {
43
+ warnings.push(
44
+ 'No .nuxt/components.d.ts found — falling back to scanning components/. ' +
45
+ 'Run `nuxt prepare` for exact auto-import names and library components.',
46
+ )
47
+ }
48
+
49
+ // Independent filesystem walks — overlap their I/O. Only resolveComposables
50
+ // pushes warnings, and all node insertion happens after, so order is stable.
51
+ const [componentsByName, composablesByName] = await Promise.all([
52
+ resolveComponents(project, generatedComponents),
53
+ resolveComposables(project, generatedImports, warnings),
54
+ ])
55
+
56
+ // Nodes for things that exist on disk, before any edges are drawn.
57
+ for (const [name, info] of componentsByName) {
58
+ if (info.origin === 'local') {
59
+ g.node(`component:${name}`, {
60
+ type: 'component',
61
+ label: name,
62
+ file: rel(info.file, project.root),
63
+ meta: { origin: 'local' },
64
+ })
65
+ }
66
+ }
67
+ for (const [name, info] of composablesByName) {
68
+ g.node(`composable:${name}`, {
69
+ type: 'composable',
70
+ label: name,
71
+ file: rel(info.file, project.root),
72
+ meta: { origin: info.origin, pkg: info.pkg, loc: info.loc },
73
+ })
74
+ }
75
+
76
+ const collections = await scanContentConfig(project, g)
77
+ await scanLayouts(project, g, componentsByName, composablesByName, warnings)
78
+ // Before pages, so a page's definePageMeta middleware can link to real nodes.
79
+ await scanMiddlewareAndPlugins(project, g, composablesByName, warnings)
80
+ await scanPages(project, g, componentsByName, composablesByName, warnings)
81
+ await scanComponentBodies(project, g, componentsByName, composablesByName, warnings)
82
+ await scanComposableBodies(project, g, composablesByName, warnings)
83
+ await scanServer(project, g, warnings)
84
+ await scanContentFiles(project, g, collections, componentsByName, warnings)
85
+
86
+ await annotateChurn(g, project.root)
87
+
88
+ return { project, graph: g, warnings }
89
+ }
90
+
91
+ /**
92
+ * Collapse a route's dynamic segments to `*` so the two ways of writing the
93
+ * same endpoint land on one node: a caller's template literal
94
+ * (`/api/debate/${slug}/vote`) and its handler file
95
+ * (server/api/debate/[slug]/vote.post.ts → `/api/debate/:slug/vote`). Without
96
+ * this the caller invents a phantom endpoint and the real handler looks
97
+ * uncalled — two wrong facts from one mismatch.
98
+ */
99
+ const canonicalRoute = (path) =>
100
+ path
101
+ .replace(/\$\{[^}]*\}/g, '*')
102
+ .replace(/:[A-Za-z_]\w*/g, '*')
103
+ .replace(/\*\*/g, '*')
104
+ .replace(/\/{2,}/g, '/')
105
+ .replace(/(.)\/$/, '$1')
106
+
107
+ const serverId = (path) => `server:${canonicalRoute(path)}`
108
+
109
+ /** What @nuxt/content accepts as a source file across page and data collections. */
110
+ const CONTENT_EXTS = ['.md', '.json', '.yml', '.yaml', '.csv']
111
+
112
+ /**
113
+ * Nuxt's fallback auto-import name for a component path: directory segments
114
+ * PascalCased and concatenated, but a segment is absorbed when the next already
115
+ * starts with it — account/AccountAvatar → AccountAvatar, not AccountAccountAvatar
116
+ * (the false-orphan bug real repos surfaced). Only used without .nuxt.
117
+ */
118
+ export function componentName(segments, pathPrefix) {
119
+ const parts = (pathPrefix ? segments : [segments.at(-1)]).map(normalizeTag)
120
+ const out = []
121
+ for (const seg of parts) {
122
+ if (out.length && seg.toLowerCase().startsWith(out.at(-1).toLowerCase()))
123
+ out[out.length - 1] = seg
124
+ else out.push(seg)
125
+ }
126
+ return out.join('')
127
+ }
128
+
129
+ /**
130
+ * Every root to look for app code in: the app itself, then each layer it
131
+ * extends. Main is first, so a node it defines wins — a layer only fills in what
132
+ * the app doesn't. For an app with no `extends`, this is just the app.
133
+ */
134
+ const sourcesOf = (project) => project.sources ?? [{ root: project.root, srcDir: project.srcDir }]
135
+
136
+ async function resolveComponents(project, generated) {
137
+ const map = new Map()
138
+ if (generated) {
139
+ for (const [name, info] of generated) map.set(name, info)
140
+ return map
141
+ }
142
+ // Fallback: mirror Nuxt's own naming rules over components/, across the app
143
+ // and its layers. Main is scanned first and wins on any name collision.
144
+ for (const src of sourcesOf(project)) {
145
+ const dir = join(src.srcDir, 'components')
146
+ for (const file of await walk(dir, ['.vue'], dir)) {
147
+ if (isTest(file)) continue
148
+ const name = componentName(file.replace(/\.vue$/, '').split('/'), project.pathPrefix)
149
+ if (!map.has(name)) map.set(name, { file: join(dir, file), origin: 'local', pkg: null })
150
+ }
151
+ }
152
+ return map
153
+ }
154
+
155
+ async function resolveComposables(project, generated, warnings) {
156
+ const map = new Map()
157
+ for (const src of sourcesOf(project)) {
158
+ const dir = join(src.srcDir, 'composables')
159
+ await eachFile(dir, ['.ts', '.js', '.mts'], warnings, (file, source) => {
160
+ const full = join(dir, file)
161
+ const exported = [
162
+ ...source.matchAll(/export\s+(?:const|function|async function)\s+(use[A-Z]\w*)/g),
163
+ ]
164
+ const names = exported.length
165
+ ? exported.map((m) => m[1])
166
+ : [basename(file).replace(/\.\w+$/, '')]
167
+ for (const name of names)
168
+ if (!map.has(name))
169
+ map.set(name, { file: full, origin: 'local', pkg: null, loc: countLoc(source) })
170
+ })
171
+ }
172
+ if (generated) {
173
+ for (const [name, info] of generated) {
174
+ if (map.has(name) || !name.startsWith('use')) continue
175
+ if (info.origin === 'library' && info.pkg && !FRAMEWORK.test(info.pkg)) map.set(name, info)
176
+ }
177
+ }
178
+ return map
179
+ }
180
+
181
+ /** Wire one parsed script's data-access into typed edges out of `fromId`. */
182
+ function linkScript(g, fromId, parsed, composablesByName) {
183
+ for (const name of parsed.collections) {
184
+ g.node(`collection:${name}`, { type: 'collection', label: name, meta: {} })
185
+ g.edge(fromId, `collection:${name}`, 'queries')
186
+ }
187
+ for (const url of parsed.endpoints) {
188
+ const id = serverId(url)
189
+ g.node(id, { type: 'server', label: url, meta: { inferred: true } })
190
+ g.edge(fromId, id, 'fetches')
191
+ }
192
+ for (const host of parsed.externals) {
193
+ g.node(`external:${host}`, { type: 'external', label: host, meta: {} })
194
+ g.edge(fromId, `external:${host}`, 'fetches')
195
+ }
196
+ for (const name of parsed.calls) {
197
+ if (composablesByName.has(name)) g.edge(fromId, `composable:${name}`, 'uses')
198
+ }
199
+ if (parsed.usesAppConfig) {
200
+ g.node('config:appConfig', { type: 'config', label: 'appConfig', meta: {} })
201
+ g.edge(fromId, 'config:appConfig', 'reads')
202
+ }
203
+ if (parsed.usesRuntimeConfig) {
204
+ g.node('config:runtimeConfig', { type: 'config', label: 'runtimeConfig', meta: {} })
205
+ g.edge(fromId, 'config:runtimeConfig', 'reads')
206
+ }
207
+ if (parsed.usesState) {
208
+ g.node('config:useState', { type: 'config', label: 'useState (shared)', meta: {} })
209
+ g.edge(fromId, 'config:useState', 'reads')
210
+ }
211
+ }
212
+
213
+ async function scanPages(project, g, componentsByName, composablesByName, warnings) {
214
+ const sources = sourcesOf(project)
215
+ for (let s = 0; s < sources.length; s++) {
216
+ const isLayer = s > 0
217
+ const dir = join(sources[s].srcDir, 'pages')
218
+ await eachFile(dir, ['.vue'], warnings, (file, source) => {
219
+ const full = join(dir, file)
220
+ const route = routeFromFile(file)
221
+ const id = `route:${route}`
222
+ // A route the app defines overrides a layer's.
223
+ if (isLayer && g.has(id)) return
224
+ const parsed = parseSfc(source, full)
225
+
226
+ g.node(id, {
227
+ type: 'route',
228
+ label: route,
229
+ file: rel(full, project.root),
230
+ meta: {
231
+ dynamic: /[[\]]/.test(file),
232
+ props: parsed.props,
233
+ layout: parsed.meta.layout ?? 'default',
234
+ loc: countLoc(source),
235
+ },
236
+ })
237
+ linkTags(g, id, parsed.tags, componentsByName)
238
+ linkScript(g, id, parsed, composablesByName)
239
+
240
+ const layout = parsed.meta.layout ?? 'default'
241
+ if (layout !== 'false' && g.has(`layout:${layout}`)) g.edge(`layout:${layout}`, id, 'wraps')
242
+
243
+ for (const name of parsed.meta.middleware ?? []) {
244
+ if (g.has(`middleware:${name}`)) g.edge(`middleware:${name}`, id, 'guards')
245
+ }
246
+ })
247
+ }
248
+
249
+ // app.vue is the true entry point; without pages/ it's the whole app. The
250
+ // app's own wins; failing that, the nearest layer that defines one.
251
+ for (const src of sourcesOf(project)) {
252
+ const appFile = join(src.srcDir, 'app.vue')
253
+ const app = await readFile(appFile, 'utf8').catch(() => null)
254
+ if (!app) continue
255
+ try {
256
+ const parsed = parseSfc(app, appFile)
257
+ g.node('entry:app', {
258
+ type: 'entry',
259
+ label: 'app.vue',
260
+ file: rel(appFile, project.root),
261
+ meta: { loc: countLoc(app) },
262
+ })
263
+ linkTags(g, 'entry:app', parsed.tags, componentsByName)
264
+ linkScript(g, 'entry:app', parsed, composablesByName)
265
+ } catch (err) {
266
+ warnings.push(`skipped app.vue: ${err.message}`)
267
+ }
268
+ break
269
+ }
270
+ }
271
+
272
+ async function scanLayouts(project, g, componentsByName, composablesByName, warnings) {
273
+ const sources = sourcesOf(project)
274
+ for (let s = 0; s < sources.length; s++) {
275
+ const isLayer = s > 0
276
+ const dir = join(sources[s].srcDir, 'layouts')
277
+ await eachFile(dir, ['.vue'], warnings, (file, source) => {
278
+ const full = join(dir, file)
279
+ const name = file.replace(/\.vue$/, '')
280
+ const id = `layout:${name}`
281
+ if (isLayer && g.has(id)) return
282
+ const parsed = parseSfc(source, full)
283
+ g.node(id, {
284
+ type: 'layout',
285
+ label: name,
286
+ file: rel(full, project.root),
287
+ meta: { loc: countLoc(source) },
288
+ })
289
+ linkTags(g, id, parsed.tags, componentsByName)
290
+ linkScript(g, id, parsed, composablesByName)
291
+ })
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Composables are where a well-structured app keeps its data access, so
297
+ * skipping their bodies leaves every endpoint they call looking uncalled.
298
+ *
299
+ * Edges attach to every composable a file exports. Files almost always export
300
+ * one, and attributing a file's fetch to all of its exports is a smaller error
301
+ * than dropping the edge entirely.
302
+ */
303
+ async function scanComposableBodies(project, g, composablesByName, warnings) {
304
+ const byFile = new Map()
305
+ for (const [name, info] of composablesByName) {
306
+ if (info.origin !== 'local') continue
307
+ if (!byFile.has(info.file)) byFile.set(info.file, [])
308
+ byFile.get(info.file).push(name)
309
+ }
310
+ await readEach(
311
+ [...byFile.keys()],
312
+ warnings,
313
+ (file, source) => {
314
+ const parsed = parseScript(source)
315
+ for (const name of byFile.get(file))
316
+ linkScript(g, `composable:${name}`, parsed, composablesByName)
317
+ },
318
+ (file) => rel(file, project.root),
319
+ )
320
+ }
321
+
322
+ async function scanComponentBodies(project, g, componentsByName, composablesByName, warnings) {
323
+ const locals = [...componentsByName].filter(([, info]) => info.origin === 'local')
324
+ await readEach(
325
+ locals.map(([, info]) => info.file),
326
+ warnings,
327
+ (file, source, i) => {
328
+ const [name] = locals[i]
329
+ const id = `component:${name}`
330
+ const parsed = parseSfc(source, file)
331
+ const node = g.nodes.get(id)
332
+ if (node) node.meta = { ...node.meta, props: parsed.props, loc: countLoc(source) }
333
+ linkTags(g, id, parsed.tags, componentsByName)
334
+ linkScript(g, id, parsed, composablesByName)
335
+ },
336
+ (file) => rel(file, project.root),
337
+ )
338
+ }
339
+
340
+ async function scanServer(project, g, warnings) {
341
+ const sources = sourcesOf(project)
342
+ for (let s = 0; s < sources.length; s++) {
343
+ const isLayer = s > 0
344
+ for (const base of ['api', 'routes']) {
345
+ const dir = join(sources[s].root, 'server', base)
346
+ await eachFile(dir, ['.ts', '.js', '.mts'], warnings, (file, source) => {
347
+ const full = join(dir, file)
348
+ const { method, path } = serverRoute(file, base)
349
+ const id = serverId(path)
350
+ // A handler the app already provides overrides a layer's. Only a real
351
+ // (non-inferred) handler blocks; an inferred node still wants filling in.
352
+ if (isLayer && g.has(id) && !g.nodes.get(id).meta.inferred) return
353
+
354
+ // A caller may have created this node already from a fetch string. Either
355
+ // way the handler file is the better source of truth for every field.
356
+ const node = g.node(id, { type: 'server', meta: {} })
357
+ node.label = path
358
+ node.file = rel(full, project.root)
359
+ node.meta = { ...node.meta, method, loc: countLoc(source) }
360
+ delete node.meta.inferred
361
+
362
+ const parsed = parseScript(source)
363
+ for (const host of parsed.externals) {
364
+ g.node(`external:${host}`, { type: 'external', label: host, meta: {} })
365
+ g.edge(id, `external:${host}`, 'fetches')
366
+ }
367
+ for (const name of parsed.collections) {
368
+ g.node(`collection:${name}`, { type: 'collection', label: name, meta: {} })
369
+ g.edge(id, `collection:${name}`, 'queries')
370
+ }
371
+ if (parsed.usesRuntimeConfig) {
372
+ g.node('config:runtimeConfig', { type: 'config', label: 'runtimeConfig', meta: {} })
373
+ g.edge(id, 'config:runtimeConfig', 'reads')
374
+ }
375
+ })
376
+ }
377
+ }
378
+ }
379
+
380
+ async function scanMiddlewareAndPlugins(project, g, composablesByName, warnings) {
381
+ for (const src of sourcesOf(project)) {
382
+ const specs = [
383
+ { dir: join(src.srcDir, 'middleware'), type: 'middleware' },
384
+ { dir: join(src.srcDir, 'plugins'), type: 'plugin' },
385
+ { dir: join(src.root, 'server', 'middleware'), type: 'middleware' },
386
+ ]
387
+ for (const { dir, type } of specs) {
388
+ await eachFile(dir, ['.ts', '.js', '.mts', '.vue'], warnings, (file, source) => {
389
+ const full = join(dir, file)
390
+ const name = file.replace(/\.(ts|js|mts|vue)$/, '')
391
+ const server = full.includes('/server/')
392
+ // g.node is first-wins, so the app's file beats a layer's of the same name.
393
+ const id = `${type}:${server ? 'server-' : ''}${name}`
394
+ g.node(id, {
395
+ type,
396
+ label: server ? `${name} (server)` : name,
397
+ file: rel(full, project.root),
398
+ meta: {},
399
+ })
400
+ // Middleware and plugins call composables, fetch endpoints and read
401
+ // config too — skipping their bodies made a composable used only from
402
+ // an auth middleware or an analytics plugin look uncalled.
403
+ const parsed = full.endsWith('.vue') ? parseSfc(source, full) : parseScript(source)
404
+ linkScript(g, id, parsed, composablesByName)
405
+ })
406
+ }
407
+ }
408
+ }
409
+
410
+ /** Content collections are declared, not discovered — read them from config. */
411
+ async function scanContentConfig(project, g) {
412
+ const found = new Map()
413
+ for (const src of sourcesOf(project)) {
414
+ const raw = await readFile(join(src.root, 'content.config.ts'), 'utf8').catch(() => null)
415
+ if (!raw) continue
416
+ for (const m of raw.matchAll(/(\w+)\s*:\s*defineCollection\s*\(/g)) {
417
+ const name = m[1]
418
+ if (found.has(name)) continue // the app's collection wins over a layer's
419
+ const block = sliceBalanced(raw, m.index + m[0].length - 1, '(', ')')
420
+ const source = block.match(
421
+ /source\s*:\s*(?:['"]([^'"]+)['"]|\{[^}]*include\s*:\s*['"]([^'"]+)['"])/,
422
+ )
423
+ const info = {
424
+ type: block.match(/type\s*:\s*['"](\w+)['"]/)?.[1] ?? 'page',
425
+ source: source ? (source[1] ?? source[2]) : null,
426
+ // "empty" is only honest when the source is a local glob we can actually
427
+ // count. A `repository:` (fetched from git), or a source we can't resolve
428
+ // to a glob — a variable, an array of sources — has no countable local
429
+ // files, so it must not be flagged empty. Real on nuxt.com's docs, whose
430
+ // collections point at `source: [docsV3Source, …]` github repositories.
431
+ remote: /\brepository\s*:/.test(block) || (/\bsource\s*:/.test(block) && !source),
432
+ fields: schemaFields(block),
433
+ }
434
+ found.set(name, info)
435
+ g.node(`collection:${name}`, {
436
+ type: 'collection',
437
+ label: name,
438
+ file: 'content.config.ts',
439
+ meta: info,
440
+ })
441
+ }
442
+ }
443
+ return found
444
+ }
445
+
446
+ function schemaFields(block) {
447
+ const start = block.indexOf('z.object(')
448
+ if (start === -1) return []
449
+ return topLevelKeys(sliceBalanced(block, block.indexOf('{', start), '{', '}'))
450
+ }
451
+
452
+ /**
453
+ * Markdown drives two edges worth drawing: which collection a file belongs to,
454
+ * and which components its MDC body renders.
455
+ */
456
+ async function scanContentFiles(project, g, collections, componentsByName, warnings) {
457
+ const counts = new Map()
458
+ // A collection's source glob collapses to a fixed path prefix; precompute the
459
+ // [name, prefix] list once instead of rebuilding it and re-running the regex
460
+ // for every content file (an app can have thousands). Kept in collection
461
+ // order, so `.find` still selects the same collection a per-file scan would.
462
+ const prefixes = [...collections].map(([name, info]) => [
463
+ name,
464
+ info.source ? info.source.replace(/\*\*?.*$/, '') : null,
465
+ ])
466
+
467
+ // `type: 'data'` collections are JSON or YAML, not markdown. Walking only .md
468
+ // reports those collections as empty when they hold hundreds of files.
469
+ for (const src of sourcesOf(project)) {
470
+ const dir = join(src.root, 'content')
471
+ // @nuxt/content ignores files and directories prefixed with `_` — they're
472
+ // partials, not published pages. Counting them would inflate a collection's
473
+ // file total and draw render edges from content that never renders.
474
+ const files = (await walk(dir, CONTENT_EXTS, dir)).filter(
475
+ (f) => !f.split('/').some((seg) => seg.startsWith('_')),
476
+ )
477
+ if (!files.length) continue
478
+
479
+ // Only markdown renders components, so only markdown needs reading. Fan the
480
+ // reads out, but process files below in walk order so node insertion — and
481
+ // therefore the serialized output — stays identical to a serial scan.
482
+ const md = files.filter((f) => f.endsWith('.md'))
483
+ const mdSources = await Promise.all(
484
+ md.map((f) => readFile(join(dir, f), 'utf8').catch(() => null)),
485
+ )
486
+ const sourceOf = new Map(md.map((f, i) => [f, mdSources[i]]))
487
+
488
+ for (const file of files) {
489
+ const collection =
490
+ prefixes.find(([, prefix]) => prefix != null && file.startsWith(prefix))?.[0] ??
491
+ file.split('/')[0]
492
+
493
+ counts.set(collection, (counts.get(collection) ?? 0) + 1)
494
+ const id = `collection:${collection}`
495
+ if (!g.has(id)) g.node(id, { type: 'collection', label: collection, meta: {} })
496
+
497
+ if (!file.endsWith('.md')) continue
498
+ const raw = sourceOf.get(file)
499
+ if (raw == null) {
500
+ warnings.push(`could not read ${file}`)
501
+ continue
502
+ }
503
+ try {
504
+ for (const tag of parseMdcComponents(raw)) {
505
+ const name = normalizeTag(tag)
506
+ const info = componentsByName.get(name)
507
+ if (!info) continue
508
+ if (info.origin === 'library') {
509
+ g.node(`component:${name}`, {
510
+ type: 'lib-component',
511
+ label: name,
512
+ file: shortenPkgPath(info.file),
513
+ meta: { origin: 'library', pkg: info.pkg },
514
+ })
515
+ }
516
+ g.edge(id, `component:${name}`, 'renders')
517
+ }
518
+ } catch (err) {
519
+ warnings.push(`skipped ${file}: ${err.message}`)
520
+ }
521
+ }
522
+ }
523
+
524
+ for (const [name, count] of counts) {
525
+ const node = g.nodes.get(`collection:${name}`)
526
+ if (node) node.meta = { ...node.meta, files: count }
527
+ }
528
+ }
529
+
530
+ function serverRoute(file, base) {
531
+ const withoutExt = file.replace(/\.(ts|js|mts)$/, '')
532
+ const methodMatch = withoutExt.match(/\.(get|post|put|patch|delete|head|options)$/)
533
+ const method = methodMatch ? methodMatch[1].toUpperCase() : 'ALL'
534
+ const clean = withoutExt
535
+ .replace(/\.(get|post|put|patch|delete|head|options)$/, '')
536
+ .replace(/\/index$/, '')
537
+ .split('/')
538
+ .map((s) => (s.startsWith('[...') ? `**` : s.startsWith('[') ? `:${s.slice(1, -1)}` : s))
539
+ .join('/')
540
+ return { method, path: `/${base === 'api' ? 'api/' : ''}${clean}` }
541
+ }