@elench/testkit 0.1.64 → 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.
- package/lib/coverage/evidence.mjs +15 -3
- package/lib/coverage/evidence.test.mjs +13 -4
- package/lib/coverage/graph-builder.mjs +23 -24
- package/lib/coverage/next-ir-to-graph.mjs +240 -0
- package/node_modules/@elench/next-analysis/package.json +14 -0
- package/node_modules/@elench/next-analysis/src/api-routes.mjs +81 -0
- package/node_modules/@elench/next-analysis/src/api-routes.test.mjs +22 -0
- package/node_modules/@elench/next-analysis/src/app-root.mjs +7 -0
- package/node_modules/@elench/next-analysis/src/backend-links.mjs +31 -0
- package/node_modules/@elench/next-analysis/src/index.mjs +21 -0
- package/node_modules/@elench/next-analysis/src/pages.mjs +68 -0
- package/node_modules/@elench/next-analysis/src/project.mjs +94 -0
- package/node_modules/@elench/next-analysis/src/project.test.mjs +35 -0
- package/node_modules/@elench/next-analysis/src/route-tree.mjs +621 -0
- package/node_modules/@elench/next-analysis/src/routes.mjs +41 -0
- package/node_modules/@elench/next-analysis/src/routes.test.mjs +25 -0
- package/node_modules/@elench/next-analysis/src/server-actions.mjs +53 -0
- package/node_modules/@elench/next-analysis/src/server-actions.test.mjs +37 -0
- package/node_modules/@elench/next-analysis/src/shared.mjs +209 -0
- package/node_modules/@elench/next-analysis/src/swc.mjs +388 -0
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/node_modules/@next/routing/README.md +91 -0
- package/node_modules/@next/routing/dist/__tests__/captures.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/conditions.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/dynamic-after-rewrites.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/i18n-resolve-routes.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/i18n.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/middleware.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/normalize-next-data.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/redirects.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/resolve-routes.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/__tests__/rewrites.test.d.ts +1 -0
- package/node_modules/@next/routing/dist/destination.d.ts +22 -0
- package/node_modules/@next/routing/dist/i18n.d.ts +48 -0
- package/node_modules/@next/routing/dist/index.d.ts +5 -0
- package/node_modules/@next/routing/dist/index.js +1 -0
- package/node_modules/@next/routing/dist/matchers.d.ts +12 -0
- package/node_modules/@next/routing/dist/middleware.d.ts +12 -0
- package/node_modules/@next/routing/dist/next-data.d.ts +10 -0
- package/node_modules/@next/routing/dist/resolve-routes.d.ts +2 -0
- package/node_modules/@next/routing/dist/types.d.ts +97 -0
- package/node_modules/@next/routing/package.json +39 -0
- package/node_modules/@swc/core/README.md +100 -0
- package/node_modules/@swc/core/Visitor.d.ts +218 -0
- package/node_modules/@swc/core/Visitor.js +1399 -0
- package/node_modules/@swc/core/binding.d.ts +59 -0
- package/node_modules/@swc/core/binding.js +368 -0
- package/node_modules/@swc/core/index.d.ts +120 -0
- package/node_modules/@swc/core/index.js +443 -0
- package/node_modules/@swc/core/package.json +120 -0
- package/node_modules/@swc/core/postinstall.js +148 -0
- package/node_modules/@swc/core/spack.d.ts +51 -0
- package/node_modules/@swc/core/spack.js +87 -0
- package/node_modules/@swc/core/util.d.ts +1 -0
- package/node_modules/@swc/core/util.js +104 -0
- package/node_modules/@swc/core-linux-x64-gnu/README.md +3 -0
- package/node_modules/@swc/core-linux-x64-gnu/package.json +46 -0
- package/node_modules/@swc/core-linux-x64-gnu/swc.linux-x64-gnu.node +0 -0
- package/node_modules/@swc/counter/CHANGELOG.md +7 -0
- package/node_modules/@swc/counter/README.md +7 -0
- package/node_modules/@swc/counter/index.js +1 -0
- package/node_modules/@swc/counter/package.json +27 -0
- package/node_modules/@swc/types/LICENSE +201 -0
- package/node_modules/@swc/types/README.md +4 -0
- package/node_modules/@swc/types/assumptions.d.ts +92 -0
- package/node_modules/@swc/types/assumptions.js +2 -0
- package/node_modules/@swc/types/index.d.ts +2049 -0
- package/node_modules/@swc/types/index.js +2 -0
- package/node_modules/@swc/types/package.json +40 -0
- package/package.json +6 -4
- package/lib/coverage/next-discovery.mjs +0 -205
- package/lib/coverage/next-static-analysis.mjs +0 -1045
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
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
|
|
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 =
|
|
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 "
|
|
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
|
-
`,
|
|
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
|
-
`,
|
|
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
|
-
`,
|
|
82
|
+
`, {
|
|
83
|
+
filePath: "test.pw.testkit.ts",
|
|
84
|
+
dynamicSegmentToken: DYNAMIC_SEGMENT_TOKEN,
|
|
85
|
+
})).toEqual([`/projects/${DYNAMIC_SEGMENT_TOKEN}`]);
|
|
77
86
|
});
|
|
78
87
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { discoverDataCapabilities } from "./backend-discovery.mjs";
|
|
2
2
|
import { buildEvidenceDetails, inferCoveredNodeIdsForTest } from "./evidence.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
117
|
-
for (const edge of
|
|
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(
|
|
122
|
+
const pageByRoute = new Map(converted.pageEntries.map((entry) => [entry.route, entry]));
|
|
124
123
|
const apiRouteByKey = new Map(
|
|
125
|
-
|
|
124
|
+
converted.routeEntries.map((entry) => [`${entry.method}:${entry.requestPath}`, entry])
|
|
126
125
|
);
|
|
127
126
|
const serverActionByExportKey = new Map(
|
|
128
|
-
|
|
127
|
+
converted.actionEntries.map((entry) => [`${entry.sourceFile}#${entry.exportName}`, entry])
|
|
129
128
|
);
|
|
130
129
|
|
|
131
|
-
for (const pageEntry of
|
|
130
|
+
for (const pageEntry of converted.pageEntries) {
|
|
132
131
|
for (const request of pageEntry.requests) {
|
|
133
|
-
const apiRoute = findMatchingApiRouteEntry(request.method, request.path,
|
|
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:
|
|
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";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { routeFromAppFile } from "./routes.mjs";
|
|
4
|
+
import { compareByRoute, isPageFile, normalizePath, pageLabelFromRoute } from "./shared.mjs";
|
|
5
|
+
|
|
6
|
+
export function discoverPages({ rootDir, appRoot }) {
|
|
7
|
+
const pageFiles = walkFiles(appRoot).filter(isPageFile);
|
|
8
|
+
return pageFiles
|
|
9
|
+
.map((absolutePath) => {
|
|
10
|
+
const filePath = normalizePath(path.relative(rootDir, absolutePath));
|
|
11
|
+
const route = routeFromAppFile(appRoot, absolutePath);
|
|
12
|
+
const layoutFiles = collectLayoutFiles(rootDir, appRoot, absolutePath);
|
|
13
|
+
const rootFiles = [...layoutFiles, filePath];
|
|
14
|
+
return {
|
|
15
|
+
id: `page:${route}`,
|
|
16
|
+
kind: "page",
|
|
17
|
+
route,
|
|
18
|
+
label: pageLabelFromRoute(route),
|
|
19
|
+
filePath,
|
|
20
|
+
layoutFiles,
|
|
21
|
+
rootFiles,
|
|
22
|
+
};
|
|
23
|
+
})
|
|
24
|
+
.sort(compareByRoute);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function collectLayoutFiles(rootDir, appRoot, pageAbsolutePath) {
|
|
28
|
+
const rootFiles = [];
|
|
29
|
+
let currentDir = path.dirname(pageAbsolutePath);
|
|
30
|
+
const absoluteAppRoot = path.resolve(appRoot);
|
|
31
|
+
|
|
32
|
+
while (currentDir.startsWith(absoluteAppRoot)) {
|
|
33
|
+
const layoutFile = resolveRouteCompanion(currentDir, "layout");
|
|
34
|
+
if (layoutFile) rootFiles.push(normalizePath(path.relative(rootDir, layoutFile)));
|
|
35
|
+
if (currentDir === absoluteAppRoot) break;
|
|
36
|
+
currentDir = path.dirname(currentDir);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [...new Set(rootFiles.reverse())];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveRouteCompanion(directoryPath, baseName) {
|
|
43
|
+
const candidates = [
|
|
44
|
+
path.join(directoryPath, `${baseName}.tsx`),
|
|
45
|
+
path.join(directoryPath, `${baseName}.ts`),
|
|
46
|
+
path.join(directoryPath, `${baseName}.jsx`),
|
|
47
|
+
path.join(directoryPath, `${baseName}.js`),
|
|
48
|
+
];
|
|
49
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function walkFiles(rootDir) {
|
|
53
|
+
const results = [];
|
|
54
|
+
const queue = [rootDir];
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const current = queue.pop();
|
|
57
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
58
|
+
if (entry.isSymbolicLink()) continue;
|
|
59
|
+
const absolutePath = path.join(current, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
queue.push(absolutePath);
|
|
62
|
+
} else if (entry.isFile()) {
|
|
63
|
+
results.push(absolutePath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results.sort((left, right) => left.localeCompare(right));
|
|
68
|
+
}
|