feat(okf-hub): relocar tooling OKF do Hub para Dev/Scripts (regra: scripts fora do vault)

This commit is contained in:
2026-06-28 20:46:17 +01:00
parent e11b237a1e
commit e810bbb114
8 changed files with 1143 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Artefactos gerados (regeneráveis pelos scripts) — não versionar
okf-normalize-report.md
okf-rename-index.log
hub-okf-graph.dot
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# install-hooks.sh — Instala os git hooks OKF no Hub vault
# Uso: bash scripts/install-hooks.sh [--uninstall]
#
# Criado: 28-06-2026
VAULT="/media/ealmeida/Dados/Hub"
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
GIT_DIR=$(git -C "$VAULT" rev-parse --git-dir 2>/dev/null)
if [[ -z "$GIT_DIR" ]]; then
echo "ERRO: $VAULT não é um repositório git"
exit 1
fi
# Resolver path absoluto do .git
if [[ "$GIT_DIR" == ".git" ]]; then
GIT_ABSOLUTE="$VAULT/.git"
else
GIT_ABSOLUTE="$GIT_DIR"
fi
HOOKS_DIR="$GIT_ABSOLUTE/hooks"
HOOK_FILE="$HOOKS_DIR/pre-commit"
HOOK_SOURCE="$SCRIPTS_DIR/okf-validate.sh"
# ─── Desinstalar ──────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--uninstall" ]]; then
if [[ -L "$HOOK_FILE" ]]; then
rm "$HOOK_FILE"
echo "[OK] Hook removido: $HOOK_FILE"
elif [[ -f "$HOOK_FILE" ]]; then
echo "[AVISO] $HOOK_FILE não é um symlink — remover manualmente se necessário"
else
echo "[INFO] Nenhum hook instalado"
fi
exit 0
fi
# ─── Instalar ─────────────────────────────────────────────────────────────────
echo "=== Instalar OKF pre-commit hook ==="
echo "Vault: $VAULT"
echo "Git dir: $GIT_ABSOLUTE"
echo "Hook: $HOOK_FILE"
echo "Source: $HOOK_SOURCE"
echo ""
# Verificar que o script de validação existe
if [[ ! -f "$HOOK_SOURCE" ]]; then
echo "ERRO: Script não encontrado — $HOOK_SOURCE"
exit 1
fi
# Tornar executável
chmod +x "$HOOK_SOURCE"
# Criar diretório hooks se não existir
mkdir -p "$HOOKS_DIR"
# Backup do hook existente (se não for nosso symlink)
if [[ -f "$HOOK_FILE" ]] && [[ ! -L "$HOOK_FILE" ]]; then
BACKUP="$HOOK_FILE.backup.$(date +%Y%m%d)"
mv "$HOOK_FILE" "$BACKUP"
echo "[INFO] Hook existente guardado em: $BACKUP"
fi
# Remover symlink antigo se existir
[[ -L "$HOOK_FILE" ]] && rm "$HOOK_FILE"
# Criar symlink
ln -s "$HOOK_SOURCE" "$HOOK_FILE"
chmod +x "$HOOK_FILE"
echo "[OK] Hook instalado: $HOOK_FILE$HOOK_SOURCE"
echo ""
echo "Testar: git -C $VAULT commit --dry-run -m 'test'"
echo "Validar tudo: bash $HOOK_SOURCE --all"
echo "Desinstalar: bash $SCRIPTS_DIR/install-hooks.sh --uninstall"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
okf-convert-wikilinks.py — Fase 3: Converte [[wikilinks]] → [texto](path.md) nos index.md
OKF §5: links bundle-relative para navegação entre conceitos
Âmbito: apenas ficheiros index.md (navegação)
Corpo de documentos (PROC, QR, etc.) mantém wikilinks — OKF tolera e Obsidian renderiza ambos.
Uso:
python3 okf-convert-wikilinks.py [--dry-run] [--dir /path/to/Hub]
Criado: 28-06-2026
"""
import os
import re
import sys
from pathlib import Path
HUB_DEFAULT = "/media/ealmeida/Dados/Hub"
EXCLUDE_DIRS = {".stversions", "node_modules", ".git", ".obsidian", ".trash"}
# Padrão wikilink: [[NomeFicheiro]] ou [[NomeFicheiro|Alias]]
WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]')
def build_file_index(hub: Path) -> dict:
"""Constrói índice nome→path para resolução de wikilinks."""
index = {} # stem → Path relativo ao hub
for root, dirs, files in os.walk(hub):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
for fname in files:
if fname.endswith(".md"):
fp = Path(root) / fname
stem = fp.stem.lower()
rel = fp.relative_to(hub)
# Guardar o primeiro match (mais provável no vault activo)
if stem not in index:
index[stem] = rel
# Também indexar o nome completo sem extensão
full_name = fname.lower()
if full_name not in index:
index[full_name] = rel
return index
def resolve_wikilink(target: str, current_file: Path, file_index: dict, hub: Path) -> str:
"""Resolve [[target]] para um caminho relativo ao ficheiro actual."""
# Limpar o target (remover ^anchor, #heading, etc.)
target_clean = re.split(r'[#^]', target)[0].strip()
target_lower = target_clean.lower()
target_with_ext = target_lower + ".md" if not target_lower.endswith(".md") else target_lower
# Tentar resolver
resolved = file_index.get(target_with_ext) or file_index.get(target_lower)
if resolved:
# Calcular path relativo a partir do directório do ficheiro actual
try:
rel_path = os.path.relpath(hub / resolved, current_file.parent)
return rel_path.replace("\\", "/")
except Exception:
return str(resolved)
return None
def convert_wikilinks_in_file(filepath: Path, file_index: dict, hub: Path, dry_run: bool) -> dict:
"""Converte wikilinks no ficheiro. Retorna estatísticas."""
result = {"file": str(filepath.relative_to(hub)), "converted": 0, "unresolved": [], "action": "skip"}
try:
content = filepath.read_text(encoding="utf-8")
except Exception as e:
result["action"] = "error"
result["error"] = str(e)
return result
if "[[" not in content:
result["action"] = "no_wikilinks"
return result
def replace_wikilink(m):
target = m.group(1)
alias = m.group(2)
display = alias if alias else target
resolved_path = resolve_wikilink(target, filepath, file_index, hub)
if resolved_path:
result["converted"] += 1
return f"[{display}]({resolved_path})"
else:
# Manter como wikilink se não resolvível
result["unresolved"].append(target)
return m.group(0)
new_content = WIKILINK_RE.sub(replace_wikilink, content)
if new_content != content:
result["action"] = "converted"
if not dry_run:
filepath.write_text(new_content, encoding="utf-8")
else:
result["action"] = "no_changes"
return result
def main():
dry_run = "--dry-run" in sys.argv
hub = Path(HUB_DEFAULT)
for arg in sys.argv[1:]:
if arg.startswith("--dir="):
hub = Path(arg[6:])
if not hub.exists():
print(f"ERRO: Hub não encontrado em {hub}", file=sys.stderr)
sys.exit(1)
print(f"{'[DRY-RUN] ' if dry_run else ''}A construir índice de ficheiros…")
file_index = build_file_index(hub)
print(f" {len(file_index)} ficheiros indexados")
print(f"A converter wikilinks nos index.md…")
total_converted = 0
total_unresolved = []
files_changed = 0
for root, dirs, files in os.walk(hub):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
for fname in files:
if fname != "index.md":
continue
filepath = Path(root) / fname
result = convert_wikilinks_in_file(filepath, file_index, hub, dry_run)
if result["action"] == "converted":
files_changed += 1
total_converted += result["converted"]
total_unresolved.extend(result["unresolved"])
print(f" [OK] {result['file']}: {result['converted']} convertidos"
+ (f", {len(result['unresolved'])} não resolvidos" if result["unresolved"] else ""))
elif result["action"] == "error":
print(f" [ERRO] {result['file']}: {result.get('error')}")
print(f"\n=== Resultado ===")
print(f"Ficheiros alterados: {files_changed}")
print(f"Wikilinks convertidos: {total_converted}")
if total_unresolved:
print(f"Não resolvidos ({len(total_unresolved)}): {', '.join(set(total_unresolved))[:200]}")
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# okf-gen-graph.sh — Gera grafo OKF do Hub para integração com Wayland/visualização
#
# Uso:
# bash scripts/okf-gen-graph.sh → gera hub-okf-graph.dot
# bash scripts/okf-gen-graph.sh --svg → gera também hub-okf-graph.svg (requer graphviz)
# bash scripts/okf-gen-graph.sh --info → mostra inventário do bundle
#
# Requer: okf CLI (cargo install --git https://github.com/W4G1/okf)
#
# Criado: 28-06-2026
set -euo pipefail
VAULT="/media/ealmeida/Dados/Hub"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_DOT="$SCRIPT_DIR/hub-okf-graph.dot"
BLUE='\033[0;34m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[AVISO]${NC} $*"; }
if ! command -v okf &>/dev/null; then
echo -e "${RED}[ERRO]${NC} okf CLI não encontrado."
echo " Instalar: cargo install --git https://github.com/W4G1/okf"
echo " Rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
exit 1
fi
# Inventário do bundle
if [[ "${1:-}" == "--info" ]]; then
info "=== OKF Bundle Inventory ==="
okf info "$VAULT" 2>/dev/null
exit 0
fi
# Gerar grafo DOT
info "A gerar grafo OKF do Hub..."
okf graph "$VAULT" --dot 2>/dev/null > "$OUTPUT_DOT"
NODE_COUNT=$(grep -c "^ " "$OUTPUT_DOT" 2>/dev/null || echo "?")
ok "Grafo gerado: $OUTPUT_DOT ($NODE_COUNT nós/arestas)"
# Gerar SVG se graphviz disponível e --svg pedido
if [[ "${1:-}" == "--svg" ]]; then
OUTPUT_SVG="${OUTPUT_DOT%.dot}.svg"
if command -v dot &>/dev/null; then
info "A gerar SVG via graphviz..."
dot -Tsvg "$OUTPUT_DOT" -o "$OUTPUT_SVG" 2>/dev/null
ok "SVG gerado: $OUTPUT_SVG"
info "Abrir com: xdg-open $OUTPUT_SVG"
else
warn "graphviz não instalado — só o DOT foi gerado"
warn "Instalar: sudo apt install graphviz"
warn "Ou visualizar online: https://dreampuf.github.io/GraphvizOnline/"
fi
fi
info "Para Wayland F6: usar $OUTPUT_DOT como input de importação da estrutura Hub"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# okf-gen-logs.sh — Fase 4: Gera log.md por directório top-level
# OKF §7: log.md com histórico de alterações por data ISO, newest first
# Criado: 28-06-2026
HUB="/media/ealmeida/Dados/Hub"
DAYS=90 # Últimos N dias de histórico
DRY_RUN=false
if [[ "$1" == "--dry-run" ]]; then
DRY_RUN=true
echo "[DRY-RUN] Nenhum ficheiro será criado."
fi
# Directórios top-level a processar (excluir .stversions, node_modules, tmp)
TOP_DIRS=(
"00-Inbox"
"03-Propostas"
"04-Stack"
"05-Projectos"
"06-Operacoes"
"07-Clientes"
"90-Templates"
"99-Arquivo"
)
SINCE=$(date -d "-${DAYS} days" +%Y-%m-%d)
generate_log() {
local dir="$1"
local dir_path="$HUB/$dir"
local log_path="$dir_path/log.md"
if [[ ! -d "$dir_path" ]]; then
echo "[SKIP] $dir não existe"
return
fi
echo "A gerar log.md para $dir (últimos ${DAYS} dias desde ${SINCE})…"
# Obter commits que tocaram nesta pasta
local git_log
git_log=$(git -C "$HUB" log \
--since="$SINCE" \
--format="%cd|%s" \
--date=format:"%Y-%m-%d" \
-- "$dir/" 2>/dev/null)
if [[ -z "$git_log" ]]; then
echo " [INFO] Sem commits no período para $dir"
git_log=""
fi
# Agrupar por data e gerar markdown
local log_content
log_content="# Log de Actualizações — $dir
$(echo "$git_log" | awk -F'|' '
{
date=$1; msg=$2
if (date != prev_date) {
if (prev_date != "") print ""
print "## " date
prev_date=date
}
# Classificar entrada
if (msg ~ /^(feat|add|create|novo|cria)/) prefix="**Creation**"
else if (msg ~ /^(fix|corr|resolv)/) prefix="**Fix**"
else if (msg ~ /^(archive|arquiv)/) prefix="**Archive**"
else if (msg ~ /^(delete|remov|apag)/) prefix="**Deletion**"
else prefix="**Update**"
print "* " prefix ": " msg
}' 2>/dev/null || echo "_(sem histórico git no período)_")
"
if [[ "$DRY_RUN" == "true" ]]; then
echo " [DRY] $log_path"
echo " Primeiras linhas: $(echo "$log_content" | head -5)"
else
echo "$log_content" > "$log_path"
echo " [OK] $log_path"
fi
}
echo "=== okf-gen-logs.sh — $(date -I) ==="
echo "Hub: $HUB"
echo ""
for dir in "${TOP_DIRS[@]}"; do
generate_log "$dir"
done
echo ""
echo "=== Concluído ==="
echo "log.md gerado em ${#TOP_DIRS[@]} directórios."
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""
okf-normalize.py — Fase 1: Normaliza frontmatter OKF em todos os .md do Hub
Adiciona/completa: type, title, description, timestamp
OKF SPEC §4.1: type é o único campo obrigatório
Uso:
python3 okf-normalize.py [--dry-run] [--dir /path/to/Hub]
Criado: 28-06-2026
"""
import os
import re
import sys
import subprocess
from datetime import datetime, timezone
from pathlib import Path
HUB_DEFAULT = "/media/ealmeida/Dados/Hub"
# Directórios excluídos do scan
EXCLUDE_DIRS = {
".stversions",
"node_modules",
".git",
".obsidian",
".trash",
"99-Arquivo",
}
# Ficheiros reservados OKF — sem frontmatter obrigatório
OKF_RESERVED = {"index.md", "log.md"}
# Taxonomia Hub → OKF type
def infer_type(filepath: Path) -> str:
name = filepath.name
parts = str(filepath).lower()
if name.startswith("PROC-") or name.startswith("proc-"):
return "Playbook"
if name.startswith("QR-") or name.startswith("qr-"):
return "Reference"
if name.lower() in ("index.md", "index.md"):
return "Index"
if name.endswith("-SPEC.md") or name == "SPEC.md":
return "Specification"
if name.startswith("STATUS"):
return "Status"
if name.upper().startswith("CHANGELOG"):
return "Changelog"
if name.upper().startswith("README"):
return "Reference"
if "proposta" in parts or "orcamento" in parts or "budget" in parts:
return "Proposal"
if "90-templates" in parts or "/template" in parts:
return "Template"
if "07-clientes" in parts:
return "Client Profile"
return "Document"
def get_git_timestamp(filepath: Path, hub: Path) -> str:
"""Obter timestamp da última modificação via git log."""
try:
rel = filepath.relative_to(hub)
result = subprocess.run(
["git", "log", "-1", "--format=%cI", "--", str(rel)],
cwd=str(hub),
capture_output=True,
text=True,
timeout=5,
)
ts = result.stdout.strip()
if ts:
return ts
except Exception:
pass
# fallback: mtime do ficheiro
mtime = filepath.stat().st_mtime
return datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
def parse_frontmatter(content: str):
"""Retorna (frontmatter_str, body_str, has_fm) ou (None, content, False)."""
if content.startswith("---\n"):
end = content.find("\n---\n", 4)
if end != -1:
fm = content[4:end]
body = content[end + 5:]
return fm, body, True
return None, content, False
def first_useful_sentence(body: str) -> str:
"""Extrai primeira frase útil do body para description."""
# Remover headings, listas, blocos de código
lines = body.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("#"):
continue
if line.startswith("```"):
continue
if line.startswith("|"):
continue
if line.startswith("-") or line.startswith("*"):
# Lista: usar conteúdo sem bullet
line = re.sub(r"^[-*]\s+", "", line)
# Limpar markdown inline
line = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
line = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", line)
line = line.strip()
if len(line) > 10:
# Truncar em 120 chars
return line[:120].rstrip(".") + ("" if len(line) > 120 else "")
return ""
def normalize_file(filepath: Path, hub: Path, dry_run: bool) -> dict:
"""Normaliza um ficheiro. Retorna dict com acção tomada."""
result = {"file": str(filepath.relative_to(hub)), "action": "skip", "changes": []}
try:
content = filepath.read_text(encoding="utf-8")
except Exception as e:
result["action"] = "error"
result["error"] = str(e)
return result
fm_str, body, has_fm = parse_frontmatter(content)
if not has_fm:
# Injetar frontmatter mínimo
inferred_type = infer_type(filepath)
title = filepath.stem.replace("-", " ").replace("_", " ").title()
description = first_useful_sentence(body)
timestamp = get_git_timestamp(filepath, hub)
new_fm_lines = [f"type: {inferred_type}", f"title: {title}"]
if description:
new_fm_lines.append(f"description: >-\n {description}")
new_fm_lines.append(f"timestamp: {timestamp}")
new_content = "---\n" + "\n".join(new_fm_lines) + "\n---\n" + content
result["action"] = "add_frontmatter"
result["changes"] = new_fm_lines
else:
# Ficheiro já tem frontmatter — completar campos em falta
fm_lines = fm_str.split("\n")
changes = []
has_type = any(line.startswith("type:") for line in fm_lines)
has_title = any(line.startswith("title:") for line in fm_lines)
has_description = any(line.startswith("description:") for line in fm_lines)
has_timestamp = any(
line.startswith("timestamp:") or line.startswith("date:")
for line in fm_lines
)
if not has_type:
inferred_type = infer_type(filepath)
fm_lines.insert(0, f"type: {inferred_type}")
changes.append(f"+ type: {inferred_type}")
if not has_title:
title = filepath.stem.replace("-", " ").replace("_", " ").title()
# Inserir após type
type_idx = next(
(i for i, l in enumerate(fm_lines) if l.startswith("type:")), 0
)
fm_lines.insert(type_idx + 1, f"title: {title}")
changes.append(f"+ title: {title}")
if not has_description:
desc = first_useful_sentence(body)
if desc:
desc_entry = f"description: >-\n {desc}"
title_idx = next(
(i for i, l in enumerate(fm_lines) if l.startswith("title:")), 1
)
fm_lines.insert(title_idx + 1, desc_entry)
changes.append(f"+ description: {desc[:60]}")
if not has_timestamp:
ts = get_git_timestamp(filepath, hub)
fm_lines.append(f"timestamp: {ts}")
changes.append(f"+ timestamp: {ts}")
if not changes:
result["action"] = "already_ok"
return result
new_fm = "\n".join(fm_lines)
new_content = "---\n" + new_fm + "\n---\n" + body
result["action"] = "update_frontmatter"
result["changes"] = changes
if not dry_run:
try:
filepath.write_text(new_content, encoding="utf-8")
except Exception as e:
result["action"] = "error"
result["error"] = str(e)
return result
def scan_hub(hub: Path, dry_run: bool):
"""Scan recursivo do vault Hub."""
stats = {"add": 0, "update": 0, "ok": 0, "skip": 0, "error": 0}
report_lines = [
f"# okf-normalize — {'DRY-RUN' if dry_run else 'EXECUÇÃO'}{datetime.now().isoformat()[:16]}",
f"Hub: {hub}",
"",
]
for root, dirs, files in os.walk(hub):
root_path = Path(root)
# Excluir directórios
dirs[:] = [
d for d in dirs
if d not in EXCLUDE_DIRS and not d.startswith(".")
]
for fname in files:
if not fname.endswith(".md"):
continue
if fname.lower() in OKF_RESERVED:
continue
filepath = root_path / fname
result = normalize_file(filepath, hub, dry_run)
action = result["action"]
if action == "add_frontmatter":
stats["add"] += 1
report_lines.append(f"[ADD] {result['file']}")
for c in result["changes"]:
report_lines.append(f" {c}")
elif action == "update_frontmatter":
stats["update"] += 1
report_lines.append(f"[UPD] {result['file']}")
for c in result["changes"]:
report_lines.append(f" {c}")
elif action == "already_ok":
stats["ok"] += 1
elif action == "error":
stats["error"] += 1
report_lines.append(f"[ERR] {result['file']}: {result.get('error')}")
else:
stats["skip"] += 1
report_lines += [
"",
"## Resultado",
f"- Frontmatter adicionado: {stats['add']}",
f"- Frontmatter actualizado: {stats['update']}",
f"- Já conformes: {stats['ok']}",
f"- Erros: {stats['error']}",
f"- Ignorados: {stats['skip']}",
]
return stats, "\n".join(report_lines)
def main():
dry_run = "--dry-run" in sys.argv
hub = Path(HUB_DEFAULT)
for arg in sys.argv[1:]:
if arg.startswith("--dir="):
hub = Path(arg[6:])
if not hub.exists():
print(f"ERRO: Hub não encontrado em {hub}", file=sys.stderr)
sys.exit(1)
print(f"{'[DRY-RUN] ' if dry_run else ''}A normalizar OKF em {hub}")
stats, report = scan_hub(hub, dry_run)
report_path = hub / "04-Stack/02.04-Sistemas/MemoriaCentral/scripts/okf-normalize-report.md"
report_path.write_text(report, encoding="utf-8")
print(report_path.read_text(encoding="utf-8").split("## Resultado")[1].strip())
print(f"\nRelatório completo: {report_path}")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# okf-rename-index.sh — Fase 2: Renomeia INDEX.md → index.md no vault Hub
# OKF §6: index.md é ficheiro reservado (lowercase)
# Criado: 28-06-2026
HUB="/media/ealmeida/Dados/Hub"
DRY_RUN=false
LOG_FILE="$(dirname "$0")/okf-rename-index.log"
# Modo dry-run com --dry-run
if [[ "$1" == "--dry-run" ]]; then
DRY_RUN=true
echo "[DRY-RUN] Nenhum ficheiro será alterado."
fi
echo "=== okf-rename-index.sh — $(date -I) ===" | tee "$LOG_FILE"
echo "Hub: $HUB" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
COUNT=0
ERRORS=0
# Encontrar todos os INDEX.md excluindo .stversions e node_modules
while IFS= read -r -d '' INDEX_FILE; do
DIR=$(dirname "$INDEX_FILE")
TARGET="$DIR/index.md"
# Verificar se já existe index.md (colisão)
if [[ -f "$TARGET" ]]; then
echo "[SKIP] Colisão: $TARGET já existe — manter INDEX.md" | tee -a "$LOG_FILE"
((ERRORS++))
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY] $INDEX_FILE$TARGET" | tee -a "$LOG_FILE"
else
# Usar git mv para preservar histórico
if git -C "$HUB" mv "${INDEX_FILE#$HUB/}" "${TARGET#$HUB/}" 2>>"$LOG_FILE"; then
echo "[OK] $INDEX_FILE$TARGET" | tee -a "$LOG_FILE"
else
echo "[ERRO] Falha: $INDEX_FILE" | tee -a "$LOG_FILE"
((ERRORS++))
continue
fi
fi
((COUNT++))
done < <(find "$HUB" -name "INDEX.md" \
-not -path "*/.stversions/*" \
-not -path "*/node_modules/*" \
-not -path "*/99-Arquivo/*" \
-print0)
# Incluir 99-Arquivo separadamente (sem git mv — só rename simples)
while IFS= read -r -d '' INDEX_FILE; do
DIR=$(dirname "$INDEX_FILE")
TARGET="$DIR/index.md"
if [[ -f "$TARGET" ]]; then
echo "[SKIP] Colisão: $TARGET já existe" | tee -a "$LOG_FILE"
((ERRORS++))
continue
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY-ARQUIVO] $INDEX_FILE$TARGET" | tee -a "$LOG_FILE"
else
if mv "$INDEX_FILE" "$TARGET" 2>>"$LOG_FILE"; then
echo "[OK-ARQUIVO] $INDEX_FILE$TARGET" | tee -a "$LOG_FILE"
else
echo "[ERRO-ARQUIVO] $INDEX_FILE" | tee -a "$LOG_FILE"
((ERRORS++))
continue
fi
fi
((COUNT++))
done < <(find "$HUB/99-Arquivo" -name "INDEX.md" \
-not -path "*/.stversions/*" \
-print0)
echo "" | tee -a "$LOG_FILE"
echo "=== Resultado ===" | tee -a "$LOG_FILE"
echo "Renomeados: $COUNT" | tee -a "$LOG_FILE"
echo "Erros/Colisoes: $ERRORS" | tee -a "$LOG_FILE"
echo "" | tee -a "$LOG_FILE"
if [[ "$DRY_RUN" == "false" && $COUNT -gt 0 ]]; then
echo "=== Actualizar referencias internas ===" | tee -a "$LOG_FILE"
# Substituir [INDEX.md] e (INDEX.md) por index.md nas referencias
grep -rl "INDEX\.md" "$HUB" \
--include="*.md" \
--exclude-dir=".stversions" \
--exclude-dir="node_modules" | while read -r FILE; do
sed -i 's/\bINDEX\.md\b/index.md/g' "$FILE"
echo "[REF] $FILE" >> "$LOG_FILE"
done
echo "Referencias actualizadas — ver log para detalhes." | tee -a "$LOG_FILE"
fi
echo "" | tee -a "$LOG_FILE"
echo "Log: $LOG_FILE"
+352
View File
@@ -0,0 +1,352 @@
#!/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}