#!/usr/bin/env python3 """ Hook PostToolUse: verifica conformidade básica com `.agents/skills/boas-praticas-oo/SKILL.md` em arquivos Python de `code/engine/` recém-escritos ou editados por Claude Code. Este script não persiste dados e não é importado pelo `engine` — é uma ferramenta de infraestrutura de desenvolvimento, acionada pelo hook configurado em `.claude/settings.json`. Ele recebe no stdin o JSON de entrada do hook (evento PostToolUse, tool Write ou Edit), lê o arquivo do disco (já gravado nesse ponto) e checa, via `ast`, se o arquivo tem: docstring de módulo, docstring em toda classe pública, docstring em toda função/método público, e nenhum `except` genérico demais (`except:` puro, ou `except Exception:` cujo corpo é só `pass`). Ele não verifica nomenclatura em PT-BR (a checagem por regex geraria falsos positivos demais para identificadores técnicos/externos) — isso continua sendo responsabilidade da revisão humana ou do `/code-review`. Quando encontra violações, devolve um JSON com `"decision": "block"` e `"reason"` explicando o que falta, para que o próprio Claude Code corrija antes de considerar a tarefa concluída. Nunca bloqueia a gravação em si (o arquivo já foi escrito quando o hook roda) — o bloqueio é apenas o sinal que leva o agente a corrigir na mesma tarefa. """ from __future__ import annotations import ast import json import sys from pathlib import Path def arquivo_e_alvo_da_verificacao(caminho: Path) -> bool: """ Decide se um caminho deve ser verificado por esta checagem. Parâmetros: caminho: Caminho absoluto do arquivo editado ou criado. Retorna: True quando o arquivo é um `.py` dentro de `code/engine/`. """ partes = caminho.parts return caminho.suffix == ".py" and "code" in partes and "engine" in partes def arquivo_e_de_teste(caminho: Path) -> bool: """ Decide se um arquivo é um módulo de teste, onde funções/métodos de teste ficam isentos da exigência de docstring (o nome do teste já documenta o comportamento, convenção adotada pela skill `tdd`). Parâmetros: caminho: Caminho absoluto do arquivo. Retorna: True quando o arquivo está em `code/engine/testes/` ou seu nome começa com ``test_``. """ return "testes" in caminho.parts or caminho.name.startswith("test_") def coletar_violacoes_de_docstring(arvore: ast.Module, e_arquivo_de_teste: bool) -> list[str]: """ Coleta violações de docstring obrigatória em módulo, classes e funções públicas. Parâmetros: arvore: AST do arquivo já analisado. e_arquivo_de_teste: Quando True, não exige docstring em funções e métodos (apenas em módulo e classes) — testes se documentam pelo nome. Retorna: Lista de mensagens de violação, uma por item sem docstring. """ violacoes: list[str] = [] if not ast.get_docstring(arvore): violacoes.append("Falta docstring de módulo no topo do arquivo.") for no in arvore.body: if isinstance(no, ast.ClassDef) and not no.name.startswith("_"): if not ast.get_docstring(no): violacoes.append(f"Linha {no.lineno}: classe '{no.name}' sem docstring.") if not e_arquivo_de_teste: violacoes.extend(_violacoes_de_metodos(no)) elif isinstance(no, (ast.FunctionDef, ast.AsyncFunctionDef)) and not e_arquivo_de_teste: if not no.name.startswith("_") and not ast.get_docstring(no): violacoes.append(f"Linha {no.lineno}: função '{no.name}' sem docstring.") return violacoes def _violacoes_de_metodos(classe: ast.ClassDef) -> list[str]: """ Coleta violações de docstring nos métodos públicos de uma classe. Parâmetros: classe: Nó da classe cujos métodos serão verificados. Retorna: Lista de mensagens de violação para métodos públicos sem docstring. """ violacoes = [] for membro in classe.body: if isinstance(membro, (ast.FunctionDef, ast.AsyncFunctionDef)): if not membro.name.startswith("_") and not ast.get_docstring(membro): violacoes.append( f"Linha {membro.lineno}: método '{classe.name}.{membro.name}' sem docstring.") return violacoes def coletar_violacoes_de_except_generico(arvore: ast.Module) -> list[str]: """ Coleta usos de `except` genéricos demais: bare `except:` e `except Exception:` cujo corpo é só `pass`. Parâmetros: arvore: AST do arquivo já analisado. Retorna: Lista de mensagens de violação, uma por `except` problemático. """ violacoes: list[str] = [] for no in ast.walk(arvore): if not isinstance(no, ast.ExceptHandler): continue e_bare = no.type is None e_exception_generica = isinstance(no.type, ast.Name) and no.type.id == "Exception" corpo_e_so_pass = len(no.body) == 1 and isinstance(no.body[0], ast.Pass) if e_bare: violacoes.append( f"Linha {no.lineno}: 'except:' genérico captura tudo, inclusive " "KeyboardInterrupt/SystemExit — use uma exceção específica.") elif e_exception_generica and corpo_e_so_pass: violacoes.append( f"Linha {no.lineno}: 'except Exception: pass' engole o erro " "silenciosamente — trate, registre ou levante uma exceção específica.") return violacoes def verificar(caminho: Path) -> list[str]: """ Executa todas as checagens de boas práticas sobre um arquivo Python. Parâmetros: caminho: Caminho absoluto do arquivo a verificar. Retorna: Lista de mensagens de violação encontradas. Lista vazia quando o arquivo está conforme, o arquivo não existe mais, ou o conteúdo tem erro de sintaxe (não é responsabilidade deste hook apontar erros de sintaxe). """ try: codigo_fonte = caminho.read_text(encoding="utf-8") except OSError: return [] try: arvore = ast.parse(codigo_fonte, filename=str(caminho)) except SyntaxError: return [] e_arquivo_de_teste = arquivo_e_de_teste(caminho) return ( coletar_violacoes_de_docstring(arvore, e_arquivo_de_teste) + coletar_violacoes_de_except_generico(arvore) ) def main() -> None: """ Ponto de entrada do hook: lê o evento no stdin e reporta violações encontradas. Não gera efeitos colaterais além de imprimir no stdout o JSON de saída esperado pelo contrato de hooks do Claude Code. Sempre encerra com código de saída 0 — o "bloqueio" é comunicado via ``{"decision": "block"}``, não via código de saída, porque o arquivo já foi gravado quando este hook roda (evento PostToolUse). """ try: entrada = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): return caminho_bruto = (entrada.get("tool_input") or {}).get("file_path") if not caminho_bruto: return caminho = Path(caminho_bruto).resolve() if not arquivo_e_alvo_da_verificacao(caminho): return violacoes = verificar(caminho) if not violacoes: return motivo = ( f"Padrões de código (.agents/skills/boas-praticas-oo/SKILL.md) " f"não atendidos em {caminho.name}:\n- " + "\n- ".join(violacoes) ) print(json.dumps({"decision": "block", "reason": motivo}, ensure_ascii=False)) if __name__ == "__main__": main()