next-arch-map 0.1.25 → 0.1.26
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/dist/analyzers/prismaSchema.d.ts +5 -0
- package/dist/analyzers/prismaSchema.js +284 -0
- package/dist/cli.js +46 -0
- package/dist/describe.d.ts +9 -0
- package/dist/describe.js +66 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/model.d.ts +1 -1
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +66 -3
- package/package.json +1 -1
- package/viewer/src/App.tsx +2 -0
- package/viewer/src/GraphView.tsx +153 -26
- package/viewer/src/types.ts +2 -1
|
@@ -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.
|
package/dist/describe.js
ADDED
|
@@ -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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/viewer/src/App.tsx
CHANGED
|
@@ -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];
|
package/viewer/src/GraphView.tsx
CHANGED
|
@@ -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
|
|
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,39 @@ export function GraphView(props: GraphViewProps) {
|
|
|
298
413
|
sourcePosition: Position.Right,
|
|
299
414
|
targetPosition: Position.Left,
|
|
300
415
|
selectable: true,
|
|
301
|
-
style:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
style: isDbTable
|
|
417
|
+
? {
|
|
418
|
+
width: nodeWidth,
|
|
419
|
+
borderRadius: 6,
|
|
420
|
+
padding: 0,
|
|
421
|
+
background: "transparent",
|
|
422
|
+
opacity: status === "removed" ? 0.65 : 1,
|
|
423
|
+
boxShadow: isSelected
|
|
424
|
+
? `0 0 0 3px rgba(30, 41, 59, 0.15), 0 8px 24px rgba(0, 0, 0, 0.12)`
|
|
319
425
|
: `0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)`,
|
|
320
|
-
|
|
321
|
-
|
|
426
|
+
transition: "opacity 0.15s ease, box-shadow 0.15s ease",
|
|
427
|
+
}
|
|
428
|
+
: {
|
|
429
|
+
width: nodeWidth,
|
|
430
|
+
borderRadius: 12,
|
|
431
|
+
border: `2px ${borderStyle} ${borderColor}`,
|
|
432
|
+
padding: "10px 14px",
|
|
433
|
+
background: NODE_COLOR[node.type],
|
|
434
|
+
color: "#ffffff",
|
|
435
|
+
fontSize: 12,
|
|
436
|
+
fontWeight: 600,
|
|
437
|
+
fontFamily: "'Inter', -apple-system, sans-serif",
|
|
438
|
+
letterSpacing: "-0.01em",
|
|
439
|
+
opacity: status === "removed" ? 0.65 : 1,
|
|
440
|
+
boxShadow: isSelected
|
|
441
|
+
? `0 0 0 3px rgba(30, 41, 59, 0.15), 0 8px 24px rgba(0, 0, 0, 0.12)`
|
|
442
|
+
: status === "added"
|
|
443
|
+
? `0 0 0 3px rgba(34, 197, 94, 0.2)`
|
|
444
|
+
: status === "removed"
|
|
445
|
+
? `0 0 0 3px rgba(239, 68, 68, 0.15)`
|
|
446
|
+
: `0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04)`,
|
|
447
|
+
transition: "opacity 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease",
|
|
448
|
+
},
|
|
322
449
|
});
|
|
323
450
|
});
|
|
324
451
|
});
|