docguard-cli 0.5.1 → 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.
@@ -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
+ }
@@ -39,7 +39,7 @@ DocGuard works with any AI coding agent that can read CLI output:
39
39
  ### Step 1: Diagnose
40
40
 
41
41
  ```bash
42
- npx docguard diagnose
42
+ npx docguard-cli diagnose
43
43
  ```
44
44
 
45
45
  Output:
@@ -66,7 +66,7 @@ Output:
66
66
  The AI reads the remediation plan and executes `docguard fix --doc <name>` for each issue. Each fix command outputs research instructions:
67
67
 
68
68
  ```bash
69
- npx docguard fix --doc architecture
69
+ npx docguard-cli fix --doc architecture
70
70
  ```
71
71
 
72
72
  Output:
@@ -90,7 +90,7 @@ WRITE THE DOCUMENT:
90
90
  ### Step 3: Verify
91
91
 
92
92
  ```bash
93
- npx docguard guard
93
+ npx docguard-cli guard
94
94
  ```
95
95
 
96
96
  If all checks pass → done. If issues remain → repeat from Step 1.
@@ -100,7 +100,7 @@ If all checks pass → done. If issues remain → repeat from Step 1.
100
100
  For programmatic integration:
101
101
 
102
102
  ```bash
103
- npx docguard diagnose --format json
103
+ npx docguard-cli diagnose --format json
104
104
  ```
105
105
 
106
106
  ```json
@@ -124,7 +124,7 @@ npx docguard diagnose --format json
124
124
  ```
125
125
 
126
126
  ```bash
127
- npx docguard guard --format json
127
+ npx docguard-cli guard --format json
128
128
  ```
129
129
 
130
130
  ```json
@@ -157,7 +157,7 @@ jobs:
157
157
  - uses: actions/checkout@v4
158
158
  - uses: actions/setup-node@v4
159
159
  with: { node-version: '20' }
160
- - run: npx docguard ci --format json --threshold 70
160
+ - run: npx docguard-cli ci --format json --threshold 70
161
161
  ```
162
162
 
163
163
  Or copy `templates/ci/github-actions.yml` from this repo.
@@ -165,7 +165,7 @@ Or copy `templates/ci/github-actions.yml` from this repo.
165
165
  ### Pre-commit Hook
166
166
 
167
167
  ```bash
168
- npx docguard hooks
168
+ npx docguard-cli hooks
169
169
  ```
170
170
 
171
171
  Automatically runs `guard` before every commit.