docguard-cli 0.10.0 → 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 +23 -1
- package/cli/commands/diagnose.mjs +157 -52
- package/cli/commands/fix.mjs +113 -1
- package/cli/commands/generate.mjs +91 -0
- 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/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 +149 -0
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/validators/api-surface.mjs +112 -37
- package/cli/validators/changelog.mjs +3 -2
- package/cli/validators/metadata-sync.mjs +6 -1
- package/cli/validators/metrics-consistency.mjs +5 -2
- 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/docs/doc-sections.md +37 -0
- package/extensions/spec-kit-docguard/README.md +7 -4
- 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/sync.md +62 -0
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
package/cli/scanners/routes.mjs
CHANGED
|
@@ -60,6 +60,22 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
|
|
|
60
60
|
routes.push(...scanDjangoRoutes(dir));
|
|
61
61
|
}
|
|
62
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
|
+
|
|
63
79
|
if (framework.includes('FastAPI') || framework.includes('Flask')) {
|
|
64
80
|
routes.push(...scanFastAPIRoutes(dir));
|
|
65
81
|
}
|
|
@@ -357,6 +373,139 @@ function scanFastAPIRoutes(dir) {
|
|
|
357
373
|
return routes;
|
|
358
374
|
}
|
|
359
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
|
+
|
|
360
509
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
361
510
|
|
|
362
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
|
}
|
|
@@ -15,17 +15,23 @@
|
|
|
15
15
|
* downgraded to WARNING on heuristic code-scan only.
|
|
16
16
|
* - present-but-undocumented → WARNING (a real route missing from the docs).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
18
|
+
* Also flags MULTIPLE OpenAPI specs in the repo that disagree on their endpoint
|
|
19
|
+
* set (e.g. a served spec and a generated spec that have diverged).
|
|
20
|
+
*
|
|
21
|
+
* Returns { errors, warnings, passed, total, fixes, authoritativeSpec } — the
|
|
22
|
+
* `fixes` array lists deterministic remove-endpoint actions that
|
|
23
|
+
* `docguard fix --write` can apply without an LLM.
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
27
|
import { resolve, dirname, join } from 'node:path';
|
|
23
28
|
import { detectOpenAPI } from '../scanners/doc-tools.mjs';
|
|
24
29
|
import { scanRoutesDeep } from '../scanners/routes.mjs';
|
|
25
|
-
import { parseApiReferenceDoc, compareEndpoints } from '../scanners/api-doc.mjs';
|
|
30
|
+
import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
|
|
26
31
|
import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
|
|
27
32
|
|
|
28
33
|
const MAX_REPORTED = 15;
|
|
34
|
+
const API_DOC = 'docs-canonical/API-REFERENCE.md';
|
|
29
35
|
|
|
30
36
|
/** Walk up from a dir to the nearest enclosing package.json directory. */
|
|
31
37
|
function nearestPackageDir(projectDir, startDir) {
|
|
@@ -44,7 +50,8 @@ function nearestPackageDir(projectDir, startDir) {
|
|
|
44
50
|
* Build an ordered list of directories to search for an OpenAPI spec.
|
|
45
51
|
* The spec under the configured sourceRoot's package takes precedence over a
|
|
46
52
|
* (possibly stale) copy at the repo root — monorepos frequently keep a
|
|
47
|
-
* divergent root copy.
|
|
53
|
+
* divergent root copy. Only CANONICAL bases are searched (sourceRoot package,
|
|
54
|
+
* workspaces, repo root) — never worktrees / vendor / scan-tool dirs.
|
|
48
55
|
*/
|
|
49
56
|
function orderedSpecDirs(projectDir, config) {
|
|
50
57
|
const ordered = [];
|
|
@@ -65,18 +72,45 @@ function orderedSpecDirs(projectDir, config) {
|
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
75
|
+
* Enumerate every OpenAPI spec found in a canonical location, in priority order.
|
|
76
|
+
* @returns {Array<{ absPath: string, relPath: string, endpoints: object[] }>}
|
|
70
77
|
*/
|
|
71
|
-
function
|
|
78
|
+
export function findAllOpenApiSpecs(projectDir, config) {
|
|
79
|
+
const specs = [];
|
|
80
|
+
const seenAbs = new Set();
|
|
72
81
|
for (const dir of orderedSpecDirs(projectDir, config)) {
|
|
73
82
|
const oa = detectOpenAPI(dir);
|
|
74
|
-
if (oa.found
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
if (!oa.found || !oa.endpoints?.length) continue;
|
|
84
|
+
const absPath = resolve(dir, oa.path);
|
|
85
|
+
if (seenAbs.has(absPath)) continue;
|
|
86
|
+
seenAbs.add(absPath);
|
|
87
|
+
specs.push({
|
|
88
|
+
absPath,
|
|
89
|
+
relPath: absPath.startsWith(resolve(projectDir))
|
|
90
|
+
? absPath.slice(resolve(projectDir).length + 1)
|
|
91
|
+
: absPath,
|
|
92
|
+
endpoints: oa.endpoints.filter(e => e && e.method && e.path),
|
|
93
|
+
});
|
|
78
94
|
}
|
|
79
|
-
return
|
|
95
|
+
return specs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Detect divergence between multiple canonical OpenAPI specs.
|
|
100
|
+
* @returns {null | { specs, divergent: string[], authoritative: string }}
|
|
101
|
+
*/
|
|
102
|
+
export function detectSpecDivergence(projectDir, config) {
|
|
103
|
+
const specs = findAllOpenApiSpecs(projectDir, config);
|
|
104
|
+
if (specs.length < 2) return null;
|
|
105
|
+
|
|
106
|
+
const keySets = specs.map(s => new Set(s.endpoints.map(e => endpointKey(e.method, e.path))));
|
|
107
|
+
// Union and symmetric difference across all specs.
|
|
108
|
+
const union = new Set();
|
|
109
|
+
for (const ks of keySets) for (const k of ks) union.add(k);
|
|
110
|
+
const divergent = [...union].filter(k => !keySets.every(ks => ks.has(k)));
|
|
111
|
+
|
|
112
|
+
if (divergent.length === 0) return null;
|
|
113
|
+
return { specs, divergent, authoritative: specs[0].relPath };
|
|
80
114
|
}
|
|
81
115
|
|
|
82
116
|
function detectFramework(projectDir, config) {
|
|
@@ -96,12 +130,13 @@ function detectFramework(projectDir, config) {
|
|
|
96
130
|
* @returns {{ endpoints: Array<{method,path}>, confidence: 'spec'|'code'|'none', source: string }}
|
|
97
131
|
*/
|
|
98
132
|
export function resolveApiSurface(projectDir, config) {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
133
|
+
const specs = findAllOpenApiSpecs(projectDir, config);
|
|
134
|
+
if (specs.length > 0) {
|
|
135
|
+
const spec = specs[0]; // highest priority (sourceRoot first, root last)
|
|
101
136
|
return {
|
|
102
137
|
endpoints: spec.endpoints.map(e => ({ method: e.method, path: e.path })),
|
|
103
138
|
confidence: 'spec',
|
|
104
|
-
source: spec.
|
|
139
|
+
source: spec.relPath,
|
|
105
140
|
};
|
|
106
141
|
}
|
|
107
142
|
|
|
@@ -119,61 +154,101 @@ export function resolveApiSurface(projectDir, config) {
|
|
|
119
154
|
return { endpoints: [], confidence: 'none', source: null };
|
|
120
155
|
}
|
|
121
156
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Compute API-surface drift in a structured, reusable form.
|
|
159
|
+
* Used by the validator AND by `docguard fix --write`.
|
|
160
|
+
* @returns {{ applicable, confidence, source, documented, documentedButAbsent,
|
|
161
|
+
* presentButUndocumented, matched }}
|
|
162
|
+
*/
|
|
163
|
+
export function computeApiSurfaceDrift(projectDir, config) {
|
|
164
|
+
const apiDocPath = resolve(projectDir, API_DOC);
|
|
127
165
|
if (!existsSync(apiDocPath)) {
|
|
128
|
-
|
|
129
|
-
|
|
166
|
+
return { applicable: false, confidence: 'none', source: null,
|
|
167
|
+
documented: [], documentedButAbsent: [], presentButUndocumented: [], matched: [] };
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
const documented = parseApiReferenceDoc(readFileSync(apiDocPath, 'utf-8'));
|
|
133
171
|
const surface = resolveApiSurface(projectDir, config);
|
|
134
172
|
|
|
135
|
-
// If we cannot determine the actual surface, do not fabricate drift.
|
|
136
173
|
if (surface.confidence === 'none' || documented.length === 0) {
|
|
137
|
-
return {
|
|
174
|
+
return { applicable: false, confidence: surface.confidence, source: surface.source,
|
|
175
|
+
documented, documentedButAbsent: [], presentButUndocumented: [], matched: [] };
|
|
138
176
|
}
|
|
139
177
|
|
|
140
|
-
const
|
|
141
|
-
|
|
178
|
+
const cmp = compareEndpoints(documented, surface.endpoints);
|
|
179
|
+
return {
|
|
180
|
+
applicable: true,
|
|
181
|
+
confidence: surface.confidence,
|
|
182
|
+
source: surface.source,
|
|
183
|
+
documented,
|
|
184
|
+
documentedButAbsent: cmp.documentedButAbsent,
|
|
185
|
+
presentButUndocumented: cmp.presentButUndocumented,
|
|
186
|
+
matched: cmp.matched,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function validateApiSurface(projectDir, config) {
|
|
191
|
+
const errors = [];
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const fixes = [];
|
|
194
|
+
|
|
195
|
+
const drift = computeApiSurfaceDrift(projectDir, config);
|
|
196
|
+
|
|
197
|
+
// ── Multi-spec divergence (independent of the API-REFERENCE doc) ──
|
|
198
|
+
const divergence = detectSpecDivergence(projectDir, config);
|
|
199
|
+
if (divergence) {
|
|
200
|
+
const others = divergence.specs.slice(1).map(s => s.relPath).join(', ');
|
|
201
|
+
const sample = divergence.divergent.slice(0, 8).join(', ');
|
|
202
|
+
const more = divergence.divergent.length > 8 ? ` (+${divergence.divergent.length - 8} more)` : '';
|
|
203
|
+
warnings.push(
|
|
204
|
+
`Multiple OpenAPI specs disagree on ${divergence.divergent.length} endpoint(s): ` +
|
|
205
|
+
`${divergence.authoritative} (treated as authoritative) vs ${others}. Divergent: ${sample}${more}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!drift.applicable) {
|
|
210
|
+
// Nothing to validate against the API-REFERENCE doc.
|
|
211
|
+
return { errors, warnings, passed: 0, total: 0, fixes, authoritativeSpec: drift.source };
|
|
212
|
+
}
|
|
142
213
|
|
|
214
|
+
const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
|
|
143
215
|
const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
|
|
144
216
|
const passed = matched.length;
|
|
145
217
|
|
|
146
218
|
const trim = (arr) => {
|
|
147
219
|
const shown = arr.slice(0, MAX_REPORTED);
|
|
148
|
-
|
|
149
|
-
return { shown, extra };
|
|
220
|
+
return { shown, extra: arr.length - shown.length };
|
|
150
221
|
};
|
|
151
222
|
|
|
152
|
-
// documented-but-absent
|
|
223
|
+
// documented-but-absent → deterministic remove-endpoint fixes
|
|
153
224
|
if (documentedButAbsent.length) {
|
|
154
225
|
const { shown, extra } = trim(documentedButAbsent);
|
|
155
226
|
for (const e of shown) {
|
|
156
|
-
const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (
|
|
157
|
-
if (
|
|
227
|
+
const msg = `Documented endpoint not found in code: ${e.method} ${e.path} (${API_DOC})`;
|
|
228
|
+
if (confidence === 'spec') errors.push(msg);
|
|
158
229
|
else warnings.push(`${msg} [code-scan — verify]`);
|
|
159
230
|
}
|
|
160
231
|
if (extra > 0) {
|
|
161
232
|
const tail = `…and ${extra} more documented endpoint(s) not found in code`;
|
|
162
|
-
if (
|
|
233
|
+
if (confidence === 'spec') errors.push(tail);
|
|
163
234
|
else warnings.push(tail);
|
|
164
235
|
}
|
|
236
|
+
// Only spec-confirmed absences are safe to auto-remove.
|
|
237
|
+
if (confidence === 'spec') {
|
|
238
|
+
for (const e of documentedButAbsent) {
|
|
239
|
+
fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
165
242
|
}
|
|
166
243
|
|
|
167
|
-
// present-but-undocumented
|
|
244
|
+
// present-but-undocumented → warning (NOT auto-applied; needs a real block)
|
|
168
245
|
if (presentButUndocumented.length) {
|
|
169
246
|
const { shown, extra } = trim(presentButUndocumented);
|
|
170
247
|
for (const e of shown) {
|
|
171
|
-
warnings.push(`Undocumented endpoint in code: ${e.method} ${e.path} — add it to
|
|
172
|
-
}
|
|
173
|
-
if (extra > 0) {
|
|
174
|
-
warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
|
|
248
|
+
warnings.push(`Undocumented endpoint in code: ${e.method} ${e.path} — add it to ${API_DOC}`);
|
|
175
249
|
}
|
|
250
|
+
if (extra > 0) warnings.push(`…and ${extra} more undocumented endpoint(s) in code`);
|
|
176
251
|
}
|
|
177
252
|
|
|
178
|
-
return { errors, warnings, passed, total };
|
|
253
|
+
return { errors, warnings, passed, total, fixes, authoritativeSpec: source };
|
|
179
254
|
}
|
|
@@ -25,7 +25,7 @@ function getStagedFiles(projectDir) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function validateChangelog(projectDir, config) {
|
|
28
|
-
const results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0 };
|
|
28
|
+
const results = { name: 'changelog', errors: [], warnings: [], passed: 0, total: 0, fixes: [] };
|
|
29
29
|
|
|
30
30
|
const changelogPath = resolve(projectDir, config.requiredFiles.changelog);
|
|
31
31
|
if (!existsSync(changelogPath)) {
|
|
@@ -40,7 +40,8 @@ export function validateChangelog(projectDir, config) {
|
|
|
40
40
|
if (content.includes('[Unreleased]') || content.includes('[unreleased]')) {
|
|
41
41
|
results.passed++;
|
|
42
42
|
} else {
|
|
43
|
-
results.warnings.push('CHANGELOG.md: missing [Unreleased] section');
|
|
43
|
+
results.warnings.push('CHANGELOG.md: missing [Unreleased] section — fix with `docguard fix --write`');
|
|
44
|
+
results.fixes.push({ type: 'insert-changelog-unreleased', file: config.requiredFiles.changelog });
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Check it follows Keep a Changelog format (at least has ## headers)
|
|
@@ -23,6 +23,7 @@ const IGNORE_DIRS = new Set([
|
|
|
23
23
|
*/
|
|
24
24
|
export function validateMetadataSync(projectDir, config) {
|
|
25
25
|
const warnings = [];
|
|
26
|
+
const fixes = [];
|
|
26
27
|
let passed = 0;
|
|
27
28
|
let total = 0;
|
|
28
29
|
|
|
@@ -60,6 +61,7 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
60
61
|
warnings.push(
|
|
61
62
|
`${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
|
|
62
63
|
);
|
|
64
|
+
fixes.push({ type: 'replace-version', file: relPath, found: versionMatch[1], actual: currentVersion });
|
|
63
65
|
} else {
|
|
64
66
|
passed++;
|
|
65
67
|
}
|
|
@@ -120,6 +122,9 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
120
122
|
warnings.push(
|
|
121
123
|
`${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
|
|
122
124
|
);
|
|
125
|
+
if (!fixes.some(f => f.file === relPath && f.found === foundVersion)) {
|
|
126
|
+
fixes.push({ type: 'replace-version', file: relPath, found: foundVersion, actual: currentVersion });
|
|
127
|
+
}
|
|
123
128
|
} else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
|
|
124
129
|
total++;
|
|
125
130
|
passed++;
|
|
@@ -128,7 +133,7 @@ export function validateMetadataSync(projectDir, config) {
|
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
return { errors: [], warnings, passed, total };
|
|
136
|
+
return { errors: [], warnings, passed, total, fixes };
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -24,6 +24,7 @@ const IGNORE_DIRS = new Set([
|
|
|
24
24
|
*/
|
|
25
25
|
export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
26
26
|
const warnings = [];
|
|
27
|
+
const fixes = [];
|
|
27
28
|
let passed = 0;
|
|
28
29
|
let total = 0;
|
|
29
30
|
|
|
@@ -83,8 +84,10 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
83
84
|
const found = parseInt(match[1], 10);
|
|
84
85
|
if (found !== actuals[key] && found > 0) {
|
|
85
86
|
warnings.push(
|
|
86
|
-
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}.
|
|
87
|
+
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
|
|
87
88
|
);
|
|
89
|
+
// Deterministic, surgical token replacement — safe to auto-apply.
|
|
90
|
+
fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
|
|
88
91
|
} else {
|
|
89
92
|
passed++;
|
|
90
93
|
}
|
|
@@ -92,7 +95,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
return { errors: [], warnings, passed, total };
|
|
98
|
+
return { errors: [], warnings, passed, total, fixes };
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|