#!/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