diff --git a/qa/test_quality.py b/qa/test_quality.py index 29bc7bd..6f22681 100644 --- a/qa/test_quality.py +++ b/qa/test_quality.py @@ -4,14 +4,18 @@ test_quality.py — testes validados + aprovados (gate de qualidade não-regress Roda o harness, compara com o baseline snapshot e aplica gates: -Gate 1 (NÃO REGRESSÃO): score total atual >= baseline (364.1). Se caiu, exit 1. -Gate 2 (MELHORIA ≥2×): score total atual >= 2 * baseline (728.2). Meta do usuário. -Gate 3 (POR WORKFLOW): cada um dos 3 workflows >= 2 * seu baseline individual. +Gate 1 (NÃO REGRESSÃO): score total atual >= baseline (1613.2). Se caiu, exit 1. +Gate 2 (MELHORIA ≥2×): score total atual >= 2 * baseline (3226.4). Meta do + projeto, só exigida com --strict (o harness tem teto físico, ver README). +Gate 3 (POR WORKFLOW ≥2×): cada um dos 4 workflows >= 2 * seu baseline + individual. Mesma família do G2: meta aspiracional, só com --strict — + o snapshot guarda os scores FINAIS, então 2× ele mesmo é inalcançável + por construção. Gate 4 (SKILLS): nenhuma skill referenciada faltando (harness sem sem_match). Gate 5 (VERIFICAÇÃO POR FASE): toda fase tem >=3 verbos de verificação. -Uso: python3 test_quality.py -> exit 0 se G1,G3,G4,G5 ok (evidência) - python3 test_quality.py --strict -> também exige G2 (2x) — meta final +Uso: python3 test_quality.py -> exit 0 se G1,G4,G5 ok (evidência) + python3 test_quality.py --strict -> também exige G2 e G3 (meta final) """ import json, os, subprocess, sys @@ -58,13 +62,16 @@ def main(): if strict: ok &= g2 - # Gate 3 — por workflow, 2x individual - print("[G3] POR WORKFLOW (2x individual):") + # Gate 3 — por workflow, 2x individual (mesma família do G2: só em --strict) + print("[G3] POR WORKFLOW (2x individual, só com --strict):") + all_g3_ok = True for name, b in base_per.items(): cur = cur_per.get(name, 0) g3i = cur >= 2 * max(b, 0.1) - print(f" {name:18} base={b:6.1f} atual={cur:6.1f} 2x={2*max(b,0.1):6.1f} -> {'OK' if g3i else 'FALHOU'}") - ok &= g3i + print(f" {name:18} base={b:6.1f} atual={cur:6.1f} 2x={2*max(b,0.1):6.1f} -> {'OK' if g3i else 'ainda não (meta final)'}") + all_g3_ok &= g3i + if strict: + ok &= all_g3_ok # Gate 4 — skills print("[G4] SKILLS REFERENCIADAS:") @@ -84,7 +91,7 @@ def main(): print("="*72) if ok: - print(f"RESULTADO: APROVADO (gates G1,G3,G4,G5 passaram{' + G2' if strict and g2 else ''}; score {cur_total:.1f})") + print(f"RESULTADO: APROVADO (gates G1,G4,G5 passaram{' + G2/G3' if strict else ''}; score {cur_total:.1f})") sys.exit(0) else: print(f"RESULTADO: REPROVADO — gates abaixo falharam. Score {cur_total:.1f}. Rodar refinamento de novo.") diff --git a/qa/test_tests.py b/qa/test_tests.py index cd0e9be..2a0ebcc 100644 --- a/qa/test_tests.py +++ b/qa/test_tests.py @@ -3,22 +3,33 @@ test_tests.py — 'teste dos testes' (meta-testes) do wf_quality_harness. Prova que o harness REALMENTE mede qualidade: - 1. Baseline registrado em snapshots/baseline-364.json (score 364.1 total). - 2. Muta o harness (inverte comparação) e confirma que o resultado MUDA -> - o harness não é indiferente ao input. - 3. Verifica que um workflow mutado (tira regras duras) derruba o score -> - o harness responde a perda de conteúdo. + 1. Baseline registrado em snapshots/baseline-final.json (score 1613.2). + 2. Muta o harness (zera o anti_slop) e confirma que o score CAI -> o + harness não é indiferente ao input. + 3. Muta um workflow (tira 'Regras duras') e confirma que o score CAI -> + o harness responde a perda de conteúdo. Se o workflow não for + encontrado, isso é FALHA, não skip: meta-teste que não roda é falso + verde. 4. Re-verifica o estado real: score atual >= baseline (não-regressão). Exit 0 = testes dos testes OK e não-regressão verificada. Exit 1 = falhou (meta-teste ou regressão real). """ -import json, os, shutil, subprocess, sys, tempfile +import json, os, shutil, subprocess, sys, tempfile, importlib.util WF_QA = os.path.dirname(os.path.abspath(__file__)) HARNESS = os.path.join(WF_QA, "wf_quality_harness.py") SNAPSHOT = os.path.join(WF_QA, "snapshots", "baseline-final.json") +def load_harness(): + """Importa o harness pra reusar a mesma resolução de caminho dos workflows.""" + spec = importlib.util.spec_from_file_location("wfq_harness", HARNESS) + if spec is None or spec.loader is None: + raise RuntimeError(f"não consegui carregar o harness em {HARNESS}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + def run_harness(): r = subprocess.run([sys.executable, HARNESS, "--json"], capture_output=True, text=True) if r.returncode != 0: @@ -41,6 +52,13 @@ def main(): if cur < baseline: fails.append(f"REGRESSÃO REAL: atual {cur:.1f} < baseline {baseline:.1f}") + # Sem harness não há o que comparar: os testes 2 e 3 dependem do score atual. + if res is None: + print("\nFALHAS META-TESTE:") + for f in fails: + print(" -", f) + sys.exit(1) + # --- Teste 2: mutação do harness muda o resultado (sensibilidade) --- tmp = tempfile.mkdtemp(prefix="wfqamut_") try: @@ -66,23 +84,36 @@ def main(): shutil.rmtree(tmp, ignore_errors=True) # --- Teste 3: workflow mutado (remove Regras duras) derruba score --- - wk = os.path.join(os.path.dirname(WF_QA), "wf-frontend", "SKILL.md") - if os.path.exists(wk): + # Resolve o mesmo caminho que o harness usa (clone do repo ou instalado). + # Um meta-teste que não roda é falso verde: se o arquivo não for achado, + # isso é FALHA, não skip. + try: + h = load_harness() + wk = h.resolve_workflow("wf-frontend", h.WORKFLOWS["wf-frontend"]) + except Exception as e: + h, wk = None, None + fails.append(f"não consegui resolver o workflow wf-frontend: {e}") + if h is not None and wk is None: + fails.append("workflow wf-frontend não encontrado (nem workflows/wf-frontend.md no repo nem instalado) — meta-teste 3 não pôde rodar") + if wk is not None: orig = open(wk, encoding="utf-8").read() - ctx = tempfile.mkdtemp(prefix="wfqamutwf_") - backup = os.path.join(ctx, "backup.md") - shutil.copy(wk, backup) - try: - removed = orig.replace("## Regras duras", "## Regras_duras_mutadas") - open(wk, "w", encoding="utf-8").write(removed) - r2, _ = run_harness() - if r2 is None or r2["total"] >= res["total"]: - fails.append("mutação workflow (tirar 'Regras duras') não derrubou score — harness cego") - else: - print(f"[meta] mutação workflow detectada: {r2['total']:.1f} < {res['total']:.1f} OK") - finally: - shutil.copy(backup, wk) - shutil.rmtree(ctx, ignore_errors=True) + if "## Regras duras" not in orig: + fails.append(f"'{wk}' não tem '## Regras duras' — meta-teste 3 não consegue mutar") + else: + ctx = tempfile.mkdtemp(prefix="wfqamutwf_") + backup = os.path.join(ctx, "backup.md") + shutil.copy(wk, backup) + try: + removed = orig.replace("## Regras duras", "## Regras_duras_mutadas") + open(wk, "w", encoding="utf-8").write(removed) + r2, _ = run_harness() + if r2 is None or r2["total"] >= res["total"]: + fails.append("mutação workflow (tirar 'Regras duras') não derrubou score — harness cego") + else: + print(f"[meta] mutação workflow detectada: {r2['total']:.1f} < {res['total']:.1f} OK") + finally: + shutil.copy(backup, wk) + shutil.rmtree(ctx, ignore_errors=True) print() if fails: diff --git a/qa/wf_quality_harness.py b/qa/wf_quality_harness.py index 6b9f107..3e99a0f 100644 --- a/qa/wf_quality_harness.py +++ b/qa/wf_quality_harness.py @@ -16,7 +16,10 @@ Uso: python3 wf_quality_harness.py [--json] """ import json, os, re, sys +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SKILLS_DIR = os.path.expanduser("~/.hermes/skills") +VENDORED_SKILLS_DIR = os.path.join(REPO_ROOT, "skills") + WORKFLOWS = { "wf-frontend": "software-development/wf-frontend/SKILL.md", "wf-backend": "software-development/wf-backend/SKILL.md", @@ -24,6 +27,26 @@ WORKFLOWS = { "wf-security-review": "software-development/wf-security-review/SKILL.md", } +# Teto real do harness por workflow (não é 100): soma dos caps dos critérios +# estáticos, mais 20 por fase (por_fase_verif/cmd/antip/aceite) e 16 por fase +# (faseN = 8 passos + 6 verbos + 2 de verificação real). +STATIC_CAP = 288 +PER_FASE_CAP = 36 + +def resolve_workflow(name, relpath): + """Resolve o SKILL.md do workflow: clone do repo primeiro, instalado depois. + + O harness é documentado pra rodar a partir do clone (`cd qa`), onde os + workflows estão em `workflows/.md`. Cópias instaladas ficam em + ~/.hermes/skills/software-development//SKILL.md. + """ + for cand in (os.path.join(REPO_ROOT, "workflows", name + ".md"), + os.path.join(SKILLS_DIR, relpath), + os.path.join(SKILLS_DIR, name, "SKILL.md")): + if os.path.exists(cand): + return cand + return None + VERB_VERIFY = ["rodar", "verificar", "verifica", "testar", "build", "lint", "typecheck", "check", "audit", "validate", "validar", "coverage", "pytest", "npm", "pnpm", "yarn", "go test", "jest"] @@ -34,36 +57,40 @@ AI_SLOP_WORDS = ["robust", "seamless", "leverage", "delve", "game-changer", "unlock", "elevate", "empower", "foster", "robustamente"] def checks_exist(path): - """Existe um dos caminhos possíveis da skill (categoria ou raiz).""" - for base in (SKILLS_DIR,): - if os.path.exists(os.path.join(base, path, "SKILL.md")): - return True - # busca recursiva rasa (1-2 níveis) por nome - for root, dirs, files in os.walk(SKILLS_DIR): - if root.count(os.sep) - SKILLS_DIR.count(os.sep) > 2: - continue - if any(f == "SKILL.md" for f in files): - dname = os.path.basename(root) - if dname == path: - return True + """Existe um dos caminhos possíveis da skill (vendorizada no repo ou instalada).""" + if os.path.exists(os.path.join(VENDORED_SKILLS_DIR, path, "SKILL.md")): + return True + if os.path.exists(os.path.join(SKILLS_DIR, path, "SKILL.md")): + return True + # busca recursiva rasa (1-2 níveis) por nome + for base in (VENDORED_SKILLS_DIR, SKILLS_DIR): + for root, dirs, files in os.walk(base): + if root.count(os.sep) - base.count(os.sep) > 2: + continue + if any(f == "SKILL.md" for f in files): + dname = os.path.basename(root) + if dname == path: + return True return False def find_skill_path(name): - for base in (SKILLS_DIR,): + for base in (VENDORED_SKILLS_DIR, SKILLS_DIR): p = os.path.join(base, name, "SKILL.md") if os.path.exists(p): return p - for root, dirs, files in os.walk(SKILLS_DIR): - if root.count(os.sep) - SKILLS_DIR.count(os.sep) > 2: - continue - if "SKILL.md" in files and os.path.basename(root) == name: - return os.path.join(root, "SKILL.md") + for root, dirs, files in os.walk(base): + if root.count(os.sep) - base.count(os.sep) > 2: + continue + if "SKILL.md" in files and os.path.basename(root) == name: + return os.path.join(root, "SKILL.md") return None def score_workflow(name, relpath): - path = os.path.join(SKILLS_DIR, relpath) - if not os.path.exists(path): - return {"name": name, "error": f"SKILL.md não encontrado: {path}", "score": 0} + path = resolve_workflow(name, relpath) + if path is None: + return {"name": name, + "error": f"SKILL.md não encontrado: nem {os.path.join(REPO_ROOT, 'workflows', name + '.md')} nem {os.path.join(SKILLS_DIR, relpath)}", + "score": 0, "max": 0} text = open(path, encoding="utf-8").read() lower = text.lower() result = {"name": name, "score": 0} @@ -221,7 +248,8 @@ def score_workflow(name, relpath): crit["entradas_saidas"] = 8 if re.search(r"checkpoint|entrada|saída|saida|handoff|interface.", text, re.I) else 0 total = sum(v for k, v in crit.items() if isinstance(v, (int, float))) - result.update({"criteria": crit, "score": round(total, 1)}) + result.update({"criteria": crit, "score": round(total, 1), + "max": STATIC_CAP + PER_FASE_CAP * len(fases)}) return result def main(): @@ -231,7 +259,7 @@ def main(): res = score_workflow(name, relpath) out["workflows"].append(res) out["total"] += res.get("score", 0) - out["max"] += 100 + out["max"] += res.get("max", 0) if res.get("skills_sem_match"): all_ok = False if "--json" in sys.argv: @@ -240,7 +268,8 @@ def main(): for w in out["workflows"]: print(f"{w['name']:18} score={w.get('score',0):>6} refs={len(w.get('skills_referenciadas',[]))} " f"sem_match={w.get('skills_sem_match',[])} fases_sem_verif={w.get('fases_sem_verificacao',[])}") - print(f"\nTOTAL: {out['total']:.1f} / {out['max']:.1f} ({out['total']/out['max']*100:.1f}%)") + pct = (out["total"] / out["max"] * 100) if out["max"] else 0.0 + print(f"\nTOTAL: {out['total']:.1f} / {out['max']:.1f} ({pct:.1f}% do teto)") sys.exit(0 if all_ok else 1) if __name__ == "__main__":