From 4aff3e206bb323824b46458789a61ce9654af56d Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:03:41 -0600 Subject: [PATCH 01/23] ci: comment on PRs that break internal links or anchors Add a pull_request workflow that runs dev/check-links.mjs --check-anchors on both the PR head and its merge base, and reports only the findings the PR introduces: outbound links from changed pages, and inbound links from other pages to a page or heading the PR removed or renamed. Pre-existing broken anchors on main are ignored. The job comments on the PR and fails when new breakage is found. dev/check-links.mjs gains --root, --format (text|json|markdown) and --baseline to support that diff, plus case-mismatch detection for routes (links that resolve on macOS but 404 on Linux) and scanning of *.md files. Only *.mdx files count as routes, matching contentlayer's filePathPattern. Amp-Thread-ID: https://ampcode.com/threads/T-01a0753f-0f4f-7478-b36c-87466e7c0261 Co-authored-by: Amp --- .github/workflows/check-links.yml | 88 +++++++++++++++ AGENTS.md | 1 + dev/check-links.mjs | 180 +++++++++++++++++++++++------- 3 files changed, 227 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/check-links.yml diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 000000000..9ee5eaf46 --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,88 @@ +name: Check links + +# Reports internal links and #anchors that this PR breaks, compared with the +# merge base. Pre-existing broken links on the base branch are ignored. + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + check-links: + name: Broken links introduced by this PR + runs-on: ubuntu-latest + steps: + - name: Check out pull request head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.25.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.6 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check out merge base + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git worktree add "$RUNNER_TEMP/base" "$(git merge-base "$BASE_SHA" HEAD)" + + - name: Record broken links already present on the base branch + # Exit 1 means findings, which is expected here + run: | + node dev/check-links.mjs --check-anchors --format json \ + --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-links.json" \ + || [ $? -eq 1 ] + + - name: Find broken links introduced by this PR + id: check + run: | + if node dev/check-links.mjs --check-anchors --format markdown \ + --baseline "$RUNNER_TEMP/base-links.json" > "$RUNNER_TEMP/report.md"; then + echo "broken=false" >> "$GITHUB_OUTPUT" + else + echo "broken=true" >> "$GITHUB_OUTPUT" + fi + cat "$RUNNER_TEMP/report.md" + + - name: Comment on the pull request + # Fork PRs get a read-only token; the report is still in the job log + if: github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BROKEN: ${{ steps.check.outputs.broken }} + run: | + marker='' + existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) + + # Only comment when there is something to report, or a stale report to resolve + if [ -z "$existing_comment" ] && [ "$BROKEN" != true ]; then + exit 0 + fi + + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" + if [ -n "$existing_comment" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \ + --field body=@"$RUNNER_TEMP/comment.md" + else + gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md" + fi + + - name: Fail when this PR introduces broken links + if: steps.check.outputs.broken == 'true' + run: exit 1 diff --git a/AGENTS.md b/AGENTS.md index 9a5e9b001..beafaaf42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - **Build**: `npm run build` - **Dev**: `npm run dev` - **Lint**: `npm run lint` +- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`) ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 2a8b24093..6f98fd49c 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -8,10 +8,18 @@ * * Checks for: * - Broken internal links (markdown and JSX/HTML style) + * - Links whose case differs from the real path (work on macOS, 404 on Linux) * - Missing anchor/heading references * - Invalid file paths * - * Usage: node dev/check-links.mjs [--check-anchors] + * Usage: node dev/check-links.mjs [options] + * --check-anchors Also validate #anchors against headings + * --root Repository to check (default: this repository) + * --format Output as text (default), json, or markdown + * --baseline Only report findings absent from this JSON file + * (produced by --format json on another revision) + * + * Exits 1 when any finding is reported. */ import fs from 'fs'; @@ -23,11 +31,23 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const DOCS_DIR = path.join(path.dirname(__dirname), 'docs'); - // Parse CLI flags const args = process.argv.slice(2); const CHECK_ANCHORS = args.includes('--check-anchors'); +const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); +const FORMAT = flagValue('--format') ?? 'text'; +const BASELINE_FILE = flagValue('--baseline'); + +const DOCS_DIR = path.join(ROOT_DIR, 'docs'); +// Files whose links are checked. Only .mdx files become site routes; see +// `filePathPattern` in contentlayer.config.ts. +const SOURCE_GLOB = '**/*.{md,mdx}'; +const ROUTE_GLOB = '**/*.mdx'; + +function flagValue(name) { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} // Regex patterns for extracting links const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g; @@ -56,8 +76,10 @@ function extractHeadings(content) { // Get all MDX files and build a map of valid paths async function buildPathMap() { - const files = await glob('**/*.mdx', { cwd: DOCS_DIR }); + const files = await glob(ROUTE_GLOB, { cwd: DOCS_DIR }); const pathMap = new Map(); + // Lowercased route -> real route, to detect case mismatches + const routesByLowerCase = new Map(); const headingsMap = new Map(); for (const file of files) { @@ -70,6 +92,7 @@ async function buildPathMap() { // Also allow trailing slash variant pathMap.set(routePath, fullPath); pathMap.set(routePath + '/', fullPath); + routesByLowerCase.set(routePath.toLowerCase(), routePath); // Handle index files if (file.endsWith('index.mdx')) { @@ -84,12 +107,12 @@ async function buildPathMap() { headingsMap.set(routePath + '/', headings); } - return { pathMap, headingsMap }; + return { pathMap, routesByLowerCase, headingsMap }; } // Check if a path exists in public directory function checkPublicPath(linkPath) { - const publicPath = path.join(path.dirname(__dirname), 'public', linkPath); + const publicPath = path.join(ROOT_DIR, 'public', linkPath); return fs.existsSync(publicPath); } @@ -137,7 +160,7 @@ function extractLinks(content, filePath) { } // Check if a link is valid -function validateLink(link, currentFile, pathMap, headingsMap) { +function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap }) { const { url } = link; // Skip external links, mailto, tel, javascript, etc. @@ -211,6 +234,14 @@ function validateLink(link, currentFile, pathMap, headingsMap) { return null; } + // Same route with different case: resolves on macOS, 404s on the Linux build + const realRoute = routesByLowerCase.get( + resolvedPath.replace(/\/$/, '').toLowerCase() + ); + if (realRoute) { + return `Case mismatch: "${resolvedPath}" should be "${realRoute}"`; + } + // Check if it's a public asset if (checkPublicPath(resolvedPath)) { return null; @@ -228,60 +259,125 @@ function validateLink(link, currentFile, pathMap, headingsMap) { return `Page not found: "${resolvedPath}"`; } -async function main() { - console.log('πŸ” Checking for dead links in MDX files...\n'); - - const { pathMap, headingsMap } = await buildPathMap(); - const files = await glob('**/*.mdx', { cwd: DOCS_DIR }); +// Find every broken link: [{ file, line, url, error }] +async function findBrokenLinks() { + const maps = await buildPathMap(); + const files = await glob(SOURCE_GLOB, { cwd: DOCS_DIR }); + const findings = []; - let totalErrors = 0; - const errors = []; - - for (const file of files) { + for (const file of files.sort()) { const fullPath = path.join(DOCS_DIR, file); const content = fs.readFileSync(fullPath, 'utf-8'); - const links = extractLinks(content, fullPath); - const fileErrors = []; - - for (const link of links) { - const error = validateLink(link, fullPath, pathMap, headingsMap); + for (const link of extractLinks(content, fullPath)) { + const error = validateLink(link, fullPath, maps); if (error) { - fileErrors.push({ + findings.push({ + file: `docs/${file}`, line: link.lineNumber, url: link.url, error }); } } - - if (fileErrors.length > 0) { - errors.push({ - file: `docs/${file}`, - errors: fileErrors - }); - totalErrors += fileErrors.length; - } } - // Output results - if (errors.length === 0) { - console.log('βœ… No dead links found!'); - process.exit(0); + return findings; +} + +// Identity of a finding across revisions: line numbers shift, so ignore them +function findingKey({ file, url, error }) { + return `${file}\n${url}\n${error}`; +} + +function withoutBaseline(findings, baselineFile) { + const baseline = new Set( + JSON.parse(fs.readFileSync(baselineFile, 'utf-8')).map(findingKey) + ); + return findings.filter(finding => !baseline.has(findingKey(finding))); +} + +function groupByFile(findings) { + const byFile = new Map(); + for (const finding of findings) { + if (!byFile.has(finding.file)) { + byFile.set(finding.file, []); + } + byFile.get(finding.file).push(finding); + } + return byFile; +} + +function formatText(findings) { + const scope = BASELINE_FILE ? 'new ' : ''; + if (findings.length === 0) { + return `βœ… No ${scope}dead links found!\n`; } - console.log(`❌ Found ${totalErrors} dead link(s) in ${errors.length} file(s):\n`); + const byFile = groupByFile(findings); + const lines = [ + `❌ Found ${findings.length} ${scope}dead link(s) in ${byFile.size} file(s):\n` + ]; + for (const [file, fileFindings] of byFile) { + lines.push(`\nπŸ“„ ${file}`); + for (const { line, url, error } of fileFindings) { + lines.push(` Line ${line}: ${url}`); + lines.push(` └─ ${error}`); + } + } + return lines.join('\n') + '\n'; +} + +// Body for a pull request comment +function formatMarkdown(findings) { + if (findings.length === 0) { + return '### βœ… This PR introduces no broken links\n'; + } - for (const { file, errors: fileErrors } of errors) { - console.log(`\nπŸ“„ ${file}`); - for (const { line, url, error } of fileErrors) { - console.log(` Line ${line}: ${url}`); - console.log(` └─ ${error}`); + const lines = [ + `### ❌ This PR introduces ${findings.length} broken link(s)`, + '', + 'Findings in files this PR did not change mean the PR removed or ' + + 'renamed a page or heading that those files link to.', + '' + ]; + for (const [file, fileFindings] of groupByFile(findings)) { + lines.push(`**\`${file}\`**`); + for (const { line, url, error } of fileFindings) { + lines.push(`- line ${line}: \`${url}\` β€” ${error}`); } + lines.push(''); + } + lines.push( + 'Reproduce locally with `pnpm check-links --check-anchors` ' + + '(see `dev/check-links.mjs`).' + ); + return lines.join('\n') + '\n'; +} + +const FORMATTERS = { + text: formatText, + json: findings => JSON.stringify(findings, null, '\t') + '\n', + markdown: formatMarkdown +}; + +async function main() { + const format = FORMATTERS[FORMAT]; + if (!format) { + throw new Error(`Unknown --format "${FORMAT}"; use text, json, or markdown`); } - console.log('\n'); - process.exit(1); + if (FORMAT === 'text') { + console.log('πŸ” Checking for dead links in MDX files...\n'); + } + + let findings = await findBrokenLinks(); + if (BASELINE_FILE) { + findings = withoutBaseline(findings, BASELINE_FILE); + } + + process.stdout.write(format(findings)); + process.exit(findings.length === 0 ? 0 : 1); } main().catch(err => { From b42d4f08661cc620bcee4d400622bcb5bfc4c967 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:05:39 -0600 Subject: [PATCH 02/23] test: rename a linked heading to exercise the PR comment (will be reverted) Amp-Thread-ID: https://ampcode.com/threads/T-01a0753f-0f4f-7478-b36c-87466e7c0261 Co-authored-by: Amp --- docs/code-search/features.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx index b86a4d8f3..fbc1cdedd 100644 --- a/docs/code-search/features.mdx +++ b/docs/code-search/features.mdx @@ -53,7 +53,7 @@ Searching for symbols makes it easier to find specific functions, variables, and Saved searches let you save and describe search queries so you can easily monitor the results on an ongoing basis. You can create a saved search for anything, including diffs and commits across all branches of your repositories. Saved searches can be an early warning system for common problems in your code and a way to monitor best practices, the progress of refactors, etc. -## Search contexts +## Search Contexts (renamed) Search contexts help you search the code you care about on Sourcegraph. A search context represents a set of repositories at specific revisions on a Sourcegraph instance that will be targeted by search queries by default. From 1c725f3815e6dd3535dee87b489890a2d70bb7f8 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:07:01 -0600 Subject: [PATCH 03/23] Revert "test: rename a linked heading to exercise the PR comment (will be reverted)" This reverts commit 1edcff8bf5ccb1cf73d2ec044d1e99c4408019c7. --- docs/code-search/features.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx index fbc1cdedd..b86a4d8f3 100644 --- a/docs/code-search/features.mdx +++ b/docs/code-search/features.mdx @@ -53,7 +53,7 @@ Searching for symbols makes it easier to find specific functions, variables, and Saved searches let you save and describe search queries so you can easily monitor the results on an ongoing basis. You can create a saved search for anything, including diffs and commits across all branches of your repositories. Saved searches can be an early warning system for common problems in your code and a way to monitor best practices, the progress of refactors, etc. -## Search Contexts (renamed) +## Search contexts Search contexts help you search the code you care about on Sourcegraph. A search context represents a set of repositories at specific revisions on a Sourcegraph instance that will be targeted by search queries by default. From 11def837c8bdc1af0b91ed083350554ad7a518db Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:13:35 -0600 Subject: [PATCH 04/23] check-links: accept and id= attributes as anchor targets Generated pages such as admin/telemetry/protocol.mdx define anchors with rather than headings. Drops 132 false positives on main (431 -> 299 with --check-anchors). Amp-Thread-ID: https://ampcode.com/threads/T-01a0753f-0f4f-7478-b36c-87466e7c0261 Co-authored-by: Amp --- dev/check-links.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 6f98fd49c..dc2d42f3c 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -54,10 +54,12 @@ const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g; const JSX_HREF_REGEX = /href=["']([^"']+)["']/g; const SRC_ATTR_REGEX = /src=["']([^"']+)["']/g; -// Extract headings from MDX content to build anchor map +// Extract anchor targets from MDX content: heading slugs, plus explicit +// and id="..." attributes function extractHeadings(content) { const slugger = new GithubSlugger(); const headingRegex = /^#{1,6}\s+(.+)$/gm; + const explicitAnchorRegex = /<[a-zA-Z][^>]*\s(?:id|name)=["']([^"']+)["']/g; const headings = new Set(); // Remove code blocks to avoid false positives @@ -71,6 +73,10 @@ function extractHeadings(content) { headings.add(slugger.slug(title.trim())); } + while ((match = explicitAnchorRegex.exec(contentWithoutCode)) !== null) { + headings.add(match[1]); + } + return headings; } From 89a9e29429086618cf51324bd2a8a83a8523560e Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:30:04 -0600 Subject: [PATCH 05/23] check-links: detect case mismatches in public/ and docs/ asset links too Replace the fs.existsSync asset checks (case-insensitive on macOS, so they hid links that 404 on the Linux build) with an enumerated lowercase -> real path map of files under public/ and docs/, mirroring the route check. Also register docs/index.mdx as the / route. It was never in the path map (the /index strip needed a leading slash), and existsSync('public/') was masking that by accepting any '/' link. Six pre-existing broken /#anchor links on the homepage are now reported. Amp-Thread-ID: https://ampcode.com/threads/T-01a07597-43c0-751b-8c49-6e5809e714d2 Co-authored-by: Amp --- dev/check-links.mjs | 70 +++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index dc2d42f3c..30c462f59 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -80,6 +80,11 @@ function extractHeadings(content) { return headings; } +// Site route for a file under docs/: foo/bar.mdx -> /foo/bar, foo/index.mdx -> /foo, index.mdx -> / +function routeFor(file) { + return '/' + file.replace(/\.mdx$/, '').replace(/(^|\/)index$/, ''); +} + // Get all MDX files and build a map of valid paths async function buildPathMap() { const files = await glob(ROUTE_GLOB, { cwd: DOCS_DIR }); @@ -92,40 +97,35 @@ async function buildPathMap() { const fullPath = path.join(DOCS_DIR, file); const content = fs.readFileSync(fullPath, 'utf-8'); - // Route path (without .mdx extension) - const routePath = '/' + file.replace(/\.mdx$/, '').replace(/\/index$/, ''); + const routePath = routeFor(file); // Also allow trailing slash variant pathMap.set(routePath, fullPath); pathMap.set(routePath + '/', fullPath); routesByLowerCase.set(routePath.toLowerCase(), routePath); - // Handle index files - if (file.endsWith('index.mdx')) { - const dirPath = '/' + file.replace(/\/index\.mdx$/, ''); - pathMap.set(dirPath, fullPath); - pathMap.set(dirPath + '/', fullPath); - } - // Extract headings for anchor validation const headings = extractHeadings(content); headingsMap.set(routePath, headings); headingsMap.set(routePath + '/', headings); } - return { pathMap, routesByLowerCase, headingsMap }; + return { pathMap, routesByLowerCase, headingsMap, assetsByLowerCase: await buildAssetMap() }; } -// Check if a path exists in public directory -function checkPublicPath(linkPath) { - const publicPath = path.join(ROOT_DIR, 'public', linkPath); - return fs.existsSync(publicPath); -} - -// Check if a path exists in docs directory (for images in docs/) -function checkDocsPath(linkPath) { - const docsPath = path.join(DOCS_DIR, linkPath); - return fs.existsSync(docsPath); +// Lowercased link path -> real link path, for files under public/ and docs/ +// (images, PDFs, ...). An enumerated map rather than fs.existsSync, which is +// case-insensitive on macOS and would hide links that 404 on Linux. +async function buildAssetMap() { + const assetsByLowerCase = new Map(); + for (const dir of ['public', 'docs']) { + const files = await glob('**/*', { cwd: path.join(ROOT_DIR, dir), nodir: true }); + for (const file of files) { + const linkPath = '/' + file; + assetsByLowerCase.set(linkPath.toLowerCase(), linkPath); + } + } + return assetsByLowerCase; } // Parse and validate links in a single file @@ -166,7 +166,7 @@ function extractLinks(content, filePath) { } // Check if a link is valid -function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap }) { +function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap, assetsByLowerCase }) { const { url } = link; // Skip external links, mailto, tel, javascript, etc. @@ -193,10 +193,7 @@ function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsM return null; } const anchor = url.substring(1); - const currentRoute = '/' + path.relative(DOCS_DIR, currentFile) - .replace(/\.mdx$/, '') - .replace(/\/index$/, ''); - const headings = headingsMap.get(currentRoute); + const headings = headingsMap.get(routeFor(path.relative(DOCS_DIR, currentFile))); if (headings && !headings.has(anchor)) { return `Anchor "${anchor}" not found in current file`; @@ -240,25 +237,22 @@ function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsM return null; } - // Same route with different case: resolves on macOS, 404s on the Linux build - const realRoute = routesByLowerCase.get( + // Check if it's an asset under public/ or docs/ + const realAsset = assetsByLowerCase.get(resolvedPath.toLowerCase()); + if (realAsset === resolvedPath) { + return null; + } + + // Same route or asset with different case: resolves on macOS, 404s on the Linux build + const realPath = realAsset ?? routesByLowerCase.get( resolvedPath.replace(/\/$/, '').toLowerCase() ); - if (realRoute) { - return `Case mismatch: "${resolvedPath}" should be "${realRoute}"`; - } - - // Check if it's a public asset - if (checkPublicPath(resolvedPath)) { - return null; + if (realPath) { + return `Case mismatch: "${resolvedPath}" should be "${realPath}"`; } // Check if it's a file with extension (like .png, .pdf) if (path.extname(resolvedPath)) { - // Could be an asset - check public folder or docs folder - if (checkPublicPath(resolvedPath) || checkDocsPath(resolvedPath)) { - return null; - } return `File not found: "${resolvedPath}"`; } From 228105f09924816f0a6882e59c6d01d3f5df1331 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:40:59 -0600 Subject: [PATCH 06/23] check-links workflow: never comment on a clean PR A green run posts nothing. If an earlier run left a report and the PR has since been fixed, delete that comment instead of editing it to a checkmark. Amp-Thread-ID: https://ampcode.com/threads/T-01a07597-43c0-751b-8c49-6e5809e714d2 Co-authored-by: Amp --- .github/workflows/check-links.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 9ee5eaf46..b849ef247 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -70,8 +70,11 @@ jobs: existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) - # Only comment when there is something to report, or a stale report to resolve - if [ -z "$existing_comment" ] && [ "$BROKEN" != true ]; then + # A clean PR carries no comment: remove a stale report once fixed + if [ "$BROKEN" != true ]; then + if [ -n "$existing_comment" ]; then + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" + fi exit 0 fi From 85d10d47e4ab5a5b29019e0eb6a1ad602a57c776 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:43:29 -0600 Subject: [PATCH 07/23] check-links workflow: say what the resolved comment actually means The checkmark comment only ever appears after an earlier run reported breakage, so word it that way instead of "introduces no broken links". Keeps the comment (reverts the delete from the previous commit). Amp-Thread-ID: https://ampcode.com/threads/T-01a07597-43c0-751b-8c49-6e5809e714d2 Co-authored-by: Amp --- .github/workflows/check-links.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index b849ef247..8c9af1d1b 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -70,15 +70,16 @@ jobs: existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1) - # A clean PR carries no comment: remove a stale report once fixed - if [ "$BROKEN" != true ]; then - if [ -n "$existing_comment" ]; then - gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" - fi + # Comment only when there is something to report, or an earlier report to resolve + if [ "$BROKEN" = true ]; then + { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" + elif [ -n "$existing_comment" ]; then + printf '%s\n### βœ… The broken links an earlier revision of this PR introduced are fixed\n' \ + "$marker" > "$RUNNER_TEMP/comment.md" + else exit 0 fi - { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md" if [ -n "$existing_comment" ]; then gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \ --field body=@"$RUNNER_TEMP/comment.md" From 6aefb8195c2afed881773e9b7e2e6a5f2810ea20 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:12:25 -0600 Subject: [PATCH 08/23] check-links: strip fences line by line, first file wins a route, same-page anchors use own headings Amp-Thread-ID: https://ampcode.com/threads/T-01a07623-9d65-7356-96b8-2bebb31ffa5a Co-authored-by: Amp --- dev/check-links.mjs | 58 +++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 30c462f59..196c202a0 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -54,6 +54,36 @@ const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g; const JSX_HREF_REGEX = /href=["']([^"']+)["']/g; const SRC_ATTR_REGEX = /src=["']([^"']+)["']/g; +// A fence opener/closer is a run of 3+ backticks or tildes at the start of a line. +const FENCE_LINE_REGEX = /^\s*(`{3,}|~{3,})/; + +// Blank out fenced code blocks, keeping line numbers intact, so `# comment` +// lines and example links inside them are ignored. Walks line by line: a naive +// /```[\s\S]*?```/ regex also matches inline backtick runs in prose (e.g. +// `"true```), which flips every later fence pairing. +function stripFencedCodeBlocks(content) { + let openFence; + return content.split('\n').map(line => { + const fence = line.match(FENCE_LINE_REGEX)?.[1]; + if (openFence) { + const closesOpenFence = + fence !== undefined && + fence[0] === openFence[0] && + fence.length >= openFence.length && + line.trim() === fence; + if (closesOpenFence) { + openFence = undefined; + } + return ''; + } + if (fence) { + openFence = fence; + return ''; + } + return line; + }).join('\n'); +} + // Extract anchor targets from MDX content: heading slugs, plus explicit // and id="..." attributes function extractHeadings(content) { @@ -62,8 +92,7 @@ function extractHeadings(content) { const explicitAnchorRegex = /<[a-zA-Z][^>]*\s(?:id|name)=["']([^"']+)["']/g; const headings = new Set(); - // Remove code blocks to avoid false positives - const contentWithoutCode = content.replace(/```[\s\S]*?```/g, ''); + const contentWithoutCode = stripFencedCodeBlocks(content); let match; while ((match = headingRegex.exec(contentWithoutCode)) !== null) { @@ -87,30 +116,33 @@ function routeFor(file) { // Get all MDX files and build a map of valid paths async function buildPathMap() { - const files = await glob(ROUTE_GLOB, { cwd: DOCS_DIR }); + // Sorted so foo.mdx precedes foo/index.mdx; when both exist the site serves + // the first match (allPosts.find), so the first file owns the route here too. + const files = (await glob(ROUTE_GLOB, { cwd: DOCS_DIR })).sort(); const pathMap = new Map(); // Lowercased route -> real route, to detect case mismatches const routesByLowerCase = new Map(); const headingsMap = new Map(); + // Absolute file path -> headings, for same-page #anchor links + const headingsByFile = new Map(); for (const file of files) { const fullPath = path.join(DOCS_DIR, file); - const content = fs.readFileSync(fullPath, 'utf-8'); + const headings = extractHeadings(fs.readFileSync(fullPath, 'utf-8')); + headingsByFile.set(fullPath, headings); const routePath = routeFor(file); + if (pathMap.has(routePath)) continue; // Also allow trailing slash variant pathMap.set(routePath, fullPath); pathMap.set(routePath + '/', fullPath); routesByLowerCase.set(routePath.toLowerCase(), routePath); - - // Extract headings for anchor validation - const headings = extractHeadings(content); headingsMap.set(routePath, headings); headingsMap.set(routePath + '/', headings); } - return { pathMap, routesByLowerCase, headingsMap, assetsByLowerCase: await buildAssetMap() }; + return { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase: await buildAssetMap() }; } // Lowercased link path -> real link path, for files under public/ and docs/ @@ -132,11 +164,7 @@ async function buildAssetMap() { function extractLinks(content, filePath) { const links = []; - // Remove code blocks to avoid checking links in code examples - const contentWithoutCode = content.replace(/```[\s\S]*?```/g, (match) => { - // Replace with same number of newlines to preserve line numbers - return match.replace(/[^\n]/g, ' '); - }); + const contentWithoutCode = stripFencedCodeBlocks(content); // Extract markdown links [text](url) let match; @@ -166,7 +194,7 @@ function extractLinks(content, filePath) { } // Check if a link is valid -function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap, assetsByLowerCase }) { +function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase }) { const { url } = link; // Skip external links, mailto, tel, javascript, etc. @@ -193,7 +221,7 @@ function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsM return null; } const anchor = url.substring(1); - const headings = headingsMap.get(routeFor(path.relative(DOCS_DIR, currentFile))); + const headings = headingsByFile.get(currentFile); if (headings && !headings.has(anchor)) { return `Anchor "${anchor}" not found in current file`; From 72335cc084eeac52b37465e134eda8fd88a7e69a Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:44:23 -0600 Subject: [PATCH 09/23] Add verify-links-live: prove a branch's changed links resolve on a deployed site Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a07623-9d65-7356-96b8-2bebb31ffa5a --- AGENTS.md | 1 + dev/check-links.mjs | 15 +++-- dev/verify-links-live.mjs | 130 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 dev/verify-links-live.mjs diff --git a/AGENTS.md b/AGENTS.md index beafaaf42..cdb3ac2a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ - **Dev**: `npm run dev` - **Lint**: `npm run lint` - **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`) +- **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 196c202a0..df4cd878a 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -26,7 +26,7 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import GithubSlugger from 'github-slugger'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -86,7 +86,7 @@ function stripFencedCodeBlocks(content) { // Extract anchor targets from MDX content: heading slugs, plus explicit // and id="..." attributes -function extractHeadings(content) { +export function extractHeadings(content) { const slugger = new GithubSlugger(); const headingRegex = /^#{1,6}\s+(.+)$/gm; const explicitAnchorRegex = /<[a-zA-Z][^>]*\s(?:id|name)=["']([^"']+)["']/g; @@ -408,7 +408,10 @@ async function main() { process.exit(findings.length === 0 ? 0 : 1); } -main().catch(err => { - console.error('Error running link checker:', err); - process.exit(1); -}); +// Only run when executed directly; dev/verify-links-live.mjs imports extractHeadings. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(err => { + console.error('Error running link checker:', err); + process.exit(1); + }); +} diff --git a/dev/verify-links-live.mjs b/dev/verify-links-live.mjs new file mode 100644 index 000000000..7f4434a3e --- /dev/null +++ b/dev/verify-links-live.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Prove that the links a branch changed resolve on a deployed site. +// +// For every internal link that differs between a base ref and the working +// tree, fetch the target page on --site and check the rendered HTML, not the +// HTTP status: the docs site serves its not-found page with 200 (see the +// dynamicParams fix) and even real pages embed the not-found text in their +// RSC payload, so neither status nor page text proves anything. A link passes +// when +// - the target page's own first heading id (from its local MDX source) is an +// id in the HTML, which the not-found page never has, and +// - the link's #fragment, when present, is an id in the HTML. +// Prints a Markdown table to paste into a PR. Old links point at --old-site so +// reviewers can see the current breakage. +// +// node dev/verify-links-live.mjs --site https://.vercel.app [--old-site https://sourcegraph.com/docs] [--base origin/main] +// +// Production serves under https://sourcegraph.com/docs (basePath in +// next.config.js); Vercel previews serve at the root, so pass the full prefix +// in --site. +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { extractHeadings } from './check-links.mjs'; + +const args = process.argv.slice(2); +const argValue = (flag, fallback) => { + const index = args.indexOf(flag); + return index === -1 ? fallback : args[index + 1]; +}; +const SITE = argValue('--site', 'https://sourcegraph.com/docs').replace(/\/$/, ''); +const OLD_SITE = argValue('--old-site', 'https://sourcegraph.com/docs').replace(/\/$/, ''); +const BASE_REF = argValue('--base', 'origin/main'); + +const LINK = /\]\(([^)\s]+)\)|href=["']([^"']+)["']/g; + +function routeFor(file) { + return '/' + file.replace(/^docs\//, '').replace(/\.mdx$/, '').replace(/\/index$/, ''); +} + +// Same precedence as the site (first glob match): foo.mdx before foo/index.mdx. +function sourceFileFor(route) { + const stem = route === '/' ? 'docs/index' : `docs${route}`; + return [`${stem}.mdx`, `${stem}/index.mdx`].find(candidate => fs.existsSync(candidate)); +} + +const firstHeadingCache = new Map(); +function firstHeadingId(route) { + if (!firstHeadingCache.has(route)) { + const file = sourceFileFor(route); + const [first] = file ? extractHeadings(fs.readFileSync(file, 'utf-8')) : []; + firstHeadingCache.set(route, first); + } + return firstHeadingCache.get(route); +} + +// Collect { file, oldUrl, newUrl } for every link that changed. +function changedLinks() { + const diff = execSync(`git diff -U0 ${BASE_REF}`, { encoding: 'utf-8' }); + const result = []; + let file, removed = [], added = []; + const flush = () => { + if (removed.length === added.length) removed.forEach((oldLine, index) => { + const oldLinks = [...oldLine.matchAll(LINK)].map(m => m[1] ?? m[2]); + const newLinks = [...added[index].matchAll(LINK)].map(m => m[1] ?? m[2]); + if (oldLinks.length !== newLinks.length) return; + oldLinks.forEach((oldUrl, i) => { + if (oldUrl !== newLinks[i]) result.push({ file, oldUrl, newUrl: newLinks[i] }); + }); + }); + removed = []; added = []; + }; + for (const line of diff.split('\n')) { + if (line.startsWith('+++ b/')) { flush(); file = line.slice(6); continue; } + if (line.startsWith('@@')) { flush(); continue; } + if (line.startsWith('---')) continue; + if (line.startsWith('-')) removed.push(line.slice(1)); + else if (line.startsWith('+')) added.push(line.slice(1)); + } + flush(); + return result; +} + +function resolveTarget(file, url) { + if (/^(https?:|mailto:|tel:)/.test(url)) return null; + const [pagePart, fragment] = url.split('#'); + let page; + if (pagePart === '') page = routeFor(file); + else if (pagePart.startsWith('/')) page = pagePart; + else page = path.posix.join(path.posix.dirname(routeFor(file)), pagePart); + page = page.replace(/\/$/, '') || '/'; + return { page, fragment, href: `${page}${fragment ? '#' + fragment : ''}` }; +} + +const pageCache = new Map(); +async function fetchPage(page) { + if (!pageCache.has(page)) { + pageCache.set(page, fetch(`${SITE}${page}`, { redirect: 'follow' }).then(response => response.text())); + } + return pageCache.get(page); +} + +function hasId(html, id) { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\sid=["']${escaped}["']`).test(html); +} + +const links = changedLinks(); +const rows = []; +let failures = 0; +for (const { file, oldUrl, newUrl } of links) { + const target = resolveTarget(file, newUrl); + if (!target) continue; + const html = await fetchPage(target.page); + const heading = firstHeadingId(target.page); + // No local source file means no such page; a page with no headings cannot be verified either. + const pageOk = Boolean(heading) && hasId(html, heading); + const anchorOk = !target.fragment || hasId(html, target.fragment); + if (!pageOk || !anchorOk) failures++; + const oldTarget = resolveTarget(file, oldUrl); + const oldCell = oldTarget ? `[\`${oldUrl}\`](${OLD_SITE}${oldTarget.href})` : `\`${oldUrl}\``; + rows.push(`| \`${file}\` | ${oldCell} | [\`${newUrl}\`](${SITE}${target.href}) | ${pageOk ? 'βœ…' : '❌'} | ${target.fragment ? (anchorOk ? 'βœ…' : '❌') : 'β€”'} |`); +} + +console.log(`Checked ${rows.length} changed links against ${SITE}: ${rows.length - failures} resolve, ${failures} fail.`); +console.log('Page rendered = the target page\'s first heading id is present (the 404 page never has it); Anchor = the #fragment is an id on the page. Old links point at the current site.\n'); +console.log('| File containing the link | Old link (broken today) | New link (preview) | Page rendered | Anchor found |'); +console.log('|--------------------------|-------------------------|--------------------|---------------|--------------|'); +console.log(rows.join('\n')); +process.exitCode = failures ? 1 : 0; From 1574b85b6b2e1caa68b154b7062af81b63d74a23 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:36:32 -0600 Subject: [PATCH 10/23] check-links: slug the full heading text when a heading contains a link, matching rehype-slug Amp-Thread-ID: https://ampcode.com/threads/T-01a07623-9d65-7356-96b8-2bebb31ffa5a Co-authored-by: Amp --- dev/check-links.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index df4cd878a..07ad7b0bd 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -96,9 +96,9 @@ export function extractHeadings(content) { let match; while ((match = headingRegex.exec(contentWithoutCode)) !== null) { - // Handle headings with links: [Text](/path) -> Text - const linkMatch = match[1].match(/\[([^\]]+)\]\([^)]+\)/); - const title = linkMatch ? linkMatch[1] : match[1]; + // rehype-slug slugs the heading's full text, with links reduced to their text: + // "How can I use [GitHub expression syntax](url) literally" -> "How can I use GitHub expression syntax literally" + const title = match[1].replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); headings.add(slugger.slug(title.trim())); } From 35be99e97dfc7e3bfdb7a72f9ceee2f8d0c940dc Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:46:48 -0600 Subject: [PATCH 11/23] check-links: state that redirects do not satisfy the PR check The checker resolves links against docs/**/*.mdx routes only and never reads src/data/redirects.ts, so a redirect added alongside a page move still leaves inbound links reported. Say so in the PR comment and in AGENTS.md so contributors update the links instead. Amp-Thread-ID: https://ampcode.com/threads/T-01a085bf-fe07-77a1-a4e2-55bff4679bcc Co-authored-by: Amp --- AGENTS.md | 2 +- dev/check-links.mjs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdb3ac2a2..1af2ac832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ - **Build**: `npm run build` - **Dev**: `npm run dev` - **Lint**: `npm run lint` -- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`) +- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check - **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 07ad7b0bd..3bb3cceab 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -366,7 +366,9 @@ function formatMarkdown(findings) { `### ❌ This PR introduces ${findings.length} broken link(s)`, '', 'Findings in files this PR did not change mean the PR removed or ' + - 'renamed a page or heading that those files link to.', + 'renamed a page or heading that those files link to. Update those ' + + 'links: adding a redirect in `src/data/redirects.ts` does not ' + + 'satisfy this check.', '' ]; for (const [file, fileFindings] of groupByFile(findings)) { From 7bfe4e6300b35d615c3e078f1c48e337094a7881 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:20:42 -0600 Subject: [PATCH 12/23] check-links: reword the inbound-link guidance in the PR comment Amp-Thread-ID: https://ampcode.com/threads/T-01a088d9-b8fd-76fd-ba93-3a416c5829a3 Co-authored-by: Amp --- dev/check-links.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 3bb3cceab..7203b75b1 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -365,10 +365,10 @@ function formatMarkdown(findings) { const lines = [ `### ❌ This PR introduces ${findings.length} broken link(s)`, '', - 'Findings in files this PR did not change mean the PR removed or ' + - 'renamed a page or heading that those files link to. Update those ' + - 'links: adding a redirect in `src/data/redirects.ts` does not ' + - 'satisfy this check.', + 'Any new broken links found on pages not changed in this PR indicate ' + + 'your PR has broken inbound links. Please fix the inbound links on ' + + 'the other pages. Adding a redirect in `src/data/redirects.ts` does ' + + 'not satisfy this check.', '' ]; for (const [file, fileFindings] of groupByFile(findings)) { From 7ac4191eb4d3451f852995ca930accce2a50709e Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:22:44 -0600 Subject: [PATCH 13/23] check-links: split the inbound-link guidance and say why redirects do not count Amp-Thread-ID: https://ampcode.com/threads/T-01a088d9-b8fd-76fd-ba93-3a416c5829a3 Co-authored-by: Amp --- dev/check-links.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 7203b75b1..5dbe9fe28 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -365,10 +365,12 @@ function formatMarkdown(findings) { const lines = [ `### ❌ This PR introduces ${findings.length} broken link(s)`, '', - 'Any new broken links found on pages not changed in this PR indicate ' + + 'Any broken links found here on pages not changed in this PR indicate ' + 'your PR has broken inbound links. Please fix the inbound links on ' + - 'the other pages. Adding a redirect in `src/data/redirects.ts` does ' + - 'not satisfy this check.', + 'the other pages.', + '', + 'Adding a redirect in `src/data/redirects.ts` does not satisfy this ' + + 'check, because it’s a workaround instead of a fix.', '' ]; for (const [file, fileFindings] of groupByFile(findings)) { From 29074eb640e3baddb2a6203471df3edf1fafca60 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:24:49 -0600 Subject: [PATCH 14/23] check-links: link each file path in the PR comment to the file on the PR branch Amp-Thread-ID: https://ampcode.com/threads/T-01a088d9-b8fd-76fd-ba93-3a416c5829a3 Co-authored-by: Amp --- .github/workflows/check-links.yml | 6 +++++- dev/check-links.mjs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 8c9af1d1b..472f677df 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -49,9 +49,13 @@ jobs: - name: Find broken links introduced by this PR id: check + env: + # File links in the report open the file on the PR branch + LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} run: | if node dev/check-links.mjs --check-anchors --format markdown \ - --baseline "$RUNNER_TEMP/base-links.json" > "$RUNNER_TEMP/report.md"; then + --baseline "$RUNNER_TEMP/base-links.json" \ + --link-base "$LINK_BASE" > "$RUNNER_TEMP/report.md"; then echo "broken=false" >> "$GITHUB_OUTPUT" else echo "broken=true" >> "$GITHUB_OUTPUT" diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 5dbe9fe28..3cef6facc 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -18,6 +18,8 @@ * --format Output as text (default), json, or markdown * --baseline Only report findings absent from this JSON file * (produced by --format json on another revision) + * --link-base Markdown output links each file path to /, + * e.g. https://github.com/sourcegraph/docs/blob/ * * Exits 1 when any finding is reported. */ @@ -37,6 +39,7 @@ const CHECK_ANCHORS = args.includes('--check-anchors'); const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); const FORMAT = flagValue('--format') ?? 'text'; const BASELINE_FILE = flagValue('--baseline'); +const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); const DOCS_DIR = path.join(ROOT_DIR, 'docs'); // Files whose links are checked. Only .mdx files become site routes; see @@ -374,7 +377,8 @@ function formatMarkdown(findings) { '' ]; for (const [file, fileFindings] of groupByFile(findings)) { - lines.push(`**\`${file}\`**`); + const fileLabel = `**\`${file}\`**`; + lines.push(LINK_BASE ? `[${fileLabel}](${LINK_BASE}/${file})` : fileLabel); for (const { line, url, error } of fileFindings) { lines.push(`- line ${line}: \`${url}\` β€” ${error}`); } From 5c3ac3a172d97309deba0372e594db306a0b974b Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:38:57 -0600 Subject: [PATCH 15/23] check-links: install only github-slugger in CI, link line numbers to the source view Drop glob in favour of fs.readdirSync(recursive) so the PR check needs one package instead of the whole site, and skip the pnpm/setup-node steps. Every finding now links to the file and line on the PR branch, in the ?plain=1 code view where #L anchors work. Amp-Thread-ID: https://ampcode.com/threads/T-01a088d9-b8fd-76fd-ba93-3a416c5829a3 Co-authored-by: Amp --- .github/workflows/check-links.yml | 20 ++++------- dev/check-links.mjs | 58 ++++++++++++++++++------------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 472f677df..ee3868760 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -21,19 +21,13 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.25.0 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20.19.6 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Install github-slugger, the only dependency of dev/check-links.mjs + # Into a scratch prefix, not the repo: `npm install ` next to + # package.json would install every dependency of the site + run: | + npm install --prefix "$RUNNER_TEMP/deps" --no-package-lock --no-audit --no-fund \ + "github-slugger@$(node -p 'require("./package.json").dependencies["github-slugger"]')" + ln -s "$RUNNER_TEMP/deps/node_modules" node_modules - name: Check out merge base env: diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 3cef6facc..690d38a78 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -26,7 +26,6 @@ import fs from 'fs'; import path from 'path'; -import { glob } from 'glob'; import GithubSlugger from 'github-slugger'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -44,14 +43,26 @@ const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); const DOCS_DIR = path.join(ROOT_DIR, 'docs'); // Files whose links are checked. Only .mdx files become site routes; see // `filePathPattern` in contentlayer.config.ts. -const SOURCE_GLOB = '**/*.{md,mdx}'; -const ROUTE_GLOB = '**/*.mdx'; +const SOURCE_EXTENSIONS = ['.md', '.mdx']; +const ROUTE_EXTENSIONS = ['.mdx']; function flagValue(name) { const index = args.indexOf(name); return index === -1 ? undefined : args[index + 1]; } +// Sorted relative paths of every file under dir, optionally limited to some +// extensions. Sorted so foo.mdx precedes foo/index.mdx; when both exist the +// site serves the first match (allPosts.find), so the first file owns the route. +function listFiles(dir, extensions) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && (!extensions || extensions.includes(path.extname(entry.name)))) + .map(entry => path.relative(dir, path.join(entry.parentPath, entry.name))) + .sort(); +} + // Regex patterns for extracting links const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g; const JSX_HREF_REGEX = /href=["']([^"']+)["']/g; @@ -118,10 +129,8 @@ function routeFor(file) { } // Get all MDX files and build a map of valid paths -async function buildPathMap() { - // Sorted so foo.mdx precedes foo/index.mdx; when both exist the site serves - // the first match (allPosts.find), so the first file owns the route here too. - const files = (await glob(ROUTE_GLOB, { cwd: DOCS_DIR })).sort(); +function buildPathMap() { + const files = listFiles(DOCS_DIR, ROUTE_EXTENSIONS); const pathMap = new Map(); // Lowercased route -> real route, to detect case mismatches const routesByLowerCase = new Map(); @@ -145,17 +154,16 @@ async function buildPathMap() { headingsMap.set(routePath + '/', headings); } - return { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase: await buildAssetMap() }; + return { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase: buildAssetMap() }; } // Lowercased link path -> real link path, for files under public/ and docs/ // (images, PDFs, ...). An enumerated map rather than fs.existsSync, which is // case-insensitive on macOS and would hide links that 404 on Linux. -async function buildAssetMap() { +function buildAssetMap() { const assetsByLowerCase = new Map(); for (const dir of ['public', 'docs']) { - const files = await glob('**/*', { cwd: path.join(ROOT_DIR, dir), nodir: true }); - for (const file of files) { + for (const file of listFiles(path.join(ROOT_DIR, dir))) { const linkPath = '/' + file; assetsByLowerCase.set(linkPath.toLowerCase(), linkPath); } @@ -291,12 +299,11 @@ function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsM } // Find every broken link: [{ file, line, url, error }] -async function findBrokenLinks() { - const maps = await buildPathMap(); - const files = await glob(SOURCE_GLOB, { cwd: DOCS_DIR }); +function findBrokenLinks() { + const maps = buildPathMap(); const findings = []; - for (const file of files.sort()) { + for (const file of listFiles(DOCS_DIR, SOURCE_EXTENSIONS)) { const fullPath = path.join(DOCS_DIR, file); const content = fs.readFileSync(fullPath, 'utf-8'); @@ -359,6 +366,10 @@ function formatText(findings) { return lines.join('\n') + '\n'; } +function linkTo(text, url) { + return url ? `[${text}](${url})` : text; +} + // Body for a pull request comment function formatMarkdown(findings) { if (findings.length === 0) { @@ -377,10 +388,12 @@ function formatMarkdown(findings) { '' ]; for (const [file, fileFindings] of groupByFile(findings)) { - const fileLabel = `**\`${file}\`**`; - lines.push(LINK_BASE ? `[${fileLabel}](${LINK_BASE}/${file})` : fileLabel); + // ?plain=1 opens GitHub's code view, where #L anchors work; the rendered + // Markdown preview ignores them + const fileUrl = LINK_BASE && `${LINK_BASE}/${file}?plain=1`; + lines.push(linkTo(`**\`${file}\`**`, fileUrl)); for (const { line, url, error } of fileFindings) { - lines.push(`- line ${line}: \`${url}\` β€” ${error}`); + lines.push(`- ${linkTo(`line ${line}`, fileUrl && `${fileUrl}#L${line}`)}: \`${url}\` β€” ${error}`); } lines.push(''); } @@ -397,7 +410,7 @@ const FORMATTERS = { markdown: formatMarkdown }; -async function main() { +function main() { const format = FORMATTERS[FORMAT]; if (!format) { throw new Error(`Unknown --format "${FORMAT}"; use text, json, or markdown`); @@ -407,7 +420,7 @@ async function main() { console.log('πŸ” Checking for dead links in MDX files...\n'); } - let findings = await findBrokenLinks(); + let findings = findBrokenLinks(); if (BASELINE_FILE) { findings = withoutBaseline(findings, BASELINE_FILE); } @@ -418,8 +431,5 @@ async function main() { // Only run when executed directly; dev/verify-links-live.mjs imports extractHeadings. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch(err => { - console.error('Error running link checker:', err); - process.exit(1); - }); + main(); } From f83945e8b78f0a761d1e9bd76c6a296cbd5bd8c5 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:00:58 -0600 Subject: [PATCH 16/23] check-links: split the PR comment into outbound and inbound broken links Outbound findings are in files the PR changed (it added or edited a bad link); inbound ones are elsewhere (the PR renamed or removed a link target). The workflow passes git diff --name-only against the merge base. --- .github/workflows/check-links.yml | 6 ++- dev/check-links.mjs | 71 +++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index ee3868760..821704258 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -32,7 +32,10 @@ jobs: - name: Check out merge base env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: git worktree add "$RUNNER_TEMP/base" "$(git merge-base "$BASE_SHA" HEAD)" + run: | + merge_base=$(git merge-base "$BASE_SHA" HEAD) + git worktree add "$RUNNER_TEMP/base" "$merge_base" + git diff --name-only "$merge_base" HEAD > "$RUNNER_TEMP/changed-files.txt" - name: Record broken links already present on the base branch # Exit 1 means findings, which is expected here @@ -49,6 +52,7 @@ jobs: run: | if node dev/check-links.mjs --check-anchors --format markdown \ --baseline "$RUNNER_TEMP/base-links.json" \ + --changed-files "$RUNNER_TEMP/changed-files.txt" \ --link-base "$LINK_BASE" > "$RUNNER_TEMP/report.md"; then echo "broken=false" >> "$GITHUB_OUTPUT" else diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 690d38a78..02dfbdbe9 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -20,6 +20,8 @@ * (produced by --format json on another revision) * --link-base Markdown output links each file path to /, * e.g. https://github.com/sourcegraph/docs/blob/ + * --changed-files Markdown output splits findings into outbound (in one + * of these files, one path per line) and inbound (elsewhere) * * Exits 1 when any finding is reported. */ @@ -39,6 +41,13 @@ const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); const FORMAT = flagValue('--format') ?? 'text'; const BASELINE_FILE = flagValue('--baseline'); const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); +const CHANGED_FILES = readPathList(flagValue('--changed-files')); + +// Set of the non-empty lines of file, or undefined when no file is given +function readPathList(file) { + if (!file) return undefined; + return new Set(fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean)); +} const DOCS_DIR = path.join(ROOT_DIR, 'docs'); // Files whose links are checked. Only .mdx files become site routes; see @@ -370,23 +379,9 @@ function linkTo(text, url) { return url ? `[${text}](${url})` : text; } -// Body for a pull request comment -function formatMarkdown(findings) { - if (findings.length === 0) { - return '### βœ… This PR introduces no broken links\n'; - } - - const lines = [ - `### ❌ This PR introduces ${findings.length} broken link(s)`, - '', - 'Any broken links found here on pages not changed in this PR indicate ' + - 'your PR has broken inbound links. Please fix the inbound links on ' + - 'the other pages.', - '', - 'Adding a redirect in `src/data/redirects.ts` does not satisfy this ' + - 'check, because it’s a workaround instead of a fix.', - '' - ]; +// Markdown list of findings grouped by file, linked to the source when --link-base is set +function markdownFindingList(findings) { + const lines = []; for (const [file, fileFindings] of groupByFile(findings)) { // ?plain=1 opens GitHub's code view, where #L anchors work; the rendered // Markdown preview ignores them @@ -397,9 +392,49 @@ function formatMarkdown(findings) { } lines.push(''); } + return lines; +} + +// Body for a pull request comment. With --changed-files, findings are split into +// outbound (in a file this PR changed: the PR added or edited a bad link) and +// inbound (in a file it did not: the PR renamed or removed a link target). +function formatMarkdown(findings) { + if (findings.length === 0) { + return '### βœ… This PR introduces no broken links\n'; + } + + const lines = [`### ❌ This PR introduces ${findings.length} broken link(s)`, '']; + if (CHANGED_FILES) { + const outbound = findings.filter(finding => CHANGED_FILES.has(finding.file)); + const inbound = findings.filter(finding => !CHANGED_FILES.has(finding.file)); + if (outbound.length > 0) { + lines.push( + '### Outbound', + '', + 'Your PR includes links to pages or anchors that do not exist.', + '', + ...markdownFindingList(outbound) + ); + } + if (inbound.length > 0) { + lines.push( + '### Inbound', + '', + 'A change your PR made broke inbound links from elsewhere. ' + + 'Please fix the inbound links on the other pages.', + '', + ...markdownFindingList(inbound) + ); + } + } else { + lines.push(...markdownFindingList(findings)); + } lines.push( 'Reproduce locally with `pnpm check-links --check-anchors` ' + - '(see `dev/check-links.mjs`).' + '(see `dev/check-links.mjs`).', + '', + 'Adding a redirect in `src/data/redirects.ts` does not satisfy this ' + + 'check, because it’s a workaround instead of a fix.' ); return lines.join('\n') + '\n'; } From 41790f3d552642e28cf235966a6dd473cd746c4b Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:22:23 -0600 Subject: [PATCH 17/23] check-links: export listFiles and routeFor for check-redirects, cancel superseded runs Amp-Thread-ID: https://ampcode.com/threads/T-01a088d9-b8fd-76fd-ba93-3a416c5829a3 Co-authored-by: Amp --- .github/workflows/check-links.yml | 5 +++++ dev/check-links.mjs | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 821704258..f2c5b5132 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -6,6 +6,11 @@ name: Check links on: pull_request: +# A new push supersedes the run for the previous one +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read pull-requests: write diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 02dfbdbe9..18f36c42b 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -63,7 +63,7 @@ function flagValue(name) { // Sorted relative paths of every file under dir, optionally limited to some // extensions. Sorted so foo.mdx precedes foo/index.mdx; when both exist the // site serves the first match (allPosts.find), so the first file owns the route. -function listFiles(dir, extensions) { +export function listFiles(dir, extensions) { if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -133,7 +133,7 @@ export function extractHeadings(content) { } // Site route for a file under docs/: foo/bar.mdx -> /foo/bar, foo/index.mdx -> /foo, index.mdx -> / -function routeFor(file) { +export function routeFor(file) { return '/' + file.replace(/\.mdx$/, '').replace(/(^|\/)index$/, ''); } @@ -464,7 +464,8 @@ function main() { process.exit(findings.length === 0 ? 0 : 1); } -// Only run when executed directly; dev/verify-links-live.mjs imports extractHeadings. +// Only run when executed directly; dev/verify-links-live.mjs and dev/check-redirects.mjs +// import the exported helpers. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } From c17d30e71a653f18554bf939b0c53e889a58ede7 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:56:56 -0600 Subject: [PATCH 18/23] check-links: flag absolute self-links with a suggested relative link, probe external links on added lines Absolute links to this site (https://sourcegraph.com/docs/..., the legacy docs.sourcegraph.com host, http://, //, www.) are findings even when the target exists: they leave preview deployments and local dev, and hide moved pages behind redirects. The finding proposes the relative link, following src/data/redirects.ts when the page moved. Version-pinned links (/@5.1/...) stay external. --diff replaces --changed-files and scopes two new features to added lines: --check-external requests each external link (HEAD, then GET on an error status, following redirects) and reports 404/410 only; --review writes a GitHub review with one suggested change per line, which the workflow posts, skipping suggestions already on the PR. Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp --- .github/workflows/check-links.yml | 30 +++- AGENTS.md | 2 +- dev/check-links.mjs | 267 ++++++++++++++++++++++++++---- 3 files changed, 266 insertions(+), 33 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index f2c5b5132..f5015d912 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -1,7 +1,8 @@ name: Check links # Reports internal links and #anchors that this PR breaks, compared with the -# merge base. Pre-existing broken links on the base branch are ignored. +# merge base, absolute links to this site, and external links on added lines +# that 404. Pre-existing broken links on the base branch are ignored. on: pull_request: @@ -40,7 +41,7 @@ jobs: run: | merge_base=$(git merge-base "$BASE_SHA" HEAD) git worktree add "$RUNNER_TEMP/base" "$merge_base" - git diff --name-only "$merge_base" HEAD > "$RUNNER_TEMP/changed-files.txt" + git diff -U0 "$merge_base" HEAD > "$RUNNER_TEMP/changes.diff" - name: Record broken links already present on the base branch # Exit 1 means findings, which is expected here @@ -55,9 +56,10 @@ jobs: # File links in the report open the file on the PR branch LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} run: | - if node dev/check-links.mjs --check-anchors --format markdown \ + if node dev/check-links.mjs --check-anchors --check-external --format markdown \ --baseline "$RUNNER_TEMP/base-links.json" \ - --changed-files "$RUNNER_TEMP/changed-files.txt" \ + --diff "$RUNNER_TEMP/changes.diff" \ + --review "$RUNNER_TEMP/review.json" \ --link-base "$LINK_BASE" > "$RUNNER_TEMP/report.md"; then echo "broken=false" >> "$GITHUB_OUTPUT" else @@ -94,6 +96,26 @@ jobs: gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md" fi + - name: Suggest fixes as review comments + # One suggested change per added line with a fix. Suggestions already on + # the PR (same file, line, and text) are not posted again. + if: steps.check.outputs.broken == 'true' && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \ + --jq '.[] | {path, line, body}' | jq -s . > "$RUNNER_TEMP/posted.json" + jq --slurpfile posted "$RUNNER_TEMP/posted.json" \ + '.comments |= map(select(. as $comment | $posted[0] | index({path: $comment.path, line: $comment.line, body: $comment.body}) | not))' \ + "$RUNNER_TEMP/review.json" > "$RUNNER_TEMP/review-new.json" + + if [ "$(jq '.comments | length' "$RUNNER_TEMP/review-new.json")" -gt 0 ]; then + gh api --method POST "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \ + --input "$RUNNER_TEMP/review-new.json" > /dev/null \ + || echo "::warning::Could not post the suggested fixes; they are in the report above" + fi + - name: Fail when this PR introduces broken links if: steps.check.outputs.broken == 'true' run: exit 1 diff --git a/AGENTS.md b/AGENTS.md index 1af2ac832..0462d5798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ - **Build**: `npm run build` - **Dev**: `npm run dev` - **Lint**: `npm run lint` -- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check +- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check. Link to this site with relative paths (`/admin/config/site-config`), never `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`. To also probe the external links you added: `npm run check-links -- --check-anchors --check-external --diff <(git diff -U0 origin/main)` - **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 18f36c42b..10ca27f0e 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -11,6 +11,10 @@ * - Links whose case differs from the real path (work on macOS, 404 on Linux) * - Missing anchor/heading references * - Invalid file paths + * - Absolute links to this site (https://sourcegraph.com/docs/..., the legacy + * https://docs.sourcegraph.com/... host, http://, //, www.), which should be + * relative links; the finding proposes one, following src/data/redirects.ts + * - With --check-external, external links on added lines that return 404 or 410 * * Usage: node dev/check-links.mjs [options] * --check-anchors Also validate #anchors against headings @@ -20,8 +24,16 @@ * (produced by --format json on another revision) * --link-base Markdown output links each file path to /, * e.g. https://github.com/sourcegraph/docs/blob/ - * --changed-files Markdown output splits findings into outbound (in one - * of these files, one path per line) and inbound (elsewhere) + * --diff Unified diff of the change under review, e.g. from + * `git diff -U0 origin/main`. Markdown output splits + * findings into outbound (in a file the diff touches) and + * inbound (elsewhere); the added lines scope the two flags below + * --check-external Request every external link on an added line and report + * 404s and 410s. Follows redirects; ignores #anchors, other + * statuses, and network errors. Requires --diff + * --review Write a GitHub pull request review (JSON body for + * POST /repos/{owner}/{repo}/pulls/{n}/reviews) with one + * suggested-change comment per added line that has a fix * * Exits 1 when any finding is reported. */ @@ -41,12 +53,41 @@ const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); const FORMAT = flagValue('--format') ?? 'text'; const BASELINE_FILE = flagValue('--baseline'); const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, ''); -const CHANGED_FILES = readPathList(flagValue('--changed-files')); +const DIFF = parseDiff(flagValue('--diff')); +const CHECK_EXTERNAL = args.includes('--check-external'); +const REVIEW_FILE = flagValue('--review'); -// Set of the non-empty lines of file, or undefined when no file is given -function readPathList(file) { +if (CHECK_EXTERNAL && !DIFF) { + throw new Error('--check-external needs --diff, to know which lines were added'); +} + +// Files and added lines of a unified diff, with paths relative to the repository: +// { files: Set<'docs/foo.mdx'>, addedLines: Map<'docs/foo.mdx', Set> }. +// Works with any amount of context, so `git diff` and `git diff -U0` both do. +function parseDiff(file) { if (!file) return undefined; - return new Set(fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean)); + const files = new Set(); + const addedLines = new Map(); + let currentFile; + let lineNumber; + for (const line of fs.readFileSync(file, 'utf-8').split('\n')) { + if (line.startsWith('diff --git ')) { + currentFile = undefined; + } else if (line.startsWith('+++ ') && !currentFile) { + // `+++ /dev/null` is a deleted file, which has no added lines + currentFile = line.slice(4).replace(/^b\//, ''); + if (currentFile === '/dev/null') continue; + files.add(currentFile); + addedLines.set(currentFile, new Set()); + } else if (line.startsWith('@@ ')) { + lineNumber = Number(line.match(/^@@ -\S+ \+(\d+)/)[1]); + } else if (line.startsWith('+') && currentFile) { + addedLines.get(currentFile).add(lineNumber++); + } else if (line.startsWith(' ')) { + lineNumber++; + } + } + return { files, addedLines }; } const DOCS_DIR = path.join(ROOT_DIR, 'docs'); @@ -163,7 +204,26 @@ function buildPathMap() { headingsMap.set(routePath + '/', headings); } - return { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase: buildAssetMap() }; + return { + pathMap, + routesByLowerCase, + headingsMap, + headingsByFile, + assetsByLowerCase: buildAssetMap(), + redirects: loadRedirects() + }; +} + +// Source route -> destination of src/data/redirects.ts. The middleware uses the +// first rule whose source equals the requested path, so first entry wins here too. +function loadRedirects() { + const redirects = new Map(); + const source = fs.readFileSync(path.join(ROOT_DIR, 'src/data/redirects.ts'), 'utf-8'); + const ruleRegex = /source:\s*(['"])(.*?)\1,\s*destination:\s*(['"])(.*?)\3/gs; + for (const [, , from, , to] of source.matchAll(ruleRegex)) { + if (!redirects.has(from)) redirects.set(from, to); + } + return redirects; } // Lowercased link path -> real link path, for files under public/ and docs/ @@ -213,12 +273,63 @@ function extractLinks(content, filePath) { return links; } -// Check if a link is valid -function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase }) { +// Absolute links to this site, in every form the docs have used: http or https, +// scheme-relative, www., the legacy docs.sourcegraph.com host, or sourcegraph.com/docs. +// Links pinned to an old version (/@5.1/..., /v/5.1/...) are external: the +// middleware sends them to that version's own site. +const SELF_LINK_REGEX = /^(?:https?:)?\/\/(?:www\.)?(?:docs\.sourcegraph\.com|sourcegraph\.com\/docs)(?=[/#?]|$)(?!\/@|\/v\/)/i; + +export function isSelfLink(url) { + return SELF_LINK_REGEX.test(url); +} + +// The relative form of an absolute self-link: https://sourcegraph.com/docs/a/b/#c -> /a/b#c. +// A ?query has no meaning on a docs page and is dropped. +function relativeSelfLink(url) { + const [pathAndQuery, anchor] = url.replace(SELF_LINK_REGEX, '').split('#'); + const route = pathAndQuery.split('?')[0].replace(/\/$/, '') || '/'; + return anchor ? `${route}#${anchor}` : route; +} + +// Absolute self-links break on preview deployments and local dev, and hide moved +// pages behind redirects, so they are findings even when the target exists. The +// fix is the relative link, following src/data/redirects.ts when the page moved. +// The redirect destination's own #anchor wins over the link's, like the middleware. +function validateSelfLink(url, currentFile, maps) { + const relative = relativeSelfLink(url); + const anchor = relative.split('#')[1]; + const visited = new Set(); + let candidate = relative; + while (true) { + const moved = candidate === relative ? '' : ' to a moved page'; + const problem = validateLink({ url: candidate }, currentFile, maps); + if (!problem) { + return { error: `Absolute self-link${moved}; use "${candidate}" instead`, fix: candidate }; + } + const destination = maps.redirects.get(candidate.split('#')[0]); + if (!destination || visited.has(destination)) { + const replaced = moved ? `; "${candidate}" replaced it, but` : ', and'; + return { error: `Absolute self-link${moved}${replaced} ${problem[0].toLowerCase()}${problem.slice(1)}` }; + } + visited.add(destination); + candidate = isSelfLink(destination) ? relativeSelfLink(destination) : destination; + if (anchor && !candidate.includes('#') && !/^https?:/.test(candidate)) { + candidate += `#${anchor}`; + } + } +} + +// Check if a link is valid. Returns null, an error string, or { error, fix }. +function validateLink(link, currentFile, maps) { + const { pathMap, routesByLowerCase, headingsMap, headingsByFile, assetsByLowerCase } = maps; const { url } = link; + + if (isSelfLink(url)) { + return validateSelfLink(url, currentFile, maps); + } // Skip external links, mailto, tel, javascript, etc. - if (url.startsWith('http://') || url.startsWith('https://') || + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('command:')) { @@ -307,29 +418,88 @@ function validateLink(link, currentFile, { pathMap, routesByLowerCase, headingsM return `Page not found: "${resolvedPath}"`; } -// Find every broken link: [{ file, line, url, error }] -function findBrokenLinks() { +function isAddedLine(file, line) { + return DIFF?.addedLines.get(file)?.has(line) ?? false; +} + +// Find every broken link: [{ file, line, url, error, fix? }] +async function findBrokenLinks() { const maps = buildPathMap(); const findings = []; + const externalLinks = []; for (const file of listFiles(DOCS_DIR, SOURCE_EXTENSIONS)) { const fullPath = path.join(DOCS_DIR, file); const content = fs.readFileSync(fullPath, 'utf-8'); for (const link of extractLinks(content, fullPath)) { - const error = validateLink(link, fullPath, maps); - if (error) { - findings.push({ - file: `docs/${file}`, - line: link.lineNumber, - url: link.url, - error - }); + const location = { file: `docs/${file}`, line: link.lineNumber, url: link.url }; + const problem = validateLink(link, fullPath, maps); + if (problem) { + findings.push({ ...location, ...(typeof problem === 'string' ? { error: problem } : problem) }); + } else if (CHECK_EXTERNAL && isExternalLink(link.url) && isAddedLine(location.file, location.line)) { + externalLinks.push(location); } } } - return findings; + return [...findings, ...(await findDeadExternalLinks(externalLinks))]; +} + +// Hosts reserved for examples and documentation (RFC 2606, RFC 6761), never requested +const PLACEHOLDER_HOST_REGEX = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost|local|internal)$/i; +// Templated URLs like https:/// or https://$HOST/, never requested +const PLACEHOLDER_URL_REGEX = /[<>{}$*]/; + +function isExternalLink(url) { + if (!/^https?:\/\//i.test(url) || PLACEHOLDER_URL_REGEX.test(url)) return false; + try { + return !PLACEHOLDER_HOST_REGEX.test(new URL(url).hostname); + } catch { + return false; + } +} + +// HTTP status of url after redirects, or undefined on a network error or timeout. +// HEAD first; some servers refuse or misreport HEAD, so an error status is +// confirmed with a GET whose body is not read. +async function probeUrl(url) { + const request = method => + fetch(url, { + method, + redirect: 'follow', + signal: AbortSignal.timeout(15_000), + headers: { 'user-agent': 'sourcegraph-docs-check-links (+https://github.com/sourcegraph/docs)' } + }); + try { + let response = await request('HEAD'); + if (response.status >= 400) { + response = await request('GET'); + await response.body?.cancel(); + } + return response.status; + } catch { + return undefined; + } +} + +// Findings for external links whose target is gone. Only 404 and 410 count: rate +// limits, bot blocks, server errors, and network failures are not the PR's fault. +async function findDeadExternalLinks(links) { + const urls = [...new Set(links.map(link => link.url.split('#')[0]))]; + const statusByUrl = new Map(); + const queue = [...urls]; + const worker = async () => { + for (let url = queue.shift(); url !== undefined; url = queue.shift()) { + statusByUrl.set(url, await probeUrl(url)); + } + }; + await Promise.all(Array.from({ length: 8 }, worker)); + + return links.flatMap(link => { + const status = statusByUrl.get(link.url.split('#')[0]); + return status === 404 || status === 410 ? [{ ...link, error: `External link returns HTTP ${status}` }] : []; + }); } // Identity of a finding across revisions: line numbers shift, so ignore them @@ -395,7 +565,7 @@ function markdownFindingList(findings) { return lines; } -// Body for a pull request comment. With --changed-files, findings are split into +// Body for a pull request comment. With --diff, findings are split into // outbound (in a file this PR changed: the PR added or edited a bad link) and // inbound (in a file it did not: the PR renamed or removed a link target). function formatMarkdown(findings) { @@ -404,14 +574,14 @@ function formatMarkdown(findings) { } const lines = [`### ❌ This PR introduces ${findings.length} broken link(s)`, '']; - if (CHANGED_FILES) { - const outbound = findings.filter(finding => CHANGED_FILES.has(finding.file)); - const inbound = findings.filter(finding => !CHANGED_FILES.has(finding.file)); + if (DIFF) { + const outbound = findings.filter(finding => DIFF.files.has(finding.file)); + const inbound = findings.filter(finding => !DIFF.files.has(finding.file)); if (outbound.length > 0) { lines.push( '### Outbound', '', - 'Your PR includes links to pages or anchors that do not exist.', + 'Your PR includes links to pages or anchors that do not exist, or absolute links to this site.', '', ...markdownFindingList(outbound) ); @@ -429,6 +599,15 @@ function formatMarkdown(findings) { } else { lines.push(...markdownFindingList(findings)); } + if (findings.some(finding => isSelfLink(finding.url))) { + lines.push( + 'Write links to this site as relative paths (`/admin/config/site-config`), ' + + 'not `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`: ' + + 'absolute links leave the preview deployment and local dev server, and ' + + 'hide moved pages behind redirects.', + '' + ); + } lines.push( 'Reproduce locally with `pnpm check-links --check-anchors` ' + '(see `dev/check-links.mjs`).', @@ -439,13 +618,42 @@ function formatMarkdown(findings) { return lines.join('\n') + '\n'; } +// Body for POST /repos/{owner}/{repo}/pulls/{n}/reviews: one suggested change per +// added line that has fixes, so the author can apply them from the PR. Review +// comments must sit on a line of the diff, hence the added-line restriction. +function reviewRequest(findings) { + const fixesByLine = new Map(); + for (const finding of findings) { + if (!finding.fix || !isAddedLine(finding.file, finding.line)) continue; + const key = `${finding.file}:${finding.line}`; + if (!fixesByLine.has(key)) fixesByLine.set(key, { file: finding.file, line: finding.line, fixes: [] }); + fixesByLine.get(key).fixes.push(finding); + } + + const comments = [...fixesByLine.values()].map(({ file, line, fixes }) => { + const source = fs.readFileSync(path.join(ROOT_DIR, file), 'utf-8').split('\n')[line - 1]; + const fixed = fixes.reduce((text, { url, fix }) => text.split(url).join(fix), source); + return { + path: file, + line, + side: 'RIGHT', + body: [...fixes.map(({ error }) => `- ${error}`), '```suggestion', fixed, '```'].join('\n') + }; + }); + return { + event: 'COMMENT', + body: 'Suggested fixes for the links this PR adds; details in the check-links comment.', + comments + }; +} + const FORMATTERS = { text: formatText, json: findings => JSON.stringify(findings, null, '\t') + '\n', markdown: formatMarkdown }; -function main() { +async function main() { const format = FORMATTERS[FORMAT]; if (!format) { throw new Error(`Unknown --format "${FORMAT}"; use text, json, or markdown`); @@ -455,11 +663,14 @@ function main() { console.log('πŸ” Checking for dead links in MDX files...\n'); } - let findings = findBrokenLinks(); + let findings = await findBrokenLinks(); if (BASELINE_FILE) { findings = withoutBaseline(findings, BASELINE_FILE); } + if (REVIEW_FILE) { + fs.writeFileSync(REVIEW_FILE, JSON.stringify(reviewRequest(findings), null, '\t') + '\n'); + } process.stdout.write(format(findings)); process.exit(findings.length === 0 ? 0 : 1); } From aba7bc56166e7fb8badacbab02c4f687ac419553 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:13:02 -0600 Subject: [PATCH 19/23] check-links: review comment lists every finding on the line, naming the ones the suggestion leaves Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp --- dev/check-links.mjs | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 10ca27f0e..43a7a5999 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -276,7 +276,8 @@ function extractLinks(content, filePath) { // Absolute links to this site, in every form the docs have used: http or https, // scheme-relative, www., the legacy docs.sourcegraph.com host, or sourcegraph.com/docs. // Links pinned to an old version (/@5.1/..., /v/5.1/...) are external: the -// middleware sends them to that version's own site. +// middleware sends them to that version's own site (5.1.sourcegraph.com), whose +// pages are not in this repo, so only --check-external can validate them. const SELF_LINK_REGEX = /^(?:https?:)?\/\/(?:www\.)?(?:docs\.sourcegraph\.com|sourcegraph\.com\/docs)(?=[/#?]|$)(?!\/@|\/v\/)/i; export function isSelfLink(url) { @@ -619,27 +620,32 @@ function formatMarkdown(findings) { } // Body for POST /repos/{owner}/{repo}/pulls/{n}/reviews: one suggested change per -// added line that has fixes, so the author can apply them from the PR. Review -// comments must sit on a line of the diff, hence the added-line restriction. +// added line that has fixes, so the author can apply them from the PR. The comment +// lists every finding on the line, so the ones the suggestion cannot fix are not +// mistaken for accepted. Review comments must sit on a line of the diff, hence the +// added-line restriction. function reviewRequest(findings) { - const fixesByLine = new Map(); + const findingsByLine = new Map(); for (const finding of findings) { - if (!finding.fix || !isAddedLine(finding.file, finding.line)) continue; + if (!isAddedLine(finding.file, finding.line)) continue; const key = `${finding.file}:${finding.line}`; - if (!fixesByLine.has(key)) fixesByLine.set(key, { file: finding.file, line: finding.line, fixes: [] }); - fixesByLine.get(key).fixes.push(finding); - } - - const comments = [...fixesByLine.values()].map(({ file, line, fixes }) => { - const source = fs.readFileSync(path.join(ROOT_DIR, file), 'utf-8').split('\n')[line - 1]; - const fixed = fixes.reduce((text, { url, fix }) => text.split(url).join(fix), source); - return { - path: file, - line, - side: 'RIGHT', - body: [...fixes.map(({ error }) => `- ${error}`), '```suggestion', fixed, '```'].join('\n') - }; - }); + if (!findingsByLine.has(key)) findingsByLine.set(key, []); + findingsByLine.get(key).push(finding); + } + + const comments = [...findingsByLine.values()] + .filter(lineFindings => lineFindings.some(finding => finding.fix)) + .map(lineFindings => { + const { file, line } = lineFindings[0]; + const source = fs.readFileSync(path.join(ROOT_DIR, file), 'utf-8').split('\n')[line - 1]; + const fixed = lineFindings + .filter(finding => finding.fix) + .reduce((text, { url, fix }) => text.split(url).join(fix), source); + const notes = lineFindings.map( + ({ url, error, fix }) => `- \`${url}\`: ${error}${fix ? '' : ' (not fixed by this suggestion)'}` + ); + return { path: file, line, side: 'RIGHT', body: [...notes, '```suggestion', fixed, '```'].join('\n') }; + }); return { event: 'COMMENT', body: 'Suggested fixes for the links this PR adds; details in the check-links comment.', From 953692f9cfbb2ea1f74c000ecbd61997f89d1a67 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:42:25 -0600 Subject: [PATCH 20/23] check-links: report absolute self-links only with --check-self-links, so next build fails on dead links alone Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp --- .github/workflows/check-links.yml | 4 ++-- AGENTS.md | 2 +- dev/check-links.mjs | 15 ++++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index f5015d912..e0dab4ba3 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -46,7 +46,7 @@ jobs: - name: Record broken links already present on the base branch # Exit 1 means findings, which is expected here run: | - node dev/check-links.mjs --check-anchors --format json \ + node dev/check-links.mjs --check-anchors --check-self-links --format json \ --root "$RUNNER_TEMP/base" > "$RUNNER_TEMP/base-links.json" \ || [ $? -eq 1 ] @@ -56,7 +56,7 @@ jobs: # File links in the report open the file on the PR branch LINK_BASE: ${{ github.event.pull_request.head.repo.html_url }}/blob/${{ github.event.pull_request.head.ref }} run: | - if node dev/check-links.mjs --check-anchors --check-external --format markdown \ + if node dev/check-links.mjs --check-anchors --check-self-links --check-external --format markdown \ --baseline "$RUNNER_TEMP/base-links.json" \ --diff "$RUNNER_TEMP/changes.diff" \ --review "$RUNNER_TEMP/review.json" \ diff --git a/AGENTS.md b/AGENTS.md index 0462d5798..e0c1083c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ - **Build**: `npm run build` - **Dev**: `npm run dev` - **Lint**: `npm run lint` -- **Check links**: `npm run check-links -- --check-anchors` (CI comments on PRs that break links; see `dev/check-links.mjs`). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check. Link to this site with relative paths (`/admin/config/site-config`), never `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`. To also probe the external links you added: `npm run check-links -- --check-anchors --check-external --diff <(git diff -U0 origin/main)` +- **Check links**: `npm run check-links -- --check-anchors --check-self-links` (CI comments on PRs that break links; see `dev/check-links.mjs`; `next build` runs it without flags, so only dead page links fail a deploy). When moving a page or renaming a heading, update every link to it; a redirect in `src/data/redirects.ts` does not satisfy the check. Link to this site with relative paths (`/admin/config/site-config`), never `https://sourcegraph.com/docs/…` or `https://docs.sourcegraph.com/…`. To also probe the external links you added: `npm run check-links -- --check-anchors --check-self-links --check-external --diff <(git diff -U0 origin/main)` - **Prove changed links resolve on a deploy**: `node dev/verify-links-live.mjs --site ` prints a Markdown table for the PR description ## AI Chat Integration diff --git a/dev/check-links.mjs b/dev/check-links.mjs index 43a7a5999..e37c940f7 100644 --- a/dev/check-links.mjs +++ b/dev/check-links.mjs @@ -11,13 +11,17 @@ * - Links whose case differs from the real path (work on macOS, 404 on Linux) * - Missing anchor/heading references * - Invalid file paths - * - Absolute links to this site (https://sourcegraph.com/docs/..., the legacy - * https://docs.sourcegraph.com/... host, http://, //, www.), which should be - * relative links; the finding proposes one, following src/data/redirects.ts + * - With --check-self-links, absolute links to this site (https://sourcegraph.com/docs/..., + * the legacy https://docs.sourcegraph.com/... host, http://, //, www.), which + * should be relative links; the finding proposes one, following src/data/redirects.ts * - With --check-external, external links on added lines that return 404 or 410 - * + * + * next.config.js runs this with no flags on every build, so only dead page links + * can fail a deploy; the flags below are for the pull request workflow. + * * Usage: node dev/check-links.mjs [options] * --check-anchors Also validate #anchors against headings + * --check-self-links Also report absolute links to this site * --root Repository to check (default: this repository) * --format Output as text (default), json, or markdown * --baseline Only report findings absent from this JSON file @@ -49,6 +53,7 @@ const __dirname = path.dirname(__filename); // Parse CLI flags const args = process.argv.slice(2); const CHECK_ANCHORS = args.includes('--check-anchors'); +const CHECK_SELF_LINKS = args.includes('--check-self-links'); const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname)); const FORMAT = flagValue('--format') ?? 'text'; const BASELINE_FILE = flagValue('--baseline'); @@ -326,7 +331,7 @@ function validateLink(link, currentFile, maps) { const { url } = link; if (isSelfLink(url)) { - return validateSelfLink(url, currentFile, maps); + return CHECK_SELF_LINKS ? validateSelfLink(url, currentFile, maps) : null; } // Skip external links, mailto, tel, javascript, etc. From b3d025d6119be1983bb52fd06a2e7eeeafa3ce35 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:02:04 -0600 Subject: [PATCH 21/23] test: add absolute self-links and a dead external link to exercise the check-links workflow Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp --- docs/code-search/features.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx index b86a4d8f3..04677d334 100644 --- a/docs/code-search/features.mdx +++ b/docs/code-search/features.mdx @@ -148,3 +148,5 @@ This file picker is useful when comparing branches with thousands of changed fil - When viewing a file or directory, press the `y` key to expand the URL to its canonical form (with the full 40-character Git commit SHA). - To share a link to multi-line range in a file, click on the starting line number and shift-click on the ending line number (in the left-hand gutter). + +Test links for the check-links workflow (will be reverted): [site config](https://sourcegraph.com/docs/admin/config/site_config), [tracing](http://docs.sourcegraph.com/admin/observability/tracing#sampling), [dead external](https://github.com/sourcegraph/docs/blob/main/this-file-does-not-exist.md), [live external](https://github.com/sourcegraph/docs). From 34db61d24cb0f48be33aba53bc42c53d23e30ed4 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:03:57 -0600 Subject: [PATCH 22/23] test: re-run check-links to verify suggestions are not posted twice Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp From 67f39d4466a5b4faddcf44066819cab7bb39aa3a Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:13:21 -0600 Subject: [PATCH 23/23] test: more link shapes on one line Amp-Thread-ID: https://ampcode.com/threads/T-01a08a01-44c1-775b-84d0-d67ff9501905 Co-authored-by: Amp --- docs/code-search/features.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx index 04677d334..519bc14c8 100644 --- a/docs/code-search/features.mdx +++ b/docs/code-search/features.mdx @@ -149,4 +149,4 @@ This file picker is useful when comparing branches with thousands of changed fil - When viewing a file or directory, press the `y` key to expand the URL to its canonical form (with the full 40-character Git commit SHA). - To share a link to multi-line range in a file, click on the starting line number and shift-click on the ending line number (in the left-hand gutter). -Test links for the check-links workflow (will be reverted): [site config](https://sourcegraph.com/docs/admin/config/site_config), [tracing](http://docs.sourcegraph.com/admin/observability/tracing#sampling), [dead external](https://github.com/sourcegraph/docs/blob/main/this-file-does-not-exist.md), [live external](https://github.com/sourcegraph/docs). +Test links for the check-links workflow (will be reverted): [site config](https://sourcegraph.com/docs/admin/config/site_config), [search](//www.sourcegraph.com/docs/code-search/), [tracing](http://docs.sourcegraph.com/admin/observability/tracing#sampling), [dead external](https://github.com/sourcegraph/docs/blob/main/this-file-does-not-exist.md), [dead pinned](https://sourcegraph.com/docs/@5.1/no-such-page), [live pinned](https://sourcegraph.com/docs/@5.1/admin/search), [live external](https://github.com/sourcegraph/docs).