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.
- package/PHILOSOPHY.md +59 -106
- package/README.md +26 -3
- package/cli/commands/diagnose.mjs +171 -58
- package/cli/commands/diff.mjs +110 -137
- package/cli/commands/fix.mjs +152 -4
- package/cli/commands/generate.mjs +148 -27
- package/cli/commands/guard.mjs +45 -24
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/api-doc.mjs +122 -0
- package/cli/scanners/doc-tools.mjs +1 -1
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +194 -32
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-source.mjs +247 -0
- package/cli/validators/api-surface.mjs +254 -0
- package/cli/validators/architecture.mjs +4 -3
- package/cli/validators/changelog.mjs +45 -4
- package/cli/validators/doc-quality.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +9 -14
- package/cli/validators/docs-diff.mjs +117 -66
- package/cli/validators/docs-sync.mjs +30 -24
- package/cli/validators/drift.mjs +6 -2
- package/cli/validators/environment.mjs +43 -3
- package/cli/validators/freshness.mjs +4 -3
- package/cli/validators/metadata-sync.mjs +17 -7
- package/cli/validators/metrics-consistency.mjs +9 -4
- package/cli/validators/schema-sync.mjs +19 -10
- package/cli/validators/security.mjs +20 -7
- package/cli/validators/structure.mjs +8 -1
- package/cli/validators/test-spec.mjs +26 -17
- package/cli/validators/todo-tracking.mjs +21 -8
- package/cli/validators/traceability.mjs +61 -36
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/commands/docguard.guard.md +5 -4
- package/docs/doc-sections.md +37 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +8 -5
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/guard.md +6 -5
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- package/templates/commands/docguard.guard.md +3 -3
package/cli/scanners/routes.mjs
CHANGED
|
@@ -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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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) {
|
package/cli/scanners/schemas.mjs
CHANGED
|
@@ -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
|
}
|