272 lines
7.4 KiB
Bash
Executable File
272 lines
7.4 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/"
|
|
)
|
|
|
|
# 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="$(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
|