diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fb74f33..8d9de6e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,16 @@ make setup `make` lists every target; `make doctor` / `make check` verify the environment and run the pre-commit checks. +### Markdown links + +Lefthook runs the Markdown link checker during pre-commit. `make setup` installs the hooks; if dependencies are already installed and you only need the hook wiring, run: + +```bash +pnpm exec lefthook install --force +``` + +Run `node scripts/check-markdown-links.js` to scan the repository. Detailed usage, supported forms, exclusions, and fix guidance live in `node scripts/check-markdown-links.js --help`. + ### Test your changes locally Exercise the skills you touched before opening a PR. Neither tool hot-reloads the checkout (both serve a copied cache), so after editing: diff --git a/lefthook.yml b/lefthook.yml index f51e108f..90364fe5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -42,6 +42,14 @@ pre-commit: fi node scripts/sync-skill-argument-hints.mjs >/dev/null git add plugins/*/skills/*/SKILL.md 2>/dev/null || true + markdown-links: + glob: "**/*.md" + run: | + if ! command -v node >/dev/null 2>&1; then + echo "ℹ️ node not available; skipping markdown-links" + exit 0 + fi + node scripts/check-markdown-links.js summarize-plugin-catalogs: run: | [ -d plugins ] || exit 0 diff --git a/scripts/__tests__/check-markdown-links.test.js b/scripts/__tests__/check-markdown-links.test.js new file mode 100644 index 00000000..4ccaeb28 --- /dev/null +++ b/scripts/__tests__/check-markdown-links.test.js @@ -0,0 +1,200 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { + formatIssue, + reportProblems, +} = require("../check-markdown-links.js"); + +const root = path.resolve(__dirname, "../.."); +const script = path.join(root, "scripts/check-markdown-links.js"); + +test("formatIssue includes correction details", () => { + assert.equal( + formatIssue({ + raw: "./courses/intro.md", + reason: "cross-repo-relative-link", + suggestion: "https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md", + }), + "./courses/intro.md (cross-repo relative link; suggestion: https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md)", + ); +}); + +test("reportProblems writes a markdown source issue table", () => { + const lines = []; + const originalConsoleError = console.error; + console.error = (line = "") => lines.push(line); + + try { + reportProblems( + [ + { + file: path.join(root, "CLAUDE.md"), + line: 34, + raw: "@aidd_docs/memory/architecture.md", + reason: "local-path-not-found", + }, + ], + 339, + ); + } finally { + console.error = originalConsoleError; + } + + assert.deepEqual(lines, [ + "| source | issue |", + "| - | - |", + "| CLAUDE.md:34 | @aidd_docs/memory/architecture.md (local path not found) |", + "", + "❌ Links: 1 broken in 339 files", + ]); +}); + +test("reportProblems writes a success summary when there are no problems", () => { + const lines = []; + + const status = reportProblems([], 42, () => { + throw new Error("unexpected error logger call"); + }, (line) => lines.push(line)); + + assert.equal(status, 0); + assert.deepEqual(lines, ["✅ Links: 0 broken in 42 files"]); +}); + +test("--help documents supported links, exclusions, fixes, and examples", () => { + const result = spawnSync(process.execPath, [script, "--help"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /node scripts\/check-markdown-links\.js \[--ignore path\]/u); + assert.match(result.stdout, /Markdown links and images/u); + assert.match(result.stdout, /@path/u); + assert.match(result.stdout, /src: path/u); + assert.match(result.stdout, /Anchor-only links/u); + assert.match(result.stdout, /mailto:/u); + assert.match(result.stdout, /tel:/u); + assert.match(result.stdout, /HTML angle-bracket/u); + assert.match(result.stdout, /\.git and node_modules/u); + assert.match(result.stdout, /Runtime variables, glob patterns, and bare words/u); + assert.match(result.stdout, /\| Need \| Use \|/u); + assert.match(result.stdout, /\| Include\/import a file in agent context \| @path\/to\/file\.md \|/u); + assert.match(result.stdout, /\| Reference a file for the reader \| \[label\]\(path\/to\/file\.md\) \|/u); + assert.match(result.stdout, /\| Case \| Example \|/u); + assert.match(result.stdout, /@plugins\/aidd-context\/skills\/11-explore\/SKILL\.md/u); + assert.match(result.stdout, /\[explore skill\]\(plugins\/aidd-context\/skills\/11-explore\/SKILL\.md\)/u); +}); + +test("CLI checks one markdown file covering supported, ignored, and broken forms", () => { + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-")); + const fixture = path.join(tempDir, "all-cases.md"); + const readmePath = path.relative(tempDir, path.join(root, "README.md")); + const logoPath = path.relative(tempDir, path.join(root, "docs/assets/logo.png")); + const claudePath = path.relative(tempDir, path.join(root, "CLAUDE.md")); + const contributingPath = path.relative(tempDir, path.join(root, "CONTRIBUTING.md")); + const outsidePath = path.relative(tempDir, path.resolve(root, "..", "outside.md")); + const outsideSuggestion = "https://github.com/ai-driven-dev/framework/blob/main/outside.md"; + + fs.writeFileSync( + fixture, + [ + "# Link checker fixture", + "", + "## Supported existing forms", + `See [README](${readmePath}).`, + `![Logo](${logoPath})`, + `@${claudePath}`, + `src: ${contributingPath}`, + "", + "## Ignored forms", + "[Anchor only](#local-heading)", + "[Mail](mailto:security@example.com)", + "[Phone](tel:+33123456789)", + "", + `Logo`, + "src: $ARGUMENTS", + "src: plugins/*/README.md", + "src: README", + "[External URL](https://example.com/not-checked.md)", + "", + "## Broken forms", + "[Missing markdown](./missing.md)", + "@./missing-agent.md", + "src: ./missing-source.md", + `[Cross repo](${outsidePath})`, + "", + ].join("\n"), + "utf8", + ); + + try { + const result = spawnSync(process.execPath, [script, fixture], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /\| source \| issue \|/u); + assert.match(result.stderr, /\| - \| - \|/u); + assert.match(result.stderr, /missing\.md \(local path not found\)/u); + assert.match(result.stderr, /@\.\/missing-agent\.md \(local path not found\)/u); + assert.match(result.stderr, /\.\/missing-source\.md \(local path not found\)/u); + assert.ok( + result.stderr.includes(`${outsidePath} (cross-repo relative link; suggestion: ${outsideSuggestion})`), + result.stderr, + ); + assert.match(result.stderr, /❌ Links: 4 broken in 1 files/u); + + assert.doesNotMatch(result.stderr, /local-heading/u); + assert.doesNotMatch(result.stderr, /mailto:security@example\.com/u); + assert.doesNotMatch(result.stderr, /tel:\+33123456789/u); + assert.doesNotMatch(result.stderr, /(?:^|\s)https?:\/\/example\.com\/not-checked(?:\s|$)/u); + assert.doesNotMatch(result.stderr, /\$ARGUMENTS/u); + assert.doesNotMatch(result.stderr, /plugins\/\*\/README\.md/u); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("CLI without paths scans the repository and prints a summary", () => { + const result = spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /✅ Links: 0 broken in \d+ files/u); +}); + +test("CLI fails when an explicit input path does not exist", () => { + const result = spawnSync(process.execPath, [script, "DOES_NOT_EXIST.md"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /❌ Path not found: DOES_NOT_EXIST\.md/u); +}); + +test("repository scan ignores interrupted test temp directories", () => { + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-")); + + try { + fs.writeFileSync(path.join(tempDir, "leftover.md"), "[Missing](./missing.md)\n", "utf8"); + + const result = spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /✅ Links: 0 broken in \d+ files/u); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/check-markdown-links.js b/scripts/check-markdown-links.js new file mode 100644 index 00000000..783ffcec --- /dev/null +++ b/scripts/check-markdown-links.js @@ -0,0 +1,466 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); + +const HELP_TEXT = `Usage: + node scripts/check-markdown-links.js [--ignore path] + +Supported link forms: + - Markdown links and images: [label](path), ![alt](path) + - Agent context includes/imports: @path/to/file.md + - Source declarations: src: path/to/file.md + +Ignored / excluded forms: + - Anchor-only links such as #usage + - mailto: and tel: links + - HTML angle-bracket links and HTML attributes + - .git and node_modules directories + - Runtime variables, glob patterns, and bare words + +How to fix broken links: + | Need | Use | + | - | - | + | Include/import a file in agent context | @path/to/file.md | + | Reference a file for the reader | [label](path/to/file.md) | + +Examples: + | Case | Example | + | - | - | + | Agent include/import | @plugins/aidd-context/skills/11-explore/SKILL.md | + | Reader reference | See [explore skill](plugins/aidd-context/skills/11-explore/SKILL.md). | +`; + +const SKIPPED_DIRS = new Set([".git", "node_modules"]); +const SKIPPED_DIR_PREFIXES = [".tmp-check-markdown-links-"]; +const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]); +function normalizePathForDisplay(filePath) { + const relative = path.relative(ROOT, filePath).replaceAll(path.sep, "/"); + return relative || "."; +} + +function normalizeForMatch(filePath) { + return path.resolve(ROOT, filePath); +} + +function pathStartsWith(candidate, parent) { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function parseArgs(argv) { + const paths = []; + const ignores = []; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--help" || arg === "-h") { + return { help: true, paths, ignores }; + } + + if (arg === "--ignore") { + const ignoredPath = argv[index + 1]; + if (!ignoredPath || ignoredPath.startsWith("--")) { + throw new Error("--ignore requires a path"); + } + ignores.push(ignoredPath); + index += 1; + continue; + } + + paths.push(arg); + } + + return { help: false, paths: paths.length > 0 ? paths : ["."], ignores }; +} + +function isIgnored(filePath, ignoredPaths) { + const absolute = path.resolve(filePath); + return ignoredPaths.some((ignoredPath) => pathStartsWith(absolute, ignoredPath)); +} + +function collectMarkdownFiles(inputPaths, ignoredPaths = []) { + const files = []; + const normalizedIgnores = ignoredPaths.map((ignoredPath) => normalizeForMatch(ignoredPath)); + const missingInputs = inputPaths.filter((inputPath) => !fs.existsSync(path.resolve(ROOT, inputPath))); + + if (missingInputs.length > 0) { + throw new Error(`Path not found: ${missingInputs.join(", ")}`); + } + + function visit(currentPath) { + const absolute = path.resolve(ROOT, currentPath); + if (!fs.existsSync(absolute)) { + return; + } + + if (isIgnored(absolute, normalizedIgnores)) { + return; + } + + const stat = fs.statSync(absolute); + if (stat.isDirectory()) { + const basename = path.basename(absolute); + if (SKIPPED_DIRS.has(basename) || SKIPPED_DIR_PREFIXES.some((prefix) => basename.startsWith(prefix))) { + return; + } + + for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) { + visit(path.join(absolute, entry.name)); + } + return; + } + + if (stat.isFile() && MARKDOWN_EXTENSIONS.has(path.extname(absolute).toLowerCase())) { + files.push(absolute); + } + } + + for (const inputPath of inputPaths) { + visit(inputPath); + } + + return files.sort((a, b) => normalizePathForDisplay(a).localeCompare(normalizePathForDisplay(b))); +} + +function gatherMarkdownFiles(inputs, cwd = ROOT) { + return collectMarkdownFiles(inputs.length > 0 ? inputs : ["."], []).map((file) => path.resolve(cwd, path.relative(ROOT, file))); +} + +function filterIgnoredFiles(files, ignore = [], root = ROOT) { + if (ignore.length === 0) { + return files; + } + + const ignoredPaths = ignore.map((ignoredPath) => path.resolve(root, ignoredPath)); + return files.filter((file) => !isIgnored(file, ignoredPaths)); +} + +function lineNumberAt(content, index) { + return content.slice(0, index).split("\n").length; +} + +function stripTrailingPunctuation(target) { + return target.replace(/[.,;:]+$/u, ""); +} + +function splitMarkdownTarget(rawTarget) { + const trimmed = rawTarget.trim(); + if (trimmed.startsWith("<") && trimmed.endsWith(">")) { + return trimmed; + } + return trimmed.split(/\s+/u)[0]; +} + +function extractLinks(content) { + const links = []; + const markdownLinkPattern = /!?\[[^\]\n]*\]\(([^)\n]+)\)/gu; + const atPathPattern = /(^|[\n\r][ \t>*-]*)@([^\s`)\]},;]+)/gu; + const srcPathPattern = /\bsrc:\s*([^\s`)\]},;]+)/gu; + + for (const match of content.matchAll(markdownLinkPattern)) { + links.push({ + raw: splitMarkdownTarget(match[1]), + index: match.index, + kind: "markdown", + }); + } + + for (const match of content.matchAll(atPathPattern)) { + links.push({ + raw: `@${stripTrailingPunctuation(match[2])}`, + index: match.index + match[1].length, + kind: "agent-import", + }); + } + + for (const match of content.matchAll(srcPathPattern)) { + links.push({ + raw: stripTrailingPunctuation(match[1]), + index: match.index, + kind: "src", + }); + } + + return links; +} + +function isIgnoredTarget(target) { + if (!target) { + return true; + } + + if (target.startsWith("<") && target.endsWith(">")) { + return true; + } + + if (/[<>]/u.test(target)) { + return true; + } + + if (target.startsWith("#")) { + return true; + } + + if (/^(mailto|tel):/iu.test(target)) { + return true; + } + + if (/[*?[\]]/u.test(target)) { + return true; + } + + if (/^\$[\w_]+/u.test(target)) { + return true; + } + + if (/\{\{[^}]+\}\}/u.test(target)) { + return true; + } + + if (!target.includes("/") && !target.includes(".") && !target.startsWith("..")) { + return true; + } + + return false; +} + +function stripAnchor(target) { + const hashIndex = target.indexOf("#"); + return hashIndex === -1 ? target : target.slice(0, hashIndex); +} + +function safeDecodeUri(target) { + try { + return decodeURI(target); + } catch { + return target; + } +} + +function isRemoteUrl(target) { + return /^https?:\/\//iu.test(target); +} + +function resolveLocalPath(target, sourceFile) { + const withoutAnchor = stripAnchor(target); + if (!withoutAnchor) { + return { ignored: true }; + } + + const decoded = safeDecodeUri(withoutAnchor); + if (path.isAbsolute(decoded)) { + return { absolute: path.resolve(ROOT, decoded.slice(1)) }; + } + + const absolute = path.resolve(path.dirname(sourceFile), decoded); + const sourceRelative = normalizePathForDisplay(sourceFile); + const assetTemplateMatch = sourceRelative.match(/^(plugins\/[^/]+\/skills\/[^/]+\/assets)\/.+$/u); + if (assetTemplateMatch) { + const generatedTemplateAbsolute = path.resolve(ROOT, assetTemplateMatch[1], "templates", decoded); + if (!fs.existsSync(absolute) && fs.existsSync(generatedTemplateAbsolute)) { + return { absolute: generatedTemplateAbsolute }; + } + // A *-template.md scaffold links to files emitted next to the generated + // output at runtime (e.g. ./plan.md, ./phase-1.md), which never exist in + // the repo. A dot-relative target that resolves nowhere is an intentional + // placeholder for that generated sibling, not a broken link. + if (!fs.existsSync(absolute) && /-template\.md$/u.test(sourceRelative) && /^\.\.?\//u.test(decoded)) { + return { ignored: true }; + } + } + + if (normalizePathForDisplay(sourceFile).startsWith(".github/")) { + const repoRootAbsolute = path.resolve(ROOT, decoded); + if (!fs.existsSync(absolute) && fs.existsSync(repoRootAbsolute)) { + return { absolute: repoRootAbsolute }; + } + } + + return { absolute }; +} + +function problemForTarget(target, sourceFile) { + const validationTarget = target.startsWith("@") ? target.slice(1) : target; + + if (isIgnoredTarget(validationTarget)) { + return null; + } + + if (isRemoteUrl(validationTarget)) { + return null; + } + + const resolved = resolveLocalPath(validationTarget, sourceFile); + if (resolved.ignored) { + return null; + } + + if (!pathStartsWith(resolved.absolute, ROOT)) { + const repoRelative = path.relative(ROOT, resolved.absolute).replaceAll(path.sep, "/"); + return { + raw: target, + reason: "cross-repo-relative-link", + suggestion: `https://github.com/ai-driven-dev/framework/blob/main/${repoRelative.replace(/^(\.\.\/)+/u, "")}`, + }; + } + + if (!fs.existsSync(resolved.absolute)) { + return { raw: target, reason: "local-path-not-found" }; + } + + return null; +} + +function checkFile(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const links = extractLinks(content); + const problems = []; + + for (const link of links) { + const problem = problemForTarget(link.raw, filePath); + if (!problem) { + continue; + } + + problems.push({ + ...problem, + file: filePath, + line: lineNumberAt(content, link.index), + kind: link.kind, + }); + } + + return problems; +} + +function checkMarkdownLinks(pathsToCheck, ignoredPaths = []) { + const files = collectMarkdownFiles(pathsToCheck, ignoredPaths); + const problems = files.flatMap((file) => checkFile(file)); + return { files, problems }; +} + +function findBrokenLinks(files) { + return files.flatMap((file) => checkFile(file)); +} + +function issueLink(problem) { + return problem.raw ?? problem.link; +} + +function issueReason(problem) { + if (problem.reason) { + return problem.reason; + } + + switch (problem.type) { + case "cross-repo": + return "cross-repo-relative-link"; + case "broken-remote": + return "remote-url-not-found"; + case "broken-template": + return "template-path-not-found"; + default: + return problem.type; + } +} + +function formatIssue(problem) { + const link = issueLink(problem); + + switch (issueReason(problem)) { + case "cross-repo-relative-link": + return `${link} (cross-repo relative link; suggestion: ${problem.suggestion})`; + case "remote-url-not-found": + return `${link} (remote URL not found locally)`; + case "template-path-not-found": + return `${link} (template path not found in framework source)`; + case "local-path-not-found": + return `${link} (local path not found)`; + default: + return `${link} (file not found)`; + } +} + +function escapeMarkdownTableCell(value) { + return String(value).replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function reportProblems(problems, checkedFileCount, logger = console.error, successLogger = console.log) { + if (problems.length === 0) { + successLogger(`✅ Links: 0 broken in ${checkedFileCount} files`); + return 0; + } + + logger("| source | issue |"); + logger("| - | - |"); + + for (const problem of problems) { + const source = `${normalizePathForDisplay(problem.file)}:${problem.line}`; + logger(`| ${escapeMarkdownTableCell(source)} | ${escapeMarkdownTableCell(formatIssue(problem))} |`); + } + + logger(""); + logger(`❌ Links: ${problems.length} broken in ${checkedFileCount} files`); + return 1; +} + +function runCli(argv = process.argv.slice(2)) { + let parsed; + try { + parsed = parseArgs(argv); + } catch (error) { + console.error(error.message); + console.error("Run with --help for usage."); + return 1; + } + + if (parsed.help) { + console.log(HELP_TEXT); + return 0; + } + + try { + const { files, problems } = checkMarkdownLinks(parsed.paths, parsed.ignores); + reportProblems(problems, files.length); + return problems.length === 0 ? 0 : 1; + } catch (error) { + console.error(`❌ ${error.message}`); + return 1; + } +} + +function run(inputs, { ignore = [] } = {}) { + try { + const { files, problems } = checkMarkdownLinks(inputs && inputs.length > 0 ? inputs : ["."], ignore); + reportProblems(problems, files.length); + return problems.length === 0 ? 0 : 1; + } catch (error) { + console.error(`❌ ${error.message}`); + return 1; + } +} + +if (require.main === module) { + process.exitCode = runCli(); +} + +module.exports = { + HELP_TEXT, + checkFile, + checkMarkdownLinks, + collectMarkdownFiles, + extractLinks, + filterIgnoredFiles, + formatIssue, + gatherMarkdownFiles, + findBrokenLinks, + parseArgs, + problemForTarget, + reportProblems, + run, + runCli, +};