From 5b8c8efaaa2efaddda6c480995fdadca9ec80fe3 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:10:50 -0500 Subject: [PATCH 1/9] docs: add design spec for heading anchor link updates When the vale-autofix workflow changes headings, anchor links referencing the old slug can break the Docusaurus build. This spec describes the approach for automatically updating those links across all three phases. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...4-01-heading-anchor-link-updates-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md diff --git a/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md b/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md new file mode 100644 index 0000000000..4502d84b24 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md @@ -0,0 +1,133 @@ +# Heading Anchor Link Updates — Design Spec + +## Problem + +The vale-autofix workflow (Phase 1 script, Phase 2 Claude, Phase 3 Dale) can modify heading text in markdown files. When heading text changes, the Docusaurus-generated anchor slug changes too. Any links referencing the old anchor — within the same file or elsewhere in the product/version folder — silently break. Because Docusaurus is configured with `onBrokenAnchors: 'throw'`, this causes build failures. + +## Goal + +Whenever the auto-fix process changes a heading, automatically find and update all anchor links referencing the old heading slug within the same product/version folder. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Which phases | All three | Phase 1 makes the most heading changes (contractions, filler removal); AI phases also rewrite headings | +| Search scope | Product/version folder | Cross-product links are rare; same-file-only would miss common inter-page links | +| Include non-PR files | Yes | A heading change in `install.md` can break a link in `overview.md` even if `overview.md` wasn't in the PR | +| Slug generation (Phase 1) | Inline bash function | Simple enough for tr/sed; consistency between old and new slug matters more than exact Docusaurus parity | +| AI prompt detail level | Detailed step-by-step | CI Claude has no conversation history; explicit instructions reduce missed links | + +## Architecture + +### Phase 1: Bash script changes (`scripts/vale-autofix.sh`) + +#### `slugify()` function + +Converts a markdown heading line to a Docusaurus-style anchor slug. + +Algorithm: +1. Check for custom anchor ID suffix `{#custom-id}` — if present, extract and return `custom-id` +2. Strip markdown heading prefix (`##`, `###`, etc.) and leading/trailing whitespace +3. Lowercase +4. Strip everything except `[a-z0-9 -]` +5. Replace spaces with hyphens +6. Collapse consecutive hyphens into one +7. Trim leading/trailing hyphens + +Examples: +- `## Don't Click Here` → `dont-click-here` +- `### Setup the Application {#setup}` → `setup` +- `## Step 1: Install the Agent` → `step-1-install-the-agent` + +#### `update_heading_anchors()` function + +Runs after Phase 1 commits. Detects heading changes and updates anchor links. + +Steps: +1. Run `git diff HEAD~1 HEAD -- '*.md'` to get the unified diff +2. Parse the diff to find changed heading lines — lines starting with `#` (markdown headings) that appear in both `-` and `+` hunks within the same hunk block +3. Pair old (`-`) and new (`+`) headings positionally within each hunk +4. For each pair, compute old and new slugs using `slugify()` +5. If old slug equals new slug, skip (heading text changed but slug didn't) +6. Determine the product/version folder from the file path: + - Try `docs///` first (multi-version products) + - Fall back to `docs//` (single-version/SaaS products) +7. Search all `.md` files in that folder for these patterns: + - `](#old-slug)` — same-page anchor links + - `](filename#old-slug)` — relative file links with anchor + - `](path/to/filename#old-slug)` — deeper relative links +8. Replace `#old-slug` with `#new-slug` using `sed -i` +9. If any replacements were made, stage and commit: `fix(vale): update anchor links for changed headings` + +#### Product/version folder detection + +Given a file path like `docs/accessanalyzer/12.0/install/setup.md`: +- Split on `/` after `docs/` +- If the second segment matches a version pattern (digits, dots, underscores), folder is `docs///` +- Otherwise, folder is `docs//` + +### Phase 2: Claude prompt additions (`vale-autofix.yml`) + +The following instructions are appended to the existing Phase 2 prompt: + +> **Heading anchor updates:** When you modify a heading line (any line starting with `#`), you MUST update all anchor links that reference the old heading. +> +> 1. Record the original heading text before editing +> 2. Compute the old anchor slug: strip the `#` prefix and whitespace, lowercase, remove everything except `[a-z0-9 -]`, replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. If the heading has a `{#custom-id}` suffix, use `custom-id` as the slug instead. +> 3. After editing, compute the new anchor slug the same way +> 4. If the slug changed, determine the product/version folder from the file path: +> - Multi-version: `docs///` (e.g., `docs/accessanalyzer/12.0/`) +> - Single-version: `docs//` (e.g., `docs/threatprevention/`) +> 5. Search ALL `.md` files in that folder (not just PR-changed files) for these link patterns: +> - `](#old-slug)` — same-page links +> - `](filename#old-slug)` — relative links +> - `](path/to/filename#old-slug)` — deeper relative links +> 6. Replace `#old-slug` with `#new-slug` in all matches +> 7. Include each anchor update in the `fixed` array of your summary JSON, using the same `check` value as the heading fix that caused it, with action describing the anchor change (e.g., `"updated anchor link from #do-not-use to #dont-use"`) + +### Phase 3: Dale prompt additions (`vale-autofix.yml`) + +Identical instructions to Phase 2, with the only difference being the summary format uses `"rule"` instead of `"check"`: + +> 7. Include each anchor update in the `fixed` array of your summary JSON, using the same `rule` value as the heading fix that caused it, with action describing the anchor change (e.g., `"updated anchor link from #do-not-use to #dont-use"`) + +### Workflow changes (`vale-autofix.yml`) + +One new step added after "Commit Phase 1 fixes": + +```yaml +- name: Update heading anchors (Phase 1) + if: steps.phase1-commit.outputs.committed == 'true' + run: | + # Source the function from vale-autofix.sh + source scripts/vale-autofix.sh --anchors-only + # Or: a dedicated call that runs update_heading_anchors +``` + +Implementation note: The `update_heading_anchors` function needs to be callable standalone from the workflow. Options: export it from `vale-autofix.sh` behind a flag, or extract to a separate callable section at the end of the script. The implementation plan will determine the cleanest approach. + +## Edge Cases + +| Case | Handling | +|------|----------| +| Custom anchor IDs (`{#custom-id}`) | `slugify()` detects `{#...}` suffix and returns the custom ID. Both old and new headings checked. | +| Single-version (SaaS) products | Folder detection falls back to `docs//` when no version segment found | +| Duplicate headings | Docusaurus appends `-1`, `-2` for duplicates. Phase 1 does best-effort exact slug match; Phases 2/3 Claude can use judgment | +| Slug unchanged after heading fix | Skip anchor update (e.g., removing "please" from `## Please See Overview` changes text but slug `please-see-overview` → `see-overview` does change; however `## The Setup` → `## The Set Up` both slug to `the-set-up` — no update needed) | +| No headings changed | Anchor-update step is a no-op | +| Heading deleted entirely | Not an auto-fix scenario — Vale/Dale fix text, they don't delete headings | +| Links in code blocks | Phase 1 sed operates on all matches; risk is low since anchor patterns inside fenced code blocks are rare. Phases 2/3 Claude can use judgment to skip code blocks. | + +## Files Modified + +| File | Change | +|------|--------| +| `scripts/vale-autofix.sh` | Add `slugify()` and `update_heading_anchors()` functions | +| `.github/workflows/vale-autofix.yml` | Add anchor-update step after Phase 1 commit; update Phase 2 and Phase 3 prompts | + +## Out of Scope + +- Updating anchors in files outside the product/version folder +- Handling anchor changes caused by manual edits (outside the auto-fix workflow) +- Updating Docusaurus sidebar or navbar references (these don't use heading anchors) From b08107e9578f3f38e3706f3300ad0c17d944205c Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:14:42 -0500 Subject: [PATCH 2/9] docs: add implementation plan for heading anchor link updates 6-task plan covering slugify function, anchor update logic, workflow step, Phase 2/3 prompt updates, and end-to-end verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-01-heading-anchor-link-updates.md | 609 ++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md diff --git a/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md b/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md new file mode 100644 index 0000000000..afa1458fdb --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md @@ -0,0 +1,609 @@ +# Heading Anchor Link Updates — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically update anchor links when the vale-autofix workflow changes heading text, preventing broken builds from `onBrokenAnchors: 'throw'`. + +**Architecture:** Add a `slugify()` and `update_heading_anchors()` function to `scripts/vale-autofix.sh`, called from a new workflow step after Phase 1 commits. Update Phase 2 and Phase 3 Claude prompts with detailed anchor-update instructions. + +**Tech Stack:** Bash (sed, tr, awk, git diff), GitHub Actions YAML + +**Spec:** `docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `scripts/vale-autofix.sh` | Modify | Add `slugify()` and `update_heading_anchors()` functions at top of script; add `--anchors-only` entry point | +| `.github/workflows/vale-autofix.yml` | Modify | Add anchor-update step after Phase 1 commit; append anchor instructions to Phase 2 and Phase 3 prompts | +| `scripts/test-slugify.sh` | Create | Test script for `slugify()` function | +| `scripts/test-anchor-update.sh` | Create | Integration test for `update_heading_anchors()` using a temp git repo | + +--- + +### Task 1: Add `slugify()` function to `vale-autofix.sh` + +**Files:** +- Modify: `scripts/vale-autofix.sh:1-6` (add function before existing code) +- Create: `scripts/test-slugify.sh` + +- [ ] **Step 1: Write the test script for `slugify()`** + +Create `scripts/test-slugify.sh`: + +```bash +#!/usr/bin/env bash +# test-slugify.sh — unit tests for the slugify function in vale-autofix.sh +set -euo pipefail + +# Source just the functions (--test mode skips the main script logic) +source "$(dirname "$0")/vale-autofix.sh" --test + +PASS=0 +FAIL=0 + +assert_slug() { + local input="$1" + local expected="$2" + local actual + actual=$(slugify "$input") + if [ "$actual" = "$expected" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: slugify '$input'" + echo " expected: '$expected'" + echo " actual: '$actual'" + fi +} + +# Basic headings +assert_slug "## Hello World" "hello-world" +assert_slug "### Step 1: Install the Agent" "step-1-install-the-agent" +assert_slug "# Overview" "overview" + +# Contractions (apostrophes stripped) +assert_slug "## Don't Click Here" "dont-click-here" +assert_slug "## Can't Stop Won't Stop" "cant-stop-wont-stop" + +# Punctuation stripped +assert_slug "## What is This?" "what-is-this" +assert_slug "## Install (Optional)" "install-optional" +assert_slug "## Step 1. Configure" "step-1-configure" + +# Custom anchor IDs +assert_slug '## Setup the Application {#setup}' "setup" +assert_slug '### Advanced Options {#advanced-opts}' "advanced-opts" + +# Extra whitespace and hyphens +assert_slug "## Lots of Spaces" "lots-of-spaces" +assert_slug "## Already-Hyphenated--Word" "already-hyphenated--word" + +# Edge cases +assert_slug "## 123 Numbers First" "123-numbers-first" +assert_slug "## ALL CAPS HEADING" "all-caps-heading" +assert_slug '## Quotes "and" Stuff' "quotes-and-stuff" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bash scripts/test-slugify.sh` +Expected: FAIL — `slugify` function not defined (source will fail or function won't exist) + +- [ ] **Step 3: Add `slugify()` function and `--test`/`--anchors-only` entry point to `vale-autofix.sh`** + +Add the following at the **top** of `scripts/vale-autofix.sh`, right after the `set -euo pipefail` line (line 6) and before the `VIOLATIONS_FILE=` line (line 8): + +```bash +# --- Shared functions --- + +slugify() { + local heading="$1" + + # Check for custom anchor ID: {#custom-id} + if [[ "$heading" =~ \{#([a-zA-Z0-9_-]+)\} ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + + echo "$heading" \ + | sed -E 's/^#+ +//' \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E "s/[^a-z0-9 -]//g" \ + | sed -E 's/ +/-/g' \ + | sed -E 's/-+/-/g' \ + | sed -E 's/^-+//;s/-+$//' +} + +# Allow sourcing for tests or running anchor-update only +case "${1:-}" in + --test) + # Sourced for testing — define functions but skip main script logic + return 0 2>/dev/null || exit 0 + ;; + --anchors-only) + # Will be handled after update_heading_anchors is defined (Task 2) + ;; +esac + +# --- Main autofix logic --- +``` + +**Important:** The `slugify()` function (and later `update_heading_anchors()` in Task 2) must be defined **before** the `case` block so they're available in all modes. The `--test` early return skips only the main autofix logic below. + +Then move the existing `VIOLATIONS_FILE=` line to after the `# --- Main autofix logic ---` comment. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `bash scripts/test-slugify.sh` +Expected: All assertions PASS + +- [ ] **Step 5: Commit** + +```bash +git add scripts/vale-autofix.sh scripts/test-slugify.sh +git commit -m "feat: add slugify function to vale-autofix.sh with tests" +``` + +--- + +### Task 2: Add `update_heading_anchors()` function to `vale-autofix.sh` + +**Files:** +- Modify: `scripts/vale-autofix.sh` (add function after `slugify()`, before entry point `case`) +- Create: `scripts/test-anchor-update.sh` + +- [ ] **Step 1: Write the integration test** + +Create `scripts/test-anchor-update.sh`: + +```bash +#!/usr/bin/env bash +# test-anchor-update.sh — integration test for update_heading_anchors() +# Creates a temp git repo, makes a heading change, and verifies anchor links update +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "Setting up test repo in $TMPDIR..." + +cd "$TMPDIR" +git init -q +git config user.name "test" +git config user.email "test@test.com" + +# Create a product/version folder structure +mkdir -p docs/testproduct/1.0/install +mkdir -p docs/testproduct/1.0/admin + +# File with a heading that will change +cat > docs/testproduct/1.0/install/setup.md << 'MDEOF' +# Install the Product + +## Do Not Use the Old Method + +Follow these steps instead. + +## Configure Settings + +See the configuration guide. +MDEOF + +# File with links to the heading above +cat > docs/testproduct/1.0/admin/guide.md << 'MDEOF' +# Admin Guide + +See [old method](../install/setup.md#do-not-use-the-old-method) for details. + +Also check [configure](../install/setup.md#configure-settings). +MDEOF + +# Same-page link in the same file +cat > docs/testproduct/1.0/install/overview.md << 'MDEOF' +# Overview + +For setup, see [setup instructions](setup.md#do-not-use-the-old-method). +MDEOF + +git add -A +git commit -q -m "initial" + +# Now simulate Phase 1 changing the heading (contractions fix) +sed -i 's/## Do Not Use the Old Method/## Don'\''t Use the Old Method/' docs/testproduct/1.0/install/setup.md + +git add -A +git commit -q -m "fix(vale): auto-fix substitutions and removals" + +# Run the anchor update function +source "$SCRIPT_DIR/vale-autofix.sh" --test +update_heading_anchors + +# Verify: guide.md should have updated anchor +PASS=0 +FAIL=0 + +check_contains() { + local file="$1" + local expected="$2" + local label="$3" + if grep -qF "$expected" "$file"; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: $label" + echo " expected '$expected' in $file" + echo " actual content:" + cat "$file" + fi +} + +check_not_contains() { + local file="$1" + local unexpected="$2" + local label="$3" + if grep -qF "$unexpected" "$file"; then + FAIL=$((FAIL + 1)) + echo "FAIL: $label" + echo " did not expect '$unexpected' in $file" + else + PASS=$((PASS + 1)) + fi +} + +check_contains "docs/testproduct/1.0/admin/guide.md" "#dont-use-the-old-method" "cross-file link updated" +check_not_contains "docs/testproduct/1.0/admin/guide.md" "#do-not-use-the-old-method" "old cross-file link removed" +check_contains "docs/testproduct/1.0/install/overview.md" "#dont-use-the-old-method" "relative link updated" +check_not_contains "docs/testproduct/1.0/install/overview.md" "#do-not-use-the-old-method" "old relative link removed" +check_contains "docs/testproduct/1.0/admin/guide.md" "#configure-settings" "unrelated link unchanged" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bash scripts/test-anchor-update.sh` +Expected: FAIL — `update_heading_anchors` function not defined + +- [ ] **Step 3: Add `update_heading_anchors()` to `vale-autofix.sh`** + +Add this function after `slugify()` and before the `case "${1:-}"` block: + +```bash +update_heading_anchors() { + # Detect heading changes in the most recent commit and update anchor links + # across the product/version folder. + + local diff_output + diff_output=$(git diff HEAD~1 HEAD -- '*.md' 2>/dev/null || true) + + if [ -z "$diff_output" ]; then + return 0 + fi + + local current_file="" + local old_headings=() + local new_headings=() + local in_hunk=0 + local anchor_updates=0 + + while IFS= read -r line; do + # Track which file we're in + if [[ "$line" =~ ^diff\ --git\ a/(.*\.md)\ b/ ]]; then + # Process any pending heading pairs from previous file + _process_heading_pairs "$current_file" old_headings new_headings + anchor_updates=$((anchor_updates + $?)) + current_file="${BASH_REMATCH[1]}" + old_headings=() + new_headings=() + in_hunk=0 + continue + fi + + # Track hunk boundaries to pair headings + if [[ "$line" =~ ^@@ ]]; then + _process_heading_pairs "$current_file" old_headings new_headings + anchor_updates=$((anchor_updates + $?)) + old_headings=() + new_headings=() + in_hunk=1 + continue + fi + + if [ "$in_hunk" -eq 0 ]; then + continue + fi + + # Collect removed headings (lines starting with - then #) + if [[ "$line" =~ ^-#{1,6}\ + ]]; then + old_headings+=("${line:1}") + fi + + # Collect added headings (lines starting with + then #) + if [[ "$line" =~ ^\+#{1,6}\ + ]]; then + new_headings+=("${line:1}") + fi + done <<< "$diff_output" + + # Process final file + _process_heading_pairs "$current_file" old_headings new_headings + anchor_updates=$((anchor_updates + $?)) + + if [ "$anchor_updates" -gt 0 ]; then + echo "Updated $anchor_updates anchor link(s)" + fi + + return 0 +} + +_get_product_version_folder() { + # Given a file path like docs/product/1.0/install/setup.md, + # return docs/product/1.0/ or docs/product/ + local filepath="$1" + + # Strip docs/ prefix + local rest="${filepath#docs/}" + # Get product name (first segment) + local product="${rest%%/*}" + rest="${rest#*/}" + # Get potential version (second segment) + local version="${rest%%/*}" + + # Check if version segment looks like a version (digits, dots, underscores) + if [[ "$version" =~ ^[0-9][0-9._]*$ ]]; then + echo "docs/${product}/${version}/" + else + echo "docs/${product}/" + fi +} + +_process_heading_pairs() { + local file="$1" + local -n _old="$2" + local -n _new="$3" + local updates=0 + + if [ -z "$file" ] || [ ${#_old[@]} -eq 0 ] || [ ${#_new[@]} -eq 0 ]; then + return 0 + fi + + # Pair old and new headings positionally + local count=${#_old[@]} + if [ ${#_new[@]} -lt "$count" ]; then + count=${#_new[@]} + fi + + local folder + folder=$(_get_product_version_folder "$file") + + if [ ! -d "$folder" ]; then + return 0 + fi + + for ((i = 0; i < count; i++)); do + local old_slug new_slug + old_slug=$(slugify "${_old[$i]}") + new_slug=$(slugify "${_new[$i]}") + + if [ "$old_slug" = "$new_slug" ] || [ -z "$old_slug" ] || [ -z "$new_slug" ]; then + continue + fi + + # Replace #old-slug with #new-slug in all .md files in the folder + # Match patterns: ](#old-slug), (filename#old-slug), (path/filename#old-slug) + find "$folder" -name '*.md' -exec \ + sed -i "s|#${old_slug})|#${new_slug})|g" {} + + + updates=$((updates + 1)) + done + + return "$updates" +} +``` + +Also update the `--anchors-only` case in the entry point block: + +```bash + --anchors-only) + update_heading_anchors + exit 0 + ;; +``` + +- [ ] **Step 4: Run the integration test to verify it passes** + +Run: `bash scripts/test-anchor-update.sh` +Expected: All assertions PASS + +- [ ] **Step 5: Run the slugify tests to make sure nothing broke** + +Run: `bash scripts/test-slugify.sh` +Expected: All assertions PASS + +- [ ] **Step 6: Commit** + +```bash +git add scripts/vale-autofix.sh scripts/test-anchor-update.sh +git commit -m "feat: add update_heading_anchors function with integration tests" +``` + +--- + +### Task 3: Add anchor-update workflow step after Phase 1 commit + +**Files:** +- Modify: `.github/workflows/vale-autofix.yml:112-123` (insert new step after "Commit Phase 1 fixes") + +- [ ] **Step 1: Add the new workflow step** + +In `.github/workflows/vale-autofix.yml`, insert the following new step immediately after the "Commit Phase 1 fixes" step (after line 122) and before the "Re-run Vale for remaining violations" step: + +```yaml + - name: Update heading anchors (Phase 1) + if: steps.phase1-commit.outputs.committed == 'true' + run: | + RESULT=$(bash scripts/vale-autofix.sh --anchors-only 2>&1 || true) + echo "$RESULT" + if ! git diff --quiet; then + git add -A docs/ + git commit -m "fix(vale): update anchor links for changed headings" + fi +``` + +- [ ] **Step 2: Verify YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/vale-autofix.yml +git commit -m "ci: add heading anchor update step after Phase 1 fixes" +``` + +--- + +### Task 4: Update Phase 2 prompt with anchor-update instructions + +**Files:** +- Modify: `.github/workflows/vale-autofix.yml:158-193` (Phase 2 prompt block) + +- [ ] **Step 1: Append anchor-update instructions to Phase 2 prompt** + +In `.github/workflows/vale-autofix.yml`, find the Phase 2 prompt section. Insert the following text after the existing instruction #3 ("If you are NOT confident...SKIP it") and before the "After fixing, write a JSON summary" paragraph: + +``` + HEADING ANCHOR UPDATES: + When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. + + 1. Before editing a heading, record the original heading text + 2. Compute the old anchor slug: + - If the heading has a {#custom-id} suffix, use custom-id as the slug + - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens + - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup + 3. After editing, compute the new anchor slug the same way + 4. If the slug changed, determine the product/version folder from the file path: + - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) + - Single-version: docs// (e.g., docs/threatprevention/) + - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. + 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: + - ](#old-slug) — same-page links + - ](filename#old-slug) — relative links + - ](path/to/filename#old-slug) — deeper relative links + 6. Replace #old-slug with #new-slug in every match + 7. Include each anchor update in the fixed array of your summary JSON, using the same check value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" +``` + +- [ ] **Step 2: Verify YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/vale-autofix.yml +git commit -m "ci: add anchor-update instructions to Phase 2 Claude prompt" +``` + +--- + +### Task 5: Update Phase 3 prompt with anchor-update instructions + +**Files:** +- Modify: `.github/workflows/vale-autofix.yml:206-241` (Phase 3 prompt block) + +- [ ] **Step 1: Append anchor-update instructions to Phase 3 prompt** + +In `.github/workflows/vale-autofix.yml`, find the Phase 3 (Dale) prompt section. Insert the following text after Step 4 ("Fix each violation...SKIP it.") and before Step 5 ("Write a JSON summary"): + +``` + HEADING ANCHOR UPDATES: + When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. + + 1. Before editing a heading, record the original heading text + 2. Compute the old anchor slug: + - If the heading has a {#custom-id} suffix, use custom-id as the slug + - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens + - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup + 3. After editing, compute the new anchor slug the same way + 4. If the slug changed, determine the product/version folder from the file path: + - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) + - Single-version: docs// (e.g., docs/threatprevention/) + - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. + 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: + - ](#old-slug) — same-page links + - ](filename#old-slug) — relative links + - ](path/to/filename#old-slug) — deeper relative links + 6. Replace #old-slug with #new-slug in every match + 7. Include each anchor update in the fixed array of your summary JSON, using the same rule value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" +``` + +Note: This is identical to Phase 2 except step 7 uses `rule` instead of `check`. + +- [ ] **Step 2: Verify YAML syntax** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` +Expected: No output (valid YAML) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/vale-autofix.yml +git commit -m "ci: add anchor-update instructions to Phase 3 Dale prompt" +``` + +--- + +### Task 6: End-to-end manual verification + +**Files:** +- All modified files from Tasks 1-5 + +- [ ] **Step 1: Run all tests** + +```bash +bash scripts/test-slugify.sh && bash scripts/test-anchor-update.sh +``` + +Expected: Both scripts report all tests passing. + +- [ ] **Step 2: Validate workflow YAML** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))" +``` + +Expected: No output (valid YAML) + +- [ ] **Step 3: Verify the autofix script still works in normal mode** + +```bash +echo '[]' > /tmp/test-violations.json +bash scripts/vale-autofix.sh /tmp/test-violations.json +``` + +Expected: Outputs `{"total": 0, "by_category": {}}` — the `--test` and `--anchors-only` modes don't interfere with normal operation. + +- [ ] **Step 4: Review the full diff** + +```bash +git diff dev...HEAD --stat +git diff dev...HEAD +``` + +Review the changes to ensure everything looks correct and no unintended modifications were made. From e9bba00e924084fb566163009e7a7c1d9d8a1d38 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:16:49 -0500 Subject: [PATCH 3/9] feat: add slugify function to vale-autofix.sh with tests Co-Authored-By: Claude Sonnet 4.6 --- scripts/test-slugify.sh | 57 +++++++++++++++++++++++++++++++++++++++++ scripts/vale-autofix.sh | 33 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 scripts/test-slugify.sh diff --git a/scripts/test-slugify.sh b/scripts/test-slugify.sh new file mode 100644 index 0000000000..4697d82a04 --- /dev/null +++ b/scripts/test-slugify.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# test-slugify.sh — unit tests for the slugify function in vale-autofix.sh +set -euo pipefail + +# Source just the functions (--test mode skips the main script logic) +source "$(dirname "$0")/vale-autofix.sh" --test + +PASS=0 +FAIL=0 + +assert_slug() { + local input="$1" + local expected="$2" + local actual + actual=$(slugify "$input") + if [ "$actual" = "$expected" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: slugify '$input'" + echo " expected: '$expected'" + echo " actual: '$actual'" + fi +} + +# Basic headings +assert_slug "## Hello World" "hello-world" +assert_slug "### Step 1: Install the Agent" "step-1-install-the-agent" +assert_slug "# Overview" "overview" + +# Contractions (apostrophes stripped) +assert_slug "## Don't Click Here" "dont-click-here" +assert_slug "## Can't Stop Won't Stop" "cant-stop-wont-stop" + +# Punctuation stripped +assert_slug "## What is This?" "what-is-this" +assert_slug "## Install (Optional)" "install-optional" +assert_slug "## Step 1. Configure" "step-1-configure" + +# Custom anchor IDs +assert_slug '## Setup the Application {#setup}' "setup" +assert_slug '### Advanced Options {#advanced-opts}' "advanced-opts" + +# Extra whitespace and hyphens +assert_slug "## Lots of Spaces" "lots-of-spaces" +assert_slug "## Already-Hyphenated--Word" "already-hyphenated-word" + +# Edge cases +assert_slug "## 123 Numbers First" "123-numbers-first" +assert_slug "## ALL CAPS HEADING" "all-caps-heading" +assert_slug '## Quotes "and" Stuff' "quotes-and-stuff" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/vale-autofix.sh b/scripts/vale-autofix.sh index 0827a867e7..6ed67c0655 100755 --- a/scripts/vale-autofix.sh +++ b/scripts/vale-autofix.sh @@ -5,6 +5,39 @@ set -euo pipefail +# --- Shared functions --- + +slugify() { + local heading="$1" + + # Check for custom anchor ID: {#custom-id} + if [[ "$heading" =~ \{#([a-zA-Z0-9_-]+)\} ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + + echo "$heading" \ + | sed -E 's/^#+ +//' \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E "s/[^a-z0-9 -]//g" \ + | sed -E 's/ +/-/g' \ + | sed -E 's/-+/-/g' \ + | sed -E 's/^-+//;s/-+$//' +} + +# Allow sourcing for tests or running anchor-update only +case "${1:-}" in + --test) + # Sourced for testing — define functions but skip main script logic + return 0 2>/dev/null || exit 0 + ;; + --anchors-only) + # Will be handled after update_heading_anchors is defined (Task 2) + ;; +esac + +# --- Main autofix logic --- + VIOLATIONS_FILE="${1:?Usage: vale-autofix.sh }" if [ ! -f "$VIOLATIONS_FILE" ]; then From d46df143d244314f7e2bb5e59334ad9ae3a6dab9 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:23:25 -0500 Subject: [PATCH 4/9] feat: add update_heading_anchors function with integration tests Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/test-anchor-update.sh | 107 ++++++++++++++++++++++++++++ scripts/vale-autofix.sh | 130 +++++++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 scripts/test-anchor-update.sh diff --git a/scripts/test-anchor-update.sh b/scripts/test-anchor-update.sh new file mode 100644 index 0000000000..3e3c60acb8 --- /dev/null +++ b/scripts/test-anchor-update.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# test-anchor-update.sh — integration test for update_heading_anchors() +# Creates a temp git repo, makes a heading change, and verifies anchor links update +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "Setting up test repo in $TMPDIR..." + +cd "$TMPDIR" +git init -q +git config user.name "test" +git config user.email "test@test.com" + +# Create a product/version folder structure +mkdir -p docs/testproduct/1.0/install +mkdir -p docs/testproduct/1.0/admin + +# File with a heading that will change +cat > docs/testproduct/1.0/install/setup.md << 'MDEOF' +# Install the Product + +## Do Not Use the Old Method + +Follow these steps instead. See also [configure settings](#configure-settings). + +## Configure Settings + +See the configuration guide. Do not use [the old method](#do-not-use-the-old-method). +MDEOF + +# File with links to the heading above +cat > docs/testproduct/1.0/admin/guide.md << 'MDEOF' +# Admin Guide + +See [old method](../install/setup.md#do-not-use-the-old-method) for details. + +Also check [configure](../install/setup.md#configure-settings). +MDEOF + +# Same-page link in the same file +cat > docs/testproduct/1.0/install/overview.md << 'MDEOF' +# Overview + +For setup, see [setup instructions](setup.md#do-not-use-the-old-method). +MDEOF + +git add -A +git commit -q -m "initial" + +# Now simulate Phase 1 changing the heading (contractions fix) +sed -i "s/## Do Not Use the Old Method/## Don't Use the Old Method/" docs/testproduct/1.0/install/setup.md + +git add -A +git commit -q -m "fix(vale): auto-fix substitutions and removals" + +# Run the anchor update function +source "$SCRIPT_DIR/vale-autofix.sh" --test +update_heading_anchors + +# Verify: guide.md should have updated anchor +PASS=0 +FAIL=0 + +check_contains() { + local file="$1" + local expected="$2" + local label="$3" + if grep -qF "$expected" "$file"; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: $label" + echo " expected '$expected' in $file" + echo " actual content:" + cat "$file" + fi +} + +check_not_contains() { + local file="$1" + local unexpected="$2" + local label="$3" + if grep -qF "$unexpected" "$file"; then + FAIL=$((FAIL + 1)) + echo "FAIL: $label" + echo " did not expect '$unexpected' in $file" + else + PASS=$((PASS + 1)) + fi +} + +check_contains "docs/testproduct/1.0/admin/guide.md" "#dont-use-the-old-method" "cross-file link updated" +check_not_contains "docs/testproduct/1.0/admin/guide.md" "#do-not-use-the-old-method" "old cross-file link removed" +check_contains "docs/testproduct/1.0/install/overview.md" "#dont-use-the-old-method" "relative link updated" +check_not_contains "docs/testproduct/1.0/install/overview.md" "#do-not-use-the-old-method" "old relative link removed" +check_contains "docs/testproduct/1.0/admin/guide.md" "#configure-settings" "unrelated link unchanged" +check_contains "docs/testproduct/1.0/install/setup.md" "#dont-use-the-old-method" "same-file anchor link updated" +check_not_contains "docs/testproduct/1.0/install/setup.md" "#do-not-use-the-old-method" "old same-file anchor link removed" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/vale-autofix.sh b/scripts/vale-autofix.sh index 6ed67c0655..d0ba07c2cb 100755 --- a/scripts/vale-autofix.sh +++ b/scripts/vale-autofix.sh @@ -25,6 +25,133 @@ slugify() { | sed -E 's/^-+//;s/-+$//' } +_get_product_version_folder() { + local filepath="$1" + local rest="${filepath#docs/}" + local product="${rest%%/*}" + rest="${rest#*/}" + local version="${rest%%/*}" + if [[ "$version" =~ ^[0-9][0-9._]*$ ]]; then + echo "docs/${product}/${version}/" + else + echo "docs/${product}/" + fi +} + +_process_heading_pairs() { + local file="$1" + local -n _old="$2" + local -n _new="$3" + local updates=0 + + if [ -z "$file" ] || [ ${#_old[@]} -eq 0 ] || [ ${#_new[@]} -eq 0 ]; then + echo 0 + return 0 + fi + + local count=${#_old[@]} + if [ ${#_new[@]} -lt "$count" ]; then + count=${#_new[@]} + fi + + local folder + folder=$(_get_product_version_folder "$file") + + if [ ! -d "$folder" ]; then + echo 0 + return 0 + fi + + for ((i = 0; i < count; i++)); do + local old_slug new_slug + old_slug=$(slugify "${_old[$i]}") + new_slug=$(slugify "${_new[$i]}") + + if [ "$old_slug" = "$new_slug" ] || [ -z "$old_slug" ] || [ -z "$new_slug" ]; then + continue + fi + + # Replace #old-slug with #new-slug in all .md files in the folder + find "$folder" -name '*.md' -exec \ + sed -i "s|#${old_slug}\([) ]\)|#${new_slug}\1|g" {} + + + updates=$((updates + 1)) + done + + echo "$updates" + return 0 +} + +update_heading_anchors() { + local diff_output + diff_output=$(git diff HEAD~1 HEAD -- '*.md' 2>/dev/null || true) + + if [ -z "$diff_output" ]; then + return 0 + fi + + local current_file="" + local old_headings=() + local new_headings=() + local in_hunk=0 + local anchor_updates=0 + + while IFS= read -r line; do + if [[ "$line" =~ ^diff\ --git\ a/(.*\.md)\ b/ ]]; then + # Process pending heading pairs from previous file + if [ -n "$current_file" ] && [ ${#old_headings[@]} -gt 0 ] && [ ${#new_headings[@]} -gt 0 ]; then + local _pair_result + _pair_result=$(_process_heading_pairs "$current_file" old_headings new_headings) + anchor_updates=$((anchor_updates + _pair_result)) + fi + current_file="${BASH_REMATCH[1]}" + old_headings=() + new_headings=() + in_hunk=0 + continue + fi + + if [[ "$line" =~ ^@@ ]]; then + if [ -n "$current_file" ] && [ ${#old_headings[@]} -gt 0 ] && [ ${#new_headings[@]} -gt 0 ]; then + local _pair_result + _pair_result=$(_process_heading_pairs "$current_file" old_headings new_headings) + anchor_updates=$((anchor_updates + _pair_result)) + fi + old_headings=() + new_headings=() + in_hunk=1 + continue + fi + + if [ "$in_hunk" -eq 0 ]; then + continue + fi + + # Collect removed headings (lines starting with - then #) + if [[ "$line" =~ ^-#{1,6}\ + ]]; then + old_headings+=("${line:1}") + fi + + # Collect added headings (lines starting with + then #) + if [[ "$line" =~ ^\+#{1,6}\ + ]]; then + new_headings+=("${line:1}") + fi + done <<< "$diff_output" + + # Process final file + if [ -n "$current_file" ] && [ ${#old_headings[@]} -gt 0 ] && [ ${#new_headings[@]} -gt 0 ]; then + local _pair_result + _pair_result=$(_process_heading_pairs "$current_file" old_headings new_headings) + anchor_updates=$((anchor_updates + _pair_result)) + fi + + if [ "$anchor_updates" -gt 0 ]; then + echo "Updated $anchor_updates anchor link(s)" + fi + + return 0 +} + # Allow sourcing for tests or running anchor-update only case "${1:-}" in --test) @@ -32,7 +159,8 @@ case "${1:-}" in return 0 2>/dev/null || exit 0 ;; --anchors-only) - # Will be handled after update_heading_anchors is defined (Task 2) + update_heading_anchors + exit 0 ;; esac From db941b8c7fb35eab73ea2fac44bbef2251297188 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:31:07 -0500 Subject: [PATCH 5/9] ci: add heading anchor update step after Phase 1 fixes --- .github/workflows/vale-autofix.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml index f2735e8beb..1e6dd6f9b6 100644 --- a/.github/workflows/vale-autofix.yml +++ b/.github/workflows/vale-autofix.yml @@ -121,6 +121,16 @@ jobs: echo "committed=true" >> "$GITHUB_OUTPUT" fi + - name: Update heading anchors (Phase 1) + if: steps.phase1-commit.outputs.committed == 'true' + run: | + RESULT=$(bash scripts/vale-autofix.sh --anchors-only 2>&1 || true) + echo "$RESULT" + if ! git diff --quiet; then + git add -A docs/ + git commit -m "fix(vale): update anchor links for changed headings" + fi + - name: Re-run Vale for remaining violations id: vale-remaining if: steps.vale-initial.outputs.total > 0 From 41cca2eaf61df78c0ef33ac0be6cab6b444446d8 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:31:50 -0500 Subject: [PATCH 6/9] ci: add anchor-update instructions to Phase 2 Claude prompt --- .github/workflows/vale-autofix.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml index 1e6dd6f9b6..bf17ca3972 100644 --- a/.github/workflows/vale-autofix.yml +++ b/.github/workflows/vale-autofix.yml @@ -181,6 +181,26 @@ jobs: 2. Apply a fix that resolves the Vale rule while preserving the author's meaning 3. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning) SKIP it + HEADING ANCHOR UPDATES: + When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. + + 1. Before editing a heading, record the original heading text + 2. Compute the old anchor slug: + - If the heading has a {#custom-id} suffix, use custom-id as the slug + - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens + - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup + 3. After editing, compute the new anchor slug the same way + 4. If the slug changed, determine the product/version folder from the file path: + - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) + - Single-version: docs// (e.g., docs/threatprevention/) + - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. + 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: + - ](#old-slug) — same-page links + - ](filename#old-slug) — relative links + - ](path/to/filename#old-slug) — deeper relative links + 6. Replace #old-slug with #new-slug in every match + 7. Include each anchor update in the fixed array of your summary JSON, using the same check value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" + After fixing, write a JSON summary to /tmp/phase2-summary.json with this structure: ```json { From 165d3b3753c3610bd661a66bfab8e47e3706994d Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:32:28 -0500 Subject: [PATCH 7/9] ci: add anchor-update instructions to Phase 3 Dale prompt --- .github/workflows/vale-autofix.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml index bf17ca3972..4d057d1f1a 100644 --- a/.github/workflows/vale-autofix.yml +++ b/.github/workflows/vale-autofix.yml @@ -248,6 +248,26 @@ jobs: Step 4: Fix each violation in-place, preserving the author's meaning. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning), SKIP it. + HEADING ANCHOR UPDATES: + When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. + + 1. Before editing a heading, record the original heading text + 2. Compute the old anchor slug: + - If the heading has a {#custom-id} suffix, use custom-id as the slug + - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens + - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup + 3. After editing, compute the new anchor slug the same way + 4. If the slug changed, determine the product/version folder from the file path: + - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) + - Single-version: docs// (e.g., docs/threatprevention/) + - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. + 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: + - ](#old-slug) — same-page links + - ](filename#old-slug) — relative links + - ](path/to/filename#old-slug) — deeper relative links + 6. Replace #old-slug with #new-slug in every match + 7. Include each anchor update in the fixed array of your summary JSON, using the same rule value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" + Step 5: Write a JSON summary to /tmp/dale-summary.json with this structure: ```json { From 7793ec1db9a3e28666370f3b96a0b1b2332093e9 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:34:18 -0500 Subject: [PATCH 8/9] fix(vale): remove HeadingPunctuation rule Co-Authored-By: Claude Opus 4.6 (1M context) --- .vale/styles/Netwrix/HeadingPunctuation.yml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .vale/styles/Netwrix/HeadingPunctuation.yml diff --git a/.vale/styles/Netwrix/HeadingPunctuation.yml b/.vale/styles/Netwrix/HeadingPunctuation.yml deleted file mode 100644 index 0ed367dd3a..0000000000 --- a/.vale/styles/Netwrix/HeadingPunctuation.yml +++ /dev/null @@ -1,13 +0,0 @@ -extends: existence -message: "Don't use punctuation in headings ('%s'). Rewrite the heading to remove it." -level: warning -scope: heading -nonword: true -tokens: - - '\.' - - ':' - - ';' - - '\?' - - '!' - - '\(' - - '\)' From 19ed959ed712d46ddf54b53e5bd1e090955461a9 Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 1 Apr 2026 10:36:20 -0500 Subject: [PATCH 9/9] deleted superpowers stuff --- .../2026-04-01-heading-anchor-link-updates.md | 609 ------------------ ...4-01-heading-anchor-link-updates-design.md | 133 ---- 2 files changed, 742 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md delete mode 100644 docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md diff --git a/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md b/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md deleted file mode 100644 index afa1458fdb..0000000000 --- a/docs/superpowers/plans/2026-04-01-heading-anchor-link-updates.md +++ /dev/null @@ -1,609 +0,0 @@ -# Heading Anchor Link Updates — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Automatically update anchor links when the vale-autofix workflow changes heading text, preventing broken builds from `onBrokenAnchors: 'throw'`. - -**Architecture:** Add a `slugify()` and `update_heading_anchors()` function to `scripts/vale-autofix.sh`, called from a new workflow step after Phase 1 commits. Update Phase 2 and Phase 3 Claude prompts with detailed anchor-update instructions. - -**Tech Stack:** Bash (sed, tr, awk, git diff), GitHub Actions YAML - -**Spec:** `docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md` - ---- - -## File Structure - -| File | Action | Responsibility | -|------|--------|----------------| -| `scripts/vale-autofix.sh` | Modify | Add `slugify()` and `update_heading_anchors()` functions at top of script; add `--anchors-only` entry point | -| `.github/workflows/vale-autofix.yml` | Modify | Add anchor-update step after Phase 1 commit; append anchor instructions to Phase 2 and Phase 3 prompts | -| `scripts/test-slugify.sh` | Create | Test script for `slugify()` function | -| `scripts/test-anchor-update.sh` | Create | Integration test for `update_heading_anchors()` using a temp git repo | - ---- - -### Task 1: Add `slugify()` function to `vale-autofix.sh` - -**Files:** -- Modify: `scripts/vale-autofix.sh:1-6` (add function before existing code) -- Create: `scripts/test-slugify.sh` - -- [ ] **Step 1: Write the test script for `slugify()`** - -Create `scripts/test-slugify.sh`: - -```bash -#!/usr/bin/env bash -# test-slugify.sh — unit tests for the slugify function in vale-autofix.sh -set -euo pipefail - -# Source just the functions (--test mode skips the main script logic) -source "$(dirname "$0")/vale-autofix.sh" --test - -PASS=0 -FAIL=0 - -assert_slug() { - local input="$1" - local expected="$2" - local actual - actual=$(slugify "$input") - if [ "$actual" = "$expected" ]; then - PASS=$((PASS + 1)) - else - FAIL=$((FAIL + 1)) - echo "FAIL: slugify '$input'" - echo " expected: '$expected'" - echo " actual: '$actual'" - fi -} - -# Basic headings -assert_slug "## Hello World" "hello-world" -assert_slug "### Step 1: Install the Agent" "step-1-install-the-agent" -assert_slug "# Overview" "overview" - -# Contractions (apostrophes stripped) -assert_slug "## Don't Click Here" "dont-click-here" -assert_slug "## Can't Stop Won't Stop" "cant-stop-wont-stop" - -# Punctuation stripped -assert_slug "## What is This?" "what-is-this" -assert_slug "## Install (Optional)" "install-optional" -assert_slug "## Step 1. Configure" "step-1-configure" - -# Custom anchor IDs -assert_slug '## Setup the Application {#setup}' "setup" -assert_slug '### Advanced Options {#advanced-opts}' "advanced-opts" - -# Extra whitespace and hyphens -assert_slug "## Lots of Spaces" "lots-of-spaces" -assert_slug "## Already-Hyphenated--Word" "already-hyphenated--word" - -# Edge cases -assert_slug "## 123 Numbers First" "123-numbers-first" -assert_slug "## ALL CAPS HEADING" "all-caps-heading" -assert_slug '## Quotes "and" Stuff' "quotes-and-stuff" - -echo "" -echo "Results: $PASS passed, $FAIL failed" -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bash scripts/test-slugify.sh` -Expected: FAIL — `slugify` function not defined (source will fail or function won't exist) - -- [ ] **Step 3: Add `slugify()` function and `--test`/`--anchors-only` entry point to `vale-autofix.sh`** - -Add the following at the **top** of `scripts/vale-autofix.sh`, right after the `set -euo pipefail` line (line 6) and before the `VIOLATIONS_FILE=` line (line 8): - -```bash -# --- Shared functions --- - -slugify() { - local heading="$1" - - # Check for custom anchor ID: {#custom-id} - if [[ "$heading" =~ \{#([a-zA-Z0-9_-]+)\} ]]; then - echo "${BASH_REMATCH[1]}" - return - fi - - echo "$heading" \ - | sed -E 's/^#+ +//' \ - | tr '[:upper:]' '[:lower:]' \ - | sed -E "s/[^a-z0-9 -]//g" \ - | sed -E 's/ +/-/g' \ - | sed -E 's/-+/-/g' \ - | sed -E 's/^-+//;s/-+$//' -} - -# Allow sourcing for tests or running anchor-update only -case "${1:-}" in - --test) - # Sourced for testing — define functions but skip main script logic - return 0 2>/dev/null || exit 0 - ;; - --anchors-only) - # Will be handled after update_heading_anchors is defined (Task 2) - ;; -esac - -# --- Main autofix logic --- -``` - -**Important:** The `slugify()` function (and later `update_heading_anchors()` in Task 2) must be defined **before** the `case` block so they're available in all modes. The `--test` early return skips only the main autofix logic below. - -Then move the existing `VIOLATIONS_FILE=` line to after the `# --- Main autofix logic ---` comment. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `bash scripts/test-slugify.sh` -Expected: All assertions PASS - -- [ ] **Step 5: Commit** - -```bash -git add scripts/vale-autofix.sh scripts/test-slugify.sh -git commit -m "feat: add slugify function to vale-autofix.sh with tests" -``` - ---- - -### Task 2: Add `update_heading_anchors()` function to `vale-autofix.sh` - -**Files:** -- Modify: `scripts/vale-autofix.sh` (add function after `slugify()`, before entry point `case`) -- Create: `scripts/test-anchor-update.sh` - -- [ ] **Step 1: Write the integration test** - -Create `scripts/test-anchor-update.sh`: - -```bash -#!/usr/bin/env bash -# test-anchor-update.sh — integration test for update_heading_anchors() -# Creates a temp git repo, makes a heading change, and verifies anchor links update -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT - -echo "Setting up test repo in $TMPDIR..." - -cd "$TMPDIR" -git init -q -git config user.name "test" -git config user.email "test@test.com" - -# Create a product/version folder structure -mkdir -p docs/testproduct/1.0/install -mkdir -p docs/testproduct/1.0/admin - -# File with a heading that will change -cat > docs/testproduct/1.0/install/setup.md << 'MDEOF' -# Install the Product - -## Do Not Use the Old Method - -Follow these steps instead. - -## Configure Settings - -See the configuration guide. -MDEOF - -# File with links to the heading above -cat > docs/testproduct/1.0/admin/guide.md << 'MDEOF' -# Admin Guide - -See [old method](../install/setup.md#do-not-use-the-old-method) for details. - -Also check [configure](../install/setup.md#configure-settings). -MDEOF - -# Same-page link in the same file -cat > docs/testproduct/1.0/install/overview.md << 'MDEOF' -# Overview - -For setup, see [setup instructions](setup.md#do-not-use-the-old-method). -MDEOF - -git add -A -git commit -q -m "initial" - -# Now simulate Phase 1 changing the heading (contractions fix) -sed -i 's/## Do Not Use the Old Method/## Don'\''t Use the Old Method/' docs/testproduct/1.0/install/setup.md - -git add -A -git commit -q -m "fix(vale): auto-fix substitutions and removals" - -# Run the anchor update function -source "$SCRIPT_DIR/vale-autofix.sh" --test -update_heading_anchors - -# Verify: guide.md should have updated anchor -PASS=0 -FAIL=0 - -check_contains() { - local file="$1" - local expected="$2" - local label="$3" - if grep -qF "$expected" "$file"; then - PASS=$((PASS + 1)) - else - FAIL=$((FAIL + 1)) - echo "FAIL: $label" - echo " expected '$expected' in $file" - echo " actual content:" - cat "$file" - fi -} - -check_not_contains() { - local file="$1" - local unexpected="$2" - local label="$3" - if grep -qF "$unexpected" "$file"; then - FAIL=$((FAIL + 1)) - echo "FAIL: $label" - echo " did not expect '$unexpected' in $file" - else - PASS=$((PASS + 1)) - fi -} - -check_contains "docs/testproduct/1.0/admin/guide.md" "#dont-use-the-old-method" "cross-file link updated" -check_not_contains "docs/testproduct/1.0/admin/guide.md" "#do-not-use-the-old-method" "old cross-file link removed" -check_contains "docs/testproduct/1.0/install/overview.md" "#dont-use-the-old-method" "relative link updated" -check_not_contains "docs/testproduct/1.0/install/overview.md" "#do-not-use-the-old-method" "old relative link removed" -check_contains "docs/testproduct/1.0/admin/guide.md" "#configure-settings" "unrelated link unchanged" - -echo "" -echo "Results: $PASS passed, $FAIL failed" -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bash scripts/test-anchor-update.sh` -Expected: FAIL — `update_heading_anchors` function not defined - -- [ ] **Step 3: Add `update_heading_anchors()` to `vale-autofix.sh`** - -Add this function after `slugify()` and before the `case "${1:-}"` block: - -```bash -update_heading_anchors() { - # Detect heading changes in the most recent commit and update anchor links - # across the product/version folder. - - local diff_output - diff_output=$(git diff HEAD~1 HEAD -- '*.md' 2>/dev/null || true) - - if [ -z "$diff_output" ]; then - return 0 - fi - - local current_file="" - local old_headings=() - local new_headings=() - local in_hunk=0 - local anchor_updates=0 - - while IFS= read -r line; do - # Track which file we're in - if [[ "$line" =~ ^diff\ --git\ a/(.*\.md)\ b/ ]]; then - # Process any pending heading pairs from previous file - _process_heading_pairs "$current_file" old_headings new_headings - anchor_updates=$((anchor_updates + $?)) - current_file="${BASH_REMATCH[1]}" - old_headings=() - new_headings=() - in_hunk=0 - continue - fi - - # Track hunk boundaries to pair headings - if [[ "$line" =~ ^@@ ]]; then - _process_heading_pairs "$current_file" old_headings new_headings - anchor_updates=$((anchor_updates + $?)) - old_headings=() - new_headings=() - in_hunk=1 - continue - fi - - if [ "$in_hunk" -eq 0 ]; then - continue - fi - - # Collect removed headings (lines starting with - then #) - if [[ "$line" =~ ^-#{1,6}\ + ]]; then - old_headings+=("${line:1}") - fi - - # Collect added headings (lines starting with + then #) - if [[ "$line" =~ ^\+#{1,6}\ + ]]; then - new_headings+=("${line:1}") - fi - done <<< "$diff_output" - - # Process final file - _process_heading_pairs "$current_file" old_headings new_headings - anchor_updates=$((anchor_updates + $?)) - - if [ "$anchor_updates" -gt 0 ]; then - echo "Updated $anchor_updates anchor link(s)" - fi - - return 0 -} - -_get_product_version_folder() { - # Given a file path like docs/product/1.0/install/setup.md, - # return docs/product/1.0/ or docs/product/ - local filepath="$1" - - # Strip docs/ prefix - local rest="${filepath#docs/}" - # Get product name (first segment) - local product="${rest%%/*}" - rest="${rest#*/}" - # Get potential version (second segment) - local version="${rest%%/*}" - - # Check if version segment looks like a version (digits, dots, underscores) - if [[ "$version" =~ ^[0-9][0-9._]*$ ]]; then - echo "docs/${product}/${version}/" - else - echo "docs/${product}/" - fi -} - -_process_heading_pairs() { - local file="$1" - local -n _old="$2" - local -n _new="$3" - local updates=0 - - if [ -z "$file" ] || [ ${#_old[@]} -eq 0 ] || [ ${#_new[@]} -eq 0 ]; then - return 0 - fi - - # Pair old and new headings positionally - local count=${#_old[@]} - if [ ${#_new[@]} -lt "$count" ]; then - count=${#_new[@]} - fi - - local folder - folder=$(_get_product_version_folder "$file") - - if [ ! -d "$folder" ]; then - return 0 - fi - - for ((i = 0; i < count; i++)); do - local old_slug new_slug - old_slug=$(slugify "${_old[$i]}") - new_slug=$(slugify "${_new[$i]}") - - if [ "$old_slug" = "$new_slug" ] || [ -z "$old_slug" ] || [ -z "$new_slug" ]; then - continue - fi - - # Replace #old-slug with #new-slug in all .md files in the folder - # Match patterns: ](#old-slug), (filename#old-slug), (path/filename#old-slug) - find "$folder" -name '*.md' -exec \ - sed -i "s|#${old_slug})|#${new_slug})|g" {} + - - updates=$((updates + 1)) - done - - return "$updates" -} -``` - -Also update the `--anchors-only` case in the entry point block: - -```bash - --anchors-only) - update_heading_anchors - exit 0 - ;; -``` - -- [ ] **Step 4: Run the integration test to verify it passes** - -Run: `bash scripts/test-anchor-update.sh` -Expected: All assertions PASS - -- [ ] **Step 5: Run the slugify tests to make sure nothing broke** - -Run: `bash scripts/test-slugify.sh` -Expected: All assertions PASS - -- [ ] **Step 6: Commit** - -```bash -git add scripts/vale-autofix.sh scripts/test-anchor-update.sh -git commit -m "feat: add update_heading_anchors function with integration tests" -``` - ---- - -### Task 3: Add anchor-update workflow step after Phase 1 commit - -**Files:** -- Modify: `.github/workflows/vale-autofix.yml:112-123` (insert new step after "Commit Phase 1 fixes") - -- [ ] **Step 1: Add the new workflow step** - -In `.github/workflows/vale-autofix.yml`, insert the following new step immediately after the "Commit Phase 1 fixes" step (after line 122) and before the "Re-run Vale for remaining violations" step: - -```yaml - - name: Update heading anchors (Phase 1) - if: steps.phase1-commit.outputs.committed == 'true' - run: | - RESULT=$(bash scripts/vale-autofix.sh --anchors-only 2>&1 || true) - echo "$RESULT" - if ! git diff --quiet; then - git add -A docs/ - git commit -m "fix(vale): update anchor links for changed headings" - fi -``` - -- [ ] **Step 2: Verify YAML syntax** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` -Expected: No output (valid YAML) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/vale-autofix.yml -git commit -m "ci: add heading anchor update step after Phase 1 fixes" -``` - ---- - -### Task 4: Update Phase 2 prompt with anchor-update instructions - -**Files:** -- Modify: `.github/workflows/vale-autofix.yml:158-193` (Phase 2 prompt block) - -- [ ] **Step 1: Append anchor-update instructions to Phase 2 prompt** - -In `.github/workflows/vale-autofix.yml`, find the Phase 2 prompt section. Insert the following text after the existing instruction #3 ("If you are NOT confident...SKIP it") and before the "After fixing, write a JSON summary" paragraph: - -``` - HEADING ANCHOR UPDATES: - When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. - - 1. Before editing a heading, record the original heading text - 2. Compute the old anchor slug: - - If the heading has a {#custom-id} suffix, use custom-id as the slug - - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens - - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup - 3. After editing, compute the new anchor slug the same way - 4. If the slug changed, determine the product/version folder from the file path: - - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) - - Single-version: docs// (e.g., docs/threatprevention/) - - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. - 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: - - ](#old-slug) — same-page links - - ](filename#old-slug) — relative links - - ](path/to/filename#old-slug) — deeper relative links - 6. Replace #old-slug with #new-slug in every match - 7. Include each anchor update in the fixed array of your summary JSON, using the same check value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" -``` - -- [ ] **Step 2: Verify YAML syntax** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` -Expected: No output (valid YAML) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/vale-autofix.yml -git commit -m "ci: add anchor-update instructions to Phase 2 Claude prompt" -``` - ---- - -### Task 5: Update Phase 3 prompt with anchor-update instructions - -**Files:** -- Modify: `.github/workflows/vale-autofix.yml:206-241` (Phase 3 prompt block) - -- [ ] **Step 1: Append anchor-update instructions to Phase 3 prompt** - -In `.github/workflows/vale-autofix.yml`, find the Phase 3 (Dale) prompt section. Insert the following text after Step 4 ("Fix each violation...SKIP it.") and before Step 5 ("Write a JSON summary"): - -``` - HEADING ANCHOR UPDATES: - When you modify a heading line (any line starting with #), you MUST update all anchor links that reference the old heading. - - 1. Before editing a heading, record the original heading text - 2. Compute the old anchor slug: - - If the heading has a {#custom-id} suffix, use custom-id as the slug - - Otherwise: strip the # prefix and whitespace, lowercase, remove everything except [a-z0-9 -], replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens - - Examples: "## Do Not Click" → do-not-click, "## Step 1: Install" → step-1-install, "## Setup {#setup}" → setup - 3. After editing, compute the new anchor slug the same way - 4. If the slug changed, determine the product/version folder from the file path: - - Multi-version: docs/// (e.g., docs/accessanalyzer/12.0/) - - Single-version: docs// (e.g., docs/threatprevention/) - - The folder is the first 2 or 3 segments of the path after docs/. If the second segment is a version number (digits/dots), include it. - 5. Search ALL .md files in that folder (not just PR-changed files) for link patterns containing #old-slug: - - ](#old-slug) — same-page links - - ](filename#old-slug) — relative links - - ](path/to/filename#old-slug) — deeper relative links - 6. Replace #old-slug with #new-slug in every match - 7. Include each anchor update in the fixed array of your summary JSON, using the same rule value as the heading fix that caused it, with action like "updated anchor link from #old-slug to #new-slug" -``` - -Note: This is identical to Phase 2 except step 7 uses `rule` instead of `check`. - -- [ ] **Step 2: Verify YAML syntax** - -Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))"` -Expected: No output (valid YAML) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/vale-autofix.yml -git commit -m "ci: add anchor-update instructions to Phase 3 Dale prompt" -``` - ---- - -### Task 6: End-to-end manual verification - -**Files:** -- All modified files from Tasks 1-5 - -- [ ] **Step 1: Run all tests** - -```bash -bash scripts/test-slugify.sh && bash scripts/test-anchor-update.sh -``` - -Expected: Both scripts report all tests passing. - -- [ ] **Step 2: Validate workflow YAML** - -```bash -python3 -c "import yaml; yaml.safe_load(open('.github/workflows/vale-autofix.yml'))" -``` - -Expected: No output (valid YAML) - -- [ ] **Step 3: Verify the autofix script still works in normal mode** - -```bash -echo '[]' > /tmp/test-violations.json -bash scripts/vale-autofix.sh /tmp/test-violations.json -``` - -Expected: Outputs `{"total": 0, "by_category": {}}` — the `--test` and `--anchors-only` modes don't interfere with normal operation. - -- [ ] **Step 4: Review the full diff** - -```bash -git diff dev...HEAD --stat -git diff dev...HEAD -``` - -Review the changes to ensure everything looks correct and no unintended modifications were made. diff --git a/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md b/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md deleted file mode 100644 index 4502d84b24..0000000000 --- a/docs/superpowers/specs/2026-04-01-heading-anchor-link-updates-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# Heading Anchor Link Updates — Design Spec - -## Problem - -The vale-autofix workflow (Phase 1 script, Phase 2 Claude, Phase 3 Dale) can modify heading text in markdown files. When heading text changes, the Docusaurus-generated anchor slug changes too. Any links referencing the old anchor — within the same file or elsewhere in the product/version folder — silently break. Because Docusaurus is configured with `onBrokenAnchors: 'throw'`, this causes build failures. - -## Goal - -Whenever the auto-fix process changes a heading, automatically find and update all anchor links referencing the old heading slug within the same product/version folder. - -## Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Which phases | All three | Phase 1 makes the most heading changes (contractions, filler removal); AI phases also rewrite headings | -| Search scope | Product/version folder | Cross-product links are rare; same-file-only would miss common inter-page links | -| Include non-PR files | Yes | A heading change in `install.md` can break a link in `overview.md` even if `overview.md` wasn't in the PR | -| Slug generation (Phase 1) | Inline bash function | Simple enough for tr/sed; consistency between old and new slug matters more than exact Docusaurus parity | -| AI prompt detail level | Detailed step-by-step | CI Claude has no conversation history; explicit instructions reduce missed links | - -## Architecture - -### Phase 1: Bash script changes (`scripts/vale-autofix.sh`) - -#### `slugify()` function - -Converts a markdown heading line to a Docusaurus-style anchor slug. - -Algorithm: -1. Check for custom anchor ID suffix `{#custom-id}` — if present, extract and return `custom-id` -2. Strip markdown heading prefix (`##`, `###`, etc.) and leading/trailing whitespace -3. Lowercase -4. Strip everything except `[a-z0-9 -]` -5. Replace spaces with hyphens -6. Collapse consecutive hyphens into one -7. Trim leading/trailing hyphens - -Examples: -- `## Don't Click Here` → `dont-click-here` -- `### Setup the Application {#setup}` → `setup` -- `## Step 1: Install the Agent` → `step-1-install-the-agent` - -#### `update_heading_anchors()` function - -Runs after Phase 1 commits. Detects heading changes and updates anchor links. - -Steps: -1. Run `git diff HEAD~1 HEAD -- '*.md'` to get the unified diff -2. Parse the diff to find changed heading lines — lines starting with `#` (markdown headings) that appear in both `-` and `+` hunks within the same hunk block -3. Pair old (`-`) and new (`+`) headings positionally within each hunk -4. For each pair, compute old and new slugs using `slugify()` -5. If old slug equals new slug, skip (heading text changed but slug didn't) -6. Determine the product/version folder from the file path: - - Try `docs///` first (multi-version products) - - Fall back to `docs//` (single-version/SaaS products) -7. Search all `.md` files in that folder for these patterns: - - `](#old-slug)` — same-page anchor links - - `](filename#old-slug)` — relative file links with anchor - - `](path/to/filename#old-slug)` — deeper relative links -8. Replace `#old-slug` with `#new-slug` using `sed -i` -9. If any replacements were made, stage and commit: `fix(vale): update anchor links for changed headings` - -#### Product/version folder detection - -Given a file path like `docs/accessanalyzer/12.0/install/setup.md`: -- Split on `/` after `docs/` -- If the second segment matches a version pattern (digits, dots, underscores), folder is `docs///` -- Otherwise, folder is `docs//` - -### Phase 2: Claude prompt additions (`vale-autofix.yml`) - -The following instructions are appended to the existing Phase 2 prompt: - -> **Heading anchor updates:** When you modify a heading line (any line starting with `#`), you MUST update all anchor links that reference the old heading. -> -> 1. Record the original heading text before editing -> 2. Compute the old anchor slug: strip the `#` prefix and whitespace, lowercase, remove everything except `[a-z0-9 -]`, replace spaces with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. If the heading has a `{#custom-id}` suffix, use `custom-id` as the slug instead. -> 3. After editing, compute the new anchor slug the same way -> 4. If the slug changed, determine the product/version folder from the file path: -> - Multi-version: `docs///` (e.g., `docs/accessanalyzer/12.0/`) -> - Single-version: `docs//` (e.g., `docs/threatprevention/`) -> 5. Search ALL `.md` files in that folder (not just PR-changed files) for these link patterns: -> - `](#old-slug)` — same-page links -> - `](filename#old-slug)` — relative links -> - `](path/to/filename#old-slug)` — deeper relative links -> 6. Replace `#old-slug` with `#new-slug` in all matches -> 7. Include each anchor update in the `fixed` array of your summary JSON, using the same `check` value as the heading fix that caused it, with action describing the anchor change (e.g., `"updated anchor link from #do-not-use to #dont-use"`) - -### Phase 3: Dale prompt additions (`vale-autofix.yml`) - -Identical instructions to Phase 2, with the only difference being the summary format uses `"rule"` instead of `"check"`: - -> 7. Include each anchor update in the `fixed` array of your summary JSON, using the same `rule` value as the heading fix that caused it, with action describing the anchor change (e.g., `"updated anchor link from #do-not-use to #dont-use"`) - -### Workflow changes (`vale-autofix.yml`) - -One new step added after "Commit Phase 1 fixes": - -```yaml -- name: Update heading anchors (Phase 1) - if: steps.phase1-commit.outputs.committed == 'true' - run: | - # Source the function from vale-autofix.sh - source scripts/vale-autofix.sh --anchors-only - # Or: a dedicated call that runs update_heading_anchors -``` - -Implementation note: The `update_heading_anchors` function needs to be callable standalone from the workflow. Options: export it from `vale-autofix.sh` behind a flag, or extract to a separate callable section at the end of the script. The implementation plan will determine the cleanest approach. - -## Edge Cases - -| Case | Handling | -|------|----------| -| Custom anchor IDs (`{#custom-id}`) | `slugify()` detects `{#...}` suffix and returns the custom ID. Both old and new headings checked. | -| Single-version (SaaS) products | Folder detection falls back to `docs//` when no version segment found | -| Duplicate headings | Docusaurus appends `-1`, `-2` for duplicates. Phase 1 does best-effort exact slug match; Phases 2/3 Claude can use judgment | -| Slug unchanged after heading fix | Skip anchor update (e.g., removing "please" from `## Please See Overview` changes text but slug `please-see-overview` → `see-overview` does change; however `## The Setup` → `## The Set Up` both slug to `the-set-up` — no update needed) | -| No headings changed | Anchor-update step is a no-op | -| Heading deleted entirely | Not an auto-fix scenario — Vale/Dale fix text, they don't delete headings | -| Links in code blocks | Phase 1 sed operates on all matches; risk is low since anchor patterns inside fenced code blocks are rare. Phases 2/3 Claude can use judgment to skip code blocks. | - -## Files Modified - -| File | Change | -|------|--------| -| `scripts/vale-autofix.sh` | Add `slugify()` and `update_heading_anchors()` functions | -| `.github/workflows/vale-autofix.yml` | Add anchor-update step after Phase 1 commit; update Phase 2 and Phase 3 prompts | - -## Out of Scope - -- Updating anchors in files outside the product/version folder -- Handling anchor changes caused by manual edits (outside the auto-fix workflow) -- Updating Docusaurus sidebar or navbar references (these don't use heading anchors)