diff --git a/.github/workflows/check-man-nav.yml b/.github/workflows/check-man-nav.yml new file mode 100644 index 0000000..7abd503 --- /dev/null +++ b/.github/workflows/check-man-nav.yml @@ -0,0 +1,22 @@ +name: Check man page nav coverage + +# docs.json nav entries are maintained by hand while man/*.mdx is synced +# from flox/flox, so a new command's page can ship without a sidebar link. +# Fail PRs that add a man page (or touch the nav) without a docs.json entry. +on: + pull_request: + paths: + - 'man/**' + - 'docs.json' + - 'scripts/check-man-nav.sh' + workflow_dispatch: + +jobs: + check-man-nav: + runs-on: ubuntu-latest + steps: + - name: Checkout docs + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Check nav coverage + run: ./scripts/check-man-nav.sh diff --git a/AGENTS.md b/AGENTS.md index f510279..bae4bda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,10 @@ Everything under `man/` is generated from the man page sources in the The script requires `pandoc` (e.g. `flox install pandoc`). - New pages also need a navigation entry in `docs.json` under the - "CLI reference" tab. + "CLI reference" tab. `scripts/check-man-nav.sh` enforces this (it runs + as a warning during the sync and as a failing check on PRs that touch + `man/` or `docs.json`); pages deliberately left out of the sidebar go + in that script's `ALLOWLIST`. ## Content boundaries diff --git a/scripts/check-man-nav.sh b/scripts/check-man-nav.sh new file mode 100755 index 0000000..9a9202d --- /dev/null +++ b/scripts/check-man-nav.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# check-man-nav.sh — verify every synced man page has a nav entry in docs.json +# +# The man-page sync (scripts/sync-man-pages.sh) writes man/*.mdx, but nav +# entries in docs.json are maintained by hand, so a newly added command can +# ship live-but-invisible: reachable by URL and search, missing from the +# sidebar (this happened with flox-run and flox-deactivate). +# +# Fails when a page under man/ is not referenced anywhere in the docs.json +# `navigation` tree, unless it is deliberately excluded via ALLOWLIST below. +# Also warns when an ALLOWLIST entry is stale (file gone, or page now in nav). +# +# Usage: +# ./scripts/check-man-nav.sh [docs-root] # defaults to the repo root +# +# Requires: python3 (for JSON parsing) + +set -euo pipefail + +# Pages deliberately excluded from the sidebar. Keep in sync with curation +# decisions — e.g. c2f6538 removed the auto-activation pages from the nav +# because concepts/auto-activation covers that material. +ALLOWLIST=( + flox-activate-allow + flox-activate-deny +) + +docs_root="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +docs_json="$docs_root/docs.json" +man_dir="$docs_root/man" + +[ -f "$docs_json" ] || { echo "error: docs.json not found: $docs_json" >&2; exit 1; } +[ -d "$man_dir" ] || { echo "error: man dir not found: $man_dir" >&2; exit 1; } +command -v python3 > /dev/null || { echo "error: python3 not found on PATH" >&2; exit 1; } + +# Every string in the docs.json `navigation` tree. Group/tab labels come +# along too, but they can't collide with "man/" page paths. +nav_pages="$(python3 - "$docs_json" <<'EOF' +import json, sys + +def walk(node, out): + if isinstance(node, dict): + for value in node.values(): + walk(value, out) + elif isinstance(node, list): + for value in node: + walk(value, out) + elif isinstance(node, str): + out.append(node) + +with open(sys.argv[1]) as f: + data = json.load(f) +pages = [] +walk(data.get("navigation", {}), pages) +print("\n".join(pages)) +EOF +)" + +in_nav() { grep -Fxq "man/$1" <<< "$nav_pages"; } + +allowlisted() { + local name entry + name="$1" + for entry in "${ALLOWLIST[@]}"; do + [ "$entry" = "$name" ] && return 0 + done + return 1 +} + +missing=() +for page in "$man_dir"/*.mdx; do + name="$(basename "$page" .mdx)" + allowlisted "$name" && continue + in_nav "$name" || missing+=("$name") +done + +# Stale allowlist entries are warnings, not failures +for entry in "${ALLOWLIST[@]}"; do + if [ ! -f "$man_dir/$entry.mdx" ]; then + echo "warning: allowlist entry '$entry' has no man/$entry.mdx (remove it from ALLOWLIST in $0)" >&2 + elif in_nav "$entry"; then + echo "warning: allowlist entry '$entry' is in the docs.json nav (remove it from ALLOWLIST in $0)" >&2 + fi +done + +if [ "${#missing[@]}" -gt 0 ]; then + echo "error: man pages with no docs.json nav entry:" >&2 + for name in "${missing[@]}"; do + echo " man/$name" >&2 + done + echo >&2 + echo "Add each page to the \"CLI reference\" tab in docs.json (see AGENTS.md)," >&2 + echo "or add it to ALLOWLIST in $0 if it is deliberately unlisted." >&2 + exit 1 +fi + +echo "ok: all man pages are in the docs.json nav (${#ALLOWLIST[@]} deliberately excluded)" diff --git a/scripts/sync-man-pages.sh b/scripts/sync-man-pages.sh index a7d74c8..e61bb85 100755 --- a/scripts/sync-man-pages.sh +++ b/scripts/sync-man-pages.sh @@ -88,6 +88,14 @@ sync_man_pages() { echo "Run 'mint broken-links' from the docs repo root to validate rendering." >&2 fi + # Nav coverage: docs.json is hand-curated, so a newly synced page has no + # sidebar entry until someone adds one (see AGENTS.md). Warn here so local + # syncs surface it; the check-man-nav workflow is the hard gate on PRs. + if [ -f "$docs_root/docs.json" ]; then + "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-man-nav.sh" "$docs_root" \ + || echo "warning: add the pages above to the docs.json nav before merging." >&2 + fi + echo echo "synced $(find "$docs_man_pages_dir" -name '*.mdx' | wc -l | tr -d ' ') pages to $docs_man_pages_dir" }