next-arch-map 0.1.25 → 0.1.27

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.
@@ -0,0 +1,5 @@
1
+ import type { Edge, Node } from "../model.js";
2
+ export declare function analyzePrismaSchema(projectRoot: string): Promise<{
3
+ nodes: Node[];
4
+ edges: Edge[];
5
+ }>;
@@ -0,0 +1,284 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const MODEL_BLOCK_PATTERN = /^model\s+(\w+)\s*\{/;
4
+ const ENUM_BLOCK_PATTERN = /^enum\s+(\w+)\s*\{/;
5
+ const SCALAR_TYPES = new Set([
6
+ "String",
7
+ "Int",
8
+ "Float",
9
+ "Boolean",
10
+ "DateTime",
11
+ "Json",
12
+ "Bytes",
13
+ "Decimal",
14
+ "BigInt",
15
+ ]);
16
+ export async function analyzePrismaSchema(projectRoot) {
17
+ const schemaPath = path.join(projectRoot, "prisma", "schema.prisma");
18
+ if (!fs.existsSync(schemaPath)) {
19
+ return { nodes: [], edges: [] };
20
+ }
21
+ let content;
22
+ try {
23
+ content = fs.readFileSync(schemaPath, "utf8");
24
+ }
25
+ catch {
26
+ return { nodes: [], edges: [] };
27
+ }
28
+ const models = parseModels(content);
29
+ const enumNames = parseEnumNames(content);
30
+ const nodes = [];
31
+ const edges = [];
32
+ const edgeKeys = new Set();
33
+ // Build a map from lowercase model name to the camelCase form used by Prisma client
34
+ const modelIdMap = new Map();
35
+ for (const model of models) {
36
+ const camelCase = model.name.charAt(0).toLowerCase() + model.name.slice(1);
37
+ modelIdMap.set(model.name, camelCase);
38
+ }
39
+ for (const model of models) {
40
+ const camelName = modelIdMap.get(model.name) ?? model.name;
41
+ const nodeId = `db:${camelName}`;
42
+ nodes.push({
43
+ id: nodeId,
44
+ type: "db",
45
+ label: model.name,
46
+ meta: {
47
+ filePath: "prisma/schema.prisma",
48
+ model: model.name,
49
+ columns: model.columns,
50
+ },
51
+ });
52
+ for (const relation of model.relations) {
53
+ if (relation.isList) {
54
+ // Skip list relations — the "belongs-to" side will create the edge
55
+ continue;
56
+ }
57
+ const fromId = nodeId;
58
+ const relatedCamelName = modelIdMap.get(relation.relatedModel) ?? relation.relatedModel;
59
+ const toId = `db:${relatedCamelName}`;
60
+ const edgeKey = `${fromId}::${toId}::db-relation`;
61
+ if (edgeKeys.has(edgeKey)) {
62
+ continue;
63
+ }
64
+ edgeKeys.add(edgeKey);
65
+ edges.push({
66
+ from: fromId,
67
+ to: toId,
68
+ kind: "db-relation",
69
+ meta: {
70
+ field: relation.foreignKey ?? relation.field,
71
+ foreignKey: "id",
72
+ },
73
+ });
74
+ }
75
+ }
76
+ return {
77
+ nodes: nodes.sort((a, b) => a.id.localeCompare(b.id)),
78
+ edges: edges.sort((a, b) => a.kind.localeCompare(b.kind) ||
79
+ a.from.localeCompare(b.from) ||
80
+ a.to.localeCompare(b.to)),
81
+ };
82
+ }
83
+ function parseEnumNames(content) {
84
+ const names = new Set();
85
+ const lines = content.split("\n");
86
+ for (const line of lines) {
87
+ const match = ENUM_BLOCK_PATTERN.exec(line.trim());
88
+ if (match) {
89
+ names.add(match[1]);
90
+ }
91
+ }
92
+ return names;
93
+ }
94
+ function parseModels(content) {
95
+ const models = [];
96
+ const lines = content.split("\n");
97
+ const modelNames = new Set();
98
+ const enumNames = parseEnumNames(content);
99
+ // First pass: collect all model names
100
+ for (const line of lines) {
101
+ const match = MODEL_BLOCK_PATTERN.exec(line.trim());
102
+ if (match) {
103
+ modelNames.add(match[1]);
104
+ }
105
+ }
106
+ let currentModel = null;
107
+ let braceDepth = 0;
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+ if (!currentModel) {
111
+ const match = MODEL_BLOCK_PATTERN.exec(trimmed);
112
+ if (match) {
113
+ currentModel = { name: match[1], columns: [], relations: [] };
114
+ braceDepth = 1;
115
+ }
116
+ continue;
117
+ }
118
+ // Track brace depth
119
+ for (const ch of trimmed) {
120
+ if (ch === "{")
121
+ braceDepth++;
122
+ if (ch === "}")
123
+ braceDepth--;
124
+ }
125
+ if (braceDepth <= 0) {
126
+ models.push(currentModel);
127
+ currentModel = null;
128
+ continue;
129
+ }
130
+ // Skip comments, empty lines, and @@-level attributes
131
+ if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("@@")) {
132
+ continue;
133
+ }
134
+ const field = parseField(trimmed, modelNames, enumNames);
135
+ if (!field) {
136
+ continue;
137
+ }
138
+ if (field.kind === "relation") {
139
+ currentModel.relations.push(field.relation);
140
+ }
141
+ else {
142
+ currentModel.columns.push(field.column);
143
+ }
144
+ }
145
+ if (currentModel) {
146
+ models.push(currentModel);
147
+ }
148
+ return models;
149
+ }
150
+ function parseField(line, modelNames, enumNames) {
151
+ // Tokenize: split on whitespace but respect parentheses content
152
+ const tokens = tokenizeLine(line);
153
+ if (tokens.length < 2) {
154
+ return null;
155
+ }
156
+ const fieldName = tokens[0];
157
+ let rawType = tokens[1];
158
+ // Skip if field name starts with @ (attribute line)
159
+ if (fieldName.startsWith("@")) {
160
+ return null;
161
+ }
162
+ const isList = rawType.endsWith("[]");
163
+ const isOptional = rawType.endsWith("?");
164
+ if (isList) {
165
+ rawType = rawType.slice(0, -2);
166
+ }
167
+ else if (isOptional) {
168
+ rawType = rawType.slice(0, -1);
169
+ }
170
+ const isRequired = !isOptional && !isList;
171
+ // Check if it's a relation field (type references another model)
172
+ if (modelNames.has(rawType)) {
173
+ const relation = {
174
+ field: fieldName,
175
+ relatedModel: rawType,
176
+ isList,
177
+ };
178
+ // Extract @relation fields/references
179
+ const relationAttr = extractAttribute(tokens, "@relation");
180
+ if (relationAttr) {
181
+ const fields = extractArrayParam(relationAttr, "fields");
182
+ const references = extractArrayParam(relationAttr, "references");
183
+ if (fields && fields.length > 0) {
184
+ relation.foreignKey = fields[0];
185
+ }
186
+ if (!relation.foreignKey && references && references.length > 0) {
187
+ // fallback
188
+ }
189
+ }
190
+ return { kind: "relation", relation };
191
+ }
192
+ // It's a column
193
+ const column = {
194
+ name: fieldName,
195
+ type: rawType,
196
+ isId: false,
197
+ isRequired,
198
+ isUnique: false,
199
+ };
200
+ const restTokens = tokens.slice(2);
201
+ const restLine = restTokens.join(" ");
202
+ if (restLine.includes("@id")) {
203
+ column.isId = true;
204
+ }
205
+ if (restLine.includes("@unique")) {
206
+ column.isUnique = true;
207
+ }
208
+ const defaultMatch = /@default\(([^)]*)\)/.exec(restLine);
209
+ if (defaultMatch) {
210
+ column.default = defaultMatch[1];
211
+ }
212
+ const mapMatch = /@map\("([^"]*)"\)/.exec(restLine);
213
+ if (mapMatch) {
214
+ column.map = mapMatch[1];
215
+ }
216
+ return { kind: "column", column };
217
+ }
218
+ function tokenizeLine(line) {
219
+ const tokens = [];
220
+ let current = "";
221
+ let parenDepth = 0;
222
+ let inString = false;
223
+ let stringChar = "";
224
+ for (let i = 0; i < line.length; i++) {
225
+ const ch = line[i];
226
+ if (inString) {
227
+ current += ch;
228
+ if (ch === stringChar) {
229
+ inString = false;
230
+ }
231
+ continue;
232
+ }
233
+ if (ch === '"' || ch === "'") {
234
+ inString = true;
235
+ stringChar = ch;
236
+ current += ch;
237
+ continue;
238
+ }
239
+ if (ch === "(") {
240
+ parenDepth++;
241
+ current += ch;
242
+ continue;
243
+ }
244
+ if (ch === ")") {
245
+ parenDepth--;
246
+ current += ch;
247
+ continue;
248
+ }
249
+ if (/\s/.test(ch) && parenDepth === 0) {
250
+ if (current) {
251
+ tokens.push(current);
252
+ current = "";
253
+ }
254
+ continue;
255
+ }
256
+ current += ch;
257
+ }
258
+ if (current) {
259
+ tokens.push(current);
260
+ }
261
+ return tokens;
262
+ }
263
+ function extractAttribute(tokens, attrName) {
264
+ for (const token of tokens) {
265
+ if (token.startsWith(attrName + "(")) {
266
+ return token;
267
+ }
268
+ if (token === attrName) {
269
+ return token;
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+ function extractArrayParam(attrString, paramName) {
275
+ const pattern = new RegExp(`${paramName}:\\s*\\[([^\\]]*)]`);
276
+ const match = pattern.exec(attrString);
277
+ if (!match) {
278
+ return null;
279
+ }
280
+ return match[1]
281
+ .split(",")
282
+ .map((s) => s.trim())
283
+ .filter(Boolean);
284
+ }
package/dist/cli.js CHANGED
@@ -3,11 +3,23 @@ import { spawn } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { analyzeProject, diffGraphs } from "./index.js";
6
+ import { generateDescribeContext } from "./describe.js";
6
7
  import { captureScreenshots, generateParamsTemplate, } from "./screenshot.js";
7
8
  import { serve } from "./serve.js";
8
9
  import { readJsonFile, writeJsonFile } from "./utils.js";
9
10
  async function main() {
10
11
  const [commandOrArg, ...rest] = process.argv.slice(2);
12
+ if (commandOrArg === "describe") {
13
+ const options = parseDescribeArgs(rest);
14
+ const result = generateDescribeContext({
15
+ graphPath: path.resolve(options.graphPath),
16
+ outPath: path.resolve(options.outPath),
17
+ onlyMissing: !options.all,
18
+ });
19
+ console.log(`describe context written to ${options.outPath} (${result.nodeCount} nodes)`);
20
+ console.log(`Tell your AI: "Read ${options.outPath} and update the descriptions in ${options.graphPath}"`);
21
+ return;
22
+ }
11
23
  if (commandOrArg === "dev") {
12
24
  const options = parseDevArgs(rest);
13
25
  await runDev(options);
@@ -261,6 +273,34 @@ function parseScreenshotArgs(args) {
261
273
  }
262
274
  return { baseUrl, graphPath, outDir, paramsPath, generateParams };
263
275
  }
276
+ function parseDescribeArgs(args) {
277
+ let graphPath = "arch/graph.full.json";
278
+ let outPath = "arch/describe-context.md";
279
+ let all = false;
280
+ for (let index = 0; index < args.length; index += 1) {
281
+ const argument = args[index];
282
+ if (argument === "--graph" && args[index + 1]) {
283
+ graphPath = args[index + 1];
284
+ index += 1;
285
+ continue;
286
+ }
287
+ if (argument === "--out" && args[index + 1]) {
288
+ outPath = args[index + 1];
289
+ index += 1;
290
+ continue;
291
+ }
292
+ if (argument === "--all") {
293
+ all = true;
294
+ continue;
295
+ }
296
+ if (argument === "--help" || argument === "-h") {
297
+ printHelp();
298
+ process.exit(0);
299
+ }
300
+ throw new Error(`Unknown argument: ${argument}`);
301
+ }
302
+ return { graphPath, outPath, all };
303
+ }
264
304
  async function runDev(options) {
265
305
  const children = [];
266
306
  const cleanup = () => {
@@ -361,6 +401,7 @@ function logScreenshotSummary(result) {
361
401
  function printHelp() {
362
402
  console.log(`next-arch-map analyze [options]
363
403
  next-arch-map diff --before <path> --after <path> [--out <path>]
404
+ next-arch-map describe [options]
364
405
  next-arch-map screenshot [options]
365
406
  next-arch-map serve [options]
366
407
  next-arch-map dev [options]
@@ -375,6 +416,11 @@ Diff options:
375
416
  --after <path> Path to the updated graph JSON.
376
417
  --out <path> Output diff JSON path. Defaults to arch/graph.diff.json.
377
418
 
419
+ Describe options:
420
+ --graph <path> Path to graph JSON. Defaults to arch/graph.full.json.
421
+ --out <path> Output context file. Defaults to arch/describe-context.md.
422
+ --all Include all nodes, not just those missing descriptions.
423
+
378
424
  Screenshot options:
379
425
  --base-url <url> Base URL of the running app (e.g. http://localhost:3000).
380
426
  --graph <path> Path to graph JSON. Defaults to arch/graph.full.json.
@@ -0,0 +1,9 @@
1
+ export type DescribeOptions = {
2
+ graphPath: string;
3
+ outPath: string;
4
+ onlyMissing: boolean;
5
+ };
6
+ export declare function generateDescribeContext(options: DescribeOptions): {
7
+ contextPath: string;
8
+ nodeCount: number;
9
+ };
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureDirectory, readJsonFile } from "./utils.js";
4
+ function needsDescription(node, onlyMissing) {
5
+ if (!onlyMissing)
6
+ return true;
7
+ return !node.meta?.description;
8
+ }
9
+ function readSourceSafe(filePath) {
10
+ try {
11
+ if (fs.existsSync(filePath)) {
12
+ return fs.readFileSync(filePath, "utf8");
13
+ }
14
+ }
15
+ catch {
16
+ // ignore
17
+ }
18
+ return null;
19
+ }
20
+ export function generateDescribeContext(options) {
21
+ const graph = readJsonFile(options.graphPath);
22
+ const nodes = graph.nodes.filter((node) => needsDescription(node, options.onlyMissing));
23
+ const lines = [
24
+ "# Describe Architecture Graph Nodes",
25
+ "",
26
+ `Update the node descriptions in \`${options.graphPath}\`.`,
27
+ "",
28
+ "For **every** node listed below, read the source code and add two fields to its `meta` object:",
29
+ "",
30
+ "- `meta.description` — A single sentence (under 80 characters). Displayed inside the node box in the viewer.",
31
+ "- `meta.descriptionLong` — 2-3 sentences with more context. Shown in the details panel when clicking the node.",
32
+ "",
33
+ "## Style guide",
34
+ "",
35
+ "- Present tense, third person (\"Displays...\", \"Handles...\", \"Stores...\").",
36
+ "- Be specific — prefer \"Displays paginated user list with search\" over \"Shows users\".",
37
+ "- Don't repeat the node label — add information beyond what the name already tells you.",
38
+ "",
39
+ `## Nodes (${nodes.length})`,
40
+ "",
41
+ ];
42
+ for (const node of nodes) {
43
+ const filePath = node.meta?.filePath ? String(node.meta.filePath) : null;
44
+ const source = filePath ? readSourceSafe(filePath) : null;
45
+ lines.push(`### ${node.id}`);
46
+ lines.push("");
47
+ lines.push(`- **Type:** ${node.type}`);
48
+ lines.push(`- **Label:** ${node.label}`);
49
+ if (filePath) {
50
+ lines.push(`- **File:** ${filePath}`);
51
+ }
52
+ if (node.meta?.description) {
53
+ lines.push(`- **Current description:** ${String(node.meta.description)}`);
54
+ }
55
+ if (source) {
56
+ lines.push("");
57
+ lines.push("```typescript");
58
+ lines.push(source.trimEnd());
59
+ lines.push("```");
60
+ }
61
+ lines.push("");
62
+ }
63
+ ensureDirectory(path.dirname(options.outPath));
64
+ fs.writeFileSync(options.outPath, lines.join("\n"), "utf8");
65
+ return { contextPath: options.outPath, nodeCount: nodes.length };
66
+ }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export type { DiffStatus, EdgeDiff, GraphDiff, NodeDiff } from "./diff.js";
14
14
  export type { Edge, EdgeKind, Graph, Node, NodeType } from "./model.js";
15
15
  export { analyzePagesToEndpoints } from "./analyzers/pagesToEndpoints.js";
16
16
  export { analyzeEndpointsToDb } from "./analyzers/endpointsToDb.js";
17
+ export { analyzePrismaSchema } from "./analyzers/prismaSchema.js";
17
18
  export { mergeGraphs, mergePartial } from "./merge.js";
18
19
  export { getDbModelsForPage, getEndpointsForPage, getPagesForDbModel } from "./query.js";
20
+ export { generateDescribeContext } from "./describe.js";
19
21
  export { captureScreenshots, generateParamsTemplate } from "./screenshot.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { analyzeEndpointsToDb } from "./analyzers/endpointsToDb.js";
2
2
  import { analyzePagesToEndpoints } from "./analyzers/pagesToEndpoints.js";
3
+ import { analyzePrismaSchema } from "./analyzers/prismaSchema.js";
3
4
  import { mergePartial } from "./merge.js";
4
5
  export async function analyzeProject(options) {
5
6
  const pagesToEndpoints = await analyzePagesToEndpoints({
@@ -14,11 +15,14 @@ export async function analyzeProject(options) {
14
15
  apiDirs: options.apiDirs,
15
16
  dbClientIdentifiers: options.dbClientIdentifiers,
16
17
  });
17
- return mergePartial(pagesToEndpoints, endpointsToDb);
18
+ const prismaSchema = await analyzePrismaSchema(options.projectRoot);
19
+ return mergePartial(mergePartial(pagesToEndpoints, endpointsToDb), prismaSchema);
18
20
  }
19
21
  export { diffGraphs } from "./diff.js";
20
22
  export { analyzePagesToEndpoints } from "./analyzers/pagesToEndpoints.js";
21
23
  export { analyzeEndpointsToDb } from "./analyzers/endpointsToDb.js";
24
+ export { analyzePrismaSchema } from "./analyzers/prismaSchema.js";
22
25
  export { mergeGraphs, mergePartial } from "./merge.js";
23
26
  export { getDbModelsForPage, getEndpointsForPage, getPagesForDbModel } from "./query.js";
27
+ export { generateDescribeContext } from "./describe.js";
24
28
  export { captureScreenshots, generateParamsTemplate } from "./screenshot.js";
package/dist/model.d.ts CHANGED
@@ -5,7 +5,7 @@ export type Node = {
5
5
  label: string;
6
6
  meta?: Record<string, any>;
7
7
  };
8
- export type EdgeKind = "page-endpoint" | "endpoint-db" | "endpoint-handler" | "page-action" | "action-endpoint";
8
+ export type EdgeKind = "page-endpoint" | "endpoint-db" | "endpoint-handler" | "page-action" | "action-endpoint" | "db-relation";
9
9
  export type Edge = {
10
10
  from: string;
11
11
  to: string;
package/dist/serve.d.ts CHANGED
@@ -2,5 +2,6 @@ export type ServeOptions = {
2
2
  projectRoot: string;
3
3
  port?: number;
4
4
  appDirs?: string[];
5
+ graphPath?: string;
5
6
  };
6
7
  export declare function serve(options: ServeOptions): Promise<void>;
package/dist/serve.js CHANGED
@@ -1,16 +1,53 @@
1
+ import fs from "node:fs";
1
2
  import http from "node:http";
2
3
  import path from "node:path";
3
4
  import chokidar from "chokidar";
4
5
  import { analyzeProject } from "./index.js";
5
6
  import { diffGraphs } from "./diff.js";
6
7
  import { getDbModelsForPage, getEndpointsForPage, getPagesForDbModel } from "./query.js";
8
+ import { readJsonFile, writeJsonFile } from "./utils.js";
9
+ const PRESERVED_META_KEYS = ["description", "descriptionLong", "screenshot"];
10
+ function preserveMetaFields(freshGraph, existingGraph) {
11
+ const existingById = new Map();
12
+ for (const node of existingGraph.nodes) {
13
+ existingById.set(node.id, node);
14
+ }
15
+ for (const node of freshGraph.nodes) {
16
+ const existing = existingById.get(node.id);
17
+ if (!existing?.meta)
18
+ continue;
19
+ for (const key of PRESERVED_META_KEYS) {
20
+ if (key in existing.meta) {
21
+ if (!node.meta)
22
+ node.meta = {};
23
+ // Only carry forward if the fresh analysis didn't set it
24
+ if (!(key in node.meta)) {
25
+ node.meta[key] = existing.meta[key];
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
7
31
  export async function serve(options) {
8
32
  const projectRoot = path.resolve(options.projectRoot);
9
33
  const port = options.port ?? 4321;
34
+ const graphPath = path.resolve(projectRoot, options.graphPath ?? "arch/graph.full.json");
10
35
  let currentGraph = null;
11
36
  let previousGraph = null;
12
37
  let isAnalyzing = false;
13
38
  let rerunRequested = false;
39
+ let suppressGraphFileWatch = false;
40
+ function readExistingGraph() {
41
+ try {
42
+ if (fs.existsSync(graphPath)) {
43
+ return readJsonFile(graphPath);
44
+ }
45
+ }
46
+ catch {
47
+ // ignore corrupt file
48
+ }
49
+ return null;
50
+ }
14
51
  async function runAnalysis() {
15
52
  if (isAnalyzing) {
16
53
  rerunRequested = true;
@@ -22,8 +59,20 @@ export async function serve(options) {
22
59
  projectRoot,
23
60
  appDirs: options.appDirs,
24
61
  });
62
+ // Preserve descriptions/screenshots from the existing graph file
63
+ const existingGraph = readExistingGraph();
64
+ if (existingGraph) {
65
+ preserveMetaFields(nextGraph, existingGraph);
66
+ }
25
67
  previousGraph = currentGraph;
26
68
  currentGraph = nextGraph;
69
+ // Write to disk so AI tools and the CLI can read/edit it
70
+ suppressGraphFileWatch = true;
71
+ writeJsonFile(graphPath, currentGraph);
72
+ // Release suppression after a short delay for the watcher to settle
73
+ setTimeout(() => {
74
+ suppressGraphFileWatch = false;
75
+ }, 500);
27
76
  const pageCount = currentGraph.nodes.filter((node) => node.type === "page").length;
28
77
  const endpointCount = currentGraph.nodes.filter((node) => node.type === "endpoint").length;
29
78
  const handlerCount = currentGraph.nodes.filter((node) => node.type === "handler").length;
@@ -52,13 +101,26 @@ export async function serve(options) {
52
101
  }
53
102
  }
54
103
  await runAnalysis();
55
- const watcher = chokidar.watch(projectRoot, {
104
+ // Watch source files for re-analysis
105
+ const sourceWatcher = chokidar.watch(projectRoot, {
56
106
  ignored: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/arch/**"],
57
107
  ignoreInitial: true,
58
108
  });
59
- watcher.on("all", () => {
109
+ sourceWatcher.on("all", () => {
60
110
  void runAnalysis();
61
111
  });
112
+ // Watch the graph file for external edits (e.g. AI adding descriptions)
113
+ const graphWatcher = chokidar.watch(graphPath, { ignoreInitial: true });
114
+ graphWatcher.on("change", () => {
115
+ if (suppressGraphFileWatch)
116
+ return;
117
+ const updated = readExistingGraph();
118
+ if (updated) {
119
+ previousGraph = currentGraph;
120
+ currentGraph = updated;
121
+ console.log("mode=serve graph file updated externally, reloaded");
122
+ }
123
+ });
62
124
  const server = http.createServer((req, res) => {
63
125
  res.setHeader("Access-Control-Allow-Origin", "*");
64
126
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -144,7 +206,8 @@ export async function serve(options) {
144
206
  });
145
207
  });
146
208
  const close = async () => {
147
- await watcher.close();
209
+ await sourceWatcher.close();
210
+ await graphWatcher.close();
148
211
  await new Promise((resolve, reject) => {
149
212
  server.close((error) => {
150
213
  if (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-arch-map",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Static analyzer that builds a multi-layer architecture graph for Next.js-style apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@ const ALL_EDGE_KINDS: EdgeKind[] = [
15
15
  "page-endpoint",
16
16
  "endpoint-db",
17
17
  "endpoint-handler",
18
+ "db-relation",
18
19
  ];
19
20
 
20
21
 
@@ -498,6 +499,7 @@ function buildFocusedSubgraph(graph: Graph, route: string): Graph {
498
499
  "page-endpoint",
499
500
  "endpoint-handler",
500
501
  "endpoint-db",
502
+ "db-relation",
501
503
  ]);
502
504
  const reachableNodeIds = new Set<string>([pageId]);
503
505
  const worklist = [pageId];
@@ -44,6 +44,9 @@ const EDGE_COLOR: Record<EdgeKind, string> = {
44
44
  "page-endpoint": "#06b6d4",
45
45
  "endpoint-db": "#f97316",
46
46
  "endpoint-handler": "#22c55e",
47
+ "page-action": "#8b5cf6",
48
+ "action-endpoint": "#a855f7",
49
+ "db-relation": "#94a3b8",
47
50
  };
48
51
 
49
52
  const DIFF_BORDER_COLOR: Record<DiffStatus, string> = {
@@ -182,6 +185,102 @@ function PageNode({ data }: NodeProps) {
182
185
  );
183
186
  }
184
187
 
188
+ type DbColumn = {
189
+ name: string;
190
+ type: string;
191
+ isId?: boolean;
192
+ isRequired?: boolean;
193
+ isUnique?: boolean;
194
+ default?: string;
195
+ };
196
+
197
+ function DbTableNode({ data }: NodeProps) {
198
+ const d = data as Record<string, unknown>;
199
+ const label = String(d.label ?? "");
200
+ const columns = (d.columns as DbColumn[] | undefined) ?? [];
201
+
202
+ return (
203
+ <div
204
+ style={{
205
+ background: "#ffffff",
206
+ border: "1px solid #cbd5e1",
207
+ borderRadius: 6,
208
+ overflow: "hidden",
209
+ fontFamily: "'Inter', -apple-system, sans-serif",
210
+ fontSize: 11,
211
+ minWidth: 220,
212
+ }}
213
+ >
214
+ <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
215
+ {/* Header */}
216
+ <div
217
+ style={{
218
+ background: "#991b1b",
219
+ color: "#ffffff",
220
+ padding: "6px 10px",
221
+ fontWeight: 700,
222
+ fontSize: 12,
223
+ letterSpacing: "0.01em",
224
+ }}
225
+ >
226
+ {label}
227
+ </div>
228
+ {/* Columns */}
229
+ {columns.map((col, i) => (
230
+ <div
231
+ key={col.name}
232
+ style={{
233
+ display: "flex",
234
+ alignItems: "center",
235
+ gap: 6,
236
+ padding: "3px 10px",
237
+ borderTop: i === 0 ? "none" : "1px solid #f1f5f9",
238
+ color: "#334155",
239
+ }}
240
+ >
241
+ {col.isId && (
242
+ <span style={{ fontSize: 10, color: "#eab308" }} title="Primary Key">
243
+ 🔑
244
+ </span>
245
+ )}
246
+ {!col.isId && col.isUnique && (
247
+ <span style={{ fontSize: 9, color: "#94a3b8" }} title="Unique">
248
+ U
249
+ </span>
250
+ )}
251
+ {!col.isId && !col.isUnique && (
252
+ <span style={{ width: 12, display: "inline-block" }} />
253
+ )}
254
+ <span style={{ fontWeight: 500, flex: 1 }}>{col.name}</span>
255
+ <span
256
+ style={{
257
+ fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
258
+ fontSize: 10,
259
+ color: "#64748b",
260
+ }}
261
+ >
262
+ {col.type}
263
+ </span>
264
+ {col.isRequired === false && (
265
+ <span
266
+ style={{
267
+ fontSize: 9,
268
+ background: "#f1f5f9",
269
+ color: "#94a3b8",
270
+ borderRadius: 3,
271
+ padding: "0 3px",
272
+ }}
273
+ >
274
+ ?
275
+ </span>
276
+ )}
277
+ </div>
278
+ ))}
279
+ <Handle type="source" position={Position.Right} style={{ visibility: "hidden" }} />
280
+ </div>
281
+ );
282
+ }
283
+
185
284
  function DescribedNode({ data }: NodeProps) {
186
285
  const d = data as Record<string, unknown>;
187
286
  const description = d.description as string | undefined;
@@ -201,6 +300,7 @@ function DescribedNode({ data }: NodeProps) {
201
300
  const nodeTypes = {
202
301
  pageNode: PageNode,
203
302
  describedNode: DescribedNode,
303
+ dbTableNode: DbTableNode,
204
304
  };
205
305
 
206
306
  export function GraphView(props: GraphViewProps) {
@@ -263,9 +363,17 @@ export function GraphView(props: GraphViewProps) {
263
363
  );
264
364
  const pageRowHeight = hasScreenshots ? 140 : defaultRowHeight;
265
365
 
366
+ // Compute max db column count for row height
367
+ const dbNodes = nodesByType.get("db") ?? [];
368
+ const maxDbColumns = dbNodes.reduce((max, n) => {
369
+ const cols = Array.isArray(n.meta?.columns) ? (n.meta?.columns as unknown[]).length : 0;
370
+ return Math.max(max, cols);
371
+ }, 0);
372
+ const dbRowHeight = maxDbColumns > 0 ? Math.max(defaultRowHeight, 30 + maxDbColumns * 22) : defaultRowHeight;
373
+
266
374
  activeTypeOrder.forEach((type, columnIndex) => {
267
375
  const nodes = nodesByType.get(type) ?? [];
268
- const rowHeight = type === "page" ? pageRowHeight : defaultRowHeight;
376
+ const rowHeight = type === "page" ? pageRowHeight : type === "db" ? dbRowHeight : defaultRowHeight;
269
377
  nodes.forEach((node, rowIndex) => {
270
378
  const isSelected = node.id === selectedNodeId;
271
379
  const status = nodeStatusById?.get(node.id) ?? "unchanged";
@@ -275,12 +383,18 @@ export function GraphView(props: GraphViewProps) {
275
383
  const screenshot = isPage ? (node.meta?.screenshot as string | undefined) : undefined;
276
384
  const description = node.meta?.description as string | undefined;
277
385
  const isDarkText = false;
386
+ const isDbTable = node.type === "db" && Array.isArray(node.meta?.columns) && (node.meta?.columns as unknown[]).length > 0;
387
+ const dbColumns = isDbTable ? (node.meta?.columns as DbColumn[]) : undefined;
388
+
389
+ const nodeType = isDbTable
390
+ ? "dbTableNode"
391
+ : isPage
392
+ ? "pageNode"
393
+ : description
394
+ ? "describedNode"
395
+ : undefined;
278
396
 
279
- const nodeType = isPage
280
- ? "pageNode"
281
- : description
282
- ? "describedNode"
283
- : undefined;
397
+ const nodeWidth = isDbTable ? 240 : 200;
284
398
 
285
399
  flowNodes.push({
286
400
  id: node.id,
@@ -290,6 +404,7 @@ export function GraphView(props: GraphViewProps) {
290
404
  ...(screenshot ? { screenshot } : {}),
291
405
  ...(description ? { description } : {}),
292
406
  ...(isDarkText ? { dark: true } : {}),
407
+ ...(dbColumns ? { columns: dbColumns } : {}),
293
408
  },
294
409
  position: {
295
410
  x: 80 + columnIndex * columnWidth,
@@ -298,27 +413,40 @@ export function GraphView(props: GraphViewProps) {
298
413
  sourcePosition: Position.Right,
299
414
  targetPosition: Position.Left,
300
415
  selectable: true,
301
- style: {
302
- width: 200,
303
- borderRadius: 12,
304
- border: `2px ${borderStyle} ${borderColor}`,
305
- padding: "10px 14px",
306
- background: NODE_COLOR[node.type],
307
- color: "#ffffff",
308
- fontSize: 12,
309
- fontWeight: 600,
310
- fontFamily: "'Inter', -apple-system, sans-serif",
311
- letterSpacing: "-0.01em",
312
- opacity: status === "removed" ? 0.65 : 1,
313
- boxShadow: isSelected
314
- ? `0 0 0 3px rgba(30, 41, 59, 0.15), 0 8px 24px rgba(0, 0, 0, 0.12)`
315
- : status === "added"
316
- ? `0 0 0 3px rgba(34, 197, 94, 0.2)`
317
- : status === "removed"
318
- ? `0 0 0 3px rgba(239, 68, 68, 0.15)`
416
+ draggable: isDbTable,
417
+ style: isDbTable
418
+ ? {
419
+ width: nodeWidth,
420
+ borderRadius: 6,
421
+ padding: 0,
422
+ background: "transparent",
423
+ opacity: status === "removed" ? 0.65 : 1,
424
+ boxShadow: isSelected
425
+ ? `0 0 0 3px rgba(30, 41, 59, 0.15), 0 8px 24px rgba(0, 0, 0, 0.12)`
319
426
  : `0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)`,
320
- transition: "opacity 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease",
321
- },
427
+ transition: "opacity 0.15s ease, box-shadow 0.15s ease",
428
+ }
429
+ : {
430
+ width: nodeWidth,
431
+ borderRadius: 12,
432
+ border: `2px ${borderStyle} ${borderColor}`,
433
+ padding: "10px 14px",
434
+ background: NODE_COLOR[node.type],
435
+ color: "#ffffff",
436
+ fontSize: 12,
437
+ fontWeight: 600,
438
+ fontFamily: "'Inter', -apple-system, sans-serif",
439
+ letterSpacing: "-0.01em",
440
+ opacity: status === "removed" ? 0.65 : 1,
441
+ boxShadow: isSelected
442
+ ? `0 0 0 3px rgba(30, 41, 59, 0.15), 0 8px 24px rgba(0, 0, 0, 0.12)`
443
+ : status === "added"
444
+ ? `0 0 0 3px rgba(34, 197, 94, 0.2)`
445
+ : status === "removed"
446
+ ? `0 0 0 3px rgba(239, 68, 68, 0.15)`
447
+ : `0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)`,
448
+ transition: "opacity 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease",
449
+ },
322
450
  });
323
451
  });
324
452
  });
@@ -423,7 +551,7 @@ export function GraphView(props: GraphViewProps) {
423
551
  onNodeMouseEnter={handleNodeMouseEnter}
424
552
  onNodeMouseLeave={handleNodeMouseLeave}
425
553
  onPaneClick={() => onSelectNode(null)}
426
- nodesDraggable={false}
554
+ nodesDraggable
427
555
  nodesConnectable={false}
428
556
  elementsSelectable
429
557
  proOptions={{ hideAttribution: true }}
@@ -16,7 +16,8 @@ export type EdgeKind =
16
16
  | "endpoint-db"
17
17
  | "endpoint-handler"
18
18
  | "page-action"
19
- | "action-endpoint";
19
+ | "action-endpoint"
20
+ | "db-relation";
20
21
 
21
22
  export type Edge = {
22
23
  from: string;