@paramour-js/next 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/bin/paramour.js +19 -0
  3. package/dist/app.d.ts +76 -0
  4. package/dist/app.js +32 -0
  5. package/dist/cli-args.d.ts +28 -0
  6. package/dist/cli-args.js +35 -0
  7. package/dist/cli-inputs.d.ts +32 -0
  8. package/dist/cli-inputs.js +80 -0
  9. package/dist/cli-io.d.ts +15 -0
  10. package/dist/cli-io.js +16 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +5 -0
  13. package/dist/collisions.d.ts +37 -0
  14. package/dist/collisions.js +80 -0
  15. package/dist/commands/doctor.d.ts +7 -0
  16. package/dist/commands/doctor.js +56 -0
  17. package/dist/commands/generate.d.ts +11 -0
  18. package/dist/commands/generate.js +222 -0
  19. package/dist/commands/init.d.ts +9 -0
  20. package/dist/commands/init.js +205 -0
  21. package/dist/commands/list.d.ts +9 -0
  22. package/dist/commands/list.js +94 -0
  23. package/dist/config.d.ts +35 -0
  24. package/dist/config.js +99 -0
  25. package/dist/doctor/checks.d.ts +16 -0
  26. package/dist/doctor/checks.js +231 -0
  27. package/dist/emit.d.ts +35 -0
  28. package/dist/emit.js +74 -0
  29. package/dist/generate.d.ts +70 -0
  30. package/dist/generate.js +106 -0
  31. package/dist/index.d.ts +9 -0
  32. package/dist/index.js +9 -0
  33. package/dist/init/scaffold.d.ts +39 -0
  34. package/dist/init/scaffold.js +244 -0
  35. package/dist/init/wrap-next-config.d.ts +41 -0
  36. package/dist/init/wrap-next-config.js +99 -0
  37. package/dist/list/discover-route-defs.d.ts +52 -0
  38. package/dist/list/discover-route-defs.js +121 -0
  39. package/dist/list/render.d.ts +35 -0
  40. package/dist/list/render.js +132 -0
  41. package/dist/lock.d.ts +29 -0
  42. package/dist/lock.js +88 -0
  43. package/dist/pages.d.ts +49 -0
  44. package/dist/pages.js +62 -0
  45. package/dist/run-cli.d.ts +11 -0
  46. package/dist/run-cli.js +53 -0
  47. package/dist/scan-app.d.ts +30 -0
  48. package/dist/scan-app.js +149 -0
  49. package/dist/scan-pages.d.ts +10 -0
  50. package/dist/scan-pages.js +102 -0
  51. package/dist/scan.d.ts +32 -0
  52. package/dist/scan.js +77 -0
  53. package/dist/select.d.ts +94 -0
  54. package/dist/select.js +195 -0
  55. package/dist/watch.d.ts +39 -0
  56. package/dist/watch.js +87 -0
  57. package/dist/with-typed-routes.d.ts +44 -0
  58. package/dist/with-typed-routes.js +200 -0
  59. package/package.json +67 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Paff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Committed bin stub: pnpm creates bin shims at install time and silently
3
+ // skips any whose target doesn't exist yet — dist/cli.js only exists after
4
+ // a build, so pointing `bin` at it left the examples' shims uncreated (and
5
+ // a second install won't retry: an up-to-date lockfile skips the link
6
+ // phase). This file always exists, so the shim is always created; it just
7
+ // delegates to the built CLI.
8
+ import { existsSync } from "node:fs";
9
+
10
+ const cli = new URL("../dist/cli.js", import.meta.url);
11
+
12
+ if (!existsSync(cli)) {
13
+ console.error(
14
+ "paramour: @paramour-js/next is not built (dist/cli.js is missing). Run `pnpm build:packages` first.",
15
+ );
16
+ process.exit(1);
17
+ }
18
+
19
+ await import(cli.href);
package/dist/app.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { type AnyAppRoute, type InferRouteParams, type SafeResult, type SearchOutputOf } from "paramour";
2
+ import { type SelectOptions } from "./select.js";
3
+ export type { SelectOptions } from "./select.js";
4
+ /**
5
+ * Client hooks (DESIGN §9, design-07). Each layers over Next's
6
+ * `useSearchParams()` / `useParams()` — App-Router params are synchronous on
7
+ * the client, so there is no loading state, no `useEffect`/`useState`, and
8
+ * the result is SSR-consistent. Two layers per hook (design-07):
9
+ *
10
+ * - Raw-slice stabilization (SEL4): the decode is keyed on the DECLARED
11
+ * slice of the raw source, not on Next's object reference — a URL change
12
+ * that only touches keys the route doesn't own (`?utm_source=` churn)
13
+ * returns the previous result by identity, without re-decoding. Next still
14
+ * re-renders every subscriber on any URL change (it owns the subscription
15
+ * — SEL7: selectors stabilize slices, they cannot skip renders); this
16
+ * layer makes that render cheap and downstream-invisible.
17
+ * - Selection (SEL1–SEL3): every hook takes an optional `{ select }` that
18
+ * projects the decoded value, with result-equality checking (`Object.is`,
19
+ * `equality: "shallow"` opt-in) so an unchanged selection keeps its
20
+ * previous reference when OTHER params change.
21
+ *
22
+ * Both layers are render-phase ref caches (SEL8) — the one sanctioned
23
+ * departure from the pure-`useMemo` discipline these hooks previously held.
24
+ *
25
+ * Two surfaces per half, mirroring core's server `parse` vs `safeParse`:
26
+ * - `useSearch` / `useRouteParams` return the `SafeResult` union
27
+ * (discriminated on `status`, PR12) — a user editing the URL never crashes
28
+ * the component. The selector runs on the success arm only (SEL2).
29
+ * - `useSearchOrThrow` / `useRouteParamsOrThrow` throw the decode error in
30
+ * render, to the nearest client error boundary.
31
+ *
32
+ * Both read the route's blessed-internal `~search` / `~params` via the core
33
+ * decoders (design-03 RL6 — `@paramour/next` is a sanctioned consumer).
34
+ *
35
+ * Every hook is gated to `AnyAppRoute` (design-06 PR3): a pages-branded route
36
+ * at one of these call sites is a compile error, not a runtime surprise —
37
+ * these hooks read Next's App-Router navigation hooks, whose pages twin has
38
+ * different state cardinality (`@paramour-js/next/pages`).
39
+ */
40
+ /**
41
+ * Decoded route params as a `SafeResult` (discriminated on `status`, PR12),
42
+ * optionally projected through `options.select` (design-07 SEL1/SEL2).
43
+ *
44
+ * `useParams()` returns `null` outside an App-Router tree — including the
45
+ * initial render of every pages-router page in a hybrid app — so a `null`
46
+ * context degrades to an empty source (`{}`): required params surface as
47
+ * ordinary "missing" decode issues (a `SafeResult` error arm), never a crash.
48
+ * Core keeps its loud throw for genuinely non-object sources from plain-JS
49
+ * callers; the null tolerance lives here at the adapter.
50
+ */
51
+ export declare function useRouteParams<R extends AnyAppRoute>(route: R): SafeResult<InferRouteParams<R>>;
52
+ export declare function useRouteParams<R extends AnyAppRoute, U>(route: R, options: SelectOptions<InferRouteParams<R>, U>): SafeResult<U>;
53
+ /**
54
+ * Decoded route params, or a thrown {@link ParamsDecodeError} (→ nearest
55
+ * client error boundary) on a malformed URL. Optionally projected through
56
+ * `options.select` (design-07 SEL1/SEL2).
57
+ *
58
+ * A `null` `useParams()` (outside an App-Router tree, e.g. a hybrid app's
59
+ * pages-router initial render) degrades to `{}` so required params throw the
60
+ * documented {@link ParamsDecodeError}, not an undocumented error class.
61
+ */
62
+ export declare function useRouteParamsOrThrow<R extends AnyAppRoute>(route: R): InferRouteParams<R>;
63
+ export declare function useRouteParamsOrThrow<R extends AnyAppRoute, U>(route: R, options: SelectOptions<InferRouteParams<R>, U>): U;
64
+ /**
65
+ * Decoded search params as a `SafeResult` (discriminated on `status`, PR12),
66
+ * optionally projected through `options.select` (design-07 SEL1/SEL2).
67
+ */
68
+ export declare function useSearch<R extends AnyAppRoute>(route: R): SafeResult<SearchOutputOf<R["~search"]>>;
69
+ export declare function useSearch<R extends AnyAppRoute, U>(route: R, options: SelectOptions<SearchOutputOf<R["~search"]>, U>): SafeResult<U>;
70
+ /**
71
+ * Decoded search params, or a thrown {@link SearchDecodeError} (→ nearest
72
+ * client error boundary) on a malformed URL. Optionally projected through
73
+ * `options.select` (design-07 SEL1/SEL2).
74
+ */
75
+ export declare function useSearchOrThrow<R extends AnyAppRoute>(route: R): SearchOutputOf<R["~search"]>;
76
+ export declare function useSearchOrThrow<R extends AnyAppRoute, U>(route: R, options: SelectOptions<SearchOutputOf<R["~search"]>, U>): U;
package/dist/app.js ADDED
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useParams, useSearchParams } from "next/navigation";
3
+ import { decodeParams, decodeSearch, safeDecodeParams, safeDecodeSearch, } from "paramour";
4
+ import { paramsFingerprint, searchParamsFingerprint, useSelectedResult, useSelectedValue, useStableResult, } from "./select.js";
5
+ export function useRouteParams(route, options) {
6
+ const params = useParams() ?? {};
7
+ const result = useStableResult(route, paramsFingerprint(route, params), () => safeDecodeParams(route, params));
8
+ return useSelectedResult(result, options);
9
+ }
10
+ export function useRouteParamsOrThrow(route, options) {
11
+ const params = useParams() ?? {};
12
+ const value = useStableResult(route, paramsFingerprint(route, params), () => decodeParams(route, params));
13
+ return useSelectedValue(value, options);
14
+ }
15
+ export function useSearch(route, options) {
16
+ const searchParams = useSearchParams();
17
+ const result = useStableResult(route, searchParamsFingerprint(route, searchParams), () => safeDecodeSearch(route, searchParams));
18
+ return useSelectedResult(result, options);
19
+ }
20
+ export function useSearchOrThrow(route, options) {
21
+ const searchParams = useSearchParams();
22
+ const value = useStableResult(route, searchParamsFingerprint(route, searchParams),
23
+ // decodeSearch is keyed on SearchOutputOf (design-04 SS6) — the correct
24
+ // public type — but AnyAppRoute erases its SC to `any`, so for a still-
25
+ // generic R the call's SearchOutputOf<R["~search"]> reduces to `unknown`
26
+ // on the value side while staying deferred on the annotation side. The
27
+ // cast bridges that inference gap to the SAME (correct) type, so a
28
+ // rawSearch route now infers its schema output here, not a garbage
29
+ // {~kind, ~schema} shape.
30
+ () => decodeSearch(route["~search"], searchParams));
31
+ return useSelectedValue(value, options);
32
+ }
@@ -0,0 +1,28 @@
1
+ import { parseArgs, type ParseArgsOptionsConfig } from "node:util";
2
+ import { type ResolvedIo } from "./cli-io.js";
3
+ /** Every command declares `--help` identically; the prologue relies on it. */
4
+ interface HelpOption {
5
+ help: {
6
+ default: false;
7
+ short: "h";
8
+ type: "boolean";
9
+ };
10
+ }
11
+ /** What `parseArgs` infers for `values` from an options table `T`. */
12
+ type ParsedValues<T extends ParseArgsOptionsConfig> = ReturnType<typeof parseArgs<{
13
+ allowPositionals: true;
14
+ args: string[];
15
+ options: T;
16
+ }>>["values"];
17
+ /**
18
+ * The shared command prologue (TR7): parse flags, print usage on a parse
19
+ * error (exit 2) or `--help` (exit 0), and reject positionals — no command
20
+ * takes one. Callers branch on `"exit" in result`; anything past the
21
+ * prologue (mode merging, flag exclusivity) stays per-command.
22
+ */
23
+ export declare function parseCommandFlags<const T extends HelpOption & ParseArgsOptionsConfig>(argv: readonly string[], options: T, usage: string, { stderr, stdout }: ResolvedIo): {
24
+ exit: 0 | 2;
25
+ } | {
26
+ values: ParsedValues<T>;
27
+ };
28
+ export {};
@@ -0,0 +1,35 @@
1
+ import { parseArgs } from "node:util";
2
+ import { message } from "./cli-io.js";
3
+ /**
4
+ * The shared command prologue (TR7): parse flags, print usage on a parse
5
+ * error (exit 2) or `--help` (exit 0), and reject positionals — no command
6
+ * takes one. Callers branch on `"exit" in result`; anything past the
7
+ * prologue (mode merging, flag exclusivity) stays per-command.
8
+ */
9
+ export function parseCommandFlags(argv, options, usage, { stderr, stdout }) {
10
+ let parsed;
11
+ try {
12
+ parsed = parseArgs({
13
+ allowPositionals: true,
14
+ args: [...argv],
15
+ options,
16
+ });
17
+ }
18
+ catch (error) {
19
+ stderr(`paramour: ${message(error)}`);
20
+ stderr(usage);
21
+ return { exit: 2 };
22
+ }
23
+ // parseArgs's values type stays opaque while T is unresolved; the
24
+ // HelpOption constraint guarantees the property exists.
25
+ if (parsed.values.help === true) {
26
+ stdout(usage);
27
+ return { exit: 0 };
28
+ }
29
+ if (parsed.positionals.length > 0) {
30
+ stderr(`paramour: unexpected argument "${parsed.positionals[0] ?? ""}"`);
31
+ stderr(usage);
32
+ return { exit: 2 };
33
+ }
34
+ return { values: parsed.values };
35
+ }
@@ -0,0 +1,32 @@
1
+ import { type ParamourConfig } from "./config.js";
2
+ import { type GenerateInputs } from "./generate.js";
3
+ /**
4
+ * The shared route-dir/artifact flags — every command that scans
5
+ * (`generate`, `check`, `list`) parses these and resolves them here.
6
+ */
7
+ export interface InputFlags {
8
+ "app-dir"?: string | undefined;
9
+ "out-file"?: string | undefined;
10
+ "page-extensions"?: string | undefined;
11
+ "pages-dir"?: string | undefined;
12
+ }
13
+ /**
14
+ * The no-route-dirs case as a distinct class: `generate`/`check`/`list`
15
+ * treat it like any other exit-2 error, but `init` downgrades it to a
16
+ * warn-and-skip — a fresh project legitimately has no app/ or pages/ yet.
17
+ */
18
+ export declare class NoRouteDirsError extends Error {
19
+ }
20
+ /**
21
+ * Precedence lives in exactly this function (TR7 / §7.2): flags → config
22
+ * file → joint discovery (PR8). Paths resolve against the project root
23
+ * (= cwd, where `next` itself would run). Discovery only runs for dirs not
24
+ * explicitly given — passing both bypasses it (and its populated-ignored-dir
25
+ * config error) entirely, which is the documented escape hatch. Only when
26
+ * NEITHER dir exists is that an error (PR8): app-only and pages-only
27
+ * projects are both fine.
28
+ *
29
+ * Commands that already loaded the config file (for fields beyond these,
30
+ * e.g. `list`'s routeFiles) pass it as `preloaded` so jiti runs once.
31
+ */
32
+ export declare function resolveInputs(flags: InputFlags, projectRoot: string, preloaded?: ParamourConfig): Promise<GenerateInputs>;
@@ -0,0 +1,80 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { loadConfigFile } from "./config.js";
4
+ import {} from "./generate.js";
5
+ import { DEFAULT_PAGE_EXTENSIONS } from "./scan-app.js";
6
+ import { resolveRouteDirs } from "./scan.js";
7
+ /**
8
+ * The no-route-dirs case as a distinct class: `generate`/`check`/`list`
9
+ * treat it like any other exit-2 error, but `init` downgrades it to a
10
+ * warn-and-skip — a fresh project legitimately has no app/ or pages/ yet.
11
+ */
12
+ export class NoRouteDirsError extends Error {
13
+ }
14
+ /**
15
+ * Precedence lives in exactly this function (TR7 / §7.2): flags → config
16
+ * file → joint discovery (PR8). Paths resolve against the project root
17
+ * (= cwd, where `next` itself would run). Discovery only runs for dirs not
18
+ * explicitly given — passing both bypasses it (and its populated-ignored-dir
19
+ * config error) entirely, which is the documented escape hatch. Only when
20
+ * NEITHER dir exists is that an error (PR8): app-only and pages-only
21
+ * projects are both fine.
22
+ *
23
+ * Commands that already loaded the config file (for fields beyond these,
24
+ * e.g. `list`'s routeFiles) pass it as `preloaded` so jiti runs once.
25
+ */
26
+ export async function resolveInputs(flags, projectRoot, preloaded) {
27
+ const file = preloaded ?? (await loadConfigFile(projectRoot))?.config;
28
+ const pageExtensions = parsePageExtensions(flags["page-extensions"]) ??
29
+ file?.pageExtensions ??
30
+ DEFAULT_PAGE_EXTENSIONS;
31
+ const explicitAppDir = flags["app-dir"] ?? file?.appDir;
32
+ const explicitPagesDir = flags["pages-dir"] ?? file?.pagesDir;
33
+ let appDir = explicitAppDir === undefined
34
+ ? undefined
35
+ : checkedDir(explicitAppDir, "app", projectRoot);
36
+ let pagesDir = explicitPagesDir === undefined
37
+ ? undefined
38
+ : checkedDir(explicitPagesDir, "pages", projectRoot);
39
+ if (explicitAppDir === undefined || explicitPagesDir === undefined) {
40
+ const discovered = resolveRouteDirs(projectRoot, pageExtensions);
41
+ appDir ??= discovered.appDir;
42
+ pagesDir ??= discovered.pagesDir;
43
+ }
44
+ if (appDir === undefined && pagesDir === undefined) {
45
+ throw new NoRouteDirsError(`no route directory (app/, pages/, src/app/, or src/pages/) under ${projectRoot}; pass --app-dir/--pages-dir or set appDir/pagesDir in paramour.config`);
46
+ }
47
+ return {
48
+ appDir,
49
+ artifactPath: resolve(projectRoot, flags["out-file"] ?? file?.outFile ?? "paramour-env.d.ts"),
50
+ pageExtensions,
51
+ pagesDir,
52
+ };
53
+ }
54
+ /** Resolve an explicitly-given dir and require that it exists. */
55
+ function checkedDir(dir, router, projectRoot) {
56
+ const resolved = resolve(projectRoot, dir);
57
+ if (!statSync(resolved, { throwIfNoEntry: false })?.isDirectory()) {
58
+ throw new Error(`${router} directory not found: ${resolved}`);
59
+ }
60
+ return resolved;
61
+ }
62
+ /** `--page-extensions tsx,mdx` → `["tsx", "mdx"]`; empty list is an error. */
63
+ function parsePageExtensions(flag) {
64
+ if (flag === undefined)
65
+ return undefined;
66
+ const list = flag
67
+ .split(",")
68
+ .map((ext) => ext.trim())
69
+ .filter((ext) => ext !== "");
70
+ if (list.length === 0) {
71
+ throw new Error("--page-extensions requires a comma-separated list");
72
+ }
73
+ // Mirrors the config-file validation: a leading dot silently matches
74
+ // nothing (`page..tsx` never exists on disk).
75
+ const dotted = list.find((ext) => ext.startsWith("."));
76
+ if (dotted !== undefined) {
77
+ throw new Error(`--page-extensions entries must not start with a dot: "${dotted}"`);
78
+ }
79
+ return list;
80
+ }
@@ -0,0 +1,15 @@
1
+ /** @internal I/O seams for tests; defaults write to the console. */
2
+ export interface CliIo {
3
+ /** Aborting stops `generate --watch` and resolves `runCli` with 0. */
4
+ signal?: AbortSignal;
5
+ stderr?: (line: string) => void;
6
+ stdout?: (line: string) => void;
7
+ }
8
+ /** {@link CliIo} with the write seams defaulted. */
9
+ export interface ResolvedIo {
10
+ stderr: (line: string) => void;
11
+ stdout: (line: string) => void;
12
+ }
13
+ /** Error message without the stack — CLI output, not a crash report. */
14
+ export declare function message(error: unknown): string;
15
+ export declare function resolveIo(io: CliIo): ResolvedIo;
package/dist/cli-io.js ADDED
@@ -0,0 +1,16 @@
1
+ /** Error message without the stack — CLI output, not a crash report. */
2
+ export function message(error) {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
5
+ export function resolveIo(io) {
6
+ return {
7
+ stderr: io.stderr ??
8
+ ((line) => {
9
+ console.error(line);
10
+ }),
11
+ stdout: io.stdout ??
12
+ ((line) => {
13
+ console.log(line);
14
+ }),
15
+ };
16
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./run-cli.js";
3
+ // The bin entry (TR7): all logic lives in run-cli.ts so tests never execute
4
+ // this statement. exitCode, not exit() — pending stdio writes must flush.
5
+ process.exitCode = await runCli(process.argv.slice(2));
@@ -0,0 +1,37 @@
1
+ /** A scanned route path labeled with the router that produced it (PR9). */
2
+ export interface ScannedRoute {
3
+ path: string;
4
+ router: "app" | "pages";
5
+ }
6
+ /**
7
+ * Route-collision failure mode (PR9): states Next itself refuses to build
8
+ * have no valid artifact, so the scanners throw instead of emitting one.
9
+ * Composition points map this error to their ruled exits — CLI exit 2,
10
+ * `withTypedRoutes` throw during config evaluation, and a non-fatal loud
11
+ * log under watch (the TR5 exception: a collision mid-`--watch` is usually
12
+ * a file mid-move, so the last good artifact stays on disk).
13
+ */
14
+ export declare class RouteCollisionError extends Error {
15
+ name: string;
16
+ }
17
+ /**
18
+ * PR9's structural collisions — same detection pass, non-equal strings. Two
19
+ * states Next also refuses to build that plain string equality misses:
20
+ *
21
+ * - **Different slug names at one level**: `/x/[id]` + `/x/[slug]` — Next:
22
+ * "You cannot use different slug names for the same dynamic path". Within
23
+ * one parent, at one segment position, at most one dynamic segment of each
24
+ * KIND may exist — across both scanners, and cross-router for a shared
25
+ * prefix, which is why the entries carry router labels. Kinds are compared
26
+ * separately because `[id]` beside `[...slug]` is Next's documented
27
+ * priority pattern (predefined > dynamic > catch-all), not an error; but
28
+ * `[...a]` beside `[[...a]]` IS one ("optional and required catch-all at
29
+ * the same level"), so the catch-all family compares whole segments.
30
+ * - **Optional-catch-all specificity**: `/docs` + `/docs/[[...slug]]` — an
31
+ * optional catch-all also matches its own base path, so the pair collides
32
+ * at `/docs` even though the strings differ.
33
+ *
34
+ * Callers: each scanner over its own output, then the orchestrator over the
35
+ * merged, labeled union (cross-router pairs only surface there).
36
+ */
37
+ export declare function assertNoStructuralCollisions(routes: readonly ScannedRoute[]): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Route-collision failure mode (PR9): states Next itself refuses to build
3
+ * have no valid artifact, so the scanners throw instead of emitting one.
4
+ * Composition points map this error to their ruled exits — CLI exit 2,
5
+ * `withTypedRoutes` throw during config evaluation, and a non-fatal loud
6
+ * log under watch (the TR5 exception: a collision mid-`--watch` is usually
7
+ * a file mid-move, so the last good artifact stays on disk).
8
+ */
9
+ export class RouteCollisionError extends Error {
10
+ name = "RouteCollisionError";
11
+ }
12
+ /**
13
+ * `[[...name]]` → optional catch-all; used for the specificity check below.
14
+ */
15
+ const OPTIONAL_CATCH_ALL = /^\[\[\.\.\..+\]\]$/;
16
+ /**
17
+ * Classifies a path segment: `plain` for `[id]`, `catchAll` for `[...slug]`
18
+ * AND `[[...slug]]` (one family — mixing required and optional at one level
19
+ * is itself a Next error), `undefined` for a literal segment. Alternatives
20
+ * ordered most-wrapped first so `[[...x]]` is never misread as a `[name]`
21
+ * whose name is `[...x]`.
22
+ */
23
+ const DYNAMIC_SEGMENT = /^(?:\[\[\.\.\.(?<optional>.+)\]\]|\[\.\.\.(?<catchAll>.+)\]|\[(?<plain>[^[\]]+)\])$/;
24
+ /**
25
+ * PR9's structural collisions — same detection pass, non-equal strings. Two
26
+ * states Next also refuses to build that plain string equality misses:
27
+ *
28
+ * - **Different slug names at one level**: `/x/[id]` + `/x/[slug]` — Next:
29
+ * "You cannot use different slug names for the same dynamic path". Within
30
+ * one parent, at one segment position, at most one dynamic segment of each
31
+ * KIND may exist — across both scanners, and cross-router for a shared
32
+ * prefix, which is why the entries carry router labels. Kinds are compared
33
+ * separately because `[id]` beside `[...slug]` is Next's documented
34
+ * priority pattern (predefined > dynamic > catch-all), not an error; but
35
+ * `[...a]` beside `[[...a]]` IS one ("optional and required catch-all at
36
+ * the same level"), so the catch-all family compares whole segments.
37
+ * - **Optional-catch-all specificity**: `/docs` + `/docs/[[...slug]]` — an
38
+ * optional catch-all also matches its own base path, so the pair collides
39
+ * at `/docs` even though the strings differ.
40
+ *
41
+ * Callers: each scanner over its own output, then the orchestrator over the
42
+ * merged, labeled union (cross-router pairs only surface there).
43
+ */
44
+ export function assertNoStructuralCollisions(routes) {
45
+ // parent-prefix + position + kind → the first dynamic segment seen there.
46
+ const dynamicAt = new Map();
47
+ const byPath = new Map();
48
+ for (const route of routes)
49
+ byPath.set(route.path, route);
50
+ for (const route of routes) {
51
+ const segments = route.path === "/" ? [] : route.path.slice(1).split("/");
52
+ for (const [index, segment] of segments.entries()) {
53
+ const kind = dynamicKind(segment);
54
+ if (kind === undefined)
55
+ continue;
56
+ const key = `${segments.slice(0, index).join("/")}#${String(index)}#${kind}`;
57
+ const existing = dynamicAt.get(key);
58
+ if (existing !== undefined && existing.segment !== segment) {
59
+ throw new RouteCollisionError(`route collision: "${existing.path}" (${existing.router}) and "${route.path}" (${route.router}) declare conflicting dynamic segments (${existing.segment} vs ${segment}) at the same position — Next refuses different slug names for the same dynamic path (PR9)`);
60
+ }
61
+ if (existing === undefined) {
62
+ dynamicAt.set(key, { ...route, segment });
63
+ }
64
+ }
65
+ const last = segments.at(-1);
66
+ if (last !== undefined && OPTIONAL_CATCH_ALL.test(last)) {
67
+ const base = segments.length === 1 ? "/" : `/${segments.slice(0, -1).join("/")}`;
68
+ const baseRoute = byPath.get(base);
69
+ if (baseRoute !== undefined) {
70
+ throw new RouteCollisionError(`route collision: "${route.path}" (${route.router}) also matches "${base}" (${baseRoute.router}) — an optional catch-all has the same specificity as its base path, which Next refuses to build (PR9)`);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ function dynamicKind(segment) {
76
+ const groups = DYNAMIC_SEGMENT.exec(segment)?.groups;
77
+ if (groups === undefined)
78
+ return undefined;
79
+ return groups.plain === undefined ? "catchAll" : "plain";
80
+ }
@@ -0,0 +1,7 @@
1
+ import { type CliIo } from "../cli-io.js";
2
+ /**
3
+ * @internal `paramour doctor` — a verification, so its exit codes follow
4
+ * `check`'s class: 0 pass/warn, 1 any fail, 2 only when doctor itself
5
+ * cannot run.
6
+ */
7
+ export declare function runDoctor(argv: readonly string[], io: CliIo): Promise<number>;
@@ -0,0 +1,56 @@
1
+ import { parseCommandFlags } from "../cli-args.js";
2
+ import { message, resolveIo } from "../cli-io.js";
3
+ import { runDoctorChecks } from "../doctor/checks.js";
4
+ const MARKS = { fail: "✖", pass: "✔", warn: "⚠" };
5
+ const USAGE = [
6
+ "Usage: paramour doctor [options]",
7
+ "",
8
+ "Diagnose the project's paramour setup: config validity, artifact",
9
+ "freshness, next.config wrapping, version alignment, tsconfig coverage,",
10
+ "and route-definition discovery (which evaluates matched modules, like",
11
+ "`paramour list`).",
12
+ "",
13
+ "Exit codes: 0 all checks pass (warnings allowed), 1 any check fails.",
14
+ "",
15
+ "Options:",
16
+ " --help, -h show this help",
17
+ " --json machine-readable output",
18
+ ].join("\n");
19
+ /**
20
+ * @internal `paramour doctor` — a verification, so its exit codes follow
21
+ * `check`'s class: 0 pass/warn, 1 any fail, 2 only when doctor itself
22
+ * cannot run.
23
+ */
24
+ export async function runDoctor(argv, io) {
25
+ const { stderr, stdout } = resolveIo(io);
26
+ const parsed = parseCommandFlags(argv, {
27
+ help: { default: false, short: "h", type: "boolean" },
28
+ json: { default: false, type: "boolean" },
29
+ }, USAGE, { stderr, stdout });
30
+ if ("exit" in parsed)
31
+ return parsed.exit;
32
+ const flags = parsed.values;
33
+ let checks;
34
+ try {
35
+ checks = await runDoctorChecks(process.cwd());
36
+ }
37
+ catch (error) {
38
+ stderr(`paramour: ${message(error)}`);
39
+ return 2;
40
+ }
41
+ const failed = checks.filter((check) => check.status === "fail").length;
42
+ const warned = checks.filter((check) => check.status === "warn").length;
43
+ const status = failed > 0 ? "fail" : warned > 0 ? "warn" : "pass";
44
+ if (flags.json) {
45
+ stdout(JSON.stringify({ checks, status }, null, 2));
46
+ return failed > 0 ? 1 : 0;
47
+ }
48
+ for (const check of checks) {
49
+ stdout(` ${MARKS[check.status]} ${check.label}`);
50
+ for (const line of check.detail ?? [])
51
+ stdout(` ${line}`);
52
+ }
53
+ stdout("");
54
+ stdout(`doctor: ${String(checks.length)} checks — ${String(failed)} failed, ${String(warned)} warning${warned === 1 ? "" : "s"}`);
55
+ return failed > 0 ? 1 : 0;
56
+ }
@@ -0,0 +1,11 @@
1
+ import { type CliIo } from "../cli-io.js";
2
+ /**
3
+ * @internal `paramour generate` and its `check` alias (TR7), in-process
4
+ * testable: returns the exit code instead of exiting. Codes are grep-style
5
+ * so CI can tell drift from breakage: 0 success, 1 check-drift ONLY, 2
6
+ * usage/config/operational errors — route collisions included (PR9: Next
7
+ * itself fails that build, so there is no artifact to emit). Unlike the
8
+ * wrapper's never-load-bearing stance (§7.3), the CLI fails loudly —
9
+ * running it is explicit user intent.
10
+ */
11
+ export declare function runGenerate(argv: readonly string[], io: CliIo, mode: "check" | "generate"): Promise<number>;