#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
import { join, resolve, relative, dirname, basename, normalize } from 'node:path';
const PROJECT_ROOT = resolve(import.meta.dirname, '..', '..');
const TMP_DIR = join(PROJECT_ROOT, '_tmp');
const DOC_DIRS = ['docs-for-ai', 'ai-dev'];
function toPosix(p) { return p.split(/\\/).join('/'); }
const MD_LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
const BACKTICK_PATH_RE = /`([a-zA-Z0-9_][a-zA-Z0-9_./\-]*\.(?:md|xml|json|yaml|yml|java|txt|sh|cmd|mjs|js))`/g;
const BACKTICK_DIR_RE = /`((?:docs-for-ai|ai-dev)\/[a-zA-Z0-9_.\/\-]*[a-zA-Z0-9_\/])`/g;
const URL_RE = /^(?:https?|mailto|ftp):/;
const HEADING_ANCHOR_RE = /#[a-z0-9-]+$/;
function collectMdFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
function walk(d) {
for (const entry of readdirSync(d, { withFileTypes: true })) {
const full = join(d, entry.name);
if (entry.name === 'node_modules' || entry.name === '.git') continue;
if (entry.isDirectory()) walk(full);
else if (entry.isFile() && entry.name.endsWith('.md')) results.push(full);
}
}
walk(dir);
return results;
}
function collectAllFiles(dir) {
const results = [];
if (!existsSync(dir)) return results;
function walk(d) {
for (const entry of readdirSync(d, { withFileTypes: true })) {
const full = join(d, entry.name);
if (entry.name === 'node_modules' || entry.name === '.git') continue;
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) results.push(full);
}
}
walk(dir);
return results;
}
function extractDocRefs(content) {
const refs = new Set();
let m;
MD_LINK_RE.lastIndex = 0;
while ((m = MD_LINK_RE.exec(content)) !== null) {
let target = m[2].trim().replace(HEADING_ANCHOR_RE, '');
if (!target || URL_RE.test(target)) continue;
refs.add(target);
}
BACKTICK_PATH_RE.lastIndex = 0;
while ((m = BACKTICK_PATH_RE.exec(content)) !== null) {
if (URL_RE.test(m[1])) continue;
refs.add(m[1]);
}
BACKTICK_DIR_RE.lastIndex = 0;
while ((m = BACKTICK_DIR_RE.exec(content)) !== null) {
if (!m[1].match(/\.[a-z]{1,4}$/)) refs.add(m[1]);
else refs.add(m[1]);
}
return [...refs];
}
function resolveRelPath(refRaw, sourceFilePath, sourceType) {
const sourceDir = dirname(sourceFilePath);
if (refRaw.startsWith('./') || refRaw.startsWith('../')) {
return toPosix(normalize(join(sourceDir, refRaw)));
}
if (refRaw.startsWith('docs-for-ai/') || refRaw.startsWith('ai-dev/')) {
return toPosix(normalize(join(PROJECT_ROOT, refRaw)));
}
if (refRaw.includes('/')) {
const candidates = [
toPosix(normalize(join(PROJECT_ROOT, refRaw))),
];
if (sourceType === 'docs-for-ai') {
candidates.push(toPosix(normalize(join(PROJECT_ROOT, 'docs-for-ai', refRaw))));
}
if (sourceType === 'ai-dev') {
candidates.push(toPosix(normalize(join(PROJECT_ROOT, 'ai-dev', refRaw))));
}
candidates.push(toPosix(normalize(join(sourceDir, refRaw))));
for (const c of candidates) {
if (existsSync(c)) return c;
}
return candidates[0];
}
const c1 = toPosix(normalize(join(sourceDir, refRaw)));
if (existsSync(c1)) return c1;
const c2 = toPosix(normalize(join(PROJECT_ROOT, refRaw)));
if (existsSync(c2)) return c2;
return c1;
}
function parseIndexEntries(indexPath) {
const content = readFileSync(indexPath, 'utf-8');
const entries = [];
for (const line of content.split('\n')) {
const tdMatch = line.match(/\|\s*`([^`]+)`\s*\|/g);
if (tdMatch) {
for (const td of tdMatch) {
const inner = td.match(/`([^`]+)`/);
if (inner) {
const path = inner[1];
if (path.match(/\.(md|mjs|js|sh|java|xml|yaml|yml)$/)) {
entries.push(path);
}
}
}
}
const linkMatch = line.match(/\[([^\]]*)\]\(([^)]+)\)/g);
if (linkMatch) {
for (const lm of linkMatch) {
const inner = lm.match(/\(([^)]+)\)/);
if (inner) {
let target = inner[1].trim().replace(HEADING_ANCHOR_RE, '');
if (!target || URL_RE.test(target)) continue;
if (target.match(/\.(md|mjs|js|sh|java|xml|yaml|yml)$/)) {
entries.push(target);
}
}
}
}
}
return [...new Set(entries)];
}
function parseRouteTable(indexPath) {
const content = readFileSync(indexPath, 'utf-8');
const routes = [];
const lines = content.split('\n');
for (const line of lines) {
const cells = line.split('|').map(c => c.trim()).filter(c => c);
if (cells.length >= 2) {
const task = cells[0].replace(/\*\*/g, '');
const doc = cells[1].replace(/`/g, '');
if (doc.match(/\.(md|mjs|js|sh|java|xml|yaml|yml)$/) && !doc.match(/^---/) && !doc.match(/^用途/)) {
routes.push({ task, doc, line: lines.indexOf(line) + 1 });
}
}
}
return routes;
}
function normalizePath(p, sourceDir, sourceType) {
if (p.startsWith('./') || p.startsWith('../')) {
return toPosix(relative(PROJECT_ROOT, normalize(join(sourceDir, p))));
}
if (p.startsWith('docs-for-ai/') || p.startsWith('ai-dev/')) return p;
const fromSource = toPosix(normalize(join(sourceDir, p)));
if (existsSync(fromSource)) {
return toPosix(relative(PROJECT_ROOT, fromSource));
}
if (sourceType === 'docs-for-ai') {
const fromRoot = toPosix(normalize(join(PROJECT_ROOT, 'docs-for-ai', p)));
if (existsSync(fromRoot)) return toPosix(relative(PROJECT_ROOT, fromRoot));
}
if (sourceType === 'ai-dev') {
const fromRoot = toPosix(normalize(join(PROJECT_ROOT, 'ai-dev', p)));
if (existsSync(fromRoot)) return toPosix(relative(PROJECT_ROOT, fromRoot));
}
return toPosix(relative(PROJECT_ROOT, fromSource));
}
function findIndexFiles() {
const indices = [];
for (const dir of DOC_DIRS) {
const root = join(PROJECT_ROOT, dir);
if (!existsSync(root)) continue;
const rootIndex = join(root, 'INDEX.md');
if (existsSync(rootIndex)) indices.push({ path: rootIndex, type: 'top-level', dir });
const readme = join(root, 'README.md');
if (existsSync(readme)) indices.push({ path: readme, type: 'top-level', dir });
function walk(d) {
for (const entry of readdirSync(d, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const full = join(d, entry.name);
if (entry.isDirectory()) {
const subReadme = join(full, 'README.md');
if (existsSync(subReadme)) {
indices.push({ path: subReadme, type: 'sub-index', dir });
}
walk(full);
}
}
}
walk(root);
}
return indices;
}
function gatherExistingFiles() {
const files = new Map();
for (const dir of DOC_DIRS) {
const root = join(PROJECT_ROOT, dir);
if (!existsSync(root)) continue;
const mdFiles = collectMdFiles(root);
for (const f of mdFiles) {
const rel = toPosix(relative(PROJECT_ROOT, f));
files.set(rel, f);
}
}
return files;
}
function findDirectoryWithoutIndex(dir) {
const results = [];
if (!existsSync(dir)) return results;
function walk(d) {
const entries = readdirSync(d, { withFileTypes: true });
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'README.md');
const hasReadme = entries.some(e => e.isFile() && e.name === 'README.md');
if (mdFiles.length >= 8 && !hasReadme) {
results.push(toPosix(relative(PROJECT_ROOT, d)));
}
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const full = join(d, entry.name);
if (entry.isDirectory()) walk(full);
}
}
walk(dir);
return results;
}
function checkOrphans(existingFiles, indexFiles) {
const orphans = [];
const indexedSet = new Set();
for (const idx of indexFiles) {
const entries = parseIndexEntries(idx.path);
const sourceDir = dirname(idx.path);
const sourceRel = toPosix(relative(PROJECT_ROOT, idx.path));
const sourceType = sourceRel.startsWith('docs-for-ai/') ? 'docs-for-ai'
: sourceRel.startsWith('ai-dev/') ? 'ai-dev' : 'other';
for (const entry of entries) {
const normalized = normalizePath(entry, sourceDir, sourceType);
indexedSet.add(normalized);
}
}
for (const [rel] of existingFiles) {
if (rel.match(/\/00-.*-guide\.md$/)) continue;
if (rel.match(/README\.md$/)) continue;
if (rel.endsWith('INDEX.md')) continue;
let found = false;
for (const idx of indexedSet) {
if (rel === idx || rel.endsWith('/' + idx) || idx.endsWith('/' + basename(rel))) {
found = true;
break;
}
}
if (!found) {
orphans.push(rel);
}
}
return orphans;
}
function findDuplicatedRules() {
const RULE_PATTERNS = [
{ pattern: /先模型.*(?:再|然后).*Delta.*(?:最后|然后).*Java/, label: '先模型再Delta最后Java' },
{ pattern: /不要修改.*_gen/, label: '不要修改生成物' },
{ pattern: /CrudBizModel/, label: 'CrudBizModel默认基类' },
{ pattern: /requireEntity\(\).*doFindList\(\).*doFindPage\(\)/, label: '安全查询API' },
{ pattern: /@BizMutation.*(?:自动|已自动).*事务/, label: '@BizMutation自动事务' },
{ pattern: /@Inject.*(?:不支持|不能).*private/, label: '@Inject不支持private' },
];
const occurrences = [];
for (const dir of DOC_DIRS) {
const root = join(PROJECT_ROOT, dir);
const mdFiles = collectMdFiles(root);
for (const f of mdFiles) {
const content = readFileSync(f, 'utf-8');
const rel = toPosix(relative(PROJECT_ROOT, f));
for (const { pattern, label } of RULE_PATTERNS) {
if (pattern.test(content)) {
occurrences.push({ rule: label, file: rel });
}
}
}
}
const grouped = {};
for (const o of occurrences) {
if (!grouped[o.rule]) grouped[o.rule] = [];
grouped[o.rule].push(o.file);
}
return Object.entries(grouped)
.filter(([, files]) => files.length > 1)
.map(([rule, files]) => ({ rule, files }));
}
function checkIndexSyncWithFiles() {
const issues = [];
const docsForAiRoot = join(PROJECT_ROOT, 'docs-for-ai');
const indexFile = join(docsForAiRoot, 'INDEX.md');
if (!existsSync(indexFile)) return issues;
const indexContent = readFileSync(indexFile, 'utf-8');
const defaultsFile = join(docsForAiRoot, '00-start-here', 'ai-defaults.md');
if (existsSync(defaultsFile)) {
const defaultsContent = readFileSync(defaultsFile, 'utf-8');
const indexOrder = indexContent.match(/##\s*推荐查找顺序[\s\S]*?(?=##|$)/);
const defaultsOrder = defaultsContent.match(/##\s*默认查找顺序[\s\S]*?(?=##|$)/);
if (indexOrder && defaultsOrder) {
const indexSteps = indexOrder[0].match(/\d+\.\s+.+/g) || [];
const defaultsSteps = defaultsOrder[0].match(/\d+\.\s+.+/g) || [];
const extractFileRefs = (steps) => steps.map(s => {
const matches = s.match(/`([^`]+)`/g);
return matches ? matches.map(m => m.replace(/`/g, '')) : [];
}).flat();
const indexRefs = extractFileRefs(indexSteps);
const defaultsRefs = extractFileRefs(defaultsSteps);
if (JSON.stringify(indexRefs) !== JSON.stringify(defaultsRefs)) {
issues.push({
severity: 'auto-fix',
rule: 'INDEX_ORDER_MISMATCH',
file: 'docs-for-ai/00-start-here/ai-defaults.md',
message: 'ai-defaults.md 默认查找顺序与 INDEX.md 推荐查找顺序不一致',
indexRefs,
defaultsRefs,
});
}
}
const indexDefaultRules = indexContent.match(/##\s*默认规则[\s\S]*?(?=##|$)/);
const defaultsDefaultRules = defaultsContent.match(/##\s*当前仓库的硬规则[\s\S]*?(?=##|$)/);
if (indexDefaultRules && defaultsDefaultRules) {
const indexRules = (indexDefaultRules[0].match(/^- .+$/gm) || []).sort();
const defaultsRules = (defaultsDefaultRules[0].match(/^\|[^|]+\|[^|]+\|$/gm) || []).sort();
if (indexRules.length > 0 && defaultsRules.length > 0) {
issues.push({
severity: 'warning',
rule: 'RULE_DUPLICATION',
files: ['docs-for-ai/INDEX.md', 'docs-for-ai/00-start-here/ai-defaults.md'],
message: `默认规则在 INDEX.md 和 ai-defaults.md 中重复定义(INDEX ${indexRules.length} 条,ai-defaults ${defaultsRules.length} 条)`,
});
}
}
}
const runbooksReadme = join(docsForAiRoot, '03-runbooks', 'README.md');
if (existsSync(runbooksReadme)) {
const runbookContent = readFileSync(runbooksReadme, 'utf-8');
const runbookRules = runbookContent.match(/##\s*默认规则[\s\S]*?(?=##|$)/);
if (runbookRules) {
issues.push({
severity: 'warning',
rule: 'RULE_DUPLICATION',
files: ['docs-for-ai/INDEX.md', 'docs-for-ai/03-runbooks/README.md'],
message: '默认规则在 INDEX.md 和 03-runbooks/README.md 中重复定义',
});
}
}
return issues;
}
function checkLogIndexSync() {
const issues = [];
const logsDir = join(PROJECT_ROOT, 'ai-dev', 'logs');
const yearDir = join(logsDir, '2026');
const indexFile = join(logsDir, 'index.md');
if (!existsSync(yearDir) || !existsSync(indexFile)) return issues;
const actualFiles = readdirSync(yearDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''))
.sort();
const indexContent = readFileSync(indexFile, 'utf-8');
const indexTreeFiles = [];
const treeMatch = indexContent.match(/```text[\s\S]*?```/);
if (treeMatch) {
const lines = treeMatch[0].split('\n');
for (const line of lines) {
const m = line.match(/^\s*├──\s*(\d{2}-\d{2}\.md)\s*$/);
const m2 = line.match(/^\s*└──\s*(\d{2}-\d{2}\.md)\s*$/);
const file = (m || m2)?.[1]?.replace('.md', '');
if (file) indexTreeFiles.push(file);
}
}
const indexLinkedFiles = [];
const linkRe = /\[(\d{2}-\d{2})\]/g;
let lm;
while ((lm = linkRe.exec(indexContent)) !== null) {
indexLinkedFiles.push(lm[1]);
}
const missingFromTree = actualFiles.filter(f => !indexTreeFiles.includes(f));
const missingFromIndex = actualFiles.filter(f => !indexLinkedFiles.includes(f));
for (const f of missingFromTree) {
issues.push({
severity: 'auto-fix',
rule: 'LOG_TREE_STALE',
file: 'ai-dev/logs/index.md',
message: `日志文件 2026/${f}.md 存在但目录树中未列出`,
detail: f,
});
}
for (const f of missingFromIndex) {
issues.push({
severity: 'auto-fix',
rule: 'LOG_INDEX_STALE',
file: 'ai-dev/logs/index.md',
message: `日志文件 2026/${f}.md 存在但索引链接中未列出`,
detail: f,
});
}
return issues;
}
function fixLogIndex(missing) {
const logsDir = join(PROJECT_ROOT, 'ai-dev', 'logs');
const indexFile = join(logsDir, 'index.md');
const yearDir = join(logsDir, '2026');
const actualFiles = readdirSync(yearDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''))
.sort()
.reverse();
let content = readFileSync(indexFile, 'utf-8');
const treeMissing = missing.filter(m => m.rule === 'LOG_TREE_STALE').map(m => m.detail);
if (treeMissing.length > 0) {
const treeBlock = content.match(/```text\n([\s\S]*?)```/);
if (treeBlock) {
const indent = ' ';
const treeLines = actualFiles.map((f, i) => {
const connector = i === actualFiles.length - 1 ? '└──' : '├──';
return `${indent}${connector} ${f}.md`;
});
const newTree = '```text\nai-dev/logs/\n├── index.md\n├── 00-log-writing-guide.md\n└── 2026/\n' + treeLines.join('\n') + '\n```';
content = content.replace(/```text\n[\s\S]*?```/, newTree);
}
}
const indexMissing = missing.filter(m => m.rule === 'LOG_INDEX_STALE').map(m => m.detail);
if (indexMissing.length > 0) {
const monthHeader = '### 2026-05';
const sectionMatch = content.match(new RegExp(
'(' + monthHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?)(?=###|##|$)'
));
if (sectionMatch) {
const section = sectionMatch[1];
const existingLinks = new Set();
const linkRe = /- \[(\d{2}-\d{2})\]/g;
let lm;
while ((lm = linkRe.exec(section)) !== null) {
existingLinks.add(lm[1]);
}
const mayFiles = actualFiles.filter(f => f.startsWith('05'));
const newEntries = mayFiles
.filter(f => !existingLinks.has(f))
.map(f => `- [${f}](2026/${f}.md) — `);
if (newEntries.length > 0) {
const insertAfter = section.indexOf('\n') + 1;
const updatedSection = section.slice(0, insertAfter) + newEntries.join('\n') + '\n' + section.slice(insertAfter);
content = content.replace(sectionMatch[0], updatedSection);
}
}
}
writeFileSync(indexFile, content, 'utf-8');
}
function fixDefaultsOrder(indexRefs) {
const defaultsFile = join(PROJECT_ROOT, 'docs-for-ai', '00-start-here', 'ai-defaults.md');
if (!existsSync(defaultsFile)) return;
let content = readFileSync(defaultsFile, 'utf-8');
const orderSection = content.match(/##\s*默认查找顺序[\s\S]*?(?=##|$)/);
if (!orderSection) return;
const indexFile = join(PROJECT_ROOT, 'docs-for-ai', 'INDEX.md');
const indexContent = readFileSync(indexFile, 'utf-8');
const indexOrderSection = indexContent.match(/##\s*推荐查找顺序[\s\S]*?(?=##|$)/);
if (!indexOrderSection) return;
const newOrderSection = orderSection[0].replace(
/(\d+\.\s+[^\n]+\n?)+/,
indexOrderSection[0]
.replace(/##\s*推荐查找顺序\n*/, '')
.replace(/^\d+\.\s+先看本页\n?/m, '')
.replace(/\n+/g, '\n')
.split('\n')
.filter(l => l.trim())
.map((line, i) => `${i + 1}. ${line.replace(/^\d+\.\s*/, '')}`)
.join('\n') + '\n'
);
content = content.replace(orderSection[0], '## 默认查找顺序\n' + newOrderSection.replace(/##\s*默认查找顺序\n*/, ''));
writeFileSync(defaultsFile, content, 'utf-8');
}
function generateReport(allIssues, fixes) {
const bySeverity = { 'error': [], 'warning': [], 'info': [], 'auto-fix': [] };
for (const issue of allIssues) {
const sev = issue.severity || 'info';
if (!bySeverity[sev]) bySeverity[sev] = [];
bySeverity[sev].push(issue);
}
let h = '';
h += `# Doc Index Audit Report\n\n`;
h += `- **Timestamp**: ${new Date().toISOString()}\n`;
h += `- **Total findings**: ${allIssues.length}\n`;
h += `- **Auto-fixed**: ${fixes.length}\n\n`;
for (const sev of ['error', 'auto-fix', 'warning', 'info']) {
const items = bySeverity[sev];
if (!items || items.length === 0) continue;
const label = sev === 'auto-fix' ? 'Auto-Fixed' : sev.charAt(0).toUpperCase() + sev.slice(1) + 's';
h += `## ${label} (${items.length})\n\n`;
for (const item of items) {
const file = item.file || (item.files || []).join(', ');
h += `- **[${item.rule}]** \`${file}\`\n`;
h += ` > ${item.message}\n`;
if (item.detail) h += ` > Detail: ${item.detail}\n`;
}
h += '\n';
}
return h;
}
function main() {
const args = process.argv.slice(2);
const fixMode = args.includes('--fix');
const strictMode = args.includes('--strict');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
console.log('=== Doc Index Audit ===\n');
console.log(`Project root: ${PROJECT_ROOT}`);
console.log(`Fix mode: ${fixMode ? 'ON' : 'OFF'}\n`);
const allIssues = [];
const fixes = [];
console.log('1. Checking index entry links...');
const indexFiles = findIndexFiles();
for (const idx of indexFiles) {
const entries = parseIndexEntries(idx.path);
const sourceDir = dirname(idx.path);
const sourceRel = toPosix(relative(PROJECT_ROOT, idx.path));
const sourceType = sourceRel.startsWith('docs-for-ai/') ? 'docs-for-ai'
: sourceRel.startsWith('ai-dev/') ? 'ai-dev' : 'other';
for (const entry of entries) {
const normalized = normalizePath(entry, sourceDir, sourceType);
const absPath = normalized.includes(':') || normalized.startsWith('/')
? normalized
: join(PROJECT_ROOT, normalized);
if (!existsSync(absPath)) {
allIssues.push({
severity: 'error',
rule: 'BROKEN_INDEX_LINK',
file: sourceRel,
message: `索引条目 \`${entry}\` 指向的文件不存在 (解析为 ${normalized})`,
detail: entry,
});
}
}
}
console.log('2. Checking for orphan files...');
const existingFiles = gatherExistingFiles();
const orphans = checkOrphans(existingFiles, indexFiles);
for (const orphan of orphans) {
allIssues.push({
severity: 'warning',
rule: 'ORPHAN_FILE',
file: orphan,
message: `文件未被任何索引引用`,
});
}
console.log('3. Checking directories without index...');
for (const dir of DOC_DIRS) {
const noIndex = findDirectoryWithoutIndex(join(PROJECT_ROOT, dir));
for (const d of noIndex) {
allIssues.push({
severity: 'warning',
rule: 'MISSING_SUB_INDEX',
file: d,
message: `目录下有 >=8 个 .md 文件但缺少 README.md`,
});
}
}
console.log('4. Checking for duplicated rules...');
const dups = findDuplicatedRules();
for (const dup of dups) {
allIssues.push({
severity: 'warning',
rule: 'DUPLICATED_RULE',
files: dup.files,
file: dup.files.join(', '),
message: `规则"${dup.rule}"在 ${dup.files.length} 个文件中重复: ${dup.files.join(', ')}`,
});
}
console.log('5. Checking index sync...');
const syncIssues = checkIndexSyncWithFiles();
allIssues.push(...syncIssues);
console.log('6. Checking log index sync...');
const logIssues = checkLogIndexSync();
allIssues.push(...logIssues);
if (fixMode) {
console.log('\n--- Auto-fixing ---\n');
const logFixes = logIssues.filter(i => i.severity === 'auto-fix');
if (logFixes.length > 0) {
console.log(`Fixing log index (${logFixes.length} items)...`);
fixLogIndex(logFixes);
fixes.push(...logFixes);
}
const orderFixes = syncIssues.filter(i => i.severity === 'auto-fix' && i.rule === 'INDEX_ORDER_MISMATCH');
if (orderFixes.length > 0) {
for (const fix of orderFixes) {
console.log(`Fixing ${fix.file}: syncing lookup order with INDEX.md...`);
fixDefaultsOrder(fix.indexRefs);
fixes.push(fix);
}
}
}
const report = generateReport(allIssues, fixes);
mkdirSync(TMP_DIR, { recursive: true });
const reportPath = join(TMP_DIR, `doc-index-audit-${timestamp}.md`);
writeFileSync(reportPath, report, 'utf-8');
console.log('\n=== Report ===\n');
console.log(report);
console.log(`Report saved to: ${reportPath}`);
const errorCount = allIssues.filter(i => i.severity === 'error').length;
if (errorCount > 0) {
console.error(`\n${errorCount} error(s) found.`);
if (strictMode) process.exit(1);
} else {
console.log('\nNo errors found.');
}
}
main();