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 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
- ## 9 Validators
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 | Off |
217
- | 9 | **Architecture** | Imports follow layer boundaries | Off |
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 what we can (unless --no-fix) ──
107
- const shouldAutoFix = !flags.noFix && flags.format !== 'json';
108
- if (shouldAutoFix && issues.length > 0) {
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.dirname, '..', 'docguard.mjs');
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.dirname, '..', 'docguard.mjs');
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') {
@@ -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.dirname, '..', 'docguard.mjs');
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
 
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {