diff --git a/.github/workflows/verify-invocation.yml b/.github/workflows/verify-invocation.yml index 1bfb3d6a..6552ae7f 100644 --- a/.github/workflows/verify-invocation.yml +++ b/.github/workflows/verify-invocation.yml @@ -10,12 +10,14 @@ on: paths: - 'agents/**' - 'scripts/verify-invocation.py' + - 'scripts/test-reviewer-integrity.py' - 'scripts/test-installer-transport.mjs' - '.github/workflows/verify-invocation.yml' pull_request: paths: - 'agents/**' - 'scripts/verify-invocation.py' + - 'scripts/test-reviewer-integrity.py' - 'scripts/test-installer-transport.mjs' workflow_dispatch: @@ -32,5 +34,7 @@ jobs: node-version: '20' - name: Verificar eixo de invocação run: python3 scripts/verify-invocation.py + - name: Testar gate determinístico do Reviewer + run: python3 scripts/test-reviewer-integrity.py - name: Smoke test de transporte do installer run: node scripts/test-installer-transport.mjs diff --git a/.gitignore b/.gitignore index aa604b4d..d9eff3c8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ site/ .npm-cache/ .tmp-mkdocs-site/ specs/ +__pycache__/ +*.py[cod] diff --git a/agents/reversa-reviewer/SKILL.md b/agents/reversa-reviewer/SKILL.md index b1508f7a..09f17547 100644 --- a/agents/reversa-reviewer/SKILL.md +++ b/agents/reversa-reviewer/SKILL.md @@ -153,6 +153,42 @@ Specs nas pastas de unit em `/` são atualizadas in-place com as Os artefatos próprios do Reviewer (`confidence-report.md`, `questions.md`, `gaps.md`, `cross-review-result.md`) são transversais à organização escolhida em `[specs]` e ficam na raiz de `/`, fora das pastas de unit. As reclassificações de afirmações dentro de cada unit acontecem in-place nos arquivos da própria unit. +## Gate de integridade antes do checkpoint + +Antes de informar conclusão ao Reversa, execute o validador distribuído com esta skill: + +```bash +python3 /scripts/verify-discovery-review.py \ + \ + --doc-level \ + --output .reversa/reviewer-integrity-result.json +``` + +Se a revisão cruzada foi realmente executada, acrescente `--cross-review-performed`. + +Um waiver só pode ser considerado quando o usuário o aprovou explicitamente e já forneceu um arquivo JSON com esta forma: + +```json +{ + "schema_version": 1, + "approved_by": "", + "reason": "", + "finding_ids": [""] +} +``` + +Nesse caso, copie somente os `id` das ocorrências explicitamente aprovadas para `finding_ids` e acrescente `--waiver `. Nunca crie, amplie ou altere o waiver por conta própria; cada finding não listado continua bloqueando. + +O gate é read-only e valida deterministicamente: + +- todo ID `Q-XX-NN` referenciado existe em `questions.md`; +- artefatos obrigatórios existem para o `doc_level` selecionado; +- severidades publicadas não divergem do registro canônico; +- o conjunto de bloqueantes de `review-report.md`, quando presente, coincide com as perguntas 🔴; +- contagens publicadas de Markdown e linhas, quando presentes, correspondem à árvore final. + +Se o comando retornar código diferente de zero, leia os findings JSON, corrija os artefatos e execute novamente. **Não informe o checkpoint de revisão como concluído enquanto o resultado não tiver `ok=true` e zero `unwaived_findings`.** Informe ao Reversa o caminho de `.reversa/reviewer-integrity-result.json`, o status `passed` ou `waived`, a quantidade de findings e o waiver usado, quando aplicável. O orquestrador repetirá o gate antes de persistir o checkpoint. + ## Checkpoint Informe ao Reversa: diff --git a/agents/reversa-reviewer/references/questions-template.md b/agents/reversa-reviewer/references/questions-template.md index fabf6c39..be2c63a2 100644 --- a/agents/reversa-reviewer/references/questions-template.md +++ b/agents/reversa-reviewer/references/questions-template.md @@ -6,10 +6,10 @@ Este arquivo é gerado pelo Revisor e preenchido pelo usuário. ## Como o Revisor cria este arquivo -Para cada lacuna 🔴 que só o usuário pode resolver, crie uma entrada: +Para cada lacuna 🔴 que só o usuário pode resolver, atribua um ID estável no formato `Q--` e crie uma entrada. Use um código de unit em letras maiúsculas, numeração crescente com pelo menos dois dígitos e nunca recicle um ID removido: ```markdown -## Pergunta [N] +## Q-[UNIT]-[NN] 🔴 — Pergunta [N] **Contexto:** [Onde no sistema surgiu esta dúvida — módulo, função, linha] **Spec afetada:** [`_reversa_sdd//{requirements|design|tasks}.md`] @@ -52,7 +52,7 @@ Após receber aviso do usuário: --- -## Pergunta 1 +## Q-AUTH-01 🔴 — Pergunta 1 **Contexto:** Módulo `auth` — função `validateSession()` em `src/auth/session.ts:47` **Spec afetada:** [`_reversa_sdd/auth/requirements.md`] @@ -63,7 +63,7 @@ Após receber aviso do usuário: --- -## Pergunta 2 +## Q-ORDERS-01 🔴 — Pergunta 2 **Contexto:** Módulo `orders` — constante `MAX_ITEMS_PER_ORDER = 50` em `src/orders/constants.ts:12` **Spec afetada:** [`_reversa_sdd/orders/requirements.md`] diff --git a/agents/reversa-reviewer/scripts/verify-discovery-review.py b/agents/reversa-reviewer/scripts/verify-discovery-review.py new file mode 100755 index 00000000..2dba194a --- /dev/null +++ b/agents/reversa-reviewer/scripts/verify-discovery-review.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Deterministic integrity checks for Reversa Reviewer artifacts. + +The validator is intentionally read-only with respect to the review root. It +prints a stable JSON result and returns a non-zero exit code when an integrity +error is found. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +QUESTION_ID = re.compile(r"\bQ-[A-Z]+-\d+\b") +QUESTION_DEFINITION = re.compile( + r"^#{2,6}\s+(Q-[A-Z]+-\d+)\s+([🔴🟡])(?:\s|$)", re.MULTILINE +) +QUESTION_SEVERITY_MARKER = re.compile(r"[🔴🟡]") +SEVERITY = {"🔴": "red", "🟡": "yellow"} +DOC_LEVELS = ("essencial", "completo", "detalhado") + + +def _markdown_files(root: Path) -> list[Path]: + return sorted(path for path in root.rglob("*.md") if path.is_file()) + + +def _read_markdown(path: Path) -> str: + return path.read_text(encoding="utf-8-sig", errors="replace").replace("\x00", "") + + +def _question_severities(text: str) -> list[tuple[str, str]]: + observed: list[tuple[str, str]] = [] + for line in text.splitlines(): + ids = list(QUESTION_ID.finditer(line)) + markers = list(QUESTION_SEVERITY_MARKER.finditer(line)) + if not ids or not markers: + continue + for question in ids: + nearest = min(markers, key=lambda marker: abs(marker.start() - question.start())) + observed.append((question.group(0), nearest.group(0))) + return observed + + +def _finding_id(finding: dict[str, Any]) -> str: + identity = json.dumps( + {"code": finding["code"], "details": finding["details"]}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + digest = hashlib.sha256(identity).hexdigest()[:16] + return f"{finding['code']}:{digest}" + + +def _evaluate_waiver( + waiver_path: Path | None, + findings: list[dict[str, Any]], +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + if waiver_path is None: + return None, list(findings) + + resolved = waiver_path.resolve() + metadata: dict[str, Any] = {"path": str(resolved), "status": "invalid"} + try: + payload = json.loads(resolved.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + metadata["error"] = f"waiver ilegível: {exc.__class__.__name__}" + return metadata, list(findings) + + if not isinstance(payload, dict): + metadata["error"] = "waiver deve ser um objeto JSON" + return metadata, list(findings) + + approved_by = str(payload.get("approved_by", "")).strip() + reason = str(payload.get("reason", "")).strip() + raw_finding_ids = payload.get("finding_ids") + finding_ids = ( + [finding_id.strip() for finding_id in raw_finding_ids] + if isinstance(raw_finding_ids, list) + and all( + isinstance(finding_id, str) and finding_id.strip() + for finding_id in raw_finding_ids + ) + else [] + ) + observed_ids = {finding["id"] for finding in findings} + valid_ids = bool(finding_ids) and set(finding_ids).issubset(observed_ids) + if ( + payload.get("schema_version") != 1 + or not approved_by + or len(reason) < 20 + or not valid_ids + ): + metadata["error"] = ( + "waiver exige schema_version=1, approved_by, reason com pelo menos " + "20 caracteres e finding_ids presentes no resultado" + ) + return metadata, list(findings) + + covered_ids = sorted(set(finding_ids)) + metadata.update( + { + "status": "valid", + "approved_by": approved_by, + "reason": reason, + "finding_ids": covered_ids, + } + ) + unwaived = [finding for finding in findings if finding["id"] not in covered_ids] + return metadata, unwaived + + +def validate_review( + root: Path, + *, + doc_level: str, + cross_review_performed: bool = False, + waiver_path: Path | None = None, +) -> dict[str, Any]: + """Validate one completed Reviewer output tree without mutating it.""" + if doc_level not in DOC_LEVELS: + raise ValueError(f"doc_level inválido: {doc_level}") + + root = root.resolve() + findings: list[dict[str, Any]] = [] + markdown_files = _markdown_files(root) if root.is_dir() else [] + + questions_path = root / "questions.md" + questions_text = _read_markdown(questions_path) if questions_path.is_file() else "" + definitions = { + question_id: SEVERITY[marker] + for question_id, marker in QUESTION_DEFINITION.findall(questions_text) + } + + references: dict[str, set[str]] = {} + for path in markdown_files: + if path == questions_path: + continue + relative = path.relative_to(root).as_posix() + for question_id in QUESTION_ID.findall(_read_markdown(path)): + references.setdefault(question_id, set()).add(relative) + + for question_id in sorted(set(references) - set(definitions)): + findings.append( + { + "code": "orphan-question-reference", + "severity": "error", + "details": { + "question_id": question_id, + "references": sorted(references[question_id]), + }, + } + ) + + required = ["confidence-report.md", "questions.md"] + if doc_level in {"completo", "detalhado"}: + required.append("gaps.md") + if cross_review_performed: + required.append("cross-review-result.md") + for relative in required: + if not (root / relative).is_file(): + findings.append( + { + "code": "missing-required-artifact", + "severity": "error", + "details": {"path": relative, "doc_level": doc_level}, + } + ) + + for path in markdown_files: + if path == questions_path: + continue + relative = path.relative_to(root).as_posix() + seen: set[tuple[str, str]] = set() + for question_id, marker in _question_severities(_read_markdown(path)): + observed = SEVERITY[marker] + canonical = definitions.get(question_id) + key = (question_id, observed) + if canonical is not None and observed != canonical and key not in seen: + findings.append( + { + "code": "question-severity-conflict", + "severity": "error", + "details": { + "question_id": question_id, + "canonical": canonical, + "observed": observed, + "path": relative, + }, + } + ) + seen.add(key) + + report_path = root / "review-report.md" + if report_path.is_file(): + report_text = _read_markdown(report_path) + blocker_section = re.search( + r"^#{2,6}\s+🔴[^\n]*\n(?P.*?)(?=^#{2,6}\s|\Z)", + report_text, + re.MULTILINE | re.DOTALL, + ) + if blocker_section: + reported_blockers = sorted(set(QUESTION_ID.findall(blocker_section.group("body")))) + canonical_red = sorted( + question_id + for question_id, severity in definitions.items() + if severity == "red" + ) + if reported_blockers != canonical_red: + findings.append( + { + "code": "blocker-set-mismatch", + "severity": "error", + "details": { + "canonical_red": canonical_red, + "reported_blockers": reported_blockers, + "missing_from_report": sorted( + set(canonical_red) - set(reported_blockers) + ), + "extra_in_report": sorted( + set(reported_blockers) - set(canonical_red) + ), + }, + } + ) + + file_metric = re.search( + r"\|\s*Arquivos Markdown gerados\s*\|\s*([\d.]+)\s*\|", + report_text, + ) + if file_metric: + reported = int(file_metric.group(1).replace(".", "")) + observed = len(markdown_files) + if reported != observed: + findings.append( + { + "code": "markdown-file-count-mismatch", + "severity": "error", + "details": { + "reported": reported, + "observed": observed, + "metric": "logical markdown files under review root", + }, + } + ) + + line_metric = re.search( + r"\|\s*Linhas de spec\s*\|\s*([\d.]+)\s*\|", + report_text, + ) + if line_metric: + reported = int(line_metric.group(1).replace(".", "")) + observed = sum(len(_read_markdown(path).splitlines()) for path in markdown_files) + if reported != observed: + findings.append( + { + "code": "markdown-line-count-mismatch", + "severity": "error", + "details": { + "reported": reported, + "observed": observed, + "metric": "sum(len(text.splitlines()))", + }, + } + ) + + findings.sort( + key=lambda item: ( + item["code"], + item["details"].get("question_id", ""), + item["details"].get("path", ""), + ) + ) + for finding in findings: + finding["id"] = _finding_id(finding) + waiver, unwaived_findings = _evaluate_waiver(waiver_path, findings) + waived = ( + bool(findings) + and waiver is not None + and waiver.get("status") == "valid" + and not unwaived_findings + ) + return { + "schema_version": 1, + "root": str(root), + "doc_level": doc_level, + "cross_review_performed": cross_review_performed, + "ok": not unwaived_findings, + "waived": waived, + "waiver": waiver, + "summary": { + "markdown_files": len(markdown_files), + "defined_questions": len(definitions), + "referenced_questions": len(references), + "findings": len(findings), + "unwaived_findings": len(unwaived_findings), + }, + "findings": findings, + "unwaived_findings": unwaived_findings, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Valida deterministicamente os artefatos do Reversa Reviewer." + ) + parser.add_argument("root", type=Path, help="Pasta de saída, por exemplo _reversa_sdd") + parser.add_argument("--doc-level", choices=DOC_LEVELS, required=True) + parser.add_argument( + "--cross-review-performed", + action="store_true", + help="Exige cross-review-result.md porque a revisão cruzada foi realizada.", + ) + parser.add_argument( + "--waiver", + type=Path, + help=( + "JSON de waiver explícito com schema_version, approved_by, reason e " + "finding_ids exatos do resultado. Findings não cobertos continuam bloqueando." + ), + ) + parser.add_argument( + "--output", + type=Path, + help="Opcional: grava uma cópia do JSON fora da árvore validada.", + ) + args = parser.parse_args() + + result = validate_review( + args.root, + doc_level=args.doc_level, + cross_review_performed=args.cross_review_performed, + waiver_path=args.waiver, + ) + rendered = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agents/reversa/SKILL.md b/agents/reversa/SKILL.md index 9a187cf7..8a9a03a4 100644 --- a/agents/reversa/SKILL.md +++ b/agents/reversa/SKILL.md @@ -24,9 +24,18 @@ Execute as tarefas do plano **sequencialmente, uma por vez**: 1. Informe o usuário: "Iniciando o **[Nome do Agente]** — [o que ele fará]." 2. Leia `reversa-[agente]/SKILL.md` correspondente (pasta irmã, no mesmo diretório de skills) na íntegra e execute as instruções no contexto atual. -3. Após conclusão: salve checkpoint em `.reversa/state.json` seguindo `references/checkpoint-guide.md` e marque a tarefa com ✅ em `.reversa/plan.md`. +3. Após conclusão: aplique os gates especiais abaixo; somente então salve checkpoint em `.reversa/state.json` seguindo `references/checkpoint-guide.md` e marque a tarefa com ✅ em `.reversa/plan.md`. 4. Apresente resumo breve do que foi gerado. +**Gate especial após o Reviewer:** + +Antes de salvar o checkpoint `reviewer` da fase `revisao`, o próprio orquestrador deve executar novamente — não apenas confiar no relato do Reviewer — o validador em `reversa-reviewer/scripts/verify-discovery-review.py`, usando o `output_folder` e o `doc_level` atuais. Acrescente `--cross-review-performed` somente se a revisão cruzada ocorreu. + +- Se o comando retornar código diferente de zero ou `ok=false`, **não salve o checkpoint e não marque a tarefa como concluída**. Devolva os findings ao Reviewer para correção e execute o gate novamente. +- Um waiver só pode ser usado quando o usuário o aprovou explicitamente e já existe um arquivo JSON com `schema_version`, `approved_by`, `reason` e `finding_ids`. Passe esse arquivo com `--waiver`; nunca o crie, amplie ou altere por conta própria. +- Para aceitar waiver, o resultado deve ter `ok=true`, `waived=true`, `waiver.status=valid` e zero `unwaived_findings`. +- Registre no checkpoint o status `passed` ou `waived`, a quantidade de findings, o caminho do waiver quando aplicável e o comando executado. Isso torna a decisão retomável e auditável. + **Ação especial após o Scout:** 1. Leia `.reversa/context/surface.json` e atualize a Fase 2 de `.reversa/plan.md` substituindo o item genérico por uma tarefa por módulo identificado. Exemplo: diff --git a/agents/reversa/references/checkpoint-guide.md b/agents/reversa/references/checkpoint-guide.md index 3a46b48f..ffe91ff5 100644 --- a/agents/reversa/references/checkpoint-guide.md +++ b/agents/reversa/references/checkpoint-guide.md @@ -34,6 +34,34 @@ O Reversa é o único agente que **escreve** no state.json. Os demais agentes ap } ``` +### Ao concluir o Reviewer + +O checkpoint `reviewer` só pode ser salvo depois do gate especial descrito em `reversa/SKILL.md`. Persista o recibo sem remover os campos já existentes: + +```json +{ + "checkpoints": { + "reviewer": { + "completed_at": "2026-04-26T12:00:00Z", + "files": [ + "_reversa_sdd/confidence-report.md", + "_reversa_sdd/questions.md" + ], + "integrity": { + "status": "passed", + "command": "python3 /scripts/verify-discovery-review.py _reversa_sdd --doc-level completo", + "result_path": ".reversa/reviewer-integrity-result.json", + "findings": 0, + "unwaived_findings": 0, + "waiver_path": null + } + } + } +} +``` + +Use `status: "waived"` e preencha `waiver_path` somente quando o resultado do gate confirmar `waived=true`, `waiver.status=valid` e zero `unwaived_findings`. + ### Ao concluir uma fase inteira ```json { diff --git a/package.json b/package.json index d08a1dad..52791246 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "type": "module", "scripts": { - "verify": "python3 scripts/verify-invocation.py && node scripts/test-installer-transport.mjs" + "verify": "python3 scripts/verify-invocation.py && python3 scripts/test-reviewer-integrity.py && node scripts/test-installer-transport.mjs" }, "keywords": [ "legacy", diff --git a/scripts/test-reviewer-integrity.py b/scripts/test-reviewer-integrity.py new file mode 100755 index 00000000..db302a2e --- /dev/null +++ b/scripts/test-reviewer-integrity.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Focused tests for the deterministic Reviewer integrity gate.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.dont_write_bytecode = True + +REPO_ROOT = Path(__file__).resolve().parents[1] +VALIDATOR_PATH = ( + REPO_ROOT + / "agents" + / "reversa-reviewer" + / "scripts" + / "verify-discovery-review.py" +) + + +def load_validator(): + spec = importlib.util.spec_from_file_location("verify_discovery_review", VALIDATOR_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"não foi possível carregar {VALIDATOR_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class ReviewerIntegrityTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.root = Path(self.tempdir.name) / "_reversa_sdd" + write(self.root / "questions.md", "# Perguntas\n\n### Q-SEC-01 🔴\n\nPergunta.\n") + write(self.root / "confidence-report.md", "# Confiança\n") + write(self.root / "gaps.md", "# Gaps\n") + self.validator = load_validator() + + def validate(self, **kwargs): + return self.validator.validate_review(self.root, doc_level="completo", **kwargs) + + def test_accepts_consistent_review(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-SEC-01.\n") + result = self.validate() + self.assertTrue(result["ok"]) + self.assertEqual([], result["findings"]) + + def test_accepts_canonical_template_heading(self) -> None: + write( + self.root / "questions.md", + "# Perguntas\n\n## Q-AUTH-01 🔴 — Pergunta 1\n\nPergunta.\n", + ) + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-AUTH-01.\n") + result = self.validate() + self.assertTrue(result["ok"]) + self.assertEqual([], result["findings"]) + + def test_accepts_question_definition_headings_h2_through_h6(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-AUTH-01.\n") + for level in range(2, 7): + with self.subTest(level=level): + write( + self.root / "questions.md", + f"# Perguntas\n\n{'#' * level} Q-AUTH-01 🔴 — Pergunta 1\n", + ) + orphans = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "orphan-question-reference" + ] + self.assertEqual([], orphans) + + def test_rejects_orphan_question_reference(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-AE-01.\n") + result = self.validate() + finding = result["findings"][0] + self.assertEqual("orphan-question-reference", finding["code"]) + self.assertEqual("error", finding["severity"]) + self.assertEqual( + {"question_id": "Q-AE-01", "references": ["architecture.md"]}, + finding["details"], + ) + self.assertRegex( + finding["id"], + r"^orphan-question-reference:[0-9a-f]{16}$", + ) + + def test_rejects_missing_required_reviewer_artifacts(self) -> None: + (self.root / "confidence-report.md").unlink() + (self.root / "gaps.md").unlink() + result = self.validate() + self.assertEqual( + ["confidence-report.md", "gaps.md"], + [ + finding["details"]["path"] + for finding in result["findings"] + if finding["code"] == "missing-required-artifact" + ], + ) + + def test_rejects_conflicting_question_severity(self) -> None: + write( + self.root / "review-report.md", + "# Revisão\n\n## Bloqueantes\n\n- Q-SEC-01 🟡 — classificação divergente.\n", + ) + conflicts = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "question-severity-conflict" + ] + self.assertEqual(1, len(conflicts)) + self.assertEqual("red", conflicts[0]["details"]["canonical"]) + self.assertEqual("yellow", conflicts[0]["details"]["observed"]) + + def test_detects_severity_when_marker_precedes_question_id(self) -> None: + write( + self.root / "review-report.md", + "# Revisão\n\n- 🟡 Q-SEC-01 — classificação divergente.\n", + ) + conflicts = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "question-severity-conflict" + ] + self.assertEqual(1, len(conflicts)) + self.assertEqual("yellow", conflicts[0]["details"]["observed"]) + + def test_uses_nearest_marker_when_line_has_multiple_question_ids(self) -> None: + write( + self.root / "questions.md", + "# Perguntas\n\n### Q-AUTH-01 🟡\n\n### Q-SEC-01 🔴\n", + ) + write( + self.root / "review-report.md", + "# Revisão\n\n- 🟡 Q-AUTH-01; Q-SEC-01 🔴\n", + ) + conflicts = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "question-severity-conflict" + ] + self.assertEqual([], conflicts) + + def test_rejects_blocker_set_mismatch(self) -> None: + write( + self.root / "questions.md", + "# Perguntas\n\n### Q-SEC-01 🔴\n\n### Q-SEC-02 🟡\n", + ) + write( + self.root / "review-report.md", + "# Revisão\n\n### 🔴 Bloqueantes para reimplementação fiel (1)\n\n" + "| Pergunta | Impacto |\n|---|---|\n| Q-SEC-02 | impacto |\n", + ) + mismatches = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "blocker-set-mismatch" + ] + self.assertEqual(1, len(mismatches)) + self.assertEqual(["Q-SEC-01"], mismatches[0]["details"]["missing_from_report"]) + self.assertEqual(["Q-SEC-02"], mismatches[0]["details"]["extra_in_report"]) + + def test_rejects_blocker_set_mismatch_with_h2_through_h6(self) -> None: + write( + self.root / "questions.md", + "# Perguntas\n\n### Q-SEC-01 🔴\n\n### Q-SEC-02 🟡\n", + ) + for level in range(2, 7): + with self.subTest(level=level): + write( + self.root / "review-report.md", + f"# Revisão\n\n{'#' * level} 🔴 Bloqueantes\n\n- Q-SEC-02\n", + ) + mismatches = [ + finding + for finding in self.validate()["findings"] + if finding["code"] == "blocker-set-mismatch" + ] + self.assertEqual(1, len(mismatches)) + self.assertEqual( + ["Q-SEC-01"], + mismatches[0]["details"]["missing_from_report"], + ) + + def test_rejects_report_denominator_drift(self) -> None: + write( + self.root / "review-report.md", + "# Revisão\n\n| Métrica | Valor |\n|---|---:|\n" + "| Arquivos Markdown gerados | 3 |\n| Linhas de spec | 1 |\n", + ) + by_code = {finding["code"]: finding for finding in self.validate()["findings"]} + self.assertEqual(4, by_code["markdown-file-count-mismatch"]["details"]["observed"]) + self.assertGreater(by_code["markdown-line-count-mismatch"]["details"]["observed"], 1) + self.assertEqual( + "sum(len(text.splitlines()))", + by_code["markdown-line-count-mismatch"]["details"]["metric"], + ) + + def test_questions_registry_is_always_required(self) -> None: + (self.root / "questions.md").unlink() + result = self.validate() + missing = [ + finding["details"]["path"] + for finding in result["findings"] + if finding["code"] == "missing-required-artifact" + ] + self.assertIn("questions.md", missing) + + def test_cross_review_artifact_is_conditional(self) -> None: + without_cross_review = self.validator.validate_review( + self.root, doc_level="detalhado", cross_review_performed=False + ) + self.assertNotIn( + "cross-review-result.md", + [finding["details"].get("path") for finding in without_cross_review["findings"]], + ) + with_cross_review = self.validator.validate_review( + self.root, doc_level="detalhado", cross_review_performed=True + ) + self.assertIn( + "cross-review-result.md", + [finding["details"].get("path") for finding in with_cross_review["findings"]], + ) + + def test_result_is_stably_json_serializable(self) -> None: + first = self.validate() + second = self.validate() + self.assertEqual(first, second) + json.dumps(first, ensure_ascii=False, sort_keys=True) + + def test_explicit_waiver_can_cover_known_findings(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-AE-01.\n") + baseline = self.validate() + orphan_id = next( + finding["id"] + for finding in baseline["findings"] + if finding["code"] == "orphan-question-reference" + ) + waiver = Path(self.tempdir.name) / "review-waiver.json" + write( + waiver, + json.dumps( + { + "schema_version": 1, + "approved_by": "maintainer@example", + "reason": "Aceite consciente para concluir esta execução específica.", + "finding_ids": [orphan_id], + } + ), + ) + result = self.validator.validate_review( + self.root, + doc_level="completo", + waiver_path=waiver, + ) + self.assertTrue(result["ok"]) + self.assertTrue(result["waived"]) + self.assertEqual([], result["unwaived_findings"]) + self.assertEqual("maintainer@example", result["waiver"]["approved_by"]) + + def test_waiver_is_scoped_to_one_finding_instance(self) -> None: + write( + self.root / "architecture.md", + "# Arquitetura\n\nVer Q-AE-01 e Q-AE-02.\n", + ) + baseline = self.validate() + orphan_findings = [ + finding + for finding in baseline["findings"] + if finding["code"] == "orphan-question-reference" + ] + self.assertEqual(2, len(orphan_findings)) + waiver = Path(self.tempdir.name) / "review-waiver.json" + write( + waiver, + json.dumps( + { + "schema_version": 1, + "approved_by": "maintainer@example", + "reason": "Aceite explícito de somente uma ocorrência conhecida.", + "finding_ids": [orphan_findings[0]["id"]], + } + ), + ) + result = self.validator.validate_review( + self.root, + doc_level="completo", + waiver_path=waiver, + ) + remaining_orphans = [ + finding + for finding in result["unwaived_findings"] + if finding["code"] == "orphan-question-reference" + ] + self.assertFalse(result["ok"]) + self.assertEqual(1, len(remaining_orphans)) + + def test_invalid_waiver_does_not_cover_findings(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-AE-01.\n") + waiver = Path(self.tempdir.name) / "review-waiver.json" + write( + waiver, + json.dumps( + { + "schema_version": 1, + "approved_by": "", + "reason": "curta", + "finding_ids": ["orphan-question-reference:invalid"], + } + ), + ) + result = self.validator.validate_review( + self.root, + doc_level="completo", + waiver_path=waiver, + ) + self.assertFalse(result["ok"]) + self.assertFalse(result["waived"]) + self.assertEqual("invalid", result["waiver"]["status"]) + + def test_cli_exit_code_and_read_only_behavior(self) -> None: + write(self.root / "architecture.md", "# Arquitetura\n\nVer Q-ORPHAN-01.\n") + before = { + path.relative_to(self.root).as_posix(): path.read_bytes() + for path in self.root.rglob("*") + if path.is_file() + } + completed = subprocess.run( + [ + sys.executable, + str(VALIDATOR_PATH), + str(self.root), + "--doc-level", + "completo", + ], + check=False, + capture_output=True, + text=True, + ) + after = { + path.relative_to(self.root).as_posix(): path.read_bytes() + for path in self.root.rglob("*") + if path.is_file() + } + self.assertEqual(1, completed.returncode) + self.assertFalse(json.loads(completed.stdout)["ok"]) + self.assertEqual(before, after) + + +if __name__ == "__main__": + unittest.main(verbosity=2)