fix(docs): repair 4 links broken by #18, add a doc-link gate - #19
Conversation
Collapsing seven skills into one router deleted skills/{nushell,shebang,
streams,zsh}/ but left reference/{nushell,shebang,streams,zsh}.md linking to
them. All five gates stayed green through the merge because none of them
reads a link -- in a repo whose product is its documentation, that is a real
hole. The four now point at skills/shell/.
tools/check-doc-links.sh resolves every relative Markdown link against its own
file's directory and fails on any miss. External URLs are deliberately not
fetched: needs network, slow, and turns someone else's outage into a red
build. Verified both ways -- 83 links resolve, and an injected bad link fails
the run.
Wired into task ci, a standalone task doc-links, and lefthook pre-commit on
*.md. The hook self-scans the whole tree rather than only staged files,
because moving one file breaks links in files the commit never touches --
exactly how this shipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new validation script, tools/check-doc-links.sh, along with integrations in Taskfile.yml and lefthook.yml to ensure all relative Markdown links in the repository resolve correctly. It also fixes several broken links in the reference/ directory. The review feedback highlights three key areas of improvement for the new script: making the URI scheme exclusion more robust to handle other protocols and protocol-relative URLs, properly decoding percent-encoded characters and handling root-relative links, and appending || true to the extraction pipeline to prevent script failures under set -eo pipefail when a file contains no links.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| case "${target}" in | ||
| http://* | https://* | mailto:* | '#'*) continue ;; | ||
| *) ;; | ||
| esac |
There was a problem hiding this comment.
The current case statement only excludes a hardcoded list of protocols (http, https, mailto). It will fail on other valid URI schemes (like ftp://, news:, tel:, etc.) or protocol-relative URLs (like //example.com), incorrectly treating them as relative file paths and reporting them as broken.
We can make this much more robust and simpler by skipping any target that contains a colon : (which covers all URI schemes) or starts with // (protocol-relative).
| case "${target}" in | |
| http://* | https://* | mailto:* | '#'*) continue ;; | |
| *) ;; | |
| esac | |
| case "${target}" in | |
| *:* | //* | '#'*) continue ;; | |
| *) ;; | |
| esac |
| if [[ ! -e "${dir}/${target}" ]]; then | ||
| printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2 | ||
| status=1 | ||
| fi |
There was a problem hiding this comment.
There are two issues with the path resolution here:
- URL-Encoded Links: If a relative link contains percent-encoded characters (e.g.,
%20for spaces),-ewill fail because it looks for the literal%20on the filesystem instead of the decoded space. - Root-Relative Links: If a link starts with
/(referring to the repository root), resolving it as${dir}/${target}will produce an incorrect path (e.g.,reference//some-file.mdinstead of./some-file.md).
We can resolve both issues by:
- Decoding percent-encoded characters using a fast, pure-bash
printfexpansion. - Checking if the decoded target starts with
/and resolving it relative to the repository root (.) instead of${dir}.
| if [[ ! -e "${dir}/${target}" ]]; then | |
| printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2 | |
| status=1 | |
| fi | |
| # Decode percent-encoded characters (e.g., %20 -> space) for the file check | |
| # while keeping the original target for the error message. | |
| decoded_target=$(printf '%b' "${target//%/\\x}") | |
| resolved_path="${dir}/${decoded_target}" | |
| if [[ ${decoded_target} == /* ]]; then | |
| resolved_path=".${decoded_target}" | |
| fi | |
| if [[ ! -e "${resolved_path}" ]]; then | |
| printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2 | |
| status=1 | |
| fi |
| fi | ||
| # `[text](target)` with no spaces or parens inside the target, which is | ||
| # what every link in this repo looks like. | ||
| done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/') |
There was a problem hiding this comment.
If a markdown file contains no links at all, grep -o will find no matches and exit with status 1. Under set -o pipefail, this causes the entire pipeline inside the process substitution to return 1. Depending on the bash version and environment, this can cause the script to exit prematurely or behave unpredictably under set -e.
Appending || true (or || :) to the pipeline inside the process substitution ensures it always exits successfully even when no links are found.
| done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/') | |
| done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/' || true) |
The bug #18 shipped
Collapsing seven skills into one router deleted
skills/{nushell,shebang,streams,zsh}/, but fourreference/*.mdfiles still linked to them:reference/nushell.md../skills/nushell/reference/shebang.md../skills/shebang/reference/streams.md../skills/streams/reference/zsh.md../skills/zsh/All repointed at
../skills/shell/.Why CI didn't catch it
Every gate was green through the merge —
fmt-check,lint,examples,nushell,nushell-demo,yaml-schemas,ai-integrations— because none of them reads a link. In a repo whose product is its documentation, that's a real hole, not a nit.The gate
tools/check-doc-links.shresolves every relative Markdown link against its own file's directory and fails on any miss.External URLs are deliberately not fetched: it needs network, it's slow, and it turns someone else's outage into a red build here.
Verified both directions:
doc-links: OK (83 relative links resolve)[bogus](../skills/deleted-thing/)→ non-zero exit, names the file and targetWired into
task ci, a standalonetask doc-links, and lefthook pre-commit on*.md.The hook self-scans the whole tree rather than only staged files — moving or deleting one file breaks links in files the commit never touches, which is precisely how this shipped.
Note on the implementation
It runs
grep+sedonce per markdown file. An earlier revision hoisted both out of the loop to avoid the subprocesses, and silently mangled its own field splitting — reporting all 83 links as broken. Two subprocesses per file is cheap; a checker that lies is not. That reasoning is recorded in the script header so the next person doesn't repeat the "optimization."🤖 Generated with Claude Code