docguard-cli 0.9.11 → 0.11.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.
Files changed (55) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +26 -3
  3. package/cli/commands/diagnose.mjs +171 -58
  4. package/cli/commands/diff.mjs +110 -137
  5. package/cli/commands/fix.mjs +152 -4
  6. package/cli/commands/generate.mjs +148 -27
  7. package/cli/commands/guard.mjs +45 -24
  8. package/cli/commands/hooks.mjs +40 -2
  9. package/cli/commands/score.mjs +22 -0
  10. package/cli/commands/sync.mjs +123 -0
  11. package/cli/docguard.mjs +22 -0
  12. package/cli/scanners/api-doc.mjs +122 -0
  13. package/cli/scanners/doc-tools.mjs +1 -1
  14. package/cli/scanners/frontend.mjs +438 -0
  15. package/cli/scanners/integrations.mjs +116 -0
  16. package/cli/scanners/memory-plan.mjs +242 -0
  17. package/cli/scanners/project-type.mjs +310 -0
  18. package/cli/scanners/routes.mjs +194 -32
  19. package/cli/scanners/schemas.mjs +174 -1
  20. package/cli/shared-source.mjs +247 -0
  21. package/cli/validators/api-surface.mjs +254 -0
  22. package/cli/validators/architecture.mjs +4 -3
  23. package/cli/validators/changelog.mjs +45 -4
  24. package/cli/validators/doc-quality.mjs +3 -2
  25. package/cli/validators/docs-coverage.mjs +9 -14
  26. package/cli/validators/docs-diff.mjs +117 -66
  27. package/cli/validators/docs-sync.mjs +30 -24
  28. package/cli/validators/drift.mjs +6 -2
  29. package/cli/validators/environment.mjs +43 -3
  30. package/cli/validators/freshness.mjs +4 -3
  31. package/cli/validators/metadata-sync.mjs +17 -7
  32. package/cli/validators/metrics-consistency.mjs +9 -4
  33. package/cli/validators/schema-sync.mjs +19 -10
  34. package/cli/validators/security.mjs +20 -7
  35. package/cli/validators/structure.mjs +8 -1
  36. package/cli/validators/test-spec.mjs +26 -17
  37. package/cli/validators/todo-tracking.mjs +21 -8
  38. package/cli/validators/traceability.mjs +61 -36
  39. package/cli/writers/api-reference.mjs +101 -0
  40. package/cli/writers/mechanical.mjs +116 -0
  41. package/cli/writers/sections.mjs +148 -0
  42. package/commands/docguard.fix.md +19 -3
  43. package/commands/docguard.guard.md +5 -4
  44. package/docs/doc-sections.md +37 -0
  45. package/docs/quickstart.md +1 -1
  46. package/extensions/spec-kit-docguard/README.md +8 -5
  47. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  48. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  49. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  50. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  54. package/package.json +1 -1
  55. package/templates/commands/docguard.guard.md +3 -3
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, relative, basename, extname, dirname } from 'node:path';
11
+ import { resolveSourceRoots } from '../shared-source.mjs';
11
12
 
12
13
  const IGNORE_DIRS = new Set([
13
14
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -19,9 +20,10 @@ const IGNORE_DIRS = new Set([
19
20
  * @param {string} dir - Project root
20
21
  * @param {object} stack - Detected tech stack
21
22
  * @param {object} docTools - Detected doc tools (may include OpenAPI)
23
+ * @param {object} [opts] - { config } — config enables monorepo-aware source roots
22
24
  * @returns {Array} Array of route objects { method, path, handler, file, auth, description }
23
25
  */
24
- export function scanRoutesDeep(dir, stack, docTools) {
26
+ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
25
27
  // Priority 1: Use OpenAPI spec if available (most accurate)
26
28
  if (docTools?.openapi?.found && docTools.openapi.endpoints?.length > 0) {
27
29
  return docTools.openapi.endpoints.map(ep => ({
@@ -31,30 +33,49 @@ export function scanRoutesDeep(dir, stack, docTools) {
31
33
  }));
32
34
  }
33
35
 
34
- // Priority 2: Framework-specific code scanning
36
+ // Priority 2: Framework-specific code scanning.
37
+ // Monorepo-aware: when a config is supplied, scan the resolved source roots
38
+ // (honors config.sourceRoot + workspaces) instead of only root-relative dirs.
35
39
  const framework = stack?.framework || '';
36
40
  const routes = [];
41
+ const roots = opts.config ? resolveSourceRoots(dir, opts.config) : null;
37
42
 
38
43
  if (framework.includes('Next.js') || framework.includes('Next')) {
39
44
  routes.push(...scanNextJsRoutes(dir));
40
45
  }
41
46
 
42
47
  if (framework.includes('Express') || !framework) {
43
- routes.push(...scanExpressRoutes(dir));
48
+ routes.push(...scanExpressRoutes(dir, roots));
44
49
  }
45
50
 
46
51
  if (framework.includes('Fastify')) {
47
- routes.push(...scanFastifyRoutes(dir));
52
+ routes.push(...scanFastifyRoutes(dir, roots));
48
53
  }
49
54
 
50
55
  if (framework.includes('Hono')) {
51
- routes.push(...scanHonoRoutes(dir));
56
+ routes.push(...scanHonoRoutes(dir, roots));
52
57
  }
53
58
 
54
59
  if (framework.includes('Django')) {
55
60
  routes.push(...scanDjangoRoutes(dir));
56
61
  }
57
62
 
63
+ if (framework.includes('Spring') || framework.includes('Java')) {
64
+ routes.push(...scanSpringBootRoutes(dir));
65
+ }
66
+
67
+ if (framework.includes('Rails') || framework.includes('Ruby')) {
68
+ routes.push(...scanRailsRoutes(dir));
69
+ }
70
+
71
+ if (framework.includes('Gin') || framework.includes('Echo') || framework.includes('Chi') || framework.includes('Fiber') || framework.includes('Go')) {
72
+ routes.push(...scanGoWebRoutes(dir));
73
+ }
74
+
75
+ if (framework.includes('Axum') || framework.includes('Actix') || framework.includes('Rocket') || framework.includes('Warp') || framework.includes('Rust')) {
76
+ routes.push(...scanRustWebRoutes(dir));
77
+ }
78
+
58
79
  if (framework.includes('FastAPI') || framework.includes('Flask')) {
59
80
  routes.push(...scanFastAPIRoutes(dir));
60
81
  }
@@ -167,13 +188,16 @@ function scanNextJsRoutes(dir) {
167
188
 
168
189
  // ── Express / Generic Node.js ───────────────────────────────────────────────
169
190
 
170
- function scanExpressRoutes(dir) {
191
+ function scanExpressRoutes(dir, roots = null) {
171
192
  const routes = [];
172
193
  const routePattern = /(?:app|router|server)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
173
194
 
174
- const searchDirs = ['src', 'routes', 'api', 'server', 'lib'];
175
- for (const searchDir of searchDirs) {
176
- const fullDir = resolve(dir, searchDir);
195
+ // Monorepo-aware: walk resolved absolute source roots when provided,
196
+ // otherwise fall back to conventional root-relative directories.
197
+ const searchTargets = roots && roots.length
198
+ ? roots
199
+ : ['src', 'routes', 'api', 'server', 'lib'].map(d => resolve(dir, d));
200
+ for (const fullDir of searchTargets) {
177
201
  if (!existsSync(fullDir)) continue;
178
202
 
179
203
  walkRouteDirs(fullDir, (filePath) => {
@@ -224,42 +248,47 @@ function scanExpressRoutes(dir) {
224
248
 
225
249
  // ── Fastify ─────────────────────────────────────────────────────────────────
226
250
 
227
- function scanFastifyRoutes(dir) {
251
+ function scanFastifyRoutes(dir, roots = null) {
228
252
  const routes = [];
229
253
  const pattern = /(?:fastify|server|app)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
230
254
 
231
- walkRouteDirs(resolve(dir, 'src'), (filePath) => {
232
- if (!isJSFile(filePath)) return;
233
- const content = readFileSafe(filePath);
234
- if (!content) return;
255
+ const searchTargets = roots && roots.length ? roots : [resolve(dir, 'src')];
256
+ for (const fullDir of searchTargets) {
257
+ if (!existsSync(fullDir)) continue;
258
+ walkRouteDirs(fullDir, (filePath) => {
259
+ if (!isJSFile(filePath)) return;
260
+ const content = readFileSafe(filePath);
261
+ if (!content) return;
235
262
 
236
- let match;
237
- const regex = new RegExp(pattern.source, 'gi');
238
- while ((match = regex.exec(content)) !== null) {
239
- routes.push({
240
- method: match[1].toUpperCase(),
241
- path: match[2],
242
- handler: extractHandlerName(content, match.index),
243
- file: relative(dir, filePath),
244
- source: 'fastify',
245
- auth: hasAuthCheck(content),
246
- description: extractNearbyComment(content, match.index),
247
- });
248
- }
249
- });
263
+ let match;
264
+ const regex = new RegExp(pattern.source, 'gi');
265
+ while ((match = regex.exec(content)) !== null) {
266
+ routes.push({
267
+ method: match[1].toUpperCase(),
268
+ path: match[2],
269
+ handler: extractHandlerName(content, match.index),
270
+ file: relative(dir, filePath),
271
+ source: 'fastify',
272
+ auth: hasAuthCheck(content),
273
+ description: extractNearbyComment(content, match.index),
274
+ });
275
+ }
276
+ });
277
+ }
250
278
 
251
279
  return routes;
252
280
  }
253
281
 
254
282
  // ── Hono ────────────────────────────────────────────────────────────────────
255
283
 
256
- function scanHonoRoutes(dir) {
284
+ function scanHonoRoutes(dir, roots = null) {
257
285
  const routes = [];
258
286
  const pattern = /(?:app|router)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
259
287
 
260
- const searchDirs = ['src', '.'];
261
- for (const searchDir of searchDirs) {
262
- const fullDir = resolve(dir, searchDir);
288
+ const searchTargets = roots && roots.length
289
+ ? roots
290
+ : ['src', '.'].map(d => resolve(dir, d));
291
+ for (const fullDir of searchTargets) {
263
292
  if (!existsSync(fullDir)) continue;
264
293
 
265
294
  walkRouteDirs(fullDir, (filePath) => {
@@ -344,6 +373,139 @@ function scanFastAPIRoutes(dir) {
344
373
  return routes;
345
374
  }
346
375
 
376
+ // ── Spring Boot (Java/Kotlin) ────────────────────────────────────────────────
377
+
378
+ function scanSpringBootRoutes(dir) {
379
+ const routes = [];
380
+ // Method-level verb annotations (NOT @RequestMapping — that's class-level base).
381
+ // Optional path; bare `@PostMapping` means "base path only".
382
+ const verbMap = /@(Get|Post|Put|Delete|Patch)Mapping(?:\s*\(\s*(?:value\s*=\s*)?["']([^"']*)["'])?/g;
383
+ const classBase = /@RequestMapping\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["'][^)]*\)\s*[\r\n][\s\S]*?(?:public\s+)?class\s+\w+/;
384
+
385
+ const javaFiles = findFiles(dir, /\.(java|kt)$/);
386
+ for (const filePath of javaFiles) {
387
+ const content = readFileSafe(filePath);
388
+ if (!content || !content.includes('Mapping')) continue;
389
+
390
+ // Class-level base path, if any.
391
+ const cb = classBase.exec(content);
392
+ const basePath = cb ? cb[1] : '';
393
+ const authPresent = /@PreAuthorize|@Secured|SecurityContext/.test(content);
394
+
395
+ let match;
396
+ const re = new RegExp(verbMap.source, 'g');
397
+ while ((match = re.exec(content)) !== null) {
398
+ const method = match[1].toUpperCase();
399
+ const sub = match[2] || '';
400
+ const path = (basePath + sub).replace(/\/+/g, '/') || '/';
401
+ routes.push({
402
+ method, path,
403
+ handler: '', file: relative(dir, filePath), source: 'spring-boot',
404
+ auth: authPresent, description: '',
405
+ });
406
+ }
407
+ }
408
+ return routes;
409
+ }
410
+
411
+ // ── Rails (Ruby) — config/routes.rb ──────────────────────────────────────────
412
+
413
+ function scanRailsRoutes(dir) {
414
+ const routes = [];
415
+ const routesFile = resolve(dir, 'config/routes.rb');
416
+ if (!existsSync(routesFile)) return routes;
417
+ const content = readFileSafe(routesFile);
418
+ if (!content) return routes;
419
+
420
+ // Verb DSL: get '/x', post '/x', etc. AND resources :things (RESTful 7 actions)
421
+ const verbDsl = /^\s*(get|post|put|patch|delete)\s+['"]([^'"]+)['"]/gm;
422
+ let m;
423
+ while ((m = verbDsl.exec(content)) !== null) {
424
+ routes.push({
425
+ method: m[1].toUpperCase(),
426
+ path: m[2].startsWith('/') ? m[2] : '/' + m[2],
427
+ handler: '', file: 'config/routes.rb', source: 'rails', auth: false, description: '',
428
+ });
429
+ }
430
+ // resources :users → 7 standard RESTful routes.
431
+ const resourcesRe = /^\s*resources\s+:([a-z_]+)/gm;
432
+ while ((m = resourcesRe.exec(content)) !== null) {
433
+ const r = m[1];
434
+ const base = `/${r}`;
435
+ const seven = [
436
+ ['GET', base], ['GET', `${base}/new`], ['POST', base],
437
+ ['GET', `${base}/:id`], ['GET', `${base}/:id/edit`],
438
+ ['PATCH', `${base}/:id`], ['DELETE', `${base}/:id`],
439
+ ];
440
+ for (const [method, path] of seven) {
441
+ routes.push({ method, path, handler: '', file: 'config/routes.rb', source: 'rails', auth: false, description: '' });
442
+ }
443
+ }
444
+ return routes;
445
+ }
446
+
447
+ // ── Go web frameworks (Gin / Echo / Chi / Fiber / std mux) ───────────────────
448
+
449
+ function scanGoWebRoutes(dir) {
450
+ const routes = [];
451
+ // Generic: <recv>.<METHOD>("/path", handler) for Gin/Echo/Chi/Fiber/mux.Router
452
+ const pattern = /\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|HandleFunc|Handle)\s*\(\s*["']([^"']+)["']/g;
453
+ const goFiles = findFiles(dir, /\.go$/);
454
+ for (const filePath of goFiles) {
455
+ const content = readFileSafe(filePath);
456
+ if (!content) continue;
457
+ let m;
458
+ const re = new RegExp(pattern.source, 'g');
459
+ while ((m = re.exec(content)) !== null) {
460
+ const verb = m[1];
461
+ // HandleFunc / Handle are method-agnostic.
462
+ const method = ['HandleFunc', 'Handle'].includes(verb) ? 'ANY' : verb;
463
+ const path = m[2];
464
+ if (!path.startsWith('/')) continue;
465
+ routes.push({
466
+ method, path,
467
+ handler: '', file: relative(dir, filePath), source: 'go-web',
468
+ auth: /Authorization|jwt\.|middleware\.Auth/.test(content),
469
+ description: '',
470
+ });
471
+ }
472
+ }
473
+ return routes;
474
+ }
475
+
476
+ // ── Rust web frameworks (Axum / Actix / Rocket / Warp) ───────────────────────
477
+
478
+ function scanRustWebRoutes(dir) {
479
+ const routes = [];
480
+ const rsFiles = findFiles(dir, /\.rs$/);
481
+ for (const filePath of rsFiles) {
482
+ const content = readFileSafe(filePath);
483
+ if (!content) continue;
484
+
485
+ // Axum: .route("/x", get(handler)) / .route("/x", post(handler).get(handler))
486
+ const axum = /\.route\s*\(\s*"([^"]+)"\s*,\s*([a-z]+)\s*\(/g;
487
+ let m;
488
+ while ((m = axum.exec(content)) !== null) {
489
+ const method = m[2].toUpperCase();
490
+ if (!['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(method)) continue;
491
+ routes.push({ method, path: m[1], handler: '', file: relative(dir, filePath), source: 'axum', auth: false, description: '' });
492
+ }
493
+
494
+ // Actix-web: .route("/x", web::get().to(handler))
495
+ const actix = /\.route\s*\(\s*"([^"]+)"\s*,\s*web::(get|post|put|delete|patch)\(\)/g;
496
+ while ((m = actix.exec(content)) !== null) {
497
+ routes.push({ method: m[2].toUpperCase(), path: m[1], handler: '', file: relative(dir, filePath), source: 'actix', auth: false, description: '' });
498
+ }
499
+
500
+ // Rocket: #[get("/x")] etc.
501
+ const rocket = /#\[(get|post|put|delete|patch)\(\s*"([^"]+)"/g;
502
+ while ((m = rocket.exec(content)) !== null) {
503
+ routes.push({ method: m[1].toUpperCase(), path: m[2], handler: '', file: relative(dir, filePath), source: 'rocket', auth: false, description: '' });
504
+ }
505
+ }
506
+ return routes;
507
+ }
508
+
347
509
  // ── Helpers ──────────────────────────────────────────────────────────────────
348
510
 
349
511
  function readFileSafe(path) {
@@ -69,6 +69,15 @@ export function scanSchemasDeep(dir, stack, docTools) {
69
69
  relationships.push(...mongooseResult.relationships);
70
70
  }
71
71
 
72
+ // ── Multi-language model scanners (additive; supports polyglot repos) ──
73
+ for (const scanner of [scanPythonModels, scanRustModels, scanGoModels, scanJpaModels, scanRailsModels]) {
74
+ const result = scanner(dir);
75
+ if (result.entities.length > 0) {
76
+ entities.push(...result.entities);
77
+ relationships.push(...(result.relationships || []));
78
+ }
79
+ }
80
+
72
81
  return {
73
82
  entities,
74
83
  relationships,
@@ -490,6 +499,170 @@ function mapMongooseType(type) {
490
499
  return map[type] || type;
491
500
  }
492
501
 
502
+ // ── Python: SQLAlchemy + Pydantic ────────────────────────────────────────────
503
+
504
+ function scanPythonModels(dir) {
505
+ const entities = [];
506
+ const relationships = [];
507
+ walkDir(dir, (filePath) => {
508
+ if (!filePath.endsWith('.py')) return;
509
+ const content = readFileSafe(filePath);
510
+ if (!content) return;
511
+ if (!/class\s+\w+\s*\([^)]*(Base|BaseModel|db\.Model|Model|SQLModel)/.test(content)) return;
512
+
513
+ // SQLAlchemy ORM: class X(Base): __tablename__ = "x"; id = Column(...)
514
+ const ormRe = /class\s+(\w+)\s*\([^)]*(?:Base|db\.Model|SQLModel)[^)]*\):([\s\S]*?)(?=\nclass\s+\w+|\n*$)/g;
515
+ let m;
516
+ while ((m = ormRe.exec(content)) !== null) {
517
+ const name = m[1];
518
+ const body = m[2];
519
+ const fields = [];
520
+ const colRe = /^\s*(\w+)\s*=\s*(?:mapped_column|Column)\s*\(\s*([A-Za-z_]+)(?:\([^)]*\))?([^)]*)\)/gm;
521
+ let cm;
522
+ while ((cm = colRe.exec(body)) !== null) {
523
+ const required = !/nullable\s*=\s*True/.test(cm[3]);
524
+ fields.push({ name: cm[1], type: cm[2], required, description: '' });
525
+ }
526
+ const relRe = /(\w+)\s*[:=]\s*(?:Mapped\[[^\]]*?["'](\w+)["']|relationship\s*\(\s*["'](\w+)["'])/g;
527
+ let rm;
528
+ while ((rm = relRe.exec(body)) !== null) {
529
+ relationships.push({ from: name, to: rm[2] || rm[3], type: 'related' });
530
+ }
531
+ if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'sqlalchemy' });
532
+ }
533
+
534
+ // Pydantic / SQLModel: class X(BaseModel): name: str
535
+ const pydRe = /class\s+(\w+)\s*\([^)]*(?:BaseModel|SQLModel)[^)]*\):([\s\S]*?)(?=\nclass\s+\w+|\n*$)/g;
536
+ while ((m = pydRe.exec(content)) !== null) {
537
+ const name = m[1];
538
+ if (entities.some(e => e.name === name)) continue;
539
+ const body = m[2];
540
+ const fields = [];
541
+ const fieldRe = /^\s{2,}(\w+)\s*:\s*([\w\[\],\s|]+?)(?:\s*=\s*([^\n]+))?$/gm;
542
+ let fm;
543
+ while ((fm = fieldRe.exec(body)) !== null) {
544
+ const fname = fm[1];
545
+ if (/^[A-Z_]+$/.test(fname)) continue;
546
+ const type = fm[2].trim();
547
+ const required = !/Optional|None|None\s*$/.test(type + (fm[3] || ''));
548
+ fields.push({ name: fname, type, required, description: '' });
549
+ }
550
+ if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'pydantic' });
551
+ }
552
+ });
553
+ return { entities, relationships };
554
+ }
555
+
556
+ // ── Rust: Diesel `table! { ... }` ─────────────────────────────────────────────
557
+
558
+ function scanRustModels(dir) {
559
+ const entities = [];
560
+ walkDir(dir, (filePath) => {
561
+ if (!filePath.endsWith('.rs')) return;
562
+ const content = readFileSafe(filePath);
563
+ if (!content || !content.includes('table!')) return;
564
+ const tableRe = /table!\s*\{\s*(\w+)\s*\([^)]*\)\s*\{([\s\S]*?)\}\s*\}/g;
565
+ let m;
566
+ while ((m = tableRe.exec(content)) !== null) {
567
+ const name = m[1];
568
+ const body = m[2];
569
+ const fields = [];
570
+ const colRe = /(\w+)\s*->\s*(\w+)/g;
571
+ let cm;
572
+ while ((cm = colRe.exec(body)) !== null) {
573
+ fields.push({ name: cm[1], type: cm[2], required: !/Nullable/.test(cm[2]), description: '' });
574
+ }
575
+ if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'diesel' });
576
+ }
577
+ });
578
+ return { entities, relationships: [] };
579
+ }
580
+
581
+ // ── Go: structs with json/gorm/db tags ───────────────────────────────────────
582
+
583
+ function scanGoModels(dir) {
584
+ const entities = [];
585
+ walkDir(dir, (filePath) => {
586
+ if (!filePath.endsWith('.go')) return;
587
+ const content = readFileSafe(filePath);
588
+ if (!content || !/`[^`]*\b(json|gorm|db|bson):/.test(content)) return;
589
+ const structRe = /type\s+(\w+)\s+struct\s*\{([\s\S]*?)\}/g;
590
+ let m;
591
+ while ((m = structRe.exec(content)) !== null) {
592
+ const name = m[1];
593
+ const body = m[2];
594
+ const fields = [];
595
+ const fieldRe = /^\s*(\w+)\s+([\w*.\[\]]+)\s+`([^`]+)`/gm;
596
+ let fm;
597
+ while ((fm = fieldRe.exec(body)) !== null) {
598
+ const fname = fm[1];
599
+ const ftype = fm[2];
600
+ const tag = fm[3];
601
+ if (!/\b(json|gorm|db|bson):/.test(tag)) continue;
602
+ const required = !tag.includes('omitempty');
603
+ fields.push({ name: fname, type: ftype, required, description: '' });
604
+ }
605
+ if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'go-struct' });
606
+ }
607
+ });
608
+ return { entities, relationships: [] };
609
+ }
610
+
611
+ // ── Java/Kotlin: JPA @Entity ─────────────────────────────────────────────────
612
+
613
+ function scanJpaModels(dir) {
614
+ const entities = [];
615
+ walkDir(dir, (filePath) => {
616
+ if (!/\.(java|kt)$/.test(filePath)) return;
617
+ const content = readFileSafe(filePath);
618
+ if (!content || !content.includes('@Entity')) return;
619
+ const classRe = /@Entity[\s\S]*?class\s+(\w+)\s*(?:\([^)]*\))?\s*\{([\s\S]*?)^\}/gm;
620
+ let m;
621
+ while ((m = classRe.exec(content)) !== null) {
622
+ const name = m[1];
623
+ const body = m[2];
624
+ const fields = [];
625
+ const fieldRe = /(?:private|public|protected|val|var)\s+([\w<>]+)\s+(\w+)\s*[;=]/g;
626
+ let fm;
627
+ while ((fm = fieldRe.exec(body)) !== null) {
628
+ const ftype = fm[1];
629
+ const fname = fm[2];
630
+ if (/^(boolean|int|long|short|byte|float|double|char)$/.test(ftype) || /^[A-Z]/.test(ftype)) {
631
+ fields.push({ name: fname, type: ftype, required: true, description: '' });
632
+ }
633
+ }
634
+ if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'jpa' });
635
+ }
636
+ });
637
+ return { entities, relationships: [] };
638
+ }
639
+
640
+ // ── Rails: ActiveRecord migrations + schema.rb ───────────────────────────────
641
+
642
+ function scanRailsModels(dir) {
643
+ const entities = [];
644
+ walkDir(dir, (filePath) => {
645
+ if (!/db\/(migrate|schema\.rb)/.test(filePath) || !filePath.endsWith('.rb')) return;
646
+ const content = readFileSafe(filePath);
647
+ if (!content || !content.includes('create_table')) return;
648
+ const tableRe = /create_table\s+:(\w+)\s+do\s+\|t\|([\s\S]*?)end/g;
649
+ let m;
650
+ while ((m = tableRe.exec(content)) !== null) {
651
+ const name = m[1];
652
+ const body = m[2];
653
+ const fields = [{ name: 'id', type: 'integer', required: true, description: '' }];
654
+ const colRe = /t\.(string|text|integer|float|decimal|datetime|date|time|boolean|json|binary|references)\s+:(\w+)(?:\s*,\s*([^,\n]+))?/g;
655
+ let cm;
656
+ while ((cm = colRe.exec(body)) !== null) {
657
+ const required = !!cm[3] && /null:\s*false/.test(cm[3]);
658
+ fields.push({ name: cm[2], type: cm[1], required, description: '' });
659
+ }
660
+ entities.push({ name, fields, file: filePath, source: 'rails-migration' });
661
+ }
662
+ });
663
+ return { entities, relationships: [] };
664
+ }
665
+
493
666
  // ── Helpers ──────────────────────────────────────────────────────────────────
494
667
 
495
668
  function extractOpenAPIRelationships(schemas) {
@@ -526,7 +699,7 @@ function walkDir(dir, callback) {
526
699
  const fullPath = join(dir, entry.name);
527
700
  if (entry.isDirectory()) {
528
701
  walkDir(fullPath, callback);
529
- } else if (entry.isFile() && /\.(js|mjs|cjs|ts|tsx|jsx|py)$/.test(entry.name)) {
702
+ } else if (entry.isFile() && /\.(js|mjs|cjs|ts|tsx|jsx|py|rs|go|java|kt|rb)$/.test(entry.name)) {
530
703
  callback(fullPath);
531
704
  }
532
705
  }