GOV8 建立连续型 Issue 治理基线
这个提交包含在:
@@ -0,0 +1,259 @@
|
||||
#!/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 || '<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);
|
||||
在新工单中引用
屏蔽一个用户