docguard-cli 0.10.0 → 0.11.1

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 (44) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +23 -1
  3. package/cli/commands/diagnose.mjs +157 -52
  4. package/cli/commands/fix.mjs +113 -1
  5. package/cli/commands/generate.mjs +91 -0
  6. package/cli/commands/hooks.mjs +40 -2
  7. package/cli/commands/score.mjs +22 -0
  8. package/cli/commands/sync.mjs +123 -0
  9. package/cli/docguard.mjs +22 -0
  10. package/cli/scanners/cdk.mjs +10 -0
  11. package/cli/scanners/frontend.mjs +438 -0
  12. package/cli/scanners/iac.mjs +235 -0
  13. package/cli/scanners/integrations.mjs +116 -0
  14. package/cli/scanners/memory-plan.mjs +242 -0
  15. package/cli/scanners/project-type.mjs +310 -0
  16. package/cli/scanners/routes.mjs +149 -0
  17. package/cli/scanners/schemas.mjs +174 -1
  18. package/cli/shared-ignore.mjs +29 -2
  19. package/cli/shared-source.mjs +2 -1
  20. package/cli/validators/api-surface.mjs +112 -37
  21. package/cli/validators/changelog.mjs +3 -2
  22. package/cli/validators/docs-coverage.mjs +125 -6
  23. package/cli/validators/docs-sync.mjs +49 -8
  24. package/cli/validators/metadata-sync.mjs +6 -1
  25. package/cli/validators/metrics-consistency.mjs +5 -2
  26. package/cli/validators/test-spec.mjs +129 -11
  27. package/cli/validators/todo-tracking.mjs +55 -2
  28. package/cli/writers/api-reference.mjs +101 -0
  29. package/cli/writers/mechanical.mjs +116 -0
  30. package/cli/writers/sections.mjs +148 -0
  31. package/commands/docguard.fix.md +19 -3
  32. package/docs/doc-sections.md +37 -0
  33. package/extensions/spec-kit-docguard/README.md +7 -4
  34. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  35. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  36. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  37. package/extensions/spec-kit-docguard/extension.yml +1 -1
  38. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +13 -3
  39. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  40. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  41. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  42. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  43. package/package.json +1 -1
  44. package/templates/ARCHITECTURE.md.template +52 -0
@@ -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) {
@@ -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,6 +15,31 @@
15
15
  * Zero NPM dependencies — pure Node.js built-ins only.
16
16
  */
17
17
 
18
+ /**
19
+ * Canonical set of directory names that should never be scanned, regardless
20
+ * of validator. Build outputs, VCS internals, package caches, framework synth
21
+ * outputs. Validators MAY extend this with their own additions but SHOULD
22
+ * start from this base so behavior is consistent across the tool.
23
+ */
24
+ export const DEFAULT_IGNORE_DIRS = new Set([
25
+ // Package managers
26
+ 'node_modules', 'vendor', '.venv', '__pycache__',
27
+ // VCS
28
+ '.git', '.jj', '.hg', '.svn',
29
+ // Build outputs — JS/TS, Rust/Java, generic
30
+ 'dist', 'build', 'out', 'coverage', 'target', '.gradle',
31
+ // Framework synth/cache
32
+ '.next', '.nuxt', '.turbo', '.vercel', '.cache', '.svelte-kit', 'cdk.out',
33
+ // OS
34
+ '.DS_Store',
35
+ ]);
36
+
37
+ // Regex for paths that must always be rejected at any depth, regardless of
38
+ // the glob pattern matching them. These are duplicate file trees (worktrees)
39
+ // or runtime caches that should NEVER be treated as primary source.
40
+ const ALWAYS_REJECT_PATH_RE =
41
+ /(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
42
+
18
43
  /**
19
44
  * Convert a glob pattern to a RegExp.
20
45
  * Supports: * (any chars except /), ** (any path segments), . (literal dot).
@@ -111,8 +136,10 @@ function globToMatchRegex(pattern) {
111
136
  export function globMatch(relPath, patterns) {
112
137
  if (!relPath || !patterns || patterns.length === 0) return false;
113
138
 
114
- // Always reject paths containing node_modules at any depth
115
- if (/(?:^|[/\\])node_modules(?:[/\\]|$)/.test(relPath)) return false;
139
+ // Always reject paths inside node_modules / worktree copies / .jj at any
140
+ // depth. A user's testPatterns like "**/*.test.ts" would otherwise match
141
+ // duplicate trees under .claude/worktrees and inflate test counts.
142
+ if (ALWAYS_REJECT_PATH_RE.test(relPath)) return false;
116
143
 
117
144
  const regexes = patterns.map(p => globToMatchRegex(p));
118
145
  return regexes.some(r => r.test(relPath));
@@ -18,8 +18,9 @@ import { resolve, join, dirname, relative, extname } from 'node:path';
18
18
  import { shouldIgnore } from './shared-ignore.mjs';
19
19
 
20
20
  const IGNORE_DIRS = new Set([
21
- 'node_modules', '.git', '.next', 'dist', 'build',
21
+ 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
22
22
  'coverage', '.cache', '__pycache__', '.venv', 'vendor', '.turbo',
23
+ 'cdk.out',
23
24
  ]);
24
25
 
25
26
  const CODE_EXTENSIONS = new Set([
@@ -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
- * Returns { errors, warnings, passed, total } like the other validators.
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
- * Locate the authoritative OpenAPI spec across the monorepo.
69
- * Returns the FIRST spec found in priority order (sourceRoot first).
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 findOpenApiEndpoints(projectDir, config) {
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 && oa.endpoints?.length) {
75
- const endpoints = oa.endpoints.filter(e => e && e.method && e.path);
76
- if (endpoints.length) return { endpoints, path: oa.path };
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 null;
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 spec = findOpenApiEndpoints(projectDir, config);
100
- if (spec) {
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.path,
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
- export function validateApiSurface(projectDir, config) {
123
- const errors = [];
124
- const warnings = [];
125
-
126
- const apiDocPath = resolve(projectDir, 'docs-canonical/API-REFERENCE.md');
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
- // No API reference doc nothing to validate (not applicable).
129
- return { errors, warnings, passed: 0, total: 0 };
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 { errors, warnings, passed: documented.length, total: documented.length };
174
+ return { applicable: false, confidence: surface.confidence, source: surface.source,
175
+ documented, documentedButAbsent: [], presentButUndocumented: [], matched: [] };
138
176
  }
139
177
 
140
- const { documentedButAbsent, presentButUndocumented, matched } =
141
- compareEndpoints(documented, surface.endpoints);
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
- const extra = arr.length - shown.length;
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} (docs-canonical/API-REFERENCE.md)`;
157
- if (surface.confidence === 'spec') errors.push(msg);
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 (surface.confidence === 'spec') errors.push(tail);
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 docs-canonical/API-REFERENCE.md`);
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)