diff --git a/.github/workflows/base-std-docs-sync.yml b/.github/workflows/base-std-docs-sync.yml
index 485da3aa8..07f54cbe0 100644
--- a/.github/workflows/base-std-docs-sync.yml
+++ b/.github/workflows/base-std-docs-sync.yml
@@ -1076,7 +1076,6 @@ jobs:
# deriving it avoids another hardcoded main/master mismatch.
DOCS_BASE_BRANCH: ${{ github.event.repository.default_branch }}
BRANCH: ${{ steps.sync.outputs.branch }}
- TOUCHED_PATHS: ${{ steps.sync.outputs.touched_paths }}
REJECTED_PAGES: ${{ steps.sync.outputs.rejected_pages }}
REJECTED_COUNT: ${{ steps.sync.outputs.rejected_count }}
PROVENANCE_MD_PATH: ${{ steps.sync.outputs.provenance_md_path }}
@@ -1164,7 +1163,7 @@ jobs:
fi
# Reviewer checklist + newly-introduced external URLs. Placed
- # ahead of the file-touched / provenance sections so the
+ # ahead of the combined touched-files / provenance table so the
# action-required items are the first thing a reviewer reads
# after the source-PR link. Sync script writes the file at
# $REVIEW_MD_PATH; we splice it in verbatim.
@@ -1172,11 +1171,12 @@ jobs:
cat "${REVIEW_MD_PATH}"
fi
- echo
- echo "## Files touched"
- for p in ${TOUCHED_PATHS:-}; do
- echo "- \`${p}\`"
- done
+ # The sync script writes one table containing both the docs files
+ # changed and their upstream provenance. It is the authoritative
+ # touched-file list, avoiding a redundant standalone list.
+ if [[ -n "${PROVENANCE_MD_PATH:-}" ]] && [[ -f "${PROVENANCE_MD_PATH}" ]]; then
+ cat "${PROVENANCE_MD_PATH}"
+ fi
# Surface pages that Claude tried to write but the validator
# rejected. Reviewer should expect those pages to be missing from
@@ -1195,13 +1195,6 @@ jobs:
done
fi
- # Splice in the source-provenance markdown table the script wrote.
- # Lets a reviewer click straight from each doc page to the source
- # file(s) in base that drove its edit.
- if [[ -n "${PROVENANCE_MD_PATH:-}" ]] && [[ -f "${PROVENANCE_MD_PATH}" ]]; then
- cat "${PROVENANCE_MD_PATH}"
- fi
-
echo
echo "_Opened by \`Apply Base Std Update\` workflow._"
} > "$body_file"
diff --git a/scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs b/scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs
index 5e0080f4f..dbcb0b874 100644
--- a/scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs
+++ b/scripts/sync-from-base-std/__tests__/base-std-routing.test.mjs
@@ -6,8 +6,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import {
buildProvenanceComment,
+ loadKnownRoutes,
loadStyleGuide,
+ renderProvenanceSection,
routeCodeChange,
+ validateMdx,
} from "../index.mjs";
const REPO_ROOT = path.resolve(
@@ -120,6 +123,97 @@ test("loadStyleGuide reads the root content instructions", async () => {
assert.equal(await loadStyleGuide({ repoRoot: REPO_ROOT }), expected);
});
+test("internal-link validation accepts index aliases and configured redirects", async () => {
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "base-std-routes-"));
+ const docsRoot = path.join(repoRoot, "docs");
+ const b20Root = path.join(docsRoot, "base-chain", "specs", "reference", "b20");
+ await fs.mkdir(b20Root, { recursive: true });
+ await fs.writeFile(path.join(b20Root, "index.mdx"), "---\ntitle: B20\n---\n");
+ await fs.writeFile(
+ path.join(docsRoot, "docs.json"),
+ JSON.stringify({
+ redirects: [
+ { source: "/legacy-b20" },
+ { source: "/legacy-reference/:slug*" },
+ ],
+ }),
+ );
+
+ try {
+ const routes = await loadKnownRoutes({ repoRoot });
+ assert.ok(routes.exact.has("/base-chain/specs/reference/b20"));
+ assert.ok(routes.exact.has("/base-chain/specs/reference/b20/index"));
+
+ const current = "---\ntitle: Example\n---\n\nExisting copy.\n";
+ const withValidLinks = `${current}\n[B20](/base-chain/specs/reference/b20#factory)\n[literal](/legacy-b20)\n[old](/legacy-reference/interfaces/IB20)\n`;
+ assert.equal(
+ validateMdx(withValidLinks, "docs/example.mdx", routes, current),
+ null,
+ );
+ } finally {
+ await fs.rm(repoRoot, { recursive: true, force: true });
+ }
+});
+
+test("internal-link validation ignores retained legacy links but rejects new broken links", async () => {
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "base-std-link-delta-"));
+ const docsRoot = path.join(repoRoot, "docs");
+ await fs.mkdir(docsRoot, { recursive: true });
+ await fs.writeFile(path.join(docsRoot, "index.mdx"), "---\ntitle: Home\n---\n");
+
+ try {
+ const routes = await loadKnownRoutes({ repoRoot });
+ const current = "---\ntitle: Example\n---\n\n[Legacy](/retired-page)\n";
+ const retainedLegacyLink = `${current}\nUpdated copy.\n`;
+ assert.equal(
+ validateMdx(retainedLegacyLink, "docs/example.mdx", routes, current),
+ null,
+ );
+
+ const newBrokenLink = `${retainedLegacyLink}\n[Broken](/missing-page)\n`;
+ assert.match(
+ validateMdx(newBrokenLink, "docs/example.mdx", routes, current),
+ /broken new internal link\(s\): `\/missing-page`/,
+ );
+ } finally {
+ await fs.rm(repoRoot, { recursive: true, force: true });
+ }
+});
+
+test("renderProvenanceSection combines touched docs pages and source files", () => {
+ const section = renderProvenanceSection(
+ "code-change",
+ { source_repo: "base/base-std", sha: "abcdef0123456789" },
+ [
+ {
+ page: "docs/base-chain/specs/reference/b20/index.mdx",
+ sourceFiles: ["changelog/02_policy.md"],
+ },
+ ],
+ );
+
+ assert.match(section, /^\n## Files touched & source provenance\n/m);
+ assert.match(section, /\| Docs page \| Source file\(s\) in base \|/);
+ assert.match(section, /`docs\/base-chain\/specs\/reference\/b20\/index\.mdx`/);
+ assert.match(section, /https:\/\/github\.com\/base\/base-std\/blob\/abcdef0123456789\/changelog\/02_policy\.md/);
+ assert.doesNotMatch(section, /^## Files touched$/m);
+
+ const releaseSection = renderProvenanceSection(
+ "release",
+ { source_repo: "base/base-std", tag: "v1.2.3" },
+ [{ page: "docs/base-chain/specs/reference/b20/index.mdx" }],
+ );
+ assert.match(releaseSection, /\| Docs page \| Source provenance \|/);
+ assert.match(releaseSection, /releases\/tag\/v1\.2\.3/);
+
+ const manualSection = renderProvenanceSection(
+ "manual-update",
+ { source_refs: ["https://example.test/source"] },
+ [{ page: "docs/base-chain/specs/reference/b20/index.mdx" }],
+ );
+ assert.match(manualSection, /https:\/\/example\.test\/source/);
+});
+
test("buildProvenanceComment cannot inject a second HTML comment boundary", () => {
const comment = buildProvenanceComment("manual-update", {
intent: "Update docs --> --!>",
diff --git a/scripts/sync-from-base-std/index.mjs b/scripts/sync-from-base-std/index.mjs
index 8f507db1b..4b1e45715 100644
--- a/scripts/sync-from-base-std/index.mjs
+++ b/scripts/sync-from-base-std/index.mjs
@@ -727,6 +727,60 @@ export function buildProvenanceComment(kind, payload, sourceFiles) {
return lines.join("\n");
}
+/**
+ * Render the single PR-body section that identifies every modified docs page
+ * and the upstream source that justified its edit. Keeping this pure makes
+ * the reviewer-facing table easy to test without running the workflow.
+ */
+export function renderProvenanceSection(kind, payload, provenance) {
+ const source = sourceRepo(payload);
+ const sha = payload.sha || "";
+ const rows = ["", "## Files touched & source provenance", ""];
+
+ if (kind === "code-change") {
+ rows.push(
+ `Each row shows which file(s) in [\`${source}@${shortSha(sha)}\`](https://github.com/${source}/commit/${sha}) drove an edit to a docs page. Click into a source file to verify the claim before merging.`,
+ "",
+ "| Docs page | Source file(s) in base |",
+ "|---|---|",
+ );
+ for (const item of provenance) {
+ const files = (item.sourceFiles || [])
+ .map(
+ (file) =>
+ `[\`${file}\`](https://github.com/${source}/blob/${sha}/${file})`,
+ )
+ .join("
") || "_(unknown)_";
+ rows.push(`| \`${item.page}\` | ${files} |`);
+ }
+ } else if (kind === "release") {
+ const releaseUrl = `https://github.com/${source}/releases/tag/${payload.tag}`;
+ rows.push(
+ `Driven by release \`${payload.tag}\` of [${source}](${releaseUrl}).`,
+ "",
+ "| Docs page | Source provenance |",
+ "|---|---|",
+ );
+ for (const item of provenance) {
+ rows.push(`| \`${item.page}\` | [release notes](${releaseUrl}) |`);
+ }
+ } else if (kind === "manual-update") {
+ const refs = (payload.source_refs || []).join("
") || "_(none provided)_";
+ rows.push(
+ "Maintainer-curated update.",
+ "",
+ "| Docs page | Source provenance |",
+ "|---|---|",
+ );
+ for (const item of provenance) {
+ rows.push(`| \`${item.page}\` | ${refs} |`);
+ }
+ }
+
+ rows.push("");
+ return rows.join("\n");
+}
+
/**
* Insert the provenance comment right after the frontmatter block in an MDX
* file, or at the top if there's no frontmatter (e.g. llms.txt).
@@ -810,20 +864,39 @@ const REASONING_LEAK_PATTERNS = [
/**
- * Walk docs/ once and return the set of canonical site-internal route
- * paths the agent can legally link to. A route is the file's path under
- * docs/, with the leading slash present and the `.mdx`/`.txt` suffix
- * stripped — same convention Mintlify uses in Base Docs.
+ * Walk docs/ once and return the runtime site-internal route inventory the
+ * agent can legally link to. Exact routes come from documentation files and
+ * configured redirect sources are compiled separately.
*
* docs/base-chain/specs/upgrades/beryl/b20/specification/reference/interfaces/IB20/transfer.mdx
* → /base-chain/specs/upgrades/beryl/b20/specification/reference/interfaces/IB20/transfer
*
- * Used by `validateMdx` to reject pages whose internal Markdown links
- * point at a route that doesn't exist.
+ * `index.mdx` is served at both its file-style path (`/guide/index`) and its
+ * directory path (`/guide`). The latter is what writers normally use. Static
+ * and parameterized redirect sources in docs/docs.json are runtime-valid too,
+ * even when they do not have a matching file under docs/.
*/
-async function loadKnownRoutes() {
- const contentRoot = path.join(REPO_ROOT, DOCS_ROOT);
- const out = new Set();
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function redirectSourceToRegExp(source) {
+ const segments = String(source || "").split("/");
+ const pattern = segments
+ .map((segment, index) => {
+ if (index === 0) return "";
+ if (segment === "*") return ".*";
+ if (/^:[A-Za-z_][A-Za-z0-9_]*\*$/.test(segment)) return ".+";
+ if (/^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) return "[^/]+";
+ return escapeRegExp(segment);
+ })
+ .join("/");
+ return new RegExp(`^${pattern || "/"}$`);
+}
+
+export async function loadKnownRoutes({ repoRoot = REPO_ROOT } = {}) {
+ const contentRoot = path.join(repoRoot, DOCS_ROOT);
+ const exact = new Set();
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
@@ -837,14 +910,33 @@ async function loadKnownRoutes() {
if (entry.name.startsWith(".")) continue;
const rel = path.relative(contentRoot, abs);
const noSuffix = rel.replace(/\.(mdx|md|txt)$/i, "");
- out.add("/" + noSuffix);
+ const fileRoute = "/" + noSuffix;
+ exact.add(fileRoute);
+ if (path.posix.basename(noSuffix) === "index") {
+ const directoryRoute = path.posix.dirname(fileRoute);
+ exact.add(directoryRoute === "/" ? "/" : directoryRoute);
+ }
}
}
}
if (existsSync(contentRoot)) {
await walk(contentRoot);
}
- return out;
+ const redirectMatchers = [];
+ const docsConfigPath = path.join(contentRoot, "docs.json");
+ if (existsSync(docsConfigPath)) {
+ try {
+ const config = JSON.parse(await fs.readFile(docsConfigPath, "utf8"));
+ for (const redirect of config.redirects || []) {
+ if (typeof redirect?.source === "string" && redirect.source.startsWith("/")) {
+ redirectMatchers.push(redirectSourceToRegExp(redirect.source));
+ }
+ }
+ } catch (err) {
+ console.warn(`[routes] could not read ${docsConfigPath}: ${err.message}`);
+ }
+ }
+ return { exact, redirectMatchers };
}
/**
@@ -874,8 +966,8 @@ export async function loadStyleGuide({ repoRoot = REPO_ROOT } = {}) {
* A site-internal link is a `[label](/path...)` whose target starts with
* '/' (i.e. an absolute path on this docs site). External links
* (`https://...`) and relative links (`./foo`, `#anchor`) are NOT
- * site-internal and are ignored here. The returned target preserves the
- * route but strips any `#anchor` fragment so the route check is exact.
+ * site-internal and are ignored here. The route path is returned without any
+ * `#anchor` fragment or query string so route matching is exact.
*
* We also pick up `` style links in raw HTML/MDX, since
* a few legacy pages use them.
@@ -895,16 +987,27 @@ function extractInternalLinks(content) {
while ((m = hrefRe.exec(content)) !== null) {
targets.push(m[1]);
}
- // Dedup + strip fragments/queries for the route check.
- const cleaned = new Set();
- for (const t of targets) {
- const noFrag = t.replace(/[#?].*$/, "");
- cleaned.add(noFrag);
+ // Strip fragments/queries for the route check, but retain duplicate targets
+ // so validation can distinguish an existing link from an added occurrence.
+ return targets.map((target) => target.replace(/[#?].*$/, ""));
+}
+
+function countInternalLinks(content) {
+ const counts = new Map();
+ for (const target of extractInternalLinks(content)) {
+ counts.set(target, (counts.get(target) || 0) + 1);
}
- return [...cleaned];
+ return counts;
+}
+
+function isKnownRoute(target, knownRoutes) {
+ return (
+ knownRoutes.exact.has(target) ||
+ knownRoutes.redirectMatchers.some((matcher) => matcher.test(target))
+ );
}
-function validateMdx(content, pagePath, knownRoutes) {
+export function validateMdx(content, pagePath, knownRoutes, currentContent = "") {
if (pagePath.endsWith(".mdx")) {
if (!/^---\n[\s\S]+?\n---/m.test(content)) {
return "missing or malformed frontmatter block";
@@ -938,23 +1041,24 @@ function validateMdx(content, pagePath, knownRoutes) {
if (seen.size > 0) {
return `output uses MDX component(s) not registered in Base Docs: ${[...seen].join(", ")}`;
}
- // Check every site-internal `/`-rooted Markdown/HTML link target against
- // the route set built from docs/. The route set is the full truth of
- // what Mintlify serves at build time, so a target absent from it 404s at
- // runtime. The check is unscoped: a target's prefix isn't a reliable
- // signal of whether it should match a content route.
+ // Validate only targets added or changed by the model. Existing links are
+ // part of the repository baseline and must not reject an otherwise-valid
+ // sync because a legacy route is absent from this validator's inventory.
//
// Asset paths under Next.js public folders (`/images/...`, `/static/...`,
// etc.) are valid runtime URLs but live outside docs/, so we exempt
// the known asset prefixes.
- if (knownRoutes && knownRoutes.size > 0) {
+ if (knownRoutes && knownRoutes.exact.size > 0) {
+ const currentCounts = countInternalLinks(currentContent);
+ const outputCounts = countInternalLinks(content);
const broken = [];
- for (const target of extractInternalLinks(content)) {
+ for (const [target, count] of outputCounts) {
+ if (count <= (currentCounts.get(target) || 0)) continue;
if (ASSET_PREFIXES.test(target)) continue;
- if (!knownRoutes.has(target)) broken.push(target);
+ if (!isKnownRoute(target, knownRoutes)) broken.push(target);
}
if (broken.length > 0) {
- return `broken internal link(s): ${broken.slice(0, 3).map((t) => `\`${t}\``).join(", ")}${broken.length > 3 ? ` (+${broken.length - 3} more)` : ""}. Use the full route path that exists under docs/ (e.g. \`/base-chain/specs/upgrades/beryl/b20/specification/reference/interfaces/IB20/transfer\`).`;
+ return `broken new internal link(s): ${broken.slice(0, 3).map((t) => `\`${t}\``).join(", ")}${broken.length > 3 ? ` (+${broken.length - 3} more)` : ""}. The target does not resolve to a docs page or configured redirect.`;
}
}
// Server-side mirror of system-prompt rules 3–5: raw HTML, dangerous URL
@@ -1067,7 +1171,7 @@ async function processPage(item, shared, useGroups) {
console.log(`[claude] ${item.page} — ${prompt.length} prompt chars`);
const out = await callClaude(prompt, item.page, { system: SYSTEM_PROMPT });
- const err = validateMdx(out, item.page, knownRoutes);
+ const err = validateMdx(out, item.page, knownRoutes, current);
if (err) {
console.error(`[reject] ${item.page}: ${err}`);
return { page: item.page, status: "rejected", reason: err };
@@ -1152,7 +1256,9 @@ async function main() {
// routes that don't exist on disk. Empty (skipped) when docs/ is
// missing — keeps the script usable in dry-test contexts.
const knownRoutes = await loadKnownRoutes();
- console.log(`[sync] loaded ${knownRoutes.size} known doc route(s) for link validation`);
+ console.log(
+ `[sync] loaded ${knownRoutes.exact.size} exact doc route(s) and ${knownRoutes.redirectMatchers.length} redirect pattern(s) for link validation`,
+ );
// Read the house writing-style guide once. Threaded into every Claude
// prompt via ctx.styleGuide. Empty string when content-instructions.md is
// missing or empty.
@@ -1295,53 +1401,13 @@ async function main() {
`rejected_pages=${rejectedLine}\n`,
);
}
- // Emit a markdown fragment the workflow splices into the PR body — gives a
- // reviewer a clickable mapping from each modified doc page back to the
- // source files in base that drove the edit. This is Plan A from NOTES.md.
+ // Emit the combined touched-files / source-provenance section the workflow
+ // splices into the PR body. The table itself is the authoritative list of
+ // files changed by this sync, so reviewers do not have to reconcile two
+ // separate sections.
if (provenance.length > 0 && process.env.RUNNER_TEMP) {
- const source = sourceRepo(payload);
const provPath = path.join(process.env.RUNNER_TEMP, "sync-provenance.md");
- const rows = [];
- rows.push("");
- rows.push("## Source provenance");
- rows.push("");
- if (kind === "code-change") {
- rows.push(
- `Each row shows which file(s) in [\`${source}@${shortSha(sha)}\`](https://github.com/${source}/commit/${sha}) drove an edit to a docs page. Click into a source file to verify the claim before merging.`,
- );
- rows.push("");
- rows.push("| Docs page | Source file(s) in base |");
- rows.push("|---|---|");
- for (const p of provenance) {
- const files = (p.sourceFiles || [])
- .map(
- (f) =>
- `[\`${f}\`](https://github.com/${source}/blob/${sha}/${f})`,
- )
- .join("
") || "_(unknown)_";
- rows.push(`| \`${p.page}\` | ${files} |`);
- }
- } else if (kind === "release") {
- rows.push(
- `Driven by release \`${payload.tag}\` of [${source}](https://github.com/${source}/releases/tag/${payload.tag}).`,
- );
- rows.push("");
- rows.push("| Docs page | Source |");
- rows.push("|---|---|");
- for (const p of provenance) {
- rows.push(
- `| \`${p.page}\` | [release notes](https://github.com/${source}/releases/tag/${payload.tag}) |`,
- );
- }
- } else if (kind === "manual-update") {
- rows.push("Maintainer-curated update. See PR body for intent + refs.");
- rows.push("");
- rows.push("| Docs page |");
- rows.push("|---|");
- for (const p of provenance) rows.push(`| \`${p.page}\` |`);
- }
- rows.push("");
- const md = rows.join("\n");
+ const md = renderProvenanceSection(kind, payload, provenance);
await fs.writeFile(provPath, md, "utf8");
console.log(`[provenance] wrote table to ${provPath}`);
if (process.env.GITHUB_OUTPUT) {