261 行
8.0 KiB
JavaScript
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);
|