next-arch-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # next-arch-map
2
+
3
+ Static analyzer that builds a multi-layer graph for Next.js-style apps. The graph can contain pages, endpoints, DB entities, and UI components.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ npm install --save-dev next-arch-map
9
+ npx next-arch-map analyze --project-root . --out arch/graph.full.json
10
+ ```
11
+
12
+ ## What It Produces
13
+
14
+ `next-arch-map` emits a single JSON graph:
15
+
16
+ ```json
17
+ {
18
+ "nodes": [
19
+ { "id": "page:/dashboard", "type": "page", "label": "/dashboard" },
20
+ { "id": "endpoint:/api/users", "type": "endpoint", "label": "/api/users" },
21
+ { "id": "db:user", "type": "db", "label": "user" },
22
+ { "id": "ui:ProfileCard", "type": "ui", "label": "ProfileCard" }
23
+ ],
24
+ "edges": [
25
+ { "from": "page:/dashboard", "to": "endpoint:/api/users", "kind": "page-endpoint" },
26
+ { "from": "endpoint:/api/users", "to": "db:user", "kind": "endpoint-db" },
27
+ { "from": "page:/dashboard", "to": "ui:ProfileCard", "kind": "page-ui" }
28
+ ]
29
+ }
30
+ ```
31
+
32
+ ## CLI
33
+
34
+ ```bash
35
+ npx next-arch-map analyze --project-root . --out arch/graph.full.json
36
+ ```
37
+
38
+ The CLI accepts:
39
+
40
+ - `--project-root <path>`
41
+ - `--out <path>`
42
+ - `--app-dir <path>` (repeatable)
43
+
44
+ ## Library API
45
+
46
+ ```ts
47
+ import { analyzeProject } from "next-arch-map";
48
+
49
+ const graph = await analyzeProject({
50
+ projectRoot: process.cwd(),
51
+ });
52
+ ```
53
+
54
+ ## Notes
55
+
56
+ - Page to endpoint detection currently looks for direct string-literal HTTP calls such as `fetch("/api/...")`, `axios.get("/api/...")`, and `apiClient.get("/api/...")`.
57
+ - Endpoint to DB detection currently looks for Prisma-style calls such as `prisma.user.findMany(...)`.
58
+ - Page to UI detection currently looks at page imports that resolve into component-like paths.
@@ -0,0 +1,11 @@
1
+ import type { Edge, Node } from "../model.js";
2
+ type AnalyzeEndpointsToDbOptions = {
3
+ projectRoot: string;
4
+ apiDirs?: string[];
5
+ dbClientIdentifiers?: string[];
6
+ };
7
+ export declare function analyzeEndpointsToDb(options: AnalyzeEndpointsToDbOptions): Promise<{
8
+ nodes: Node[];
9
+ edges: Edge[];
10
+ }>;
11
+ export {};
@@ -0,0 +1,348 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { buildDbNode, buildEdgeKey, buildEndpointNode, buildHandlerNode, ensureNode, getEndpointRouteFromFile, getExistingDirectories, getSourceFile, isIgnoredSourceFile, isRouteHandlerFile, resolveLocalModulePath, resolveProjectRoot, walkDirectory, } from "../utils.js";
4
+ const DEFAULT_API_DIRS = ["app", "src/app", "app/api", "src/app/api", "src/server", "src/api"];
5
+ const DEFAULT_DB_CLIENT_IDENTIFIERS = ["prisma"];
6
+ const ROUTE_METHOD_EXPORTS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
7
+ export async function analyzeEndpointsToDb(options) {
8
+ const projectRoot = resolveProjectRoot(options.projectRoot);
9
+ const scanRoots = getExistingDirectories(projectRoot, options.apiDirs ?? DEFAULT_API_DIRS);
10
+ const dbClientIdentifiers = new Set(options.dbClientIdentifiers ?? DEFAULT_DB_CLIENT_IDENTIFIERS);
11
+ const nodes = [];
12
+ const edges = [];
13
+ const nodeIds = new Set();
14
+ const edgeKeys = new Set();
15
+ const sourceFileCache = new Map();
16
+ const moduleCache = new Map();
17
+ const seenRouteFiles = new Set();
18
+ for (const scanRoot of scanRoots) {
19
+ for (const filePath of walkDirectory(scanRoot)) {
20
+ if (seenRouteFiles.has(filePath) || isIgnoredSourceFile(filePath) || !isRouteHandlerFile(filePath)) {
21
+ continue;
22
+ }
23
+ seenRouteFiles.add(filePath);
24
+ const endpointPath = getEndpointRouteFromFile(scanRoot, filePath);
25
+ const { availableMethods, dbUsageByModel } = analyzeEndpoint(filePath, projectRoot, moduleCache, sourceFileCache, dbClientIdentifiers);
26
+ const endpointNode = ensureNode(nodes, nodeIds, buildEndpointNode(endpointPath, filePath));
27
+ const handlerMethods = availableMethods.size > 0 ? [...availableMethods] : [undefined];
28
+ for (const methodName of handlerMethods) {
29
+ const handlerNode = ensureNode(nodes, nodeIds, buildHandlerNode(endpointPath, filePath, methodName));
30
+ const edgeKey = buildEdgeKey(endpointNode.id, handlerNode.id, "endpoint-handler");
31
+ if (edgeKeys.has(edgeKey)) {
32
+ continue;
33
+ }
34
+ edgeKeys.add(edgeKey);
35
+ edges.push({
36
+ from: endpointNode.id,
37
+ to: handlerNode.id,
38
+ kind: "endpoint-handler",
39
+ meta: methodName ? { method: methodName } : undefined,
40
+ });
41
+ }
42
+ for (const [modelName, dbUsage] of dbUsageByModel) {
43
+ const dbNode = ensureNode(nodes, nodeIds, buildDbNode(modelName, dbUsage.filePath));
44
+ const edgeKey = buildEdgeKey(endpointNode.id, dbNode.id, "endpoint-db");
45
+ if (edgeKeys.has(edgeKey)) {
46
+ continue;
47
+ }
48
+ edgeKeys.add(edgeKey);
49
+ edges.push({
50
+ from: endpointNode.id,
51
+ to: dbNode.id,
52
+ kind: "endpoint-db",
53
+ meta: dbUsage.actionName ? { action: dbUsage.actionName } : undefined,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ return {
59
+ nodes: nodes.sort((left, right) => left.id.localeCompare(right.id)),
60
+ edges: edges.sort((left, right) => left.kind.localeCompare(right.kind) ||
61
+ left.from.localeCompare(right.from) ||
62
+ left.to.localeCompare(right.to)),
63
+ };
64
+ }
65
+ function analyzeEndpoint(routeFilePath, projectRoot, moduleCache, sourceFileCache, dbClientIdentifiers) {
66
+ const state = {
67
+ projectRoot,
68
+ moduleCache,
69
+ sourceFileCache,
70
+ visitedDeclarationKeys: new Set(),
71
+ dbUsageByModel: new Map(),
72
+ };
73
+ const availableMethods = new Set();
74
+ for (const methodName of ROUTE_METHOD_EXPORTS) {
75
+ const resolvedDeclaration = resolveExportReference(routeFilePath, methodName, state);
76
+ if (resolvedDeclaration) {
77
+ availableMethods.add(methodName);
78
+ analyzeDeclaration(resolvedDeclaration, state, new Set(dbClientIdentifiers));
79
+ }
80
+ }
81
+ return {
82
+ availableMethods,
83
+ dbUsageByModel: state.dbUsageByModel,
84
+ };
85
+ }
86
+ function analyzeDeclaration(declaration, state, activeDbClients) {
87
+ if (state.visitedDeclarationKeys.has(declaration.key)) {
88
+ return;
89
+ }
90
+ state.visitedDeclarationKeys.add(declaration.key);
91
+ visitNode(getAnalyzableNode(declaration.node), declaration.filePath, state, activeDbClients);
92
+ }
93
+ function visitNode(node, filePath, state, activeDbClients) {
94
+ if (ts.isCallExpression(node)) {
95
+ const dbUsage = parseDbUsage(node, activeDbClients);
96
+ if (dbUsage) {
97
+ const existingUsage = state.dbUsageByModel.get(dbUsage.modelName);
98
+ if (!existingUsage) {
99
+ state.dbUsageByModel.set(dbUsage.modelName, {
100
+ actionName: dbUsage.actionName,
101
+ filePath,
102
+ });
103
+ }
104
+ else if (!existingUsage.actionName && dbUsage.actionName) {
105
+ state.dbUsageByModel.set(dbUsage.modelName, {
106
+ ...existingUsage,
107
+ actionName: dbUsage.actionName,
108
+ });
109
+ }
110
+ }
111
+ const transactionCallback = getTransactionCallback(node, activeDbClients);
112
+ if (transactionCallback) {
113
+ visitNode(getFunctionBodyNode(transactionCallback.callback), filePath, state, new Set([...activeDbClients, transactionCallback.clientIdentifier]));
114
+ }
115
+ if (shouldResolveCall(node)) {
116
+ const resolvedCallee = resolveIdentifierReference(node.expression.text, filePath, state);
117
+ if (resolvedCallee) {
118
+ analyzeDeclaration(resolvedCallee, state, activeDbClients);
119
+ }
120
+ }
121
+ }
122
+ ts.forEachChild(node, (child) => visitNode(child, filePath, state, activeDbClients));
123
+ }
124
+ function parseDbUsage(node, activeDbClients) {
125
+ if (!ts.isPropertyAccessExpression(node.expression) ||
126
+ !ts.isPropertyAccessExpression(node.expression.expression) ||
127
+ !ts.isIdentifier(node.expression.expression.expression)) {
128
+ return null;
129
+ }
130
+ const clientIdentifier = node.expression.expression.expression.text;
131
+ if (!activeDbClients.has(clientIdentifier)) {
132
+ return null;
133
+ }
134
+ return {
135
+ modelName: node.expression.expression.name.text,
136
+ actionName: node.expression.name.text,
137
+ };
138
+ }
139
+ function getTransactionCallback(node, activeDbClients) {
140
+ if (!ts.isPropertyAccessExpression(node.expression) ||
141
+ !ts.isIdentifier(node.expression.expression) ||
142
+ !activeDbClients.has(node.expression.expression.text) ||
143
+ node.expression.name.text !== "$transaction") {
144
+ return null;
145
+ }
146
+ const callback = node.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
147
+ const firstParameter = callback?.parameters[0];
148
+ if (!callback || !firstParameter || !ts.isIdentifier(firstParameter.name)) {
149
+ return null;
150
+ }
151
+ return {
152
+ callback,
153
+ clientIdentifier: firstParameter.name.text,
154
+ };
155
+ }
156
+ function shouldResolveCall(node) {
157
+ return (ts.isIdentifier(node.expression) &&
158
+ !node.arguments.some((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)));
159
+ }
160
+ function resolveIdentifierReference(identifierName, filePath, state) {
161
+ const moduleInfo = getModuleInfo(filePath, state);
162
+ const localDeclaration = moduleInfo.localDeclarations.get(identifierName);
163
+ if (localDeclaration) {
164
+ return {
165
+ filePath,
166
+ key: `${filePath}::local::${identifierName}`,
167
+ node: localDeclaration,
168
+ };
169
+ }
170
+ const importTarget = moduleInfo.importsByLocalName.get(identifierName);
171
+ if (!importTarget) {
172
+ return null;
173
+ }
174
+ return resolveExportReference(importTarget.filePath, importTarget.exportName, state);
175
+ }
176
+ function resolveExportReference(filePath, exportName, state, seen = new Set()) {
177
+ const seenKey = `${filePath}::${exportName}`;
178
+ if (seen.has(seenKey)) {
179
+ return null;
180
+ }
181
+ seen.add(seenKey);
182
+ const moduleInfo = getModuleInfo(filePath, state);
183
+ const exportTarget = moduleInfo.exportsByName.get(exportName);
184
+ if (!exportTarget) {
185
+ const fallbackDeclaration = moduleInfo.localDeclarations.get(exportName);
186
+ if (!fallbackDeclaration) {
187
+ return null;
188
+ }
189
+ return {
190
+ filePath,
191
+ key: `${filePath}::local::${exportName}`,
192
+ node: fallbackDeclaration,
193
+ };
194
+ }
195
+ if (exportTarget.kind === "local") {
196
+ const localDeclaration = moduleInfo.localDeclarations.get(exportTarget.localName);
197
+ if (!localDeclaration) {
198
+ return null;
199
+ }
200
+ return {
201
+ filePath,
202
+ key: `${filePath}::local::${exportTarget.localName}`,
203
+ node: localDeclaration,
204
+ };
205
+ }
206
+ if (exportTarget.kind === "node") {
207
+ return {
208
+ filePath,
209
+ key: `${filePath}::node::${exportName}`,
210
+ node: exportTarget.node,
211
+ };
212
+ }
213
+ return resolveExportReference(exportTarget.filePath, exportTarget.exportName, state, seen);
214
+ }
215
+ function getModuleInfo(filePath, state) {
216
+ const cachedModuleInfo = state.moduleCache.get(filePath);
217
+ if (cachedModuleInfo) {
218
+ return cachedModuleInfo;
219
+ }
220
+ const sourceFile = getSourceFile(filePath, state.sourceFileCache);
221
+ if (!sourceFile) {
222
+ throw new Error(`Could not parse ${path.relative(state.projectRoot, filePath)}.`);
223
+ }
224
+ const localDeclarations = new Map();
225
+ const importsByLocalName = new Map();
226
+ const exportsByName = new Map();
227
+ for (const statement of sourceFile.statements) {
228
+ if (ts.isFunctionDeclaration(statement) && statement.name) {
229
+ localDeclarations.set(statement.name.text, statement);
230
+ if (hasExportModifier(statement)) {
231
+ exportsByName.set(statement.name.text, {
232
+ kind: "local",
233
+ localName: statement.name.text,
234
+ });
235
+ }
236
+ if (hasDefaultModifier(statement)) {
237
+ exportsByName.set("default", {
238
+ kind: "local",
239
+ localName: statement.name.text,
240
+ });
241
+ }
242
+ continue;
243
+ }
244
+ if (ts.isVariableStatement(statement)) {
245
+ const exported = hasExportModifier(statement);
246
+ for (const declaration of statement.declarationList.declarations) {
247
+ if (!ts.isIdentifier(declaration.name)) {
248
+ continue;
249
+ }
250
+ localDeclarations.set(declaration.name.text, declaration);
251
+ if (exported) {
252
+ exportsByName.set(declaration.name.text, {
253
+ kind: "local",
254
+ localName: declaration.name.text,
255
+ });
256
+ }
257
+ }
258
+ continue;
259
+ }
260
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
261
+ const resolvedImportPath = resolveLocalModulePath(filePath, statement.moduleSpecifier.text, state.projectRoot);
262
+ if (!resolvedImportPath || !statement.importClause) {
263
+ continue;
264
+ }
265
+ if (statement.importClause.name) {
266
+ importsByLocalName.set(statement.importClause.name.text, {
267
+ filePath: resolvedImportPath,
268
+ exportName: "default",
269
+ });
270
+ }
271
+ if (statement.importClause.namedBindings &&
272
+ ts.isNamedImports(statement.importClause.namedBindings)) {
273
+ for (const element of statement.importClause.namedBindings.elements) {
274
+ importsByLocalName.set(element.name.text, {
275
+ filePath: resolvedImportPath,
276
+ exportName: element.propertyName?.text ?? element.name.text,
277
+ });
278
+ }
279
+ }
280
+ continue;
281
+ }
282
+ if (ts.isExportDeclaration(statement) &&
283
+ statement.exportClause &&
284
+ ts.isNamedExports(statement.exportClause)) {
285
+ const resolvedModulePath = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
286
+ ? resolveLocalModulePath(filePath, statement.moduleSpecifier.text, state.projectRoot)
287
+ : null;
288
+ for (const element of statement.exportClause.elements) {
289
+ const exportName = element.name.text;
290
+ if (resolvedModulePath) {
291
+ exportsByName.set(exportName, {
292
+ kind: "reexport",
293
+ filePath: resolvedModulePath,
294
+ exportName: element.propertyName?.text ?? element.name.text,
295
+ });
296
+ continue;
297
+ }
298
+ exportsByName.set(exportName, {
299
+ kind: "local",
300
+ localName: element.propertyName?.text ?? element.name.text,
301
+ });
302
+ }
303
+ continue;
304
+ }
305
+ if (ts.isExportAssignment(statement)) {
306
+ if (ts.isIdentifier(statement.expression)) {
307
+ exportsByName.set("default", {
308
+ kind: "local",
309
+ localName: statement.expression.text,
310
+ });
311
+ }
312
+ else {
313
+ exportsByName.set("default", {
314
+ kind: "node",
315
+ node: statement.expression,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ const moduleInfo = {
321
+ localDeclarations,
322
+ importsByLocalName,
323
+ exportsByName,
324
+ };
325
+ state.moduleCache.set(filePath, moduleInfo);
326
+ return moduleInfo;
327
+ }
328
+ function getAnalyzableNode(node) {
329
+ if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) {
330
+ return node.body ?? node;
331
+ }
332
+ if (ts.isVariableDeclaration(node)) {
333
+ return node.initializer ?? node;
334
+ }
335
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
336
+ return getFunctionBodyNode(node);
337
+ }
338
+ return node;
339
+ }
340
+ function getFunctionBodyNode(node) {
341
+ return node.body;
342
+ }
343
+ function hasExportModifier(node) {
344
+ return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
345
+ }
346
+ function hasDefaultModifier(node) {
347
+ return ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
348
+ }
@@ -0,0 +1,10 @@
1
+ import type { Graph } from "../model.js";
2
+ type AnalyzePagesToEndpointsOptions = {
3
+ projectRoot: string;
4
+ appDirs?: string[];
5
+ extraScanDirs?: string[];
6
+ httpClientIdentifiers?: string[];
7
+ httpClientMethods?: string[];
8
+ };
9
+ export declare function analyzePagesToEndpoints(options: AnalyzePagesToEndpointsOptions): Promise<Graph>;
10
+ export {};