docguard-cli 0.5.2 → 0.7.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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {