@elench/testkit 0.1.63 → 0.1.65

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 (90) hide show
  1. package/lib/coverage/backend-discovery.mjs +6 -30
  2. package/lib/coverage/evidence.mjs +15 -3
  3. package/lib/coverage/evidence.test.mjs +13 -4
  4. package/lib/coverage/fs-walk.mjs +2 -2
  5. package/lib/coverage/graph-builder.mjs +23 -24
  6. package/lib/coverage/next-ir-to-graph.mjs +240 -0
  7. package/node_modules/@elench/next-analysis/package.json +14 -0
  8. package/node_modules/@elench/next-analysis/src/api-routes.mjs +81 -0
  9. package/node_modules/@elench/next-analysis/src/api-routes.test.mjs +22 -0
  10. package/node_modules/@elench/next-analysis/src/app-root.mjs +7 -0
  11. package/node_modules/@elench/next-analysis/src/backend-links.mjs +31 -0
  12. package/node_modules/@elench/next-analysis/src/index.mjs +21 -0
  13. package/node_modules/@elench/next-analysis/src/pages.mjs +68 -0
  14. package/node_modules/@elench/next-analysis/src/project.mjs +94 -0
  15. package/node_modules/@elench/next-analysis/src/project.test.mjs +35 -0
  16. package/node_modules/@elench/next-analysis/src/route-tree.mjs +621 -0
  17. package/node_modules/@elench/next-analysis/src/routes.mjs +41 -0
  18. package/node_modules/@elench/next-analysis/src/routes.test.mjs +25 -0
  19. package/node_modules/@elench/next-analysis/src/server-actions.mjs +53 -0
  20. package/node_modules/@elench/next-analysis/src/server-actions.test.mjs +37 -0
  21. package/node_modules/@elench/next-analysis/src/shared.mjs +209 -0
  22. package/node_modules/@elench/next-analysis/src/swc.mjs +388 -0
  23. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  24. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  25. package/node_modules/@elench/ts-analysis/package.json +10 -0
  26. package/node_modules/@elench/ts-analysis/src/callables.mjs +135 -0
  27. package/node_modules/@elench/ts-analysis/src/callables.test.mjs +55 -0
  28. package/node_modules/@elench/ts-analysis/src/exports.mjs +69 -0
  29. package/node_modules/@elench/ts-analysis/src/exports.test.mjs +50 -0
  30. package/node_modules/@elench/ts-analysis/src/index.mjs +14 -0
  31. package/node_modules/@elench/ts-analysis/src/jsx.mjs +69 -0
  32. package/node_modules/@elench/ts-analysis/src/jsx.test.mjs +43 -0
  33. package/node_modules/@elench/ts-analysis/src/project.mjs +100 -0
  34. package/node_modules/@elench/ts-analysis/src/project.test.mjs +54 -0
  35. package/node_modules/@elench/ts-analysis/src/requests.mjs +141 -0
  36. package/node_modules/@elench/ts-analysis/src/requests.test.mjs +35 -0
  37. package/node_modules/@elench/ts-analysis/src/resolution.mjs +53 -0
  38. package/node_modules/@elench/ts-analysis/src/shared.mjs +32 -0
  39. package/node_modules/@elench/ts-analysis/src/syntax.mjs +27 -0
  40. package/node_modules/@next/routing/README.md +91 -0
  41. package/node_modules/@next/routing/dist/__tests__/captures.test.d.ts +1 -0
  42. package/node_modules/@next/routing/dist/__tests__/conditions.test.d.ts +1 -0
  43. package/node_modules/@next/routing/dist/__tests__/dynamic-after-rewrites.test.d.ts +1 -0
  44. package/node_modules/@next/routing/dist/__tests__/i18n-resolve-routes.test.d.ts +1 -0
  45. package/node_modules/@next/routing/dist/__tests__/i18n.test.d.ts +1 -0
  46. package/node_modules/@next/routing/dist/__tests__/middleware.test.d.ts +1 -0
  47. package/node_modules/@next/routing/dist/__tests__/normalize-next-data.test.d.ts +1 -0
  48. package/node_modules/@next/routing/dist/__tests__/redirects.test.d.ts +1 -0
  49. package/node_modules/@next/routing/dist/__tests__/resolve-routes.test.d.ts +1 -0
  50. package/node_modules/@next/routing/dist/__tests__/rewrites.test.d.ts +1 -0
  51. package/node_modules/@next/routing/dist/destination.d.ts +22 -0
  52. package/node_modules/@next/routing/dist/i18n.d.ts +48 -0
  53. package/node_modules/@next/routing/dist/index.d.ts +5 -0
  54. package/node_modules/@next/routing/dist/index.js +1 -0
  55. package/node_modules/@next/routing/dist/matchers.d.ts +12 -0
  56. package/node_modules/@next/routing/dist/middleware.d.ts +12 -0
  57. package/node_modules/@next/routing/dist/next-data.d.ts +10 -0
  58. package/node_modules/@next/routing/dist/resolve-routes.d.ts +2 -0
  59. package/node_modules/@next/routing/dist/types.d.ts +97 -0
  60. package/node_modules/@next/routing/package.json +39 -0
  61. package/node_modules/@swc/core/README.md +100 -0
  62. package/node_modules/@swc/core/Visitor.d.ts +218 -0
  63. package/node_modules/@swc/core/Visitor.js +1399 -0
  64. package/node_modules/@swc/core/binding.d.ts +59 -0
  65. package/node_modules/@swc/core/binding.js +368 -0
  66. package/node_modules/@swc/core/index.d.ts +120 -0
  67. package/node_modules/@swc/core/index.js +443 -0
  68. package/node_modules/@swc/core/package.json +120 -0
  69. package/node_modules/@swc/core/postinstall.js +148 -0
  70. package/node_modules/@swc/core/spack.d.ts +51 -0
  71. package/node_modules/@swc/core/spack.js +87 -0
  72. package/node_modules/@swc/core/util.d.ts +1 -0
  73. package/node_modules/@swc/core/util.js +104 -0
  74. package/node_modules/@swc/core-linux-x64-gnu/README.md +3 -0
  75. package/node_modules/@swc/core-linux-x64-gnu/package.json +46 -0
  76. package/node_modules/@swc/core-linux-x64-gnu/swc.linux-x64-gnu.node +0 -0
  77. package/node_modules/@swc/counter/CHANGELOG.md +7 -0
  78. package/node_modules/@swc/counter/README.md +7 -0
  79. package/node_modules/@swc/counter/index.js +1 -0
  80. package/node_modules/@swc/counter/package.json +27 -0
  81. package/node_modules/@swc/types/LICENSE +201 -0
  82. package/node_modules/@swc/types/README.md +4 -0
  83. package/node_modules/@swc/types/assumptions.d.ts +92 -0
  84. package/node_modules/@swc/types/assumptions.js +2 -0
  85. package/node_modules/@swc/types/index.d.ts +2049 -0
  86. package/node_modules/@swc/types/index.js +2 -0
  87. package/node_modules/@swc/types/package.json +40 -0
  88. package/package.json +7 -3
  89. package/lib/coverage/next-discovery.mjs +0 -205
  90. package/lib/coverage/next-static-analysis.mjs +0 -1047
@@ -1,11 +1,15 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import {
4
+ extractExportedFunctionBody,
5
+ extractExportedFunctions,
6
+ extractExportedMethodBodies,
7
+ } from "@elench/ts-analysis";
3
8
  import {
4
9
  dedupeBackendImports,
5
10
  dedupeDataImports,
6
11
  dedupeEdges,
7
12
  dedupeNodes,
8
- escapeRegExp,
9
13
  hasWord,
10
14
  isBackendSpecifier,
11
15
  isDataSpecifier,
@@ -123,35 +127,7 @@ export function extractDataImports({
123
127
  return dedupeDataImports(imports);
124
128
  }
125
129
 
126
- export function extractExportedMethodBodies(content, methods) {
127
- const bodies = [];
128
- for (const method of methods) {
129
- const body = extractExportedFunctionBody(content, method);
130
- if (body) bodies.push([method, body]);
131
- }
132
- return bodies;
133
- }
134
-
135
- export function extractExportedFunctions(content) {
136
- const exported = [];
137
- const functionRegex = /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*\(/gu;
138
- for (const match of content.matchAll(functionRegex)) {
139
- const name = match[1];
140
- const body = extractExportedFunctionBody(content, name);
141
- if (!body) continue;
142
- exported.push({ name, body });
143
- }
144
- return exported;
145
- }
146
-
147
- export function extractExportedFunctionBody(content, exportName) {
148
- const functionStart = new RegExp(`export\\s+(?:async\\s+)?function\\s+${escapeRegExp(exportName)}\\s*\\(`, "u");
149
- const startMatch = functionStart.exec(content);
150
- if (!startMatch) return null;
151
- const afterSignatureIndex = content.indexOf("{", startMatch.index);
152
- if (afterSignatureIndex === -1) return null;
153
- return readBalancedBlock(content, afterSignatureIndex);
154
- }
130
+ export { extractExportedFunctionBody, extractExportedFunctions, extractExportedMethodBodies };
155
131
 
156
132
  export function readBalancedBlock(content, startIndex) {
157
133
  let depth = 0;
@@ -1,12 +1,14 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { extractHttpSuiteRequests, extractPlaywrightVisitedRoutes } from "./next-static-analysis.mjs";
3
+ import { extractHttpRequests, extractPlaywrightVisitedRoutes } from "@elench/ts-analysis";
4
4
  import {
5
5
  apiRouteLookupKey,
6
+ DYNAMIC_SEGMENT_TOKEN,
6
7
  HTTP_METHODS,
7
8
  dedupeTargets,
8
9
  findMatchingApiRouteEntry,
9
10
  findMatchingRouteValue,
11
+ normalizeRoute,
10
12
  toApiRequestPath,
11
13
  toSelectionType,
12
14
  } from "./shared.mjs";
@@ -36,7 +38,13 @@ export function inferCoveredNodeIdsForTest(entry, context) {
36
38
 
37
39
  // Capability: Playwright page routes (content-first, path-fallback)
38
40
  if (entry.framework === "playwright") {
39
- const gotoRoutes = content ? extractPlaywrightVisitedRoutes(content, entry.filePath) : [];
41
+ const gotoRoutes = content
42
+ ? extractPlaywrightVisitedRoutes(content, {
43
+ filePath: entry.filePath,
44
+ normalizeRoute,
45
+ dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
46
+ })
47
+ : [];
40
48
  const routes = gotoRoutes.length > 0
41
49
  ? gotoRoutes
42
50
  : (() => { const r = inferPageRouteFromTestFile(entry.filePath, context.nextAppRoot, context.serviceRoot); return r ? [r] : []; })();
@@ -56,7 +64,11 @@ export function inferCoveredNodeIdsForTest(entry, context) {
56
64
 
57
65
  // Capability: HTTP request extraction (any test type with rawReq/fetch/wrapper calls)
58
66
  if (content) {
59
- const requests = extractHttpSuiteRequests(content, entry.filePath);
67
+ const requests = extractHttpRequests(content, {
68
+ filePath: entry.filePath,
69
+ normalizeRoute,
70
+ dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
71
+ });
60
72
  for (const request of requests) {
61
73
  if (!request.path.startsWith("/api/")) continue;
62
74
  const routeEntry = resolveApiRouteEntry(context, request.method, request.path);
@@ -4,7 +4,7 @@ import {
4
4
  extractPlaywrightTargetsFromContent,
5
5
  inferDataCapabilitiesFromOwner,
6
6
  } from "./evidence.mjs";
7
- import { extractPlaywrightVisitedRoutes } from "./next-static-analysis.mjs";
7
+ import { extractPlaywrightVisitedRoutes } from "@elench/ts-analysis";
8
8
  import { DYNAMIC_SEGMENT_TOKEN } from "./shared.mjs";
9
9
 
10
10
  describe("coverage evidence helpers", () => {
@@ -60,19 +60,28 @@ describe("extractPlaywrightVisitedRoutes", () => {
60
60
  await page.goto("https://external.com/path"); // external
61
61
  await page.goto("http://localhost:3000/events"); // same-origin absolute
62
62
  });
63
- `, "nav.pw.testkit.ts")).toEqual(["/dashboard", "/settings/profile", "/events"]);
63
+ `, {
64
+ filePath: "nav.pw.testkit.ts",
65
+ dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
66
+ })).toEqual(["/dashboard", "/settings/profile", "/events"]);
64
67
  });
65
68
 
66
69
  it("handles property chain: foo.page.goto()", () => {
67
70
  expect(extractPlaywrightVisitedRoutes(`
68
71
  await this.page.goto("/projects");
69
- `, "test.pw.testkit.ts")).toEqual(["/projects"]);
72
+ `, {
73
+ filePath: "test.pw.testkit.ts",
74
+ dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
75
+ })).toEqual(["/projects"]);
70
76
  });
71
77
 
72
78
  it("normalizes dynamic goto expressions into route patterns", () => {
73
79
  expect(extractPlaywrightVisitedRoutes(`
74
80
  await page.goto(\`/projects/\${id}\`);
75
81
  await page.goto(someVar);
76
- `, "test.pw.testkit.ts")).toEqual([`/projects/${DYNAMIC_SEGMENT_TOKEN}`]);
82
+ `, {
83
+ filePath: "test.pw.testkit.ts",
84
+ dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
85
+ })).toEqual([`/projects/${DYNAMIC_SEGMENT_TOKEN}`]);
77
86
  });
78
87
  });
@@ -5,7 +5,7 @@ import {
5
5
  shouldExcludeDiscoveryPath,
6
6
  } from "../discovery/path-policy.mjs";
7
7
  import { normalizePath } from "./shared.mjs";
8
- import { resolveImportToSourceFile as resolveImportToSourceFileFromAst } from "./next-static-analysis.mjs";
8
+ import { resolveImportToSourceFile as resolveImportToSourceFileFromTsAnalysis } from "@elench/ts-analysis";
9
9
 
10
10
  export function findNextAppRoot(serviceRoot) {
11
11
  const candidates = [path.join(serviceRoot, "app"), path.join(serviceRoot, "src", "app")];
@@ -43,7 +43,7 @@ export function walkFiles(rootDir, options = {}) {
43
43
  }
44
44
 
45
45
  export function resolveImportToSourceFile(serviceRoot, fromFilePath, specifier) {
46
- return resolveImportToSourceFileFromAst(serviceRoot, fromFilePath, specifier);
46
+ return resolveImportToSourceFileFromTsAnalysis(serviceRoot, fromFilePath, specifier);
47
47
  }
48
48
 
49
49
  export function resolveSourceCandidate(basePath) {
@@ -1,7 +1,7 @@
1
1
  import { discoverDataCapabilities } from "./backend-discovery.mjs";
2
2
  import { buildEvidenceDetails, inferCoveredNodeIdsForTest } from "./evidence.mjs";
3
- import { findNextAppRoot, resolveImportToSourceFile, resolveServiceRoot } from "./fs-walk.mjs";
4
- import { discoverApiRoutes, discoverPageViews, discoverServerActions } from "./next-discovery.mjs";
3
+ import { resolveServiceRoot } from "./fs-walk.mjs";
4
+ import { convertNextAnalysisToGraph } from "./next-ir-to-graph.mjs";
5
5
  import {
6
6
  appendGraph,
7
7
  createEmptyGraph,
@@ -11,6 +11,7 @@ import {
11
11
  toSelectionType,
12
12
  } from "./shared.mjs";
13
13
  import { DEFAULT_DISCOVERY_EXCLUDES, normalizeDiscoveryConfig } from "../discovery/path-policy.mjs";
14
+ import { createNextAnalysisProject } from "@elench/next-analysis";
14
15
 
15
16
  export function buildCoverageGraph({ productDir, repoDiscovery = {}, services = {}, discoveryFiles = [] }) {
16
17
  const graph = createEmptyGraph();
@@ -85,7 +86,8 @@ export function buildCoverageGraph({ productDir, repoDiscovery = {}, services =
85
86
 
86
87
  export function buildServiceCoverageContext(productDir, serviceName, config, repoDiscovery = {}) {
87
88
  const serviceRoot = resolveServiceRoot(productDir, config);
88
- const nextAppRoot = findNextAppRoot(serviceRoot);
89
+ const nextProject = createNextAnalysisProject({ rootDir: serviceRoot });
90
+ const nextAppRoot = nextProject.findAppRoot();
89
91
  if (!nextAppRoot) return null;
90
92
  const serviceDiscovery = normalizeDiscoveryConfig(config?.discovery, { allowRoots: true });
91
93
  const exclude = [
@@ -97,40 +99,37 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
97
99
  ];
98
100
 
99
101
  const graph = createEmptyGraph();
100
- const discoveryOptions = {
102
+ const pages = nextProject.discoverPages();
103
+ const apiRoutes = nextProject.discoverApiRoutes();
104
+ const serverActions = nextProject.discoverServerActions();
105
+ const routeTrees = pages.map((page) => nextProject.analyzeRouteTree(page.route)).filter(Boolean);
106
+ const converted = convertNextAnalysisToGraph({
101
107
  serviceName,
102
- serviceRoot,
103
- nextAppRoot,
104
- exclude,
105
- resolveImportToSourceFile,
106
- };
107
- const pages = discoverPageViews(discoveryOptions);
108
- const apiRoutes = discoverApiRoutes(discoveryOptions);
109
- const serverActions = discoverServerActions({
110
- serviceName,
111
- serviceRoot,
112
- exclude,
113
- resolveImportToSourceFile,
108
+ pages,
109
+ apiRoutes,
110
+ serverActions,
111
+ routeTrees,
114
112
  });
115
113
 
116
- for (const node of [...pages.nodes, ...apiRoutes.nodes, ...serverActions.nodes]) graph.nodes.push(node);
117
- for (const edge of [...pages.edges, ...apiRoutes.edges, ...serverActions.edges]) graph.edges.push(edge);
114
+ for (const node of converted.nodes) graph.nodes.push(node);
115
+ for (const edge of converted.edges) graph.edges.push(edge);
118
116
 
119
117
  const dataAugmentation = discoverDataCapabilities({ serviceName, serviceRoot, nodes: graph.nodes });
120
118
  for (const node of dataAugmentation.nodes) graph.nodes.push(node);
121
119
  for (const edge of dataAugmentation.edges) graph.edges.push(edge);
120
+ for (const diagnostic of nextProject.getDiagnostics()) graph.diagnostics.push(diagnostic);
122
121
 
123
- const pageByRoute = new Map(pages.pageEntries.map((entry) => [entry.route, entry]));
122
+ const pageByRoute = new Map(converted.pageEntries.map((entry) => [entry.route, entry]));
124
123
  const apiRouteByKey = new Map(
125
- apiRoutes.routeEntries.map((entry) => [`${entry.method}:${entry.requestPath}`, entry])
124
+ converted.routeEntries.map((entry) => [`${entry.method}:${entry.requestPath}`, entry])
126
125
  );
127
126
  const serverActionByExportKey = new Map(
128
- serverActions.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
127
+ converted.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
129
128
  );
130
129
 
131
- for (const pageEntry of pages.pageEntries) {
130
+ for (const pageEntry of converted.pageEntries) {
132
131
  for (const request of pageEntry.requests) {
133
- const apiRoute = findMatchingApiRouteEntry(request.method, request.path, apiRoutes.routeEntries);
132
+ const apiRoute = findMatchingApiRouteEntry(request.method, request.path, converted.routeEntries);
134
133
  if (!apiRoute) continue;
135
134
  graph.edges.push({
136
135
  id: `handles:${request.node.id}:${apiRoute.node.id}`,
@@ -161,7 +160,7 @@ export function buildServiceCoverageContext(productDir, serviceName, config, rep
161
160
  graph,
162
161
  pageByRoute,
163
162
  apiRouteByKey,
164
- apiRouteEntries: apiRoutes.routeEntries,
163
+ apiRouteEntries: converted.routeEntries,
165
164
  serverActionByExportKey,
166
165
  dataCapabilities: dataAugmentation.nodes,
167
166
  };
@@ -0,0 +1,240 @@
1
+ import {
2
+ normalizePath,
3
+ pageLabelFromRoute,
4
+ } from "@elench/next-analysis";
5
+ import {
6
+ dedupeEdges,
7
+ dedupeNodes,
8
+ findMatchingApiRouteEntry,
9
+ hasWord,
10
+ modulePathKey,
11
+ } from "./shared.mjs";
12
+
13
+ export function convertNextAnalysisToGraph({ serviceName, pages = [], apiRoutes = [], serverActions = [], routeTrees = [] }) {
14
+ const nodes = [];
15
+ const edges = [];
16
+ const pageEntries = [];
17
+ const routeEntries = [];
18
+ const actionEntries = [];
19
+
20
+ const routeTreeByRoute = new Map(routeTrees.filter(Boolean).map((entry) => [entry.route, entry]));
21
+
22
+ for (const page of pages) {
23
+ const pageNode = {
24
+ id: `page_view:${serviceName}:${page.route}`,
25
+ kind: "page_view",
26
+ service: serviceName,
27
+ label: pageLabelFromRoute(page.route),
28
+ route: page.route,
29
+ filePath: page.filePath,
30
+ };
31
+ nodes.push(pageNode);
32
+
33
+ const routeTree = routeTreeByRoute.get(page.route);
34
+ const surfacesByTargetValue = new Map();
35
+ if (routeTree) {
36
+ for (const surface of routeTree.surfaces) {
37
+ const surfaceNode = {
38
+ id: `ui_surface:${serviceName}:${page.route}:${surface.targetHint?.value || `${surface.filePath}:${surface.tagName}:${surface.line}`}`,
39
+ kind: "ui_surface",
40
+ service: serviceName,
41
+ label: surface.label,
42
+ route: page.route,
43
+ filePath: surface.filePath,
44
+ ...(surface.targetHint ? { target: surface.targetHint } : {}),
45
+ metadata: {
46
+ surfaceKind: surface.surfaceKind,
47
+ tagName: surface.tagName,
48
+ },
49
+ };
50
+ nodes.push(surfaceNode);
51
+ edges.push({
52
+ id: `contains:${pageNode.id}:${surfaceNode.id}`,
53
+ kind: "contains",
54
+ from: pageNode.id,
55
+ to: surfaceNode.id,
56
+ confidence: "high",
57
+ });
58
+ if (surface.targetHint?.kind === "testId") {
59
+ surfacesByTargetValue.set(surface.targetHint.value, surfaceNode);
60
+ }
61
+ if (surface.actionId) {
62
+ const actionKey = stripActionPrefix(surface.actionId, page.route);
63
+ edges.push({
64
+ id: `triggers:${surfaceNode.id}:ui_action:${serviceName}:${page.route}:${actionKey}`,
65
+ kind: "triggers",
66
+ from: surfaceNode.id,
67
+ to: `ui_action:${serviceName}:${page.route}:${actionKey}`,
68
+ confidence: "high",
69
+ });
70
+ }
71
+ }
72
+
73
+ for (const action of routeTree.actions) {
74
+ const actionKey = stripActionPrefix(action.id, page.route);
75
+ nodes.push({
76
+ id: `ui_action:${serviceName}:${page.route}:${actionKey}`,
77
+ kind: "ui_action",
78
+ service: serviceName,
79
+ label: action.label,
80
+ route: page.route,
81
+ filePath: action.filePath,
82
+ metadata: {
83
+ bindingKind: action.bindingKind,
84
+ actionProp: action.actionProp,
85
+ },
86
+ });
87
+ }
88
+
89
+ for (const request of routeTree.requests) {
90
+ const ownerId = mapRouteTreeOwnerToGraphId(serviceName, page.route, request.ownerId);
91
+ const requestNode = {
92
+ id: `client_request:${serviceName}:${request.filePath}:${request.ownerId}:${request.method}:${request.path}`,
93
+ kind: "client_request",
94
+ service: serviceName,
95
+ label: `${request.method} ${request.path}`,
96
+ method: request.method,
97
+ path: request.path,
98
+ filePath: request.filePath,
99
+ };
100
+ nodes.push(requestNode);
101
+ edges.push({
102
+ id: `requests:${ownerId}:${requestNode.id}`,
103
+ kind: "requests",
104
+ from: ownerId,
105
+ to: requestNode.id,
106
+ confidence: request.confidence,
107
+ });
108
+ }
109
+
110
+ pageEntries.push({
111
+ node: pageNode,
112
+ route: page.route,
113
+ filePath: page.filePath,
114
+ requests: routeTree.requests.map((request) => ({
115
+ originNodeId: mapRouteTreeOwnerToGraphId(serviceName, page.route, request.ownerId),
116
+ node: {
117
+ id: `client_request:${serviceName}:${request.filePath}:${request.ownerId}:${request.method}:${request.path}`,
118
+ kind: "client_request",
119
+ service: serviceName,
120
+ label: `${request.method} ${request.path}`,
121
+ method: request.method,
122
+ path: request.path,
123
+ filePath: request.filePath,
124
+ },
125
+ method: request.method,
126
+ path: request.path,
127
+ })),
128
+ serverActionRefs: routeTree.serverActionRefs.map((ref) => ({
129
+ originNodeId: mapRouteTreeOwnerToGraphId(serviceName, page.route, ref.ownerId),
130
+ exportKey: ref.exportKey,
131
+ confidence: ref.confidence,
132
+ })),
133
+ surfacesByTargetValue,
134
+ });
135
+ } else {
136
+ pageEntries.push({
137
+ node: pageNode,
138
+ route: page.route,
139
+ filePath: page.filePath,
140
+ requests: [],
141
+ serverActionRefs: [],
142
+ surfacesByTargetValue,
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const apiRoute of apiRoutes) {
148
+ const node = {
149
+ id: `api_route:${serviceName}:${apiRoute.method}:${apiRoute.requestPath}`,
150
+ kind: "api_route",
151
+ service: serviceName,
152
+ label: `${apiRoute.method} ${apiRoute.requestPath}`,
153
+ route: apiRoute.route,
154
+ method: apiRoute.method,
155
+ path: apiRoute.requestPath,
156
+ filePath: apiRoute.filePath,
157
+ };
158
+ nodes.push(node);
159
+ routeEntries.push({
160
+ node,
161
+ method: apiRoute.method,
162
+ route: apiRoute.route,
163
+ requestPath: apiRoute.requestPath,
164
+ filePath: apiRoute.filePath,
165
+ });
166
+
167
+ for (const backendRef of apiRoute.backendRefs || []) {
168
+ const capabilityNode = createServerCapabilityNode(serviceName, backendRef);
169
+ nodes.push(capabilityNode);
170
+ edges.push({
171
+ id: `delegates_to:${node.id}:${capabilityNode.id}`,
172
+ kind: "delegates_to",
173
+ from: node.id,
174
+ to: capabilityNode.id,
175
+ confidence: "high",
176
+ });
177
+ }
178
+ }
179
+
180
+ for (const serverAction of serverActions) {
181
+ const node = {
182
+ id: `server_action:${serviceName}:${serverAction.filePath}#${serverAction.exportName}`,
183
+ kind: "server_action",
184
+ service: serviceName,
185
+ label: serverAction.exportName,
186
+ filePath: serverAction.filePath,
187
+ };
188
+ nodes.push(node);
189
+ actionEntries.push({
190
+ node,
191
+ exportName: serverAction.exportName,
192
+ sourceFile: serverAction.filePath,
193
+ });
194
+ for (const backendRef of serverAction.backendRefs || []) {
195
+ const capabilityNode = createServerCapabilityNode(serviceName, backendRef);
196
+ nodes.push(capabilityNode);
197
+ edges.push({
198
+ id: `delegates_to:${node.id}:${capabilityNode.id}`,
199
+ kind: "delegates_to",
200
+ from: node.id,
201
+ to: capabilityNode.id,
202
+ confidence: "high",
203
+ });
204
+ }
205
+ }
206
+
207
+ return {
208
+ nodes: dedupeNodes(nodes),
209
+ edges: dedupeEdges(edges),
210
+ pageEntries,
211
+ routeEntries,
212
+ actionEntries,
213
+ };
214
+ }
215
+
216
+ function mapRouteTreeOwnerToGraphId(serviceName, route, ownerId) {
217
+ if (ownerId === `page:${route}`) return `page_view:${serviceName}:${route}`;
218
+ if (ownerId.startsWith("action:")) {
219
+ return `ui_action:${serviceName}:${route}:${stripActionPrefix(ownerId, route)}`;
220
+ }
221
+ return ownerId;
222
+ }
223
+
224
+ function stripActionPrefix(actionId, route) {
225
+ const prefix = `action:${route}:`;
226
+ return actionId.startsWith(prefix) ? actionId.slice(prefix.length) : actionId;
227
+ }
228
+
229
+ function createServerCapabilityNode(serviceName, backendRef) {
230
+ return {
231
+ id: `server_capability:${modulePathKey(backendRef.modulePath)}#${backendRef.exportName}`,
232
+ kind: "server_capability",
233
+ service: serviceName,
234
+ label: backendRef.exportName,
235
+ filePath: backendRef.modulePath || null,
236
+ metadata: {
237
+ specifier: backendRef.specifier,
238
+ },
239
+ };
240
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@elench/next-analysis",
3
+ "version": "0.1.65",
4
+ "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs",
8
+ "./package.json": "./package.json"
9
+ },
10
+ "dependencies": {
11
+ "@next/routing": "^16.2.4",
12
+ "@swc/core": "^1.15.32"
13
+ }
14
+ }
@@ -0,0 +1,81 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { extractBackendRefs } from "./backend-links.mjs";
4
+ import { routeFromApiFile } from "./routes.mjs";
5
+ import { HTTP_METHODS, isRouteHandlerFile, normalizePath, toApiRequestPath } from "./shared.mjs";
6
+ import { parseModule } from "./swc.mjs";
7
+
8
+ export function discoverApiRoutes({ rootDir, appRoot }) {
9
+ const apiRoot = path.join(appRoot, "api");
10
+ if (!fs.existsSync(apiRoot)) return [];
11
+ const routeFiles = walkFiles(apiRoot).filter(isRouteHandlerFile);
12
+ const entries = [];
13
+
14
+ for (const absolutePath of routeFiles) {
15
+ const filePath = normalizePath(path.relative(rootDir, absolutePath));
16
+ const route = routeFromApiFile(appRoot, absolutePath);
17
+ const requestPath = toApiRequestPath(route);
18
+ const content = fs.readFileSync(absolutePath, "utf8");
19
+ const ast = parseModule(content, filePath);
20
+ const backendRefs = extractBackendRefs({
21
+ ast,
22
+ rootDir,
23
+ filePath,
24
+ readSourceFile: (targetPath) => fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null,
25
+ });
26
+ const exportedMethods = extractExportedMethods(ast);
27
+
28
+ for (const method of exportedMethods) {
29
+ entries.push({
30
+ id: `api:${method}:${requestPath}`,
31
+ kind: "api_route",
32
+ route,
33
+ requestPath,
34
+ method,
35
+ filePath,
36
+ backendRefs,
37
+ });
38
+ }
39
+ }
40
+
41
+ return entries.sort((left, right) => {
42
+ return left.requestPath.localeCompare(right.requestPath) || left.method.localeCompare(right.method);
43
+ });
44
+ }
45
+
46
+ function extractExportedMethods(ast) {
47
+ const methods = [];
48
+ for (const statement of ast.body || []) {
49
+ if (statement.type !== "ExportDeclaration") continue;
50
+ const declaration = statement.declaration;
51
+ if (declaration?.type === "FunctionDeclaration" && declaration.identifier) {
52
+ const name = declaration.identifier.value.toUpperCase();
53
+ if (HTTP_METHODS.includes(name)) methods.push(name);
54
+ } else if (declaration?.type === "VariableDeclaration") {
55
+ for (const declarator of declaration.declarations || []) {
56
+ if (declarator.id?.type !== "Identifier") continue;
57
+ const name = declarator.id.value.toUpperCase();
58
+ if (HTTP_METHODS.includes(name)) methods.push(name);
59
+ }
60
+ }
61
+ }
62
+ return [...new Set(methods)];
63
+ }
64
+
65
+ function walkFiles(rootDir) {
66
+ const results = [];
67
+ const queue = [rootDir];
68
+ while (queue.length > 0) {
69
+ const current = queue.pop();
70
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
71
+ if (entry.isSymbolicLink()) continue;
72
+ const absolutePath = path.join(current, entry.name);
73
+ if (entry.isDirectory()) {
74
+ queue.push(absolutePath);
75
+ } else if (entry.isFile()) {
76
+ results.push(absolutePath);
77
+ }
78
+ }
79
+ }
80
+ return results.sort((left, right) => left.localeCompare(right));
81
+ }
@@ -0,0 +1,22 @@
1
+ import path from "path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createNextAnalysisProject } from "./index.mjs";
4
+
5
+ const fixtureRoot = path.resolve(
6
+ "/home/georgedlr/workspace/elench/testkit/test/fixtures/integration/next-dynamic-routes-product"
7
+ );
8
+
9
+ describe("@elench/next-analysis api routes", () => {
10
+ it("discovers dynamic API routes with normalized request paths", () => {
11
+ const project = createNextAnalysisProject({ rootDir: fixtureRoot });
12
+ expect(project.discoverApiRoutes()).toEqual(
13
+ expect.arrayContaining([
14
+ expect.objectContaining({
15
+ method: "GET",
16
+ route: "/projects/[projectId]",
17
+ requestPath: "/api/projects/[projectId]",
18
+ }),
19
+ ])
20
+ );
21
+ });
22
+ });
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+ import { directoryExists } from "./shared.mjs";
3
+
4
+ export function findNextAppRoot(rootDir) {
5
+ const candidates = [path.join(rootDir, "app"), path.join(rootDir, "src", "app")];
6
+ return candidates.find((candidate) => directoryExists(candidate)) || null;
7
+ }
@@ -0,0 +1,31 @@
1
+ import { isBackendSpecifier, modulePathKey, normalizePath } from "./shared.mjs";
2
+ import { collectImports } from "./swc.mjs";
3
+
4
+ export function extractBackendRefs({ ast, rootDir, filePath, readSourceFile }) {
5
+ const imports = collectImports(ast, { rootDir, filePath, readSourceFile });
6
+ const refs = [];
7
+
8
+ for (const [localName, imported] of imports.entries()) {
9
+ if (!isBackendSpecifier(imported.specifier)) continue;
10
+ refs.push({
11
+ id: `server_ref:${modulePathKey(imported.resolvedFilePath || filePath)}#${imported.importedName}`,
12
+ kind: "server_capability",
13
+ importName: localName,
14
+ exportName: imported.importedName,
15
+ modulePath: normalizePath(imported.resolvedFilePath || filePath),
16
+ specifier: imported.specifier,
17
+ });
18
+ }
19
+
20
+ return dedupeBackendRefs(refs);
21
+ }
22
+
23
+ function dedupeBackendRefs(entries) {
24
+ const seen = new Set();
25
+ return entries.filter((entry) => {
26
+ const key = `${entry.modulePath}#${entry.exportName}`;
27
+ if (seen.has(key)) return false;
28
+ seen.add(key);
29
+ return true;
30
+ });
31
+ }
@@ -0,0 +1,21 @@
1
+ export { createNextAnalysisProject } from "./project.mjs";
2
+ export { findNextAppRoot } from "./app-root.mjs";
3
+ export { discoverPages } from "./pages.mjs";
4
+ export { discoverApiRoutes } from "./api-routes.mjs";
5
+ export { discoverServerActions } from "./server-actions.mjs";
6
+ export { analyzeRouteTree } from "./route-tree.mjs";
7
+ export {
8
+ normalizePath,
9
+ normalizeRoute,
10
+ normalizeRouteSegments,
11
+ pageLabelFromRoute,
12
+ toApiRequestPath,
13
+ HTTP_METHODS,
14
+ } from "./shared.mjs";
15
+ export {
16
+ routeFromAppFile,
17
+ routeFromApiFile,
18
+ routePatternMatches,
19
+ requestPathPatternFromLiteral,
20
+ } from "./routes.mjs";
21
+ export { parseModule, extractHttpRequests, extractPlaywrightVisitedRoutes } from "./swc.mjs";