#!/usr/bin/env bash # okf-validate.sh — validador OKF v0.1 para o Hub Obsidian # Uso: okf-validate.sh [--all] [--strict] [file ...] # --all valida todos os .md do Hub (exclui exclusões) # --strict aplica regras layer:wiki (description≥45ch, tags≥2) # Sem args + stdin: valida ficheiros staged (pre-commit hook) set -euo pipefail HUB_ROOT="/media/ealmeida/Dados/Hub" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # --- Exclusion patterns (relative to HUB_ROOT) --- EXCLUDE_PATTERNS=( "99-Arquivo/" ".stversions/" ".obsidian/" ".ijfw/" "_templates/" "90-Templates/" ".git/" "node_modules/" ".github/" ".wayland/" ".hermes/" ) # Exclude specific filenames EXCLUDE_FILES=( "MEMORY.md" "CLAUDE.md" "GEMINI.md" "AGENTS.md" "copilot-instructions.md" ) # Canonical type vocabulary (§5 of OKF-Compliance-Plan-Global.md) CANONICAL_TYPES=( "Playbook" "Reference" "Specification" "Status" "Changelog" "Index" "Template" "Client Profile" "Document" "note" ) ERRORS=0 WARNINGS=0 CHECKED=0 STRICT=false RED='\033[0;31m' YEL='\033[0;33m' GRN='\033[0;32m' NC='\033[0m' is_excluded() { local f="$1" local rel="${f#$HUB_ROOT/}" # Check directory patterns for pat in "${EXCLUDE_PATTERNS[@]}"; do if [[ "$rel" == *"$pat"* ]]; then return 0 fi done # Check filename patterns local base base="$(basename "$rel")" for pat in "${EXCLUDE_FILES[@]}"; do if [[ "$base" == "$pat" ]]; then return 0 fi done return 1 } is_index_subfolder() { # index.md in subfolders don't need frontmatter (anti-pattern) local f="$1" local base base="$(basename "$f")" local dir dir="$(dirname "$f")" local parent_base parent_base="$(basename "$dir")" # Root index.md DOES need frontmatter if [[ "$dir" == "$HUB_ROOT" ]]; then return 1 fi if [[ "$base" == "index.md" ]]; then return 0 fi return 1 } is_log_file() { local base base="$(basename "$1")" [[ "$base" == "log.md" ]] } validate_file() { local f="$1" local rel="${f#$HUB_ROOT/}" local content if [[ ! -f "$f" ]]; then echo -e "${RED}ERROR${NC} $rel: file not found" ((ERRORS++)) || true return fi ((CHECKED++)) || true # Reservados OKF (index.md em qualquer nível, log.md) — isentos de todas as verificações local base; base="$(basename "$f")" if [[ "$base" == "index.md" || "$base" == "log.md" ]]; then return fi # Check frontmatter if ! head -1 "$f" | grep -q '^---$'; then # log.md and subfolder index.md are exempt if is_log_file "$f" || is_index_subfolder "$f"; then return fi echo -e "${RED}ERROR${NC} $rel: missing frontmatter" ((ERRORS++)) || true return fi # Extract frontmatter using awk (robust: handles leading whitespace, multiline values) content="$(awk 'BEGIN{n=0} /^---[[:space:]]*$/{n++; if(n==2) exit} n==1 && n>0{print}' "$f")" if [[ -z "$content" ]]; then echo -e "${RED}ERROR${NC} $rel: empty or invalid frontmatter" ((ERRORS++)) || true return fi # Parse YAML fields (simple grep — no Python dependency) local has_type has_title has_timestamp has_desc has_tags has_layer has_type=$(echo "$content" | grep -c '^type:' || true) has_title=$(echo "$content" | grep -c '^title:' || true) has_timestamp=$(echo "$content" | grep -c '^timestamp:' || true) has_desc=$(echo "$content" | grep -c '^description:' || true) has_tags=$(echo "$content" | grep -c '^tags:' || true) has_layer=$(echo "$content" | grep -c '^layer:' || true) # Required for all if [[ "$has_type" -eq 0 ]]; then echo -e "${RED}ERROR${NC} $rel: missing 'type'" ((ERRORS++)) || true fi if [[ "$has_title" -eq 0 ]]; then echo -e "${RED}ERROR${NC} $rel: missing 'title'" ((ERRORS++)) || true fi if [[ "$has_timestamp" -eq 0 ]]; then echo -e "${RED}ERROR${NC} $rel: missing 'timestamp'" ((ERRORS++)) || true fi # Strict checks (layer:wiki or --strict flag) if [[ "$STRICT" == "true" ]] || [[ "$has_layer" -gt 0 ]] && echo "$content" | grep -q '^layer: wiki'; then if [[ "$has_desc" -eq 0 ]]; then echo -e "${RED}ERROR${NC} $rel [wiki]: missing 'description'" ((ERRORS++)) || true else # Check description length (≥45 chars, excluding YAML key prefix) # Comprimento da description (>=45 ch; trata folded scalars >- / |) local desc_len desc_len=$(echo "$content" | awk ' /^description:/{f=1; s=$0; sub(/^description:[[:space:]]*[>|+-]*[[:space:]]*/,"",s); buf=s; next} f && /^[[:space:]]+[^[:space:]]/{t=$0; sub(/^[[:space:]]+/,"",t); buf=buf " " t; next} f && /^[^[:space:]]/{f=0} END{gsub(/[>|]/,"",buf); print length(buf)} ') if [[ "$desc_len" -lt 45 ]]; then echo -e "${RED}ERROR${NC} $rel [wiki]: description too short ($desc_len < 45 chars)" ((ERRORS++)) || true fi fi if [[ "$has_tags" -eq 0 ]]; then echo -e "${RED}ERROR${NC} $rel [wiki]: missing 'tags'" ((ERRORS++)) || true fi fi # Warnings (non-blocking) if [[ "$has_desc" -eq 0 ]]; then echo -e "${YEL}WARN${NC} $rel: missing 'description'" ((WARNINGS++)) || true fi if [[ "$has_tags" -eq 0 ]]; then echo -e "${YEL}WARN${NC} $rel: missing 'tags'" ((WARNINGS++)) || true fi # Check for wikilinks in body (wiki layer) if echo "$content" | grep -q '^layer: wiki'; then local body body="$(sed -n '/^---$/,/^---$/d; p' "$f")" if echo "$body" | grep -q '\[\['; then echo -e "${YEL}WARN${NC} $rel [wiki]: contains wikilinks [[ ]] — convert to [text](path)" ((WARNINGS++)) || true fi fi } # --- Main --- FILES=() ALL=false for arg in "$@"; do case "$arg" in --all) ALL=true ;; --strict) STRICT=true ;; *) FILES+=("$arg") ;; esac done if [[ "$ALL" == "true" ]]; then while IFS= read -r -d '' f; do if ! is_excluded "$f"; then FILES+=("$f") fi done < <(find "$HUB_ROOT" -name '*.md' -not -path '*/.git/*' -not -path '*/node_modules/*' -print0) elif [[ ${#FILES[@]} -eq 0 ]]; then # Pre-commit mode: read staged files from git while IFS= read -r f; do if [[ "$f" == *.md ]] && ! is_excluded "$HUB_ROOT/$f"; then FILES+=("$HUB_ROOT/$f") fi done < <(git -C "$HUB_ROOT" diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) fi if [[ ${#FILES[@]} -eq 0 ]]; then echo "No .md files to validate." exit 0 fi echo "Validating ${#FILES[@]} files..." echo "" for f in "${FILES[@]}"; do validate_file "$f" done echo "" echo "────────────────────────────────────" echo -e "Checked: $CHECKED | ${GRN}OK: $((CHECKED - ERRORS - WARNINGS))${NC} | ${YEL}Warnings: $WARNINGS${NC} | ${RED}Errors: $ERRORS${NC}" if [[ "$ERRORS" -gt 0 ]]; then echo -e "${RED}VALIDATION FAILED${NC}" exit 1 else echo -e "${GRN}VALIDATION PASSED${NC}" exit 0 fi