knowledge-base/scripts/wiki-lint.sh
Hector 4f95a55c70 chore(wiki): maintenance-system v1.0 (lint script + test suite + subagent templates)
Plan abgestimmt mit k9ert im RamaDama-Topic 2026-06-16.
Detection/Execution-Trennung: scripts/wiki-lint.sh ist mechanisch
(kein LLM), Subagent-Spawn nur für Fixes.

Includes:
- scripts/wiki-lint.sh (v0.1, 360 lines, 8 checks, exit 0/1/2)
- scripts/wiki-lint-test.sh (17 asserts, 3 fixtures, all green)
- scripts/test-fixtures/wiki-lint/{clean,messy,broken}-wiki/
- scripts/subagents/{wiki-lint-runner,frontmatter-migration}.md
- wiki/tasks/maintenance-todo.md (WMT-001..006, WMT-000 done)
- AGENTS.md v1.4 (Maintenance-Section hinzugefügt)
- wiki/log.md (Eintrag)
- scripts/wiki-lint-report.md (erster Lint-Run: 46 Pages, 2 Issues)

Erste Lint-Befunde (siehe Report):
- 12 Wiki-Pages ohne Frontmatter
- 14 Stubs (30% Quote)

Co-Authored-By: Hector <hector-bot@botreasury.local>
2026-06-16 11:39:47 +02:00

360 lines
12 KiB
Bash
Executable file

#!/usr/bin/env bash
# wiki-lint.sh — Mechanischer Wiki-Health-Check (kein LLM)
# Version: 0.1 (Draft 2026-06-16)
# Repo: knowledge-base
# Usage: ./scripts/wiki-lint.sh [repo-root] [output-file]
set -u # nounset; NICHT -e, weil wir alle Checks durchlaufen wollen
# --- Konfiguration (Schwellwerte) ---
STUB_WARN_PCT=10
STUB_FAIL_PCT=25
DIR_WARN_FILES=12
DIR_FAIL_FILES=20
STALE_DAYS=30
EXIT_OK=0
EXIT_ISSUES=1
EXIT_ERROR=2
REPO_ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
OUTPUT="${2:-$REPO_ROOT/scripts/wiki-lint-report.md}"
TIMESTAMP="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
cd "$REPO_ROOT" || { echo "FAIL: cannot cd to $REPO_ROOT" >&2; exit 2; }
# --- Zähler ---
total_pages=0
issues=0
status_ok="✅"
status_warn="⚠️"
status_fail="❌"
# --- Ergebnis-Sammler ---
declare -a section_lines
current_section=""
# Helper: Sektion hinzufügen
add_line() { section_lines+=("$1"); }
new_section() {
current_section="$1"
section_lines+=("")
section_lines+=("### $1")
}
# --- Files sammeln ---
# Meta-Files und Tasks-Dir ausschließen
mapfile -t all_wiki < <(find wiki -name "*.md" -type f -not -path "wiki/tasks/*" 2>/dev/null | sort)
total_pages=${#all_wiki[@]}
# ============================================================
# CHECK 1: File-Count per Dir (Trigger für Refactor)
# ============================================================
new_section "File-Count per Dir (Refactor-Trigger)"
declare -A dir_count
while IFS= read -r f; do
dir=$(dirname "$f")
dir_count["$dir"]=$(( ${dir_count["$dir"]:-0} + 1 ))
done < <(printf '%s\n' "${all_wiki[@]}")
add_line "| Dir | Files | Trigger |"
add_line "|-----|-------|---------|"
for dir in $(printf '%s\n' "${!dir_count[@]}" | sort); do
cnt=${dir_count["$dir"]}
if [ "$cnt" -ge "$DIR_FAIL_FILES" ]; then
add_line "| $dir | $cnt | ❌ FAIL (refactor) |"
issues=$((issues+1))
elif [ "$cnt" -ge "$DIR_WARN_FILES" ]; then
add_line "| $dir | $cnt | ⚠️ warn (refactor) |"
else
add_line "| $dir | $cnt | ✅ |"
fi
done
# ============================================================
# CHECK 2: Stubs (<30 Zeilen) + Stub-Quote
# ============================================================
new_section "Stub-Quote (<30 Zeilen)"
stub_count=0
stub_files=()
for f in "${all_wiki[@]}"; do
lines=$(wc -l < "$f")
if [ "$lines" -lt 30 ]; then
# Meta-Files ausschließen
case "$(basename "$f")" in
index.md|log.md|ideas.md|tasks.md) continue ;;
esac
stub_count=$((stub_count+1))
stub_files+=("$f ($lines lines)")
fi
done
if [ "$total_pages" -gt 0 ]; then
stub_pct=$((stub_count * 100 / total_pages))
else
stub_pct=0
fi
if [ "$stub_pct" -ge "$STUB_FAIL_PCT" ]; then
add_line "$status_fail **$stub_count / $total_pages ($stub_pct%)** — threshold $STUB_WARN_PCT%, fail $STUB_FAIL_PCT%"
issues=$((issues+1))
elif [ "$stub_pct" -ge "$STUB_WARN_PCT" ]; then
add_line "$status_warn **$stub_count / $total_pages ($stub_pct%)** — threshold $STUB_WARN_PCT%"
else
add_line "$status_ok **$stub_count / $total_pages ($stub_pct%)**"
fi
if [ "${#stub_files[@]}" -gt 0 ] && [ "$stub_count" -le 15 ]; then
add_line ""
add_line "Stub-Files:"
for s in "${stub_files[@]}"; do add_line "- \`$s\`"; done
fi
# ============================================================
# CHECK 3: Frontmatter fehlt
# ============================================================
new_section "Frontmatter Präsenz"
missing_fm=()
present_fm=0
for f in "${all_wiki[@]}"; do
# Meta-Files dürfen ohne
case "$(basename "$f")" in
index.md|log.md|ideas.md) continue ;;
esac
first=$(head -1 "$f")
if [ "$first" = "---" ]; then
present_fm=$((present_fm+1))
else
missing_fm+=("$f")
fi
done
if [ "${#missing_fm[@]}" -gt 0 ]; then
add_line "$status_fail **${#missing_fm[@]} missing, $present_fm present**"
issues=$((issues+1))
add_line ""
add_line "Missing:"
for m in "${missing_fm[@]}"; do add_line "- \`$m\`"; done
else
add_line "$status_ok **$present_fm / $total_pages present**"
fi
# ============================================================
# CHECK 4: Broken Cross-Refs
# ============================================================
new_section "Broken Cross-References"
broken_refs=()
# Sammle alle .md-Pfade relativ zum Repo
mapfile -t all_md_rel < <(find . -name "*.md" -not -path "./.git/*" -type f 2>/dev/null | sed 's|^\./||' | sort)
# Grep nach ](path.md) in wiki-Files
for f in "${all_wiki[@]}"; do
while IFS= read -r match; do
# match ist z.B. "concepts/llm/foo.md" oder "../other/bar.md"
target=$(echo "$match" | sed 's|^(\.\./)*||' | sed 's|^wiki/||')
# Auflösen relativ zur File-Dir
fdir=$(dirname "$f")
if [ "$fdir" = "." ]; then
resolved="$target"
else
resolved="$fdir/$target"
fi
# Normalize
resolved=$(realpath -m --relative-to=. "$resolved" 2>/dev/null || echo "$resolved")
if [ ! -f "$resolved" ] && [ ! -f "wiki/$target" ] && [ ! -f "$target" ]; then
broken_refs+=("$f$match")
fi
done < <(grep -oE '\]\(([^)]+\.md)\)' "$f" 2>/dev/null | sed 's|](||;s|)||')
done
if [ "${#broken_refs[@]}" -gt 0 ]; then
add_line "$status_fail **${#broken_refs[@]} broken refs**"
issues=$((issues+1))
for b in "${broken_refs[@]}"; do add_line "- \`$b\`"; done
else
add_line "$status_ok **0 broken**"
fi
# ============================================================
# CHECK 5: Orphan Pages (kein eingehender Link)
# ============================================================
new_section "Orphan Pages (kein eingehender Link)"
orphans=()
for f in "${all_wiki[@]}"; do
basename=$(basename "$f" .md)
# Meta-Files auslassen
case "$basename" in
index|log|ideas|tasks) continue ;;
esac
# Suche nach basename in allen anderen wiki-Files (ohne die Datei selbst)
if ! grep -rq "$basename" "${all_wiki[@]/$f/}" 2>/dev/null; then
orphans+=("$f")
fi
done
if [ "${#orphans[@]}" -gt 0 ]; then
add_line "$status_warn **${#orphans[@]} orphan pages**"
for o in "${orphans[@]}"; do add_line "- \`$o\`"; done
else
add_line "$status_ok **0 orphans**"
fi
# ============================================================
# CHECK 6: Dup-Topic-Cluster (Keyword-Suche)
# ============================================================
new_section "Dup-Topic Cluster (substantielle Vorkommen)"
keywords=("polymarket" "lightning" "iron.condor" "subconscious" "memory" "trading")
declare -A cluster_files
for kw in "${keywords[@]}"; do
cluster_files["$kw"]=""
done
for f in "${all_wiki[@]}"; do
case "$(basename "$f")" in
index.md|log.md|ideas.md|tasks.md) continue ;;
esac
for kw in "${keywords[@]}"; do
count=$(grep -ciE "\\b$kw\\b" "$f" 2>/dev/null)
count=${count:-0}
if [ "$count" -ge 3 ]; then
cluster_files["$kw"]="${cluster_files[$kw]}$f|$count
"
fi
done
done
cluster_found=0
for kw in "${keywords[@]}"; do
files_str="${cluster_files[$kw]}"
if [ -n "$files_str" ]; then
file_count=$(echo "$files_str" | grep -c "^[^-]")
if [ "$file_count" -ge 3 ]; then
cluster_found=$((cluster_found+1))
add_line "$status_warn **$kw**: $file_count Files (≥3 Vorkommen je File)"
while IFS='|' read -r fpath fcount; do
[ -n "$fpath" ] && add_line " - \`$fpath\` ($fcount Treffer)"
done <<< "$files_str"
fi
fi
done
if [ "$cluster_found" -eq 0 ]; then
add_line "$status_ok **keine signifikanten Cluster** (Schwelle: ≥3 Vorkommen in ≥3 Files)"
fi
# ============================================================
# CHECK 7: Stale Wiki (mtime > 30d ohne index-Update)
# ============================================================
new_section "Stale Pages (mtime > 30d)"
stale=()
thirty_days_ago=$(date -d "30 days ago" +%s 2>/dev/null || date -v-30d +%s 2>/dev/null || echo 0)
if [ "$thirty_days_ago" -gt 0 ]; then
while IFS= read -r f; do
mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo 0)
if [ "$mtime" -gt 0 ] && [ "$mtime" -lt "$thirty_days_ago" ]; then
stale+=("$f")
fi
done < <(printf '%s\n' "${all_wiki[@]}")
fi
if [ "${#stale[@]}" -gt 0 ]; then
add_line "$status_warn **${#stale[@]} stale pages** (>30d)"
for s in "${stale[@]:0:10}"; do add_line "- \`$s\`"; done
if [ "${#stale[@]}" -gt 10 ]; then add_line "- ... und $((${#stale[@]}-10)) weitere"; fi
else
add_line "$status_ok **0 stale**"
fi
# ============================================================
# CHECK 8: Uncovered Raw (raw/-Files ohne Wiki-Cross-Ref)
# ============================================================
new_section "Uncovered Raw Sources"
mapfile -t all_raw < <(find raw -name "*.md" -type f 2>/dev/null | sort)
uncovered=()
for r in "${all_raw[@]}"; do
rbase=$(basename "$r")
# Suche in Wiki nach dem Raw-Basename (ohne .md) oder nach dem Slug-Teil
slug=$(echo "$rbase" | sed 's/\.md$//' | sed 's/^[0-9-]*_//')
if ! grep -rq "$slug" wiki/ 2>/dev/null; then
uncovered+=("$r (slug: $slug)")
fi
done
if [ "${#uncovered[@]}" -gt 0 ]; then
add_line "$status_warn **${#uncovered[@]} uncovered raw**"
for u in "${uncovered[@]}"; do add_line "- \`$u\`"; done
else
add_line "$status_ok **${#all_raw[@]} raw, all covered**"
fi
# ============================================================
# Zusammenfassung + Empfehlung
# ============================================================
summary_section="## Summary"
section_lines+=("")
section_lines+=("$summary_section")
section_lines+=("")
section_lines+=("- Total Wiki-Pages: $total_pages")
section_lines+=("- Issues: $issues")
section_lines+=("")
if [ "$issues" -gt 0 ]; then
section_lines+=("→ **Action needed**: subagent spawn for fixes. See '## Recommended Subagent Spawns'.")
else
section_lines+=("→ Wiki is clean. No subagent spawn needed.")
fi
# ============================================================
# Empfohlene Subagent-Spawns (heuristisch)
# ============================================================
section_lines+=("")
section_lines+=("## Recommended Subagent Spawns")
section_lines+=("")
# Heuristik: Welche Subagents sind nötig?
if [ "${#missing_fm[@]}" -gt 0 ]; then
section_lines+=("- **frontmatter-migration** — ${#missing_fm[@]} files missing frontmatter")
fi
if [ "${#orphans[@]}" -gt 0 ]; then
section_lines+=("- **orphan-resolver** — ${#orphans[@]} orphan pages (link or delete)")
fi
if [ "${#stub_files[@]}" -gt 0 ] && [ "$stub_pct" -ge 10 ]; then
section_lines+=("- **stub-killer** — $stub_count stubs ($stub_pct%, threshold 10%)")
fi
# Dup-Topic-Cluster: bei polymarket/lightning/trading ≥3 hits
dup_warn=$(printf '%s\n' "${section_lines[@]}" | grep -c "^\*\*\(polymarket\|lightning\|iron\.condor\|trading\)\*\*" 2>/dev/null) || dup_warn=0
dup_warn=${dup_warn:-0}
if [ "$dup_warn" -gt 0 ]; then
section_lines+=("- **hub-page-creator** — dup-topic clusters detected")
fi
# Tools-Dir ≥12
if [ "${dir_count[wiki/tools]:-0}" -ge 12 ]; then
section_lines+=("- **tools-refactor** — wiki/tools/ hat ${dir_count[wiki/tools]} files")
fi
if [ "${#uncovered[@]}" -gt 0 ]; then
section_lines+=("- **raw-coverage-check** — ${#uncovered[@]} raw files ohne Wiki-Cross-Ref")
fi
if [ "$issues" -eq 0 ] && [ "${#orphans[@]}" -eq 0 ] && [ "$stub_pct" -lt 10 ]; then
section_lines+=("(keine — Wiki sauber)")
fi
# ============================================================
# Output schreiben
# ============================================================
{
echo "# Wiki Lint Report"
echo ""
echo "*Generated: $TIMESTAMP*"
echo "*Repo: $REPO_ROOT*"
echo "*Script: scripts/wiki-lint.sh v0.1*"
echo ""
printf '%s\n' "${section_lines[@]}"
} > "$OUTPUT"
# Console-Kurzfassung
echo "Wiki Lint — $TIMESTAMP"
echo "Total: $total_pages | Issues: $issues"
echo "Report: $OUTPUT"
# Exit-Code
if [ "$issues" -gt 0 ]; then
exit "$EXIT_ISSUES"
else
exit "$EXIT_OK"
fi