Files

281 lines
7.7 KiB
Bash
Executable File

#!/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/"
"ijfw/"
".sync-conflict-"
)
# 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")"
[[ "$base" == MEMORY-*.md ]] && return 0 # variantes MEMORY legacy
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="$(awk '
NR==1 && $0=="---" {infm=1; next}
infm && $0=="---" {infm=0; next}
infm {next}
/^[[:space:]]*```/ || /^[[:space:]]*~~~/ {infence=!infence; next}
infence {next}
{gsub(/`[^`]*`/,""); print}
' "$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