next-route-graph 1.0.2 → 1.2.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 (74) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/CONTRIBUTING.md +74 -0
  3. package/README.md +169 -134
  4. package/SECURITY.md +19 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +126 -44
  8. package/dist/core/analyzer.d.ts +16 -0
  9. package/dist/core/analyzer.d.ts.map +1 -0
  10. package/dist/core/analyzer.js +113 -29
  11. package/dist/core/detector.d.ts +19 -0
  12. package/dist/core/detector.d.ts.map +1 -0
  13. package/dist/core/detector.js +31 -3
  14. package/dist/core/inspect.d.ts +6 -0
  15. package/dist/core/inspect.d.ts.map +1 -0
  16. package/dist/core/inspect.js +21 -0
  17. package/dist/core/linter.d.ts +11 -0
  18. package/dist/core/linter.d.ts.map +1 -0
  19. package/dist/core/linter.js +194 -0
  20. package/dist/core/parser.d.ts +95 -0
  21. package/dist/core/parser.d.ts.map +1 -0
  22. package/dist/core/parser.js +242 -15
  23. package/dist/core/scanner.d.ts +12 -0
  24. package/dist/core/scanner.d.ts.map +1 -0
  25. package/dist/core/scanner.js +32 -6
  26. package/dist/errors/index.d.ts +35 -0
  27. package/dist/errors/index.d.ts.map +1 -0
  28. package/dist/errors/index.js +65 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +17 -0
  32. package/dist/preview.d.ts +4 -0
  33. package/dist/preview.d.ts.map +1 -0
  34. package/dist/preview.js +25 -0
  35. package/dist/render/dot.d.ts +13 -0
  36. package/dist/render/dot.d.ts.map +1 -0
  37. package/dist/render/dot.js +66 -0
  38. package/dist/render/flat.d.ts +13 -0
  39. package/dist/render/flat.d.ts.map +1 -0
  40. package/dist/render/flat.js +14 -2
  41. package/dist/render/graph.d.ts +13 -0
  42. package/dist/render/graph.d.ts.map +1 -0
  43. package/dist/render/graph.js +53 -0
  44. package/dist/render/html.d.ts +8 -0
  45. package/dist/render/html.d.ts.map +1 -0
  46. package/dist/render/html.js +718 -0
  47. package/dist/render/index.d.ts +20 -0
  48. package/dist/render/index.d.ts.map +1 -0
  49. package/dist/render/index.js +37 -0
  50. package/dist/render/json.d.ts +13 -0
  51. package/dist/render/json.d.ts.map +1 -0
  52. package/dist/render/json.js +12 -14
  53. package/dist/render/markdown.d.ts +13 -0
  54. package/dist/render/markdown.d.ts.map +1 -0
  55. package/dist/render/markdown.js +59 -0
  56. package/dist/render/mermaid.d.ts +13 -0
  57. package/dist/render/mermaid.d.ts.map +1 -0
  58. package/dist/render/mermaid.js +64 -0
  59. package/dist/render/renderer.interface.d.ts +8 -0
  60. package/dist/render/renderer.interface.d.ts.map +1 -0
  61. package/dist/render/renderer.interface.js +1 -0
  62. package/dist/render/svg.d.ts +8 -0
  63. package/dist/render/svg.d.ts.map +1 -0
  64. package/dist/render/svg.js +76 -0
  65. package/dist/render/tree.d.ts +13 -0
  66. package/dist/render/tree.d.ts.map +1 -0
  67. package/dist/render/tree.js +57 -7
  68. package/dist/types/index.d.ts +132 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/docs/ARCHITECTURE.md +71 -0
  71. package/docs/NEW_FEATURES.md +16 -0
  72. package/docs/adr/0001-offline-reports-and-shared-route-graph.md +29 -0
  73. package/docs/route-report.png +0 -0
  74. package/package.json +71 -52
package/dist/cli.js CHANGED
@@ -1,58 +1,140 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { stripVTControlCharacters } from "node:util";
5
+ import { Command, Option } from "commander";
3
6
  import pc from "picocolors";
4
- import { analyzeRoutes } from "./core/analyzer.js";
5
- import { detectAppRouter } from "./core/detector.js";
6
- import { scanAppDir } from "./core/scanner.js";
7
- import { renderFlat } from "./render/flat.js";
8
- import { renderJson } from "./render/json.js";
9
- import { renderTree } from "./render/tree.js";
10
- const program = new Command();
11
- program
12
- .name("next-route-graph")
13
- .description("Inspect and visualize Next.js App Router project routes")
14
- .version("1.0.0")
15
- .argument("[projectPath]", "Path to the Next.js project root", ".")
16
- .option("--flat", "Output only flat route paths")
17
- .option("--json", "Output machine-readable JSON")
18
- .option("--search <query>", "Filter routes by search string")
19
- .action(async (projectPath, options) => {
7
+ import { filterRoutes, inspectProject } from "./core/inspect.js";
8
+ import { NextRouteGraphError, RouteLintError } from "./errors/index.js";
9
+ import { startPreview } from "./preview.js";
10
+ import { getRenderer } from "./render/index.js";
11
+ function resolveFormat(options) {
12
+ const aliases = [
13
+ "json",
14
+ "flat",
15
+ "mermaid",
16
+ "dot",
17
+ "markdown",
18
+ "html",
19
+ "svg",
20
+ ];
21
+ const selected = aliases.filter((format) => options[format]);
22
+ if (selected.length + Number(Boolean(options.format)) > 1) {
23
+ throw new Error("Choose one output format: --format <type> or one format flag.");
24
+ }
25
+ const format = selected[0] ??
26
+ options.format ??
27
+ (options.serve !== undefined ? "html" : "tree");
28
+ if (options.html && options.output)
29
+ throw new Error("Use --html [file] or --output <file>, not both.");
30
+ if (options.serve !== undefined &&
31
+ (format !== "html" || options.output || options.html)) {
32
+ throw new Error("Use --serve on its own or with --format html; file output is separate.");
33
+ }
34
+ return format;
35
+ }
36
+ async function runAnalysis(projectPath, options) {
20
37
  try {
21
- const { appDir, displayRoot } = detectAppRouter(projectPath);
22
- const scannedFiles = await scanAppDir(appDir);
23
- let analysisResult = await analyzeRoutes(scannedFiles, projectPath, displayRoot);
24
- if (options.search) {
25
- const search = options.search;
26
- analysisResult.routes = analysisResult.routes.filter((route) => route.path.includes(search));
27
- analysisResult.summary = {
28
- ...analysisResult.summary,
29
- routes: analysisResult.routes.length,
30
- dynamic: analysisResult.routes.filter((route) => route.segmentType === "dynamic").length,
31
- client: analysisResult.routes.filter((route) => route.rendering === "client").length,
32
- server: analysisResult.routes.filter((route) => route.rendering === "server").length,
33
- };
38
+ const format = resolveFormat(options);
39
+ const portText = options.serve === true ? "3000" : String(options.serve);
40
+ if (options.serve !== undefined &&
41
+ (!/^\d+$/.test(portText) || Number(portText) > 65535)) {
42
+ throw new Error("Preview port must be an integer between 0 and 65535 (0 chooses a free port).");
34
43
  }
35
- if (options.json) {
36
- console.log(renderJson(analysisResult));
37
- }
38
- else if (options.flat) {
39
- const flatOutput = renderFlat(analysisResult.routes);
40
- if (flatOutput) {
41
- console.log(flatOutput);
44
+ const fullResult = await inspectProject(projectPath);
45
+ const result = options.search
46
+ ? filterRoutes(fullResult, options.search)
47
+ : fullResult;
48
+ let output = getRenderer(format).render(result);
49
+ const lint = fullResult.lint;
50
+ const failed = lint &&
51
+ ((options.check && lint.hasErrors) ||
52
+ (options.strict && (lint.hasErrors || lint.warningCount > 0)));
53
+ if (options.serve !== undefined) {
54
+ // A failed CI check must terminate instead of leaving a preview process alive.
55
+ if (failed)
56
+ throw new RouteLintError(lint.errorCount, lint.warningCount);
57
+ const server = await startPreview(output, Number(portText));
58
+ const address = server.address();
59
+ if (address && typeof address !== "string") {
60
+ console.error(pc.green(`✓ Route Graph server running at http://127.0.0.1:${address.port}`));
61
+ console.error("Static snapshot · Press Ctrl+C to stop");
42
62
  }
63
+ return;
43
64
  }
44
- else {
45
- console.log(renderTree(analysisResult));
65
+ const target = options.html
66
+ ? typeof options.html === "string"
67
+ ? options.html
68
+ : "route-graph.html"
69
+ : options.output;
70
+ if (target && target !== "-") {
71
+ const absolute = path.resolve(target);
72
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
73
+ if (format === "tree")
74
+ output = stripVTControlCharacters(output);
75
+ await fs.writeFile(absolute, output + "\n", "utf-8");
76
+ console.error(pc.green(`✓ ${format === "html" ? "HTML report" : format.toUpperCase() + " export"} written to ${target}`));
46
77
  }
78
+ else if (output) {
79
+ process.stdout.write(output + "\n");
80
+ }
81
+ // Write the report before applying CI exit policy so failing checks leave artifacts.
82
+ if (failed)
83
+ throw new RouteLintError(lint.errorCount, lint.warningCount);
47
84
  }
48
85
  catch (error) {
49
- if (options.json) {
50
- console.error(JSON.stringify({ error: error.message }));
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ if (options.json || options.format === "json") {
88
+ console.error(JSON.stringify({
89
+ error: message,
90
+ code: error instanceof NextRouteGraphError ? error.code : "ERR_CLI",
91
+ }));
51
92
  }
52
93
  else {
53
- console.error(pc.red(`✗ ${error.message}`));
94
+ console.error(pc.red(`✗ ${message}`));
95
+ if (error instanceof NextRouteGraphError && error.hint)
96
+ console.error(pc.dim(` Hint: ${error.hint}`));
54
97
  }
55
- process.exit(1);
98
+ process.exitCode = 1;
56
99
  }
100
+ }
101
+ const program = new Command();
102
+ const { version } = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf-8"));
103
+ program
104
+ .name("next-route-graph")
105
+ .description("Inspect, lint, and visualize Next.js App Router project routes")
106
+ .version(version)
107
+ .argument("[projectPath]", "Path to the Next.js project root", ".")
108
+ .option("--flat", "Output flat route paths")
109
+ .option("--json", "Output machine-readable JSON")
110
+ .option("--mermaid", "Output Mermaid diagram source")
111
+ .option("--dot", "Output Graphviz DOT source")
112
+ .option("--markdown", "Output a Markdown table")
113
+ .option("--svg", "Output a rendered SVG route diagram")
114
+ .option("--html [file]", "Write an offline interactive HTML report (default: route-graph.html)")
115
+ .option("--serve [port]", "Preview an HTML snapshot on 127.0.0.1 (default: 3000)")
116
+ .addOption(new Option("--format <type>", "Output format (default: tree)").choices([
117
+ "tree",
118
+ "flat",
119
+ "json",
120
+ "mermaid",
121
+ "dot",
122
+ "markdown",
123
+ "html",
124
+ "svg",
125
+ ]))
126
+ .option("-o, --output <file>", "Write any format to a file; '-' writes to stdout")
127
+ .option("--search <query>", "Filter path, source file, or HTTP method (case-insensitive)")
128
+ .option("--check", "Fail on route validation errors")
129
+ .option("--strict", "Fail on errors or warnings")
130
+ .action(runAnalysis);
131
+ program
132
+ .command("check")
133
+ .description("Check route collisions, root layouts, and slot fallbacks")
134
+ .argument("[projectPath]", "Path to the Next.js project root", ".")
135
+ .option("--strict", "Fail on warnings in addition to errors")
136
+ .option("--json", "Output check report as JSON")
137
+ .action(async (projectPath, options) => {
138
+ await runAnalysis(projectPath, { ...options, check: true });
57
139
  });
58
- program.parse(process.argv);
140
+ await program.parseAsync(process.argv);
@@ -0,0 +1,16 @@
1
+ import { AnalysisResult, DiscoveredFile, RouteEntry, SummaryStats } from "../types/index.js";
2
+ /**
3
+ * Aggregates summary statistics for the analyzed routes and convention files.
4
+ */
5
+ export declare function getRoutesSummary(routes: RouteEntry[], layouts: number, loading: number, errors: number): SummaryStats;
6
+ /**
7
+ * Analyzes discovered files in a Next.js App Router to construct route entries,
8
+ * evaluate rendering types, HTTP methods, layouts, and linter rules.
9
+ *
10
+ * @param files - All discovered files within the App Router directory
11
+ * @param projectPath - Base directory path of the project
12
+ * @param displayRoot - Display name of the App Router root ('app' or 'src/app')
13
+ * @returns Fully populated AnalysisResult with route metadata and lint report
14
+ */
15
+ export declare function analyzeRoutes(files: DiscoveredFile[], projectPath: string, displayRoot: string): Promise<AnalysisResult>;
16
+ //# sourceMappingURL=analyzer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../src/core/analyzer.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,cAAc,EACd,cAAc,EACd,UAAU,EAEV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAyK3B;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,EAAE,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,YAAY,CA4Bd;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,cAAc,EAAE,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,cAAc,CAAC,CAqDzB"}
@@ -1,5 +1,11 @@
1
+ import fs from "node:fs/promises";
1
2
  import path from "node:path";
2
- import { detectRenderingType, getSegmentType, parseRoutePath, parseSpecialFileType, } from "./parser.js";
3
+ import { RouteFileReadError } from "../errors/index.js";
4
+ import { lintRoutes } from "./linter.js";
5
+ import { detectHttpMethods, getPrologueDirectives, detectRouteConfig, detectServerActions, extractRouteMetadata, getSegmentType, parseRoutePath, parseSpecialFileType, } from "./parser.js";
6
+ /**
7
+ * Groups discovered files by their directory relative to the App Router root.
8
+ */
3
9
  function groupFilesByDirectory(files) {
4
10
  const dirMap = new Map();
5
11
  for (const file of files) {
@@ -14,6 +20,11 @@ function groupFilesByDirectory(files) {
14
20
  }
15
21
  return dirMap;
16
22
  }
23
+ /**
24
+ * Processes all special convention files in a single directory.
25
+ * Performs a single disk read per file to extract rendering directives,
26
+ * HTTP methods, server actions, and segment configs.
27
+ */
17
28
  async function processDirectoryFiles(dirFiles) {
18
29
  const routeFiles = [];
19
30
  let layouts = 0;
@@ -24,12 +35,28 @@ async function processDirectoryFiles(dirFiles) {
24
35
  if (!specialType) {
25
36
  continue;
26
37
  }
27
- const rendering = await detectRenderingType(file.fullPath);
38
+ let content = "";
39
+ try {
40
+ content = await fs.readFile(file.fullPath, "utf-8");
41
+ }
42
+ catch (cause) {
43
+ throw new RouteFileReadError(file.relativePath, cause);
44
+ }
45
+ const rendering = getPrologueDirectives(content).has("use client")
46
+ ? "client"
47
+ : "server";
48
+ const methods = specialType === "route" ? detectHttpMethods(content) : undefined;
49
+ const hasServerActions = detectServerActions(content);
50
+ const config = detectRouteConfig(content);
28
51
  routeFiles.push({
29
52
  type: specialType,
30
53
  fileName: file.fileName,
31
54
  relativePath: file.relativePath,
32
55
  rendering,
56
+ methods,
57
+ hasServerActions,
58
+ runtime: config.runtime,
59
+ dynamicConfig: config.dynamicConfig,
33
60
  });
34
61
  if (specialType === "layout") {
35
62
  layouts++;
@@ -37,7 +64,7 @@ async function processDirectoryFiles(dirFiles) {
37
64
  if (specialType === "loading") {
38
65
  loading++;
39
66
  }
40
- if (specialType === "error") {
67
+ if (specialType === "error" || specialType === "global-error") {
41
68
  errors++;
42
69
  }
43
70
  }
@@ -48,7 +75,29 @@ async function processDirectoryFiles(dirFiles) {
48
75
  errors,
49
76
  };
50
77
  }
51
- function createRouteEntry(routeFiles) {
78
+ /**
79
+ * Finds all layout files in parent directories that wrap a specific directory.
80
+ */
81
+ function findLayoutChain(targetDir, layoutsByDirectory) {
82
+ const layoutChain = [];
83
+ const normalizedTarget = targetDir === "." ? "" : targetDir;
84
+ const segments = normalizedTarget ? normalizedTarget.split("/") : [];
85
+ // Check from root downwards
86
+ for (let i = 0; i <= segments.length; i++) {
87
+ const currentPath = segments.slice(0, i).join("/");
88
+ const checkDir = currentPath === "" ? "." : currentPath;
89
+ const layout = layoutsByDirectory.get(checkDir);
90
+ if (layout) {
91
+ layoutChain.push(layout);
92
+ }
93
+ }
94
+ return layoutChain;
95
+ }
96
+ /**
97
+ * Creates a RouteEntry from the convention files in a directory.
98
+ * Uses cached metadata from the in-memory RouteFile to eliminate duplicate disk reads.
99
+ */
100
+ function createRouteEntry(routeFiles, layoutsByDirectory) {
52
101
  const pageFile = routeFiles.find((file) => file.type === "page");
53
102
  const routeHandlerFile = routeFiles.find((file) => file.type === "route");
54
103
  const primaryFile = pageFile ?? routeHandlerFile;
@@ -56,43 +105,41 @@ function createRouteEntry(routeFiles) {
56
105
  return null;
57
106
  }
58
107
  const computedPath = parseRoutePath(primaryFile.relativePath);
108
+ const metadata = extractRouteMetadata(primaryFile.relativePath);
109
+ const dir = path.dirname(primaryFile.relativePath).replace(/\\/g, "/");
110
+ const layoutChain = findLayoutChain(dir, layoutsByDirectory);
59
111
  return {
60
112
  path: computedPath,
61
113
  type: pageFile ? "page" : "route",
62
114
  file: primaryFile.relativePath,
63
115
  segmentType: getSegmentType(computedPath),
64
116
  rendering: primaryFile.rendering,
117
+ methods: primaryFile.methods,
118
+ hasServerActions: primaryFile.hasServerActions,
119
+ runtime: primaryFile.runtime,
120
+ dynamicConfig: primaryFile.dynamicConfig,
65
121
  files: routeFiles,
122
+ parallelSlots: metadata.slots.length > 0 ? metadata.slots : undefined,
123
+ intercepting: metadata.intercepting,
124
+ layoutChain: layoutChain.length > 0 ? layoutChain : undefined,
66
125
  };
67
126
  }
68
- async function analyzeDirectories(dirMap) {
69
- const routesMap = new Map();
70
- let totalLayouts = 0;
71
- let totalLoading = 0;
72
- let totalErrors = 0;
73
- for (const dirFiles of dirMap.values()) {
74
- const result = await processDirectoryFiles(dirFiles);
75
- totalLayouts += result.layouts;
76
- totalLoading += result.loading;
77
- totalErrors += result.errors;
78
- const route = createRouteEntry(result.routeFiles);
79
- if (route) {
80
- routesMap.set(route.path, route);
81
- }
82
- }
83
- return {
84
- routesMap,
85
- totalLayouts,
86
- totalLoading,
87
- totalErrors,
88
- };
89
- }
90
- function getRoutesSummary(routes, layouts, loading, errors) {
127
+ /**
128
+ * Aggregates summary statistics for the analyzed routes and convention files.
129
+ */
130
+ export function getRoutesSummary(routes, layouts, loading, errors) {
91
131
  let dynamic = 0;
92
132
  let client = 0;
133
+ let apiRoutes = 0;
134
+ let parallelRoutes = 0;
135
+ let interceptedRoutes = 0;
93
136
  for (const route of routes) {
94
137
  dynamic += route.segmentType !== "static" ? 1 : 0;
95
138
  client += route.rendering === "client" ? 1 : 0;
139
+ apiRoutes += route.type === "route" ? 1 : 0;
140
+ parallelRoutes +=
141
+ route.parallelSlots && route.parallelSlots.length > 0 ? 1 : 0;
142
+ interceptedRoutes += route.intercepting ? 1 : 0;
96
143
  }
97
144
  return {
98
145
  routes: routes.length,
@@ -102,20 +149,57 @@ function getRoutesSummary(routes, layouts, loading, errors) {
102
149
  layouts,
103
150
  loading,
104
151
  errors,
152
+ apiRoutes,
153
+ parallelRoutes,
154
+ interceptedRoutes,
105
155
  };
106
156
  }
157
+ /**
158
+ * Analyzes discovered files in a Next.js App Router to construct route entries,
159
+ * evaluate rendering types, HTTP methods, layouts, and linter rules.
160
+ *
161
+ * @param files - All discovered files within the App Router directory
162
+ * @param projectPath - Base directory path of the project
163
+ * @param displayRoot - Display name of the App Router root ('app' or 'src/app')
164
+ * @returns Fully populated AnalysisResult with route metadata and lint report
165
+ */
107
166
  export async function analyzeRoutes(files, projectPath, displayRoot) {
108
167
  const startTime = Date.now();
109
168
  const dirMap = groupFilesByDirectory(files);
110
- const { routesMap, totalLayouts, totalLoading, totalErrors } = await analyzeDirectories(dirMap);
111
- const routes = Array.from(routesMap.values()).sort((a, b) => a.path.localeCompare(b.path));
169
+ const layoutsByDirectory = new Map();
170
+ for (const [dir, dirFiles] of dirMap) {
171
+ const layout = dirFiles.find((file) => parseSpecialFileType(file.fileName) === "layout");
172
+ if (layout)
173
+ layoutsByDirectory.set(dir, layout.relativePath);
174
+ }
175
+ const discoveredRoutes = [];
176
+ let totalLayouts = 0;
177
+ let totalLoading = 0;
178
+ let totalErrors = 0;
179
+ for (const dirFiles of dirMap.values()) {
180
+ const result = await processDirectoryFiles(dirFiles);
181
+ totalLayouts += result.layouts;
182
+ totalLoading += result.loading;
183
+ totalErrors += result.errors;
184
+ const route = createRouteEntry(result.routeFiles, layoutsByDirectory);
185
+ if (route) {
186
+ discoveredRoutes.push(route);
187
+ }
188
+ }
189
+ // Sort routes deterministically by URL path, then by file path for duplicate paths
190
+ const routes = discoveredRoutes.sort((a, b) => {
191
+ const cmp = a.path.localeCompare(b.path);
192
+ return cmp !== 0 ? cmp : a.file.localeCompare(b.file);
193
+ });
112
194
  const summary = getRoutesSummary(routes, totalLayouts, totalLoading, totalErrors);
195
+ const lint = lintRoutes(routes, files);
113
196
  return {
114
197
  project: path.basename(path.resolve(projectPath)),
115
198
  router: "App Router",
116
199
  root: displayRoot,
117
200
  routes,
118
201
  summary,
202
+ lint,
119
203
  executionTimeMs: Date.now() - startTime,
120
204
  };
121
205
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Result of App Router detection
3
+ */
4
+ export interface DetectionResult {
5
+ appDir: string;
6
+ displayRoot: string;
7
+ }
8
+ /**
9
+ * Detects the Next.js App Router root directory within a project path.
10
+ * Supports standard `./app`, `./src/app`, and direct App Router folder execution.
11
+ *
12
+ * @param projectDir - Path to the project root or App Router directory
13
+ * @returns DetectionResult containing absolute app directory and relative display path
14
+ * @throws {ProjectNotFoundError} If the target project directory does not exist
15
+ * @throws {AmbiguousRouterError} If both ./app and ./src/app are present
16
+ * @throws {NoAppRouterError} If neither ./app nor ./src/app are found
17
+ */
18
+ export declare function detectAppRouter(projectDir: string): DetectionResult;
19
+ //# sourceMappingURL=detector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/core/detector.ts"],"names":[],"mappings":"AAQA;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAsDnE"}
@@ -1,16 +1,27 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { AmbiguousRouterError, NoAppRouterError, ProjectNotFoundError, } from "../errors/index.js";
4
+ /**
5
+ * Detects the Next.js App Router root directory within a project path.
6
+ * Supports standard `./app`, `./src/app`, and direct App Router folder execution.
7
+ *
8
+ * @param projectDir - Path to the project root or App Router directory
9
+ * @returns DetectionResult containing absolute app directory and relative display path
10
+ * @throws {ProjectNotFoundError} If the target project directory does not exist
11
+ * @throws {AmbiguousRouterError} If both ./app and ./src/app are present
12
+ * @throws {NoAppRouterError} If neither ./app nor ./src/app are found
13
+ */
3
14
  export function detectAppRouter(projectDir) {
4
15
  const absoluteDir = path.resolve(projectDir);
5
16
  if (!fs.existsSync(absoluteDir)) {
6
- throw new Error("The specified project path does not exist.");
17
+ throw new ProjectNotFoundError(projectDir);
7
18
  }
8
19
  const srcAppPath = path.join(absoluteDir, "src", "app");
9
20
  const appPath = path.join(absoluteDir, "app");
10
21
  const hasSrcApp = fs.existsSync(srcAppPath) && fs.statSync(srcAppPath).isDirectory();
11
22
  const hasApp = fs.existsSync(appPath) && fs.statSync(appPath).isDirectory();
12
23
  if (hasSrcApp && hasApp) {
13
- throw new Error(`Could not determine the App Router root.\n\nFound both:\n ./app\n ./src/app\n\nPlease keep only one App Router root for analysis.`);
24
+ throw new AmbiguousRouterError();
14
25
  }
15
26
  if (hasSrcApp) {
16
27
  return {
@@ -24,5 +35,22 @@ export function detectAppRouter(projectDir) {
24
35
  displayRoot: "app",
25
36
  };
26
37
  }
27
- throw new Error(`No Next.js App Router directory found.\n\nExpected:\n ./app\n ./src/app`);
38
+ // Fallback: Check if the user pointed directly to the app directory itself
39
+ const isDirectAppDir = fs.statSync(absoluteDir).isDirectory() &&
40
+ (path.basename(absoluteDir) === "app" ||
41
+ fs.existsSync(path.join(absoluteDir, "layout.tsx")) ||
42
+ fs.existsSync(path.join(absoluteDir, "layout.jsx")) ||
43
+ fs.existsSync(path.join(absoluteDir, "layout.js")) ||
44
+ fs.existsSync(path.join(absoluteDir, "page.tsx")) ||
45
+ fs.existsSync(path.join(absoluteDir, "page.jsx")) ||
46
+ fs.existsSync(path.join(absoluteDir, "page.js")));
47
+ if (isDirectAppDir) {
48
+ const parentDirName = path.basename(path.dirname(absoluteDir));
49
+ const displayRoot = parentDirName === "src" ? "src/app" : path.basename(absoluteDir);
50
+ return {
51
+ appDir: absoluteDir,
52
+ displayRoot,
53
+ };
54
+ }
55
+ throw new NoAppRouterError();
28
56
  }
@@ -0,0 +1,6 @@
1
+ import type { AnalysisResult } from "../types/index.js";
2
+ /** Scan a project without starting Next.js or executing project code. */
3
+ export declare function inspectProject(projectPath?: string): Promise<AnalysisResult>;
4
+ /** Filter endpoint counts; convention-file totals and diagnostics remain project-wide. */
5
+ export declare function filterRoutes(result: AnalysisResult, query: string): AnalysisResult;
6
+ //# sourceMappingURL=inspect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../src/core/inspect.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,yEAAyE;AACzE,wBAAsB,cAAc,CAClC,WAAW,SAAM,GAChB,OAAO,CAAC,cAAc,CAAC,CAMzB;AAED,0FAA0F;AAC1F,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,MAAM,GACZ,cAAc,CAiBhB"}
@@ -0,0 +1,21 @@
1
+ import { analyzeRoutes, getRoutesSummary } from "./analyzer.js";
2
+ import { detectAppRouter } from "./detector.js";
3
+ import { scanAppDir } from "./scanner.js";
4
+ /** Scan a project without starting Next.js or executing project code. */
5
+ export async function inspectProject(projectPath = ".") {
6
+ const start = performance.now();
7
+ const { appDir, displayRoot } = detectAppRouter(projectPath);
8
+ const files = await scanAppDir(appDir);
9
+ const result = await analyzeRoutes(files, projectPath, displayRoot);
10
+ return { ...result, executionTimeMs: Math.round(performance.now() - start) };
11
+ }
12
+ /** Filter endpoint counts; convention-file totals and diagnostics remain project-wide. */
13
+ export function filterRoutes(result, query) {
14
+ const search = query.toLowerCase();
15
+ const routes = result.routes.filter((route) => [route.path, route.file, ...(route.methods ?? [])].some((value) => value.toLowerCase().includes(search)));
16
+ return {
17
+ ...result,
18
+ routes,
19
+ summary: getRoutesSummary(routes, result.summary.layouts, result.summary.loading, result.summary.errors),
20
+ };
21
+ }
@@ -0,0 +1,11 @@
1
+ import { DiscoveredFile, LintResult, RouteEntry } from "../types/index.js";
2
+ /**
3
+ * Validates Next.js App Router routes and file structures against best practices
4
+ * and official framework rules.
5
+ *
6
+ * @param routes - Array of all analyzed route entries
7
+ * @param files - All discovered files in the App Router directory
8
+ * @returns LintResult containing discovered errors, warnings, and info diagnostics
9
+ */
10
+ export declare function lintRoutes(routes: RouteEntry[], files: DiscoveredFile[]): LintResult;
11
+ //# sourceMappingURL=linter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../../src/core/linter.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EAEd,UAAU,EACV,UAAU,EACX,MAAM,mBAAmB,CAAC;AAkB3B;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,UAAU,EAAE,EACpB,KAAK,EAAE,cAAc,EAAE,GACtB,UAAU,CA8LZ"}