文件

261 行
8.0 KiB
JavaScript

#!/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',
'.trellis/workflow.md',
'.trellis/spec/hyb-oa/index.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 || '<empty>'} 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);