diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml index f2735e8beb..4d057d1f1a 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 @@ -171,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 { @@ -218,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 { 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: - - '\.' - - ':' - - ';' - - '\?' - - '!' - - '\(' - - '\)' 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/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..d0ba07c2cb 100755 --- a/scripts/vale-autofix.sh +++ b/scripts/vale-autofix.sh @@ -5,6 +5,167 @@ 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/-+$//' +} + +_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) + # Sourced for testing — define functions but skip main script logic + return 0 2>/dev/null || exit 0 + ;; + --anchors-only) + update_heading_anchors + exit 0 + ;; +esac + +# --- Main autofix logic --- + VIOLATIONS_FILE="${1:?Usage: vale-autofix.sh }" if [ ! -f "$VIOLATIONS_FILE" ]; then