#!/usr/bin/env node /** * run-link-check.mjs — markdown link-check runner (M1, S-104) * * Runs markdown-link-check across README.md, docs/, and every SKILL.md * (excluding node_modules and generated agent-variant directories). * * Modes: * --offline (default) — checks only repo-relative links and #anchor links. * All http/https links are ignored. Safe for CI and local dev * without network access. * --online — checks all links, including external URLs, with timeouts * and retries as configured in markdown-link-check.online.json. * * Exit codes: * 0 — all checked links are alive (or ignored in offline mode) * 1 — one or more broken links found */ import { spawnSync } from "node:child_process"; import { readdirSync, existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../.."); // ── CLI arguments ────────────────────────────────────────────────────────── const online = process.argv.includes("--online"); const configFile = online ? path.join(REPO_ROOT, "markdown-link-check.online.json") : path.join(REPO_ROOT, "markdown-link-check.json"); // ── File discovery ───────────────────────────────────────────────────────── const SKIP_PATHS = new Set([ "skills/atlassian/codex", "skills/atlassian/claude-code", "skills/atlassian/cursor", "skills/atlassian/opencode", "skills/atlassian/pi", "skills/web-automation/claude-code", "skills/web-automation/cursor", "skills/web-automation/opencode", "skills/web-automation/pi", "pi-package", ]); // Also skip any _source/ subdirectory within skills — canonical source files // use relative paths calibrated to the generated location (one level shallower). const SKIP_SEGMENT = "_source"; function shouldSkip(absPath) { const rel = path.relative(REPO_ROOT, absPath); for (const skip of SKIP_PATHS) { if (rel === skip || rel.startsWith(skip + path.sep)) return true; } const parts = rel.split(path.sep); if (parts.includes("node_modules")) return true; // Skip canonical _source/ directories (links calibrated to generated location) if (parts.includes(SKIP_SEGMENT)) return true; return false; } function collectMarkdownFiles(dir) { const found = []; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; } for (const entry of entries) { const full = path.join(dir, entry.name); if (shouldSkip(full)) continue; if (entry.isDirectory()) { found.push(...collectMarkdownFiles(full)); } else if ( entry.isFile() && (entry.name.endsWith(".md") || entry.name === "SKILL.md") ) { found.push(full); } } return found; } // ── Collect target files ─────────────────────────────────────────────────── const files = [ path.join(REPO_ROOT, "README.md"), ...collectMarkdownFiles(path.join(REPO_ROOT, "docs")), ...collectMarkdownFiles(path.join(REPO_ROOT, "skills")), ].filter(existsSync); // De-duplicate (README.md could appear twice) const uniqueFiles = [...new Set(files)]; if (uniqueFiles.length === 0) { console.log("link-check: no markdown files found — nothing to check."); process.exit(0); } console.log( `link-check: checking ${uniqueFiles.length} file(s) ` + `[mode: ${online ? "online" : "offline"}]…` ); // ── Run markdown-link-check ──────────────────────────────────────────────── const mlcBin = path.join( REPO_ROOT, "node_modules", ".bin", "markdown-link-check" ); let failures = 0; for (const file of uniqueFiles.sort()) { const result = spawnSync( mlcBin, ["--config", configFile, "--quiet", file], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } ); const output = (result.stdout + result.stderr).trim(); const rel = path.relative(REPO_ROOT, file); if (result.status !== 0) { failures += 1; console.error(`\n--- ${rel} ---`); if (output) console.error(output); } } if (failures > 0) { console.error( `\nlink-check: ${failures} file(s) have broken links. ` + `See docs/CLEANUP-BASELINE.md for the as-is baseline.` ); process.exit(1); } else { console.log("link-check: all links OK."); process.exit(0); }