353 lines
12 KiB
Bash
Executable File
353 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
# okf-validate.sh — Validação OKF pre-commit para o Hub Obsidian
|
|
#
|
|
# Instalar: bash scripts/install-hooks.sh
|
|
# Executar manualmente: bash scripts/okf-validate.sh [--all] [--warn-only]
|
|
#
|
|
# Comportamento:
|
|
# Sem args → valida apenas ficheiros staged (para pre-commit)
|
|
# --all → valida todos os ficheiros activos do vault
|
|
# --warn-only → não bloqueia o commit (só avisos)
|
|
#
|
|
# Criado: 28-06-2026
|
|
|
|
set -euo pipefail
|
|
|
|
VAULT="/media/ealmeida/Dados/Hub"
|
|
ERRORS=0
|
|
WARNINGS=0
|
|
WARN_ONLY=false
|
|
ALL_FILES=false
|
|
|
|
# Parsing de argumentos
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--warn-only) WARN_ONLY=true ;;
|
|
--all) ALL_FILES=true ;;
|
|
esac
|
|
done
|
|
|
|
# Cores para output
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
GREEN='\033[0;32m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m' # No Color
|
|
|
|
err() { echo -e "${RED}[ERRO]${NC} $*" >&2; ERRORS=$((ERRORS+1)); }
|
|
warn() { echo -e "${YELLOW}[AVISO]${NC} $*" >&2; WARNINGS=$((WARNINGS+1)); }
|
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
|
|
# ─── Determinar ficheiros a validar ───────────────────────────────────────────
|
|
|
|
get_files() {
|
|
if [[ "$ALL_FILES" == "true" ]]; then
|
|
# Todos os .md activos (excluir arquivo, stversions, gitignore)
|
|
find "$VAULT" -name "*.md" \
|
|
-not -path "*/99-Arquivo/*" \
|
|
-not -path "*/.stversions/*" \
|
|
-not -path "*/node_modules/*" \
|
|
-not -path "*/.git/*" \
|
|
-not -path "*/.obsidian/*" \
|
|
-not -path "*/.ijfw/*" \
|
|
-not -path "*/\.trash/*" \
|
|
2>/dev/null
|
|
else
|
|
# Só ficheiros staged (modo pre-commit)
|
|
git -C "$VAULT" diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
| grep "\.md$" \
|
|
| while IFS= read -r f; do echo "$VAULT/$f"; done
|
|
fi
|
|
}
|
|
|
|
# ─── Regras de exclusão ───────────────────────────────────────────────────────
|
|
|
|
should_skip() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
local filepath_lower
|
|
filepath_lower=$(echo "$file" | tr '[:upper:]' '[:lower:]')
|
|
|
|
# OKF reserved — sem frontmatter obrigatório
|
|
[[ "$basename" == "index.md" ]] && return 0
|
|
[[ "$basename" == "log.md" ]] && return 0
|
|
|
|
# Ficheiros de sistema/config — não são documentos OKF
|
|
[[ "$basename" == "CLAUDE.md" ]] && return 0
|
|
[[ "$basename" == "AGENTS.md" ]] && return 0
|
|
[[ "$basename" == "README.md" ]] && return 0
|
|
[[ "$basename" == "README.txt" ]] && return 0
|
|
[[ "$basename" == ".desk-project" ]] && return 0
|
|
|
|
# Paths internos de ferramentas e config
|
|
[[ "$filepath_lower" == *"/.wayland/"* ]] && return 0
|
|
[[ "$filepath_lower" == *"/.hermes/"* ]] && return 0
|
|
[[ "$filepath_lower" == *"/ijfw/"* ]] && return 0
|
|
[[ "$filepath_lower" == *"/.github/"* ]] && return 0
|
|
[[ "$filepath_lower" == *"/\.obsidian/"* ]] && return 0
|
|
|
|
# Relatórios de scripts (gerados automaticamente)
|
|
[[ "$basename" == "okf-normalize-report.md" ]] && return 0
|
|
[[ "$basename" == "project-journal.md" ]] && return 0
|
|
|
|
return 1
|
|
}
|
|
|
|
# ─── Verificações ─────────────────────────────────────────────────────────────
|
|
|
|
check_type_field() {
|
|
local file="$1"
|
|
if ! grep -q "^type:" "$file" 2>/dev/null; then
|
|
err "Sem 'type:' — $file"
|
|
return
|
|
fi
|
|
# Verificar valor válido
|
|
local type_val
|
|
type_val=$(grep "^type:" "$file" | head -1 | sed 's/^type: *//' | tr -d '"'"'" | xargs)
|
|
# Tipos OKF canónicos
|
|
local canonical_types=(
|
|
"Document" "Index" "Playbook" "Reference" "Specification"
|
|
"Status" "Template" "Changelog" "Proposal" "Client Profile"
|
|
"Concept" "Decision" "Guide" "Record" "Runbook"
|
|
)
|
|
# Tipos legacy (pré-OKF) — aceites sem aviso para não bloquear trabalho normal
|
|
local legacy_types=(
|
|
"note" "Note" "concept" "manual" "Manual" "procedimento"
|
|
"procedure" "reference" "spec" "taskforce" "api" "departamento"
|
|
"report" "plan" "deep-research" "research-report" "research"
|
|
"documentation" "componente-ia" "proposta" "pesquisa"
|
|
"deep-research-prompt" "audit" "relatorio-ic" "runbook"
|
|
"guide" "record" "decision" "Worklist" "meeting-notes"
|
|
"journal" "review" "analysis" "summary" "overview"
|
|
# Hub-specific legacy types descobertos via okf info/validate
|
|
"schema" "proc" "servico" "redirect" "diagnostic"
|
|
"deep-research-sources" "source-list" "documentacao"
|
|
"agente" "analise" "archive-marker" "arquitectura" "arquivo"
|
|
"checklist" "checkpoint" "checkup" "checkup-consolidado"
|
|
"componente-infra" "content" "continuacao" "conversa-exportada"
|
|
"daily" "decisao"
|
|
"auditoria" "design-spec" "diagnostico" "diario" "doc"
|
|
"documentacao-tecnica" "estrategia" "evaluation" "evidencia"
|
|
"final-report" "fontes-curadas" "framework" "inbox" "insight"
|
|
"mapeamento" "metricas" "notes" "planning" "plano"
|
|
"plano-execucao" "procedimento-infra" "process-map" "product-spec"
|
|
"projecto" "prompt" "prompts-pesquisa" "reconnaissance"
|
|
"registo-historico" "relatorio-pesquisa" "reuniao" "revisao"
|
|
"roadmap" "session-handoff" "sintese" "sistema" "spec-design"
|
|
"spec-umbrella" "status" "tarefa" "triangulacao" "troubleshooting"
|
|
"visao" "worklog"
|
|
)
|
|
local found=false
|
|
for vt in "${canonical_types[@]}" "${legacy_types[@]}"; do
|
|
[[ "$type_val" == "$vt" ]] && found=true && break
|
|
done
|
|
if [[ "$found" == "false" ]]; then
|
|
warn "type desconhecido '$type_val' — usar: Document, Playbook, Reference, Specification, Status — $file"
|
|
fi
|
|
}
|
|
|
|
check_description_field() {
|
|
local file="$1"
|
|
if ! grep -q "^description:" "$file" 2>/dev/null; then
|
|
# Só aviso — não bloqueia
|
|
warn "Sem 'description:' — $file"
|
|
fi
|
|
}
|
|
|
|
check_timestamp_field() {
|
|
local file="$1"
|
|
# Aceitar 'timestamp:' OU 'date:' (muitos ficheiros antigos têm 'date:')
|
|
if ! grep -qE "^(timestamp|date):" "$file" 2>/dev/null; then
|
|
warn "Sem 'timestamp:' — $file"
|
|
fi
|
|
}
|
|
|
|
check_uppercase_index() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
# Bloquear criação de INDEX.md maiúsculo (deprecated desde 28-06-2026)
|
|
if [[ "$basename" == "INDEX.md" ]]; then
|
|
err "INDEX.md uppercase está deprecated desde 28-06-2026 — usar 'index.md' — $file"
|
|
fi
|
|
}
|
|
|
|
check_index_wikilinks() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
if [[ "$basename" == "index.md" ]]; then
|
|
if grep -q "\[\[" "$file" 2>/dev/null; then
|
|
local count
|
|
count=$(grep -c "\[\[" "$file" 2>/dev/null || echo 0)
|
|
warn "index.md com $count wikilinks — converter para [texto](path.md) — $file"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
check_qr_line_limit() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
if [[ "$basename" == QR-*.md ]]; then
|
|
local lines
|
|
lines=$(wc -l < "$file" 2>/dev/null || echo 0)
|
|
if [[ "$lines" -gt 350 ]]; then
|
|
# Erro só para QR verdadeiramente gigantes (>350) — indica decomposição urgente
|
|
err "QR-*.md excede 350 linhas ($lines) — dividir imediatamente — $file"
|
|
elif [[ "$lines" -gt 200 ]]; then
|
|
# Aviso para QR entre 200-350 — dívida técnica, não bloqueia
|
|
warn "QR-*.md excede 200 linhas ($lines) — dividir quando possível — $file"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
check_index_size() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
if [[ "$basename" == "index.md" ]]; then
|
|
local lines
|
|
lines=$(wc -l < "$file" 2>/dev/null || echo 0)
|
|
if [[ "$lines" -gt 100 ]]; then
|
|
warn "index.md muito longo ($lines linhas, max recomendado: 80) — $file"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
check_no_content_in_index() {
|
|
local file="$1"
|
|
local basename
|
|
basename=$(basename "$file")
|
|
if [[ "$basename" != "index.md" ]]; then return; fi
|
|
|
|
# Contar linhas de conteúdo substantivo (não links, não headings, não vazias, não frontmatter)
|
|
local subst_lines
|
|
subst_lines=$(awk '
|
|
/^---$/ { in_fm = !in_fm; next }
|
|
in_fm { next }
|
|
/^\s*$/ { next }
|
|
/^#/ { next }
|
|
/^\[/ { next }
|
|
/^\|/ { next }
|
|
/^>/ { next }
|
|
/^```/ { next }
|
|
{ count++ }
|
|
END { print count+0 }
|
|
' "$file" 2>/dev/null || echo 0)
|
|
|
|
if [[ "$subst_lines" -gt 5 ]]; then
|
|
warn "index.md tem $subst_lines linhas de conteúdo substantivo — index.md deve conter só links — $file"
|
|
fi
|
|
}
|
|
|
|
check_sync_conflicts() {
|
|
local file="$1"
|
|
if [[ "$file" == *".sync-conflict-"* ]]; then
|
|
warn "Ficheiro sync-conflict a ser commitado — resolver antes — $file"
|
|
fi
|
|
}
|
|
|
|
# ─── Verificação de links quebrados via okf CLI ───────────────────────────────
|
|
|
|
check_broken_links_okf() {
|
|
if ! command -v okf &>/dev/null; then
|
|
return 0
|
|
fi
|
|
if [[ "$ALL_FILES" != "true" ]]; then
|
|
return 0 # só correr em modo --all (vault completo)
|
|
fi
|
|
info "OKF CLI: a verificar links quebrados no bundle..."
|
|
local okf_out
|
|
okf_out=$(okf validate "$VAULT" 2>&1) || true
|
|
# Filtrar: excluir erros de directórios ocultos (dot-paths: .ijfw, .stversions, .github, .wayland)
|
|
# que o okf não sabe ignorar — são erros de parsing, não broken links
|
|
local broken_lines
|
|
broken_lines=$(echo "$okf_out" \
|
|
| grep -iE "broken|not found|missing link" \
|
|
| grep -v "Invalid concept id segment" \
|
|
| grep -v "/\." \
|
|
2>/dev/null || true)
|
|
if [[ -n "$broken_lines" ]]; then
|
|
local count
|
|
count=$(echo "$broken_lines" | wc -l | tr -d ' ')
|
|
warn "OKF CLI: $count links quebrados detectados"
|
|
echo "$broken_lines" | head -30 >&2
|
|
fi
|
|
}
|
|
|
|
# ─── Loop principal ────────────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
info "=== OKF Validation $(date '+%Y-%m-%d %H:%M') ==="
|
|
if [[ "$ALL_FILES" == "true" ]]; then
|
|
info "Modo: COMPLETO (todos os ficheiros activos)"
|
|
else
|
|
info "Modo: STAGED (ficheiros em staging)"
|
|
fi
|
|
echo ""
|
|
|
|
FILE_COUNT=0
|
|
mapfile -t files < <(get_files)
|
|
|
|
for file in "${files[@]}"; do
|
|
[[ -z "$file" ]] && continue
|
|
[[ ! -f "$file" ]] && continue
|
|
|
|
if should_skip "$file"; then
|
|
continue
|
|
fi
|
|
|
|
FILE_COUNT=$((FILE_COUNT+1))
|
|
|
|
check_uppercase_index "$file"
|
|
check_sync_conflicts "$file"
|
|
check_type_field "$file"
|
|
check_description_field "$file"
|
|
check_timestamp_field "$file"
|
|
check_index_wikilinks "$file"
|
|
check_index_size "$file"
|
|
check_no_content_in_index "$file"
|
|
check_qr_line_limit "$file"
|
|
done
|
|
|
|
# Verificação de links quebrados (só em modo --all)
|
|
check_broken_links_okf
|
|
|
|
# ─── Sumário ──────────────────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo "─────────────────────────────────────────"
|
|
info "Ficheiros validados: $FILE_COUNT"
|
|
|
|
if [[ $WARNINGS -gt 0 ]]; then
|
|
echo -e "${YELLOW}Avisos: $WARNINGS${NC}"
|
|
fi
|
|
|
|
if [[ $ERRORS -gt 0 ]]; then
|
|
echo -e "${RED}Erros: $ERRORS${NC}"
|
|
echo ""
|
|
if [[ "$WARN_ONLY" == "true" ]]; then
|
|
warn "Modo --warn-only: commit não bloqueado apesar de $ERRORS erros"
|
|
exit 0
|
|
else
|
|
err "Commit bloqueado — corrigir erros OKF antes de commitar"
|
|
echo " Dica: bash scripts/okf-validate.sh --warn-only (para forçar)"
|
|
echo " Dica: bash scripts/okf-normalize.py (para auto-corrigir frontmatter)"
|
|
exit 1
|
|
fi
|
|
else
|
|
ok "OKF Validation PASSED ($FILE_COUNT ficheiros, $WARNINGS avisos)"
|
|
fi
|
|
|
|
# ─── OKF Bundle Inventory (modo --all) ───────────────────────────────────────
|
|
if command -v okf &>/dev/null && [[ "$ALL_FILES" == "true" ]]; then
|
|
echo ""
|
|
info "=== OKF Bundle Inventory ==="
|
|
okf info "$VAULT" 2>/dev/null || true
|
|
fi
|
|
|
|
exit ${ERRORS:-0}
|