docguard-cli 0.9.10 → 0.10.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 (38) hide show
  1. package/README.md +3 -2
  2. package/cli/commands/diagnose.mjs +23 -15
  3. package/cli/commands/diff.mjs +110 -137
  4. package/cli/commands/fix.mjs +39 -3
  5. package/cli/commands/generate.mjs +57 -27
  6. package/cli/commands/guard.mjs +45 -24
  7. package/cli/commands/score.mjs +24 -2
  8. package/cli/docguard.mjs +0 -0
  9. package/cli/scanners/api-doc.mjs +122 -0
  10. package/cli/scanners/doc-tools.mjs +1 -1
  11. package/cli/scanners/routes.mjs +45 -32
  12. package/cli/shared-ignore.mjs +43 -0
  13. package/cli/shared-source.mjs +247 -0
  14. package/cli/validators/api-surface.mjs +179 -0
  15. package/cli/validators/architecture.mjs +4 -3
  16. package/cli/validators/changelog.mjs +42 -2
  17. package/cli/validators/doc-quality.mjs +3 -2
  18. package/cli/validators/docs-coverage.mjs +9 -14
  19. package/cli/validators/docs-diff.mjs +128 -85
  20. package/cli/validators/docs-sync.mjs +30 -24
  21. package/cli/validators/drift.mjs +6 -2
  22. package/cli/validators/environment.mjs +43 -3
  23. package/cli/validators/freshness.mjs +4 -3
  24. package/cli/validators/metadata-sync.mjs +11 -6
  25. package/cli/validators/metrics-consistency.mjs +4 -2
  26. package/cli/validators/schema-sync.mjs +19 -10
  27. package/cli/validators/security.mjs +20 -7
  28. package/cli/validators/structure.mjs +8 -1
  29. package/cli/validators/test-spec.mjs +26 -17
  30. package/cli/validators/todo-tracking.mjs +21 -8
  31. package/cli/validators/traceability.mjs +61 -36
  32. package/commands/docguard.guard.md +5 -4
  33. package/docs/quickstart.md +1 -1
  34. package/extensions/spec-kit-docguard/README.md +1 -1
  35. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  36. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  37. package/package.json +1 -1
  38. package/templates/commands/docguard.guard.md +3 -3
@@ -311,6 +311,8 @@ function detectStack(dir) {
311
311
 
312
312
  // ── Project Scanner ────────────────────────────────────────────────────────
313
313
 
314
+
315
+
314
316
  function scanProject(dir) {
315
317
  const scan = {
316
318
  routes: [],
@@ -324,6 +326,30 @@ function scanProject(dir) {
324
326
  totalLines: 0,
325
327
  };
326
328
 
329
+ scanRoutes(dir, scan);
330
+ scanModels(dir, scan);
331
+ scanServices(dir, scan);
332
+ scanTests(dir, scan);
333
+ scanComponents(dir, scan);
334
+ scanMiddlewares(dir, scan);
335
+ scanEnvVars(dir, scan);
336
+
337
+ // Count files and lines
338
+ countFilesAndLines(dir, scan);
339
+
340
+ // ── Filter test files out of source lists ──
341
+ // Test files (*.test.*, *.spec.*, __tests__/) should NOT appear as source files
342
+ const isTestFile = (f) => f.includes('__tests__') || f.includes('__test__') || /\.(test|spec)\.[^.]+$/.test(f);
343
+ scan.routes = scan.routes.filter(f => !isTestFile(f));
344
+ scan.models = scan.models.filter(f => !isTestFile(f));
345
+ scan.services = scan.services.filter(f => !isTestFile(f));
346
+ scan.components = scan.components.filter(f => !isTestFile(f));
347
+ scan.middlewares = scan.middlewares.filter(f => !isTestFile(f));
348
+
349
+ return scan;
350
+ }
351
+
352
+ function scanRoutes(dir, scan) {
327
353
  // Find routes
328
354
  ['src/app/api', 'src/routes', 'routes', 'api', 'src/api'].forEach(routeDir => {
329
355
  const fullDir = resolve(dir, routeDir);
@@ -334,7 +360,10 @@ function scanProject(dir) {
334
360
  }
335
361
  }
336
362
  });
363
+ }
364
+
337
365
 
366
+ function scanModels(dir, scan) {
338
367
  // Find models/entities
339
368
  ['src/models', 'models', 'src/entities', 'entities', 'src/schema', 'schema', 'prisma'].forEach(modelDir => {
340
369
  const fullDir = resolve(dir, modelDir);
@@ -345,7 +374,10 @@ function scanProject(dir) {
345
374
  }
346
375
  }
347
376
  });
377
+ }
378
+
348
379
 
380
+ function scanServices(dir, scan) {
349
381
  // Find services
350
382
  ['src/services', 'services', 'src/lib', 'lib'].forEach(svcDir => {
351
383
  const fullDir = resolve(dir, svcDir);
@@ -356,7 +388,10 @@ function scanProject(dir) {
356
388
  }
357
389
  }
358
390
  });
391
+ }
392
+
359
393
 
394
+ function scanTests(dir, scan) {
360
395
  // Find tests — top-level test dirs
361
396
  ['tests', 'test', '__tests__', 'spec', 'e2e'].forEach(testDir => {
362
397
  const fullDir = resolve(dir, testDir);
@@ -416,7 +451,10 @@ function scanProject(dir) {
416
451
  break; // Use first found config
417
452
  }
418
453
  }
454
+ }
419
455
 
456
+
457
+ function scanComponents(dir, scan) {
420
458
  // Find components
421
459
  ['src/components', 'components', 'src/ui'].forEach(compDir => {
422
460
  const fullDir = resolve(dir, compDir);
@@ -427,7 +465,10 @@ function scanProject(dir) {
427
465
  }
428
466
  }
429
467
  });
468
+ }
469
+
430
470
 
471
+ function scanMiddlewares(dir, scan) {
431
472
  // Find middleware
432
473
  ['src/middleware', 'middleware', 'src/middlewares'].forEach(mwDir => {
433
474
  const fullDir = resolve(dir, mwDir);
@@ -438,7 +479,10 @@ function scanProject(dir) {
438
479
  }
439
480
  }
440
481
  });
482
+ }
483
+
441
484
 
485
+ function scanEnvVars(dir, scan) {
442
486
  // Parse .env.example for env vars
443
487
  const envExample = resolve(dir, '.env.example');
444
488
  if (existsSync(envExample)) {
@@ -451,20 +495,6 @@ function scanProject(dir) {
451
495
  }
452
496
  }
453
497
  }
454
-
455
- // Count files and lines
456
- countFilesAndLines(dir, scan);
457
-
458
- // ── Filter test files out of source lists ──
459
- // Test files (*.test.*, *.spec.*, __tests__/) should NOT appear as source files
460
- const isTestFile = (f) => f.includes('__tests__') || f.includes('__test__') || /\.(test|spec)\.[^.]+$/.test(f);
461
- scan.routes = scan.routes.filter(f => !isTestFile(f));
462
- scan.models = scan.models.filter(f => !isTestFile(f));
463
- scan.services = scan.services.filter(f => !isTestFile(f));
464
- scan.components = scan.components.filter(f => !isTestFile(f));
465
- scan.middlewares = scan.middlewares.filter(f => !isTestFile(f));
466
-
467
- return scan;
468
498
  }
469
499
 
470
500
  function countFilesAndLines(dir, scan) {
@@ -535,7 +565,7 @@ function generateArchitecture(dir, config, stack, scan, flags, docTools) {
535
565
  ## 1. Introduction & Goals
536
566
  <!-- arc42: §1 — Introduction and Goals -->
537
567
 
538
- <!-- TODO: Describe what this system does, who it's for, and key quality goals -->
568
+ <!-- TBD: Describe what this system does, who it's for, and key quality goals -->
539
569
  ${config.projectName} is a ${stack.framework || stack.language || 'software'} application.
540
570
 
541
571
  ### Quality Goals
@@ -611,8 +641,8 @@ See \\\`docs-canonical/DEPLOYMENT.md\\\` for details.
611
641
  | Environment | Infrastructure | URL |
612
642
  |-------------|---------------|-----|
613
643
  | Development | localhost | http://localhost:3000 |
614
- | Staging | ${stack.hosting || 'TBD'} | <!-- TODO --> |
615
- | Production | ${stack.hosting || 'TBD'} | <!-- TODO --> |
644
+ | Staging | ${stack.hosting || 'TBD'} | <!-- TBD --> |
645
+ | Production | ${stack.hosting || 'TBD'} | <!-- TBD --> |
616
646
 
617
647
  ## 8. Crosscutting Concepts
618
648
  <!-- arc42: §8 — Crosscutting Concepts -->
@@ -715,7 +745,7 @@ ${r.description ? `- **Description:** ${r.description}` : ''}
715
745
 
716
746
  | Parameter | In | Type | Required | Description |
717
747
  |-----------|-----|------|:--------:|-------------|
718
- | <!-- TODO --> | | | | |
748
+ | <!-- TBD --> | | | | |
719
749
 
720
750
  | Status | Response |
721
751
  |--------|----------|
@@ -742,7 +772,7 @@ ${routeDetails}`;
742
772
  |----------|-------|
743
773
  | **Status** | ![Status](https://img.shields.io/badge/status-draft-yellow) |
744
774
  | **Base URL** | \`http://localhost:3000\` |
745
- | **Auth** | <!-- TODO: Describe auth mechanism --> |
775
+ | **Auth** | <!-- TBD: Describe auth mechanism --> |
746
776
  | **Total Endpoints** | ${deepRoutes.length} |
747
777
  | **Source** | ${deepRoutes[0]?.source || 'code scan'} |
748
778
 
@@ -826,7 +856,7 @@ function generateDataModel(dir, config, stack, scan, flags, deepSchemas) {
826
856
 
827
857
  | Field | Type | Required | Default | Constraints | Description |
828
858
  |-------|------|----------|---------|-------------|-------------|
829
- | <!-- TODO: Fill in fields --> | | | | | |
859
+ | <!-- TBD: Fill in fields --> | | | | | |
830
860
  `;
831
861
  }
832
862
  const fieldRows = e.fields.map(f =>
@@ -913,7 +943,7 @@ ${erDiagram}
913
943
 
914
944
  | Table | Index Name | Fields | Type | Purpose |
915
945
  |-------|-----------|--------|------|---------|
916
- | <!-- TODO: Document indexes --> | | | | |
946
+ | <!-- TBD: Document indexes --> | | | | |
917
947
 
918
948
  ---
919
949
 
@@ -1046,9 +1076,9 @@ function generateTestSpec(dir, config, stack, scan, flags) {
1046
1076
 
1047
1077
  | Metric | Target | Current |
1048
1078
  |--------|:------:|:-------:|
1049
- | Line Coverage | 80% | <!-- TODO --> |
1050
- | Branch Coverage | 70% | <!-- TODO --> |
1051
- | Function Coverage | 80% | <!-- TODO --> |
1079
+ | Line Coverage | 80% | <!-- TBD --> |
1080
+ | Branch Coverage | 70% | <!-- TBD --> |
1081
+ | Function Coverage | 80% | <!-- TBD --> |
1052
1082
 
1053
1083
  ## Service-to-Test Map
1054
1084
 
@@ -1103,7 +1133,7 @@ function generateSecurity(dir, config, stack, scan, flags) {
1103
1133
 
1104
1134
  | Method | Provider | Token Type | Expiry |
1105
1135
  |--------|---------|-----------|--------|
1106
- | ${stack.auth || '<!-- TODO -->'} | | | |
1136
+ | ${stack.auth || '<!-- TBD -->'} | | | |
1107
1137
 
1108
1138
  ## Authorization
1109
1139
 
@@ -1117,8 +1147,8 @@ function generateSecurity(dir, config, stack, scan, flags) {
1117
1147
  | Secret | Storage | Rotation | Access |
1118
1148
  |--------|---------|----------|--------|
1119
1149
  ${scan.envVars.filter(v => isSecretVar(v.name)).map(v =>
1120
- `| \`${v.name}\` | Environment Variable | <!-- TODO --> | Application |`
1121
- ).join('\n') || '| <!-- TODO --> | | | |'}
1150
+ `| \`${v.name}\` | Environment Variable | <!-- TBD --> | Application |`
1151
+ ).join('\n') || '| <!-- TBD --> | | | |'}
1122
1152
 
1123
1153
  ## Security Rules
1124
1154
 
@@ -20,6 +20,7 @@ import { validateArchitecture } from '../validators/architecture.mjs';
20
20
  import { validateFreshness } from '../validators/freshness.mjs';
21
21
  import { validateTraceability } from '../validators/traceability.mjs';
22
22
  import { validateDocsDiff } from '../validators/docs-diff.mjs';
23
+ import { validateApiSurface } from '../validators/api-surface.mjs';
23
24
  import { validateMetadataSync } from '../validators/metadata-sync.mjs';
24
25
  import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
25
26
  import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
@@ -32,6 +33,38 @@ import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
32
33
  * Internal guard — returns structured data, no console output, no process.exit.
33
34
  * Used by diagnose, ci, and guard --format json.
34
35
  */
36
+ /**
37
+ * Classify a validator result into a status + quality badge.
38
+ *
39
+ * Critically, a check that found NOTHING to validate (no errors, no warnings,
40
+ * total === 0) — or that explicitly reports `applicable === false` — is status
41
+ * 'na' (not applicable), NOT 'pass'. This prevents a validator from rendering a
42
+ * confident green ✅ when it actually checked nothing (the root cause of the
43
+ * "clean bill of health on out-of-sync docs" incident).
44
+ */
45
+ export function classifyResult(result) {
46
+ const hasErrors = result.errors.length > 0;
47
+ const hasWarnings = result.warnings.length > 0;
48
+
49
+ if (!hasErrors && !hasWarnings && (result.applicable === false || result.total === 0)) {
50
+ return { status: 'na', quality: null };
51
+ }
52
+
53
+ const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
54
+
55
+ // Quality label: HIGH/MEDIUM/LOW (inspired by CJE quality stratification, Lopez et al. TRACE 2026)
56
+ let quality;
57
+ if (hasErrors) {
58
+ quality = 'LOW';
59
+ } else if (hasWarnings) {
60
+ quality = 'MEDIUM';
61
+ } else {
62
+ const ratio = result.total > 0 ? result.passed / result.total : 1;
63
+ quality = ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
64
+ }
65
+ return { status, quality };
66
+ }
67
+
35
68
  export function runGuardInternal(projectDir, config) {
36
69
  const validators = config.validators || {};
37
70
  const results = [];
@@ -40,7 +73,7 @@ export function runGuardInternal(projectDir, config) {
40
73
  { key: 'structure', name: 'Structure', fn: () => validateStructure(projectDir, config) },
41
74
  { key: 'structure', name: 'Doc Sections', fn: () => validateDocSections(projectDir, config) },
42
75
  { key: 'docsSync', name: 'Docs-Sync', fn: () => validateDocsSync(projectDir, config) },
43
- { key: 'drift', name: 'Drift', fn: () => validateDrift(projectDir, config) },
76
+ { key: 'drift', name: 'Drift-Comments', fn: () => validateDrift(projectDir, config) },
44
77
  { key: 'changelog', name: 'Changelog', fn: () => validateChangelog(projectDir, config) },
45
78
  { key: 'testSpec', name: 'Test-Spec', fn: () => validateTestSpec(projectDir, config) },
46
79
  { key: 'environment', name: 'Environment', fn: () => validateEnvironment(projectDir, config) },
@@ -60,6 +93,7 @@ export function runGuardInternal(projectDir, config) {
60
93
  }},
61
94
  { key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
62
95
  { key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
96
+ { key: 'apiSurface', name: 'API-Surface', fn: () => validateApiSurface(projectDir, config) },
63
97
  { key: 'metadataSync', name: 'Metadata-Sync', fn: () => validateMetadataSync(projectDir, config) },
64
98
  { key: 'docsCoverage', name: 'Docs-Coverage', fn: () => validateDocsCoverage(projectDir, config) },
65
99
  { key: 'docQuality', name: 'Doc-Quality', fn: () => validateDocQuality(projectDir, config) },
@@ -77,23 +111,7 @@ export function runGuardInternal(projectDir, config) {
77
111
 
78
112
  try {
79
113
  const result = fn();
80
- const hasErrors = result.errors.length > 0;
81
- const hasWarnings = result.warnings.length > 0;
82
- const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
83
-
84
- // Quality label: HIGH/MEDIUM/LOW (inspired by CJE quality stratification, Lopez et al. TRACE 2026)
85
- let quality;
86
- if (hasErrors) {
87
- quality = 'LOW';
88
- } else if (hasWarnings) {
89
- quality = 'MEDIUM';
90
- } else {
91
- // Pass — check coverage ratio for HIGH vs MEDIUM
92
- const ratio = result.total > 0 ? result.passed / result.total : 1;
93
- quality = ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
94
- }
95
-
96
- results.push({ ...result, name, key, status, quality });
114
+ results.push({ ...result, name, key, ...classifyResult(result) });
97
115
  } catch (err) {
98
116
  results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
99
117
  }
@@ -103,12 +121,7 @@ export function runGuardInternal(projectDir, config) {
103
121
  if (validators.metricsConsistency !== false) {
104
122
  try {
105
123
  const result = validateMetricsConsistency(projectDir, config, results);
106
- const hasErrors = result.errors.length > 0;
107
- const hasWarnings = result.warnings.length > 0;
108
- const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
109
- const ratio = result.total > 0 ? result.passed / result.total : 1;
110
- const quality = hasErrors ? 'LOW' : hasWarnings ? 'MEDIUM' : ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
111
- results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', status, quality });
124
+ results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', ...classifyResult(result) });
112
125
  } catch (err) {
113
126
  results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
114
127
  }
@@ -161,6 +174,14 @@ export function runGuard(projectDir, config, flags) {
161
174
  continue;
162
175
  }
163
176
 
177
+ // Not applicable — nothing to validate. Render neutrally (NOT a green pass)
178
+ // so the reader can tell "checked and clean" apart from "nothing checked".
179
+ if (v.status === 'na') {
180
+ const reason = v.note ? ` ${c.dim}(${v.note})${c.reset}` : ` ${c.dim}(nothing to validate)${c.reset}`;
181
+ console.log(` ${c.dim}➖ ${v.name}${c.reset} ${c.dim}[N/A]${c.reset}${reason}`);
182
+ continue;
183
+ }
184
+
164
185
  // Quality label badge
165
186
  const qColor = v.quality === 'HIGH' ? c.green : v.quality === 'MEDIUM' ? c.yellow : c.red;
166
187
  const qBadge = `${qColor}[${v.quality}]${c.reset}`;
@@ -494,8 +494,30 @@ function calcTestingScore(dir, config) {
494
494
  }
495
495
 
496
496
  // ── Check 4: CI test step (15 pts) ──
497
- const ciFiles = ['.github/workflows/ci.yml', '.github/workflows/test.yml'];
498
- const hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
497
+ // Support multiple CI systems — not just GitHub Actions
498
+ const ciFiles = [
499
+ '.github/workflows/ci.yml', '.github/workflows/test.yml',
500
+ '.github/workflows/ci.yaml', '.github/workflows/test.yaml',
501
+ 'buildspec.yml', 'buildspec.test.yml', // AWS CodeBuild
502
+ 'amplify.yml', // AWS Amplify
503
+ 'Jenkinsfile', // Jenkins
504
+ '.circleci/config.yml', // CircleCI
505
+ '.gitlab-ci.yml', // GitLab CI
506
+ '.travis.yml', // Travis CI
507
+ ];
508
+ let hasCITest = ciFiles.some(f => existsSync(resolve(dir, f)));
509
+
510
+ // Also check turbo.json for "test" pipeline task
511
+ if (!hasCITest) {
512
+ const turboPath = resolve(dir, 'turbo.json');
513
+ if (existsSync(turboPath)) {
514
+ try {
515
+ const turboContent = readFileSync(turboPath, 'utf-8');
516
+ if (/"test"/.test(turboContent)) hasCITest = true;
517
+ } catch { /* skip */ }
518
+ }
519
+ }
520
+
499
521
  if (hasCITest) score += 15;
500
522
 
501
523
  return Math.min(100, score);
package/cli/docguard.mjs CHANGED
File without changes
@@ -0,0 +1,122 @@
1
+ /**
2
+ * API Documentation Parser
3
+ *
4
+ * Extracts API endpoints from a canonical API doc (API-REFERENCE.md) robustly,
5
+ * and provides normalized comparison primitives shared by `docguard diff` and
6
+ * the API-Surface guard validator.
7
+ *
8
+ * Handles two documentation styles:
9
+ * 1. Headings: `#### GET /api/admin/users` (method + path, backticks optional)
10
+ * 2. Table rows: | `GET` | `/api/admin/users` | ... |
11
+ *
12
+ * Normalization makes comparison reliable:
13
+ * - method split into its own field, upper-cased
14
+ * - path params unified: `:id` ≡ `{id}` → `{}` placeholder
15
+ * - trailing slashes, backticks, and table pipes stripped
16
+ * - query strings / fragments removed
17
+ *
18
+ * Zero NPM dependencies — pure Node.js built-ins only.
19
+ */
20
+
21
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
22
+
23
+ /**
24
+ * Normalize an API path for comparison.
25
+ * Strips decoration and collapses param syntax so `:id` and `{id}` match.
26
+ * @param {string} raw
27
+ * @returns {string} normalized path (e.g. "/api/users/{}") or '' if not a path
28
+ */
29
+ export function normalizePath(raw) {
30
+ if (!raw) return '';
31
+ let p = String(raw).trim();
32
+ // strip surrounding backticks / pipes / quotes / whitespace
33
+ p = p.replace(/^[|`'"\s]+/, '').replace(/[|`'"\s]+$/, '');
34
+ // cut query string / fragment
35
+ p = p.split(/[?#]/)[0];
36
+ if (!p.startsWith('/')) return '';
37
+ // collapse param syntax: :param and {param} → {}
38
+ p = p.replace(/\{[^}/]+\}/g, '{}').replace(/:[^/]+/g, '{}');
39
+ // strip trailing slash (but keep root "/")
40
+ if (p.length > 1) p = p.replace(/\/+$/, '');
41
+ return p;
42
+ }
43
+
44
+ /** Build a canonical comparison key for an endpoint. */
45
+ export function endpointKey(method, path) {
46
+ return `${String(method).toUpperCase()} ${normalizePath(path)}`;
47
+ }
48
+
49
+ /**
50
+ * Parse endpoints documented in an API reference markdown string.
51
+ * @param {string} content
52
+ * @returns {Array<{ method: string, path: string, key: string }>}
53
+ */
54
+ export function parseApiReferenceDoc(content) {
55
+ if (!content) return [];
56
+ const found = new Map(); // key → { method, path, key }
57
+
58
+ const addEndpoint = (method, rawPath) => {
59
+ const m = String(method).toUpperCase();
60
+ if (!HTTP_METHODS.has(m)) return;
61
+ const path = normalizePath(rawPath);
62
+ if (!path || path.length < 2) return;
63
+ const key = `${m} ${path}`;
64
+ if (!found.has(key)) found.set(key, { method: m, path, key });
65
+ };
66
+
67
+ const lines = content.split('\n');
68
+
69
+ // Style 1: headings — "#### GET `/api/...`" (method + path, backticks optional)
70
+ const headingRe = /^#{2,6}\s+`?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)`?\s+`?(\/[^\s`|]+)`?/i;
71
+
72
+ // Style 2: table rows — "| `GET` | `/api/...` | ... |"
73
+ for (const line of lines) {
74
+ const h = line.match(headingRe);
75
+ if (h) {
76
+ addEndpoint(h[1], h[2]);
77
+ continue;
78
+ }
79
+
80
+ if (line.includes('|')) {
81
+ const cells = line.split('|').map(s => s.trim()).filter(s => s.length > 0);
82
+ if (cells.length >= 2) {
83
+ const c0 = cells[0].replace(/`/g, '').trim().toUpperCase();
84
+ if (HTTP_METHODS.has(c0)) {
85
+ // path is the next cell that looks like a route
86
+ for (let i = 1; i < cells.length; i++) {
87
+ const cand = cells[i].replace(/`/g, '').trim();
88
+ if (cand.startsWith('/')) { addEndpoint(c0, cand); break; }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return [...found.values()];
96
+ }
97
+
98
+ /**
99
+ * Compare a documented endpoint set against an actual endpoint set.
100
+ * @param {Array<{method,path}>} documented
101
+ * @param {Array<{method,path}>} actual
102
+ * @returns {{ documentedButAbsent: object[], presentButUndocumented: object[], matched: object[] }}
103
+ */
104
+ export function compareEndpoints(documented, actual) {
105
+ const docMap = new Map();
106
+ for (const e of documented) docMap.set(endpointKey(e.method, e.path), e);
107
+ const actMap = new Map();
108
+ for (const e of actual) actMap.set(endpointKey(e.method, e.path), e);
109
+
110
+ const documentedButAbsent = [];
111
+ const matched = [];
112
+ for (const [key, e] of docMap) {
113
+ if (actMap.has(key)) matched.push(e);
114
+ else documentedButAbsent.push(e);
115
+ }
116
+ const presentButUndocumented = [];
117
+ for (const [key, e] of actMap) {
118
+ if (!docMap.has(key)) presentButUndocumented.push(e);
119
+ }
120
+
121
+ return { documentedButAbsent, presentButUndocumented, matched };
122
+ }
@@ -36,7 +36,7 @@ export function detectDocTools(dir) {
36
36
 
37
37
  // ── OpenAPI / Swagger Spec ─────────────────────────────────────────────────
38
38
 
39
- function detectOpenAPI(dir) {
39
+ export function detectOpenAPI(dir) {
40
40
  const candidates = [
41
41
  'openapi.yaml', 'openapi.yml', 'openapi.json',
42
42
  'swagger.yaml', 'swagger.yml', 'swagger.json',
@@ -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,24 +33,27 @@ 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')) {
@@ -167,13 +172,16 @@ function scanNextJsRoutes(dir) {
167
172
 
168
173
  // ── Express / Generic Node.js ───────────────────────────────────────────────
169
174
 
170
- function scanExpressRoutes(dir) {
175
+ function scanExpressRoutes(dir, roots = null) {
171
176
  const routes = [];
172
177
  const routePattern = /(?:app|router|server)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
173
178
 
174
- const searchDirs = ['src', 'routes', 'api', 'server', 'lib'];
175
- for (const searchDir of searchDirs) {
176
- const fullDir = resolve(dir, searchDir);
179
+ // Monorepo-aware: walk resolved absolute source roots when provided,
180
+ // otherwise fall back to conventional root-relative directories.
181
+ const searchTargets = roots && roots.length
182
+ ? roots
183
+ : ['src', 'routes', 'api', 'server', 'lib'].map(d => resolve(dir, d));
184
+ for (const fullDir of searchTargets) {
177
185
  if (!existsSync(fullDir)) continue;
178
186
 
179
187
  walkRouteDirs(fullDir, (filePath) => {
@@ -224,42 +232,47 @@ function scanExpressRoutes(dir) {
224
232
 
225
233
  // ── Fastify ─────────────────────────────────────────────────────────────────
226
234
 
227
- function scanFastifyRoutes(dir) {
235
+ function scanFastifyRoutes(dir, roots = null) {
228
236
  const routes = [];
229
237
  const pattern = /(?:fastify|server|app)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
230
238
 
231
- walkRouteDirs(resolve(dir, 'src'), (filePath) => {
232
- if (!isJSFile(filePath)) return;
233
- const content = readFileSafe(filePath);
234
- if (!content) return;
239
+ const searchTargets = roots && roots.length ? roots : [resolve(dir, 'src')];
240
+ for (const fullDir of searchTargets) {
241
+ if (!existsSync(fullDir)) continue;
242
+ walkRouteDirs(fullDir, (filePath) => {
243
+ if (!isJSFile(filePath)) return;
244
+ const content = readFileSafe(filePath);
245
+ if (!content) return;
235
246
 
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
- });
247
+ let match;
248
+ const regex = new RegExp(pattern.source, 'gi');
249
+ while ((match = regex.exec(content)) !== null) {
250
+ routes.push({
251
+ method: match[1].toUpperCase(),
252
+ path: match[2],
253
+ handler: extractHandlerName(content, match.index),
254
+ file: relative(dir, filePath),
255
+ source: 'fastify',
256
+ auth: hasAuthCheck(content),
257
+ description: extractNearbyComment(content, match.index),
258
+ });
259
+ }
260
+ });
261
+ }
250
262
 
251
263
  return routes;
252
264
  }
253
265
 
254
266
  // ── Hono ────────────────────────────────────────────────────────────────────
255
267
 
256
- function scanHonoRoutes(dir) {
268
+ function scanHonoRoutes(dir, roots = null) {
257
269
  const routes = [];
258
270
  const pattern = /(?:app|router)\s*\.\s*(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
259
271
 
260
- const searchDirs = ['src', '.'];
261
- for (const searchDir of searchDirs) {
262
- const fullDir = resolve(dir, searchDir);
272
+ const searchTargets = roots && roots.length
273
+ ? roots
274
+ : ['src', '.'].map(d => resolve(dir, d));
275
+ for (const fullDir of searchTargets) {
263
276
  if (!existsSync(fullDir)) continue;
264
277
 
265
278
  walkRouteDirs(fullDir, (filePath) => {