Skip to content

fix(docs): repair 4 links broken by #18, add a doc-link gate - #19

Merged
posidoni merged 1 commit into
mainfrom
fix/doc-link-gate
Jul 19, 2026
Merged

fix(docs): repair 4 links broken by #18, add a doc-link gate#19
posidoni merged 1 commit into
mainfrom
fix/doc-link-gate

Conversation

@posidoni

Copy link
Copy Markdown
Owner

The bug #18 shipped

Collapsing seven skills into one router deleted skills/{nushell,shebang,streams,zsh}/, but four reference/*.md files still linked to them:

File Dangling target
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.sh resolves 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:

  • clean tree → doc-links: OK (83 relative links resolve)
  • injected [bogus](../skills/deleted-thing/) → non-zero exit, names the file and target

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 — 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+sed once 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

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>
@posidoni
posidoni merged commit 6607768 into main Jul 19, 2026
2 checks passed
@posidoni
posidoni deleted the fix/doc-link-gate branch July 19, 2026 14:31

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tools/check-doc-links.sh
Comment on lines +29 to +32
case "${target}" in
http://* | https://* | mailto:* | '#'*) continue ;;
*) ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Suggested change
case "${target}" in
http://* | https://* | mailto:* | '#'*) continue ;;
*) ;;
esac
case "${target}" in
*:* | //* | '#'*) continue ;;
*) ;;
esac

Comment thread tools/check-doc-links.sh
Comment on lines +38 to +41
if [[ ! -e "${dir}/${target}" ]]; then
printf 'broken link: %s -> %s\n' "${file}" "${target}" >&2
status=1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are two issues with the path resolution here:

  1. URL-Encoded Links: If a relative link contains percent-encoded characters (e.g., %20 for spaces), -e will fail because it looks for the literal %20 on the filesystem instead of the decoded space.
  2. 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.md instead of ./some-file.md).

We can resolve both issues by:

  • Decoding percent-encoded characters using a fast, pure-bash printf expansion.
  • Checking if the decoded target starts with / and resolving it relative to the repository root (.) instead of ${dir}.
Suggested change
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

Comment thread tools/check-doc-links.sh
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/')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/')
done < <(grep -o '\[[^][]*\]([^() ]*)' "${file}" | sed 's/.*(\(.*\))/\1/' || true)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant