@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.
- package/lib/coverage/backend-discovery.mjs +6 -30
- package/lib/coverage/evidence.mjs +15 -3
- package/lib/coverage/evidence.test.mjs +13 -4
- package/lib/coverage/fs-walk.mjs +2 -2
- 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 +10 -0
- package/node_modules/@elench/ts-analysis/src/callables.mjs +135 -0
- package/node_modules/@elench/ts-analysis/src/callables.test.mjs +55 -0
- package/node_modules/@elench/ts-analysis/src/exports.mjs +69 -0
- package/node_modules/@elench/ts-analysis/src/exports.test.mjs +50 -0
- package/node_modules/@elench/ts-analysis/src/index.mjs +14 -0
- package/node_modules/@elench/ts-analysis/src/jsx.mjs +69 -0
- package/node_modules/@elench/ts-analysis/src/jsx.test.mjs +43 -0
- package/node_modules/@elench/ts-analysis/src/project.mjs +100 -0
- package/node_modules/@elench/ts-analysis/src/project.test.mjs +54 -0
- package/node_modules/@elench/ts-analysis/src/requests.mjs +141 -0
- package/node_modules/@elench/ts-analysis/src/requests.test.mjs +35 -0
- package/node_modules/@elench/ts-analysis/src/resolution.mjs +53 -0
- package/node_modules/@elench/ts-analysis/src/shared.mjs +32 -0
- package/node_modules/@elench/ts-analysis/src/syntax.mjs +27 -0
- 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 +7 -3
- package/lib/coverage/next-discovery.mjs +0 -205
- 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
|
|
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 {
|
|
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
|
});
|
package/lib/coverage/fs-walk.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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";
|