#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; const repoRoot = process.cwd(); const args = new Set(process.argv.slice(2)); const registryPath = 'docs/文档事实源状态矩阵.md'; const requiredFields = [ 'Doc_ID', 'Path', 'Category', 'Authority', 'Status', 'Owner', 'Related_Issues', 'Evidence', 'Supersedes', 'Last_Audited', 'Audit_Method', 'Risk', 'Notes', ]; const validStatuses = new Set([ 'CURRENT', 'DRAFT', 'EVIDENCE', 'HISTORICAL', 'SUPERSEDED', 'REMOVED', ]); const requiredCorePaths = [ 'docs/文档索引.md', 'docs/文档事实源状态矩阵.md', 'docs/海音达OA-MVP需求规格说明书.md', 'docs/开发交付与变更管理规约.md', 'docs/测试规约与回归门禁.md', 'docs/产品设计规则.md', 'docs/产品口径.md', 'AGENTS.md', '.agents/skills/hyb-oa-delivery/SKILL.md', ]; function printHelp() { console.log(`Usage: node scripts/qa/audit-docs.mjs [options] Options: --check-registry Validate docs registry fields, status values, and core paths. --check-links Scan docs markdown links to local .md/.csv targets. --help Show this help. Default without options runs registry, link, stale-term, and sensitive-risk checks. This script is read-only and never prints secret values.`); } function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); } function pathExists(relativePath) { return fs.existsSync(path.join(repoRoot, relativePath)); } function listFiles(dir, predicate = () => true) { const absDir = path.join(repoRoot, dir); if (!fs.existsSync(absDir)) return []; const result = []; for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { const relative = path.join(dir, entry.name); if (entry.isDirectory()) { result.push(...listFiles(relative, predicate)); } else if (predicate(relative)) { result.push(relative); } } return result; } function extractRegistryTable() { const text = readText(registryPath); const marker = '## 文档台账'; const start = text.indexOf(marker); if (start < 0) { return { header: [], rows: [], errors: ['missing registry section: ## 文档台账'] }; } const after = text.slice(start + marker.length); const nextSection = after.search(/\n## /); const section = nextSection >= 0 ? after.slice(0, nextSection) : after; const tableLines = section .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.startsWith('|') && line.endsWith('|')); if (tableLines.length < 2) { return { header: [], rows: [], errors: ['registry table is missing or too short'] }; } const cells = (line) => line.slice(1, -1).split('|').map((cell) => cell.trim()); const header = cells(tableLines[0]); const rows = tableLines.slice(2).map((line) => cells(line)); return { header, rows, errors: [] }; } function stripCode(value) { return value.replace(/^`|`$/g, '').trim(); } function checkRegistry() { const errors = []; const warnings = []; if (!pathExists(registryPath)) { return { errors: [`missing ${registryPath}`], warnings }; } const { header, rows, errors: parseErrors } = extractRegistryTable(); errors.push(...parseErrors); for (const field of requiredFields) { if (!header.includes(field)) { errors.push(`registry missing field: ${field}`); } } const pathIndex = header.indexOf('Path'); const statusIndex = header.indexOf('Status'); const registeredPaths = new Set(); for (const row of rows) { if (row.length !== header.length) { errors.push(`registry row has ${row.length} cells, expected ${header.length}: ${row.join(' | ')}`); continue; } const docPath = stripCode(row[pathIndex] ?? ''); const status = stripCode(row[statusIndex] ?? ''); registeredPaths.add(docPath); if (!validStatuses.has(status)) { errors.push(`invalid status ${status || ''} for ${docPath}`); } if (status !== 'REMOVED' && docPath !== 'none' && !pathExists(docPath)) { errors.push(`registered path missing: ${docPath}`); } } for (const corePath of requiredCorePaths) { if (!registeredPaths.has(corePath)) { errors.push(`core path not registered: ${corePath}`); } } const docsFiles = listFiles('docs', (file) => file.endsWith('.md') || file.endsWith('.csv')); const unregistered = docsFiles.filter((file) => !registeredPaths.has(file)); if (unregistered.length > 0) { warnings.push(`unregistered docs files: ${unregistered.length}`); } return { errors, warnings }; } function normalizeLinkTarget(sourceFile, target) { let clean = target.trim(); if (clean.startsWith('<') && clean.endsWith('>')) clean = clean.slice(1, -1); if (/^(https?:|mailto:|#)/i.test(clean)) return null; clean = clean.split('#')[0].trim(); if (!clean || !/\.(md|csv)$/i.test(clean)) return null; if (clean.startsWith('/home/shunli/Projects/hyb-oa/')) { return path.relative(repoRoot, clean); } if (clean.startsWith('/')) return null; return path.normalize(path.join(path.dirname(sourceFile), clean)); } function checkLinks() { const errors = []; const warnings = []; const markdownFiles = listFiles('docs', (file) => file.endsWith('.md')); const linkPattern = /\[[^\]]+\]\(([^)]+)\)/g; for (const file of markdownFiles) { const text = readText(file); for (const match of text.matchAll(linkPattern)) { const normalized = normalizeLinkTarget(file, match[1]); if (!normalized) continue; if (!pathExists(normalized)) { warnings.push(`broken local link in ${file}: ${match[1]}`); } } } return { errors, warnings }; } function checkSensitiveRisk() { const warnings = []; const files = [ ...listFiles('docs', (file) => /\.(md|csv)$/i.test(file)), ...listFiles('issues', (file) => file.endsWith('.csv') || file.endsWith('.md')), ...listFiles('scripts/qa', (file) => /\.(mjs|js|sh)$/i.test(file)), ]; const patterns = [ /BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY/, /AKIA[0-9A-Z]{16}/, /(secret|token|password|api[_-]?key)\s*[:=]\s*['"]?[A-Za-z0-9_./+=-]{16,}/i, ]; for (const file of files) { const text = readText(file); if (patterns.some((pattern) => pattern.test(text))) { warnings.push(`possible sensitive literal risk in ${file}`); } } return { errors: [], warnings }; } function checkStaleTerms() { const warnings = []; const currentDocs = [ 'docs/文档索引.md', 'docs/文档事实源状态矩阵.md', 'docs/海音达OA-MVP需求规格说明书.md', 'docs/开发交付与变更管理规约.md', 'docs/测试规约与回归门禁.md', ]; const staleTerms = ['TODO', '未部署', '尚未完成', '下一批', '旧口径']; for (const file of currentDocs) { if (!pathExists(file)) continue; const text = readText(file); const hits = staleTerms.filter((term) => text.includes(term)); if (hits.length > 0) { warnings.push(`stale-term review needed in ${file}: ${hits.join(', ')}`); } } return { errors: [], warnings }; } function mergeResults(results) { return results.reduce( (acc, item) => ({ errors: [...acc.errors, ...item.errors], warnings: [...acc.warnings, ...item.warnings], }), { errors: [], warnings: [] }, ); } if (args.has('--help')) { printHelp(); process.exit(0); } const checks = []; if (args.has('--check-registry')) checks.push(checkRegistry); if (args.has('--check-links')) checks.push(checkLinks); if (checks.length === 0) { checks.push(checkRegistry, checkLinks, checkStaleTerms, checkSensitiveRisk); } const result = mergeResults(checks.map((check) => check())); console.log(`audit-docs: errors=${result.errors.length} warnings=${result.warnings.length}`); for (const warning of result.warnings.slice(0, 40)) { console.log(`warning: ${warning}`); } if (result.warnings.length > 40) { console.log(`warning: ${result.warnings.length - 40} more warnings omitted`); } for (const error of result.errors) { console.error(`error: ${error}`); } process.exit(result.errors.length === 0 ? 0 : 1);