next-arch-map 0.1.29 → 0.1.30

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.
@@ -75,9 +75,7 @@ export async function analyzePrismaSchema(projectRoot) {
75
75
  }
76
76
  return {
77
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)),
78
+ edges: edges.sort((a, b) => a.kind.localeCompare(b.kind) || a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
81
79
  };
82
80
  }
83
81
  function parseEnumNames(content) {
@@ -205,9 +203,13 @@ function parseField(line, modelNames, enumNames) {
205
203
  if (restLine.includes("@unique")) {
206
204
  column.isUnique = true;
207
205
  }
208
- const defaultMatch = /@default\(([^)]*)\)/.exec(restLine);
209
- if (defaultMatch) {
210
- column.default = defaultMatch[1];
206
+ const defaultAttr = extractAttribute(restTokens, "@default");
207
+ if (defaultAttr) {
208
+ const innerStart = "@default(".length;
209
+ const innerEnd = defaultAttr.lastIndexOf(")");
210
+ if (innerEnd > innerStart) {
211
+ column.default = defaultAttr.slice(innerStart, innerEnd);
212
+ }
211
213
  }
212
214
  const mapMatch = /@map\("([^"]*)"\)/.exec(restLine);
213
215
  if (mapMatch) {
package/dist/cli.js CHANGED
@@ -4,9 +4,164 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { analyzeProject, diffGraphs } from "./index.js";
6
6
  import { generateDescribeContext } from "./describe.js";
7
- import { captureScreenshots, generateParamsTemplate, } from "./screenshot.js";
7
+ import { captureScreenshots, generateParamsTemplate } from "./screenshot.js";
8
8
  import { serve } from "./serve.js";
9
9
  import { readJsonFile, writeJsonFile } from "./utils.js";
10
+ function parseArgs(args, defs) {
11
+ const result = {};
12
+ for (const def of Object.values(defs)) {
13
+ if (def.type === "string[]") {
14
+ result[def.key] = [];
15
+ }
16
+ else if (def.type === "boolean") {
17
+ result[def.key] = false;
18
+ }
19
+ else if ("default" in def && def.default !== undefined) {
20
+ result[def.key] = def.default;
21
+ }
22
+ else {
23
+ result[def.key] = def.type === "number" ? 0 : "";
24
+ }
25
+ }
26
+ for (let i = 0; i < args.length; i += 1) {
27
+ const flag = args[i];
28
+ if (flag === "--help" || flag === "-h") {
29
+ printHelp();
30
+ process.exit(0);
31
+ }
32
+ const def = defs[flag];
33
+ if (!def) {
34
+ throw new Error(`Unknown argument: ${flag}`);
35
+ }
36
+ if (def.type === "boolean") {
37
+ result[def.key] = true;
38
+ continue;
39
+ }
40
+ const value = args[i + 1];
41
+ if (!value) {
42
+ throw new Error(`Missing value for ${flag}`);
43
+ }
44
+ i += 1;
45
+ if (def.type === "number") {
46
+ const num = Number(value);
47
+ if (!Number.isInteger(num) || num <= 0) {
48
+ throw new Error(`Invalid ${flag}: ${value}`);
49
+ }
50
+ result[def.key] = num;
51
+ }
52
+ else if (def.type === "string[]") {
53
+ result[def.key].push(value);
54
+ }
55
+ else {
56
+ result[def.key] = value;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Command-specific argument wrappers
63
+ // ---------------------------------------------------------------------------
64
+ function parseAnalyzeArgs(args) {
65
+ const raw = parseArgs(args, {
66
+ "--project-root": { key: "projectRoot", type: "string", default: process.cwd() },
67
+ "--out": { key: "out", type: "string", default: "arch/graph.full.json" },
68
+ "--app-dir": { key: "appDirs", type: "string[]" },
69
+ });
70
+ const appDirs = raw.appDirs;
71
+ return {
72
+ projectRoot: path.resolve(raw.projectRoot),
73
+ out: raw.out,
74
+ appDirs: appDirs.length > 0 ? appDirs : undefined,
75
+ };
76
+ }
77
+ function parseDiffArgs(args) {
78
+ const raw = parseArgs(args, {
79
+ "--before": { key: "beforePath", type: "string" },
80
+ "--after": { key: "afterPath", type: "string" },
81
+ "--out": { key: "out", type: "string", default: "arch/graph.diff.json" },
82
+ });
83
+ if (!raw.beforePath || !raw.afterPath) {
84
+ throw new Error("The diff command requires --before <path> and --after <path>.");
85
+ }
86
+ return {
87
+ beforePath: raw.beforePath,
88
+ afterPath: raw.afterPath,
89
+ out: raw.out,
90
+ };
91
+ }
92
+ function parseServeArgs(args) {
93
+ const raw = parseArgs(args, {
94
+ "--project-root": { key: "projectRoot", type: "string", default: process.cwd() },
95
+ "--port": { key: "port", type: "number", default: 4321 },
96
+ "--app-dir": { key: "appDirs", type: "string[]" },
97
+ });
98
+ const appDirs = raw.appDirs;
99
+ return {
100
+ projectRoot: path.resolve(raw.projectRoot),
101
+ port: raw.port,
102
+ appDirs: appDirs.length > 0 ? appDirs : undefined,
103
+ };
104
+ }
105
+ function parseDevArgs(args) {
106
+ const raw = parseArgs(args, {
107
+ "--project-root": { key: "projectRoot", type: "string", default: process.cwd() },
108
+ "--port": { key: "port", type: "number", default: 4321 },
109
+ "--viewer-dir": { key: "viewerDir", type: "string" },
110
+ "--app-dir": { key: "appDirs", type: "string[]" },
111
+ });
112
+ let viewerDir = raw.viewerDir;
113
+ if (!viewerDir) {
114
+ const packageDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
115
+ const builtinViewer = path.join(packageDir, "viewer");
116
+ if (fs.existsSync(path.join(builtinViewer, "package.json"))) {
117
+ viewerDir = builtinViewer;
118
+ }
119
+ if (!viewerDir) {
120
+ throw new Error("Could not find viewer directory. Provide --viewer-dir <path> pointing to the viewer app.");
121
+ }
122
+ }
123
+ else {
124
+ viewerDir = path.resolve(viewerDir);
125
+ }
126
+ const appDirs = raw.appDirs;
127
+ return {
128
+ projectRoot: path.resolve(raw.projectRoot),
129
+ port: raw.port,
130
+ viewerDir,
131
+ appDirs: appDirs.length > 0 ? appDirs : undefined,
132
+ };
133
+ }
134
+ function parseScreenshotArgs(args) {
135
+ const raw = parseArgs(args, {
136
+ "--base-url": { key: "baseUrl", type: "string" },
137
+ "--graph": { key: "graphPath", type: "string", default: "arch/graph.full.json" },
138
+ "--out-dir": { key: "outDir", type: "string", default: "arch/screenshots" },
139
+ "--params": { key: "paramsPath", type: "string", default: "arch/screenshot-params.json" },
140
+ "--generate-params": { key: "generateParams", type: "boolean" },
141
+ });
142
+ return {
143
+ baseUrl: raw.baseUrl,
144
+ graphPath: raw.graphPath,
145
+ outDir: raw.outDir,
146
+ paramsPath: raw.paramsPath,
147
+ generateParams: raw.generateParams,
148
+ };
149
+ }
150
+ function parseDescribeArgs(args) {
151
+ const raw = parseArgs(args, {
152
+ "--graph": { key: "graphPath", type: "string", default: "arch/graph.full.json" },
153
+ "--out": { key: "outPath", type: "string", default: "arch/describe-context.md" },
154
+ "--all": { key: "all", type: "boolean" },
155
+ });
156
+ return {
157
+ graphPath: raw.graphPath,
158
+ outPath: raw.outPath,
159
+ all: raw.all,
160
+ };
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Commands
164
+ // ---------------------------------------------------------------------------
10
165
  async function main() {
11
166
  const [commandOrArg, ...rest] = process.argv.slice(2);
12
167
  if (commandOrArg === "describe") {
@@ -74,233 +229,6 @@ async function main() {
74
229
  writeJsonFile(outputFile, graph);
75
230
  logAnalyzeSummary(graph, outputFile, options.projectRoot);
76
231
  }
77
- function parseAnalyzeArgs(args) {
78
- let projectRoot = process.cwd();
79
- let out = "arch/graph.full.json";
80
- const appDirs = [];
81
- for (let index = 0; index < args.length; index += 1) {
82
- const argument = args[index];
83
- if (argument === "--project-root" && args[index + 1]) {
84
- projectRoot = path.resolve(args[index + 1]);
85
- index += 1;
86
- continue;
87
- }
88
- if (argument === "--out" && args[index + 1]) {
89
- out = args[index + 1];
90
- index += 1;
91
- continue;
92
- }
93
- if (argument === "--app-dir" && args[index + 1]) {
94
- appDirs.push(args[index + 1]);
95
- index += 1;
96
- continue;
97
- }
98
- if (argument === "--help" || argument === "-h") {
99
- printHelp();
100
- process.exit(0);
101
- }
102
- throw new Error(`Unknown argument: ${argument}`);
103
- }
104
- return {
105
- projectRoot,
106
- out,
107
- appDirs: appDirs.length > 0 ? appDirs : undefined,
108
- };
109
- }
110
- function parseDiffArgs(args) {
111
- let beforePath = "";
112
- let afterPath = "";
113
- let out = "arch/graph.diff.json";
114
- for (let index = 0; index < args.length; index += 1) {
115
- const argument = args[index];
116
- if (argument === "--before" && args[index + 1]) {
117
- beforePath = args[index + 1];
118
- index += 1;
119
- continue;
120
- }
121
- if (argument === "--after" && args[index + 1]) {
122
- afterPath = args[index + 1];
123
- index += 1;
124
- continue;
125
- }
126
- if (argument === "--out" && args[index + 1]) {
127
- out = args[index + 1];
128
- index += 1;
129
- continue;
130
- }
131
- if (argument === "--help" || argument === "-h") {
132
- printHelp();
133
- process.exit(0);
134
- }
135
- throw new Error(`Unknown argument: ${argument}`);
136
- }
137
- if (!beforePath || !afterPath) {
138
- throw new Error("The diff command requires --before <path> and --after <path>.");
139
- }
140
- return {
141
- beforePath,
142
- afterPath,
143
- out,
144
- };
145
- }
146
- function parseServeArgs(args) {
147
- let projectRoot = process.cwd();
148
- let port = 4321;
149
- const appDirs = [];
150
- for (let index = 0; index < args.length; index += 1) {
151
- const argument = args[index];
152
- if (argument === "--project-root" && args[index + 1]) {
153
- projectRoot = path.resolve(args[index + 1]);
154
- index += 1;
155
- continue;
156
- }
157
- if (argument === "--port" && args[index + 1]) {
158
- port = Number(args[index + 1]);
159
- if (!Number.isInteger(port) || port <= 0) {
160
- throw new Error(`Invalid port: ${args[index + 1]}`);
161
- }
162
- index += 1;
163
- continue;
164
- }
165
- if (argument === "--app-dir" && args[index + 1]) {
166
- appDirs.push(args[index + 1]);
167
- index += 1;
168
- continue;
169
- }
170
- if (argument === "--help" || argument === "-h") {
171
- printHelp();
172
- process.exit(0);
173
- }
174
- throw new Error(`Unknown argument: ${argument}`);
175
- }
176
- return {
177
- projectRoot,
178
- port,
179
- appDirs: appDirs.length > 0 ? appDirs : undefined,
180
- };
181
- }
182
- function parseDevArgs(args) {
183
- let projectRoot = process.cwd();
184
- let port = 4321;
185
- let viewerDir = "";
186
- const appDirs = [];
187
- for (let index = 0; index < args.length; index += 1) {
188
- const argument = args[index];
189
- if (argument === "--project-root" && args[index + 1]) {
190
- projectRoot = path.resolve(args[index + 1]);
191
- index += 1;
192
- continue;
193
- }
194
- if (argument === "--port" && args[index + 1]) {
195
- port = Number(args[index + 1]);
196
- if (!Number.isInteger(port) || port <= 0) {
197
- throw new Error(`Invalid port: ${args[index + 1]}`);
198
- }
199
- index += 1;
200
- continue;
201
- }
202
- if (argument === "--viewer-dir" && args[index + 1]) {
203
- viewerDir = path.resolve(args[index + 1]);
204
- index += 1;
205
- continue;
206
- }
207
- if (argument === "--app-dir" && args[index + 1]) {
208
- appDirs.push(args[index + 1]);
209
- index += 1;
210
- continue;
211
- }
212
- if (argument === "--help" || argument === "-h") {
213
- printHelp();
214
- process.exit(0);
215
- }
216
- throw new Error(`Unknown argument: ${argument}`);
217
- }
218
- if (!viewerDir) {
219
- // Try to find the viewer relative to the analyzer package
220
- const packageDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
221
- const builtinViewer = path.join(packageDir, "viewer");
222
- if (fs.existsSync(path.join(builtinViewer, "package.json"))) {
223
- viewerDir = builtinViewer;
224
- }
225
- if (!viewerDir) {
226
- throw new Error("Could not find viewer directory. Provide --viewer-dir <path> pointing to the viewer app.");
227
- }
228
- }
229
- return {
230
- projectRoot,
231
- port,
232
- viewerDir,
233
- appDirs: appDirs.length > 0 ? appDirs : undefined,
234
- };
235
- }
236
- function parseScreenshotArgs(args) {
237
- let baseUrl = "";
238
- let graphPath = "arch/graph.full.json";
239
- let outDir = "arch/screenshots";
240
- let paramsPath = "arch/screenshot-params.json";
241
- let generateParams = false;
242
- for (let index = 0; index < args.length; index += 1) {
243
- const argument = args[index];
244
- if (argument === "--base-url" && args[index + 1]) {
245
- baseUrl = args[index + 1];
246
- index += 1;
247
- continue;
248
- }
249
- if (argument === "--graph" && args[index + 1]) {
250
- graphPath = args[index + 1];
251
- index += 1;
252
- continue;
253
- }
254
- if (argument === "--out-dir" && args[index + 1]) {
255
- outDir = args[index + 1];
256
- index += 1;
257
- continue;
258
- }
259
- if (argument === "--params" && args[index + 1]) {
260
- paramsPath = args[index + 1];
261
- index += 1;
262
- continue;
263
- }
264
- if (argument === "--generate-params") {
265
- generateParams = true;
266
- continue;
267
- }
268
- if (argument === "--help" || argument === "-h") {
269
- printHelp();
270
- process.exit(0);
271
- }
272
- throw new Error(`Unknown argument: ${argument}`);
273
- }
274
- return { baseUrl, graphPath, outDir, paramsPath, generateParams };
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
- }
304
232
  async function runDev(options) {
305
233
  const children = [];
306
234
  const cleanup = () => {
@@ -365,6 +293,9 @@ async function runDev(options) {
365
293
  // Keep the process alive
366
294
  await new Promise(() => { });
367
295
  }
296
+ // ---------------------------------------------------------------------------
297
+ // Output helpers
298
+ // ---------------------------------------------------------------------------
368
299
  function logAnalyzeSummary(graph, outputFile, projectRoot) {
369
300
  const pageCount = graph.nodes.filter((node) => node.type === "page").length;
370
301
  const endpointCount = graph.nodes.filter((node) => node.type === "endpoint").length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-arch-map",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Static analyzer that builds a multi-layer architecture graph for Next.js-style apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,15 +3,17 @@ import * as Switch from "@radix-ui/react-switch";
3
3
  import { Filters } from "./Filters";
4
4
  import { GraphView } from "./GraphView";
5
5
  import { NodeDetails } from "./NodeDetails";
6
- import type { DiffStatus, EdgeKind, Graph, GraphDiff, Node, NodeType } from "./types";
7
-
8
- const ALL_NODE_TYPES: NodeType[] = [
9
- "page",
10
- "endpoint",
11
- "handler",
12
- "db",
13
- "service",
14
- ];
6
+ import {
7
+ buildEdgeKey,
8
+ type DiffStatus,
9
+ type EdgeKind,
10
+ type Graph,
11
+ type GraphDiff,
12
+ type Node,
13
+ type NodeType,
14
+ } from "./types";
15
+
16
+ const ALL_NODE_TYPES: NodeType[] = ["page", "endpoint", "handler", "db", "service"];
15
17
  const ALL_EDGE_KINDS: EdgeKind[] = [
16
18
  "page-endpoint",
17
19
  "page-service",
@@ -20,7 +22,6 @@ const ALL_EDGE_KINDS: EdgeKind[] = [
20
22
  "db-relation",
21
23
  ];
22
24
 
23
-
24
25
  function isRecord(value: unknown): value is Record<string, unknown> {
25
26
  return !!value && typeof value === "object";
26
27
  }
@@ -48,10 +49,6 @@ function isGraphDiff(value: unknown): value is GraphDiff {
48
49
  );
49
50
  }
50
51
 
51
- function buildEdgeKey(from: string, to: string, kind: EdgeKind): string {
52
- return `${from}::${to}::${kind}`;
53
- }
54
-
55
52
  export function App() {
56
53
  const [graph, setGraph] = useState<Graph | null>(null);
57
54
  const [graphDiff, setGraphDiff] = useState<GraphDiff | null>(null);
@@ -234,11 +231,8 @@ export function App() {
234
231
  const baseGraph = getRenderedGraph(graph, graphDiff);
235
232
  const pageRoutes = getPageRoutes(baseGraph);
236
233
  const renderedGraph =
237
- focusedPageRoute && baseGraph
238
- ? buildFocusedSubgraph(baseGraph, focusedPageRoute)
239
- : baseGraph;
240
- const selectedNode =
241
- renderedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null;
234
+ focusedPageRoute && baseGraph ? buildFocusedSubgraph(baseGraph, focusedPageRoute) : baseGraph;
235
+ const selectedNode = renderedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null;
242
236
  const nodeStatusById = graphDiff ? buildNodeStatusById(graphDiff) : undefined;
243
237
  const edgeStatusByKey = graphDiff ? buildEdgeStatusByKey(graphDiff) : undefined;
244
238
 
@@ -263,12 +257,8 @@ export function App() {
263
257
  <div className="p-5 space-y-5">
264
258
  {/* Header */}
265
259
  <div>
266
- <h1 className="text-base font-bold text-slate-900 tracking-tight">
267
- next-arch-map
268
- </h1>
269
- <p className="text-[11px] text-slate-400 mt-0.5">
270
- Architecture graph viewer
271
- </p>
260
+ <h1 className="text-base font-bold text-slate-900 tracking-tight">next-arch-map</h1>
261
+ <p className="text-[11px] text-slate-400 mt-0.5">Architecture graph viewer</p>
272
262
  </div>
273
263
 
274
264
  {/* Status badge */}
@@ -280,11 +270,7 @@ export function App() {
280
270
  </span>
281
271
  <span className="text-xs text-slate-500">
282
272
  {nodeCount} nodes, {edgeCount} edges
283
- {graphDiff && (
284
- <span className="ml-1 text-emerald-600 font-medium">
285
- (diff)
286
- </span>
287
- )}
273
+ {graphDiff && <span className="ml-1 text-emerald-600 font-medium">(diff)</span>}
288
274
  </span>
289
275
  </div>
290
276
  ) : loadError ? (
@@ -337,9 +323,7 @@ export function App() {
337
323
  )}
338
324
  {!useServer && (
339
325
  <div>
340
- <label className="text-[11px] text-slate-500 block mb-1.5">
341
- Load graph JSON
342
- </label>
326
+ <label className="text-[11px] text-slate-500 block mb-1.5">Load graph JSON</label>
343
327
  <input
344
328
  type="file"
345
329
  accept="application/json,.json"
@@ -404,9 +388,7 @@ export function App() {
404
388
  {isQueryLoading ? "Loading..." : "Query"}
405
389
  </button>
406
390
 
407
- {queryError && (
408
- <div className="text-xs text-red-600">{queryError}</div>
409
- )}
391
+ {queryError && <div className="text-xs text-red-600">{queryError}</div>}
410
392
 
411
393
  {queryResult && (
412
394
  <div className="text-xs text-slate-600">
@@ -443,8 +425,18 @@ export function App() {
443
425
  <div className="h-full flex items-center justify-center">
444
426
  <div className="text-center space-y-3">
445
427
  <div className="text-4xl opacity-20">
446
- <svg className="mx-auto h-12 w-12 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
447
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
428
+ <svg
429
+ className="mx-auto h-12 w-12 text-slate-300"
430
+ fill="none"
431
+ viewBox="0 0 24 24"
432
+ stroke="currentColor"
433
+ >
434
+ <path
435
+ strokeLinecap="round"
436
+ strokeLinejoin="round"
437
+ strokeWidth={1.5}
438
+ d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
439
+ />
448
440
  </svg>
449
441
  </div>
450
442
  <p className="text-sm text-slate-400">
@@ -16,6 +16,7 @@ const NODE_TYPE_COLORS: Record<NodeType, string> = {
16
16
  endpoint: "bg-emerald-600",
17
17
  handler: "bg-teal-500",
18
18
  db: "bg-red-600",
19
+ action: "bg-purple-500",
19
20
  service: "bg-violet-600",
20
21
  };
21
22
 
@@ -38,10 +39,7 @@ export function Filters(props: FiltersProps) {
38
39
  </h3>
39
40
  <div className="space-y-1.5">
40
41
  {allNodeTypes.map((type) => (
41
- <label
42
- key={type}
43
- className="flex items-center gap-2 cursor-pointer group"
44
- >
42
+ <label key={type} className="flex items-center gap-2 cursor-pointer group">
45
43
  <Checkbox.Root
46
44
  checked={visibleNodeTypes.has(type)}
47
45
  onCheckedChange={() => onToggleNodeType(type)}
@@ -59,9 +57,7 @@ export function Filters(props: FiltersProps) {
59
57
  </svg>
60
58
  </Checkbox.Indicator>
61
59
  </Checkbox.Root>
62
- <span
63
- className={`h-2.5 w-2.5 rounded-full ${NODE_TYPE_COLORS[type]}`}
64
- />
60
+ <span className={`h-2.5 w-2.5 rounded-full ${NODE_TYPE_COLORS[type]}`} />
65
61
  <span className="text-xs text-slate-600 group-hover:text-slate-900 transition-colors">
66
62
  {type}
67
63
  </span>
@@ -77,10 +73,7 @@ export function Filters(props: FiltersProps) {
77
73
  </h3>
78
74
  <div className="space-y-1.5">
79
75
  {allEdgeKinds.map((kind) => (
80
- <label
81
- key={kind}
82
- className="flex items-center gap-2 cursor-pointer group"
83
- >
76
+ <label key={kind} className="flex items-center gap-2 cursor-pointer group">
84
77
  <Checkbox.Root
85
78
  checked={visibleEdgeKinds.has(kind)}
86
79
  onCheckedChange={() => onToggleEdgeKind(kind)}
@@ -13,7 +13,7 @@ import {
13
13
  type NodeProps,
14
14
  } from "@xyflow/react";
15
15
  import "@xyflow/react/dist/style.css";
16
- import type { DiffStatus, EdgeKind, Graph, NodeType } from "./types";
16
+ import { buildEdgeKey, type DiffStatus, type EdgeKind, type Graph, type NodeType } from "./types";
17
17
 
18
18
  type GraphViewProps = {
19
19
  graph: Graph;
@@ -30,6 +30,7 @@ const NODE_COLOR: Record<NodeType, string> = {
30
30
  endpoint: "#059669",
31
31
  db: "#dc2626",
32
32
  handler: "#14b8a6",
33
+ action: "#8b5cf6",
33
34
  service: "#7c3aed",
34
35
  };
35
36
 
@@ -46,19 +47,17 @@ const EDGE_COLOR: Record<EdgeKind, string> = {
46
47
  const DIFF_BORDER_COLOR: Record<DiffStatus, string> = {
47
48
  added: "#22c55e",
48
49
  removed: "#ef4444",
50
+ modified: "#f59e0b",
49
51
  unchanged: "rgba(15, 23, 42, 0.12)",
50
52
  };
51
53
 
52
54
  const DIFF_EDGE_COLOR: Record<DiffStatus, string> = {
53
55
  added: "#22c55e",
54
56
  removed: "#ef4444",
57
+ modified: "#f59e0b",
55
58
  unchanged: "#000000",
56
59
  };
57
60
 
58
- function buildEdgeKey(from: string, to: string, kind: EdgeKind): string {
59
- return `${from}::${to}::${kind}`;
60
- }
61
-
62
61
  /**
63
62
  * Reorder nodes within each column so that connected nodes are placed
64
63
  * close together vertically, minimizing long diagonal edge crossings.
@@ -156,9 +155,7 @@ function PageNode({ data }: NodeProps) {
156
155
  return (
157
156
  <div>
158
157
  <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
159
- <div style={{ fontSize: 12, fontWeight: 600 }}>
160
- {String(d.label ?? "")}
161
- </div>
158
+ <div style={{ fontSize: 12, fontWeight: 600 }}>{String(d.label ?? "")}</div>
162
159
  {description && <DescriptionLine text={description} />}
163
160
  {screenshot && (
164
161
  <img
@@ -240,9 +237,7 @@ function DbTableNode({ data }: NodeProps) {
240
237
  U
241
238
  </span>
242
239
  )}
243
- {!col.isId && !col.isUnique && (
244
- <span style={{ width: 12, display: "inline-block" }} />
245
- )}
240
+ {!col.isId && !col.isUnique && <span style={{ width: 12, display: "inline-block" }} />}
246
241
  <span style={{ fontWeight: 500, flex: 1 }}>{col.name}</span>
247
242
  <span
248
243
  style={{
@@ -280,9 +275,7 @@ function DescribedNode({ data }: NodeProps) {
280
275
  return (
281
276
  <div>
282
277
  <Handle type="target" position={Position.Left} style={{ visibility: "hidden" }} />
283
- <div style={{ fontSize: 12, fontWeight: 600 }}>
284
- {String(d.label ?? "")}
285
- </div>
278
+ <div style={{ fontSize: 12, fontWeight: 600 }}>{String(d.label ?? "")}</div>
286
279
  {description && <DescriptionLine text={description} dark={dark} />}
287
280
  <Handle type="source" position={Position.Right} style={{ visibility: "hidden" }} />
288
281
  </div>
@@ -332,9 +325,7 @@ export function GraphView(props: GraphViewProps) {
332
325
  const typeOrder: NodeType[] = ["page", "endpoint", "handler", "db", "service"];
333
326
  const visibleNodes = graph.nodes.filter((node) => visibleNodeTypes.has(node.type));
334
327
  const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
335
- const nodesByType = new Map<NodeType, typeof visibleNodes>(
336
- typeOrder.map((type) => [type, []]),
337
- );
328
+ const nodesByType = new Map<NodeType, typeof visibleNodes>(typeOrder.map((type) => [type, []]));
338
329
 
339
330
  for (const node of visibleNodes) {
340
331
  nodesByType.get(node.type)?.push(node);
@@ -350,9 +341,7 @@ export function GraphView(props: GraphViewProps) {
350
341
  const flowNodes: FlowNode[] = [];
351
342
  const columnWidth = 300;
352
343
  const defaultRowHeight = 80;
353
- const hasScreenshots = graph.nodes.some(
354
- (n) => n.type === "page" && n.meta?.screenshot,
355
- );
344
+ const hasScreenshots = graph.nodes.some((n) => n.type === "page" && n.meta?.screenshot);
356
345
  const pageRowHeight = hasScreenshots ? 140 : defaultRowHeight;
357
346
 
358
347
  // Compute max db column count for row height
@@ -361,11 +350,13 @@ export function GraphView(props: GraphViewProps) {
361
350
  const cols = Array.isArray(n.meta?.columns) ? (n.meta?.columns as unknown[]).length : 0;
362
351
  return Math.max(max, cols);
363
352
  }, 0);
364
- const dbRowHeight = maxDbColumns > 0 ? Math.max(defaultRowHeight, 30 + maxDbColumns * 22) : defaultRowHeight;
353
+ const dbRowHeight =
354
+ maxDbColumns > 0 ? Math.max(defaultRowHeight, 30 + maxDbColumns * 22) : defaultRowHeight;
365
355
 
366
356
  activeTypeOrder.forEach((type, columnIndex) => {
367
357
  const nodes = nodesByType.get(type) ?? [];
368
- const rowHeight = type === "page" ? pageRowHeight : type === "db" ? dbRowHeight : defaultRowHeight;
358
+ const rowHeight =
359
+ type === "page" ? pageRowHeight : type === "db" ? dbRowHeight : defaultRowHeight;
369
360
  nodes.forEach((node, rowIndex) => {
370
361
  const isSelected = node.id === selectedNodeId;
371
362
  const status = nodeStatusById?.get(node.id) ?? "unchanged";
@@ -375,7 +366,10 @@ export function GraphView(props: GraphViewProps) {
375
366
  const screenshot = isPage ? (node.meta?.screenshot as string | undefined) : undefined;
376
367
  const description = node.meta?.description as string | undefined;
377
368
  const isDarkText = false;
378
- const isDbTable = node.type === "db" && Array.isArray(node.meta?.columns) && (node.meta?.columns as unknown[]).length > 0;
369
+ const isDbTable =
370
+ node.type === "db" &&
371
+ Array.isArray(node.meta?.columns) &&
372
+ (node.meta?.columns as unknown[]).length > 0;
379
373
  const dbColumns = isDbTable ? (node.meta?.columns as DbColumn[]) : undefined;
380
374
 
381
375
  const nodeType = isDbTable
@@ -451,8 +445,10 @@ export function GraphView(props: GraphViewProps) {
451
445
  visibleNodeIds.has(edge.to),
452
446
  )
453
447
  .map((edge, index) => {
454
- const status = edgeStatusByKey?.get(buildEdgeKey(edge.from, edge.to, edge.kind)) ?? "unchanged";
455
- const strokeColor = status === "unchanged" ? EDGE_COLOR[edge.kind] : DIFF_EDGE_COLOR[status];
448
+ const status =
449
+ edgeStatusByKey?.get(buildEdgeKey(edge.from, edge.to, edge.kind)) ?? "unchanged";
450
+ const strokeColor =
451
+ status === "unchanged" ? EDGE_COLOR[edge.kind] : DIFF_EDGE_COLOR[status];
456
452
 
457
453
  return {
458
454
  id: `${edge.from}=>${edge.to}::${edge.kind}::${index}`,
@@ -474,14 +470,7 @@ export function GraphView(props: GraphViewProps) {
474
470
  });
475
471
 
476
472
  return { flowNodes, flowEdges };
477
- }, [
478
- edgeStatusByKey,
479
- graph,
480
- nodeStatusById,
481
- selectedNodeId,
482
- visibleEdgeKinds,
483
- visibleNodeTypes,
484
- ]);
473
+ }, [edgeStatusByKey, graph, nodeStatusById, selectedNodeId, visibleEdgeKinds, visibleNodeTypes]);
485
474
 
486
475
  // Apply hover highlighting as a cheap pass over precomputed nodes/edges
487
476
  const activeNodeId = hoveredNodeId ?? selectedNodeId;
@@ -9,15 +9,14 @@ const TYPE_BADGE_COLORS: Record<NodeType, string> = {
9
9
  endpoint: "bg-emerald-100 text-emerald-700",
10
10
  handler: "bg-teal-100 text-teal-700",
11
11
  db: "bg-red-100 text-red-700",
12
+ action: "bg-purple-100 text-purple-700",
12
13
  service: "bg-violet-100 text-violet-700",
13
14
  };
14
15
 
15
16
  export function NodeDetails({ node }: NodeDetailsProps) {
16
17
  if (!node) {
17
18
  return (
18
- <div className="mt-5 py-4 text-center text-xs text-slate-400">
19
- Click a node to inspect
20
- </div>
19
+ <div className="mt-5 py-4 text-center text-xs text-slate-400">Click a node to inspect</div>
21
20
  );
22
21
  }
23
22
 
@@ -36,14 +35,10 @@ export function NodeDetails({ node }: NodeDetailsProps) {
36
35
  >
37
36
  {node.type}
38
37
  </span>
39
- <span className="text-sm font-medium text-slate-900 truncate">
40
- {node.label}
41
- </span>
38
+ <span className="text-sm font-medium text-slate-900 truncate">{node.label}</span>
42
39
  </div>
43
40
 
44
- <div className="text-[11px] text-slate-500 font-mono break-all">
45
- {node.id}
46
- </div>
41
+ <div className="text-[11px] text-slate-500 font-mono break-all">{node.id}</div>
47
42
 
48
43
  {(node.meta?.descriptionLong ?? node.meta?.description) ? (
49
44
  <p className="text-xs text-slate-600 leading-relaxed">
@@ -73,7 +68,8 @@ export function NodeDetails({ node }: NodeDetailsProps) {
73
68
  {JSON.stringify(
74
69
  Object.fromEntries(
75
70
  Object.entries(node.meta).filter(
76
- ([key]) => key !== "screenshot" && key !== "description" && key !== "descriptionLong",
71
+ ([key]) =>
72
+ key !== "screenshot" && key !== "description" && key !== "descriptionLong",
77
73
  ),
78
74
  ),
79
75
  null,
@@ -1,9 +1,6 @@
1
- export type NodeType =
2
- | "page"
3
- | "endpoint"
4
- | "db"
5
- | "handler"
6
- | "service";
1
+ // Keep in sync with src/model.ts and src/diff.ts (the backend is the source of truth).
2
+
3
+ export type NodeType = "page" | "endpoint" | "db" | "handler" | "action" | "service";
7
4
 
8
5
  export type Node = {
9
6
  id: string;
@@ -33,7 +30,7 @@ export type Graph = {
33
30
  edges: Edge[];
34
31
  };
35
32
 
36
- export type DiffStatus = "added" | "removed" | "unchanged";
33
+ export type DiffStatus = "added" | "removed" | "modified" | "unchanged";
37
34
 
38
35
  export type NodeDiff = {
39
36
  node: Node;
@@ -49,3 +46,7 @@ export type GraphDiff = {
49
46
  nodes: NodeDiff[];
50
47
  edges: EdgeDiff[];
51
48
  };
49
+
50
+ export function buildEdgeKey(from: string, to: string, kind: EdgeKind): string {
51
+ return `${from}::${to}::${kind}`;
52
+ }