Skip to content

Commit b66ed14

Browse files
author
Test
committed
test(ac00-00b6): add link-check script for README.md and INSTALL.md (merge worktree-20260416-144914)
2 parents c100ca8 + 024e979 commit b66ed14

File tree

7 files changed

+403
-5
lines changed

7 files changed

+403
-5
lines changed

.test-index

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,4 @@ plugins/dso/README.md:tests/skills/test-nextjs-starter-docs.sh
180180
CLAUDE.md:tests/skills/test-nextjs-starter-docs.sh
181181
plugins/dso/docs/designs/dso-nextjs-starter-plugin-install.md:tests/scripts/test-dso-nextjs-starter-plugin-install.sh [test_plugin_consent_doc_has_required_sections]
182182
plugins/dso/scripts/create-dso-app.sh:tests/scripts/test-create-dso-app.sh
183+
INSTALL.md:tests/test-install-doc-contract.sh [test_install_doc_contract]

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Priority: 0-4 (0=critical, 4=backlog). Never use "high"/"medium"/"low".
5454
**architect-foundation guided mode** (`/dso:architect-foundation`): Reads `project-understanding.md` to generate enforcement artifacts (`ARCH_ENFORCEMENT.md`, `docs/adr/`). Key behaviors: `--auto` flag skips Phase 1 Socratic dialogue and selects recommended defaults; Phase 2.75 batches all artifact confirmations into a single user prompt instead of per-file confirmations; Phase 2.8 always generates ADRs for session decisions without asking; Phase 2.9 re-run idempotency uses append-only merge so existing enforcement rules are never overwritten.
5555
**Validation gate**: `validate.sh` writes state; hooks block sprint/epic if validation hasn't passed. `--verbose` for real-time progress.
5656
**Pre-commit hooks** (self-enforcing — print errors with fix instructions): `check-portability.sh` (hardcoded paths; suppress: `# portability-ok`), `check-shim-refs.sh` (direct plugin script refs; suppress: `# shim-exempt: <reason>`; use `.claude/scripts/dso <script-name>` shim instead), `check-contract-schemas.sh` (contract markdown structure), `check-referential-integrity.sh` (dead path references in instruction files), `check-plugin-self-ref.sh` (blocks all `plugins/dso/` literal paths in plugin scripts — no suppression annotation exists; use `_PLUGIN_ROOT` / `_PLUGIN_GIT_PATH` instead).
57-
**Agent routing**: `discover-agents.sh` resolves routing categories to agents via `agent-routing.conf`; all fall back to `general-purpose`. See `plugins/dso/docs/INSTALL.md`. **Named-agent dispatch** (via `subagent_type`, defined in `plugins/dso/agents/`):
57+
**Agent routing**: `discover-agents.sh` resolves routing categories to agents via `agent-routing.conf`; all fall back to `general-purpose`. See `INSTALL.md`. **Named-agent dispatch** (via `subagent_type`, defined in `plugins/dso/agents/`):
5858

5959
| Agent | Model | Dispatched by |
6060
|-------|-------|---------------|
@@ -173,7 +173,7 @@ These rules protect core structural boundaries. Violating them causes subtle bug
173173

174174
**Worktree session setup**: See `plugins/dso/docs/WORKTREE-GUIDE.md` (Session Setup section).
175175

176-
**If `dso` command not found (98ff-99f5)**: The shim lives at `.claude/scripts/dso` in the repo root. If that path does not exist, do NOT use the plugin cache path (`~/.claude/plugins/...`). Run `ls .claude/scripts/dso` to verify; if missing, check `plugins/dso/docs/INSTALL.md` for shim installation steps. The `.tickets-tracker/` directory must also be present (checkout the `tickets` orphan branch: `git worktree add .tickets-tracker tickets 2>/dev/null || git checkout tickets -- . 2>/dev/null || true`).
176+
**If `dso` command not found (98ff-99f5)**: The shim lives at `.claude/scripts/dso` in the repo root. If that path does not exist, do NOT use the plugin cache path (`~/.claude/plugins/...`). Run `ls .claude/scripts/dso` to verify; if missing, check `INSTALL.md` for shim installation steps. The `.tickets-tracker/` directory must also be present (checkout the `tickets` orphan branch: `git worktree add .tickets-tracker tickets 2>/dev/null || git checkout tickets -- . 2>/dev/null || true`).
177177

178178
**Primary tickets**: Use `/dso:sprint` — it runs `plugins/dso/scripts/validate.sh --ci` automatically and blocks until the codebase is healthy.
179179
**Bug fixes**: Use `/dso:fix-bug` — TDD-based; investigates before fixing.

docs/adr/0007-stack-aware-architect-foundation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Three blocks are provided: Python (`requirements.txt` / `pyproject.toml` → `ac
4242

4343
### INSTALL.md Restructure
4444

45-
Restructure `plugins/dso/docs/INSTALL.md` into Required (universal) and Optional-by-Stack sections so that engineers only read the stack sections relevant to their project. This separates prerequisite noise for Python-only teams from Ruby or Rust teams and vice versa.
45+
Restructure `INSTALL.md` (at repo root) into Required (universal) and Optional-by-Stack sections so that engineers only read the stack sections relevant to their project. This separates prerequisite noise for Python-only teams from Ruby or Rust teams and vice versa.
4646

4747
---
4848

plugins/dso/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dso",
3-
"version": "1.12.28",
3+
"version": "1.12.29",
44
"description": "Workflow infrastructure plugin for Claude Code projects",
55
"commands": "./commands/",
66
"skills": "./skills/",

plugins/dso/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ To install the nextjs-starter scaffolding, run the `create-dso-app.sh` installer
1919

2020
## Documentation
2121

22-
- **Installation**: See `docs/INSTALL.md`
22+
- **Installation**: See `INSTALL.md`
2323
- **Configuration**: See `docs/CONFIGURATION-REFERENCE.md`
2424
- **Worktree Guide**: See `docs/WORKTREE-GUIDE.md`
2525
- **Ticket CLI Reference**: See `docs/ticket-cli-reference.md`

tests/test-doc-links.sh

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env bash
2+
# tests/test-doc-links.sh
3+
# Automated link-check script that scans README.md and the current INSTALL.md
4+
# (currently plugins/dso/docs/INSTALL.md; task 24a0-4c55 will migrate to root INSTALL.md
5+
# once task 93d2-cd41 creates it and task fe56-8919 removes the old path).
6+
# for broken hyperlinks (markdown links and bare URLs).
7+
#
8+
# Checks:
9+
# - External URLs (http/https): HEAD request, accept HTTP < 400; fail on 5xx or connection error
10+
# - Internal relative paths: file must exist in repo
11+
# - Anchor fragments (#section): target file must contain a matching heading
12+
#
13+
# OPT-OUT LIST — add volatile/rate-limited URLs here (one per line, no trailing slash variation):
14+
# Format: OPT_OUT_URLS+=("https://example.com/volatile-url")
15+
# ---- BEGIN OPT-OUT ----
16+
OPT_OUT_URLS=(
17+
# Returns 403 on HEAD requests (CDN/WAF blocks non-browser requests); URL is valid
18+
"https://acli.atlassian.com"
19+
)
20+
# ---- END OPT-OUT ----
21+
22+
set -euo pipefail
23+
24+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25+
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
26+
27+
FAILURES=0
28+
TESTS=0
29+
30+
pass() { TESTS=$((TESTS + 1)); echo " PASS: $1"; }
31+
fail() { TESTS=$((TESTS + 1)); FAILURES=$((FAILURES + 1)); echo " FAIL: $1"; }
32+
33+
# Files to scan (relative to REPO_ROOT)
34+
SCAN_FILES=(
35+
"README.md"
36+
"plugins/dso/docs/INSTALL.md"
37+
)
38+
39+
# Check if a URL is in the opt-out list
40+
_is_opted_out() {
41+
local url="$1"
42+
for opt_url in "${OPT_OUT_URLS[@]:-}"; do
43+
if [[ "$url" == "$opt_url" ]]; then
44+
return 0
45+
fi
46+
done
47+
return 1
48+
}
49+
50+
# Extract markdown links [text](url) and bare http/https URLs from a file
51+
_extract_links() {
52+
local file="$1"
53+
# Markdown links: [text](url) — capture the URL part
54+
grep -oE '\[([^]]*)\]\(([^)]+)\)' "$file" | sed 's/\[.*\](\(.*\))/\1/' || true
55+
# Bare URLs not already in markdown link syntax
56+
# Exclude chars that typically end a URL: ) space > " ` | , ; backtick
57+
grep -oE '(^|[^(])(https?://[^)[:space:]>"`|,;]+)' "$file" | grep -oE 'https?://[^)[:space:]>"`|,;]+' || true
58+
}
59+
60+
# Check an external URL via HEAD request
61+
_check_external_url() {
62+
local url="$1"
63+
# Strip trailing punctuation that may have been captured
64+
url="${url%[.,;:\"\'!]}"
65+
66+
if _is_opted_out "$url"; then
67+
echo " SKIP (opt-out): $url"
68+
return 0
69+
fi
70+
71+
local http_code
72+
http_code=$(curl -s -o /dev/null -w "%{http_code}" -I --max-time 10 --location "$url" 2>/dev/null || echo "000")
73+
# Normalize: strip non-numeric chars (handles edge cases where curl appends extra output)
74+
http_code=$(echo "$http_code" | grep -oE '^[0-9]+' | head -1 || echo "0")
75+
http_code="${http_code:-0}"
76+
77+
if [[ "$http_code" -le 0 ]]; then
78+
fail "Connection error for external URL: $url"
79+
return 1
80+
elif [[ "$http_code" -ge 500 ]]; then
81+
fail "Server error ($http_code) for external URL: $url"
82+
return 1
83+
elif [[ "$http_code" -ge 400 ]]; then
84+
fail "Client error ($http_code) for external URL: $url"
85+
return 1
86+
else
87+
pass "External URL OK ($http_code): $url"
88+
return 0
89+
fi
90+
}
91+
92+
# Check an internal path (optionally with #anchor)
93+
_check_internal_path() {
94+
local raw_path="$1"
95+
local base_file="$2"
96+
97+
# Split on '#' for anchor
98+
local path_part anchor_part
99+
path_part="${raw_path%%#*}"
100+
if [[ "$raw_path" == *#* ]]; then
101+
anchor_part="${raw_path#*#}"
102+
else
103+
anchor_part=""
104+
fi
105+
106+
# Resolve path relative to the file's directory, then to repo root
107+
local resolved
108+
if [[ -z "$path_part" ]]; then
109+
# Pure anchor — check within the same file
110+
resolved="$base_file"
111+
elif [[ "$path_part" == /* ]]; then
112+
# Absolute path from repo root
113+
resolved="$REPO_ROOT$path_part"
114+
else
115+
# Relative to the file's directory
116+
resolved="$(dirname "$base_file")/$path_part"
117+
fi
118+
119+
# Normalize (remove /./ and /../ sequences)
120+
if command -v realpath >/dev/null 2>&1; then
121+
resolved="$(realpath -m "$resolved" 2>/dev/null || echo "$resolved")"
122+
fi
123+
124+
# Check file existence (skip if path_part is empty — pure anchor on same file)
125+
if [[ -n "$path_part" ]]; then
126+
if [[ ! -e "$resolved" ]]; then
127+
fail "Internal path not found: $raw_path (resolved: $resolved) [in $base_file]"
128+
return 1
129+
fi
130+
fi
131+
132+
# Check anchor if present
133+
if [[ -n "$anchor_part" ]]; then
134+
if [[ ! -f "$resolved" ]]; then
135+
fail "Anchor target file not found for #$anchor_part: $resolved [in $base_file]"
136+
return 1
137+
fi
138+
# GitHub-style anchor: lowercase, spaces→dashes, strip punctuation
139+
local normalized_anchor
140+
normalized_anchor=$(echo "$anchor_part" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | sed 's/--*/-/g' | sed 's/^-//; s/-$//' || true)
141+
142+
# Search for heading in the target file (# Heading, ## Heading, etc.)
143+
local found=false
144+
while IFS= read -r heading_line; do
145+
# Strip leading # characters and spaces
146+
local heading_text
147+
heading_text=$(echo "$heading_line" | sed 's/^#* *//' | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | sed 's/--*/-/g' | sed 's/^-//; s/-$//' || true)
148+
if [[ "$heading_text" == "$normalized_anchor" ]]; then
149+
found=true
150+
break
151+
fi
152+
done < <(grep -E '^#{1,6} ' "$resolved" || true)
153+
154+
if ! $found; then
155+
fail "Anchor #$anchor_part not found in $resolved [in $base_file]"
156+
return 1
157+
fi
158+
fi
159+
160+
pass "Internal path OK: $raw_path [in $base_file]"
161+
return 0
162+
}
163+
164+
echo "=== Doc Link Checker ==="
165+
echo "Scanning: ${SCAN_FILES[*]}"
166+
echo ""
167+
168+
# Deduplicate URLs to avoid redundant external checks
169+
declare -A seen_external=()
170+
171+
for rel_file in "${SCAN_FILES[@]}"; do
172+
full_path="$REPO_ROOT/$rel_file"
173+
174+
if [[ ! -f "$full_path" ]]; then
175+
fail "Source file not found: $rel_file"
176+
continue
177+
fi
178+
179+
echo "--- Checking links in: $rel_file ---"
180+
181+
# Collect all links from the file
182+
links=$(_extract_links "$full_path")
183+
184+
if [[ -z "$links" ]]; then
185+
echo " (no links found)"
186+
continue
187+
fi
188+
189+
while IFS= read -r link; do
190+
[[ -z "$link" ]] && continue
191+
192+
# Strip trailing punctuation
193+
link="${link%[.,;:\"\'!]}"
194+
[[ -z "$link" ]] && continue
195+
196+
if [[ "$link" == http://* ]] || [[ "$link" == https://* ]]; then
197+
# External URL
198+
if [[ -z "${seen_external[$link]+_}" ]]; then
199+
seen_external[$link]=1
200+
_check_external_url "$link" || true
201+
sleep 1
202+
else
203+
echo " DEDUP (already checked): $link"
204+
fi
205+
elif [[ "$link" == mailto:* ]]; then
206+
# Skip mailto links
207+
echo " SKIP (mailto): $link"
208+
elif [[ "$link" == "#"* ]]; then
209+
# Pure anchor on the same file
210+
_check_internal_path "$link" "$full_path" || true
211+
else
212+
# Internal relative path (with or without anchor)
213+
_check_internal_path "$link" "$full_path" || true
214+
fi
215+
done <<< "$links"
216+
217+
echo ""
218+
done
219+
220+
echo "=== Results: $((TESTS - FAILURES))/$TESTS passed ==="
221+
if (( FAILURES > 0 )); then
222+
echo "FAILED: $FAILURES link(s) broken"
223+
exit 1
224+
else
225+
echo "All links OK."
226+
exit 0
227+
fi

0 commit comments

Comments
 (0)