diff --git a/package.json b/package.json index 1307c8d..150b571 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "lint": "eslint .", "lock:complete": "node bin/torch-lock.mjs complete", "validate:scheduler": "node scripts/validate-scheduler-roster.mjs && node scripts/validate-prompt-contract.mjs && node scripts/validate-scheduler-flow-parity.mjs && npm run validate:scheduler-failure-schema", + "validate:docs": "node scripts/validate-docs-links.mjs", "scheduler:daily": "node scripts/agent/run-scheduler-cycle.mjs daily", "scheduler:weekly": "node scripts/agent/run-scheduler-cycle.mjs weekly", "report:lock-reliability": "node scripts/agent/summarize-lock-reliability.mjs --since-days 30", diff --git a/scripts/validate-docs-links.mjs b/scripts/validate-docs-links.mjs new file mode 100644 index 0000000..61df7f9 --- /dev/null +++ b/scripts/validate-docs-links.mjs @@ -0,0 +1,185 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT_DIR = path.resolve(__dirname, '..'); + +const IGNORE_DIRS = [ + 'node_modules', + '.git', + 'dist', + 'coverage', + 'test_logs', + 'task-logs', + 'reports', + 'artifacts', + 'bin' +]; + +const IGNORE_FILES = [ + 'package-lock.json', + 'yarn.lock' +]; + +function getAllFiles(dirPath, arrayOfFiles) { + const files = fs.readdirSync(dirPath); + + arrayOfFiles = arrayOfFiles || []; + + files.forEach(function(file) { + if (IGNORE_DIRS.includes(file) || IGNORE_FILES.includes(file)) { + return; + } + const fullPath = path.join(dirPath, file); + if (fs.statSync(fullPath).isDirectory()) { + arrayOfFiles = getAllFiles(fullPath, arrayOfFiles); + } else { + if (file.endsWith('.md')) { + arrayOfFiles.push(fullPath); + } + } + }); + + return arrayOfFiles; +} + +function slugify(text) { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove all non-word chars + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with - + .replace(/^-+|-+$/g, ''); // Remove leading/trailing - +} + +function getAnchors(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const anchors = new Set(); + + // Markdown headings + const headingRegex = /^(#{1,6})\s+(.*)$/gm; + let match; + while ((match = headingRegex.exec(content)) !== null) { + anchors.add(slugify(match[2])); + } + + // HTML anchors or + const htmlAnchorRegex = / { + const content = fs.readFileSync(file, 'utf8'); + const relativeFilePath = path.relative(ROOT_DIR, file); + + const processLink = (link) => { + link = link.trim(); + // Remove title + if (link.includes(' "') || link.includes(" '")) { + const parts = link.split(/\s+["']/); + link = parts[0]; + } + // Remove wrapping < > + if (link.startsWith('<') && link.endsWith('>')) { + link = link.slice(1, -1); + } + validateLink(link, file, relativeFilePath, brokenLinks); + }; + + // Regex for [text](url) + const inlineLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + let match; + while ((match = inlineLinkRegex.exec(content)) !== null) { + processLink(match[2]); + } + + // Regex for [ref]: url + const refLinkRegex = /^\[([^\]]+)\]:\s*(\S+)/gm; + while ((match = refLinkRegex.exec(content)) !== null) { + processLink(match[2]); + } + }); + + return brokenLinks; +} + +function validateLink(link, currentFile, relativeFilePath, brokenLinks) { + if (!link) return; + if (link.startsWith('http://') || link.startsWith('https://') || link.startsWith('mailto:') || link.startsWith('data:')) return; + + const [linkPath, anchor] = link.split('#'); + + let targetPath; + + if (!linkPath) { + // Internal anchor link: #header + targetPath = currentFile; + } else if (linkPath.startsWith('/')) { + targetPath = path.join(ROOT_DIR, linkPath); + } else { + targetPath = path.resolve(path.dirname(currentFile), linkPath); + } + + if (!fs.existsSync(targetPath)) { + brokenLinks.push({ + file: relativeFilePath, + link: link, + target: path.relative(ROOT_DIR, targetPath), + reason: 'File not found' + }); + return; + } + + // Check anchor if present and if target is a markdown file + if (anchor && targetPath.endsWith('.md')) { + const anchors = getFileAnchors(targetPath); + if (!anchors.has(anchor)) { + // Try decoding URI component just in case + try { + if (anchors.has(decodeURIComponent(anchor))) return; + } catch { + // Ignore decoding errors + } + + brokenLinks.push({ + file: relativeFilePath, + link: link, + target: path.relative(ROOT_DIR, targetPath), + reason: `Anchor '#${anchor}' not found` + }); + } + } +} + +console.log('Scanning for markdown files...'); +const files = getAllFiles(ROOT_DIR); +console.log(`Found ${files.length} markdown files. Checking links (including anchors)...`); +const brokenLinks = checkLinks(files); + +if (brokenLinks.length > 0) { + console.error(`Found ${brokenLinks.length} broken links:`); + brokenLinks.forEach(item => { + console.error(` ${item.file}: '${item.link}' -> ${item.reason}`); + }); + process.exit(1); +} else { + console.log('No broken links found.'); +} diff --git a/src/todo/TODO_2026-02-20T02-09-00Z.md b/src/todo/TODO_2026-02-20T02-09-00Z.md index 6202ea1..384387e 100644 --- a/src/todo/TODO_2026-02-20T02-09-00Z.md +++ b/src/todo/TODO_2026-02-20T02-09-00Z.md @@ -13,8 +13,8 @@ Executing prompt: `src/prompts/daily/docs-agent.md` ## Pending - [ ] Verify package.json scripts against docs. -- [ ] Check for broken links to missing docs. ## Completed +- [x] Check for broken links to missing docs. - [x] Initial audit of README.md and CONTRIBUTING.md. - [x] Update docs-agent.md to remove stale audit targets.