docguard-cli 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/generate.mjs +432 -82
- package/cli/commands/publish.mjs +246 -0
- package/cli/docguard.mjs +19 -3
- package/cli/scanners/doc-tools.mjs +351 -0
- package/cli/scanners/routes.mjs +461 -0
- package/cli/scanners/schemas.mjs +567 -0
- package/package.json +1 -1
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep Schema Scanner
|
|
3
|
+
* Parses schema definitions from ORM/validation libraries.
|
|
4
|
+
* Supports: Prisma, Drizzle, Zod, Mongoose, TypeORM, OpenAPI schemas
|
|
5
|
+
*
|
|
6
|
+
* Priority: OpenAPI schemas > ORM schemas > Validation schemas
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const IGNORE_DIRS = new Set([
|
|
13
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
14
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Deep scan schemas from ORM definitions, validation libraries, and OpenAPI specs.
|
|
19
|
+
* @param {string} dir - Project root
|
|
20
|
+
* @param {object} stack - Detected tech stack
|
|
21
|
+
* @param {object} docTools - Detected doc tools (may include OpenAPI)
|
|
22
|
+
* @returns {object} { entities: [...], relationships: [...], source: string }
|
|
23
|
+
*/
|
|
24
|
+
export function scanSchemasDeep(dir, stack, docTools) {
|
|
25
|
+
// Priority 1: OpenAPI schemas
|
|
26
|
+
if (docTools?.openapi?.found && docTools.openapi.schemas?.length > 0) {
|
|
27
|
+
return {
|
|
28
|
+
entities: docTools.openapi.schemas.map(s => ({
|
|
29
|
+
name: s.name,
|
|
30
|
+
fields: s.fields,
|
|
31
|
+
file: docTools.openapi.path,
|
|
32
|
+
source: 'openapi',
|
|
33
|
+
description: s.description,
|
|
34
|
+
})),
|
|
35
|
+
relationships: extractOpenAPIRelationships(docTools.openapi.schemas),
|
|
36
|
+
source: 'openapi',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Priority 2: ORM-specific scanning
|
|
41
|
+
const orm = stack?.orm || '';
|
|
42
|
+
const entities = [];
|
|
43
|
+
const relationships = [];
|
|
44
|
+
|
|
45
|
+
// Prisma
|
|
46
|
+
const prismaResult = scanPrismaDeep(dir);
|
|
47
|
+
if (prismaResult.entities.length > 0) {
|
|
48
|
+
entities.push(...prismaResult.entities);
|
|
49
|
+
relationships.push(...prismaResult.relationships);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Drizzle
|
|
53
|
+
const drizzleResult = scanDrizzleSchemas(dir);
|
|
54
|
+
if (drizzleResult.entities.length > 0) {
|
|
55
|
+
entities.push(...drizzleResult.entities);
|
|
56
|
+
relationships.push(...drizzleResult.relationships);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Zod (if no ORM found, Zod schemas are the data model)
|
|
60
|
+
if (entities.length === 0) {
|
|
61
|
+
const zodResult = scanZodSchemas(dir);
|
|
62
|
+
entities.push(...zodResult.entities);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Mongoose
|
|
66
|
+
const mongooseResult = scanMongooseSchemas(dir);
|
|
67
|
+
if (mongooseResult.entities.length > 0) {
|
|
68
|
+
entities.push(...mongooseResult.entities);
|
|
69
|
+
relationships.push(...mongooseResult.relationships);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
entities,
|
|
74
|
+
relationships,
|
|
75
|
+
source: entities.length > 0 ? entities[0].source : 'none',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Prisma Deep Parser ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function scanPrismaDeep(dir) {
|
|
82
|
+
const entities = [];
|
|
83
|
+
const relationships = [];
|
|
84
|
+
|
|
85
|
+
const schemaPath = resolve(dir, 'prisma/schema.prisma');
|
|
86
|
+
if (!existsSync(schemaPath)) return { entities, relationships };
|
|
87
|
+
|
|
88
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
89
|
+
|
|
90
|
+
// Parse all models
|
|
91
|
+
const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g;
|
|
92
|
+
let modelMatch;
|
|
93
|
+
|
|
94
|
+
while ((modelMatch = modelRegex.exec(content)) !== null) {
|
|
95
|
+
const modelName = modelMatch[1];
|
|
96
|
+
const body = modelMatch[2];
|
|
97
|
+
const fields = [];
|
|
98
|
+
|
|
99
|
+
const lines = body.split('\n');
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
|
|
103
|
+
|
|
104
|
+
// Parse field: name Type @modifiers
|
|
105
|
+
const fieldMatch = trimmed.match(/^(\w+)\s+([\w\[\]?]+)(\s+.*)?$/);
|
|
106
|
+
if (!fieldMatch) continue;
|
|
107
|
+
|
|
108
|
+
const fieldName = fieldMatch[1];
|
|
109
|
+
const rawType = fieldMatch[2];
|
|
110
|
+
const modifiers = fieldMatch[3] || '';
|
|
111
|
+
|
|
112
|
+
// Skip relation fields for field list (but track relationships)
|
|
113
|
+
const isRelation = rawType.endsWith('[]') || modifiers.includes('@relation');
|
|
114
|
+
|
|
115
|
+
if (isRelation && rawType.endsWith('[]')) {
|
|
116
|
+
relationships.push({
|
|
117
|
+
from: modelName,
|
|
118
|
+
to: rawType.replace('[]', '').replace('?', ''),
|
|
119
|
+
type: 'one-to-many',
|
|
120
|
+
field: fieldName,
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isRelation && !rawType.endsWith('[]') && modifiers.includes('@relation')) {
|
|
126
|
+
relationships.push({
|
|
127
|
+
from: modelName,
|
|
128
|
+
to: rawType.replace('?', ''),
|
|
129
|
+
type: 'many-to-one',
|
|
130
|
+
field: fieldName,
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fields.push({
|
|
136
|
+
name: fieldName,
|
|
137
|
+
type: mapPrismaType(rawType),
|
|
138
|
+
required: !rawType.includes('?'),
|
|
139
|
+
primaryKey: modifiers.includes('@id'),
|
|
140
|
+
unique: modifiers.includes('@unique'),
|
|
141
|
+
default: extractPrismaDefault(modifiers),
|
|
142
|
+
description: extractInlineComment(line),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
entities.push({
|
|
147
|
+
name: modelName,
|
|
148
|
+
fields,
|
|
149
|
+
file: 'prisma/schema.prisma',
|
|
150
|
+
source: 'prisma',
|
|
151
|
+
description: '',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse enums
|
|
156
|
+
const enumRegex = /enum\s+(\w+)\s*\{([^}]+)\}/g;
|
|
157
|
+
let enumMatch;
|
|
158
|
+
while ((enumMatch = enumRegex.exec(content)) !== null) {
|
|
159
|
+
const enumName = enumMatch[1];
|
|
160
|
+
const values = enumMatch[2].trim().split('\n')
|
|
161
|
+
.map(l => l.trim())
|
|
162
|
+
.filter(l => l && !l.startsWith('//'));
|
|
163
|
+
|
|
164
|
+
entities.push({
|
|
165
|
+
name: enumName,
|
|
166
|
+
fields: values.map(v => ({ name: v, type: 'enum_value', required: true })),
|
|
167
|
+
file: 'prisma/schema.prisma',
|
|
168
|
+
source: 'prisma-enum',
|
|
169
|
+
description: `Enum with ${values.length} values`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { entities, relationships };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mapPrismaType(rawType) {
|
|
177
|
+
const type = rawType.replace('?', '').replace('[]', '');
|
|
178
|
+
const map = {
|
|
179
|
+
'String': 'string', 'Int': 'integer', 'BigInt': 'bigint',
|
|
180
|
+
'Float': 'float', 'Decimal': 'decimal', 'Boolean': 'boolean',
|
|
181
|
+
'DateTime': 'datetime', 'Json': 'json', 'Bytes': 'bytes',
|
|
182
|
+
};
|
|
183
|
+
return map[type] || type;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractPrismaDefault(modifiers) {
|
|
187
|
+
const match = modifiers.match(/@default\(([^)]+)\)/);
|
|
188
|
+
if (!match) return '—';
|
|
189
|
+
return match[1];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractInlineComment(line) {
|
|
193
|
+
const match = line.match(/\/\/\s*(.+)$/);
|
|
194
|
+
return match ? match[1].trim() : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Drizzle Scanner ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function scanDrizzleSchemas(dir) {
|
|
200
|
+
const entities = [];
|
|
201
|
+
const relationships = [];
|
|
202
|
+
|
|
203
|
+
const schemaDirs = ['src/db', 'src/schema', 'db', 'schema', 'drizzle', 'src/drizzle', 'src'];
|
|
204
|
+
const tablePattern = /(?:export\s+(?:const|let)\s+)?(\w+)\s*=\s*(?:pg|mysql|sqlite)Table\s*\(\s*['"`](\w+)['"`]\s*,\s*\{([^}]+)\}/g;
|
|
205
|
+
|
|
206
|
+
for (const schemaDir of schemaDirs) {
|
|
207
|
+
const fullDir = resolve(dir, schemaDir);
|
|
208
|
+
if (!existsSync(fullDir)) continue;
|
|
209
|
+
|
|
210
|
+
walkDir(fullDir, (filePath) => {
|
|
211
|
+
const content = readFileSafe(filePath);
|
|
212
|
+
if (!content || !content.includes('Table(')) return;
|
|
213
|
+
|
|
214
|
+
let match;
|
|
215
|
+
const regex = new RegExp(tablePattern.source, 'g');
|
|
216
|
+
while ((match = regex.exec(content)) !== null) {
|
|
217
|
+
const varName = match[1];
|
|
218
|
+
const tableName = match[2];
|
|
219
|
+
const body = match[3];
|
|
220
|
+
const fields = parseDrizzleColumns(body);
|
|
221
|
+
|
|
222
|
+
// Look for references (foreign keys)
|
|
223
|
+
for (const field of fields) {
|
|
224
|
+
if (field._ref) {
|
|
225
|
+
relationships.push({
|
|
226
|
+
from: tableName,
|
|
227
|
+
to: field._ref,
|
|
228
|
+
type: 'many-to-one',
|
|
229
|
+
field: field.name,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
entities.push({
|
|
235
|
+
name: tableName,
|
|
236
|
+
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
237
|
+
file: relative(dir, filePath),
|
|
238
|
+
source: 'drizzle',
|
|
239
|
+
description: '',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { entities, relationships };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseDrizzleColumns(body) {
|
|
249
|
+
const fields = [];
|
|
250
|
+
const lines = body.split('\n');
|
|
251
|
+
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
const trimmed = line.trim().replace(/,$/, '');
|
|
254
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
255
|
+
|
|
256
|
+
// Match: fieldName: type('column_name')
|
|
257
|
+
const colMatch = trimmed.match(/(\w+)\s*:\s*(\w+)\s*\(\s*['"`]?(\w+)?['"`]?\s*\)/);
|
|
258
|
+
if (!colMatch) continue;
|
|
259
|
+
|
|
260
|
+
const fieldName = colMatch[1];
|
|
261
|
+
const drizzleType = colMatch[2];
|
|
262
|
+
|
|
263
|
+
const field = {
|
|
264
|
+
name: fieldName,
|
|
265
|
+
type: mapDrizzleType(drizzleType),
|
|
266
|
+
required: !trimmed.includes('.notNull()') ? false : true,
|
|
267
|
+
primaryKey: trimmed.includes('.primaryKey()') || drizzleType === 'serial',
|
|
268
|
+
unique: trimmed.includes('.unique()'),
|
|
269
|
+
default: extractDrizzleDefault(trimmed),
|
|
270
|
+
description: '',
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Check for references
|
|
274
|
+
const refMatch = trimmed.match(/\.references\(\s*\(\)\s*=>\s*(\w+)\.\w+\)/);
|
|
275
|
+
if (refMatch) {
|
|
276
|
+
field._ref = refMatch[1];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
fields.push(field);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return fields;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function mapDrizzleType(type) {
|
|
286
|
+
const map = {
|
|
287
|
+
'serial': 'integer (auto)', 'integer': 'integer', 'bigint': 'bigint',
|
|
288
|
+
'smallint': 'smallint', 'text': 'string', 'varchar': 'string',
|
|
289
|
+
'char': 'string', 'boolean': 'boolean', 'timestamp': 'datetime',
|
|
290
|
+
'date': 'date', 'time': 'time', 'json': 'json', 'jsonb': 'json',
|
|
291
|
+
'real': 'float', 'doublePrecision': 'double', 'numeric': 'decimal',
|
|
292
|
+
'uuid': 'uuid',
|
|
293
|
+
};
|
|
294
|
+
return map[type] || type;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractDrizzleDefault(line) {
|
|
298
|
+
const match = line.match(/\.default\(([^)]+)\)/);
|
|
299
|
+
if (match) return match[1].replace(/['"`]/g, '');
|
|
300
|
+
if (line.includes('.defaultNow()')) return 'now()';
|
|
301
|
+
if (line.includes('.defaultRandom()')) return 'random()';
|
|
302
|
+
return '—';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Zod Scanner ─────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
function scanZodSchemas(dir) {
|
|
308
|
+
const entities = [];
|
|
309
|
+
|
|
310
|
+
const schemaDirs = ['src/schema', 'src/schemas', 'schema', 'schemas', 'src/types', 'src/validation', 'src'];
|
|
311
|
+
const zodPattern = /(?:export\s+(?:const|let)\s+)(\w+(?:Schema|Validator|Input|Output))\s*=\s*z\.object\s*\(\s*\{([^}]+)\}\s*\)/g;
|
|
312
|
+
|
|
313
|
+
for (const schemaDir of schemaDirs) {
|
|
314
|
+
const fullDir = resolve(dir, schemaDir);
|
|
315
|
+
if (!existsSync(fullDir)) continue;
|
|
316
|
+
|
|
317
|
+
walkDir(fullDir, (filePath) => {
|
|
318
|
+
const content = readFileSafe(filePath);
|
|
319
|
+
if (!content || !content.includes('z.object')) return;
|
|
320
|
+
|
|
321
|
+
let match;
|
|
322
|
+
const regex = new RegExp(zodPattern.source, 'g');
|
|
323
|
+
while ((match = regex.exec(content)) !== null) {
|
|
324
|
+
const schemaName = match[1].replace(/Schema$|Validator$/, '');
|
|
325
|
+
const body = match[2];
|
|
326
|
+
const fields = parseZodFields(body);
|
|
327
|
+
|
|
328
|
+
entities.push({
|
|
329
|
+
name: schemaName,
|
|
330
|
+
fields,
|
|
331
|
+
file: relative(dir, filePath),
|
|
332
|
+
source: 'zod',
|
|
333
|
+
description: '',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { entities };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseZodFields(body) {
|
|
343
|
+
const fields = [];
|
|
344
|
+
const lines = body.split('\n');
|
|
345
|
+
|
|
346
|
+
for (const line of lines) {
|
|
347
|
+
const trimmed = line.trim().replace(/,$/, '');
|
|
348
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
349
|
+
|
|
350
|
+
// Match: fieldName: z.type()
|
|
351
|
+
const fieldMatch = trimmed.match(/(\w+)\s*:\s*z\.\s*(\w+)\s*\(/);
|
|
352
|
+
if (!fieldMatch) continue;
|
|
353
|
+
|
|
354
|
+
const fieldName = fieldMatch[1];
|
|
355
|
+
const zodType = fieldMatch[2];
|
|
356
|
+
|
|
357
|
+
fields.push({
|
|
358
|
+
name: fieldName,
|
|
359
|
+
type: mapZodType(zodType),
|
|
360
|
+
required: !trimmed.includes('.optional()') && !trimmed.includes('.nullable()'),
|
|
361
|
+
primaryKey: false,
|
|
362
|
+
unique: false,
|
|
363
|
+
default: trimmed.includes('.default(') ? 'has default' : '—',
|
|
364
|
+
description: '',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return fields;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function mapZodType(type) {
|
|
372
|
+
const map = {
|
|
373
|
+
'string': 'string', 'number': 'number', 'boolean': 'boolean',
|
|
374
|
+
'date': 'date', 'bigint': 'bigint', 'array': 'array',
|
|
375
|
+
'object': 'object', 'enum': 'enum', 'union': 'union',
|
|
376
|
+
'literal': 'literal', 'record': 'record', 'tuple': 'tuple',
|
|
377
|
+
'any': 'any', 'unknown': 'unknown', 'null': 'null',
|
|
378
|
+
'undefined': 'undefined', 'void': 'void', 'never': 'never',
|
|
379
|
+
'coerce': 'coerced',
|
|
380
|
+
};
|
|
381
|
+
return map[type] || type;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Mongoose Scanner ────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
function scanMongooseSchemas(dir) {
|
|
387
|
+
const entities = [];
|
|
388
|
+
const relationships = [];
|
|
389
|
+
|
|
390
|
+
const schemaDirs = ['src/models', 'models', 'src/schema', 'schema'];
|
|
391
|
+
const schemaPattern = /(?:const|let|var)\s+(\w+)(?:Schema)?\s*=\s*new\s+(?:mongoose\.)?Schema\s*\(\s*\{([^}]+)\}/g;
|
|
392
|
+
|
|
393
|
+
for (const schemaDir of schemaDirs) {
|
|
394
|
+
const fullDir = resolve(dir, schemaDir);
|
|
395
|
+
if (!existsSync(fullDir)) continue;
|
|
396
|
+
|
|
397
|
+
walkDir(fullDir, (filePath) => {
|
|
398
|
+
const content = readFileSafe(filePath);
|
|
399
|
+
if (!content || !content.includes('Schema(')) return;
|
|
400
|
+
|
|
401
|
+
let match;
|
|
402
|
+
const regex = new RegExp(schemaPattern.source, 'g');
|
|
403
|
+
while ((match = regex.exec(content)) !== null) {
|
|
404
|
+
const schemaName = match[1].replace(/Schema$/i, '');
|
|
405
|
+
const body = match[2];
|
|
406
|
+
const fields = parseMongooseFields(body);
|
|
407
|
+
|
|
408
|
+
// Check for refs (relationships)
|
|
409
|
+
for (const field of fields) {
|
|
410
|
+
if (field._ref) {
|
|
411
|
+
relationships.push({
|
|
412
|
+
from: schemaName,
|
|
413
|
+
to: field._ref,
|
|
414
|
+
type: 'many-to-one',
|
|
415
|
+
field: field.name,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
entities.push({
|
|
421
|
+
name: schemaName.charAt(0).toUpperCase() + schemaName.slice(1),
|
|
422
|
+
fields: fields.map(f => ({ ...f, _ref: undefined })),
|
|
423
|
+
file: relative(dir, filePath),
|
|
424
|
+
source: 'mongoose',
|
|
425
|
+
description: '',
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { entities, relationships };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function parseMongooseFields(body) {
|
|
435
|
+
const fields = [];
|
|
436
|
+
const lines = body.split('\n');
|
|
437
|
+
|
|
438
|
+
for (const line of lines) {
|
|
439
|
+
const trimmed = line.trim().replace(/,$/, '');
|
|
440
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
441
|
+
|
|
442
|
+
// Match: fieldName: Type or fieldName: { type: Type }
|
|
443
|
+
const simpleMatch = trimmed.match(/(\w+)\s*:\s*(\w+)/);
|
|
444
|
+
if (!simpleMatch) continue;
|
|
445
|
+
|
|
446
|
+
const fieldName = simpleMatch[1];
|
|
447
|
+
const typeOrKey = simpleMatch[2];
|
|
448
|
+
|
|
449
|
+
if (typeOrKey === 'type') {
|
|
450
|
+
// Complex field: { type: String, required: true }
|
|
451
|
+
const typeMatch = trimmed.match(/type\s*:\s*(\w+)/);
|
|
452
|
+
const required = trimmed.includes('required: true') || trimmed.includes("required: 'true'");
|
|
453
|
+
const unique = trimmed.includes('unique: true');
|
|
454
|
+
const refMatch = trimmed.match(/ref\s*:\s*['"](\w+)['"]/);
|
|
455
|
+
|
|
456
|
+
fields.push({
|
|
457
|
+
name: fieldName,
|
|
458
|
+
type: mapMongooseType(typeMatch ? typeMatch[1] : 'Mixed'),
|
|
459
|
+
required,
|
|
460
|
+
primaryKey: fieldName === '_id',
|
|
461
|
+
unique,
|
|
462
|
+
default: '—',
|
|
463
|
+
description: '',
|
|
464
|
+
_ref: refMatch ? refMatch[1] : null,
|
|
465
|
+
});
|
|
466
|
+
} else {
|
|
467
|
+
// Simple field: fieldName: String
|
|
468
|
+
fields.push({
|
|
469
|
+
name: fieldName,
|
|
470
|
+
type: mapMongooseType(typeOrKey),
|
|
471
|
+
required: false,
|
|
472
|
+
primaryKey: fieldName === '_id',
|
|
473
|
+
unique: false,
|
|
474
|
+
default: '—',
|
|
475
|
+
description: '',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return fields;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function mapMongooseType(type) {
|
|
484
|
+
const map = {
|
|
485
|
+
'String': 'string', 'Number': 'number', 'Boolean': 'boolean',
|
|
486
|
+
'Date': 'date', 'Buffer': 'buffer', 'ObjectId': 'ObjectId',
|
|
487
|
+
'Array': 'array', 'Map': 'map', 'Mixed': 'mixed',
|
|
488
|
+
'Schema': 'embedded',
|
|
489
|
+
};
|
|
490
|
+
return map[type] || type;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function extractOpenAPIRelationships(schemas) {
|
|
496
|
+
const relationships = [];
|
|
497
|
+
for (const schema of schemas) {
|
|
498
|
+
for (const field of schema.fields) {
|
|
499
|
+
if (field.type !== 'string' && field.type !== 'number' && field.type !== 'boolean' && field.type !== 'integer') {
|
|
500
|
+
// Likely a reference to another schema
|
|
501
|
+
const target = schemas.find(s => s.name.toLowerCase() === field.type.toLowerCase());
|
|
502
|
+
if (target) {
|
|
503
|
+
relationships.push({
|
|
504
|
+
from: schema.name,
|
|
505
|
+
to: target.name,
|
|
506
|
+
type: 'reference',
|
|
507
|
+
field: field.name,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return relationships;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function readFileSafe(path) {
|
|
517
|
+
try { return readFileSync(path, 'utf-8'); } catch { return null; }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function walkDir(dir, callback) {
|
|
521
|
+
if (!existsSync(dir)) return;
|
|
522
|
+
try {
|
|
523
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
524
|
+
for (const entry of entries) {
|
|
525
|
+
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
526
|
+
const fullPath = join(dir, entry.name);
|
|
527
|
+
if (entry.isDirectory()) {
|
|
528
|
+
walkDir(fullPath, callback);
|
|
529
|
+
} else if (entry.isFile() && /\.(js|mjs|cjs|ts|tsx|jsx|py)$/.test(entry.name)) {
|
|
530
|
+
callback(fullPath);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} catch { /* skip */ }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Generate mermaid ER diagram from entities and relationships.
|
|
538
|
+
*/
|
|
539
|
+
export function generateERDiagram(entities, relationships) {
|
|
540
|
+
if (entities.length === 0) return '';
|
|
541
|
+
|
|
542
|
+
const lines = ['erDiagram'];
|
|
543
|
+
|
|
544
|
+
// Add entities with fields
|
|
545
|
+
for (const entity of entities) {
|
|
546
|
+
if (entity.source === 'prisma-enum') continue; // Skip enums in ER
|
|
547
|
+
const fieldLines = entity.fields
|
|
548
|
+
.slice(0, 8) // Limit fields shown
|
|
549
|
+
.map(f => {
|
|
550
|
+
const pk = f.primaryKey ? ' PK' : '';
|
|
551
|
+
const uk = f.unique ? ' UK' : '';
|
|
552
|
+
return ` ${f.type.replace(/[^a-zA-Z0-9]/g, '_')} ${f.name}${pk}${uk}`;
|
|
553
|
+
});
|
|
554
|
+
lines.push(` ${entity.name} {`);
|
|
555
|
+
lines.push(...fieldLines);
|
|
556
|
+
lines.push(` }`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Add relationships
|
|
560
|
+
for (const rel of relationships) {
|
|
561
|
+
const arrow = rel.type === 'one-to-many' ? '||--o{' :
|
|
562
|
+
rel.type === 'many-to-one' ? '}o--||' : '||--||';
|
|
563
|
+
lines.push(` ${rel.from} ${arrow} ${rel.to} : "${rel.field}"`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return lines.join('\n');
|
|
567
|
+
}
|