init
This commit is contained in:
342
.opencode/scripts/validate-docs.cjs
Normal file
342
.opencode/scripts/validate-docs.cjs
Normal file
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate documentation accuracy against codebase.
|
||||
* Detects potential hallucinations: invented APIs, broken links, missing env vars.
|
||||
*
|
||||
* Usage:
|
||||
* node .claude/scripts/validate-docs.cjs [docs-dir] [--src dir1,dir2]
|
||||
*
|
||||
* Checks:
|
||||
* 1. Code references - verify `functionName()` and `ClassName` exist
|
||||
* 2. Internal links - verify markdown links point to existing files
|
||||
* 3. Config keys - verify ENV_VAR exist in .env.example
|
||||
*
|
||||
* Exit: Always 0 (non-blocking, warn-only mode)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Patterns
|
||||
const CODE_REF_PATTERN = /`([A-Za-z_][A-Za-z0-9_]*(?:\(\))?)`/g;
|
||||
const LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const ENV_PATTERN = /`([A-Z][A-Z0-9_]{2,})`|\$([A-Z][A-Z0-9_]{2,})/g;
|
||||
|
||||
// Common code terms to ignore (not actual code refs)
|
||||
const IGNORE_CODE_REFS = new Set([
|
||||
'true', 'false', 'null', 'undefined', 'string', 'number', 'boolean',
|
||||
'object', 'array', 'function', 'async', 'await', 'const', 'let', 'var',
|
||||
'if', 'else', 'for', 'while', 'return', 'import', 'export', 'default',
|
||||
'npm', 'npx', 'node', 'yarn', 'pnpm', 'git', 'bash', 'sh', 'zsh',
|
||||
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS',
|
||||
'JSON', 'XML', 'HTML', 'CSS', 'SQL', 'API', 'URL', 'URI', 'HTTP', 'HTTPS',
|
||||
'OK', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'TRACE',
|
||||
'README', 'LICENSE', 'CHANGELOG', 'TODO', 'FIXME', 'NOTE', 'HACK',
|
||||
'dev', 'prod', 'test', 'staging', 'production', 'development',
|
||||
'src', 'lib', 'dist', 'build', 'docs', 'tests', 'config',
|
||||
'index', 'main', 'app', 'server', 'client', 'utils', 'helpers'
|
||||
]);
|
||||
|
||||
// Common env var prefixes to ignore (not project-specific)
|
||||
const IGNORE_ENV_PREFIXES = ['NODE_', 'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'PWD', 'CI'];
|
||||
|
||||
// Markdown template variables (not actual env vars)
|
||||
const IGNORE_ENV_VARS = new Set(['ARGUMENTS']);
|
||||
|
||||
/**
|
||||
* Find all markdown files in directory.
|
||||
*/
|
||||
function findMarkdownFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => path.join(dir, f));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code references from markdown content.
|
||||
*/
|
||||
function extractCodeRefs(content, filepath) {
|
||||
const refs = [];
|
||||
let match;
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
// Skip code blocks
|
||||
if (line.trim().startsWith('```')) return;
|
||||
|
||||
while ((match = CODE_REF_PATTERN.exec(line)) !== null) {
|
||||
const ref = match[1];
|
||||
// Filter out common terms
|
||||
if (IGNORE_CODE_REFS.has(ref.replace('()', '').toLowerCase())) continue;
|
||||
// Only check function calls and PascalCase classes
|
||||
if (ref.endsWith('()') || /^[A-Z][a-z]/.test(ref)) {
|
||||
refs.push({ ref, file: filepath, line: idx + 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract internal links from markdown content.
|
||||
*/
|
||||
function extractLinks(content, filepath) {
|
||||
const links = [];
|
||||
let match;
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
while ((match = LINK_PATTERN.exec(line)) !== null) {
|
||||
const href = match[2];
|
||||
// Skip external links and anchors
|
||||
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('mailto:')) continue;
|
||||
links.push({ href, file: filepath, line: idx + 1, text: match[1] });
|
||||
}
|
||||
});
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract env var references from markdown content.
|
||||
*/
|
||||
function extractEnvVars(content, filepath) {
|
||||
const vars = [];
|
||||
let match;
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
// Skip code blocks
|
||||
if (line.trim().startsWith('```')) return;
|
||||
|
||||
while ((match = ENV_PATTERN.exec(line)) !== null) {
|
||||
const envVar = match[1] || match[2];
|
||||
// Filter common system vars and template variables
|
||||
if (IGNORE_ENV_PREFIXES.some(p => envVar.startsWith(p))) continue;
|
||||
if (IGNORE_ENV_VARS.has(envVar)) continue;
|
||||
vars.push({ envVar, file: filepath, line: idx + 1 });
|
||||
}
|
||||
});
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if code reference exists in source directories.
|
||||
*/
|
||||
function checkCodeRefExists(ref, srcDirs) {
|
||||
const name = ref.replace('()', '');
|
||||
const patterns = [
|
||||
`function ${name}`,
|
||||
`const ${name}`,
|
||||
`class ${name}`,
|
||||
`def ${name}`,
|
||||
`export.*${name}`,
|
||||
`${name}:` // object methods
|
||||
];
|
||||
|
||||
for (const srcDir of srcDirs) {
|
||||
if (!fs.existsSync(srcDir)) continue;
|
||||
for (const pattern of patterns) {
|
||||
// Use spawnSync with args array to prevent command injection
|
||||
const result = spawnSync('grep', ['-rl', pattern, srcDir], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000
|
||||
});
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if internal link target exists.
|
||||
*/
|
||||
function checkLinkExists(href, sourceFile) {
|
||||
const sourceDir = path.dirname(sourceFile);
|
||||
const targetPath = path.resolve(sourceDir, href.split('#')[0]);
|
||||
return fs.existsSync(targetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load env vars from .env.example.
|
||||
*/
|
||||
function loadEnvExample(projectRoot) {
|
||||
const envPath = path.join(projectRoot, '.env.example');
|
||||
if (!fs.existsSync(envPath)) return new Set();
|
||||
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
const vars = new Set();
|
||||
|
||||
content.split('\n').forEach(line => {
|
||||
const match = line.match(/^([A-Z][A-Z0-9_]+)=/);
|
||||
if (match) vars.add(match[1]);
|
||||
});
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all validations and generate report.
|
||||
*/
|
||||
function validate(docsDir, srcDirs, projectRoot) {
|
||||
const issues = {
|
||||
codeRefs: [],
|
||||
links: [],
|
||||
envVars: []
|
||||
};
|
||||
const stats = {
|
||||
filesChecked: 0,
|
||||
codeRefsChecked: 0,
|
||||
linksChecked: 0,
|
||||
envVarsChecked: 0,
|
||||
codeRefsValid: 0,
|
||||
linksValid: 0,
|
||||
envVarsValid: 0
|
||||
};
|
||||
|
||||
const mdFiles = findMarkdownFiles(docsDir);
|
||||
stats.filesChecked = mdFiles.length;
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
console.log(`No markdown files found in ${docsDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const envExample = loadEnvExample(projectRoot);
|
||||
|
||||
for (const filepath of mdFiles) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filepath, 'utf8');
|
||||
} catch (err) {
|
||||
// File deleted during validation - skip
|
||||
continue;
|
||||
}
|
||||
const relPath = path.relative(projectRoot, filepath);
|
||||
|
||||
// Check code references
|
||||
const codeRefs = extractCodeRefs(content, relPath);
|
||||
stats.codeRefsChecked += codeRefs.length;
|
||||
for (const { ref, file, line } of codeRefs) {
|
||||
if (checkCodeRefExists(ref, srcDirs)) {
|
||||
stats.codeRefsValid++;
|
||||
} else {
|
||||
issues.codeRefs.push({ ref, file, line });
|
||||
}
|
||||
}
|
||||
|
||||
// Check internal links
|
||||
const links = extractLinks(content, filepath);
|
||||
stats.linksChecked += links.length;
|
||||
for (const { href, file, line, text } of links) {
|
||||
if (checkLinkExists(href, file)) {
|
||||
stats.linksValid++;
|
||||
} else {
|
||||
issues.links.push({ href, file: relPath, line, text });
|
||||
}
|
||||
}
|
||||
|
||||
// Check env vars
|
||||
const envVars = extractEnvVars(content, relPath);
|
||||
stats.envVarsChecked += envVars.length;
|
||||
for (const { envVar, file, line } of envVars) {
|
||||
if (envExample.has(envVar)) {
|
||||
stats.envVarsValid++;
|
||||
} else {
|
||||
issues.envVars.push({ envVar, file, line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate report
|
||||
console.log('\n## Docs Validation Report\n');
|
||||
console.log(`**Files Checked:** ${stats.filesChecked}`);
|
||||
console.log(`**Scan Date:** ${new Date().toISOString().split('T')[0]}\n`);
|
||||
|
||||
const hasIssues = issues.codeRefs.length || issues.links.length || issues.envVars.length;
|
||||
|
||||
if (hasIssues) {
|
||||
console.log('### Potential Issues\n');
|
||||
|
||||
if (issues.codeRefs.length) {
|
||||
console.log(`⚠️ **Code References** (${issues.codeRefs.length} issues)`);
|
||||
for (const { ref, file, line } of issues.codeRefs.slice(0, 10)) {
|
||||
console.log(`- \`${ref}\` in ${file}:${line} - not found in codebase`);
|
||||
}
|
||||
if (issues.codeRefs.length > 10) {
|
||||
console.log(`- ... and ${issues.codeRefs.length - 10} more`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (issues.links.length) {
|
||||
console.log(`⚠️ **Internal Links** (${issues.links.length} issues)`);
|
||||
for (const { href, file, line } of issues.links.slice(0, 10)) {
|
||||
console.log(`- \`${href}\` in ${file}:${line} - file not found`);
|
||||
}
|
||||
if (issues.links.length > 10) {
|
||||
console.log(`- ... and ${issues.links.length - 10} more`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (issues.envVars.length) {
|
||||
console.log(`⚠️ **Config Keys** (${issues.envVars.length} issues)`);
|
||||
for (const { envVar, file, line } of issues.envVars.slice(0, 10)) {
|
||||
console.log(`- \`${envVar}\` in ${file}:${line} - not in .env.example`);
|
||||
}
|
||||
if (issues.envVars.length > 10) {
|
||||
console.log(`- ... and ${issues.envVars.length - 10} more`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('### Verified OK\n');
|
||||
if (stats.codeRefsValid > 0) console.log(`✅ ${stats.codeRefsValid} code references validated`);
|
||||
if (stats.linksValid > 0) console.log(`✅ ${stats.linksValid} internal links working`);
|
||||
if (stats.envVarsValid > 0) console.log(`✅ ${stats.envVarsValid} config keys confirmed`);
|
||||
if (stats.codeRefsValid === 0 && stats.linksValid === 0 && stats.envVarsValid === 0) {
|
||||
console.log('ℹ️ No validatable references found');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI arguments.
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
docsDir: 'docs',
|
||||
srcDirs: ['src', 'lib', 'app', 'scripts', '.claude']
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--src' && args[i + 1]) {
|
||||
result.srcDirs = args[++i].split(',');
|
||||
} else if (!arg.startsWith('-')) {
|
||||
result.docsDir = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Main
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = process.cwd();
|
||||
const docsDir = path.resolve(projectRoot, args.docsDir);
|
||||
const srcDirs = args.srcDirs.map(d => path.resolve(projectRoot, d));
|
||||
|
||||
validate(docsDir, srcDirs, projectRoot);
|
||||
|
||||
// Always exit 0 (non-blocking)
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user