@elytracms/cli 0.0.79 → 0.0.81

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 CHANGED
@@ -29,7 +29,8 @@ elytra doctor Diagnose the active deployment's readiness and print each fix.
29
29
  by NAME only — values are never read into the report or printed.
30
30
  When the publish revalidate pair is set, also sends a signed
31
31
  no-op ping to the endpoint to prove the publish → live chain
32
- end to end. Also reports the generated-previews artifacts
32
+ end to end. Also checks your cms/ route set for URL conflicts
33
+ (see below), and reports the generated-previews artifacts
33
34
  (manifest present, image files present, no strays — warnings
34
35
  only; previews are optional). Read-only; exits non-zero if any
35
36
  check fails.
@@ -175,3 +176,90 @@ has no `pairWith` escalation because its real partner lives on a plane this
175
176
  doctor cannot read. Missing it is a warning: draft preview is closed
176
177
  everywhere (401 on both sides) until it's set — a project that never uses
177
178
  draft preview may legitimately leave it unset.
179
+
180
+ ## Route conflicts (`elytra doctor`)
181
+
182
+ Routes are code, so a route conflict is a build-time bug, and `elytra doctor`
183
+ reports it: two routes that would match the exact same URLs fail the run with
184
+ the offending ids and patterns named. Nothing about this needs a deployment —
185
+ the check runs in playground mode too.
186
+
187
+ The doctor reads the route set from your project's `cms/index.ts` (the
188
+ `collections` + `routes` your `elytra.config.ts` passes through). That tree is
189
+ plain, React-free TypeScript by convention, so the CLI imports it natively —
190
+ no bundler, no Vite, no dev server. Two consequences worth knowing:
191
+
192
+ - It needs a Node that runs TypeScript natively (**≥22.18**). On an older Node
193
+ the check reports "not checked here" and names the version — never a false
194
+ pass.
195
+ - If the route set cannot be read for any reason (no `cms/index.ts`, a module
196
+ that throws, an import the CLI cannot resolve such as a `tsconfig` path
197
+ alias, TypeScript syntax Node cannot strip, or a `routes` export of the
198
+ wrong shape), the check reports **why**, as "not checked here". It never
199
+ crashes the doctor and never claims your routes are fine.
200
+
201
+ ### Conflicting hierarchy mounts
202
+
203
+ A hierarchy mount (`/:path*`, `/docs/:path*`) is not a router route: it is pulled
204
+ out of the router and resolved per request against a collection's composed page
205
+ tree. So the route-conflict check above cannot see mounts at all, and a second
206
+ row — `mount-conflicts` — judges them by what they declare instead:
207
+
208
+ - **Two collections mounted at the same path fail the run.** They claim an
209
+ identical URL space, and the only thing separating them is declaration order.
210
+ - **A mount nested inside another's URL space is a warning.** It is a real
211
+ shadow, but only where the outer collection's tree actually composes a path
212
+ into the inner mount's prefix — that is content, which a CLI cannot see, so
213
+ naming the precedence is honest and failing your build on it would not be.
214
+
215
+ The precedence being named is worth stating plainly, because nothing else does:
216
+ **mounts are tried in declaration order, not by specificity**, and the first
217
+ whose collection resolves a document for that URL wins. A root mount declared
218
+ first therefore outranks a deeper one — the opposite of how ordinary routes
219
+ behave, where more static segments always win. Declare the more specific mount
220
+ first.
221
+
222
+ ### Declarations that serve nothing
223
+
224
+ A third row — `unreachable-routes` — asks a different question from the two
225
+ above: not "do two declarations fight?" but "can this declaration ever serve
226
+ anything at all?" Three shapes trip it, all of which used to 404 silently with
227
+ no issue raised anywhere:
228
+
229
+ - **An all-dynamic route over a mount's URL space** — a **warning**. `/:slug`
230
+ matches *every* one-segment URL, so the `/:path*` mount is consulted there
231
+ only where the route resolves no document. Nothing is unconditionally
232
+ unreachable (the resolver falls through — see *Precedence* below), but
233
+ precedence at that depth is then decided by your **content**: a page whose
234
+ composed path collides with a post slug is silently shadowed, and neither
235
+ side is told. `/blog/:slug` is fine and always was: a static segment claims
236
+ only the namespace you carved out. It is the *all*-dynamic pattern that takes
237
+ a whole depth.
238
+ - **A mount on a collection that cannot back it** — no `parent` self-relation,
239
+ so there are no nested paths to compose, or the collection is not declared at
240
+ all. The resolver skips such a mount silently; every URL under it 404s. This
241
+ one **fails** the run: no content and no ordering can make it work.
242
+ - **A catch-all with a dynamic prefix** (`/:lang/:path*`) — also a **failure**.
243
+ It is dead in both directions: every router drops it as a catch-all, and no
244
+ mount is built from it because a mount prefix must be static. For per-locale
245
+ mounts, declare one mount per locale instead:
246
+ `{ pattern: '/de/:path*', locale: 'de' }`.
247
+
248
+ ### Precedence: a match is not a result
249
+
250
+ A route always wins over a mount **for the URLs it can actually serve**. When a
251
+ route matches a URL but resolves no document there — no post with that slug, or
252
+ none published in this perspective — the resolver falls through to the hierarchy
253
+ mounts instead of returning 404. So declaring `/blog/:slug` does not take the
254
+ `/blog/*` namespace away from your page tree: a page at `blog/kid` is served,
255
+ and it stops being served the moment a *post* with slug `kid` exists.
256
+
257
+ The consequence worth knowing: which collection serves such a URL depends on
258
+ what exists, not on the routes alone, and it is evaluated per perspective — a
259
+ draft-only post wins in preview while the published page still serves live.
260
+
261
+ **Redirect loops are not checked here.** Since EC-303 redirects are editorial
262
+ data in the backend, not code — a CLI has no editorial data. The studio checks
263
+ them (and route conflicts, and the route-side half of `unreachable-routes`)
264
+ live, at the section-rail foot, because it holds both the config routes and the
265
+ redirects rail.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/cli",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.79",
4
+ "version": "0.0.81",
5
5
  "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "node": ">=20"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.79"
20
+ "@elytracms/core": "^0.0.81"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -29,9 +29,10 @@ Commands:
29
29
  values never shown — config, membership) and print each fix. When the
30
30
  instant-publish revalidate pair is set, also sends a signed no-op ping
31
31
  to the endpoint to prove the publish → live chain end to end. Also
32
- reports the generated-previews artifacts (manifest/files present;
33
- hash staleness lives in \`elytra previews --check\`). Exit non-zero
34
- if any check fails. Read-only. Optional: --url <convex url>.
32
+ checks your cms/ routes for URL conflicts, and reports the
33
+ generated-previews artifacts (manifest/files present; hash staleness
34
+ lives in \`elytra previews --check\`). Exit non-zero if any check
35
+ fails. Read-only. Optional: --url <convex url>.
35
36
  previews Render every canvas-eligible component's fixtures against YOUR theme
36
37
  (headless Chromium via Playwright — an optional install) and write
37
38
  committed preview images (studio/public/elytra-previews/) plus an
@@ -110,9 +111,11 @@ Diagnose the active deployment's readiness (URL, reachability, auth signing
110
111
  keys, expected deployment env vars — set/missing by NAME, values never shown
111
112
  — config, membership) and print each fix. When the instant-publish
112
113
  revalidate pair is set, also sends a signed no-op ping to the endpoint to
113
- prove the publish → live chain end to end. Also reports the
114
- generated-previews artifacts (manifest/files present; hash staleness lives
115
- in \`elytra previews --check\`). Exit non-zero if any check fails. Read-only.
114
+ prove the publish → live chain end to end. Also checks the route set in your
115
+ cms/ for URL conflicts (routes are code — redirect loops are editorial data
116
+ and are checked in the studio), and reports the generated-previews artifacts
117
+ (manifest/files present; hash staleness lives in \`elytra previews --check\`).
118
+ Exit non-zero if any check fails. Read-only.
116
119
 
117
120
  Options:
118
121
  --url <convex url> Diagnose this deployment instead of the one resolved
@@ -470,6 +473,8 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
470
473
  // EC-399: where `convex env list` runs (name enumeration — presence only),
471
474
  // and whether the AI provider-key expectation applies.
472
475
  studioDir: project.apps.studio ? join(project.root, 'studio') : undefined,
476
+ // EC-469: where the project's `cms/` route set is read from (code, not backend).
477
+ root: project.root,
473
478
  aiRouteConfigured: project.hasAiRoute,
474
479
  exec,
475
480
  })
package/src/doctor.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from '@elytracms/core/export-sync/deployment-ready'
10
10
  import { convexEnvGet, defaultConvexExec } from './provision.js'
11
11
  import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
12
+ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
12
13
 
13
14
  /**
14
15
  * `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
@@ -236,6 +237,22 @@ const BUCKET_TITLE = {
236
237
  previews: 'Previews',
237
238
  }
238
239
 
240
+ /**
241
+ * Splice extra checks into an existing bucket's run, right after that bucket's last
242
+ * check (EC-469). {@link formatReport} opens a bucket heading whenever `check.bucket`
243
+ * changes, so appending an `app` check after the `user`/`previews` ones would print
244
+ * "App:" twice. Bucket absent from the report → the extras go at the end. Pure.
245
+ */
246
+ export function spliceBucketChecks(checks, bucket, extra) {
247
+ if (extra.length === 0) return [...checks]
248
+ let last = -1
249
+ for (let i = 0; i < checks.length; i++) {
250
+ if (checks[i].bucket === bucket) last = i
251
+ }
252
+ if (last === -1) return [...checks, ...extra]
253
+ return [...checks.slice(0, last + 1), ...extra, ...checks.slice(last + 1)]
254
+ }
255
+
239
256
  /**
240
257
  * Render a `DeploymentReadinessReport` as the bucketed doctor report (pure — no I/O,
241
258
  * no color, so it's directly testable). Failing AND warning checks print their
@@ -284,6 +301,12 @@ export function formatReport(report) {
284
301
  * findings there are warnings (previews are optional); input-hash STALENESS needs
285
302
  * the component registry and lives in `elytra previews --check` (the ratified
286
303
  * split). `previewsArtifacts` is injectable for tests.
304
+ *
305
+ * EC-469: when a project `root` is known, the `app` bucket also reports ROUTE
306
+ * CONFLICTS, read from the project's React-free `cms/` tree (see `routes-check.js`).
307
+ * That check CAN fail — a conflict is deterministic in code — so it participates in
308
+ * the exit code; a route set that cannot be read reports `unknown` with its reason and
309
+ * never fails. `routeFacts` is injectable for tests.
287
310
  */
288
311
  export async function runDoctor({
289
312
  env = {},
@@ -291,9 +314,11 @@ export async function runDoctor({
291
314
  log = (l) => console.log(l),
292
315
  url,
293
316
  studioDir,
317
+ root,
294
318
  aiRouteConfigured = false,
295
319
  exec,
296
320
  previewsArtifacts,
321
+ routeFacts,
297
322
  } = {}) {
298
323
  const target = resolveDoctorTarget(env, { url })
299
324
 
@@ -325,12 +350,22 @@ export async function runDoctor({
325
350
 
326
351
  // EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
327
352
  // the shared report before formatting so it wears the same glyph/fix conventions;
328
- // its checks are ok/warn/unknown only, so `report.ok` (the exit code) is unchanged.
353
+ // its checks are ok/warn/unknown only, so the exit code is unchanged by them.
329
354
  const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
330
- const merged = artifacts
331
- ? { ...report, checks: [...report.checks, ...previewsDoctorChecks(artifacts)] }
332
- : report
355
+ let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
356
+
357
+ // EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
358
+ // Backend-independent (routes are CODE) — it runs in playground mode too.
359
+ if (root !== undefined || routeFacts !== undefined) {
360
+ const facts = routeFacts ?? (await loadProjectRoutes(root))
361
+ checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(facts))
362
+ }
363
+
364
+ // One rule for the exit code, the same one `checkDeploymentReady` applies: a
365
+ // report is ok when nothing FAILED. Recomputed over the merged set so an appended
366
+ // bucket's failure can never be silently dropped from the exit code.
367
+ const merged = { ok: !checks.some((c) => c.status === 'fail'), checks }
333
368
 
334
369
  log(formatReport(merged))
335
- return report.ok ? 0 : 1
370
+ return merged.ok ? 0 : 1
336
371
  }
@@ -0,0 +1,473 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import {
5
+ createRouter,
6
+ declaredMounts,
7
+ hierarchyMountConflicts,
8
+ isHierarchyTemplate,
9
+ routeRecordSchema,
10
+ routeReachabilityIssues,
11
+ } from '@elytracms/core/cms-core'
12
+
13
+ /**
14
+ * `elytra doctor`'s route check (EC-469) — the CODE side of route validation.
15
+ *
16
+ * `createRouter` computes real structural issues and, before this, no surface showed
17
+ * them: the studio has had no Routes screen since EC-303 (correctly — routes are
18
+ * code), and doctor deferred structure to "the studio boot and `elytra build`",
19
+ * neither of which checked routes. A route conflict shipped silently.
20
+ *
21
+ * **What lives here and why only this.** `createRouter` produces exactly two codes —
22
+ * `route-conflict` (from routes alone) and `redirect-loop` (from redirects). Since
23
+ * EC-303 redirects are editorial DATA in the backend, so a bundler-less, session-less
24
+ * CLI cannot see them; and `unknown-route-target` is not a router issue at all — the
25
+ * *resolver* raises it per URL against the documents that exist
26
+ * (`core/src/content/client.ts`). So the honest CLI-side answer is **route conflicts**,
27
+ * and the other two are the studio's (which holds both the routes and the redirects).
28
+ * The `redirect-loops` check below says exactly that, as an `unknown` — the same
29
+ * convention `config-valid` already uses for a fact the CLI cannot gather.
30
+ *
31
+ * EC-482 adds the half the router structurally cannot see: **hierarchy mounts**. They
32
+ * are filtered OUT of the router (below, and identically in delivery), so two mounts
33
+ * fighting over the same URLs were invisible to every surface. `hierarchyMountConflicts`
34
+ * judges them by declaration instead of by pattern shape; the second check row reports
35
+ * it. Both checks are code-side and mutually exclusive by construction — a mount never
36
+ * reaches the router, an ordinary route never reaches the mount detector.
37
+ *
38
+ * EC-484 adds the third row, `unreachable-routes`, for the case where nothing is in
39
+ * conflict and the declaration simply does not work: a `/:slug`-shaped route that
40
+ * matches every URL at a depth the mount serves (so the mount is never reached and
41
+ * existing pages 404), a mount whose collection cannot compose nested paths, and a
42
+ * catch-all with a dynamic prefix that every router drops and no mount is built from.
43
+ * All three were measured to resolve `notFound` with zero issues on every surface.
44
+ *
45
+ * **Loading the routes.** Doctor deliberately executes no `elytra.config.ts` (it
46
+ * imports React components). But a project's `cms/` tree is React-free by convention
47
+ * — measured across `apps/demo`, the scaffold template, and both real consumer repos:
48
+ * every external import is `import type`, erased by Node's own TypeScript support. So
49
+ * doctor imports `cms/index.ts` natively, with no bundler and no Vite, and reads the
50
+ * same `collections` + `routes` that `elytra.config.ts` passes straight through.
51
+ *
52
+ * Two things stand in the way, and both are handled rather than assumed away:
53
+ *
54
+ * 1. **Node floor.** Native TS needs Node ≥22.18 (measured: 22.13 → `ERR_UNKNOWN_
55
+ * FILE_EXTENSION`, 22.21 → loads). Below the floor the check reports `unknown`
56
+ * with the version as its reason — the CLI's own `engines` promise (>=20) is not
57
+ * quietly broken.
58
+ * 2. **Bundler-style specifiers.** Real `cms/` trees write `from './collections'` —
59
+ * extensionless, and a directory at that. Plain Node ESM resolves neither, at any
60
+ * version. {@link withBundlerResolution} registers a *fallback* resolve hook that
61
+ * supplies the extension/index a bundler would, and only after Node's own
62
+ * resolution has already failed — so it can never hijack a specifier that already
63
+ * resolves.
64
+ *
65
+ * Executing consumer code in a tool that otherwise only does HTTP is a change in
66
+ * kind, so it is bounded: one conventional file, read-only, no writes, and every
67
+ * failure mode (missing file, throwing module, unresolvable import, non-erasable TS,
68
+ * a `routes` export of the wrong shape, a derive that throws) degrades to a reported
69
+ * `unknown` carrying its reason. There is no path here that crashes the doctor and
70
+ * none that reports a pass it did not establish.
71
+ */
72
+
73
+ // --- Loading the project's routes (impure) -----------------------------------
74
+
75
+ /** Where the route set is read from, by convention (AD-11: structure is code, in-repo). */
76
+ export const CMS_ENTRY = join('cms', 'index.ts')
77
+
78
+ /** Relative-specifier suffixes a bundler would try, in the order it tries them. */
79
+ const RESOLVE_CANDIDATES = ['.ts', '.tsx', '/index.ts', '/index.tsx', '.js', '.mjs', '/index.js']
80
+
81
+ /**
82
+ * Run `load` with a FALLBACK module-resolution hook that supplies the extension or
83
+ * `/index` a bundler would for a relative specifier — but only for specifiers Node
84
+ * itself could not resolve, so an already-valid specifier is never rewritten. The
85
+ * hook is deregistered before returning, in success and in failure alike: doctor
86
+ * never leaves process-wide resolution patched.
87
+ *
88
+ * `module.registerHooks` is Node ≥22.15. Where it is absent the load simply runs
89
+ * unhooked (a `cms/` tree with explicit extensions still works) — never an error.
90
+ */
91
+ export async function withBundlerResolution(load) {
92
+ let handle
93
+ try {
94
+ const { registerHooks } = await import('node:module')
95
+ if (typeof registerHooks === 'function') {
96
+ handle = registerHooks({
97
+ resolve(specifier, context, nextResolve) {
98
+ try {
99
+ return nextResolve(specifier, context)
100
+ } catch (error) {
101
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) throw error
102
+ if (!context.parentURL) throw error
103
+ for (const suffix of RESOLVE_CANDIDATES) {
104
+ const url = new URL(specifier + suffix, context.parentURL)
105
+ if (url.protocol === 'file:' && existsSync(fileURLToPath(url))) {
106
+ return { url: url.href, shortCircuit: true }
107
+ }
108
+ }
109
+ throw error
110
+ }
111
+ },
112
+ })
113
+ }
114
+ } catch {
115
+ // No resolve-hook support in this runtime — load unhooked.
116
+ }
117
+ try {
118
+ return await load()
119
+ } finally {
120
+ handle?.deregister()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Silence ONE Node warning for the duration of `run`:
126
+ * `MODULE_TYPELESS_PACKAGE_JSON`, which fires for every consumer repo whose root
127
+ * `package.json` omits `"type": "module"` (both real ones). It is unrelated to route
128
+ * validity and would print into the middle of the doctor report. Every other warning
129
+ * still prints, and the original listeners are restored unconditionally.
130
+ */
131
+ async function withoutTypelessModuleWarning(run) {
132
+ const listeners = process.listeners('warning')
133
+ process.removeAllListeners('warning')
134
+ const passthrough = (warning) => {
135
+ if (warning?.code === 'MODULE_TYPELESS_PACKAGE_JSON') return
136
+ for (const listener of listeners) listener(warning)
137
+ }
138
+ process.on('warning', passthrough)
139
+ try {
140
+ return await run()
141
+ } finally {
142
+ process.off('warning', passthrough)
143
+ for (const listener of listeners) process.on('warning', listener)
144
+ }
145
+ }
146
+
147
+ /** The first line of an error, safe for a one-line report row. */
148
+ function reason(error) {
149
+ const text = error instanceof Error ? error.message : String(error)
150
+ return text.split('\n')[0].trim()
151
+ }
152
+
153
+ /**
154
+ * Materialize a `routes` config value against the declared collections — the same
155
+ * rule `studio-core`'s `materializeRecords` applies (EC-207): a derive function runs
156
+ * once, a static array passes through. Both real consumer repos declare the array
157
+ * form; the demo and the scaffold declare the derive.
158
+ */
159
+ function materializeRoutes(routes, collections) {
160
+ return typeof routes === 'function' ? routes(collections) : [...routes]
161
+ }
162
+
163
+ /**
164
+ * Load the project's declared route set. Returns either `{ routes, routeCount }` or
165
+ * `{ unavailable, fix? }` — an honest reason the routes could not be read. Never
166
+ * throws.
167
+ *
168
+ * `importModule` is injectable for tests (the default is a native dynamic import).
169
+ */
170
+ export async function loadProjectRoutes(root, { importModule } = {}) {
171
+ const entry = join(root, CMS_ENTRY)
172
+ if (!existsSync(entry)) {
173
+ return {
174
+ unavailable: `No ${CMS_ENTRY} found — routes are declared in the project's cms/ directory.`,
175
+ }
176
+ }
177
+
178
+ const load = importModule ?? ((path) => import(pathToFileURL(path).href))
179
+ let mod
180
+ try {
181
+ mod = await withoutTypelessModuleWarning(() => withBundlerResolution(() => load(entry)))
182
+ } catch (error) {
183
+ if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
184
+ return {
185
+ unavailable: `${CMS_ENTRY} could not be read: this Node (${process.version}) does not run TypeScript natively.`,
186
+ fix: 'Use Node >=22.18 (the version the engine targets) to have `elytra doctor` check routes.',
187
+ }
188
+ }
189
+ if (error?.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
190
+ return {
191
+ unavailable: `${CMS_ENTRY} uses TypeScript syntax Node cannot strip (${reason(error)}).`,
192
+ fix: 'Keep cms/ to erasable TypeScript (no enum/namespace/parameter properties) so build-time tools can read your structure.',
193
+ }
194
+ }
195
+ return { unavailable: `${CMS_ENTRY} could not be loaded: ${reason(error)}` }
196
+ }
197
+
198
+ if (mod?.routes === undefined) {
199
+ return {
200
+ unavailable: `${CMS_ENTRY} exports no \`routes\` — the route set was not checked here.`,
201
+ }
202
+ }
203
+ if (typeof mod.routes !== 'function' && !Array.isArray(mod.routes)) {
204
+ return {
205
+ unavailable: `${CMS_ENTRY} exports \`routes\` that is neither a RouteRecord[] nor a (collections) => RouteRecord[].`,
206
+ }
207
+ }
208
+ // Distinguish "exports no collections" from "exports an empty array": the
209
+ // reachability check must report NOT CHECKED rather than a pass when it cannot see
210
+ // the collection set (a mount's target is judged against it).
211
+ const collections = Array.isArray(mod.collections) ? mod.collections : undefined
212
+
213
+ let records
214
+ try {
215
+ records = materializeRoutes(mod.routes, collections ?? [])
216
+ } catch (error) {
217
+ return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} threw: ${reason(error)}` }
218
+ }
219
+ if (!Array.isArray(records)) {
220
+ return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} did not return an array.` }
221
+ }
222
+
223
+ // Shape-validate before handing records to the router: a malformed record (no
224
+ // pattern) would otherwise fault inside pattern compilation. Config validity is
225
+ // the studio boot's job, so this reports rather than duplicating that check.
226
+ const invalid = records.filter((record) => !routeRecordSchema.safeParse(record).success)
227
+ if (invalid.length > 0) {
228
+ return {
229
+ unavailable: `${invalid.length} of ${records.length} route record(s) are not valid RouteRecords — config validation reports the details.`,
230
+ fix: 'Open the studio (or run your build) to see the config validation issues, then re-run `elytra doctor`.',
231
+ }
232
+ }
233
+
234
+ return {
235
+ routes: records,
236
+ routeCount: records.length,
237
+ ...(collections ? { collections } : {}),
238
+ }
239
+ }
240
+
241
+ // --- Detection (pure) --------------------------------------------------------
242
+
243
+ /**
244
+ * The router issues of a declared route set — route conflicts, since a CLI passes
245
+ * no redirects.
246
+ *
247
+ * Hierarchy-mount templates (`/:path*`, EC-218) are filtered out first, exactly as
248
+ * the delivery resolver does (`core/src/content/client.ts:230`): a mount is resolved
249
+ * per-request against the parent chain, not by the router, so counting it as a route
250
+ * would report conflicts the runtime does not have. Running the same filter is what
251
+ * makes doctor unable to disagree with delivery.
252
+ *
253
+ * The filter is NOT widened to cover EC-482 — {@link mountIssuesOf} answers that
254
+ * question about the routes this one removes. Widening the router's signature
255
+ * comparison instead would reinstate the `/:slug` + `/:path*` false positive the
256
+ * filter exists to prevent (pinned in both this package's and cms-core's tests).
257
+ */
258
+ export function routeIssuesOf(routes) {
259
+ return createRouter(routes.filter((route) => !isHierarchyTemplate(route)), []).issues
260
+ }
261
+
262
+ /**
263
+ * The conflicts BETWEEN hierarchy mounts (EC-482) — the routes {@link routeIssuesOf}
264
+ * filters away. `error` for two mounts on the same path (identical URL space, split
265
+ * only by declaration order), `warning` for a nested pair (a real shadow, but only
266
+ * where the outer collection's page tree actually reaches into the inner prefix — a
267
+ * content fact a CLI cannot see).
268
+ */
269
+ export function mountIssuesOf(routes) {
270
+ return hierarchyMountConflicts(routes)
271
+ }
272
+
273
+ /**
274
+ * The declarations that can never serve content (EC-484) — the third question, after
275
+ * "do two routes fight" and "do two mounts fight": **does this declaration work at
276
+ * all?** An all-dynamic route (`/:slug`) matching every URL at a depth the mount
277
+ * serves, a mount whose collection has no `parent` self-relation (or is not declared),
278
+ * and a catch-all with a dynamic prefix (`/:lang/:path*`) that every router drops and
279
+ * no mount is built from. All three were measured to resolve `notFound` with ZERO
280
+ * issues anywhere before this.
281
+ *
282
+ * `collections` is passed through when the project exported them; without them the
283
+ * mount-target half is skipped and {@link routesDoctorChecks} reports `unknown`
284
+ * instead of a pass it did not establish.
285
+ */
286
+ export function reachabilityIssuesOf(routes, collections) {
287
+ return routeReachabilityIssues(routes, collections)
288
+ }
289
+
290
+ // --- Doctor checks (pure) ----------------------------------------------------
291
+
292
+ const CONFLICT_FIX =
293
+ 'Two routes match the same URLs — give one a distinct pattern (or delete it) in cms/routes.ts.'
294
+
295
+ const MOUNT_CONFLICT_FIX =
296
+ 'Two collections cannot be served from the same URL space — give one mount a distinct prefix (e.g. "/docs/:path*") in cms/routes.ts, or drop it.'
297
+
298
+ const MOUNT_SHADOW_FIX =
299
+ 'Mounts resolve in declaration order, not by specificity — declare the more specific mount first, and keep the outer collection\'s page tree clear of that prefix.'
300
+
301
+ const UNREACHABLE_FIX =
302
+ 'Give an all-dynamic route a static segment (e.g. "/blog/:slug"), mount only collections with a `parent` self-relation, and keep a mount prefix static — in cms/routes.ts.'
303
+
304
+ /** An `unknown` check row carrying the reason the routes could not be read. */
305
+ function unavailableCheck(id, facts) {
306
+ return {
307
+ id,
308
+ bucket: 'app',
309
+ status: 'unknown',
310
+ detail: facts?.unavailable ?? 'Routes were not evaluated here.',
311
+ ...(facts?.fix ? { fix: facts.fix } : {}),
312
+ }
313
+ }
314
+
315
+ /**
316
+ * The doctor's route checks, for the `app` bucket. Consumes the already-loaded facts
317
+ * (the {@link loadProjectRoutes} result) so the reporting shape is pure and directly
318
+ * testable, like `formatReport` and `previewsDoctorChecks`.
319
+ *
320
+ * Four rows, in the order a reader needs them: the router's own conflicts, the
321
+ * mount-vs-mount conflicts the router cannot see (EC-482), the declarations that serve
322
+ * nothing at all (EC-484), then the redirects a CLI cannot see at all.
323
+ *
324
+ * `route-conflicts`, `mount-conflicts` and `unreachable-routes` are the checks here
325
+ * that can FAIL — all deterministic in code, so failing the exit code is safe for CI.
326
+ * The latter two can also `warn`, in both cases for the same reason: the finding only
327
+ * costs content where a collection's tree actually reaches that far, which is CONTENT
328
+ * a CLI cannot see, so naming it is honest where failing a build would not be.
329
+ * `redirect-loops` is always `unknown`: they are editorial data, and saying so is how
330
+ * the report stays honest about what a CLI can and cannot see.
331
+ */
332
+ export function routesDoctorChecks(facts) {
333
+ const redirects = {
334
+ id: 'redirect-loops',
335
+ bucket: 'app',
336
+ status: 'unknown',
337
+ detail:
338
+ 'Redirect loops were not evaluated here — redirects are editorial data in the backend; the studio checks them.',
339
+ }
340
+
341
+ if (!facts || facts.unavailable) {
342
+ return [
343
+ unavailableCheck('route-conflicts', facts),
344
+ unavailableCheck('mount-conflicts', facts),
345
+ unavailableCheck('unreachable-routes', facts),
346
+ redirects,
347
+ ]
348
+ }
349
+
350
+ const count = facts.routeCount ?? facts.routes.length
351
+ const plural = count === 1 ? '' : 's'
352
+ const conflicts = routeIssuesOf(facts.routes).filter((issue) => issue.code === 'route-conflict')
353
+
354
+ const routeConflicts =
355
+ conflicts.length === 0
356
+ ? {
357
+ id: 'route-conflicts',
358
+ bucket: 'app',
359
+ status: 'ok',
360
+ detail: `${count} declared route${plural}, no URL conflicts.`,
361
+ }
362
+ : {
363
+ id: 'route-conflicts',
364
+ bucket: 'app',
365
+ status: 'fail',
366
+ detail:
367
+ `${conflicts.length} route conflict(s) among ${count} declared route${plural}. ` +
368
+ conflicts.map((issue) => issue.message).join(' '),
369
+ fix: CONFLICT_FIX,
370
+ }
371
+
372
+ return [
373
+ routeConflicts,
374
+ mountConflictsCheck(facts.routes),
375
+ unreachableRoutesCheck(facts.routes, facts.collections),
376
+ redirects,
377
+ ]
378
+ }
379
+
380
+ /**
381
+ * The `unreachable-routes` row (EC-484): declarations that resolve to nothing.
382
+ *
383
+ * Like `mount-conflicts` it states what it LOOKED at when it passes — a bare "ok"
384
+ * reads the same whether the check examined the mounts or skipped them. And when the
385
+ * project exported no collections it reports `unknown` rather than `ok`: the
386
+ * mount-target half could not run, and a pass it did not establish would be exactly
387
+ * the silent failure this ticket exists to remove.
388
+ */
389
+ function unreachableRoutesCheck(routes, collections) {
390
+ const mounts = declaredMounts(routes)
391
+ const scope = `${routes.length} declared route${routes.length === 1 ? '' : 's'}, ${mounts.length} hierarchy mount${mounts.length === 1 ? '' : 's'}`
392
+ const base = { id: 'unreachable-routes', bucket: 'app' }
393
+
394
+ const issues = reachabilityIssuesOf(routes, collections)
395
+ const errors = issues.filter((issue) => issue.severity === 'error')
396
+ const warnings = issues.filter((issue) => issue.severity !== 'error')
397
+
398
+ if (errors.length > 0) {
399
+ return {
400
+ ...base,
401
+ status: 'fail',
402
+ detail:
403
+ `${errors.length} declaration(s) that cannot serve content, among ${scope}. ` +
404
+ [...errors, ...warnings].map((issue) => issue.message).join(' '),
405
+ fix: UNREACHABLE_FIX,
406
+ }
407
+ }
408
+ if (warnings.length > 0) {
409
+ return {
410
+ ...base,
411
+ status: 'warn',
412
+ detail:
413
+ `${warnings.length} partially shadowed declaration(s), among ${scope}. ` +
414
+ warnings.map((issue) => issue.message).join(' '),
415
+ fix: UNREACHABLE_FIX,
416
+ }
417
+ }
418
+ if (mounts.length > 0 && !collections) {
419
+ return {
420
+ ...base,
421
+ status: 'unknown',
422
+ detail: `${scope}: no route shadows a mount, but mount targets were not checked — ${CMS_ENTRY} exports no \`collections\`.`,
423
+ fix: `Export \`collections\` from ${CMS_ENTRY} (the shape elytra.config.ts re-exports) so mounts can be checked against them.`,
424
+ }
425
+ }
426
+ return { ...base, status: 'ok', detail: `${scope} — every declaration can serve content.` }
427
+ }
428
+
429
+ /**
430
+ * The `mount-conflicts` row (EC-482). Reports the mount COUNT even when clean — a row
431
+ * that says "1 hierarchy mount, no overlap" proves the check looked at the mounts,
432
+ * where a bare "ok" would read the same whether it examined them or skipped them.
433
+ */
434
+ function mountConflictsCheck(routes) {
435
+ const mounts = declaredMounts(routes)
436
+ const mountPlural = mounts.length === 1 ? '' : 's'
437
+ const base = { id: 'mount-conflicts', bucket: 'app' }
438
+
439
+ if (mounts.length === 0) {
440
+ return { ...base, status: 'ok', detail: 'No hierarchy mounts declared.' }
441
+ }
442
+
443
+ const issues = mountIssuesOf(routes)
444
+ const errors = issues.filter((issue) => issue.severity === 'error')
445
+ const warnings = issues.filter((issue) => issue.severity !== 'error')
446
+ const among = `among ${mounts.length} hierarchy mount${mountPlural}`
447
+
448
+ if (errors.length > 0) {
449
+ return {
450
+ ...base,
451
+ status: 'fail',
452
+ detail:
453
+ `${errors.length} conflicting mount pair(s) ${among}. ` +
454
+ [...errors, ...warnings].map((issue) => issue.message).join(' '),
455
+ fix: MOUNT_CONFLICT_FIX,
456
+ }
457
+ }
458
+ if (warnings.length > 0) {
459
+ return {
460
+ ...base,
461
+ status: 'warn',
462
+ detail:
463
+ `${warnings.length} overlapping mount pair(s) ${among}. ` +
464
+ warnings.map((issue) => issue.message).join(' '),
465
+ fix: MOUNT_SHADOW_FIX,
466
+ }
467
+ }
468
+ return {
469
+ ...base,
470
+ status: 'ok',
471
+ detail: `${mounts.length} hierarchy mount${mountPlural}, no overlapping mounts.`,
472
+ }
473
+ }