From 723626a8b36927683db653adb3a0841bb55532ba Mon Sep 17 00:00:00 2001 From: sohey Date: Thu, 27 Aug 2026 15:45:33 +0200 Subject: [PATCH] updated action auth --- .github/workflows/base-std-docs-sync.yml | 21 +- .../__tests__/base-std-routing.test.mjs | 94 ++++++++ scripts/sync-from-base-std/index.mjs | 218 ++++++++++++------ 3 files changed, 243 insertions(+), 90 deletions(-) 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) {