docguard-cli 0.9.11 → 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.
- package/README.md +3 -2
- package/cli/commands/diagnose.mjs +23 -15
- package/cli/commands/diff.mjs +110 -137
- package/cli/commands/fix.mjs +39 -3
- package/cli/commands/generate.mjs +57 -27
- package/cli/commands/guard.mjs +45 -24
- package/cli/docguard.mjs +0 -0
- package/cli/scanners/api-doc.mjs +122 -0
- package/cli/scanners/doc-tools.mjs +1 -1
- package/cli/scanners/routes.mjs +45 -32
- package/cli/shared-source.mjs +247 -0
- package/cli/validators/api-surface.mjs +179 -0
- package/cli/validators/architecture.mjs +4 -3
- package/cli/validators/changelog.mjs +42 -2
- package/cli/validators/doc-quality.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +9 -14
- package/cli/validators/docs-diff.mjs +117 -66
- package/cli/validators/docs-sync.mjs +30 -24
- package/cli/validators/drift.mjs +6 -2
- package/cli/validators/environment.mjs +43 -3
- package/cli/validators/freshness.mjs +4 -3
- package/cli/validators/metadata-sync.mjs +11 -6
- package/cli/validators/metrics-consistency.mjs +4 -2
- package/cli/validators/schema-sync.mjs +19 -10
- package/cli/validators/security.mjs +20 -7
- package/cli/validators/structure.mjs +8 -1
- package/cli/validators/test-spec.mjs +26 -17
- package/cli/validators/todo-tracking.mjs +21 -8
- package/cli/validators/traceability.mjs +61 -36
- package/commands/docguard.guard.md +5 -4
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/commands/guard.md +6 -5
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
- package/package.json +1 -1
- 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
|
-
<!--
|
|
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'} | <!--
|
|
615
|
-
| Production | ${stack.hosting || 'TBD'} | <!--
|
|
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
|
-
| <!--
|
|
748
|
+
| <!-- TBD --> | | | | |
|
|
719
749
|
|
|
720
750
|
| Status | Response |
|
|
721
751
|
|--------|----------|
|
|
@@ -742,7 +772,7 @@ ${routeDetails}`;
|
|
|
742
772
|
|----------|-------|
|
|
743
773
|
| **Status** |  |
|
|
744
774
|
| **Base URL** | \`http://localhost:3000\` |
|
|
745
|
-
| **Auth** | <!--
|
|
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
|
-
| <!--
|
|
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
|
-
| <!--
|
|
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% | <!--
|
|
1050
|
-
| Branch Coverage | 70% | <!--
|
|
1051
|
-
| Function Coverage | 80% | <!--
|
|
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 || '<!--
|
|
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 | <!--
|
|
1121
|
-
).join('\n') || '| <!--
|
|
1150
|
+
`| \`${v.name}\` | Environment Variable | <!-- TBD --> | Application |`
|
|
1151
|
+
).join('\n') || '| <!-- TBD --> | | | |'}
|
|
1122
1152
|
|
|
1123
1153
|
## Security Rules
|
|
1124
1154
|
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}`;
|
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',
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { resolve, join, relative, basename, extname, dirname } from 'node:path';
|
|
11
|
+
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
11
12
|
|
|
12
13
|
const IGNORE_DIRS = new Set([
|
|
13
14
|
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
@@ -19,9 +20,10 @@ const IGNORE_DIRS = new Set([
|
|
|
19
20
|
* @param {string} dir - Project root
|
|
20
21
|
* @param {object} stack - Detected tech stack
|
|
21
22
|
* @param {object} docTools - Detected doc tools (may include OpenAPI)
|
|
23
|
+
* @param {object} [opts] - { config } — config enables monorepo-aware source roots
|
|
22
24
|
* @returns {Array} Array of route objects { method, path, handler, file, auth, description }
|
|
23
25
|
*/
|
|
24
|
-
export function scanRoutesDeep(dir, stack, docTools) {
|
|
26
|
+
export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
|
|
25
27
|
// Priority 1: Use OpenAPI spec if available (most accurate)
|
|
26
28
|
if (docTools?.openapi?.found && docTools.openapi.endpoints?.length > 0) {
|
|
27
29
|
return docTools.openapi.endpoints.map(ep => ({
|
|
@@ -31,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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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) => {
|