docguard-cli 0.8.0 → 0.8.2
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 +31 -3
- package/cli/commands/diagnose.mjs +15 -8
- package/cli/commands/fix.mjs +3 -2
- package/cli/commands/generate.mjs +59 -1
- package/cli/commands/guard.mjs +21 -0
- package/cli/shared.mjs +41 -0
- package/cli/validators/docs-coverage.mjs +387 -0
- package/cli/validators/metadata-sync.mjs +179 -0
- package/cli/validators/metrics-consistency.mjs +166 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,27 @@ diagnose → AI reads prompts → AI fixes docs → guard verifies
|
|
|
81
81
|
|
|
82
82
|
---
|
|
83
83
|
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Initialize CDD docs for your project
|
|
88
|
+
npx docguard-cli init
|
|
89
|
+
|
|
90
|
+
# Reverse-engineer docs from existing code
|
|
91
|
+
npx docguard-cli generate
|
|
92
|
+
|
|
93
|
+
# Validate project — use as CI gate
|
|
94
|
+
npx docguard-cli guard
|
|
95
|
+
|
|
96
|
+
# Get AI-actionable fix prompts
|
|
97
|
+
npx docguard-cli diagnose
|
|
98
|
+
|
|
99
|
+
# Check CDD maturity score
|
|
100
|
+
npx docguard-cli score
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
84
105
|
## 13 Commands
|
|
85
106
|
|
|
86
107
|
### 🔮 Generate — Reverse-engineer docs from code
|
|
@@ -202,7 +223,7 @@ $ npx docguard-cli guard
|
|
|
202
223
|
|
|
203
224
|
---
|
|
204
225
|
|
|
205
|
-
##
|
|
226
|
+
## 14 Validators
|
|
206
227
|
|
|
207
228
|
| # | Validator | What It Checks | Default |
|
|
208
229
|
|---|-----------|---------------|---------|
|
|
@@ -213,8 +234,15 @@ $ npx docguard-cli guard
|
|
|
213
234
|
| 5 | **Changelog** | CHANGELOG.md has [Unreleased] section | ✅ On |
|
|
214
235
|
| 6 | **Test-Spec** | Tests exist per TEST-SPEC.md rules | ✅ On |
|
|
215
236
|
| 7 | **Environment** | Env vars documented, .env.example exists | ✅ On |
|
|
216
|
-
| 8 | **Security** | No hardcoded secrets in source code |
|
|
217
|
-
| 9 | **Architecture** | Imports follow layer boundaries |
|
|
237
|
+
| 8 | **Security** | No hardcoded secrets in source code | ✅ On |
|
|
238
|
+
| 9 | **Architecture** | Imports follow layer boundaries | ✅ On |
|
|
239
|
+
| 10 | **Freshness** | Docs not stale relative to code changes | ✅ On |
|
|
240
|
+
| 11 | **Traceability** | Canonical docs linked to source code | ✅ On |
|
|
241
|
+
| 12 | **Docs-Diff** | Code artifacts match documented entities | ✅ On |
|
|
242
|
+
| 13 | **Metadata-Sync** | Version refs consistent across docs | ✅ On |
|
|
243
|
+
| 14 | **Docs-Coverage** | Code features referenced in documentation | ✅ On |
|
|
244
|
+
| 15 | **Metrics-Consistency** | Hardcoded numbers match actual counts | ✅ On |
|
|
245
|
+
| 16 | **Docs-Sync** | Source files have matching doc entries | ✅ On |
|
|
218
246
|
|
|
219
247
|
---
|
|
220
248
|
|
|
@@ -17,7 +17,8 @@ import { c } from '../shared.mjs';
|
|
|
17
17
|
import { runGuardInternal } from './guard.mjs';
|
|
18
18
|
import { runScoreInternal } from './score.mjs';
|
|
19
19
|
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
20
|
-
import { resolve } from 'node:path';
|
|
20
|
+
import { resolve, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { execSync } from 'node:child_process';
|
|
22
23
|
|
|
23
24
|
// Map validator failures to the right fix --doc target
|
|
@@ -103,16 +104,16 @@ export function runDiagnose(projectDir, config, flags) {
|
|
|
103
104
|
// ── Step 2: Collect issues ──
|
|
104
105
|
let issues = collectIssues(guardData);
|
|
105
106
|
|
|
106
|
-
// ── Step 3: Auto-fix
|
|
107
|
-
const shouldAutoFix =
|
|
108
|
-
if (
|
|
107
|
+
// ── Step 3: Auto-fix (only with --auto flag) or suggest fixes ──
|
|
108
|
+
const shouldAutoFix = flags.auto && flags.format !== 'json';
|
|
109
|
+
if (issues.length > 0) {
|
|
109
110
|
const autoFixable = issues.filter(i => i.autoFixable);
|
|
110
111
|
const hasStructural = issues.some(i => i.validator === 'Structure');
|
|
111
112
|
|
|
112
|
-
if (hasStructural || autoFixable.length > 0) {
|
|
113
|
+
if (shouldAutoFix && (hasStructural || autoFixable.length > 0)) {
|
|
113
114
|
// Run init to create missing files
|
|
114
115
|
try {
|
|
115
|
-
const cliPath = resolve(import.meta.
|
|
116
|
+
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docguard.mjs');
|
|
116
117
|
execSync(`node "${cliPath}" init --dir "${projectDir}"`, {
|
|
117
118
|
encoding: 'utf-8',
|
|
118
119
|
stdio: 'pipe',
|
|
@@ -121,7 +122,7 @@ export function runDiagnose(projectDir, config, flags) {
|
|
|
121
122
|
|
|
122
123
|
// Run generate to fill in content
|
|
123
124
|
try {
|
|
124
|
-
const cliPath = resolve(import.meta.
|
|
125
|
+
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docguard.mjs');
|
|
125
126
|
execSync(`node "${cliPath}" generate --dir "${projectDir}" --force`, {
|
|
126
127
|
encoding: 'utf-8',
|
|
127
128
|
stdio: 'pipe',
|
|
@@ -138,6 +139,12 @@ export function runDiagnose(projectDir, config, flags) {
|
|
|
138
139
|
console.log(` ${c.green}⚡ Auto-fixed ${fixedCount} issue(s)${c.reset} (created/regenerated docs)\n`);
|
|
139
140
|
}
|
|
140
141
|
}
|
|
142
|
+
} else if (!shouldAutoFix && (hasStructural || autoFixable.length > 0) && (!flags.format || flags.format === 'text')) {
|
|
143
|
+
// Suggest-only mode: tell user what they can do
|
|
144
|
+
console.log(` ${c.yellow}💡 ${autoFixable.length + (hasStructural ? 1 : 0)} issue(s) can be auto-fixed.${c.reset} Run with ${c.cyan}--auto${c.reset} to create/regenerate docs, or manually:`);
|
|
145
|
+
if (hasStructural) console.log(` ${c.dim}docguard init --dir .${c.reset}`);
|
|
146
|
+
if (autoFixable.length > 0) console.log(` ${c.dim}docguard generate --dir . --force${c.reset}`);
|
|
147
|
+
console.log('');
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
|
|
@@ -340,7 +347,7 @@ function outputPrompt(projectDir, guardData, scoreData, issues, flags) {
|
|
|
340
347
|
lines.push('VALIDATION:');
|
|
341
348
|
lines.push('After making all fixes, run: docguard guard');
|
|
342
349
|
lines.push('Expected result: All checks pass (0 errors, 0 warnings)');
|
|
343
|
-
lines.push(`Target score: ≥${scoreData.score + 5}/100`);
|
|
350
|
+
lines.push(`Target score: ≥${Math.min(scoreData.score + 5, 100)}/100`);
|
|
344
351
|
|
|
345
352
|
// Agent-aware: add explicit checklist for basic-tier agents
|
|
346
353
|
if (agentTier === 'basic') {
|
package/cli/commands/fix.mjs
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
17
|
-
import { resolve, basename } from 'node:path';
|
|
17
|
+
import { resolve, basename, dirname } from 'node:path';
|
|
18
18
|
import { execSync } from 'node:child_process';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { c } from '../shared.mjs';
|
|
20
21
|
|
|
21
22
|
// ── Document Quality Definitions ───────────────────────────────────────────
|
|
@@ -471,7 +472,7 @@ function autoFixIssues(projectDir, config, issues) {
|
|
|
471
472
|
}
|
|
472
473
|
|
|
473
474
|
try {
|
|
474
|
-
const cliPath = resolve(import.meta.
|
|
475
|
+
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docguard.mjs');
|
|
475
476
|
execSync(`node ${cliPath} init --dir "${projectDir}"`, {
|
|
476
477
|
encoding: 'utf-8',
|
|
477
478
|
stdio: 'pipe',
|
|
@@ -318,7 +318,7 @@ function scanProject(dir) {
|
|
|
318
318
|
}
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
-
// Find tests
|
|
321
|
+
// Find tests — top-level test dirs
|
|
322
322
|
['tests', 'test', '__tests__', 'spec', 'e2e'].forEach(testDir => {
|
|
323
323
|
const fullDir = resolve(dir, testDir);
|
|
324
324
|
if (existsSync(fullDir)) {
|
|
@@ -329,6 +329,55 @@ function scanProject(dir) {
|
|
|
329
329
|
}
|
|
330
330
|
});
|
|
331
331
|
|
|
332
|
+
// Find co-located tests: src/**/__tests__/ and src/**/*.test.* / src/**/*.spec.*
|
|
333
|
+
const srcDir = resolve(dir, 'src');
|
|
334
|
+
if (existsSync(srcDir)) {
|
|
335
|
+
walkDir(srcDir, (filePath) => {
|
|
336
|
+
const rel = relative(dir, filePath);
|
|
337
|
+
const isTestDir = rel.includes('__tests__') || rel.includes('__test__');
|
|
338
|
+
const isTestFile = /\.(test|spec)\.[^.]+$/.test(rel);
|
|
339
|
+
if ((isTestDir || isTestFile) && !scan.tests.includes(rel)) {
|
|
340
|
+
scan.tests.push(rel);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Read vitest/jest config for custom test patterns
|
|
346
|
+
const testConfigs = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts', 'jest.config.ts', 'jest.config.js'];
|
|
347
|
+
for (const cfgFile of testConfigs) {
|
|
348
|
+
const cfgPath = resolve(dir, cfgFile);
|
|
349
|
+
if (existsSync(cfgPath)) {
|
|
350
|
+
try {
|
|
351
|
+
const cfgContent = readFileSync(cfgPath, 'utf-8');
|
|
352
|
+
// Extract include patterns like: include: ['src/**/*.test.ts']
|
|
353
|
+
const includeMatch = cfgContent.match(/include\s*:\s*\[([^\]]+)\]/);
|
|
354
|
+
if (includeMatch) {
|
|
355
|
+
// Parse the test root from the pattern (e.g., 'src/**/*.test.ts' → 'src')
|
|
356
|
+
const patterns = includeMatch[1].match(/['"]([^'"]+)['"]/g);
|
|
357
|
+
if (patterns) {
|
|
358
|
+
for (const p of patterns) {
|
|
359
|
+
const pattern = p.replace(/['"]|\s/g, '');
|
|
360
|
+
// Extract root dir from glob (e.g., 'src/**/*.test.ts' → 'src')
|
|
361
|
+
const rootDir = pattern.split('/')[0];
|
|
362
|
+
if (rootDir && rootDir !== '**' && rootDir !== '*') {
|
|
363
|
+
const fullDir = resolve(dir, rootDir);
|
|
364
|
+
if (existsSync(fullDir)) {
|
|
365
|
+
walkDir(fullDir, (filePath) => {
|
|
366
|
+
const rel = relative(dir, filePath);
|
|
367
|
+
if (/\.(test|spec)\.[^.]+$/.test(rel) && !scan.tests.includes(rel)) {
|
|
368
|
+
scan.tests.push(rel);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch { /* config parse may fail */ }
|
|
377
|
+
break; // Use first found config
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// Find components
|
|
333
382
|
['src/components', 'components', 'src/ui'].forEach(compDir => {
|
|
334
383
|
const fullDir = resolve(dir, compDir);
|
|
@@ -367,6 +416,15 @@ function scanProject(dir) {
|
|
|
367
416
|
// Count files and lines
|
|
368
417
|
countFilesAndLines(dir, scan);
|
|
369
418
|
|
|
419
|
+
// ── Filter test files out of source lists ──
|
|
420
|
+
// Test files (*.test.*, *.spec.*, __tests__/) should NOT appear as source files
|
|
421
|
+
const isTestFile = (f) => f.includes('__tests__') || f.includes('__test__') || /\.(test|spec)\.[^.]+$/.test(f);
|
|
422
|
+
scan.routes = scan.routes.filter(f => !isTestFile(f));
|
|
423
|
+
scan.models = scan.models.filter(f => !isTestFile(f));
|
|
424
|
+
scan.services = scan.services.filter(f => !isTestFile(f));
|
|
425
|
+
scan.components = scan.components.filter(f => !isTestFile(f));
|
|
426
|
+
scan.middlewares = scan.middlewares.filter(f => !isTestFile(f));
|
|
427
|
+
|
|
370
428
|
return scan;
|
|
371
429
|
}
|
|
372
430
|
|
package/cli/commands/guard.mjs
CHANGED
|
@@ -19,6 +19,9 @@ import { validateArchitecture } from '../validators/architecture.mjs';
|
|
|
19
19
|
import { validateFreshness } from '../validators/freshness.mjs';
|
|
20
20
|
import { validateTraceability } from '../validators/traceability.mjs';
|
|
21
21
|
import { validateDocsDiff } from '../validators/docs-diff.mjs';
|
|
22
|
+
import { validateMetadataSync } from '../validators/metadata-sync.mjs';
|
|
23
|
+
import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
|
|
24
|
+
import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Internal guard — returns structured data, no console output, no process.exit.
|
|
@@ -52,6 +55,9 @@ export function runGuardInternal(projectDir, config) {
|
|
|
52
55
|
}},
|
|
53
56
|
{ key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
|
|
54
57
|
{ key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
|
|
58
|
+
{ key: 'metadataSync', name: 'Metadata-Sync', fn: () => validateMetadataSync(projectDir, config) },
|
|
59
|
+
{ key: 'docsCoverage', name: 'Docs-Coverage', fn: () => validateDocsCoverage(projectDir, config) },
|
|
60
|
+
// Metrics-Consistency runs post-loop (needs guard results)
|
|
55
61
|
];
|
|
56
62
|
|
|
57
63
|
for (const { key, name, fn } of validatorMap) {
|
|
@@ -84,6 +90,21 @@ export function runGuardInternal(projectDir, config) {
|
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
// ── Metrics-Consistency runs AFTER all other validators (needs their results) ──
|
|
94
|
+
if (validators.metricsConsistency !== false) {
|
|
95
|
+
try {
|
|
96
|
+
const result = validateMetricsConsistency(projectDir, config, results);
|
|
97
|
+
const hasErrors = result.errors.length > 0;
|
|
98
|
+
const hasWarnings = result.warnings.length > 0;
|
|
99
|
+
const status = hasErrors ? 'fail' : hasWarnings ? 'warn' : 'pass';
|
|
100
|
+
const ratio = result.total > 0 ? result.passed / result.total : 1;
|
|
101
|
+
const quality = hasErrors ? 'LOW' : hasWarnings ? 'MEDIUM' : ratio >= 0.9 ? 'HIGH' : 'MEDIUM';
|
|
102
|
+
results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', status, quality });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
const activeResults = results.filter(r => r.status !== 'skipped');
|
|
88
109
|
const totalErrors = activeResults.reduce((sum, r) => sum + r.errors.length, 0);
|
|
89
110
|
const totalWarnings = activeResults.reduce((sum, r) => sum + r.warnings.length, 0);
|
package/cli/shared.mjs
CHANGED
|
@@ -63,3 +63,44 @@ export const PROFILES = {
|
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
|
+
|
|
67
|
+
// ── .docguardignore Support ───────────────────────────────────────────────
|
|
68
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
69
|
+
import { resolve, relative } from 'node:path';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load ignore patterns from .docguardignore (like .gitignore).
|
|
73
|
+
* Returns a function that checks if a relative path should be ignored.
|
|
74
|
+
*
|
|
75
|
+
* Format: one pattern per line, # comments, blank lines skipped.
|
|
76
|
+
* Supports simple glob: * (any chars), ** (any path segments).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} projectDir - Project root
|
|
79
|
+
* @returns {(relPath: string) => boolean} - Returns true if file should be ignored
|
|
80
|
+
*/
|
|
81
|
+
export function loadIgnorePatterns(projectDir) {
|
|
82
|
+
const ignorePath = resolve(projectDir, '.docguardignore');
|
|
83
|
+
if (!existsSync(ignorePath)) return () => false;
|
|
84
|
+
|
|
85
|
+
let content;
|
|
86
|
+
try { content = readFileSync(ignorePath, 'utf-8'); } catch { return () => false; }
|
|
87
|
+
|
|
88
|
+
const patterns = content
|
|
89
|
+
.split('\n')
|
|
90
|
+
.map(line => line.trim())
|
|
91
|
+
.filter(line => line && !line.startsWith('#'))
|
|
92
|
+
.map(pattern => {
|
|
93
|
+
// Convert glob to regex:
|
|
94
|
+
// ** → match any path segments
|
|
95
|
+
// * → match any chars except /
|
|
96
|
+
// . → literal dot
|
|
97
|
+
const escaped = pattern
|
|
98
|
+
.replace(/\./g, '\\.')
|
|
99
|
+
.replace(/\*\*/g, '§§') // temp placeholder
|
|
100
|
+
.replace(/\*/g, '[^/]*')
|
|
101
|
+
.replace(/§§/g, '.*');
|
|
102
|
+
return new RegExp(`^${escaped}$|/${escaped}$|^${escaped}/|/${escaped}/`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return (relPath) => patterns.some(regex => regex.test(relPath));
|
|
106
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs-Coverage Validator — Detects code features not referenced in docs.
|
|
3
|
+
*
|
|
4
|
+
* Generic validator for ANY project type. Scans the project for
|
|
5
|
+
* "documentable artifacts" and checks if at least one canonical doc
|
|
6
|
+
* or README references them.
|
|
7
|
+
*
|
|
8
|
+
* What it catches:
|
|
9
|
+
* - Config/dotfiles at root not mentioned in docs
|
|
10
|
+
* - Config filenames referenced in source code (resolve/readFile calls) but not documented
|
|
11
|
+
* - package.json bin entries not documented
|
|
12
|
+
* - Source directories not referenced in ARCHITECTURE.md
|
|
13
|
+
* - README.md missing standard sections (inspired by Standard README spec)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const IGNORE_DIRS = new Set([
|
|
20
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
21
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Dotfiles that are universally common and don't need documentation
|
|
25
|
+
const COMMON_DOTFILES = new Set([
|
|
26
|
+
'.gitignore', '.gitattributes', '.git', '.DS_Store',
|
|
27
|
+
'.editorconfig', '.prettierrc', '.prettierignore',
|
|
28
|
+
'.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.cjs',
|
|
29
|
+
'.eslintignore', '.nvmrc', '.node-version', '.npmrc', '.npmignore',
|
|
30
|
+
'.env', '.env.local', '.env.development', '.env.production',
|
|
31
|
+
'.vscode', '.idea', '.github', '.husky',
|
|
32
|
+
'.babelrc', '.browserslistrc', '.stylelintrc',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate that code artifacts are referenced in documentation.
|
|
37
|
+
* @param {string} projectDir - Project root directory
|
|
38
|
+
* @param {object} config - DocGuard config
|
|
39
|
+
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
40
|
+
*/
|
|
41
|
+
export function validateDocsCoverage(projectDir, config) {
|
|
42
|
+
const warnings = [];
|
|
43
|
+
let passed = 0;
|
|
44
|
+
let total = 0;
|
|
45
|
+
|
|
46
|
+
// Collect all doc content for searching
|
|
47
|
+
const allDocContent = collectDocContent(projectDir);
|
|
48
|
+
if (!allDocContent) {
|
|
49
|
+
return { errors: [], warnings, passed: 0, total: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Check 1: Project-specific config/dotfiles referenced in docs ──
|
|
53
|
+
const configChecks = checkConfigFiles(projectDir, allDocContent);
|
|
54
|
+
total += configChecks.total;
|
|
55
|
+
passed += configChecks.passed;
|
|
56
|
+
warnings.push(...configChecks.warnings);
|
|
57
|
+
|
|
58
|
+
// ── Check 2: package.json bin entries documented ──
|
|
59
|
+
const binChecks = checkPackageBins(projectDir, allDocContent);
|
|
60
|
+
total += binChecks.total;
|
|
61
|
+
passed += binChecks.passed;
|
|
62
|
+
warnings.push(...binChecks.warnings);
|
|
63
|
+
|
|
64
|
+
// ── Check 3: Source directory structure matches ARCHITECTURE.md ──
|
|
65
|
+
const dirChecks = checkSourceDirs(projectDir, allDocContent);
|
|
66
|
+
total += dirChecks.total;
|
|
67
|
+
passed += dirChecks.passed;
|
|
68
|
+
warnings.push(...dirChecks.warnings);
|
|
69
|
+
|
|
70
|
+
// ── Check 4: Config filenames referenced in source code but not documented ──
|
|
71
|
+
const codeConfigChecks = checkCodeReferencedConfigs(projectDir, allDocContent);
|
|
72
|
+
total += codeConfigChecks.total;
|
|
73
|
+
passed += codeConfigChecks.passed;
|
|
74
|
+
warnings.push(...codeConfigChecks.warnings);
|
|
75
|
+
|
|
76
|
+
// ── Check 5: README section completeness (Standard README spec) ──
|
|
77
|
+
const readmeChecks = checkReadmeSections(projectDir);
|
|
78
|
+
total += readmeChecks.total;
|
|
79
|
+
passed += readmeChecks.passed;
|
|
80
|
+
warnings.push(...readmeChecks.warnings);
|
|
81
|
+
|
|
82
|
+
return { errors: [], warnings, passed, total };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Check Functions ─────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check 1: Project-specific config/dotfiles are mentioned in docs.
|
|
89
|
+
* Skips universally common files (.gitignore, .eslintrc, etc.).
|
|
90
|
+
*/
|
|
91
|
+
function checkConfigFiles(projectDir, allDocContent) {
|
|
92
|
+
const warnings = [];
|
|
93
|
+
let passed = 0;
|
|
94
|
+
let total = 0;
|
|
95
|
+
|
|
96
|
+
let entries;
|
|
97
|
+
try { entries = readdirSync(projectDir); } catch { return { warnings, passed, total }; }
|
|
98
|
+
|
|
99
|
+
const lowerDocContent = allDocContent.toLowerCase();
|
|
100
|
+
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const isDotFile = entry.startsWith('.');
|
|
103
|
+
const isProjectConfig = entry.endsWith('.config.js') ||
|
|
104
|
+
entry.endsWith('.config.ts') ||
|
|
105
|
+
entry.endsWith('.config.mjs') ||
|
|
106
|
+
entry.endsWith('.config.cjs') ||
|
|
107
|
+
entry.endsWith('.json') && !['package.json', 'package-lock.json', 'tsconfig.json'].includes(entry);
|
|
108
|
+
|
|
109
|
+
if (!isDotFile && !isProjectConfig) continue;
|
|
110
|
+
if (COMMON_DOTFILES.has(entry)) continue;
|
|
111
|
+
if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
|
|
112
|
+
|
|
113
|
+
total++;
|
|
114
|
+
if (lowerDocContent.includes(entry.toLowerCase())) {
|
|
115
|
+
passed++;
|
|
116
|
+
} else {
|
|
117
|
+
warnings.push(
|
|
118
|
+
`Config file "${entry}" exists but is not mentioned in any documentation. Document its purpose in ARCHITECTURE.md or README.md`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { warnings, passed, total };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check 2: package.json bin entries (CLI commands users run) are documented.
|
|
128
|
+
*/
|
|
129
|
+
function checkPackageBins(projectDir, allDocContent) {
|
|
130
|
+
const warnings = [];
|
|
131
|
+
let passed = 0;
|
|
132
|
+
let total = 0;
|
|
133
|
+
|
|
134
|
+
const pkgPath = resolve(projectDir, 'package.json');
|
|
135
|
+
if (!existsSync(pkgPath)) return { warnings, passed, total };
|
|
136
|
+
|
|
137
|
+
let pkg;
|
|
138
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { warnings, passed, total }; }
|
|
139
|
+
|
|
140
|
+
const bins = typeof pkg.bin === 'string'
|
|
141
|
+
? { [pkg.name]: pkg.bin }
|
|
142
|
+
: (pkg.bin || {});
|
|
143
|
+
|
|
144
|
+
const lowerDocContent = allDocContent.toLowerCase();
|
|
145
|
+
|
|
146
|
+
for (const [binName] of Object.entries(bins)) {
|
|
147
|
+
total++;
|
|
148
|
+
if (lowerDocContent.includes(binName.toLowerCase())) {
|
|
149
|
+
passed++;
|
|
150
|
+
} else {
|
|
151
|
+
warnings.push(
|
|
152
|
+
`package.json defines CLI command "${binName}" but it's not mentioned in any documentation`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { warnings, passed, total };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check 3: Source directories are referenced in ARCHITECTURE.md.
|
|
162
|
+
*/
|
|
163
|
+
function checkSourceDirs(projectDir, allDocContent) {
|
|
164
|
+
const warnings = [];
|
|
165
|
+
let passed = 0;
|
|
166
|
+
let total = 0;
|
|
167
|
+
|
|
168
|
+
const archPath = resolve(projectDir, 'docs-canonical/ARCHITECTURE.md');
|
|
169
|
+
if (!existsSync(archPath)) return { warnings, passed, total };
|
|
170
|
+
|
|
171
|
+
let archContent;
|
|
172
|
+
try { archContent = readFileSync(archPath, 'utf-8'); } catch { return { warnings, passed, total }; }
|
|
173
|
+
|
|
174
|
+
const lowerArchContent = archContent.toLowerCase();
|
|
175
|
+
const sourceRoots = ['src', 'lib', 'app', 'cli', 'server', 'api'];
|
|
176
|
+
|
|
177
|
+
for (const root of sourceRoots) {
|
|
178
|
+
const rootDir = resolve(projectDir, root);
|
|
179
|
+
if (!existsSync(rootDir)) continue;
|
|
180
|
+
|
|
181
|
+
let entries;
|
|
182
|
+
try { entries = readdirSync(rootDir); } catch { continue; }
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
const fullPath = join(rootDir, entry);
|
|
186
|
+
try {
|
|
187
|
+
const stat = statSync(fullPath);
|
|
188
|
+
if (!stat.isDirectory()) continue;
|
|
189
|
+
} catch { continue; }
|
|
190
|
+
|
|
191
|
+
if (IGNORE_DIRS.has(entry) || entry.startsWith('.') || entry === '__tests__' || entry === '__test__') continue;
|
|
192
|
+
|
|
193
|
+
total++;
|
|
194
|
+
const searchName = entry.toLowerCase();
|
|
195
|
+
if (lowerArchContent.includes(searchName) || lowerArchContent.includes(root + '/' + entry)) {
|
|
196
|
+
passed++;
|
|
197
|
+
} else {
|
|
198
|
+
warnings.push(
|
|
199
|
+
`Source directory "${root}/${entry}/" is not referenced in ARCHITECTURE.md`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { warnings, passed, total };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check 4: Config files that code actually READS are documented.
|
|
210
|
+
*
|
|
211
|
+
* Scans source code for resolve(dir, '.configname') and existsSync('.configname')
|
|
212
|
+
* patterns — these are configs the project USES. Avoids matching config names
|
|
213
|
+
* sitting in arrays (scan patterns for detecting other projects' configs).
|
|
214
|
+
*/
|
|
215
|
+
function checkCodeReferencedConfigs(projectDir, allDocContent) {
|
|
216
|
+
const warnings = [];
|
|
217
|
+
let passed = 0;
|
|
218
|
+
let total = 0;
|
|
219
|
+
|
|
220
|
+
const lowerDocContent = allDocContent.toLowerCase();
|
|
221
|
+
const foundConfigs = new Set();
|
|
222
|
+
|
|
223
|
+
// Only match config filenames inside function calls that actually USE the file:
|
|
224
|
+
// resolve(dir, '.docguardignore'), existsSync('.env.example'), readFileSync('vitest.config.ts')
|
|
225
|
+
const usageRegex = /(?:resolve|join|existsSync|readFileSync|accessSync|writeFileSync)\s*\([^)]*['"`]([^'"`\n]{2,})['"`]/g;
|
|
226
|
+
|
|
227
|
+
const sourceRoots = ['src', 'lib', 'cli', 'bin', 'server', 'api', 'app'];
|
|
228
|
+
|
|
229
|
+
const scanFile = (filePath) => {
|
|
230
|
+
const ext = extname(filePath);
|
|
231
|
+
if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return;
|
|
232
|
+
let content;
|
|
233
|
+
try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
|
|
234
|
+
|
|
235
|
+
usageRegex.lastIndex = 0;
|
|
236
|
+
let match;
|
|
237
|
+
while ((match = usageRegex.exec(content)) !== null) {
|
|
238
|
+
const name = match[1];
|
|
239
|
+
// Must be a dotfile (.something) or *.config.* — not a path
|
|
240
|
+
if (name.includes('/') || name.startsWith('..')) continue;
|
|
241
|
+
const isDotConfig = name.startsWith('.') && name.length > 2;
|
|
242
|
+
const isNamedConfig = /^[\w-]+\.config\.\w+$/.test(name);
|
|
243
|
+
if (!isDotConfig && !isNamedConfig) continue;
|
|
244
|
+
// Skip bare extensions
|
|
245
|
+
if (/^\.[a-z]{1,4}$/i.test(name)) continue;
|
|
246
|
+
foundConfigs.add(name);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
for (const root of sourceRoots) {
|
|
251
|
+
const rootDir = resolve(projectDir, root);
|
|
252
|
+
if (!existsSync(rootDir)) continue;
|
|
253
|
+
walkFiles(rootDir, scanFile);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const configName of foundConfigs) {
|
|
257
|
+
if (COMMON_DOTFILES.has(configName)) continue;
|
|
258
|
+
total++;
|
|
259
|
+
if (lowerDocContent.includes(configName.toLowerCase())) {
|
|
260
|
+
passed++;
|
|
261
|
+
} else {
|
|
262
|
+
warnings.push(
|
|
263
|
+
`Code references config file "${configName}" but no documentation mentions it. Add it to README.md or ARCHITECTURE.md`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { warnings, passed, total };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check 5: README section completeness.
|
|
273
|
+
* Inspired by Standard README (https://github.com/RichardLitt/standard-readme)
|
|
274
|
+
* and Make a README (https://www.makeareadme.com/).
|
|
275
|
+
*/
|
|
276
|
+
function checkReadmeSections(projectDir) {
|
|
277
|
+
const warnings = [];
|
|
278
|
+
let passed = 0;
|
|
279
|
+
let total = 0;
|
|
280
|
+
|
|
281
|
+
const readmePath = resolve(projectDir, 'README.md');
|
|
282
|
+
if (!existsSync(readmePath)) return { warnings, passed, total };
|
|
283
|
+
|
|
284
|
+
let content;
|
|
285
|
+
try { content = readFileSync(readmePath, 'utf-8'); } catch { return { warnings, passed, total }; }
|
|
286
|
+
|
|
287
|
+
const lowerContent = content.toLowerCase();
|
|
288
|
+
|
|
289
|
+
// Required sections — every well-documented project should have these
|
|
290
|
+
const requiredSections = [
|
|
291
|
+
{ name: 'Installation', patterns: ['install', 'getting started', 'setup', 'quickstart', 'quick start'] },
|
|
292
|
+
{ name: 'Usage', patterns: ['usage', 'how to use', 'examples', 'getting started'] },
|
|
293
|
+
{ name: 'License', patterns: ['license', 'licence'] },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
// Recommended — count toward score but don't warn
|
|
297
|
+
const recommendedSections = [
|
|
298
|
+
{ name: 'Contributing', patterns: ['contributing', 'contribution', 'how to contribute'] },
|
|
299
|
+
{ name: 'Description', patterns: ['## what', '## about', '## description', '## overview'] },
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const section of requiredSections) {
|
|
303
|
+
total++;
|
|
304
|
+
if (section.patterns.some(p => lowerContent.includes(p))) {
|
|
305
|
+
passed++;
|
|
306
|
+
} else {
|
|
307
|
+
warnings.push(`README.md is missing a "${section.name}" section (Standard README spec)`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const section of recommendedSections) {
|
|
312
|
+
total++;
|
|
313
|
+
if (section.patterns.some(p => lowerContent.includes(p))) {
|
|
314
|
+
passed++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { warnings, passed, total };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Collect all documentation content into a single searchable string.
|
|
325
|
+
*/
|
|
326
|
+
function collectDocContent(projectDir) {
|
|
327
|
+
const docPaths = [];
|
|
328
|
+
|
|
329
|
+
const rootDocs = ['README.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'STANDARD.md'];
|
|
330
|
+
for (const doc of rootDocs) {
|
|
331
|
+
const p = resolve(projectDir, doc);
|
|
332
|
+
if (existsSync(p)) docPaths.push(p);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const canonDir = resolve(projectDir, 'docs-canonical');
|
|
336
|
+
if (existsSync(canonDir)) {
|
|
337
|
+
try {
|
|
338
|
+
for (const entry of readdirSync(canonDir)) {
|
|
339
|
+
if (entry.endsWith('.md')) docPaths.push(resolve(canonDir, entry));
|
|
340
|
+
}
|
|
341
|
+
} catch { /* skip */ }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const extDir = resolve(projectDir, 'extensions');
|
|
345
|
+
if (existsSync(extDir)) {
|
|
346
|
+
walkFiles(extDir, (f) => {
|
|
347
|
+
if (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml')) {
|
|
348
|
+
docPaths.push(f);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const docsDir of ['docs', 'docs-implementation']) {
|
|
354
|
+
const d = resolve(projectDir, docsDir);
|
|
355
|
+
if (existsSync(d)) {
|
|
356
|
+
walkFiles(d, (f) => {
|
|
357
|
+
if (f.endsWith('.md')) docPaths.push(f);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (docPaths.length === 0) return null;
|
|
363
|
+
const parts = [];
|
|
364
|
+
for (const p of docPaths) {
|
|
365
|
+
try { parts.push(readFileSync(p, 'utf-8')); } catch { /* skip */ }
|
|
366
|
+
}
|
|
367
|
+
return parts.join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function walkFiles(dir, callback) {
|
|
371
|
+
if (!existsSync(dir)) return;
|
|
372
|
+
let entries;
|
|
373
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
374
|
+
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
377
|
+
const fullPath = join(dir, entry);
|
|
378
|
+
try {
|
|
379
|
+
const stat = statSync(fullPath);
|
|
380
|
+
if (stat.isDirectory()) {
|
|
381
|
+
walkFiles(fullPath, callback);
|
|
382
|
+
} else if (stat.isFile()) {
|
|
383
|
+
callback(fullPath);
|
|
384
|
+
}
|
|
385
|
+
} catch { /* skip */ }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Sync Validator — Detects stale version references across docs.
|
|
3
|
+
*
|
|
4
|
+
* Cross-checks package.json version against extension.yml and all .md files.
|
|
5
|
+
* Flags outdated version strings (e.g., README references v0.7.2 but package.json is 0.8.0).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { resolve, join, relative, extname } from 'node:path';
|
|
10
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
11
|
+
|
|
12
|
+
const IGNORE_DIRS = new Set([
|
|
13
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
14
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate version/metadata consistency across project files.
|
|
19
|
+
* @param {string} projectDir - Project root directory
|
|
20
|
+
* @param {object} config - DocGuard config
|
|
21
|
+
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
22
|
+
*/
|
|
23
|
+
export function validateMetadataSync(projectDir, config) {
|
|
24
|
+
const warnings = [];
|
|
25
|
+
let passed = 0;
|
|
26
|
+
let total = 0;
|
|
27
|
+
|
|
28
|
+
// ── Get source of truth: package.json version ──
|
|
29
|
+
const pkgPath = resolve(projectDir, 'package.json');
|
|
30
|
+
if (!existsSync(pkgPath)) {
|
|
31
|
+
return { errors: [], warnings, passed: 0, total: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let pkg;
|
|
35
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return { errors: [], warnings, passed: 0, total: 0 }; }
|
|
36
|
+
const currentVersion = pkg.version;
|
|
37
|
+
if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
|
|
38
|
+
|
|
39
|
+
// Parse into components for smart comparison
|
|
40
|
+
const vParts = currentVersion.split('.');
|
|
41
|
+
const major = parseInt(vParts[0], 10);
|
|
42
|
+
const minor = parseInt(vParts[1], 10);
|
|
43
|
+
|
|
44
|
+
// ── Check 1: extension.yml version sync ──
|
|
45
|
+
const extFiles = findExtensionYmls(projectDir);
|
|
46
|
+
for (const extFile of extFiles) {
|
|
47
|
+
total++;
|
|
48
|
+
const relPath = relative(projectDir, extFile);
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(extFile, 'utf-8');
|
|
51
|
+
const versionMatch = content.match(/version:\s*["']?(\d+\.\d+\.\d+)["']?/);
|
|
52
|
+
if (versionMatch) {
|
|
53
|
+
if (versionMatch[1] !== currentVersion) {
|
|
54
|
+
warnings.push(
|
|
55
|
+
`${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
passed++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch { /* skip unreadable */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Check 2: Version references in markdown files ──
|
|
65
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
66
|
+
const mdFiles = findMarkdownFiles(projectDir);
|
|
67
|
+
// Version patterns to find: v0.7.2, @0.7.2, /v0.7.2/, docguard-cli@0.7.2
|
|
68
|
+
const versionRegex = /(?:v|@|\/v?)(\d+\.\d+\.\d+)/g;
|
|
69
|
+
|
|
70
|
+
for (const mdFile of mdFiles) {
|
|
71
|
+
const relPath = relative(projectDir, mdFile);
|
|
72
|
+
// Skip CHANGELOG.md and DRIFT-LOG.md — these are historical by definition
|
|
73
|
+
const baseName = relPath.toLowerCase();
|
|
74
|
+
if (baseName.includes('changelog') || baseName.includes('drift-log')) continue;
|
|
75
|
+
// Skip files matched by .docguardignore
|
|
76
|
+
if (isIgnored(relPath)) continue;
|
|
77
|
+
|
|
78
|
+
let content;
|
|
79
|
+
try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
|
|
80
|
+
|
|
81
|
+
// Only flag version references in actionable contexts:
|
|
82
|
+
// - URLs (download, install, archive links)
|
|
83
|
+
// - version: declarations (YAML-style)
|
|
84
|
+
// - npm install / npx commands
|
|
85
|
+
// - Badge URLs
|
|
86
|
+
// NOT in prose text like "In v0.2.0 we added..." or roadmap discussions
|
|
87
|
+
const actionablePatterns = [
|
|
88
|
+
// URLs with version: /v0.7.2/, /tags/v0.7.2, @0.7.2
|
|
89
|
+
/(?:archive|tags|releases|download)\/v?(\d+\.\d+\.\d+)/g,
|
|
90
|
+
// npm install/npx commands: docguard-cli@0.7.2
|
|
91
|
+
/@(\d+\.\d+\.\d+)/g,
|
|
92
|
+
// YAML-style: version: "0.7.2" or version: 0.7.2
|
|
93
|
+
/version:\s*["']?(\d+\.\d+\.\d+)["']?/g,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const pattern of actionablePatterns) {
|
|
97
|
+
pattern.lastIndex = 0;
|
|
98
|
+
let match;
|
|
99
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
100
|
+
const foundVersion = match[1];
|
|
101
|
+
const fParts = foundVersion.split('.');
|
|
102
|
+
const fMajor = parseInt(fParts[0], 10);
|
|
103
|
+
const fMinor = parseInt(fParts[1], 10);
|
|
104
|
+
|
|
105
|
+
// Only flag if same major but older minor (same package, stale ref)
|
|
106
|
+
if (fMajor === major && fMinor < minor && foundVersion !== currentVersion) {
|
|
107
|
+
total++;
|
|
108
|
+
warnings.push(
|
|
109
|
+
`${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
|
|
110
|
+
);
|
|
111
|
+
} else if (fMajor === major && fMinor === minor && foundVersion === currentVersion) {
|
|
112
|
+
total++;
|
|
113
|
+
passed++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { errors: [], warnings, passed, total };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function findExtensionYmls(dir) {
|
|
125
|
+
const results = [];
|
|
126
|
+
const extDir = resolve(dir, 'extensions');
|
|
127
|
+
if (existsSync(extDir)) {
|
|
128
|
+
walkFiles(extDir, (f) => {
|
|
129
|
+
if (f.endsWith('extension.yml') || f.endsWith('extension.yaml')) {
|
|
130
|
+
results.push(f);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Also check root
|
|
135
|
+
const rootExt = resolve(dir, 'extension.yml');
|
|
136
|
+
if (existsSync(rootExt)) results.push(rootExt);
|
|
137
|
+
return results;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function findMarkdownFiles(dir) {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const mdFiles = [];
|
|
143
|
+
const searchDirs = [
|
|
144
|
+
dir,
|
|
145
|
+
resolve(dir, 'docs-canonical'),
|
|
146
|
+
resolve(dir, 'extensions'),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
for (const searchDir of searchDirs) {
|
|
150
|
+
if (!existsSync(searchDir)) continue;
|
|
151
|
+
walkFiles(searchDir, (f) => {
|
|
152
|
+
if (f.endsWith('.md') && !seen.has(f)) {
|
|
153
|
+
seen.add(f);
|
|
154
|
+
mdFiles.push(f);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return mdFiles;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function walkFiles(dir, callback) {
|
|
163
|
+
if (!existsSync(dir)) return;
|
|
164
|
+
let entries;
|
|
165
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
166
|
+
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
169
|
+
const fullPath = join(dir, entry);
|
|
170
|
+
try {
|
|
171
|
+
const stat = statSync(fullPath);
|
|
172
|
+
if (stat.isDirectory()) {
|
|
173
|
+
walkFiles(fullPath, callback);
|
|
174
|
+
} else if (stat.isFile()) {
|
|
175
|
+
callback(fullPath);
|
|
176
|
+
}
|
|
177
|
+
} catch { /* skip */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics Consistency Validator — Detects stale hardcoded numbers in docs.
|
|
3
|
+
*
|
|
4
|
+
* Scans all .md files for patterns like "N checks", "N validators", "N tests"
|
|
5
|
+
* and compares against actual values from guard results and package.json.
|
|
6
|
+
* Returns warnings for mismatches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { resolve, join, relative } from 'node:path';
|
|
11
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
12
|
+
|
|
13
|
+
const IGNORE_DIRS = new Set([
|
|
14
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
15
|
+
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate metrics consistency across documentation.
|
|
20
|
+
* @param {string} projectDir - Project root directory
|
|
21
|
+
* @param {object} config - DocGuard config
|
|
22
|
+
* @param {object} [guardResults] - Results from runGuardInternal (optional)
|
|
23
|
+
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
24
|
+
*/
|
|
25
|
+
export function validateMetricsConsistency(projectDir, config, guardResults) {
|
|
26
|
+
const warnings = [];
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let total = 0;
|
|
29
|
+
|
|
30
|
+
// ── Collect actual metrics ──
|
|
31
|
+
const actuals = {};
|
|
32
|
+
|
|
33
|
+
// Guard check count (from guard results if available)
|
|
34
|
+
if (guardResults && Array.isArray(guardResults)) {
|
|
35
|
+
const totalChecks = guardResults.reduce((sum, r) => {
|
|
36
|
+
if (r.status === 'skipped') return sum;
|
|
37
|
+
return sum + (r.total || 0);
|
|
38
|
+
}, 0);
|
|
39
|
+
const validatorCount = guardResults.filter(r => r.status !== 'skipped').length;
|
|
40
|
+
|
|
41
|
+
actuals.checks = totalChecks;
|
|
42
|
+
actuals.validators = validatorCount;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test count — count test files on disk
|
|
46
|
+
const testFiles = findTestFiles(projectDir);
|
|
47
|
+
if (testFiles.length > 0) {
|
|
48
|
+
actuals.tests = testFiles.length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If no actuals to compare, skip
|
|
52
|
+
if (Object.keys(actuals).length === 0) {
|
|
53
|
+
return { errors: [], warnings, passed: 0, total: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Scan markdown files for hardcoded numbers ──
|
|
57
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
58
|
+
const mdFiles = findMarkdownFiles(projectDir);
|
|
59
|
+
// Patterns must match standalone number references, not ratio-style "8/8 checks"
|
|
60
|
+
const patterns = [
|
|
61
|
+
{ key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
|
|
62
|
+
{ key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators' },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
for (const mdFile of mdFiles) {
|
|
66
|
+
const relPath = relative(projectDir, mdFile);
|
|
67
|
+
// Skip changelog (historical numbers are fine by definition)
|
|
68
|
+
if (relPath.toLowerCase().includes('changelog')) continue;
|
|
69
|
+
// Skip files matched by .docguardignore
|
|
70
|
+
if (isIgnored(relPath)) continue;
|
|
71
|
+
|
|
72
|
+
let content;
|
|
73
|
+
try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
|
|
74
|
+
|
|
75
|
+
for (const { key, regex, label } of patterns) {
|
|
76
|
+
if (actuals[key] === undefined) continue;
|
|
77
|
+
|
|
78
|
+
regex.lastIndex = 0;
|
|
79
|
+
let match;
|
|
80
|
+
while ((match = regex.exec(content)) !== null) {
|
|
81
|
+
total++;
|
|
82
|
+
const found = parseInt(match[1], 10);
|
|
83
|
+
if (found !== actuals[key] && found > 0) {
|
|
84
|
+
warnings.push(
|
|
85
|
+
`${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Update the doc or run \`docguard generate --force\``
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
passed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { errors: [], warnings, passed, total };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function findTestFiles(dir) {
|
|
100
|
+
const tests = [];
|
|
101
|
+
const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
|
|
102
|
+
|
|
103
|
+
// Top-level test dirs
|
|
104
|
+
for (const td of testDirs) {
|
|
105
|
+
const fullDir = resolve(dir, td);
|
|
106
|
+
if (existsSync(fullDir)) {
|
|
107
|
+
walkFiles(fullDir, (f) => {
|
|
108
|
+
if (/\.(test|spec)\.[^.]+$/.test(f)) tests.push(f);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Co-located tests in src/
|
|
114
|
+
const srcDir = resolve(dir, 'src');
|
|
115
|
+
if (existsSync(srcDir)) {
|
|
116
|
+
walkFiles(srcDir, (f) => {
|
|
117
|
+
if (/\.(test|spec)\.[^.]+$/.test(f) || f.includes('__tests__')) {
|
|
118
|
+
if (!tests.includes(f)) tests.push(f);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return tests;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findMarkdownFiles(dir) {
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const mdFiles = [];
|
|
129
|
+
// Check root, docs-canonical, and extensions
|
|
130
|
+
const searchDirs = [
|
|
131
|
+
dir,
|
|
132
|
+
resolve(dir, 'docs-canonical'),
|
|
133
|
+
resolve(dir, 'extensions'),
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const searchDir of searchDirs) {
|
|
137
|
+
if (!existsSync(searchDir)) continue;
|
|
138
|
+
walkFiles(searchDir, (f) => {
|
|
139
|
+
if (f.endsWith('.md') && !seen.has(f)) {
|
|
140
|
+
seen.add(f);
|
|
141
|
+
mdFiles.push(f);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return mdFiles;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function walkFiles(dir, callback) {
|
|
150
|
+
if (!existsSync(dir)) return;
|
|
151
|
+
let entries;
|
|
152
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
156
|
+
const fullPath = join(dir, entry);
|
|
157
|
+
try {
|
|
158
|
+
const stat = statSync(fullPath);
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
walkFiles(fullPath, callback);
|
|
161
|
+
} else if (stat.isFile()) {
|
|
162
|
+
callback(fullPath);
|
|
163
|
+
}
|
|
164
|
+
} catch { /* skip */ }
|
|
165
|
+
}
|
|
166
|
+
}
|