diff --git a/.github/workflows/validate-redirects.yml b/.github/workflows/validate-redirects.yml new file mode 100644 index 000000000..1fe0109b5 --- /dev/null +++ b/.github/workflows/validate-redirects.yml @@ -0,0 +1,34 @@ +name: Validate redirects + +permissions: + contents: read + +on: + pull_request: + paths: + - "docs/**" + - "scripts/validate-redirects.js" + - "scripts/validate-redirects.test.js" + - ".github/workflows/validate-redirects.yml" + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Test redirect validator + run: node --test scripts/validate-redirects.test.js + + - name: Reject newly broken redirects + env: + DOCS_BASE_REF: ${{ github.event.pull_request.base.sha }} + run: node scripts/validate-redirects.js --base-ref "$DOCS_BASE_REF" diff --git a/scripts/README.md b/scripts/README.md index a7a9b9a01..90001d351 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,27 @@ # Scripts +## Redirect validator + +Check every redirect destination against the docs tree and other redirects: + +```bash +node scripts/validate-redirects.js +``` + +The repository has pre-existing broken redirects. CI compares a pull request with its base +revision so existing debt does not block unrelated work while newly broken destinations, +redirect chains, and cycles fail the check: + +```bash +node scripts/validate-redirects.js --base-ref +``` + +Run the dependency-free test suite with: + +```bash +node --test scripts/validate-redirects.test.js +``` + ## MDX Linter Deterministic linter for MDX documentation files. diff --git a/scripts/validate-redirects.js b/scripts/validate-redirects.js new file mode 100644 index 000000000..ef35fe68a --- /dev/null +++ b/scripts/validate-redirects.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const REPO_ROOT = path.join(__dirname, ".."); +const DOCS_DIR = path.join(REPO_ROOT, "docs"); +const CONFIG_PATH = path.join(DOCS_DIR, "docs.json"); + +function normalizeRoute(route) { + const pathname = route.split(/[?#]/, 1)[0]; + const withLeadingSlash = pathname.startsWith("/") ? pathname : `/${pathname}`; + return withLeadingSlash.length > 1 ? withLeadingSlash.replace(/\/$/, "") : withLeadingSlash; +} + +function routesFromFilePaths(filePaths, docsPrefix = "") { + const routes = new Set(); + + for (const filePath of filePaths) { + let relative = filePath.replaceAll("\\", "/"); + if (docsPrefix && relative.startsWith(docsPrefix)) { + relative = relative.slice(docsPrefix.length); + } + if (!relative || relative === "docs.json") continue; + + routes.add(normalizeRoute(relative)); + if (!/\.mdx?$/.test(relative)) continue; + + const withoutExtension = relative.replace(/\.mdx?$/, ""); + routes.add(normalizeRoute(withoutExtension)); + if (withoutExtension.endsWith("/index")) { + routes.add(normalizeRoute(withoutExtension.slice(0, -"/index".length))); + } + } + + return routes; +} + +function walkFiles(directory, files = [], root = directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + walkFiles(fullPath, files, root); + } else { + files.push(path.relative(root, fullPath)); + } + } + return files; +} + +function compileRedirects(redirects) { + const exact = new Map(); + const dynamic = []; + + for (const redirect of redirects) { + if (!redirect.source.includes(":")) { + exact.set(normalizeRoute(redirect.source), redirect.destination); + continue; + } + + const names = []; + const escaped = normalizeRoute(redirect.source) + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/:([A-Za-z][A-Za-z0-9_]*)(\*)?/g, (_, name, wildcard) => { + names.push({ name, wildcard: Boolean(wildcard) }); + return wildcard ? "(.*)" : "([^/]+)"; + }); + + dynamic.push({ + ...redirect, + names, + pattern: new RegExp(`^${escaped}$`), + }); + } + + return { exact, dynamic }; +} + +function redirectTarget(route, compiled) { + if (compiled.exact.has(route)) return compiled.exact.get(route); + + for (const redirect of compiled.dynamic) { + const match = route.match(redirect.pattern); + if (!match) continue; + + let destination = redirect.destination; + redirect.names.forEach(({ name, wildcard }, index) => { + const token = `:${name}${wildcard ? "*" : ""}`; + destination = destination.replace(token, match[index + 1]); + }); + return destination; + } + + return undefined; +} + +function terminalExists(destination, routes) { + if (/^https?:\/\//.test(destination)) return true; + + const route = normalizeRoute(destination); + if (routes.has(route)) return true; + + const parameterIndex = route.indexOf("/:"); + if (parameterIndex === -1) return false; + + const prefix = route.slice(0, parameterIndex); + return [...routes].some(candidate => candidate.startsWith(`${prefix}/`)); +} + +function resolveDestination(destination, compiled, routes) { + let route = normalizeRoute(destination); + const seen = new Set(); + + for (let depth = 0; depth < 50; depth += 1) { + if (/^https?:\/\//.test(destination) || terminalExists(route, routes)) { + return { valid: true, terminal: route }; + } + if (seen.has(route)) { + return { valid: false, reason: "redirect cycle", terminal: route }; + } + + seen.add(route); + const target = redirectTarget(route, compiled); + if (!target) { + return { valid: false, reason: "missing destination", terminal: route }; + } + destination = target; + route = normalizeRoute(target); + } + + return { valid: false, reason: "redirect chain exceeds 50 hops", terminal: route }; +} + +function findInvalidRedirects(redirects, routes) { + const compiled = compileRedirects(redirects); + return redirects.flatMap(redirect => { + const result = resolveDestination(redirect.destination, compiled, routes); + return result.valid ? [] : [{ ...redirect, ...result }]; + }); +} + +function invalidKey(redirect) { + return `${redirect.source}\n${redirect.destination}`; +} + +function findNewInvalidRedirects(current, baseline) { + const baselineKeys = new Set(baseline.map(invalidKey)); + return current.filter(redirect => !baselineKeys.has(invalidKey(redirect))); +} + +function currentRoutes() { + return routesFromFilePaths(walkFiles(DOCS_DIR)); +} + +function routesAtRef(ref) { + const files = execFileSync("git", ["ls-tree", "-r", "--name-only", ref, "--", "docs"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim().split("\n").filter(Boolean); + return routesFromFilePaths(files, "docs/"); +} + +function configAtRef(ref) { + return JSON.parse(execFileSync("git", ["show", `${ref}:docs/docs.json`], { + cwd: REPO_ROOT, + encoding: "utf8", + })); +} + +function printInvalid(redirects) { + for (const redirect of redirects) { + console.error( + `- ${redirect.source} -> ${redirect.destination} (${redirect.reason}: ${redirect.terminal})`, + ); + } +} + +function main() { + const baseRefIndex = process.argv.indexOf("--base-ref"); + const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1]; + if (baseRefIndex !== -1 && !baseRef) { + console.error("--base-ref requires a Git ref"); + process.exit(2); + } + + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + const invalid = findInvalidRedirects(config.redirects ?? [], currentRoutes()); + const failures = baseRef + ? findNewInvalidRedirects( + invalid, + findInvalidRedirects(configAtRef(baseRef).redirects ?? [], routesAtRef(baseRef)), + ) + : invalid; + + if (failures.length > 0) { + console.error(`Found ${failures.length} new invalid redirect${failures.length === 1 ? "" : "s"}:`); + printInvalid(failures); + process.exit(1); + } + + const scope = baseRef ? `relative to ${baseRef}` : "in docs.json"; + console.log(`Redirect validation passed ${scope}.`); +} + +module.exports = { + compileRedirects, + findInvalidRedirects, + findNewInvalidRedirects, + normalizeRoute, + resolveDestination, + routesFromFilePaths, +}; + +if (require.main === module) main(); diff --git a/scripts/validate-redirects.test.js b/scripts/validate-redirects.test.js new file mode 100644 index 000000000..95646dc71 --- /dev/null +++ b/scripts/validate-redirects.test.js @@ -0,0 +1,77 @@ +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); + +const { + findInvalidRedirects, + findNewInvalidRedirects, + routesFromFilePaths, +} = require("./validate-redirects.js"); + +const routes = routesFromFilePaths([ + "docs/apps/index.mdx", + "docs/apps/quickstart/build-app.mdx", + "docs/base-chain/api-reference/ethereum-json-rpc-api/eth_call.mdx", +], "docs/"); + +describe("redirect validation", () => { + it("accepts page destinations and redirect chains", () => { + const redirects = [ + { source: "/old-apps", destination: "/apps-v1" }, + { source: "/apps-v1", destination: "/apps" }, + ]; + + assert.deepEqual(findInvalidRedirects(redirects, routes), []); + }); + + it("accepts dynamic destinations backed by a docs directory", () => { + const redirects = [{ + source: "/rpc/:method", + destination: "/base-chain/api-reference/ethereum-json-rpc-api/:method", + }]; + + assert.deepEqual(findInvalidRedirects(redirects, routes), []); + }); + + it("resolves wildcard redirect chains", () => { + const redirects = [ + { source: "/legacy/:slug*", destination: "/moved/:slug*" }, + { source: "/moved/:slug*", destination: "/apps/quickstart/build-app" }, + ]; + + assert.deepEqual(findInvalidRedirects(redirects, routes), []); + }); + + it("reports missing destinations and cycles", () => { + const redirects = [ + { source: "/missing", destination: "/not-a-page" }, + { source: "/cycle-a", destination: "/cycle-b" }, + { source: "/cycle-b", destination: "/cycle-a" }, + ]; + + const invalid = findInvalidRedirects(redirects, routes); + assert.equal(invalid.length, 3); + assert.equal(invalid[0].reason, "missing destination"); + assert.equal(invalid[1].reason, "redirect cycle"); + assert.equal(invalid[2].reason, "redirect cycle"); + }); + + it("fails only invalid redirects introduced after the baseline", () => { + const existing = [{ + source: "/known-broken", + destination: "/missing-before-this-change", + reason: "missing destination", + terminal: "/missing-before-this-change", + }]; + const current = [ + ...existing, + { + source: "/new-broken", + destination: "/new-missing-page", + reason: "missing destination", + terminal: "/new-missing-page", + }, + ]; + + assert.deepEqual(findNewInvalidRedirects(current, existing), [current[1]]); + }); +});